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

UC-MSG-005. Сигналинг аудио/видео-звонка

Назначение

Описывает сценарий: подтверждённый контакт инициирует звонок (call-session.md), проходит цикл StartCall → Ringing → Answered → Ended (плюс Declined/Missed/Failed). Звонок разрешён только между Accepted-контактами (contact.md, INV-CALL-1) вне чёрного списка (blacklist.md) — сводный гейт CommunicationPolicy.CanCall. В медиа-фазе (ADR-0062) бэкенд не релеит SDP/ICE: start/answer возвращают дескриптор входа MediaJoinModel (комната SFU + короткоживущий токен), приглашение доставляется событием CallInvite (RealtimeCallInvite с Room), а медиа-негоциация идёт клиент ↔ media-стек (SFU); токены доступа/ICE в БД не персистятся (INV-CALL-4 — только факты жизненного цикла), клиент обновляет токен методом media-token. Медиа гейтится feature flag CALLS_MEDIA_ENABLED (иначе MSG_CALLS_DISABLED, 503). CallSignal теперь несёт только lifecycle (Answered/Declined/Cancelled/Ended/Missed), без медиа. Таймаут неответа закрывает фоновый call-timeout-reaper (Ringing → Missed). Полный алгоритм и маппинг — в API-методах: start-call.md, answer-call.md, decline-call.md, end-call.md, media-token.md. Связан с контекстом messenger.

Metadata

ПолеЗначение
IDUC-MSG-005
Контекстmessenger (см. docs/06-services/messenger/)
Типосновной (+ A1 отклонён, A2 неответ→Missed, A3 не контакт/блок, A4 уже активный звонок, A5 отмена исходящего до ответа, A6 сбой сигналинга/медиа → Failed)
Статусdraft

Актор

ПолеЗначение
РольИнициатор (Caller) и вызываемый (Callee) — оба User, стороны Accepted-контакта
Permissions / scopeJWT identity; messenger.calls.use (участник — только Caller/Callee); guest → 401
Tenantне применяется — звонок между пользователями вне tenant (call-session.md, ADR-0052)
Record-levelдоступ = актор ∈ {CallerUserId, CalleeUserId}; разрешение — Accepted ContactEntity пары

Предусловие

  • Между Caller и Callee есть ContactEntity со Status = Accepted (INV-CT-5); ни одна сторона не в чёрном списке другой (INV-BL-3).
  • Feature flag CALLS_MEDIA_ENABLED = true; клиенты подключены к hub /hubs/messenger (группы user:{id}).

Триггер

ПолеЗначение
ИсточникUI (кнопка «Позвонить» в карточке контакта/чате) → API call + hub CallInvite/CallSignal
Триггерящий элементPOST /api/messenger/calls (start) → CallInvite с MediaJoinModel → `POST /calls/{id}/answer

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

Нить — успешный аудиозвонок. Полный алгоритм и маппинг — в start-call.md; ниже — поток на уровне use-case.

  1. Инициация. Caller жмёт «Позвонить» и отправляет { CalleeUserId, MediaType }; инициатор (CallerUserId) берётся из токена.
  2. Гейт CanCall. Между сторонами есть контакт Accepted (иначе MSG_CALL_NOT_CONTACT, A3); CommunicationPolicy.CanCall = CanMessage ∧ Accepted — нет блокировки (иначе MSG_CALL_BLOCKED, A3, INV-CALL-1); нет уже активного звонка на пару (ix_call_active: нет Ringing/Answered), иначе MSG_CALL_ALREADY_ACTIVE (A4, INV-CALL-5).
  3. Создание сессии + комната. Создаётся сессия звонка: Status = Ringing, StartedAt = now, детерминированное Room.RoomName (SFU, ADR-0062), append-only лог; поднимается доменное CallStartedEvent. Ответ start несёт Room: MediaJoinModel инициатору; токены/ICE в БД не пишутся (INV-CALL-4).
  4. Persist + push invite. Сессия и строка realtime-outbox CallInvite фиксируются вместе; далее CallInvite (RealtimeCallInvite с Room: MediaJoinModel) пушится в user:{CalleeUserId} через realtime-dispatchтокен доступа минтится в момент доставки, не хранится (INV-CALL-4). SDP/ICE хаб не переносит.
  5. Ответ callee. Callee принимает (answer-call.md): актор = CalleeUserId, Status = Ringing (INV-CALL-3). Сессия переходит Ringing → Answered, AnsweredAt = now; ответ answer несёт Room: MediaJoinModel вызываемому; поднимается CallAnsweredEvent.
  6. Медиа клиент ↔ SFU. После Answered обе стороны входят в комнату SFU нативным клиентом по MediaJoinModel; медиа-негоциация (SDP/ICE) и поток идут клиент ↔ media-стек, вне хаба и БД (INV-CALL-4). Токен обновляется заранее POST /calls/{id}/media-token (MediaTokenModel); lifecycle-сигналы идут через CallSignal (без медиа).
  7. Завершение. Любая сторона кладёт трубку (end-call.md): сессия переходит Answered → Ended (EndReason = HangupCaller|HangupCallee), EndedAt = now, DurationSeconds = EndedAt − AnsweredAt; поднимается CallEndedEvent; фиксация + push call_ended обеим сторонам.
  8. Журнал. Сессия остаётся в логе (list-calls: входящие/исходящие/пропущенные, call-session.md); Kafka-события звонок не публикует; уведомление о пропущенном (системная строка) — отложено с media.

Диаграмма

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

A1. Callee отклонил

  1. Callee жмёт «Отклонить» (decline-call.md): актор = Callee, Status = Ringing. Сессия переходит Ringing → Declined, EndReason = Declined; поднимается CallDeclinedEvent → push CallSignal(Declined) обеим сторонам. DurationSeconds = null.

A2. Неответ → Missed (таймаут)

  1. Callee не отвечает: Status = Ringing, now − StartedAt > RingTimeout (45 с, INV-CALL-2).
  2. call-timeout-reaper (цикл 15 с) выбирает сессию под блокировкой (FOR UPDATE SKIP LOCKED) и переводит Ringing → Missed, EndReason = Timeout; push call_missed инициатору. Идемпотентно (guard WHERE Status='Ringing').

A3. Не контакт / в чёрном списке

  1. Контакт пары не AcceptedMSG_CALL_NOT_CONTACT (409); либо CanCall = deny из-за блокировки → MSG_CALL_BLOCKED (409, INV-CALL-1). Сессия не создаётся.

A4. Уже есть активный звонок на пару

  1. Для пары (Caller, Callee) уже есть активный звонок Ringing/Answered (ix_call_active) → MSG_CALL_ALREADY_ACTIVE (409, INV-CALL-5). Клиент предлагает присоединиться/подождать; новая сессия не создаётся.

A5. Отмена исходящего до ответа (Caller кладёт трубку на Ringing)

  1. Пока звонок Ringing, инициатор жмёт «Отмена» (end-call.md): актор = Caller, допустимый источник — Ringing ∧ actor == Caller (INV-CALL-3). Сессия переходит Ringing → Ended, EndReason = HangupCaller, EndedAt = now, DurationSeconds = null; поднимается CallEndedEvent → push CallSignal(Ended) обеим сторонам.
  2. Отбой Callee на Ringing — это A1 (Declined), не отмена: попытка Callee завершить Ringing-звонок через end → MSG_CALL_FORBIDDEN (403). Новых статусов/значений CallEndReason не вводится.

A6. Сбой сигналинга/медиа → Failed

  1. Фатальный сбой сигналинга/поднятия комнаты до соединения переводит Ringing → Failed, EndReason = Error (INV-CALL-3, stateDiagram call-session.md); сессия терминальна, DurationSeconds = null. (Отказ резерва комнаты на самом старте — это MSG_RECORDING_PROVISION_FAILED в start-call.md, сессия при этом не создаётся вовсе.)
  2. Обе стороны отвалились без hangup на принятом звонке: Answered без прогресса сигналинга дольше StaleAnsweredTimeout (default 2 ч) — фоновый call-timeout-reaper выбирает сессию под блокировкой (FOR UPDATE SKIP LOCKED) и переводит Answered → Failed, EndReason = Error, DurationSeconds = null; push CallSignal(Ended) обеим сторонам. Идемпотентно (guard WHERE Status='Answered').

Постусловие

  • Создана CallSessionEntity с корректным терминальным статусом (Ended/Declined/Missed/Failed) и фазовыми метками (StartedAt/AnsweredAt?/EndedAt?); DurationSeconds — только при Answered → Ended.
  • SDP/ICE, медиа и токены доступа в БД отсутствуют (INV-CALL-4); журнал доступен в list-calls.
  • Realtime-push (CallInvite, CallSignal Answered/Declined/Ended/Missed) доставлен участникам; Kafka-событий нет.

Инварианты, статусы и валидации

Переходы CallStatus (полный перечень)

FromToУсловие переходаИнициатор
[*]RingingCanCall (INV-CALL-1) ∧ нет активного звонка пары (INV-CALL-5)Caller (start)
RingingAnsweredcallee принял, источник Status = Ringing (INV-CALL-3)Callee (answer)
RingingDeclinedcallee отклонилCallee (decline, A1)
RingingEndedранняя отмена исходящего (DurationSeconds = null)Caller (end, A5)
RingingMissednow − StartedAt > RingTimeout (45 с, INV-CALL-2)reaper (A2)
RingingFailedфатальный сбой сигналинга до соединения (INV-CALL-3)система (A6)
AnsweredEndedhangup любой стороной, DurationSeconds = EndedAt − AnsweredAtCaller/Callee (end)
AnsweredFailedобе стороны пропали без hangup > StaleAnsweredTimeoutreaper (A6)

Терминальные: Ended/Declined/Missed/Failed. EndReason обязателен при терминале кроме Missed (Missed подразумевает Timeout).

Инварианты (полный перечень)

ИнвариантСмысл
INV-CALL-1Звонок разрешён только Accepted-контакту пары вне чёрного списка (сводный гейт CommunicationPolicy.CanCall).
INV-CALL-2Ringing без ответа дольше RingTimeout (45 с) → Missed фоновым reaper-ом.
INV-CALL-3Допустимы только перечисленные переходы; недопустимый источник answer/decline/end → MSG_CALL_INVALID_STATE.
INV-CALL-4SDP/ICE/медиа не релеятся и не персистятся: негоциация клиент ↔ SFU, в БД — только факты жизненного цикла.
INV-CALL-5Не более одного активного (Ringing/Answered) звонка на пару (Caller, Callee) одновременно.
INV-CALL-6Room.RoomName детерминирован и уникален среди активных; токены доступа/ICE эфемерны, минтятся при входе, не хранятся и не логируются.

Валидации входа старта

  • CalleeUserId != Empty и != CallerUserId — иначе MSG_CALL_SELF (400).
  • MediaType ∈ {Audio, Video} — иначе ошибка валидации (400).
  • callee существует и активен — иначе MSG_CALL_USER_NOT_FOUND (404).

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

КодHTTPУсловиеПоведение клиента
MSG_CALL_NOT_CONTACT409контакт не Accepted (A3, INV-CALL-1)«Звонки доступны только подтверждённым контактам»
MSG_CALL_BLOCKED409блокировка любой стороной (A3, INV-BL-3)Скрыть «позвонить»
MSG_CALL_ALREADY_ACTIVE409активный Ringing/Answered на пару (A4, INV-CALL-5)«Звонок уже идёт»
MSG_CALL_INVALID_STATE409answer/decline/end в недопустимом статусе (INV-CALL-3)Обновить состояние по hub/REST
MSG_CALL_NOT_FOUND404сессия не найдена / актор не участник«Звонок не найден»
MSG_CALL_FORBIDDEN403актор ∉ {Caller,Callee} (record-level)«Нет доступа к звонку»
MSG_CALLS_DISABLED503CALLS_MEDIA_ENABLED = false (медиа выключено)«Звонки временно недоступны»

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

СобытиеКогда поднимаетсяSubscribers
CallStartedEvent[*] → Ringingpush CallInvite (комната + токен при доставке) в user:{Callee}
CallAnsweredEvent→ Answeredpush CallSignal(Answered) обеим сторонам; вход в комнату SFU
CallDeclinedEvent→ Declined (A1)push CallSignal(Declined) обеим сторонам
CallMissedEvent→ Missed (A2, reaper)push CallSignal(Missed) инициатору
CallEndedEvent→ Endedpush CallSignal(Ended) обеим сторонам; DurationSeconds

Все — доменные события; hub-push несёт non-PII (CallId/Status/MediaType). CallSignal теперь несёт только lifecycle (без SDP/ICE); медиа-негоциация — клиент ↔ SFU, вне хаба и БД (INV-CALL-4). Токен доступа минтится при доставке CallInvite, не хранится.

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

СобытиеTopicКогда публикуетсяConsumers
звонок Kafka-событий не публикует (call-session.md)

Сигналинг и журнал локальны для messenger; offline-уведомление о пропущенном звонке (через message-posted-подобный механизм) — роадмап. 1:1-звонок — person-to-person вне сделки, conference-ended/ call-recording-available его не касаются (это события конференций/записи, UC-MSG-006/UC-MSG-007).

Конкурентность, идемпотентность и восстановление

  • Старт. Idempotency-ключа нет; повтор и гонка двух StartCall на пару разрешаются партиал-индексом активных звонков (INV-CALL-5): ровно один переходит в Ringing, второй → MSG_CALL_ALREADY_ACTIVE (409).
  • answer/decline/end. Естественная идемпотентность через guard источника (INV-CALL-3): повторная команда на терминале → MSG_CALL_INVALID_STATE (409). Побеждает первый зафиксированный переход; проигравший получает актуальный статус и перечитывает журнал list-calls.
  • Гонка reaper ↔ answer. Reaper выбирает Ringing под блокировкой (skip-locked) и переводит в Missed только по guard Status = Ringing. Если answer зафиксировал Answered до тика — сессия вне выборки; если reaper успел первым — answer на MissedMSG_CALL_INVALID_STATE.
  • Kafka. 1:1-звонок интеграционных событий не публикует — ретраев/DLQ по шине нет.
  • Realtime-push. CallInvite/CallSignal — persist-then-push, at-least-once после фиксации перехода; потеря push безопасна: клиент при reconnect перезапрашивает журнал list-calls (REST — источник истины). Токен входа минтится в момент доставки, не хранится.

Кросс-сервисные контракты и промежуточные состояния

Ожидает (входные реплики/гарантии):

  • Реплика пользователей (pin.identity.users.v1) — адресация hub-групп user:{id}; при устаревании реплики адресация всё равно возможна по id, гейт остаётся контактным.
  • Контакт пары Accepted + отсутствие блокировки — локальный гейт CommunicationPolicy.CanCall (contact.md, blacklist.md, контекст messenger).
  • media-стек (SFU): резерв комнаты при старте, минт короткоживущего токена входа; медиа-негоциация клиент ↔ SFU вне БД (INV-CALL-4).

Предоставляет:

  • Наружу интеграционных Kafka-событий нет (person-to-person). Если 1:1-звонок писался, готовность записи публикует агрегат записи (pin.messenger.call-recording-available.v1, UC-MSG-007), а не сессия звонка.
  • Журнал list-calls (входящие/исходящие/пропущенные) — источник истины для UI.

Промежуточные состояния (окно eventual-consistency):

  • После 201 инициатор видит Ringing; приглашение доходит до callee через диспетчер realtime (sub-second … секунды). Пока не доставлено — callee не видит вызов; при потере push сработает ring-timeout (Missed).
  • После end/decline push отбоя доставляется асинхронно; вторая сторона узнаёт о завершении из push либо при reconnect из list-calls.

Наблюдаемость и НФТ

  • Целевая нагрузка мала (ADR-0056): звонки редки, сигналинг-операции — единицы/сек в пике; латентность старта/ответа — сотни мс (бэкенд вне медиа-плоскости).
  • Логи: call start/answer/decline/end (CallId, участники, Status, EndReason, DurationSeconds); токены доступа/Credential/SDP/ICE — никогда в логи (INV-CALL-4/6).
  • Метрики: messenger_call_start_total{result}, messenger_call_end_total{result}, messenger_call_duration_seconds, backlog reaper-а messenger_call_reaper_active_backlog; SLO точности Missed — ≤ StartedAt + RingTimeout + Interval.
  • Доступ: permission messenger.calls.use; участник — только Caller/Callee (record-level).

Acceptance Criteria

#КритерийПроверка
AC1Звонок разрешён только Accepted-контакту вне чёрного спискаТест start-call: не контакт → 409 NOT_CONTACT; блок → 409 BLOCKED
AC2Цикл статусов: Ringing→Answered→Ended с DurationSeconds; недопустимый переход → INVALID_STATEТест: answer из Ended409
AC3Неответ 45 с → Missed фоновым reaper идемпотентноТест A2: Ringing без ответа → call-timeout-reaperMissed, push инициатору
AC4Вход раздаётся MediaJoinModel (start/answer/media-token); SDP/ICE/токены не персистятся, хаб их не релеитТест: call_sessions без SDP/токенов; start/answer возвращают Room; медиа клиент ↔ SFU
AC5Один активный звонок на пару (INV-CALL-5)Тест A4: второй start-call при активном → 409 ALREADY_ACTIVE
AC6Kafka-событий нет; журнал list-calls (входящие/исходящие/пропущенные)Тест: Kafka-outbox пуст; list-calls отдаёт статусы
AC7Ранняя отмена: Caller на RingingEnded (HangupCaller, DurationSeconds = null); Callee end на Ringing403 FORBIDDENТест A5: Caller end(Ringing) → Ended; Callee end(Ringing) → MSG_CALL_FORBIDDEN
AC8Зависший Answered без прогресса > StaleAnsweredTimeoutFailed (Error) reaper-ом идемпотентноТест A6: Answered устарел → Failed, push обеим сторонам; повтор reaper без второго события
AC9Побеждает первый зафиксированный переход: гонка answer ↔ reaper — проигравший видит MSG_CALL_INVALID_STATEТест: reaper→Missed до answer → answer возвращает 409 INVALID_STATE

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

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

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