POST /api/v1/posts/{id}/comments — добавить комментарий/ответ
Назначение
Принять от аутентифицированного пользователя текст комментария (корневого либо ответа на существующий
комментарий), построить узел вложенного дерева (Path/Depth/RootCommentId), инкрементировать
денормализованный Post.CommentsCount и опубликовать интеграционное событие comment-added для
уведомления автора поста и автора родительского комментария.
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | public |
| Feature group | comments |
| Статус | draft |
| Документ | docs/06-services/news/api/public/comments/add-comment.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (аутентифицированный пользователь; guest — 401) |
| Auth policy / Permissions | JWT identity; отдельного permission нет — комментирование self-scope (README «Комментарии/подписки — self-scope») |
| Scope (RBAC) | не применяется (публичный UGC под опубликованным постом) |
| Record-level | AuthorUserId = actor.UserId (стамп автора; авторские операции над узлом — по нему, INV-CMT-004) |
| Tenant isolation | не применяется — Comment не tenanted (comment.md, ADR-0052 п. публичного UGC) |
| Audit | create: CreatedByUserId = AuthorUserId; Body — публичный UGC, PII нет |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/posts/{id}/comments |
| Success status | 201 (создан) / 200 (идемпотентный повтор при совпадающем Idempotency-Key) |
| Idempotency header | Idempotency-Key (опц.); при отсутствии — обычное создание |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Единая секция: HTTP-источник + типизированное поле request-модели AddCommentCommand.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
PostId | route (id) | Guid | Да | != Guid.Empty; существующий Post (шаг 2) | — |
ParentCommentId | body | Guid? | Нет | null — корневой; иначе существующий Comment того же поста (шаг 2) | — |
Body | body | string | Да | 1..4000 после Trim, не пусто | UGC; sanitize при рендере (XSS) |
IdempotencyKey | header (Idempotency-Key) | string? | Нет | <= 128, если задан | — |
AuthorUserId | identity claims | Guid | Да | из JWT актора | стамп автора, не из тела |
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
CommentId | Guid | Да | Comment.Id (v7) | — | Идентификатор созданного комментария. |
PostId | Guid | Да | Comment.PostId | — | Пост-владелец обсуждения. |
ParentCommentId | Guid? | Нет | Comment.ParentCommentId | — | null — корневой комментарий. |
Depth | short | Да | Comment.Depth (0..6) | — | Уровень вложенности узла. |
CreatedAt | DateTimeOffset | Да | Comment.CreatedAt | — | Момент создания (совпадает с v7-Id). |
Алгоритм
- Контекст и доступ. Комментирует авторизованный пользователь (
User); гость → 401. Отдельного
permission нет — комментирование self-scope; автор фиксируется из JWT (AuthorUserId). Если задан
Idempotency-Key — при повторе ранее завершённого запроса возвращается сохранённый результат с 200.
- Проверка входа и предусловий.
Body 1..4000 после trim, PostId != Guid.Empty; нарушение →
VALIDATION_FAILED. Найти пост по PostId; если не найден → NEWS_POST_NOT_FOUND. Комментирование
доступно только когда пост опубликован и открыт (INV-CMT-001 / INV-NWS-006): Status = Published и
CommentsEnabled = true, иначе → NEWS_COMMENTS_DISABLED. Если задан ParentCommentId — родитель
должен существовать (иначе → NEWS_POST_NOT_FOUND), принадлежать тому же посту (INV-CMT-002, иначе →
NEWS_COMMENT_PARENT_MISMATCH), и глубина ответа не должна превышать предел (parent.Depth + 1 <= 6,
INV-CMT-003, иначе → NEWS_COMMENT_TOO_DEEP).
- Создание узла. Создать
Comment в статусе Visible (единственное стартовое состояние):
- корневой (
ParentCommentId == null): Depth = 0, RootCommentId = Id, Path = "/{Id}";
- ответ:
Depth = parent.Depth + 1, RootCommentId = parent.RootCommentId ?? parent.Id,
Path = parent.Path + "/{Id}".
Автор фиксируется в CreatedByUserId; CreatedAt — момент создания.
- Согласованность и событие. Новый комментарий, инкремент денормализованного
Post.CommentsCount
(INV-CMT-005; ночной comment-count-reconcile страхует дрейф) и публикуемое событие фиксируются
атомарно. Публикуется событие comment-added (топик pin.news.comment-added.v1, ContractVersion = 1,
ключ партиционирования PostId) с payload { CommentId, PostId, ParentCommentId, RootCommentId, AuthorUserId, Depth, CreatedAt } — потребитель notifications уведомляет автора поста и автора
родительского комментария. Дом контракта — comment-added.md.
- Результат.
CommentResult { CommentId, PostId, ParentCommentId, Depth, CreatedAt } → 201 (при
idempotency-replay — 200).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | событие comment-added → pin.news.comment-added.v1 (key = PostId, ContractVersion = 1) |
| External calls | none (гейт и родитель читаются из своей БД) |
| Cache / projections / search | денорм. Post.CommentsCount инкремент (та же tx); reconcile-job страхует дрейф |
| Notifications | опосредованно: notifications — консюмер comment-added уведомляет автора поста и автора родительского комментария |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_FAILED | 400 | Body вне 1..4000 / пустой PostId | «Проверьте текст комментария» | no retry |
NEWS_POST_NOT_FOUND | 404 | пост либо родительский комментарий не найден | «Пост не найден» | no retry |
NEWS_COMMENTS_DISABLED | 409 | Post.Status != Published ∨ CommentsEnabled == false (INV-CMT-001) | «Комментарии к посту отключены» | no retry |
NEWS_COMMENT_PARENT_MISMATCH | 400 | parent.PostId != PostId (INV-CMT-002) | «Родительский комментарий из другого поста» | no retry |
NEWS_COMMENT_TOO_DEEP | 400 | parent.Depth + 1 > 6 (INV-CMT-003) | «Достигнут предел вложенности ответов» | no retry |
Идемпотентность и конкурентность
- Область ключа. Идемпотентность считается по паре (
AuthorUserId, Idempotency-Key): повтор с тем же
ключом от того же автора в пределах окна хранения возвращает сохранённый CommentResult с 200 — второй
узел не создаётся, Post.CommentsCount повторно не инкрементируется, событие comment-added повторно не
публикуется. Без заголовка каждый вызов — новое создание: естественной дедупликации у комментариев нет (один
и тот же текст допустим дважды).
- Повтор «в полёте». Пока первый запрос с ключом не завершён, конкурентный повтор с тем же ключом дубля не
создаёт — второй возвращает тот же результат первого (узел и событие фиксирует единственный победитель).
- Параллельные комментарии к одному посту. Несколько авторов (или один без ключа) комментируют
одновременно — все узлы создаются, каждый со своим
Id/Path/RootCommentId; конкурентные инкременты
Post.CommentsCount узлов не теряют, а возможное расхождение счётчика страхует ночной
comment-count-reconcile (INV-CMT-005).
- Ответ на терминальный родитель. Если родитель между проверкой и записью перешёл в
HiddenByModerator/DeletedByAuthor, ответ всё равно допустим — плейсхолдер сохраняет ветку (INV-CMT-006),
глубина по-прежнему считается как parent.Depth + 1.
Acceptance Criteria
- Корневой комментарий. Дано: пост
Status = Published, CommentsEnabled = true. Когда: User шлёт Body
длиной 1..4000 без ParentCommentId. Тогда: 201, Depth = 0, RootCommentId = CommentId,
Path = "/{CommentId}", статус Visible; Post.CommentsCount вырос на 1; опубликовано comment-added
(ключ PostId) с ParentCommentId = null.
- Ответ в треде. Дано: существующий комментарий того же поста на глубине
d < 6. Когда: User отвечает на
него. Тогда: 201, Depth = d + 1, RootCommentId = parent.RootCommentId ?? parent.Id; событие несёт
ParentCommentId и адрес автора родителя.
- Обсуждение закрыто. Дано:
Post.CommentsEnabled = false или Status != Published. Когда: попытка
комментария. Тогда: 409 NEWS_COMMENTS_DISABLED (INV-CMT-001); узел не создан, событие не опубликовано,
счётчик не изменён.
- Родитель из другого поста. Дано:
ParentCommentId принадлежит другому посту. Когда: ответ. Тогда:
400 NEWS_COMMENT_PARENT_MISMATCH (INV-CMT-002).
- Предел вложенности. Дано:
parent.Depth = 6. Когда: ответ. Тогда: 400 NEWS_COMMENT_TOO_DEEP
(INV-CMT-003).
- Пустой/переполненный текст. Дано:
Body после trim пуст или > 4000. Когда: запрос. Тогда:
400 VALIDATION_FAILED.
- Гость. Дано: запрос без JWT. Когда: комментарий. Тогда:
401.
- Идемпотентный повтор. Дано: успешный запрос с
Idempotency-Key. Когда: повтор с тем же ключом от того же
автора. Тогда: 200 с тем же CommentResult; ровно один узел, один инкремент счётчика, одно событие.
Совместимость и наблюдаемость
- Новые optional поля запроса/ответа — additive; удаление/переименование/смена типа — breaking
(migration + rollout).
CommentResult — стабильный контракт; Depth в диапазоне 0..6.
- Logs:
comment received/created (CommentId, PostId, Depth, AuthorUserId); metrics:
news_comments_added_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard
news-overview (comment rate, дрейф CommentsCount, DLQ); runbook — «drift CommentsCount».
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы
- Сущности:
comment.md (поля, инварианты INV-CMT-001…006, Path/Depth),
post.md (гейт CommentsEnabled INV-NWS-006, денорм. CommentsCount).
- Событие:
comment-added.md (контракт pin.news.comment-added.v1).
- Соседние методы:
GET /api/v1/posts/{id}/comments (дерево комментариев),
DELETE /api/v1/comments/{id} (soft-delete автора/модерация) — карта в README.
- Permission-каталог:
identity/domain/permission.md
(news.comments.moderate).
- ADR: ADR-0052,
ADR-0056.