1566 lines
95 KiB
Markdown
1566 lines
95 KiB
Markdown
# 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`) |
|
||
|
||
---
|
||
|
||
*Документ описывает состояние проекта на момент последнего коммита.
|
||
Если что-то в коде разошлось с описанием — правьте код, а потом этот файл.*
|