Consumer: Identity Events → Notifications (notifications-identity-events)
Назначение
Группа notifications-identity-events слушает события identity (регистрация, доступы, входы, инвайты
компаний, членство агента) и решает две задачи: (1) создаёт уведомления безопасности/доступа/приглашений
(NotificationEntity); (2) ведёт локальную реплику контактов получателя
(RecipientContactEntity) из pin.identity.users.v1 — ФИО, телефон, статус
учётки, tenant. Часть событий общих топиков в уведомления не отображается (upsert без релевантного
пользователю факта), но users-upsert при этом не выбрасывается: его ФИО/телефон/статус применяются к
контактной реплике, чтобы при enqueue не делать синхронный lookup в identity. Собственных доменных фактов в
identity consumer не меняет (кросс-контекстной записи нет; реплика контактов — внутренняя проекция самого
notifications); persist-then-send. Общий алгоритм enqueue — как в crm-events.md; здесь —
дельта источника и ведение реплики.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | notifications (проект Pin.Notifications.Background) |
| Consumer group | notifications-identity-events |
| Обрабатываемые события | топики pin.identity.*.v1 (таблица ниже) |
| Producer | identity |
| ContractVersion | ожидается 1 по каждому топику; несовпадение — warn-and-proceed |
| Эффект (событие → что меняется) | создаются NotificationEntity (Pending) для затронутых пользователей; апсертится RecipientContactEntity (реплика контактов) из users-топика |
Топики и маппинг
| Топик | Событие → NotificationKind | Получатель | Urgency |
|---|---|---|---|
pin.identity.users.v1 | first-registration → WelcomeRegistered; любой upsert → апдейт контактной реплики (ФИО/телефон/статус/tenant); блокировка → реплика RecipientStatus = Blocked; уведомления по прочему upsert нет | сам пользователь (уведомление) / реплика (все) | Low |
pin.identity.companies.v1 | suspend → CompanySuspended; прочий upsert — игнор | админы/менеджеры компании | Normal |
pin.identity.access.v1 | смена ролей/permissions → AccessChanged | затронутый пользователь | Normal |
pin.identity.sign-ins.v1 | новый вход/устройство → SecurityNewSignIn | вошедший пользователь | Critical |
pin.identity.company-invitation-changed.v1 (forward, ADR-0061) | приглашение создано → CompanyInvitationReceived; принято/отклонено → CompanyInvitationResolved | приглашаемый + инициатор | Normal |
pin.identity.agent-membership-changed.v1 (forward, ADR-0061) | вступление/выход/смена агентства → AgentMembershipChanged | агент + руководитель агентства | Normal |
Дом полей payload — event-контракты identity (напр. user-upserted.md,
user-signed-in.md,
user-access-changed.md); инвайты/членство — forward-контракты
(создаются работой ADR-0061), поля описаны в notification.md
(SecurityNotificationPayload/AccessNotificationPayload/InvitationNotificationPayload). Контактные поля
users-топика (Name/PhoneNumber/Status/CompanyId/Version) применяются к реплике
RecipientContactEntity (а не к уведомлению).
Контекст события
- Из метаданных события доступны
MessageId,CreatedAt,Version,AggregateId; actor = system; tenant уведомления — по получателю (INullableTenantedEntity). - Идемпотентность: consumer-level ключ
notifications-identity:{EventType}:{MessageId}; business-level — unique(SourceMessageId, RecipientUserId, Channel, Kind)(INV-NTF-1).
Входные данные / payload события
| Поле payload (типовое) | Тип | Обяз. | Как используется |
|---|---|---|---|
UserId/CompanyId/InvitationId | Guid | Да | получатель fan-out + id для payload уведомления |
SignedInAt/Ip/Device/Location | скаляры | sign-in | → SecurityNotificationPayload (Ip маскируется) |
Role/Scope/Change | enum/строка | access | → AccessNotificationPayload (ref-модель роли) |
CompanyName/Role/State | строка/enum | invite/membership | → InvitationNotificationPayload (ref-модель компании) |
Name/PhoneNumber/Status/CompanyId/Version | FullNameSnapshot/строка/enum/Guid?/long | users-upsert | → апдейт реплики RecipientContactEntity (guard по Version, INV-RC-2); в уведомление не идут |
Алгоритм
Общая логика enqueue (резолв каналов/шаблона/адреса, материализация, согласованность) — как в
crm-events.md; дельта источника — фильтр не-уведомляемых событий (шаг 1) и ведение контактной
реплики из users-топика (шаг 1a).
- Контекст события. Восстановить метаданные и сверить
ContractVersion(несовпадение — warn-and-proceed); проверить consumer-идемпотентность поnotifications-identity:{EventType}:{MessageId}(дубль →skipped-duplicate, ack). ОпределитьKindпо типу события; дляusers-топика (upsert/blocked)Kindможет отсутствовать (не уведомляемый факт) — событие всё равно не выбрасывается, а идёт в ведение реплики (шаг 1a). 1a. Ведение реплики контактов (users-топик). Дляpin.identity.users.v1применить контактные поля к RecipientContactEntity поUserId(first-seen — создать, иначе — обновить):Name/PhoneNumber/RecipientStatus/TenantId, guard монотонности поVersion(устаревшее событие — no-op, INV-RC-2);user-blocked→RecipientStatus = Blocked; анонимизация (Archived, наибольшаяVersion) очищает PII (INV-RC-3). Если событие не уведомляемое (upsert безKind, кроме first-registration →WelcomeRegistered) — после апдейта реплики логreplica-updated, ack (уведомление не создаётся). Апдейт реплики фиксируется в том же согласованном изменении, что и отметка идемпотентности (шаг 5). - Проверка входа. Обязательные идентификаторы непусты; иначе →
NTF_INVALID_PAYLOAD(DLQ). - Резолв получателя. Пользователь / админы компании / агент + руководитель; дедуп; пустое множество →
no-recipients(ack). - Резолв доставки и материализация. По каждому получателю — каналы для
Kind/Urgency(INV-UNS-3/4;SecurityNewSignIn—Critical, opt-out игнорируется); локаль и адрес — снапшот из контактной репликиRecipientContactEntity(INV-RC-4/5; отсутствие точки канала не блокирует — graceful, in-app доступен всегда); шаблон(Kind, Locale, Channel); тихие часы; создаётсяNotification(Pending)с рендером (dedup INV-NTF-1). Нет активного шаблона →NTF_TEMPLATE_NOT_RESOLVED(DLQ). Отправку выполняет dispatcher; собственных интеграционных событий consumer не публикует. - Согласованность и результат. Уведомления, апдейт реплики и отметка идемпотентности фиксируются в одном
согласованном изменении, offset — после фиксации. Успех/
replica-updated/no-recipients→ ack; retriable → retry; non-retriable → DLQ.
Диаграмма
Идемпотентность и replay
- Consumer-level по
MessageId; business-level — dedup-unique (INV-NTF-1). Replay: да (dedup-ключ).
Retry и DLQ
| Сбой | Код | Retry | DLQ |
|---|---|---|---|
| Инфраструктурный | — | backoff + пауза партиции | Нет |
| Нарушение payload | NTF_INVALID_PAYLOAD | Нет | Да + алерт |
| Шаблон не резолвится | NTF_TEMPLATE_NOT_RESOLVED | Нет | Да + алерт |
Кросс-сервисные контракты
- Ожидает от
identity: события на топикахpin.identity.*.v1, key = id агрегата (UserId/CompanyId/InvitationId),ContractVersion = 1, доставка at-least-once. Топикusersнесёт и не-уведомляемые upsert-факты, но они не выбрасываются: их контактные поля (Name/PhoneNumber/Status/CompanyId, guard поVersion) применяются к контактной реплике RecipientContactEntity; уведомление создаётся только по уведомляемым фактам (маппинг «Событие → Kind»). Топикcompaniesфильтруется как прежде (уведомляемый факт →Kind, прочее —ignored-event). Дом полей — event-контрактыidentity(../../identity/events/). - Предоставляет (реплика):
RecipientContactEntity— долгоживущая контактная проекция получателя, из которой enqueue снимаетRecipientAddress/Localeбез синхронного lookup вidentity; source of truth контактов остаётся заidentity/profile,notificationsдержит только реплику. - Forward-топики
company-invitation-changed/agent-membership-changedсоздаются работой ADR-0061: до их появления consumer подписан, но сообщений нет (пустой поток — не ошибка); при появлении контракта маппинг уже задан. - Предоставляет:
NotificationEntity(Pending) для затронутых пользователей; вidentityне пишет. Собственных интеграционных событий не публикует; финальный исход — за dispatcher-ом.
Пред- и постусловия
- Предусловие. Событие получено;
ContractVersionсовместима. Для уведомляемого факта — естьActive-шаблон(Kind, Locale)с включённым каналом; дляusers-upsert достаточно валидногоUserId(даже без уведомляемого факта — ведётся реплика). - Постусловие (успех). Для уведомляемого факта — для каждой разрешённой пары
(получатель, канал)созданPending(dedup INV-NTF-1) с адресом/локалью из контактной реплики; дляusers-upsert — репликаRecipientContactEntityотражает актуальные ФИО/телефон/статус (guard поVersion). Отметка идемпотентности и апдейт реплики зафиксированы; offset подтверждён после фиксации.SecurityNewSignIn(Urgency = Critical) доставляется без учёта тихих часов иGlobalMute; Ip/устройство маскируются.
Acceptance criteria
- Given
pin.identity.sign-ins.v1о новом входе, when обработка, thenSecurityNewSignInсоздаётся,Criticalигнорирует тихие часы/opt-out, Ip замаскирован,ack. - Given
users-upsert без уведомляемого факта (обновление ФИО/телефона), when обработка, then контактная репликаRecipientContactEntityобновлена (guard поVersion), уведомлений нет,replica-updated,ack(без DLQ); givencompanies-upsert без релевантного факта —ignored-event,ack. - Given для получателя есть контактная реплика, when enqueue, then
RecipientAddress/Localeберутся из реплики без синхронного lookup; given реплики ещё нет (событие опередилоusers-upsert), then in-app создаётся, внешний адрес пуст и догоняется после апдейта реплики (graceful, не DLQ). - Given
user-blocked/анонимизация вusers-топике, when обработка, then реплика получаетRecipientStatus = Blocked/Archived(PII очищена приArchived), внешние каналы подавляются, in-app сохраняется. - Given повтор того же события (тот же
MessageId), when обработка, thenskipped-duplicate, дублей нет. - Given пустое множество получателей после дедупа, when обработка, then
no-recipients, отметка идемпотентности зафиксирована,ack. - Given для
KindнетActive-шаблона, when обработка, thenNTF_TEMPLATE_NOT_RESOLVED→ DLQ + алерт; given payload без обязательного id —NTF_INVALID_PAYLOAD→ DLQ + алерт. - Given forward-топик инвайтов ещё не существует, when цикл потребления, then пустой поток без
ошибок; при появлении события — маппится в
CompanyInvitationReceived/CompanyInvitationResolved.
Наблюдаемость и безопасность
- Logs:
consumed/replica-updated/ignored-event/skipped-duplicate/no-recipients/enqueued/failed(PII/Ip/ФИО/телефон маскируются). - Metrics:
notifications_enqueued_total{kind,channel},notifications_consumer_lag_seconds{source=identity},notifications_dlq_total. - Безопасность: system-actor;
SecurityNewSignInсодержит маскированный Ip/устройство; топики по m2m ACL Kafka.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: RecipientContactEntity
- События (6): Event Contract: pin.crm.lead-created (LeadCreatedEvent), Event Contract: pin.identity.user-blocked (UserBlockedKafkaEvent), Event Contract: pin.identity.user-upserted (UserUpsertedKafkaEvent), Event Contract: pin.support.ticket-created (TicketCreatedKafkaEvent), Event Contract: pin.support.ticket-message-posted (TicketMessagePostedKafkaEvent), Event Contract: pin.support.ticket-status-changed (TicketStatusChangedKafkaEvent)
- index.md (1): Notifications Service
Связанные документы
- Целевой агрегат: notification.md; контактная реплика — recipient-contact.md; настройки — user-notification-settings.md; общий алгоритм — crm-events.md.
- Входные контракты: identity/events/; forward — инвайты/членство (ADR-0061); карта — README контекста.
- ADR: 0052, 0056.