Перейти к основному содержимому

Admin: модерация постов и комментариев (m2m)

Три m2m-операции админ-модерации ленты новостей (feature group moderation, area admin, ADR-0053): скрыть пост, восстановить пост, скрыть комментарий. Операции разделяют актора, политику, аудит и переходы статусов, поэтому документируются вместе. Дом полей и инвариантов агрегатов — post.md / comment.md.

Назначение

Дать доверенной админ-панели (m2m) минимальную модерацию UGC: убрать из ленты нарушающий пост (Published → Unpublished, с пометкой модерации) и вернуть его обратно, а также скрыть нарушающий комментарий (Visible → HiddenByModerator), не разрушая ветку обсуждения и историю версий поста. Расширенный арбитраж (жалобы, эскалация, разбор) — за сервисом support (отложено, решение №10).

Metadata

ПолеЗначение
Сервис/контекстnews
API areaadmin (m2m, ADR-0053)
Feature groupmoderation
Статусdraft
Документdocs/06-services/news/api/admin/moderate-content.md

Актор и доступ

ПроверкаЗначение
Actor typeOperator(Admin) или ExternalSystem (m2m-токен админ-панели, ADR-0053)
Auth policy / Permissionsпосты — news.posts.moderate; комментарии — news.comments.moderate (канонический каталог — permission.md)
Scope (RBAC)платформенный: модерация действует кросс-tenant (tenant-фильтр обходится admin-scope-ом)
Record-levelне применяется — авторизация только по permission-гейту (не по владельцу)
Tenant isolationне применяется (admin платформенного scope; Comment вне tenant, Post — nullable-tenant, ADR-0052)
Auditupdate/moderate: actor-stamp (UpdatedByUserId) + Status + deep-audit (actor + Reason) admin-API

HTTP-контракт

ОперацияMethodRouteSuccessIdempotency
Скрыть постPOST/api/v1/admin/posts/{id}/hide200естественная (повтор на целевом статусе → 200 без мутации)
Восстановить постPOST/api/v1/admin/posts/{id}/restore200естественная
Скрыть комментарийPOST/api/v1/admin/comments/{id}/hide200естественная

Correlation — сквозной trace через traceparent; Idempotency-header не применяется.

Входные данные / параметры запроса

Единый вход всех трёх операций: id из route + опциональная причина Reason из тела.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
IdrouteGuidДа!= Guid.Empty; существующий пост/комментарий (шаг 2)
ReasonbodyModerationReason?Нетenum строкой; допустимое значение или nullпишется в deep-audit (ReasonCode)
ActorUserIdidentity claimsGuidДаm2m-субъект admin-токена; actor-stamp мутации

Enum ModerationReason (передаётся/хранится строкой)

ЗначениеОписание
SpamСпам/реклама.
AbuseОскорбления/травля.
OffensiveНедопустимый/противоправный контент.
MisinformationДезинформация/фейк.
OffTopicНе по теме.
OtherИная причина (уточняется в audit).

Unknown enum handling: неизвестное значение → VALIDATION_FAILED; новые причины добавляются additively. Значения выровнены с CommentModerationReason авторского удаления (delete-comment.md).

Пометка модерации

Модерационную пометку поста несёт поле Post.ModerationState : PostModerationState (None / HiddenByModerator) — дом агрегата post.md (поле расширяет Post для feature group moderation). Пометка отличает скрытие модератором от авторского Unpublished: автор не может self-republish пост с ModerationState = HiddenByModerator (гейт в authoring-publish-handler), а restore восстанавливает только посты с пометкой.

Модель ответа

Списки не возвращаются (одиночная операция над одним агрегатом); будущая очередь модерации (листинг) — со support (отложено).

PostModerationResult (hide/restore поста)

ПолеТипОбязательностьИсточникОписание
PostIdGuidДаPost.IdИдентификатор поста.
StatusPostStatusДаPost.Status (enum-строка)"Unpublished" после hide / "Published" после restore.
ModerationStatePostModerationStateДаPost.ModerationState (enum-строка)"HiddenByModerator" после hide / "None" после restore.
ModeratedAtDateTimeOffsetДаPost.UpdatedAtМомент перехода.

CommentModerationResult (hide комментария)

ПолеТипОбязательностьИсточникОписание
CommentIdGuidДаComment.IdИдентификатор комментария.
StatusCommentStatusДаComment.Status (enum-строка)Всегда "HiddenByModerator".
ModeratedAtDateTimeOffsetДаComment.UpdatedAtМомент скрытия.

Алгоритм

Три ветки модерации над одним агрегатом. Актор — доверенная админ-панель (m2m, ADR-0053) в платформенном scope (tenant-фильтр обходится); требуется permission news.posts.moderate (для постов) или news.comments.moderate (для комментариев), иначе — FORBIDDEN. Идемпотентность обеспечивается целевым статусом — Idempotency-header не применяется. Причина Reason (ModerationReason, опц.) пишется в deep-audit.

  1. Проверка входа. Id != Guid.Empty; Reason (если задан) — допустимое значение ModerationReason, иначе VALIDATION_FAILED. Найти целевой пост/комментарий по Id; если не найден → NEWS_POST_NOT_FOUND / NEWS_COMMENT_NOT_FOUND (404). Embed-snapshot-ы поста при hide/restore не читаются и не отвязываются (detach — только при Archived/удалении).
  2. Скрыть пост (HidePost). Допустим только из Status = Published. Переход: Published → Unpublished, ModerationState = HiddenByModerator, actor-stamp. PublishedAt/FirstPublishedAt/CurrentVersionNumber не меняются; новый PostVersion не пишется (скрытие — не публикация, INV-NWS-005 неприменим); embed- snapshot-ы не отвязываются (INV-SEL-001 — пост восстановим). Если пост уже Unpublished с пометкой HiddenByModerator — идемпотентный повтор (200). Если статус Draft/Archived/авторский UnpublishedNEWS_POST_INVALID_STATE (409). Пост выпадает из ленты (частичный индекс ix_posts_feed where status='Published') и удаляется из поисковой проекции news-posts (по PostUnpublishedEvent, search/posts.md).
  3. Восстановить пост (RestorePost). Допустим только из Status = Unpublished с пометкой ModerationState = HiddenByModerator. Переход: Unpublished → Published, ModerationState = None, actor-stamp. PublishedAt сохраняется (лента — исходный порядок); нового PostVersion и события post-published нет (контент не менялся — это не republish). Если пост уже Published — идемпотентный повтор. Если пометки нет (None/Draft/Archived) → NEWS_POST_NOT_MODERATED (409) — модератор не восстанавливает то, что снял с публикации автор. Документ возвращается в поисковую проекцию news-posts.
  4. Скрыть комментарий (HideComment). Допустим только из Status = Visible. Переход: Visible → HiddenByModerator (INV-CMT-004), actor-stamp; DeletedAt не заполняется; дети ветки (Path/RootCommentId) сохраняются. Денормализованный Post.CommentsCount декрементируется в той же транзакции (INV-CMT-005; ночной comment-count-reconcile страхует дрейф). Если комментарий уже HiddenByModerator — идемпотентный повтор. Из DeletedByAuthor скрыть нельзя → NEWS_COMMENT_INVALID_STATE (409, INV-CMT-006 no-resurrect).

Переход агрегата, декремент CommentsCount (для комментария) и deep-audit применяются атомарно, без outbox — интеграционные Kafka-события модерация осознанно не публикует (news публикует лишь post-published и comment-added). Конкурентный переход статуса → ConcurrencyConflict (409), один повтор с перечиткой. Идемпотентный повтор возвращает тот же 200 с текущим состоянием.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (осознанно: модерация не публикуется в Kafka)
External callsnone — selections не вызывается (snapshots не detached)
Cache / projections / searchHidePost/RestorePost меняют членство поста в ленте (частичный ix_posts_feed where status='Published') и в Elasticsearch-проекции news-posts (delete/upsert по переходу статуса, search/posts.md); HideComment декрементит Post.CommentsCount (денорм.; ночной comment-count-reconcile страхует дрейф, INV-CMT-005)
Notificationsnone — повторное уведомление подписчиков на restore не шлётся (не republish)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Id == Guid.Empty или недопустимый Reason«Проверьте корректность запроса»no retry
FORBIDDEN403нет permission news.posts.moderate / news.comments.moderate«Недостаточно прав»no retry
NEWS_POST_NOT_FOUND404пост не найден по Id«Пост не найден»no retry
NEWS_COMMENT_NOT_FOUND404комментарий не найден по Id«Комментарий не найден»no retry
NEWS_POST_INVALID_STATE409HidePost из недопустимого статуса (Draft/Archived/авторский Unpublished)«Пост нельзя скрыть в текущем статусе»no retry
NEWS_POST_NOT_MODERATED409RestorePost без пометки ModerationState = HiddenByModerator«Пост не скрыт модерацией»no retry
NEWS_COMMENT_INVALID_STATE409HideComment из DeletedByAuthor (INV-CMT-006)«Комментарий нельзя скрыть в текущем статусе»no retry
ConcurrencyConflict409конкурентный переход статуса (RowVersion/xmin)«Повторите попытку»retry (перечитка FindById)

Предусловия и постусловия

Предусловия (истинны до сценария):

  • запрос от доверенной админ-панели (m2m-токен, ADR-0053) в платформенном scope (tenant-фильтр обходится); для постов — news.posts.moderate, для комментариев — news.comments.moderate;
  • целевой пост/комментарий существует; HidePost допустим из Published, RestorePost — из Unpublished с пометкой HiddenByModerator, HideComment — из Visible.

Постусловия (гарантированы после модерации):

  • HidePost: Published → Unpublished, ModerationState = HiddenByModerator; PublishedAt/FirstPublishedAt/CurrentVersionNumber неизменны, новый PostVersion не пишется, embed-snapshot-ы не отвязаны (пост восстановим, INV-SEL-001); пост вне ленты и вне news-posts;
  • RestorePost: Unpublished → Published, ModerationState = None; PostVersion и событие post-published не создаются (контент не менялся — не republish); пост возвращён в news-posts;
  • HideComment: Visible → HiddenByModerator (INV-CMT-004), DeletedAt не заполняется, дети ветки сохранены; Post.CommentsCount декрементирован в той же транзакции (INV-CMT-005);
  • переход агрегата, декремент счётчика и deep-audit применены атомарно; интеграционных Kafka-событий модерация осознанно не публикует.

Интеграционная последовательность

Всё синхронно в запросе и атомарно (переход агрегата + декремент CommentsCount для комментария + deep-audit), без outbox. Что предоставляет наружу: HidePost/RestorePost меняют членство поста в ленте (частичный ix_posts_feed where status='Published') и в проекции news-posts (delete/upsert по переходу статуса, окно свежести — секунды); Kafka-событий нет — news публикует лишь post-published и comment-added, модерация их не порождает. Что ожидает: selections не вызывается (snapshot-ы не detached при hide/restore — пост восстановим). Что видит пользователь: скрытый пост исчезает из ленты сразу, из поиска — в окне проекции; при restore порядок ленты сохраняется (исходный PublishedAt), повторного уведомления подписчиков нет. Конкурентность: оптимистическая блокировка версии → ConcurrencyConflict (409), один повтор с перечиткой; идемпотентный повтор на целевом статусе → тот же 200.

Acceptance Criteria

  • Скрыть пост. Дано: модератор с news.posts.moderate, пост Published. Когда: POST /api/v1/admin/posts/{id}/hide {reason?}. Тогда: 200, Published → Unpublished, ModerationState = HiddenByModerator, PostVersion не пишется, snapshot-ы не отвязаны, Reason в deep-audit.
  • Идемпотентный повтор hide. Дано: пост уже Unpublished с пометкой HiddenByModerator. Когда: POST hide. Тогда: 200 без мутации.
  • Восстановить пост. Дано: пост Unpublished с ModerationState = HiddenByModerator. Когда: POST /restore. Тогда: 200, Unpublished → Published, ModerationState = None, post-published не публикуется, PublishedAt сохранён.
  • Restore без пометки модерации. Дано: пост снят автором (ModerationState = None) или Draft/Archived. Когда: POST restore. Тогда: 409 NEWS_POST_NOT_MODERATED (модератор не восстанавливает авторское снятие).
  • Hide из недопустимого статуса. Дано: пост Draft/Archived/авторский Unpublished. Когда: POST hide. Тогда: 409 NEWS_POST_INVALID_STATE.
  • Гейт self-republish. Дано: пост с ModerationState = HiddenByModerator. Когда: автор пытается publish. Тогда: публикация отклонена (гейт в authoring-publish).
  • Скрыть комментарий. Дано: комментарий Visible. Когда: POST /api/v1/admin/comments/{id}/hide. Тогда: 200, Visible → HiddenByModerator (INV-CMT-004), дети сохранены, Post.CommentsCount декрементирован (INV-CMT-005).
  • Комментарий уже скрыт/удалён автором. Дано: HiddenByModerator (повтор) → 200 идемпотентно; DeletedByAuthor409 NEWS_COMMENT_INVALID_STATE (INV-CMT-006 no-resurrect).
  • Нет прав. Дано: нет news.posts.moderate/news.comments.moderate. Когда: операция. Тогда: 403 FORBIDDEN.
  • Не найден. Дано: пост/комментарий отсутствует. Когда: операция. Тогда: 404 NEWS_POST_NOT_FOUND/NEWS_COMMENT_NOT_FOUND.
  • Недопустимый Reason. Дано: неизвестное значение ModerationReason. Когда: операция. Тогда: 400 VALIDATION_FAILED.
  • Конкурентный переход. Дано: параллельная смена статуса. Когда: операция. Тогда: 409 ConcurrencyConflict, один повтор с перечиткой.

Совместимость и наблюдаемость

  • Reason (ModerationReason) — additive optional; новые значения enum добавляются additively, неизвестное → VALIDATION_FAILED. Пометка Post.ModerationState и результат-модели — additive. Удаление/переименование полей или смена статус-семантики — breaking (migration + rollout).
  • Logs: post hidden/restored, comment hidden (PostId/CommentId, actor-kind = m2m, Reason); metrics: news_moderation_total{target,action}, latency p95; traces — сквозной trace через traceparent; dashboard news-overview (алерт «drift CommentsCount»); runbook — пересчёт CommentsCount (comment-count-reconcile).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы