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

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
ConsumerMessengerDealMembersChangedConsumer
Обрабатываемое событиеDealMembersChangedEvent
Topicpin.crm.deal-members-changed.v1 (key = DealId, 3 партиции)
ContractVersionожидается 1; при несовпадении версии — warn-and-proceed (messenger_deal_members_contract_mismatch_total)
Consumer groupmessenger-deal-members-changed
Producercrm: добавление/удаление участника сделки / смена его роли
Эффект (событие → что меняется)Состав участников чата приводится к активному составу сделки; членские факты пишутся в ленту

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

  • Из метаданных события доступны: идентификатор сообщения (для дедупликации), время события, версия контракта, идентификатор и тип агрегата-источника (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ТипОбязательностьКак используется (маппинг)
DealIdGuidДаПоиск чата сделки по DealId
ActiveMembersList<DealMemberSnapshot>ДаЦелевой состав реконсиляции: UserId + Role (единый ParticipantRole, переносится 1:1)
MembersList<DealMemberChange>ДаДельта: ChangeType (Added/Removed/RoleChanged/ExecutorChanged/ResponsibleChanged) → тип ChatEvent
VersionlongДаМонотонный guard → Chat.DealMemberVersion
ChangedAtDateTimeOffsetДа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. Контекст события. Восстановить метаданные события; сверить версию контракта с 1 (при несовпадении — warn-and-proceed). Проверить бизнес-идемпотентность по messenger-deal-members:{MessageId}: событие уже обработано → пропустить как дубль (skipped-duplicate, ack).
  2. Проверка входа. DealId задан; ActiveMembers содержит ≥ 1 активного участника (INV-CHAT-7); payload.Version >= 1. Пустой/невалидный ActiveMembersMSG_DEAL_MEMBERS_INVALID (DLQ + алерт).
  3. Что читаем. Найти чат сделки по DealId вместе с активными участниками. Если чата ещё нет — он не провизионирован (событие пришло раньше deal-created): отказ MSG_CHAT_NOT_PROVISIONED с ретраем (backoff).
  4. Бизнес-правила и маппинг.
    • 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).
  5. Что меняется. Для toAdd — добавить/реактивировать участника (INV-PART-1); для toRemove — пометить участие удалённым (soft RemovedAt, INV-PART-4); для toRole — сменить роль. Применённая версия сохраняется: chat.DealMemberVersion = 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 + алерт.

Диаграмма

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

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

  • Состав DealMember изменён в crm (добавление/удаление участника или смена роли) — событие deal-members-changed публикуется с полным ActiveMembers (целевое множество) и дельтой Members + монотонным Version.
  • Чат сделки, как правило, уже провизионирован (deal-created); порядок per-deal по DealId гарантирует, что deal-created предшествует. Если чата ещё нет — допустимая гонка, разрешается ретраем.

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

  • Активный состав участников чата тождествен ActiveMembers события (роли по маппингу): недостающие добавлены/реактивированы (INV-PART-1), лишние помечены удалёнными (soft RemovedAt, 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, soft RemovedAt), удалённый теряет подписку на чат.
  • 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 повтор; Then skipped-duplicate, ack.
  • Чат не готов: Given чат ещё не провизионирован; When обработка; Then MSG_CHAT_NOT_PROVISIONED, ретрай backoff.
  • Пустой/битый состав: Given ActiveMembers пуст или невалиден; When обработка; Then MSG_DEAL_MEMBERS_INVALID, DLQ + алерт.
  • Конкурентная правка: Given параллельная правка состава того же чата; When обе фиксируются; Then выигрывает последняя по version-guard, проигравшая перечитывает (3 попытки), состав не затирается более старым.
  • Неизвестный Role: Given роль вне enum ParticipantRole; 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

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

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

  • Logs: consumed / skipped-duplicate / skipped-stale / synced(added,removed,roleChanged) / retry-not-provisioned / failed c MessageId, 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.

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

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

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