Merge pull request #37 from SlimusMinus/add-redis
added posts on feed and posts on map
This commit is contained in:
@@ -64,6 +64,7 @@ dependencies {
|
|||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
|
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
|
|||||||
@@ -9,9 +9,22 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
||||||
redis:
|
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:
|
ports:
|
||||||
- "6379:6379"
|
- "127.0.0.1:6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "redis-cli", "ping" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: quay.io/minio/minio:RELEASE.2024-04-18T19-09-19Z
|
image: quay.io/minio/minio:RELEASE.2024-04-18T19-09-19Z
|
||||||
|
|||||||
63
src/main/java/com/krylov/refound/config/RedisConfig.java
Normal file
63
src/main/java/com/krylov/refound/config/RedisConfig.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ import com.krylov.refound.dto.MapMarkerDto;
|
|||||||
import com.krylov.refound.service.MapService;
|
import com.krylov.refound.service.MapService;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RequestMapping("/api/v1")
|
@RequestMapping("/api/v1")
|
||||||
@@ -17,8 +19,9 @@ public class MapController {
|
|||||||
private final MapService mapService;
|
private final MapService mapService;
|
||||||
|
|
||||||
@GetMapping("/posts/map")
|
@GetMapping("/posts/map")
|
||||||
public List<MapMarkerDto> getMapMarkers(@RequestParam(required = false) String type, @RequestParam(required = false) String 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) {
|
||||||
return mapService.findAllWithCoordinates(type, category);
|
log.info("getMapMarkers");
|
||||||
|
return mapService.findMarkers(minLat, maxLat, minLng, maxLng, type, category);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class PostController {
|
|||||||
return service.getFeed(search, pageable);
|
return service.getFeed(search, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id:\\d+}")
|
||||||
public List<PostResponse> getById(@PathVariable Long id) {
|
public List<PostResponse> getById(@PathVariable Long id) {
|
||||||
log.info("get feed by id {}", id);
|
log.info("get feed by id {}", id);
|
||||||
return service.getPostsByUserId(id);
|
return service.getPostsByUserId(id);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
14
src/main/java/com/krylov/refound/dto/CachedMapMarkers.java
Normal file
14
src/main/java/com/krylov/refound/dto/CachedMapMarkers.java
Normal 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;
|
||||||
|
}
|
||||||
20
src/main/java/com/krylov/refound/dto/CachedPostPage.java
Normal file
20
src/main/java/com/krylov/refound/dto/CachedPostPage.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package com.krylov.refound.repository;
|
|||||||
|
|
||||||
import com.krylov.refound.dto.MapMarkerDto;
|
import com.krylov.refound.dto.MapMarkerDto;
|
||||||
import com.krylov.refound.entity.Post;
|
import com.krylov.refound.entity.Post;
|
||||||
|
import com.krylov.refound.enums.PostCategory;
|
||||||
import com.krylov.refound.enums.PostStatus;
|
import com.krylov.refound.enums.PostStatus;
|
||||||
|
import com.krylov.refound.enums.PostType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -41,16 +43,22 @@ public interface PostRepository extends JpaRepository<Post, Long>, JpaSpecificat
|
|||||||
p.type,
|
p.type,
|
||||||
(select i.url from Image i
|
(select i.url from Image i
|
||||||
where i.post = p
|
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
|
from Post p
|
||||||
where p.status not in :excludedStatuses
|
where p.status in :visibleStatuses
|
||||||
and p.latitude is not null
|
and p.latitude between :minLat and :maxLat
|
||||||
and p.longitude is not null
|
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);
|
List<MapMarkerDto> findMapMarkers(
|
||||||
|
@Param("visibleStatuses") Collection<PostStatus> visibleStatuses,
|
||||||
@Query("SELECT p FROM Post p WHERE p.id = :postIds")
|
@Param("minLat") double minLat,
|
||||||
List<Long> findFavoritePostIds(List<Long> postIds);
|
@Param("maxLat") double maxLat,
|
||||||
|
@Param("minLng") double minLng,
|
||||||
|
@Param("maxLng") double maxLng,
|
||||||
|
@Param("type") PostType type,
|
||||||
|
@Param("category") PostCategory category);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,47 @@
|
|||||||
package com.krylov.refound.service;
|
package com.krylov.refound.service;
|
||||||
|
|
||||||
import com.krylov.refound.dto.MapMarkerDto;
|
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.repository.PostRepository;
|
||||||
|
import com.krylov.refound.service.redis.PostCacheKeyGenerator;
|
||||||
|
import com.krylov.refound.util.PostVisibility;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MapService {
|
public class MapService {
|
||||||
|
|
||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
|
private final PostCacheService postCacheService;
|
||||||
|
private final PostCacheKeyGenerator postCacheKeyGenerator;
|
||||||
|
|
||||||
public List<MapMarkerDto> findAllWithCoordinates(String type, String category) {
|
public List<MapMarkerDto> findMarkers(double minLat, double maxLat, double minLng, double maxLng,
|
||||||
return postRepository.findMapMarkers(List.of(PostStatus.REJECTED, PostStatus.MODERATION));
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.krylov.refound.entity.Image;
|
|||||||
import com.krylov.refound.entity.Post;
|
import com.krylov.refound.entity.Post;
|
||||||
import com.krylov.refound.enums.PostStatus;
|
import com.krylov.refound.enums.PostStatus;
|
||||||
import com.krylov.refound.repository.PostRepository;
|
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 com.krylov.refound.util.ByteArrayMultipartFile;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -28,6 +30,8 @@ public class PostModerationExecutorService {
|
|||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
private final ContentModerationFacade contentModerationFacade;
|
private final ContentModerationFacade contentModerationFacade;
|
||||||
private final FileStorageService fileStorageService;
|
private final FileStorageService fileStorageService;
|
||||||
|
private final PostCacheService postCacheService;
|
||||||
|
private final MapCacheService mapCacheService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void moderateOne(Long postId) {
|
public void moderateOne(Long postId) {
|
||||||
@@ -54,6 +58,11 @@ public class PostModerationExecutorService {
|
|||||||
postRepository.updateStatus(postId, PostStatus.ACTIVE);
|
postRepository.updateStatus(postId, PostStatus.ACTIVE);
|
||||||
log.info("Пост {} прошёл модерацию.", post.getId());
|
log.info("Пост {} прошёл модерацию.", post.getId());
|
||||||
|
|
||||||
|
// Пост впервые становится виден публике — и в ленте, и на карте.
|
||||||
|
// Инвалидируем оба кэша, иначе одобренный пост висит до TTL (2 минуты)
|
||||||
|
postCacheService.invalidatePosts();
|
||||||
|
mapCacheService.clearMapMarkersCache();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Пост {} не прошёл модерацию: {}", post.getId(), e.getMessage());
|
log.warn("Пост {} не прошёл модерацию: {}", post.getId(), e.getMessage());
|
||||||
postRepository.updateStatus(postId, PostStatus.REJECTED);
|
postRepository.updateStatus(postId, PostStatus.REJECTED);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.krylov.refound.service;
|
|||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.krylov.refound.dto.AddressInfo;
|
import com.krylov.refound.dto.AddressInfo;
|
||||||
|
import com.krylov.refound.dto.CachedPostPage;
|
||||||
import com.krylov.refound.dto.PostRequest;
|
import com.krylov.refound.dto.PostRequest;
|
||||||
import com.krylov.refound.dto.PostResponse;
|
import com.krylov.refound.dto.PostResponse;
|
||||||
import com.krylov.refound.entity.Image;
|
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.mapper.PostMapper;
|
||||||
import com.krylov.refound.repository.ImageRepository;
|
import com.krylov.refound.repository.ImageRepository;
|
||||||
import com.krylov.refound.repository.PostRepository;
|
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.PostSpecification;
|
||||||
|
import com.krylov.refound.util.PostVisibility;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.EnumSet;
|
import java.util.EnumSet;
|
||||||
@@ -25,6 +31,7 @@ import java.util.Set;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -38,8 +45,8 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class PostService {
|
public class PostService {
|
||||||
// Статусы, которые показываются в публичной ленте. MODERATION и REJECTED видны
|
// Статусы, которые показываются в публичной ленте. MODERATION и REJECTED видны
|
||||||
// только владельцу через getPostsByUserId, но не в общем фиде.
|
// только владельцу (getPostsByUserId, getPostOnMapById), но не в общем фиде.
|
||||||
private static final Set<PostStatus> FEED_VISIBLE_STATUSES = EnumSet.of(PostStatus.ACTIVE, PostStatus.CLOSED);
|
private static final Set<PostStatus> FEED_VISIBLE_STATUSES = PostVisibility.FEED_VISIBLE_STATUSES;
|
||||||
|
|
||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
private final PostMapper mapper;
|
private final PostMapper mapper;
|
||||||
@@ -49,6 +56,9 @@ public class PostService {
|
|||||||
private final FileStorageService fileStorageService;
|
private final FileStorageService fileStorageService;
|
||||||
private final StatisticsService statisticsService;
|
private final StatisticsService statisticsService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PostCacheService postCacheService;
|
||||||
|
private final PostCacheKeyGenerator postCacheKeyGenerator;
|
||||||
|
private final MapCacheService mapCacheService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PostRequest create(PostRequest request, List<MultipartFile> images) {
|
public PostRequest create(PostRequest request, List<MultipartFile> images) {
|
||||||
@@ -75,27 +85,58 @@ public class PostService {
|
|||||||
log.info("К посту id={} прикреплено {} изображений", post.getId(), uploaded);
|
log.info("К посту id={} прикреплено {} изображений", post.getId(), uploaded);
|
||||||
|
|
||||||
statisticsService.incrementCreated();
|
statisticsService.incrementCreated();
|
||||||
|
postCacheService.invalidatePosts();
|
||||||
|
mapCacheService.clearMapMarkersCache();
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<PostResponse> getFeed(String search, Pageable pageable) {
|
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)
|
Specification<Post> spec = PostSpecification.hasSearch(search)
|
||||||
.and((root, query, cb) ->
|
.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);
|
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). Если у вас уже есть контроллер,
|
||||||
* вызывающий старое имя getById, переименуйте вызов на getPostsByUserId.
|
* вызывающий старое имя getById, переименуйте вызов на getPostsByUserId.
|
||||||
|
* Владелец видит все свои посты (включая MODERATION и REJECTED), остальные — только
|
||||||
|
* опубликованные (ACTIVE и CLOSED).
|
||||||
*/
|
*/
|
||||||
public List<PostResponse> getPostsByUserId(Long userId) {
|
public List<PostResponse> getPostsByUserId(Long userId) {
|
||||||
|
boolean owner = isCurrentUser(userId);
|
||||||
|
|
||||||
List<Post> posts = postRepository.findByUserId(userId).stream()
|
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();
|
.toList();
|
||||||
|
|
||||||
if (posts.isEmpty()) {
|
if (posts.isEmpty()) {
|
||||||
@@ -136,8 +177,8 @@ public class PostService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PostResponse doUpdate(Long id, PostRequest request, List<MultipartFile> images, List<String> existingUrls) {
|
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.setStatus(PostStatus.MODERATION);
|
||||||
post.setTitle(request.getTitle());
|
post.setTitle(request.getTitle());
|
||||||
@@ -174,31 +215,35 @@ public class PostService {
|
|||||||
|
|
||||||
Post updated = postRepository.save(post);
|
Post updated = postRepository.save(post);
|
||||||
log.info("Обновлён пост id={}: удалено изображений={}, добавлено={}, статус={}", id, toRemove.size(), uploaded, updated.getStatus());
|
log.info("Обновлён пост id={}: удалено изображений={}, добавлено={}, статус={}", id, toRemove.size(), uploaded, updated.getStatus());
|
||||||
|
postCacheService.invalidatePosts();
|
||||||
|
mapCacheService.clearMapMarkersCache();
|
||||||
|
log.info("redis cache invalidated in update");
|
||||||
return mapper.toResponse(updated);
|
return mapper.toResponse(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id) {
|
public void delete(Long id) {
|
||||||
Post post = postRepository.findById(id)
|
Post post = findOwnedPost(id);
|
||||||
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
|
|
||||||
|
|
||||||
// Без этого файлы останутся в MinIO мусором после удаления объявления
|
// Без этого файлы останутся в MinIO мусором после удаления объявления
|
||||||
post.getImages().forEach(img -> deleteFileQuietly(img.getUrl()));
|
post.getImages().forEach(img -> deleteFileQuietly(img.getUrl()));
|
||||||
|
|
||||||
postRepository.delete(post);
|
postRepository.delete(post);
|
||||||
log.info("Удалён пост id={}, изображений={}", id, post.getImages().size());
|
log.info("Удалён пост id={}, изображений={}", id, post.getImages().size());
|
||||||
|
postCacheService.invalidatePosts();
|
||||||
|
mapCacheService.clearMapMarkersCache();
|
||||||
|
log.info("redis cache invalidated in delete");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PostResponse updateStatus(Long id, String statusString) {
|
public PostResponse updateStatus(Long id, String statusString) {
|
||||||
Post post = postRepository.findById(id)
|
Post post = findOwnedPost(id);
|
||||||
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
|
|
||||||
|
|
||||||
User currentUser = userService.getCurrentUser();
|
// Пост на модерации или отклонённый владелец не может сам перевести в ACTIVE:
|
||||||
if (!post.getUser().getId().equals(currentUser.getId())) {
|
// иначе можно обойти модерацию (edit -> MODERATION -> updateStatus(ACTIVE))
|
||||||
log.warn("Попытка сменить статус чужого поста: postId={}, userId={}", id, currentUser.getId());
|
if (!FEED_VISIBLE_STATUSES.contains(post.getStatus())) {
|
||||||
throw new ApiException(ErrorCode.FORBIDDEN, "Only the post owner can update status", HttpStatus.FORBIDDEN);
|
log.warn("Попытка сменить статус неопубликованного поста: postId={}, статус={}", id, post.getStatus());
|
||||||
|
throw new ApiException(ErrorCode.FORBIDDEN, "Post is not published yet", HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
PostStatus newStatus = parseOwnerSettableStatus(statusString);
|
PostStatus newStatus = parseOwnerSettableStatus(statusString);
|
||||||
@@ -211,6 +256,9 @@ public class PostService {
|
|||||||
post.setStatus(newStatus);
|
post.setStatus(newStatus);
|
||||||
Post updated = postRepository.save(post);
|
Post updated = postRepository.save(post);
|
||||||
log.info("Статус поста id={} изменён: {} -> {}", id, previousStatus, newStatus);
|
log.info("Статус поста id={} изменён: {} -> {}", id, previousStatus, newStatus);
|
||||||
|
postCacheService.invalidatePosts();
|
||||||
|
mapCacheService.clearMapMarkersCache();
|
||||||
|
log.info("redis cache invalidated in update status");
|
||||||
return mapper.toResponse(updated);
|
return mapper.toResponse(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +284,35 @@ public class PostService {
|
|||||||
return postRepository.findDistinctCities(FEED_VISIBLE_STATUSES);
|
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) {
|
private PostStatus parseOwnerSettableStatus(String statusString) {
|
||||||
PostStatus newStatus;
|
PostStatus newStatus;
|
||||||
try {
|
try {
|
||||||
@@ -316,6 +393,12 @@ public class PostService {
|
|||||||
Post post = postRepository.findById(postId)
|
Post post = postRepository.findById(postId)
|
||||||
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "Post not found", HttpStatus.NOT_FOUND));
|
.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);
|
return toResponse(post, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
public static Specification<Post> cityOrDistrictLike(String query) {
|
||||||
if (!StringUtils.hasText(query)) {
|
if (!StringUtils.hasText(query)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
15
src/main/java/com/krylov/refound/util/PostVisibility.java
Normal file
15
src/main/java/com/krylov/refound/util/PostVisibility.java
Normal 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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,14 @@ spring:
|
|||||||
|
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: localhost
|
host: ${REDIS_HOST:localhost}
|
||||||
port: 6379
|
port: 6379
|
||||||
|
password: ${REDIS_PASSWORD}
|
||||||
|
timeout: 500ms
|
||||||
|
connect-timeout: 500ms
|
||||||
|
web:
|
||||||
|
pageable:
|
||||||
|
max-page-size: 50
|
||||||
|
|
||||||
liquibase:
|
liquibase:
|
||||||
change-log: classpath:db/changelog/db.changelog-master.yaml
|
change-log: classpath:db/changelog/db.changelog-master.yaml
|
||||||
@@ -52,4 +58,4 @@ jwt:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
cors:
|
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
|
||||||
@@ -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;
|
||||||
@@ -25,3 +25,4 @@ databaseChangeLog:
|
|||||||
- include: { file: db/changelog/alter/012-advertising-media_key.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/alter/013-del-ad_id-click-impression.yaml }
|
||||||
- include: { file: db/changelog/create/011-create-fullscreen-ads.yaml }
|
- include: { file: db/changelog/create/011-create-fullscreen-ads.yaml }
|
||||||
|
- include: { file: db/changelog/constraint/010-idx-post-lat-lng-index.yaml }
|
||||||
|
|||||||
Reference in New Issue
Block a user