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

Consumer: Booking Expired Card (MessengerBookingExpiredConsumer)

Назначение

Consumer слушает событие pin.crm.booking-expired.v1 и постит в чат сделки карточку целевого действия «Бронь истекла — подтвердите/продлите» (CardType = BookingExpired, Category = TargetAction, ActionStatus = Pending). Текст карточки зависит от фазы бронирования: Reserve — «время резерва истекло, подтвердите бронь»; Booking — «бронь истекла, подтвердите/продлите». Карточка видна всем участникам чата (персональных строк-получателей нет, INV-MSG-5). Обработка идемпотентна по источнику события (INV-MSG-8).

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

ПолеЗначение
Сервис/контекстmessenger
ConsumerMessengerBookingExpiredConsumer
Обрабатываемое событиеBookingExpiredEvent
Topicpin.crm.booking-expired.v1 (key = BookingId, 6 партиций)
ContractVersionожидается 1; при несовпадении версии — warn-and-proceed (messenger_booking_expired_contract_mismatch_total)
Consumer groupmessenger-booking-expired
Producercrm: воркер booking-expiration (INV-BKG-5)
Эффект (событие → что меняется)В ленту чата сделки добавляется системная карточка BookingExpired с целевым действием

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

  • Из метаданных события доступны: идентификатор сообщения (для дедупликации), время события, версия контракта, идентификатор и тип агрегата-источника (BookingId / Booking).
  • Актор — системный; чат не tenant-фильтруется, доступ по членству (ADR-0052).
  • Корреляция сквозного трейса восстанавливается из метаданных события.
  • Ключ бизнес-идемпотентности: messenger-booking-expired:{MessageId}; на уровне данных — уникальность карточки по (ChatId, CardType = BookingExpired, SourceEventId = MessageId) (INV-MSG-8). Обе фазы — разные события (разный MessageId), поэтому не де-дуплятся между собой (карточка резерва и карточка брони обе допустимы).

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

Контракт — дом booking-expired.md. Ниже — читаемые поля.

Поле payloadТипОбязательностьКак используется (маппинг)
BookingIdGuidДаCardBookingExpiredPayload.BookingId
DealIdGuidДаПоиск чата сделки по DealId; Payload.DealId
Phasestring (BookingExpirationPhase)ДаВыбор текста (Reserve/Booking); в payload карточки не входит
ExpiredAtDateTimeOffsetДаPayload.ExpiredAt; момент отправки карточки

TenantId/PropertyObjectId на карточку не влияют (освобождение объекта — консюмер chessboard, чужая граница). Неизвестная Phase — нейтральная формулировка «срок бронирования истёк» + warning (fallback, не падать).

Модель payload карточки CardBookingExpiredPayload

Дом — message.md (раздел «Модели JSONB»); фаза кодируется выбором текста (i18n-ключа), а не полем payload.

ПолеТипОбязательностьИсточник
DealIdGuidДаBookingExpiredEvent.DealId
BookingIdGuidДаBookingExpiredEvent.BookingId
ExpiredAtDateTimeOffsetДаBookingExpiredEvent.ExpiredAt

Алгоритм

  1. Контекст события. Восстановить метаданные события; сверить версию контракта с 1 (при несовпадении — warn-and-proceed). Проверить бизнес-идемпотентность по messenger-booking-expired:{MessageId}: событие уже обработано → пропустить как дубль (skipped-duplicate, ack).
  2. Проверка входа. DealId и BookingId заданы; Phase непустой (неизвестное значение — не отказ, а нейтральный fallback-текст). Структурно невалидный payload → MSG_BOOKING_EXPIRED_CARD_INVALID (DLQ + алерт).
  3. Что читаем. Найти чат сделки по DealId. Если чата ещё нет — он не провизионирован (событие пришло раньше deal-created): отказ MSG_CHAT_NOT_PROVISIONED с ретраем (backoff, догонит по BookingId).
  4. Бизнес-правила и маппинг. Собрать карточку: CardType = BookingExpired, Category = TargetAction, ActionStatus = Pending, SourceEventId = MessageId, Payload = CardBookingExpiredPayload { DealId, BookingId, ExpiredAt }; текст — по фазе: Reserve → «подтвердите бронь», Booking → «подтвердите/продлите», unknown → нейтральный. Видимость — все участники (INV-MSG-5). Статус чата не меняется (закрытие/возврат на Application придёт отдельным deal-stage-changed).
  5. Что меняется. В ленту чата добавляется системная карточка (Seq присваивается монотонно, денормализуется last-message; SenderUserId пустой, IsSystem = true, INV-MSG-4).
  6. Что фиксируется. По факту публикации карточки участникам чата уходит realtime-доставка MessageReceived (persist-then-push). Интеграционных Kafka-событий нет: offline-push шлёт notifications, освобождение объекта — chessboard, оба напрямую из booking-expired.
  7. Согласованность. Карточка и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации.
  8. Результат. Успех → ack (SLA < 5 мин). MSG_CHAT_NOT_PROVISIONED/инфраструктурный сбой → ретрай (backoff); структурная ошибка → DLQ + алерт.

Диаграмма

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

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

  • Событие booking-expired опубликовано crm воркером booking-expiration (INV-BKG-5) — бронь фактически истекла в источнике; messenger факт истечения не переисчисляет, а отражает.
  • Сделка (DealId) существовала на момент публикации; чат сделки, как правило, уже провизионирован (deal-created). Если ещё нет — это допустимая гонка, разрешается ретраем (шаг 3).

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

  • В ленте чата ровно одна карточка BookingExpired для данного MessageId (Category = TargetAction, ActionStatus = Pending); при повторной доставке новых карточек не появляется.
  • last-message чата денормализован на эту карточку; Seq монотонно возрос на 1.
  • Онлайн-участникам ушла realtime-доставка MessageReceived; оффлайн-участники получат её при подключении (persist-then-push — карточка уже persisted, доставка eventual).
  • Статус чата и статус сделки не изменены; наружу не ушло ни одного Kafka-события; offset подтверждён только после фиксации.
  • Карточка резерва и карточка брони по одной брони сосуществуют, если пришли обе фазы (разные MessageId).

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

Ожидается от crm (вход pin.crm.booking-expired.v1): at-least-once доставка; ключ партиционирования BookingId (порядок per-booking сохранён); обязательные поля DealId, BookingId, Phase, ExpiredAt, MessageId (дедуп), ContractVersion. Гарантия продьюсера: DealId указывает на реальную сделку; Phase ∈ {Reserve, Booking} — неизвестное значение допустимо (нейтральный текст, не отказ).

Ожидается от deal-created: чат по DealId провизионирован (eventual; окно — обычно секунды, при лаге до нескольких минут; пока нет — MSG_CHAT_NOT_PROVISIONED + backoff-ретрай).

Предоставляет наружу: ничего — исходящих Kafka-событий нет. Освобождение объекта (chessboard) и offline-push (notifications) слушают booking-expired напрямую, не транзитом через messenger (нет двойного пуша). Единственный эффект вовне контекста — realtime-доставка карточки участникам внутри messenger.

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

  • Happy (резерв): Given провизионированный Deal-чат и событие booking-expired с Phase = Reserve; When consumer обрабатывает событие; Then в ленте появляется карточка BookingExpired (Pending) с текстом «подтвердите бронь», участникам уходит MessageReceived, offset подтверждён, Kafka молчит.
  • Happy (бронь): Given то же с Phase = Booking; When обработка; Then карточка с текстом «подтвердите/продлите»; обе карточки (резерв+бронь) допустимо сосуществуют.
  • Дубль: Given событие с уже обработанным MessageId; When повторная доставка; Then вторая карточка не создаётся (skipped-duplicate), ack.
  • Чат не готов: Given чат по DealId ещё не провизионирован; When обработка; Then MSG_CHAT_NOT_PROVISIONED, ретрай с backoff, после лимита — DLQ; карточка не постится.
  • Неизвестная фаза: Given Phase вне enum; When обработка; Then карточка с нейтральным текстом «срок бронирования истёк» + warning, не отказ.
  • Битый payload: Given структурно невалидное событие; When обработка; Then MSG_BOOKING_EXPIRED_CARD_INVALID, DLQ + алерт, без ретрая.
  • Иная версия контракта: Given ContractVersion ≠ 1; When обработка; Then warn-and-proceed (метрика mismatch), обработка продолжается.

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

  • По сообщению: ключ messenger-booking-expired:{MessageId} — повтор доставки не создаёт вторую карточку.
  • По данным: уникальность (ChatId, CardType, SourceEventId = MessageId) (INV-MSG-8); две фазы (Reserve/Booking) — разные MessageId, обе карточки допустимы.
  • Replay: да. Повторный re-read безопасен (де-дуп по SourceEventId). Порядок per-booking гарантирован ключом BookingId.

Retry и DLQ

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

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

  • Logs: consumed / skipped-duplicate / card-posted(Phase) / retry-not-provisioned / failed c MessageId, DealId, ChatId, BookingId, Phase. PII нет (id/enum).
  • Metrics: messenger_booking_expired_consume_duration_ms, messenger_booking_expired_cards_posted_total{phase}, messenger_booking_expired_dlq_total, messenger_booking_expired_lag_seconds.
  • Traces: приём события → обработка → обращения к хранилищу; корреляция из метаданных события.
  • Alerts: лаг > 5 мин; всплеск MSG_CHAT_NOT_PROVISIONED; DLQ > 0.
  • Безопасность: системный актор, доступ по членству; событие — только id/enum/timestamp (PII нет); ФИО резолвятся из реплики messenger-user.md.

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

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

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