From 2468c26b84d46cd895658c25feb278a857f6818c Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Fri, 31 Jul 2026 01:08:53 +0300 Subject: [PATCH] fixed controllers --- .../refound/ai/client/AiHttpClient.java | 3 +- .../ai/client/AiMultipartHttpClient.java | 10 +- .../ai/client/ImageModerationClient.java | 34 +-- .../ai/client/TextModerationClient.java | 21 +- .../refound/ai/config/AiProperties.java | 2 + .../refound/ai/config/WebClientConfig.java | 12 +- .../pipeline/ContentModerationPipeline.java | 3 + .../ai/pipeline/ModerationContext.java | 2 + .../ai/pipeline/TextModerationProcessor.java | 19 +- .../com/krylov/refound/config/WebConfig.java | 29 --- .../krylov/refound/config/WebMvcConfig.java | 16 -- .../refound/controller/AuthController.java | 74 ++---- .../refound/controller/ChatController.java | 48 ++-- .../controller/ChatWebSocketController.java | 20 +- .../controller/FavoriteController.java | 11 +- .../controller/FileStorageController.java | 32 ++- .../refound/controller/PostController.java | 58 ++--- .../refound/controller/ReviewsController.java | 9 +- .../controller/StatisticsController.java | 3 + .../refound/controller/UserController.java | 2 +- .../krylov/refound/dto/UserRegisterDto.java | 13 ++ .../exception/GlobalExceptionHandler.java | 11 + .../InvalidCredentialsException.java | 8 + .../LoginAlreadyExistsException.java | 8 + .../refound/repository/MessageRepository.java | 16 +- .../krylov/refound/service/AuthService.java | 77 ++++++ .../krylov/refound/service/AvatarService.java | 26 --- .../krylov/refound/service/ChatService.java | 78 ++++--- .../refound/service/FavoriteService.java | 38 +-- .../refound/service/FileStorageService.java | 219 +++++++++++------- .../krylov/refound/service/PostService.java | 49 ++++ .../krylov/refound/service/UserService.java | 17 +- 32 files changed, 536 insertions(+), 432 deletions(-) delete mode 100644 src/main/java/com/krylov/refound/config/WebConfig.java delete mode 100644 src/main/java/com/krylov/refound/config/WebMvcConfig.java create mode 100644 src/main/java/com/krylov/refound/dto/UserRegisterDto.java create mode 100644 src/main/java/com/krylov/refound/exception/InvalidCredentialsException.java create mode 100644 src/main/java/com/krylov/refound/exception/LoginAlreadyExistsException.java create mode 100644 src/main/java/com/krylov/refound/service/AuthService.java delete mode 100644 src/main/java/com/krylov/refound/service/AvatarService.java diff --git a/src/main/java/com/krylov/refound/ai/client/AiHttpClient.java b/src/main/java/com/krylov/refound/ai/client/AiHttpClient.java index 95260d3..eab9bb4 100644 --- a/src/main/java/com/krylov/refound/ai/client/AiHttpClient.java +++ b/src/main/java/com/krylov/refound/ai/client/AiHttpClient.java @@ -10,7 +10,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientRequestException; - @Slf4j @Component @RequiredArgsConstructor @@ -19,7 +18,7 @@ public class AiHttpClient { private final WebClient aiWebClient; public R post(String uri, T request, Class responseType) { - log.info("AI POST {}", uri); + log.info("AI TEXT POST {}", uri); try { R response = aiWebClient.post() .uri(uri) diff --git a/src/main/java/com/krylov/refound/ai/client/AiMultipartHttpClient.java b/src/main/java/com/krylov/refound/ai/client/AiMultipartHttpClient.java index 72cb296..9a8d7b6 100644 --- a/src/main/java/com/krylov/refound/ai/client/AiMultipartHttpClient.java +++ b/src/main/java/com/krylov/refound/ai/client/AiMultipartHttpClient.java @@ -21,18 +21,14 @@ public class AiMultipartHttpClient { private final WebClient aiWebClient; public R post(String uri, MultipartFile file, Class responseType) { - log.info("AI POST {}", uri); + log.info("AI IMAGE POST {}", uri); try { MultipartBodyBuilder builder = new MultipartBodyBuilder(); - builder.part( - "file", - new ByteArrayResource(file.getBytes()) { - + builder.part("file", new ByteArrayResource(file.getBytes()) { @Override public String getFilename() { return file.getOriginalFilename(); } - } ); @@ -56,8 +52,10 @@ public class AiMultipartHttpClient { log.info("AI image response received"); return response; } catch (WebClientRequestException ex) { + log.error("WebClientRequestException in AiMultipartHttpClient {}", ex.getMessage(), ex); throw new AiUnavailableException(ex); } catch (Exception ex) { + log.error("Exception in AiMultipartHttpClient {}", ex.getMessage(), ex); throw new RuntimeException("Cannot send image to AI", ex); } } diff --git a/src/main/java/com/krylov/refound/ai/client/ImageModerationClient.java b/src/main/java/com/krylov/refound/ai/client/ImageModerationClient.java index 7596ef0..76c904d 100644 --- a/src/main/java/com/krylov/refound/ai/client/ImageModerationClient.java +++ b/src/main/java/com/krylov/refound/ai/client/ImageModerationClient.java @@ -1,40 +1,40 @@ package com.krylov.refound.ai.client; import com.krylov.refound.ai.dto.ImageModerationResponse; +import com.krylov.refound.ai.dto.ModerationResponse; import com.krylov.refound.ai.exception.AiServerException; import com.krylov.refound.ai.exception.AiUnavailableException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; - +@Slf4j @Service @RequiredArgsConstructor public class ImageModerationClient { private final AiMultipartHttpClient client; @Retryable( - retryFor = { - AiUnavailableException.class, - AiServerException.class - }, + retryFor = {AiUnavailableException.class, AiServerException.class}, maxAttempts = 3, - backoff = @Backoff( - delay = 500, - multiplier = 2 - ) + backoff = @Backoff(delay = 500, multiplier = 2) ) - public ImageModerationResponse moderate( - MultipartFile file - ) { + public ImageModerationResponse moderate(MultipartFile file) { + log.info("Calling AI ImageModeration service"); + return client.post("/api/v1/moderation/image", file, ImageModerationResponse.class); + } - return client.post( - "/api/v1/moderation/image", - file, - ImageModerationResponse.class - ); + @Recover + public ModerationResponse recover(AiUnavailableException e, String text) { + log.error("AI ImageModeration unavailable after retries", e); + return ModerationResponse.builder() + .approved(false) + .reason("AI_UNAVAILABLE") + .build(); } } diff --git a/src/main/java/com/krylov/refound/ai/client/TextModerationClient.java b/src/main/java/com/krylov/refound/ai/client/TextModerationClient.java index c72cc05..7ecb105 100644 --- a/src/main/java/com/krylov/refound/ai/client/TextModerationClient.java +++ b/src/main/java/com/krylov/refound/ai/client/TextModerationClient.java @@ -19,29 +19,18 @@ public class TextModerationClient { private final AiHttpClient client; @Retryable( - retryFor = { - AiUnavailableException.class, - AiServerException.class - }, + retryFor = {AiUnavailableException.class, AiServerException.class}, maxAttempts = 3, - backoff = @Backoff( - delay = 500, - multiplier = 2 - ) + backoff = @Backoff(delay = 500, multiplier = 2) ) public ModerationResponse moderate(String text) { - log.info("Calling AI service"); - return client.post( - "/api/v1/moderation/text", - new TextModerationRequest(text), - ModerationResponse.class - ); - + log.info("Calling AI TextModeration service"); + return client.post("/api/v1/moderation/text", new TextModerationRequest(text), ModerationResponse.class); } @Recover public ModerationResponse recover(AiUnavailableException e, String text) { - log.error("AI unavailable after retries", e); + log.error("AI TextModeration unavailable after retries", e); return ModerationResponse.builder() .approved(false) diff --git a/src/main/java/com/krylov/refound/ai/config/AiProperties.java b/src/main/java/com/krylov/refound/ai/config/AiProperties.java index e190d5a..6b917bc 100644 --- a/src/main/java/com/krylov/refound/ai/config/AiProperties.java +++ b/src/main/java/com/krylov/refound/ai/config/AiProperties.java @@ -2,11 +2,13 @@ package com.krylov.refound.ai.config; import lombok.Getter; import lombok.Setter; +import lombok.ToString; import org.springframework.boot.context.properties.ConfigurationProperties; @Getter @Setter @ConfigurationProperties(prefix = "ai") +@ToString public class AiProperties { /** * http://localhost:8000 diff --git a/src/main/java/com/krylov/refound/ai/config/WebClientConfig.java b/src/main/java/com/krylov/refound/ai/config/WebClientConfig.java index 16fac02..3e785e2 100644 --- a/src/main/java/com/krylov/refound/ai/config/WebClientConfig.java +++ b/src/main/java/com/krylov/refound/ai/config/WebClientConfig.java @@ -1,6 +1,7 @@ package com.krylov.refound.ai.config; import java.time.Duration; +import lombok.extern.slf4j.Slf4j; import reactor.netty.http.client.HttpClient; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -8,26 +9,23 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; +@Slf4j @Configuration @EnableConfigurationProperties(AiProperties.class) public class WebClientConfig { @Bean public WebClient aiWebClient(AiProperties properties) { - + log.info("aiWebClient get for properties {}", properties); HttpClient httpClient = HttpClient.create() .responseTimeout( - Duration.ofSeconds( - properties.getTimeout() - ) + Duration.ofSeconds(properties.getTimeout()) ); return WebClient.builder() .baseUrl(properties.getUrl()) - .clientConnector( - new ReactorClientHttpConnector(httpClient) - ) + .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); } diff --git a/src/main/java/com/krylov/refound/ai/pipeline/ContentModerationPipeline.java b/src/main/java/com/krylov/refound/ai/pipeline/ContentModerationPipeline.java index b8c2711..cb70a2a 100644 --- a/src/main/java/com/krylov/refound/ai/pipeline/ContentModerationPipeline.java +++ b/src/main/java/com/krylov/refound/ai/pipeline/ContentModerationPipeline.java @@ -2,8 +2,10 @@ package com.krylov.refound.ai.pipeline; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class ContentModerationPipeline { @@ -11,6 +13,7 @@ public class ContentModerationPipeline { private final List processors; public void execute(ModerationContext context) { + log.info("execute ContentModerationPipeline"); for (ModerationProcessor processor : processors) { processor.process(context); } diff --git a/src/main/java/com/krylov/refound/ai/pipeline/ModerationContext.java b/src/main/java/com/krylov/refound/ai/pipeline/ModerationContext.java index eb4bdad..b998938 100644 --- a/src/main/java/com/krylov/refound/ai/pipeline/ModerationContext.java +++ b/src/main/java/com/krylov/refound/ai/pipeline/ModerationContext.java @@ -3,9 +3,11 @@ package com.krylov.refound.ai.pipeline; import java.util.List; import lombok.Builder; import lombok.Data; +import lombok.ToString; import org.springframework.web.multipart.MultipartFile; @Data +@ToString @Builder public class ModerationContext { private String title; diff --git a/src/main/java/com/krylov/refound/ai/pipeline/TextModerationProcessor.java b/src/main/java/com/krylov/refound/ai/pipeline/TextModerationProcessor.java index 3c8c8ed..edaaab6 100644 --- a/src/main/java/com/krylov/refound/ai/pipeline/TextModerationProcessor.java +++ b/src/main/java/com/krylov/refound/ai/pipeline/TextModerationProcessor.java @@ -18,32 +18,22 @@ public class TextModerationProcessor implements ModerationProcessor { @Override public void process(ModerationContext context) { - + log.info("process TextModerationProcessor for context {}", context); String text = buildText( context.getTitle(), context.getDescription() ); ModerationResponse response = client.moderate(text); - - log.info( - "AI moderation result approved={}, score={}, reason={}", - response.getApproved(), - response.getScore(), - response.getReason() - ); + log.info("AI moderation result approved={}, score={}, reason={}", response.getApproved(), response.getScore(), response.getReason()); if (!response.getApproved()) { - throw new ContentBlockedException( - response.getReason(), - response.getDetectedLabels() - ); + throw new ContentBlockedException(response.getReason(), response.getDetectedLabels()); } } private String buildText(String title, String description) { - StringBuilder builder = new StringBuilder(); if (title != null) { @@ -51,8 +41,7 @@ public class TextModerationProcessor implements ModerationProcessor { } if (description != null) { - builder.append("\n") - .append(description); + builder.append("\n").append(description); } return builder.toString(); diff --git a/src/main/java/com/krylov/refound/config/WebConfig.java b/src/main/java/com/krylov/refound/config/WebConfig.java deleted file mode 100644 index 88eb95c..0000000 --- a/src/main/java/com/krylov/refound/config/WebConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -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.CorsRegistry; -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); - } - - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOriginPatterns("*") // πŸ”₯ Π²Π°ΠΆΠ½ΠΎ - .allowedMethods("*") - .allowedHeaders("*") - .allowCredentials(true); - } -} diff --git a/src/main/java/com/krylov/refound/config/WebMvcConfig.java b/src/main/java/com/krylov/refound/config/WebMvcConfig.java deleted file mode 100644 index 5fc2c18..0000000 --- a/src/main/java/com/krylov/refound/config/WebMvcConfig.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.krylov.refound.config; - -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 WebMvcConfig implements WebMvcConfigurer { - - @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - registry - .addResourceHandler("/uploads/**") - .addResourceLocations("file:uploads/"); // ΠΏΠ°ΠΏΠΊΠ° uploads/ Π² ΠΊΠΎΡ€Π½Π΅ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π° - } -} diff --git a/src/main/java/com/krylov/refound/controller/AuthController.java b/src/main/java/com/krylov/refound/controller/AuthController.java index dd7e094..8d0a586 100644 --- a/src/main/java/com/krylov/refound/controller/AuthController.java +++ b/src/main/java/com/krylov/refound/controller/AuthController.java @@ -4,15 +4,12 @@ import com.krylov.refound.dto.AuthRequest; import com.krylov.refound.dto.AuthResponse; import com.krylov.refound.dto.CheckLoginDto; import com.krylov.refound.dto.RegisterRequest; -import com.krylov.refound.dto.UserDto; -import com.krylov.refound.entity.User; -import com.krylov.refound.repository.UserRepository; -import com.krylov.refound.security.JwtService; +import com.krylov.refound.dto.UserRegisterDto; +import com.krylov.refound.service.AuthService; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -24,73 +21,28 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor public class AuthController { - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - private final JwtService jwtService; + private final AuthService authService; @PostMapping("/register") public ResponseEntity register(@RequestBody RegisterRequest request) { - - // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° Ρ‡Ρ‚ΠΎ Π»ΠΎΠ³ΠΈΠ½ Π½Π΅ занят - if (userRepository.findByLogin(request.getLogin()).isPresent()) { - return ResponseEntity - .badRequest() - .body(Map.of("message", "Π›ΠΎΠ³ΠΈΠ½ ΡƒΠΆΠ΅ занят")); - } - - // Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ - User user = new User(); - user.setName(request.getName()); - user.setLogin(request.getLogin()); - user.setPassword(passwordEncoder.encode(request.getPassword())); - - userRepository.save(user); - - String token = jwtService.generateToken(user.getLogin()); - - UserDto userDto = new UserDto( - user.getId(), - user.getName(), - user.getLastName(), - user.getLogin(), - user.getEmail(), - user.getPhone(), - user.getAvatarUrl(), - user.getRole() - ); - return ResponseEntity.ok(new AuthResponse(token, userDto)); + log.info("register user {}", request); + UserRegisterDto userRegisterDto = authService.registerUser(request); + return ResponseEntity.ok( + new AuthResponse(userRegisterDto.getToken(), userRegisterDto.getUserDto())); } @PostMapping("/login") public ResponseEntity login(@RequestBody AuthRequest request) { - - User user = userRepository.findByLogin(request.getLogin()) - .orElse(null); - - if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { - return ResponseEntity - .badRequest() - .body(Map.of("message", "НСвСрный Π»ΠΎΠ³ΠΈΠ½ ΠΈΠ»ΠΈ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ")); - } - - String token = jwtService.generateToken(user.getLogin()); - - UserDto userDto = new UserDto( - user.getId(), - user.getName(), - user.getLastName(), - user.getLogin(), - user.getEmail(), - user.getPhone(), - user.getAvatarUrl(), - user.getRole() - ); - return ResponseEntity.ok(new AuthResponse(token, userDto)); + log.info("user {} logging", request.getLogin()); + UserRegisterDto userRegisterDto = authService.loginUser(request); + return ResponseEntity.ok( + new AuthResponse(userRegisterDto.getToken(), userRegisterDto.getUserDto())); } @PostMapping("/check-login") public ResponseEntity checkLogin(@RequestBody CheckLoginDto request) { - boolean exists = userRepository.existsByLogin(request.getLogin()); + log.info("checkLogin for {}", request.getLogin()); + boolean exists = authService.existsByLogin(request); return ResponseEntity.ok(Map.of("exists", exists)); } diff --git a/src/main/java/com/krylov/refound/controller/ChatController.java b/src/main/java/com/krylov/refound/controller/ChatController.java index 1053682..966c1c6 100644 --- a/src/main/java/com/krylov/refound/controller/ChatController.java +++ b/src/main/java/com/krylov/refound/controller/ChatController.java @@ -4,11 +4,17 @@ import com.krylov.refound.dto.*; import com.krylov.refound.service.ChatService; import com.krylov.refound.service.UserService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/chats") @@ -20,40 +26,46 @@ public class ChatController { // Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Ρ‡Π°Ρ‚ с ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΌ ΠΈΠ»ΠΈ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ @PostMapping("/with/{otherUserId}") public ChatDto createOrGetChat(@PathVariable Long otherUserId, @RequestParam(required = false) Long postId, Authentication auth) { - Long currentUserId = extractUserId(auth); // см. Π½ΠΈΠΆΠ΅ ΠΊΠ°ΠΊ Π΄ΠΎΡΡ‚Π°Ρ‚ΡŒ id + Long currentUserId = extractUserId(auth); + log.info("createOrGetChat currentUserId {}, otherUserId {}, postId {}", currentUserId, otherUserId, postId); return chatService.getOrCreateChat(currentUserId, otherUserId, postId); } // Бписок всСх Ρ‡Π°Ρ‚ΠΎΠ² Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ @GetMapping public List getMyChats(Authentication auth) { - return chatService.getUserChats(extractUserId(auth)); + Long currentUserId = extractUserId(auth); + log.info("getMyChats for user {}", currentUserId); + return chatService.getUserChats(currentUserId); } - // Π˜ΡΡ‚ΠΎΡ€ΠΈΡ сообщСний ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΠΎΠ³ΠΎ Ρ‡Π°Ρ‚Π° + // Π˜ΡΡ‚ΠΎΡ€ΠΈΡ сообщСний ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΠΎΠ³ΠΎ Ρ‡Π°Ρ‚Π°, с ΠΏΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΠ΅ΠΉ @GetMapping("/{chatId}/messages") - public List getMessages(@PathVariable Long chatId) { - return chatService.getMessages(chatId); - } - - private Long extractUserId(Authentication auth) { - // ΠŸΠΎΠ΄ΡΡ‚Π°Π²ΡŒ свою Π»ΠΎΠ³ΠΈΠΊΡƒ получСния userId ΠΈΠ· JWT/Authentication - // НапримСр Ссли Π² Ρ‚ΠΎΠΊΠ΅Π½Π΅ login, Π° Π½Π΅ id - подтяни Ρ‡Π΅Ρ€Π΅Π· UserService - String login = auth.getName(); - return userService.getUserIdByLogin(login); + public Page getMessages(@PathVariable Long chatId, @PageableDefault(size = 30, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, Authentication auth) { + Long currentUserId = extractUserId(auth); + log.info("getMessages for chatId {} page {} by user {}", chatId, pageable.getPageNumber(), currentUserId); + return chatService.getMessages(chatId, currentUserId, pageable); } @DeleteMapping("/{chatId}") public void deleteChat(@PathVariable Long chatId, Authentication auth) { Long currentUserId = extractUserId(auth); - chatService.deleteChat(chatId); + log.info("user {} deleteChat {}", currentUserId, chatId); + chatService.deleteChat(chatId, currentUserId); } - @PostMapping("/api/v1/chats/{chatId}/read") - public void markAsRead(@PathVariable Long chatId) { - Long userId = (Long) SecurityContextHolder.getContext() - .getAuthentication().getPrincipal(); // ΠΈΠ»ΠΈ Ρ‚Π²ΠΎΠΉ способ - chatService.markAsRead(chatId, userId); + @PostMapping("/{chatId}/read") + public void markAsRead(@PathVariable Long chatId, Authentication auth) { + Long currentUserId = extractUserId(auth); + log.info("markAsRead chatId {} by user {}", chatId, currentUserId); + chatService.markAsRead(chatId, currentUserId); + } + + private Long extractUserId(Authentication auth) { + // ΠŸΠΎΠ΄ΡΡ‚Π°Π²ΡŒ свою Π»ΠΎΠ³ΠΈΠΊΡƒ получСния userId ΠΈΠ· JWT/Authentication + String login = auth.getName(); + log.debug("extractUserId by login {}", login); + return userService.getUserIdByLogin(login); } } diff --git a/src/main/java/com/krylov/refound/controller/ChatWebSocketController.java b/src/main/java/com/krylov/refound/controller/ChatWebSocketController.java index c7d0e97..37aecc8 100644 --- a/src/main/java/com/krylov/refound/controller/ChatWebSocketController.java +++ b/src/main/java/com/krylov/refound/controller/ChatWebSocketController.java @@ -2,21 +2,37 @@ package com.krylov.refound.controller; import com.krylov.refound.dto.SendMessageRequest; import com.krylov.refound.service.ChatService; +import java.util.Objects; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.bind.annotation.RestController; +@Slf4j @RestController @RequiredArgsConstructor public class ChatWebSocketController { private final ChatService chatService; + private final SimpMessagingTemplate messagingTemplate; // ΠšΠ»ΠΈΠ΅Π½Ρ‚ ΡˆΠ»Ρ‘Ρ‚ сюда: /app/chat.send @MessageMapping("/chat.send") public void sendMessage(SendMessageRequest request, SimpMessageHeaderAccessor headerAccessor) { - Long senderId = (Long) headerAccessor.getSessionAttributes().get("userId"); - chatService.sendMessage(senderId, request.getChatId(), request.getContent()); + Long senderId = (Long) Objects.requireNonNull(headerAccessor.getSessionAttributes()).get("userId"); + if (senderId == null) { + log.warn("sendMessage: senderId is null, session attrs missing userId"); + return; // ΠΈΠ»ΠΈ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ ΠΎΡˆΠΈΠ±ΠΊΡƒ ΠΊΠ»ΠΈΠ΅Π½Ρ‚Ρƒ + } + if (request.getContent() == null || request.getContent().isBlank()) { + return; + } + try { + chatService.sendMessage(senderId, request.getChatId(), request.getContent()); + } catch (SecurityException | IllegalArgumentException e) { + messagingTemplate.convertAndSendToUser(senderId.toString(), "/queue/errors", e.getMessage()); + } // ChatService сам ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ сообщСниС всСм подписчикам Ρ‡Π΅Ρ€Π΅Π· messagingTemplate } } diff --git a/src/main/java/com/krylov/refound/controller/FavoriteController.java b/src/main/java/com/krylov/refound/controller/FavoriteController.java index abf04a5..0244d9f 100644 --- a/src/main/java/com/krylov/refound/controller/FavoriteController.java +++ b/src/main/java/com/krylov/refound/controller/FavoriteController.java @@ -1,34 +1,33 @@ 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 lombok.extern.slf4j.Slf4j; 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; +@Slf4j @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) { + log.info("toggle post {}", postId); service.toggle(postId); } @GetMapping public List getAll() { - return service.getFavorites() - .stream() - .map(mapper::toResponse) - .toList(); + log.info("get all likes"); + return service.getFavorites(); } } diff --git a/src/main/java/com/krylov/refound/controller/FileStorageController.java b/src/main/java/com/krylov/refound/controller/FileStorageController.java index 4c04a55..0e57e72 100644 --- a/src/main/java/com/krylov/refound/controller/FileStorageController.java +++ b/src/main/java/com/krylov/refound/controller/FileStorageController.java @@ -2,15 +2,18 @@ package com.krylov.refound.controller; import com.krylov.refound.service.FileStorageService; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.util.UriUtils; - -import java.nio.charset.StandardCharsets; +@Slf4j @RestController @RequestMapping("/api/v1/files") @RequiredArgsConstructor @@ -19,26 +22,21 @@ public class FileStorageController { @PostMapping public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { + log.info("uploadFile"); String objectName = fileStorageService.uploadFile(file); return ResponseEntity.ok(objectName); // Ρ„Ρ€ΠΎΠ½Ρ‚ сохранит objectName ΠΊΠ°ΠΊ ΠΊΠ»ΡŽΡ‡ ΠΊΠ°Ρ€Ρ‚ΠΈΠ½ΠΊΠΈ } - @GetMapping("/{objectName}") + @GetMapping("/{*objectName}") public ResponseEntity downloadFile(@PathVariable String objectName) { - FileStorageService.DownloadedFile downloadedFile = fileStorageService.downloadFile(objectName); - - String encodedFilename = UriUtils.encode(downloadedFile.originalFilename(), StandardCharsets.UTF_8); - HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename); - - return ResponseEntity.ok() - .headers(headers) - .contentType(MediaType.parseMediaType(downloadedFile.contentType())) - .body(downloadedFile.content()); + // objectName ΠΏΡ€ΠΈΠ΄Ρ‘Ρ‚ ΠΊΠ°ΠΊ "/avatars/cf80aebe-...jpg" β€” ΠΎΠ±Ρ€Π΅ΠΆΡŒ Π²Π΅Π΄ΡƒΡ‰ΠΈΠΉ слэш + String key = objectName.startsWith("/") ? objectName.substring(1) : objectName; + return fileStorageService.downloadFile(key); } @DeleteMapping("/{objectName}") public ResponseEntity deleteFile(@PathVariable String objectName) { + log.info("deleteFile {}", objectName); fileStorageService.deleteFile(objectName); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/com/krylov/refound/controller/PostController.java b/src/main/java/com/krylov/refound/controller/PostController.java index 9589da9..85a9591 100644 --- a/src/main/java/com/krylov/refound/controller/PostController.java +++ b/src/main/java/com/krylov/refound/controller/PostController.java @@ -1,22 +1,17 @@ package com.krylov.refound.controller; -import com.fasterxml.jackson.core.JsonProcessingException; import com.krylov.refound.dto.PostRequest; import com.krylov.refound.dto.PostResponse; import com.krylov.refound.dto.StatusUpdateRequest; -import com.krylov.refound.enums.PostType; import com.krylov.refound.service.PostService; -import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -27,78 +22,53 @@ 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.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +@Slf4j @RestController @RequestMapping("/api/v1/posts") @RequiredArgsConstructor -@Slf4j public class PostController { private final PostService service; - private final ObjectMapper objectMapper; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public PostRequest create(@ModelAttribute PostRequest request, @RequestParam(value = "images", required = false) List images) { - log.info(request.toString()); + log.info("create post {}", request.toString()); return service.create(request, images); } @GetMapping - public Page getFeed( - @RequestParam(required = false) String search, - @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + public Page getFeed(@RequestParam(required = false) String search, @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + log.info("get all feed"); return service.getFeed(search, pageable); } @GetMapping("/{id}") public List getById(@PathVariable Long id) { + log.info("get feed by id {}", id); return service.getById(id); } @PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public PostResponse update( - @PathVariable Long id, - @RequestParam String type, - @RequestParam String title, - @RequestParam(required = false) String address, - @RequestParam(required = false) Double latitude, - @RequestParam(required = false) Double longitude, - @RequestParam(required = false) String description, - @RequestParam(required = false) String category, - @RequestParam(required = false) String phone, - @RequestParam(value = "images", required = false) List images, - @RequestParam(value = "existingImages", required = false) String existingImagesJson) { - PostRequest request = new PostRequest(); - request.setType(PostType.from(type)); - request.setTitle(title); - request.setDescription(description); - request.setCategory(category); - request.setPhone(phone); - request.setLatitude(latitude); - request.setLongitude(longitude); - request.setAddress(address); - List existingUrls; - try { - existingUrls = existingImagesJson != null ? Arrays.asList( - objectMapper.readValue(existingImagesJson, String[].class)) : List.of(); - } catch (JsonProcessingException e) { - throw new RuntimeException(e); - } - - return service.update(id, request, images, existingUrls); + public PostResponse update(@PathVariable Long id, @RequestParam String type, @RequestParam String title, @RequestParam(required = false) String address, + @RequestParam(required = false) Double latitude, @RequestParam(required = false) Double longitude, @RequestParam(required = false) String description, + @RequestParam(required = false) String category, @RequestParam(required = false) String phone, + @RequestParam(value = "images", required = false) List images, @RequestParam(value = "existingImages", required = false) String existingImagesJson) { + log.info("update post {}", id); + return service.update(id, type, title, address, latitude, longitude, description, category, phone, images, existingImagesJson); } @DeleteMapping("/{id}") public void delete(@PathVariable Long id) { + log.info("delete feed by id {}", id); service.delete(id); } @PatchMapping("/{id}/status") - public PostResponse updateStatus(@PathVariable Long id, - @RequestBody StatusUpdateRequest request) { + public PostResponse updateStatus(@PathVariable Long id, @RequestBody StatusUpdateRequest request) { + log.info("update feed {} on status {}", id, request.getStatus()); return service.updateStatus(id, request.getStatus()); } } diff --git a/src/main/java/com/krylov/refound/controller/ReviewsController.java b/src/main/java/com/krylov/refound/controller/ReviewsController.java index 31a767f..7fbc630 100644 --- a/src/main/java/com/krylov/refound/controller/ReviewsController.java +++ b/src/main/java/com/krylov/refound/controller/ReviewsController.java @@ -15,25 +15,24 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Slf4j @RestController @RequestMapping("/api/v1/reviews") @RequiredArgsConstructor -@Slf4j public class ReviewsController { private final ReviewService reviewService; @GetMapping public List getAllReviews() { - List allReviews = reviewService.getAllReviews(); - log.info("Got all reviews: {}", allReviews); - return allReviews; + log.info("get all reviews"); + return reviewService.getAllReviews(); } @PostMapping @PreAuthorize("isAuthenticated()") public ReviewResponseDto createReview(@Valid @RequestBody ReviewRequestDto request, Authentication authentication) { - // Π’Π·ΡΡ‚ΡŒ имя ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ ΠΈΠ· authentication + log.info("user {} create review {}", authentication.getName(), request); return reviewService.createReview(request, authentication.getName()); } } diff --git a/src/main/java/com/krylov/refound/controller/StatisticsController.java b/src/main/java/com/krylov/refound/controller/StatisticsController.java index 4a35063..75e4e04 100644 --- a/src/main/java/com/krylov/refound/controller/StatisticsController.java +++ b/src/main/java/com/krylov/refound/controller/StatisticsController.java @@ -3,10 +3,12 @@ package com.krylov.refound.controller; import com.krylov.refound.entity.Statistics; import com.krylov.refound.service.StatisticsService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/statistics") @@ -15,6 +17,7 @@ public class StatisticsController { @GetMapping public Statistics get() { + log.info("get all statistics"); return statisticsService.getStatistics(); } } diff --git a/src/main/java/com/krylov/refound/controller/UserController.java b/src/main/java/com/krylov/refound/controller/UserController.java index 84ca0dd..a0d1b29 100644 --- a/src/main/java/com/krylov/refound/controller/UserController.java +++ b/src/main/java/com/krylov/refound/controller/UserController.java @@ -23,7 +23,7 @@ public class UserController { @PutMapping("/{id}") public ResponseEntity updateUser(@PathVariable Long id, @ModelAttribute UserUpdateDto userUpdateDto) { log.info("updateUser, id={}", id); - UserResponseDto userResponseDto = userService.updateUser(id, userUpdateDto); + UserResponseDto userResponseDto = userService.updateUser(id, userUpdateDto); return ResponseEntity.ok(userResponseDto); } diff --git a/src/main/java/com/krylov/refound/dto/UserRegisterDto.java b/src/main/java/com/krylov/refound/dto/UserRegisterDto.java new file mode 100644 index 0000000..8086f60 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/UserRegisterDto.java @@ -0,0 +1,13 @@ +package com.krylov.refound.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@RequiredArgsConstructor +public class UserRegisterDto { + private final UserDto userDto; + private final String token; +} diff --git a/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java b/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java index 9afda02..58a9251 100644 --- a/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java @@ -16,6 +16,17 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { + + @ExceptionHandler(InvalidCredentialsException.class) + public ResponseEntity handleInvalidCredentials(InvalidCredentialsException ex) { + return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage())); + } + + @ExceptionHandler(LoginAlreadyExistsException.class) + public ResponseEntity handleLoginAlreadyExists(LoginAlreadyExistsException ex) { + return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage())); + } + @ExceptionHandler(ApiException.class) public ResponseEntity handleApiException(ApiException ex, HttpServletRequest request) { return buildResponse(ex.getStatus(), ex.getErrorCode(), ex.getMessage(), request); diff --git a/src/main/java/com/krylov/refound/exception/InvalidCredentialsException.java b/src/main/java/com/krylov/refound/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..3a6c2af --- /dev/null +++ b/src/main/java/com/krylov/refound/exception/InvalidCredentialsException.java @@ -0,0 +1,8 @@ +package com.krylov.refound.exception; + +public class InvalidCredentialsException extends RuntimeException { + + public InvalidCredentialsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/krylov/refound/exception/LoginAlreadyExistsException.java b/src/main/java/com/krylov/refound/exception/LoginAlreadyExistsException.java new file mode 100644 index 0000000..4e60bdf --- /dev/null +++ b/src/main/java/com/krylov/refound/exception/LoginAlreadyExistsException.java @@ -0,0 +1,8 @@ +package com.krylov.refound.exception; + +public class LoginAlreadyExistsException extends RuntimeException { + + public LoginAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/krylov/refound/repository/MessageRepository.java b/src/main/java/com/krylov/refound/repository/MessageRepository.java index 9463513..e0dfa62 100644 --- a/src/main/java/com/krylov/refound/repository/MessageRepository.java +++ b/src/main/java/com/krylov/refound/repository/MessageRepository.java @@ -1,8 +1,10 @@ package com.krylov.refound.repository; -import com.krylov.refound.dto.DialogResponse; import com.krylov.refound.entity.Message; import java.util.List; +import java.util.Optional; +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.Modifying; import org.springframework.data.jpa.repository.Query; @@ -10,10 +12,14 @@ import org.springframework.data.repository.query.Param; public interface MessageRepository extends JpaRepository { - List findByChatIdOrderByCreatedAtAsc(Long chatId); - long countByChatIdAndIsReadFalseAndSenderIdNot(Long chatId, Long currentUserId); + Page findByChatIdOrderByCreatedAtDesc(Long chatId, Pageable pageable); + + Optional findFirstByChatIdOrderByCreatedAtDesc(Long chatId); + + long countByChatIdAndIsReadFalseAndSenderIdNot(Long chatId, Long userId); + @Modifying - @Query("UPDATE Message m SET m.isRead = true WHERE m.chatId = :chatId AND m.senderId != :userId") - int markAsReadByChatIdAndRecipient(@Param("chatId") Long chatId, @Param("userId") Long userId); + @Query("update Message m set m.isRead = true where m.chatId = :chatId and m.senderId != :userId") + void markAsReadByChatIdAndRecipient(@Param("chatId") Long chatId, @Param("userId") Long userId); } diff --git a/src/main/java/com/krylov/refound/service/AuthService.java b/src/main/java/com/krylov/refound/service/AuthService.java new file mode 100644 index 0000000..8b2b0a3 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/AuthService.java @@ -0,0 +1,77 @@ +package com.krylov.refound.service; + +import com.krylov.refound.dto.AuthRequest; +import com.krylov.refound.dto.CheckLoginDto; +import com.krylov.refound.dto.RegisterRequest; +import com.krylov.refound.dto.UserDto; +import com.krylov.refound.dto.UserRegisterDto; +import com.krylov.refound.entity.User; +import com.krylov.refound.exception.InvalidCredentialsException; +import com.krylov.refound.exception.LoginAlreadyExistsException; +import com.krylov.refound.repository.UserRepository; +import com.krylov.refound.security.JwtService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + + public UserRegisterDto registerUser(RegisterRequest request){ + if (userRepository.findByLogin(request.getLogin()).isPresent()) { + log.warn("registration failed: login {} already taken", request.getLogin()); + throw new LoginAlreadyExistsException("Π›ΠΎΠ³ΠΈΠ½ ΡƒΠΆΠ΅ занят"); + } + User user = new User(); + user.setName(request.getName()); + user.setLogin(request.getLogin()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + + userRepository.save(user); + + String token = jwtService.generateToken(user.getLogin()); + + UserDto userDto = getUserDto(user); + + return new UserRegisterDto(userDto, token); + } + + public UserRegisterDto loginUser(AuthRequest request){ + User user = userRepository.findByLogin(request.getLogin()).orElse(null); + + if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { + log.warn("login for user {} or password is incorrect", request.getLogin()); + throw new InvalidCredentialsException("НСвСрный Π»ΠΎΠ³ΠΈΠ½ ΠΈΠ»ΠΈ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ"); + } + + String token = jwtService.generateToken(user.getLogin()); + UserDto userDto = getUserDto(user); + + return new UserRegisterDto(userDto, token); + } + + public boolean existsByLogin(CheckLoginDto request){ + return userRepository.existsByLogin(request.getLogin()); + } + + private static UserDto getUserDto(User user) { + return new UserDto( + user.getId(), + user.getName(), + user.getLastName(), + user.getLogin(), + user.getEmail(), + user.getPhone(), + user.getAvatarUrl(), + user.getRole() + ); + } + +} diff --git a/src/main/java/com/krylov/refound/service/AvatarService.java b/src/main/java/com/krylov/refound/service/AvatarService.java deleted file mode 100644 index eae7706..0000000 --- a/src/main/java/com/krylov/refound/service/AvatarService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.krylov.refound.service; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.UUID; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -@Service -public class AvatarService { - private final Path uploadDir = Paths.get("uploads/avatars"); - - public String save(MultipartFile file) { - try { - Files.createDirectories(uploadDir); - String filename = UUID.randomUUID() + "_" + file.getOriginalFilename(); - Path target = uploadDir.resolve(filename); - file.transferTo(target); - return "/uploads/avatars/" + filename; // ΠΈΠ»ΠΈ ΠΏΠΎΠ»Π½Ρ‹ΠΉ URL, Ссли Π΅ΡΡ‚ΡŒ статик-сСрвСр/CDN - } catch (IOException e) { - throw new RuntimeException("Failed to save avatar", e); - } - } -} diff --git a/src/main/java/com/krylov/refound/service/ChatService.java b/src/main/java/com/krylov/refound/service/ChatService.java index 671bb38..1044511 100644 --- a/src/main/java/com/krylov/refound/service/ChatService.java +++ b/src/main/java/com/krylov/refound/service/ChatService.java @@ -11,17 +11,22 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class ChatService { + private final ChatRepository chatRepository; private final MessageRepository messageRepository; private final SimpMessagingTemplate messagingTemplate; - private final UserRepository userRepository; // Ρ‚Π²ΠΎΠΉ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΉ ΡŽΠ·Π΅Ρ€ΠΎΠ² + private final UserRepository userRepository; // Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Ρ‡Π°Ρ‚ с ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΌ (ΠΈΠ»ΠΈ Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ) @Transactional @@ -46,28 +51,28 @@ public class ChatService { } // ВсС Ρ‡Π°Ρ‚Ρ‹ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ + @Transactional(readOnly = true) public List getUserChats(Long userId) { return chatRepository.findAllByUserId(userId).stream() .map(c -> toDto(c, userId)) .collect(Collectors.toList()); } - // Π˜ΡΡ‚ΠΎΡ€ΠΈΡ сообщСний Π² Ρ‡Π°Ρ‚Π΅ - public List getMessages(Long chatId) { - return messageRepository.findByChatIdOrderByCreatedAtAsc(chatId).stream() - .map(this::toMessageDto) - .collect(Collectors.toList()); + // Π˜ΡΡ‚ΠΎΡ€ΠΈΡ сообщСний Π² Ρ‡Π°Ρ‚Π΅, с ΠΏΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΠ΅ΠΉ ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΎΠΉ доступа + @Transactional(readOnly = true) + public Page getMessages(Long chatId, Long userId, Pageable pageable) { + Chat chat = getChatOrThrow(chatId); + checkParticipant(chat, userId); + + return messageRepository.findByChatIdOrderByCreatedAtDesc(chatId, pageable) + .map(this::toMessageDto); } // ΠžΡ‚ΠΏΡ€Π°Π²ΠΊΠ° сообщСния (вызываСтся ΠΈΠ· WebSocket ΠΊΠΎΠ½Ρ‚Ρ€ΠΎΠ»Π»Π΅Ρ€Π°) @Transactional public MessageDto sendMessage(Long senderId, Long chatId, String content) { - Chat chat = chatRepository.findById(chatId) - .orElseThrow(() -> new IllegalArgumentException("Π§Π°Ρ‚ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½")); - - if (!chat.getUserOneId().equals(senderId) && !chat.getUserTwoId().equals(senderId)) { - throw new SecurityException("Π’Ρ‹ Π½Π΅ участник этого Ρ‡Π°Ρ‚Π°"); - } + Chat chat = getChatOrThrow(chatId); + checkParticipant(chat, senderId); Message message = new Message(); message.setChatId(chatId); @@ -95,6 +100,33 @@ public class ChatService { return dto; } + @Transactional + public void deleteChat(Long chatId, Long userId) { + Chat chat = getChatOrThrow(chatId); + checkParticipant(chat, userId); + chatRepository.deleteById(chatId); + } + + @Transactional + public void markAsRead(Long chatId, Long userId) { + Chat chat = getChatOrThrow(chatId); + checkParticipant(chat, userId); + messageRepository.markAsReadByChatIdAndRecipient(chatId, userId); + } + + // ---------- Π²ΡΠΏΠΎΠΌΠΎΠ³Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΌΠ΅Ρ‚ΠΎΠ΄Ρ‹ ---------- + + private Chat getChatOrThrow(Long chatId) { + return chatRepository.findById(chatId) + .orElseThrow(() -> new IllegalArgumentException("Π§Π°Ρ‚ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½")); + } + + private void checkParticipant(Chat chat, Long userId) { + if (!chat.getUserOneId().equals(userId) && !chat.getUserTwoId().equals(userId)) { + throw new SecurityException("Π’Ρ‹ Π½Π΅ участник этого Ρ‡Π°Ρ‚Π°"); + } + } + private ChatDto toDto(Chat chat, Long currentUserId) { Long otherUserId = chat.getUserOneId().equals(currentUserId) ? chat.getUserTwoId() : chat.getUserOneId(); @@ -104,18 +136,17 @@ public class ChatService { dto.setOtherUserId(otherUserId); dto.setPostId(chat.getPostId()); - // ΠŸΠΎΠ΄Ρ‚ΡΠ³ΠΈΠ²Π°Π΅ΠΌ имя/Π°Π²Π°Ρ‚Π°Ρ€ собСсСдника ΠΈΠ· Ρ‚Π²ΠΎΠ΅Π³ΠΎ UserRepository userRepository.findById(otherUserId).ifPresent(user -> { dto.setOtherUserName(user.getName() + " " + user.getLastName()); dto.setOtherUserAvatar(user.getAvatarUrl()); }); - List messages = messageRepository.findByChatIdOrderByCreatedAtAsc(chat.getId()); - if (!messages.isEmpty()) { - Message last = messages.getLast(); - dto.setLastMessage(last.getContent()); - dto.setLastMessageAt(last.getCreatedAt()); - } + // Π±Π΅Π· N+1: Π±Π΅Ρ€Ρ‘ΠΌ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ послСднСС сообщСниС, Π° Π½Π΅ всю ΠΈΡΡ‚ΠΎΡ€ΠΈΡŽ + messageRepository.findFirstByChatIdOrderByCreatedAtDesc(chat.getId()) + .ifPresent(last -> { + dto.setLastMessage(last.getContent()); + dto.setLastMessageAt(last.getCreatedAt()); + }); dto.setUnreadCount( messageRepository.countByChatIdAndIsReadFalseAndSenderIdNot(chat.getId(), currentUserId) @@ -134,13 +165,4 @@ public class ChatService { dto.setCreatedAt(m.getCreatedAt()); return dto; } - - public void deleteChat(Long chatId) { - chatRepository.deleteById(chatId); - } - - @Transactional - public void markAsRead(Long chatId, Long userId) { - messageRepository.markAsReadByChatIdAndRecipient(chatId, userId); - } } diff --git a/src/main/java/com/krylov/refound/service/FavoriteService.java b/src/main/java/com/krylov/refound/service/FavoriteService.java index 6a7fa71..6c0eb02 100644 --- a/src/main/java/com/krylov/refound/service/FavoriteService.java +++ b/src/main/java/com/krylov/refound/service/FavoriteService.java @@ -1,16 +1,19 @@ package com.krylov.refound.service; +import com.krylov.refound.dto.PostResponse; 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.mapper.PostMapper; 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; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -18,44 +21,19 @@ public class FavoriteService { private final FavoriteRepository repository; private final PostRepository postRepository; private final UserService userService; + private final PostMapper mapper; - public void addToFavorites(Long postId) { - + @Transactional(readOnly = true) + public List getFavorites() { 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) + .map(mapper::toResponse) .toList(); } + @Transactional public void toggle(Long postId) { User user = userService.getCurrentUser(); diff --git a/src/main/java/com/krylov/refound/service/FileStorageService.java b/src/main/java/com/krylov/refound/service/FileStorageService.java index 03d0739..887b310 100644 --- a/src/main/java/com/krylov/refound/service/FileStorageService.java +++ b/src/main/java/com/krylov/refound/service/FileStorageService.java @@ -1,140 +1,203 @@ package com.krylov.refound.service; import com.krylov.refound.config.MinioProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.ResponseBytes; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.*; - +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.exception.ApiException; import java.io.IOException; import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriUtils; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; @Service @Slf4j @RequiredArgsConstructor public class FileStorageService { + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "image/jpeg", "image/png", "image/webp" + ); + private final S3Client s3Client; private final MinioProperties properties; /** * DTO для ΠΏΠ΅Ρ€Π΅Π΄Π°Ρ‡ΠΈ Π΄Π°Π½Π½Ρ‹Ρ… скачанного Ρ„Π°ΠΉΠ»Π°. - * @param originalFilename ΠžΡ€ΠΈΠ³ΠΈΠ½Π°Π»ΡŒΠ½ΠΎΠ΅ имя Ρ„Π°ΠΉΠ»Π°. - * @param contentType MIME-Ρ‚ΠΈΠΏ Ρ„Π°ΠΉΠ»Π°. - * @param content Π‘ΠΎΠ΄Π΅Ρ€ΠΆΠΈΠΌΠΎΠ΅ Ρ„Π°ΠΉΠ»Π° Π² Π²ΠΈΠ΄Π΅ массива Π±Π°ΠΉΡ‚. */ - public record DownloadedFile( - String originalFilename, - String contentType, - byte[] content + private record DownloadedFile( + String originalFilename, + String contentType, + byte[] content ) {} /** - * Π—Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ Ρ„Π°ΠΉΠ» Π² MinIO, сохраняя ΠΎΡ€ΠΈΠ³ΠΈΠ½Π°Π»ΡŒΠ½ΠΎΠ΅ имя Π² ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Ρ…, - * ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π΅Π³ΠΎ ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½ΠΎΠ΅ имя (Π²ΠΊΠ»ΡŽΡ‡Π°Ρ Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΠ΅). - * - * @param file Π—Π°Π³Ρ€ΡƒΠΆΠ°Π΅ΠΌΡ‹ΠΉ Ρ„Π°ΠΉΠ». - * @return Π‘Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Π½Π½ΠΎΠ΅ ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½ΠΎΠ΅ имя Ρ„Π°ΠΉΠ»Π°. + * Π—Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ Ρ„Π°ΠΉΠ» Π² MinIO Π² ΠΊΠΎΡ€Π΅Π½ΡŒ Π±Π°ΠΊΠ΅Ρ‚Π°. */ public String uploadFile(MultipartFile file) { + return uploadFile(file, null); + } + + /** + * Π—Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ Ρ„Π°ΠΉΠ» Π² MinIO Π² ΡƒΠΊΠ°Π·Π°Π½Π½ΡƒΡŽ "ΠΏΠ°ΠΏΠΊΡƒ" (прСфикс ΠΊΠ»ΡŽΡ‡Π°), + * Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ "avatars" β€” сохранится ΠΊΠ°ΠΊ avatars/.ext + */ + public String uploadFile(MultipartFile file, String folder) { + validateFile(file); + + String originalFilename = file.getOriginalFilename(); + String extension = extractExtension(originalFilename); + String prefix = (folder != null && !folder.isBlank()) + ? folder.replaceAll("/+$", "") + "/" + : ""; + String objectName = prefix + UUID.randomUUID() + extension; + + log.info("Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Ρ„Π°ΠΉΠ»Π° '{}'. НовоС имя: {}", originalFilename, objectName); + try { - if (file.isEmpty()) { - log.error("ΠŸΠΎΠΏΡ‹Ρ‚ΠΊΠ° Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ пустой Ρ„Π°ΠΉΠ»."); - throw new RuntimeException("Π€Π°ΠΉΠ» Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ пустым."); - } - - String originalFilename = file.getOriginalFilename(); - String extension = ""; - if (originalFilename != null && originalFilename.contains(".")) { - extension = originalFilename.substring(originalFilename.lastIndexOf(".")); - } - - String objectName = UUID.randomUUID().toString() + extension; - log.info("Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Ρ„Π°ΠΉΠ»Π° '{}'. НовоС имя: {}", originalFilename, objectName); - Map metadata = new HashMap<>(); metadata.put("original-filename", URLEncoder.encode(originalFilename, StandardCharsets.UTF_8)); PutObjectRequest request = PutObjectRequest.builder() - .bucket(properties.bucket()) - .key(objectName) - .contentType(file.getContentType()) - .metadata(metadata) - .build(); + .bucket(properties.bucket()) + .key(objectName) + .contentType(file.getContentType()) + .metadata(metadata) + .build(); s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); log.info("Π€Π°ΠΉΠ» '{}' ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ Π·Π°Π³Ρ€ΡƒΠΆΠ΅Π½.", objectName); return objectName; + } catch (IOException e) { - log.error("Ошибка ΠΏΡ€ΠΈ Ρ‡Ρ‚Π΅Π½ΠΈΠΈ Ρ„Π°ΠΉΠ»Π° для Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ: {}", e.getMessage()); - throw new RuntimeException("Ошибка ΠΏΡ€ΠΈ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ΅ Ρ„Π°ΠΉΠ»Π°.", e); + log.error("Ошибка ΠΏΡ€ΠΈ Ρ‡Ρ‚Π΅Π½ΠΈΠΈ Ρ„Π°ΠΉΠ»Π° для Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ: {}", e.getMessage(), e); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка ΠΏΡ€ΠΈ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ΅ Ρ„Π°ΠΉΠ»Π°.", HttpStatus.INTERNAL_SERVER_ERROR); } catch (Exception e) { - log.error("НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Ρ„Π°ΠΉΠ»: {}", e.getMessage()); - throw new RuntimeException("Ошибка ΠΏΡ€ΠΈ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ΅ Ρ„Π°ΠΉΠ»Π°.", e); + log.error("НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ Ρ„Π°ΠΉΠ»: {}", e.getMessage(), e); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка ΠΏΡ€ΠΈ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ΅ Ρ„Π°ΠΉΠ»Π°.", HttpStatus.INTERNAL_SERVER_ERROR); } } /** - * Π‘ΠΊΠ°Ρ‡ΠΈΠ²Π°Π΅Ρ‚ Ρ„Π°ΠΉΠ» ΠΈΠ· MinIO. - * - * @param objectName УникальноС имя Ρ„Π°ΠΉΠ»Π° Π² Ρ…Ρ€Π°Π½ΠΈΠ»ΠΈΡ‰Π΅. - * @return DTO с содСрТимым Ρ„Π°ΠΉΠ»Π° ΠΈ Π΅Π³ΠΎ ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹ΠΌΠΈ. + * Π‘ΠΊΠ°Ρ‡ΠΈΠ²Π°Π΅Ρ‚ Ρ„Π°ΠΉΠ» ΠΈΠ· MinIO ΠΈ собираСт Π³ΠΎΡ‚ΠΎΠ²Ρ‹ΠΉ HTTP-ΠΎΡ‚Π²Π΅Ρ‚ с Π½ΡƒΠΆΠ½Ρ‹ΠΌΠΈ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ°ΠΌΠΈ. + * Вся Π»ΠΎΠ³ΠΈΠΊΠ° формирования Content-Disposition/Content-Type инкапсулирована здСсь, + * ΠΊΠΎΠ½Ρ‚Ρ€ΠΎΠ»Π»Π΅Ρ€Ρƒ остаётся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π²Π΅Ρ€Π½ΡƒΡ‚ΡŒ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚. */ - public DownloadedFile downloadFile(String objectName) { - try { - // 1. ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Π΅ ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Π°, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΈΠ·Π²Π»Π΅Ρ‡ΡŒ ΠΎΡ€ΠΈΠ³ΠΈΠ½Π°Π»ΡŒΠ½ΠΎΠ΅ имя - HeadObjectRequest headRequest = HeadObjectRequest.builder() - .bucket(properties.bucket()) - .key(objectName) - .build(); - HeadObjectResponse headResponse = s3Client.headObject(headRequest); - // Π”Π΅ΠΊΠΎΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ имя Ρ„Π°ΠΉΠ»Π° ΠΈΠ· ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Ρ… - String originalFilename = URLDecoder.decode(headResponse.metadata().get("original-filename"), StandardCharsets.UTF_8); - String contentType = headResponse.contentType(); + public ResponseEntity downloadFile(String objectName) { + DownloadedFile file = fetchFile(objectName); - // 2. ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ сам ΠΎΠ±ΡŠΠ΅ΠΊΡ‚ (Π΅Π³ΠΎ содСрТимоС) - GetObjectRequest getRequest = GetObjectRequest.builder() - .bucket(properties.bucket()) - .key(objectName) - .build(); - ResponseBytes responseBytes = s3Client.getObjectAsBytes(getRequest); + String encodedFilename = UriUtils.encode(file.originalFilename(), StandardCharsets.UTF_8); + String disposition = file.contentType().startsWith("image/") ? "inline" : "attachment"; - log.info("Π€Π°ΠΉΠ» '{}' (ΠΎΡ€ΠΈΠ³ΠΈΠ½Π°Π»ΡŒΠ½ΠΎΠ΅ имя: '{}') ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ скачан.", objectName, originalFilename); - return new DownloadedFile(originalFilename, contentType, responseBytes.asByteArray()); + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, disposition + "; filename*=UTF-8''" + encodedFilename); - } catch (Exception e) { - log.error("Ошибка ΠΏΡ€ΠΈ скачивании Ρ„Π°ΠΉΠ»Π° '{}': {}", objectName, e.getMessage()); - throw new RuntimeException("Ошибка ΠΏΡ€ΠΈ скачивании Ρ„Π°ΠΉΠ»Π°.", e); - } + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.parseMediaType(file.contentType())) + .body(file.content()); } /** * УдаляСт Ρ„Π°ΠΉΠ» ΠΈΠ· MinIO ΠΏΠΎ Π΅Π³ΠΎ ΠΈΠΌΠ΅Π½ΠΈ. - * - * @param objectName Имя Ρ„Π°ΠΉΠ»Π° для удалСния. */ public void deleteFile(String objectName) { try { log.info("Запрос Π½Π° ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ Ρ„Π°ΠΉΠ»Π°: {}", objectName); DeleteObjectRequest request = DeleteObjectRequest.builder() - .bucket(properties.bucket()) - .key(objectName) - .build(); + .bucket(properties.bucket()) + .key(objectName) + .build(); s3Client.deleteObject(request); log.info("Π€Π°ΠΉΠ» '{}' ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ ΡƒΠ΄Π°Π»Π΅Π½.", objectName); } catch (Exception e) { - log.error("Ошибка ΠΏΡ€ΠΈ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΈ Ρ„Π°ΠΉΠ»Π° '{}': {}", objectName, e.getMessage()); - throw new RuntimeException("Ошибка ΠΏΡ€ΠΈ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΈ Ρ„Π°ΠΉΠ»Π°.", e); + log.error("Ошибка ΠΏΡ€ΠΈ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΈ Ρ„Π°ΠΉΠ»Π° '{}': {}", objectName, e.getMessage(), e); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка ΠΏΡ€ΠΈ ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠΈ Ρ„Π°ΠΉΠ»Π°.", HttpStatus.INTERNAL_SERVER_ERROR); } } + + // ---------- Π²ΡΠΏΠΎΠΌΠΎΠ³Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΌΠ΅Ρ‚ΠΎΠ΄Ρ‹ ---------- + + private DownloadedFile fetchFile(String objectName) { + try { + HeadObjectRequest headRequest = HeadObjectRequest.builder() + .bucket(properties.bucket()) + .key(objectName) + .build(); + HeadObjectResponse headResponse = s3Client.headObject(headRequest); + + String originalFilename = URLDecoder.decode( + headResponse.metadata().get("original-filename"), StandardCharsets.UTF_8); + + String contentType = headResponse.contentType() != null + ? headResponse.contentType() + : MediaType.APPLICATION_OCTET_STREAM_VALUE; + + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(properties.bucket()) + .key(objectName) + .build(); + ResponseBytes responseBytes = s3Client.getObjectAsBytes(getRequest); + + log.info("Π€Π°ΠΉΠ» '{}' (ΠΎΡ€ΠΈΠ³ΠΈΠ½Π°Π»ΡŒΠ½ΠΎΠ΅ имя: '{}') ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ скачан.", objectName, originalFilename); + return new DownloadedFile(originalFilename, contentType, responseBytes.asByteArray()); + + } catch (NoSuchKeyException e) { + log.warn("Π€Π°ΠΉΠ» Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½: {}", objectName); + throw new ApiException(ErrorCode.NOT_FOUND, "Π€Π°ΠΉΠ» Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½.", HttpStatus.NOT_FOUND); + } catch (ApiException e) { + throw e; + } catch (Exception e) { + log.error("Ошибка ΠΏΡ€ΠΈ скачивании Ρ„Π°ΠΉΠ»Π° '{}': {}", objectName, e.getMessage(), e); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка ΠΏΡ€ΠΈ скачивании Ρ„Π°ΠΉΠ»Π°.", HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private void validateFile(MultipartFile file) { + if (file.isEmpty()) { + log.warn("ΠŸΠΎΠΏΡ‹Ρ‚ΠΊΠ° Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ пустой Ρ„Π°ΠΉΠ»."); + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Π€Π°ΠΉΠ» Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ пустым.", HttpStatus.BAD_REQUEST); + } + + if (file.getSize() > MAX_FILE_SIZE) { + log.warn("Π€Π°ΠΉΠ» ΠΏΡ€Π΅Π²Ρ‹ΡˆΠ°Π΅Ρ‚ допустимый Ρ€Π°Π·ΠΌΠ΅Ρ€: {} Π±Π°ΠΉΡ‚.", file.getSize()); + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Π€Π°ΠΉΠ» слишком большой (максимум 10 ΠœΠ‘).", HttpStatus.BAD_REQUEST); + } + + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { + log.warn("НСдопустимый Ρ‚ΠΈΠΏ Ρ„Π°ΠΉΠ»Π°: {}", contentType); + throw new ApiException(ErrorCode.VALIDATION_ERROR, "НСдопустимый Ρ‚ΠΈΠΏ Ρ„Π°ΠΉΠ»Π°.", HttpStatus.BAD_REQUEST); + } + } + + private String extractExtension(String originalFilename) { + if (originalFilename != null && originalFilename.contains(".")) { + return originalFilename.substring(originalFilename.lastIndexOf(".")); + } + return ""; + } } \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/PostService.java b/src/main/java/com/krylov/refound/service/PostService.java index 86eedc4..73eeb19 100644 --- a/src/main/java/com/krylov/refound/service/PostService.java +++ b/src/main/java/com/krylov/refound/service/PostService.java @@ -1,5 +1,7 @@ package com.krylov.refound.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.krylov.refound.ai.facade.ContentModerationFacade; import com.krylov.refound.dto.PostRequest; import com.krylov.refound.dto.PostResponse; @@ -9,6 +11,7 @@ import com.krylov.refound.entity.User; import com.krylov.refound.enums.ErrorCode; import com.krylov.refound.enums.PostCategory; import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.enums.PostType; import com.krylov.refound.exception.ApiException; import com.krylov.refound.mapper.PostMapper; import com.krylov.refound.repository.FavoriteRepository; @@ -20,6 +23,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -52,6 +56,7 @@ public class PostService { private final FileStorageService fileStorageService; private final ContentModerationFacade contentModerationFacade; private final StatisticsService statisticsService; + private final ObjectMapper objectMapper; public PostRequest create(PostRequest request, List images) { @@ -153,6 +158,35 @@ public class PostService { .toList(); } + public PostResponse update( + Long id, + String type, + String title, + String address, + Double latitude, + Double longitude, + String description, + String category, + String phone, + List images, + String existingImagesJson) { + + PostRequest request = new PostRequest(); + request.setType(PostType.from(type)); + request.setTitle(title); + request.setDescription(description); + request.setCategory(category); + request.setPhone(phone); + request.setLatitude(latitude); + request.setLongitude(longitude); + request.setAddress(address); + + List existingUrls = parseExistingImages(existingImagesJson); + + return update(id, request, images, existingUrls); + } + + // старый ΠΌΠ΅Ρ‚ΠΎΠ΄ остаётся ΠΊΠ°ΠΊ Π΅ΡΡ‚ΡŒ β€” Ρ€Π΅Ρ„Π°ΠΊΡ‚ΠΎΡ€ΠΈΡ‚ΡŒ Π±ΡƒΠ΄Π΅ΠΌ ΠΏΠΎΠ·ΠΆΠ΅ public PostResponse update(Long id, PostRequest request, List images, List existingUrls) { Post post = repository.findById(id) @@ -217,6 +251,21 @@ public class PostService { return mapper.toResponse(updated); } + private List parseExistingImages(String existingImagesJson) { + if (existingImagesJson == null) { + return List.of(); + } + try { + return Arrays.asList(objectMapper.readValue(existingImagesJson, String[].class)); + } catch (JsonProcessingException e) { + throw new ApiException( + ErrorCode.VALIDATION_ERROR, + "НСкоррСктный Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ existingImages", + HttpStatus.BAD_REQUEST + ); + } + } + @CacheEvict(value = {"posts", "post"}, allEntries = true) public void delete(Long id) { Post post = repository.findById(id) diff --git a/src/main/java/com/krylov/refound/service/UserService.java b/src/main/java/com/krylov/refound/service/UserService.java index 6f04524..6a62dc8 100644 --- a/src/main/java/com/krylov/refound/service/UserService.java +++ b/src/main/java/com/krylov/refound/service/UserService.java @@ -20,7 +20,7 @@ import org.springframework.web.multipart.MultipartFile; @RequiredArgsConstructor public class UserService { private final UserRepository repository; - private final AvatarService avatarService; + private final FileStorageService fileStorageService; public User getCurrentUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -55,8 +55,19 @@ public class UserService { MultipartFile avatar = dto.getAvatar(); if (avatar != null && !avatar.isEmpty()) { - String url = avatarService.save(avatar); // сохранСниС Π½Π° диск/Π² S3/Yandex Object Storage - user.setAvatarUrl(url); + String oldAvatarKey = user.getAvatarUrl(); + + String newAvatarKey = fileStorageService.uploadFile(avatar, "avatars"); + user.setAvatarUrl(newAvatarKey); + + // ΠΏΠΎΠ΄Ρ‡ΠΈΡ‰Π°Π΅ΠΌ старый Ρ„Π°ΠΉΠ», Ссли ΠΎΠ½ Π±Ρ‹Π» ΠΈ это Ρ€Π΅Π°Π»ΡŒΠ½ΠΎ ΠΊΠ»ΡŽΡ‡ MinIO + if (oldAvatarKey != null && !oldAvatarKey.isBlank()) { + try { + fileStorageService.deleteFile(oldAvatarKey); + } catch (Exception e) { + log.warn("НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ старый Π°Π²Π°Ρ‚Π°Ρ€ ΠΈΠ· Ρ…Ρ€Π°Π½ΠΈΠ»ΠΈΡ‰Π°: {}", oldAvatarKey, e); + } + } } repository.save(user);