Skip to content

ТЗ — Модерация (workflow) (cms/moderation)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Workflow согласования пользовательского контента: статусная машина, назначение ревьюеров по ролям, комментарии к правкам и единая очередь модерации в Filament. Общий движок, которым должны пользоваться cms/ugc, cms/comments, cms/reviews вместо самостоятельной реализации статусов в каждом модуле (по факту на момент этого ТЗ cms/reviews контракт не реализует — см. ⚠️ в «Крайние случаи»).

  • Статусная машина draft → review → approved/rejected (конфигурируемые переходы)
  • Назначение ревьюера по роли или конкретному пользователю
  • Комментарии ревьюера к отклонённому контенту (причина, что исправить)
  • Единая очередь модерации в Filament, агрегирующая записи из модулей-потребителей
  • Реестр provides: moderation-workflow — модули регистрируют свои сущности как модерируемые
  • История переходов статусов (кто, когда, с каким комментарием)
  • SLA-таймер (сколько висит в очереди) для приоритизации

Зависимости и выключение

requires: ядро · provides: moderation-workflow

Поведение при выключении: модули-потребители (cms/ugc, cms/comments, и в целевом состоянии cms/reviews) переходят на собственный упрощённый статус pending/approved без workflow-очереди и назначения ревьюеров — контент по-прежнему требует одобрения, но без истории переходов. Элементы очереди (cms_moderation_items) не удаляются при выключении — данные и история сохраняются, очередь снова видна при повторном включении.

Модель данных

ТаблицаКлючевые поляПримечание
cms_moderation_itemsid, moderatable_type, moderatable_id, content_hash, status, assigned_to, sla_deadline, lock_versionэлемент очереди модерации
cms_moderation_transitionsid, item_id, from_status, to_status, comment, actor_user_id, created_atистория переходов (append-only)

status/from_status/to_status — PHP Enum (draft/review/approved/rejected); moderatable_type+moderatable_id — составной уникальный индекс (один элемент очереди на сущность-потребитель — защита от дублирующей регистрации, см. крайний случай ниже); item_idconstrained()->cascadeOnDelete(); cms_moderation_transitions — append-only (без UPDATE/DELETE); content_hash — снимок хеша/updated_at контента на момент постановки в очередь, используется для детекции протухшей версии при approve; lock_version — optimistic lock при взятии элемента в работу.

Входные и выходные данные

Входы:

ИсточникПоляЧем валидируется
Регистрация модерируемой сущности (provides: moderation-workflow, вызов из модуля-потребителя)moderatable_type, moderatable_id, content_hashсобственный реестр модуля cms/moderation (регистрация типа через публичный сервис provides: moderation-workflow, не контракт ядра): тип обязан быть заранее объявлен в этом реестре, дубли по (moderatable_type, moderatable_id) отклоняются
Событие создания контента от потребителя (ReviewSubmitted, CommentSubmitted, UgcSubmitted)id сущности, тип, автортонкий слушатель — валидирует факт, создаёт cms_moderation_items через тот же контракт, не пишет напрямую в чужие таблицы
Форма перехода статуса (Filament, PUT)status (review/approved/rejected), comment, lock_versionFormRequest whitelist, moderation.require_comment_on_reject, optimistic lock
Форма назначения ревьюера (Filament)assigned_to (user_id) либо roleFormRequest whitelist, permission moderation.assign, пользователь должен обладать нужной ролью
SLA-джоба (внутренний триггер, ScheduleRegistrar)текущее время vs sla_deadlineсравнение в БД по индексу (status, sla_deadline)
Legacy/первичный импорт (cms:moderation:import-legacy)статусы уже существующих записей потребителей (pending/approved в их собственных таблицах)маппинг на draft/review/approved/rejected, идемпотентность по (moderatable_type, moderatable_id)

Выходы:

ПотребительДанныеФормат
Модуль-потребитель (cms/ugc, cms/comments, cms/reviews)ModerationStatusChangedканал 1, после коммита; потребитель сам применяет новый статус к своей сущности
Filament (очередь модерации)список элементов + история переходовадмин UI, фильтры по типу/статусу/ревьюеру/просрочке
cms/health/Pulseразмер очереди по статусам, факт просрочки SLAhealth-агрегат/метрики
cms/audit (если включён)actor_user_id, переход, комментарийсобытие ModerationStatusChanged, канал 1
cms:moderation:sla-check --jsonотчёт просроченных элементовstdout JSON (диагностика/CI/cron-лог)

Всё, что не перечислено как вход, отвергается: попытка перевести элемент без content_hash, регистрация неизвестного moderatable_type, переход, не описанный в статусной машине.

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

КлючТипДефолтaffectsPageCacheОписание
moderation.sla_hoursint24нетЧасы до просрочки SLA элемента очереди
moderation.auto_assign_by_rolebooltrueнетАвтоназначение ревьюера по роли при поступлении
moderation.require_comment_on_rejectbooltrueнетОбязательный комментарий при отклонении
moderation.max_queue_size_per_typeint10000нетЛимит незакрытых элементов очереди на один moderatable_type — защита от неограниченного роста (алерт при приближении, не блокировка приёма)
moderation.escalate_after_breachesint1нетСколько повторных просрочек SLA до эскалации на роль выше (см. крайний случай «эскалация»)
moderation.actor_anonymize_after_daysint/nullnull (не ограничено)нетЧерез сколько дней обезличивать actor_user_id в истории переходов; «забыть по запросу» срабатывает немедленно вне зависимости от таймера

Достижение max_queue_size_per_type — алерт в cms/health, не отказ в приёме новых элементов (иначе контент потребителя завис бы без возможности пройти модерацию).

API

Отдельного публичного API нет — админ-CRUD и очередь модерации через Filament; модули- потребители взаимодействуют с движком через внутренний контракт provides: moderation-workflow (канал 3), не через HTTP.

Компоненты

Filament: единая страница очереди модерации (фильтр по типу сущности, статусу, ревьюеру, просрочке SLA), история переходов на карточке элемента, индикатор «в работе у …» при взятии элемента ревьюером. Команды: cms:moderation:sla-check --json (находит просроченные элементы), cms:moderation:reconcile --json (находит осиротевшие элементы — потребитель выключен/сущность удалена, см. крайние случаи), cms:moderation:import-legacy --json.

Фронтенд-бюджет и демо-контент: не применимо — у модуля нет публичного фронтенда, только Filament-интерфейс (страница очереди, RelationManager истории), который подчиняется общему бюджету админ-shell ядра, не собственному.

Эксплуатация (ранбук). Метрики: moderation_queue_size_by_status, moderation_sla_breach_count, moderation_avg_resolution_hours, moderation_orphaned_items_count. Алерты: очередь по типу приближается к max_queue_size_per_type; cms:moderation:sla-check не выполнялся дольше sla_hours (сама диагностика зависла); доля просроченных элементов растёт неделю подряд.

СимптомПроверитьКоманда
Очередь растёт, ревьюеры не видят новых элементоврасписание sla-check выполняется, регистрация потребителя через контракт прошлаcms:doctor --json, лог ScheduleRegistrar
Элементы очереди без соответствующей сущности у потребителяпотребитель выключен/удалил записиcms:moderation:reconcile --json
SLA не эскалируется при повторной просрочкеmoderation.escalate_after_breaches, получатели эскалации сконфигурированыручная проверка настроек группы moderation

Бэкап/рестор: cms_moderation_items и cms_moderation_transitions — часть бэкапа (append-only журнал восстанавливается как есть). После рестора обязательный шаг — cms:moderation:reconcile, чтобы найти элементы, чьи moderatable_id не пережили рестор потребителя (частичный рестор разных модулей в разное время) — рестор без этого шага не считается завершённым.

События и обмен

СобытиеКогдаPayload
ModerationItemAssignedэлемент назначен ревьюеруitem_id, moderatable_type, assigned_to
ModerationStatusChangedстатус элемента изменёнitem_id, from_status, to_status, actor_user_id
ModerationSlaBreachedSLA просроченitem_id, sla_deadline

Слушает: события создания модерируемого контента от cms/ugc, cms/comments, cms/reviews. Реализует provides-контракт moderation-workflow — модули-потребители регистрируют свои сущности как модерируемые через этот контракт.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/commentsprovides moderation-workflow + событие CommentSubmitted (канал 1/3)comments → moderationрегистрирует комментарий как элемент очереди
cms/ugcprovides moderation-workflow + событие UgcSubmitted (канал 1/3)ugc → moderationрегистрирует UGC-запись как элемент очереди
cms/reviewsзаявлено как потребитель в назначении модуля, фактически не реализует контрактотзывы модерируются своей статусной машиной; см. ⚠️ ниже
Ядро: RequestContextrequiresmoderation → ядротекущий пользователь как actor_user_id в истории
Ядро: ScheduleRegistrarrequiresmoderation → ядрорегистрация cms:moderation:sla-check
Ядро: EventBusканал 1moderation → все подписчикииздание ModerationItemAssigned/ModerationStatusChanged/ModerationSlaBreached
cms/healthподписка на ModerationSlaBreached (канал 1)moderation → healthалерт по просрочке, виден в мониторинге
cms/audit (если включён)подписка на ModerationStatusChanged (канал 1)moderation → auditзапись действия ревьюера с actor_user_id

Фоновая работа

Джоба cms:moderation:sla-check (очередь moderation) — по расписанию через ScheduleRegistrar ядра находит просроченные элементы и публикует ModerationSlaBreached; при повторной просрочке того же элемента (после moderation.escalate_after_breaches циклов) эскалирует на роль выше (менеджер → админ) — событие несёт признак эскалации в истории переходов (комментарий системного действия), не тихо повторяет тот же алерт. Джоба cms:moderation:reconcile (очередь moderation, ручной/по расписанию запуск) — находит элементы, чей moderatable_type больше не зарегистрирован ни одним включённым модулем (осиротевшие) и помечает их в отчёте, не удаляя (решение — за админом).

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

  • Ожидаемые объёмы: очередь обычно от десятков до нескольких тысяч незакрытых элементов одновременно при активном UGC/отзывах/комментариях на крупном сайте.
  • Горячие пути: Filament-список очереди с фильтрами по типу/статусу/ревьюеру/ просрочке — читается напрямую из БД (актуальность статусов важнее кеша), поэтому критичны индексы, а не теги.
  • Бюджет запросов: список очереди — ≤ 2 запроса (пагинация + count по фильтрам); агрегация из нескольких moderatable_type не делает отдельный запрос к сервису потребителя на каждую строку — превью контента подгружается пакетно (батч-вызов сервиса владельца по списку id) или лениво по клику на карточку.
  • Критичные индексы: (status, sla_deadline) для SLA-джобы; (moderatable_type, moderatable_id) уникальный; (assigned_to, status) для фильтра «мои задачи»; item_id на cms_moderation_transitions (FK), created_at — BRIN при большом объёме истории (журнальная append-only таблица растёт постоянно).
  • Кеш: собственных тегов кеша страниц нет — очередь не участвует в page-cache (внутренний инструмент). Счётчик очереди для дашборда допустимо кешировать коротким TTL (секунды), если агрегация по нескольким модулям заметно нагружает БД — актуальность дашборда некритична, актуальность самой очереди — критична (не кешируется).

Безопасность

Границы входа: FormRequest-whitelist на переход статуса и назначение ревьюера (админка). Комментарий ревьюера при отклонении — обязателен и санитизируется двойным барьером (сохранение + вывод); пустая строка из одних пробелов трактуется как отсутствие комментария (trim() перед валидацией required) — не проходит require_comment_on_reject. Публичного API нет, rate-limit не требуется.

Векторы: регистрация moderatable_type, не объявленного заранее в контракте (запрет — только предопределённые типы через provides) · попытка перехода статуса в обход статусной машины (approved → review напрямую или draft → approved минуя review) — сервис проверяет допустимость перехода по конфигурируемому графу, недопустимый переход — 422, не тихое применение · подмена assigned_to пользователем без права — Policy проверяет permission moderation.assign, а не только факт аутентификации.

Права: moderation.view, moderation.manage, moderation.assignassign отделено от manage (назначение ревьюера vs принятие решения по статусу).

Матрица ролей:

PermissionАдминМенеджерРедакторStudio
moderation.view✅ (только назначенные себе)
moderation.assign
moderation.manage (approve/reject)ограниченно — только назначенные себе элементы

ПДн-паспорт. Хранит actor_user_id в cms_moderation_transitions — append-only журнал (без UPDATE/DELETE по конструкции, §4 стандарта). Конфликт с «забыть по запросу» (152-ФЗ) разрешается обезличиванием, а не удалением: хук ядра «забыть по запросу» заменяет actor_user_id на псевдонимизированный маркер (deleted_user, без связи с реальным пользователем), сама запись перехода (факт, время, статус, комментарий) остаётся неизменной — история модерации целостна, но не идентифицирует человека. Плановое обезличивание — moderation.actor_anonymize_after_days (если задано, джоба sla-check-класса обезличивает старые переходы фоном); ПДн модерируемого контента (текст отзыва/комментария и т.п.) — ответственность модуля-владельца сущности, не cms/moderation (он хранит только ссылку moderatable_id, не сам контент).

UX-требования

Админ: пустое состояние очереди — «Очередь пуста» отдельно на каждый активный фильтр (тип/статус/ревьюер); массовые действия — групповое назначение ревьюера, групповое одобрение с подтверждением (особенно при количестве элементов больше разумного для одного клика); человеческие ошибки — «Нельзя одобрить: контент изменился с момента постановки в очередь, обновите и проверьте заново» вместо тихого одобрения устаревшей версии; подтверждение — отклонение требует обязательный комментарий (кнопка неактивна до заполнения), любое редактирование/удаление истории переходов запрещено интерфейсом (append-only не только в БД, но и в UI).

Посетитель: не применимо — у модуля нет публичного интерфейса; UX конечного пользователя, чей контент модерируется, целиком на стороне модуля-потребителя (cms/reviews, cms/comments, cms/ugc), который отображает свой собственный статус.

Крайние случаи и типовые баги

  • Объект изменён, пока висел в очереди (ревьюер открывает элемент на основании старой версии контента) → content_hash, снятый при регистрации, сверяется с текущим хешем/updated_at сущности у потребителя (через requires-вызов его сервиса) в момент approve; расхождение — отказ с сообщением «контент изменился, актуализируйте перед одобрением», не молчаливое одобрение устаревшей версии.
  • Два модератора одновременно взяли в работу один и тот же элементlock_version (optimistic lock) на cms_moderation_items; попытка взять/перевести статус элемента, уже заблокированного другим, получает 409 и видит «элемент сейчас обрабатывает <имя>», а не тихо перезаписывает решение коллеги.
  • Эскалация просроченных по SLA → первая просрочка публикует ModerationSlaBreached и уведомляет назначенного ревьюера/его роль; при повторной просрочке того же элемента (счётчик moderation.escalate_after_breaches) эскалируется на роль выше (менеджер → админ) с отдельной пометкой в истории переходов; повторные эскалации не дублируют один и тот же алерт бесконечно — счётчик привязан к конкретному циклу просрочки.
  • Модуль-потребитель выключен, пока его элементы висят в очередиcms_moderation_items не удаляются и не блокируют работу движка (чужие данные не трогаются при выключении соседа), но становятся «осиротевшими» относительно живого контента; cms:moderation:reconcile находит такие элементы по факту, что moderatable_type больше не зарегистрирован ни одним включённым модулем, и помечает их в отчёте — решение (ждать/архивировать) остаётся за админом, не автоматическое удаление.
  • Отказ от комментария при отклонении (require_comment_on_reject=true), а введённый комментарий — строка из одних пробелов → валидация делает trim() перед проверкой required, пустой после trim комментарий отклоняется 422, как будто поле не заполнено — обход требования пробелами невозможен.
  • Реестр provides: moderation-workflow — два модуля регистрируют один и тот же moderatable_type → уникальный индекс (moderatable_type, moderatable_id) не спасает от коллизии типов (два разных модуля используют одинаковое имя типа) — собственный реестр модуля cms/moderation обязан требовать глобально уникальное имя типа при регистрации (аналог канона именования <slug>.<entity>), повторная регистрация того же имени другим модулем отклоняется на этапе boot с ошибкой конфигурации, не тихим переопределением.
  • Статусная машина: попытка перехода approved → review напрямую (обход draft) → граф допустимых переходов хранится как конфигурация модуля (не хардкод в сервисе); запрошенный переход сверяется с графом до применения — недопустимый переход отдаёт 422 с перечнем разрешённых из текущего статуса, действие не выполняется частично.
  • ⚠️ Противоречие: потребитель cms/reviews не реализовал provides-контракт корректно (фактически не реализовал вовсе). Раздел «Назначение и возможности» этого ТЗ называет cms/reviews штатным потребителем движка модерации, но docs/modules/reviews.md не объявляет suggests: cms/moderation и ведёт отдельную статусную машину pending/approved/rejected без обращения к provides: moderation-workflow. Общая защита от «потребитель не реализовал контракт корректно» — движок не должен падать или показывать пустую строку в едином списке из-за модуля, который контракт не вызывает: он просто не появляется в очереди cms/moderation (это отдельный, не общий список, и это нормально для модуля без suggests). Настоящая проблема — несогласованность документации/архитектуры, а не отказоустойчивости движка. Разрешение (зафиксировано и в docs/modules/reviews.md): добавить cms/reviews в suggests, реализовать регистрацию Review как moderatable_type при включённом cms/moderation, с деградацией к собственной статусной машине при выключенном модуле — по тому же паттерну, что уже применяют cms/ugc/cms/comments.

Донорский код

Донор: — (новая разработка). Прямого legacy-донора нет, но у переезжающих клиентов могут быть модерируемые сущности с собственными простыми статусами (например, cms/reviews до реализации разрешения выше, или сторонние модули с полем status).

Универсальный импорт при первом включении.cms:moderation:import-legacy --source=<consumer-module> --dry-run — вместо маппинга конкретной донорской БД (донора нет) читает уже существующие записи модулей- потребителей через их публичный сервис (не raw SQL в чужие таблицы — тот же принцип владения данными, что и в рантайме) и создаёт для каждой соответствующий cms_moderation_items + один синтетический переход в истории («импортирован из существующего статуса <X>»); идемпотентна по естественному ключу (moderatable_type, moderatable_id) — повторный прогон не дублирует элементы, только обновляет расхождения; --dry-run печатает, сколько будет создано/пропущено (уже зарегистрированные).

Тесты и приёмка

  • [ ] Контрактный тест: переход review → rejected без комментария отклоняется валидацией
  • [ ] Комментарий из одних пробелов при отклонении отклоняется так же, как пустой
  • [ ] Переход approved → review (в обход draft) отклоняется 422 по графу переходов
  • [ ] Одобрение элемента с устаревшим content_hash (сущность изменилась в очереди) отклоняется с понятным сообщением, не одобряет устаревшую версию молча
  • [ ] Взятие в работу одного элемента двумя ревьюерами параллельно — второй получает 409 (lock_version)
  • [ ] Повторная регистрация того же moderatable_type другим модулем отклоняется на boot
  • [ ] При выключении модуля-потребителя его элементы очереди не удаляются; cms:moderation:reconcile находит их как осиротевшие, не падает
  • [ ] При выключении cms/moderation потребители работают на упрощённом статусе без ошибок
  • [ ] Права moderation.assign отделены от moderation.manage (назначение vs решение); редактор видит/решает только назначенные себе элементы
  • [ ] История переходов полна и неизменяема (append-only, без UPDATE/DELETE записей)
  • [ ] «Забыть по запросу» обезличивает actor_user_id в истории, не удаляя сами переходы
  • [ ] Нет N+1 в очереди модерации при агрегации из нескольких типов сущностей
  • [ ] SLA-таймер корректно помечает просроченные элементы событием ModerationSlaBreached; повторная просрочка того же элемента эскалирует, не дублирует алерт бесконечно
  • [ ] cms:moderation:import-legacy --dry-run корректно читает существующие статусы модулей-потребителей через их сервис, не raw SQL
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут админки (очередь, назначение, переход статуса)
  • [ ] Тестовая БД только moderation_test; migrate:fresh/refresh/reset/db:wipe запрещены

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