added posts on feed and posts on map

This commit is contained in:
SlimusMinus
2026-09-29 01:03:54 +03:00
parent a44ca00222
commit 870739f45f
22 changed files with 639 additions and 44 deletions

View File

@@ -3,6 +3,7 @@ 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;
@@ -16,7 +17,12 @@ 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;
@@ -25,6 +31,7 @@ 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;
@@ -38,8 +45,8 @@ import org.springframework.web.multipart.MultipartFile;
@Slf4j
public class PostService {
// Статусы, которые показываются в публичной ленте. MODERATION и REJECTED видны
// только владельцу через getPostsByUserId, но не в общем фиде.
private static final Set<PostStatus> FEED_VISIBLE_STATUSES = EnumSet.of(PostStatus.ACTIVE, PostStatus.CLOSED);
// только владельцу (getPostsByUserId, getPostOnMapById), но не в общем фиде.
private static final Set<PostStatus> FEED_VISIBLE_STATUSES = PostVisibility.FEED_VISIBLE_STATUSES;
private final PostRepository postRepository;
private final PostMapper mapper;
@@ -49,6 +56,9 @@ public class PostService {
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) {
@@ -75,27 +85,58 @@ public class PostService {
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));
root.get("status").in(FEED_VISIBLE_STATUSES)
);
Page<Post> postsPage = postRepository.findAll(spec, pageable);
return postsPage.map(post -> toResponse(post, true));
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,
* Возвращает посты указанного пользователя (это НЕ поиск поста по его собственному 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 -> post.getStatus() == PostStatus.ACTIVE || post.getStatus() == PostStatus.CLOSED)
.filter(post -> owner || FEED_VISIBLE_STATUSES.contains(post.getStatus()))
.toList();
if (posts.isEmpty()) {
@@ -136,8 +177,8 @@ public class PostService {
}
private PostResponse doUpdate(Long id, PostRequest request, List<MultipartFile> images, List<String> existingUrls) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
// Проверка владельца выполняется до любых изменений и загрузки файлов
Post post = findOwnedPost(id);
post.setStatus(PostStatus.MODERATION);
post.setTitle(request.getTitle());
@@ -174,31 +215,35 @@ public class PostService {
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 = postRepository.findById(id)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
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 = postRepository.findById(id)
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
Post post = findOwnedPost(id);
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);
// Пост на модерации или отклонённый владелец не может сам перевести в 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);
@@ -211,6 +256,9 @@ public class PostService {
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);
}
@@ -236,6 +284,35 @@ public class PostService {
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 {
@@ -316,6 +393,12 @@ public class PostService {
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);
}
}