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

GET /api/messenger/chats/{chatId}/messages — История чата (keyset-пагинация)

Назначение

Отдаёт участнику страницу ленты сообщений чата — text / attachment / event-card — упорядоченную по монотонному per-chat Seq, keyset-пагинацией «прокрутка вверх» (курсор по Seq, а не offset). Только чтение проекции сообщения (message.md); скрытая история правок (MessageEditEntity) и Sensitive IpAddress/UserAgent не отдаются (INV-MSG-2, кроме admin-арбитража); per-participant видимость event-карточек применяется на чтении (INV-MSG-5). Вызывает участник чата для первичной загрузки и подгрузки «вверх» (realtime-новые приходят через хаб; при переподключении клиент перезапрашивает REST).

Metadata

ПолеЗначение
Сервис/контекстmessenger
API areamessages
Feature groupmessages
Статусdraft
Документdocs/06-services/messenger/api/messages/list-messages.md

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

ПроверкаЗначение
Actor typeUser (Инвестор / Агент / Руководитель / Менеджер застройщика / Менеджер ПИН)
Auth policy / Permissionsauthenticated (JWT) + permission messenger.chats.view
Scope (RBAC)не применяется: доступ по членству, не по scope (ADR-0055/0052)
Record-levelактивный ParticipantEntity (ChatId,UserId = actor, RemovedAt is null) — INV-CHAT-4
Tenant isolationне tenanted: чаты кросс-сторонние; изоляция — членство (chat.md)
Auditread (не sensitive наружу): IpAddress/UserAgent и история правок из ответа исключены

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/messenger/chats/{chatId}/messages
Success status200
Idempotency headerне применяется (read-only, безопасный метод)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ChatIdrouteGuidДа!= Guid.Empty; существующий чат; actor — активный участник (см. Алгоритм)
BeforeSeqquerylong?Нетkeyset-курсор: вернуть сообщения с Seq < BeforeSeq (страница «старее»); null — от последних; >= 1
PageSizequeryintНетdefault 30, 1..100защита от unbounded
UserIdidentity claimsGuidДаRequired; предикат членства + фильтр видимости карточек (INV-MSG-5)scope isolation

Keyset (не offset) обоснован высокой объёмностью ленты и стабильностью курсора при дозаписи (ADR-0056); Seq монотонен по SentAt, поэтому упорядочение по времени и по Seq совпадают.

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

Контейнер keyset-страницы (offset здесь неуместен). Дом полей — message.md; ниже — проекция строки ленты (union через Kind + типизированные nullable-подмодели).

ПолеТипОбязательностьИсточникMaskingОписание
ItemsIReadOnlyList<MessageModel>Дапроекция MessageEntityСтраница, порядок Seq desc (новые→старые); клиент разворачивает для показа.
NextBeforeSeqlong?Нетmin(Items.Seq)Курсор для следующей страницы «вверх»; null — достигнуто начало.
HasMoreboolДаcomputedЕсть ли ещё более старые сообщения.

MessageModel — общие поля + одна непустая под-модель по Kind:

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаMessageEntity.IdИдентификатор сообщения.
SeqlongДаSeqПорядок в ленте / курсор.
Kindstring (enum MessageKind)ДаKindText / Attachment / EventCard.
SenderUserIdGuid?НетSenderUserIdАвтор; null — системная карточка.
SenderRolestring? (enum ParticipantRole)НетSenderRoleРоль автора на момент отправки.
IsSystemboolДаIsSystemtrue — event-card/системное.
SentAtDateTimeOffsetДаSentAtМомент отправки.
EditedAtDateTimeOffset?НетEditedAtМомент последней правки (флаг «изменено»).
IsDeletedboolДаDeletedAt != nullтело скрытоУдалено → тело/подпись не отдаются («сообщение удалено»).
EditableUntilDateTimeOffset?НетEditableUntilГраница окна правки для автора (UI).
TextMessageTextModel?НетTextMessageEntity{ Body: string }; null, если не Text или удалено.
AttachmentMessageAttachmentModel?НетAttachmentMessageEntity{ FileId, FileName, ContentType, SizeBytes, Caption?, AttachmentKind, DownloadUrl } (presign, см. Алгоритм).
CardMessageCardModel?НетEventCardMessageEntity + recipient{ CardType, Category, ActionStatus, Text, DealId? }; Text = TextOverride ?? DefaultText.

IpAddress/UserAgent и MessageEditEntity в модель не попадают (Sensitive/internal, INV-MSG-6).

Алгоритм

  1. Контекст и доступ. Операцию выполняет пользователь; требуется право messenger.chats.view (иначе — FORBIDDEN; без JWT — UNAUTHORIZED). ChatId берётся из маршрута, BeforeSeq/PageSize — из query.
  2. Проверка входа и предусловий. ChatId непуст, BeforeSeq >= 1 (если задан), PageSize в 1..100 — иначе VALIDATION_FAILED. Актор должен быть активным участником чата — иначе MSG_CHAT_ACCESS_DENIED (существование чата отдельно не раскрывается — тот же 403). Статус чата чтение не ограничивает (историю Closed-чата читать можно).
  3. Что читаем. Keyset-выборка сообщений чата: Seq < BeforeSeq (если задан), сортировка Seq desc, берём PageSize + 1 строк (лишняя определяет HasMore). Индекс ux_messages_chat_seq (ChatId,Seq) покрывает keyset и порядок; для фильтра видимости карточек подгружаются recipient-строки.
  4. Маппинг и видимость. NextBeforeSeq = min(Seq) страницы. Фильтр видимости карточек (INV-MSG-5): нет recipient-строк ⇒ видно всем (DefaultText); иначе строка UserId с IsVisible = true, Text = TextOverride ?? DefaultText; невидимые карточки исключаются. Удалённые (DeletedAt != null): IsDeleted = true, тело/подпись не отдаются (строка остаётся ради Seq). Для Attachment — presign download-URL у storage-адаптера по FileId (короткоживущий, не логируется). IP/UA и история правок исключены.
  5. Результат. MessagePageModel { Items (Seq desc), NextBeforeSeq, HasMore }, 200 (пустой Items для пустого чата — тоже 200).

Диаграмма

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

ТипДетали
Внешние вызовыstorage/CDN presign download-URL для Attachment (короткий TTL; FileId/URL не логируются)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400пустой ChatId, BeforeSeq < 1, PageSize вне 1..100«Проверьте параметры запроса»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»no retry
FORBIDDEN403нет permission messenger.chats.view«Недостаточно прав»no retry
MSG_CHAT_ACCESS_DENIED403actor — не активный участник (или чат не существует, единый ответ)«Нет доступа к чату»no retry

Acceptance Criteria

  • Первая страница. Дано активный участник и BeforeSeq не задан. Когда GET. Тогда 200 с PageSize последних сообщений в порядке Seq desc, NextBeforeSeq = min(Seq), HasMore отражает наличие более старых.
  • Подгрузка вверх. Дано задан BeforeSeq. Когда GET. Тогда возвращаются сообщения Seq < BeforeSeq; при достижении начала NextBeforeSeq = null, HasMore = false.
  • Видимость event-карточек (INV-MSG-5). Дано EventCard c recipient-строками. Когда читает получатель. Тогда Text = TextOverride ?? DefaultText; невидимые для актора карточки исключены; при отсутствии recipient-строк карточка видна всем.
  • Удалённое сообщение. Дано soft-deleted сообщение. Когда GET. Тогда IsDeleted = true, Text/Attachment = null, Seq сохранён (строка остаётся ради порядка).
  • Вложение. Дано Attachment. Когда GET. Тогда DownloadUrl — короткоживущий presign у storage-адаптера (не логируется).
  • Закрытый чат. Дано чат Closed. Когда GET. Тогда история читается (статус чтение не ограничивает).
  • Не участник / несуществующий чат. Дано актор не активный участник (или чата нет). Когда GET. Тогда MSG_CHAT_ACCESS_DENIED (403) единым ответом — существование чата не раскрывается.
  • Валидация. Дано PageSize вне 1..100 или BeforeSeq < 1. Когда GET. Тогда VALIDATION_FAILED (400).
  • Приватность. IpAddress/UserAgent и история правок (MessageEditEntity) в ответ не входят (INV-MSG-6).

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

  • Новые optional поля MessageModel/под-моделей — additive без breaking change; удаление/переименование/ смена типа поля или значения enum (MessageKind/ParticipantRole) — breaking (migration + rollout).
  • Logs: chat-messages-listed (ChatId, UserId, PageSize, count; тела/FileId/IP/UA не пишутся); metrics: messenger_messages_list_total{result}, latency p95 (SLO списков < 500 мс); traces через traceparent; dashboard pin-messenger.

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

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

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