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

GET /api/v1/posts/{id}/comments — Дерево комментариев поста

Назначение

Публичное чтение обсуждения под постом ленты новостей: вложенное переключаемое дерево комментариев с keyset-подгрузкой корневых веток и точечной догрузкой одной ветки. Материализованный путь Path даёт ветку одним индексным сканом по ix_comments_post_thread (post_id, root_comment_id, path); глубина ограничена Depth ≤ MaxDepth(6) (INV-CMT-003). Терминальные узлы (HiddenByModerator/DeletedByAuthor) остаются в дереве плейсхолдерами без Body, чтобы ответы не «оторвались» (INV-CMT-006).

Metadata

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

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

ПроверкаЗначение
Actor typeAnonymous (публичное чтение) / User (аутентифицированный — та же проекция)
Auth policy / Permissionsnone — публичный endpoint; тело скрытых/удалённых узлов не отдаётся
Tenant isolationне применяется — комментарии не tenanted (публичный UGC под опубликованным постом, comment.md)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/posts/{id}/comments
Success status200
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
PostIdroute ({id})GuidДаRequired, != Guid.Empty; пост существует и Published (шаг 2)
Cursorquerystring?Нетbase64url CommentThreadCursor (ContractVersion = 1, LastRootCreatedAt, LastRootId); сбой парсинга → NEWS_COMMENT_CURSOR_INVALID (шаг 1)не секрет; подмена = другая страница корневых
LimitqueryintНет (default 30)1..50
RootCommentIdqueryGuid?Нетесли задан — догрузка одной ветки (Cursor игнорируется); корень не из этого поста → пустая выдача, не ошибка (без утечки)

Модель ответа (курсорная страница CommentNode)

Контейнер курсорной страницы: Items — корневые узлы страницы; NextCursor — keyset следующей страницы корневых (null — тред исчерпан); HasMore — есть ли ещё корневые.

Вложенная модель CommentNode (проекция comment.md)

ПолеТипОбязательностьИсточникMaskingОписание
CommentIdGuidДаComment.IdИдентификатор узла (v7 — хронологический).
ParentCommentIdGuid?НетComment.ParentCommentIdnull — корневой узел.
AuthorDisplayNamestring?НетAuthorReplica.DisplayName по AuthorUserIdnull для не-VisibleПодпись автора; у плейсхолдера скрыта.
Bodystring?НетComment.Bodynull для HiddenByModerator/DeletedByAuthorТекст (sanitized при рендере); у плейсхолдера — null.
StatusCommentStatus (string)ДаComment.StatusVisible/HiddenByModerator/DeletedByAuthor (comment.md).
StatusLabelstring?Нетcomputed по StatusМетка плейсхолдера («Скрыто модератором»/«Удалено автором»); null для Visible.
DepthshortДаComment.DepthУровень вложенности 0..6.
CreatedAtDateTimeOffsetДаComment.CreatedAtМомент создания.
ChildrenIReadOnlyList<CommentNode>Да (может быть пустым)сборка по Path/ParentCommentIdЗагруженные ответы (DFS по Path).
ChildrenCountintДачисло прямых детейВсего прямых ответов узла.
HasMoreChildrenboolДаcomputedtrue — ветка загружена не полностью (упор в MaxDepth либо RootCommentId-догрузка): подгрузить через RootCommentId = CommentId.

Алгоритм

  1. Контекст и доступ. Публичное чтение: актор Anonymous либо User (на проекцию не влияет); tenant-контекст не устанавливается (публичный UGC). Если задан RootCommentId — режим догрузки одной ветки (Cursor игнорируется); иначе — keyset-страница корневых веток. При разборе Cursor: некорректный base64url или чужой ContractVersion → ошибка NEWS_COMMENT_CURSOR_INVALID (400).
  2. Проверка входа и предусловий. PostId непустой; Limit в [1..50] (default 30) — иначе VALIDATION_ERROR (400). Пост существует и опубликован (Status = Published, не soft-deleted) — иначе NEWS_POST_NOT_FOUND (404): черновик/снятый/архивный/несуществующий пост обсуждение не раскрывает (дом статуса — post.md). RootCommentId, не принадлежащий посту, → пустая выдача (без утечки), не ошибка.
  3. Что читаем (индекс ix_comments_post_thread (post_id, root_comment_id, path)):
    • Корневые узлы (RootCommentId == null): keyset-страница корней по (CreatedAt, Id) после курсора, размер Limit (плюс одна пробная строка — определить HasMore, затем отбросить); тай-брейк — Id.
    • Догрузка одной ветки (RootCommentId != null): выбирается ветка этого корня; keyset корней и HasMore не применяются (NextCursor = null).
    • Узлы веток берутся по посту и корню, сортировка по Path (порядок родитель→дети, DFS). Глобальный soft-delete-фильтр к чтению треда не применяется: узлы Visible/HiddenByModerator/DeletedByAuthor берутся все (плейсхолдеры сохраняют связность, INV-CMT-006); Depth ≤ MaxDepth(6).
    • Подписи авторов видимых узлов добираются батчем из локальной реплики AuthorReplica (без N+1 и без синхронного запроса в identity, identity-replicas.md); плейсхолдерам подпись не нужна.
  4. Сборка дерева и маскирование. Мутаций/переходов нет. Дерево собирается из плоского списка по ParentCommentId (порядок Path гарантирует, что родитель встречен раньше ребёнка). Маппинг поле-в-поле по таблице CommentNode: для Status != VisibleBody = null, AuthorDisplayName = null, StatusLabel = метка по статусу; для VisibleAuthorDisplayName — подпись из реплики автора, StatusLabel = null. ChildrenCount — число прямых детей узла в выборке; HasMoreChildren = true, если ветка обрезана (упор в MaxDepth или частичная RootCommentId-догрузка).
  5. Результат. При режиме корней и HasMore == true NextCursor собирается из последнего корневого узла страницы (CommentThreadCursor { ContractVersion = 1, LastRootCreatedAt, LastRootId } → base64url); иначе null. Возвращается страница корневых узлов с NextCursor и HasMore (HTTP 200); ошибки — 400/404.

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_ERROR400PostId == Guid.Empty, Limit вне [1..50]«Некорректные параметры запроса»no retry
NEWS_COMMENT_CURSOR_INVALID400Курсор не парсится / чужой ContractVersion«Курсор устарел — обновите обсуждение»retry без cursor (первая страница)
NEWS_POST_NOT_FOUND404Пост не существует / не Published / soft-deleted«Пост не найден»no retry
INTERNAL_ERROR500Инфраструктурный сбойgenericretry (backoff)

Совместимость и наблюдаемость

  • Новые optional-поля CommentNode и параметры запроса — additive; смена default Limit, семантики keyset или формата курсора — breaking (bump ContractVersion курсора + rollout).
  • Enum CommentStatus — строкой; клиентский fallback на unknown обязателен (additive-значения статусов).
  • Logs: postId, rootCommentId?, размер страницы корней, HasMore (тело/подписи не логируются); metrics: news_comments_list_requests_total{branch=root|thread}, latency histogram (SLO чтение p99 < 200 мс, README); traces — сквозной span news.list-comments через traceparent.
  • Dashboard/runbook — news-overview (README.md).

Acceptance Criteria

  • Первая страница корней. Дано: Published-пост с N корневыми комментариями. Когда: GET без cursor, limit = 30. Тогда: 200, до 30 корней в порядке (CreatedAt, Id), у каждого загружены дети (DFS по Path); при N > 30HasMore = true и непустой NextCursor из последнего корня.
  • Догрузка ветки. Дано: известен RootCommentId. Когда: GET ?rootCommentId=.... Тогда: возвращается эта ветка (Cursor игнорируется), NextCursor = null, keyset корней не применяется.
  • Плейсхолдеры. Дано: в ветке есть узел HiddenByModerator/DeletedByAuthor. Когда: чтение дерева. Тогда: узел присутствует с Body = null, AuthorDisplayName = null, заполненным StatusLabel; его дети остаются прикреплёнными (INV-CMT-006), связность не рвётся.
  • Пост недоступен. Дано: пост Draft/Unpublished/Archived/soft-deleted или не существует. Когда: GET. Тогда: 404 NEWS_POST_NOT_FOUND — обсуждение не раскрывается.
  • Битый курсор. Дано: cursor не парсится или чужой ContractVersion. Когда: GET. Тогда: 400 NEWS_COMMENT_CURSOR_INVALID; повтор без cursor даёт первую страницу.
  • Некорректный лимит. Дано: Limit вне [1..50]. Когда: GET. Тогда: 400 VALIDATION_ERROR.
  • Чужой корень. Дано: RootCommentId не принадлежит посту. Когда: GET. Тогда: пустая выдача (не ошибка, без утечки существования).
  • Предел глубины. Дано: ветка глубже MaxDepth(6). Когда: чтение. Тогда: узлы за пределом не отдаются, у граничного узла HasMoreChildren = true (догрузка через RootCommentId = CommentId).

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

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

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

  • Сущности/проекции: comment.md (поля, CommentStatus, INV-CMT-003/006, ix_comments_post_thread), identity-replicas.md (AuthorReplica.DisplayName), post.md (статус Published).
  • Соседние операции: POST /api/v1/posts/{id}/comments (добавить/ответить), DELETE /api/v1/comments/{id} (soft) — карта area в README.md.