Consumer: Deal Chat Members Sync (MessengerDealMembersChangedConsumer)
Назначение
Consumer слушает событие pin.crm.deal-members-changed.v1 и синхронизирует состав участников чата сделки
с активным составом DealMember в crm. Применяет идемпотентную set-реконсиляцию по ActiveMembers
(пригласить недостающих, убрать отсутствующих, поправить роли) и материализует членские факты в системную
ленту чата (ChatEventEntity типов ParticipantJoined/ParticipantLeft/ParticipantRoleChanged,
INV-CE-4). Порядок изменений одной сделки гарантирован ключом DealId; монотонный version-guard отсекает
устаревшую доставку.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | messenger |
| Consumer | MessengerDealMembersChangedConsumer |
| Обрабатываемое событие | DealMembersChangedEvent |
| Topic | pin.crm.deal-members-changed.v1 (key = DealId, 3 партиции) |
| ContractVersion | ожидается 1; при несовпадении версии — warn-and-proceed (messenger_deal_members_contract_mismatch_total) |
| Consumer group | messenger-deal-members-changed |
| Producer | crm: добавление/удаление участника сделки / смена его роли |
| Эффект (событие → что меняется) | Состав участников чата приводится к активному составу сделки; членские факты пишутся в ленту |
Контекст события
- Из метаданных события доступны: идентификатор сообщения (для дедупликации), время события, версия контракта,
идентификатор и тип агрегата-источника (
DealId/Deal). - Актор — системный; чат не tenant-фильтруется, доступ по членству (ADR-0052). Смену ролей участников выполняет только этот consumer (пользовательских операций нет, participant.md).
- Корреляция сквозного трейса восстанавливается из метаданных события.
- Ключ бизнес-идемпотентности:
messenger-deal-members:{MessageId}; плюс монотонныйpayload.Version-guard против применённой доменной версии на чате (дельта ниже);ActiveMembersделает реконсиляцию самодостаточной (пропуск дельты сходится).
Требуемая дельта к дому chat.md
Version-guard требует хранить последнюю применённую доменную версию сделки. Дом поля — chat.md;
consumer использует поле ChatEntity.DealMemberVersion : long? (nullable; монотонный guard применённых
member-снапшотов). Поле объявляется в доме chat.md, здесь только используется.
Входные данные / payload события
Контракт — дом deal-members-changed.md (payload +
DealMemberChange + DealMemberSnapshot). Ниже — читаемые поля.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
DealId | Guid | Да | Поиск чата сделки по DealId |
ActiveMembers | List<DealMemberSnapshot> | Да | Целевой состав реконсиляции: UserId + Role (единый ParticipantRole, переносится 1:1) |
Members | List<DealMemberChange> | Да | Дельта: ChangeType (Added/Removed/RoleChanged/ExecutorChanged/ResponsibleChanged) → тип ChatEvent |
Version | long | Да | Монотонный guard → Chat.DealMemberVersion |
ChangedAt | DateTimeOffset | Да | ChatEvent.OccurredAt |
crm и messenger используют единый ParticipantRole (ADR-0071): роль переносится в чат 1:1 по
значению (identity-маппинг, без трансляции) — Executor→Executor, Responsible→Responsible,
CoAgent→CoAgent, Assistant→Assistant, Lawyer→Lawyer, InvestorPrimary→InvestorPrimary,
InvestorSecondary→InvestorSecondary, SellManager→SellManager, PinManager→PinManager
(participant.md). Неизвестное значение Role — сохранить строкой как есть +
warning (отображается как Guest), реконсиляция состава не ломается (fail-safe). Неизвестный ChangeType —
трактовать как no-op дельты и опираться на ActiveMembers (состав всё равно сойдётся). TenantId/
ChangedByUserId на синк не влияют.
Алгоритм
- Контекст события. Восстановить метаданные события; сверить версию контракта с
1(при несовпадении — warn-and-proceed). Проверить бизнес-идемпотентность поmessenger-deal-members:{MessageId}: событие уже обработано → пропустить как дубль (skipped-duplicate, ack). - Проверка входа.
DealIdзадан;ActiveMembersсодержит ≥ 1 активного участника (INV-CHAT-7);payload.Version >= 1. Пустой/невалидныйActiveMembers→MSG_DEAL_MEMBERS_INVALID(DLQ + алерт). - Что читаем. Найти чат сделки по
DealIdвместе с активными участниками. Если чата ещё нет — он не провизионирован (событие пришло раньше deal-created): отказMSG_CHAT_NOT_PROVISIONEDс ретраем (backoff). - Бизнес-правила и маппинг.
- Version-guard: если
payload.Version <= chat.DealMemberVersion— доставка устаревшая/дубль: метрикаmessenger_deal_members_stale_total, логskipped-stale, ack без изменений. - Set-реконсиляция по
ActiveMembers(маппинг ролей): целевое множество активных(UserId, ParticipantRole). Diff с текущими активными участниками:toAdd(нет в чате),toRemove(активны в чате, нет в цели),toRole(роль изменилась). ДельтаMembersиспользуется только для формулировки/типаChatEvent; авторитет состава —ActiveMembers(fail-safe).
- Version-guard: если
- Что меняется. Для
toAdd— добавить/реактивировать участника (INV-PART-1); дляtoRemove— пометить участие удалённым (softRemovedAt, INV-PART-4); дляtoRole— сменить роль. Применённая версия сохраняется:chat.DealMemberVersion = 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 + алерт.
Диаграмма
Предусловия и постусловия
До обработки (истинно на входе):
- Состав
DealMemberизменён вcrm(добавление/удаление участника или смена роли) — событиеdeal-members-changedпубликуется с полнымActiveMembers(целевое множество) и дельтойMembers+ монотоннымVersion. - Чат сделки, как правило, уже провизионирован (deal-created); порядок per-deal по
DealIdгарантирует, чтоdeal-createdпредшествует. Если чата ещё нет — допустимая гонка, разрешается ретраем.
После успешной обработки (гарантируется):
- Активный состав участников чата тождествен
ActiveMembersсобытия (роли по маппингу): недостающие добавлены/реактивированы (INV-PART-1), лишние помечены удалёнными (softRemovedAt, INV-PART-4), роли приведены к целевым. Инвариант INV-CHAT-7 (≥ 1 активный участник; число участников по ролям не ограничено — несколько агентов/инвесторов допустимо, ролевой состав валидируетcrm) сохранён. chat.DealMemberVersion = payload.Version(монотонно не убывает).- В системную ленту материализованы факты
ParticipantJoined/ParticipantLeft/ParticipantRoleChanged(INV-CE-4); онлайн-участникам ушлаChatUpdated; удалённый участник потерял подписку на чат. - Наружу Kafka-событий нет; всё — в одном согласованном изменении; offset подтверждён после фиксации.
Промежуточное состояние: между фактическим изменением состава в crm и обработкой события участник может
короткое время видеть неактуальный ростер (только что добавленный ещё без доступа, удалённый ещё с доступом).
Окно — секунды (SLA < 1 мин); реконсиляция по ActiveMembers гарантирует сходимость даже при пропуске дельт.
Кросс-сервисные контракты
Ожидается от crm (вход pin.crm.deal-members-changed.v1): at-least-once доставка; ключ DealId
(порядок per-deal); обязательные поля DealId, ActiveMembers (полный целевой состав — не только дельта),
Members (дельта для формулировки фактов), Version (монотонный, ≥ 1), ChangedAt, MessageId,
ContractVersion. Ключевая гарантия: ActiveMembers — самодостаточный снапшот активного состава на момент
Version; на нём строится реконсиляция, поэтому потеря/переупорядочивание отдельных дельт не ломает состав.
Предоставляет наружу: ничего — исходящих Kafka-событий нет; эффект ограничен составом чата и его лентой
внутри messenger. Смена ролей участников выполняется только этим consumer-ом (пользовательских операций нет).
Критерии приёмки (Given/When/Then)
- Happy (добавление): Given провизионированный чат и событие с новым
UserIdвActiveMembers; When обработка; Then участник добавлен (ParticipantJoined),ChatUpdatedонлайн-участникам,DealMemberVersion = Version, offset подтверждён. - Happy (удаление): Given
ActiveMembersбез ранее активного участника; When обработка; Then участие помечено удалённым (ParticipantLeft, softRemovedAt), удалённый теряет подписку на чат. - Happy (смена роли): Given у участника изменилась
Role; When обработка; Then роль обновлена (ParticipantRoleChanged), состав неизменен по членству. - Устаревшая доставка (version-guard): Given
payload.Version ≤ chat.DealMemberVersion; When обработка; Then изменений нет,skipped-stale, метрикаstale_total, ack. - Out-of-order + пропуск дельты: Given дельты пришли не по порядку/одна потеряна, но
ActiveMembersактуален; When обработка последнего события; Then состав сходится кActiveMembers(реконсиляция самодостаточна). - Дубль: Given уже обработанный
MessageId; When повтор; Thenskipped-duplicate, ack. - Чат не готов: Given чат ещё не провизионирован; When обработка; Then
MSG_CHAT_NOT_PROVISIONED, ретрай backoff. - Пустой/битый состав: Given
ActiveMembersпуст или невалиден; When обработка; ThenMSG_DEAL_MEMBERS_INVALID, DLQ + алерт. - Конкурентная правка: Given параллельная правка состава того же чата; When обе фиксируются; Then выигрывает последняя по version-guard, проигравшая перечитывает (3 попытки), состав не затирается более старым.
- Неизвестный
Role: Given роль вне enumParticipantRole; When обработка; Then строка роли сохранена как есть + warning (отображается какGuest), участник учтён в составе, реконсиляция не ломается. - Неизвестный
ChangeType: Given значение вне enum; When обработка; Then no-op дельты + warning, состав всё равно сходится поActiveMembers.
Идемпотентность и replay
- По сообщению: ключ
messenger-deal-members:{MessageId}. - По версии: монотонный
payload.Version-guard противChat.DealMemberVersion(out-of-order, replay);ActiveMembersделает реконсиляцию сходящейся даже при пропуске отдельной дельты. - Replay: да. Повторный re-read сходится к последнему составу (last-write-wins по key =
DealId+ version-guard). Runbook — «пересинхронизация состава Deal-чатов» (runbooks/, будущий путь).
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Чат ещё не провизионирован | MSG_CHAT_NOT_PROVISIONED | Да, backoff | После лимита | Проверить лаг messenger-deal-created |
| Инфраструктурный (PostgreSQL/Kafka) | — | Бесконечный backoff 1s→60s | Нет | Runbook: восстановить зависимость |
| Конкурентная правка состава | — | 3 попытки (перечитать чат) | После исчерпания | Расследование гонки |
Пустой/невалидный ActiveMembers | MSG_DEAL_MEMBERS_INVALID | Нет | Да + алерт | Проверить продьюсер crm |
| ContractVersion mismatch | — | warn-and-proceed | Нет | Проверить версию контракта |
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/skipped-stale/synced(added,removed,roleChanged) /retry-not-provisioned/failedcMessageId,DealId,ChatId,payload.Version. PII нет (id/роли). - Metrics:
messenger_deal_members_consume_duration_ms,messenger_participants_added_total,messenger_participants_removed_total,messenger_deal_members_stale_total,messenger_deal_members_dlq_total,messenger_deal_members_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 (2): UC-MSG-002. Авто-провизия чата сделки по событию CRM, UC-MSG-008. Добавленный участник появляется в чате сделки/лида
- События (2): События сервиса messenger: реестр топиков, Event Contract: pin.crm.deal-members-changed (DealMembersChangedEvent)
- Консюмеры (5): Consumer: Deal Chat Provisioning (MessengerDealCreatedConsumer), Consumer: Deal Stage Card (MessengerDealStageChangedConsumer), Consumer: Document Request Card (MessengerDocumentRequestedConsumer), Consumer: Lead Chat Participants Sync (MessengerLeadParticipantsChangedConsumer), Consumer: Messenger User Replica Sync (MessengerUserUpsertedConsumer)
Связанные документы
- Целевые агрегаты: chat.md (INV-CHAT-6/7, дельта
DealMemberVersion), participant.md (маппинг ролей, INV-PART-1/3/4), chat-event.md (INV-CE-4, member-типы). - Входное событие (дом): deal-members-changed.md.
- Соседние консюмеры: deal-created.md, identity-user-upserted.md.
- README; ADR-0055,
0052,
0071 (единый
ParticipantRole).