Тема
ТЗ — Модерация (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_items | id, moderatable_type, moderatable_id, content_hash, status, assigned_to, sla_deadline, lock_version | элемент очереди модерации |
cms_moderation_transitions | id, 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_id — constrained()->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_version | FormRequest whitelist, moderation.require_comment_on_reject, optimistic lock |
| Форма назначения ревьюера (Filament) | assigned_to (user_id) либо role | FormRequest 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 | размер очереди по статусам, факт просрочки SLA | health-агрегат/метрики |
cms/audit (если включён) | actor_user_id, переход, комментарий | событие ModerationStatusChanged, канал 1 |
cms:moderation:sla-check --json | отчёт просроченных элементов | stdout JSON (диагностика/CI/cron-лог) |
Всё, что не перечислено как вход, отвергается: попытка перевести элемент без content_hash, регистрация неизвестного moderatable_type, переход, не описанный в статусной машине.
Настройки (группа moderation)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
moderation.sla_hours | int | 24 | нет | Часы до просрочки SLA элемента очереди |
moderation.auto_assign_by_role | bool | true | нет | Автоназначение ревьюера по роли при поступлении |
moderation.require_comment_on_reject | bool | true | нет | Обязательный комментарий при отклонении |
moderation.max_queue_size_per_type | int | 10000 | нет | Лимит незакрытых элементов очереди на один moderatable_type — защита от неограниченного роста (алерт при приближении, не блокировка приёма) |
moderation.escalate_after_breaches | int | 1 | нет | Сколько повторных просрочек SLA до эскалации на роль выше (см. крайний случай «эскалация») |
moderation.actor_anonymize_after_days | int/null | null (не ограничено) | нет | Через сколько дней обезличивать 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 |
ModerationSlaBreached | SLA просрочен | item_id, sla_deadline |
Слушает: события создания модерируемого контента от cms/ugc, cms/comments, cms/reviews. Реализует provides-контракт moderation-workflow — модули-потребители регистрируют свои сущности как модерируемые через этот контракт.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/comments | provides moderation-workflow + событие CommentSubmitted (канал 1/3) | comments → moderation | регистрирует комментарий как элемент очереди |
cms/ugc | provides moderation-workflow + событие UgcSubmitted (канал 1/3) | ugc → moderation | регистрирует UGC-запись как элемент очереди |
cms/reviews | заявлено как потребитель в назначении модуля, фактически не реализует контракт | — | отзывы модерируются своей статусной машиной; см. ⚠️ ниже |
Ядро: RequestContext | requires | moderation → ядро | текущий пользователь как actor_user_id в истории |
Ядро: ScheduleRegistrar | requires | moderation → ядро | регистрация cms:moderation:sla-check |
Ядро: EventBus | канал 1 | moderation → все подписчики | издание 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.assign — assign отделено от 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запрещены