Files
nahodka-back/README.MD
2026-09-30 00:27:10 +03:00

1566 lines
95 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <jwt>
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:<uuid>` → `login`, а наружу отдаётся строка `<uuid>.<jwt>`.
### 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:<objectName>`,
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:<sessionId>: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<userId>:adId` или `ad:freq:s<sessionId>:adId`, TTL **24 часа**,
операция `INCR`.
Если кап исчерпан — откатываем счётчик и берём следующее.
6. Вернуть `{advertisingId, type, mediaUrl, targetUrl}`, где `mediaUrl = /api/v1/ads-media/<mediaKey>`.
### 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=<sha256>:page=N:size=N:sort=…` | **2 мин** | `PostService.getFeed` |
| Маркеры карты | `posts:map:v<вер>:bbox=…:type=…:category=…` | **5 мин** | `MapService` |
| Refresh-токены | `refresh_token:<uuid>` | **7 дней** | `RefreshTokenService` |
| Кап рекламы | `ad:freq:{u<id>\|s<sid>}:<adId>` | **24 ч** | `AdvertisingFrequencyService` |
| Cooldown рекламы | `ad:session:<sid>: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<MapMarkerDto>`),
плюс стандартный `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`) | фото объявлений, аватары | `<uuid>.jpg`, `avatars/<uuid>.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:<objectName>`, 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`) |
---
*Документ описывает состояние проекта на момент последнего коммита.
Если что-то в коде разошлось с описанием — правьте код, а потом этот файл.*