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 |
| Kind | AggregateRoot (собственный жизненный цикл модерации; ссылается на Post по PostId) |
| Source of truth | этот контекст (news) |
| Таблица | news.comments |
| Tenant isolation | не tenanted: публичный UGC под опубликованным постом. Изоляция — record-level по AuthorUserId (авторские операции) + permission-гейт модерации (ADR-0052, политика tenancy) |
| Concurrency | оптимистическая блокировка на переходах статуса (правка не должна затирать чужую) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK pk_comments | app | v7 — хронологический порядок в треде без отдельной сортировки. |
PostId | Guid | Да | FK fk_comments_post → news.posts(id) restrict; в композите ix_comments_post_thread | app | Пост-владелец обсуждения. |
ParentCommentId | Guid? | Нет | FK fk_comments_parent → news.comments(id) restrict; index ix_comments_parent | app | null — корневой комментарий; иначе ответ (вложенность). |
RootCommentId | Guid? | Нет | в композите ix_comments_post_thread | app | Корень треда для выборки ветки одним запросом; = Id у корневого. |
Path | string | Да | varchar(512); материализованный путь /{rootId}/…/{id} | app | Сортировка/лимит глубины без ltree (ADR-0056). |
Depth | short | Да | 0..MaxDepth (MaxDepth = 6), check ck_comments_depth | app | Уровень вложенности; лимит (INV-CMT-003). |
AuthorUserId | Guid | Да | index ix_comments_author; логич. ссылка identity.users (без FK) | identity (актор) | Автор комментария; record-level авторизация авторских операций. |
Body | string | Да | 1..4000 (varchar(4000)) | user (UGC) | Текст комментария; sanitized при рендере (XSS). |
Status | CommentStatus | Да | enum (хранится как text), varchar(16), index ix_comments_status | app | Видимость узла в дереве (см. enum). |
CreatedAt | DateTimeOffset | Да | — | app | Базовый Entity; совпадает с моментом Id (v7). |
CreatedByUserId | Guid? | Да (при создании) | без FK; = AuthorUserId | app | Actor-stamp автора. |
UpdatedAt | DateTimeOffset | Да | — | app | Последний переход статуса. |
UpdatedByUserId | Guid? | Нет | без FK | app | Актор перехода (автор/модератор). |
DeletedAt | DateTimeOffset? | Нет | soft-delete (только DeletedByAuthor) | app | Момент авторского удаления; узел сохраняется плейсхолдером. |
DeletedByUserId | Guid? | Нет | без FK | app | Актор soft-delete. |
Enum CommentStatus (хранится как text)
| Значение | Описание |
|---|---|
Visible | Узел в дереве; тело (Body) отдаётся. Единственное стартовое состояние. |
HiddenByModerator | Скрыт модерацией (permission news.comments.moderate): тело не отдаётся, рендерится плейсхолдер, ветка ответов сохраняется. DeletedAt не заполняется (это не удаление). |
DeletedByAuthor | Soft-удалён автором: плейсхолдер «удалено», дети остаются, DeletedAt заполнен. |
Терминальные HiddenByModerator/DeletedByAuthor не исключают строку из выборки дерева — узел нужен как
плейсхолдер, чтобы вложенные ответы не «оторвались» (INV-CMT-006, restrict-delete родителя).
Value objects
Value objects не используются.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
Post | reference | post_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 = Published ∧ Post.CommentsEnabled = true (INV-NWS-006, post.md) | validator команды: GetById(PostId) + guard | NEWS_COMMENTS_DISABLED |
| INV-CMT-002 ParentSamePost | ParentComment.PostId == PostId (ответ в том же посте) | метод агрегата Reply (GetById(ParentCommentId)) | NEWS_COMMENT_PARENT_MISMATCH |
| INV-CMT-003 MaxDepth | Depth <= MaxDepth (6); глубже — ответ прикрепляется к предку на MaxDepth либо отклоняется (политика — UC-NWS-003) | check + метод Reply | NEWS_COMMENT_TOO_DEEP |
| INV-CMT-004 RecordLevelDelete | DeleteByAuthor — только actor == AuthorUserId; HiddenByModerator — permission news.comments.moderate | authorization в handler | 403 |
| INV-CMT-005 CountConsistency | Post.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в дереве невозможно (checkck_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) |
| Партиционирование / retention | none — soft-delete сохраняет узлы как плейсхолдеры; бессрочно |
| Concurrency | оптимистическая блокировка на переходах статуса (правка не должна затирать чужую) |
| Permissions / PII | Чтение Visible — public; создание — аутентифицированный user + гейт CommentsEnabled (INV-CMT-001); модерация (HiddenByModerator) — permission news.comments.moderate (identity/domain/permission.md). PII нет (Body — UGC) |
| Audit | Actor-stamping (CreatedByUserId/UpdatedByUserId/DeletedByUserId) + Status + soft-delete: видно, кто и когда создал/скрыл/удалил комментарий |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: Post
- API (5): Admin: модерация постов и комментариев (m2m), DELETE /api/v1/comments/{id} — Удалить/скрыть комментарий, GET /api/v1/posts/{id}/comments — Дерево комментариев поста, POST /api/v1/posts/{id}/comments — добавить комментарий/ответ, POST /api/v1/posts/{id}/comments-settings — переключить комментарии
- Use Cases (1): UC-NWS-003. Вложенные переключаемые комментарии
- События (2): События сервиса news: карта топиков и отклонённые события/консюмеры, Event Contract: pin.news.comment-added (CommentAddedKafkaEvent)
- Фоновые задачи (1): Background Job: CommentCountReconcileJob
- Дашборды (1): Панель Наблюдаемости: news-overview (эксплуатация)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- 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).