Merge pull request #38 from SlimusMinus/fix-25_jdk
changed jdk on 25 springBoot on 3.5.6 and fix code
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -5,6 +5,17 @@ build/
|
|||||||
!**/src/main/**/build/
|
!**/src/main/**/build/
|
||||||
!**/src/test/**/build/
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### Секреты ###
|
||||||
|
# .env содержит реальные ключи MinIO, пароль Redis и секрет JWT — в репозиторий ему нельзя.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
### Локальные данные ###
|
||||||
|
# том MinIO, создаётся автоматически при первом запуске
|
||||||
|
/data/
|
||||||
|
/minio-data/
|
||||||
|
|
||||||
### STS ###
|
### STS ###
|
||||||
.apt_generated
|
.apt_generated
|
||||||
.classpath
|
.classpath
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'java'
|
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'
|
id 'io.spring.dependency-management' version '1.1.7'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ description = 'ReFound'
|
|||||||
|
|
||||||
java {
|
java {
|
||||||
toolchain {
|
toolchain {
|
||||||
languageVersion = JavaLanguageVersion.of(21)
|
languageVersion = JavaLanguageVersion.of(25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +57,8 @@ dependencies {
|
|||||||
implementation 'org.springframework.retry:spring-retry'
|
implementation 'org.springframework.retry:spring-retry'
|
||||||
implementation 'org.springframework:spring-aspects'
|
implementation 'org.springframework:spring-aspects'
|
||||||
|
|
||||||
compileOnly 'org.projectlombok:lombok'
|
compileOnly 'org.projectlombok:lombok:1.18.42'
|
||||||
annotationProcessor 'org.projectlombok:lombok'
|
annotationProcessor 'org.projectlombok:lombok:1.18.42'
|
||||||
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
|
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
|
||||||
|
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ public class FileStorageController {
|
|||||||
return fileStorageService.downloadFile(key);
|
return fileStorageService.downloadFile(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{objectName}")
|
@DeleteMapping("/{*objectName}")
|
||||||
public ResponseEntity<Void> deleteFile(@PathVariable String objectName) {
|
public ResponseEntity<Void> deleteFile(@PathVariable String objectName) {
|
||||||
log.info("deleteFile {}", 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();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.krylov.refound.ai.dto.ModerationResponse;
|
|||||||
import com.krylov.refound.service.SchedulerService;
|
import com.krylov.refound.service.SchedulerService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -16,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RequestMapping("/api/v1/test")
|
@RequestMapping("/api/v1/test")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
public class TestController {
|
public class TestController {
|
||||||
|
|
||||||
private final SchedulerService schedulerService;
|
private final SchedulerService schedulerService;
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ public class Post {
|
|||||||
private Double longitude;
|
private Double longitude;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private String phone;
|
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 Boolean isReward;
|
||||||
private String reward;
|
private String reward;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public interface PostMapper {
|
|||||||
|
|
||||||
@Mapping(source = "category", target = "category", qualifiedByName = "stringToPostCategory")
|
@Mapping(source = "category", target = "category", qualifiedByName = "stringToPostCategory")
|
||||||
@Mapping(source = "rewardText", target = "reward")
|
@Mapping(source = "rewardText", target = "reward")
|
||||||
|
@Mapping(source = "rulesAccepted", target = "rulesAccepted", qualifiedByName = "stringToBoolean")
|
||||||
Post toEntity(PostRequest request);
|
Post toEntity(PostRequest request);
|
||||||
|
|
||||||
@Mapping(source = "user.id", target = "userId")
|
@Mapping(source = "user.id", target = "userId")
|
||||||
@@ -23,8 +24,23 @@ public interface PostMapper {
|
|||||||
@Mapping(target = "category", expression = "java(post.getCategory().getDisplayName())")
|
@Mapping(target = "category", expression = "java(post.getCategory().getDisplayName())")
|
||||||
@Mapping(source = "isReward", target = "reward")
|
@Mapping(source = "isReward", target = "reward")
|
||||||
@Mapping(source = "reward", target = "rewardText")
|
@Mapping(source = "reward", target = "rewardText")
|
||||||
|
@Mapping(source = "rulesAccepted", target = "rulesAccepted", qualifiedByName = "booleanToString")
|
||||||
PostResponse toResponse(Post post);
|
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")
|
@Named("imagesToUrls")
|
||||||
default List<String> imagesToUrls(List<Image> images) {
|
default List<String> imagesToUrls(List<Image> images) {
|
||||||
if (images == null) return List.of();
|
if (images == null) return List.of();
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ public interface ChatRepository extends JpaRepository<Chat, Long> {
|
|||||||
@Query("SELECT c FROM Chat c WHERE c.userOneId = :u1 AND c.userTwoId = :u2")
|
@Query("SELECT c FROM Chat c WHERE c.userOneId = :u1 AND c.userTwoId = :u2")
|
||||||
Optional<Chat> findByUsers(Long u1, Long 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")
|
@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
|
List<Chat> findAllByUserId(Long userId); // Changed from Optional to List
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
// публичные эндпоинты
|
// публичные эндпоинты
|
||||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||||
.requestMatchers("/api/v1/test/**").permitAll()
|
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/ads-media/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/v1/ads-media/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
|
||||||
@@ -74,9 +73,13 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.POST, "/api/ads/click").permitAll()
|
.requestMatchers(HttpMethod.POST, "/api/ads/click").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/fullscreen-ad").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/fullscreen-ad").permitAll()
|
||||||
|
|
||||||
// пример разграничения по ролям — раскомментировать и адаптировать под свои admin-эндпоинты
|
// пример разграничения по ролям
|
||||||
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||||
|
|
||||||
|
// служебные endpoints: только для администратора
|
||||||
|
// (в методах стоит @PreAuthorize, здесь — первый рубеж)
|
||||||
|
.requestMatchers("/api/v1/test/**").hasRole("ADMIN")
|
||||||
|
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.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) {
|
private Chat getChatOrThrow(Long chatId) {
|
||||||
return chatRepository.findById(chatId).orElseThrow(() -> new IllegalArgumentException("Чат не найден"));
|
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.config.MinioProperties;
|
||||||
import com.krylov.refound.dto.DownloadedFile;
|
import com.krylov.refound.dto.DownloadedFile;
|
||||||
import com.krylov.refound.enums.ErrorCode;
|
import com.krylov.refound.enums.ErrorCode;
|
||||||
|
import com.krylov.refound.enums.Role;
|
||||||
import com.krylov.refound.exception.ApiException;
|
import com.krylov.refound.exception.ApiException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URLDecoder;
|
import java.net.URLDecoder;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.util.UriUtils;
|
import org.springframework.web.util.UriUtils;
|
||||||
@@ -40,11 +45,20 @@ import software.amazon.awssdk.core.exception.SdkClientException;
|
|||||||
public class FileStorageService {
|
public class FileStorageService {
|
||||||
private final S3Client s3Client;
|
private final S3Client s3Client;
|
||||||
private final MinioProperties properties;
|
private final MinioProperties properties;
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
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 Set<String> ALLOWED_CONTENT_TYPES = Set.of("image/jpeg", "image/png", "image/webp");
|
||||||
private static final String ORIGINAL_FILENAME_METADATA_KEY = "original-filename";
|
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 в корень бакета.
|
* Загружает файл в MinIO в корень бакета.
|
||||||
*/
|
*/
|
||||||
@@ -79,6 +93,7 @@ public class FileStorageService {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
||||||
|
registerOwner(objectName);
|
||||||
log.info("Файл '{}' успешно загружен.", objectName);
|
log.info("Файл '{}' успешно загружен.", objectName);
|
||||||
return objectName;
|
return objectName;
|
||||||
|
|
||||||
@@ -122,6 +137,8 @@ public class FileStorageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Удаляет файл из MinIO по его имени.
|
* Удаляет файл из MinIO по его имени.
|
||||||
|
* Служебный метод без проверки прав: вызывается только из кода, который сам
|
||||||
|
* проверил, что файл можно удалять (удаление поста, аватара, модерация).
|
||||||
*/
|
*/
|
||||||
public void deleteFile(String objectName) {
|
public void deleteFile(String objectName) {
|
||||||
try {
|
try {
|
||||||
@@ -132,6 +149,7 @@ public class FileStorageService {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
s3Client.deleteObject(request);
|
s3Client.deleteObject(request);
|
||||||
|
forgetOwner(objectName);
|
||||||
log.info("Файл '{}' успешно удален.", objectName);
|
log.info("Файл '{}' успешно удален.", objectName);
|
||||||
} catch (S3Exception | SdkClientException e) {
|
} catch (S3Exception | SdkClientException e) {
|
||||||
log.error("Ошибка при удалении файла '{}': {}", objectName, e.getMessage(), 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) {
|
private DownloadedFile fetchFile(String objectName) {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import com.krylov.refound.dto.user.UserResponseDto;
|
|||||||
import com.krylov.refound.dto.user.UserUpdateDto;
|
import com.krylov.refound.dto.user.UserUpdateDto;
|
||||||
import com.krylov.refound.entity.User;
|
import com.krylov.refound.entity.User;
|
||||||
import com.krylov.refound.enums.ErrorCode;
|
import com.krylov.refound.enums.ErrorCode;
|
||||||
|
import com.krylov.refound.enums.Role;
|
||||||
import com.krylov.refound.exception.ApiException;
|
import com.krylov.refound.exception.ApiException;
|
||||||
import com.krylov.refound.repository.UserRepository;
|
import com.krylov.refound.repository.UserRepository;
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -35,15 +35,48 @@ public class UserService {
|
|||||||
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "User not found", HttpStatus.NOT_FOUND));
|
.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) {
|
public UserResponseDto getUserById(Long id) {
|
||||||
User user = repository.findById(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);
|
return getUserResponseDto(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserResponseDto updateUser(Long id, UserUpdateDto dto, Boolean removeAvatar) {
|
public UserResponseDto updateUser(Long id, UserUpdateDto dto, Boolean removeAvatar) {
|
||||||
|
assertCanModifyUser(id);
|
||||||
|
|
||||||
User user = repository.findById(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.setLogin(dto.getLogin());
|
||||||
user.setName(dto.getFirstName());
|
user.setName(dto.getFirstName());
|
||||||
@@ -92,7 +125,13 @@ public class UserService {
|
|||||||
|
|
||||||
public Long getUserIdByLogin(String login) {
|
public Long getUserIdByLogin(String login) {
|
||||||
return repository.findUserByLogin(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) {
|
private static UserResponseDto getUserResponseDto(User user) {
|
||||||
|
|||||||
@@ -1,53 +1,169 @@
|
|||||||
package com.krylov.refound.util;
|
package com.krylov.refound.util;
|
||||||
|
|
||||||
import com.krylov.refound.security.JwtService;
|
import com.krylov.refound.security.JwtService;
|
||||||
|
import com.krylov.refound.service.ChatService;
|
||||||
import com.krylov.refound.service.UserService;
|
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.Message;
|
||||||
import org.springframework.messaging.MessageChannel;
|
import org.springframework.messaging.MessageChannel;
|
||||||
|
import org.springframework.messaging.MessagingException;
|
||||||
import org.springframework.messaging.simp.stomp.StompCommand;
|
import org.springframework.messaging.simp.stomp.StompCommand;
|
||||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||||
import org.springframework.messaging.support.ChannelInterceptor;
|
import org.springframework.messaging.support.ChannelInterceptor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class WsStompInterceptor implements ChannelInterceptor {
|
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 JwtService jwtService;
|
||||||
private final UserService userService;
|
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
|
@Override
|
||||||
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
||||||
|
|
||||||
StompHeaderAccessor accessor =
|
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
|
||||||
StompHeaderAccessor.wrap(message);
|
|
||||||
|
|
||||||
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
|
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
|
||||||
|
handleConnect(accessor);
|
||||||
String authorization = accessor.getFirstNativeHeader("Authorization");
|
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
|
||||||
|
handleSubscribe(accessor);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return message;
|
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:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update # на старте удобно
|
# Схемой управляет Liquibase. "update" молча правил таблицы мимо changelog,
|
||||||
|
# из-за чего базы расходились. Для разработки можно переопределить
|
||||||
|
# переменной окружения JPA_DDL_AUTO=update.
|
||||||
|
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||||
show-sql: true
|
show-sql: true
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
|
|||||||
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
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
databaseChangeLog:
|
databaseChangeLog:
|
||||||
- changeSet:
|
- changeSet:
|
||||||
id: 004-create-chats-messages
|
id: 014-create-chats
|
||||||
author: you
|
author: krylov
|
||||||
|
comment: >
|
||||||
|
Таблица чатов. Раньше создавалась Hibernate (ddl-auto: update),
|
||||||
|
поэтому changeset идемпотентен: если таблица уже есть — MARK_RAN.
|
||||||
|
preConditions:
|
||||||
|
onFail: MARK_RAN
|
||||||
|
not:
|
||||||
|
tableExists:
|
||||||
|
tableName: chats
|
||||||
changes:
|
changes:
|
||||||
- createTable:
|
- createTable:
|
||||||
tableName: chats
|
tableName: chats
|
||||||
@@ -14,4 +22,43 @@ databaseChangeLog:
|
|||||||
- addUniqueConstraint:
|
- addUniqueConstraint:
|
||||||
tableName: chats
|
tableName: chats
|
||||||
columnNames: user_one_id, user_two_id
|
columnNames: user_one_id, user_two_id
|
||||||
constraintName: uq_chat_users
|
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:
|
# УСТАРЕЛО. Файл намеренно не подключён в db.changelog-master.yaml.
|
||||||
- changeSet:
|
#
|
||||||
id: 005-drop-old-messages
|
# Раньше здесь был changeSet "005-drop-old-messages", который удалял таблицу
|
||||||
author: you
|
# messages вместе со всеми сообщениями. Подключение этого файла уничтожило бы
|
||||||
changes:
|
# переписку пользователей, поэтому он заменён на безопасную альтернативу.
|
||||||
- dropTable:
|
#
|
||||||
tableName: messages
|
# Актуальная миграция: db/changelog/add/017-messages-chat-id.yaml
|
||||||
cascadeConstraints: true
|
# Она добавляет messages.chat_id, создаёт чаты для старых диалогов
|
||||||
|
# и переносит сообщения, не удаляя данные.
|
||||||
- changeSet:
|
#
|
||||||
id: 006-create-messages-new
|
# Если чаты уже созданы Hibernate, 017 идемпотентна: preConditions + MARK_RAN.
|
||||||
author: you
|
databaseChangeLog: []
|
||||||
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
|
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ databaseChangeLog:
|
|||||||
- include: { file: db/changelog/alter/013-del-ad_id-click-impression.yaml }
|
- include: { file: db/changelog/alter/013-del-ad_id-click-impression.yaml }
|
||||||
- include: { file: db/changelog/create/011-create-fullscreen-ads.yaml }
|
- include: { file: db/changelog/create/011-create-fullscreen-ads.yaml }
|
||||||
- include: { file: db/changelog/constraint/010-idx-post-lat-lng-index.yaml }
|
- 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