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

UC-NWS-003. Вложенные переключаемые комментарии

Назначение

Аутентифицированный читатель комментирует опубликованный пост ленты новостей и отвечает на существующие комментарии, строя вложенное дерево (материализованный путь Path, Depth ≤ 6); автор поста переключает приём новых комментариев флагом CommentsEnabled, автор/модератор удаляет узел, не разрушая ветку. Дом полей и инвариантов — comment.md, гейт публикации — post.md (INV-NWS-006); операции — add-comment.md, list-comments.md, toggle-comments.md, delete-comment.md.

Metadata

ПолеЗначение
IDUC-NWS-003
Контекстnews (см. docs/06-services/news/)
Типосновной (+ альтернативы A1 переключение, A2 удаление/скрытие, A3 лимит глубины)
Статусdraft

Актор

РольPermissions / scopeTenantRecord-level
User (комментатор)аутентификация identity; комментирование self-scope (без отдельного permission); guest — 401не применяется (Comment не tenanted, ADR-0052)автор своего узла (AuthorUserId, INV-CMT-004)
Автор поста (переключение/удаление)record-level Post.AuthorUserId или менеджер компании в scope AuthorCompanyId или news.posts.manage (INV-NWS-007)TenantId = Post.AuthorCompanyId (nullable)владелец поста
Оператор (модерация A2)permission news.comments.moderate (m2m Admin, ADR-0053)не применяетсяpermission-гейт
System (потребитель notifications, фоновая сверка comment-count-reconcile)system-scopeне применяется

Предусловие

  • Пост существует, Post.Status = Published и виден в ленте/по прямой ссылке (get-post.md); дерево обсуждения читается публично (list-comments.md).
  • Для добавления: Post.CommentsEnabled = true (гейт INV-NWS-006 / INV-CMT-001) и актор аутентифицирован.
  • Для ответа: родительский Comment существует, принадлежит тому же посту (INV-CMT-002) и его Depth + 1 ≤ MaxDepth (6) (INV-CMT-003).

Триггер

ПолеЗначение
ИсточникUI button (форма комментария/ответа под постом) → API call
Триггерящий элементPOST /api/v1/posts/{id}/commentsadd-comment.md; переключение — POST /api/v1/posts/{id}/comments-settings; удаление — DELETE /api/v1/comments/{id}

Основной Поток

Нить — добавление корневого комментария или ответа под открытым к комментированию постом.

  1. Открытие поста и дерева. Читатель открывает пост и запрашивает обсуждение GET /api/v1/posts/{id}/comments (list-comments.md): keyset-страница корневых комментариев, ветки собраны по Path, плейсхолдеры удалённых/скрытых узлов сохраняют связность. Просмотр трекает фронт → user-activity (ADR-0054).
  2. Отправка комментария/ответа. Форма отправляет тело { parentCommentId?, body }; AuthorUserId берётся из контекста актора (не из тела); опционально клиент передаёт Idempotency-Key для защиты от двойной отправки.
  3. Валидация и гейт. Проверить body: 1..4000 символов после Trim — иначе VALIDATION_FAILED. Пост существует — иначе NEWS_POST_NOT_FOUND. Гейт INV-NWS-006 / INV-CMT-001: Post.Status == Published ∧ Post.CommentsEnabled == true, иначе NEWS_COMMENTS_DISABLED (A1). При ответе (parentCommentId != null): родитель существует и принадлежит тому же посту (INV-CMT-002, иначе NEWS_COMMENT_PARENT_MISMATCH); parent.Depth + 1 ≤ MaxDepth(6) (INV-CMT-003, иначе NEWS_COMMENT_TOO_DEEPA3).
  4. Построение узла. Комментарий создаётся со Status = Visible (единственное стартовое состояние); идентификатор хронологически упорядочен в треде:
    • корневой (parent == null): Depth = 0, RootCommentId = Id, Path = "/{Id}";
    • ответ: Depth = parent.Depth + 1, RootCommentId = parent.RootCommentId ?? parent.Id, Path = parent.Path + "/{Id}".
  5. Счётчик и событие. Денормализованный Post.CommentsCount инкрементируется в том же согласованном изменении (INV-CMT-005). Публикуется интеграционное событие pin.news.comment-added.v1 (ContractVersion = 1, ключ партиционирования = PostId) — атомарно с созданием комментария.
  6. Результат и уведомления. Ответ { CommentId, PostId, ParentCommentId?, Depth, CreatedAt } → HTTP 201 (idempotency-replay — 200). Потребитель notifications по comment-added уведомляет автора поста и автора родительского комментария. Фронт перечитывает дерево — новый узел в своей ветке.

Диаграмма

Альтернативные Потоки

A1. Автор выключил комментарии (переключение)

  1. Автор поста вызывает POST /api/v1/posts/{id}/comments-settings { enabled: false } (toggle-comments.md): Post.CommentsEnabled = false, статус поста и дерево комментариев не трогаются, событий нет.
  2. Существующие узлы остаются Visible и читаемы; добавление новых блокируется гейтом основного потока (шаг 3) → NEWS_COMMENTS_DISABLED (409).
  3. Повторное открытие — { enabled: true } (идемпотентно). Возврат к основному потоку.

A2. Автор/модератор удаляет комментарий (ветка сохраняется)

  1. DELETE /api/v1/comments/{id} (delete-comment.md); ветвление record-level (INV-CMT-004): ActorUserId == Comment.AuthorUserId → авторская ветка; иначе — permission news.comments.moderate; ни то ни другое ⇒ FORBIDDEN.
  2. Переход Visible → … (guard INV-CMT-006 no-resurrect): автор — Status = DeletedByAuthor, DeletedAt = now; модератор — Status = HiddenByModerator (Reason: CommentModerationReason), DeletedAt пуст. Узел остаётся плейсхолдером: Body/подпись при рендере скрыты, дети по Path/RootCommentId не «отрываются». Post.CommentsCount-- (INV-CMT-005).
  3. Интеграционных событий нет (осознанно — удаление/скрытие в Kafka не публикуется). 204 No Content; терминальный повтор идемпотентен. Ответы на плейсхолдер по-прежнему добавляются (гейт по посту, не по узлу).

A3. Превышение глубины вложенности

  1. Ответ на комментарий с Depth = 6: parent.Depth + 1 = 7 > MaxDepth(6) (INV-CMT-003).
  2. Политика UC-NWS-003 — отклонение (не тихое прикрепление к предку): NEWS_COMMENT_TOO_DEEP (400); узел не создаётся, счётчик/событие не меняются.
  3. Клиент показывает «Достигнут предел вложенности ответов» и предлагает ответить в корень треда.

A4. Ответ на параллельно удалённый/скрытый родитель

  1. Пользователь отвечает на комментарий, который в этот момент переводится автором/модератором в DeletedByAuthor/HiddenByModerator. Узел-плейсхолдер не исчезает (удаление не рушит ветку, A2): его Path/Depth/RootCommentId валидны, поэтому ответ штатно прикрепляется к плейсхолдеру — гейт по посту (Published ∧ CommentsEnabled), а не по состоянию родительского узла.
  2. Обратного перехода плейсхолдера в Visible не бывает (INV-CMT-006 no-resurrect) — «гонки воскрешения» нет; новый ответ создаётся как Visible, CommentsCount++, событие comment-added публикуется штатно. Дерево остаётся связным (плейсхолдер-предок + новый видимый потомок). Завершается успешно.

Постусловие

  • Создан Comment со Status = Visible под PostId — узел вложенного дерева (Path, Depth ≤ 6, RootCommentId); читается GET /api/v1/posts/{id}/comments в своей ветке.
  • Post.CommentsCount инкрементирован (денорм. для отображения/сортировки); ночная сверка comment-count-reconcile контролирует и страхует дрейф (INV-CMT-005).
  • Опубликован pin.news.comment-added.v1только при создании Visible-узла (не при удалении/скрытии/ переключении); notifications уведомил автора поста и автора родителя.
  • A1: Post.CommentsEnabled = false — существующие комментарии видны, новые запрещены гейтом. A2: узел — плейсхолдер (DeletedByAuthor/HiddenByModerator), дети целы, CommentsCount--. A3: изменений нет.

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
VALIDATION_FAILED400body вне 1..4000 / пустой PostId / недопустимый ReasonПоказать ошибку формы
NEWS_POST_NOT_FOUND404пост не найден / не Published / soft-deleted (либо родитель не найден)«Пост не найден»
NEWS_COMMENTS_DISABLED409Post.Status != PublishedCommentsEnabled == false (A1, INV-CMT-001)«Комментарии к посту отключены»
NEWS_COMMENT_PARENT_MISMATCH400parent.PostId != PostId (INV-CMT-002)«Родительский комментарий из другого поста»
NEWS_COMMENT_TOO_DEEP400parent.Depth + 1 > 6 (A3, INV-CMT-003)«Достигнут предел вложенности ответов»
NEWS_COMMENT_NOT_FOUND404удаляемый комментарий не найден (A2)«Комментарий не найден»
FORBIDDEN403не автор узла и без news.comments.moderate (A2, INV-CMT-004); переключение без прав на пост (A1, INV-NWS-007)«Недостаточно прав»
ConcurrencyConflict409конкурентный переход статуса одного узлаРетрай (перечитать состояние узла)
NEWS_COMMENT_CURSOR_INVALID400курсор дерева не парсится / чужая версия курсора (чтение)Ретрай без cursor (первая страница)

Доменные События

СобытиеКогда поднимаетсяЭффект
CommentCreatedDomainEventСоздание узла ([*] → Visible)инкремент CommentsCount (INV-CMT-005) + публикация pin.news.comment-added.v1 в том же согласованном изменении
CommentRemovedDomainEventVisible → DeletedByAuthor/HiddenByModerator (A2)декремент CommentsCount; интеграционного события нет

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
CommentAddedKafkaEventpin.news.comment-added.v1Только при создании Visible-узла ([*] → Visible); A1/A2/A3 — no-opnotifications (автор поста + автор родителя)

Payload { CommentId, PostId, ParentCommentId?, RootCommentId, AuthorUserId, Depth, CreatedAt }, ContractVersion = 1, ключ = PostId (per-post порядок); публикуется атомарно с созданием комментария. Дом контракта — comment-added.md. Переключение CommentsEnabled и удаление/скрытие комментария интеграционных событий не порождают.

Acceptance Criteria

#КритерийПроверка
AC1Гейт: комментарий создаётся только при Post.Status = Published ∧ CommentsEnabled = true; иначе NEWS_COMMENTS_DISABLEDIntegration-тест add-comment: пост Draft/UnpublishedCommentsEnabled=false → 409
AC2Вложенность: Depth = parent.Depth + 1, ≤ 6; глубже → NEWS_COMMENT_TOO_DEEP, узел не создаётся (A3)Тест: цепочка ответов до Depth=6; 7-й ответ → 400, новая строка не появляется
AC3Удаление не рушит ветку: DeletedByAuthor/HiddenByModerator — плейсхолдер (Body/подпись null), дети по Path сохраняются и читаютсяТест A2: удалить родителя с детьми → list-comments отдаёт плейсхолдер + детей
AC4Счётчик консистентен: CommentsCount++ при Visible-создании, -- при удалении/скрытии; сверка comment-count-reconcile контролирует дрейфТест: создать 3 + удалить 1 → CommentsCount = 2; прогон comment-count-reconcile не меняет значение
AC5Событие comment-added — только на создание; уведомляет автора поста и автора родителя; не на delete/toggleТест: одно событие pin.news.comment-added.v1 на создание; при A2/A1 событие не публикуется
AC6Переключение: enabled=false не трогает существующие узлы (видны), блокирует только новые; события нетТест A1: toggle → старые Visible читаются, POST comment → 409, событие не публикуется
AC7Дерево: list-comments собирает ветки по Path (keyset корневых), плейсхолдеры без Body/подписиТест list-comments: порядок DFS по Path, маскирование не-Visible узлов
AC8Ответ на параллельно удалённый родитель (A4) прикрепляется к плейсхолдеру (ветка цела, no-resurrect INV-CMT-006); ответ создаётся Visible, счётчик/событие штатныКонкурентный тест: удалить родителя и одновременно добавить ответ → ответ виден под плейсхолдером

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

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

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

  • Операции: add-comment.md, list-comments.md, toggle-comments.md, delete-comment.md; чтение поста — get-post.md.
  • Domain: comment.md (CommentStatus, Path/Depth/RootCommentId, INV-CMT-001…006), post.md (гейт CommentsEnabled INV-NWS-006, денорм. CommentsCount, record-level INV-NWS-007), identity-replicas.md (подписи авторов дерева).
  • Kafka: comment-added.md (pin.news.comment-added.v1); потребитель — docs/06-services/notifications/ (будущий путь по конвенции); фоновая сверка — comment-count-reconcile (README, раздел Jobs).
  • Permission-каталог: permission.md (news.comments.moderate, news.posts.manage).
  • ADR: ADR-0052 (tenancy, публичный UGC), ADR-0053 (модерация m2m), ADR-0054 (просмотры → user-activity), ADR-0056 (материализованный Path без ltree).