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 |
| Consumer | MessengerBookingExpiredConsumer |
| Обрабатываемое событие | BookingExpiredEvent |
| Topic | pin.crm.booking-expired.v1 (key = BookingId, 6 партиций) |
| ContractVersion | ожидается 1; при несовпадении версии — warn-and-proceed (messenger_booking_expired_contract_mismatch_total) |
| Consumer group | messenger-booking-expired |
| Producer | crm: воркер 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 | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
BookingId | Guid | Да | CardBookingExpiredPayload.BookingId |
DealId | Guid | Да | Поиск чата сделки по DealId; Payload.DealId |
Phase | string (BookingExpirationPhase) | Да | Выбор текста (Reserve/Booking); в payload карточки не входит |
ExpiredAt | DateTimeOffset | Да | Payload.ExpiredAt; момент отправки карточки |
TenantId/PropertyObjectId на карточку не влияют (освобождение объекта — консюмер chessboard, чужая граница).
Неизвестная Phase — нейтральная формулировка «срок бронирования истёк» + warning (fallback, не падать).
Модель payload карточки CardBookingExpiredPayload
Дом — message.md (раздел «Модели JSONB»); фаза кодируется выбором текста (i18n-ключа), а не полем payload.
| Поле | Тип | Обязательность | Источник |
|---|---|---|---|
DealId | Guid | Да | BookingExpiredEvent.DealId |
BookingId | Guid | Да | BookingExpiredEvent.BookingId |
ExpiredAt | DateTimeOffset | Да | BookingExpiredEvent.ExpiredAt |
Алгоритм
- Контекст события. Восстановить метаданные события; сверить версию контракта с
1(при несовпадении — warn-and-proceed). Проверить бизнес-идемпотентность поmessenger-booking-expired:{MessageId}: событие уже обработано → пропустить как дубль (skipped-duplicate, ack). - Проверка входа.
DealIdиBookingIdзаданы;Phaseнепустой (неизвестное значение — не отказ, а нейтральный fallback-текст). Структурно невалидный payload →MSG_BOOKING_EXPIRED_CARD_INVALID(DLQ + алерт). - Что читаем. Найти чат сделки по
DealId. Если чата ещё нет — он не провизионирован (событие пришло раньше deal-created): отказMSG_CHAT_NOT_PROVISIONEDс ретраем (backoff, догонит поBookingId). - Бизнес-правила и маппинг. Собрать карточку:
CardType = BookingExpired,Category = TargetAction,ActionStatus = Pending,SourceEventId = MessageId,Payload = CardBookingExpiredPayload { DealId, BookingId, ExpiredAt }; текст — по фазе:Reserve→ «подтвердите бронь»,Booking→ «подтвердите/продлите», unknown → нейтральный. Видимость — все участники (INV-MSG-5). Статус чата не меняется (закрытие/возврат наApplicationпридёт отдельнымdeal-stage-changed). - Что меняется. В ленту чата добавляется системная карточка (
Seqприсваивается монотонно, денормализуется last-message;SenderUserIdпустой,IsSystem = true, INV-MSG-4). - Что фиксируется. По факту публикации карточки участникам чата уходит realtime-доставка
MessageReceived(persist-then-push). Интеграционных Kafka-событий нет: offline-push шлётnotifications, освобождение объекта —chessboard, оба напрямую изbooking-expired. - Согласованность. Карточка и отметка идемпотентности фиксируются в одном согласованном изменении; offset подтверждается только после успешной фиксации.
- Результат. Успех → 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 обработка; ThenMSG_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
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Чат ещё не провизионирован | MSG_CHAT_NOT_PROVISIONED | Да, backoff | После лимита | Проверить лаг messenger-deal-created |
| Инфраструктурный (PostgreSQL/Kafka) | — | Бесконечный backoff 1s→60s | Нет | Runbook: восстановить зависимость |
| Структурная ошибка payload | MSG_BOOKING_EXPIRED_CARD_INVALID | Нет | Да + алерт | Проверить продьюсер crm |
| ContractVersion mismatch | — | warn-and-proceed | Нет | Проверить версию контракта |
Наблюдаемость и безопасность
- Logs:
consumed/skipped-duplicate/card-posted(Phase) /retry-not-provisioned/failedcMessageId,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.
Обратные ссылки
Автоссылки: где используется этот документ.
- События (2): События сервиса messenger: реестр топиков, Event Contract: pin.crm.booking-expired (BookingExpiredEvent)
- Консюмеры (2): Consumer: Booking Paid Card (MessengerBookingPaidConsumer), Consumer: Document Request Card (MessengerDocumentRequestedConsumer)
Связанные документы
- Целевые агрегаты: message.md (
EventCardType.BookingExpired,CardBookingExpiredPayload), chat.md (публикация карточки в ленту). - Входное событие (дом): booking-expired.md.
- Соседние консюмеры: booking-paid.md, deal-stage-changed.md (возврат/закрытие сделки), deal-created.md.
- README; ADR-0055, 0056.