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

SignalR Hub /hubs/messenger — MessengerHub (realtime-контракт)

MessengerHub — единственная realtime-точка контекста messenger (ADR-0055 — собственный мини-мессенджер PIN). Хаб — транспорт доставки, не источник истины: любое сохранение (сообщение, правка, read-cursor, контакт, звонок) идёт через REST-операции с транзакционной публикацией, и только после фиксации фоновый диспетчер realtime-dispatch пушит проекцию в хаб (persist-then-push, chat.md INV-CHAT-5, participant.md). При переподключении клиент добирает состояние по REST — потерянный push не критичен.

Назначение

Двунаправленный realtime-канал портала инвестора/агента и админ-панели. Направление сервер → клиент — доставка новых сообщений, правок/удалений, read-receipt, изменений состава/статуса чата, заявок в контакты и сигналинга звонков/конференций: приглашения (комната + токен доступа), lifecycle, вход/выход участников, состояние записи и демонстрации экрана. Направление клиент → сервер — только эфемерные операции (подписка на группу чата, typing). Медиа (звук/видео/демонстрация экрана) идёт клиент ↔ SFU (ADR-0062) — бэкенд не релеит SDP/ICE. Этот контракт — то, ради чего фронт подключается к хабу: он заменяет устаревший GET /me/chat-token (jid/websocketUrl/mucService, ADR-0055).

Metadata

ПолеЗначение
Сервис/контекстmessenger
API arearealtime (SignalR hub)
Feature grouphub
Статусdraft
Документdocs/06-services/messenger/api/hub/messenger-hub.md

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

ПроверкаЗначение
Actor typeUser (клиент подключения); System (realtime-dispatch — push сервер → клиент)
Auth policy / PermissionsJWT bearer (KeyCloak); connect требует messenger.chats.view; per-операция права — на REST-операциях (.write/.calls.use/.conferences.use/.recordings.view/.contacts.manage)
Scope (RBAC)не применяется (доступ — по членству, не по scope)
Record-levelактивный ParticipantEntity (RemovedAt is null) — гейт группы chat:{chatId}; звонок 1:1 — пара Caller/Callee; конференция — членство в Deal-чате
Tenant isolationнет (кросс-tenant, chat.md): изоляция по членству/паре
Auditхаб не сохраняет данные; аудит — на REST-операциях и ChatEvent/MessageEdit

Hub-контракт

ПолеЗначение
Endpoint/hubs/messenger
ТранспортWebSockets (fallback SSE/LongPolling); JWT — заголовок Authorization: Bearer либо ?access_token=
АутентификацияJWT-контекст соединения при подключении; при отсутствии валидного контекста — соединение закрывается
Группыuser:{UserId} — авто-подписка при подключении (адресные пуши: контакты, звонки, доставка «во все чаты»); chat:{ChatId} — подписка по JoinChat после проверки членства
BackplaneRedis (REALTIME_BACKPLANE_ENABLED) — опционально на 1 инстансе (ADR-0056), обязателен при multi-replica (ADR-0043)
Лимитыразмер сообщения ≤ 32 КБ, keep-alive 15 с, тайм-аут клиента 30 с; rate-limit relay ≤ 10 msg/с на пользователя

Хаб не принимает отправку/правку сообщений: они идут по REST (send-text-message, edit-message, …). Серверные методы хаба — только эфемерные, без записи в хранилище.

Серверные методы хаба (клиент → сервер)

МетодНазначениеГейт
JoinChat(chatId)подписать соединение на группу chat:{chatId} (при открытии чата)активное членство актора в чате; иначе — ошибка MSG_CHAT_ACCESS_DENIED
LeaveChat(chatId)отписать соединение от группы chat:{chatId}
Typing(chatId, isTyping)эфемерный typing-индикатор в chat:{chatId} (кроме автора)активное членство (как JoinChat); не сохраняется

Метод SendCallSignal (relay SDP/ICE) удалён: с нативным клиентом и SFU (ADR-0062) медиа-негоциация идёт клиент ↔ SFU, хаб её не переносит. Вход в звонок/конференцию раздаётся приглашением (CallInvite/ ConferenceInvite с комнатой и токеном), обновляется методом refresh-call-media-token по REST.

Входные данные / параметры подключения

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
access_tokenquery / headerJWTДаподпись KeyCloak, messenger.chats.viewне логируется
chatIdJoinChat/LeaveChat/TypingGuidДасуществует + активное членство актора

Клиентские события (сервер → клиент)

Имя метода = имя события на проводе; каждое несёт типизированный payload.

СобытиеPayloadКогда
ReceiveMessageRealtimeMessageновое сообщение (домен: MessagePosted)
MessageEditedRealtimeMessageправка в окне ≤ 60 с
MessageDeletedRealtimeMessageDeletedмягкое удаление в окне
ChatReadRealtimeReadReceiptread-cursor продвинут (домен: ReadStateChanged)
ChatUpdatedRealtimeChatUpdateсостав/переименование/статус (ParticipantAdded/Removed/RoleChanged)
ContactRequestRealtimeContactRequestзаявка/подтверждение/отклонение/удаление контакта
CallInviteRealtimeCallInviteприглашение в 1:1-звонок (комната + токен доступа) → user:{Callee}
CallSignalRealtimeCallSignallifecycle 1:1-звонка (Answered/Declined/Cancelled/Ended/Missed), без медиа
ConferenceInviteRealtimeConferenceInviteприглашение в конференцию (комната + токен) → broadcast в chat:{ChatId} при старте; адресно в user:{UserId} при invite-participant
ConferenceSignalRealtimeConferenceSignallifecycle конференции (Started/Ended, смена модератора)
MediaParticipantRealtimeMediaParticipantвход/выход участника (1:1 и конференция)
RecordingStateRealtimeRecordingStateсостояние записи (Started/Stopped/Available/Failed)
ScreenShareStateRealtimeScreenShareстарт/стоп демонстрации экрана участником
TypingRealtimeTypingэфемерный typing

Модель payload RealtimeMessage (для ReceiveMessage/MessageEdited)

Проекция сообщения адресована участникам чата (авторизованы членством) → несёт тело (в отличие от интеграционного события pin.messenger.message-posted.v1, где тела нет). Sensitive-поля IpAddress/ UserAgent исключены (message.md INV-MSG-6). Строгая типизация: под-модели по Kind.

ПолеТипОбязательностьОписание
ChatIdGuidДаЧат.
MessageIdGuidДаСообщение.
SeqlongДаPer-chat монотонный порядковый; ключ дедупликации/порядка.
KindMessageKindДаText / Attachment / EventCard.
SenderUserIdGuid?Нетnull — система/EventCard.
SenderRoleParticipantRole?НетРоль автора на момент отправки.
IsSystemboolДаtrue — системное/event-card.
SentAtDateTimeOffsetДаМомент отправки.
EditedAtDateTimeOffset?НетМомент правки.
TextRealtimeTextContent?НетДля Kind == Text: { Body: string }.
AttachmentRealtimeAttachmentContent?НетДля Kind == Attachment.
EventCardRealtimeEventCardContent?НетДля Kind == EventCard (per-participant текст уже применён).

Под-модель RealtimeAttachmentContent: { FileId: Guid, FileName: string, ContentType: string, SizeBytes: long, AttachmentKind: AttachmentType, Caption: string? }. Под-модель RealtimeEventCardContent: { CardType: EventCardType, Category: EventCardCategory, ActionStatus: EventCardActionStatus, Text: string (= TextOverride ?? DefaultText, INV-MSG-5), DealId: Guid? }.

Модель MediaJoinModel (дескриптор входа в медиа-комнату)

Раздаётся в приглашениях (CallInvite/ConferenceInvite) и возвращается REST-методами start/answer/join/ media-token (../calls/start-call.md и смежные). Несёт всё, что нужно нативному клиенту, чтобы войти в комнату SFU. AccessTokenSensitive: короткоживущий токен доступа, минтится в момент доставки (realtime-dispatch), в realtime_outbox/БД не хранится, в логи не пишется.

ПолеТипОбязательностьОписание
RoomNamestringДаИмя медиа-комнаты (MediaRoom.RoomName); маршрутный id, не медиа.
AccessTokenstringДаТокен доступа в комнату (Sensitive, короткий TTL).
SignalingUrlstringДаEndpoint сигналинга media-стека (secure websocket).
MediaDomainstringДаДомен/поддомен медиа-развёртывания (MediaRoom.Domain).
IceServersMediaIceServerEntry[]ДаSTUN/TURN для обхода NAT (SFU/coturn).
ExpiresAtDateTimeOffsetДаИстечение AccessToken; клиент обновляет заранее (media-token).

Под-модель MediaIceServerEntry: { Urls: string[], Username: string?, Credential: string? } (Credential — короткоживущий TURN-креденшл, Sensitive, не логируется).

Прочие payload-модели

МодельПоля (v1, строго типизированные)Событие
RealtimeMessageDeleted{ ChatId: Guid, MessageId: Guid, Seq: long, DeletedAt: DateTimeOffset }MessageDeleted
RealtimeReadReceipt{ ChatId: Guid, UserId: Guid, LastReadSeq: long, LastReadAt: DateTimeOffset }ChatRead
RealtimeChatUpdate{ ChatId: Guid, Kind: ChatUpdateKind, UserId: Guid?, Role: ParticipantRole?, Title: string?, Status: ChatStatus? }ChatUpdated
RealtimeContactRequest{ ContactId: Guid, Kind: ContactEventKind, RequesterUserId: Guid, AddresseeUserId: Guid, RequestMessage: string?, OccurredAt: DateTimeOffset }ContactRequest
RealtimeCallInvite{ CallId: Guid, MediaType: CallMediaType, CallerUserId: Guid, Room: MediaJoinModel }CallInvite
RealtimeCallSignal{ CallId: Guid, Kind: CallSignalKind, FromUserId: Guid?, ToUserId: Guid?, EndReason: CallEndReason?, DurationSeconds: int? }CallSignal
RealtimeConferenceInvite{ ConferenceId: Guid, ChatId: Guid, DealId: Guid?, MediaType: CallMediaType, StartedByUserId: Guid, Title: string?, Room: MediaJoinModel }ConferenceInvite
RealtimeConferenceSignal{ ConferenceId: Guid, ChatId: Guid, Kind: ConferenceSignalKind, Status: ConferenceStatus?, ActiveParticipantCount: int? }ConferenceSignal
RealtimeMediaParticipant{ SessionKind: SessionKind, SessionId: Guid, ChatId: Guid?, UserId: Guid, Kind: ParticipantEventKind, Role: ParticipantRole?, OccurredAt: DateTimeOffset }MediaParticipant
RealtimeRecordingState{ SessionKind: SessionKind, SessionId: Guid, ChatId: Guid?, RecordingId: Guid, Kind: RecordingEventKind, FileId: Guid?, DurationSeconds: int?, OccurredAt: DateTimeOffset }RecordingState
RealtimeScreenShare{ SessionKind: SessionKind, SessionId: Guid, ChatId: Guid?, UserId: Guid, Kind: ScreenShareEventKind, OccurredAt: DateTimeOffset }ScreenShareState
RealtimeTyping{ ChatId: Guid, UserId: Guid, IsTyping: bool }Typing

RealtimeRecordingState несёт FileId только при Kind == Available и не несёт ссылку/URL — клиент получает ссылку на скачивание отдельным методом (../recordings/get-recording-download.md).

Enum'ы контракта: ChatUpdateKind { MemberAdded, MemberRemoved, RoleChanged, Renamed, StatusChanged }, ContactEventKind { Received, Accepted, Declined, Removed }, CallSignalKind { Invite, RoomReady, Answered, Declined, Cancelled, Hangup, Ended, Missed } (все — без медиа; вход раздаётся CallInvite с MediaJoinModel, а не relay-ом SDP), ConferenceSignalKind { Invited, Started, Ended }, ParticipantEventKind { Joined, Left }, RecordingEventKind { Started, Stopped, Available, Failed }, ScreenShareEventKind { Started, Stopped }. Домен-enum'ы (MessageKind, ParticipantRole, AttachmentType, EventCard*, ChatStatus, CallMediaType, CallEndReason, SessionKind, ConferenceStatus) — из domain-доков, не дублируются.

Алгоритм

Канонический путь «отправка → доставка» (persist-then-push); эфемерные server-методы — вне хранилища.

  1. Приём и контекст. Клиент вызывает REST POST /chats/{chatId}/messages (JWT → актор/trace); hub-соединение уже подписано на группы user:{actor} (при подключении) и chat:{chatId} (JoinChat).
  2. Проверка. REST-операция проверяет: активное членство (INV-CHAT-4), коммуникационную политику (актор не в чёрном списке), Kind != EventCard, длину тела 1..8000, чат не Closed.
  3. Что читаем. Загружается чат с активными участниками; для карточек — идемпотентная обработка события в консюмере crm по SourceEventId.
  4. Бизнес-правила и переход. Чат выдаёт Seq (per-chat монотонный); из агрегата собирается проекция RealtimeMessage (без IP/UA; текст EventCard = TextOverride ?? DefaultText).
  5. Что меняется. Появляется сообщение, чат обновляет денормализацию (последнее сообщение, LastMessageSeq); одновременно записывается намерение доставки push.
  6. Факты и события. Доменный факт MessagePosted и интеграционное событие MessagePostedEvent (pin.messenger.message-posted.v1) публикуются атомарно с сохранением сообщения.
  7. Согласованность. Сообщение и намерение push фиксируются в одном согласованном изменении до любого push (persist-then-push).
  8. Доставка. REST отвечает 201 с RealtimeMessage; диспетчер realtime-dispatch читает зафиксированный факт и вызывает ReceiveMessage(RealtimeMessage) в группе chat:{chatId}. Потеря push при разрыве → клиент добирает list-messages по Seq.

Диаграмма

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

ТипДетали
Realtime pushклиентские события в группы chat:{ChatId} (сообщения/read/typing/состав/конференции/запись) и user:{UserId} (контакты/1:1-звонки)
Токены доступаMediaJoinModel.AccessToken/TURN-креды минтятся при доставке инвайта (realtime-dispatch), не хранятся в realtime_outbox/БД, не логируются
Интеграционные событиядоставляются из транзакционной публикации: pin.messenger.message-posted.v1, …contact-request-created/accepted.v1, …deal-chat-status-changed.v1, …conference-ended.v1, …call-recording-available.v1 (хаб их не публикует — только доставляет проекции)
Внешние вызовынет (медиа-негоциация SDP/ICE идёт клиент ↔ SFU, вне хаба и БД, INV-CALL-4)
ПроекцииRedis backplane (при multi-replica) — транспорт групп; не кэш данных
Уведомленияoffline push/email — notifications (консюмер Kafka-событий); хаб — только in-app realtime

Ошибки и failure modes

КодКаналУсловиеСообщениеRetry
MSG_CHAT_ACCESS_DENIEDошибка хаба (JoinChat/Typing)нет активного участия«Нет доступа к чату»нет
Unauthorized / abortconnectJWT невалиден / нет messenger.chats.viewclose 401reconnect после refresh
потеря pushтранспортразрыв / отсутствие backplane при multi-replica— (не источник истины)клиент добирает REST по Seq
rate-limitrelay> 10 msg/с на пользователяthrottleклиент снижает частоту

Acceptance Criteria

  • Подключение с правами. Дано валидный JWT с messenger.chats.view. Когда клиент открывает соединение. Тогда соединение установлено и авто-подписано на группу user:{UserId} (адресные пуши контактов/1:1-звонков).
  • Подключение без прав. Дано невалидный JWT или отсутствует messenger.chats.view. Когда connect. Тогда соединение закрывается (401/abort); клиент переподключается после refresh токена.
  • Подписка на чат. Дано активное членство актора в чате. Когда JoinChat(chatId). Тогда соединение подписано на chat:{chatId}; при отсутствии активного участия — ошибка хаба MSG_CHAT_ACCESS_DENIED.
  • Доставка нового сообщения (persist-then-push). Дано участник подписан на chat:{chatId}. Когда другой участник отправил сообщение REST-операцией. Тогда только после фиксации диспетчер realtime-dispatch вызывает ReceiveMessage(RealtimeMessage) в группе; проекция несёт тело, но не IpAddress/UserAgent.
  • Потеря push при разрыве. Дано соединение разорвано в момент доставки. Когда push потерян. Тогда это не критично (хаб не источник истины): при переподключении клиент добирает list-messages по Seq.
  • Приглашение в 1:1-звонок. Дано инициатор стартовал звонок. Когда звонок раздаётся. Тогда CallInvite с MediaJoinModel уходит в user:{Callee}; AccessToken/TURN-креды минтятся при доставке, в realtime_outbox/ БД не хранятся и в логи не пишутся.
  • Состояние записи без URL. Дано запись сессии стала Available. Когда push RecordingState. Тогда payload несёт FileId (при Kind == Available) и не несёт ссылку — клиент берёт её отдельным методом get-recording-download.
  • Эфемерный typing. Дано активное членство. Когда Typing(chatId, true). Тогда остальным участникам chat:{chatId} уходит Typing; в хранилище ничего не сохраняется.
  • Rate-limit. Дано клиент шлёт эфемерные relay-события чаще 10/с. Когда лимит превышен. Тогда throttle; клиент снижает частоту.
  • Медиа вне хаба. Дано идёт медиа-звонок. Когда клиент договаривается о SDP/ICE. Тогда негоциация идёт клиент ↔ SFU (INV-CALL-4); хаб SDP/ICE не переносит (метод SendCallSignal удалён).

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

  • Новые optional-поля payload и новые клиентские события (CallInvite/ConferenceInvite/MediaParticipant/RecordingState/ScreenShareState) — не breaking. Удаление метода SendCallSignal и полей Sdp/IceCandidate/MediaType из RealtimeCallSignal, сжатие CallSignalKind (убраны Offer/Answer/Ice) — breaking: бамп версии hub-контракта + согласованный rollout фронта (переход на нативный клиент + SFU, ADR-0062).
  • Метрики: hub connect/disconnect rate, lag/ошибки realtime-dispatch, backlog доставки; correlation через traceparent. PII/тела/IpAddress в логи не пишутся.

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

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

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