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

POST /api/messenger/chats/{chatId}/read — Отметить чат прочитанным

Назначение

Отметить чат (ChatEntity) прочитанным до сообщения UpToMessageId для актора- участника: продвинуть монотонный курсор Participant.LastReadSeq (GREATEST, INV-PART-2) и LastReadAt, обнулив/уменьшив непрочитанные. Порождает read-receipt собеседнику в Direct (push read_receipt_updated, ADR-0055).

Metadata

ПолеЗначение
Сервис/контекстmessenger
API areachats
Feature groupchats
Статусdraft
Документdocs/06-services/messenger/api/chats/mark-chat-read.md

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

ПроверкаЗначение
Actor typeUser (активный участник чата)
Auth policy / Permissionspermission messenger.chats.view — каталог ../../../identity/domain/permission.md
Scope (RBAC)не применяется — доступ по членству (ADR-0052 п.3)
Record-levelактивный ParticipantEntity (UserId = actor, RemovedAt is null, INV-CHAT-4); не-участник → MESSENGER_CHAT_NOT_FOUND (privacy)
Tenant isolationнет (кросс-стороннее общение; messenger не tenanted строково)
Auditupdate read-state актора (LastReadSeq/LastReadAt); тело сообщения не читается/не логируется

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/messenger/chats/{chatId}/read
Success status200
Idempotency headerне применяется — идемпотентность естественная: монотонный GREATEST (повтор с тем же/меньшим Seq — no-op, INV-PART-2)
Correlationсквозной trace через traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claimsGuidДаRequired; permission messenger.chats.viewrecord-level (членство)
ChatIdrouteGuidДа!= Guid.Empty; чат-участник актора (шаг 2)
UpToMessageIdbodyGuidДа!= Guid.Empty; сообщение принадлежит ChatId (шаг 3); его Seq ≤ Chat.LastMessageSeq (INV-PART-2)

Тело — типизированная модель MarkChatReadRequest { UpToMessageId: Guid }.

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

ПолеТипОбязательностьИсточникMaskingОписание
ChatIdGuidДаChat.IdЧат.
LastReadSeqlongДаParticipant.LastReadSeq после операцииНовый курсор прочитанного (монотонный).
UnreadCountlongДаcomputedmax(0, Chat.LastMessageSeq − LastReadSeq) (обычно 0).
LastReadAtDateTimeOffsetДаParticipant.LastReadAtМомент отметки (read-receipt в Direct).
AdvancedboolДаcomputed (шаг 4)true — курсор реально продвинулся; false — no-op (уже был прочитан).

Предусловия и постусловия

Предусловия. Актор авторизован и имеет messenger.chats.view. Чат существует и не soft-deleted; актор — его активный участник (RemovedAt is null, INV-CHAT-4). Сообщение UpToMessageId принадлежит ленте ChatId, его Seq ≤ Chat.LastMessageSeq (INV-PART-2).

Постусловия (при Advanced = true). Participant.LastReadSeq продвинут до Seq(UpToMessageId) монотонно (GREATEST, INV-PART-2), LastReadAt = now; UnreadCount = max(0, Chat.LastMessageSeq − LastReadSeq). В realtime-канал chat:{ChatId} отправлен read_receipt_updatedDirect — собеседнику; в Deal — обновление агрегированного счётчика прочитавших). Kafka-события не публикуются (read-state локален), лента чата не материализуется (INV-PART-5). Постусловие (no-op). При newLastRead == LastReadSeq состояние участника не изменилось, push не отправлен; возвращается текущий курсор.

Алгоритм

  1. Контекст и доступ. Операцию выполняет участник чата (User); ActorUserId — из claims; требуется право messenger.chats.view. Идемпотентность естественная — курсор монотонный (дедуп на шаге 4).
  2. Проверка входа и членство. ChatId и UpToMessageId непустые, иначе VALIDATION_FAILED. Чат существует и актор — его активный участник (RemovedAt is null, INV-CHAT-4); иначе MESSENGER_CHAT_NOT_FOUND (privacy).
  3. Разрешение сообщения. Сообщение UpToMessageId существует, принадлежит ChatId и его Seq не больше Chat.LastMessageSeq (INV-PART-2, курсор не за пределами ленты); иначе MESSENGER_READ_SEQ_INVALID. Из него берётся целевой seq.
  4. Продвижение курсора. newLastRead = GREATEST(Participant.LastReadSeq, seq) — монотонный guard (INV-PART-2). Если курсор не изменился → no-op (Advanced = false), переход к результату без мутации. Иначе Participant.LastReadSeq = newLastRead, LastReadAt = now; Advanced = true; UnreadCount = max(0, Chat.LastMessageSeq − newLastRead).
  5. Реалтайм-уведомление. При Advanced — в той же согласованной операции persist-then-push read_receipt_updated в realtime-канал chat:{ChatId}Direct — собеседнику; в Deal — обновление агрегированного счётчика прочитавших). Ленту чата операция не материализует (INV-PART-5). Интеграционных Kafka-событий нет (read-state локален).
  6. Согласованность. При Advanced — обновление строки участника и реалтайм-эффект фиксируются атомарно; при no-op изменений не происходит. Монотонный GREATEST идемпотентен и не конфликтует с конкурентным продвижением LastMessageSeq родителя (INV-PART-2, participant.md).
  7. Результат. MarkChatReadResult (LastReadSeq, UnreadCount, LastReadAt, Advanced) → HTTP 200 (в т.ч. для идемпотентного no-op — возвращается текущее состояние курсора).

Диаграмма

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

ТипДетали
Integration eventsnone (read-state локален; Kafka не публикуется)
External callsnone
Cache / projections / searchnone (unread — вычисляемый денорм по LastMessageSeq/LastReadSeq)
Notificationsrealtime: persist-then-push read_receipt_updated в hub group chat:{ChatId} (при Advanced); offline-push — нет

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400ChatId/UpToMessageId = Guid.Empty«Некорректный запрос»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»re-login
MESSENGER_CHAT_NOT_FOUND404чат не существует, soft-deleted, либо актор не активный участник (INV-CHAT-4, privacy)«Чат не найден»no retry
MESSENGER_READ_SEQ_INVALID409сообщение не из этого чата или Seq > Chat.LastMessageSeq (INV-PART-2)«Некорректная отметка прочтения»no retry

Acceptance Criteria

  • Продвижение (happy). Дано: актор — участник, Participant.LastReadSeq = 5, Chat.LastMessageSeq = 9. Когда отмечает прочитанным до сообщения с Seq = 9. Тогда LastReadSeq = 9, UnreadCount = 0, Advanced = true, 200; собеседник получает realtime read_receipt_updated.
  • Частичное продвижение. Дано: LastReadSeq = 2, LastMessageSeq = 9. Когда отмечает до Seq = 6. Тогда LastReadSeq = 6, UnreadCount = 3, Advanced = true.
  • Идемпотентный повтор / откат. Дано: LastReadSeq = 9. Когда отмечает до Seq = 7 (или снова до Seq = 9). Тогда no-op: LastReadSeq не меняется, Advanced = false, push не отправляется, 200 с текущим состоянием.
  • Курсор за пределами ленты. Дано: UpToMessageId с Seq > Chat.LastMessageSeq либо сообщение из другого чата. Когда отмечает прочитанным. Тогда 409 MESSENGER_READ_SEQ_INVALID, состояние не меняется.
  • Не участник (privacy). Дано: актор не активный участник чата (или чат soft-deleted). Когда отмечает прочитанным. Тогда 404 MESSENGER_CHAT_NOT_FOUND (наличие чата не раскрывается).
  • Конкурентная отметка. Дано: два параллельных вызова с Seq = 6 и Seq = 9. Тогда итоговый LastReadSeq = 9 (монотонный GREATEST), оба ответа 200; повторное продвижение до меньшего значения не откатывает курсор.

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

  • Новые optional-поля ответа — additive; смена типа UpToMessageId/LastReadSeq — breaking (migration + rollout). Идемпотентный контракт (монотонный курсор, Advanced) стабилен.
  • Logs: chat marked read (ChatId, ActorUserId, LastReadSeq, Advanced; тело сообщения не читается); metrics: messenger_mark_read_total{advanced,result}, latency p95 (< 300 мс, README); traces — сквозной trace (traceparent); dashboard pin-messenger.

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

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

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