Skip to content

ТЗ — Комментарии (cms/comments)

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

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

Полиморфные комментарии к любой сущности контента (посты, страницы, товары) с деревом ответов, премодерацией и антиспам-защитой. Новая разработка, без готового донора.

  • Полиморфная привязка к любой сущности (commentable_type/commentable_id)
  • Дерево ответов (self-ref, ограничение глубины вложенности конфигом)
  • Премодерация: новый комментарий по умолчанию pending, публикация после одобрения
  • Антиспам-крючья через cms/antispam (honeypot, rate-limit, содержательные эвристики)
  • Двойная санитизация: на сохранении (strip опасных тегов) и на выводе (эскейпинг Blade всегда)
  • Гостевые комментарии (имя+email) и от авторизованных пользователей
  • Уведомление автору сущности о новом комментарии (через cms/notifications-bus)
  • Жалоба на комментарий с очередью на модерацию

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

requires: ядро · suggests: cms/antispam, cms/notifications-bus

Поведение при выключении: блок комментариев скрывается на всех страницах (fallback — пусто, без ошибки), существующие комментарии сохраняются в БД и восстанавливаются при повторном включении модуля.

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

ТаблицаКлючевые поляПримечание
cms_commentsid, commentable_type, commentable_id, parent_id, user_id (nullable), guest_name, guest_email, body, status, locale, lock_versionстатус: pending/approved/rejected/deleted
cms_comment_reportsid, comment_id, reporter_user_id (nullable), reason, statusжалобы на комментарии

status — PHP Enum (pending/approved/rejected/deleted); commentable_type+commentable_id — составной индекс; parent_idconstrained()->index() под дерево ответов (⚠️ без cascadeOnDelete() — см. «Крайние случаи», удаление родителя выполняется как soft-delete, не каскадное физическое удаление детей).

ПДн-паспорт: модуль хранит ПДн в cms_commentsguest_name, guest_email (гостевые комментарии, без подтверждения владения email), user_id (связь с учёткой авторизованного автора); в cms_comment_reportsreporter_user_id. Срок хранения: бессрочно, пока комментарий не удалён модератором или не затронут ретеншном; ретеншн-джоба (в составе cms:comments:purge-spam) физически анонимизирует rejected-комментарии старше настраиваемого срока (см. «Настройки»). Хуки ядра: «выгрузить всё по субъекту» — выборка по user_id (для авторизованных) либо по guest_email (для гостевых, best-effort, email не верифицирован); «забыть по запросу» — анонимизация guest_name→«Удалено», guest_email→NULL, body не трогается только если на комментарий уже есть ответы (тело остаётся, чтобы не разрушить контекст треда — маскируется автор, не содержание).

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

Входы:

ИсточникПоляЧем валидируется
Форма отправки комментария (публичная)body, guest_name, guest_email, parent_id, commentable_type, commentable_idFormRequest whitelist, rate-limit (comments.rate_limit_per_minute), honeypot, spam-filter-контракт при наличии cms/antispam
Форма жалобыcomment_id, reasonFormRequest whitelist + отдельный rate-limit
Админка модерацииstatus (approve/reject/delete)FormRequest + право comments.moderate/comments.manage
spam-filter (provides-контракт cms/antispam, если включён)body, guest_email → скор/вердиктконтракт ядра, не пользовательский ввод
Импорт легаси (cms:comments:import-legacy)external_id, author, body, created_at, parent_external_idуниверсальный маппер источника + --dry-run отчёт

Выходы:

ПотребительДанныеФормат
GET /api/v1/commentsдерево одобренных комментариев сущности{data: [...], meta} (keyset)
Блок «Комментарии» на страницедерево + форма отправкиBlade-компонент / JSON пропсов блока
Filament: очередь модерациисписок pending + жалобыadmin UI
События CommentSubmitted/CommentApproved/CommentReportedподписчики (cms/notifications-bus, аудит)payload события
cms:comments:purge-spam --jsonотчёт по очищенным/анонимизированным записямJSON

Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта).

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

КлючТипДефолтaffectsPageCacheОписание
comments.premoderation_enabledbooltrueнетТребовать одобрение перед публикацией
comments.guest_allowedbooltrueнетРазрешить комментарии без авторизации
comments.max_depthint3нетМаксимальная глубина дерева ответов
comments.rate_limit_per_minuteint3нетЛимит комментариев на пользователя/IP в минуту
comments.max_body_lengthint2000нетМаксимальная длина текста комментария (символов)
comments.rejected_retention_daysint90нетСрок хранения отклонённых/спам-комментариев до анонимизации ретеншн-джобой
comments.public_submission_enabledbooltrueнетKill-switch: аварийное отключение публичной формы отправки (например при вспышке спама) без выключения модуля целиком — чтение и модерация продолжают работать

Достижение max_body_length/rate_limit_per_minute — понятная ошибка формы (422 с человеческим текстом) и метрика, не тихое обрезание текста.

API

МетодПутьДоступНазначение
GET/api/v1/commentspublicОдобренные комментарии сущности (пагинация, дерево)
POST/api/v1/commentspublic (rate-limit, honeypot)Отправка нового комментария (в очередь премодерации)
GET/PUT/api/v1/admin/comments…admin (comments.manage)Модерация: одобрение/отклонение/удаление
POST/api/v1/comments/{id}/reportpublic (rate-limit)Жалоба на комментарий

Список комментариев — keyset-пагинация по (created_at, id), не OFFSET.

Компоненты

Блоки (BlockRegistry): «Комментарии» (дерево + форма отправки) — demo-props показывают демо-тред без ручного ввода; ответы глубже первого уровня подгружаются по клику «показать ещё N ответов» (lazy, без загрузки всего дерева разом), высота формы/дерева зарезервирована (без CLS при подгрузке), кнопки «ответить»/«свернуть»/«пожаловаться» и поля формы доступны с клавиатуры и снабжены label. Filament: очередь модерации комментариев с фильтром по статусу, список жалоб. Команды: cms:comments:purge-spam --json.

Демо-контент: сидер создаёт демо-тред (3–4 уровня вложенности, включая гостевые и пользовательские комментарии, один pending) на демо-странице — playground и /_gallery показывают блок без ручного ввода.

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

СобытиеКогдаPayload
CommentSubmittedновый комментарий отправленcomment_id, commentable_type, commentable_id, status
CommentApprovedкомментарий одобрен модераторомcomment_id
CommentReportedподана жалоба на комментарийcomment_id, reason

Использует антиспам-фильтры cms/antispam (honeypot, эвристики) при наличии модуля; уведомления публикуются через cms/notifications-bus. Provides-контрактов не реализует.

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

Сущность/модульКаналНаправлениеЧто происходит
cms/antispam (suggests)provides-контракт spam-filter (канал 3)comments → antispamПроверка body/guest_email на спам-эвристики при отправке; модуль отсутствует → деградация до honeypot + rate-limit
cms/notifications-bus (suggests)provides-контракт notification-channel (канал 3)comments → notifications-busПисьмо автору сущности о новом комментарии; модуль отсутствует → NotificationDispatch уходит в лог-fallback, сайт жив
Ядро: ContentRepository/PageRepositoryсервис ядра (core-contracts)comments → ядроПроверка существования commentable_type/commentable_id перед созданием комментария
Ядро: RequestContextсервисный контракт ядраcomments → ядроТекущая locale и пользователь сохраняются в cms_comments
CommentSubmitted/CommentApproved/CommentReportedсобытие (канал 1)comments → подписчикиПрочие модули (аналитика, геймификация, cms/audit) при подписке реагируют на факт
cms/audit (если включён)событие (канал 1)comments → auditМодерационные действия (approve/reject/delete) логируются в аудит
cms:comments:import-legacyкоманда (не канал обмена)оператор → commentsРазовый маппинг данных при миграции клиента, см. «Донорский код»

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

Джоба cms:comments:purge-spam (очередь comments) — периодическая чистка спам-меток и анонимизация rejected-комментариев старше comments.rejected_retention_days через ScheduleRegistrar ядра; отправка уведомлений о новом комментарии — асинхронно через очередь notifications-bus. При realtime-показе (premoderation_enabled=false) асинхронная спам-эвристика cms/antispam может пометить уже опубликованный комментарий rejected постфактум — джоба меняет статус и инвалидирует кеш дерева.

Ранбук (эксплуатация):

  • Метрики: число pending в очереди модерации, длительность cms:comments:purge-spam, доля отклонённых/спам от общего числа отправок, отставание очереди comments.
  • Алерты: очередь модерации растёт без разгребания > суток; всплеск отправок сверх rate_limit_per_minute (возможная волна спама/атака) — рекомендация включить kill-switch comments.public_submission_enabled=false.
  • Симптом → команда: подозрение на волну спама → выключить comments.public_submission_enabled (форма скрывается, чтение и модерация живы) → cms:comments:purge-spam --json (ручной прогон очистки) → cms:doctor --json (консистентность дерева, orphan parent_id).
  • Бэкап/рестор: в бэкап попадают cms_comments, cms_comment_reports; после рестора ничего не пересоздаётся отдельной командой (нет денормализованных агрегатов) — рестор таблиц достаточен для целостности треда.

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

Ожидаемые объёмы: от единиц до десятков тысяч комментариев на популярную сущность; дерево ограничено comments.max_depth (дефолт 3) по вертикали, ширина ветки не ограничена схемой — лимитируется постраничной подгрузкой на фронте. Горячий путь — рендер блока комментариев на публичной странице: дерево строится одним запросом (->with('replies.user') с ограничением по max_depth), без N+1 по глубине; список модерации — keyset-пагинация по (created_at, id). Критичные индексы: составной commentable_type+commentable_id, индекс parent_id, составной status+created_at (под keyset и фильтр pending), индекс cms_comment_reports.comment_id. Тег comments (с уточнением сущности: comments:<type>:<id>) — на дереве комментариев сущности; инвалидация по CommentApproved, удалению/анонимизации. Влияет на page-cache страниц с блоком комментариев.

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

Границы входа: форма отправки комментария — FormRequest-whitelist (body, guest_name, guest_email, parent_id), rate-limit + honeypot на публичной отправке, дополнительно spam-filter-контракт при наличии cms/antispam. Двойная санитизация: strip опасных тегов на сохранении, эскейпинг Blade всегда на выводе — {!! !!} для комментариев запрещён. Жалобы — отдельный rate-limit.

Конкретные векторы:

  • XSS в guest_name/guest_email: оба поля — обычный текст, но выводятся в шаблоне рядом с телом; обязателен и для них, не только для body — попытка вставить <script> в имя должна экранироваться так же, как в тексте.
  • Спуфинг guest_email: email не подтверждается (нет verification-флоу) — модуль не должен использовать его для действий, требующих доказанного владения (управление подпиской, восстановление доступа); используется только для отображения (опционально) и как сигнал антиспам-эвристикам, не как источник доверия.
  • ReDoS: пользовательские regex в модуле не используются (только whitelist статусов и типов) — уязвимость неприменима.
  • Обход UI-лимита глубины: POST /api/v1/comments с parent_id глубже max_depth обязан валидироваться на сервере (см. «Крайние случаи») — скрытие кнопки «ответить» в UI не является защитой.

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

ПравоАдминМенеджерРедакторStudio
comments.view (чтение, включая pending)
comments.moderate (approve/reject конкретного комментария, разбор жалоб)
comments.manage (настройки модуля, kill-switch, массовое удаление, ретеншн)

moderate отделено от manage (принятие решения по конкретному комментарию vs полное управление модулем); массовое удаление и включение/выключение публичной формы — только manage, с подтверждением необратимых операций.

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

Админ: пустое состояние очереди модерации — «нет комментариев на рассмотрении» вместо пустой таблицы; массовое действие «одобрить/отклонить выбранные» в списке; человеческая ошибка при попытке одобрить уже удалённый/несуществующий комментарий («этот комментарий уже удалён», не 500); подтверждение перед массовым удалением и перед включением/выключением comments.public_submission_enabled.

Посетитель: форма отправки сохраняет введённый текст при ошибке валидации (rate-limit, слишком длинный текст) — не очищает textarea; после отправки — мгновенная обратная связь «отправлено, ожидает модерации» (или сразу видно при premoderation_enabled=false) без ожидания полного релоада страницы; кнопки «ответить»/«свернуть»/«пожаловаться» и текстовое поле доступны с клавиатуры, вложенные ответы объявляются скринридером при подгрузке.

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

  • Ответ на комментарий на max_depth: кнопка «ответить» скрывается в UI на комментариях предельной глубины, но сервер — не UI — обязан быть источником истины: прямой POST с parent_id глубже лимита не создаёт более глубокий узел, а «сплющивается» — новый комментарий подвешивается к ближайшему предку на max_depth (не отклоняется 422, чтобы не терять текст пользователя).
  • ⚠️ Противоречие: cascadeOnDelete() vs soft-delete родителя. Исходная модель данных объявляла parent_id: constrained()->cascadeOnDelete(), что физически удаляет все дочерние ответы при удалении родителя; UX-ожидание — «комментарий удалён» вместо текста, а ответы остаются видимыми (типовое поведение форумов/соцсетей). Разрешение: убрать cascadeOnDelete() (уже отражено в «Модель данных» выше); удаление модератором — смена status=deleted и затирание body/guest_name/guest_email, узел физически остаётся, дети сохраняют parent_id. Физический cascade-delete допустим только для cms:comments:purge-spam-анонимизации по ретеншну, не для обычного удаления.
  • Редактирование после ответа: в модели нет редактирования текста — ни автором, ни модератором; модератор меняет только status, текст комментария неизменяем с момента отправки (иначе подмена чужих слов задним числом). Явно зафиксировано как ограничение модуля, не пропуск.
  • Антиспам-крючья при отсутствии cms/antispam: деградация до honeypot + rate-limit без содержательных эвристик; premoderation_enabled остаётся включённым по умолчанию как компенсирующий контроль на случай отсутствия модуля.
  • premoderation_enabled=false (реалтайм-показ) и спам: комментарий публикуется сразу после honeypot/rate-limit/синхронного spam-filter-контракта (если есть); более тяжёлые асинхронные эвристики cms/antispam могут пометить его спамом постфактум — статус меняется на rejected фоновой джобой, кеш дерева инвалидируется, комментарий исчезает с уже отрендеренной страницы при следующем заходе (page-cache TTL/тег).
  • Двойной сабмит формы: клиентский debounce недостаточен — сервер отклоняет дубль по окну (тот же commentable, тот же автор/guest_email, тот же хеш body за короткий интервал) либо принимает Idempotency-Key на мутации (конвенция ядра §7 стандарта), чтобы повторная отправка не создавала второй комментарий.
  • Жалоба на уже удалённый комментарий: comment_id со status=deleted404 с понятным сообщением, не 500; кнопка «пожаловаться» в UI скрывается для уже удалённых/скрытых узлов, но сервер всё равно обязан проверять состояние.
  • Гостевой комментарий с чужим email: email не верифицируется — нельзя отправлять на него письма с управляющими действиями (отписка, ссылка на редактирование); используется только для отображения (опционально, по настройке приватности) и антиспам-сигнала; при этом сам факт хранения email — ПДн, подпадает под ретеншн и «забыть по запросу» из паспорта выше, независимо от того, чей это реально email.
  • Гонка модераторов: два администратора одновременно approve/reject один и тот же pending-комментарий → lock_version (optimistic lock) на cms_comments, второй запрос получает 409, а не тихую перезапись статуса.
  • Огромная ветка ответов (тысячи узлов на одном уровне/родителе): рендер блока не грузит дерево целиком — постраничная подгрузка «показать ещё N ответов» с keyset по каждому родителю, иначе публичная страница отдаёт мегабайты HTML за один запрос.

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

Донор: — (новая разработка). Готового донора нет, но команда легаси-импорта предусмотрена заранее — по стандарту (§16) она нужна при переезде клиентов со сторонних CMS/движков комментариев на этот модуль.

Legacy-импорт. cms:comments:import-legacy --source=<профиль> [--dry-run]:

  • универсальный маппер полей произвольного источника (не привязан к конкретному донору студии): author/nameguest_name или резолв user_id по совпадению email с учёткой, contentbody (через ту же двойную санитизацию, что и обычная отправка), datecreated_at, parent_external_id → восстановление дерева по графу внешних id перед вставкой (топологическая сортировка родитель-раньше-ребёнка);
  • профили-кандидаты: экспорт из WordPress (core comments), Disqus, VK Comments Widget — профиль настраивается декларативно (сопоставление колонок), не кодом;
  • идемпотентность — ключ external_id на cms_comments (повторный прогон обновляет, не дублирует); --dry-run — отчёт «прочитано/создано/обновлено/пропущено» с построчными расхождениями (например «родитель не найден» для сирот дерева).

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

  • [ ] Контрактный тест: гостевой комментарий уходит в pending, не виден публично до одобрения
  • [ ] При выключении модуля блок комментариев отдаёт пустой fallback, без 500
  • [ ] Двойная санитизация подтверждена тестом (XSS-пейлоад не проходит ни на входе, ни на выходе, включая guest_name/guest_email)
  • [ ] Антиспам-крючья (cms/antispam) реально блокируют honeypot-заполненные отправки; при отсутствии модуля деградация до honeypot+rate-limit подтверждена тестом
  • [ ] Права comments.moderate отделены от comments.manage (модерация vs полное управление)
  • [ ] Нет N+1 при построении дерева ответов (->with('replies.user') с ограничением глубины)
  • [ ] Инвалидация кеша блока комментариев по тегу сущности при CommentApproved
  • [ ] Удаление родительского комментария — soft (status=deleted, тело затёрто), дочерние ответы остаются доступны в дереве (тест на отсутствие каскадного физического удаления)
  • [ ] Ответ на комментарии глубины max_depth не создаёт более глубокий узел (сплющивание к предку), даже при прямом обращении к API в обход UI
  • [ ] Двойной сабмит формы не создаёт дубль-комментарий (тест на окно идемпотентности/Idempotency-Key)
  • [ ] Гонка двух модераторов на одном комментарии даёт 409 по lock_version, не тихую перезапись
  • [ ] Жалоба на удалённый комментарий отдаёт 404, не 500
  • [ ] Kill-switch comments.public_submission_enabled=false скрывает форму отправки, но чтение и модерация продолжают работать
  • [ ] Ретеншн-джоба анонимизирует rejected-комментарии старше rejected_retention_days; хук «забыть по запросу» подтверждён тестом на guest_email/user_id
  • [ ] cms:comments:import-legacy --dry-run прогнан на синтетическом наборе с сиротами дерева, идемпотентность повторного прогона подтверждена тестом
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (список, отправка, модерация, жалоба)
  • [ ] Тестовая БД только comments_test; migrate:fresh/refresh/reset/db:wipe запрещены

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