From 870739f45f9d4d7723e93d2fbfe882783a892b5d Mon Sep 17 00:00:00 2001 From: SlimusMinus Date: Tue, 29 Sep 2026 01:03:54 +0300 Subject: [PATCH] added posts on feed and posts on map --- build.gradle | 1 + docker-compose.yaml | 17 ++- .../krylov/refound/config/RedisConfig.java | 63 ++++++++++ .../refound/controller/MapController.java | 7 +- .../refound/controller/PostController.java | 2 +- .../refound/controller/TestController.java | 39 ++++++ .../krylov/refound/dto/CachedMapMarkers.java | 14 +++ .../krylov/refound/dto/CachedPostPage.java | 20 +++ .../refound/repository/PostRepository.java | 26 ++-- .../krylov/refound/service/MapService.java | 34 ++++- .../PostModerationExecutorService.java | 9 ++ .../krylov/refound/service/PostService.java | 117 +++++++++++++++--- .../service/redis/MapCacheKeyGenerator.java | 22 ++++ .../service/redis/MapCacheService.java | 77 ++++++++++++ .../service/redis/PostCacheKeyGenerator.java | 53 ++++++++ .../service/redis/PostCacheService.java | 102 +++++++++++++++ .../service/redis/RedisCacheKeyUtil.java | 34 +++++ .../refound/util/PostSpecification.java | 7 -- .../krylov/refound/util/PostVisibility.java | 15 +++ src/main/resources/application.yaml | 10 +- .../010-idx-post-lat-lng-index.yaml | 11 ++ .../db/changelog/db.changelog-master.yaml | 3 +- 22 files changed, 639 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/krylov/refound/config/RedisConfig.java create mode 100644 src/main/java/com/krylov/refound/controller/TestController.java create mode 100644 src/main/java/com/krylov/refound/dto/CachedMapMarkers.java create mode 100644 src/main/java/com/krylov/refound/dto/CachedPostPage.java create mode 100644 src/main/java/com/krylov/refound/service/redis/MapCacheKeyGenerator.java create mode 100644 src/main/java/com/krylov/refound/service/redis/MapCacheService.java create mode 100644 src/main/java/com/krylov/refound/service/redis/PostCacheKeyGenerator.java create mode 100644 src/main/java/com/krylov/refound/service/redis/PostCacheService.java create mode 100644 src/main/java/com/krylov/refound/service/redis/RedisCacheKeyUtil.java create mode 100644 src/main/java/com/krylov/refound/util/PostVisibility.java create mode 100644 src/main/resources/db/changelog/constraint/010-idx-post-lat-lng-index.yaml diff --git a/build.gradle b/build.gradle index 8255f90..d16147b 100644 --- a/build.gradle +++ b/build.gradle @@ -64,6 +64,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } tasks.named('test') { diff --git a/docker-compose.yaml b/docker-compose.yaml index 515a399..69bbac1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,9 +9,22 @@ services: - "5432:5432" redis: - image: redis:7 + image: redis:7-alpine + command: > + redis-server + --requirepass ${REDIS_PASSWORD:?задайте REDIS_PASSWORD в .env} + --maxmemory 256mb + --maxmemory-policy volatile-lru + --save "" --appendonly no + environment: + REDISCLI_AUTH: ${REDIS_PASSWORD} # чтобы healthcheck не светил пароль в аргументах ports: - - "6379:6379" + - "127.0.0.1:6379:6379" + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 10s + timeout: 3s + retries: 5 minio: image: quay.io/minio/minio:RELEASE.2024-04-18T19-09-19Z diff --git a/src/main/java/com/krylov/refound/config/RedisConfig.java b/src/main/java/com/krylov/refound/config/RedisConfig.java new file mode 100644 index 0000000..833bed9 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/RedisConfig.java @@ -0,0 +1,63 @@ +package com.krylov.refound.config; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.krylov.refound.dto.CachedMapMarkers; +import com.krylov.refound.dto.CachedPostPage; +import com.krylov.refound.dto.MapMarkerDto; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate postCacheRedisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) { + return buildTemplate(connectionFactory, objectMapper, CachedPostPage.class); + } + + @Bean + public RedisTemplate feedRedisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) { + return buildTemplate(connectionFactory, objectMapper, CachedMapMarkers.class); + } + + private static RedisTemplate buildTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper, Class type) { + // Своя копия: изменения веб-настроек ObjectMapper не должны ломать формат кэша, + // а неизвестные поля в старых записях не должны валить десериализацию + ObjectMapper cacheMapper = objectMapper.copy().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + StringRedisSerializer keySerializer = new StringRedisSerializer(); + Jackson2JsonRedisSerializer valueSerializer = new Jackson2JsonRedisSerializer<>(cacheMapper, type); + + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(keySerializer); + template.setValueSerializer(valueSerializer); + template.setHashKeySerializer(keySerializer); + template.setHashValueSerializer(valueSerializer); + return template; + } + + @Bean + public RedisTemplate> mapRedisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) { + + ObjectMapper cacheMapper = objectMapper.copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + StringRedisSerializer keySerializer = new StringRedisSerializer(); + Jackson2JsonRedisSerializer> valueSerializer = + new Jackson2JsonRedisSerializer<>(cacheMapper, + objectMapper.getTypeFactory().constructCollectionType(List.class, MapMarkerDto.class)); + + RedisTemplate> template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(keySerializer); + template.setValueSerializer(valueSerializer); + return template; + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/controller/MapController.java b/src/main/java/com/krylov/refound/controller/MapController.java index 8c04039..b7ec178 100644 --- a/src/main/java/com/krylov/refound/controller/MapController.java +++ b/src/main/java/com/krylov/refound/controller/MapController.java @@ -4,11 +4,13 @@ import com.krylov.refound.dto.MapMarkerDto; import com.krylov.refound.service.MapService; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Slf4j @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") @@ -17,8 +19,9 @@ public class MapController { private final MapService mapService; @GetMapping("/posts/map") - public List getMapMarkers(@RequestParam(required = false) String type, @RequestParam(required = false) String category) { - return mapService.findAllWithCoordinates(type, category); + public List getMapMarkers(@RequestParam double minLat, @RequestParam double maxLat, @RequestParam double minLng, @RequestParam double maxLng, @RequestParam(required = false) String type, @RequestParam(required = false) String category) { + log.info("getMapMarkers"); + return mapService.findMarkers(minLat, maxLat, minLng, maxLng, type, category); } } diff --git a/src/main/java/com/krylov/refound/controller/PostController.java b/src/main/java/com/krylov/refound/controller/PostController.java index e0d9248..e668761 100644 --- a/src/main/java/com/krylov/refound/controller/PostController.java +++ b/src/main/java/com/krylov/refound/controller/PostController.java @@ -45,7 +45,7 @@ public class PostController { return service.getFeed(search, pageable); } - @GetMapping("/{id}") + @GetMapping("/{id:\\d+}") public List getById(@PathVariable Long id) { log.info("get feed by id {}", id); return service.getPostsByUserId(id); diff --git a/src/main/java/com/krylov/refound/controller/TestController.java b/src/main/java/com/krylov/refound/controller/TestController.java new file mode 100644 index 0000000..adcfd7e --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/TestController.java @@ -0,0 +1,39 @@ +package com.krylov.refound.controller; + + +import com.krylov.refound.ai.client.TextModerationClient; +import com.krylov.refound.ai.dto.ModerationResponse; +import com.krylov.refound.service.SchedulerService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/test") +@RequiredArgsConstructor +@Slf4j +public class TestController { + + private final SchedulerService schedulerService; + private final TextModerationClient client; + + @GetMapping("/test1") + public Integer tested1() { + + schedulerService.deleteExpiredPosts(); + return 200; + } + + @PostMapping("/test-ai") + public ModerationResponse test(@RequestParam String text){ + + return client.moderate(text); + + } + + +} diff --git a/src/main/java/com/krylov/refound/dto/CachedMapMarkers.java b/src/main/java/com/krylov/refound/dto/CachedMapMarkers.java new file mode 100644 index 0000000..77e188b --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/CachedMapMarkers.java @@ -0,0 +1,14 @@ +package com.krylov.refound.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CachedMapMarkers { + + private List content; +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/dto/CachedPostPage.java b/src/main/java/com/krylov/refound/dto/CachedPostPage.java new file mode 100644 index 0000000..c3ed31d --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/CachedPostPage.java @@ -0,0 +1,20 @@ +package com.krylov.refound.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CachedPostPage { + + private List content; + + private int page; + private int size; + private long totalElements; + private int totalPages; +} diff --git a/src/main/java/com/krylov/refound/repository/PostRepository.java b/src/main/java/com/krylov/refound/repository/PostRepository.java index ecc60a9..583b44c 100644 --- a/src/main/java/com/krylov/refound/repository/PostRepository.java +++ b/src/main/java/com/krylov/refound/repository/PostRepository.java @@ -2,7 +2,9 @@ package com.krylov.refound.repository; import com.krylov.refound.dto.MapMarkerDto; import com.krylov.refound.entity.Post; +import com.krylov.refound.enums.PostCategory; import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.enums.PostType; import java.time.LocalDateTime; import java.util.Collection; import java.util.List; @@ -41,16 +43,22 @@ public interface PostRepository extends JpaRepository, JpaSpecificat p.type, (select i.url from Image i where i.post = p - and i.id = (select min(i2.id) from Image i2 where i2.post = p)) + order by i.id asc + limit 1) ) from Post p - where p.status not in :excludedStatuses - and p.latitude is not null - and p.longitude is not null + where p.status in :visibleStatuses + and p.latitude between :minLat and :maxLat + and p.longitude between :minLng and :maxLng + and (:type is null or p.type = :type) + and (:category is null or p.category = :category) """) - List findMapMarkers(@Param("excludedStatuses") Collection excludedStatuses); - - @Query("SELECT p FROM Post p WHERE p.id = :postIds") - List findFavoritePostIds(List postIds); - + List findMapMarkers( + @Param("visibleStatuses") Collection visibleStatuses, + @Param("minLat") double minLat, + @Param("maxLat") double maxLat, + @Param("minLng") double minLng, + @Param("maxLng") double maxLng, + @Param("type") PostType type, + @Param("category") PostCategory category); } \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/MapService.java b/src/main/java/com/krylov/refound/service/MapService.java index 56f1e34..9ca586a 100644 --- a/src/main/java/com/krylov/refound/service/MapService.java +++ b/src/main/java/com/krylov/refound/service/MapService.java @@ -1,19 +1,47 @@ package com.krylov.refound.service; import com.krylov.refound.dto.MapMarkerDto; -import com.krylov.refound.enums.PostStatus; +import com.krylov.refound.enums.PostCategory; +import com.krylov.refound.service.redis.PostCacheService; +import com.krylov.refound.enums.PostType; import com.krylov.refound.repository.PostRepository; +import com.krylov.refound.service.redis.PostCacheKeyGenerator; +import com.krylov.refound.util.PostVisibility; +import java.time.Duration; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; @Service +@Slf4j @RequiredArgsConstructor public class MapService { private final PostRepository postRepository; + private final PostCacheService postCacheService; + private final PostCacheKeyGenerator postCacheKeyGenerator; - public List findAllWithCoordinates(String type, String category) { - return postRepository.findMapMarkers(List.of(PostStatus.REJECTED, PostStatus.MODERATION)); + public List findMarkers(double minLat, double maxLat, double minLng, double maxLng, + String typeParam, String categoryParam) { + + PostType type = StringUtils.hasText(typeParam) ? PostType.from(typeParam) : null; + PostCategory category = StringUtils.hasText(categoryParam) ? PostCategory.fromDisplayName(categoryParam) : null; + + String cacheKey = postCacheKeyGenerator.mapKey(minLat, maxLat, minLng, maxLng, type, category); + + List cached = postCacheService.getMap(cacheKey); + if (cached != null) { + log.debug("Map cache HIT: {}", cacheKey); + return cached; + } + log.debug("Map cache MISS: {}", cacheKey); + + List markers = postRepository.findMapMarkers( + PostVisibility.FEED_VISIBLE_STATUSES, minLat, maxLat, minLng, maxLng, type, category); + + postCacheService.saveMap(cacheKey, markers, Duration.ofMinutes(5)); + return markers; } } diff --git a/src/main/java/com/krylov/refound/service/PostModerationExecutorService.java b/src/main/java/com/krylov/refound/service/PostModerationExecutorService.java index 6d58440..8fde5c9 100644 --- a/src/main/java/com/krylov/refound/service/PostModerationExecutorService.java +++ b/src/main/java/com/krylov/refound/service/PostModerationExecutorService.java @@ -6,6 +6,8 @@ import com.krylov.refound.entity.Image; import com.krylov.refound.entity.Post; import com.krylov.refound.enums.PostStatus; import com.krylov.refound.repository.PostRepository; +import com.krylov.refound.service.redis.MapCacheService; +import com.krylov.refound.service.redis.PostCacheService; import com.krylov.refound.util.ByteArrayMultipartFile; import java.util.List; import lombok.RequiredArgsConstructor; @@ -28,6 +30,8 @@ public class PostModerationExecutorService { private final PostRepository postRepository; private final ContentModerationFacade contentModerationFacade; private final FileStorageService fileStorageService; + private final PostCacheService postCacheService; + private final MapCacheService mapCacheService; @Transactional public void moderateOne(Long postId) { @@ -54,6 +58,11 @@ public class PostModerationExecutorService { postRepository.updateStatus(postId, PostStatus.ACTIVE); log.info("Пост {} прошёл модерацию.", post.getId()); + // Пост впервые становится виден публике — и в ленте, и на карте. + // Инвалидируем оба кэша, иначе одобренный пост висит до TTL (2 минуты) + postCacheService.invalidatePosts(); + mapCacheService.clearMapMarkersCache(); + } catch (Exception e) { log.warn("Пост {} не прошёл модерацию: {}", post.getId(), e.getMessage()); postRepository.updateStatus(postId, PostStatus.REJECTED); diff --git a/src/main/java/com/krylov/refound/service/PostService.java b/src/main/java/com/krylov/refound/service/PostService.java index 8b0182b..a376a8c 100644 --- a/src/main/java/com/krylov/refound/service/PostService.java +++ b/src/main/java/com/krylov/refound/service/PostService.java @@ -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 FEED_VISIBLE_STATUSES = EnumSet.of(PostStatus.ACTIVE, PostStatus.CLOSED); + // только владельцу (getPostsByUserId, getPostOnMapById), но не в общем фиде. + private static final Set 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 images) { @@ -75,27 +85,58 @@ public class PostService { log.info("К посту id={} прикреплено {} изображений", post.getId(), uploaded); statisticsService.incrementCreated(); + postCacheService.invalidatePosts(); + mapCacheService.clearMapMarkersCache(); return request; } public Page 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 spec = PostSpecification.hasSearch(search) .and((root, query, cb) -> - root.get("status").in(FEED_VISIBLE_STATUSES)); + root.get("status").in(FEED_VISIBLE_STATUSES) + ); Page postsPage = postRepository.findAll(spec, pageable); - return postsPage.map(post -> toResponse(post, true)); + Page 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 getPostsByUserId(Long userId) { + boolean owner = isCurrentUser(userId); + List 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 images, List 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); } } \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/redis/MapCacheKeyGenerator.java b/src/main/java/com/krylov/refound/service/redis/MapCacheKeyGenerator.java new file mode 100644 index 0000000..d206c7d --- /dev/null +++ b/src/main/java/com/krylov/refound/service/redis/MapCacheKeyGenerator.java @@ -0,0 +1,22 @@ +package com.krylov.refound.service.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MapCacheKeyGenerator { + + private static final String PREFIX = "maps:markers:v"; + + private final MapCacheService mapCacheService; + + public String markersKey(String type, String category) { + // Версия читается здесь, то есть в самом начале getMapMarkers, до запроса в БД + String version = mapCacheService.currentMapVersion(); + + return PREFIX + version + + ":type=" + RedisCacheKeyUtil.sha256(RedisCacheKeyUtil.normalize(type)) + + ":category=" + RedisCacheKeyUtil.sha256(RedisCacheKeyUtil.normalize(category)); + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/redis/MapCacheService.java b/src/main/java/com/krylov/refound/service/redis/MapCacheService.java new file mode 100644 index 0000000..bd8f331 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/redis/MapCacheService.java @@ -0,0 +1,77 @@ +package com.krylov.refound.service.redis; + +import com.krylov.refound.dto.CachedMapMarkers; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MapCacheService { + + // Ключ версии без TTL. Redis должен работать с maxmemory-policy volatile-lru, + // чтобы этот ключ не вытеснялся + private static final String VERSION_KEY = "maps:markers:version"; + + private final RedisTemplate redisTemplate; + private final StringRedisTemplate stringRedisTemplate; + + public CachedMapMarkers getMarkers(String key) { + try { + return redisTemplate.opsForValue().get(key); + } catch (Exception e) { + // Кэш best-effort: любая проблема (Redis недоступен, битый JSON) = miss + log.warn("Не удалось прочитать метки карты из Redis, key={}: {}", key, e.toString()); + return null; + } + } + + public void saveMarkers(String key, CachedMapMarkers value, Duration ttl) { + try { + redisTemplate.opsForValue().set(key, value, ttl); + } catch (Exception e) { + log.warn("Не удалось сохранить метки карты в Redis, key={}: {}", key, e.toString()); + } + } + + /** Текущая версия меток карты. Должна читаться ДО запроса в БД. */ + public String currentMapVersion() { + try { + String version = stringRedisTemplate.opsForValue().get(VERSION_KEY); + return version == null ? "0" : version; + } catch (Exception e) { + log.warn("Не удалось прочитать версию map cache: {}", e.toString()); + return "0"; + } + } + + /** Инвалидирует метки карты после коммита текущей транзакции (или сразу, если транзакции нет). */ + public void clearMapMarkersCache() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + bumpMapVersion(); + } + }); + } else { + bumpMapVersion(); + } + } + + private void bumpMapVersion() { + try { + Long version = stringRedisTemplate.opsForValue().increment(VERSION_KEY); + log.info("Map cache инвалидирован, версия={}", version); + } catch (Exception e) { + // Устаревшие записи доживут до TTL (2 минуты) + log.warn("Не удалось инвалидировать map cache: {}", e.toString()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/redis/PostCacheKeyGenerator.java b/src/main/java/com/krylov/refound/service/redis/PostCacheKeyGenerator.java new file mode 100644 index 0000000..f159946 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/redis/PostCacheKeyGenerator.java @@ -0,0 +1,53 @@ +package com.krylov.refound.service.redis; + +import com.krylov.refound.enums.PostCategory; +import com.krylov.refound.enums.PostType; +import com.krylov.refound.service.redis.PostCacheService; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PostCacheKeyGenerator { + + private static final String PREFIX = "posts:feed:v"; + private static final String MAP_PREFIX = "posts:map"; + private static final double GRID_STEP = 0.01; + + private final PostCacheService postCacheService; + + public String feedKey(String search, Pageable pageable) { + // Версия читается здесь, то есть в самом начале getFeed, до запроса в БД + String version = postCacheService.currentVersion(); + + String sort = pageable.getSort() + .stream() + .map(order -> order.getProperty() + ":" + order.getDirection()) + .collect(Collectors.joining(",")); + + return PREFIX + version + + ":search=" + RedisCacheKeyUtil.sha256(RedisCacheKeyUtil.normalize(search)) + + ":page=" + pageable.getPageNumber() + + ":size=" + pageable.getPageSize() + + ":sort=" + sort; + } + + public String mapKey(double minLat, double maxLat, double minLng, double maxLng, + PostType type, PostCategory category) { + + String version = postCacheService.currentVersion(); + + return MAP_PREFIX + ":v" + version + + ":bbox=" + snap(minLat) + "," + snap(maxLat) + "," + snap(minLng) + "," + snap(maxLng) + + ":type=" + (type == null ? "" : type) + + ":category=" + (category == null ? "" : category); + } + + private static double snap(double value) { + // Округление до сетки, чтобы близкие viewport-ы карты попадали в один и тот же + // кэш-ключ, а не создавали новый на каждый пиксель прокрутки + return Math.round(value / GRID_STEP) * GRID_STEP; + } +} diff --git a/src/main/java/com/krylov/refound/service/redis/PostCacheService.java b/src/main/java/com/krylov/refound/service/redis/PostCacheService.java new file mode 100644 index 0000000..b084c87 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/redis/PostCacheService.java @@ -0,0 +1,102 @@ +package com.krylov.refound.service.redis; + +import com.krylov.refound.dto.CachedPostPage; +import com.krylov.refound.dto.MapMarkerDto; +import java.time.Duration; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PostCacheService { + + // Общая версия ВСЕХ данных posts. От неё зависят и лента, и карта — + // любое изменение поста должно инвалидировать оба представления разом. + // Ключ без TTL. Redis должен работать с maxmemory-policy volatile-lru, + // чтобы этот ключ не вытеснялся + private static final String VERSION_KEY = "posts:version"; + + private final RedisTemplate feedRedisTemplate; + private final RedisTemplate> mapRedisTemplate; + private final StringRedisTemplate stringRedisTemplate; + + public CachedPostPage getFeed(String key) { + try { + return feedRedisTemplate.opsForValue().get(key); + } catch (Exception e) { + // Кэш best-effort: любая проблема (Redis недоступен, битый JSON) = miss + log.warn("Не удалось прочитать feed из Redis, key={}: {}", key, e.toString()); + return null; + } + } + + public void saveFeed(String key, CachedPostPage value, Duration ttl) { + try { + feedRedisTemplate.opsForValue().set(key, value, ttl); + } catch (Exception e) { + log.warn("Не удалось сохранить feed в Redis, key={}: {}", key, e.toString()); + } + } + + public List getMap(String key) { + try { + return mapRedisTemplate.opsForValue().get(key); + } catch (Exception e) { + log.warn("Не удалось прочитать map из Redis, key={}: {}", key, e.toString()); + return null; + } + } + + public void saveMap(String key, List value, Duration ttl) { + try { + mapRedisTemplate.opsForValue().set(key, value, ttl); + } catch (Exception e) { + log.warn("Не удалось сохранить map в Redis, key={}: {}", key, e.toString()); + } + } + + /** Текущая версия данных posts. Должна читаться ДО запроса в БД, в обоих сервисах. */ + public String currentVersion() { + try { + String version = stringRedisTemplate.opsForValue().get(VERSION_KEY); + return version == null ? "0" : version; + } catch (Exception e) { + log.warn("Не удалось прочитать версию posts cache: {}", e.toString()); + return "0"; + } + } + + /** + * Инвалидирует ВСЕ производные кэши (ленту и карту) после коммита текущей транзакции + * (или сразу, если транзакции нет). + */ + public void invalidatePosts() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + bumpVersion(); + } + }); + } else { + bumpVersion(); + } + } + + private void bumpVersion() { + try { + Long version = stringRedisTemplate.opsForValue().increment(VERSION_KEY); + log.info("Posts cache инвалидирован (лента + карта), версия={}", version); + } catch (Exception e) { + // Устаревшие записи доживут до TTL + log.warn("Не удалось инвалидировать posts cache: {}", e.toString()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/redis/RedisCacheKeyUtil.java b/src/main/java/com/krylov/refound/service/redis/RedisCacheKeyUtil.java new file mode 100644 index 0000000..db6ec64 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/redis/RedisCacheKeyUtil.java @@ -0,0 +1,34 @@ +package com.krylov.refound.service.redis; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** Утилиты для построения Redis-ключей. */ +public final class RedisCacheKeyUtil { + + private RedisCacheKeyUtil() { + } + + /** Приводит пользовательский фильтр к стабильному виду для ключа. */ + public static String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(); + } + + /** + * SHA-256 значение, чтобы встраивать произвольный ввод пользователя в ключ. + * Пустая строка -> "", чтобы ключи без фильтра оставались короткими и читаемыми. + */ + public static String sha256(String value) { + if (value.isEmpty()) { + return ""; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} \ 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 2faa81d..441a1cb 100644 --- a/src/main/java/com/krylov/refound/util/PostSpecification.java +++ b/src/main/java/com/krylov/refound/util/PostSpecification.java @@ -18,13 +18,6 @@ public class PostSpecification { }; } - public static Specification hasType(String type) { - return (root, query, cb) -> { - if (type == null || type.isBlank()) return cb.conjunction(); - return cb.equal(cb.lower(root.get("type")), type.toLowerCase()); - }; - } - public static Specification cityOrDistrictLike(String query) { if (!StringUtils.hasText(query)) { return null; diff --git a/src/main/java/com/krylov/refound/util/PostVisibility.java b/src/main/java/com/krylov/refound/util/PostVisibility.java new file mode 100644 index 0000000..79e91f4 --- /dev/null +++ b/src/main/java/com/krylov/refound/util/PostVisibility.java @@ -0,0 +1,15 @@ +package com.krylov.refound.util; + +import com.krylov.refound.enums.PostStatus; +import java.util.EnumSet; +import java.util.Set; + +public final class PostVisibility { + + // Статусы, видимые всем: в публичной ленте и на карте. MODERATION и REJECTED + // видны только владельцу поста (getPostsByUserId, getPostOnMapById в PostService) + public static final Set FEED_VISIBLE_STATUSES = EnumSet.of(PostStatus.ACTIVE, PostStatus.CLOSED); + + private PostVisibility() { + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 0fdfa71..6c585d9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -23,8 +23,14 @@ spring: data: redis: - host: localhost + host: ${REDIS_HOST:localhost} port: 6379 + password: ${REDIS_PASSWORD} + timeout: 500ms + connect-timeout: 500ms + web: + pageable: + max-page-size: 50 liquibase: change-log: classpath:db/changelog/db.changelog-master.yaml @@ -52,4 +58,4 @@ jwt: app: cors: - allowed-origins: http://localhost:5173,http://localhost:3000,http://192.168.1.75:3000 \ No newline at end of file + allowed-origins: http://localhost:5173,http://localhost:3000,http://192.168.1.76:3000 \ No newline at end of file diff --git a/src/main/resources/db/changelog/constraint/010-idx-post-lat-lng-index.yaml b/src/main/resources/db/changelog/constraint/010-idx-post-lat-lng-index.yaml new file mode 100644 index 0000000..2522304 --- /dev/null +++ b/src/main/resources/db/changelog/constraint/010-idx-post-lat-lng-index.yaml @@ -0,0 +1,11 @@ +databaseChangeLog: + - changeSet: + id: 2026-09-28-01-create-post-lat-lng-index + author: a.krylov + changes: + - sql: + sql: > + CREATE INDEX idx_post_lat_lng + ON posts (latitude, longitude) + WHERE latitude IS NOT NULL + AND longitude IS NOT NULL; \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index bfe7db1..2927e01 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -24,4 +24,5 @@ databaseChangeLog: - include: { file: db/changelog/add/016-add-sessionid-click.yaml } - include: { file: db/changelog/alter/012-advertising-media_key.yaml } - include: { file: db/changelog/alter/013-del-ad_id-click-impression.yaml } - - include: { file: db/changelog/create/011-create-fullscreen-ads.yaml } \ No newline at end of file + - include: { file: db/changelog/create/011-create-fullscreen-ads.yaml } + - include: { file: db/changelog/constraint/010-idx-post-lat-lng-index.yaml }