Merge pull request #37 from SlimusMinus/add-redis

added posts on feed and posts on map
This commit is contained in:
SlimusMinus
2026-09-29 01:04:39 +03:00
committed by GitHub
22 changed files with 639 additions and 44 deletions

View File

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

View File

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

View File

@@ -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<String, CachedPostPage> postCacheRedisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) {
return buildTemplate(connectionFactory, objectMapper, CachedPostPage.class);
}
@Bean
public RedisTemplate<String, CachedMapMarkers> feedRedisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) {
return buildTemplate(connectionFactory, objectMapper, CachedMapMarkers.class);
}
private static <T> RedisTemplate<String, T> buildTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper, Class<T> type) {
// Своя копия: изменения веб-настроек ObjectMapper не должны ломать формат кэша,
// а неизвестные поля в старых записях не должны валить десериализацию
ObjectMapper cacheMapper = objectMapper.copy().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
StringRedisSerializer keySerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<T> valueSerializer = new Jackson2JsonRedisSerializer<>(cacheMapper, type);
RedisTemplate<String, T> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
template.setHashKeySerializer(keySerializer);
template.setHashValueSerializer(valueSerializer);
return template;
}
@Bean
public RedisTemplate<String, List<MapMarkerDto>> mapRedisTemplate(RedisConnectionFactory connectionFactory, ObjectMapper objectMapper) {
ObjectMapper cacheMapper = objectMapper.copy()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
StringRedisSerializer keySerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<List<MapMarkerDto>> valueSerializer =
new Jackson2JsonRedisSerializer<>(cacheMapper,
objectMapper.getTypeFactory().constructCollectionType(List.class, MapMarkerDto.class));
RedisTemplate<String, List<MapMarkerDto>> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(keySerializer);
template.setValueSerializer(valueSerializer);
return template;
}
}

View File

@@ -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<MapMarkerDto> getMapMarkers(@RequestParam(required = false) String type, @RequestParam(required = false) String category) {
return mapService.findAllWithCoordinates(type, category);
public List<MapMarkerDto> 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);
}
}

View File

@@ -45,7 +45,7 @@ public class PostController {
return service.getFeed(search, pageable);
}
@GetMapping("/{id}")
@GetMapping("/{id:\\d+}")
public List<PostResponse> getById(@PathVariable Long id) {
log.info("get feed by id {}", id);
return service.getPostsByUserId(id);

View File

@@ -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);
}
}

View File

@@ -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<MapMarkerDto> content;
}

View File

@@ -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<PostResponse> content;
private int page;
private int size;
private long totalElements;
private int totalPages;
}

View File

@@ -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<Post, Long>, 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<MapMarkerDto> findMapMarkers(@Param("excludedStatuses") Collection<PostStatus> excludedStatuses);
@Query("SELECT p FROM Post p WHERE p.id = :postIds")
List<Long> findFavoritePostIds(List<Long> postIds);
List<MapMarkerDto> findMapMarkers(
@Param("visibleStatuses") Collection<PostStatus> 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);
}

View File

@@ -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<MapMarkerDto> findAllWithCoordinates(String type, String category) {
return postRepository.findMapMarkers(List.of(PostStatus.REJECTED, PostStatus.MODERATION));
public List<MapMarkerDto> 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<MapMarkerDto> cached = postCacheService.getMap(cacheKey);
if (cached != null) {
log.debug("Map cache HIT: {}", cacheKey);
return cached;
}
log.debug("Map cache MISS: {}", cacheKey);
List<MapMarkerDto> markers = postRepository.findMapMarkers(
PostVisibility.FEED_VISIBLE_STATUSES, minLat, maxLat, minLng, maxLng, type, category);
postCacheService.saveMap(cacheKey, markers, Duration.ofMinutes(5));
return markers;
}
}

View File

@@ -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);

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);
}
}

View File

@@ -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));
}
}

View File

@@ -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<String, CachedMapMarkers> 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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, CachedPostPage> feedRedisTemplate;
private final RedisTemplate<String, List<MapMarkerDto>> 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<MapMarkerDto> 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<MapMarkerDto> 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());
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -18,13 +18,6 @@ public class PostSpecification {
};
}
public static Specification<Post> 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<Post> cityOrDistrictLike(String query) {
if (!StringUtils.hasText(query)) {
return null;

View File

@@ -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<PostStatus> FEED_VISIBLE_STATUSES = EnumSet.of(PostStatus.ACTIVE, PostStatus.CLOSED);
private PostVisibility() {
}
}

View File

@@ -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
allowed-origins: http://localhost:5173,http://localhost:3000,http://192.168.1.76:3000

View File

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

View File

@@ -25,3 +25,4 @@ databaseChangeLog:
- 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 }
- include: { file: db/changelog/constraint/010-idx-post-lat-lng-index.yaml }