Files
nahodka-back/src/main/java/com/krylov/refound/service/PostService.java
2026-09-29 01:03:54 +03:00

404 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.krylov.refound.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.krylov.refound.dto.AddressInfo;
import com.krylov.refound.dto.CachedPostPage;
import com.krylov.refound.dto.PostRequest;
import com.krylov.refound.dto.PostResponse;
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.mapper.PostMapper;
import com.krylov.refound.repository.ImageRepository;
import com.krylov.refound.repository.PostRepository;
import com.krylov.refound.service.redis.MapCacheService;
import com.krylov.refound.service.redis.PostCacheKeyGenerator;
import com.krylov.refound.service.redis.PostCacheService;
import com.krylov.refound.util.PostSpecification;
import com.krylov.refound.util.PostVisibility;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
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.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, getPostOnMapById), но не в общем фиде.
private static final Set<PostStatus> FEED_VISIBLE_STATUSES = PostVisibility.FEED_VISIBLE_STATUSES;
private final PostRepository postRepository;
private final PostMapper mapper;
private final ImageRepository imageRepository;
private final GeocodingService geocodingService;
private final UserService userService;
private final FileStorageService fileStorageService;
private final StatisticsService statisticsService;
private final ObjectMapper objectMapper;
private final PostCacheService postCacheService;
private final PostCacheKeyGenerator postCacheKeyGenerator;
private final MapCacheService mapCacheService;
@Transactional
public PostRequest create(PostRequest request, List<MultipartFile> images) {
User currentUser = userService.getCurrentUser();
Post post = mapper.toEntity(request);
post.setCreatedAt(LocalDateTime.now());
post.setStatus(PostStatus.MODERATION);
post.setUser(currentUser);
post.setIsReward(request.getReward());
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();
postCacheService.invalidatePosts();
mapCacheService.clearMapMarkersCache();
return request;
}
public Page<PostResponse> getFeed(String search, Pageable pageable) {
// Ключ содержит версию ленты, она читается до запроса в БД
String cacheKey = postCacheKeyGenerator.feedKey(search, pageable);
CachedPostPage cached = postCacheService.getFeed(cacheKey);
if (cached != null) {
log.debug("Feed cache HIT: {}", cacheKey);
return new PageImpl<>(cached.getContent(), pageable, cached.getTotalElements());
}
log.debug("Feed cache MISS: {}", cacheKey);
Specification<Post> spec = PostSpecification.hasSearch(search)
.and((root, query, cb) ->
root.get("status").in(FEED_VISIBLE_STATUSES)
);
Page<Post> postsPage = postRepository.findAll(spec, pageable);
Page<PostResponse> responsePage = postsPage.map(post -> toResponse(post, true));
CachedPostPage cachedPage = new CachedPostPage(
responsePage.getContent(),
responsePage.getNumber(),
responsePage.getSize(),
responsePage.getTotalElements(),
responsePage.getTotalPages()
);
postCacheService.saveFeed(cacheKey, cachedPage, Duration.ofMinutes(2));
return responsePage;
}
/**
* Возвращает посты указанного пользователя (это НЕ поиск поста по его собственному id,
* несмотря на то, как метод назывался раньше — getById). Если у вас уже есть контроллер,
* вызывающий старое имя getById, переименуйте вызов на getPostsByUserId.
* Владелец видит все свои посты (включая MODERATION и REJECTED), остальные — только
* опубликованные (ACTIVE и CLOSED).
*/
public List<PostResponse> getPostsByUserId(Long userId) {
boolean owner = isCurrentUser(userId);
List<Post> posts = postRepository.findByUserId(userId).stream()
.filter(post -> owner || FEED_VISIBLE_STATUSES.contains(post.getStatus()))
.toList();
if (posts.isEmpty()) {
return List.of();
}
return posts.stream()
.map(post -> toResponse(post, false))
.toList();
}
/**
* Удобный 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, Boolean reward,
String rewardText, List<MultipartFile> images, String existingImagesJson) {
PostRequest request = new PostRequest();
request.setType(PostType.from(type));
request.setTitle(title);
request.setDescription(description);
request.setCategory(category);
request.setPhone(phone);
request.setLatitude(latitude);
request.setLongitude(longitude);
request.setAddress(address);
request.setReward(reward);
request.setRewardText(rewardText);
List<String> existingUrls = parseExistingImages(existingImagesJson);
return doUpdate(id, request, images, existingUrls);
}
private PostResponse doUpdate(Long id, PostRequest request, List<MultipartFile> images, List<String> existingUrls) {
// Проверка владельца выполняется до любых изменений и загрузки файлов
Post post = findOwnedPost(id);
post.setStatus(PostStatus.MODERATION);
post.setTitle(request.getTitle());
post.setDescription(request.getDescription());
if (request.getCategory() != null) {
post.setCategory(PostCategory.fromDisplayName(request.getCategory()));
}
post.setPhone(request.getPhone());
post.setType(request.getType());
post.setIsReward(request.getReward());
post.setReward(request.getRewardText());
if (request.getLatitude() != null && request.getLongitude() != null) {
post.setLatitude(request.getLatitude());
post.setLongitude(request.getLongitude());
AddressInfo address = geocodingService.getAddress(
request.getLatitude().toString(), request.getLongitude().toString());
post.setCity(address.city());
post.setDistrict(address.district());
} else {
post.setCity(request.getAddress());
}
List<String> keepUrls = existingUrls != null ? existingUrls : List.of();
List<Image> toRemove = post.getImages().stream()
.filter(img -> !keepUrls.contains(img.getUrl()))
.toList();
removeImages(post, toRemove);
int uploaded = attachImages(post, images);
Post updated = postRepository.save(post);
log.info("Обновлён пост id={}: удалено изображений={}, добавлено={}, статус={}", id, toRemove.size(), uploaded, updated.getStatus());
postCacheService.invalidatePosts();
mapCacheService.clearMapMarkersCache();
log.info("redis cache invalidated in update");
return mapper.toResponse(updated);
}
@Transactional
public void delete(Long id) {
Post post = findOwnedPost(id);
// Без этого файлы останутся в MinIO мусором после удаления объявления
post.getImages().forEach(img -> deleteFileQuietly(img.getUrl()));
postRepository.delete(post);
log.info("Удалён пост id={}, изображений={}", id, post.getImages().size());
postCacheService.invalidatePosts();
mapCacheService.clearMapMarkersCache();
log.info("redis cache invalidated in delete");
}
@Transactional
public PostResponse updateStatus(Long id, String statusString) {
Post post = findOwnedPost(id);
// Пост на модерации или отклонённый владелец не может сам перевести в ACTIVE:
// иначе можно обойти модерацию (edit -> MODERATION -> updateStatus(ACTIVE))
if (!FEED_VISIBLE_STATUSES.contains(post.getStatus())) {
log.warn("Попытка сменить статус неопубликованного поста: postId={}, статус={}", id, post.getStatus());
throw new ApiException(ErrorCode.FORBIDDEN, "Post is not published yet", HttpStatus.FORBIDDEN);
}
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);
postCacheService.invalidatePosts();
mapCacheService.clearMapMarkersCache();
log.info("redis cache invalidated in update status");
return mapper.toResponse(updated);
}
public List<PostResponse> searchByCity(String city) {
if (!StringUtils.hasText(city)) {
return List.of();
}
Specification<Post> spec = Specification.where(PostSpecification.cityOrDistrictLike(city))
.and((root, query, cb) -> root.get("status").in(FEED_VISIBLE_STATUSES));
List<Post> posts = postRepository.findAll(spec);
if (posts.isEmpty()) {
return List.of();
}
return posts.stream()
.map(post -> toResponse(post, true))
.toList();
}
public List<String> getCities() {
return postRepository.findDistinctCities(FEED_VISIBLE_STATUSES);
}
/**
* Находит пост и проверяет, что текущий пользователь — его владелец.
* NOT_FOUND, если поста нет; FORBIDDEN, если пост чужой.
*/
private Post findOwnedPost(Long id) {
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 modify this post", HttpStatus.FORBIDDEN);
}
return post;
}
/**
* true, если запрос выполняет авторизованный пользователь с указанным id.
* Для анонимного запроса возвращает false.
*/
private boolean isCurrentUser(Long userId) {
try {
User currentUser = userService.getCurrentUser();
return currentUser != null && currentUser.getId().equals(userId);
} catch (ApiException e) {
return false;
}
}
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<MultipartFile> 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<Image> 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);
}
}
public PostResponse toResponse(Post post, boolean includeUserRole) {
PostResponse response = mapper.toResponse(post);
response.setReward(post.getIsReward());
if (includeUserRole) {
response.setUserRole(post.getUser().getRole());
}
return response;
}
private List<String> parseExistingImages(String existingImagesJson) {
if (existingImagesJson == null) {
return List.of();
}
try {
return Arrays.asList(objectMapper.readValue(existingImagesJson, String[].class));
} catch (JsonProcessingException e) {
throw new ApiException(ErrorCode.VALIDATION_ERROR, "Некорректный формат existingImages", HttpStatus.BAD_REQUEST);
}
}
public PostResponse getPostOnMapById(Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
// Неопубликованный пост (MODERATION, REJECTED) виден только владельцу.
// Остальным отвечаем 404, чтобы не раскрывать, что такой id существует
if (!FEED_VISIBLE_STATUSES.contains(post.getStatus()) && !isCurrentUser(post.getUser().getId())) {
throw new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND);
}
return toResponse(post, true);
}
}