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
| Поле | Значение |
|---|---|
| ID | UC-MSG-005 |
| Контекст | messenger (см. docs/06-services/messenger/) |
| Тип | основной (+ A1 отклонён, A2 неответ→Missed, A3 не контакт/блок, A4 уже активный звонок, A5 отмена исходящего до ответа, A6 сбой сигналинга/медиа → Failed) |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Инициатор (Caller) и вызываемый (Callee) — оба User, стороны Accepted-контакта |
| Permissions / scope | JWT 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.
- Инициация.
Callerжмёт «Позвонить» и отправляет{ CalleeUserId, MediaType }; инициатор (CallerUserId) берётся из токена. - Гейт 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). - Создание сессии + комната. Создаётся сессия звонка:
Status = Ringing,StartedAt = now, детерминированноеRoom.RoomName(SFU, ADR-0062), append-only лог; поднимается доменноеCallStartedEvent. ОтветstartнесётRoom: MediaJoinModelинициатору; токены/ICE в БД не пишутся (INV-CALL-4). - Persist + push invite. Сессия и строка realtime-outbox
CallInviteфиксируются вместе; далееCallInvite(RealtimeCallInviteсRoom: MediaJoinModel) пушится вuser:{CalleeUserId}через realtime-dispatch — токен доступа минтится в момент доставки, не хранится (INV-CALL-4). SDP/ICE хаб не переносит. - Ответ callee.
Calleeпринимает (answer-call.md): актор =CalleeUserId,Status = Ringing(INV-CALL-3). Сессия переходитRinging → Answered,AnsweredAt = now; ответanswerнесётRoom: MediaJoinModelвызываемому; поднимаетсяCallAnsweredEvent. - Медиа клиент ↔ SFU. После
Answeredобе стороны входят в комнату SFU нативным клиентом поMediaJoinModel; медиа-негоциация (SDP/ICE) и поток идут клиент ↔ media-стек, вне хаба и БД (INV-CALL-4). Токен обновляется заранееPOST /calls/{id}/media-token(MediaTokenModel); lifecycle-сигналы идут черезCallSignal(без медиа). - Завершение. Любая сторона кладёт трубку (end-call.md): сессия переходит
Answered → Ended(EndReason = HangupCaller|HangupCallee),EndedAt = now,DurationSeconds = EndedAt − AnsweredAt; поднимаетсяCallEndedEvent; фиксация + pushcall_endedобеим сторонам. - Журнал. Сессия остаётся в логе (
list-calls: входящие/исходящие/пропущенные, call-session.md); Kafka-события звонок не публикует; уведомление о пропущенном (системная строка) — отложено с media.
Диаграмма
Альтернативные Потоки
A1. Callee отклонил
Calleeжмёт «Отклонить» (decline-call.md): актор =Callee,Status = Ringing. Сессия переходитRinging → Declined,EndReason = Declined; поднимаетсяCallDeclinedEvent→ pushCallSignal(Declined)обеим сторонам.DurationSeconds = null.
A2. Неответ → Missed (таймаут)
Calleeне отвечает:Status = Ringing,now − StartedAt > RingTimeout(45 с, INV-CALL-2).- call-timeout-reaper (цикл 15 с) выбирает сессию под блокировкой
(FOR UPDATE SKIP LOCKED) и переводит
Ringing → Missed,EndReason = Timeout; pushcall_missedинициатору. Идемпотентно (guardWHERE Status='Ringing').
A3. Не контакт / в чёрном списке
- Контакт пары не
Accepted→MSG_CALL_NOT_CONTACT(409); либоCanCall = denyиз-за блокировки →MSG_CALL_BLOCKED(409, INV-CALL-1). Сессия не создаётся.
A4. Уже есть активный звонок на пару
- Для пары
(Caller, Callee)уже есть активный звонокRinging/Answered(ix_call_active) →MSG_CALL_ALREADY_ACTIVE(409, INV-CALL-5). Клиент предлагает присоединиться/подождать; новая сессия не создаётся.
A5. Отмена исходящего до ответа (Caller кладёт трубку на Ringing)
- Пока звонок
Ringing, инициатор жмёт «Отмена» (end-call.md): актор =Caller, допустимый источник —Ringing ∧ actor == Caller(INV-CALL-3). Сессия переходитRinging → Ended,EndReason = HangupCaller,EndedAt = now,DurationSeconds = null; поднимаетсяCallEndedEvent→ pushCallSignal(Ended)обеим сторонам. - Отбой
CalleeнаRinging— это A1 (Declined), не отмена: попыткаCalleeзавершитьRinging-звонок через end →MSG_CALL_FORBIDDEN(403). Новых статусов/значенийCallEndReasonне вводится.
A6. Сбой сигналинга/медиа → Failed
- Фатальный сбой сигналинга/поднятия комнаты до соединения переводит
Ringing → Failed,EndReason = Error(INV-CALL-3, stateDiagram call-session.md); сессия терминальна,DurationSeconds = null. (Отказ резерва комнаты на самом старте — этоMSG_RECORDING_PROVISION_FAILEDв start-call.md, сессия при этом не создаётся вовсе.) - Обе стороны отвалились без hangup на принятом звонке:
Answeredбез прогресса сигналинга дольшеStaleAnsweredTimeout(default 2 ч) — фоновый call-timeout-reaper выбирает сессию под блокировкой (FOR UPDATE SKIP LOCKED) и переводитAnswered → Failed,EndReason = Error,DurationSeconds = null; pushCallSignal(Ended)обеим сторонам. Идемпотентно (guardWHERE Status='Answered').
Постусловие
- Создана
CallSessionEntityс корректным терминальным статусом (Ended/Declined/Missed/Failed) и фазовыми метками (StartedAt/AnsweredAt?/EndedAt?);DurationSeconds— только приAnswered → Ended. - SDP/ICE, медиа и токены доступа в БД отсутствуют (INV-CALL-4); журнал доступен в
list-calls. - Realtime-push (
CallInvite,CallSignalAnswered/Declined/Ended/Missed) доставлен участникам; Kafka-событий нет.
Инварианты, статусы и валидации
Переходы CallStatus (полный перечень)
| From | To | Условие перехода | Инициатор |
|---|---|---|---|
[*] | Ringing | CanCall (INV-CALL-1) ∧ нет активного звонка пары (INV-CALL-5) | Caller (start) |
Ringing | Answered | callee принял, источник Status = Ringing (INV-CALL-3) | Callee (answer) |
Ringing | Declined | callee отклонил | Callee (decline, A1) |
Ringing | Ended | ранняя отмена исходящего (DurationSeconds = null) | Caller (end, A5) |
Ringing | Missed | now − StartedAt > RingTimeout (45 с, INV-CALL-2) | reaper (A2) |
Ringing | Failed | фатальный сбой сигналинга до соединения (INV-CALL-3) | система (A6) |
Answered | Ended | hangup любой стороной, DurationSeconds = EndedAt − AnsweredAt | Caller/Callee (end) |
Answered | Failed | обе стороны пропали без hangup > StaleAnsweredTimeout | reaper (A6) |
Терминальные: Ended/Declined/Missed/Failed. EndReason обязателен при терминале кроме Missed
(Missed подразумевает Timeout).
Инварианты (полный перечень)
| Инвариант | Смысл |
|---|---|
| INV-CALL-1 | Звонок разрешён только Accepted-контакту пары вне чёрного списка (сводный гейт CommunicationPolicy.CanCall). |
| INV-CALL-2 | Ringing без ответа дольше RingTimeout (45 с) → Missed фоновым reaper-ом. |
| INV-CALL-3 | Допустимы только перечисленные переходы; недопустимый источник answer/decline/end → MSG_CALL_INVALID_STATE. |
| INV-CALL-4 | SDP/ICE/медиа не релеятся и не персистятся: негоциация клиент ↔ SFU, в БД — только факты жизненного цикла. |
| INV-CALL-5 | Не более одного активного (Ringing/Answered) звонка на пару (Caller, Callee) одновременно. |
| INV-CALL-6 | Room.RoomName детерминирован и уникален среди активных; токены доступа/ICE эфемерны, минтятся при входе, не хранятся и не логируются. |
Валидации входа старта
CalleeUserId != Emptyи!= CallerUserId— иначеMSG_CALL_SELF(400).MediaType ∈ {Audio, Video}— иначе ошибка валидации (400).- callee существует и активен — иначе
MSG_CALL_USER_NOT_FOUND(404).
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
MSG_CALL_NOT_CONTACT | 409 | контакт не Accepted (A3, INV-CALL-1) | «Звонки доступны только подтверждённым контактам» |
MSG_CALL_BLOCKED | 409 | блокировка любой стороной (A3, INV-BL-3) | Скрыть «позвонить» |
MSG_CALL_ALREADY_ACTIVE | 409 | активный Ringing/Answered на пару (A4, INV-CALL-5) | «Звонок уже идёт» |
MSG_CALL_INVALID_STATE | 409 | answer/decline/end в недопустимом статусе (INV-CALL-3) | Обновить состояние по hub/REST |
MSG_CALL_NOT_FOUND | 404 | сессия не найдена / актор не участник | «Звонок не найден» |
MSG_CALL_FORBIDDEN | 403 | актор ∉ {Caller,Callee} (record-level) | «Нет доступа к звонку» |
MSG_CALLS_DISABLED | 503 | CALLS_MEDIA_ENABLED = false (медиа выключено) | «Звонки временно недоступны» |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
CallStartedEvent | [*] → Ringing | push CallInvite (комната + токен при доставке) в user:{Callee} |
CallAnsweredEvent | → Answered | push CallSignal(Answered) обеим сторонам; вход в комнату SFU |
CallDeclinedEvent | → Declined (A1) | push CallSignal(Declined) обеим сторонам |
CallMissedEvent | → Missed (A2, reaper) | push CallSignal(Missed) инициатору |
CallEndedEvent | → Ended | push 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только по guardStatus = Ringing. ЕслиanswerзафиксировалAnsweredдо тика — сессия вне выборки; если reaper успел первым —answerнаMissed→MSG_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 из Ended → 409 |
| AC3 | Неответ 45 с → Missed фоновым reaper идемпотентно | Тест A2: Ringing без ответа → call-timeout-reaper → Missed, 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 |
| AC6 | Kafka-событий нет; журнал list-calls (входящие/исходящие/пропущенные) | Тест: Kafka-outbox пуст; list-calls отдаёт статусы |
| AC7 | Ранняя отмена: Caller на Ringing → Ended (HangupCaller, DurationSeconds = null); Callee end на Ringing → 403 FORBIDDEN | Тест A5: Caller end(Ringing) → Ended; Callee end(Ringing) → MSG_CALL_FORBIDDEN |
| AC8 | Зависший Answered без прогресса > StaleAnsweredTimeout → Failed (Error) reaper-ом идемпотентно | Тест A6: Answered устарел → Failed, push обеим сторонам; повтор reaper без второго события |
| AC9 | Побеждает первый зафиксированный переход: гонка answer ↔ reaper — проигравший видит MSG_CALL_INVALID_STATE | Тест: reaper→Missed до answer → answer возвращает 409 INVALID_STATE |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (1): UC-MSG-006. Групповой звонок-конференция в чате сделки
- calls (1): Jitsi-звонки: план верификации и приёмки
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API (дом алгоритма/маппинга): start-call.md, answer-call.md, decline-call.md, end-call.md, list-calls.md, media-token.md (обновление токена входа).
- Domain: call-session.md (INV-CALL-1…5,
CallStatus/CallEndReason, SDP/ICE/токены вне БД), contact.md (INV-CT-5 право звонка), blacklist.md (CommunicationPolicy.CanCall). - Realtime: messenger-hub.md (
CallInvite/CallSignal,MediaJoinModel); диспетчер — realtime-dispatch.md; фоновый таймаут — call-timeout-reaper.md. - Групповой аналог: UC-MSG-006; запись сессии сделки — UC-MSG-007.
- Permission-каталог: permission.md (
messenger.calls.use). - ADR: ADR-0062 (media-стек и запись, supersedes ADR-0055 §2.4), ADR-0055 (мини-мессенджер), ADR-0056.