diff --git a/.gitignore b/.gitignore index c2065bc..5622bd9 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..eb539b9 --- /dev/null +++ b/README.MD @@ -0,0 +1,1565 @@ +# ReFound — документация проекта + +Привет! Это подробное описание проекта **ReFound** — бэкенд сайта объявлений +«Потерял / нашёл» (потерянные питомцы, вещи, документы и т.д.). + +Документ написан простым языком и отвечает на три вопроса: + +1. **Что здесь лежит** — какая папка за что отвечает. +2. **Как это связано** — что с чем общается, какой запрос куда идёт. +3. **Как это запустить** — что нужно поставить и как поднять локально. + +--- + +## Содержание + +1. [Что это за проект](#1-что-это-за-проект) +2. [Быстрый старт](#2-быстрый-старт) +3. [Технологии](#3-технологии) +4. [Структура проекта](#4-структура-проекта) +5. [Архитектура: как ходит запрос](#5-архитектура-как-ходит-запрос) +6. [База данных](#6-база-данных) +7. [API: все эндпоинты](#7-api-все-эндпоинты) +8. [Подсистемы подробно](#8-подсистемы-подробно) +9. [Кэширование в Redis](#9-кэширование-в-redis) +10. [Хранение файлов в MinIO](#10-хранение-файлов-в-minio) +11. [Настройки и переменные окружения](#11-настройки-и-переменные-окружения) +12. [Обработка ошибок](#12-обработка-ошибок) +13. [Известные проблемы и что стоит починить](#13-известные-проблемы-и-что-стоит-починить) +14. [Тесты](#14-тесты) +15. [Шпаргалка «где что искать»](#15-шпаргалка-где-что-искать) + +--- + +## 1. Что это за проект + +**ReFound** — это Spring Boot 3 бэкенд (Java 21) для социального сервиса +объявлений. База данных называется `nahodka` («находка»). + +Что умеет: + +| Возможность | Суть | +|---|---| +| Объявления | Пользователь создаёт объявление «потерял» или «нашёл». Фото грузятся в MinIO, координаты — на карту | +| Модерация через ИИ | Каждое новое объявление сначала уходит на проверку во внешний ИИ-сервис. Только после одобрения оно попадает в ленту | +| Карта | Все объявления с координатами рисуются маркерами на карте, с фильтром по прямоугольнику и типу | +| Избранное | Пользователь может «залайкать» объявление | +| Чаты | Переписка между двумя пользователями через WebSocket + STOMP | +| Отзывы | Публичные отзывы с рейтингом 1–5 | +| Статистика | Два счётчика: сколько объявлений создано, сколько найдено | +| Реклама | Ротация рекламных объявлений с весами, капами, загрузкой и перекодировкой видео через ffmpeg | +| Полноэкранная реклама | Ровно одна активная реклама на всё приложение (обычно видео при запуске) | + +Роли пользователя (`Role`): `USER`, `ADMIN`, `LOST_AND_FOUND`, `METRO`. + +--- + +## 2. Быстрый старт + +### 2.1 Что нужно поставить + +| Что | Версия | Зачем | +|---|---|---| +| JDK | 21 | Собирать и запускать | +| Docker + Docker Compose | любой свежий | Поднять БД, Redis, MinIO | +| PostgreSQL | 15 | Основная база (поднимается через docker-compose) | +| Redis | 7 | Кэш и refresh-токены (через docker-compose) | +| MinIO | — | Хранилище картинок и видео (через docker-compose) | +| **ffmpeg** | 9.x | Перекодировка рекламных видео. **Ставится вручную**, в контейнере его нет | +| **ИИ-сервис модерации** | — | Отдельное приложение на порту 8000. **В этом репозитории его нет** | + +### 2.2 Поднять инфраструктуру + +```bash +docker compose up -d +``` + +Поднимется три сервиса: + +| Сервис | Порт | Логин/пароль | +|---|---|---| +| PostgreSQL (БД `nahodka`) | `5432` | `user` / `pass` | +| Redis | `6379` (только localhost) | пароль из `REDIS_PASSWORD` | +| MinIO API | `9010` | ключи из `.env` | +| MinIO веб-консоль | `9011` | — | + +> **Важно:** у MinIO в `docker-compose.yaml` захардкожены свои ключи (`admin` / `admin123`), +> а приложение читает ключи из `.env`. Если они не совпадают — приложение не сможет +> подключиться к MinIO. Приведите их к одним и тем же значениям. + +Бакеты MinIO (`ads-media`, `refound-images`) создаёт служебный контейнер `mc-init` +при первом запуске. Он же накладывает CORS-правила — но **только на `ads-media`**. + +### 2.3 Заполнить `.env` + +В корне проекта уже лежит `.env`. Он **не** читается приложением напрямую — +Spring Boot подставляет переменные из окружения процесса. Поэтому: + +- `docker compose` подхватывает `.env` сам (для контейнеров); +- для приложения нужно либо задать переменные в IDE (Run Configuration → Environment variables), + либо экспортировать их в терминале. + +Что должно быть в `.env`: + +| Переменная | Обязательна? | +|---|---| +| `MINIO_ACCESS_KEY` | **да**, иначе приложение не стартует | +| `MINIO_SECRET_KEY` | **да**, иначе приложение не стартует | +| `REDIS_PASSWORD` | **да**, иначе приложение не стартует | +| `JWT_SECRET` | нет, но дефолт в коде небезопасный — задайте свой | +| `MINIO_ENDPOINT`, `MINIO_BUCKET`, `MINIO_ADS_BUCKET`, `MINIO_REGION`, `REDIS_HOST`, `AI_SERVICE_URL`, `JWT_ACCESS_EXPIRATION`, `JWT_REFRESH_EXPIRATION` | нет, есть дефолты | + +### 2.4 Запустить приложение + +```bash +./gradlew bootRun # Linux / macOS +gradlew.bat bootRun # Windows +``` + +При первом старте автоматически: +1. Liquibase прогоняет миграции и создаёт/обновляет схему. +2. Hibernate в режиме `validate` проверяет, что схема совпадает с сущностями, + и падает с внятной ошибкой при расхождении. Ничего не переписывает. +3. `StorageStructureService` создаёт бакет `refound-images`, включает версионирование и правило «удалять через 180 дней». + +> `.env` читает только `docker compose`. Сам Spring Boot переменные из `.env` +> не подхватывает — экспортируйте их в терминал или задайте в настройках +> запуска IDE. Шаблон смотрите в `.env.example`. + +### 2.5 Задать ffmpeg + +В `src/main/resources/application.yaml` прописан **абсолютный путь под Windows**: + +```yaml +ffmpeg: + binary-path: C:\ffmpeg\ffmpeg-9.0.2-essentials_build\bin\ffmpeg.exe +``` + +На другой машине путь нужно поменять, иначе перекодировка рекламы будет падать +(приложение при этом стартует нормально — ошибка всплывёт только при загрузке видео). + +--- + +## 3. Технологии + +Всё взято из `build.gradle`. + +| Слой | Технология | Зачем именно | +|---|---|---| +| Язык | Java 21 | toolchain, задан в `build.gradle` | +| Фреймворк | Spring Boot 3.2.5 | всё остальное | +| Веб | `spring-boot-starter-web` | REST | +| Реалтайм | `spring-boot-starter-websocket` | STOMP-чат | +| Реактивный клиент | `spring-boot-starter-webflux` (`WebClient`) | походы в ИИ-сервис | +| Данные | `spring-boot-starter-data-jpa` + Hibernate | ORM | +| Миграции | Liquibase | история изменений схемы | +| БД | PostgreSQL | основное хранилище | +| Кэш | `spring-boot-starter-data-redis` | лента, карта, refresh-токены, капы рекламы | +| Безопасность | `spring-boot-starter-security` + `jjwt 0.12.5` | JWT-авторизация | +| Маппинг | MapStruct 1.5.5 | `Post` ⇄ `PostRequest`/`PostResponse` без ручного кода | +| S3 | `spring-cloud-aws-starter-s3` 3.1.1 + `io.minio:minio:8.6.0` | работа с MinIO | +| Мониторинг | `spring-boot-starter-actuator` | health-эндпоинты | +| Валидация | `spring-boot-starter-validation` | `@NotBlank`, `@Min` и т.п. | +| Повторы | `spring-retry` + `spring-aspects` | повторные попытки до ИИ | +| Логирование | Lombok `@Slf4j` | везде | +| Сборка | Gradle | — | + +--- + +## 4. Структура проекта + +``` +D:\reFound\ReFound\ +│ +├── build.gradle Список зависимостей и версия Java +├── settings.gradle Имя Gradle-проекта +├── gradlew / gradlew.bat Обёртки Gradle +├── docker-compose.yaml PostgreSQL + Redis + MinIO + служебный mc-init +├── .env Переменные окружения (СЕКРЕТЫ — см. раздел 13) +├── .gitignore Что не коммитим (но .env там нет — см. раздел 13) +├── HELP.md Шаблон Spring Initializr, в .gitignore +│ +├── minio/ +│ └── cors.json CORS-правило для бакета ads-media (нужно для +│ прямой загрузки видео из браузера) +│ +├── data/ Локальные данные MinIO (создаётся автоматически) +│ +├── build/, .gradle/, .idea/ Служебное, генерируется само +│ +└── src/ + ├── main/ + │ ├── java/com/krylov/refound/ + │ │ ├── ReFoundApplication.java Точка входа + включение фич + │ │ │ + │ │ ├── controller/ ← ВХОД: сюда приходят HTTP-запросы + │ │ │ ├── AuthController.java + │ │ │ ├── PostController.java + │ │ │ ├── MapController.java + │ │ │ ├── FavoriteController.java + │ │ │ ├── UserController.java + │ │ │ ├── FileStorageController.java + │ │ │ ├── ChatController.java + │ │ │ ├── ChatWebSocketController.java ← приём STOMP-сообщений + │ │ │ ├── ReviewsController.java + │ │ │ ├── StatisticsController.java + │ │ │ ├── TestController.java + │ │ │ └── advertising/ Админка рекламы + отдача рекламы + │ │ │ ├── CampaignAdminController.java + │ │ │ ├── AdvertisingAdminController.java + │ │ │ ├── FullscreenAdAdminController.java + │ │ │ ├── AdvertisingController.java + │ │ │ ├── FullscreenAdController.java + │ │ │ └── AdvertisingMediaController.java + │ │ │ + │ │ ├── service/ ← БИЗНЕС-ЛОГИКА: что делать + │ │ │ ├── AuthService.java + │ │ │ ├── RefreshTokenService.java + │ │ │ ├── UserService.java + │ │ │ ├── PostService.java (самый большой — CRUD + лента) + │ │ │ ├── PostModerationExecutorService.java + │ │ │ ├── SchedulerService.java фоновые задачи + │ │ │ ├── FileStorageService.java работа с MinIO + │ │ │ ├── GeocodingService.java координаты → город/район + │ │ │ ├── MapService.java + │ │ │ ├── FavoriteService.java + │ │ │ ├── ChatService.java + │ │ │ ├── ReviewService.java + │ │ │ ├── StatisticsService.java + │ │ │ ├── redis/ КЭШИРОВАНИЕ + │ │ │ │ ├── PostCacheService.java + │ │ │ │ ├── PostCacheKeyGenerator.java + │ │ │ │ ├── MapCacheService.java + │ │ │ │ ├── MapCacheKeyGenerator.java + │ │ │ │ └── RedisCacheKeyUtil.java + │ │ │ └── advertising/ Вся логика рекламы + │ │ │ ├── AdvertisingSelectionService.java выбор объявления + │ │ │ ├── AdvertisingFrequencyService.java капы и cooldown + │ │ │ ├── AdvertisingImpressionService.java учёт показов + │ │ │ ├── AdvertisingClickService.java учёт кликов + │ │ │ ├── AdvertisingMediaService.java загрузка/выдача медиа + │ │ │ ├── AdvertisingTranscodingService.java + │ │ │ ├── AdvertisingStatusService.java + │ │ │ ├── AdvertisingAdminService.java + │ │ │ ├── CampaignService.java + │ │ │ ├── VideoTranscoderService.java запуск ffmpeg + │ │ │ ├── VideoUploadEventListener.java + │ │ │ ├── FullscreenAdTranscodingService.java + │ │ │ ├── FullscreenAdStatusService.java + │ │ │ └── FullscreenAdVideoUploadEventListener.java + │ │ │ + │ │ ├── ai/ ← МОДЕРАЦИЯ ЧЕРЕЗ ИИ + │ │ │ ├── facade/ContentModerationFacade.java единая точка входа + │ │ │ ├── pipeline/ + │ │ │ │ ├── ContentModerationPipeline.java прогон всех проверок + │ │ │ │ ├── ModerationProcessor.java интерфейс проверки + │ │ │ │ ├── ModerationContext.java данные для проверки + │ │ │ │ ├── TextModerationProcessor.java проверка текста (order 10) + │ │ │ │ └── ImageModerationProcessor.java проверка картинок (order 20) + │ │ │ ├── client/ + │ │ │ │ ├── AiHttpClient.java HTTP + JSON к ИИ + │ │ │ │ ├── AiMultipartHttpClient.java HTTP + файлы к ИИ + │ │ │ │ ├── TextModerationClient.java + повторы + │ │ │ │ └── ImageModerationClient.java + повторы + │ │ │ ├── config/ (AiProperties, AiRetryProperties, WebClientConfig) + │ │ │ ├── dto/ (запросы/ответы ИИ) + │ │ │ └── exception/ (Ai*, ContentBlockedException) + │ │ │ + │ │ ├── entity/ ← ТАБЛИЦЫ БД (объекты JPA) + │ │ │ ├── User.java, Post.java, Image.java, Favorite.java, + │ │ │ ├── Message.java, Chat.java, Review.java, Statistics.java + │ │ │ └── advertising/ Campaign, Advertising, Impression, Click, + │ │ │ FullscreenAdvertising + │ │ │ + │ │ ├── repository/ ← ЗАПРОСЫ К БД + │ │ │ ├── UserRepository.java, PostRepository.java, ImageRepository.java, + │ │ │ ├── FavoriteRepository.java, MessageRepository.java, + │ │ │ ├── ChatRepository.java, ReviewRepository.java, StatisticsRepository.java + │ │ │ └── advertising/ Advertising, Campaign, Impression, Click, + │ │ │ FullscreenAdvertising + │ │ │ + │ │ ├── security/ ← АВТОРИЗАЦИЯ + │ │ │ ├── SecurityConfig.java правила доступа + CORS + │ │ │ ├── JwtService.java создание и проверка токенов + │ │ │ └── JwtAuthenticationFilter.java читает заголовок Authorization + │ │ │ + │ │ ├── config/ ← НАСТРОЙКА БИНОВ + │ │ │ ├── MinioProperties.java типизированные minio.* + │ │ │ ├── RedisConfig.java три RedisTemplate + │ │ │ ├── S3ClientConfig.java S3Client + S3Presigner + │ │ │ ├── AsyncConfig.java пул потоков ffmpeg (2 потока) + │ │ │ ├── RetryConfig.java включение @Retryable + │ │ │ └── WebSocketConfig.java STOMP: /ws, брокер /topic, префикс /app + │ │ │ + │ │ ├── dto/ ← ФОРМЫ ЗАПРОСОВ И ОТВЕТОВ + │ │ │ ├── AuthRequest, RegisterRequest, RefreshRequest, AuthResponse... + │ │ │ ├── PostRequest, PostResponse, StatusUpdateRequest, MapMarkerDto... + │ │ │ ├── user/ (UserDto, UserUpdateDto, UserResponseDto) + │ │ │ └── advertising/ (Campaign*, Advertising*, Presign*, Click*) + │ │ │ + │ │ ├── enums/ ← ПЕРЕЧИСЛЕНИЯ + │ │ │ ├── Role, PostType, PostStatus, PostCategory, ErrorCode + │ │ │ └── advertising/ CampaignStatus, AdvertisingType, + │ │ │ TranscodingStatus, FullscreenAdStatus + │ │ │ + │ │ ├── exception/ ← ОШИБКИ + │ │ │ ├── GlobalExceptionHandler.java @RestControllerAdvice + │ │ │ ├── ErrorResponse.java тело ответа с ошибкой + │ │ │ ├── ApiException.java + │ │ │ ├── InvalidCredentialsException, LoginAlreadyExistsException, + │ │ │ └── InvalidCampaignStatusTransitionException + │ │ │ + │ │ ├── mapper/PostMapper.java MapStruct + │ │ ├── util/ + │ │ │ ├── PostSpecification.java динамические WHERE-запросы + │ │ │ ├── PostVisibility.java «какие статусы видны в ленте» + │ │ │ ├── StringToPostTypeConverter.java + │ │ │ ├── WsStompInterceptor.java авторизация WebSocket + │ │ │ └── ByteArrayMultipartFile.java картинка из памяти как MultipartFile + │ │ └── health/ + │ │ └── StorageStructureService.java автосоздание бакета при старте + │ │ + │ └── resources/ + │ ├── application.yaml вся конфигурация + │ └── db/changelog/ миграции Liquibase + │ ├── db.changelog-master.yaml ← главный файл, порядок include + │ ├── create/ 001…011 + │ ├── add/ 003…016 + │ ├── alter/ 010…013 + │ └── constraint/ 006…010 + │ + └── test/java/com/krylov/refound/ + └── ReFoundApplicationTests.java один smoke-тест +``` + +### 4.1 Как связаны слои + +Правило простое, сверху вниз: + +``` +HTTP-запрос + ↓ +[security] JwtAuthenticationFilter — достаёт токен → кладёт в SecurityContext + ↓ +[controller] — принимает запрос, проверяет права, валидирует вход (bean validation) + ↓ +[service] — вся бизнес-логика, транзакции + ↓ может позвать: другой service | ai | repository | redis | s3 +[repository] — SQL-запросы к PostgreSQL +``` + +Обратно: `service` собирает `entity`, `mapper` (MapStruct) превращает её в `dto`, +`controller` возвращает `dto`. Наружу наружу уходят **DTO**, а не сущности. + +### 4.2 Кто кого вызывает (главное) + +``` +PostController ──► PostService ──┬──► PostRepository (SELECT/UPDATE в БД) + ├──► ImageRepository (картинки) + ├──► FileStorageService (MinIO: загрузка/удаление) + ├──► GeocodingService (Nominatim: lat/lon → город) + ├──► UserService (кто текущий пользователь) + ├──► StatisticsService (счётчики) + ├──► PostCacheService (Redis: сброс кэша ленты) + └──► MapCacheService (Redis: сброс кэша карты) +``` + +``` +SchedulerService (каждую минуту) + └──► PostModerationExecutorService ──► ContentModerationFacade + └──► ContentModerationPipeline + ├──► TextModerationProcessor (order 10) + │ └──► TextModerationClient + └──► ImageModerationProcessor (order 20) + └──► ImageModerationClient + оба → AiHttpClient / AiMultipartHttpClient + └──► HTTP на ai.url (порт 8000) +``` + +``` +AdvertisingController ──► AdvertisingSelectionService + ├──► AdvertisingFrequencyService (Redis: cooldown + кап) + └──► AdvertisingRepository (findEligibleAds) + +Админ загружает видео: +AdvertisingAdminController + → AdvertisingAdminService + AdvertisingMediaService → presigned URL + → (браузер сам кладёт файл в MinIO) + → confirm-upload: publishEvent(VideoUploadConfirmedEvent) + → VideoUploadEventListener (@TransactionalEventListener AFTER_COMMIT) + → AdvertisingTranscodingService (@Async "ffmpegExecutor") + → VideoTranscoderService (запускает ffmpeg) + → AdvertisingStatusService (ставит READY/FAILED + шлёт статус в WebSocket) +``` + +--- + +## 5. Архитектура: как ходит запрос + +Полный путь любого защищённого запроса: + +``` +1. Браузер шлёт Authorization: Bearer +2. JwtAuthenticationFilter проверяет подпись и срок → кладёт в SecurityContext +3. SecurityConfig решает: permitAll / authenticated / hasRole("ADMIN") +4. @Valid на аргументах контроллера проверяет вход +5. Контроллер → сервис +6. Сервис → репозиторий / Redis / MinIO / ИИ +7. Service → DTO → Jackson → JSON клиенту +8. Если что-то упало → GlobalExceptionHandler формирует ErrorResponse +``` + +### 5.1 Что пропускает внутрь, а что нет + +Правила проверяются **по порядку, первое совпадение выигрывает**. + +| Правило | Кто попадёт | +|---|---| +| `/api/v1/auth/**` | кто угодно, без токена | +| `/api/v1/test/**` | кто угодно (⚠️ там деструктивные ручки) | +| `GET /api/v1/files/**` | кто угодно — посмотреть картинку | +| `GET /api/v1/ads-media/**` | кто угодно — посмотреть рекламу | +| `GET /api/v1/posts/**` | кто угодно — публичная лента | +| `GET /api/v1/reviews/**` | кто угодно — почитать отзывы | +| `/ws/**` | кто угодно на рукопожатие; токен проверяется позже, на STOMP CONNECT | +| `POST`/`DELETE /api/v1/files/**` | нужен токен | +| `POST`/`PUT`/`DELETE /api/v1/posts/**` | нужен токен | +| `POST`/`PUT`/`DELETE /api/v1/reviews/**` | нужен токен | +| `GET /api/ads/next`, `POST /api/ads/impression`, `POST /api/ads/click`, `GET /api/fullscreen-ad` | кто угодно (реклама показывается и гостям) | +| `/api/v1/admin/**` | только роль `ADMIN` | +| **всё остальное** | нужен токен (чаты, избранное, статистика, пользователь) | + +Сессий на сервере нет (`STATELESS`) — каждый запрос сам по себе приносит токен. + +--- + +## 6. База данных + +PostgreSQL, база `nahodka`. Схему версионирует **Liquibase** через +`src/main/resources/db/changelog/db.changelog-master.yaml`. + +### 6.1 Порядок миграций + +`db.changelog-master.yaml` подключает 30 файлов именно в таком порядке: + +| # | Файл | Что делает | +|---|---|---| +| 1 | `create/001-create-users.yaml` | таблица `users` | +| 2 | `create/002-create-posts.yaml` | таблица `posts` | +| 3 | `create/003-create-images.yaml` | таблица `images` | +| 4 | `create/004-create-message.yaml` | таблица `messages` (старый формат) | +| 5 | `create/005-create-favorite.yaml` | таблица `favorites` | +| 6 | `constraint/006-fk-images-post-constraint.yaml` | FK images → posts | +| 7 | `constraint/007-fk-post-user-constraint.yaml` | FK posts → users | +| 8 | `constraint/008-fk-message-user.yaml` | FK messages → users + индекс | +| 9 | `constraint/009-fk-favorite-user.yaml` | FK favorites + уникальность (user, post) | +| 10 | `alter/010-alter-user.yaml` | переименование `email` → `login` | +| 11 | `add/011-add-name-user.yaml` | `users.name` | +| 12 | `add/012-add-email-role.yaml` | `users.email`, `users.role` | +| 13 | `add/003-add-phone-posts.yaml` | `posts.phone` | +| 14 | `add/004-add-fields-user.yaml` | `users.last_name`, `users.phone` | +| 15 | `add/005-add-user-avatarUrl.yaml` | `users.avatar_url` | +| 16 | `create/008-create-reviews.yaml` | таблица `reviews` | +| 17 | `add/013-add-rules_accepted-posts.yaml` | `posts.rules_accepted` | +| 18 | `create/009-create-statistic.yaml` | таблица `statistics` + одна строка `id=1` | +| 19 | `alter/011-alter-posts-district-length.yaml` | `posts.district` → VARCHAR(500) | +| 20 | `add/014-add-reward-posts.yaml` | `posts.is_reward` | +| 21 | `add/015-add-desc-reward-posts.yaml` | `posts.reward` | +| 22 | `create/010-add-advertising.yaml` | `campaign`, `advertising`, `impression`, `click` | +| 23 | `add/016-add-sessionid-click.yaml` | `click.session_id` | +| 24 | `alter/012-advertising-media_key.yaml` | снимает NOT NULL с `advertising.media_key` | +| 25 | `alter/013-del-ad_id-click-impression.yaml` | удаление несуществующих колонок (MARK_RAN) | +| 26 | `create/011-create-fullscreen-ads.yaml` | `fullscreen_advertising` + уникальный индекс | +| 27 | `constraint/010-idx-post-lat-lng-index.yaml` | индекс по широте/долготе для карты | +| 28 | `create/006-create-chats.yaml` | таблица `chats` + индексы по участникам | +| 29 | `add/017-messages-chat-id.yaml` | перевод `messages` на чаты: `chat_id`, FK с `ON DELETE CASCADE`, удаление `receiver_id` | +| 30 | `alter/014-posts-rules-accepted-type.yaml` | `posts.rules_accepted` → `boolean` | + +> Схемой управляет **только** Liquibase: `spring.jpa.hibernate.ddl-auto` = +> `${JPA_DDL_AUTO:validate}`. Раньше стоял `update`, и Hibernate дописывал +> таблицы мимо changelog — из-за чего `chats` и часть колонок расходились +> с описанием. Для локальной разработки можно вернуть `update` через +> `JPA_DDL_AUTO=update`, но тогда миграции перестанут быть источником правды. + +> `create/007-recreate-messages.yaml` отключён намеренно: он удалял таблицу +> `messages` целиком вместе с перепиской. Его работа заменена миграцией +> `add/017-messages-chat-id.yaml`, которая переносит данные, а не уничтожает их. +> +> Новые changeset-ы написаны идемпотентно: `preConditions` + `onFail: MARK_RAN`, +> поэтому базы, где схему уже создал Hibernate, обновляются без конфликтов. + +### 6.2 Таблицы простым языком + +#### `users` — пользователи + +| Колонка | Тип | Что значит | +|---|---|---| +| `id` | BIGSERIAL PK | идентификатор | +| `login` | VARCHAR(255) NOT NULL UNIQUE | логин (раньше назывался `email`) | +| `password` | VARCHAR(255) NOT NULL | BCrypt-хеш | +| `name`, `last_name` | VARCHAR(255) | имя и фамилия | +| `email` | VARCHAR(255) | обычная почта (не логин!) | +| `phone` | VARCHAR(255) | телефон | +| `avatar_url` | VARCHAR(255) | ключ картинки аватара в MinIO | +| `role` | VARCHAR(255) | `USER` / `ADMIN` / `LOST_AND_FOUND` / `METRO` | +| `created_at` | TIMESTAMP | проставляется в `@PrePersist` | + +#### `posts` — объявления + +| Колонка | Тип | Что значит | +|---|---|---| +| `id` | BIGSERIAL PK | | +| `type` | VARCHAR(20) | `LOSS` (потерял) / `FOUND` (нашёл) | +| `title` | VARCHAR(255) | заголовок | +| `description` | TEXT | описание | +| `category` | VARCHAR(100) | `ANIMAL` / `THING` / `OTHER` | +| `city`, `district` | VARCHAR | получены из координат | +| `latitude`, `longitude` | DOUBLE | точка на карте | +| `status` | VARCHAR(20) | `MODERATION` / `ACTIVE` / `CLOSED` / `REJECTED` | +| `created_at` | TIMESTAMP | ставится в сервисе | +| `user_id` | BIGINT FK → users | автор | +| `phone` | VARCHAR(255) | телефон для связи | +| `rules_accepted` | boolean NOT NULL | принял ли правила | +| `is_reward` | boolean | обещает награду | +| `reward` | VARCHAR(255) | текст награды | + +Индекс: `idx_post_lat_lng (latitude, longitude)` — частичный, только для строк с координатами. + +#### `images` — картинки объявления + +| Колонка | Что значит | +|---|---| +| `id` PK | | +| `url` VARCHAR(500) | **ключ объекта в MinIO**, а не URL | +| `post_id` FK → posts | | + +В БД каскада нет — при удалении объявления картинки из БД удаляет сервис, а файлы в MinIO — `FileStorageService`. + +#### `favorites` — избранное + +`id`, `user_id` FK → users, `post_id` FK → posts. +Уникальное ограничение `unique_user_post (user_id, post_id)` — нельзя добавить дважды. + +#### `messages` — сообщения чатов + +| Колонка | Что значит | +|---|---| +| `id` PK | | +| `chat_id` BIGINT NOT NULL, FK → `chats.id` **ON DELETE CASCADE** | чат, которому принадлежит сообщение | +| `sender_id` BIGINT NOT NULL, FK → `users.id` | кто отправил | +| `content` TEXT NOT NULL | текст | +| `is_read` BOOLEAN NOT NULL | прочитано ли | +| `created_at` NOT NULL | | + +Индекс `idx_messages_chat_created (chat_id, created_at)` обслуживает историю +чата и подсчёт непрочитанных. `receiver_id` удалён миграцией +`add/017-messages-chat-id.yaml` — получатель определяется через `chats`. + +Каскад `ON DELETE CASCADE` обязателен: `ChatService.deleteChat` удаляет чат, +и без каскада удаление падало бы на внешнем ключе. + +#### `chats` — диалоги + +| Колонка | Что значит | +|---|---| +| `id` PK | | +| `user_one_id` BIGINT NOT NULL | первый участник, **всегда меньший id** (для уникальности пары) | +| `user_two_id` BIGINT NOT NULL | второй участник, всегда больший id | +| `post_id` | объявление, по поводу которого начат чат (необязательно) | +| `created_at` NOT NULL | | + +Уникальное ограничение на `(user_one_id, user_two_id)` плюс индексы +`idx_chats_user_one` и `idx_chats_user_two` — по ним ищутся чаты пользователя. +Внешних ключей на пользователей нет — только логические связи. Пары +упорядочиваются, чтобы не было дублей. + +#### `reviews` — отзывы + +`id`, `author_name`, `rating` INTEGER NOT NULL (1–5), `text` VARCHAR(500), `created_at`. +Связи с `users` нет — отзыв анонимный. + +#### `statistics` — счётчики + +`id` (всегда 1, ручное значение — поэтому в сущности нет `@GeneratedValue`), +`posts_created`, `posts_found`. + +#### Реклама: `campaign` + +| Колонка | Что значит | +|---|---| +| `id` PK | | +| `name` NOT NULL | название кампании | +| `status` NOT NULL, CHECK | `DRAFT` / `PAUSED` / `ACTIVE` / `FINISHED` | +| `start_date`, `end_date` NOT NULL | окно показа (включительно) | +| `daily_impression_cap` NOT NULL, дефолт 10 | лимит показов на пользователя | +| `created_at` | | + +#### Реклама: `advertising` + +| Колонка | Что значит | +|---|---| +| `id` PK | | +| `campaign_id` BIGINT NOT NULL FK → campaign | какая кампания | +| `type` VARCHAR(10) NOT NULL CHECK | `IMAGE` / `VIDEO` | +| `media_key` VARCHAR(512), **NULL разрешён** | ключ файла в MinIO; для видео появляется только после перекодировки | +| `target_url` VARCHAR(1024) NOT NULL | куда ведёт клик | +| `weight` INTEGER NOT NULL, дефолт 1 | вес в случайной ротации | +| `transcoding_status` NOT NULL CHECK | `PENDING` / `PROCESSING` / `READY` / `FAILED` | +| `created_at` | | + +#### Реклама: `impression` и `click` + +| `impression` | `click` | Что значит | +|---|---|---| +| `advertising_id` NOT NULL FK | `advertising_id` NOT NULL FK | какое объявление | +| `user_id` (без FK) | `user_id` (без FK) | кто, `NULL` у гостей | +| `session_id` NOT NULL VARCHAR(64) | `session_id` **nullable** VARCHAR(64) | id сессии | +| `shown_at` | `clicked_at` | когда | + +#### `fullscreen_advertising` — полноэкранная реклама + +| Колонка | Что значит | +|---|---| +| `id` PK | | +| `media_key`, `target_url` | могут быть пустыми (создаём «оболочку», потом заливаем видео) | +| `status` NOT NULL, CHECK | `DRAFT` / `ACTIVE` / `INACTIVE` | +| `transcoding_status` NOT NULL CHECK | как у `advertising` | +| `created_at` | | + +Ключевая деталь: **частичный уникальный индекс** + +```sql +CREATE UNIQUE INDEX uq_fullscreen_ad_single_active + ON fullscreen_advertising (status) WHERE status = 'ACTIVE'; +``` + +Он гарантирует на уровне БД, что активная полноэкранная реклама может быть **только одна**, +даже при гонке запросов. + +### 6.3 Схема связей + +``` + ┌──────────┐ + │ users │ + └────┬─────┘ + ┌─────────┼──────────┬──────────────┬───────────────┐ + │ │ │ │ │ +┌──┴─────┐ ┌─┴────────┐ │ ┌─────┴──────┐ ┌─────┴──────┐ +│ posts │ │favorites │ │ │ messages │ │ chats │ +│ │ └──────────┘ │ └─────┬──────┘ └─────┬──────┘ +│ user_id│ │ │ chat_id │ user_one_id +└───┬────┘ │ │ (FK, CASCADE) │ user_two_id + │ │ └────────────────┘ + │ fk_images_post │ +┌───┴──────┐ │ +│ images │ │ +│ post_id │ │ +└──────────┘ │ + + ┌──────────┐ ┌──────────┐ ┌───────────────┐ + │ reviews │ │statistics│ │fullscreen_ad │ ← ни с чем не связаны + └──────────┘ └──────────┘ └───────────────┘ + + РЕКЛАМА (отдельно, к пользователям не привязана) + + ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌────────┐ + │ campaign │──fk───►│ advertising │──fk──►│ impression │ │ click │ + └──────────┘ └──────────────┘ └────────────┘ └────────┘ +``` + +Всего создано **10 внешних ключей**. `impression.user_id` и `click.user_id` — «висячие»: +значение есть, а FK на `users` нет. `messages.chat_id → chats.id` — единственный +FK с `ON DELETE CASCADE`. + +--- + +## 7. API: все эндпоинты + +Всего **57 REST-эндпоинтов** + 1 WebSocket. + +### 7.1 Авторизация — `/api/v1/auth` (публично) + +| Метод | Путь | Что делает | Ответ | +|---|---|---|---| +| POST | `/api/v1/auth/register` | регистрация | `AuthResponse` (токены + профиль) | +| POST | `/api/v1/auth/login` | вход | `AuthResponse` | +| GET | `/api/v1/auth/check-login?login=` | проверка, занят ли логин | `{"exists": true/false}` | +| POST | `/api/v1/auth/refresh` | обновить access-токен (refresh ротируется) | `AuthResponse` | +| POST | `/api/v1/auth/logout` | отозвать refresh-токен | 204 | + +Пароли хранятся как BCrypt-хеш (10 раундов). Access-токен живёт 15 минут, refresh — 7 дней. +Refresh-токен в Redis хранится в виде `refresh_token:` → `login`, а наружу отдаётся строка `.`. + +### 7.2 Объявления — `/api/v1/posts` + +| Метод | Путь | Доступ | Что делает | +|---|---|---|---| +| POST | `/api/v1/posts` | токен | создать (multipart). Ставится статус `MODERATION`. Геокодирование. Чистит кэши | +| GET | `/api/v1/posts?search=&page=&size=` | публично | лента с поиском, кэш 2 мин | +| GET | `/api/v1/posts/{id}` | публично | ⚠️ **все объявления пользователя `id`**, а не одно объявление | +| PUT | `/api/v1/posts/{id}` | токен, только автор | правка (multipart). Снова `MODERATION` | +| DELETE | `/api/v1/posts/{id}` | токен, только автор | удалить вместе с картинками в БД и MinIO | +| PATCH | `/api/v1/posts/{id}/status` | токен, только автор | `ACTIVE` ⇄ `CLOSED`. Нельзя снять с модерации мимо ИИ | +| GET | `/api/v1/posts/search?city=` | публично | поиск по городу/району | +| GET | `/api/v1/posts/cities` | публично | список городов с видимыми объявлениями | +| GET | `/api/v1/posts/detail/{postId}` | публично | одно объявление. Неопубликованное отдаёт 404 гостям | + +### 7.3 Карта — `/api/v1` + +| Метод | Путь | Доступ | Что делает | +|---|---|---|---| +| GET | `/api/v1/posts/map?minLat=&maxLat=&minLng=&maxLng=&type=&category=` | публично | маркеры в прямоугольнике, кэш 5 мин | + +Координаты округляются к сетке 0.01°, чтобы близкие «зумы» карты попадали в один ключ кэша. + +### 7.4 Файлы — `/api/v1/files` + +| Метод | Путь | Доступ | Что делает | +|---|---|---|---| +| POST | `/api/v1/files` | токен | одна картинка → возвращает ключ | +| GET | `/api/v1/files/{*objectName}` | публично | отдать файл байтами | +| DELETE | `/api/v1/files/{*objectName}` | токен | удалить **свой** файл (или любой для `ADMIN`) | + +Проверки загрузки: не пустой, есть имя, ≤ 10 МБ, тип ∈ {`image/jpeg`, `image/png`, `image/webp`}. +При загрузке владелец файла запоминается в Redis (`files:owner:`, +TTL 180 дней). Удалить можно только свой файл; если отметки о владельце нет +(файл загружен до появления проверки) — доступ закрывается. + +### 7.5 Пользователь — `/api/v1/users` + +| Метод | Путь | Доступ | Что делает | +|---|---|---|---| +| GET | `/api/v1/users/{id}` | токен | профиль | +| PUT | `/api/v1/users/{id}` | токен | обновить профиль и аватар (multipart) | + +`PUT` доступен только владельцу профиля или пользователю с ролью `ADMIN`, +иначе `403`. Логин должен оставаться уникальным. + +### 7.6 Избранное — `/api/v1/favorites` + +| Метод | Путь | Что делает | +|---|---|---| +| POST | `/api/v1/favorites/{postId}/toggle` | добавить или убрать | +| GET | `/api/v1/favorites` | список (без `userRole`) | +| GET | `/api/v1/favorites/posts` | список (с `userRole`) — дубликат предыдущего | + +### 7.7 Чаты + +REST (нужен токен): + +| Метод | Путь | Что делает | +|---|---|---| +| POST | `/api/v1/chats/with/{otherUserId}?postId=` | создать или получить чат | +| GET | `/api/v1/chats` | мои чаты + последнее сообщение + счётчик непрочитанных | +| GET | `/api/v1/chats/{chatId}/messages` | история постранично (по 30) | +| DELETE | `/api/v1/chats/{chatId}` | удалить чат | +| POST | `/api/v1/chats/{chatId}/read` | отметить прочитанными | + +WebSocket (STOMP): + +| Направление | Адрес | Что | +|---|---|---| +| рукопожатие | `ws://host/ws` (есть SockJS) | | +| клиент → сервер | `/app/chat.send` | `{"chatId": 1, "content": "..."}` | +| сервер → все в чате | `/topic/chat/{chatId}` | `MessageDto` | +| сервер → получателю | `/topic/user/{userId}/unread` | `{"chatId": 1, "unreadCount": 3}` | +| сервер → отправителю | `/user/{userId}/queue/errors` | текст ошибки | + +Авторизация чата делается в `WsStompInterceptor` на двух этапах: + +- **`CONNECT`** — берётся заголовок `Authorization`, проверяется JWT, + в атрибуты сессии кладутся `userId` и роль. +- **`SUBSCRIBE`** — проверяется, что топик соответствует роли и что у + пользователя есть право читать этот канал: + +| Топик | Кому можно | +|---|---| +| `/topic/chat/{chatId}` | только участник чата | +| `/topic/user/{userId}/unread` | только сам пользователь | +| `/user/{userId}/queue/**` | только сам пользователь | +| `/topic/ads**`, `/topic/fullscreen-ads**` | только `ADMIN` | +| всё остальное | запрещено | + +Кадры `SEND` по-прежнему не перепроверяются на уровне интерцептора — +доступ контролируется в `ChatService.sendMessage` (участник чата) и в самом +`@MessageMapping`-методе. Это оставлено в разделе 13. + +### 7.8 Отзывы и статистика + +| Метод | Путь | Доступ | Что делает | +|---|---|---|---| +| GET | `/api/v1/reviews` | публично | все отзывы | +| POST | `/api/v1/reviews` | токен | оставить отзыв (rating 1–5, текст ≤ 500) | +| GET | `/api/v1/statistics` | токен | `{postsCreated, postsFound}` | + +### 7.9 Тестовые ручки — `/api/v1/test` (только `ADMIN`) + +| Метод | Путь | Что делает | +|---|---|---| +| GET | `/api/v1/test/test1` | вручную запускает удаление старых объявлений | +| POST | `/api/v1/test/test-ai?text=` | проксирует текст в ИИ-модерацию и отдаёт вердикт | + +Закрыты `hasRole("ADMIN")` в `SecurityConfig` и `@PreAuthorize` на контроллере. + +### 7.10 Реклама — публичная часть + +| Метод | Путь | Доступ | Что делает | +|---|---|---|---| +| GET | `/api/ads/next` | публично | следующее объявление. `X-Session-Id` в заголовке. 204, если нечего показывать | +| POST | `/api/ads/impression` | публично | записать показ, 202 | +| POST | `/api/ads/click` | публично | записать клик, 202 | +| GET | `/api/fullscreen-ad` | публично | активная полноэкранная реклама. 204, если нет или пустое медиа | +| GET | `/api/v1/ads-media/{*key}` | публично | отдать файл рекламы байтами | + +### 7.11 Реклама — админка (роль `ADMIN`) + +Кампании — `/api/v1/admin/campaigns`: + +| Метод | Путь | Что делает | Код | +|---|---|---|---| +| GET | `/` | все кампании | 200 | +| GET | `/{id}` | одна кампания | 200 | +| POST | `/` | создать | 201 / 400 (если `start >= end` или кап ≤ 0) | +| PUT | `/{id}` | изменить | 200 (⚠️ без валидации дат) | +| PATCH | `/{id}/status` | сменить статус | 200 / **422** при недопустимом переходе | + +Объявления — `/api/v1/admin/ads`: + +| Метод | Путь | Что делает | Код | +|---|---|---|---| +| POST | `/{id}/video/presign-upload` | шаг 1: вернуть подписанный URL и зарезервировать ключ | 200 / 400 | +| POST | `/{id}/video/confirm-upload` | шаг 2: запустить перекодировку | **202** / 404 | +| GET | `?campaignId=` | объявления кампании | 200 | +| GET | `/{id}/status` | статус перекодировки | 200 / 404 | +| PUT | `/{id}` | поменять `targetUrl` и `weight` | 200 / 400 / 404 | +| POST | `/` | создать объявление | **201** / 400 / 404 | +| POST | `/image` | загрузить картинку (multipart) | 200 / 400 | + +Полноэкранные — `/api/v1/admin/fullscreen-ads`: + +| Метод | Путь | Что делает | Код | +|---|---|---|---| +| GET | `/` | список | 200 | +| POST | `/` | создать «оболочку» с `targetUrl` | **201** | +| POST | `/{id}/video/presign-upload` | шаг 1 | 200 / 400 / 404 | +| POST | `/{id}/video/confirm-upload` | шаг 2 | **202** | +| GET | `/{id}/status` | статус перекодировки | 200 / 404 | +| PATCH | `/{id}/activate` | сделать единственной активной | 200 / **422** если видео не `READY` | +| PATCH | `/{id}/deactivate` | снять с показа | 200 / 404 | + +### 7.12 Сводка по кодам ответа + +| Код | Когда | +|---|---| +| 200 | успех | +| 201 | создано (`admin/ads`, `admin/campaigns`, `admin/fullscreen-ads`) | +| 202 | принято в обработку (`confirm-upload`, `impression`, `click`) | +| 204 | пустой результат (`logout`, `DELETE files`, `ads/next`, `fullscreen-ad`) | +| 400 | плохой вход, неправильный статус перехода, `CONTENT_BLOCKED` | +| 401 | нет токена | +| 403 | нет прав, или объявление не опубликовано, или не ты автор | +| 404 | не найдено | +| 422 | недопустимый переход статуса кампании или попытка активировать неготовое видео | +| 500 | непредвиденная ошибка | +| 503 | сервис геокодирования недоступен | + +--- + +## 8. Подсистемы подробно + +### 8.1 Авторизация (JWT) + +**Где:** `security/`, `service/AuthService.java`, `service/RefreshTokenService.java` + +Как это работает: + +1. `POST /register` — проверяем, что логин свободен, хешируем пароль BCrypt, сохраняем, выдаём пару токенов. +2. `POST /login` — ищем по логину, сверяем пароль через `passwordEncoder.matches`. +3. `POST /refresh` — клиент шлёт refresh-токен. Он идёт в Redis; если там есть запись и логин совпадает — старая запись удаляется, выпускается новая пара (**ротация**). +4. `POST /logout` — удаляем запись из Redis. + +Что внутри токена: + +| Claim | Access-токен | Refresh-токен | +|---|---|---| +| `sub` (логин) | да | да | +| `role` | да | **нет** | +| `iat`, `exp` | да | да (7 дней) | + +`JwtAuthenticationFilter` читает `Authorization: Bearer …` на **каждом** запросе. +Если заголовка нет или токен битый — запрос просто идёт дальше анонимным +(все решения принимает `SecurityConfig`). + +Сессий на сервере нет, CSRF отключён (токен только в заголовке, куки не используются), +CORS задан списком `app.cors.allowed-origins` с `allowCredentials(true)`. + +### 8.2 Объявления и модерация + +**Где:** `service/PostService.java`, `SchedulerService.java`, `PostModerationExecutorService.java`, `ai/` + +Жизненный цикл объявления: + +``` +POST /api/v1/posts + └─► status = MODERATION, изображения в MinIO, координаты → город/район + └─► (каждую минуту) SchedulerService.moderatePendingPosts() + └─► PostModerationExecutorService.moderateOne(id) + ├─ скачивает картинки из MinIO (нужно! временный файл Tomcat давно удалён) + ├─ ContentModerationFacade → Pipeline + │ ├─ TextModerationProcessor → POST {ai.url}/api/v1/moderation/text + │ └─ ImageModerationProcessor → POST {ai.url}/api/v1/moderation/image + │ + ├─ ИИ ответило «одобрить» → status = ACTIVE + ├─ ИИ ответило «заблокировать» → ContentBlockedException → status = REJECTED + └─ ИИ недоступно (null) → ничего не менять, попробовать в следующий раз +``` + +**Защита от обхода модерации:** `PATCH /status` не даст перевести объявление из +`MODERATION` сразу в `ACTIVE` — вернётся 403. Единственный путь — дождаться ИИ. + +**Уборка мусора:** раз в сутки в 00:01 `SchedulerService.deleteExpiredPosts()`: +- сначала удаляются строки из `favorites` для закрытых объявлений, +- потом сами объявления, +- то же самое для объявлений старше 6 месяцев. + +Порядок важен — иначе упёрлись бы во внешний ключ. +Файлы в MinIO при этом **не удаляются** — их съедает правило жизненного цикла бакета (180 дней). + +**Почему картинки качаются обратно из MinIO:** модерация идёт через минуту после запроса, +а временные файлы Tomcat к тому моменту уже удалены. `ByteArrayMultipartFile` — +маленький класс-обёртка, который делает `byte[]` видом `MultipartFile`, чтобы не менять +сигнатуру модерации. + +### 8.3 ИИ-модерация + +**Где:** `ai/` + +Ожидаемый внешний сервис (по умолчанию `http://localhost:8000`): + +| Запрос | Тело | Ответ | +|---|---|---| +| `POST /api/v1/moderation/text` | `{"text": "..."}` | `{"approved": bool, "score": double, "reason": string, "detectedLabels": [string]}` | +| `POST /api/v1/moderation/image` | multipart, поле `file` | то же самое | + +Архитектура — конвейер (pipeline): + +``` +ContentModerationFacade.moderate(title, description, images) + └─► ContentModerationPipeline.execute(context) + ├─ for каждый ModerationProcessor (в порядке @Order): + │ result = processor.process(context) + │ если result == null → прерываем и возвращаем null («ИИ недоступно») + │ если процессор бросил ContentBlockedException → пробрасываем наверх + └─ все прошли → true +``` + +Чтобы добавить новую проверку (например, звук), достаточно написать ещё один `@Component`, +реализующий `ModerationProcessor` — конвейер подхватит его сам. + +Повторы: `@Retryable(maxAttempts = 3, backoff = 500ms × 2)` на `AiUnavailableException` +и `AiServerException`. Когда попытки кончились, `@Recover` возвращает `null` — +это сигнал «не знаю», и объявление остаётся в `MODERATION` до следующей попытки. + +Таймаут одного запроса — 10 секунд. + +### 8.4 Карта и геокодирование + +**Где:** `service/MapService.java`, `service/GeocodingService.java`, `util/PostSpecification.java` + +- `MapService` спрашивает `PostRepository.findMapMarkers` — JPQL-проекция прямо в `MapMarkerDto`, + первая картинка подтягивается подзапросом `limit 1`. Фильтры опциональные: + `(:type is null or p.type = :type)`. +- Результат кэшируется в Redis на **5 минут**. +- `GeocodingService` при создании/правке объявления ходит в **OpenStreetMap Nominatim** + (`https://nominatim.openstreetmap.org/reverse`) и вытаскивает город и район. + Если координат нет — в `city` записывается то, что прислал пользователь в поле `address`. + Если сервис недоступен — 503. + +### 8.5 Чаты + +**Где:** `service/ChatService.java`, `controller/ChatWebSocketController.java`, `util/WsStompInterceptor.java`, `config/WebSocketConfig.java` + +- Диалог уникален для пары пользователей: `user_one_id` всегда меньше, `user_two_id` больше. + Поиск идёт запросом `WHERE user_one_id = :u1 AND user_two_id = :u2`, поэтому порядок + в `ChatService` нормализуется через `min`/`max`. +- Отправка сообщения: сохранить → отдать в `/topic/chat/{chatId}` → посчитать непрочитанные + получателю → отдать в `/topic/user/{id}/unread`. +- Брокер — **in-memory** (`enableSimpleBroker("/topic")`). Это значит: при запуске + второй копии приложения сообщения между инстансами не поедут. Нужен RabbitMQ/STOMP relay. +- Пул для конвертации — `TaskScheduler` Spring. + +### 8.6 Реклама: выбор объявления + +**Где:** `AdvertisingSelectionService`, `AdvertisingFrequencyService`, `AdvertisingRepository` + +Пошагово: + +1. Определить, кто смотрит: `userId` из токена, иначе `sessionId` из `X-Session-Id` + (или из HTTP-сессии как запасной вариант). +2. **Cooldown 30 секунд** для этой сессии. Ключ `ad:session::cooldown`, операция `SETNX`. + Не прошёл — сразу `204`. +3. Забрать из БД подходящие: `findEligibleAds(now)`: + ```sql + SELECT a FROM Advertising a JOIN FETCH a.campaign c + WHERE c.status = 'ACTIVE' + AND a.transcodingStatus = 'READY' + AND :now BETWEEN c.startDate AND c.endDate + ``` +4. Отсортировать по весу — алгоритм **Efraimidis–Spirakis**: + ``` + ключ_i = случайное_число_i ^ (1 / max(weight_i, 1)) + ``` + сортируем по убыванию. Чем больше `weight`, тем раньше объявление. Это даёт + вероятность первого показа, пропорциональную весу. +5. Перебрать в этом порядке и взять первое, у которого не исчерпан дневной кап. + Ключ `ad:freq:u:adId` или `ad:freq:s:adId`, TTL **24 часа**, + операция `INCR`. + Если кап исчерпан — откатываем счётчик и берём следующее. +6. Вернуть `{advertisingId, type, mediaUrl, targetUrl}`, где `mediaUrl = /api/v1/ads-media/`. + +### 8.7 Реклама: загрузка и перекодировка видео + +Полный цикл (на примере обычного объявления): + +``` +① POST /api/v1/admin/ads/{id}/video/presign-upload {contentType} + проверка contentType ∈ {video/mp4, video/quicktime, video/webm} + ключ исходника: sources/{id}/{uuid}.{ext} + → {uploadUrl, sourceKey, expiryMinutes: 30} + +② Браузер сам делает PUT на uploadUrl + (файл ложится в бакет ads-media напрямую, бэкенд не участвует) + ⚠️ поэтому в minio/cors.json разрешён метод PUT и origins фронтенда + +③ POST /api/v1/admin/ads/{id}/video/confirm-upload {sourceKey} + → status = PROCESSING, публикуем VideoUploadConfirmedEvent + → 202 Accepted + +④ VideoUploadEventListener (@TransactionalEventListener AFTER_COMMIT) + → AdvertisingTranscodingService (@Async("ffmpegExecutor") — пул из 2 потоков) + +⑤ VideoTranscoderService запускает процесс: + ffmpeg -y -i <временный файл> + -vf scale=-2:720 + -c:v libx264 -preset medium + -b:v 1500k -maxrate 2000k -bufsize 3000k + -c:a aac -b:a 128k + -movflags +faststart + <временный выход> + таймаут 5 минут + +⑥ Заливает результат в MinIO: videos/{uuid}.mp4 + ИСХОДНИК УДАЛЯЕТСЯ в блоке finally + +⑦ AdvertisingStatusService: mediaKey = videos/... , status = READY (или FAILED) + + отправляет статус в /topic/ads/{id}/status через WebSocket +``` + +Полноэкранная реклама работает точно так же, но с другими префиксами: + +| Что | Обычное объявление | Полноэкранное | +|---|---|---| +| ключ исходника | `sources/{id}/{uuid}.{ext}` | `fullscreen-sources/{id}/{uuid}.{ext}` | +| ключ результата | `videos/{uuid}.mp4` | `fullscreen/{uuid}.mp4` | +| событие | `VideoUploadConfirmedEvent` | `FullscreenAdVideoUploadConfirmedEvent` | +| WS-топик статуса | `/topic/ads/{id}/status` | `/topic/fullscreen-ads/{id}/status` | +| бакет | `ads-media` | `ads-media` (тот же!) | + +Картинки объявлений грузятся отдельно и проще: `POST /api/v1/admin/ads/image` → +ключ `images/{uuid}.ext`, сразу `READY`. + +### 8.8 Реклама: статусы + +`CampaignStatus` — переходы строго проверяются в `CampaignService`: + +``` +DRAFT → ACTIVE +ACTIVE → PAUSED | FINISHED +PAUSED → ACTIVE | FINISHED +FINISHED → (никуда, необратимо) +``` + +Переход «в самого себя» запрещён. Нарушение → `InvalidCampaignStatusTransitionException` → **422**. + +`TranscodingStatus`: + +``` +PENDING ──confirm-upload──► PROCESSING ──успех──► READY + └───ошибка/таймаут───► FAILED +``` + +Жёсткой таблицы переходов нет — ограничивает только `CHECK` в БД. +В ротацию попадают **только** `READY`. + +`FullscreenAdStatus`: `DRAFT → ACTIVE`, `ACTIVE → INACTIVE`. Таблицы переходов нет, +но «ровно один ACTIVE» гарантировано частичным уникальным индексом в PostgreSQL. + +### 8.9 Статистика + +`StatisticsService` держит одну строку `id = 1` и меняет счётчики «на чтение-модификация-запись»: + +- `posts_created` — растёт при создании объявления; +- `posts_found` — растёт при переводе объявления в `CLOSED`. + +⚠️ Без блокировок и без атомарного `UPDATE ... SET x = x + 1` — при параллельных +созданиях инкременты могут потеряться. + +--- + +## 9. Кэширование в Redis + +**Где:** `service/redis/`, `config/RedisConfig.java` + +### 9.1 Что кэшируется + +| Что | Ключ | TTL | Где | +|---|---|---|---| +| Страница ленты | `posts:feed:v<вер>:search=:page=N:size=N:sort=…` | **2 мин** | `PostService.getFeed` | +| Маркеры карты | `posts:map:v<вер>:bbox=…:type=…:category=…` | **5 мин** | `MapService` | +| Refresh-токены | `refresh_token:` | **7 дней** | `RefreshTokenService` | +| Кап рекламы | `ad:freq:{u\|s}:` | **24 ч** | `AdvertisingFrequencyService` | +| Cooldown рекламы | `ad:session::cooldown` | **30 сек** | `AdvertisingFrequencyService` | +| Версия кэша | `posts:version` | без TTL | `PostCacheService` | + +### 9.2 Хитрость с «версией» + +Вместо удаления тысячи ключей по паттерну (это медленно и опасно для Redis) используется +счётчик версий: + +``` +posts:version ──INCR──► 2 +``` + +Ключи выглядят как `posts:feed:v2:...`, `posts:map:v2:...`. +Старые записи с `v1` просто перестанут читаться, а протухнут по своему TTL. +Один `INCR` — и кэш «сброшен» целиком. + +Сброс **регистрируется в транзакции** через `TransactionSynchronizationManager` +и делается в `afterCommit()`. Это важно: если бы счётчик увеличился до коммита, +другой поток мог бы заново заполнить кэш ещё не закоммиченными данными. + +### 9.3 Ключи и сериализация + +- Ключи — обычные строки, их видно в `redis-cli` глазами. +- Значения — JSON через `Jackson2JsonRedisSerializer`. +- `RedisConfig` копирует общий `ObjectMapper` и выставляет `FAIL_ON_UNKNOWN_PROPERTIES=false`: + чтобы (а) правки веб-слоя не ломали формат старых записей и (б) записи, сделанные + до переименования поля DTO, не взрывались при чтении. +- Пользовательский текст (поисковый запрос, город) **не** попадает в ключ как есть — + он нормализуется и хешируется SHA-256 (`RedisCacheKeyUtil`). Это защищает от + коллизий и «инъекций» в ключ. +- Координаты карты округляются к сетке `0.01°` (`PostCacheKeyGenerator.snap`). + +Три шаблона (`RedisConfig`): `postCacheRedisTemplate` (`CachedPostPage`), +`feedRedisTemplate` (`CachedMapMarkers`), `mapRedisTemplate` (`List`), +плюс стандартный `StringRedisTemplate` от Spring Boot. + +> ⚠️ `MapCacheService` и `MapCacheKeyGenerator` — **мёртвый код**. `MapService` давно +> кэширует маркеры через `PostCacheService`. Из `MapCacheService` реально используется +> только `clearMapMarkersCache()`, который инкрементит ключ, который никто не читает. + +--- + +## 10. Хранение файлов в MinIO + +**Где:** `config/S3ClientConfig.java`, `config/MinioProperties.java`, `service/FileStorageService.java`, +`service/advertising/AdvertisingMediaService.java`, `health/StorageStructureService.java` + +### 10.1 Два бакета + +| Бакет | Что лежит | Ключи | +|---|---|---| +| `refound-images` (`minio.bucket`) | фото объявлений, аватары | `.jpg`, `avatars/.jpg` | +| `ads-media` (`minio.ads-bucket`) | вся реклама | `images/…`, `sources/…`, `videos/…`, `fullscreen-sources/…`, `fullscreen/…` | + +### 10.2 Клиенты + +`S3ClientConfig` создаёт два бина: + +| Бин | Зачем | +|---|---| +| `S3Client` | операции на сервере: загрузка, чтение, удаление | +| `S3Presigner` | подписанные URL, чтобы браузер грузил **напрямую в MinIO** | + +Оба настроены на MinIO через `endpointOverride`, `pathStyleAccessEnabled(true)` +и статические креды. Автоконфигурация `spring-cloud-aws` отключена в +`ReFoundApplication` (`exclude = S3AutoConfiguration.class`), чтобы не было второго клиента. + +### 10.3 Отдача файлов + +Отдавать напрямую из MinIO клиенту нельзя — тогда фронтенд окажется привязан к хосту MinIO. +Поэтому приложение работает **прокси**: + +- `GET /api/v1/files/{*objectName}` — для фото объявлений и аватаров; +- `GET /api/v1/ads-media/{*key}` — для рекламы. + +Wildcard `{*…}` нужен потому, что в ключах есть слэши (`avatars/x.jpg`, `sources/1/y.mp4`). + +Контроллер сначала делает `headObject` (узнать content-type и исходное имя), +потом `getObjectAsBytes`. Для картинок ставится `Content-Disposition: inline`, +для остального — `attachment`. + +### 10.4 Что происходит при старте приложения + +`StorageStructureService` слушает `ContextRefreshedEvent` и: + +1. создаёт бакет `refound-images`, если его нет; +2. включает **версионирование** бакета; +3. ставит правило жизненного цикла **«удалить через 180 дней»** + (и для актуальных версий, и для старых). + +Правило 180 дней — это страховка от «осиротевших» файлов: планировщик удаляет +объявления из БД, но объекты из MinIO не трогает, и они уходят сами. + +> ⚠️ Бакет `ads-media` приложение не создаёт — его делает контейнер `mc-init` в docker-compose. + +--- + +## 11. Настройки и переменные окружения + +### 11.1 `src/main/resources/application.yaml` + +| Блок | Ключи | Значения по умолчанию | +|---|---|---| +| `spring.datasource` | `url`, `username`, `password` | `jdbc:postgresql://localhost:5432/nahodka`, `user`, `pass` | +| `spring.jpa.hibernate` | `ddl-auto`, `show-sql` | `${JPA_DDL_AUTO:validate}`, `true` | +| `spring.data.redis` | `host`, `port`, `password`, `timeout` | `localhost`, `6379`, `${REDIS_PASSWORD}`, 500 мс | +| `spring.data.web.pageable` | `max-page-size` | `50` (потолок `?size=`) | +| `spring.servlet.multipart` | `max-file-size`, `max-request-size` | `10MB`, `20MB` | +| `spring.liquibase.change-log` | | `classpath:db/changelog/db.changelog-master.yaml` | +| `minio` | `endpoint`, `bucket`, `ads-bucket`, `access-key`, `secret-key`, `region` | см. ниже | +| `ai` | `url`, `timeout` | `http://localhost:8000`, `10` | +| `ai.retry` | `max-attempts`, `delay`, `multiplier` | `3`, `500`, `2` | +| `jwt` | `secret`, `access-expiration`, `refresh-expiration` | 15 мин, 7 дней | +| `app.cors.allowed-origins` | | `http://localhost:5173,http://localhost:3000,http://192.168.1.76:3000` | +| `ffmpeg` | `binary-path` | абсолютный путь под Windows | + +### 11.2 Обязательные переменные + +| Переменная | Что случится, если не задать | +|---|---| +| `MINIO_ACCESS_KEY` | приложение не стартует (плейсхолдер + `@NotBlank`) | +| `MINIO_SECRET_KEY` | то же | +| `REDIS_PASSWORD` | то же | + +Остальные имеют дефолты: + +| Переменная | По умолчанию | Зачем задавать | +|---|---|---| +| `JWT_SECRET` | захардкоженное значение в `application.yaml` | иначе токен подписывается известным ключом и его можно подделать | +| `JPA_DDL_AUTO` | `validate` | `update` на время локальной отладки, когда не хочется прогонять миграции | +| `REDIS_HOST` | `localhost` | удалённый Redis | +| `MINIO_ENDPOINT` | `http://localhost:9010` | другое хранилище | +| `AI_SERVICE_URL` | `http://localhost:8000` | другой адрес ИИ-сервиса | + +> ⚠️ Параметры `ai.retry.*` в yaml сейчас **ни на что не влияют**: в `@Retryable` +> на клиентах значения прописаны константами. `AiRetryProperties` не читается никем. + +--- + +## 12. Обработка ошибок + +**Где:** `exception/GlobalExceptionHandler.java`, `exception/ErrorResponse.java`, `enums/ErrorCode.java` + +`GlobalExceptionHandler` — это `@RestControllerAdvice`: перехватывает исключения из +контроллеров и превращает в единый JSON. + +### 12.1 Полная таблица + +| Исключение | HTTP | `error` | Тело | +|---|---|---|---| +| `InvalidCredentialsException` | 400 | — | `{"message": "..."}` ⚠️ короткий формат | +| `LoginAlreadyExistsException` | 400 | — | `{"message": "..."}` ⚠️ короткий формат | +| `ApiException` | из самого исключения | из самого исключения | полный `ErrorResponse` | +| `MethodArgumentNotValidException` | 400 | `VALIDATION_ERROR` | полный, `message` = ошибка первого поля | +| `IllegalArgumentException` | 400 | `VALIDATION_ERROR` | полный | +| `ContentBlockedException` | 400 | `CONTENT_BLOCKED` | полный **с** `details.labels` | +| `InvalidCampaignStatusTransitionException` | 422 | `INVALID_STATUS_TRANSITION` | полный | +| `Exception` (всё остальное) | 500 | `INTERNAL_ERROR` | полный, `message` = `"Internal server error"` | + +### 12.2 Формат ответа + +```json +{ + "timestamp": "2026-09-29T12:34:56.789", + "status": 400, + "error": "VALIDATION_ERROR", + "message": "login не должен быть пустым", + "path": "/api/v1/auth/register", + "details": null +} +``` + +`details` заполняется только для модерации ИИ: + +```json +"details": { "labels": ["violence"], "blockedWords": null, "score": null } +``` + +### 12.3 Коды ошибок (`ErrorCode`) + +`VALIDATION_ERROR`, `NOT_FOUND`, `INTERNAL_ERROR`, `BAD_REQUEST`, `UNAUTHORIZED`, +`FORBIDDEN`, `CONTENT_BLOCKED`, `INVALID_STATUS_TRANSITION`. + +> ⚠️ `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND` и `BAD_REQUEST` в хендлере не используются. +> 401 и 403 рождаются в `SecurityConfig` и отдаются в формате `{"message": "Unauthorized"}` / +> `{"message": "Forbidden"}` — **без** `timestamp`/`status`/`error`/`path`. +> Клиенту приходится разбирать два разных формата ошибок. + +--- + +## 13. Известные проблемы и что стоит починить + +Собрано по результатам чтения кода. Порядок — по серьёзности. +Пункты со ✅ исправлены, остальные — актуальные замечания. + +### ✅ Исправлено: секреты в репозитории + +1. **`.env` добавлен в `.gitignore`** вместе с `.env.*` (кроме `.env.example`), + `/data/` и `/minio-data/`. Сам файл в Git не отслеживался, поэтому + `git rm --cached` не потребовался. Добавлен `.env.example` с пустыми + шаблонными значениями. + > Ключи из старого `.env` стоит ротировать: если файл где-то засветился, + > простого добавления в `.gitignore` недостаточно. +2. **Осталось:** `jwt.secret` в `application.yaml` захардкожен как дефолт, + и креды БД `user`/`pass` тоже в файле. Задавайте `JWT_SECRET` через + окружение; для прода стоит вынести и подключение к БД. + +### ✅ Исправлено: авторизация + +4. **`PUT /api/v1/users/{id}`** — теперь `UserService.assertCanModifyUser`: + менять профиль может только владелец или пользователь с ролью `ADMIN`, + иначе `403`. Заодно добавлена проверка уникальности логина. +5. **`DELETE /api/v1/files/{*objectName}`** — путь переведён на wildcard-вариант + (раньше ключи вида `avatars/x.jpg` не удалялись вообще), удаление проходит + через `deleteFileAsCurrentUser`. Владелец файла фиксируется в Redis + (`files:owner:`, TTL 180 дней — как правило жизненного цикла + бакета) при загрузке. Свой файл удалить можно, чужой — только `ADMIN`; + если отметки о владельце нет, доступ закрывается (fail-closed). + > Файлы, загруженные до этого изменения, удалить через API нельзя — + > обслуживаются вручную через `deleteFile` из кода. +6. **`/api/v1/test/**` закрыт** — в `SecurityConfig` стоит `hasRole("ADMIN")`, + на классе `TestController` добавлен `@PreAuthorize("hasRole('ADMIN')")`. +7. **WebSocket `SUBSCRIBE` проверяется** в `WsStompInterceptor`: + `/topic/chat/{id}` — только участник чата, `/topic/user/{id}/unread` и + `/user/{id}/queue/**` — только свой, `/topic/ads**` и `/topic/fullscreen-ads**` — + только `ADMIN`. Неизвестные топики запрещены по умолчанию. В сессию при + `CONNECT` кладётся не только `userId`, но и роль. + > Роль берётся из JWT, а при её отсутствии — из БД. + +Осталось: + +8. **WebSocket CORS = `*`** (`setAllowedOriginPatterns("*")`) — заметно шире HTTP-CORS. +9. **Refresh-токен не отличается от access-токена** (нет claim `typ` или `jti`). + Refresh проходит валидацию в фильтре и даёт аутентификацию **без прав**, + то есть формально открывает всё, что защищено только `authenticated()`. +10. **Refresh-токены пишутся в лог** в `AuthService` (3 места) и `RefreshTokenService` (5 мест). + Удалите эти `log.info`. + +### ✅ Исправлено: схема БД + +11. **`chats` и `messages.chat_id` подключены к Liquibase.** `create/006-create-chats.yaml` + теперь идемпотентен (`preConditions` + `MARK_RAN`, индексы по участникам). + Новая миграция `add/017-messages-chat-id.yaml` добавляет `chat_id`, + переносит старую переписку в чаты, удаляет `receiver_id` вместе с его FK + и индексом, добавляет `NOT NULL` и FK `messages.chat_id → chats.id` + **с `ON DELETE CASCADE`** — без каскада `ChatService.deleteChat` падал бы. + `create/007-recreate-messages.yaml`, который удалял таблицу `messages` + вместе с перепиской, отключён и помечен как устаревший. +12. **`ddl-auto` переведён на `validate`** (`${JPA_DDL_AUTO:validate}`). + Схемой управляет только Liquibase; `update` можно вернуть на время + разработки через переменную окружения. +13. **`Post.rulesAccepted` теперь `Boolean`**, а не `String`. Колонка в БД была + `varchar` — Hibernate создал её, когда поле ещё было строкой, — поэтому + добавлена миграция `alter/014-posts-rules-accepted-type.yaml`, которая + приводит тип к `boolean` с предварительной очисткой значений. + +Осталось: + +14. **`Advertising.mediaKey` — `nullable = false` в JPA, nullable в БД** (changelog `012` снял + NOT NULL). `createAdvertising` для видео явно ставит `null`. Рассинхрон может + привести к падению INSERT. +15. **`Post.description` — `@Column(length = 2000)` против `TEXT` в БД.** +16. **`Click.sessionId` без `length` (Hibernate возьмёт 255) против `VARCHAR(64)` в БД.** + +> Пункты 14–16 `validate` не ловит: Hibernate сверяет только типы, про которые +> знает, и игнорирует nullability и длину. + +### 🟠 Ошибки в логике + +17. **`GET /api/v1/posts/{id}` возвращает все объявления пользователя**, а не одно — + имя вводит в заблуждение. Либо переименуйте, либо разделите эндпоинты. +18. **`CampaignService.updateCampaignStatus` возвращает старый статус.** Bulk-`@Modifying` + обходит persistence context, поэтому маппится устаревший объект. Клиент увидит + прежний статус, хотя в БД уже новый. +19. **Таймаут ffmpeg не работает.** `reader.lines().forEach(...)` блокируется до конца + процесса, и до `waitFor(5, MINUTES)` управление не дойдёт. Если ffmpeg завис — пул + из 2 потоков исчерпается навсегда. Читать вывод надо в отдельном потоке + или использовать `redirectOutput(File)`. +20. **`confirm-upload` не проверяет, что файл загружен и что ключ его.** Можно передать + ключ из другого объявления — тогда файл перекодируется в чужое объявление, а исходник + оригинала удалится в `finally`. +21. **`EntityNotFoundException` не обработан** в `GlobalExceptionHandler` — «не найдено» + отдаётся как 500, а не 404. Задевает пользователей, кампании, рекламу. +22. **`getReferenceById` + `catch (EntityNotFoundException)` в трекинге рекламы не работает.** + Прокси бросит исключение только на flush, и оно будет `DataIntegrityViolationException`. +23. **Любое исключение при модерации = `REJECTED`.** Падение MinIO или NPE выглядит так же, + как нарушение правил, и объявление уходит в отказ навсегда. Ловите отдельно + инфраструктурные сбои. +24. **AiTimeoutException нигде не бросается.** Таймаут превращается в `AiUnavailableException`. +25. **`AiHttpClient` глотает `AiBadRequestException`.** Ошибка 400 переквалифицируется в + «недоступно» → три бесполезных повтора → пост навсегда в `MODERATION`. +26. **Ключи карты в ответе `userEmail` содержат логин** (`PostMapper.toResponse`: + `user.login` → `userEmail`). Путает и фронт, и людей. +27. **`AiRetryProperties` и `minio.ads-bucket` вне типизированных properties** — нет валидации. +28. **`ModerationResponse` с `@Builder`, но без `@Jacksonized`** — вероятно, не + десериализуется. Проверьте на живом ответе ИИ-сервиса. +29. **У `POST /api/v1/posts` возвращается `PostRequest`**, то есть эхо входа, а не созданный + объект и не 201. + +### 🟡 Производительность + +30. **`findEligibleAds` без пагинации** — каждый `GET /api/ads/next` вытягивает все + подходящие объявления в память. +31. **Дневной кап — пер-объявление, а не пер-кампания.** Кампания из 5 объявлений с капом 10 + даёт до 50 показов в сутки. +32. **Кап обходится анонимной сессией** — клиент просто шлёт новый `X-Session-Id`. + И cooldown привязан к сессии, а не к пользователю, так что с двух устройств он не работает. +33. **Redis недоступен → 500** на `GET /api/ads/next`: `tryRegisterImpression` защищён, + а `isSessionCooldownPassed` — нет. +34. **Счётчик капа тратится на выдаче объявления, а не на показе.** Фронт закрыл вкладку — + лимит израсходован, а строки в `impression` нет. +35. **Отдача медиа целиком в память** (`getObjectAsBytes`). Для видео это риск OOM, + нет поддержки Range. +36. **Счётчики статистики теряют инкременты** при параллельных записях. +37. **`show-sql: true`** — все SQL-запросы печатаются в лог. На боевом окружении выключить. +38. **Весь текст объявления логируется на INFO** (`TextModerationProcessor`, + `ContentModerationFacade`) каждые 60 секунд. + +### ⚪ Мёртвый код, который стоит убрать + +| Что | Почему мёртвый | +|---|---| +| `MapCacheService.getMarkers/saveMarkers/currentMapVersion` | `MapService` давно использует `PostCacheService` | +| `MapCacheKeyGenerator` | целиком не вызывается | +| `AiRetryProperties` | параметры повторов заданы константами в `@Retryable` | +| `AiTimeoutException` | никогда не бросается | +| `FullscreenAdActivateRequestDto` | заменён на `PATCH activate/deactivate` | +| Правило `POST /api/ads/impression/beacon` | такого эндпоинта нет | +| Правила `/files/**`, `/uploads/**` | таких контроллеров нет | +| `alter/013-del-ad_id-click-impression.yaml` | `preConditions` → `MARK_RAN`, SQL не выполняется | +| `impressions` / `clicks` коллекции в `Advertising` | объявлены с `orphanRemoval`, но в коде не используются | + +--- + +## 14. Тесты + +В проекте **один** тест: + +``` +src/test/java/com/krylov/refound/ReFoundApplicationTests.java + @Test void contextLoads() // пустой метод-«проверка, что контекст поднялся» +``` + +Что важно понимать: + +- тест требует **живые** PostgreSQL, Redis (с `REDIS_PASSWORD`) и MinIO — иначе упадёт; +- он прогоняет Liquibase по-настоящему, то есть **меняет вашу базу**; +- `ddl-auto: validate` делает его полезнее: расхождение схемы и сущностей + теперь роняет тест с внятным сообщением вместо тихой правки базы; +- никаких `Testcontainers`, никакой H2, никаких `@MockBean`. + +Запуск (переменные окружения нужны те же, что и приложению): + +```bash +./gradlew test +``` + +На Windows `JAVA_HOME` из окружения может указывать на несуществующий JDK — +тогда `gradlew.bat` падает с `JAVA_HOME is set to an invalid directory`. +Проверьте путь перед запуском. + +> ⚠️ `gradlew build` / `bootJar` сейчас не работает: Spring Boot Gradle Plugin +> 3.2.5 несовместим с Gradle 9.4.0 (`CopyProcessingSpec.getDirMode()` удалён +> в Gradle 9). Проблема существовала до всех правок схемы и не связана с +> ними. `gradlew test` и `gradlew bootRun` при этом работают. Лечится +> обновлением плагина до 3.4+ либо откатом Gradle на 8.x. + +--- + +## 15. Шпаргалка «где что искать» + +### «Я хочу понять, что происходит при …» + +| Вопрос | Файл | +|---|---| +| …входе пользователя | `security/SecurityConfig.java` → `JwtAuthenticationFilter.java` → `service/AuthService.java` | +| …создании объявления | `controller/PostController.java` → `service/PostService.java` | +| …проверке объявления ИИ | `service/SchedulerService.java` → `PostModerationExecutorService.java` → `ai/` | +| …отдаче ленты | `PostService.getFeed` → `PostCacheService` | +| …отдаче маркеров карты | `MapController` → `MapService` → `PostRepository.findMapMarkers` | +| …определении города по координатам | `service/GeocodingService.java` | +| …выборе рекламного объявления | `service/advertising/AdvertisingSelectionService.java` | +| …перекодировке видео | `service/advertising/VideoTranscoderService.java` | +| …создании бакета MinIO при старте | `health/StorageStructureService.java` | +| …сбросу кэша | `service/redis/PostCacheService.java` (`invalidatePosts`) | +| …формате ошибки | `exception/GlobalExceptionHandler.java` | +| …что лежит в таблице | `resources/db/changelog/` + `entity/` | + +### «Я хочу добавить…» + +| Задача | Что делать | +|---|---| +| новое поле в объявлении | 1) `entity/Post.java` 2) миграция в `db/changelog/add/` 3) `include` в `db.changelog-master.yaml` 4) `dto/PostRequest.java` и `PostResponse.java` 5) при необходимости `mapper/PostMapper.java` | +| новый эндпоинт | 1) метод в нужном `controller` 2) логика в `service` 3) запрос в `repository` 4) при необходимости правило доступа в `SecurityConfig` | +| новую проверку в модерации | класс с `@Component implements ModerationProcessor` — конвейер подхватит сам | +| новое правило рекламы | enum + `@Column` в `entity/advertising/` + `CHECK` в миграции + проверка в `CampaignService` | +| новую роль | `enums/Role.java` + правило в `SecurityConfig` | + +### «Важные номера и коды» + +| Что | Значение | +|---|---| +| Access-токен | 15 минут | +| Refresh-токен | 7 дней | +| TTL ленты | 2 минуты | +| TTL карты | 5 минут | +| Cooldown рекламы | 30 секунд | +| TTL капа рекламы | 24 часа | +| TTL presigned URL | 30 минут | +| Таймаут ffmpeg | 5 минут (не работает — см. п. 19) | +| Максимум страницы | 50 | +| Максимум файла | 10 МБ (запрос — 20 МБ) | +| Типы картинок | jpeg, png, webp | +| Типы видео | mp4, mov (quicktime), webm | +| Удаление в MinIO | 180 дней (правило жизненного цикла бакета) | +| Удаление объявлений | раз в сутки в 00:01: закрытые + старше 6 месяцев | +| Проверка модерации | раз в минуту | +| Пул ffmpeg | 2 потока, очередь 20 | +| Разрешение ffmpeg-видео | 720p, H.264, 1500 kbps | + +--- + +## Глоссарий + +| Слово | Что значит в проекте | +|---|---| +| **Liquibase changelog** | файл с историей изменений схемы БД | +| **DTO** | простой класс для входа/выхода API, отвязанный от БД | +| **Сущность (entity)** | класс, отображаемый в таблицу БД через JPA | +| **Bean / бин** | объект, который создаёт и хранит Spring | +| **`@Transactional`** | «всё в этом методе — одна транзакция: либо всё, либо ничего» | +| **STOMP** | простой текстовый протокол поверх WebSocket (фреймы CONNECT/SEND/SUBSCRIBE) | +| **In-memory broker** | брокер сообщений внутри одного процесса; не работает между инстансами | +| **Presigned URL** | временная ссылка, по которой браузер кладёт файл в MinIO напрямую | +| **Path-style access** | адрес вида `http://host:9010/bucket/key` вместо `http://bucket.host/key` — обязателен для MinIO | +| **Partial unique index** | уникальный индекс, действующий только на строки, подходящие под условие | +| **Cache-aside** | «сначала смотрим в кэш, если промах — идём в БД и кладём в кэш» | +| **`@Modifying` запрос** | bulk `UPDATE`/`DELETE` минуя кэш первого уровня Hibernate | +| **Self-invocation** | вызов `@Transactional`-метода изнутри того же класса — прокси Spring не срабатывает, транзакция не включается | +| **Wildcard `{*path}`** | в Spring MVC — путь, в котором допускаются слэши (`avatars/x.jpg`) | + +--- + +*Документ описывает состояние проекта на момент последнего коммита. +Если что-то в коде разошлось с описанием — правьте код, а потом этот файл.* diff --git a/build.gradle b/build.gradle index d16147b..f1345ea 100644 --- a/build.gradle +++ b/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) } } @@ -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' diff --git a/src/main/java/com/krylov/refound/controller/FileStorageController.java b/src/main/java/com/krylov/refound/controller/FileStorageController.java index 0e57e72..e875806 100644 --- a/src/main/java/com/krylov/refound/controller/FileStorageController.java +++ b/src/main/java/com/krylov/refound/controller/FileStorageController.java @@ -34,10 +34,12 @@ public class FileStorageController { return fileStorageService.downloadFile(key); } - @DeleteMapping("/{objectName}") + @DeleteMapping("/{*objectName}") public ResponseEntity 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(); } } diff --git a/src/main/java/com/krylov/refound/controller/TestController.java b/src/main/java/com/krylov/refound/controller/TestController.java index adcfd7e..f1011b9 100644 --- a/src/main/java/com/krylov/refound/controller/TestController.java +++ b/src/main/java/com/krylov/refound/controller/TestController.java @@ -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; diff --git a/src/main/java/com/krylov/refound/entity/Post.java b/src/main/java/com/krylov/refound/entity/Post.java index 129b4fd..add24b1 100644 --- a/src/main/java/com/krylov/refound/entity/Post.java +++ b/src/main/java/com/krylov/refound/entity/Post.java @@ -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; diff --git a/src/main/java/com/krylov/refound/mapper/PostMapper.java b/src/main/java/com/krylov/refound/mapper/PostMapper.java index f97fc09..7c6c410 100644 --- a/src/main/java/com/krylov/refound/mapper/PostMapper.java +++ b/src/main/java/com/krylov/refound/mapper/PostMapper.java @@ -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 imagesToUrls(List images) { if (images == null) return List.of(); diff --git a/src/main/java/com/krylov/refound/repository/ChatRepository.java b/src/main/java/com/krylov/refound/repository/ChatRepository.java index 013d306..3d96389 100644 --- a/src/main/java/com/krylov/refound/repository/ChatRepository.java +++ b/src/main/java/com/krylov/refound/repository/ChatRepository.java @@ -11,6 +11,11 @@ public interface ChatRepository extends JpaRepository { @Query("SELECT c FROM Chat c WHERE c.userOneId = :u1 AND c.userTwoId = :u2") Optional 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 findAllByUserId(Long userId); // Changed from Optional to List } diff --git a/src/main/java/com/krylov/refound/security/SecurityConfig.java b/src/main/java/com/krylov/refound/security/SecurityConfig.java index eb3f403..ba62b12 100644 --- a/src/main/java/com/krylov/refound/security/SecurityConfig.java +++ b/src/main/java/com/krylov/refound/security/SecurityConfig.java @@ -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); diff --git a/src/main/java/com/krylov/refound/service/ChatService.java b/src/main/java/com/krylov/refound/service/ChatService.java index abfbafa..a68c63e 100644 --- a/src/main/java/com/krylov/refound/service/ChatService.java +++ b/src/main/java/com/krylov/refound/service/ChatService.java @@ -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("Чат не найден")); } diff --git a/src/main/java/com/krylov/refound/service/FileStorageService.java b/src/main/java/com/krylov/refound/service/FileStorageService.java index 07026cf..c1caadd 100644 --- a/src/main/java/com/krylov/refound/service/FileStorageService.java +++ b/src/main/java/com/krylov/refound/service/FileStorageService.java @@ -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 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) { diff --git a/src/main/java/com/krylov/refound/service/UserService.java b/src/main/java/com/krylov/refound/service/UserService.java index 4ed4457..8627ec4 100644 --- a/src/main/java/com/krylov/refound/service/UserService.java +++ b/src/main/java/com/krylov/refound/service/UserService.java @@ -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) { diff --git a/src/main/java/com/krylov/refound/util/WsStompInterceptor.java b/src/main/java/com/krylov/refound/util/WsStompInterceptor.java index 0ed63f2..278c11d 100644 --- a/src/main/java/com/krylov/refound/util/WsStompInterceptor.java +++ b/src/main/java/com/krylov/refound/util/WsStompInterceptor.java @@ -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; + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 6c585d9..a3ce269 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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: diff --git a/src/main/resources/db/changelog/add/017-messages-chat-id.yaml b/src/main/resources/db/changelog/add/017-messages-chat-id.yaml new file mode 100644 index 0000000..f4db986 --- /dev/null +++ b/src/main/resources/db/changelog/add/017-messages-chat-id.yaml @@ -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 diff --git a/src/main/resources/db/changelog/alter/014-posts-rules-accepted-type.yaml b/src/main/resources/db/changelog/alter/014-posts-rules-accepted-type.yaml new file mode 100644 index 0000000..a579665 --- /dev/null +++ b/src/main/resources/db/changelog/alter/014-posts-rules-accepted-type.yaml @@ -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 diff --git a/src/main/resources/db/changelog/create/006-create-chats.yaml b/src/main/resources/db/changelog/create/006-create-chats.yaml index 41e75dc..66bf6fd 100644 --- a/src/main/resources/db/changelog/create/006-create-chats.yaml +++ b/src/main/resources/db/changelog/create/006-create-chats.yaml @@ -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 @@ -14,4 +22,43 @@ databaseChangeLog: - addUniqueConstraint: tableName: chats columnNames: user_one_id, user_two_id - constraintName: uq_chat_users \ No newline at end of file + 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 } diff --git a/src/main/resources/db/changelog/create/007-recreate-messages.yaml b/src/main/resources/db/changelog/create/007-recreate-messages.yaml index d110927..df7737f 100644 --- a/src/main/resources/db/changelog/create/007-recreate-messages.yaml +++ b/src/main/resources/db/changelog/create/007-recreate-messages.yaml @@ -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 \ No newline at end of file +# УСТАРЕЛО. Файл намеренно не подключён в 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: [] diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 2927e01..1421e92 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -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 }