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

POST /api/messenger/calls/{callId}/end — Завершить звонок (hangup, call-лог)

Любая сторона кладёт трубку. В Answered — переход CallSessionEntity в терминальный Ended (метки EndedAt, EndReason, вычисленный DurationSeconds, INV-CALL-3). В Ringing инициатор кладёт трубку до ответа — переход Cancelled (EndReason = CancelledByCaller; у callee — несостоявшийся входящий). Дом полей, статусы и инварианты — call-session.md.

Media-стек — внешний SFU (ADR-0062). Переход статуса + call-лог длительности/причины + push отбоя; при активной записи — запрос остановки рекордеру (финализация асинхронна). SDP/ICE/токены не персистятся (INV-CALL-4/6). Медиа-звонки за флагом CALLS_MEDIA_ENABLED.

Назначение

Штатно завершить соединение (Answered → Ended) с расчётом длительности или отменить исходящий до ответа (инициатор кладёт трубку на RingingCancelled). Пишет журнал звонка (длительность/причина) и уведомляет вторую сторону об отбое. Owning-сервис — messenger, feature group calls, сценарий «звонок поверх контактов».

Решение контракта: End покрывает два перехода. Answered → Ended (обе стороны, DurationSeconds считается, EndReason = HangupCaller/HangupCallee). Ringing → Cancelled только инициатором (ранняя отмена исходящего, DurationSeconds = null, EndReason = CancelledByCaller) — у callee это выглядит как несостоявшийся входящий (звонок пропал, как пропущенный). Отбой callee на Ringing — это decline-call.md (Declined), не End.

Metadata

ПолеЗначение
Сервис/контекстmessenger
API areacalls (сигналинг)
Feature groupcalls
Статусdraft
Документdocs/06-services/messenger/api/calls/end-call.md

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

ПроверкаЗначение
Actor typeUser — участник звонка (Caller или Callee)
Auth policy / Permissionspermission messenger.calls.use
Scope (RBAC)платформа (person-level)
Record-levelдоступ по membership: actor.UserId ∈ {CallerUserId, CalleeUserId}; на Ringing завершать (отменять) может только Caller
Tenant isolationне применяется (ADR-0052); изоляция — по Caller/Callee
Auditupdate: переход → Ended (из Answered) / → Cancelled (из Ringing инициатором), метки EndedAt/EndReason/DurationSeconds (иммутабельный факт лога)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/messenger/calls/{callId}/end
Success status200 (OK)
Idempotency headerне применяется — повтор ловит guard состояния (шаг 4): второй End уже терминален → MSG_CALL_INVALID_STATE
Correlationсквозной trace через traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
CallIdrouteGuidДа!= Empty; существующая сессия в Answered (обе стороны) или Ringing (только Caller), актор — участник (шаги 3–4)
ActorUserIdidentity claimsGuidДаиз JWT; ∈ {Caller, Callee}

Тела запроса нет. EndReason не передаётся клиентом — выводится сервером из ActorUserId и статуса-источника (HangupCaller/HangupCallee из Answered; CancelledByCaller из Ringing инициатором), чтобы журнал не подделывался.

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

Проекция CallSessionEntity (дом — сущность). Дельта: терминальный Ended (из Answered) либо Cancelled (ранняя отмена инициатором) + журнал длительности.

ПолеТипОбязательностьИсточникMaskingОписание
CallIdGuidДаCallSessionEntity.IdИдентификатор сессии.
SessionKindSessionKindДаcomputedВсегда Direct.
CallerUserIdGuidДаCallSessionEntity.CallerUserIdИнициатор.
CalleeUserIdGuidДаCallSessionEntity.CalleeUserIdВызываемый.
MediaTypeCallMediaTypeДаCallSessionEntity.MediaTypeAudio/Video.
StatusCallStatusДаCallSessionEntity.StatusEnded (из Answered) / Cancelled (ранняя отмена инициатором) на успехе.
StartedAtDateTimeOffsetДаCallSessionEntity.StartedAtМомент StartCall.
AnsweredAtDateTimeOffset?НетCallSessionEntity.AnsweredAtnull при ранней отмене Ringing (Cancelled).
EndedAtDateTimeOffsetДаCallSessionEntity.EndedAtМомент завершения/отмены.
EndReasonCallEndReasonДаCallSessionEntity.EndReasonHangupCaller/HangupCallee (Ended) либо CancelledByCaller (Cancelled).
DurationSecondsint?НетCallSessionEntity.DurationSecondsEndedAt − AnsweredAt при Answered → Ended; при Ringing → Cancellednull (соединения не было).
RecordingStatusRecordingStatus?НетCallRecordingEntity.StatusUploading при запрошенной остановке активной записи; иначе прежнее состояние или null.
RecordingIdGuid?НетCallRecordingEntity.IdСсылка на запись; null без записи.

Room (MediaJoinModel) в терминальном ответе не возвращается — соединение закрыто.

Алгоритм

  1. Контекст и доступ. Операцию выполняет участник звонка (User, Caller или Callee); ActorUserId — из JWT; требуется право messenger.calls.use. Медиа-звонки включены флагом CALLS_MEDIA_ENABLED, иначе MSG_CALLS_DISABLED.

  2. Проверка входа. CallId непустой; иначе ошибка валидации. Тела запроса нет; EndReason выводится сервером из актора и статуса-источника (HangupCaller/HangupCallee из Answered; CancelledByCaller из Ringing инициатором), чтобы журнал не подделывался.

  3. Загрузка сессии. Сессия по CallId; нет → MSG_CALL_NOT_FOUND. Контакт/чёрный список не проверяются.

  4. Право и переход. Актор должен быть участником: ActorUserId ∉ {Caller, Callee}MSG_CALL_FORBIDDEN. Допустимый источник перехода (INV-CALL-3):

    • Status == Answered (любая сторона) → Status = Ended, EndReason = HangupCaller/HangupCallee (по актору), EndedAt; DurationSeconds = EndedAt − AnsweredAt (≥ 0);
    • Status == Ringing ∧ actor == Caller (ранняя отмена исходящего) → Status = Cancelled, EndReason = CancelledByCaller, EndedAt; DurationSeconds = null (соединения не было). У callee это несостоявшийся входящий (звонок пропал).

    Отбой callee на Ringing — это decline-call.md (Declined), не End (Ringing-End от CalleeMSG_CALL_FORBIDDEN, ведёт к decline); прочие статусы → MSG_CALL_INVALID_STATE.

  5. Остановка записи. Если у сессии есть активная (Pending/Recording) CallRecordingEntity, ей запрашивается остановка захвата: запись переходит в Uploading и финализируется асинхронно (выгрузка файла, готовность приходит webhook-ом рекордера; терминальный Available — вне этого запроса). Отсутствие записи — no-op.

  6. Намерение доставки. В той же транзакции поднимается доменное событие (CallEndedEvent из Answered / CallCancelledEvent из Ringing) и пишется строка realtime_outbox (адресаты user:{Caller}, user:{Callee}) — persist-then-push: обеим сторонам уходит CallSignal(Ended) либо CallSignal(Cancelled) (без PII: CallId/Status/DurationSeconds) — обе стороны покидают комнату SFU, у callee гаснет входящий экран. Kafka-событий 1:1-звонок не публикует.

  7. Согласованность. Переход → Ended/→ Cancelled фиксируется атомарно; если call-timeout-reaper перевёл Ringing → Missed либо вторая сторона уже завершила звонок, guard источника не выполнится на перечитанном состоянии → MSG_CALL_INVALID_STATE (повтор — no-op на терминале).

  8. Результат. Терминальная карточка CallSessionModel (Status = Ended или Cancelled, DurationSeconds, RecordingStatus при остановленной записи) → HTTP 200 OK.

Диаграмма

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

ТипДетали
Integration eventsnone напрямую от сессии; при наличии записи её готовность позже публикует агрегат записи (pin.messenger.call-recording-available.v1)
Realtime push (hub)CallSignal(Ended) (из Answered) / CallSignal(Cancelled) (ранняя отмена инициатором) → обеим сторонам (non-PII: CallId/Status/DurationSeconds)
External callsпри активной записи — запрос остановки рекордеру (ADR-0062); финализация асинхронна
Cache / projections / searchсессия выходит из partial ix_call_active; фиксируется в журнале (ix_call_caller/ix_call_callee)
Notificationsпропущенный/завершённый — системная строка чата вне scope MVP

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400CallId = Empty«Проверьте параметры запроса»no retry
MSG_CALL_NOT_FOUND404сессии нет или актор не участник (скрытие)«Звонок не найден»no retry
MSG_CALL_FORBIDDEN403актор не Caller/Callee, либо Ringing-End от Callee (нужен decline)«Недостаточно прав для завершения звонка»no retry
MSG_CALL_INVALID_STATE409статус не Answered/Ringing (уже терминален: Ended/Cancelled/Declined/Missed/Failed, INV-CALL-3)«Звонок уже завершён»no retry (перечитать)
MSG_CALLS_DISABLED503CALLS_MEDIA_ENABLED = false«Звонки временно недоступны»retry позже

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

  • Новые optional поля ответа — additive; смена маршрута/семантики статуса — breaking. Kafka-контракта нет.
  • Logs: call end requested/ended/invalid-state (CallId, участники, DurationSeconds, EndReason); metrics: messenger_call_end_total{result}, messenger_call_duration_seconds (histogram), latency p95; traces — сквозной trace (traceparent); dashboard pin-messenger.

Acceptance Criteria

  • Штатное завершение. Given сессия в Answered, актор — участник; When POST /end; Then 200, Ended, EndReason = HangupCaller/HangupCallee (по актору), DurationSeconds = EndedAt − AnsweredAt (≥ 0), CallSignal(Ended) обеим сторонам.
  • Ранняя отмена исходящего. Given сессия в Ringing и актор — Caller; When POST /end; Then 200, Status = Cancelled, EndReason = CancelledByCaller, DurationSeconds = null, CallSignal(Cancelled) обеим сторонам (у callee — несостоявшийся входящий).
  • Callee на Ringing. Given Ringing и актор — Callee; When POST /end; Then 403 MSG_CALL_FORBIDDEN (отбой callee — это decline-call.md).
  • Остановка активной записи. Given у сессии активная (Pending/Recording) запись; When POST /end; Then запись → Uploading, финализация асинхронна (Available вне этого запроса), RecordingStatus = Uploading в ответе; без записи — no-op.
  • Уже терминален / вторая сторона завершила. Given статус не Answered/Ringing; When POST; Then 409 MSG_CALL_INVALID_STATE; повтор — no-op на терминале.
  • Не участник / скрытие. Given актор ∉ {Caller, Callee} или несуществующий CallId; When POST; Then 403 MSG_CALL_FORBIDDEN / 404 MSG_CALL_NOT_FOUND.
  • Флаг выключен. Given CALLS_MEDIA_ENABLED = false; When POST; Then 503 MSG_CALLS_DISABLED.
  • Журнал. На успехе EndReason выводится сервером из актора (не из тела), Room в терминальном ответе не возвращается; при наличии записи её готовность позже публикует pin.messenger.call-recording-available.v1.

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

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

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