Compare commits

...

10 Commits

Author SHA1 Message Date
SlimusMinus
efdae92787 Merge pull request #41 from SlimusMinus/add-reject_post
added EP rejected posts for admin
2026-10-06 02:09:21 +03:00
SlimusMinus
1a7b96e8a6 added EP rejected posts for admin 2026-10-06 02:08:28 +03:00
SlimusMinus
7adc4f28f1 Merge pull request #40 from SlimusMinus/add-lan_lat
added latitude and longitude in PostResponse
2026-10-06 01:22:58 +03:00
SlimusMinus
e53c5e3ba7 added latitude and longitude in PostResponse 2026-10-06 01:21:56 +03:00
SlimusMinus
59872436b9 Merge pull request #39 from SlimusMinus/fix-admin_tools
added all edit posts for admin
2026-10-02 01:54:56 +03:00
SlimusMinus
bffd259c25 added all edit posts for admin 2026-10-02 01:54:19 +03:00
SlimusMinus
c8a3cca059 Merge pull request #38 from SlimusMinus/fix-25_jdk
changed jdk on 25 springBoot on 3.5.6 and fix code
2026-09-30 00:27:58 +03:00
SlimusMinus
015b68d96d changed jdk on 25 springBoot on 3.5.6 and fix code 2026-09-30 00:27:10 +03:00
SlimusMinus
1efb67824f Merge pull request #37 from SlimusMinus/add-redis
added posts on feed and posts on map
2026-09-29 01:04:39 +03:00
SlimusMinus
870739f45f added posts on feed and posts on map 2026-09-29 01:03:54 +03:00
43 changed files with 3466 additions and 116 deletions

11
.gitignore vendored
View File

@@ -5,6 +5,17 @@ build/
!**/src/main/**/build/
!**/src/test/**/build/
### Секреты ###
# .env содержит реальные ключи MinIO, пароль Redis и секрет JWT — в репозиторий ему нельзя.
.env
.env.*
!.env.example
### Локальные данные ###
# том MinIO, создаётся автоматически при первом запуске
/data/
/minio-data/
### STS ###
.apt_generated
.classpath

1565
README.MD Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.5'
id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7'
}
@@ -10,7 +10,7 @@ description = 'ReFound'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(25)
}
}
@@ -28,6 +28,12 @@ dependencyManagement {
imports {
mavenBom 'io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1'
}
dependencies {
// ByteBuddy из BOM Spring Boot 3.5.6 не поддерживает class-файлы Java 25:
// без обновления Mockito падает на создании любого мока.
dependency 'net.bytebuddy:byte-buddy:1.17.6'
dependency 'net.bytebuddy:byte-buddy-agent:1.17.6'
}
}
dependencies {
@@ -57,13 +63,14 @@ dependencies {
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
compileOnly 'org.projectlombok:lombok:1.18.42'
annotationProcessor 'org.projectlombok:lombok:1.18.42'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
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

@@ -34,10 +34,12 @@ public class FileStorageController {
return fileStorageService.downloadFile(key);
}
@DeleteMapping("/{objectName}")
@DeleteMapping("/{*objectName}")
public ResponseEntity<Void> deleteFile(@PathVariable String objectName) {
log.info("deleteFile {}", objectName);
fileStorageService.deleteFile(objectName);
// objectName может прийти как "/avatars/cf80aebe-...jpg" — обрежем ведущий слэш
String key = objectName.startsWith("/") ? objectName.substring(1) : objectName;
fileStorageService.deleteFileAsCurrentUser(key);
return ResponseEntity.noContent().build();
}
}

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,41 @@
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.security.access.prepost.PreAuthorize;
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
@PreAuthorize("hasRole('ADMIN')")
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,106 @@
package com.krylov.refound.controller.admin;
import com.krylov.refound.dto.admin.AdminPostResponse;
import com.krylov.refound.dto.admin.AdminPostStatusUpdateRequest;
import com.krylov.refound.service.admin.AdminPostService;
import jakarta.validation.Valid;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* Админская лента объявлений. Доступ закрыт дважды: SecurityConfig требует ADMIN на
* /api/v1/admin/** и каждый метод дополнительно защищён @PreAuthorize.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/admin/posts")
@RequiredArgsConstructor
public class PostAdminController {
private final AdminPostService adminPostService;
/** Основная лента: только ACTIVE и CLOSED. */
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Page<AdminPostResponse>> getPosts(
@RequestParam(required = false) String search,
@RequestParam(required = false) String type,
@RequestParam(required = false) String status,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
log.info("getPosts search={} type={} status={} page={}", search, type, status, pageable.getPageNumber());
return ResponseEntity.ok(adminPostService.getPosts(search, type, status, pageable));
}
/** Отклонённые объявления: те же фильтры, но статус зафиксирован на REJECTED. */
@GetMapping("/rejected")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Page<AdminPostResponse>> getRejectedPosts(
@RequestParam(required = false) String search,
@RequestParam(required = false) String type,
@RequestParam(required = false) String status,
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable
) {
log.info("getRejectedPosts search={} type={} status={} page={}", search, type, status, pageable.getPageNumber());
return ResponseEntity.ok(adminPostService.getRejectedPosts(search, type, status, pageable));
}
/** Multipart-поля те же, что у пользовательского PUT /api/v1/posts/{id}. */
@PutMapping(value = "/{postId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<AdminPostResponse> updatePost(
@PathVariable Long postId,
@RequestParam String type,
@RequestParam String title,
@RequestParam(required = false) String address,
@RequestParam(required = false) Double latitude,
@RequestParam(required = false) Double longitude,
@RequestParam(required = false) String description,
@RequestParam(required = false) String category,
@RequestParam(required = false) String phone,
@RequestParam(required = false) Boolean reward,
@RequestParam(required = false) String rewardText,
@RequestParam(value = "images", required = false) List<MultipartFile> images,
@RequestParam(value = "existingImages", required = false) String existingImagesJson
) {
log.info("updatePost {} by admin", postId);
return ResponseEntity.ok(adminPostService.updatePost(postId, type, title, address,
latitude, longitude, description, category, phone, reward, rewardText, images, existingImagesJson));
}
@DeleteMapping("/{postId}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deletePost(@PathVariable Long postId) {
log.info("deletePost {} by admin", postId);
adminPostService.deletePost(postId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
@PatchMapping(value = "/{postId}/status", consumes = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<AdminPostResponse> updateStatus(@PathVariable Long postId, @Valid @RequestBody AdminPostStatusUpdateRequest request) {
log.info("updateStatus {} -> {}", postId, request.status());
return ResponseEntity.ok(adminPostService.updateStatus(postId, request.status()));
}
}

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

@@ -16,6 +16,11 @@ public class PostResponse {
private String category;
private String city;
private String district;
// Координаты нужны фронту, чтобы кнопка «место на карте» открывала точку
// объявления, а не общий вид карты. Без этих полей /detail/{id} отдавал
// null, и MapView падал на дефолтный центр.
private Double latitude;
private Double longitude;
private PostType type;
private PostStatus status;
private LocalDateTime createdAt;

View File

@@ -0,0 +1,43 @@
package com.krylov.refound.dto.admin;
import com.krylov.refound.enums.PostStatus;
import com.krylov.refound.enums.PostType;
import java.time.LocalDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Объявление в админской ленте: публичные поля {@code PostResponse} плюс данные владельца,
* по которым администратор ищет и фильтрует объявления.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AdminPostResponse {
private Long id;
private PostType type;
private PostStatus status;
private String title;
private String description;
private String category;
private String city;
private String district;
private Double latitude;
private Double longitude;
private String phone;
private Boolean reward;
private String rewardText;
private LocalDateTime createdAt;
private List<String> images;
private Long ownerId;
private String ownerLogin;
private String ownerName;
private String ownerFirstName;
private String ownerLastName;
}

View File

@@ -0,0 +1,12 @@
package com.krylov.refound.dto.admin;
/**
* Статус приходит строкой, а не enum-ом: {@code PostStatus} без {@code @JsonCreator} при
* неверном значении даёт {@code HttpMessageNotReadableException}, который в этом проекте
* превращается в 500. Разбор строки в сервисе возвращает понятный 400.
*/
public record AdminPostStatusUpdateRequest(
String status
) {
}

View File

@@ -41,7 +41,10 @@ public class Post {
private Double longitude;
private LocalDateTime createdAt;
private String phone;
private String rulesAccepted;
// В БД колонка boolean NOT NULL (changelog 013). Строка была рассинхронизирована
// со схемой и ломала ddl-auto: validate.
@Column(name = "rules_accepted", nullable = false)
private Boolean rulesAccepted = false;
private Boolean isReward;
private String reward;

View File

@@ -15,6 +15,7 @@ public interface PostMapper {
@Mapping(source = "category", target = "category", qualifiedByName = "stringToPostCategory")
@Mapping(source = "rewardText", target = "reward")
@Mapping(source = "rulesAccepted", target = "rulesAccepted", qualifiedByName = "stringToBoolean")
Post toEntity(PostRequest request);
@Mapping(source = "user.id", target = "userId")
@@ -23,8 +24,23 @@ public interface PostMapper {
@Mapping(target = "category", expression = "java(post.getCategory().getDisplayName())")
@Mapping(source = "isReward", target = "reward")
@Mapping(source = "reward", target = "rewardText")
@Mapping(source = "rulesAccepted", target = "rulesAccepted", qualifiedByName = "booleanToString")
PostResponse toResponse(Post post);
/** Фронт присылает правила как строку ("true"), в БД колонка boolean. */
@Named("stringToBoolean")
default Boolean stringToBoolean(String value) {
if (value == null || value.isBlank()) {
return false;
}
return Boolean.parseBoolean(value.trim());
}
@Named("booleanToString")
default String booleanToString(Boolean value) {
return String.valueOf(Boolean.TRUE.equals(value));
}
@Named("imagesToUrls")
default List<String> imagesToUrls(List<Image> images) {
if (images == null) return List.of();

View File

@@ -4,13 +4,28 @@ import com.krylov.refound.entity.Chat;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ChatRepository extends JpaRepository<Chat, Long> {
@Query("SELECT c FROM Chat c WHERE c.userOneId = :u1 AND c.userTwoId = :u2")
Optional<Chat> findByUsers(Long u1, Long u2);
/** Есть ли пользователь среди участников чата. Имя не следует конвенции Spring Data, потому что запрос задан явно. */
@Query("SELECT CASE WHEN COUNT(c) > 0 THEN true ELSE false END FROM Chat c "
+ "WHERE c.id = :chatId AND (c.userOneId = :userId OR c.userTwoId = :userId)")
boolean isParticipantOfChat(Long chatId, Long userId);
@Query("SELECT c FROM Chat c WHERE c.userOneId = :userId OR c.userTwoId = :userId ORDER BY c.createdAt DESC")
List<Chat> findAllByUserId(Long userId); // Changed from Optional to List
/**
* Объявление удалено администратором: обнуляем ссылку, чтобы переписка сохранилась.
* У posts нет ON DELETE CASCADE, поэтому без этого удаление падало бы с 500.
*/
@Modifying
@Query("UPDATE Chat c SET c.postId = NULL WHERE c.postId = :postId")
int clearPostReference(@Param("postId") Long postId);
}

View File

@@ -1,7 +1,18 @@
package com.krylov.refound.repository;
import com.krylov.refound.entity.Image;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ImageRepository extends JpaRepository<Image, Long> {
Optional<List<Image>> findByPostId(Long postId);
/** Один запрос вместо N+1 при сборке админской страницы объявлений. */
@Query("select i from Image i where i.post.id in :postIds order by i.id asc")
List<Image> findByPostIdIn(@Param("postIds") Collection<Long> postIds);
}

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

@@ -49,7 +49,6 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
// публичные эндпоинты
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/test/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/ads-media/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
@@ -74,9 +73,13 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/api/ads/click").permitAll()
.requestMatchers(HttpMethod.GET, "/api/fullscreen-ad").permitAll()
// пример разграничения по ролям — раскомментировать и адаптировать под свои admin-эндпоинты
// пример разграничения по ролям
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// служебные endpoints: только для администратора
// (в методах стоит @PreAuthorize, здесь — первый рубеж)
.requestMatchers("/api/v1/test/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

View File

@@ -115,6 +115,18 @@ public class ChatService {
}
/**
* Проверка для STOMP-интерцептора: пользователь состоит в чате или нет.
* Не бросает исключений — вызывается на этапе авторизации подписки.
*/
@Transactional(readOnly = true)
public boolean isParticipant(Long chatId, Long userId) {
if (chatId == null || userId == null) {
return false;
}
return chatRepository.isParticipantOfChat(chatId, userId);
}
private Chat getChatOrThrow(Long chatId) {
return chatRepository.findById(chatId).orElseThrow(() -> new IllegalArgumentException("Чат не найден"));
}

View File

@@ -3,21 +3,26 @@ package com.krylov.refound.service;
import com.krylov.refound.config.MinioProperties;
import com.krylov.refound.dto.DownloadedFile;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.enums.Role;
import com.krylov.refound.exception.ApiException;
import java.io.IOException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.UriUtils;
@@ -40,11 +45,20 @@ import software.amazon.awssdk.core.exception.SdkClientException;
public class FileStorageService {
private final S3Client s3Client;
private final MinioProperties properties;
private final StringRedisTemplate redisTemplate;
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/webp");
private static final String ORIGINAL_FILENAME_METADATA_KEY = "original-filename";
/**
* Сколько живёт запись о владельце файла. Совпадает с правилом жизненного цикла бакета
* (180 дней), который ставит StorageStructureService: раньше файл исчезнет из MinIO,
* чем протухнет отметка о нём.
*/
private static final Duration OWNER_TTL = Duration.ofDays(180);
private static final String OWNER_KEY_PREFIX = "files:owner:";
/**
* Загружает файл в MinIO в корень бакета.
*/
@@ -79,6 +93,7 @@ public class FileStorageService {
.build();
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
registerOwner(objectName);
log.info("Файл '{}' успешно загружен.", objectName);
return objectName;
@@ -122,6 +137,8 @@ public class FileStorageService {
/**
* Удаляет файл из MinIO по его имени.
* Служебный метод без проверки прав: вызывается только из кода, который сам
* проверил, что файл можно удалять (удаление поста, аватара, модерация).
*/
public void deleteFile(String objectName) {
try {
@@ -132,6 +149,7 @@ public class FileStorageService {
.build();
s3Client.deleteObject(request);
forgetOwner(objectName);
log.info("Файл '{}' успешно удален.", objectName);
} catch (S3Exception | SdkClientException e) {
log.error("Ошибка при удалении файла '{}': {}", objectName, e.getMessage(), e);
@@ -139,6 +157,81 @@ public class FileStorageService {
}
}
/**
* Удаляет файл по запросу пользователя: свой файл удалить можно всегда,
* чужой — только администратору. Отсутствие отметки о владельце означает,
* что сервис не знает, кому файл принадлежит, поэтому удалять его нельзя.
*/
public void deleteFileAsCurrentUser(String objectName) {
assertCanDeleteFile(objectName);
deleteFile(objectName);
}
private void assertCanDeleteFile(String objectName) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getPrincipal() == null) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "Пользователь не авторизован", HttpStatus.UNAUTHORIZED);
}
boolean isAdmin = authentication.getAuthorities().stream()
.anyMatch(authority -> Role.ADMIN.name().equals(authority.getAuthority()));
if (isAdmin) {
return;
}
String owner = readOwner(objectName);
if (owner == null) {
log.warn("Удаление файла '{}': владелец неизвестен, доступ запрещён.", objectName);
throw new ApiException(ErrorCode.FORBIDDEN, "Удалить этот файл нельзя", HttpStatus.FORBIDDEN);
}
if (!owner.equals(authentication.getPrincipal())) {
log.warn("Попытка удалить чужой файл '{}' пользователем '{}'.", objectName, authentication.getPrincipal());
throw new ApiException(ErrorCode.FORBIDDEN, "Удалить можно только свои файлы", HttpStatus.FORBIDDEN);
}
}
// ---------- владение файлами ----------
private void registerOwner(String objectName) {
String login = currentLogin();
if (login == null) {
// Служебная загрузка без HTTP-контекста (модерация, сидер) — владельца нет.
return;
}
try {
redisTemplate.opsForValue().set(OWNER_KEY_PREFIX + objectName, login, OWNER_TTL);
} catch (RuntimeException e) {
// Файл уже загружен; не роняем запрос из-за вторичной операции.
log.warn("Не удалось сохранить владельца файла '{}': {}", objectName, e.getMessage());
}
}
private void forgetOwner(String objectName) {
try {
redisTemplate.delete(OWNER_KEY_PREFIX + objectName);
} catch (RuntimeException e) {
log.warn("Не удалось удалить отметку о владельце файла '{}': {}", objectName, e.getMessage());
}
}
private String readOwner(String objectName) {
try {
return redisTemplate.opsForValue().get(OWNER_KEY_PREFIX + objectName);
} catch (RuntimeException e) {
log.warn("Не удалось прочитать владельца файла '{}': {}", objectName, e.getMessage());
return null;
}
}
private String currentLogin() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return null;
}
Object principal = authentication.getPrincipal();
return principal instanceof String login && authentication.isAuthenticated() ? login : null;
}
// ---------- вспомогательные методы ----------
private DownloadedFile fetchFile(String objectName) {

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

@@ -4,9 +4,9 @@ import com.krylov.refound.dto.user.UserResponseDto;
import com.krylov.refound.dto.user.UserUpdateDto;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.enums.Role;
import com.krylov.refound.exception.ApiException;
import com.krylov.refound.repository.UserRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
@@ -35,15 +35,48 @@ public class UserService {
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
}
/** Текущий запрос сделал администратор. */
public boolean isCurrentUserAdmin() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return false;
}
return authentication.getAuthorities().stream()
.anyMatch(authority -> Role.ADMIN.name().equals(authority.getAuthority()));
}
/**
* Проверяет, что текущий пользователь имеет право менять профиль {@code targetUserId}.
* Своим профилем управляет сам пользователь, чужим — только администратор.
*/
public void assertCanModifyUser(Long targetUserId) {
if (isCurrentUserAdmin()) {
return;
}
User current = getCurrentUser();
if (!current.getId().equals(targetUserId)) {
log.warn("Попытка изменить чужой профиль: current={} target={}", current.getId(), targetUserId);
throw new ApiException(ErrorCode.FORBIDDEN,
"Изменять можно только свой профиль", HttpStatus.FORBIDDEN);
}
}
public UserResponseDto getUserById(Long id) {
User user = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
return getUserResponseDto(user);
}
public UserResponseDto updateUser(Long id, UserUpdateDto dto, Boolean removeAvatar) {
assertCanModifyUser(id);
User user = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
if (dto.getLogin() != null && !dto.getLogin().equals(user.getLogin())
&& repository.existsByLogin(dto.getLogin())) {
throw new ApiException(ErrorCode.VALIDATION_ERROR, "Логин уже занят", HttpStatus.BAD_REQUEST);
}
user.setLogin(dto.getLogin());
user.setName(dto.getFirstName());
@@ -92,7 +125,13 @@ public class UserService {
public Long getUserIdByLogin(String login) {
return repository.findUserByLogin(login)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
}
public String getRoleByLogin(String login) {
return repository.findByLogin(login)
.map(user -> user.getRole() != null ? user.getRole().name() : Role.USER.name())
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
}
private static UserResponseDto getUserResponseDto(User user) {

View File

@@ -0,0 +1,367 @@
package com.krylov.refound.service.admin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.krylov.refound.dto.AddressInfo;
import com.krylov.refound.dto.admin.AdminPostResponse;
import com.krylov.refound.entity.Image;
import com.krylov.refound.entity.Post;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.enums.PostCategory;
import com.krylov.refound.enums.PostStatus;
import com.krylov.refound.enums.PostType;
import com.krylov.refound.exception.ApiException;
import com.krylov.refound.repository.ChatRepository;
import com.krylov.refound.repository.FavoriteRepository;
import com.krylov.refound.repository.ImageRepository;
import com.krylov.refound.repository.PostRepository;
import com.krylov.refound.service.FileStorageService;
import com.krylov.refound.service.GeocodingService;
import com.krylov.refound.service.StatisticsService;
import com.krylov.refound.service.redis.MapCacheService;
import com.krylov.refound.service.redis.PostCacheService;
import com.krylov.refound.util.PostSpecification;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
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.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
/**
* Админские операции над объявлениями. Отличия от {@code PostService}:
* <ul>
* <li>нет проверки владельца — операции доступны любому ADMIN;</li>
* <li>редактирование не меняет статус: перевод в MODERATION убрал бы объявление из ленты до
* прохождения AI-модерации, а в REJECTED — скрыл бы навсегда;</li>
* <li>удаление чистит ссылки из favorites и chats, потому что каскада в БД нет;</li>
* <li>геокодинг при правке best-effort: недоступность Nominatim не должна блокировать
* исправление текста объявления.</li>
* </ul>
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AdminPostService {
private static final int MAX_PAGE_SIZE = 100;
/** Основная админская лента: опубликованные объявления, включая закрытые. */
private static final Set<PostStatus> FEED_STATUSES = EnumSet.of(PostStatus.ACTIVE, PostStatus.CLOSED);
/** Отклонённые объявления живут отдельной лентой, в основной их не подмешиваем. */
private static final Set<PostStatus> REJECTED_STATUSES = EnumSet.of(PostStatus.REJECTED);
private final PostRepository postRepository;
private final ImageRepository imageRepository;
private final FavoriteRepository favoriteRepository;
private final ChatRepository chatRepository;
private final FileStorageService fileStorageService;
private final GeocodingService geocodingService;
private final PostCacheService postCacheService;
private final MapCacheService mapCacheService;
private final StatisticsService statisticsService;
private final ObjectMapper objectMapper;
/**
* Основная лента объявлений: только ACTIVE и CLOSED. MODERATION и REJECTED
* доступны через {@link #getRejectedPosts(String, String, String, Pageable)}.
* Фильтры приходят строками и разбираются вручную — неверное значение даёт 400 вместо 500.
*/
@Transactional(readOnly = true)
public Page<AdminPostResponse> getPosts(String search, String rawType, String rawStatus, Pageable pageable) {
return searchPosts(search, rawType, rawStatus, FEED_STATUSES, pageable);
}
/** Отклонённые объявления: те же фильтры, но статус зафиксирован на REJECTED. */
@Transactional(readOnly = true)
public Page<AdminPostResponse> getRejectedPosts(String search, String rawType, String rawStatus,
Pageable pageable) {
return searchPosts(search, rawType, rawStatus, REJECTED_STATUSES, pageable);
}
private Page<AdminPostResponse> searchPosts(String search, String rawType, String rawStatus,
Set<PostStatus> allowedStatuses, Pageable pageable) {
PostStatus status = parseStatus(rawStatus);
if (status != null && !allowedStatuses.contains(status)) {
throw new ApiException(ErrorCode.VALIDATION_ERROR,
"Статус " + status + " недоступен в этой ленте, допустимы: " + allowedStatuses,
HttpStatus.BAD_REQUEST);
}
Page<Post> page = postRepository.findAll(
PostSpecification.adminSearch(search, parseType(rawType), status, allowedStatuses),
safePageable(pageable)
);
Map<Long, List<String>> imagesByPost = loadImages(page.getContent().stream().map(Post::getId).toList());
return new PageImpl<>(
page.getContent().stream()
.map(post -> toResponse(post, imagesByPost.getOrDefault(post.getId(), List.of())))
.toList(),
page.getPageable(),
page.getTotalElements()
);
}
/** Multipart-контракт повторяет пользовательский PUT /api/v1/posts/{id}. */
@Transactional
public AdminPostResponse updatePost(Long postId, String type, String title, String address,
Double latitude, Double longitude, String description, String category, String phone,
Boolean reward, String rewardText, List<MultipartFile> images, String existingImagesJson) {
Post post = findPost(postId);
PostStatus statusBeforeEdit = post.getStatus();
post.setType(parseTypeOrThrow(type));
post.setTitle(title);
post.setDescription(description);
post.setPhone(phone);
post.setIsReward(Boolean.TRUE.equals(reward));
post.setReward(rewardText);
if (StringUtils.hasText(category)) {
post.setCategory(PostCategory.fromDisplayName(category));
}
if (latitude != null && longitude != null) {
post.setLatitude(latitude);
post.setLongitude(longitude);
resolveLocation(post, latitude, longitude, address);
} else if (StringUtils.hasText(address)) {
post.setCity(address.trim());
}
syncImages(post, existingImagesJson, images);
Post updated = postRepository.save(post);
invalidateCaches();
log.info("Post {} edited by admin, status kept as {}", postId, statusBeforeEdit);
return toResponse(updated, imageUrlsOf(updated));
}
@Transactional
public void deletePost(Long postId) {
Post post = findPost(postId);
for (Image image : post.getImages()) {
deleteFileQuietly(image.getUrl());
}
// На posts ссылаются favorites и chats, ON DELETE CASCADE в БД нет:
// без явной чистки удаление падало бы на внешнем ключе.
favoriteRepository.deleteByPostIn(List.of(post));
chatRepository.clearPostReference(postId);
postRepository.delete(post);
invalidateCaches();
log.info("Post {} deleted by admin", postId);
}
/**
* ACTIVE и CLOSED — то, что умеет фронт; MODERATION возвращает объявление в очередь
* AI-модерации, REJECTED скрывает его из ленты.
*/
@Transactional
public AdminPostResponse updateStatus(Long postId, String rawStatus) {
PostStatus target = parseStatusOrThrow(rawStatus);
Post post = findPost(postId);
PostStatus previous = post.getStatus();
if (previous != target) {
post.setStatus(target);
Post updated = postRepository.save(post);
// posts_found — счётчик закрытых объявлений за всё время, уменьшать его не нужно.
if (target == PostStatus.CLOSED) {
statisticsService.incrementFound();
}
invalidateCaches();
log.info("Post {} status {} -> {} by admin", postId, previous, target);
return toResponse(updated, imageUrlsOf(updated));
}
return toResponse(post, imageUrlsOf(post));
}
private Post findPost(Long postId) {
return postRepository.findById(postId)
.orElseThrow(() -> new ApiException(
ErrorCode.NOT_FOUND, "Post not found: " + postId, HttpStatus.NOT_FOUND));
}
private void resolveLocation(Post post, Double latitude, Double longitude, String address) {
try {
AddressInfo resolved = geocodingService.getAddress(latitude.toString(), longitude.toString());
if (StringUtils.hasText(resolved.city())) {
post.setCity(resolved.city());
post.setDistrict(resolved.district());
return;
}
} catch (ApiException e) {
log.warn("Геокодинг недоступен, город берём из address: postId={}, {}", post.getId(), e.getMessage());
}
if (StringUtils.hasText(address)) {
post.setCity(address.trim());
}
}
private void syncImages(Post post, String existingImagesJson, List<MultipartFile> newImages) {
List<String> keepUrls = parseExistingImages(existingImagesJson);
List<Image> toRemove = post.getImages().stream()
.filter(image -> !keepUrls.contains(image.getUrl()))
.toList();
for (Image image : toRemove) {
deleteFileQuietly(image.getUrl());
}
post.getImages().removeAll(toRemove);
if (newImages == null) {
return;
}
for (MultipartFile file : newImages) {
if (file == null || file.isEmpty()) {
continue;
}
Image image = new Image();
image.setUrl(fileStorageService.uploadFile(file));
image.setPost(post);
imageRepository.save(image);
post.getImages().add(image);
}
}
private List<String> parseExistingImages(String existingImagesJson) {
if (!StringUtils.hasText(existingImagesJson)) {
return List.of();
}
try {
return Arrays.asList(objectMapper.readValue(existingImagesJson, String[].class));
} catch (JsonProcessingException e) {
throw new ApiException(ErrorCode.VALIDATION_ERROR,
"Некорректный формат existingImages", HttpStatus.BAD_REQUEST);
}
}
private void invalidateCaches() {
postCacheService.invalidatePosts();
mapCacheService.clearMapMarkersCache();
}
private void deleteFileQuietly(String objectKey) {
try {
fileStorageService.deleteFile(objectKey);
} catch (Exception e) {
log.warn("Не удалось удалить файл из хранилища: {}", objectKey, e);
}
}
/** Один запрос вместо N+1 при сборке страницы объявлений. */
private Map<Long, List<String>> loadImages(List<Long> postIds) {
if (postIds.isEmpty()) {
return Map.of();
}
return imageRepository.findByPostIdIn(postIds).stream()
.collect(Collectors.groupingBy(
image -> image.getPost().getId(),
Collectors.mapping(Image::getUrl, Collectors.toList())
));
}
private List<String> imageUrlsOf(Post post) {
List<Image> images = post.getImages();
if (images == null || images.isEmpty()) {
return List.of();
}
return images.stream().map(Image::getUrl).toList();
}
private AdminPostResponse toResponse(Post post, List<String> images) {
User owner = post.getUser();
return AdminPostResponse.builder()
.id(post.getId())
.type(post.getType())
.status(post.getStatus())
.title(post.getTitle())
.description(post.getDescription())
.category(post.getCategory() == null ? null : post.getCategory().getDisplayName())
.city(post.getCity())
.district(post.getDistrict())
.latitude(post.getLatitude())
.longitude(post.getLongitude())
.phone(post.getPhone())
.reward(Boolean.TRUE.equals(post.getIsReward()))
.rewardText(post.getReward())
.createdAt(post.getCreatedAt())
.images(images)
.ownerId(owner == null ? null : owner.getId())
.ownerLogin(owner == null ? null : owner.getLogin())
.ownerName(owner == null ? null : owner.getName())
.ownerFirstName(owner == null ? null : owner.getName())
.ownerLastName(owner == null ? null : owner.getLastName())
.build();
}
private static Pageable safePageable(Pageable pageable) {
int size = Math.min(Math.max(pageable.getPageSize(), 1), MAX_PAGE_SIZE);
int page = Math.max(pageable.getPageNumber(), 0);
Sort sort = pageable.getSort().isSorted()
? pageable.getSort()
: Sort.by(Sort.Direction.DESC, "createdAt");
return PageRequest.of(page, size, sort);
}
private static PostType parseType(String raw) {
if (!StringUtils.hasText(raw)) {
return null;
}
try {
return PostType.valueOf(raw.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new ApiException(ErrorCode.VALIDATION_ERROR,
"Неизвестный тип объявления: " + raw, HttpStatus.BAD_REQUEST);
}
}
private static PostType parseTypeOrThrow(String raw) {
if (!StringUtils.hasText(raw)) {
throw new ApiException(ErrorCode.VALIDATION_ERROR, "Тип объявления обязателен", HttpStatus.BAD_REQUEST);
}
return parseType(raw);
}
private static PostStatus parseStatus(String raw) {
if (!StringUtils.hasText(raw)) {
return null;
}
try {
return PostStatus.valueOf(raw.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new ApiException(ErrorCode.VALIDATION_ERROR,
"Неизвестный статус: " + raw, HttpStatus.BAD_REQUEST);
}
}
private static PostStatus parseStatusOrThrow(String raw) {
if (!StringUtils.hasText(raw)) {
throw new ApiException(ErrorCode.VALIDATION_ERROR, "Статус обязателен", HttpStatus.BAD_REQUEST);
}
return parseStatus(raw);
}
}

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

@@ -1,10 +1,50 @@
package com.krylov.refound.util;
import com.krylov.refound.entity.Post;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.PostStatus;
import com.krylov.refound.enums.PostType;
import jakarta.persistence.criteria.Join;
import java.util.Set;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;
public class PostSpecification {
/**
* Поиск для админской ленты: видны только статусы из {@code allowedStatuses},
* параметр {@code status} сужает выборку внутри них. {@code allowedStatuses == null}
* снимает ограничение по статусу (все объявления, включая MODERATION и REJECTED).
* Совпадение ищется по названию, описанию, городу, району и данным владельца.
*/
public static Specification<Post> adminSearch(
String search, PostType type, PostStatus status, Set<PostStatus> allowedStatuses) {
return Specification.allOf(
(root, query, cb) -> type == null ? cb.conjunction() : cb.equal(root.get("type"), type),
(root, query, cb) -> allowedStatuses == null || allowedStatuses.isEmpty()
? cb.conjunction()
: root.get("status").in(allowedStatuses),
(root, query, cb) -> status == null ? cb.conjunction() : cb.equal(root.get("status"), status),
(root, query, cb) -> {
if (!StringUtils.hasText(search)) {
return cb.conjunction();
}
String pattern = "%" + search.toLowerCase().trim() + "%";
// user_id — NOT NULL, поэтому inner join не теряет объявления без владельца.
Join<Post, User> user = root.join("user");
return cb.or(
cb.like(cb.lower(root.get("title")), pattern),
cb.like(cb.lower(root.get("description")), pattern),
cb.like(cb.lower(root.get("city")), pattern),
cb.like(cb.lower(root.get("district")), pattern),
cb.like(cb.lower(user.get("login")), pattern),
cb.like(cb.lower(user.get("name")), pattern),
cb.like(cb.lower(user.get("lastName")), pattern)
);
}
);
}
public static Specification<Post> hasSearch(String search) {
return (root, query, cb) -> {
if (search == null || search.isBlank()) {
@@ -18,13 +58,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

@@ -1,53 +1,169 @@
package com.krylov.refound.util;
import com.krylov.refound.security.JwtService;
import com.krylov.refound.service.ChatService;
import com.krylov.refound.service.UserService;
import lombok.RequiredArgsConstructor;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessagingException;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class WsStompInterceptor implements ChannelInterceptor {
private static final String USER_ID_ATTR = "userId";
private static final String ROLE_ATTR = "role";
/** /topic/chat/{chatId} */
private static final Pattern CHAT_TOPIC = Pattern.compile("^/topic/chat/(\\d+)$");
/** /topic/user/{userId}/unread */
private static final Pattern UNREAD_TOPIC = Pattern.compile("^/topic/user/(\\d+)/unread$");
/** /user/{userId}/queue/** — личная очередь одного пользователя */
private static final Pattern USER_QUEUE = Pattern.compile("^/user/[^/]+/queue/.+$");
private final JwtService jwtService;
private final UserService userService;
// ChatService тянет SimpMessagingTemplate, а тот — конфигурацию WebSocket,
// в которую регистрируется этот же интерцептор. Без @Lazy получается цикл.
private final ChatService chatService;
public WsStompInterceptor(JwtService jwtService, UserService userService,
@Lazy ChatService chatService) {
this.jwtService = jwtService;
this.userService = userService;
this.chatService = chatService;
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
StompHeaderAccessor.wrap(message);
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String authorization = accessor.getFirstNativeHeader("Authorization");
if (authorization == null || !authorization.startsWith("Bearer ")) {
throw new IllegalArgumentException("Missing Authorization header");
}
String token = authorization.substring(7);
if (!jwtService.isTokenValid(token)) {
throw new IllegalArgumentException("Invalid JWT token");
}
String login = jwtService.extractLogin(token);
Long userId = userService.getUserIdByLogin(login);
if (userId == null) {
throw new IllegalArgumentException("User not found");
}
accessor.getSessionAttributes().put("userId", userId);
handleConnect(accessor);
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
handleSubscribe(accessor);
}
return message;
}
private void handleConnect(StompHeaderAccessor accessor) {
String authorization = accessor.getFirstNativeHeader("Authorization");
if (authorization == null || !authorization.startsWith("Bearer ")) {
throw new IllegalArgumentException("Missing Authorization header");
}
String token = authorization.substring(7);
if (!jwtService.isTokenValid(token)) {
throw new IllegalArgumentException("Invalid JWT token");
}
String login = jwtService.extractLogin(token);
// getUserIdByLogin бросает 404, если пользователя нет
Long userId = userService.getUserIdByLogin(login);
String role = jwtService.extractRole(token);
if (role == null) {
role = userService.getRoleByLogin(login);
}
accessor.getSessionAttributes().put(USER_ID_ATTR, userId);
accessor.getSessionAttributes().put(ROLE_ATTR, role);
log.info("WS connected: userId={} role={}", userId, role);
}
/**
* Авторизация подписки. Без неё любой авторизованный пользователь может читать
* чужие чаты и счётчики непрочитанных сообщений, подписавшись на их топики.
*/
private void handleSubscribe(StompHeaderAccessor accessor) {
String destination = accessor.getDestination();
Long userId = userIdFromSession(accessor);
String role = roleFromSession(accessor);
if (userId == null) {
log.warn("Subscribe без CONNECT: destination={}", destination);
throw new MessagingException("Ошибка авторизации");
}
if (isAdminsOnly(destination)) {
if (!isAdmin(role)) {
log.warn("Попытка подписки на служебный канал '{}' пользователем с ролью {}", destination, role);
throw new MessagingException("Доступ запрещён");
}
return;
}
Matcher unread = UNREAD_TOPIC.matcher(destination);
if (unread.matches()) {
Long targetUserId = Long.valueOf(unread.group(1));
if (!userId.equals(targetUserId)) {
log.warn("Попытка подписки на чужой счётчик непрочитанных: userId={} target={}", userId, targetUserId);
throw new MessagingException("Доступ запрещён");
}
return;
}
if (USER_QUEUE.matcher(destination).matches()) {
String[] parts = destination.split("/");
if (parts.length < 3 || !userId.toString().equals(parts[2])) {
log.warn("Попытка подписки на чужую личную очередь: userId={} destination={}", userId, destination);
throw new MessagingException("Доступ запрещён");
}
return;
}
Matcher chat = CHAT_TOPIC.matcher(destination);
if (chat.matches()) {
Long chatId = Long.valueOf(chat.group(1));
if (!chatService.isParticipant(chatId, userId)) {
log.warn("Пользователь {} не участник чата {} — подписка запрещена", userId, chatId);
throw new MessagingException("Доступ запрещён");
}
return;
}
// Неизвестные топики закрыты по умолчанию: иначе появится канал,
// который случайно разрешит чужие данные.
log.warn("Подписка на неизвестный канал '{}' пользователем {}", destination, userId);
throw new MessagingException("Доступ запрещён");
}
private boolean isAdminsOnly(String destination) {
return destination != null
&& (destination.startsWith("/topic/ads") || destination.startsWith("/topic/fullscreen-ads"));
}
private boolean isAdmin(String role) {
return "ADMIN".equalsIgnoreCase(role);
}
private Long userIdFromSession(StompHeaderAccessor accessor) {
if (accessor.getSessionAttributes() == null) {
return null;
}
Object value = accessor.getSessionAttributes().get(USER_ID_ATTR);
return value instanceof Long id ? id : null;
}
private String roleFromSession(StompHeaderAccessor accessor) {
if (accessor.getSessionAttributes() == null) {
return null;
}
Object value = accessor.getSessionAttributes().get(ROLE_ATTR);
return value instanceof String role ? role : null;
}
}

View File

@@ -15,7 +15,10 @@ spring:
jpa:
hibernate:
ddl-auto: update # на старте удобно
# Схемой управляет Liquibase. "update" молча правил таблицы мимо changelog,
# из-за чего базы расходились. Для разработки можно переопределить
# переменной окружения JPA_DDL_AUTO=update.
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: true
properties:
hibernate:
@@ -23,8 +26,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 +61,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,209 @@
databaseChangeLog:
- changeSet:
id: 017-messages-add-chat-id
author: krylov
comment: >
Перевод messages на чаты. Раньше диалоги хранились парами sender_id/receiver_id,
теперь у сообщения есть chat_id. Колонка добавляется nullable, чтобы
существующие строки не сломали миграцию — заполняется следующим changeset.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: messages
not:
columnExists:
tableName: messages
columnName: chat_id
changes:
- addColumn:
tableName: messages
columns:
- column: { name: chat_id, type: BIGINT }
- changeSet:
id: 017-messages-backfill-chats
author: krylov
comment: >
Создаёт чат для каждой уникальной пары собеседников по старым сообщениям
и проставляет chat_id. Данные сохраняются, история не теряется.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: messages
tableExists:
tableName: chats
columnExists:
tableName: messages
columnName: chat_id
changes:
# Сначала чат на каждую пару (user_one_id < user_two_id, как это делает ChatService).
- sql:
splitStatements: false
sql: |
INSERT INTO chats (user_one_id, user_two_id, post_id, created_at)
SELECT p.u1, p.u2, NULL, COALESCE(p.first_at, NOW())
FROM (
SELECT LEAST(m.sender_id, m.receiver_id) AS u1,
GREATEST(m.sender_id, m.receiver_id) AS u2,
MIN(m.created_at) AS first_at
FROM messages m
WHERE m.chat_id IS NULL
AND m.sender_id IS NOT NULL
AND m.receiver_id IS NOT NULL
GROUP BY LEAST(m.sender_id, m.receiver_id), GREATEST(m.sender_id, m.receiver_id)
) p
ON CONFLICT (user_one_id, user_two_id) DO NOTHING
# Затем проставляем каждому сообщению его чат.
- sql:
splitStatements: false
sql: |
UPDATE messages m
SET chat_id = c.id
FROM chats c
WHERE m.chat_id IS NULL
AND c.user_one_id = LEAST(m.sender_id, m.receiver_id)
AND c.user_two_id = GREATEST(m.sender_id, m.receiver_id)
- changeSet:
id: 017-messages-drop-orphans
author: krylov
comment: >
Сообщения, у которых нет чата: chat_id IS NULL (не удалось восстановить
собеседника) либо чат был удалён, а сообщения остались — так было до
появления fk_messages_chat, потому что ON DELETE CASCADE не работал.
Такие сообщения невозможно показать в интерфейсе, они удаляются.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: messages
columnExists:
tableName: messages
columnName: chat_id
changes:
- sql:
splitStatements: false
sql: |
DELETE FROM messages m
WHERE m.chat_id IS NULL
OR NOT EXISTS (SELECT 1 FROM chats c WHERE c.id = m.chat_id)
- changeSet:
id: 017-messages-chat-id-not-null
author: krylov
preConditions:
onFail: MARK_RAN
tableExists:
tableName: messages
columnExists:
tableName: messages
columnName: chat_id
changes:
- addNotNullConstraint:
tableName: messages
columnName: chat_id
columnDataType: BIGINT
- changeSet:
id: 017-messages-fk-chat
author: krylov
comment: >
ON DELETE CASCADE — при удалении чата сообщения удаляются вместе с ним.
Без каскада ChatService.deleteChat падал бы с нарушением FK.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: messages
tableExists:
tableName: chats
not:
foreignKeyConstraintExists:
constraintName: fk_messages_chat
changes:
- addForeignKeyConstraint:
baseTableName: messages
baseColumnNames: chat_id
referencedTableName: chats
referencedColumnNames: id
constraintName: fk_messages_chat
onDelete: CASCADE
- changeSet:
id: 017-messages-idx-chat-created
author: krylov
comment: >
Основные запросы выборки: история чата и счётчик непрочитанных.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: messages
not:
indexExists:
tableName: messages
indexName: idx_messages_chat_created
changes:
- createIndex:
tableName: messages
indexName: idx_messages_chat_created
columns:
- column: { name: chat_id }
- column: { name: created_at }
- changeSet:
id: 017-messages-drop-receiver
author: krylov
comment: >
receiver_id больше не используется: получатель определяется через chats.
Сначала снимаем с него внешний ключ и индекс, иначе dropColumn не пройдёт.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: messages
columnExists:
tableName: messages
columnName: receiver_id
changes:
- dropForeignKeyConstraint:
baseTableName: messages
constraintName: fk_messages_receiver
- dropIndex:
tableName: messages
indexName: idx_messages_users
- dropColumn:
tableName: messages
columnName: receiver_id
- changeSet:
id: 017-messages-not-null
author: krylov
comment: >
Сущность Message объявляет sender_id, content, is_read и created_at как
NOT NULL, но старая схема (004) создала их nullable. Hibernate validate
nullability не проверяет, поэтому расхождение молча оставалось.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: messages
sqlCheck:
expectedResult: 0
sql: >
SELECT COUNT(*) FROM messages
WHERE sender_id IS NULL OR content IS NULL
OR is_read IS NULL OR created_at IS NULL
changes:
- addNotNullConstraint:
tableName: messages
columnName: sender_id
columnDataType: BIGINT
- addNotNullConstraint:
tableName: messages
columnName: content
columnDataType: TEXT
- addNotNullConstraint:
tableName: messages
columnName: is_read
columnDataType: BOOLEAN
- addNotNullConstraint:
tableName: messages
columnName: created_at
columnDataType: TIMESTAMP

View File

@@ -0,0 +1,42 @@
databaseChangeLog:
- changeSet:
id: 018-posts-rules-accepted-type
author: krylov
comment: >
rules_accepted объявлена в changelog 013 как boolean, но Hibernate с
ddl-auto: update успел создать её как varchar — поле в сущности тогда
было String. Теперь сущность использует Boolean, и ddl-auto: validate
падал с "wrong column type". Приводим тип к схеме, приведя значения.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: posts
columnExists:
tableName: posts
columnName: rules_accepted
# Ноль boolean-колонок с таким именем = тип ещё не boolean = changeset нужен.
sqlCheck:
expectedResult: 0
sql: >
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'posts' AND column_name = 'rules_accepted'
AND data_type = 'boolean'
changes:
# В varchar могли попасть мусорные значения — приводим к true/false заранее.
- sql:
splitStatements: false
sql: |
UPDATE posts
SET rules_accepted = CASE
WHEN LOWER(TRIM(rules_accepted)) IN ('true', 't', '1', 'yes', 'y') THEN 'true'
ELSE 'false'
END
- sql:
splitStatements: false
sql: |
ALTER TABLE posts
ALTER COLUMN rules_accepted DROP DEFAULT,
ALTER COLUMN rules_accepted TYPE BOOLEAN USING rules_accepted::boolean,
ALTER COLUMN rules_accepted SET DEFAULT false,
ALTER COLUMN rules_accepted SET NOT NULL

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

@@ -1,7 +1,15 @@
databaseChangeLog:
- changeSet:
id: 004-create-chats-messages
author: you
id: 014-create-chats
author: krylov
comment: >
Таблица чатов. Раньше создавалась Hibernate (ddl-auto: update),
поэтому changeset идемпотентен: если таблица уже есть — MARK_RAN.
preConditions:
onFail: MARK_RAN
not:
tableExists:
tableName: chats
changes:
- createTable:
tableName: chats
@@ -15,3 +23,42 @@ databaseChangeLog:
tableName: chats
columnNames: user_one_id, user_two_id
constraintName: uq_chat_users
- changeSet:
id: 014-chats-idx-participants
author: krylov
comment: >
findAllByUserId ищет по (user_one_id = ? OR user_two_id = ?),
без индексов это full scan по всем чатам.
preConditions:
onFail: MARK_RAN
tableExists:
tableName: chats
not:
indexExists:
tableName: chats
indexName: idx_chats_user_one
changes:
- createIndex:
tableName: chats
indexName: idx_chats_user_one
columns:
- column: { name: user_one_id }
- changeSet:
id: 014-chats-idx-participants-two
author: krylov
preConditions:
onFail: MARK_RAN
tableExists:
tableName: chats
not:
indexExists:
tableName: chats
indexName: idx_chats_user_two
changes:
- createIndex:
tableName: chats
indexName: idx_chats_user_two
columns:
- column: { name: user_two_id }

View File

@@ -1,28 +1,12 @@
databaseChangeLog:
- changeSet:
id: 005-drop-old-messages
author: you
changes:
- dropTable:
tableName: messages
cascadeConstraints: true
- changeSet:
id: 006-create-messages-new
author: you
changes:
- createTable:
tableName: messages
columns:
- column: { name: id, type: BIGSERIAL, constraints: { primaryKey: true } }
- column: { name: chat_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: sender_id, type: BIGINT, constraints: { nullable: false } }
- column: { name: content, type: TEXT, constraints: { nullable: false } }
- column: { name: is_read, type: BOOLEAN, defaultValueBoolean: false }
- column: { name: created_at, type: TIMESTAMP, constraints: { nullable: false } }
- addForeignKeyConstraint:
baseTableName: messages
baseColumnNames: chat_id
referencedTableName: chats
referencedColumnNames: id
constraintName: fk_messages_chat
# УСТАРЕЛО. Файл намеренно не подключён в db.changelog-master.yaml.
#
# Раньше здесь был changeSet "005-drop-old-messages", который удалял таблицу
# messages вместе со всеми сообщениями. Подключение этого файла уничтожило бы
# переписку пользователей, поэтому он заменён на безопасную альтернативу.
#
# Актуальная миграция: db/changelog/add/017-messages-chat-id.yaml
# Она добавляет messages.chat_id, создаёт чаты для старых диалогов
# и переносит сообщения, не удаляя данные.
#
# Если чаты уже созданы Hibernate, 017 идемпотентна: preConditions + MARK_RAN.
databaseChangeLog: []

View File

@@ -25,3 +25,7 @@ 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 }
- include: { file: db/changelog/create/006-create-chats.yaml }
- include: { file: db/changelog/add/017-messages-chat-id.yaml }
- include: { file: db/changelog/alter/014-posts-rules-accepted-type.yaml }