From e7dfef7327aaa44d9481ce6bda5a0556ce892236 Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Wed, 5 Aug 2026 00:52:00 +0300 Subject: [PATCH] added filter by city --- .../refound/controller/PostController.java | 15 +- .../com/krylov/refound/enums/PostStatus.java | 1 + .../refound/repository/PostRepository.java | 18 + .../refound/service/FileStorageService.java | 11 + .../PostModerationExecutorService.java | 62 +++ .../krylov/refound/service/PostService.java | 447 ++++++++++-------- .../refound/service/SchedulerService.java | 29 ++ .../krylov/refound/service/UserService.java | 39 +- .../refound/util/ByteArrayMultipartFile.java | 69 +++ .../refound/util/PostSpecification.java | 12 + 10 files changed, 469 insertions(+), 234 deletions(-) create mode 100644 src/main/java/com/krylov/refound/service/PostModerationExecutorService.java create mode 100644 src/main/java/com/krylov/refound/util/ByteArrayMultipartFile.java diff --git a/src/main/java/com/krylov/refound/controller/PostController.java b/src/main/java/com/krylov/refound/controller/PostController.java index 85a9591..b083ff3 100644 --- a/src/main/java/com/krylov/refound/controller/PostController.java +++ b/src/main/java/com/krylov/refound/controller/PostController.java @@ -48,7 +48,7 @@ public class PostController { @GetMapping("/{id}") public List getById(@PathVariable Long id) { log.info("get feed by id {}", id); - return service.getById(id); + return service.getPostsByUserId(id); } @PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -71,4 +71,17 @@ public class PostController { log.info("update feed {} on status {}", id, request.getStatus()); return service.updateStatus(id, request.getStatus()); } + + @GetMapping("/search") + public List search(@RequestParam(required = false) String city) { + log.info("search posts by city {}", city); + return service.searchByCity(city); + } + + @GetMapping("/cities") + public List getCities() { + log.info("get distinct cities"); + return service.getCities(); + } + } diff --git a/src/main/java/com/krylov/refound/enums/PostStatus.java b/src/main/java/com/krylov/refound/enums/PostStatus.java index 48d52ba..3411711 100644 --- a/src/main/java/com/krylov/refound/enums/PostStatus.java +++ b/src/main/java/com/krylov/refound/enums/PostStatus.java @@ -3,5 +3,6 @@ package com.krylov.refound.enums; public enum PostStatus { ACTIVE, CLOSED, + REJECTED, MODERATION } diff --git a/src/main/java/com/krylov/refound/repository/PostRepository.java b/src/main/java/com/krylov/refound/repository/PostRepository.java index 6db7364..bae7790 100644 --- a/src/main/java/com/krylov/refound/repository/PostRepository.java +++ b/src/main/java/com/krylov/refound/repository/PostRepository.java @@ -3,9 +3,14 @@ package com.krylov.refound.repository; import com.krylov.refound.entity.Post; import com.krylov.refound.enums.PostStatus; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; public interface PostRepository extends JpaRepository, JpaSpecificationExecutor { @@ -15,4 +20,17 @@ public interface PostRepository extends JpaRepository, JpaSpecificat List findByCreatedAtBefore(LocalDateTime date); + @Query("select p.id from Post p where p.status = :status") + List findIdsByStatus(@Param("status") PostStatus status); + + @Modifying + @Transactional + @Query("update Post p set p.status = :status where p.id = :id") + int updateStatus(@Param("id") Long id, @Param("status") PostStatus status); + + List findByCityContainingIgnoreCaseOrDistrictContainingIgnoreCase(String city, String district); + + @Query("select distinct p.city from Post p where p.city is not null and p.status in :statuses order by p.city") + List findDistinctCities(@Param("statuses") Collection statuses); + } \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/FileStorageService.java b/src/main/java/com/krylov/refound/service/FileStorageService.java index f80d083..07026cf 100644 --- a/src/main/java/com/krylov/refound/service/FileStorageService.java +++ b/src/main/java/com/krylov/refound/service/FileStorageService.java @@ -111,6 +111,15 @@ public class FileStorageService { .body(file.content()); } + /** + * Скачивает файл из MinIO без HTTP-обёртки — оригинальное имя, content-type и байты. + * Используется там, где нет HTTP-контекста (например, в PostModerationScheduler, + * который восстанавливает MultipartFile для повторной прогонки через модерацию). + */ + public DownloadedFile downloadFileRaw(String objectName) { + return fetchFile(objectName); + } + /** * Удаляет файл из MinIO по его имени. */ @@ -130,6 +139,8 @@ public class FileStorageService { } } + // ---------- вспомогательные методы ---------- + private DownloadedFile fetchFile(String objectName) { try { HeadObjectRequest headRequest = HeadObjectRequest.builder() diff --git a/src/main/java/com/krylov/refound/service/PostModerationExecutorService.java b/src/main/java/com/krylov/refound/service/PostModerationExecutorService.java new file mode 100644 index 0000000..038c3dc --- /dev/null +++ b/src/main/java/com/krylov/refound/service/PostModerationExecutorService.java @@ -0,0 +1,62 @@ +package com.krylov.refound.service; + +import com.krylov.refound.ai.facade.ContentModerationFacade; +import com.krylov.refound.dto.DownloadedFile; +import com.krylov.refound.entity.Image; +import com.krylov.refound.entity.Post; +import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.repository.PostRepository; +import com.krylov.refound.util.ByteArrayMultipartFile; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +/** + * Вынесено из SchedulerService отдельным бином специально: вызов moderateOne(post) + * из цикла в том же классе был бы self-invocation и обходил бы Spring-прокси, + * из-за чего @Transactional на методе не действовал бы во время выполнения + * (post.getImages() как раз требует активной транзакции — это ленивая коллекция). + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class PostModerationExecutorService { + + private final PostRepository postRepository; + private final ContentModerationFacade contentModerationFacade; + private final FileStorageService fileStorageService; + + @Transactional + public void moderateOne(Long postId) { + Post post = postRepository.findById(postId).orElse(null); + if (post == null) { + log.warn("Пост {} не найден на момент модерации, пропускаем", postId); + return; + } + + try { + List images = post.getImages().stream() + .map(Image::getUrl) + .map(fileStorageService::downloadFileRaw) + .map(this::toMultipartFile) + .toList(); + + contentModerationFacade.moderate(post.getTitle(), post.getDescription(), images); + + postRepository.updateStatus(postId, PostStatus.ACTIVE); + log.info("Пост {} прошёл модерацию.", post.getId()); + + } catch (Exception e) { + log.warn("Пост {} не прошёл модерацию: {}", post.getId(), e.getMessage()); + postRepository.updateStatus(postId, PostStatus.REJECTED); + } + } + + + private MultipartFile toMultipartFile(DownloadedFile file) { + return new ByteArrayMultipartFile("images", file.originalFilename(), file.contentType(), file.content()); + } +} \ 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 4caec85..aee726c 100644 --- a/src/main/java/com/krylov/refound/service/PostService.java +++ b/src/main/java/com/krylov/refound/service/PostService.java @@ -2,7 +2,6 @@ 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.AddressInfo; import com.krylov.refound.dto.PostRequest; import com.krylov.refound.dto.PostResponse; @@ -21,6 +20,7 @@ import com.krylov.refound.repository.PostRepository; import com.krylov.refound.util.PostSpecification; import java.time.LocalDateTime; import java.util.Arrays; +import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -28,66 +28,63 @@ import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.cache.annotation.CacheEvict; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor @Slf4j public class PostService { + // Статусы, которые показываются в публичной ленте. MODERATION и REJECTED видны + // только владельцу через getPostsByUserId, но не в общем фиде. + private static final Set FEED_VISIBLE_STATUSES = EnumSet.of(PostStatus.ACTIVE, PostStatus.CLOSED); - private final PostRepository repository; + private final PostRepository postRepository; private final PostMapper mapper; private final ImageRepository imageRepository; private final GeocodingService geocodingService; private final UserService userService; private final FavoriteRepository favoriteRepository; private final FileStorageService fileStorageService; - private final ContentModerationFacade contentModerationFacade; private final StatisticsService statisticsService; private final ObjectMapper objectMapper; + @Transactional public PostRequest create(PostRequest request, List images) { - contentModerationFacade.moderate(request.getTitle(), request.getDescription(), images); + User currentUser = userService.getCurrentUser(); Post post = mapper.toEntity(request); post.setCreatedAt(LocalDateTime.now()); - post.setStatus(PostStatus.ACTIVE); - post.setUser(userService.getCurrentUser()); - AddressInfo address = geocodingService.getAddress( - request.getLatitude().toString(), request.getLongitude().toString()); - post.setCity(address.city()); - post.setDistrict(address.district()); - repository.save(post); - - if (images != null && !images.isEmpty()) { - for (MultipartFile image : images) { - if (image == null || image.isEmpty()) { - continue; - } - - String objectKey = fileStorageService.uploadFile(image); - - Image img = new Image(); - img.setUrl(objectKey); // храним ключ MinIO, а не путь на диске - img.setPost(post); - imageRepository.save(img); - } + post.setStatus(PostStatus.MODERATION); + post.setUser(currentUser); + if (request.getLatitude() == null || request.getLongitude() == null) { + post.setCity(request.getAddress()); + } else { + AddressInfo address = geocodingService.getAddress(request.getLatitude().toString(), request.getLongitude().toString()); + post.setCity(address.city()); + post.setDistrict(address.district()); } + + postRepository.save(post); + log.info("Создан пост id={} пользователем userId={}, статус={}", post.getId(), currentUser.getId(), post.getStatus()); + + int uploaded = attachImages(post, images); + log.info("К посту id={} прикреплено {} изображений", post.getId(), uploaded); + statisticsService.incrementCreated(); return request; } public Page getFeed(String search, Pageable pageable) { - Specification spec = PostSpecification.hasSearch(search); - Page postsPage = repository.findAll(spec, pageable); + Specification spec = PostSpecification.hasSearch(search).and((root, query, cb) -> root.get("status").in(FEED_VISIBLE_STATUSES)); + Page postsPage = postRepository.findAll(spec, pageable); List postIds = postsPage.getContent().stream() .map(Post::getId) @@ -97,78 +94,47 @@ public class PostService { return Page.empty(pageable); } - Map likeCounts = favoriteRepository.countLikesByPostIds(postIds).stream() - .collect(Collectors.toMap( - row -> (Long) row[0], - row -> (Long) row[1] - )); + Map likeCounts = countLikes(postIds); + Set userFavoriteIds = currentUserFavoriteIds(postIds); - Set userFavoriteIds = new HashSet<>(); - try { - User currentUser = userService.getCurrentUser(); - userFavoriteIds = new HashSet<>( - favoriteRepository.findFavoritePostIds(currentUser.getId(), postIds) - ); - } catch (Exception e) { - // User not authenticated - that's OK - } - - final Set finalUserFavoriteIds = userFavoriteIds; - return postsPage.map(post -> { - PostResponse response = mapper.toResponse(post); - response.setLikeCount(likeCounts.getOrDefault(post.getId(), 0L)); - response.setFavorite(finalUserFavoriteIds.contains(post.getId())); - response.setUserRole(post.getUser().getRole()); - return response; - }); + return postsPage.map(post -> toResponse(post, likeCounts, userFavoriteIds, true)); } - public List getById(Long id) { - List posts = repository.findByUserId(id); + /** + * Возвращает все посты указанного пользователя (это НЕ поиск поста по его собственному id, + * несмотря на то, как метод назывался раньше — getById). Если у вас уже есть контроллер, + * вызывающий старое имя getById, переименуйте вызов на getPostsByUserId. + */ + public List getPostsByUserId(Long userId) { + List posts = postRepository.findByUserId(userId).stream() + .filter(post -> post.getStatus() == PostStatus.ACTIVE || post.getStatus() == PostStatus.CLOSED) + .toList(); + + if (posts.isEmpty()) { + return List.of(); + } List postIds = posts.stream() .map(Post::getId) .toList(); - Map likeCounts = favoriteRepository.countLikesByPostIds(postIds).stream() - .collect(Collectors.toMap( - row -> (Long) row[0], - row -> (Long) row[1] - )); + Map likeCounts = countLikes(postIds); + Set userFavoriteIds = currentUserFavoriteIds(postIds); - Set userFavoriteIds = new HashSet<>(); - try { - User currentUser = userService.getCurrentUser(); - userFavoriteIds = new HashSet<>( - favoriteRepository.findFavoritePostIds(currentUser.getId(), postIds) - ); - } catch (Exception e) { - // User not authenticated - } - - final Set finalUserFavoriteIds = userFavoriteIds; return posts.stream() - .map(post -> { - PostResponse response = mapper.toResponse(post); - response.setLikeCount(likeCounts.getOrDefault(post.getId(), 0L)); - response.setFavorite(finalUserFavoriteIds.contains(post.getId())); - return response; - }) + .map(post -> toResponse(post, likeCounts, userFavoriteIds, false)) .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) { + /** + * Удобный overload для контроллера, принимающего multipart-поля по отдельности. + * @Transactional стоит и здесь, и на основном update() — раньше этот метод вызывал + * update(id, request, images, existingUrls) через this, что было self-invocation + * и обходило Spring-прокси: @Transactional на вызываемом методе не срабатывал. + */ + @Transactional + 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)); @@ -182,21 +148,14 @@ public class PostService { List existingUrls = parseExistingImages(existingImagesJson); - return update(id, request, images, existingUrls); + return doUpdate(id, request, images, existingUrls); } - // старый метод остаётся как есть — рефакторить будем позже - public PostResponse update(Long id, PostRequest request, List images, - List existingUrls) { - Post post = repository.findById(id) - .orElseThrow(() -> new ApiException( - ErrorCode.NOT_FOUND, - "Post not found", - HttpStatus.NOT_FOUND - )); - - contentModerationFacade.moderate(request.getTitle(), request.getDescription(), images); + private PostResponse doUpdate(Long id, PostRequest request, List images, List existingUrls) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND)); + post.setStatus(PostStatus.MODERATION); post.setTitle(request.getTitle()); post.setDescription(request.getDescription()); if (request.getCategory() != null) { @@ -223,33 +182,189 @@ public class PostService { .filter(img -> !keepUrls.contains(img.getUrl())) .toList(); - toRemove.forEach(img -> { - try { - fileStorageService.deleteFile(img.getUrl()); - } catch (Exception e) { - log.warn("Не удалось удалить файл из хранилища: {}", img.getUrl(), e); - } - }); + removeImages(post, toRemove); - post.getImages().removeAll(toRemove); + int uploaded = attachImages(post, images); - if (images != null && !images.isEmpty()) { - for (MultipartFile image : images) { - if (image == null || image.isEmpty()) { - continue; - } + Post updated = postRepository.save(post); + log.info("Обновлён пост id={}: удалено изображений={}, добавлено={}, статус={}", id, toRemove.size(), uploaded, updated.getStatus()); - String objectKey = fileStorageService.uploadFile(image); + return mapper.toResponse(updated); + } - Image img = new Image(); - img.setUrl(objectKey); - img.setPost(post); - post.getImages().add(img); - } + @Transactional + public void delete(Long id) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND)); + + // Сначала избранное — иначе DataIntegrityViolationException (FK) при удалении поста + favoriteRepository.deleteByPostIn(List.of(post)); + + // Без этого файлы останутся в MinIO мусором после удаления объявления + post.getImages().forEach(img -> deleteFileQuietly(img.getUrl())); + + postRepository.delete(post); + log.info("Удалён пост id={}, изображений={}", id, post.getImages().size()); + } + + @Transactional + public PostResponse updateStatus(Long id, String statusString) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND)); + + User currentUser = userService.getCurrentUser(); + if (!post.getUser().getId().equals(currentUser.getId())) { + log.warn("Попытка сменить статус чужого поста: postId={}, userId={}", id, currentUser.getId()); + throw new ApiException(ErrorCode.FORBIDDEN, "Only the post owner can update status", HttpStatus.FORBIDDEN); } - Post updated = repository.save(post); - return mapper.toResponse(updated); + PostStatus newStatus = parseOwnerSettableStatus(statusString); + + if (post.getStatus() != PostStatus.CLOSED && newStatus == PostStatus.CLOSED) { + statisticsService.incrementFound(); + } + + PostStatus previousStatus = post.getStatus(); + post.setStatus(newStatus); + Post updated = postRepository.save(post); + log.info("Статус поста id={} изменён: {} -> {}", id, previousStatus, newStatus); + + List postIds = List.of(id); + PostResponse response = mapper.toResponse(updated); + response.setLikeCount(countLikes(postIds).getOrDefault(id, 0L)); + response.setFavorite(currentUserFavoriteIds(postIds).contains(id)); + + return response; + } + + public List searchByCity(String city) { + if (!StringUtils.hasText(city)) { + return List.of(); + } + + Specification spec = Specification.where(PostSpecification.cityOrDistrictLike(city)) + .and((root, query, cb) -> root.get("status").in(FEED_VISIBLE_STATUSES)); + + List posts = postRepository.findAll(spec); + if (posts.isEmpty()) { + return List.of(); + } + + List postIds = posts.stream() + .map(Post::getId) + .toList(); + + Map likeCounts = countLikes(postIds); + Set userFavoriteIds = currentUserFavoriteIds(postIds); + + return posts.stream() + .map(post -> toResponse(post, likeCounts, userFavoriteIds, true)) + .toList(); + } + + public List getCities() { + return postRepository.findDistinctCities(FEED_VISIBLE_STATUSES); + } + + private PostStatus parseOwnerSettableStatus(String statusString) { + PostStatus newStatus; + try { + newStatus = PostStatus.valueOf(statusString.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Invalid status: " + statusString + ". Must be ACTIVE or CLOSED", HttpStatus.BAD_REQUEST); + } + + if (!FEED_VISIBLE_STATUSES.contains(newStatus)) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Invalid status: " + statusString + ". Must be ACTIVE or CLOSED", HttpStatus.BAD_REQUEST); + } + + return newStatus; + } + + /** + * Загружает файлы в MinIO и привязывает их к посту. Возвращает количество загруженных файлов. + */ + private int attachImages(Post post, List images) { + if (images == null || images.isEmpty()) { + return 0; + } + + int count = 0; + for (MultipartFile image : images) { + if (image == null || image.isEmpty()) { + continue; + } + + String objectKey = fileStorageService.uploadFile(image); + + Image img = new Image(); + img.setUrl(objectKey); // храним ключ MinIO, а не путь на диске + img.setPost(post); + imageRepository.save(img); + post.getImages().add(img); + count++; + } + return count; + } + + private void removeImages(Post post, List toRemove) { + toRemove.forEach(img -> deleteFileQuietly(img.getUrl())); + post.getImages().removeAll(toRemove); + } + + private void deleteFileQuietly(String objectKey) { + try { + fileStorageService.deleteFile(objectKey); + } catch (Exception e) { + // Не блокируем основную операцию из-за мусора в хранилище — это некритично, + // но стоит завести алерт на такие warn-логи, если их станет много. + log.warn("Не удалось удалить файл из хранилища: {}", objectKey, e); + } + } + + private Map countLikes(List postIds) { + if (postIds.isEmpty()) { + return Map.of(); + } + return favoriteRepository.countLikesByPostIds(postIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1] + )); + } + + /** + * Возвращает id постов, лайкнутых текущим пользователем, либо пустой набор, + * если пользователь не авторизован. + */ + private Set currentUserFavoriteIds(List postIds) { + if (postIds.isEmpty()) { + return Set.of(); + } + try { + User currentUser = userService.getCurrentUser(); + return new HashSet<>(favoriteRepository.findFavoritePostIds(currentUser.getId(), postIds)); + } catch (ApiException e) { + if (e.getErrorCode() == ErrorCode.UNAUTHORIZED) { + // Ожидаемо для анонимных пользователей — не считаем ошибкой + log.debug("Избранное не подгружено: пользователь не авторизован"); + } else { + // Подозрительный случай: в SecurityContext есть аутентификация с логином, + // но пользователя с таким логином нет в БД (например, удалён после выдачи токена) + log.warn("Избранное не подгружено: {}", e.getMessage()); + } + return Set.of(); + } + } + + private PostResponse toResponse(Post post, Map likeCounts, Set userFavoriteIds, boolean includeUserRole) { + PostResponse response = mapper.toResponse(post); + response.setLikeCount(likeCounts.getOrDefault(post.getId(), 0L)); + response.setFavorite(userFavoriteIds.contains(post.getId())); + if (includeUserRole) { + response.setUserRole(post.getUser().getRole()); + } + return response; } private List parseExistingImages(String existingImagesJson) { @@ -259,95 +374,7 @@ public class PostService { try { return Arrays.asList(objectMapper.readValue(existingImagesJson, String[].class)); } catch (JsonProcessingException e) { - throw new ApiException( - ErrorCode.VALIDATION_ERROR, - "Некорректный формат existingImages", - HttpStatus.BAD_REQUEST - ); + 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) - .orElseThrow(() -> new ApiException( - ErrorCode.NOT_FOUND, - "Post not found", - HttpStatus.NOT_FOUND - )); - - // без этого файлы останутся в MinIO мусором после удаления объявления - post.getImages().forEach(img -> { - try { - fileStorageService.deleteFile(img.getUrl()); - } catch (Exception e) { - log.warn("Не удалось удалить файл из хранилища: {}", img.getUrl(), e); - } - }); - - repository.delete(post); - } - - @Transactional - public PostResponse updateStatus(Long id, String statusString) { - // Find the post - Post post = repository.findById(id) - .orElseThrow(() -> new ApiException( - ErrorCode.NOT_FOUND, - "Post not found", - HttpStatus.NOT_FOUND - )); - - User currentUser = userService.getCurrentUser(); - if (!post.getUser().getId().equals(currentUser.getId())) { - throw new ApiException( - ErrorCode.FORBIDDEN, - "Only the post owner can update status", - HttpStatus.FORBIDDEN - ); - } - - // Parse status string to enum - PostStatus newStatus; - try { - newStatus = PostStatus.valueOf(statusString.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new ApiException( - ErrorCode.VALIDATION_ERROR, - "Invalid status: " + statusString + ". Must be ACTIVE or CLOSED", - HttpStatus.BAD_REQUEST - ); - } - - if (post.getStatus() != PostStatus.CLOSED && statusString.equals(PostStatus.CLOSED.toString())) { - statisticsService.incrementFound(); - } - - // Update status - post.setStatus(newStatus); - Post updated = repository.save(post); - // Convert to response with like count - PostResponse response = mapper.toResponse(updated); - - // Count likes for this post - List postIds = List.of(id); - Map likeCounts = favoriteRepository.countLikesByPostIds(postIds).stream() - .collect(Collectors.toMap( - row -> (Long) row[0], - row -> (Long) row[1] - )); - - response.setLikeCount(likeCounts.getOrDefault(id, 0L)); - - // Check if current user has favorited this post - try { - List favoriteIds = favoriteRepository.findFavoritePostIds(currentUser.getId(), postIds); - response.setFavorite(favoriteIds.contains(id)); - } catch (Exception e) { - response.setFavorite(false); - } - - return response; - } - -} +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/SchedulerService.java b/src/main/java/com/krylov/refound/service/SchedulerService.java index df716b7..ad7bf8a 100644 --- a/src/main/java/com/krylov/refound/service/SchedulerService.java +++ b/src/main/java/com/krylov/refound/service/SchedulerService.java @@ -19,6 +19,7 @@ public class SchedulerService { private final PostRepository postRepository; private final FavoriteRepository favoriteRepository; + private final PostModerationExecutorService postModerationExecutorService; /** * Runs every day at 00:01 to delete: @@ -56,4 +57,32 @@ public class SchedulerService { log.info("Scheduled cleanup completed successfully"); } + + /** + * Каждые 60 секунд забирает посты, ожидающие модерации, и прогоняет их через + * ContentModerationFacade. Изображения к этому моменту уже лежат в MinIO + * (залиты синхронно в PostService.create/update) — оригинальные MultipartFile + * из запроса уже недоступны (Tomcat удаляет temp-файл сразу после завершения + * запроса), поэтому скачиваем их заново и оборачиваем в ByteArrayMultipartFile, + * чтобы не менять сигнатуру ContentModerationFacade. + * + * Сама обработка одного поста вынесена в отдельный бин PostModerationExecutor — + * вызов через него идёт через Spring-прокси, поэтому @Transactional на + * moderateOne реально применяется (self-invocation внутри этого класса привёл бы + * к тому, что транзакция не открывалась бы вовсе). + */ + @Scheduled(fixedDelay = 60_000) + public void moderatePendingPosts() { + List pending = postRepository.findIdsByStatus(PostStatus.MODERATION); + if (pending.isEmpty()) { + return; + } + + log.info("На модерации {} постов.", pending.size()); + for (Long postId : pending) { + postModerationExecutorService.moderateOne(postId); + } + } + + } diff --git a/src/main/java/com/krylov/refound/service/UserService.java b/src/main/java/com/krylov/refound/service/UserService.java index 6a62dc8..d41e288 100644 --- a/src/main/java/com/krylov/refound/service/UserService.java +++ b/src/main/java/com/krylov/refound/service/UserService.java @@ -22,27 +22,19 @@ public class UserService { private final UserRepository repository; private final FileStorageService fileStorageService; - public User getCurrentUser() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + public User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || authentication.getPrincipal() == null) { - throw new ApiException( - ErrorCode.UNAUTHORIZED, - "Пользователь не авторизован", - HttpStatus.UNAUTHORIZED - ); + if (authentication == null || authentication.getPrincipal() == null) { + throw new ApiException(ErrorCode.UNAUTHORIZED, "Пользователь не авторизован", HttpStatus.UNAUTHORIZED); + } + + String login = (String) authentication.getPrincipal(); + + return repository.findByLogin(login) + .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND)); } - String login = (String) authentication.getPrincipal(); - - return repository.findByLogin(login) - .orElseThrow(() -> new ApiException( - ErrorCode.NOT_FOUND, - "User not found", - HttpStatus.NOT_FOUND - )); - } - public UserResponseDto updateUser(Long id, UserUpdateDto dto) { User user = repository.findById(id) .orElseThrow(() -> new EntityNotFoundException("User not found")); @@ -73,10 +65,15 @@ public class UserService { repository.save(user); UserResponseDto userResponseDto = getUserResponseDto(user); - log.info("User updated: {}", userResponseDto); + log.info("User updated: {}", userResponseDto.getLogin()); return userResponseDto; } + public Long getUserIdByLogin(String login) { + return repository.findUserByLogin(login) + .orElseThrow(() -> new EntityNotFoundException("User not found")); + } + private static UserResponseDto getUserResponseDto(User user) { return UserResponseDto.builder() .id(user.getId()) @@ -89,8 +86,4 @@ public class UserService { .build(); } - public Long getUserIdByLogin(String login) { - return repository.findUserByLogin(login) - .orElseThrow(() -> new EntityNotFoundException("User not found")); - } } diff --git a/src/main/java/com/krylov/refound/util/ByteArrayMultipartFile.java b/src/main/java/com/krylov/refound/util/ByteArrayMultipartFile.java new file mode 100644 index 0000000..d1c7c08 --- /dev/null +++ b/src/main/java/com/krylov/refound/util/ByteArrayMultipartFile.java @@ -0,0 +1,69 @@ +package com.krylov.refound.util; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import org.springframework.web.multipart.MultipartFile; + +/** + * Реализация {@link MultipartFile} поверх байтового массива, уже находящегося в памяти. + * Нужна, чтобы скормить существующему {@code ContentModerationFacade} (который принимает + * {@code List}) изображения, скачанные из MinIO в шедулере — в этот момент + * оригинальных {@code MultipartFile} из HTTP-запроса уже не существует. + */ +public class ByteArrayMultipartFile implements MultipartFile { + + private final String name; + private final String originalFilename; + private final String contentType; + private final byte[] content; + + public ByteArrayMultipartFile(String name, String originalFilename, String contentType, byte[] content) { + this.name = name; + this.originalFilename = originalFilename; + this.contentType = contentType; + this.content = content != null ? content : new byte[0]; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + Files.write(dest.toPath(), content); + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/util/PostSpecification.java b/src/main/java/com/krylov/refound/util/PostSpecification.java index c2f99eb..2faa81d 100644 --- a/src/main/java/com/krylov/refound/util/PostSpecification.java +++ b/src/main/java/com/krylov/refound/util/PostSpecification.java @@ -2,6 +2,7 @@ package com.krylov.refound.util; import com.krylov.refound.entity.Post; import org.springframework.data.jpa.domain.Specification; +import org.springframework.util.StringUtils; public class PostSpecification { public static Specification hasSearch(String search) { @@ -23,4 +24,15 @@ public class PostSpecification { return cb.equal(cb.lower(root.get("type")), type.toLowerCase()); }; } + + public static Specification cityOrDistrictLike(String query) { + if (!StringUtils.hasText(query)) { + return null; + } + String pattern = "%" + query.toLowerCase() + "%"; + return (root, cq, cb) -> cb.or( + cb.like(cb.lower(root.get("city")), pattern), + cb.like(cb.lower(root.get("district")), pattern) + ); + } }