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).
| Поле | Значение |
|---|
| Сервис/контекст | news |
| API area | public |
| Feature group | comments |
| Статус | draft |
| Owner | PIN Engagement |
| Документ | docs/06-services/news/api/public/comments/list-comments.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Anonymous (публичное чтение) / User (аутентифицированный — та же проекция) |
| Auth policy / Permissions | none — публичный endpoint; тело скрытых/удалённых узлов не отдаётся |
| Tenant isolation | не применяется — комментарии не tenanted (публичный UGC под опубликованным постом, comment.md) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/posts/{id}/comments |
| Success status | 200 |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
PostId | route ({id}) | Guid | Да | Required, != Guid.Empty; пост существует и Published (шаг 2) | — |
Cursor | query | string? | Нет | base64url CommentThreadCursor (ContractVersion = 1, LastRootCreatedAt, LastRootId); сбой парсинга → NEWS_COMMENT_CURSOR_INVALID (шаг 1) | не секрет; подмена = другая страница корневых |
Limit | query | int | Нет (default 30) | 1..50 | — |
RootCommentId | query | Guid? | Нет | если задан — догрузка одной ветки (Cursor игнорируется); корень не из этого поста → пустая выдача, не ошибка (без утечки) | — |
Контейнер курсорной страницы: Items — корневые узлы страницы; NextCursor — keyset следующей страницы
корневых (null — тред исчерпан); HasMore — есть ли ещё корневые.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
CommentId | Guid | Да | Comment.Id | — | Идентификатор узла (v7 — хронологический). |
ParentCommentId | Guid? | Нет | Comment.ParentCommentId | — | null — корневой узел. |
AuthorDisplayName | string? | Нет | AuthorReplica.DisplayName по AuthorUserId | null для не-Visible | Подпись автора; у плейсхолдера скрыта. |
Body | string? | Нет | Comment.Body | null для HiddenByModerator/DeletedByAuthor | Текст (sanitized при рендере); у плейсхолдера — null. |
Status | CommentStatus (string) | Да | Comment.Status | — | Visible/HiddenByModerator/DeletedByAuthor (comment.md). |
StatusLabel | string? | Нет | computed по Status | — | Метка плейсхолдера («Скрыто модератором»/«Удалено автором»); null для Visible. |
Depth | short | Да | Comment.Depth | — | Уровень вложенности 0..6. |
CreatedAt | DateTimeOffset | Да | Comment.CreatedAt | — | Момент создания. |
Children | IReadOnlyList<CommentNode> | Да (может быть пустым) | сборка по Path/ParentCommentId | — | Загруженные ответы (DFS по Path). |
ChildrenCount | int | Да | число прямых детей | — | Всего прямых ответов узла. |
HasMoreChildren | bool | Да | computed | — | true — ветка загружена не полностью (упор в MaxDepth либо RootCommentId-догрузка): подгрузить через RootCommentId = CommentId. |
Алгоритм
- Контекст и доступ. Публичное чтение: актор
Anonymous либо User (на проекцию не влияет);
tenant-контекст не устанавливается (публичный UGC). Если задан RootCommentId — режим догрузки одной
ветки (Cursor игнорируется); иначе — keyset-страница корневых веток. При разборе Cursor: некорректный
base64url или чужой ContractVersion → ошибка NEWS_COMMENT_CURSOR_INVALID (400).
- Проверка входа и предусловий.
PostId непустой; Limit в [1..50] (default 30) — иначе
VALIDATION_ERROR (400). Пост существует и опубликован (Status = Published, не soft-deleted) — иначе
NEWS_POST_NOT_FOUND (404): черновик/снятый/архивный/несуществующий пост обсуждение не раскрывает (дом
статуса — post.md). RootCommentId, не принадлежащий посту, → пустая выдача
(без утечки), не ошибка.
- Что читаем (индекс
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);
плейсхолдерам подпись не нужна.
- Сборка дерева и маскирование. Мутаций/переходов нет. Дерево собирается из плоского списка по
ParentCommentId (порядок Path гарантирует, что родитель встречен раньше ребёнка). Маппинг поле-в-поле
по таблице CommentNode: для Status != Visible → Body = null, AuthorDisplayName = null,
StatusLabel = метка по статусу; для Visible → AuthorDisplayName — подпись из реплики автора,
StatusLabel = null. ChildrenCount — число прямых детей узла в выборке; HasMoreChildren = true, если
ветка обрезана (упор в MaxDepth или частичная RootCommentId-догрузка).
- Результат. При режиме корней и
HasMore == true NextCursor собирается из последнего корневого узла
страницы (CommentThreadCursor { ContractVersion = 1, LastRootCreatedAt, LastRootId } → base64url); иначе
null. Возвращается страница корневых узлов с NextCursor и HasMore (HTTP 200); ошибки — 400/404.
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
VALIDATION_ERROR | 400 | PostId == Guid.Empty, Limit вне [1..50] | «Некорректные параметры запроса» | no retry |
NEWS_COMMENT_CURSOR_INVALID | 400 | Курсор не парсится / чужой ContractVersion | «Курсор устарел — обновите обсуждение» | retry без cursor (первая страница) |
NEWS_POST_NOT_FOUND | 404 | Пост не существует / не Published / soft-deleted | «Пост не найден» | no retry |
INTERNAL_ERROR | 500 | Инфраструктурный сбой | generic | retry (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 > 30 — HasMore = 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.