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'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
|
||||
@@ -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
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
if (!StringUtils.hasText(query)) {
|
||||
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:
|
||||
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
|
||||
@@ -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/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 }
|
||||
|
||||
Reference in New Issue
Block a user