SlimusMinus 59872436b9 Merge pull request #39 from SlimusMinus/fix-admin_tools
added all edit posts for admin
2026-10-02 01:54:56 +03:00
2026-03-24 00:34:04 +03:00
2026-09-23 23:34:05 +03:00
2026-10-02 01:54:19 +03:00
2026-03-24 00:34:04 +03:00
2026-10-02 01:54:19 +03:00
2026-03-24 00:34:04 +03:00
2026-03-24 00:34:04 +03:00
2026-03-24 00:34:04 +03:00

ReFound — документация проекта

Привет! Это подробное описание проекта ReFound — бэкенд сайта объявлений «Потерял / нашёл» (потерянные питомцы, вещи, документы и т.д.).

Документ написан простым языком и отвечает на три вопроса:

  1. Что здесь лежит — какая папка за что отвечает.
  2. Как это связано — что с чем общается, какой запрос куда идёт.
  3. Как это запустить — что нужно поставить и как поднять локально.

Содержание

  1. Что это за проект
  2. Быстрый старт
  3. Технологии
  4. Структура проекта
  5. Архитектура: как ходит запрос
  6. База данных
  7. API: все эндпоинты
  8. Подсистемы подробно
  9. Кэширование в Redis
  10. Хранение файлов в MinIO
  11. Настройки и переменные окружения
  12. Обработка ошибок
  13. Известные проблемы и что стоит починить
  14. Тесты
  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 Поднять инфраструктуру

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

При первом старте автоматически:

  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:

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

Как это работает:

  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):
    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 Формат ответа

{
  "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. Известные проблемы и что стоит починить

Собрано по результатам чтения кода. Порядок — по серьёзности. Пункты со ✅ исправлены, остальные — актуальные замечания.

✅ Исправлено: секреты в репозитории

  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 через окружение; для прода стоит вынести и подключение к БД.

✅ Исправлено: авторизация

  1. PUT /api/v1/users/{id} — теперь UserService.assertCanModifyUser: менять профиль может только владелец или пользователь с ролью ADMIN, иначе 403. Заодно добавлена проверка уникальности логина.
  2. DELETE /api/v1/files/{*objectName} — путь переведён на wildcard-вариант (раньше ключи вида avatars/x.jpg не удалялись вообще), удаление проходит через deleteFileAsCurrentUser. Владелец файла фиксируется в Redis (files:owner:<objectName>, TTL 180 дней — как правило жизненного цикла бакета) при загрузке. Свой файл удалить можно, чужой — только ADMIN; если отметки о владельце нет, доступ закрывается (fail-closed).

    Файлы, загруженные до этого изменения, удалить через API нельзя — обслуживаются вручную через deleteFile из кода.

  3. /api/v1/test/** закрыт — в SecurityConfig стоит hasRole("ADMIN"), на классе TestController добавлен @PreAuthorize("hasRole('ADMIN')").
  4. WebSocket SUBSCRIBE проверяется в WsStompInterceptor: /topic/chat/{id} — только участник чата, /topic/user/{id}/unread и /user/{id}/queue/** — только свой, /topic/ads** и /topic/fullscreen-ads** — только ADMIN. Неизвестные топики запрещены по умолчанию. В сессию при CONNECT кладётся не только userId, но и роль.

    Роль берётся из JWT, а при её отсутствии — из БД.

Осталось:

  1. WebSocket CORS = * (setAllowedOriginPatterns("*")) — заметно шире HTTP-CORS.
  2. Refresh-токен не отличается от access-токена (нет claim typ или jti). Refresh проходит валидацию в фильтре и даёт аутентификацию без прав, то есть формально открывает всё, что защищено только authenticated().
  3. Refresh-токены пишутся в лог в AuthService (3 места) и RefreshTokenService (5 мест). Удалите эти log.info.

✅ Исправлено: схема БД

  1. 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 вместе с перепиской, отключён и помечен как устаревший.
  2. ddl-auto переведён на validate (${JPA_DDL_AUTO:validate}). Схемой управляет только Liquibase; update можно вернуть на время разработки через переменную окружения.
  3. Post.rulesAccepted теперь Boolean, а не String. Колонка в БД была varchar — Hibernate создал её, когда поле ещё было строкой, — поэтому добавлена миграция alter/014-posts-rules-accepted-type.yaml, которая приводит тип к boolean с предварительной очисткой значений.

Осталось:

  1. Advertising.mediaKey — nullable = false в JPA, nullable в БД (changelog 012 снял NOT NULL). createAdvertising для видео явно ставит null. Рассинхрон может привести к падению INSERT.
  2. Post.description — @Column(length = 2000) против TEXT в БД.
  3. Click.sessionId без length (Hibernate возьмёт 255) против VARCHAR(64) в БД.

Пункты 14–16 validate не ловит: Hibernate сверяет только типы, про которые знает, и игнорирует nullability и длину.

🟠 Ошибки в логике

  1. GET /api/v1/posts/{id} возвращает все объявления пользователя, а не одно — имя вводит в заблуждение. Либо переименуйте, либо разделите эндпоинты.
  2. CampaignService.updateCampaignStatus возвращает старый статус. Bulk-@Modifying обходит persistence context, поэтому маппится устаревший объект. Клиент увидит прежний статус, хотя в БД уже новый.
  3. Таймаут ffmpeg не работает. reader.lines().forEach(...) блокируется до конца процесса, и до waitFor(5, MINUTES) управление не дойдёт. Если ffmpeg завис — пул из 2 потоков исчерпается навсегда. Читать вывод надо в отдельном потоке или использовать redirectOutput(File).
  4. confirm-upload не проверяет, что файл загружен и что ключ его. Можно передать ключ из другого объявления — тогда файл перекодируется в чужое объявление, а исходник оригинала удалится в finally.
  5. EntityNotFoundException не обработан в GlobalExceptionHandler — «не найдено» отдаётся как 500, а не 404. Задевает пользователей, кампании, рекламу.
  6. getReferenceById + catch (EntityNotFoundException) в трекинге рекламы не работает. Прокси бросит исключение только на flush, и оно будет DataIntegrityViolationException.
  7. Любое исключение при модерации = REJECTED. Падение MinIO или NPE выглядит так же, как нарушение правил, и объявление уходит в отказ навсегда. Ловите отдельно инфраструктурные сбои.
  8. AiTimeoutException нигде не бросается. Таймаут превращается в AiUnavailableException.
  9. AiHttpClient глотает AiBadRequestException. Ошибка 400 переквалифицируется в «недоступно» → три бесполезных повтора → пост навсегда в MODERATION.
  10. Ключи карты в ответе userEmail содержат логин (PostMapper.toResponse: user.login → userEmail). Путает и фронт, и людей.
  11. AiRetryProperties и minio.ads-bucket вне типизированных properties — нет валидации.
  12. ModerationResponse с @Builder, но без @Jacksonized — вероятно, не десериализуется. Проверьте на живом ответе ИИ-сервиса.
  13. У POST /api/v1/posts возвращается PostRequest, то есть эхо входа, а не созданный объект и не 201.

🟡 Производительность

  1. findEligibleAds без пагинации — каждый GET /api/ads/next вытягивает все подходящие объявления в память.
  2. Дневной кап — пер-объявление, а не пер-кампания. Кампания из 5 объявлений с капом 10 даёт до 50 показов в сутки.
  3. Кап обходится анонимной сессией — клиент просто шлёт новый X-Session-Id. И cooldown привязан к сессии, а не к пользователю, так что с двух устройств он не работает.
  4. Redis недоступен → 500 на GET /api/ads/next: tryRegisterImpression защищён, а isSessionCooldownPassed — нет.
  5. Счётчик капа тратится на выдаче объявления, а не на показе. Фронт закрыл вкладку — лимит израсходован, а строки в impression нет.
  6. Отдача медиа целиком в память (getObjectAsBytes). Для видео это риск OOM, нет поддержки Range.
  7. Счётчики статистики теряют инкременты при параллельных записях.
  8. show-sql: true — все SQL-запросы печатаются в лог. На боевом окружении выключить.
  9. Весь текст объявления логируется на 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)

Документ описывает состояние проекта на момент последнего коммита. Если что-то в коде разошлось с описанием — правьте код, а потом этот файл.

Description
No description provided
Readme 381 KiB
Languages
Java 100%