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