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 |
| Consumer | MessengerLeadParticipantsChangedConsumer |
| Обрабатываемое событие | LeadParticipantsChangedEvent |
| Topic | pin.crm.lead-participants-changed.v1 (key = LeadId, 3 партиции) |
| ContractVersion | ожидается 1; при несовпадении версии — warn-and-proceed (messenger_lead_participants_contract_mismatch_total) |
| Consumer group | messenger-lead-participants-changed |
| Producer | crm: добавление/снятие участника лида / смена роли / передача ведения / каскад увольнения агента |
| Эффект (событие → что меняется) | Состав участников чата лида приводится к активному составу лида; членские факты пишутся в ленту |
Контекст события
- Из метаданных события доступны: идентификатор сообщения (для дедупликации), время события, версия контракта,
идентификатор и тип агрегата-источника (
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 | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
LeadId | Guid | Да | Поиск чата лида по LeadId |
ActiveParticipants | List<LeadParticipantSnapshot> | Да | Целевой состав реконсиляции: UserId + Role → ParticipantRole |
Participants | List<LeadParticipantChange> | Да | Дельта: ChangeType (Added/Removed/RoleChanged/ExecutorChanged/ResponsibleChanged) → тип ChatEvent |
Version | long | Да | Монотонный guard → Chat.LeadMemberVersion |
ChangedAt | DateTimeOffset | Да | ChatEvent.OccurredAt |
Маппинг crm.ParticipantRole → messenger.ParticipantRole — identity (значение → то же значение) для всех
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(при несовпадении — warn-and-proceed). Проверить бизнес-идемпотентность поmessenger-lead-participants:{MessageId}: событие уже обработано → пропустить как дубль (skipped-duplicate, ack). - Проверка входа.
LeadIdзадан;ActiveParticipantsсодержит ≥ 1 активного участника (INV-CHAT-7);payload.Version >= 1. Пустой/невалидныйActiveParticipants→MSG_LEAD_PARTICIPANTS_INVALID(DLQ + алерт). - Что читаем. Найти чат лида по
LeadIdвместе с активными участниками. Если чата ещё нет — он не провизионирован (событие пришло раньше провизии Lead-чата): отказMSG_CHAT_NOT_PROVISIONEDс ретраем (backoff). - Бизнес-правила и маппинг.
- 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).
- Version-guard: если
- Что меняется. Для
toAdd— добавить/реактивировать участника (INV-PART-1); дляtoRemove— пометить участие удалённым (softRemovedAt, INV-PART-4); дляtoRole— сменить роль. Применённая версия сохраняется:chat.LeadMemberVersion = payload.Version. - Что фиксируется. По членским изменениям в системную ленту материализуются факты
ParticipantJoined/ParticipantLeft/ParticipantRoleChanged(INV-CE-4); участникам готовится realtime-доставкаChatUpdated(persist-then-push; удалённый теряет подписку на чат). Интеграционных Kafka-событий нет. - Согласованность. Состав участников, применённая версия, факты ленты и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после фиксации. Правка не должна затирать более свежее состояние (guard по строке чата).
- Результат. Успех → 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), лишние помечены удалёнными (softRemovedAt, 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, softRemovedAt), удалённый теряет подписку на чат. - Happy (смена роли): Given у участника изменилась
Role; When обработка; Then роль обновлена (ParticipantRoleChanged), состав неизменен по членству. - Передача ведения: Given дельта
Removed(прежнийExecutor) +ExecutorChanged(новый); When обработка; Then прежний исполнитель удалён из ростера, новый добавлен/переролен,ActiveParticipants— ровно одинExecutor. - Несколько агентов/инвесторов: Given
ActiveParticipantscExecutor+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 повтор; Thenskipped-duplicate, ack. - Чат не готов: Given чат лида ещё не провизионирован; When обработка; Then
MSG_CHAT_NOT_PROVISIONED, ретрай backoff. - Пустой/битый состав: Given
ActiveParticipantsпуст или невалиден; When обработка; ThenMSG_LEAD_PARTICIPANTS_INVALID, DLQ + алерт. - Конкурентная правка: Given параллельная правка состава того же чата; When обе фиксируются; Then выигрывает последняя по version-guard, проигравшая перечитывает (3 попытки), состав не затирается более старым.
- Неизвестный
ChangeType/Role: Given значение вне enum; When обработка; Then no-op дельты + warning (роль — bucketGuest), состав всё равно сходится по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
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Чат лида ещё не провизионирован | MSG_CHAT_NOT_PROVISIONED | Да, backoff | После лимита | Проверить лаг провизии Lead-чата |
| Инфраструктурный (PostgreSQL/Kafka) | — | Бесконечный backoff 1s→60s | Нет | Runbook: восстановить зависимость |
| Конкурентная правка состава | — | 3 попытки (перечитать чат) | После исчерпания | Расследование гонки |
Пустой/невалидный ActiveParticipants | MSG_LEAD_PARTICIPANTS_INVALID | Нет | Да + алерт | Проверить продьюсер crm |
| ContractVersion mismatch | — | warn-and-proceed | Нет | Проверить версию контракта |
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/skipped-stale/synced(added,removed,roleChanged) /retry-not-provisioned/failedcMessageId,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.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: ChatEntity (+ParticipantEntity как owned-child), Entity: ParticipantEntity (owned-child ChatEntity)
- Use Cases (1): UC-MSG-008. Добавленный участник появляется в чате сделки/лида
- События (2): Event Contract: pin.crm.lead-booking-intent-changed (LeadBookingIntentChangedEvent), Event Contract: pin.crm.lead-participants-changed (LeadParticipantsChangedEvent)
- index.md (1): Messenger Service
Связанные документы
- Целевые агрегаты: chat.md (INV-CHAT-6/7, дельта
LeadId/LeadMemberVersion), participant.md (identity-маппинг ролей, INV-PART-1/3/4), chat-event.md (INV-CE-4, member-типы). - Входное событие (дом): lead-participants-changed.md;
лид (дом полей
LeadParticipantEntity+ParticipantRole): lead.md. - Аналог для сделки (единая модель участников): deal-members-changed.md.
- Соседние консюмеры: deal-created.md, identity-user-upserted.md.
- README; ADR-0055,
0052,
0071 (единая модель
участников,
ParticipantRole), 0072 (каскад увольнения агента).