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

Consumer: Deal Chat Status (pin-crm-deal-chat-status)

Назначение

Консюмер Deal Chat Status поддерживает проекцию готовности чата сделки — поле DealEntity.ChatStatus (Pending/Provisioning/Ready/Failed) — по событиям сервиса messenger. Каждое событие переносит текущий статус провизии чата из messenger в это денормализованное поле сделки. Проекция идемпотентна (дубли не меняют состояние) и монотонна: Ready — поглощающее терминальное состояние, регресс из него запрещён. Собственных интеграционных событий консюмер не публикует (конец цепочки).

ADR-0055 — чат сделки живёт в messenger

Чат сделки хранится и управляется в messenger; CRM хранит только статус-проекцию (DealEntity.ChatStatus). Идентификатор чата на сделке не хранится.

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

ПолеЗначение
Сервис/контекстcrm
ConsumerDeal Chat Status
Обрабатываемое событиеpin.messenger.deal-chat-status-changed
Topicpin.messenger.deal-chat-status-changed.v1
ContractVersion1; при несовпадении версии события — warn-and-proceed (метрика crm_chat_status_contract_mismatch_total)
Partition keyDealId → все статус-события одной сделки в одной партиции, строгий per-deal порядок (основа монотонности)
Consumer grouppin-crm-deal-chat-status
Producermessenger (источник истины; контракт — ../../messenger/events/deal-chat-status-changed.md)
Эффект (событие → что меняется)Обновляет проекцию DealEntity.ChatStatus (см. domain/deal.md)

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

  • Из метаданных события доступны: идентификатор сообщения MessageId (для дедупликации), время смены статуса в messenger (CreatedAt), версия контракта, идентификатор сделки (DealId).
  • Актор — системный контекст консюмера (не пользовательский): проекция обновляется без actor-стемпинга бизнес-полей; tenant определяется по TenantId из события с явной сверкой против TenantId сделки (fail closed при несоответствии).
  • Корреляция сквозного трейса восстанавливается из метаданных события.
  • Идемпотентность: на уровне консюмера — по ключу crm:deal-chat-status:{MessageId}; на бизнес-уровне — монотонный rank-guard (INV-CHAT-1, шаг 4).

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

Контракт внешний (messenger); ниже — только поля, которые консюмер реально читает, и их маппинг. Значения Status — enum DealChatStatus (дом — domain/deal.md, раздел «Enum DealChatStatus»); messenger эмитит Provisioning/Ready/Failed (Pending — CRM-side default до первого события, продюсером не шлётся).

Поле payloadТипОбязательностьКак используется (маппинг)
DealIdGuidДаКлюч проекции — идентифицирует сделку (DealEntity.Id), к которой применяется статус
TenantIdGuidДаDeveloperCompanyId; сверяется с TenantId сделки — при несоответствии fail closed (CRM_CHAT_STATUS_TENANT_MISMATCH, DLQ)
StatusDealChatStatus (string enum)ДаНовый статус проекции → DealEntity.ChatStatus; неизвестное значение → CRM_CHAT_STATUS_UNKNOWN (DLQ)
ChatIdGuidДаId чата messenger; не сохраняется на сделке (ADR-0055) — только лог/трейс
FailureReasonstring?Нет (только при Status = Failed)Диагностика в лог (не хранится); PII не содержит
ChangedAtDateTimeOffsetДаМомент смены в messenger; диагностика лага/порядка (авторитет порядка — partition key DealId)

Алгоритм

  1. Контекст события. Восстановить метаданные события (идентификатор сообщения, идентификатор сделки, время, версия контракта); при несовпадении версии — warn-and-proceed. Проверить идемпотентность по ключу crm:deal-chat-status:{MessageId}: если событие уже обработано → пропустить как дубль (ack, без изменений).
  2. Проверка входа и предусловий. DealId, TenantId, ChatId заданы; значение Status распознано как DealChatStatus — неизвестное значение → CRM_CHAT_STATUS_UNKNOWN (non-retriable → DLQ + алерт). Найти сделку по DealId: если её нет → CRM_DEAL_NOT_FOUND (retriable: узкое окно read-after-write — CRM сам создал сделку до реакции messenger; отсутствие — аномалия, после исчерпания ретраев DLQ); если сделка soft-deleted → skip + warn (статус чата неактуален). Сверить tenant: TenantId события ≠ TenantId сделки → CRM_CHAT_STATUS_TENANT_MISMATCH (non-retriable → DLQ). Статус самой сделки (DealStatus) не проверяется: ChatStatus ортогонален жизненному циклу сделки (терминальные Completed/Cancelled тоже принимают статус чата — messenger может закрыть/зафейлить чат).
  3. Монотонный rank-guard (INV-CHAT-1). Ранг: Pending = 0, Provisioning = 1, Failed = 1, Ready = 2. Новый статус применяется, только если он продвигает ранг (rank(new) > rank(current)) либо равен по рангу, но отличается по значению (штатная осцилляция Provisioning ↔ Failed при ретрае провизии messenger). Если текущий статус уже Ready (максимальный ранг) — поглощение: событие устаревшее/дубль (skipped-stale, метрика crm_chat_status_stale_total), отметка идемпотентности ставится, статус не меняется, ack.
  4. Применение. Если статус продвигает ранг — обновить DealEntity.ChatStatus на новое значение и UpdatedAt. Обновляется только это проекционное поле; доменное событие не поднимается (это проекция, а не бизнес-переход агрегата — во избежание повторной публикации). Обновление проекции и отметка идемпотентности фиксируются в одном согласованном изменении; параллельные правки сделки (drag-n-drop/решения сторон) пишут другие поля и не конфликтуют с этой записью. Offset подтверждается только после успешной фиксации.
  5. Результат. Успех → ack. Retriable-код (CRM_DEAL_NOT_FOUND) → retry по политике (таблица ниже); non-retriable → DLQ + алерт. Метрики/логи — раздел «Наблюдаемость».
INV-CHAT-1 — ранги статусов
СтатусРанг
Pending0
Provisioning1
Failed1
Ready2 (поглощающий, терминальный)

Диаграмма

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

  • На уровне консюмера: ключ crm:deal-chat-status:{MessageId} — повторная доставка после сбоя не меняет проекцию.
  • На бизнес-уровне: монотонный rank-guard (INV-CHAT-1) + поглощающий Ready: событие, не продвигающее ранг, пропускается — защита от out-of-order и от повторного проигрывания.
Replay безопасен

Полный повторный проход топика с offset 0 безопасен: per-deal порядок партиции сохраняется, промежуточные Failed/Provisioning перекрываются финальным Ready (поглощение), проекция сходится к терминальному статусу чата. Runbook — docs/06-services/crm/runbooks/.

Retry и DLQ

DLQ-топик: pin.crm.deal-chat-status-dlq.v1 (envelope исходного события + заголовки x-dlq-reason, x-dlq-error-code, x-source-topic, x-original-offset).

СбойКодRetryDLQРучное действие
Инфраструктурный (PostgreSQL/Kafka недоступны, timeout)— (exception)Бесконечный exponential backoff 1s→60s с паузой партицииНетRunbook: восстановить зависимость
Сделка не найдена (аномалия/edge read-after-write)CRM_DEAL_NOT_FOUND5 попыток, backoff 5s→10s→30s→1m→2mДа, после исчерпания + алертПроверить существование/soft-delete сделки; replay из DLQ
Tenant mismatch (payload.TenantId ≠ deal.TenantId)CRM_CHAT_STATUS_TENANT_MISMATCHНет (non-retriable)Да + алертРазбор аномалии messenger; данные не применять
Неизвестный Status (unknown enum)CRM_CHAT_STATUS_UNKNOWNНетДа + алертОбновить маппинг (релиз) → replay
Устаревшее/непродвигающее событие (монотонность)НетНетНе сбой: ack + метрика crm_chat_status_stale_total
Soft-deleted сделкаНетНетSkip + warn (статус чата неактуален)

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

  • Logs (structured): consumed / skipped-duplicate / skipped-stale / applied / deal-not-found / tenant-mismatch / dead-lettered с полями DealId, TenantId, ChatId, FromStatus, ToStatus, MessageId, payload.Version. PII нет (статус чата и ChatId — Internal; FailureReason не содержит персональных данных).
  • Metrics: crm_chat_status_consume_duration_ms, crm_chat_status_applied_total{to_status}, crm_chat_status_stale_total, crm_chat_status_duplicates_total, crm_chat_status_dlq_total, crm_chat_status_contract_mismatch_total, crm_messenger_consumer_lag_seconds.
  • Traces: span приёма события → span обработки → span обращения к хранилищу; корреляция из метаданных события.
  • Alerts: лаг консюмера > 5 мин (SLO README); DLQ > 0; частота переходов в Failed > 10/ч (алерт README ChatStatus = Failed > 10/ч).
  • Dashboard / Runbook: Grafana pin-crm; runbooks docs/06-services/crm/runbooks/ (будущий путь).
  • Безопасность: консюмер работает под system-actor; доступ к топику messenger — m2m ACL Kafka; tenant mismatch → fail closed (CRM_CHAT_STATUS_TENANT_MISMATCH, DLQ); проекция-запись ортогональна permission-модели сделки (system-writer, публичных мутаций ChatStatus нет).

Acceptance Criteria

  • AC-1 (продвижение ранга). Дано: DealEntity.ChatStatus = Pending. Когда: событие Provisioning. Тогда: ChatStatus = Provisioning, UpdatedAt обновлён, ack.
  • AC-2 (поглощение Ready). Дано: ChatStatus = Ready. Когда: приходит запоздалое Provisioning/Failed. Тогда: проекция не меняется, skipped-stale, инкремент crm_chat_status_stale_total, ack.
  • AC-3 (осцилляция одного ранга). Дано: ChatStatus = Provisioning. Когда: событие Failed (тот же ранг, иное значение). Тогда: ChatStatus = Failed применяется (штатный ретрай провизии), ack.
  • AC-4 (дубль). Дано: событие с уже обработанным MessageId. Когда: повторная доставка. Тогда: skipped-duplicate, проекция без изменений, ack.
  • AC-5 (tenant mismatch). Дано: payload.TenantId ≠ TenantId сделки. Когда: событие. Тогда: CRM_CHAT_STATUS_TENANT_MISMATCH (non-retriable) → DLQ + алерт, проекция не применяется.
  • AC-6 (неизвестный статус). Дано: Status вне enum DealChatStatus. Когда: событие. Тогда: CRM_CHAT_STATUS_UNKNOWN → DLQ + алерт.
  • AC-7 (сделка не найдена). Дано: сделки с DealId ещё нет (окно read-after-write). Когда: событие. Тогда: CRM_DEAL_NOT_FOUND — ретраи по политике, после исчерпания DLQ + алерт.
  • AC-8 (replay). Дано: полный повторный проход топика с offset 0. Когда: обработка. Тогда: проекция сходится к терминальному Ready (промежуточные Failed/Provisioning поглощены), дублей мутаций нет.

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

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

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