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

POST /api/v1/posts/{id}/comments — добавить комментарий/ответ

Назначение

Принять от аутентифицированного пользователя текст комментария (корневого либо ответа на существующий комментарий), построить узел вложенного дерева (Path/Depth/RootCommentId), инкрементировать денормализованный Post.CommentsCount и опубликовать интеграционное событие comment-added для уведомления автора поста и автора родительского комментария.

Metadata

ПолеЗначение
Сервис/контекстnews
API areapublic
Feature groupcomments
Статусdraft
Документdocs/06-services/news/api/public/comments/add-comment.md

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

ПроверкаЗначение
Actor typeUser (аутентифицированный пользователь; guest — 401)
Auth policy / PermissionsJWT identity; отдельного permission нет — комментирование self-scope (README «Комментарии/подписки — self-scope»)
Scope (RBAC)не применяется (публичный UGC под опубликованным постом)
Record-levelAuthorUserId = actor.UserId (стамп автора; авторские операции над узлом — по нему, INV-CMT-004)
Tenant isolationне применяется — Comment не tenanted (comment.md, ADR-0052 п. публичного UGC)
Auditcreate: CreatedByUserId = AuthorUserId; Body — публичный UGC, PII нет

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/posts/{id}/comments
Success status201 (создан) / 200 (идемпотентный повтор при совпадающем Idempotency-Key)
Idempotency headerIdempotency-Key (опц.); при отсутствии — обычное создание
Correlationсквозной trace через traceparent

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

Единая секция: HTTP-источник + типизированное поле request-модели AddCommentCommand.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PostIdroute (id)GuidДа!= Guid.Empty; существующий Post (шаг 2)
ParentCommentIdbodyGuid?Нетnull — корневой; иначе существующий Comment того же поста (шаг 2)
BodybodystringДа1..4000 после Trim, не пустоUGC; sanitize при рендере (XSS)
IdempotencyKeyheader (Idempotency-Key)string?Нет<= 128, если задан
AuthorUserIdidentity claimsGuidДаиз JWT акторастамп автора, не из тела

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

ПолеТипОбязательностьИсточникMaskingОписание
CommentIdGuidДаComment.Id (v7)Идентификатор созданного комментария.
PostIdGuidДаComment.PostIdПост-владелец обсуждения.
ParentCommentIdGuid?НетComment.ParentCommentIdnull — корневой комментарий.
DepthshortДаComment.Depth (0..6)Уровень вложенности узла.
CreatedAtDateTimeOffsetДаComment.CreatedAtМомент создания (совпадает с v7-Id).

Алгоритм

  1. Контекст и доступ. Комментирует авторизованный пользователь (User); гость → 401. Отдельного permission нет — комментирование self-scope; автор фиксируется из JWT (AuthorUserId). Если задан Idempotency-Key — при повторе ранее завершённого запроса возвращается сохранённый результат с 200.
  2. Проверка входа и предусловий. 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).
  3. Создание узла. Создать 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 — момент создания.
  4. Согласованность и событие. Новый комментарий, инкремент денормализованного 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.
  5. Результат. CommentResult { CommentId, PostId, ParentCommentId, Depth, CreatedAt }201 (при idempotency-replay — 200).

Диаграмма

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

ТипДетали
Integration eventsсобытие comment-addedpin.news.comment-added.v1 (key = PostId, ContractVersion = 1)
External callsnone (гейт и родитель читаются из своей БД)
Cache / projections / searchденорм. Post.CommentsCount инкремент (та же tx); reconcile-job страхует дрейф
Notificationsопосредованно: notifications — консюмер comment-added уведомляет автора поста и автора родительского комментария

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Body вне 1..4000 / пустой PostId«Проверьте текст комментария»no retry
NEWS_POST_NOT_FOUND404пост либо родительский комментарий не найден«Пост не найден»no retry
NEWS_COMMENTS_DISABLED409Post.Status != PublishedCommentsEnabled == false (INV-CMT-001)«Комментарии к посту отключены»no retry
NEWS_COMMENT_PARENT_MISMATCH400parent.PostId != PostId (INV-CMT-002)«Родительский комментарий из другого поста»no retry
NEWS_COMMENT_TOO_DEEP400parent.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.