Тема
ТЗ — UGC-публикации + модерация (cms/ugc)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: catalog, er Статус: ТЗ к разработке
Назначение и возможности
Пользователи сайта размещают контент (объявления, товары, портфолио — конкретные типы публикаций декларируются модулями-потребителями) с премодерацией, антифрод-проверкой и лимитами по тарифу. Реализует модель мультивендора из /cms-v2/commerce-model и кабинетную интеграцию из /cms-v2/client-cabinets.
- Типы публикаций декларируются модулями через реестр (объявление, товар, портфолио и т.д.);
- премодерация через
cms/moderation(очередь, одобрение/отклонение/на доработку); - антифрод-проверка публикации перед выходом в премодерацию (
cms/antifraud); - лимиты и тарифы на размещение: квота активных публикаций, платное продвижение;
- заявка по объявлению создаёт лид ядра с привязкой к публикации и продавцу (
vendor_id); - статистика просмотров и заявок по публикации для автора (в его кабинете);
- жалобы на публикацию с очередью для модераторов;
- редактирование уже одобренной публикации отправляет её на повторную модерацию: правка хранится отдельно от опубликованной версии до вердикта — публично видна последняя одобренная версия, а не тихая подмена одобренного контента;
- загрузка изображений — через
MediaServiceядра: лимиты размера/количества, EXIF-очистка (геометки не публикуются наружу), скан на вредоносное содержимое/NSFW — через provides-контрактupload-scannerмедиатеки (карантинpendingдо вердикта; 0 реализаций = скан пропускается — деградация штатна, но для UGC-сайтов реализация прямо рекомендована ревизией №2, п. 4); - шедоу-бан нарушителя: публикации автора остаются видимы ему самому, но скрыты от остальных посетителей — без явного 403 в лицо (открытая блокировка провоцирует создание новых аккаунтов для обхода).
Зависимости и выключение
requires: cms/moderation · suggests: cms/antifraud, cms/cabinet-b2c · provides: ugc-publication
Поведение при выключении: ранее опубликованные материалы остаются видимыми на сайте как обычный контент (без возможности редактирования автором и без новых публикаций); кабинетные разделы «мои публикации» скрываются. Если выключен cms/moderation (жёсткая зависимость requires, не suggests) — приём новых публикаций и повторных правок останавливается с понятной ошибкой «модерация временно недоступна», уже одобренный контент остаётся на сайте (инвариант §0.1 стандарта); см. также «⚠️ Противоречие» в разделе «Крайние случаи».
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_ugc_publications | id (ULID), type, user_id, vendor_id (nullable), status, payload (json), pending_payload (json, nullable), expires_at, views_count | публикация; payload — текущая одобренная версия полей по схеме типа, pending_payload — правка, ожидающая повторной модерации |
cms_ugc_plans | id, name, max_active_publications, price, promotion_options (json) | тарифы на размещение |
cms_ugc_leads | id, publication_id, lead_id | связь заявки (лид ядра) с публикацией |
cms_ugc_author_flags | id, user_id (unique), shadow_banned_at (nullable), reason | флаг шедоу-бана автора; отдельная таблица, не портит структуру публикаций |
FK user_id, vendor_id, publication_id, lead_id — constrained()->index(). type/status в cms_ugc_publications — PHP Enum; индекс на (status, expires_at) под выборку активных/истёкших публикаций; индекс на user_id — под ленту автора и джойн с cms_ugc_author_flags при фильтрации публичной ленты от шедоу-бана. payload/pending_payload/promotion_options — json()->nullable() + cast array; GIN-индекс на payload под фильтры ленты по полям типа. id — ULID, не автоинкремент (§4 стандарта: публичные идентификаторы, защита от перебора чужих публикаций).
ПДн-паспорт. payload/pending_payload могут содержать контактные данные автора (телефон, адрес — зависит от схемы типа публикации, которую декларирует модуль-потребитель); cms_ugc_leads лишь ссылается на лид ядра — сами ПДн заявителя хранит LeadService ядра, не ugc. Ретеншн: истёкшая публикация хранится ugc.expired_publication_retention_days (дефолт 90 дней), затем контактные поля payload анонимизируются джобой ретеншна — сама публикация как факт остаётся для истории вендора. Участвует в «выгрузить всё по субъекту» (публикации + жалобы по user_id) и «забыть по запросу» (анонимизация контактных полей payload; публикация целиком не удаляется, если на неё уже есть лиды — 152-ФЗ допускает хранение факта сделки).
Входные и выходные данные
Whitelist-принцип: поле payload, не описанное схемой типа публикации, и любой параметр API вне перечня ниже — отвергаются (422), не игнорируются молча.
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
POST /api/v1/cabinet/ugc/publications | type, payload{…по схеме типа}, media_ids[] | FormRequest-whitelist + схема полей типа (FieldTypeRegistry, декларируется модулем-потребителем через реестр типов публикаций) |
PUT /api/v1/cabinet/ugc/publications/{id} | те же поля payload, что при создании | то же + Policy владения (ugc.manage.own); принятая правка уходит в pending_payload, статус — обратно в review |
POST /api/v1/admin/media (вызов MediaService из формы публикации) | файл изображения | MIME-whitelist, ugc.max_images_per_publication/ugc.max_image_size_mb, EXIF-очистка; скан вредоносного содержимого/NSFW через upload-scanner (карантин pending до вердикта) |
POST /api/v1/ugc/publications/{id}/lead | name, phone, message (whitelist полей лида) | FormRequest, rate-limit, honeypot |
Filament: решение модератора (cms/moderation) | status (approve/reject/rework), comment | Policy moderation.assign/manage движка cms/moderation; comment обязателен при отклонении |
Событие ModerationStatusChanged (от cms/moderation) | item_id, from_status, to_status, actor_user_id | контракт moderation-workflow; ugc сверяет item_id со своей публикацией/правкой |
Скор/событие AntifraudQuarantined (от cms/antifraud, suggests) | subject_id, score, signals | контракт fraud-signal; при отсутствии модуля публикация идёт в премодерацию без скоринга |
cms:ugc:expire-publications, cms:ugc:recount-views (CLI) | --dry-run, без обязательных аргументов | whitelist аргументов команды |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
GET /api/v1/ugc/publications, /{id} (публика) | опубликованные, не скрытые шедоу-баном публикации | конверт {data, meta}, keyset-пагинация |
LeadService ядра (канал 4) | publication_id, vendor_id, contact | вызов LeadService::create() → lead_id |
MediaService ядра (канал 4) | файл + коллекция ugc-publication-images | id медиа + производные конверсии |
| Блок «Лента публикаций» / «Форма заявки по объявлению» | данные для рендера | через сервис модуля, не запрос из шаблона |
Автор (GET /cabinet/ugc/publications/{id}/stats) | просмотры/заявки | конверт {data, meta} |
| Подписчики шины событий | UgcPublicationSubmitted/Resubmitted/Approved/Rejected, UgcLeadReceived, UgcAuthorShadowBanned | payload события |
NotificationDispatch (core-contract, канал 3) | уведомление автору о вердикте/шедоу-бане | вызов ядра → notifications-bus или log-fallback |
Настройки (группа ugc)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
ugc.moderation_mode | string | pre | нет | Режим модерации: pre/post |
ugc.default_publication_days | int | 30 | нет | Срок жизни публикации по умолчанию |
ugc.free_plan_max_active | int | 3 | нет | Лимит активных публикаций на бесплатном тарифе |
ugc.antifraud_check_enabled | bool | true | нет | Проверка антифродом перед премодерацией |
ugc.max_images_per_publication | int | 10 | нет | Лимит изображений на публикацию (лимиты/квоты, матрица v2.2) |
ugc.max_image_size_mb | int | 8 | нет | Лимит размера одного изображения |
ugc.expired_publication_retention_days | int | 90 | нет | Хранение истёкшей публикации до анонимизации контактных полей payload |
ugc.new_publications_enabled | bool | true | нет | Kill-switch: аварийная остановка приёма новых публикаций/правок без выключения модуля целиком |
Достижение лимита max_images_per_publication/max_image_size_mb — понятная ошибка 422 с указанием, что превышено, и метрика в cms/health, не 500 и не тихое обрезание файла (§6 стандарта).
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/ugc/publications | public | Список опубликованных материалов (фильтры, пагинация) |
| GET | /api/v1/ugc/publications/{id} | public | Одна опубликованная и не скрытая шедоу-баном публикация |
| POST | /api/v1/cabinet/ugc/publications | auth | Создать публикацию (в очередь модерации) |
| PUT | /api/v1/cabinet/ugc/publications/{id} | auth (владелец) | Редактировать свою публикацию → повторная модерация |
| POST | /api/v1/ugc/publications/{id}/lead | public (rate-limit) | Заявка по публикации → лид ядра |
| GET | /api/v1/cabinet/ugc/publications/{id}/stats | auth (владелец) | Статистика просмотров/заявок |
| DELETE | /api/v1/admin/ugc/publications/{id} | studio (ugc.manage, необратимо) | Жёсткое удаление публикации (не снятие с показа) — издаёт UgcPublicationDeleted |
Список публикаций — keyset-пагинация (не OFFSET). Создание/правка публикации принимает заголовок Idempotency-Key (конвенция API ядра, core.md): повторная отправка формы при обрыве связи не создаёт вторую публикацию/правку.
Компоненты
Блоки (BlockRegistry): «Лента публикаций», «Форма заявки по объявлению» — demo-props из сидера UgcDemoSeeder (десятки публикаций трёх типов), галерея /_gallery и playground показывают блоки без ручного ввода. Filament: очередь модерации публикаций, тарифы и лимиты, жалобы, флаги шедоу-бана. Команды: cms:ugc:expire-publications --json (снятие истёкших с публикации), cms:ugc:recount-views --json (пересчёт счётчика просмотров из журнала событий, идемпотентен), cms:ugc:import-legacy --source=<профиль> --json (см. «Донорский код»).
Фронтенд-бюджет: лента и форма — свои ассеты через пайплайн темы; изображения — lazy-load по видимости, резерв пропорций (без CLS при подгрузке); форма публикации и загрузка изображений доступны с клавиатуры, поля — с label.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
UgcPublicationSubmitted | создана публикация, отправлена в модерацию | publication_id, type, user_id |
UgcPublicationResubmitted | правка одобренной публикации отправлена на повторную модерацию | publication_id, user_id |
UgcPublicationApproved | публикация (или правка) одобрена | publication_id |
UgcPublicationRejected | публикация отклонена | publication_id, comment |
UgcPublicationDeleted | публикация удалена окончательно (studio, не снятие с показа) | id, type — облегчённый payload по канону ревизии 14.07.2026, п. 3 |
UgcLeadReceived | получена заявка по публикации | publication_id, lead_id |
UgcAuthorShadowBanned | автор помечен шедоу-баном | user_id, reason |
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/moderation (requires) | provides moderation-workflow + событие ModerationStatusChanged | out/in | ugc регистрирует публикацию/правку как модерируемую сущность; получает вердикт событием |
cms/antifraud (suggests) | сервис-вызов AntifraudService::score() | out | скоринг публикации перед выходом в премодерацию; без модуля — публикация идёт в премодерацию напрямую |
Ядро: LeadService | сервис-вызов (канал 4) | out | заявка по публикации создаёт лид ядра с привязкой к publication_id/vendor_id |
Ядро: MediaService | сервис-вызов (канал 4) | out | загрузка/конверсии/EXIF-очистка/скан изображений публикации |
Ядро: NotificationDispatch | core-contract (канал 3) | out | уведомление автора об одобрении/отклонении/шедоу-бане |
cms/cabinet-b2c (suggests) | provides-контракт cabinet-shell (CabinetSectionContract) | in | ugc регистрирует секцию «мои публикации»; без модуля — доступ только через прямые роуты API |
| Модули-потребители типов публикаций (объявление/товар/портфолио) | provides-контракт ugc-publication | out | ugc отдаёт жизненный цикл публикации, потребитель определяет схему полей payload своего типа |
Очередь ugc | очередь (канал 5) | — | expire-publications, батчевый пересчёт счётчика просмотров |
Фоновая работа
Именованная очередь ugc для cms:ugc:expire-publications (снятие истёкших с публикации по расписанию через ScheduleRegistrar ядра) и для батчевого пересчёта просмотров; джобы идемпотентны.
Эксплуатация (runbook). Метрики: размер очереди ugc и её lag, число публикаций в статусе review (в т.ч. повторных правок), доля отклонённых за сутки, число ошибок скана изображений (антивирус/NSFW) в MediaService за час. Алерты: очередь ugc отстаёт дольше расписания снятия истёкших публикаций; резкий рост доли ошибок скана изображений (сигнал о деградации медиатеки, не про сами публикации).
| Симптом | Что проверить | Команда |
|---|---|---|
| Одобренная публикация не появилась в ленте | инвалидация тега ugc:feed/ugc:publication:<id> | cms:cache:inspect --tag=ugc:feed |
| Истёкшие публикации не снимаются с показа | лаг очереди ugc, расписание | cms:ugc:expire-publications --dry-run --json |
| Счётчик просмотров разошёлся с реальными хитами | последний батч пересчёта | cms:ugc:recount-views --dry-run --json |
| Изображения не загружаются в форме публикации | здоровье MediaService/скана в cms/health | cms:doctor --json |
| Публикация «зависла» в модерации дольше SLA | очередь cms/moderation, а не ugc | cms:moderation:sla-check --json |
Бэкап/рестор: в бэкап попадают все таблицы cms_ugc_* и привязанные медиафайлы (через общий бэкап медиатеки). После рестора пересоздаются: денормализованный views_count (командой cms:ugc:recount-views) и теги кеша ленты — рестор без этого шага не считается завершённым.
Производительность и кеш
Ожидаемые объёмы: на активный сайт с UGC — тысячи–десятки тысяч публикаций, сотни одновременно в очереди модерации, единицы–десятки изображений на публикацию.
Горячие пути и бюджет запросов: публичная лента (GET /publications) — из page-cache при отсутствии персонализации, попадание без запросов к БД; при промахе — не более 2 запросов (список + withCount/агрегаты, без N+1 по изображениям — через MediaService); настройки группы ugc читаются из кеша (0 запросов на горячем пути, §6 стандарта); проверка шедоу-бана автора — джойн по индексу user_id, не отдельный запрос на публикацию.
Критичные индексы (см. «Модель данных»): (status, expires_at) — под выборку активных/истёкших; user_id — под ленту автора и джойн с cms_ugc_author_flags; GIN на payload — под фильтры ленты по полям типа публикации.
Теги кеша и инвалидация: ugc:publication:<id>, ugc:feed — инвалидируются событиями UgcPublicationApproved, UgcPublicationRejected, снятием с публикации и UgcAuthorShadowBanned (немедленно скрывает публикации автора из ленты). Счётчик просмотров обновляется батчем, не на каждый хит (защита от избыточной инвалидации).
Персональные данные (контактные поля payload, статистика автора, раздел «мои публикации») не попадают в общий page-cache — кешируется только публичная, неперсонализированная лента.
Безопасность
Автор редактирует только свои публикации — Policy владения на каждом эндпоинте, выборки скоуплены where user_id = auth()->id() в сервисе, не собираются по перебору id в контроллере (защита от IDOR: чужую публикацию по прямому id не получить ни на чтение чужого черновика/правки, ни на редактирование). Публичные идентификаторы — ULID (§4 стандарта), не автоинкремент.
Новая публикация не видна публично до одобрения при moderation_mode=pre. Правка одобренной публикации не подменяет видимый контент — до вердикта публично отдаётся старый payload, новый лежит в pending_payload. Антифрод-проверка не пропускает публикацию с высоким скорингом сразу в премодерацию без карантина. Заявка по объявлению — rate-limit, FormRequest-whitelist полей лида, honeypot. Изображения — только через MediaService (MIME-whitelist, лимиты, EXIF-очистка, скан через upload-scanner: файл в карантине pending до вердикта, в публикацию не попадает, пока не пройден).
Шедоу-бан не сообщается автору кодом ответа — API отвечает так же, как для обычной публикации (200/201), скрытие происходит на уровне выборки публичной ленты; иначе забаненный получает точный сигнал и заводит новый аккаунт.
Права: ugc.view, ugc.manage.own, ugc.moderate, ugc.manage (тарифы, лимиты, шедоу-бан).
Матрица ролей:
| Роль | Действие |
|---|---|
| Гость/незарегистрированный | читает публичную ленту (ugc.view), отправляет заявку по объявлению (rate-limit) |
| Автор (пользователь) | создаёт/редактирует свои публикации (ugc.manage.own), видит свою статистику; чужие черновики/правки недоступны |
| Модератор | решения по очереди со стороны cms/moderation (moderation.assign/manage), видит публикации независимо от статуса |
| Менеджер | управляет тарифами и лимитами (ugc.manage), может снять публикацию с показа |
| Studio | + шедоу-бан автора, жёсткое удаление публикации (DELETE .../publications/{id}), восстановительные команды (recount-views, import-legacy), доверенный HTML в карточках тарифов при необходимости |
UX-требования
Для посетителя (автора публикации):
- отправка публикации/правки — без ожидания синхронного вердикта модерации, статус «на проверке» виден сразу, форма не блокируется до ответа модератора;
- при ошибке валидации формы введённые поля и уже загруженные изображения не теряются — повторная отправка не требует загрузки файлов заново;
- шедоу-бан незаметен автору: статистика и статус его публикаций выглядят обычно, без баннера «вы заблокированы» и без демонстративных нулей в просмотрах;
- форма публикации на мобильном — компактный степпер по шагам, а не одна длинная форма с прокруткой.
Для администратора/модератора:
- пустая очередь модерации — «новых публикаций на проверку нет», а не пустая таблица без текста;
- массовые действия в списке публикаций: «снять с публикации выбранные», «перевести автора выбранных публикаций в шедоу-бан»;
- отклонение без причины — ошибка «укажите причину отклонения, автор должен понимать, что исправить», а не техническое сообщение валидации;
- подтверждение необратимых операций: шедоу-бан автора и жёсткое удаление публикации (не снятие с показа) — модальное окно с описанием последствий перед действием.
Крайние случаи и типовые баги
- редактирование одобренной публикации → правка уходит в
pending_payloadи статусreview, публично остаётся прежний одобренныйpayloadдо вердикта — не тихая подмена (см. «Назначение» и «Безопасность»); - повторная доставка события
ModerationStatusChanged(ретрай очереди) → обработчик идемпотентен: применяет переход только если публикация всё ещё в ожидаемом статусе, повторный вердикт на уже применённое решение — no-op; - двойной сабмит формы публикации (двойной клик, обрыв связи на мобильном) →
Idempotency-Keyна создании/правке не даёт создать вторую публикацию/вторую правку на тот же запрос; - выключен
cms/moderation(жёсткая зависимостьrequires, неsuggests) → ⚠️ Противоречие: стандарт (§3) подробно описывает деградацию при выключении обязательных managed-модулей (cms/updates,cms/healthи т.д.), но не определяет поведение при выключении обычногоrequires-модуля, от которого зависят другие (здесь —cms/moderationдляcms/ugc). Разрешение, принятое в этом ТЗ: до появления отдельного правила в графе зависимостей ugc трактует выключенныйcms/moderationкак сбой обязательной зависимости — новые публикации и правки не принимаются («модерация временно недоступна», не 500), ранее одобренный контент остаётся на сайте (инвариант §0.1); - отсутствует
cms/antifraud(suggests) → публикация идёт в премодерацию без скоринга, карантина не будет — премодератор — единственный барьер, деградация ожидаема, не поломка; - отсутствует
cms/cabinet-b2c(suggests) → раздел «мои публикации» в кабинете не регистрируется, автор управляет публикациями через прямые роуты API — без потери функциональности, только UX; - сбой/таймаут скана изображения (
upload-scanner) (антивирус/NSFW недоступен) → загрузка отдельного файла завершается явной ошибкой с предложением повторить, файл без прохождения скана не сохраняется в коллекцию публикации — не тихий пропуск проверки; если в инсталляции нет ни одной реализацииupload-scanner— скан пропускается штатно (деградация ядра, ревизия №2 п. 4), но для UGC-сайта её отсутствие — повод для алертаcms/health, а не тихая норма (эксплуатационная рекомендация, не гейт); - публикация без обязательных по схеме типа изображений → отклоняется на этапе валидации
payloadпо схеме типа (ugc сам не решает, обязательны ли фото — это часть контрактаugc-publicationпотребителя); - противоречие настроек
moderation_mode=post+antifraud_check_enabled=true→ публикация выходит сразу (post-модерация), но карантинный вердикт антифрода, пришедший позже асинхронно, обязан снять уже опубликованную запись обратно вreview, а не просто дописать сигнал в лог — иначе накрутка успевает пожить в публичной ленте до просмотра модератором; - гонка: шедоу-бан ставится автору с уже видимыми одобренными публикациями → скрытие в ленте — на уровне джойна с
cms_ugc_author_flagsпри выборке, не разовый backfill по публикациям: эффект применяется мгновенно ко всем текущим и будущим публикациям автора; - измерение
city_id/site_idотсутствует (сайт без мультигорода/мультисайта) → лента и лимиты тарифов работают корректно и в этом режиме — контрактный тест гоняется в обоих режимах (§4 стандарта, «правило измерений»); - истёкшая публикация с ПДн в
payloadспустяexpired_publication_retention_days→ джоба ретеншна анонимизирует контактные поля, сама запись и статистика остаются.
Донорский код
| Что взять | Путь |
|---|---|
| Подходы к пользовательским публикациям и модерации | catalog, er |
Миграция legacy-данных. Команда cms:ugc:import-legacy --source=<профиль> --json маппит объявления/товары/портфолио доноров (catalog, er) на схему cms_ugc_*: ключ идемпотентности — external_id в payload (повторный прогон обновляет, не дублирует), --dry-run показывает расхождения до применения. Прогон на копии донорских данных — часть приёмки модуля (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест: новая публикация не видна публично до одобрения при
moderation_mode=pre; - [ ] Правка одобренной публикации не подменяет видимый контент до вердикта (
pending_payloadvspayload); - [ ] Лимит
free_plan_max_activeблокирует создание новой публикации сверх квоты на бесплатном тарифе; - [ ] Заявка по объявлению создаёт лид ядра с корректной привязкой к публикации;
- [ ] Антифрод-проверка не пропускает публикацию с высоким скорингом сразу в премодерацию без карантина;
- [ ] Автор редактирует и видит только свои публикации (IDOR: чужой
idне даёт доступа ни на чтение черновика, ни на запись); - [ ] Изображение без прохождения EXIF-очистки/скана
upload-scanner(карантинpending) не сохраняется в коллекцию публикации; 0 реализаций — скан пропускается штатно; - [ ] Жёсткое удаление публикации (
DELETE .../publications/{id}, только studio) издаётUgcPublicationDeletedс облегчённым payload (id, type); - [ ] Шедоу-бан скрывает публикации автора из ленты немедленно и без 403 в ответах API автору;
- [ ] Деградация при выключении
cms/ugc/cms/moderationсохраняет ранее опубликованный контент видимым, без 500; - [ ] Истёкшие публикации снимаются джобом
cms:ugc:expire-publications, не остаются активными бессрочно; - [ ]
Idempotency-Keyна создании/правке не даёт создать дубль при повторной отправке; - [ ]
cms:ugc:import-legacy --dry-runпрогнан на копии донорских данныхcatalog/erбез расхождений сверх ожидаемых; - [ ] Матрица ролей (выше) покрыта тестами на разграничение доступа;
- [ ] Контрактный набор
cms-testingзелёный, testbench-изоляция пакета, feature-тест на каждый роут; - [ ] Тестовая БД только
ugc_test;migrate:fresh/refresh/reset,db:wipeзапрещены.