diff --git a/build.gradle b/build.gradle index 75b2260..7717599 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '4.0.4' + id 'org.springframework.boot' version '3.2.5' id 'io.spring.dependency-management' version '1.1.7' } @@ -25,22 +25,26 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-liquibase' implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-session-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.liquibase:liquibase-core' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + compileOnly 'org.projectlombok:lombok' - runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-starter-liquibase-test' - testImplementation 'org.springframework.boot:spring-boot-starter-security-test' - testImplementation 'org.springframework.boot:spring-boot-starter-session-data-redis-test' - testImplementation 'org.springframework.boot:spring-boot-starter-validation-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + + runtimeOnly 'org.postgresql:postgresql' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' } tasks.named('test') { diff --git a/docker-compose.yaml b/docker-compose.yaml index 9e4a3f5..fb3cfb1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1 +1,16 @@ -services: \ No newline at end of file +version: '3.8' + +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: nahodka + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + ports: + - "5432:5432" + + redis: + image: redis:7 + ports: + - "6379:6379" \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/config/CacheConfig.java b/src/main/java/com/krylov/refound/config/CacheConfig.java new file mode 100644 index 0000000..f250372 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/CacheConfig.java @@ -0,0 +1,10 @@ +package com.krylov.refound.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Configuration; + +@EnableCaching +@Configuration +public class CacheConfig { + +} diff --git a/src/main/java/com/krylov/refound/config/RedisConfig.java b/src/main/java/com/krylov/refound/config/RedisConfig.java new file mode 100644 index 0000000..7600024 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/RedisConfig.java @@ -0,0 +1,27 @@ +package com.krylov.refound.config; + +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; + +@Configuration +public class RedisConfig { + @Value("${cache.ttl}") + private long ttl; + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory factory) { + + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(ttl)) + .disableCachingNullValues(); + + return RedisCacheManager.builder(factory) + .cacheDefaults(config) + .build(); + } +} diff --git a/src/main/java/com/krylov/refound/config/SecurityConfig.java b/src/main/java/com/krylov/refound/config/SecurityConfig.java index 5b87ea3..b2e4edd 100644 --- a/src/main/java/com/krylov/refound/config/SecurityConfig.java +++ b/src/main/java/com/krylov/refound/config/SecurityConfig.java @@ -1,4 +1,39 @@ package com.krylov.refound.config; +import com.krylov.refound.service.security.JwtFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/com/krylov/refound/config/WebConfig.java b/src/main/java/com/krylov/refound/config/WebConfig.java new file mode 100644 index 0000000..079ecc1 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.krylov.refound.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Value("${file.upload-dir}") + private String uploadDir; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry + .addResourceHandler("/files/**") + .addResourceLocations("file:" + uploadDir); + } +} diff --git a/src/main/java/com/krylov/refound/config/WebSocketConfig.java b/src/main/java/com/krylov/refound/config/WebSocketConfig.java new file mode 100644 index 0000000..295c945 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/WebSocketConfig.java @@ -0,0 +1,27 @@ +package com.krylov.refound.config; + +import com.krylov.refound.util.JwtHandshakeInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic"); + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*") + .addInterceptors(new JwtHandshakeInterceptor()) // 🔥 JWT + .withSockJS(); + } +} diff --git a/src/main/java/com/krylov/refound/controller/AuthController.java b/src/main/java/com/krylov/refound/controller/AuthController.java index ad207fd..aff50d0 100644 --- a/src/main/java/com/krylov/refound/controller/AuthController.java +++ b/src/main/java/com/krylov/refound/controller/AuthController.java @@ -1,4 +1,27 @@ package com.krylov.refound.controller; +import com.krylov.refound.dto.AuthRequest; +import com.krylov.refound.dto.AuthResponse; +import com.krylov.refound.service.security.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor public class AuthController { + private final AuthService service; + + @PostMapping("/register") + public AuthResponse register(@RequestBody AuthRequest request) { + return service.register(request); + } + + @PostMapping("/login") + public AuthResponse login(@RequestBody AuthRequest request) { + return service.login(request); + } } diff --git a/src/main/java/com/krylov/refound/controller/ChatController.java b/src/main/java/com/krylov/refound/controller/ChatController.java new file mode 100644 index 0000000..d4dc79f --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/ChatController.java @@ -0,0 +1,37 @@ +package com.krylov.refound.controller; + +import com.krylov.refound.dto.ChatMessage; +import com.krylov.refound.entity.Message; +import com.krylov.refound.entity.User; +import com.krylov.refound.repository.UserRepository; +import com.krylov.refound.service.MessageService; +import java.security.Principal; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class ChatController { + private final MessageService service; + private final SimpMessagingTemplate messagingTemplate; + private final UserRepository userRepository; + + + @MessageMapping("/chat") + public void send(@Payload ChatMessage message, Principal principal) { + + Optional sender = userRepository.findByEmail(principal.getName()); + Message saved = new Message(); + if(sender.isPresent()){ + saved = service.send(message.getReceiverId(), message.getContent()); + } + + // отправка получателю + messagingTemplate.convertAndSend("/topic/messages/" + message.getReceiverId(), saved); + } + +} diff --git a/src/main/java/com/krylov/refound/controller/FavoriteController.java b/src/main/java/com/krylov/refound/controller/FavoriteController.java new file mode 100644 index 0000000..abf04a5 --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/FavoriteController.java @@ -0,0 +1,34 @@ +package com.krylov.refound.controller; + +import com.krylov.refound.dto.PostResponse; +import com.krylov.refound.mapper.PostMapper; +import com.krylov.refound.service.FavoriteService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/favorites") +@RequiredArgsConstructor +public class FavoriteController { + + private final FavoriteService service; + private final PostMapper mapper; + + @PostMapping("/{postId}/toggle") + public void toggle(@PathVariable Long postId) { + service.toggle(postId); + } + + @GetMapping + public List getAll() { + return service.getFavorites() + .stream() + .map(mapper::toResponse) + .toList(); + } +} diff --git a/src/main/java/com/krylov/refound/controller/FileController.java b/src/main/java/com/krylov/refound/controller/FileController.java new file mode 100644 index 0000000..ef0a7f8 --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/FileController.java @@ -0,0 +1,21 @@ +package com.krylov.refound.controller; + +import com.krylov.refound.service.FileStorageService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +public class FileController { + private final FileStorageService storageService; + + @PostMapping + public String upload(@RequestParam("file") MultipartFile file) { + return storageService.save(file); + } +} diff --git a/src/main/java/com/krylov/refound/controller/MessageController.java b/src/main/java/com/krylov/refound/controller/MessageController.java new file mode 100644 index 0000000..31f8bc1 --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/MessageController.java @@ -0,0 +1,41 @@ +package com.krylov.refound.controller; + +import com.krylov.refound.dto.DialogResponse; +import com.krylov.refound.entity.Message; +import com.krylov.refound.service.MessageService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/messages") +@RequiredArgsConstructor +public class MessageController { + private final MessageService service; + + @PostMapping + public void send(@RequestParam Long receiverId, + @RequestParam String content) { + service.send(receiverId, content); + } + + @GetMapping("/{userId}") + public List chat(@PathVariable Long userId) { + return service.getChat(userId); + } + + @GetMapping("/dialogs") + public List getDialogs() { + return service.getDialogs(); + } + + @PostMapping("/read/{userId}") + public void markAsRead(@PathVariable Long userId) { + service.markAsRead(userId); + } +} diff --git a/src/main/java/com/krylov/refound/controller/PostController.java b/src/main/java/com/krylov/refound/controller/PostController.java index 548d09d..c842b4d 100644 --- a/src/main/java/com/krylov/refound/controller/PostController.java +++ b/src/main/java/com/krylov/refound/controller/PostController.java @@ -1,4 +1,50 @@ package com.krylov.refound.controller; +import com.krylov.refound.dto.PostFilter; +import com.krylov.refound.dto.PostRequest; +import com.krylov.refound.dto.PostResponse; +import com.krylov.refound.repository.PostRepository; +import com.krylov.refound.service.PostService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/v1/posts") +@RequiredArgsConstructor public class PostController { + + private final PostService service; + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public PostResponse create(@RequestPart("data") PostRequest request, @RequestPart(value = "files", required = false) List files) { + return service.create(request, files); + } + + @GetMapping + public Page getFeed(PostFilter filter, Pageable pageable) { + return service.getFeed(filter, pageable); + } + + @PutMapping("/{id}") + public PostResponse update(@PathVariable Long id, @RequestBody PostRequest request) { + return service.update(id, request); + } + + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id) { + service.delete(id); + } } diff --git a/src/main/java/com/krylov/refound/dto/AuthRequest.java b/src/main/java/com/krylov/refound/dto/AuthRequest.java index 028f761..c2cc5db 100644 --- a/src/main/java/com/krylov/refound/dto/AuthRequest.java +++ b/src/main/java/com/krylov/refound/dto/AuthRequest.java @@ -1,4 +1,9 @@ package com.krylov.refound.dto; +import lombok.Data; + +@Data public class AuthRequest { + private String email; + private String password; } diff --git a/src/main/java/com/krylov/refound/dto/AuthResponse.java b/src/main/java/com/krylov/refound/dto/AuthResponse.java index 27ab150..227e6ba 100644 --- a/src/main/java/com/krylov/refound/dto/AuthResponse.java +++ b/src/main/java/com/krylov/refound/dto/AuthResponse.java @@ -1,4 +1,11 @@ package com.krylov.refound.dto; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor public class AuthResponse { + + private String token; } diff --git a/src/main/java/com/krylov/refound/dto/ChatMessage.java b/src/main/java/com/krylov/refound/dto/ChatMessage.java new file mode 100644 index 0000000..2c52ea3 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/ChatMessage.java @@ -0,0 +1,9 @@ +package com.krylov.refound.dto; + +import lombok.Data; + +@Data +public class ChatMessage { + private Long receiverId; + private String content; +} diff --git a/src/main/java/com/krylov/refound/dto/DialogResponse.java b/src/main/java/com/krylov/refound/dto/DialogResponse.java new file mode 100644 index 0000000..0215a26 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/DialogResponse.java @@ -0,0 +1,15 @@ +package com.krylov.refound.dto; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class DialogResponse { + private Long userId; + private String userEmail; + private String lastMessage; + private LocalDateTime lastMessageTime; + private Long unreadCount; +} diff --git a/src/main/java/com/krylov/refound/dto/PostFilter.java b/src/main/java/com/krylov/refound/dto/PostFilter.java new file mode 100644 index 0000000..3345ab2 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/PostFilter.java @@ -0,0 +1,29 @@ +package com.krylov.refound.dto; + +import com.krylov.refound.enums.PostType; +import lombok.Data; + +@Data +public class PostFilter { + + private PostType type; + private String city; + private String category; + private String district; + private String search; + private Double lat; + private Double lng; + private Double radius; // в км + + @Override + public String toString() { + return "type=" + type + + ",city=" + city + + ",category=" + category + + ",district=" + district + + ",search=" + search + + ",lat=" + lat + + ",lng=" + lng + + ",radius=" + radius; + } +} diff --git a/src/main/java/com/krylov/refound/dto/PostRequest.java b/src/main/java/com/krylov/refound/dto/PostRequest.java index 765fb88..055dc1d 100644 --- a/src/main/java/com/krylov/refound/dto/PostRequest.java +++ b/src/main/java/com/krylov/refound/dto/PostRequest.java @@ -1,4 +1,25 @@ package com.krylov.refound.dto; +import com.krylov.refound.enums.PostType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data public class PostRequest { + + @NotNull + private PostType type; + + @NotBlank + private String title; + + private String description; + + private String category; + + @NotBlank + private String city; + + private String district; } diff --git a/src/main/java/com/krylov/refound/dto/PostResponse.java b/src/main/java/com/krylov/refound/dto/PostResponse.java index 173e352..3dc7e6a 100644 --- a/src/main/java/com/krylov/refound/dto/PostResponse.java +++ b/src/main/java/com/krylov/refound/dto/PostResponse.java @@ -1,4 +1,22 @@ package com.krylov.refound.dto; +import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.enums.PostType; +import java.time.LocalDateTime; +import lombok.Data; + +@Data public class PostResponse { + + private Long id; + private String title; + private String description; + private String category; + private String city; + private String district; + private PostType type; + private PostStatus status; + private LocalDateTime createdAt; + private String userEmail; + private boolean isFavorite; } diff --git a/src/main/java/com/krylov/refound/entity/Favorite.java b/src/main/java/com/krylov/refound/entity/Favorite.java new file mode 100644 index 0000000..8a000ed --- /dev/null +++ b/src/main/java/com/krylov/refound/entity/Favorite.java @@ -0,0 +1,27 @@ +package com.krylov.refound.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "favorites", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"})) +@Getter +@Setter +public class Favorite { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + private User user; + + @ManyToOne + private Post post; +} diff --git a/src/main/java/com/krylov/refound/entity/Image.java b/src/main/java/com/krylov/refound/entity/Image.java index 27afb91..7da2c07 100644 --- a/src/main/java/com/krylov/refound/entity/Image.java +++ b/src/main/java/com/krylov/refound/entity/Image.java @@ -1,4 +1,25 @@ package com.krylov.refound.entity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "images") +@Getter +@Setter public class Image { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String url; + + @ManyToOne + private Post post; } diff --git a/src/main/java/com/krylov/refound/entity/Message.java b/src/main/java/com/krylov/refound/entity/Message.java new file mode 100644 index 0000000..a317b43 --- /dev/null +++ b/src/main/java/com/krylov/refound/entity/Message.java @@ -0,0 +1,35 @@ +package com.krylov.refound.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "messages") +@Getter +@Setter +public class Message { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + private User sender; + + @ManyToOne + private User receiver; + + @Column(length = 2000) + private String content; + + private LocalDateTime createdAt; + + private boolean isRead; +} diff --git a/src/main/java/com/krylov/refound/entity/Post.java b/src/main/java/com/krylov/refound/entity/Post.java index 5746415..af4dc46 100644 --- a/src/main/java/com/krylov/refound/entity/Post.java +++ b/src/main/java/com/krylov/refound/entity/Post.java @@ -1,4 +1,51 @@ package com.krylov.refound.entity; +import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.enums.PostType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "posts") +@Getter +@Setter public class Post { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private PostType type; + + private String title; + + @Column(length = 2000) + private String description; + + private String category; + + private String city; + + private String district; + + private Double latitude; + private Double longitude; + + private LocalDateTime createdAt; + + @Enumerated(EnumType.STRING) + private PostStatus status; + + @ManyToOne + private User user; } diff --git a/src/main/java/com/krylov/refound/entity/User.java b/src/main/java/com/krylov/refound/entity/User.java index 29be8d8..80f73ca 100644 --- a/src/main/java/com/krylov/refound/entity/User.java +++ b/src/main/java/com/krylov/refound/entity/User.java @@ -1,4 +1,27 @@ package com.krylov.refound.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "users") +@Getter +@Setter public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String email; + private String password; + + private LocalDateTime createdAt; } diff --git a/src/main/java/com/krylov/refound/enums/ErrorCode.java b/src/main/java/com/krylov/refound/enums/ErrorCode.java index fd7dd69..50ea4e8 100644 --- a/src/main/java/com/krylov/refound/enums/ErrorCode.java +++ b/src/main/java/com/krylov/refound/enums/ErrorCode.java @@ -1,4 +1,8 @@ package com.krylov.refound.enums; public enum ErrorCode { + VALIDATION_ERROR, + NOT_FOUND, + INTERNAL_ERROR, + BAD_REQUEST } diff --git a/src/main/java/com/krylov/refound/enums/PostStatus.java b/src/main/java/com/krylov/refound/enums/PostStatus.java index bc7e513..6b51d00 100644 --- a/src/main/java/com/krylov/refound/enums/PostStatus.java +++ b/src/main/java/com/krylov/refound/enums/PostStatus.java @@ -1,4 +1,6 @@ package com.krylov.refound.enums; public enum PostStatus { + ACTIVE, + CLOSED } diff --git a/src/main/java/com/krylov/refound/enums/PostType.java b/src/main/java/com/krylov/refound/enums/PostType.java index 1d3de2d..2d13236 100644 --- a/src/main/java/com/krylov/refound/enums/PostType.java +++ b/src/main/java/com/krylov/refound/enums/PostType.java @@ -1,4 +1,6 @@ package com.krylov.refound.enums; public enum PostType { + LOST, + FOUND } diff --git a/src/main/java/com/krylov/refound/exception/ApiException.java b/src/main/java/com/krylov/refound/exception/ApiException.java index f732800..518fc3d 100644 --- a/src/main/java/com/krylov/refound/exception/ApiException.java +++ b/src/main/java/com/krylov/refound/exception/ApiException.java @@ -1,4 +1,18 @@ package com.krylov.refound.exception; -public class ApiException { +import com.krylov.refound.enums.ErrorCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class ApiException extends RuntimeException { + + private final ErrorCode errorCode; + private final HttpStatus status; + + public ApiException(ErrorCode errorCode, String message, HttpStatus status) { + super(message); + this.errorCode = errorCode; + this.status = status; + } } diff --git a/src/main/java/com/krylov/refound/exception/ErrorResponse.java b/src/main/java/com/krylov/refound/exception/ErrorResponse.java index 085f418..5a31a52 100644 --- a/src/main/java/com/krylov/refound/exception/ErrorResponse.java +++ b/src/main/java/com/krylov/refound/exception/ErrorResponse.java @@ -1,4 +1,15 @@ package com.krylov.refound.exception; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder public class ErrorResponse { + private LocalDateTime timestamp; + private int status; + private String error; + private String message; + private String path; } diff --git a/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java b/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java index d348302..c687fc1 100644 --- a/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java @@ -1,4 +1,56 @@ package com.krylov.refound.exception; +import com.krylov.refound.enums.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@Slf4j public class GlobalExceptionHandler { + @ExceptionHandler(ApiException.class) + public ResponseEntity handleApiException(ApiException ex, HttpServletRequest request) { + return buildResponse(ex.getStatus(), ex.getErrorCode(), ex.getMessage(), request); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { + String message = ex.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(e -> e.getField() + " " + e.getDefaultMessage()) + .orElse("Validation error"); + + return buildResponse(HttpStatus.BAD_REQUEST, ErrorCode.VALIDATION_ERROR, message, request); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnknown(Exception ex, HttpServletRequest request) { + log.error("Unexpected error", ex); + + return buildResponse( + HttpStatus.INTERNAL_SERVER_ERROR, + ErrorCode.INTERNAL_ERROR, + "Internal server error", + request + ); + } + + private ResponseEntity buildResponse(HttpStatus status, ErrorCode code, String message, HttpServletRequest request) { + ErrorResponse response = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(status.value()) + .error(code.name()) + .message(message) + .path(request.getRequestURI()) + .build(); + + return new ResponseEntity<>(response, status); + } } diff --git a/src/main/java/com/krylov/refound/mapper/PostMapper.java b/src/main/java/com/krylov/refound/mapper/PostMapper.java index a782ac9..fe03703 100644 --- a/src/main/java/com/krylov/refound/mapper/PostMapper.java +++ b/src/main/java/com/krylov/refound/mapper/PostMapper.java @@ -1,4 +1,16 @@ package com.krylov.refound.mapper; -public class PostMapper { +import com.krylov.refound.dto.PostRequest; +import com.krylov.refound.dto.PostResponse; +import com.krylov.refound.entity.Post; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface PostMapper { + + Post toEntity(PostRequest request); + + @Mapping(source = "user.email", target = "userEmail") + PostResponse toResponse(Post post); } diff --git a/src/main/java/com/krylov/refound/repository/FavoriteRepository.java b/src/main/java/com/krylov/refound/repository/FavoriteRepository.java new file mode 100644 index 0000000..6217e17 --- /dev/null +++ b/src/main/java/com/krylov/refound/repository/FavoriteRepository.java @@ -0,0 +1,24 @@ +package com.krylov.refound.repository; + +import com.krylov.refound.entity.Favorite; +import com.krylov.refound.entity.Post; +import com.krylov.refound.entity.User; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface FavoriteRepository extends JpaRepository { + + boolean existsByUserAndPost(User user, Post post); + + List findByUser(User user); + + void deleteByUserAndPost(User user, Post post); + + @Query(""" + SELECT f.post.id FROM Favorite f + WHERE f.user.id = :userId + AND f.post.id IN :postIds + """) + List findFavoritePostIds(Long userId, List postIds); +} diff --git a/src/main/java/com/krylov/refound/repository/ImageRepository.java b/src/main/java/com/krylov/refound/repository/ImageRepository.java new file mode 100644 index 0000000..c2898cb --- /dev/null +++ b/src/main/java/com/krylov/refound/repository/ImageRepository.java @@ -0,0 +1,7 @@ +package com.krylov.refound.repository; + +import com.krylov.refound.entity.Image; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageRepository extends JpaRepository { +} diff --git a/src/main/java/com/krylov/refound/repository/MessageRepository.java b/src/main/java/com/krylov/refound/repository/MessageRepository.java new file mode 100644 index 0000000..fc150e8 --- /dev/null +++ b/src/main/java/com/krylov/refound/repository/MessageRepository.java @@ -0,0 +1,81 @@ +package com.krylov.refound.repository; + +import com.krylov.refound.dto.DialogResponse; +import com.krylov.refound.entity.Message; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface MessageRepository extends JpaRepository { + + @Query(""" + SELECT m FROM Message m + WHERE (m.sender.id = :user1 AND m.receiver.id = :user2) + OR (m.sender.id = :user2 AND m.receiver.id = :user1) + ORDER BY m.createdAt + """) + List findChat(Long user1, Long user2); + + @Query(""" + SELECT m FROM Message m + WHERE m.id IN ( + SELECT MAX(m2.id) + FROM Message m2 + WHERE m2.sender.id = :userId OR m2.receiver.id = :userId + GROUP BY + CASE + WHEN m2.sender.id = :userId THEN m2.receiver.id + ELSE m2.sender.id + END + ) + ORDER BY m.createdAt DESC + """) + List findDialogs(Long userId); + + // 🔥 количество непрочитанных от пользователя + @Query(""" + SELECT COUNT(m) FROM Message m + WHERE m.receiver.id = :userId + AND m.sender.id = :otherUserId + AND m.isRead = false + """) + int countUnread(Long userId, Long otherUserId); + + // 🔥 пометить как прочитанные + @Modifying + @Query(""" + UPDATE Message m + SET m.isRead = true + WHERE m.receiver.id = :userId + AND m.sender.id = :otherUserId + AND m.isRead = false + """) + void markAsRead(Long userId, Long otherUserId); + + @Query(""" + SELECT new com.krylov.refound.dto.DialogResponse( + CAST( + CASE + WHEN m.sender.id = :userId THEN m.receiver.id + ELSE m.sender.id + END AS long + ), + CASE + WHEN m.sender.id = :userId THEN m.receiver.email + ELSE m.sender.email + END, + m.content, + m.createdAt, + SUM(CASE WHEN m.receiver.id = :userId AND m.isRead = false THEN 1 ELSE 0 END) + ) + FROM Message m + WHERE m.sender.id = :userId OR m.receiver.id = :userId + GROUP BY + CASE WHEN m.sender.id = :userId THEN m.receiver.id ELSE m.sender.id END, + CASE WHEN m.sender.id = :userId THEN m.receiver.email ELSE m.sender.email END, + m.content, + m.createdAt + """) + List findDialogsWithUnread(Long userId); +} diff --git a/src/main/java/com/krylov/refound/repository/PostRepository.java b/src/main/java/com/krylov/refound/repository/PostRepository.java index 68f15f8..ccc37c3 100644 --- a/src/main/java/com/krylov/refound/repository/PostRepository.java +++ b/src/main/java/com/krylov/refound/repository/PostRepository.java @@ -1,4 +1,45 @@ package com.krylov.refound.repository; -public interface PostRepository { -} +import com.krylov.refound.entity.Post; +import com.krylov.refound.enums.PostType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PostRepository extends JpaRepository, JpaSpecificationExecutor { + + Page findByType(PostType type, Pageable pageable); + + @Query(value = """ + SELECT * FROM Posts p + WHERE + (:type IS NULL OR p.type = :type) + AND (:city IS NULL OR p.city = :city) + AND (:category IS NULL OR p.category = :category) + AND ( + :lat IS NULL OR :lng IS NULL OR :radius IS NULL OR + ( + 6371 * acos( + cos(radians(:lat)) * cos(radians(p.latitude)) * + cos(radians(p.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(p.latitude)) + ) + ) <= :radius + ) + """, + countQuery = "SELECT count(*) FROM Posts p", + nativeQuery = true) + Page searchWithGeo( + @Param("type") String type, + @Param("city") String city, + @Param("category") String category, + @Param("lat") Double lat, + @Param("lng") Double lng, + @Param("radius") Double radius, + Pageable pageable + ); + +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/repository/UserRepository.java b/src/main/java/com/krylov/refound/repository/UserRepository.java index 9316b42..1c1fd00 100644 --- a/src/main/java/com/krylov/refound/repository/UserRepository.java +++ b/src/main/java/com/krylov/refound/repository/UserRepository.java @@ -1,4 +1,9 @@ package com.krylov.refound.repository; -public interface UserRepository { +import com.krylov.refound.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); } diff --git a/src/main/java/com/krylov/refound/service/FavoriteService.java b/src/main/java/com/krylov/refound/service/FavoriteService.java new file mode 100644 index 0000000..6a7fa71 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/FavoriteService.java @@ -0,0 +1,79 @@ +package com.krylov.refound.service; + +import com.krylov.refound.entity.Favorite; +import com.krylov.refound.entity.Post; +import com.krylov.refound.entity.User; +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.exception.ApiException; +import com.krylov.refound.repository.FavoriteRepository; +import com.krylov.refound.repository.PostRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class FavoriteService { + private final FavoriteRepository repository; + private final PostRepository postRepository; + private final UserService userService; + + public void addToFavorites(Long postId) { + + User user = userService.getCurrentUser(); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND)); + + if (repository.existsByUserAndPost(user, post)) { + return; // уже есть + } + + Favorite favorite = new Favorite(); + favorite.setUser(user); + favorite.setPost(post); + + repository.save(favorite); + } + + public void removeFromFavorites(Long postId) { + + User user = userService.getCurrentUser(); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND)); + + repository.deleteByUserAndPost(user, post); + } + + public List getFavorites() { + User user = userService.getCurrentUser(); + + return repository.findByUser(user) + .stream() + .map(Favorite::getPost) + .toList(); + } + + public void toggle(Long postId) { + + User user = userService.getCurrentUser(); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ApiException( + ErrorCode.NOT_FOUND, + "Post not found", + HttpStatus.NOT_FOUND + )); + + if (repository.existsByUserAndPost(user, post)) { + repository.deleteByUserAndPost(user, post); + } else { + Favorite f = new Favorite(); + f.setUser(user); + f.setPost(post); + repository.save(f); + } + } +} diff --git a/src/main/java/com/krylov/refound/service/FileStorageService.java b/src/main/java/com/krylov/refound/service/FileStorageService.java new file mode 100644 index 0000000..fb19de3 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/FileStorageService.java @@ -0,0 +1,42 @@ +package com.krylov.refound.service; + +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.exception.ApiException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class FileStorageService { + + @Value("${file.upload-dir}") + private String uploadDir; + + public String save(MultipartFile file) { + try { + if (file.isEmpty()) { + throw new RuntimeException("File is empty"); + } + + String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + + Path path = Paths.get(uploadDir).toAbsolutePath().normalize(); + Files.createDirectories(path); + + Path target = path.resolve(fileName); + + Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + + return "/files/" + fileName; // URL + } catch (IOException e) { + throw new ApiException(ErrorCode.BAD_REQUEST, "Only image files are allowed", HttpStatus.BAD_REQUEST); + } + } +} diff --git a/src/main/java/com/krylov/refound/service/MessageService.java b/src/main/java/com/krylov/refound/service/MessageService.java new file mode 100644 index 0000000..372ea52 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/MessageService.java @@ -0,0 +1,58 @@ +package com.krylov.refound.service; + +import com.krylov.refound.dto.DialogResponse; +import com.krylov.refound.entity.Message; +import com.krylov.refound.entity.User; +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.exception.ApiException; +import com.krylov.refound.repository.MessageRepository; +import com.krylov.refound.repository.UserRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MessageService { + private final MessageRepository repository; + private final UserRepository userRepository; + private final UserService userService; + + public Message send(Long receiverId, String content) { + + User sender = userService.getCurrentUser(); + + User receiver = userRepository.findById(receiverId) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND)); + + Message message = new Message(); + message.setSender(sender); + message.setReceiver(receiver); + message.setContent(content); + message.setCreatedAt(LocalDateTime.now()); + message.setRead(false); + + return repository.save(message); + } + + public List getChat(Long userId) { + User current = userService.getCurrentUser(); + return repository.findChat(current.getId(), userId); + } + + public List getDialogs() { + User current = userService.getCurrentUser(); + return repository.findDialogsWithUnread(current.getId()); + } + + @Transactional + public void markAsRead(Long otherUserId) { + + User current = userService.getCurrentUser(); + + repository.markAsRead(current.getId(), otherUserId); + } +} diff --git a/src/main/java/com/krylov/refound/service/PostService.java b/src/main/java/com/krylov/refound/service/PostService.java index 4aa394d..6f32b43 100644 --- a/src/main/java/com/krylov/refound/service/PostService.java +++ b/src/main/java/com/krylov/refound/service/PostService.java @@ -1,4 +1,149 @@ package com.krylov.refound.service; +import com.krylov.refound.dto.PostFilter; +import com.krylov.refound.dto.PostRequest; +import com.krylov.refound.dto.PostResponse; +import com.krylov.refound.entity.Image; +import com.krylov.refound.entity.Post; +import com.krylov.refound.entity.User; +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.exception.ApiException; +import com.krylov.refound.mapper.PostMapper; +import com.krylov.refound.repository.FavoriteRepository; +import com.krylov.refound.repository.ImageRepository; +import com.krylov.refound.repository.PostRepository; +import com.krylov.refound.util.PostSpecification; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor public class PostService { + + private final PostRepository repository; + private final ImageRepository imageRepository; + private final FileStorageService storageService; + private final PostMapper mapper; + private final UserService userService; + private final FavoriteRepository favoriteRepository; + + @CacheEvict(value = "posts", allEntries = true) + public PostResponse create(PostRequest request, List files) { + + User user = userService.getCurrentUser(); + + Post post = mapper.toEntity(request); + post.setCreatedAt(LocalDateTime.now()); + post.setStatus(PostStatus.ACTIVE); + post.setUser(user); + + Post savedPost = repository.save(post); + + if (files != null) { + for (MultipartFile file : files) { + String url = storageService.save(file); + + Image image = new Image(); + image.setUrl(url); + image.setPost(savedPost); + + imageRepository.save(image); + } + } + + return mapper.toResponse(savedPost); + } + + @Cacheable(value = "posts", key = "#filter.toString() + '_' + #pageable.pageNumber") + public Page getFeed(PostFilter filter, Pageable pageable) { + + User user = userService.getCurrentUser(); + + Page posts; + + if (filter.getLat() != null) { + posts = repository.searchWithGeo( + filter.getType() != null ? filter.getType().name() : null, + filter.getCity(), + filter.getCategory(), + filter.getLat(), + filter.getLng(), + filter.getRadius(), + pageable + ); + } else { + posts = repository.findAll(PostSpecification.withFilter(filter), pageable); + } + + // 🔥 собираем id всех постов + List postIds = posts.getContent() + .stream() + .map(Post::getId) + .toList(); + + // 🔥 одним запросом получаем избранные + Set favoriteIds = new HashSet<>( + favoriteRepository.findFavoritePostIds(user.getId(), postIds) + ); + + return posts.map(post -> { + PostResponse response = mapper.toResponse(post); + response.setFavorite(favoriteIds.contains(post.getId())); + return response; + }); + } + + @Cacheable(value = "post", key = "#id") + public PostResponse getById(Long id) { + Post post = repository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND)); + + return mapper.toResponse(post); + } + + @CacheEvict(value = {"posts", "post"}, allEntries = true) + public PostResponse update(Long id, PostRequest request) { + + Post post = repository.findById(id) + .orElseThrow(() -> new ApiException( + ErrorCode.NOT_FOUND, + "Post not found", + HttpStatus.NOT_FOUND + )); + + post.setTitle(request.getTitle()); + post.setDescription(request.getDescription()); + post.setCategory(request.getCategory()); + post.setCity(request.getCity()); + post.setDistrict(request.getDistrict()); + post.setType(request.getType()); + + Post updated = repository.save(post); + + return mapper.toResponse(updated); + } + + @CacheEvict(value = {"posts", "post"}, allEntries = true) + public void delete(Long id) { + + Post post = repository.findById(id) + .orElseThrow(() -> new ApiException( + ErrorCode.NOT_FOUND, + "Post not found", + HttpStatus.NOT_FOUND + )); + + repository.delete(post); + } } diff --git a/src/main/java/com/krylov/refound/service/UserService.java b/src/main/java/com/krylov/refound/service/UserService.java index 573e0d4..817fe61 100644 --- a/src/main/java/com/krylov/refound/service/UserService.java +++ b/src/main/java/com/krylov/refound/service/UserService.java @@ -1,4 +1,27 @@ package com.krylov.refound.service; +import com.krylov.refound.entity.User; +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.exception.ApiException; +import com.krylov.refound.repository.UserRepository; +import com.krylov.refound.util.SecurityUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor public class UserService { + private final UserRepository repository; + + public User getCurrentUser() { + String email = SecurityUtils.getCurrentUserEmail(); + + return repository.findByEmail(email) + .orElseThrow(() -> new ApiException( + ErrorCode.NOT_FOUND, + "User not found", + HttpStatus.NOT_FOUND + )); + } } diff --git a/src/main/java/com/krylov/refound/service/security/AuthService.java b/src/main/java/com/krylov/refound/service/security/AuthService.java index beb2d56..08133ae 100644 --- a/src/main/java/com/krylov/refound/service/security/AuthService.java +++ b/src/main/java/com/krylov/refound/service/security/AuthService.java @@ -1,4 +1,4 @@ -package com.krylov.refound.service; +package com.krylov.refound.service.security; import com.krylov.refound.dto.AuthRequest; import com.krylov.refound.dto.AuthResponse; diff --git a/src/main/java/com/krylov/refound/service/security/CustomUserDetailsService.java b/src/main/java/com/krylov/refound/service/security/CustomUserDetailsService.java index e080a45..6e46414 100644 --- a/src/main/java/com/krylov/refound/service/security/CustomUserDetailsService.java +++ b/src/main/java/com/krylov/refound/service/security/CustomUserDetailsService.java @@ -1,4 +1,4 @@ -package com.krylov.refound.service; +package com.krylov.refound.service.security; import com.krylov.refound.entity.User; import com.krylov.refound.repository.UserRepository; diff --git a/src/main/java/com/krylov/refound/service/security/JwtFilter.java b/src/main/java/com/krylov/refound/service/security/JwtFilter.java index 528234d..a27376a 100644 --- a/src/main/java/com/krylov/refound/service/security/JwtFilter.java +++ b/src/main/java/com/krylov/refound/service/security/JwtFilter.java @@ -1,4 +1,4 @@ -package com.krylov.refound.service; +package com.krylov.refound.service.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/src/main/java/com/krylov/refound/service/security/JwtService.java b/src/main/java/com/krylov/refound/service/security/JwtService.java index 5ce5c0d..027bb8f 100644 --- a/src/main/java/com/krylov/refound/service/security/JwtService.java +++ b/src/main/java/com/krylov/refound/service/security/JwtService.java @@ -1,4 +1,4 @@ -package com.krylov.refound.service; +package com.krylov.refound.service.security; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; diff --git a/src/main/java/com/krylov/refound/util/JwtHandshakeInterceptor.java b/src/main/java/com/krylov/refound/util/JwtHandshakeInterceptor.java new file mode 100644 index 0000000..a0aba15 --- /dev/null +++ b/src/main/java/com/krylov/refound/util/JwtHandshakeInterceptor.java @@ -0,0 +1,46 @@ +package com.krylov.refound.util; + +import java.util.List; +import java.util.Map; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +public class JwtHandshakeInterceptor implements HandshakeInterceptor { + + @Override + public boolean beforeHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes + ) throws Exception { + + List auth = request.getHeaders().get("Authorization"); + + if (auth == null || auth.isEmpty()) { + return false; // запретить подключение + } + + String token = auth.get(0).replace("Bearer ", ""); + + // 🔥 проверяем JWT + String userEmail = JwtUtils.validateTokenAndGetEmail(token); + + if (userEmail == null) { + return false; + } + + attributes.put("userEmail", userEmail); + return true; + } + + @Override + public void afterHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception + ) { } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/util/JwtUtils.java b/src/main/java/com/krylov/refound/util/JwtUtils.java new file mode 100644 index 0000000..ab42a8d --- /dev/null +++ b/src/main/java/com/krylov/refound/util/JwtUtils.java @@ -0,0 +1,23 @@ +package com.krylov.refound.util; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtils { + private static final String SECRET = "SECRET_KEY"; // заменить на env var + + public static String validateTokenAndGetEmail(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes())) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/krylov/refound/util/PostSpecification.java b/src/main/java/com/krylov/refound/util/PostSpecification.java new file mode 100644 index 0000000..e97ee56 --- /dev/null +++ b/src/main/java/com/krylov/refound/util/PostSpecification.java @@ -0,0 +1,58 @@ +package com.krylov.refound.util; + +import com.krylov.refound.dto.PostFilter; +import com.krylov.refound.entity.Post; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.jpa.domain.Specification; + +public class PostSpecification { + public static Specification withFilter(PostFilter filter) { + return (root, query, cb) -> { + + List predicates = new ArrayList<>(); + + + + if (filter.getType() != null) { + predicates.add(cb.equal(root.get("type"), filter.getType())); + } + + if (filter.getCity() != null) { + predicates.add(cb.equal(root.get("city"), filter.getCity())); + } + + if (filter.getCategory() != null) { + predicates.add(cb.equal(root.get("category"), filter.getCategory())); + } + + if (filter.getDistrict() != null) { + predicates.add(cb.equal(root.get("district"), filter.getDistrict())); + } + if (filter.getSearch() != null && !filter.getSearch().isBlank()) { + String search = "%" + filter.getSearch().toLowerCase() + "%"; + + predicates.add(cb.or( + cb.like(cb.lower(root.get("title")), search), + cb.like(cb.lower(root.get("description")), search) + )); + } + if (filter.getLat() != null && filter.getLng() != null && filter.getRadius() != null) { + + Expression distance = cb.function( + "6371 * acos(cos(radians(?1)) * cos(radians(latitude)) * " + + "cos(radians(longitude) - radians(?2)) + sin(radians(?1)) * sin(radians(latitude)))", + Double.class, + cb.literal(filter.getLat()), + cb.literal(filter.getLng()) + ); + + predicates.add(cb.lessThanOrEqualTo(distance, filter.getRadius())); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + } +} diff --git a/src/main/java/com/krylov/refound/util/SecurityUtils.java b/src/main/java/com/krylov/refound/util/SecurityUtils.java index 6e4ef5b..0eb9e13 100644 --- a/src/main/java/com/krylov/refound/util/SecurityUtils.java +++ b/src/main/java/com/krylov/refound/util/SecurityUtils.java @@ -1,4 +1,11 @@ package com.krylov.refound.util; +import org.springframework.security.core.context.SecurityContextHolder; + public class SecurityUtils { + public static String getCurrentUserEmail() { + return SecurityContextHolder.getContext() + .getAuthentication() + .getName(); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index cd8a3f8..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=ReFound diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..14183e3 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,36 @@ +spring: + servlet: + multipart: + max-file-size: 5MB + max-request-size: 10MB + + datasource: + url: jdbc:postgresql://localhost:5432/nahodka + username: user + password: pass + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: update # на старте удобно + show-sql: true + properties: + hibernate: + format_sql: true + + data: + redis: + host: localhost + port: 6379 + + cache: + type: redis + + liquibase: + change-log: classpath:db/changelog/db.changelog-master.yaml + +file: + upload-dir: uploads/ + +cache: + ttl: 300 \ No newline at end of file diff --git a/src/main/resources/db/changelog/001-init.yaml b/src/main/resources/db/changelog/001-init.yaml new file mode 100644 index 0000000..904d0b1 --- /dev/null +++ b/src/main/resources/db/changelog/001-init.yaml @@ -0,0 +1,57 @@ +databaseChangeLog: + - changeSet: + id: 001-create-users + author: you + changes: + - createTable: + tableName: users + columns: + - column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } } + - column: { name: email, type: VARCHAR(255), constraints: { nullable: false, unique: true } } + - column: { name: password, type: VARCHAR(255), constraints: { nullable: false } } + - column: { name: created_at, type: TIMESTAMP } + + - changeSet: + id: 002-create-posts + author: you + changes: + - createTable: + tableName: posts + columns: + - column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } } + - column: { name: type, type: VARCHAR(20) } + - column: { name: title, type: VARCHAR(255) } + - column: { name: description, type: TEXT } + - column: { name: category, type: VARCHAR(100) } + - column: { name: city, type: VARCHAR(100) } + - column: { name: district, type: VARCHAR(100) } + - column: { name: latitude, type: DOUBLE } + - column: { name: longitude, type: DOUBLE } + - column: { name: status, type: VARCHAR(20) } + - column: { name: created_at, type: TIMESTAMP } + - column: { name: user_id, type: BIGINT } + + - addForeignKeyConstraint: + baseTableName: posts + baseColumnNames: user_id + referencedTableName: users + referencedColumnNames: id + constraintName: fk_posts_user # ✅ ДОБАВИТЬ + + - changeSet: + id: 003-create-images + author: you + changes: + - createTable: + tableName: images + columns: + - column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } } + - column: { name: url, type: VARCHAR(500) } + - column: { name: post_id, type: BIGINT } + + - addForeignKeyConstraint: + baseTableName: images + baseColumnNames: post_id + referencedTableName: posts + referencedColumnNames: id + constraintName: fk_images_post # ✅ \ No newline at end of file diff --git a/src/main/resources/db/changelog/002-message.yaml b/src/main/resources/db/changelog/002-message.yaml new file mode 100644 index 0000000..9a5f17c --- /dev/null +++ b/src/main/resources/db/changelog/002-message.yaml @@ -0,0 +1,37 @@ +databaseChangeLog: + - changeSet: + id: 004-create-messages + author: you + changes: + - createTable: + tableName: messages + columns: + - column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } } + - column: { name: sender_id, type: BIGINT } + - column: { name: receiver_id, type: BIGINT } + - column: { name: content, type: TEXT } + - column: { name: created_at, type: TIMESTAMP } + - column: { name: is_read, type: BOOLEAN } + + - addForeignKeyConstraint: + baseTableName: messages + baseColumnNames: sender_id + referencedTableName: users + referencedColumnNames: id + constraintName: fk_messages_sender # ✅ + + - addForeignKeyConstraint: + baseTableName: messages + baseColumnNames: receiver_id + referencedTableName: users + referencedColumnNames: id + constraintName: fk_messages_receiver # ✅ + + # 🔥 индекс для чата + - createIndex: + tableName: messages + indexName: idx_messages_users + columns: + - column: { name: sender_id } + - column: { name: receiver_id } + - column: { name: created_at } \ No newline at end of file diff --git a/src/main/resources/db/changelog/003-favorite.yaml b/src/main/resources/db/changelog/003-favorite.yaml new file mode 100644 index 0000000..c9a1ea5 --- /dev/null +++ b/src/main/resources/db/changelog/003-favorite.yaml @@ -0,0 +1,31 @@ +databaseChangeLog: + - changeSet: + id: 005-create-favorites + author: you + changes: + - createTable: + tableName: favorites + columns: + - column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } } + - column: { name: user_id, type: BIGINT } + - column: { name: post_id, type: BIGINT } + + - addForeignKeyConstraint: + baseTableName: favorites + baseColumnNames: user_id + referencedTableName: users + referencedColumnNames: id + constraintName: fk_favorites_user # ✅ + + - addForeignKeyConstraint: + baseTableName: favorites + baseColumnNames: post_id + referencedTableName: posts + referencedColumnNames: id + constraintName: fk_favorites_post # ✅ + + # 🔥 защита от дублей + - addUniqueConstraint: + tableName: favorites + columnNames: user_id, post_id + constraintName: unique_user_post \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..b0b3410 --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,9 @@ +databaseChangeLog: + - include: + file: db/changelog/001-init.yaml + + - include: + file: db/changelog/002-message.yaml + + - include: + file: db/changelog/003-favorite.yaml \ No newline at end of file