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

Consumer: Lead Chat Participants Sync (MessengerLeadParticipantsChangedConsumer)

Назначение

Consumer слушает событие pin.crm.lead-participants-changed.v1 и синхронизирует состав участников чата лида (ChatKind.Lead, chat.md) с активным составом LeadParticipant в crm. Применяет идемпотентную set-реконсиляцию по ActiveParticipants (пригласить недостающих, убрать отсутствующих, поправить роли) и материализует членские факты в системную ленту чата (ChatEventEntity типов ParticipantJoined/ParticipantLeft/ParticipantRoleChanged, INV-CE-4). Порядок изменений одного лида гарантирован ключом LeadId; монотонный version-guard отсекает устаревшую доставку. Событие штатно триггерится и каскадом увольнения агента (ADR-0072): по Removed-дельте бывший участник теряет доступ и realtime-подписку, история команде сохраняется.

Owning-сервис и триггер

ПолеЗначение
Сервис/контекстmessenger
ConsumerMessengerLeadParticipantsChangedConsumer
Обрабатываемое событиеLeadParticipantsChangedEvent
Topicpin.crm.lead-participants-changed.v1 (key = LeadId, 3 партиции)
ContractVersionожидается 1; при несовпадении версии — warn-and-proceed (messenger_lead_participants_contract_mismatch_total)
Consumer groupmessenger-lead-participants-changed
Producercrm: добавление/снятие участника лида / смена роли / передача ведения / каскад увольнения агента
Эффект (событие → что меняется)Состав участников чата лида приводится к активному составу лида; членские факты пишутся в ленту

Контекст события

  • Из метаданных события доступны: идентификатор сообщения (для дедупликации), время события, версия контракта, идентификатор и тип агрегата-источника (LeadId / Lead).
  • Актор — системный; чат не tenant-фильтруется, доступ по членству (ADR-0052). Смену ролей и состав чата лида выполняет только этот consumer (пользовательских операций нет, participant.md).
  • Корреляция сквозного трейса восстанавливается из метаданных события.
  • Ключ бизнес-идемпотентности: messenger-lead-participants:{MessageId}; плюс монотонный payload.Version-guard против применённой доменной версии на чате (дельта ниже); ActiveParticipants делает реконсиляцию самодостаточной (пропуск дельты сходится).

Требуемая дельта к дому chat.md

Version-guard требует хранить последнюю применённую доменную версию лида, а поиск комнаты — ссылку на лид. Дом полей — chat.md; consumer использует поля ChatEntity.LeadId : Guid? (ключ Lead-чата, unique where not null) и ChatEntity.LeadMemberVersion : long? (nullable; монотонный guard применённых снапшотов состава). Поля объявлены в доме chat.md, здесь только используются.

Входные данные / payload события

Контракт — дом lead-participants-changed.md (payload + LeadParticipantChange + LeadParticipantSnapshot). Ниже — читаемые поля.

Поле payloadТипОбязательностьКак используется (маппинг)
LeadIdGuidДаПоиск чата лида по LeadId
ActiveParticipantsList<LeadParticipantSnapshot>ДаЦелевой состав реконсиляции: UserId + Role → ParticipantRole
ParticipantsList<LeadParticipantChange>ДаДельта: ChangeType (Added/Removed/RoleChanged/ExecutorChanged/ResponsibleChanged) → тип ChatEvent
VersionlongДаМонотонный guard → Chat.LeadMemberVersion
ChangedAtDateTimeOffsetДаChatEvent.OccurredAt

Маппинг crm.ParticipantRole → messenger.ParticipantRoleidentity (значение → то же значение) для всех CRM-ролей лида (Executor/Responsible/CoAgent/Assistant/Lawyer/InvestorPrimary/InvestorSecondary); дом — participant.md. Неизвестный Role/ChangeType — сохранить строку как есть

  • warning (роль — bucket Guest); неизвестный ChangeType трактовать как no-op дельты и опираться на ActiveParticipants (fail-safe — состав всё равно сойдётся). ExecutorChanged/ResponsibleChanged для членства эквивалентны паре Removed (прежний носитель) + RoleChanged/Added (новый) — авторитет состава всегда ActiveParticipants. TenantId/ChangedByUserId на синк не влияют.

Алгоритм

  1. Контекст события. Восстановить метаданные события; сверить версию контракта с 1 (при несовпадении — warn-and-proceed). Проверить бизнес-идемпотентность по messenger-lead-participants:{MessageId}: событие уже обработано → пропустить как дубль (skipped-duplicate, ack).
  2. Проверка входа. LeadId задан; ActiveParticipants содержит ≥ 1 активного участника (INV-CHAT-7); payload.Version >= 1. Пустой/невалидный ActiveParticipantsMSG_LEAD_PARTICIPANTS_INVALID (DLQ + алерт).
  3. Что читаем. Найти чат лида по LeadId вместе с активными участниками. Если чата ещё нет — он не провизионирован (событие пришло раньше провизии Lead-чата): отказ MSG_CHAT_NOT_PROVISIONED с ретраем (backoff).
  4. Бизнес-правила и маппинг.
    • Version-guard: если payload.Version <= chat.LeadMemberVersion — доставка устаревшая/дубль: метрика messenger_lead_participants_stale_total, лог skipped-stale, ack без изменений.
    • Set-реконсиляция по ActiveParticipants (identity-маппинг ролей): целевое множество активных (UserId, ParticipantRole). Diff с текущими активными участниками: toAdd (нет в чате), toRemove (активны в чате, нет в цели), toRole (роль изменилась). Дельта Participants используется только для формулировки/типа ChatEvent; авторитет состава — ActiveParticipants (fail-safe).
  5. Что меняется. Для toAdd — добавить/реактивировать участника (INV-PART-1); для toRemove — пометить участие удалённым (soft RemovedAt, INV-PART-4); для toRole — сменить роль. Применённая версия сохраняется: chat.LeadMemberVersion = payload.Version.
  6. Что фиксируется. По членским изменениям в системную ленту материализуются факты ParticipantJoined/ParticipantLeft/ParticipantRoleChanged (INV-CE-4); участникам готовится realtime-доставка ChatUpdated (persist-then-push; удалённый теряет подписку на чат). Интеграционных Kafka-событий нет.
  7. Согласованность. Состав участников, применённая версия, факты ленты и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после фиксации. Правка не должна затирать более свежее состояние (guard по строке чата).
  8. Результат. Успех → ack (SLA < 1 мин). MSG_CHAT_NOT_PROVISIONED/инфраструктурный сбой → ретрай (backoff); структурная ошибка → DLQ + алерт.

Диаграмма

Провизия чата лида и переход к чату сделки

  • Провизия Lead-чата. Комната лида создаётся отдельным консюмером провизии Lead-чата (по факту создания лида, путь consumers/lead-created.md — по конвенции, соседний слой ТЗ), как Deal-чат провизионируется по deal-created. Порядок per-lead по LeadId гарантирует, что провизия предшествует изменениям состава; если комната ещё не создана — допустимая гонка, разрешается ретраем MSG_CHAT_NOT_PROVISIONED. Этот consumer комнату не создаёт, только синхронизирует её состав.
  • Создание сделки из лида. Из одного лида может порождаться несколько сделок (lead.md). Создание сделки не мигрирует Lead-чат и не переносит его сообщения: для каждой сделки провизионируется отдельный Deal-чат (ChatKind.Deal, по deal-created), состав которого синхронизирует deal-members-changed. Lead-чат продолжает жить для до-сделочной коммуникации команды по лиду (может обслуживать несколько параллельных сделок). Связь чатов — логическая, через общий crm-процесс (Deal.LeadId на стороне crm); переписка Lead- и Deal-чатов раздельна, единый ростер по ним не сводится. Терминализация лида (Archived/Expired) закрывает Lead-чат (INV-CHAT-8) отдельным сигналом от crm; активные Deal-чаты лида при этом не трогаются.

Предусловия и постусловия

До обработки (истинно на входе):

  • Состав LeadParticipant изменён в crm (добавление/снятие участника, смена роли, передача ведения или каскад увольнения) — событие lead-participants-changed публикуется с полным ActiveParticipants (целевое множество) и дельтой Participants + монотонным Version.
  • Чат лида, как правило, уже провизионирован; порядок per-lead по LeadId гарантирует, что провизия предшествует. Если чата ещё нет — допустимая гонка, разрешается ретраем.

После успешной обработки (гарантируется):

  • Активный состав участников чата лида тождествен ActiveParticipants события (роли identity-маппингом): недостающие добавлены/реактивированы (INV-PART-1), лишние помечены удалёнными (soft RemovedAt, INV-PART-4), роли приведены к целевым. Инвариант INV-CHAT-7 (≥ 1 активный участник; несколько агентов/инвесторов допустимо) сохранён.
  • chat.LeadMemberVersion = payload.Version (монотонно не убывает).
  • В системную ленту материализованы факты ParticipantJoined/ParticipantLeft/ParticipantRoleChanged (INV-CE-4); онлайн-участникам ушла ChatUpdated; удалённый участник потерял подписку на чат (в т.ч. по каскаду увольнения — история команде сохранена).
  • Наружу Kafka-событий нет; всё — в одном согласованном изменении; offset подтверждён после фиксации.

Промежуточное состояние: между фактическим изменением состава в crm и обработкой события участник может короткое время видеть неактуальный ростер (только что добавленный ещё без доступа, удалённый ещё с доступом). Окно — секунды (SLA < 1 мин); реконсиляция по ActiveParticipants гарантирует сходимость даже при пропуске дельт.

Кросс-сервисные контракты

Ожидается от crm (вход pin.crm.lead-participants-changed.v1): at-least-once доставка; ключ LeadId (порядок per-lead); обязательные поля LeadId, ActiveParticipants (полный целевой состав — не только дельта), Participants (дельта для формулировки фактов), Version (монотонный, ≥ 1), ChangedAt, MessageId, ContractVersion. Ключевая гарантия: ActiveParticipantsсамодостаточный снапшот активного состава на момент Version; на нём строится реконсиляция, поэтому потеря/переупорядочивание отдельных дельт не ломает состав.

Предоставляет наружу: ничего — исходящих Kafka-событий нет; эффект ограничен составом чата лида и его лентой внутри messenger. Смену ролей и состав чата лида выполняет только этот consumer (пользовательских операций нет).

Критерии приёмки (Given/When/Then)

  • Happy (добавление): Given провизионированный чат лида и событие с новым UserId в ActiveParticipants; When обработка; Then участник добавлен (ParticipantJoined), ChatUpdated онлайн-участникам, LeadMemberVersion = Version, offset подтверждён.
  • Happy (снятие): Given ActiveParticipants без ранее активного участника; When обработка; Then участие помечено удалённым (ParticipantLeft, soft RemovedAt), удалённый теряет подписку на чат.
  • Happy (смена роли): Given у участника изменилась Role; When обработка; Then роль обновлена (ParticipantRoleChanged), состав неизменен по членству.
  • Передача ведения: Given дельта Removed (прежний Executor) + ExecutorChanged (новый); When обработка; Then прежний исполнитель удалён из ростера, новый добавлен/переролен, ActiveParticipants — ровно один Executor.
  • Несколько агентов/инвесторов: Given ActiveParticipants c Executor + CoAgent + InvestorPrimary + InvestorSecondary; When обработка; Then все активны в ростере (INV-CHAT-7 не требует единственности агента/инвестора).
  • Каскад увольнения: Given ChangeType = Removed уволенного агента; When обработка; Then доступ и подписка сняты, факт ParticipantLeft в ленте, история сохранена.
  • Устаревшая доставка (version-guard): Given payload.Version ≤ chat.LeadMemberVersion; When обработка; Then изменений нет, skipped-stale, метрика stale_total, ack.
  • Out-of-order + пропуск дельты: Given дельты пришли не по порядку/одна потеряна, но ActiveParticipants актуален; When обработка последнего события; Then состав сходится к ActiveParticipants (реконсиляция самодостаточна).
  • Дубль: Given уже обработанный MessageId; When повтор; Then skipped-duplicate, ack.
  • Чат не готов: Given чат лида ещё не провизионирован; When обработка; Then MSG_CHAT_NOT_PROVISIONED, ретрай backoff.
  • Пустой/битый состав: Given ActiveParticipants пуст или невалиден; When обработка; Then MSG_LEAD_PARTICIPANTS_INVALID, DLQ + алерт.
  • Конкурентная правка: Given параллельная правка состава того же чата; When обе фиксируются; Then выигрывает последняя по version-guard, проигравшая перечитывает (3 попытки), состав не затирается более старым.
  • Неизвестный ChangeType/Role: Given значение вне enum; When обработка; Then no-op дельты + warning (роль — bucket Guest), состав всё равно сходится по ActiveParticipants.

Идемпотентность и replay

  • По сообщению: ключ messenger-lead-participants:{MessageId}.
  • По версии: монотонный payload.Version-guard против Chat.LeadMemberVersion (out-of-order, replay); ActiveParticipants делает реконсиляцию сходящейся даже при пропуске отдельной дельты.
  • Replay: да. Повторный re-read сходится к последнему составу (last-write-wins по key = LeadId + version-guard). Runbook — «пересинхронизация состава Lead-чатов» (runbooks/, будущий путь).

Retry и DLQ

СбойКодRetryDLQРучное действие
Чат лида ещё не провизионированMSG_CHAT_NOT_PROVISIONEDДа, backoffПосле лимитаПроверить лаг провизии Lead-чата
Инфраструктурный (PostgreSQL/Kafka)Бесконечный backoff 1s→60sНетRunbook: восстановить зависимость
Конкурентная правка состава3 попытки (перечитать чат)После исчерпанияРасследование гонки
Пустой/невалидный ActiveParticipantsMSG_LEAD_PARTICIPANTS_INVALIDНетДа + алертПроверить продьюсер crm
ContractVersion mismatchwarn-and-proceedНетПроверить версию контракта

Наблюдаемость и безопасность

  • Logs: consumed / skipped-duplicate / skipped-stale / synced(added,removed,roleChanged) / retry-not-provisioned / failed c MessageId, LeadId, ChatId, payload.Version. PII нет (id/роли).
  • Metrics: messenger_lead_participants_consume_duration_ms, messenger_participants_added_total, messenger_participants_removed_total, messenger_lead_participants_stale_total, messenger_lead_participants_dlq_total, messenger_lead_participants_lag_seconds.
  • Traces: приём события → обработка → обращения к хранилищу; корреляция из метаданных события.
  • Alerts: лаг > 5 мин; всплеск MSG_CHAT_NOT_PROVISIONED; DLQ > 0.
  • Безопасность: системный актор, доступ по членству; событие — только UserId/роли (PII нет); ФИО участников резолвятся из реплики messenger-user.md.

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

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

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