UC-MSG-006. Групповой звонок-конференция в чате сделки
Назначение
Описывает сценарий: агент/менеджер запускает групповой видеозвонок (conference-session.md)
поверх чата сделки (chat.md, Kind = Deal); другие участники чата присоединяются, кто-то
демонстрирует экран, кто-то выходит, модератор завершает встречу для всех. В отличие от 1:1-звонка
(UC-MSG-005, пара Caller/Callee) конференция имеет динамический ростер
(call-participant.md), одного модератора и авто-завершение по опустошению.
Гейт доступа — активное членство в Deal-чате (INV-CHAT-4 / INV-CONF-3), отдельного контактного гейта нет.
Медиа (звук/видео/демонстрация экрана) идёт клиент ↔ media-стек (SFU, ADR-0062) — бэкенд не релеит SDP/ICE;
вход в комнату раздаётся дескриптором MediaJoinModel (комната + короткоживущий
токен). Ростер формируется командами join/leave и сверяется presence-вебхуком media-стека (prosody,
server-side — авторитетнее клиентских событий, INV-CPART-5). Завершение публикует интеграционное
pin.messenger.conference-ended.v1; осиротевшую пустую комнату закрывает
conference-room-reaper. Полный алгоритм и маппинг — в API-методах
start-conference.md и смежных. Связан с контекстом messenger.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-MSG-006 |
| Контекст | messenger (см. docs/06-services/messenger/) |
| Тип | основной (+ A1 присоединение, A2 демонстрация экрана, A3 выход участника, A4 завершение модератором, A5 авто-завершение reaper, A6 комната не поднялась → Failed, A7 закрытие Deal-чата) |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Инициатор-модератор (Агент/МенеджерПИН) и участники — все User, активные участники Deal-чата |
| Permissions / scope | JWT identity; messenger.conferences.use; доступ — по членству в чате (не по scope); guest → 401 |
| Tenant | не применяется строково — доступ по членству в Deal-чате (INV-CHAT-4); DealTenantId — только scope маршрутизации события |
| Record-level | доступ = актор ∈ активных ParticipantEntity Deal-чата (INV-CHAT-4); модераторские действия — ConferenceRole = Moderator |
Предусловие
- Существует Deal-чат (chat.md,
Kind = Deal,Status != Closed); актор — активныйParticipantEntity(RemovedAt is null, INV-CHAT-4). - Feature flag
CONFERENCE_CALLS_ENABLED = true(иначеMSG_CONFERENCES_DISABLED, 503); клиенты подключены к hub/hubs/messengerи подписаны на группуchat:{ChatId}(JoinChat). - Нет уже активной (
Provisioning/Active) конференции на этот чат (INV-CONF-2).
Триггер
| Поле | Значение |
|---|---|
| Источник | UI (кнопка «Видеозвонок» в шапке чата сделки) → API call + hub ConferenceInvite |
| Триггерящий элемент | POST /api/messenger/chats/{chatId}/conferences (start) → POST /conferences/{id}/join|leave|end |
Основной Поток
Нить — успешная групповая видеовстреча по сделке. Полный алгоритм и маппинг — в start-conference.md; ниже — поток на уровне use-case.
- Старт. Модератор жмёт «Видеозвонок» и отправляет
POST /chats/{chatId}/conferences { MediaType, Title? }; инициатор (StartedByUserId) берётся из токена. - Гейт членства. Актор — активный участник Deal-чата (иначе
MSG_CHAT_ACCESS_DENIED, 403, INV-CONF-3); чат имеетKind = Deal(иначеMSG_CONFERENCE_CHAT_INVALID, 409, INV-CONF-1); нет активной конференции на чат (иначеMSG_CONFERENCE_ALREADY_ACTIVE, 409, INV-CONF-2). - Провизия комнаты. Создаётся конференция:
Status = Provisioning, детерминированноеRoom.RoomName(уникально среди активных, INV-CONF-5), инициатор заносится в ростер какConferenceRole = Moderator,State = Joined(call-participant.md); поднимаетсяConferenceStartedEvent. Токены доступа/ICE/SDP в БД не пишутся (INV-CONF-7). Если сессия попадает подRecordingPolicy— авто-запись провизится параллельно (UC-MSG-007). - Приглашение + вход инициатора. Конференция и строка realtime-outbox
ConferenceInviteфиксируются вместе; далееConferenceInvite(RealtimeConferenceInviteсRoom: MediaJoinModel) пушится в группуchat:{ChatId}через realtime-dispatch — токен доступа минтится в момент доставки, не хранится. Инициатор входит в комнату SFU нативным клиентом. - Подтверждение комнаты. media-стек подтверждает поднятие комнаты (admission-вебхук
jitsi-events); конференция переходит
Provisioning → Active,StartedAt = now(иначе —Failed/ProvisionTimeout, см. A6). - Присоединение участников. Другие участники чата вызывают
POST /conferences/{id}/join(A1): ростер получает/реактивирует строку (State = Joined,JoinedAt, INV-CPART-1),ActiveParticipantCount/PeakParticipantCountпересчитываются; ответ несётRoom: MediaJoinModel; pushMediaParticipant(Joined)вchat:{ChatId}. - Демонстрация экрана. Участник включает демонстрацию (A2):
ScreenShareState = Sharing; pushScreenShareState(Started). Живое состояние микрофона/камеры — у media-стека, в БД не хранится. - Presence-сверка ростера. prosody presence-вебхук (jitsi-events) server-side подтверждает фактический состав комнаты; расхождение с командами join/leave разрешается в пользу серверного события (INV-CPART-5) — ростер авторитетен по presence.
- Выход участника. Участник выходит
POST /conferences/{id}/leave(A3) или отключается: строка ростера soft-переходитJoined → Left(LeftAt,LeaveReason,DurationSeconds); pushMediaParticipant(Left);ActiveParticipantCountуменьшается. - Завершение модератором. Модератор жмёт «Завершить для всех»
POST /conferences/{id}/end(A4): актор —ConferenceRole = Moderator(иначеMSG_CONFERENCE_FORBIDDEN, 403); конференция переходитActive → Ended,EndReason = ModeratorEnded,EndedAt/DurationSeconds; активные участники закрываются (LeaveReason = ConferenceEnded); поднимаетсяConferenceEndedEvent. - Факт наружу. Атомарно со сменой состояния публикуется интеграционное
pin.messenger.conference-ended.v1(ключDealId) —crmкладёт карточку «видеовстреча по сделке» в таймлайн,notifications/dev-statsфиксируют факт; pushConferenceSignal(Ended)вchat:{ChatId}. Если велась запись — её готовность придёт отдельным событием (UC-MSG-007). - Журнал. Конференция и ростер остаются в истории (
get-conference/list-conferences, conference-session.md).
Диаграмма
Альтернативные Потоки
A1. Присоединение к идущей конференции
- Участник Deal-чата вызывает
POST /conferences/{id}/join: конференцияActive(иначеMSG_CONFERENCE_NOT_ACTIVE, 409). Ростер получает/реактивирует строку (State = Joined, INV-CPART-1); ответ —ConferenceJoinModel { Conference, Room: MediaJoinModel }; pushMediaParticipant(Joined).
A2. Демонстрация экрана
- Активный участник стартует/останавливает демонстрацию:
ScreenShareState = Sharing/None(INV-CPART-4); pushScreenShareState(Started|Stopped)вchat:{ChatId}. Медиа-трек — у SFU, вне БД.
A3. Участник вышел / отключился
POST /conferences/{id}/leave(self) или presence-вебхук о разрыве: строкаJoined → Left(soft,LeaveReason = SelfLeft/Disconnected,DurationSeconds);ActiveParticipantCount--. При выходе модератора роль передаётся старейшему активному участнику (INV-CONF-6); при выходе последнего активного — авто-завершение (A5,LastParticipantLeft).
A4. Модератор завершил для всех
POST /conferences/{id}/endактором сConferenceRole = Moderator(иначеMSG_CONFERENCE_FORBIDDEN, 403):Active → Ended,EndReason = ModeratorEnded; все активные —LeaveReason = ConferenceEnded; событиеconference-ended.v1(ключDealId).
A5. Авто-завершение пустой/зависшей комнаты (reaper)
- Последний активный вышел →
ActiveParticipantCount = 0дольше grace, либоActiveбез presence-активности (LastActivityAt) дольше cap (INV-CONF-8). conference-room-reaper выбирает сессию под блокировкой (FOR UPDATE SKIP LOCKED) и переводитActive → Ended,EndReason = LastParticipantLeft/EmptyTimeout/Timeout; событиеconference-ended.v1. Идемпотентно (guardWHERE Status IN ('Provisioning','Active')).
A6. Комната не поднялась (Provisioning → Failed)
- media-стек не подтвердил комнату в срок (ProvisionTimeout) или фатальный сбой:
Provisioning → Failed,EndReason = Error; pushConferenceSignal(Ended); клиент показывает «Не удалось начать встречу». Если уже была провизирована запись — она уходит вFailed(call-recording.md).
A7. Deal-чат закрыт во время конференции
- Сделка перешла в
Completed/Cancelled→ Deal-чатClosed(INV-CHAT-8): активная конференция закрываетсяActive → Ended,EndReason = ChatClosed; событиеconference-ended.v1.
Постусловие
- Создана
ConferenceSessionEntityс терминальным статусом (Ended/Failed), фазовыми метками (StartedAt/EndedAt) иDurationSeconds; ростер (call-participant.md) — soft-история участия (Joined/Left,DurationSecondsкаждого),PeakParticipantCountзафиксирован. - SDP/ICE/медиа и токены доступа в БД отсутствуют (INV-CONF-7); авторитетный состав сверен presence-вебхуком.
- Опубликовано ровно одно
pin.messenger.conference-ended.v1(ключDealId) —crmполучил карточку сделки; realtime-push (ConferenceInvite/MediaParticipant/ScreenShareState/ConferenceSignal) доставлен.
Инварианты, статусы и валидации
Переходы ConferenceStatus (полный перечень)
| From | To | Условие перехода | Инициатор |
|---|---|---|---|
[*] | Provisioning | членство активно (INV-CONF-3) ∧ Kind = Deal (INV-CONF-1) ∧ нет активной конференции на чат (INV-CONF-2) | Модератор (start) |
Provisioning | Active | media-стек подтвердил комнату (admission-вебхук) | media-стек |
Provisioning | Failed | комната не поднялась в срок (ProvisionTimeout, Error) | система (A6) |
Active | Ended | вышел последний активный (LastParticipantLeft) | reaper/участник (A5) |
Active | Ended | модератор завершил (ModeratorEnded) | Модератор (A4) |
Active | Ended | Deal-чат закрыт (ChatClosed, INV-CHAT-8) | crm→чат (A7) |
Active | Ended | пусто > grace (EmptyTimeout) / stale > cap (Timeout) | reaper (A5) |
Active | Failed | фатальный сбой media (Error) | система |
Ростер (CallParticipantState, call-participant.md):
Invited → Joined → Left; Left → Joined при реконнекте (реактивация, INV-CPART-1); Invited → Declined.
Инварианты (полный перечень)
| Инвариант | Смысл |
|---|---|
| INV-CONF-1 | Конференция привязана ровно к одному чату Kind = Deal; для 1:1 — UC-MSG-005. |
| INV-CONF-2 | Не более одной активной (Provisioning/Active) конференции на чат. |
| INV-CONF-3 | Старт/join = активный участник Deal-чата (record-level, INV-CHAT-4); отдельного контактного гейта нет. |
| INV-CONF-4 | Допустимы только перечисленные переходы; недопустимый — MSG_CONFERENCE_INVALID_STATE. |
| INV-CONF-5 | Room.RoomName уникален среди активных; токены/ICE/медиа не персистятся. |
| INV-CONF-6 | Ровно один активный модератор; при выходе модератора роль передаётся старейшему активному. |
| INV-CONF-7 | В БД нет медиа/сигналинга/PII и токенов доступа — только факты жизненного цикла и ростер. |
| INV-CONF-8 | ActiveParticipantCount/PeakParticipantCount — производные от активных строк ростера; пустая Active > grace → reaper закрывает. |
| INV-CPART-1 | Уникальность активного (конференция, пользователь): повторный вход реактивирует строку, а не дублирует. |
| INV-CPART-5 | Ростер авторитетен по presence: расхождение команд join/leave и presence-вебхука разрешается в пользу серверного события. |
Валидации входа
- Deal-чат существует и
Status != Closed; иначеMSG_CHAT_NOT_FOUND/ переход вChatClosed(A7). Kind = Deal— иначеMSG_CONFERENCE_CHAT_INVALID(409, INV-CONF-1).- Актор — активный участник чата (
RemovedAt is null) — иначеMSG_CHAT_ACCESS_DENIED(403). MediaType ∈ {Audio, Video};Title≤ 120 символов (опционально).
Конкурентность, идемпотентность и восстановление
- Одна активная на чат (INV-CONF-2). Гонка двух
startна один чат разрешается партиал-уникальным индексом активных конференций: одна переходит вProvisioning, вторая →MSG_CONFERENCE_ALREADY_ACTIVE(409) с предложением join. - Авторитет ростера (INV-CPART-5). Presence-вебхук media-стека сверяет команды join/leave; порядок
применения — по монотонному
OccurredAt; устаревшее/повторное событие — no-op, состояние не откатывается; расхождение — в пользу серверного события. - Reaper. Авто-завершение по опустошению/зависанию идемпотентно (guard
Status IN ('Provisioning', 'Active')); публикуется ровно одноconference-ended.v1даже при повторном проходе. - Передача модератора (INV-CONF-6). Конкурентные выход модератора и join другого участника сериализуются в границе агрегата конференции (оптимистическая блокировка родителя); проигравший перечитывает состояние.
- Доставка события.
conference-ended.v1— атомарно с переходом состояния (транзакционный outbox), at-least-once; консюмеры (crm/notifications/dev-stats) идемпотентны поConferenceId(first-seen); порядок конференций в рамках сделки — ключDealId. - Provisioning timeout (A6). Если admission не пришёл в срок —
Provisioning → Failed; уже провизированная запись уходит вFailed(UC-MSG-007).
Кросс-сервисные контракты и промежуточные состояния
Ожидает (входные реплики/гарантии):
- От
crmчерез Deal-чат (реплика в chat.md):DealId/DealTenantId,Kind = Deal, активное членство участников (INV-CHAT-4). Устаревание разрешается так: сделкаCompleted/Cancelled→ чатClosed(INV-CHAT-8) закрывает активную конференцию (A7,ChatClosed). На Deal-чатеDealIdзадан по инварианту чата — без него конференция не стартует. - От media-стека: admission-вебхук (
Provisioning → Active), presence-вебхуки (авторитет ростера, старт/стоп записи), резерв комнаты.
Предоставляет:
pin.messenger.conference-ended.v1— топик.v1,ContractVersion = 1, 3 партиции, ключDealId. Payload:ConferenceId/ChatId/DealId/TenantId/MediaType/Status/EndReason/StartedByUserId/StartedAt/EndedAt/DurationSeconds/PeakParticipantCount/Participants[]/WasRecorded/RecordingId?— без ФИО/медиа/токенов. Консюмеры:crm(карточка «видеовстреча по сделке», SLA < 1 мин),notifications,dev-stats(conference-ended.md).- Realtime:
ConferenceInvite/MediaParticipant/ScreenShareState/ConferenceSignalвchat:{ChatId}(non-PII).
Промежуточные состояния (окно eventual-consistency):
startвозвращаетProvisioning; участник видит «подключение» до admission (→ Active). Если admission не пришёл в срок →Failed(A6), клиент показывает «Не удалось начать встречу».- Ростер до presence-сверки может кратко расходиться с фактическим составом; presence авторитетно исправляет (INV-CPART-5); демонстрация экрана — best-effort до сверки.
- Карточка «видеовстреча» в
crmпоявляется с задержкой (окно консюмера < 1 мин) послеEnded. Готовность записи — отдельным, более поздним событием (UC-MSG-007), не вconference-ended(в нём лишьWasRecorded/RecordingId).
Наблюдаемость и НФТ
- Групповые звонки редки (ADR-0056), десятки участников максимум; счётчики/ростер — производные от активных строк.
- Логи: старт/join/leave/end (
ConferenceId,ChatId,UserId,Status,EndReason,PeakParticipantCount); токены/SDP/ICE — не логируются (INV-CONF-7). - Метрики: активные конференции, roster drift (presence vs команды), длительность, доля авто-завершений
reaper-ом; alert на всплеск
Failed/ProvisionTimeout. - Доступ: permission
messenger.conferences.use; членство в Deal-чате (record-level); модераторские действия —ConferenceRole = Moderator.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
MSG_CONFERENCES_DISABLED | 503 | CONFERENCE_CALLS_ENABLED = false | «Групповые звонки временно недоступны» |
MSG_CHAT_NOT_FOUND | 404 | Deal-чат не найден | «Чат не найден» |
MSG_CHAT_ACCESS_DENIED | 403 | актор не активный участник чата (INV-CONF-3) | Скрыть «видеозвонок» |
MSG_CONFERENCE_CHAT_INVALID | 409 | чат не Kind = Deal (INV-CONF-1) | Для 1:1 — обычный звонок (UC-MSG-005) |
MSG_CONFERENCE_ALREADY_ACTIVE | 409 | уже активна конференция на чат (INV-CONF-2) | «Встреча уже идёт» → предложить join |
MSG_CONFERENCE_NOT_FOUND | 404 | конференция не найдена / актор не участник чата | «Встреча не найдена» |
MSG_CONFERENCE_NOT_ACTIVE | 409 | join/leave в статусе Provisioning/Ended/Failed (INV-CONF-4) | Обновить состояние по hub/REST |
MSG_CONFERENCE_INVALID_STATE | 409 | недопустимый переход состояния (INV-CONF-4) | Обновить состояние |
MSG_CONFERENCE_FORBIDDEN | 403 | end/остановка не модератором (INV-CONF-6) | Скрыть «завершить для всех» |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
ConferenceStartedEvent | [*] → Provisioning/Active | push ConferenceInvite (комната + токен при доставке) в chat:{ChatId} |
ConferenceParticipantJoinedEvent | активный вошёл (A1) | push MediaParticipant(Joined) в chat:{ChatId} |
ConferenceParticipantLeftEvent | участник вышел (A3) | push MediaParticipant(Left) |
ConferenceScreenShareChangedEvent | старт/стоп демонстрации (A2) | push ScreenShareState |
ConferenceModeratorChangedEvent | передача роли модератора (INV-CONF-6) | push ConferenceSignal |
ConferenceEndedEvent | → Ended/Failed (A4/A5/A6/A7) | доменный push ConferenceSignal(Ended) + интеграционное событие |
Все — доменные события; hub-push несёт non-PII (ConferenceId/ChatId/Kind/UserId). Токены доступа
минтятся при доставке инвайта, вне БД (INV-CONF-7).
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
ConferenceEndedEvent | pin.messenger.conference-ended.v1 | атомарно с Active → Ended/Failed (ключ DealId) | crm (карточка сделки), notifications, dev-stats |
Публикация — через транзакционный outbox (persist-then-push). Готовность записи — отдельное событие
pin.messenger.call-recording-available.v1 (UC-MSG-007). Контракт —
conference-ended.md.
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Старт/join только активному участнику Deal-чата; вне чата → 403 CHAT_ACCESS_DENIED | Тест start/join: не участник → 403; guest → 401 |
| AC2 | Не более одной активной конференции на чат (INV-CONF-2) | Тест: второй start при активной → 409 ALREADY_ACTIVE |
| AC3 | Вход раздаётся MediaJoinModel (комната + токен), SDP/ICE не релеятся хабом | Тест: start/join возвращают Room; хаб SDP не переносит; токен не в БД/outbox |
| AC4 | Ростер авторитетен по presence: расхождение join/leave и presence-вебхука — в пользу вебхука (INV-CPART-5) | Тест A3: presence «вышел» без REST-leave → строка Left |
| AC5 | end только модератором; при выходе модератора роль передаётся (INV-CONF-6) | Тест A4: не модератор → 403 FORBIDDEN; выход модератора → передача роли |
| AC6 | Опустошение/зависание → авто-завершение reaper идемпотентно; ровно одно conference-ended.v1 | Тест A5: пусто > grace → EmptyTimeout; повтор reaper без дубля события |
| AC7 | Завершение публикует conference-ended.v1 (ключ DealId) с ростером и PeakParticipantCount | Тест: outbox содержит событие; crm кладёт карточку сделки |
| AC8 | Комната не поднялась в срок → Provisioning → Failed; провизированная запись → Failed (A6) | Тест: нет admission → Failed, conference-ended.v1 с Status = Failed, запись Failed |
| AC9 | Закрытие Deal-чата под активной конференцией → Ended (ChatClosed) с событием (A7) | Тест: сделка Completed → чат Closed → конференция Ended, ключ DealId |
| AC10 | conference-ended не несёт готовность записи — только WasRecorded/RecordingId; готовность — отдельным событием | Тест: payload conference-ended без FileId/URL; запись приходит call-recording-available.v1 |
Обратные ссылки
Автоссылки: где используется этот документ.
- Use Cases (2): UC-MSG-005. Сигналинг аудио/видео-звонка, UC-MSG-007. Авто-запись звонка по сделке и её выдача участнику
- calls (1): Jitsi-звонки: план верификации и приёмки
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API (дом алгоритма/маппинга): start-conference.md, join-conference.md, leave-conference.md, end-conference.md, get-conference.md, list-conferences.md, conference-media-token.md.
- Domain: conference-session.md (INV-CONF-1…8,
ConferenceStatus/ConferenceEndReason), call-participant.md (ростер, INV-CPART-1…5,ConferenceRole/CallParticipantState/ScreenShareState), chat.md (INV-CHAT-4/8), call-recording.md (запись сессии). - Realtime: messenger-hub.md (
ConferenceInvite/MediaParticipant/ScreenShareState/ConferenceSignal,MediaJoinModel); presence-вебхук media-стека: jitsi-events.md; фоновый reaper: conference-room-reaper.md. - События: conference-ended.md; запись — UC-MSG-007.
- Permission-каталог: permission.md (
messenger.conferences.use). - ADR: ADR-0062 (media-стек и запись), ADR-0055 (мини-мессенджер), ADR-0056.