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

Entity / Aggregate: Comment (вложенные, переключаемые)

Дом полей комментария к посту ленты новостей (контекст news). Модели ответов API дерева комментариев, payload события comment-added и любые проекции ссылаются сюда и перечисляют только дельту, а не переописывают поля.

Назначение

Comment — единица пользовательского обсуждения (UGC) под постом (post.md). Комментарии вложенные (ответ на ответ — self-reference ParentCommentId) и переключаемые per-post: пост владеет флагом CommentsEnabled (INV-NWS-006 в post.md), которым обсуждение открывают или закрывают. Собственный жизненный цикл модерации (Visible → скрыт/удалён) делает комментарий агрегатом со своими инвариантами, а не owned-частью поста; на пост он ссылается по PostId.

Вложенность реализована материализованным путём Path (/{rootId}/…/{id}) без PostgreSQL ltree (ADR-0056, simplicity-first): ветка треда выбирается одним индексным сканом по (post_id, root_comment_id, path), глубина ограничивается сравнением Depth.

Классификация

ПолеЗначение
Контекстnews
KindAggregateRoot (собственный жизненный цикл модерации; ссылается на Post по PostId)
Source of truthэтот контекст (news)
Таблицаnews.comments
Tenant isolationне tenanted: публичный UGC под опубликованным постом. Изоляция — record-level по AuthorUserId (авторские операции) + permission-гейт модерации (ADR-0052, политика tenancy)
Concurrencyоптимистическая блокировка на переходах статуса (правка не должна затирать чужую)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPK pk_commentsappv7 — хронологический порядок в треде без отдельной сортировки.
PostIdGuidДаFK fk_comments_post → news.posts(id) restrict; в композите ix_comments_post_threadappПост-владелец обсуждения.
ParentCommentIdGuid?НетFK fk_comments_parent → news.comments(id) restrict; index ix_comments_parentappnull — корневой комментарий; иначе ответ (вложенность).
RootCommentIdGuid?Нетв композите ix_comments_post_threadappКорень треда для выборки ветки одним запросом; = Id у корневого.
PathstringДаvarchar(512); материализованный путь /{rootId}/…/{id}appСортировка/лимит глубины без ltree (ADR-0056).
DepthshortДа0..MaxDepth (MaxDepth = 6), check ck_comments_depthappУровень вложенности; лимит (INV-CMT-003).
AuthorUserIdGuidДаindex ix_comments_author; логич. ссылка identity.users (без FK)identity (актор)Автор комментария; record-level авторизация авторских операций.
BodystringДа1..4000 (varchar(4000))user (UGC)Текст комментария; sanitized при рендере (XSS).
StatusCommentStatusДаenum (хранится как text), varchar(16), index ix_comments_statusappВидимость узла в дереве (см. enum).
CreatedAtDateTimeOffsetДаappБазовый Entity; совпадает с моментом Id (v7).
CreatedByUserIdGuid?Да (при создании)без FK; = AuthorUserIdappActor-stamp автора.
UpdatedAtDateTimeOffsetДаappПоследний переход статуса.
UpdatedByUserIdGuid?Нетбез FKappАктор перехода (автор/модератор).
DeletedAtDateTimeOffset?Нетsoft-delete (только DeletedByAuthor)appМомент авторского удаления; узел сохраняется плейсхолдером.
DeletedByUserIdGuid?Нетбез FKappАктор soft-delete.

Enum CommentStatus (хранится как text)

ЗначениеОписание
VisibleУзел в дереве; тело (Body) отдаётся. Единственное стартовое состояние.
HiddenByModeratorСкрыт модерацией (permission news.comments.moderate): тело не отдаётся, рендерится плейсхолдер, ветка ответов сохраняется. DeletedAt не заполняется (это не удаление).
DeletedByAuthorSoft-удалён автором: плейсхолдер «удалено», дети остаются, DeletedAt заполнен.

Терминальные HiddenByModerator/DeletedByAuthor не исключают строку из выборки дерева — узел нужен как плейсхолдер, чтобы вложенные ответы не «оторвались» (INV-CMT-006, restrict-delete родителя).

Value objects

Value objects не используются.

Модели JSONB

JSONB-модели не используются.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
Postreferencepost_id → news.posts(id)GetById(PostId) — гейт CommentsEnabled при создании (INV-CMT-001)FK restrict (жёсткое удаление поста при живых комментариях запрещено); архив поста — app-level soft-cascade скрывает комментарии (post.md)
Comment (parent)self-reference 0..1 — *parent_comment_id → news.comments(id)Filter ветки по RootCommentId + Path (без рекурсии)restrict — удаление только soft, ветка не рушится (INV-CMT-004/006)

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-CMT-001 PostCommentableСоздание только при Post.Status = PublishedPost.CommentsEnabled = true (INV-NWS-006, post.md)validator команды: GetById(PostId) + guardNEWS_COMMENTS_DISABLED
INV-CMT-002 ParentSamePostParentComment.PostId == PostId (ответ в том же посте)метод агрегата Reply (GetById(ParentCommentId))NEWS_COMMENT_PARENT_MISMATCH
INV-CMT-003 MaxDepthDepth <= MaxDepth (6); глубже — ответ прикрепляется к предку на MaxDepth либо отклоняется (политика — UC-NWS-003)check + метод ReplyNEWS_COMMENT_TOO_DEEP
INV-CMT-004 RecordLevelDeleteDeleteByAuthor — только actor == AuthorUserId; HiddenByModerator — permission news.comments.moderateauthorization в handler403
INV-CMT-005 CountConsistencyPost.CommentsCount инкремент при создании Visible, декремент при удалении/скрытии (денорм.; ночной reconcile-job страхует дрейф)обработчик доменного события в том же согласованном изменениинет — механика
INV-CMT-006 NoResurrectИз DeletedByAuthor/HiddenByModerator обратно в Visible нельзя (append-forward)guard перехода статусаNEWS_COMMENT_INVALID_STATUS_TRANSITION

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
CommentAddedKafkaEventинтеграционноеСоздание комментария ([*] → Visible)Id, PostId, ParentCommentId, RootCommentId, AuthorUserId, Depth, CreatedAt

Топик pin.news.comment-added.v1, ContractVersion = 1, ключ партиционирования = PostId (per-post порядок). Публикуется через транзакционный outbox атомарно с созданием агрегата. Дом контракта и полный payload — events/comment-added.md. Инкремент CommentsCount (INV-CMT-005) — внутренний доменный обработчик, не интеграционное событие.

Конкурентность и согласованность

  • Создание атомарно с событием и счётчиком. Comment (Visible), инкремент Post.CommentsCount и запись CommentAddedKafkaEvent в outbox фиксируются в одном согласованном изменении: комментарий виден в дереве сразу, уведомление — асинхронно (SLA < 1 мин).
  • Гонка модерации и авторского удаления. HiddenByModerator и DeletedByAuthor — оба терминальны и «вперёд» (INV-CMT-006, no-resurrect). При одновременных переходах оптимистическая блокировка пропускает один; проигравший, обнаружив уже терминальный статус, получает NEWS_COMMENT_INVALID_STATUS_TRANSITION (повторный декремент CommentsCount не выполняется). Итог — ровно один декремент счётчика на комментарий.
  • Граница глубины (INV-CMT-003). Ответ на узел с Depth = MaxDepth (6) не создаёт Depth = 7: по политике UC-NWS-003 он либо прикрепляется к предку на MaxDepth, либо отклоняется NEWS_COMMENT_TOO_DEEP — поведение фиксируется в use-case, но само превышение MaxDepth в дереве невозможно (check ck_comments_depth).
  • Плейсхолдеры сохраняют ветку. Терминальные HiddenByModerator/DeletedByAuthor не удаляют строку и не рвут Path: дети остаются доступны, узел рендерится плейсхолдером (INV-CMT-006, restrict-delete родителя). Тело Body при HiddenByModerator не отдаётся; DeletedAt заполняется только при DeletedByAuthor.
  • Дрейф счётчика. Post.CommentsCount — денормализованный; расхождение с фактом (сбои/гонки) устраняет ночной comment-count-reconcile (борд news-overview, метрика news_comments_count_drift_posts), не блокируя write-путь.
  • Архив поста. Жёсткое удаление поста при живых комментариях запрещено (FK restrict); архивация поста — app-level soft-cascade скрывает комментарии, сами строки сохраняются.

Acceptance Criteria

  • Корневой комментарий. Дано Published пост с CommentsEnabled = true; когда пользователь добавляет комментарий; тогда создаётся узел Visible, Depth = 0, RootCommentId = Id, CommentsCount++, публикуется comment-added.
  • Ответ. Дано существующий Visible комментарий того же поста; когда пользователь отвечает; тогда ParentCommentId/RootCommentId заданы, Depth = parent.Depth + 1, Path продлён.
  • Комментарии выключены. Дано CommentsEnabled = false или пост не Published; когда добавление; тогда NEWS_COMMENTS_DISABLED (INV-CMT-001).
  • Ответ в чужой пост. Дано ParentComment.PostId != PostId; когда Reply; тогда NEWS_COMMENT_PARENT_MISMATCH (INV-CMT-002).
  • Превышение глубины. Дано родитель на Depth = 6; когда ответ; тогда по политике UC-NWS-003 — прикрепление к предку на MaxDepth либо NEWS_COMMENT_TOO_DEEP (INV-CMT-003).
  • Авторское удаление. Дано Visible комментарий; когда его автор удаляет; тогда DeletedByAuthor, DeletedAt задан, дети сохранены, CommentsCount--; не-автор — 403 (INV-CMT-004).
  • Модерация. Дано Visible комментарий; когда актор с news.comments.moderate скрывает; тогда HiddenByModerator, Body не отдаётся, DeletedAt пуст, дети сохранены, CommentsCount--.
  • Нет воскрешения. Дано DeletedByAuthor/HiddenByModerator; когда попытка вернуть в Visible; тогда NEWS_COMMENT_INVALID_STATUS_TRANSITION (INV-CMT-006).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK pk_comments (id); btree ix_comments_post_thread (post_id, root_comment_id, path) — выборка дерева поста по веткам с сортировкой; ix_comments_parent (parent_comment_id); ix_comments_author (author_user_id); ix_comments_status (status)
Партиционирование / retentionnone — soft-delete сохраняет узлы как плейсхолдеры; бессрочно
Concurrencyоптимистическая блокировка на переходах статуса (правка не должна затирать чужую)
Permissions / PIIЧтение Visible — public; создание — аутентифицированный user + гейт CommentsEnabled (INV-CMT-001); модерация (HiddenByModerator) — permission news.comments.moderate (identity/domain/permission.md). PII нет (Body — UGC)
AuditActor-stamping (CreatedByUserId/UpdatedByUserId/DeletedByUserId) + Status + soft-delete: видно, кто и когда создал/скрыл/удалил комментарий

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

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

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

  • post.md — родительский пост, гейт CommentsEnabled (INV-NWS-006), денорм. CommentsCount.
  • events/comment-added.md — контракт pin.news.comment-added.v1.
  • UC-NWS-003 — сценарий комментариев к посту (создание/ответ/модерация/лимит глубины).
  • ADR-0056 (simplicity-first: материализованный путь без ltree), ADR-0052 (политика tenancy).