added all edit posts for admin

This commit is contained in:
SlimusMinus
2026-10-02 01:54:19 +03:00
parent c8a3cca059
commit bffd259c25
8 changed files with 545 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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<Page<AdminPostResponse>> 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<AdminPostResponse> 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<MultipartFile> 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<Void> 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<AdminPostResponse> updateStatus(@PathVariable Long postId, @Valid @RequestBody AdminPostStatusUpdateRequest request) {
log.info("updateStatus {} -> {}", postId, request.status());
return ResponseEntity.ok(adminPostService.updateStatus(postId, request.status()));
}
}

View File

@@ -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<String> images;
private Long ownerId;
private String ownerLogin;
private String ownerName;
private String ownerFirstName;
private String ownerLastName;
}

View File

@@ -0,0 +1,12 @@
package com.krylov.refound.dto.admin;
/**
* Статус приходит строкой, а не enum-ом: {@code PostStatus} без {@code @JsonCreator} при
* неверном значении даёт {@code HttpMessageNotReadableException}, который в этом проекте
* превращается в 500. Разбор строки в сервисе возвращает понятный 400.
*/
public record AdminPostStatusUpdateRequest(
String status
) {
}

View File

@@ -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<Chat, Long> {
@@ -18,4 +20,12 @@ public interface ChatRepository extends JpaRepository<Chat, Long> {
@Query("SELECT c FROM Chat c WHERE c.userOneId = :userId OR c.userTwoId = :userId ORDER BY c.createdAt DESC")
List<Chat> 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);
}

View File

@@ -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<Image, Long> {
Optional<List<Image>> findByPostId(Long postId);
/** Один запрос вместо N+1 при сборке админской страницы объявлений. */
@Query("select i from Image i where i.post.id in :postIds order by i.id asc")
List<Image> findByPostIdIn(@Param("postIds") Collection<Long> postIds);
}

View File

@@ -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}:
* <ul>
* <li>нет проверки владельца — операции доступны любому ADMIN;</li>
* <li>редактирование не меняет статус: перевод в MODERATION убрал бы объявление из ленты до
* прохождения AI-модерации, а в REJECTED — скрыл бы навсегда;</li>
* <li>удаление чистит ссылки из favorites и chats, потому что каскада в БД нет;</li>
* <li>геокодинг при правке best-effort: недоступность Nominatim не должна блокировать
* исправление текста объявления.</li>
* </ul>
*/
@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<AdminPostResponse> getPosts(String search, String rawType, String rawStatus, Pageable pageable) {
Page<Post> page = postRepository.findAll(
PostSpecification.adminSearch(search, parseType(rawType), parseStatus(rawStatus)),
safePageable(pageable)
);
Map<Long, List<String>> 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<MultipartFile> 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<MultipartFile> newImages) {
List<String> keepUrls = parseExistingImages(existingImagesJson);
List<Image> 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<String> 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<Long, List<String>> loadImages(List<Long> 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<String> imageUrlsOf(Post post) {
List<Image> images = post.getImages();
if (images == null || images.isEmpty()) {
return List.of();
}
return images.stream().map(Image::getUrl).toList();
}
private AdminPostResponse toResponse(Post post, List<String> 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);
}
}

View File

@@ -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<Post> 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<Post, User> 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<Post> hasSearch(String search) {
return (root, query, cb) -> {
if (search == null || search.isBlank()) {