Skip to content

ТЗ — 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_publicationsid (ULID), type, user_id, vendor_id (nullable), status, payload (json), pending_payload (json, nullable), expires_at, views_countпубликация; payload — текущая одобренная версия полей по схеме типа, pending_payload — правка, ожидающая повторной модерации
cms_ugc_plansid, name, max_active_publications, price, promotion_options (json)тарифы на размещение
cms_ugc_leadsid, publication_id, lead_idсвязь заявки (лид ядра) с публикацией
cms_ugc_author_flagsid, user_id (unique), shadow_banned_at (nullable), reasonфлаг шедоу-бана автора; отдельная таблица, не портит структуру публикаций

FK user_id, vendor_id, publication_id, lead_idconstrained()->index(). type/status в cms_ugc_publications — PHP Enum; индекс на (status, expires_at) под выборку активных/истёкших публикаций; индекс на user_id — под ленту автора и джойн с cms_ugc_author_flags при фильтрации публичной ленты от шедоу-бана. payload/pending_payload/promotion_optionsjson()->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/publicationstype, 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}/leadname, phone, message (whitelist полей лида)FormRequest, rate-limit, honeypot
Filament: решение модератора (cms/moderation)status (approve/reject/rework), commentPolicy 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-imagesid медиа + производные конверсии
Блок «Лента публикаций» / «Форма заявки по объявлению»данные для рендерачерез сервис модуля, не запрос из шаблона
Автор (GET /cabinet/ugc/publications/{id}/stats)просмотры/заявкиконверт {data, meta}
Подписчики шины событийUgcPublicationSubmitted/Resubmitted/Approved/Rejected, UgcLeadReceived, UgcAuthorShadowBannedpayload события
NotificationDispatch (core-contract, канал 3)уведомление автору о вердикте/шедоу-баневызов ядра → notifications-bus или log-fallback

Настройки (группа ugc)

КлючТипДефолтaffectsPageCacheОписание
ugc.moderation_modestringpreнетРежим модерации: pre/post
ugc.default_publication_daysint30нетСрок жизни публикации по умолчанию
ugc.free_plan_max_activeint3нетЛимит активных публикаций на бесплатном тарифе
ugc.antifraud_check_enabledbooltrueнетПроверка антифродом перед премодерацией
ugc.max_images_per_publicationint10нетЛимит изображений на публикацию (лимиты/квоты, матрица v2.2)
ugc.max_image_size_mbint8нетЛимит размера одного изображения
ugc.expired_publication_retention_daysint90нетХранение истёкшей публикации до анонимизации контактных полей payload
ugc.new_publications_enabledbooltrueнетKill-switch: аварийная остановка приёма новых публикаций/правок без выключения модуля целиком

Достижение лимита max_images_per_publication/max_image_size_mb — понятная ошибка 422 с указанием, что превышено, и метрика в cms/health, не 500 и не тихое обрезание файла (§6 стандарта).

API

МетодПутьДоступНазначение
GET/api/v1/ugc/publicationspublicСписок опубликованных материалов (фильтры, пагинация)
GET/api/v1/ugc/publications/{id}publicОдна опубликованная и не скрытая шедоу-баном публикация
POST/api/v1/cabinet/ugc/publicationsauthСоздать публикацию (в очередь модерации)
PUT/api/v1/cabinet/ugc/publications/{id}auth (владелец)Редактировать свою публикацию → повторная модерация
POST/api/v1/ugc/publications/{id}/leadpublic (rate-limit)Заявка по публикации → лид ядра
GET/api/v1/cabinet/ugc/publications/{id}/statsauth (владелец)Статистика просмотров/заявок
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 + событие ModerationStatusChangedout/inugc регистрирует публикацию/правку как модерируемую сущность; получает вердикт событием
cms/antifraud (suggests)сервис-вызов AntifraudService::score()outскоринг публикации перед выходом в премодерацию; без модуля — публикация идёт в премодерацию напрямую
Ядро: LeadServiceсервис-вызов (канал 4)outзаявка по публикации создаёт лид ядра с привязкой к publication_id/vendor_id
Ядро: MediaServiceсервис-вызов (канал 4)outзагрузка/конверсии/EXIF-очистка/скан изображений публикации
Ядро: NotificationDispatchcore-contract (канал 3)outуведомление автора об одобрении/отклонении/шедоу-бане
cms/cabinet-b2c (suggests)provides-контракт cabinet-shell (CabinetSectionContract)inugc регистрирует секцию «мои публикации»; без модуля — доступ только через прямые роуты API
Модули-потребители типов публикаций (объявление/товар/портфолио)provides-контракт ugc-publicationoutugc отдаёт жизненный цикл публикации, потребитель определяет схему полей 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/healthcms:doctor --json
Публикация «зависла» в модерации дольше SLAочередь cms/moderation, а не ugccms: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_payload vs payload);
  • [ ] Лимит 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 запрещены.

Внутренняя база знаний студии