changed jdk on 25 springBoot on 3.5.6 and fix code
ReFound — документация проекта
Привет! Это подробное описание проекта ReFound — бэкенд сайта объявлений «Потерял / нашёл» (потерянные питомцы, вещи, документы и т.д.).
Документ написан простым языком и отвечает на три вопроса:
- Что здесь лежит — какая папка за что отвечает.
- Как это связано — что с чем общается, какой запрос куда идёт.
- Как это запустить — что нужно поставить и как поднять локально.
Содержание
- Что это за проект
- Быстрый старт
- Технологии
- Структура проекта
- Архитектура: как ходит запрос
- База данных
- API: все эндпоинты
- Подсистемы подробно
- Кэширование в Redis
- Хранение файлов в MinIO
- Настройки и переменные окружения
- Обработка ошибок
- Известные проблемы и что стоит починить
- Тесты
- Шпаргалка «где что искать»
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 Поднять инфраструктуру
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 Запустить приложение
./gradlew bootRun # Linux / macOS
gradlew.bat bootRun # Windows
При первом старте автоматически:
- Liquibase прогоняет миграции и создаёт/обновляет схему.
- Hibernate в режиме
validateпроверяет, что схема совпадает с сущностями, и падает с внятной ошибкой при расхождении. Ничего не переписывает. StorageStructureServiceсоздаёт бакетrefound-images, включает версионирование и правило «удалять через 180 дней».
.envчитает толькоdocker compose. Сам Spring Boot переменные из.envне подхватывает — экспортируйте их в терминал или задайте в настройках запуска IDE. Шаблон смотрите в.env.example.
2.5 Задать ffmpeg
В src/main/resources/application.yaml прописан абсолютный путь под Windows:
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 |
Ключевая деталь: частичный уникальный индекс
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
Как это работает:
POST /register— проверяем, что логин свободен, хешируем пароль BCrypt, сохраняем, выдаём пару токенов.POST /login— ищем по логину, сверяем пароль черезpasswordEncoder.matches.POST /refresh— клиент шлёт refresh-токен. Он идёт в Redis; если там есть запись и логин совпадает — старая запись удаляется, выпускается новая пара (ротация).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. - Пул для конвертации —
TaskSchedulerSpring.
8.6 Реклама: выбор объявления
Где: AdvertisingSelectionService, AdvertisingFrequencyService, AdvertisingRepository
Пошагово:
- Определить, кто смотрит:
userIdиз токена, иначеsessionIdизX-Session-Id(или из HTTP-сессии как запасной вариант). - Cooldown 30 секунд для этой сессии. Ключ
ad:session:<sessionId>:cooldown, операцияSETNX. Не прошёл — сразу204. - Забрать из БД подходящие:
findEligibleAds(now):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 - Отсортировать по весу — алгоритм Efraimidis–Spirakis:
сортируем по убыванию. Чем больше
ключ_i = случайное_число_i ^ (1 / max(weight_i, 1))weight, тем раньше объявление. Это даёт вероятность первого показа, пропорциональную весу. - Перебрать в этом порядке и взять первое, у которого не исчерпан дневной кап.
Ключ
ad:freq:u<userId>:adIdилиad:freq:s<sessionId>:adId, TTL 24 часа, операцияINCR. Если кап исчерпан — откатываем счётчик и берём следующее. - Вернуть
{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 и:
- создаёт бакет
refound-images, если его нет; - включает версионирование бакета;
- ставит правило жизненного цикла «удалить через 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 Формат ответа
{
"timestamp": "2026-09-29T12:34:56.789",
"status": 400,
"error": "VALIDATION_ERROR",
"message": "login не должен быть пустым",
"path": "/api/v1/auth/register",
"details": null
}
details заполняется только для модерации ИИ:
"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. Известные проблемы и что стоит починить
Собрано по результатам чтения кода. Порядок — по серьёзности. Пункты со ✅ исправлены, остальные — актуальные замечания.
✅ Исправлено: секреты в репозитории
.envдобавлен в.gitignoreвместе с.env.*(кроме.env.example),/data/и/minio-data/. Сам файл в Git не отслеживался, поэтомуgit rm --cachedне потребовался. Добавлен.env.exampleс пустыми шаблонными значениями.Ключи из старого
.envстоит ротировать: если файл где-то засветился, простого добавления в.gitignoreнедостаточно.- Осталось:
jwt.secretвapplication.yamlзахардкожен как дефолт, и креды БДuser/passтоже в файле. ЗадавайтеJWT_SECRETчерез окружение; для прода стоит вынести и подключение к БД.
✅ Исправлено: авторизация
PUT /api/v1/users/{id}— теперьUserService.assertCanModifyUser: менять профиль может только владелец или пользователь с рольюADMIN, иначе403. Заодно добавлена проверка уникальности логина.DELETE /api/v1/files/{*objectName}— путь переведён на wildcard-вариант (раньше ключи видаavatars/x.jpgне удалялись вообще), удаление проходит черезdeleteFileAsCurrentUser. Владелец файла фиксируется в Redis (files:owner:<objectName>, TTL 180 дней — как правило жизненного цикла бакета) при загрузке. Свой файл удалить можно, чужой — толькоADMIN; если отметки о владельце нет, доступ закрывается (fail-closed).Файлы, загруженные до этого изменения, удалить через API нельзя — обслуживаются вручную через
deleteFileиз кода./api/v1/test/**закрыт — вSecurityConfigстоитhasRole("ADMIN"), на классеTestControllerдобавлен@PreAuthorize("hasRole('ADMIN')").- WebSocket
SUBSCRIBEпроверяется вWsStompInterceptor:/topic/chat/{id}— только участник чата,/topic/user/{id}/unreadи/user/{id}/queue/**— только свой,/topic/ads**и/topic/fullscreen-ads**— толькоADMIN. Неизвестные топики запрещены по умолчанию. В сессию приCONNECTкладётся не толькоuserId, но и роль.Роль берётся из JWT, а при её отсутствии — из БД.
Осталось:
- WebSocket CORS =
*(setAllowedOriginPatterns("*")) — заметно шире HTTP-CORS. - Refresh-токен не отличается от access-токена (нет claim
typилиjti). Refresh проходит валидацию в фильтре и даёт аутентификацию без прав, то есть формально открывает всё, что защищено толькоauthenticated(). - Refresh-токены пишутся в лог в
AuthService(3 места) иRefreshTokenService(5 мест). Удалите этиlog.info.
✅ Исправлено: схема БД
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и FKmessages.chat_id → chats.idсON DELETE CASCADE— без каскадаChatService.deleteChatпадал бы.create/007-recreate-messages.yaml, который удалял таблицуmessagesвместе с перепиской, отключён и помечен как устаревший.ddl-autoпереведён наvalidate(${JPA_DDL_AUTO:validate}). Схемой управляет только Liquibase;updateможно вернуть на время разработки через переменную окружения.Post.rulesAcceptedтеперьBoolean, а неString. Колонка в БД былаvarchar— Hibernate создал её, когда поле ещё было строкой, — поэтому добавлена миграцияalter/014-posts-rules-accepted-type.yaml, которая приводит тип кbooleanс предварительной очисткой значений.
Осталось:
Advertising.mediaKey—nullable = falseв JPA, nullable в БД (changelog012снял NOT NULL).createAdvertisingдля видео явно ставитnull. Рассинхрон может привести к падению INSERT.Post.description—@Column(length = 2000)противTEXTв БД.Click.sessionIdбезlength(Hibernate возьмёт 255) противVARCHAR(64)в БД.
Пункты 14–16
validateне ловит: Hibernate сверяет только типы, про которые знает, и игнорирует nullability и длину.
🟠 Ошибки в логике
GET /api/v1/posts/{id}возвращает все объявления пользователя, а не одно — имя вводит в заблуждение. Либо переименуйте, либо разделите эндпоинты.CampaignService.updateCampaignStatusвозвращает старый статус. Bulk-@Modifyingобходит persistence context, поэтому маппится устаревший объект. Клиент увидит прежний статус, хотя в БД уже новый.- Таймаут ffmpeg не работает.
reader.lines().forEach(...)блокируется до конца процесса, и доwaitFor(5, MINUTES)управление не дойдёт. Если ffmpeg завис — пул из 2 потоков исчерпается навсегда. Читать вывод надо в отдельном потоке или использоватьredirectOutput(File). confirm-uploadне проверяет, что файл загружен и что ключ его. Можно передать ключ из другого объявления — тогда файл перекодируется в чужое объявление, а исходник оригинала удалится вfinally.EntityNotFoundExceptionне обработан вGlobalExceptionHandler— «не найдено» отдаётся как 500, а не 404. Задевает пользователей, кампании, рекламу.getReferenceById+catch (EntityNotFoundException)в трекинге рекламы не работает. Прокси бросит исключение только на flush, и оно будетDataIntegrityViolationException.- Любое исключение при модерации =
REJECTED. Падение MinIO или NPE выглядит так же, как нарушение правил, и объявление уходит в отказ навсегда. Ловите отдельно инфраструктурные сбои. - AiTimeoutException нигде не бросается. Таймаут превращается в
AiUnavailableException. AiHttpClientглотаетAiBadRequestException. Ошибка 400 переквалифицируется в «недоступно» → три бесполезных повтора → пост навсегда вMODERATION.- Ключи карты в ответе
userEmailсодержат логин (PostMapper.toResponse:user.login→userEmail). Путает и фронт, и людей. AiRetryPropertiesиminio.ads-bucketвне типизированных properties — нет валидации.ModerationResponseс@Builder, но без@Jacksonized— вероятно, не десериализуется. Проверьте на живом ответе ИИ-сервиса.- У
POST /api/v1/postsвозвращаетсяPostRequest, то есть эхо входа, а не созданный объект и не 201.
🟡 Производительность
findEligibleAdsбез пагинации — каждыйGET /api/ads/nextвытягивает все подходящие объявления в память.- Дневной кап — пер-объявление, а не пер-кампания. Кампания из 5 объявлений с капом 10 даёт до 50 показов в сутки.
- Кап обходится анонимной сессией — клиент просто шлёт новый
X-Session-Id. И cooldown привязан к сессии, а не к пользователю, так что с двух устройств он не работает. - Redis недоступен → 500 на
GET /api/ads/next:tryRegisterImpressionзащищён, аisSessionCooldownPassed— нет. - Счётчик капа тратится на выдаче объявления, а не на показе. Фронт закрыл вкладку —
лимит израсходован, а строки в
impressionнет. - Отдача медиа целиком в память (
getObjectAsBytes). Для видео это риск OOM, нет поддержки Range. - Счётчики статистики теряют инкременты при параллельных записях.
show-sql: true— все SQL-запросы печатаются в лог. На боевом окружении выключить.- Весь текст объявления логируется на 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.
Запуск (переменные окружения нужны те же, что и приложению):
./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) |
Документ описывает состояние проекта на момент последнего коммита. Если что-то в коде разошлось с описанием — правьте код, а потом этот файл.