From bffd259c259b8bc4a608984ac75c81022abcba21 Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Fri, 2 Oct 2026 01:54:19 +0300 Subject: [PATCH] added all edit posts for admin --- build.gradle | 6 + .../controller/admin/PostAdminController.java | 91 +++++ .../refound/dto/admin/AdminPostResponse.java | 43 +++ .../admin/AdminPostStatusUpdateRequest.java | 12 + .../refound/repository/ChatRepository.java | 10 + .../refound/repository/ImageRepository.java | 13 +- .../service/admin/AdminPostService.java | 338 ++++++++++++++++++ .../refound/util/PostSpecification.java | 33 ++ 8 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/krylov/refound/controller/admin/PostAdminController.java create mode 100644 src/main/java/com/krylov/refound/dto/admin/AdminPostResponse.java create mode 100644 src/main/java/com/krylov/refound/dto/admin/AdminPostStatusUpdateRequest.java create mode 100644 src/main/java/com/krylov/refound/service/admin/AdminPostService.java diff --git a/build.gradle b/build.gradle index f1345ea..ed47c95 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,12 @@ dependencyManagement { imports { mavenBom 'io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1' } + dependencies { + // ByteBuddy из BOM Spring Boot 3.5.6 не поддерживает class-файлы Java 25: + // без обновления Mockito падает на создании любого мока. + dependency 'net.bytebuddy:byte-buddy:1.17.6' + dependency 'net.bytebuddy:byte-buddy-agent:1.17.6' + } } dependencies { diff --git a/src/main/java/com/krylov/refound/controller/admin/PostAdminController.java b/src/main/java/com/krylov/refound/controller/admin/PostAdminController.java new file mode 100644 index 0000000..739f718 --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/admin/PostAdminController.java @@ -0,0 +1,91 @@ +package com.krylov.refound.controller.admin; + +import com.krylov.refound.dto.admin.AdminPostResponse; +import com.krylov.refound.dto.admin.AdminPostStatusUpdateRequest; +import com.krylov.refound.service.admin.AdminPostService; +import jakarta.validation.Valid; +import java.util.List; +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.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * Админская лента объявлений. Доступ закрыт дважды: SecurityConfig требует ADMIN на + * /api/v1/admin/** и каждый метод дополнительно защищён @PreAuthorize. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/admin/posts") +@RequiredArgsConstructor +public class PostAdminController { + + private final AdminPostService adminPostService; + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getPosts( + @RequestParam(required = false) String search, + @RequestParam(required = false) String type, + @RequestParam(required = false) String status, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) + Pageable pageable + ) { + log.info("getPosts search={} type={} status={} page={}", search, type, status, pageable.getPageNumber()); + return ResponseEntity.ok(adminPostService.getPosts(search, type, status, pageable)); + } + + /** Multipart-поля те же, что у пользовательского PUT /api/v1/posts/{id}. */ + @PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updatePost( + @PathVariable Long postId, + @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(required = false) Boolean reward, + @RequestParam(required = false) String rewardText, + @RequestParam(value = "images", required = false) List images, + @RequestParam(value = "existingImages", required = false) String existingImagesJson + ) { + log.info("updatePost {} by admin", postId); + return ResponseEntity.ok(adminPostService.updatePost(postId, type, title, address, + latitude, longitude, description, category, phone, reward, rewardText, images, existingImagesJson)); + } + + @DeleteMapping("/{postId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deletePost(@PathVariable Long postId) { + log.info("deletePost {} by admin", postId); + adminPostService.deletePost(postId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PatchMapping(value = "/{postId}/status", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateStatus(@PathVariable Long postId, @Valid @RequestBody AdminPostStatusUpdateRequest request) { + log.info("updateStatus {} -> {}", postId, request.status()); + return ResponseEntity.ok(adminPostService.updateStatus(postId, request.status())); + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/dto/admin/AdminPostResponse.java b/src/main/java/com/krylov/refound/dto/admin/AdminPostResponse.java new file mode 100644 index 0000000..93f4e43 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/admin/AdminPostResponse.java @@ -0,0 +1,43 @@ +package com.krylov.refound.dto.admin; + +import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.enums.PostType; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Объявление в админской ленте: публичные поля {@code PostResponse} плюс данные владельца, + * по которым администратор ищет и фильтрует объявления. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminPostResponse { + + private Long id; + private PostType type; + private PostStatus status; + private String title; + private String description; + private String category; + private String city; + private String district; + private Double latitude; + private Double longitude; + private String phone; + private Boolean reward; + private String rewardText; + private LocalDateTime createdAt; + private List images; + + private Long ownerId; + private String ownerLogin; + private String ownerName; + private String ownerFirstName; + private String ownerLastName; +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/dto/admin/AdminPostStatusUpdateRequest.java b/src/main/java/com/krylov/refound/dto/admin/AdminPostStatusUpdateRequest.java new file mode 100644 index 0000000..e980080 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/admin/AdminPostStatusUpdateRequest.java @@ -0,0 +1,12 @@ +package com.krylov.refound.dto.admin; + +/** + * Статус приходит строкой, а не enum-ом: {@code PostStatus} без {@code @JsonCreator} при + * неверном значении даёт {@code HttpMessageNotReadableException}, который в этом проекте + * превращается в 500. Разбор строки в сервисе возвращает понятный 400. + */ +public record AdminPostStatusUpdateRequest( + String status +) { + +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/repository/ChatRepository.java b/src/main/java/com/krylov/refound/repository/ChatRepository.java index 3d96389..86ba2bb 100644 --- a/src/main/java/com/krylov/refound/repository/ChatRepository.java +++ b/src/main/java/com/krylov/refound/repository/ChatRepository.java @@ -4,7 +4,9 @@ import com.krylov.refound.entity.Chat; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ChatRepository extends JpaRepository { @@ -18,4 +20,12 @@ public interface ChatRepository extends JpaRepository { @Query("SELECT c FROM Chat c WHERE c.userOneId = :userId OR c.userTwoId = :userId ORDER BY c.createdAt DESC") List findAllByUserId(Long userId); // Changed from Optional to List + + /** + * Объявление удалено администратором: обнуляем ссылку, чтобы переписка сохранилась. + * У posts нет ON DELETE CASCADE, поэтому без этого удаление падало бы с 500. + */ + @Modifying + @Query("UPDATE Chat c SET c.postId = NULL WHERE c.postId = :postId") + int clearPostReference(@Param("postId") Long postId); } diff --git a/src/main/java/com/krylov/refound/repository/ImageRepository.java b/src/main/java/com/krylov/refound/repository/ImageRepository.java index c2898cb..b7c7ccb 100644 --- a/src/main/java/com/krylov/refound/repository/ImageRepository.java +++ b/src/main/java/com/krylov/refound/repository/ImageRepository.java @@ -1,7 +1,18 @@ package com.krylov.refound.repository; import com.krylov.refound.entity.Image; +import java.util.Collection; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ImageRepository extends JpaRepository { -} + + Optional> findByPostId(Long postId); + + /** Один запрос вместо N+1 при сборке админской страницы объявлений. */ + @Query("select i from Image i where i.post.id in :postIds order by i.id asc") + List findByPostIdIn(@Param("postIds") Collection postIds); +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/admin/AdminPostService.java b/src/main/java/com/krylov/refound/service/admin/AdminPostService.java new file mode 100644 index 0000000..8685947 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/admin/AdminPostService.java @@ -0,0 +1,338 @@ +package com.krylov.refound.service.admin; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.krylov.refound.dto.AddressInfo; +import com.krylov.refound.dto.admin.AdminPostResponse; +import com.krylov.refound.entity.Image; +import com.krylov.refound.entity.Post; +import com.krylov.refound.entity.User; +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.enums.PostCategory; +import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.enums.PostType; +import com.krylov.refound.exception.ApiException; +import com.krylov.refound.repository.ChatRepository; +import com.krylov.refound.repository.FavoriteRepository; +import com.krylov.refound.repository.ImageRepository; +import com.krylov.refound.repository.PostRepository; +import com.krylov.refound.service.FileStorageService; +import com.krylov.refound.service.GeocodingService; +import com.krylov.refound.service.StatisticsService; +import com.krylov.refound.service.redis.MapCacheService; +import com.krylov.refound.service.redis.PostCacheService; +import com.krylov.refound.util.PostSpecification; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +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.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +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; + +/** + * Админские операции над объявлениями. Отличия от {@code PostService}: + *
    + *
  • нет проверки владельца — операции доступны любому ADMIN;
  • + *
  • редактирование не меняет статус: перевод в MODERATION убрал бы объявление из ленты до + * прохождения AI-модерации, а в REJECTED — скрыл бы навсегда;
  • + *
  • удаление чистит ссылки из favorites и chats, потому что каскада в БД нет;
  • + *
  • геокодинг при правке best-effort: недоступность Nominatim не должна блокировать + * исправление текста объявления.
  • + *
+ */ +@Service +@Slf4j +@RequiredArgsConstructor +public class AdminPostService { + + private static final int MAX_PAGE_SIZE = 100; + + private final PostRepository postRepository; + private final ImageRepository imageRepository; + private final FavoriteRepository favoriteRepository; + private final ChatRepository chatRepository; + private final FileStorageService fileStorageService; + private final GeocodingService geocodingService; + private final PostCacheService postCacheService; + private final MapCacheService mapCacheService; + private final StatisticsService statisticsService; + private final ObjectMapper objectMapper; + + /** + * Лента объявлений: видны все статусы, включая MODERATION и REJECTED. + * Фильтры приходят строками и разбираются вручную — неверное значение даёт 400 вместо 500. + */ + @Transactional(readOnly = true) + public Page getPosts(String search, String rawType, String rawStatus, Pageable pageable) { + Page page = postRepository.findAll( + PostSpecification.adminSearch(search, parseType(rawType), parseStatus(rawStatus)), + safePageable(pageable) + ); + + Map> imagesByPost = loadImages(page.getContent().stream().map(Post::getId).toList()); + + return new PageImpl<>( + page.getContent().stream() + .map(post -> toResponse(post, imagesByPost.getOrDefault(post.getId(), List.of()))) + .toList(), + page.getPageable(), + page.getTotalElements() + ); + } + + /** Multipart-контракт повторяет пользовательский PUT /api/v1/posts/{id}. */ + @Transactional + public AdminPostResponse updatePost(Long postId, String type, String title, String address, + Double latitude, Double longitude, String description, String category, String phone, + Boolean reward, String rewardText, List images, String existingImagesJson) { + + Post post = findPost(postId); + PostStatus statusBeforeEdit = post.getStatus(); + + post.setType(parseTypeOrThrow(type)); + post.setTitle(title); + post.setDescription(description); + post.setPhone(phone); + post.setIsReward(Boolean.TRUE.equals(reward)); + post.setReward(rewardText); + + if (StringUtils.hasText(category)) { + post.setCategory(PostCategory.fromDisplayName(category)); + } + if (latitude != null && longitude != null) { + post.setLatitude(latitude); + post.setLongitude(longitude); + resolveLocation(post, latitude, longitude, address); + } else if (StringUtils.hasText(address)) { + post.setCity(address.trim()); + } + + syncImages(post, existingImagesJson, images); + Post updated = postRepository.save(post); + invalidateCaches(); + + log.info("Post {} edited by admin, status kept as {}", postId, statusBeforeEdit); + return toResponse(updated, imageUrlsOf(updated)); + } + + @Transactional + public void deletePost(Long postId) { + Post post = findPost(postId); + + for (Image image : post.getImages()) { + deleteFileQuietly(image.getUrl()); + } + // На posts ссылаются favorites и chats, ON DELETE CASCADE в БД нет: + // без явной чистки удаление падало бы на внешнем ключе. + favoriteRepository.deleteByPostIn(List.of(post)); + chatRepository.clearPostReference(postId); + + postRepository.delete(post); + invalidateCaches(); + + log.info("Post {} deleted by admin", postId); + } + + /** + * ACTIVE и CLOSED — то, что умеет фронт; MODERATION возвращает объявление в очередь + * AI-модерации, REJECTED скрывает его из ленты. + */ + @Transactional + public AdminPostResponse updateStatus(Long postId, String rawStatus) { + PostStatus target = parseStatusOrThrow(rawStatus); + Post post = findPost(postId); + PostStatus previous = post.getStatus(); + + if (previous != target) { + post.setStatus(target); + Post updated = postRepository.save(post); + // posts_found — счётчик закрытых объявлений за всё время, уменьшать его не нужно. + if (target == PostStatus.CLOSED) { + statisticsService.incrementFound(); + } + invalidateCaches(); + log.info("Post {} status {} -> {} by admin", postId, previous, target); + return toResponse(updated, imageUrlsOf(updated)); + } + + return toResponse(post, imageUrlsOf(post)); + } + + private Post findPost(Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> new ApiException( + ErrorCode.NOT_FOUND, "Post not found: " + postId, HttpStatus.NOT_FOUND)); + } + + private void resolveLocation(Post post, Double latitude, Double longitude, String address) { + try { + AddressInfo resolved = geocodingService.getAddress(latitude.toString(), longitude.toString()); + if (StringUtils.hasText(resolved.city())) { + post.setCity(resolved.city()); + post.setDistrict(resolved.district()); + return; + } + } catch (ApiException e) { + log.warn("Геокодинг недоступен, город берём из address: postId={}, {}", post.getId(), e.getMessage()); + } + if (StringUtils.hasText(address)) { + post.setCity(address.trim()); + } + } + + private void syncImages(Post post, String existingImagesJson, List newImages) { + List keepUrls = parseExistingImages(existingImagesJson); + + List toRemove = post.getImages().stream() + .filter(image -> !keepUrls.contains(image.getUrl())) + .toList(); + for (Image image : toRemove) { + deleteFileQuietly(image.getUrl()); + } + post.getImages().removeAll(toRemove); + + if (newImages == null) { + return; + } + for (MultipartFile file : newImages) { + if (file == null || file.isEmpty()) { + continue; + } + Image image = new Image(); + image.setUrl(fileStorageService.uploadFile(file)); + image.setPost(post); + imageRepository.save(image); + post.getImages().add(image); + } + } + + private List parseExistingImages(String existingImagesJson) { + if (!StringUtils.hasText(existingImagesJson)) { + 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); + } + } + + private void invalidateCaches() { + postCacheService.invalidatePosts(); + mapCacheService.clearMapMarkersCache(); + } + + private void deleteFileQuietly(String objectKey) { + try { + fileStorageService.deleteFile(objectKey); + } catch (Exception e) { + log.warn("Не удалось удалить файл из хранилища: {}", objectKey, e); + } + } + + /** Один запрос вместо N+1 при сборке страницы объявлений. */ + private Map> loadImages(List postIds) { + if (postIds.isEmpty()) { + return Map.of(); + } + return imageRepository.findByPostIdIn(postIds).stream() + .collect(Collectors.groupingBy( + image -> image.getPost().getId(), + Collectors.mapping(Image::getUrl, Collectors.toList()) + )); + } + + private List imageUrlsOf(Post post) { + List images = post.getImages(); + if (images == null || images.isEmpty()) { + return List.of(); + } + return images.stream().map(Image::getUrl).toList(); + } + + private AdminPostResponse toResponse(Post post, List images) { + User owner = post.getUser(); + + return AdminPostResponse.builder() + .id(post.getId()) + .type(post.getType()) + .status(post.getStatus()) + .title(post.getTitle()) + .description(post.getDescription()) + .category(post.getCategory() == null ? null : post.getCategory().getDisplayName()) + .city(post.getCity()) + .district(post.getDistrict()) + .latitude(post.getLatitude()) + .longitude(post.getLongitude()) + .phone(post.getPhone()) + .reward(Boolean.TRUE.equals(post.getIsReward())) + .rewardText(post.getReward()) + .createdAt(post.getCreatedAt()) + .images(images) + .ownerId(owner == null ? null : owner.getId()) + .ownerLogin(owner == null ? null : owner.getLogin()) + .ownerName(owner == null ? null : owner.getName()) + .ownerFirstName(owner == null ? null : owner.getName()) + .ownerLastName(owner == null ? null : owner.getLastName()) + .build(); + } + + private static Pageable safePageable(Pageable pageable) { + int size = Math.min(Math.max(pageable.getPageSize(), 1), MAX_PAGE_SIZE); + int page = Math.max(pageable.getPageNumber(), 0); + Sort sort = pageable.getSort().isSorted() + ? pageable.getSort() + : Sort.by(Sort.Direction.DESC, "createdAt"); + return PageRequest.of(page, size, sort); + } + + private static PostType parseType(String raw) { + if (!StringUtils.hasText(raw)) { + return null; + } + try { + return PostType.valueOf(raw.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, + "Неизвестный тип объявления: " + raw, HttpStatus.BAD_REQUEST); + } + } + + private static PostType parseTypeOrThrow(String raw) { + if (!StringUtils.hasText(raw)) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Тип объявления обязателен", HttpStatus.BAD_REQUEST); + } + return parseType(raw); + } + + private static PostStatus parseStatus(String raw) { + if (!StringUtils.hasText(raw)) { + return null; + } + try { + return PostStatus.valueOf(raw.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, + "Неизвестный статус: " + raw, HttpStatus.BAD_REQUEST); + } + } + + private static PostStatus parseStatusOrThrow(String raw) { + if (!StringUtils.hasText(raw)) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Статус обязателен", HttpStatus.BAD_REQUEST); + } + return parseStatus(raw); + } +} \ 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 441a1cb..2677ce9 100644 --- a/src/main/java/com/krylov/refound/util/PostSpecification.java +++ b/src/main/java/com/krylov/refound/util/PostSpecification.java @@ -1,10 +1,43 @@ package com.krylov.refound.util; import com.krylov.refound.entity.Post; +import com.krylov.refound.entity.User; +import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.enums.PostType; +import jakarta.persistence.criteria.Join; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; public class PostSpecification { + + /** + * Поиск для админской ленты: видимы все объявления, включая MODERATION и REJECTED. + * Совпадение ищется по названию, описанию, городу, району и данным владельца. + */ + public static Specification adminSearch(String search, PostType type, PostStatus status) { + return Specification.allOf( + (root, query, cb) -> type == null ? cb.conjunction() : cb.equal(root.get("type"), type), + (root, query, cb) -> status == null ? cb.conjunction() : cb.equal(root.get("status"), status), + (root, query, cb) -> { + if (!StringUtils.hasText(search)) { + return cb.conjunction(); + } + String pattern = "%" + search.toLowerCase().trim() + "%"; + // user_id — NOT NULL, поэтому inner join не теряет объявления без владельца. + Join user = root.join("user"); + return cb.or( + cb.like(cb.lower(root.get("title")), pattern), + cb.like(cb.lower(root.get("description")), pattern), + cb.like(cb.lower(root.get("city")), pattern), + cb.like(cb.lower(root.get("district")), pattern), + cb.like(cb.lower(user.get("login")), pattern), + cb.like(cb.lower(user.get("name")), pattern), + cb.like(cb.lower(user.get("lastName")), pattern) + ); + } + ); + } + public static Specification hasSearch(String search) { return (root, query, cb) -> { if (search == null || search.isBlank()) {