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 area | admin (m2m, ADR-0053) |
| Feature group | moderation |
| Статус | draft |
| Документ | docs/06-services/news/api/admin/moderate-content.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Operator(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) |
| Audit | update/moderate: actor-stamp (UpdatedByUserId) + Status + deep-audit (actor + Reason) admin-API |
HTTP-контракт
| Операция | Method | Route | Success | Idempotency |
|---|---|---|---|---|
| Скрыть пост | POST | /api/v1/admin/posts/{id}/hide | 200 | естественная (повтор на целевом статусе → 200 без мутации) |
| Восстановить пост | POST | /api/v1/admin/posts/{id}/restore | 200 | естественная |
| Скрыть комментарий | POST | /api/v1/admin/comments/{id}/hide | 200 | естественная |
Correlation — сквозной trace через traceparent; Idempotency-header не применяется.
Входные данные / параметры запроса
Единый вход всех трёх операций: id из route + опциональная причина Reason из тела.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
Id | route | Guid | Да | != Guid.Empty; существующий пост/комментарий (шаг 2) | — |
Reason | body | ModerationReason? | Нет | enum строкой; допустимое значение или null | пишется в deep-audit (ReasonCode) |
ActorUserId | identity claims | Guid | Да | 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 поста)
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
PostId | Guid | Да | Post.Id | Идентификатор поста. |
Status | PostStatus | Да | Post.Status (enum-строка) | "Unpublished" после hide / "Published" после restore. |
ModerationState | PostModerationState | Да | Post.ModerationState (enum-строка) | "HiddenByModerator" после hide / "None" после restore. |
ModeratedAt | DateTimeOffset | Да | Post.UpdatedAt | Момент перехода. |
CommentModerationResult (hide комментария)
| Поле | Тип | Обязательность | Источник | Описание |
|---|---|---|---|---|
CommentId | Guid | Да | Comment.Id | Идентификатор комментария. |
Status | CommentStatus | Да | Comment.Status (enum-строка) | Всегда "HiddenByModerator". |
ModeratedAt | DateTimeOffset | Да | Comment.UpdatedAt | Момент скрытия. |
Алгоритм
Три ветки модерации над одним агрегатом. Актор — доверенная админ-панель (m2m, ADR-0053) в платформенном
scope (tenant-фильтр обходится); требуется permission news.posts.moderate (для постов) или
news.comments.moderate (для комментариев), иначе — FORBIDDEN. Идемпотентность обеспечивается целевым
статусом — Idempotency-header не применяется. Причина Reason (ModerationReason, опц.) пишется в
deep-audit.
- Проверка входа.
Id != Guid.Empty;Reason(если задан) — допустимое значениеModerationReason, иначеVALIDATION_FAILED. Найти целевой пост/комментарий поId; если не найден →NEWS_POST_NOT_FOUND/NEWS_COMMENT_NOT_FOUND(404). Embed-snapshot-ы поста при hide/restore не читаются и не отвязываются (detach — только приArchived/удалении). - Скрыть пост (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/авторскийUnpublished→NEWS_POST_INVALID_STATE(409). Пост выпадает из ленты (частичный индексix_posts_feed where status='Published') и удаляется из поисковой проекцииnews-posts(поPostUnpublishedEvent, search/posts.md). - Восстановить пост (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. - Скрыть комментарий (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 events | none (осознанно: модерация не публикуется в Kafka) |
| External calls | none — selections не вызывается (snapshots не detached) |
| Cache / projections / search | HidePost/RestorePost меняют членство поста в ленте (частичный ix_posts_feed where status='Published') и в Elasticsearch-проекции news-posts (delete/upsert по переходу статуса, search/posts.md); HideComment декрементит Post.CommentsCount (денорм.; ночной comment-count-reconcile страхует дрейф, INV-CMT-005) |
| Notifications | none — повторное уведомление подписчиков на restore не шлётся (не republish) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | Id == Guid.Empty или недопустимый Reason | «Проверьте корректность запроса» | no retry |
FORBIDDEN | 403 | нет permission news.posts.moderate / news.comments.moderate | «Недостаточно прав» | no retry |
NEWS_POST_NOT_FOUND | 404 | пост не найден по Id | «Пост не найден» | no retry |
NEWS_COMMENT_NOT_FOUND | 404 | комментарий не найден по Id | «Комментарий не найден» | no retry |
NEWS_POST_INVALID_STATE | 409 | HidePost из недопустимого статуса (Draft/Archived/авторский Unpublished) | «Пост нельзя скрыть в текущем статусе» | no retry |
NEWS_POST_NOT_MODERATED | 409 | RestorePost без пометки ModerationState = HiddenByModerator | «Пост не скрыт модерацией» | no retry |
NEWS_COMMENT_INVALID_STATE | 409 | HideComment из DeletedByAuthor (INV-CMT-006) | «Комментарий нельзя скрыть в текущем статусе» | no retry |
ConcurrencyConflict | 409 | конкурентный переход статуса (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идемпотентно;DeletedByAuthor→409 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; dashboardnews-overview(алерт «driftCommentsCount»); runbook — пересчётCommentsCount(comment-count-reconcile).
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: post.md (агрегат,
PostStatus,ModerationState, INV-NWS-005/007, post-version.md), comment.md (CommentStatus, INV-CMT-004/005/006). Смежная операция: delete-comment.md (авторское удаление / модераторская ветка в public-area). - Permissions: permission.md (
news.posts.moderate,news.comments.moderate); события сервиса — post-published.md, comment-added.md (модерация их не порождает). - Иммутабельность embed: selection-snapshot.md (INV-SEL-001 — snapshots не detached при hide/restore).
- Сервис: News README.
- ADR: ADR-0052 (tenancy), ADR-0053 (admin m2m), ADR-0056 (минимальная модерация).