Полиморфные комментарии к любой сущности контента (посты, страницы, товары) с деревом ответов, премодерацией и антиспам-защитой. Новая разработка, без готового донора.
Полиморфная привязка к любой сущности (commentable_type/commentable_id)
Поведение при выключении: блок комментариев скрывается на всех страницах (fallback — пусто, без ошибки), существующие комментарии сохраняются в БД и восстанавливаются при повторном включении модуля.
id, comment_id, reporter_user_id (nullable), reason, status
жалобы на комментарии
status — PHP Enum (pending/approved/rejected/deleted); commentable_type+commentable_id — составной индекс; parent_id — constrained()->index() под дерево ответов (⚠️ без cascadeOnDelete() — см. «Крайние случаи», удаление родителя выполняется как soft-delete, не каскадное физическое удаление детей).
ПДн-паспорт: модуль хранит ПДн в cms_comments — guest_name, guest_email (гостевые комментарии, без подтверждения владения email), user_id (связь с учёткой авторизованного автора); в cms_comment_reports — reporter_user_id. Срок хранения: бессрочно, пока комментарий не удалён модератором или не затронут ретеншном; ретеншн-джоба (в составе cms:comments:purge-spam) физически анонимизирует rejected-комментарии старше настраиваемого срока (см. «Настройки»). Хуки ядра: «выгрузить всё по субъекту» — выборка по user_id (для авторизованных) либо по guest_email (для гостевых, best-effort, email не верифицирован); «забыть по запросу» — анонимизация guest_name→«Удалено», guest_email→NULL, body не трогается только если на комментарий уже есть ответы (тело остаётся, чтобы не разрушить контекст треда — маскируется автор, не содержание).
Срок хранения отклонённых/спам-комментариев до анонимизации ретеншн-джобой
comments.public_submission_enabled
bool
true
нет
Kill-switch: аварийное отключение публичной формы отправки (например при вспышке спама) без выключения модуля целиком — чтение и модерация продолжают работать
Достижение max_body_length/rate_limit_per_minute — понятная ошибка формы (422 с человеческим текстом) и метрика, не тихое обрезание текста.
Блоки (BlockRegistry): «Комментарии» (дерево + форма отправки) — demo-props показывают демо-тред без ручного ввода; ответы глубже первого уровня подгружаются по клику «показать ещё N ответов» (lazy, без загрузки всего дерева разом), высота формы/дерева зарезервирована (без CLS при подгрузке), кнопки «ответить»/«свернуть»/«пожаловаться» и поля формы доступны с клавиатуры и снабжены label. Filament: очередь модерации комментариев с фильтром по статусу, список жалоб. Команды: cms:comments:purge-spam --json.
Демо-контент: сидер создаёт демо-тред (3–4 уровня вложенности, включая гостевые и пользовательские комментарии, один pending) на демо-странице — playground и /_gallery показывают блок без ручного ввода.
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.
Бэкап/рестор: в бэкап попадают 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 не является защитой.
moderate отделено от manage (принятие решения по конкретному комментарию vs полное управление модулем); массовое удаление и включение/выключение публичной формы — только manage, с подтверждением необратимых операций.
Админ: пустое состояние очереди модерации — «нет комментариев на рассмотрении» вместо пустой таблицы; массовое действие «одобрить/отклонить выбранные» в списке; человеческая ошибка при попытке одобрить уже удалённый/несуществующий комментарий («этот комментарий уже удалён», не 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=deleted → 404 с понятным сообщением, не 500; кнопка «пожаловаться» в UI скрывается для уже удалённых/скрытых узлов, но сервер всё равно обязан проверять состояние.
Гостевой комментарий с чужим email: email не верифицируется — нельзя отправлять на него письма с управляющими действиями (отписка, ссылка на редактирование); используется только для отображения (опционально, по настройке приватности) и антиспам-сигнала; при этом сам факт хранения email — ПДн, подпадает под ретеншн и «забыть по запросу» из паспорта выше, независимо от того, чей это реально email.
Гонка модераторов: два администратора одновременно approve/reject один и тот же pending-комментарий → lock_version (optimistic lock) на cms_comments, второй запрос получает 409, а не тихую перезапись статуса.
Огромная ветка ответов (тысячи узлов на одном уровне/родителе): рендер блока не грузит дерево целиком — постраничная подгрузка «показать ещё N ответов» с keyset по каждому родителю, иначе публичная страница отдаёт мегабайты HTML за один запрос.
Донор: — (новая разработка). Готового донора нет, но команда легаси-импорта предусмотрена заранее — по стандарту (§16) она нужна при переезде клиентов со сторонних CMS/движков комментариев на этот модуль.
универсальный маппер полей произвольного источника (не привязан к конкретному донору студии): author/name → guest_name или резолв user_id по совпадению email с учёткой, content → body (через ту же двойную санитизацию, что и обычная отправка), date → created_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 запрещены
ТЗ — Комментарии (
cms/comments) Назначение и возможности
Полиморфные комментарии к любой сущности контента (посты, страницы, товары) с деревом ответов, премодерацией и антиспам-защитой. Новая разработка, без готового донора.
commentable_type/commentable_id)pending, публикация после одобренияcms/antispam(honeypot, rate-limit, содержательные эвристики)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_versioncms_comment_reportsid, comment_id, reporter_user_id (nullable), reason, statusstatus— PHP Enum (pending/approved/rejected/deleted);commentable_type+commentable_id— составной индекс;parent_id—constrained()->index()под дерево ответов (⚠️ безcascadeOnDelete()— см. «Крайние случаи», удаление родителя выполняется как soft-delete, не каскадное физическое удаление детей).ПДн-паспорт: модуль хранит ПДн в
cms_comments—guest_name,guest_email(гостевые комментарии, без подтверждения владения email),user_id(связь с учёткой авторизованного автора); вcms_comment_reports—reporter_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_idcomments.rate_limit_per_minute), honeypot,spam-filter-контракт при наличииcms/antispamcomment_id, reasonstatus(approve/reject/delete)comments.moderate/comments.managespam-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)pending+ жалобыCommentSubmitted/CommentApproved/CommentReportedcms/notifications-bus, аудит)cms:comments:purge-spam --jsonВсё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта).
Настройки (группа
comments) comments.premoderation_enabledtruecomments.guest_allowedtruecomments.max_depth3comments.rate_limit_per_minute3comments.max_body_length2000comments.rejected_retention_days90comments.public_submission_enabledtrueДостижение
max_body_length/rate_limit_per_minute— понятная ошибка формы (422 с человеческим текстом) и метрика, не тихое обрезание текста.API
/api/v1/comments/api/v1/comments/api/v1/admin/comments…comments.manage)/api/v1/comments/{id}/reportСписок комментариев — keyset-пагинация по
(created_at, id), не OFFSET.Компоненты
Блоки (BlockRegistry): «Комментарии» (дерево + форма отправки) — demo-props показывают демо-тред без ручного ввода; ответы глубже первого уровня подгружаются по клику «показать ещё N ответов» (lazy, без загрузки всего дерева разом), высота формы/дерева зарезервирована (без CLS при подгрузке), кнопки «ответить»/«свернуть»/«пожаловаться» и поля формы доступны с клавиатуры и снабжены
label. Filament: очередь модерации комментариев с фильтром по статусу, список жалоб. Команды:cms:comments:purge-spam --json.Демо-контент: сидер создаёт демо-тред (3–4 уровня вложенности, включая гостевые и пользовательские комментарии, один
pending) на демо-странице — playground и/_galleryпоказывают блок без ручного ввода.События и обмен
CommentSubmittedcomment_id, commentable_type, commentable_id, statusCommentApprovedcomment_idCommentReportedcomment_id, reasonИспользует антиспам-фильтры
cms/antispam(honeypot, эвристики) при наличии модуля; уведомления публикуются черезcms/notifications-bus. Provides-контрактов не реализует.Таблица взаимодействий:
cms/antispam(suggests)spam-filter(канал 3)body/guest_emailна спам-эвристики при отправке; модуль отсутствует → деградация до honeypot + rate-limitcms/notifications-bus(suggests)notification-channel(канал 3)NotificationDispatchуходит в лог-fallback, сайт живContentRepository/PageRepositorycore-contracts)commentable_type/commentable_idперед созданием комментарияRequestContextlocaleи пользователь сохраняются вcms_commentsCommentSubmitted/CommentApproved/CommentReportedcms/audit) при подписке реагируют на фактcms/audit(если включён)cms:comments:import-legacyФоновая работа
Джоба
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-switchcomments.public_submission_enabled=false.comments.public_submission_enabled(форма скрывается, чтение и модерация живы) →cms:comments:purge-spam --json(ручной прогон очистки) →cms:doctor --json(консистентность дерева, orphanparent_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.Конкретные векторы:
guest_name/guest_email: оба поля — обычный текст, но выводятся в шаблоне рядом с телом;обязателен и для них, не только дляbody— попытка вставить<script>в имя должна экранироваться так же, как в тексте.guest_email: email не подтверждается (нет verification-флоу) — модуль не должен использовать его для действий, требующих доказанного владения (управление подпиской, восстановление доступа); используется только для отображения (опционально) и как сигнал антиспам-эвристикам, не как источник доверия.POST /api/v1/commentsсparent_idглубжеmax_depthобязан валидироваться на сервере (см. «Крайние случаи») — скрытие кнопки «ответить» в UI не является защитой.Матрица ролей:
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/тег).commentable, тот же автор/guest_email, тот же хешbodyза короткий интервал) либо принимаетIdempotency-Keyна мутации (конвенция ядра §7 стандарта), чтобы повторная отправка не создавала второй комментарий.comment_idсоstatus=deleted→404с понятным сообщением, не500; кнопка «пожаловаться» в UI скрывается для уже удалённых/скрытых узлов, но сервер всё равно обязан проверять состояние.pending-комментарий →lock_version(optimistic lock) наcms_comments, второй запрос получает409, а не тихую перезапись статуса.Донорский код
Донор: — (новая разработка). Готового донора нет, но команда легаси-импорта предусмотрена заранее — по стандарту (§16) она нужна при переезде клиентов со сторонних CMS/движков комментариев на этот модуль.
Legacy-импорт.
cms:comments:import-legacy --source=<профиль> [--dry-run]:author/name→guest_nameили резолвuser_idпо совпадению email с учёткой,content→body(через ту же двойную санитизацию, что и обычная отправка),date→created_at,parent_external_id→ восстановление дерева по графу внешних id перед вставкой (топологическая сортировка родитель-раньше-ребёнка);external_idнаcms_comments(повторный прогон обновляет, не дублирует);--dry-run— отчёт «прочитано/создано/обновлено/пропущено» с построчными расхождениями (например «родитель не найден» для сирот дерева).Тесты и приёмка
pending, не виден публично до одобренияguest_name/guest_email)cms/antispam) реально блокируют honeypot-заполненные отправки; при отсутствии модуля деградация до honeypot+rate-limit подтверждена тестомcomments.moderateотделены отcomments.manage(модерация vs полное управление)->with('replies.user')с ограничением глубины)CommentApprovedstatus=deleted, тело затёрто), дочерние ответы остаются доступны в дереве (тест на отсутствие каскадного физического удаления)max_depthне создаёт более глубокий узел (сплющивание к предку), даже при прямом обращении к API в обход UIIdempotency-Key)409поlock_version, не тихую перезапись404, не500comments.public_submission_enabled=falseскрывает форму отправки, но чтение и модерация продолжают работатьrejected-комментарии старшеrejected_retention_days; хук «забыть по запросу» подтверждён тестом наguest_email/user_idcms:comments:import-legacy --dry-runпрогнан на синтетическом наборе с сиротами дерева, идемпотентность повторного прогона подтверждена тестомcms-testingзелёный, пакет тестируется в testbench-изоляцииcomments_test;migrate:fresh/refresh/reset/db:wipeзапрещены