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

UC-NTF-002. Событие сделки → многоканальное уведомление с учётом настроек

Назначение

Описывает сценарий: интеграционное событие crm (напр. смена стадии сделки) приводит к созданию уведомлений участникам сделки по разрешённым каналам с учётом их настроек и тихих часов. Связан с контекстом notifications.

Metadata

ПолеЗначение
IDUC-NTF-002
Контекстnotifications (см. docs/06-services/notifications/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольSystem (consumer notifications-crm-events); получатели — Агент/Инвестор/МенеджерПИН (участники сделки)
Permissions / scopesystem-actor (consumer); topic по m2m ACL Kafka
Tenantуведомление несёт TenantId получателя (CompanyId или null)
Record-levelне применяется (fan-out по участникам события)

Предусловие

  • Событие pin.crm.deal-stage-changed.v1 опубликовано crm в топик.
  • Есть Active-шаблоны (DealStageChanged, Locale) для целевых каналов (SEED/admin).
  • У участников есть UserNotificationSettingsEntity (или применяются дефолты).

Триггер

ПолеЗначение
ИсточникKafka event
Триггерящий элементconsumer notifications-crm-events (crm-events.md)

Основной Поток

  1. crm меняет стадию сделки и публикует pin.crm.deal-stage-changed.v1 (через outbox).
  2. Consumer notifications-crm-events принимает событие, восстанавливает контекст, сверяет ContractVersion и проверяет идемпотентность по идентификатору сообщения.
  3. Событие маппится в Kind = DealStageChanged; собираются получатели (участники сделки) и типизированный снапшот DealNotificationPayload.
  4. По каждому получателю: определяются разрешённые каналы по виду и срочности (дефолты + явные opt-in/out, GlobalMute), резолвится шаблон (Kind, locale, channel), локаль и адрес — снапшотом из контактной реплики RecipientContactEntity (мягкая деградация при отсутствии точки); при попадании в тихие часы interruptive-каналам выставляется ScheduledAt.
  5. По каждой валидной паре (получатель × канал) создаётся NotificationEntity в статусе Pending, рендерятся RenderedTitle/Body/DeepLink; повтор события не создаёт дубль (dedup по SourceMessageId, INV-NTF-1).
  6. Уведомления и отметка идемпотентности сохраняются в одном согласованном изменении (persist-then-send); ack.
  7. Dispatcher берёт Pending-уведомления (у которых ScheduledAt пуст/наступил) и отправляет через provider-адаптеры; in-app — realtime push (SignalR); успех → Sent (+ событие dispatched).
  8. Клиент получателя показывает in-app уведомление (колокольчик +1) realtime; email/push/… доставляются провайдерами (при включённых каналах).

Альтернативные Потоки

A1. Часть каналов отключена / тихие часы

  1. На шаге 4 у получателя push в opt-out, а sms попадает в тихие часы.
  2. push не создаётся; sms создаётся с ScheduledAt = открытие окна; in-app/email создаются сразу.

Возвращается к основному потоку на шаг 6.

A2. Дубль события (replay)

  1. На шаге 5 запись (SourceMessageId, Recipient, Channel, Kind) уже существует (INV-NTF-1).
  2. Consumer пропускает дубль (skipped-duplicate), ack — новые уведомления не создаются.

A3. Нет шаблона канала

  1. На шаге 4 нет Active-шаблона (Kind, Locale) для канала → канал пропускается (INV-TPL-5); при отсутствии шаблона для всех каналов — NTF_TEMPLATE_NOT_RESOLVED → DLQ + алерт (нужна настройка шаблона).

A4. Все каналы получателя отключены

  1. На шаге 4 у получателя выключены все каналы для Kind (не-Critical) или включён GlobalMute при не-Critical виде → множество разрешённых каналов пусто.
  2. Для этого получателя NotificationEntity не создаётся; consumer не считает это ошибкой (ack). Если пусты каналы у всех получателей — событие обработано (no-recipients/пустой fan-out), ack без DLQ.

A5. GlobalMute и Critical-вид

  1. Событие соответствует Critical-виду (напр. BookingPaid, BookingExpired). Даже при GlobalMute и в тихие часы Critical доставляется без дефера (INV-UNS-4/INV-NTF-4): ScheduledAt не выставляется.

A6. Неизвестная версия контракта события

  1. На шаге 2 ContractVersion события crm не совпадает с ожидаемой (1) → warn-and-proceed: consumer логирует расхождение и обрабатывает известные поля; отсутствие обязательного для Kind поля → шаг 3 даёт NTF_INVALID_PAYLOAD (DLQ + алерт).

A7. Адрес получателя недоступен (отставание контактной реплики)

  1. На шаге 4 контактная реплика RecipientContactEntity (recipient-contact.md) ещё не содержит точки канала (событие домена опередило users/persons-upsert) → адрес остаётся пустым; это не ошибка: NotificationEntity создаётся (in-app адрес не требуется), а email/sms-адрес догоняется после апдейта реплики/адаптером на отправке. Пользователь в промежутке видит in-app-уведомление, внешний канал приходит позже.

A8. Повторные/внеочередные события по одной сделке

  1. По одной сделке приходят несколько событий (создание, смены стадии, завершение) — каждое даёт свой NotificationEntity (append-семантика, состояние прежних уведомлений не перезаписывается). Порядок доставки на согласованность не влияет; дубль конкретного события отсекается INV-NTF-1.

Постусловие

  • Созданы NotificationEntity участникам по разрешённым каналам; in-app доставлено realtime; внешние — в очереди/Sent.
  • Идемпотентность гарантирует отсутствие дублей при повторной доставке события.
  • Offset события подтверждается только после согласованной фиксации уведомлений + отметки идемпотентности; при сбое до фиксации — событие переобрабатывается (dedup исключает дубли).

Кросс-сервисные ожидания и гарантии

НаправлениеКонтракт
Ожидает от crm (входное событие)pin.crm.deal-stage-changed.v1 (и смежные), ContractVersion = 1, ключ партиционирования = агрегат сделки (порядок в пределах сделки), at-least-once доставка; поля payload и участники — crm/events/
Ожидает от identity/profileсобытийную реплику контактов (RecipientContactEntity из pin.identity.users.v1 — ФИО/телефон/статус; email/локаль — forward из контактного события profile); адрес/локаль — снапшот с реплики, синхронного lookup нет; отставание реплики — деградация, не ошибка
Ожидает от шаблоновActive-шаблон (Kind, Locale, Channel) (notification-template.md); нет для всех каналов → NTF_TEMPLATE_NOT_RESOLVED
Ожидает от настроекрезолв каналов по UserNotificationSettings (INV-UNS-3/4/5)
Предоставляетнабор NotificationEntity(Pending) (fan-out получатель×канал); in-app realtime; на финальном исходе доставки — NotificationDispatchedEvent (pin.notifications.dispatched.v1, ключ NotificationId)
Окно eventual-consistencyin-app — доли секунды (realtime); внешние каналы — до dispatcher/retry-цикла; промежуточно пользователь видит только in-app

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
NTF_INVALID_PAYLOAD— (DLQ)нарушен контракт события crmсогласовать контракт, replay
NTF_TEMPLATE_NOT_RESOLVED— (DLQ)нет Active-шаблоназавести/опубликовать шаблон, replay

Доменные События

СобытиеКогда поднимаетсяSubscribers
NotificationCreatedпри создании NotificationEntityin-app realtime push (SignalR), счётчик непрочитанных
NotificationStatusChangedпри Sent/Failed/Readобновление бейджа/статуса

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
NotificationDispatchedEventpin.notifications.dispatched.v1при Sent/терминальном Failed (dispatcher)(будущая аналитика; v1 без прод-потребителей)

Acceptance Criteria

#КритерийПроверка
AC1Каждый участник получает уведомление только по разрешённым каналамсобытие → набор NotificationEntity = каналы, разрешённые настройками получателя
AC2Повторная доставка события не создаёт дублейreplay MessageId → нет новых записей (INV-NTF-1)
AC3In-app доставляется realtime, статус переходит в Sentподписка SignalR получает push; Status = Sent
AC4Interruptive-канал в тихие часы деферитсяScheduledAt выставлен, отправка после окна
AC5Все каналы отключены → уведомление не создаётся, событие подтвержденоopt-out всех каналов не-Critical → нет NotificationEntity, ack, без DLQ
AC6Critical обходит GlobalMute и тихие часыBookingPaid при GlobalMute в окне тишины → уведомление создано без ScheduledAt
AC7Отставание контактной реплики не блокирует созданиенет точки канала в RecipientContactEntityNotificationEntity создан; in-app доставлен; внешний адрес догнан после апдейта реплики/на retry
AC8Нарушение контракта payload → DLQобязательное поле Kind отсутствует → NTF_INVALID_PAYLOAD, DLQ + алерт (не бесконечный retry)

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

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

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