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

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

ПолеЗначение
IDUC-MSG-006
Контекстmessenger (см. docs/06-services/messenger/)
Типосновной (+ A1 присоединение, A2 демонстрация экрана, A3 выход участника, A4 завершение модератором, A5 авто-завершение reaper, A6 комната не поднялась → Failed, A7 закрытие Deal-чата)
Статусdraft

Актор

ПолеЗначение
РольИнициатор-модератор (Агент/МенеджерПИН) и участники — все User, активные участники Deal-чата
Permissions / scopeJWT 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.

  1. Старт. Модератор жмёт «Видеозвонок» и отправляет POST /chats/{chatId}/conferences { MediaType, Title? }; инициатор (StartedByUserId) берётся из токена.
  2. Гейт членства. Актор — активный участник 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).
  3. Провизия комнаты. Создаётся конференция: Status = Provisioning, детерминированное Room.RoomName (уникально среди активных, INV-CONF-5), инициатор заносится в ростер как ConferenceRole = Moderator, State = Joined (call-participant.md); поднимается ConferenceStartedEvent. Токены доступа/ICE/SDP в БД не пишутся (INV-CONF-7). Если сессия попадает под RecordingPolicy — авто-запись провизится параллельно (UC-MSG-007).
  4. Приглашение + вход инициатора. Конференция и строка realtime-outbox ConferenceInvite фиксируются вместе; далее ConferenceInvite (RealtimeConferenceInvite с Room: MediaJoinModel) пушится в группу chat:{ChatId} через realtime-dispatchтокен доступа минтится в момент доставки, не хранится. Инициатор входит в комнату SFU нативным клиентом.
  5. Подтверждение комнаты. media-стек подтверждает поднятие комнаты (admission-вебхук jitsi-events); конференция переходит Provisioning → Active, StartedAt = now (иначе — Failed/ProvisionTimeout, см. A6).
  6. Присоединение участников. Другие участники чата вызывают POST /conferences/{id}/join (A1): ростер получает/реактивирует строку (State = Joined, JoinedAt, INV-CPART-1), ActiveParticipantCount/ PeakParticipantCount пересчитываются; ответ несёт Room: MediaJoinModel; push MediaParticipant(Joined) в chat:{ChatId}.
  7. Демонстрация экрана. Участник включает демонстрацию (A2): ScreenShareState = Sharing; push ScreenShareState(Started). Живое состояние микрофона/камеры — у media-стека, в БД не хранится.
  8. Presence-сверка ростера. prosody presence-вебхук (jitsi-events) server-side подтверждает фактический состав комнаты; расхождение с командами join/leave разрешается в пользу серверного события (INV-CPART-5) — ростер авторитетен по presence.
  9. Выход участника. Участник выходит POST /conferences/{id}/leave (A3) или отключается: строка ростера soft-переходит Joined → Left (LeftAt, LeaveReason, DurationSeconds); push MediaParticipant(Left); ActiveParticipantCount уменьшается.
  10. Завершение модератором. Модератор жмёт «Завершить для всех» POST /conferences/{id}/end (A4): актор — ConferenceRole = Moderator (иначе MSG_CONFERENCE_FORBIDDEN, 403); конференция переходит Active → Ended, EndReason = ModeratorEnded, EndedAt/DurationSeconds; активные участники закрываются (LeaveReason = ConferenceEnded); поднимается ConferenceEndedEvent.
  11. Факт наружу. Атомарно со сменой состояния публикуется интеграционное pin.messenger.conference-ended.v1 (ключ DealId) — crm кладёт карточку «видеовстреча по сделке» в таймлайн, notifications/dev-stats фиксируют факт; push ConferenceSignal(Ended) в chat:{ChatId}. Если велась запись — её готовность придёт отдельным событием (UC-MSG-007).
  12. Журнал. Конференция и ростер остаются в истории (get-conference/list-conferences, conference-session.md).

Диаграмма

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

A1. Присоединение к идущей конференции

  1. Участник Deal-чата вызывает POST /conferences/{id}/join: конференция Active (иначе MSG_CONFERENCE_NOT_ACTIVE, 409). Ростер получает/реактивирует строку (State = Joined, INV-CPART-1); ответ — ConferenceJoinModel { Conference, Room: MediaJoinModel }; push MediaParticipant(Joined).

A2. Демонстрация экрана

  1. Активный участник стартует/останавливает демонстрацию: ScreenShareState = Sharing/None (INV-CPART-4); push ScreenShareState(Started|Stopped) в chat:{ChatId}. Медиа-трек — у SFU, вне БД.

A3. Участник вышел / отключился

  1. POST /conferences/{id}/leave (self) или presence-вебхук о разрыве: строка Joined → Left (soft, LeaveReason = SelfLeft/Disconnected, DurationSeconds); ActiveParticipantCount--. При выходе модератора роль передаётся старейшему активному участнику (INV-CONF-6); при выходе последнего активного — авто-завершение (A5, LastParticipantLeft).

A4. Модератор завершил для всех

  1. POST /conferences/{id}/end актором с ConferenceRole = Moderator (иначе MSG_CONFERENCE_FORBIDDEN, 403): Active → Ended, EndReason = ModeratorEnded; все активные — LeaveReason = ConferenceEnded; событие conference-ended.v1 (ключ DealId).

A5. Авто-завершение пустой/зависшей комнаты (reaper)

  1. Последний активный вышел → 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. Идемпотентно (guard WHERE Status IN ('Provisioning','Active')).

A6. Комната не поднялась (Provisioning → Failed)

  1. media-стек не подтвердил комнату в срок (ProvisionTimeout) или фатальный сбой: Provisioning → Failed, EndReason = Error; push ConferenceSignal(Ended); клиент показывает «Не удалось начать встречу». Если уже была провизирована запись — она уходит в Failed (call-recording.md).

A7. Deal-чат закрыт во время конференции

  1. Сделка перешла в 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 (полный перечень)

FromToУсловие переходаИнициатор
[*]Provisioningчленство активно (INV-CONF-3) ∧ Kind = Deal (INV-CONF-1) ∧ нет активной конференции на чат (INV-CONF-2)Модератор (start)
ProvisioningActivemedia-стек подтвердил комнату (admission-вебхук)media-стек
ProvisioningFailedкомната не поднялась в срок (ProvisionTimeout, Error)система (A6)
ActiveEndedвышел последний активный (LastParticipantLeft)reaper/участник (A5)
ActiveEndedмодератор завершил (ModeratorEnded)Модератор (A4)
ActiveEndedDeal-чат закрыт (ChatClosed, INV-CHAT-8)crm→чат (A7)
ActiveEndedпусто > grace (EmptyTimeout) / stale > cap (Timeout)reaper (A5)
ActiveFailedфатальный сбой 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-5Room.RoomName уникален среди активных; токены/ICE/медиа не персистятся.
INV-CONF-6Ровно один активный модератор; при выходе модератора роль передаётся старейшему активному.
INV-CONF-7В БД нет медиа/сигналинга/PII и токенов доступа — только факты жизненного цикла и ростер.
INV-CONF-8ActiveParticipantCount/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_DISABLED503CONFERENCE_CALLS_ENABLED = false«Групповые звонки временно недоступны»
MSG_CHAT_NOT_FOUND404Deal-чат не найден«Чат не найден»
MSG_CHAT_ACCESS_DENIED403актор не активный участник чата (INV-CONF-3)Скрыть «видеозвонок»
MSG_CONFERENCE_CHAT_INVALID409чат не Kind = Deal (INV-CONF-1)Для 1:1 — обычный звонок (UC-MSG-005)
MSG_CONFERENCE_ALREADY_ACTIVE409уже активна конференция на чат (INV-CONF-2)«Встреча уже идёт» → предложить join
MSG_CONFERENCE_NOT_FOUND404конференция не найдена / актор не участник чата«Встреча не найдена»
MSG_CONFERENCE_NOT_ACTIVE409join/leave в статусе Provisioning/Ended/Failed (INV-CONF-4)Обновить состояние по hub/REST
MSG_CONFERENCE_INVALID_STATE409недопустимый переход состояния (INV-CONF-4)Обновить состояние
MSG_CONFERENCE_FORBIDDEN403end/остановка не модератором (INV-CONF-6)Скрыть «завершить для всех»

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

СобытиеКогда поднимаетсяSubscribers
ConferenceStartedEvent[*] → Provisioning/Activepush 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
ConferenceEndedEventpin.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
AC5end только модератором; при выходе модератора роль передаётся (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
AC10conference-ended не несёт готовность записи — только WasRecorded/RecordingId; готовность — отдельным событиемТест: payload conference-ended без FileId/URL; запись приходит call-recording-available.v1

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

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

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