Compare commits
10 Commits
a44ca00222
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efdae92787 | ||
|
|
1a7b96e8a6 | ||
|
|
7adc4f28f1 | ||
|
|
e53c5e3ba7 | ||
|
|
59872436b9 | ||
|
|
bffd259c25 | ||
|
|
c8a3cca059 | ||
|
|
015b68d96d | ||
|
|
1efb67824f | ||
|
|
870739f45f |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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
|
||||
|
||||
15
build.gradle
15
build.gradle
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.krylov.refound.dto.admin;
|
||||
|
||||
/**
|
||||
* Статус приходит строкой, а не enum-ом: {@code PostStatus} без {@code @JsonCreator} при
|
||||
* неверном значении даёт {@code HttpMessageNotReadableException}, который в этом проекте
|
||||
* превращается в 500. Разбор строки в сервисе возвращает понятный 400.
|
||||
*/
|
||||
public record AdminPostStatusUpdateRequest(
|
||||
String status
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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("Чат не найден"));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
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() {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
209
src/main/resources/db/changelog/add/017-messages-chat-id.yaml
Normal file
209
src/main/resources/db/changelog/add/017-messages-chat-id.yaml
Normal 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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user