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

Consumer: Deal Chat Provisioning (MessengerDealCreatedConsumer)

Назначение

Consumer слушает событие pin.crm.deal-created.v1 и провизионирует чат сделки (ADR-0055): по факту создания сделки в crm создаёт ChatEntity (Kind = Deal), наполняет его участниками из состава Members (роли ParticipantRole, единая модель ADR-0071), публикует в ленту чата информационную карточку DealCreated и возвращает crm готовность чата событием pin.messenger.deal-chat-status-changed.v1 (проекция Deal.ChatStatus = Ready). Провизия идемпотентна по DealId (INV-CHAT-2): повторная доставка не плодит второй чат — возвращает существующий и повторно эмитит Ready.

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

ПолеЗначение
Сервис/контекстmessenger
ConsumerMessengerDealCreatedConsumer
Обрабатываемое событиеDealCreatedEvent
Topicpin.crm.deal-created.v1 (key = DealId, 3 партиции — дом deal-created.md)
ContractVersionожидается 1; при несовпадении версии — warn-and-proceed (messenger_deal_created_contract_mismatch_total)
Consumer groupmessenger-deal-created
Producercrm: создание сделки / одобрение фиксации
Эффект (событие → что меняется)Создаётся чат сделки с участниками и карточкой DealCreated; в crm возвращается готовность чата

Границы. Consumer владеет только провизией чата и его составом на момент создания; последующая синхронизация участников — deal-members-changed.md; закрытие чата на терминале сделки — deal-stage-changed.md. Consumer не пишет в crm напрямую — готовность возвращается событием (service-boundaries).

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

  • Из метаданных события доступны: идентификатор сообщения (для дедупликации), время события, версия контракта, идентификатор и тип агрегата-источника (DealId / Deal).
  • Актор — системный; чаты messenger не tenant-фильтруются (доступ по членству, ADR-0052); DealTenantId сохраняется на чате только как scope арбитража Службы заботы (см. chat.md).
  • Корреляция сквозного трейса восстанавливается из метаданных события.
  • Ключ бизнес-идемпотентности: messenger-deal-created:{MessageId}; на уровне данных — уникальность чата по DealId (INV-CHAT-2) + де-дуп карточки по (ChatId, CardType = DealCreated, SourceEventId) (INV-MSG-8).

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

Контракт — дом deal-created.md (payload + вложенная DealMemberSnapshot). Ниже — только поля, которые consumer реально читает, и их маппинг.

Поле payloadТипОбязательностьКак используется (маппинг)
DealIdGuidДаChat.DealId; ключ идемпотентности провизии
TenantIdGuidДаChat.DealTenantId (DeveloperCompanyId; scope арбитража)
ProjectIdGuidДаChat.ProjectId (навигация/группировка Deal-чатов)
TitlestringДаChat.Title («Объект №…, ЖК …»; PII не содержит)
MembersIReadOnlyList<DealMemberSnapshot>ДаСостав чата: UserId → участник, RoleParticipantRole по маппингу

Роли ParticipantRole (единая модель ADR-0071, participant.md): Investor→Investor, Agent→Agent, SellManager→DeveloperManager, PinManager→PinManager. Неизвестная роль — сохранить строку как есть + warning (fallback, не падать). AgentUserId/InvestorUserId/Status/ PaymentMethod/PropertyObjectId/StepId consumer не читает (на провизию чата не влияют; отображаемые ФИО резолвятся из реплики messenger-user.md).

Алгоритм

  1. Контекст события. Восстановить метаданные события; сверить версию контракта с 1 (при несовпадении — warn-and-proceed). Проверить бизнес-идемпотентность по messenger-deal-created:{MessageId}: событие уже обработано → пропустить как дубль (skipped-duplicate, ack).
  2. Проверка входа. DealId задан; Title непустой (≤ 256, при превышении — усечь + warning); Members содержит ≥ 1 активного участника, в т.ч. ровно один Investor и один Agent (INV-CHAT-7); нарушение структуры → MSG_DEAL_MEMBERS_INVALID (DLQ + алерт).
  3. Что читаем. Найти чат сделки по DealId (уникальность гарантирована, INV-CHAT-2). Если чат уже есть — провизия завершена ранее: перейти к шагу возврата готовности (re-emit Ready), тело чата не мутируется (идемпотентность INV-CHAT-2).
  4. Бизнес-правила и маппинг. Собрать состав: для каждого DealMemberSnapshotParticipantRole по маппингу; отфильтровать дубли UserId (последняя роль побеждает). Сформировать карточку DealCreated: CardType = DealCreated, Category = Info, ActionStatus = None, Payload = CardDealRefPayload { DealId }, SourceEventId = MessageId, текст — «Сделка создана». Карточка видна всем (INV-MSG-5).
  5. Что меняется. Создаётся чат сделки (Kind = Deal), добавляются участники (INV-PART-1), в ленту публикуется карточка DealCreated (Seq присваивается монотонно, денормализуется last-message; SenderUserId пустой, IsSystem = true, INV-MSG-4).
  6. Что фиксируется. В системную ленту чата материализуются факты DealChatProvisioned и ParticipantJoined[] (chat-event.md); участникам готовится realtime-доставка ChatUpdated/MessageReceived (persist-then-push). Наружу публикуется интеграционное событие DealChatStatusChangedEvent { DealId, ChatId, Status = Ready, OccurredAt } (topic pin.messenger.deal-chat-status-changed.v1). Отдельного MessagePostedEvent для карточки нет: offline-push об этапах шлёт notifications напрямую из событий crm (без двойного пуша).
  7. Согласованность. Чат, участники, карточка, факты ленты, отметка идемпотентности и исходящее событие готовности фиксируются в одном согласованном изменении; offset подтверждается только после фиксации.
  8. Результат. Успех → ack (SLA Provisioning → Ready < 5 мин). Инфраструктурный сбой → ретрай (backoff); падение провизии после исчерпания ретраев — воркер chat-provision-retry помечает Failed и повторно эмитит deal-chat-status-changed (Status = Failed) для проекции crm.

Диаграмма

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

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

  • Сделка создана/фиксация одобрена в crm — событие deal-created опубликовано с полным составом Members на момент создания. С точки зрения crm проекция Deal.ChatStatus = Provisioning (ждёт готовности чата).
  • Чата по DealId ещё нет (первичная провизия) или он уже создан (повторная доставка/replay — идемпотентно).

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

  • Существует ровно один ChatEntity (Kind = Deal) по DealId (INV-CHAT-2); его состав — активные участники из Members (роли по маппингу, дубли UserId схлопнуты, INV-CHAT-7: ≥1 Investor + ≥1 Agent).
  • В ленте — карточка DealCreated (Info, None) и системные факты DealChatProvisioned + ParticipantJoined[].
  • Наружу ушло ровно одно DealChatStatusChangedEvent {Status = Ready} (pin.messenger.deal-chat-status-changed.v1, ключ DealId); по нему crm переводит проекцию Deal.ChatStatus: Provisioning → Ready.
  • Всё выше — чат, участники, карточка, факты ленты, отметка идемпотентности, исходящее событие — в одном согласованном изменении; offset подтверждён после фиксации.

Промежуточное состояние (окно eventual-consistency): пока consumer не отработал, crm видит Deal.ChatStatus = Provisioning, а пользователь — сделку без доступного чата (кнопка чата неактивна/спиннер). Окно — обычно секунды; целевой SLA Provisioning → Ready < 5 мин. При падении провизии сверх ретраев проекция станет Failed (ниже), и UI покажет ошибку провизии, а не вечный спиннер.

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

Ожидается от crm (вход pin.crm.deal-created.v1): at-least-once доставка; ключ DealId (порядок per-deal — deal-created предшествует deal-members-changed/deal-stage-changed); обязательные поля DealId, TenantId, ProjectId, Title, Members (список DealMemberSnapshot с UserId + Role), MessageId, ContractVersion. Гарантия продьюсера: состав содержит валидную пару Investor+Agent; Title без PII.

Предоставляет наружу — исходящее событие DealChatStatusChangedEvent:

ПолеТипСмысл
DealIdGuidКлюч партиционирования (pin.messenger.deal-chat-status-changed.v1) и корреляции с проекцией crm
ChatIdGuidИдентификатор провизионированного чата
Statusstring (Ready/Failed)Готовность чата для проекции Deal.ChatStatus
OccurredAtDateTimeOffsetМомент фиксации готовности/отказа

Гарантии: at-least-once (потребитель crm идемпотентен — Ready/Failed можно применять повторно); порядок per-deal по DealId; messenger не пишет в crm напрямую (готовность только событием — service-boundaries). ФИО/телефоны в событии отсутствуют (только Guid).

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

  • Happy (первичная провизия): Given нет чата по DealId и валидное deal-created (Investor+Agent в Members); When обработка; Then создан один Deal-чат с участниками, в ленте карточка DealCreated + факты DealChatProvisioned/ParticipantJoined[], наружу одно deal-chat-status-changed = Ready, offset подтверждён.
  • Идемпотентная повторная доставка: Given чат по DealId уже есть; When повтор события/replay; Then тело чата не мутируется (no-op), Ready переэмитится (crm-проекция идемпотентна), ack.
  • Невалидный состав: Given Members без Investor или без Agent (нарушение INV-CHAT-7); When обработка; Then MSG_DEAL_MEMBERS_INVALID, DLQ + алерт, чат не создаётся.
  • Гонка уникальности: Given две параллельные доставки одного DealId; When обе пытаются создать чат; Then выигрывает одна (INV-CHAT-2), проигравшая перечитывает → no-op → Ready; второго чата не возникает.
  • Неизвестная роль: Given DealMemberSnapshot.Role вне enum; When маппинг; Then строка сохраняется как есть
    • warning (fallback, не отказ).
  • Длинный Title: Given Title > 256; When обработка; Then усечение + warning, провизия успешна.
  • Провизия падает сверх лимита: Given устойчивый инфраструктурный сбой; When ретраи исчерпаны; Then воркер chat-provision-retry помечает Failed и эмитит deal-chat-status-changed = Failed; UI crm показывает отказ.
  • Иная версия контракта: Given ContractVersion ≠ 1; When обработка; Then warn-and-proceed.

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

  • По сообщению: ключ messenger-deal-created:{MessageId}.
  • По данным: уникальность чата по DealId (INV-CHAT-2) — второй чат сделки невозможен; карточка де-дуплится по (ChatId, CardType, SourceEventId) (INV-MSG-8).
  • Replay: да. Повторный re-read топика безопасен: провизия no-op на существующем чате, Ready переэмитится (crm-проекция идемпотентна). Runbook — «пересоздание Deal-чатов» (runbooks/, будущий путь).

Retry и DLQ

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны)Бесконечный backoff 1s→60sНетRunbook: восстановить зависимость
Гонка уникальности DealId (параллельная доставка)1 повтор (перечитать → no-op)Нет— (идемпотентно)
Невалидный состав Members (нет Investor/Agent)MSG_DEAL_MEMBERS_INVALIDНетДа + алертПроверить продьюсер crm
Провизия падает > N цикловворкер chat-provision-retryНетЭскалация: deal-chat-status-changed = Failed
ContractVersion mismatchwarn-and-proceedНетПроверить версию контракта producer

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

  • Logs: consumed / skipped-duplicate / provisioned(ChatId,members) / already-provisioned / failed с полями MessageId, DealId, ChatId. PII в логах нет (только id).
  • Metrics: messenger_deal_created_consume_duration_ms, messenger_deal_chats_provisioned_total, messenger_deal_created_dlq_total, messenger_deal_created_lag_seconds.
  • Traces: приём события → обработка → обращения к хранилищу; корреляция из метаданных события.
  • Alerts: лаг > 5 мин; провизия Failed > 10/ч; DLQ > 0.
  • Безопасность: системный актор, доступ по членству (санкционированный кросс-tenant — чат связывает компании); событие не несёт ФИО/телефонов (только Guid); DealTenantId — scope арбитража, не фильтр строк.

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

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

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