Consumer: контроль смены телефона связанного инвестора (pin-investor-owner-identity-users)
Consumer контекста investor-owner на события пользователей identity: мониторинг расхождения
телефона у уже связанного инвестора. Входной контракт — чужой дом identity
(user-upserted.md); здесь описывается только то, что consumer
читает и делает. Дом сравниваемого снапшот-поля OwnerMapping.PhoneNumber (зафиксирован на момент
линковки, INV-7) — owner-mapping.md; правило «смена телефона НЕ разрывает
связь» — assumption README (../README.md, раздел «Известные ограничения и assumptions»).
Назначение
Смена телефона инвестора в PIN после установки связи не разрывает связь: сопоставление
Собственник ↔ Инвестор выполнено по телефону только на момент линковки. Этот consumer наблюдает поток
pin.identity.users.v1 и, когда актуальный телефон связанного инвестора расходится со снапшотом связи,
поднимает метрику расхождения и structured-лог для ручной сверки Службой заботы — без
авторазрыва и без переустановки связи. Consumer не мутирует состояние связи (опционально —
диагностическая пометка) и не производит событий (минимальность ADR-0056): расхождение — это
операционный сигнал, а не доменный факт.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | investor-owner |
| Обрабатываемое событие | UserUpsertedKafkaEvent (event type pin.identity.user-upserted) |
| Topic | pin.identity.users.v1 (общий с событием блокировки пользователя; key = UserId) |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed (метрика investor_owner_identity_users_contract_mismatch_total) |
| Consumer group | pin-investor-owner-identity-users |
| Producer | identity (регистрация / обновление инвестора / подтверждение смены телефона / admin-API / синхронизация из Talent Identity) — контракт identity, upstream source of truth |
| Эффект (событие → что меняется) | Метрика/лог расхождения телефона (owned-состояние связи не меняется; опционально — диагностическая пометка) |
Контекст события
- Из метаданных события доступны:
MessageId(Guid v7), время событияCreatedAt, версия контракта,AggregateId = UserId,AggregateType = User. - Actor — system-actor consumer-а (не пользовательский контекст); tenant не применяется
(ADR-0052 — данные инвестора вне tenant): чтение/запись связей ведётся с bypass record-level фильтра
по
InvestorUserId. - Корреляция сквозного трейса восстанавливается из метаданных события (в исходящие события не пробрасывается — consumer событий не производит).
- Идемпотентность: на уровне сообщения — по
MessageId(ключinvestor-owner-identity-users:user-upserted:{MessageId}); на бизнес-уровне — сравнение значения телефона (шаг 4).
Входные данные / payload события
Контракт события — user-upserted.md (дом полей;
FullNameSnapshot, CompanyId, Status, RegisteredVia, ReferrerUrl этим consumer-ом не читаются).
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
UserId | Guid | Да | Ключ поиска связи по InvestorUserId; = AggregateId события |
PhoneNumber | string (E.164) | Да | Нормализация E.164 → сравнение со снапшотом OwnerMapping.PhoneNumber |
Type | string (enum UserType) | Да | Short-circuit: со связью Linked может совпасть только Investor; иное → not-linked no-op |
Version | long | Да | Опциональный version-guard (tie-break при неизменном телефоне); structured-лог |
Алгоритм
- Контекст события и идемпотентность. Восстановить метаданные события (
MessageId,CreatedAt,AggregateId = UserId); сверить версию контракта с1— при несовпадении warn-and-proceed (метрикаinvestor_owner_identity_users_contract_mismatch_total); actor = system, tenant не применяется. Если сообщение с такимMessageIdуже обработано → дубль: логskipped-duplicate, ack (no-op). - Проверка входа.
UserIdзаполнен;PhoneNumberнепустой и нормализуем в E.164 — ненормализуемый номер → отказINVESTOR_OWNER_IDENTITY_INVALID_PAYLOAD(non-retriable → DLQ + алерт). Существование связи входом не проверяется — её отсутствие есть допустимый no-op (шаги 3–4), а не ошибка. - Поиск связи. Найти активную связь инвестора: по
InvestorUserId = UserId,Status = Linked, не архивированную. Единственность гарантированаux_owner_mappings_investor_user_id(INV-5). Иных загрузок нет. - Сравнение телефона (перехода состояний нет):
not-linked(связь не найдена илиType != Investor): инвестор не связан с собственником (нет связи /PendingLink/Conflict/ архив) → идемпотентный no-op, логnot-linked, фиксируется только отметка идемпотентности.- Сравнение: нормализованный телефон из payload сопоставляется со снапшотом
OwnerMapping.PhoneNumber(зафиксирован на момент линковки — дом owner-mapping.md).- совпадение: лог
skipped-no-change, no-op — телефон инвестора не расходится со снапшотом связи (бизнес-идемпотентность). - расхождение: НЕ разрывать и НЕ переустанавливать связь (assumption README; INV-7
ImmutableLinkKind —
LinkKind/InvestorUserIdиммутабельны) → поднять метрикуinvestor_owner_link_phone_divergence_total, structured-логphone-divergenceс маскированными телефонами (+7***1234). Опционально — диагностическая пометка вConflictDetails(маскированный телефон +MessageId/Version) без сменыStatus/LinkKind/ConflictReason(связь остаётсяLinked— это диагностика, не конфликт связи).
- совпадение: лог
- Маппинга поле-в-поле в owned state нет: снапшот связи владеет Talent Owners, телефон инвестора владеет
identity— consumer только сравнивает.
- Согласованность. Базовый путь — фиксируется только отметка идемпотентности (owned state связи не
меняется). Опциональный диагностический путь — обновление
ConflictDetails/UpdatedAtсвязи; эта правка и отметка идемпотентности фиксируются в одном согласованном изменении. Offset подтверждается только после успешной фиксации. - Результат. Успех → ack (
skipped-duplicate/not-linked/skipped-no-change/phone-divergence). ОтказINVESTOR_OWNER_IDENTITY_INVALID_PAYLOAD→ non-retriable → DLQ + алерт. Инфраструктурный сбой (PostgreSQL/Kafka) → бесконечный backoff (событие не теряется).
Диаграмма
Идемпотентность и replay
- На уровне сообщения: по
MessageIdсобытия (ключinvestor-owner-identity-users:user-upserted:{MessageId}) — повторная доставка после сбоя не дублирует alert/пометку. - На бизнес-уровне: сравнение значения телефона — при неизменном телефоне
skipped-no-changeno-op; опциональный version-guard поpayload.Version(применять только более новое событие) актуален лишь при добавлении watermark-поля версии identity в связь (вне текущего объёма — identity-версия в связи не денормализуется). - Replay: да. Повторный проход по топику безопасен (чистое сравнение без разрушающих мутаций;
dedup по
MessageIdподавляет повторный подсчёт расхождений). Runbook — «разбор расхождения телефона связи» (investor-owner-overview).
Retry и DLQ
DLQ-топик: pin.investor-owner.identity-users-dlq.v1 (исходное событие + заголовки
x-dlq-reason, x-dlq-error-code, x-source-topic, x-original-offset).
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — | Бесконечный exponential backoff 1s→60s с паузой партиции (событие не теряется) | Нет | Runbook: восстановить зависимость |
Невалидный payload (UserId пуст / телефон не нормализуется в E.164) | INVESTOR_OWNER_IDENTITY_INVALID_PAYLOAD | Нет (non-retriable) | Да + алерт | Исправление данных в identity → повторное событие |
Наблюдаемость и безопасность
- Logs (structured, по событию):
consumed/skipped-duplicate/not-linked/skipped-no-change/phone-divergence/invalid-payloadc полямиMessageId,InvestorUserId,payload.Version; телефоны маскируются (+7***1234), ФИО не логируется. - Metrics:
investor_owner_identity_users_lag_seconds(now − время события),investor_owner_identity_users_consume_duration_ms,investor_owner_link_phone_divergence_total,investor_owner_identity_users_dlq_total,investor_owner_identity_users_contract_mismatch_total. - Traces: span приёма события → span обработки → span обращения к реестру связей; correlation из метаданных события.
- Alerts: рост
investor_owner_link_phone_divergence_total— мониторинг Службой заботы (ручная сверка: смена номера vs передача номера другому владельцу); DLQ > 0; лаг > 15 мин (SLO README). - Dashboard / Runbook: investor-owner-overview; runbook «разбор расхождения телефона связи».
- Безопасность: consumer под system-actor (не пользовательский контекст); tenant не применяется
(ADR-0052) — чтение/запись связей с bypass record-level фильтра по
InvestorUserId;PhoneNumber/ФИО — PII, маскирование в логах; телефон не публикуется (consumer не производит событий). Доступ к топикуpin.identity.users.v1— m2m ACL Kafka:investor-ownerтребуется добавить в перечень доверенных consumer-ов топика (в контракте user-upserted.md ещё не значится — расширение ACL для чтенияPhoneNumber, assumption).
Acceptance Criteria
- Given связанный инвестор (
Status = Linked) иuser-upsertedс телефоном, совпадающим со снапшотом связи, When consume, Thenskipped-no-changeno-op (бизнес-идемпотентность), связь не мутируется, ack. - Given связанный инвестор и
user-upsertedс телефоном, расходящимся со снапшотом (INV-7 — снапшот иммутабелен), When consume, Then связь НЕ разрывается и НЕ переустанавливается, растётinvestor_owner_link_phone_divergence_total, логphone-divergenceс маскированными телефонами; опционально диагностическая пометка вConflictDetailsбез сменыStatus/LinkKind/ConflictReason. - Given пользователь без связи, в
PendingLink/Conflict/архиве, илиType != Investor, When consume, Thennot-linkedno-op (отсутствие связи — допустимо, не ошибка), ack. - Given повторная доставка того же
MessageId, When consume, Thenskipped-duplicateno-op — расхождение не подсчитывается повторно. - Given
UserIdпуст или телефон не нормализуется в E.164, When consume, ThenINVESTOR_OWNER_IDENTITY_INVALID_PAYLOAD— non-retriable → DLQ + алерт. - Given недоступность PostgreSQL/Kafka, When consume, Then бесконечный backoff (событие не теряется); повторный проход по топику безопасен (чистое сравнение без разрушающих мутаций).
- Given любой исход, When consume, Then consumer НЕ публикует событий и НЕ меняет
Status/InvestorUserIdсвязи (расхождение — операционный сигнал, не доменный факт).
Обратные ссылки
Автоссылки: где используется этот документ.
- События (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)
- Консюмеры (1): Consumer: синхронизация из Talent Owners (pin-investor-owner-talent-owners-sync)
- index.md (1): Сервис Инвестор-Собственник (investor-owner)
Связанные документы
- Дом снапшот-поля
PhoneNumberсвязи и INV-7 (телефон зафиксирован на момент линковки): owner-mapping.md. - Контракт входного события (чужой дом
identity): user-upserted.md. - Assumption «смена телефона не разрывает связь»: ../README.md.
- Смежный consumer (первичный источник связи и снапшота
PhoneNumber): talent-owners-sync.md. - Решения: ADR-0052 (инвестор вне tenant), ADR-0056 (simplicity-first: consumer не плодит событий).