added all edit posts for admin
This commit is contained in:
@@ -28,6 +28,12 @@ dependencyManagement {
|
|||||||
imports {
|
imports {
|
||||||
mavenBom 'io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1'
|
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 {
|
dependencies {
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.krylov.refound.dto.admin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статус приходит строкой, а не enum-ом: {@code PostStatus} без {@code @JsonCreator} при
|
||||||
|
* неверном значении даёт {@code HttpMessageNotReadableException}, который в этом проекте
|
||||||
|
* превращается в 500. Разбор строки в сервисе возвращает понятный 400.
|
||||||
|
*/
|
||||||
|
public record AdminPostStatusUpdateRequest(
|
||||||
|
String status
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import com.krylov.refound.entity.Chat;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
public interface ChatRepository extends JpaRepository<Chat, Long> {
|
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")
|
@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
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
package com.krylov.refound.repository;
|
package com.krylov.refound.repository;
|
||||||
|
|
||||||
import com.krylov.refound.entity.Image;
|
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.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
public interface ImageRepository extends JpaRepository<Image, Long> {
|
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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,43 @@
|
|||||||
package com.krylov.refound.util;
|
package com.krylov.refound.util;
|
||||||
|
|
||||||
import com.krylov.refound.entity.Post;
|
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.data.jpa.domain.Specification;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
public class PostSpecification {
|
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) {
|
public static Specification<Post> hasSearch(String search) {
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
if (search == null || search.isBlank()) {
|
if (search == null || search.isBlank()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user