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:
SlimusMinus
2026-09-30 00:27:58 +03:00
committed by GitHub
19 changed files with 2226 additions and 71 deletions

11
.gitignore vendored
View File

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

1565
README.MD Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.5'
id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7'
}
@@ -10,7 +10,7 @@ description = 'ReFound'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(25)
}
}
@@ -57,8 +57,8 @@ 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'

View File

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

View File

@@ -6,6 +6,7 @@ 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;
@@ -16,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/v1/test")
@RequiredArgsConstructor
@Slf4j
@PreAuthorize("hasRole('ADMIN')")
public class TestController {
private final SchedulerService schedulerService;

View File

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

View File

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

View File

@@ -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")
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
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,62 @@
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())) {
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 ")) {
@@ -39,15 +71,99 @@ public class WsStompInterceptor implements ChannelInterceptor {
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) {
throw new IllegalArgumentException("User not found");
log.warn("Subscribe без CONNECT: destination={}", destination);
throw new MessagingException("Ошибка авторизации");
}
accessor.getSessionAttributes().put("userId", userId);
if (isAdminsOnly(destination)) {
if (!isAdmin(role)) {
log.warn("Попытка подписки на служебный канал '{}' пользователем с ролью {}", destination, role);
throw new MessagingException("Доступ запрещён");
}
return;
}
return message;
Matcher unread = UNREAD_TOPIC.matcher(destination);
if (unread.matches()) {
Long targetUserId = Long.valueOf(unread.group(1));
if (!userId.equals(targetUserId)) {
log.warn("Попытка подписки на чужой счётчик непрочитанных: userId={} target={}", userId, targetUserId);
throw new MessagingException("Доступ запрещён");
}
return;
}
if (USER_QUEUE.matcher(destination).matches()) {
String[] parts = destination.split("/");
if (parts.length < 3 || !userId.toString().equals(parts[2])) {
log.warn("Попытка подписки на чужую личную очередь: userId={} destination={}", userId, destination);
throw new MessagingException("Доступ запрещён");
}
return;
}
Matcher chat = CHAT_TOPIC.matcher(destination);
if (chat.matches()) {
Long chatId = Long.valueOf(chat.group(1));
if (!chatService.isParticipant(chatId, userId)) {
log.warn("Пользователь {} не участник чата {} — подписка запрещена", userId, chatId);
throw new MessagingException("Доступ запрещён");
}
return;
}
// Неизвестные топики закрыты по умолчанию: иначе появится канал,
// который случайно разрешит чужие данные.
log.warn("Подписка на неизвестный канал '{}' пользователем {}", destination, userId);
throw new MessagingException("Доступ запрещён");
}
private boolean isAdminsOnly(String destination) {
return destination != null
&& (destination.startsWith("/topic/ads") || destination.startsWith("/topic/fullscreen-ads"));
}
private boolean isAdmin(String role) {
return "ADMIN".equalsIgnoreCase(role);
}
private Long userIdFromSession(StompHeaderAccessor accessor) {
if (accessor.getSessionAttributes() == null) {
return null;
}
Object value = accessor.getSessionAttributes().get(USER_ID_ATTR);
return value instanceof Long id ? id : null;
}
private String roleFromSession(StompHeaderAccessor accessor) {
if (accessor.getSessionAttributes() == null) {
return null;
}
Object value = accessor.getSessionAttributes().get(ROLE_ATTR);
return value instanceof String role ? role : null;
}
}

View File

@@ -15,7 +15,10 @@ spring:
jpa:
hibernate:
ddl-auto: update # на старте удобно
# Схемой управляет Liquibase. "update" молча правил таблицы мимо changelog,
# из-за чего базы расходились. Для разработки можно переопределить
# переменной окружения JPA_DDL_AUTO=update.
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: true
properties:
hibernate:

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,3 +26,6 @@ databaseChangeLog:
- 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 }