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

POST /api/messenger/chats/direct — Открыть/создать личный 1:1 чат

Назначение

Открыть личный 1:1-чат (ChatEntity, Kind = Direct) между актором и указанным пользователем. Идемпотентно по неупорядоченной паре (DirectKey, INV-CHAT-1): повторный вызов не плодит чат, а возвращает существующий. Проверяется коммуникационная политика (чёрный список любой стороны, INV-CHAT-3 / INV-BL-3): открыть чат с заблокировавшим/заблокированным нельзя.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (инициатор — любая сторона)
Auth policy / Permissionspermission messenger.chats.write — каталог ../../../identity/domain/permission.md
Scope (RBAC)не применяется — общение кросс-стороннее, вне scope/tenant (ADR-0052 п.3)
Record-levelинициатор становится участником создаваемого/существующего чата пары; чужие чаты недоступны
Tenant isolationнет (кросс-стороннее общение; messenger не tenanted строково)
Auditcreate (при создании — CreatedByUserId = actor); targetUserId — id (не PII), ФИО не логируется

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/messenger/chats/direct
Success status200 (чат уже существовал) / 201 (создан) — различает поле Created
Idempotency headerне применяется — идемпотентность естественная по DirectKey (INV-CHAT-1), не по заголовку
Correlationсквозной trace через traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claimsGuidДаRequired; permission messenger.chats.writeинициатор пары
TargetUserIdbodyGuidДа!= Guid.Empty; != ActorUserId (INV-CHAT-7); существующий IsActive пользователь (шаг 2)

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

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

ПолеТипОбязательностьИсточникMaskingОписание
ChatIdGuidДаChat.IdИдентификатор личного чата пары.
CreatedboolДаcomputed (шаг 4)true — чат создан этим вызовом; false — уже существовал (идемпотентно).
KindChatKind (enum→string)ДаChat.KindВсегда Direct.
StatusChatStatus (enum→string)ДаChat.StatusКак правило Active.
CounterpartyUserIdGuidДаTargetUserIdСобеседник (для навигации/подписи).
CreatedAtDateTimeOffsetДаChat.CreatedAtМомент создания (существовавшего или нового).

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

Предусловия. Актор авторизован и имеет messenger.chats.write. Адресат (TargetUserId) существует в реплике пользователей и активен (IsActive, INV-CT-6); TargetUserId != ActorUserId (INV-CHAT-7). Ни одна сторона не в чёрном списке другой (INV-CHAT-3 / INV-BL-3).

Постусловия. Существует ровно один Direct-чат неупорядоченной пары (DirectKey, INV-CHAT-1) со статусом Active и двумя активными участниками (Role = Member, LastReadSeq = 0). При Created = true дополнительно: в ленту чата добавлена строка ChatCreated, обеим сторонам отправлен realtime ChatCreated. При Created = false существующий чат не мутирован. Kafka-события не публикуются (Direct-чат кросс-сервисных потребителей не имеет).

Алгоритм

  1. Контекст и доступ. Операцию выполняет инициатор (User, любая сторона); ActorUserId — из claims; требуется право messenger.chats.write (иначе MESSENGER_FORBIDDEN). Идемпотентность естественная по DirectKey (INV-CHAT-1), не по заголовку.
  2. Проверка входа. TargetUserId непустой и != ActorUserId (INV-CHAT-7); иначе VALIDATION_FAILED либо MESSENGER_DIRECT_SELF. Адресат существует и активен (реплика, INV-CT-6); иначе MESSENGER_USER_NOT_FOUND.
  3. Ключ пары и политика. Вычислить DirectKey из неупорядоченной пары (min:max по Guid) — уникальность ux_chats_direct_key. Найти существующий Direct-чат пары. Проверить коммуникационную политику CommunicationPolicy.CanMessage — чёрный список любой стороны (INV-BL-3).
  4. Создание или идемпотентный возврат. Если общение заблокировано → MESSENGER_COMMUNICATION_BLOCKED (INV-CHAT-3, без раскрытия инициатора блокировки). Если чат пары уже есть → идемпотентный возврат (Created = false), чат не мутируется. Иначе создать чат (Kind = Direct, Status = Active, DirectKey, служебный Title-placeholder — ФИО резолвится на чтении) с двумя участниками (Role = Member, LastReadSeq = 0, RemovedAt = null, INV-CHAT-7/INV-PART-3); Created = true.
  5. Лента и реалтайм. При создании — в той же согласованной операции появляется строка ленты ChatEventEntity типа ChatCreated (chat-event.md) и persist-then-push ChatCreated в SignalR-hub обеим сторонам (ADR-0055). Интеграционных Kafka-событий нет (Direct-чат не имеет кросс-сервисных потребителей; pin.messenger.deal-chat-status-changed.v1 — только для Deal-чатов).
  6. Согласованность. При создании чат, его участники и строка ленты фиксируются атомарно; гонку параллельного открытия той же пары ловит уникальный ux_chats_direct_key → перечитать и вернуть существующий (Created = false, идемпотентность INV-CHAT-1).
  7. Результат. OpenDirectChatResult (ChatId, Created, Kind, Status, CounterpartyUserId, CreatedAt) → HTTP 201 Created при создании, 200 OK при возврате существующего.

Диаграмма

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

ТипДетали
Integration eventsnone (Direct-чат Kafka-событий не публикует; см. README produced-события)
External callsnone (адресат — реплика messenger-user.md; блок-лист — своя схема)
Cache / projections / searchnone
Notificationsrealtime: persist-then-push ChatCreated в SignalR-hub обеим сторонам (ADR-0055); offline-push — нет

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400TargetUserId = Guid.Empty«Некорректный получатель»no retry
MESSENGER_DIRECT_SELF400TargetUserId = ActorUserId (INV-CHAT-7)«Нельзя открыть чат с самим собой»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»re-login
MESSENGER_FORBIDDEN403нет permission messenger.chats.write«Недостаточно прав»no retry
MESSENGER_USER_NOT_FOUND404адресата нет в реплике или IsActive = false (INV-CT-6)«Пользователь не найден»no retry
MESSENGER_COMMUNICATION_BLOCKED409чёрный список любой стороны (INV-CHAT-3 / INV-BL-3)«Общение с пользователем недоступно»no retry

Acceptance Criteria

  • Первое открытие (happy). Дано: между актором и адресатом нет Direct-чата, стороны не в чёрных списках. Когда актор открывает чат. Тогда создаётся один Direct-чат (Status = Active, два участника), Created = true, 201; обе стороны получают realtime ChatCreated, в ленте — строка ChatCreated.
  • Идемпотентный повтор. Дано: чат пары уже существует. Когда любая сторона открывает чат по тому же адресату. Тогда возвращается тот же ChatId, Created = false, 200; новый чат не создаётся, лента не растёт.
  • Симметрия пары. Дано: A ранее открыл чат с B. Когда B открывает чат с A. Тогда возвращается тот же чат (DirectKey симметричен, INV-CHAT-1), Created = false.
  • Заблокированное общение. Дано: одна из сторон в чёрном списке другой. Когда актор открывает чат. Тогда 409 MESSENGER_COMMUNICATION_BLOCKED, чат не создаётся (инициатор блокировки не раскрывается).
  • Чат с собой. Дано: TargetUserId == ActorUserId. Когда актор открывает чат. Тогда 400 MESSENGER_DIRECT_SELF.
  • Неизвестный/неактивный адресат. Дано: адресата нет в реплике или IsActive = false. Когда актор открывает чат. Тогда 404 MESSENGER_USER_NOT_FOUND.
  • Гонка параллельного открытия. Дано: две параллельные попытки открыть чат той же пары. Тогда создаётся ровно один чат (ux_chats_direct_key), проигравшая попытка возвращает существующий (Created = false), дубля нет.

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

  • Новые optional-поля ответа — additive; смена типа TargetUserId/ChatId или значения enum ChatKind/ChatStatus — breaking (migration + rollout). Идемпотентный контракт (Created) стабилен.
  • Logs: direct-chat opened (ChatId, Created, ActorUserId, TargetUserId — id; ФИО не логируется); metrics: messenger_open_direct_total{created,result}, latency p95 (< 300 мс, README); traces — сквозной trace (traceparent); dashboard pin-messenger.

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

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

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