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) с расчётом длительности или отменить исходящий до ответа
(инициатор кладёт трубку на Ringing → Cancelled). Пишет журнал звонка (длительность/причина) и уведомляет
вторую сторону об отбое. 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 area | calls (сигналинг) |
| Feature group | calls |
| Статус | draft |
| Документ | docs/06-services/messenger/api/calls/end-call.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User — участник звонка (Caller или Callee) |
| Auth policy / Permissions | permission messenger.calls.use |
| Scope (RBAC) | платформа (person-level) |
| Record-level | доступ по membership: actor.UserId ∈ {CallerUserId, CalleeUserId}; на Ringing завершать (отменять) может только Caller |
| Tenant isolation | не применяется (ADR-0052); изоляция — по Caller/Callee |
| Audit | update: переход → Ended (из Answered) / → Cancelled (из Ringing инициатором), метки EndedAt/EndReason/DurationSeconds (иммутабельный факт лога) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/messenger/calls/{callId}/end |
| Success status | 200 (OK) |
| Idempotency header | не применяется — повтор ловит guard состояния (шаг 4): второй End уже терминален → MSG_CALL_INVALID_STATE |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
CallId | route | Guid | Да | != Empty; существующая сессия в Answered (обе стороны) или Ringing (только Caller), актор — участник (шаги 3–4) | — |
ActorUserId | identity claims | Guid | Да | из JWT; ∈ {Caller, Callee} | — |
Тела запроса нет. EndReason не передаётся клиентом — выводится сервером из ActorUserId и статуса-источника
(HangupCaller/HangupCallee из Answered; CancelledByCaller из Ringing инициатором), чтобы журнал не
подделывался.
Модель ответа CallSessionModel
Проекция CallSessionEntity (дом — сущность). Дельта: терминальный Ended
(из Answered) либо Cancelled (ранняя отмена инициатором) + журнал длительности.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
CallId | Guid | Да | CallSessionEntity.Id | — | Идентификатор сессии. |
SessionKind | SessionKind | Да | computed | — | Всегда Direct. |
CallerUserId | Guid | Да | CallSessionEntity.CallerUserId | — | Инициатор. |
CalleeUserId | Guid | Да | CallSessionEntity.CalleeUserId | — | Вызываемый. |
MediaType | CallMediaType | Да | CallSessionEntity.MediaType | — | Audio/Video. |
Status | CallStatus | Да | CallSessionEntity.Status | — | Ended (из Answered) / Cancelled (ранняя отмена инициатором) на успехе. |
StartedAt | DateTimeOffset | Да | CallSessionEntity.StartedAt | — | Момент StartCall. |
AnsweredAt | DateTimeOffset? | Нет | CallSessionEntity.AnsweredAt | — | null при ранней отмене Ringing (Cancelled). |
EndedAt | DateTimeOffset | Да | CallSessionEntity.EndedAt | — | Момент завершения/отмены. |
EndReason | CallEndReason | Да | CallSessionEntity.EndReason | — | HangupCaller/HangupCallee (Ended) либо CancelledByCaller (Cancelled). |
DurationSeconds | int? | Нет | CallSessionEntity.DurationSeconds | — | EndedAt − AnsweredAt при Answered → Ended; при Ringing → Cancelled — null (соединения не было). |
RecordingStatus | RecordingStatus? | Нет | CallRecordingEntity.Status | — | Uploading при запрошенной остановке активной записи; иначе прежнее состояние или null. |
RecordingId | Guid? | Нет | CallRecordingEntity.Id | — | Ссылка на запись; null без записи. |
Room (MediaJoinModel) в терминальном ответе не возвращается — соединение закрыто.
Алгоритм
-
Контекст и доступ. Операцию выполняет участник звонка (
User,CallerилиCallee);ActorUserId— из JWT; требуется правоmessenger.calls.use. Медиа-звонки включены флагомCALLS_MEDIA_ENABLED, иначеMSG_CALLS_DISABLED. -
Проверка входа.
CallIdнепустой; иначе ошибка валидации. Тела запроса нет;EndReasonвыводится сервером из актора и статуса-источника (HangupCaller/HangupCalleeизAnswered;CancelledByCallerизRingingинициатором), чтобы журнал не подделывался. -
Загрузка сессии. Сессия по
CallId; нет →MSG_CALL_NOT_FOUND. Контакт/чёрный список не проверяются. -
Право и переход. Актор должен быть участником:
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отCallee→MSG_CALL_FORBIDDEN, ведёт к decline); прочие статусы →MSG_CALL_INVALID_STATE. -
Остановка записи. Если у сессии есть активная (
Pending/Recording)CallRecordingEntity, ей запрашивается остановка захвата: запись переходит вUploadingи финализируется асинхронно (выгрузка файла, готовность приходит webhook-ом рекордера; терминальныйAvailable— вне этого запроса). Отсутствие записи — no-op. -
Намерение доставки. В той же транзакции поднимается доменное событие (
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-звонок не публикует. -
Согласованность. Переход
→ Ended/→ Cancelledфиксируется атомарно; еслиcall-timeout-reaperперевёлRinging → Missedлибо вторая сторона уже завершила звонок, guard источника не выполнится на перечитанном состоянии →MSG_CALL_INVALID_STATE(повтор — no-op на терминале). -
Результат. Терминальная карточка
CallSessionModel(Status = EndedилиCancelled,DurationSeconds,RecordingStatusпри остановленной записи) → HTTP200 OK.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none напрямую от сессии; при наличии записи её готовность позже публикует агрегат записи (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_FAILED | 400 | CallId = Empty | «Проверьте параметры запроса» | no retry |
MSG_CALL_NOT_FOUND | 404 | сессии нет или актор не участник (скрытие) | «Звонок не найден» | no retry |
MSG_CALL_FORBIDDEN | 403 | актор не Caller/Callee, либо Ringing-End от Callee (нужен decline) | «Недостаточно прав для завершения звонка» | no retry |
MSG_CALL_INVALID_STATE | 409 | статус не Answered/Ringing (уже терминален: Ended/Cancelled/Declined/Missed/Failed, INV-CALL-3) | «Звонок уже завершён» | no retry (перечитать) |
MSG_CALLS_DISABLED | 503 | CALLS_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); dashboardpin-messenger.
Acceptance Criteria
- Штатное завершение. Given сессия в
Answered, актор — участник; WhenPOST /end; Then200,Ended,EndReason = HangupCaller/HangupCallee(по актору),DurationSeconds = EndedAt − AnsweredAt(≥ 0),CallSignal(Ended)обеим сторонам. - Ранняя отмена исходящего. Given сессия в
Ringingи актор —Caller; WhenPOST /end; Then200,Status = Cancelled,EndReason = CancelledByCaller,DurationSeconds = null,CallSignal(Cancelled)обеим сторонам (у callee — несостоявшийся входящий). - Callee на Ringing. Given
Ringingи актор —Callee; WhenPOST /end; Then403 MSG_CALL_FORBIDDEN(отбой callee — это decline-call.md). - Остановка активной записи. Given у сессии активная (
Pending/Recording) запись; WhenPOST /end; Then запись →Uploading, финализация асинхронна (Availableвне этого запроса),RecordingStatus = Uploadingв ответе; без записи — no-op. - Уже терминален / вторая сторона завершила. Given статус не
Answered/Ringing; WhenPOST; Then409 MSG_CALL_INVALID_STATE; повтор — no-op на терминале. - Не участник / скрытие. Given актор ∉
{Caller, Callee}или несуществующийCallId; WhenPOST; Then403 MSG_CALL_FORBIDDEN/404 MSG_CALL_NOT_FOUND. - Флаг выключен. Given
CALLS_MEDIA_ENABLED = false; WhenPOST; Then503 MSG_CALLS_DISABLED. - Журнал. На успехе
EndReasonвыводится сервером из актора (не из тела),Roomв терминальном ответе не возвращается; при наличии записи её готовность позже публикуетpin.messenger.call-recording-available.v1.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (5): GET /api/messenger/calls — Журнал звонков (входящие / исходящие / пропущенные), POST /api/messenger/calls — Начать звонок, POST /api/messenger/calls/{callId}/answer — Принять звонок, POST /api/messenger/calls/{callId}/decline — Отклонить звонок, POST /api/messenger/calls/{callId}/media-token — Обновить токен входа в звонок
- Use Cases (1): UC-MSG-005. Сигналинг аудио/видео-звонка
- calls (1): Жизненный цикл сессии звонка — 1:1 и конференция (messenger)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность (дом полей, статусы,
INV-CALL-3,CallEndReason,DurationSeconds, методEnd): call-session.md; запись: call-recording.md. - Соседние операции: start-call.md, answer-call.md, decline-call.md, list-calls.md, media-token.md; контекст: README.
- ADR: 0055 свой мини-мессенджер, 0062 медиа-стек и запись; SDK realtime: 10-signalr-realtime.md.