Consumer: ReferralUserUpsertedConsumer (pin.identity.users.v1)
Назначение
Consumer ReferralUserUpsertedConsumer обрабатывает событие pin.identity.user-upserted
(регистрация = первое событие агрегата, Version = 1) и выполняет две обязанности:
- Реплика — апсерт
ReferralUserReplica(referral.users): тип/статус/ФИО/телефон/дата регистрации пользователя для условий правил привязки (INV-REF-002/005, «Имя пустое», окно 4 ч) и admin-проекций. - Триггер T1 — привязка при регистрации: при first-seen
UserIdсReferrerUrl != nullисполняет нормативный алгоритм привязки (attach-rules.md, правило R1- модификатор R5): создаёт
ReferralBondEntity(Active), журнальную записьReferralRegistrationEntity(Initial) и публикуетReferralBondCreatedKafkaEvent(events/bond-created.md) и — если владелец ссылки агент/застройщик —ReferralLeadAttachRequestedKafkaEvent(events/lead-attach-requested.md).
- модификатор R5): создаёт
Привязка событийная: identity публикует ReferrerUrl в снапшоте пользователя, referral реагирует
consumer-ом (ADR-0056: без синхронных межсервисных вызовов).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | referral |
| Consumer | ReferralUserUpsertedConsumer |
| Обрабатываемое событие | pin.identity.user-upserted — контракт user-upserted.md |
| Topic | pin.identity.users.v1 (6 partitions, key = UserId — строгий порядок per-user) |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed |
| Consumer group | referral-identity-users (общая для обоих consumer-ов топика) |
| Producer | identity (регистрация СМС/lazy, обновление профиля, смена телефона, admin-API, talent-identity-sync) |
| Эффект (событие → что меняется) | всегда — апсерт реплики ReferralUserReplica; при first-seen с ReferrerUrl != null — создаётся связь (Active) + запись журнала (Initial) + ReferralBondCreatedKafkaEvent; при владельце-агенте — ReferralLeadAttachRequestedKafkaEvent |
Sibling того же топика и consumer group:
| Поле | Значение |
|---|---|
| Consumer | ReferralUserBlockedConsumer |
| Обрабатываемое событие | pin.identity.user-blocked — контракт user-blocked.md |
| Эффект | обновление ReferralUserReplica.Status = Blocked (guard G4: заблокированный пользователь не привязывается; заблокированный владелец ссылки не получает лидов — edge case attach-rules.md) |
Контекст события
TenantIdв identity-событиях отсутствует; tenant создаваемыхreferral-записей —TenantIdссылки (ADR-0052), реплики —CompanyIdиз payload.- Из метаданных события доступны: идентификатор сообщения (дедупликация),
AggregateId = UserId, тип агрегатаUser, время события; время события сVersion = 1— момент регистрации (RegisteredAtреплики,BoundAt/RegisteredAtпривязки). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Транспортная идемпотентность — по ключу
pin.identity.user-upserted:{MessageId}; бизнес-идемпотентность привязки — first-seen по журналуInitial(INV-REF-R1), реплики — монотонныйReplicaVersion-guard.
Входные данные / payload события
Контракт события: user-upserted.md. Ниже — только поля, которые consumer реально читает, и их использование.
| Поле payload | Тип | Обязательность | Как используется |
|---|---|---|---|
UserId | Guid | Да | ReferralUserReplica.Id (апсерт); ReferredUserId связи/журнала; ключ first-seen; guard G3 (UserId != OwnerUserId ссылки) |
Type | string (enum UserType) | Да | ReferralUserReplica.Type; guard G2 (Investor); для владельца ссылки — INV-REF-002 (unknown → «не агент/застройщик», fail-closed) |
CompanyId | Guid? | Нет | ReferralUserReplica.CompanyId = TenantId реплики |
Status | string (enum UserStatus) | Да | ReferralUserReplica.Status; guard G4 (unknown → Blocked, fail-closed) |
Name | FullNameSnapshot | Да | ReferralUserReplica.Name; вычисление IsNameFilled = Name.FirstName.Trim().Length > 0 |
PhoneNumber | string | Да | ReferralUserReplica.PhoneNumber (referral — доверенный consumer, m2m ACL топика); только admin-проекции, в логах +7***1234 |
RegisteredVia | string (enum RegistrationSource) | Да | только structured log (различение СМС/lazy-регистрации при разборе); в реплику не пишется |
ReferrerUrl | string? | Нет | slug для триггера T1: null → ветка привязки не запускается; иначе резолв ссылки по Url |
Version | long | Да | ReplicaVersion-guard (апсерт только при Version > ReplicaVersion); Version = 1 ⇔ регистрация → RegisteredAt = OccurredAt события |
Email в payload отсутствует (PII-минимизация — см. README.md).
Алгоритм
Нормативный процесс привязки — attach-rules.md; здесь — консюмер-специфика
T1. Обработка состоит из двух веток: (A) реплика — на каждое событие, (B) привязка T1 —
только при first-seen с ReferrerUrl != null.
- Контекст события. Восстановить метаданные (идентификатор сообщения,
AggregateId = UserId, время); actor = system (m2m ACL Kafka). Несовпадение версии контракта → warning, продолжить. Транспортная идемпотентность по ключуpin.identity.user-upserted:{MessageId}— дубль → ack без обработки. Бизнес-идемпотентность ветки B — first-seen по журналуInitial(шаг 3). - Проверка входа.
UserId != Guid.Empty— иначе битый payload → DLQ. Unknown enumType→ сохранить строку в реплику, для правил трактовать как «не агент/застройщик»; unknownStatus→ вести себя какBlocked(оба — fail-closed). Для ветки B:ReferrerUrlнормализуется к lowercase и проверяется по^[a-z0-9][a-z0-9-]{2,63}$— мусор из cookie (untrusted вход) → warning + пропуск ветки B (ветка A выполняется всегда). Guard-ы G2/G3/G4 в T1 берут данные пользователя (Type,Status,Name) из payload — событие несёт свежий снапшот, порядок per-user гарантирован key-ом. - Что читаем.
- реплику
ReferralUserReplicaпоUserId— нет → первая репликация (ветка A создаёт), есть → сравнениеReplicaVersion; - ветка B (только при
ReferrerUrl != null): запись журналаInitialпользователя — если есть, это не first-seen, ветка B завершается no-op (INV-REF-R1: повторная доставка/повторный снапшот не плодит привязок); - ссылку по
Url = slug, не удалённую — нет → guard G1: no-op ветки B + warning; - владельца ссылки из реплики (тип и
Statusдля INV-REF-002); отсутствует (событие владельца ещё не доехало — другой key/partition того же топика) →REFERRAL_OWNER_REPLICA_MISSING→ retry с backoff; - активную связь пользователя (
ReferredUserId = UserId,Status = Active) — guard G5 + сериализация гонки с consumer-ом T2 (user-signed-in).
- реплику
- Бизнес-правила.
- Ветка A: guard
Version > ReplicaVersion(иначе ветка A no-op — out-of-order защита); маппинг поле-в-поле по таблице реплики в attach-rules.md:Id = UserId,Type,CompanyId(=TenantId),Status,Name,PhoneNumber,IsNameFilled = Name.FirstName.Trim() != "",RegisteredAt = OccurredAtприVersion == 1(first-seen сVersion > 1→RegisteredAt = null, окно 4 ч fail-closed),ReplicaVersion = Version. - Ветка B: guard-ы G2 (
Type == Investorиз payload), G3 (UserId != OwnerUserIdссылки), G4 (Status ∉ {Blocked, Archived}из payload), G5 (нет активной связи) → правило R1; маппинг создаваемой связи (attach-rules.md шаг 4):ReferredUserId = UserId,ReferrerUserId = OwnerUserIdссылки,ReferralLinkId,TenantId = TenantIdссылки,BoundAt = OccurredAt,AppliedRule = R1; создание поднимает доменноеReferralBondCreatedEvent(guard INV-REF-B2); решение о лиде — INV-REF-002/R5:leadRequested = владелец Type ∈ {Agent, DeveloperManager} && Status == Active.
- Ветка A: guard
- Что меняется. Апсерт реплики (создание или обновление полей — с
ReplicaVersion-guard). Ветка B: появляется связь и запись журнала (RegistrationType = Initial,AppliedRule = R1,LeadRequested = leadRequested, денормализацияLinkUrl,ReferrerUserId,TenantId,RegisteredAt = OccurredAt— INV-REF-R4); связь и её запись журнала связываются друг с другом. - Какие события фиксируются.
ReferralBondCreatedKafkaEvent(topicpin.referral.bonds.v1,ContractVersion = 1, key =ReferredUserId) — payload из полей связи +Channelссылки, контракт events/bond-created.md;- при
leadRequested—ReferralLeadAttachRequestedKafkaEvent(topicpin.referral.lead-attachments.v1,ContractVersion = 1, key =InvestorUserId = UserId) — payload:AgentUserId = OwnerUserIdссылки,AgentCompanyId = TenantIdссылки,ReferralLinkId,Channel,AppliedRule = R1,BondId,RequestedAt; контракт events/lead-attach-requested.md.
- Согласованность. Реплика + связь + журнал + отметка идемпотентности + исходящие события
фиксируются в одном согласованном изменении; offset подтверждается после фиксации. Конфликт
partial unique
ux_referral_bonds_referred_activeилиux_referral_registrations_initial(гонка с T2) → идемпотентное завершение: связь/журнал уже созданы конкурентом, событие не дублируется. - Результат. Успех → ack. Structured log:
UserId,AppliedRule, guard-исход (G1..G5/first-seen-skip/replica-only),LinkId,BondId— без PII. Контролируемые ошибки (REFERRAL_OWNER_REPLICA_MISSING, транзиентные) → retry; невосстановимые (битый payload) → DLQ.
Диаграмма
Обработка user-blocked (sibling-consumer)
ReferralUserBlockedConsumer — тот же топик и consumer group, отдельный тип события. Алгоритм
(сокращённая форма — операция однополевая):
- Контекст из метаданных (идентификатор сообщения,
AggregateId = UserId); идемпотентность — ключpin.identity.user-blocked:{MessageId}. - Проверка входа:
UserId != Guid.Empty; unknown enumReasonигнорируется (в реплику не пишется). - Что читаем: реплику по
UserId. - Реплика отсутствует → no-op с warning (снапшот user-upserted придёт и принесёт
Status); guardVersion > ReplicaVersion(защита от replay). - Что меняется:
Status = Blocked,ReplicaVersion = Version. - Событий не публикует (реплика событий не порождает; активные связи блокировка не закрывает —
оценка оснований выплат при заблокированном участнике — зона
rewards, решение № 7 README.md). - Согласованность: обновление реплики + отметка идемпотентности — в одном изменении.
- Успех → ack; лог
UserId,Version. Эффект для правил: guard G4 отсекает привязку заблокированного, INV-REF-002 перестаёт создавать лиды заблокированному владельцу ссылок.
Разблокировка отдельного события не имеет — приходит user-upserted со Status = Active (ветка A
основного consumer-а).
Промежуточное состояние и downstream
- Что видит пользователь. Ветка A (реплика) и ветка B (привязка T1) фиксируются асинхронно от
регистрации. Между регистрацией по ссылке и коммитом связи (SLO < 1 мин, README.md)
операция get-my-referrer.md возвращает
hasReferrer = false, а репликаReferralUserReplicaприглашённого может ещё не наполниться (имя реферера в других выдачах —null). После коммита обе ветки становятся согласованными (read-after-commit). - Downstream-порядок.
ReferralBondCreatedKafkaEvent(pin.referral.bonds.v1, key =ReferredUserId) потребляютrewards(основание выплат),notifications(СМС рефереру «по вашей ссылке зарегистрировался пользователь»),messenger(контакт «агент ↔ инвестор»);ReferralLeadAttachRequestedKafkaEvent(pin.referral.lead-attachments.v1, key =InvestorUserId) потребляетcrm(карточка лида). Ключ = пользователь ⇒ per-user порядок исходов сохраняется.
Идемпотентность и replay
- Транспортный ключ:
pin.identity.user-upserted:{MessageId}/pin.identity.user-blocked:{MessageId}(отметка в одном изменении с результатом). Поведение дубля: ack без обработки. - Бизнес-ключи: привязка — first-seen по
ux_referral_registrations_initial(ReferredUserId, INV-REF-R1) + partial uniqueux_referral_bonds_referred_active(INV-REF-001); реплика — монотонныйReplicaVersion(out-of-order/replay событий → no-op). - Replay: да — безопасен (backfill реплики
referral.usersпри внедрении выполняется replay-ем топика с начала: реплики строятся, повторные привязки не создаются first-seen-ом). Runbook —runbooks/referral-consumer-lag.md(реестр в README.md).
Retry и DLQ
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| Транзиентная ошибка хранилища / таймаут | Да — экспоненциальный backoff (offset не коммитится) | Нет | — |
Реплика владельца ссылки отсутствует (REFERRAL_OWNER_REPLICA_MISSING) | Да — backoff до 15 мин (событие владельца в другой partition ещё не применено / идёт backfill) | Да, после исчерпания | Проверить lag топика; после backfill-а реплик — redrive из DLQ |
Битый payload (UserId = Guid.Empty, десериализация) | Нет | Да | Разбор с командой identity (контракт), redrive после фикса producer-а |
| Конфликт unique-индексов привязки (гонка с T2) | Нет — идемпотентное завершение внутри обработки (шаг 7) | Нет | — |
| Несовпадение версии контракта | Не сбой: warn-and-proceed | Нет | Плановая миграция контракта |
Slug из ReferrerUrl не резолвится (G1) | Нет — no-op ветки B, warning | Нет | Рост метрики no-op G1 → alert (битые cookie), см. README |
Наблюдаемость и безопасность
- Logs: consumed / skipped-duplicate (
MessageId) / replica-upserted (UserId,ReplicaVersion) / attach-outcome (AppliedRule = R1| guard-код no-op) / failed. PII не логируется: ФИО — нет, телефон — только маскированный+7***1234. - Metrics: длительность обработки, retries, DLQ, дубликаты; счётчики срабатываний
R1и no-op по guard-ам G1–G5; consumer lag (alert > 5 мин — SLO привязки < 1 мин от регистрации доReferralBondCreatedKafkaEvent, откуда СМС рефереру черезnotifications). - Traces: span приёма события → span обработки → spans обращений к хранилищу; correlation из метаданных события; исходящие события получают свой trace.
- Безопасность: actor — system; доступ к топику
pin.identity.users.v1— m2m ACL Kafka (referral — доверенный consumer, получаетPhoneNumber); trusted/untrusted boundary —ReferrerUrl(slug из cookie) валидируется по формату (шаг 2); tenant создаваемых записей — из ссылки (ADR-0052), tenant mismatch невозможен (identity-события не tenant-скоупированы).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): GET /api/investors/me/referrer — свой реферер пользователя
- Use Cases (2): UC-REF-003. Регистрация по реферальной ссылке → связь + лид (правило R1), UC-REF-006. Ссылка от инвестора (не агента/застройщика) → связь без лида (модификатор R5)
- События (8): Событие «Запрошена карточка лида»: pin.referral.lead-attach-requested, Event Contract: pin.crm.lead-created (LeadCreatedEvent), Event Contract: pin.identity.user-blocked (UserBlockedKafkaEvent), Event Contract: pin.identity.user-upserted (UserUpsertedKafkaEvent), Event Contract: pin.referral.bond-created (ReferralBondCreatedKafkaEvent), 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: ReferralUserSignedInConsumer (pin.identity.sign-ins.v1)
Связанные документы
- Нормативный процесс и матрица правил: attach-rules.md; сущности: referral-bond.md, referral-registration.md, referral-link.md.
- Входные контракты: user-upserted.md, user-blocked.md.
- Исходящие контракты: bond-created.md, lead-attach-requested.md.
- Соседний триггер T2: user-signed-in.md (авторизация, правила R2/R3).