Consumer: ReferralUserSignedInConsumer (pin.identity.sign-ins.v1)
Назначение
Consumer ReferralUserSignedInConsumer реализует Триггер T2 — привязку при авторизации
инвестора, пришедшего по реферальной ссылке. При ReferrerUrl != null в событии входа исполняется
нормативный алгоритм привязки (attach-rules.md) с выбором правила по
данным реплики ReferralUserReplica:
- R2
AuthorizationLazyWindow(IsNameFilled = falseИЛИnow() - RegisteredAt < 4 ч): создаётсяReferralBondEntity(Active,AppliedRule = R2), журнальная записьReferralRegistrationEntity(Initial,R2) и публикуетсяReferralBondCreatedKafkaEvent(events/bond-created.md); - R3
AuthorizationSelfRegistered(IsNameFilled = trueИ>= 4 ч;RegisteredAt = null→ окно истекло, fail-closed): связь не создаётся, журнал не пишется — только лид; - R5/INV-REF-002 (модификатор):
ReferralLeadAttachRequestedKafkaEvent(events/lead-attach-requested.md) публикуется только если владелец ссылки —Agent/DeveloperManagerиStatus = Active.
Отличие от T1 (user-upserted.md): consumer T2 реплику не наполняет (её дом —
consumer user-upserted того же UserId, другой топик/группа), а только читает
ReferralUserReplica для условий правил; отсутствие реплики → retry (см. Retry и DLQ). Поле
ReferrerUrl в событии входа — additive: identity подставляет его из подтверждения входа
существующего пользователя (решение № 4 README.md; до его появления T2 не срабатывает).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | referral |
| Consumer | ReferralUserSignedInConsumer |
| Обрабатываемое событие | pin.identity.user-signed-in — контракт user-signed-in.md |
| Topic | pin.identity.sign-ins.v1 (6 partitions, key = UserId — строгий порядок per-user) |
| ContractVersion | 1; при несовпадении версии — warn-and-proceed (событие — факт, version-guard не нужен) |
| Consumer group | referral-identity-sign-ins — отдельная от группы топика users (referral-identity-users, user-upserted.md): независимый offset/lag; кросс-топик порядок users.v1 → sign-ins.v1 не гарантирован (отсюда retry на отсутствующую реплику) |
| Producer | identity (подтверждение входа / вход по паролю / вход пин-кодом) |
| Эффект (событие → что меняется) | при R2 — создаётся реферальная связь (Active) + запись журнала (Initial) + публикуется ReferralBondCreatedKafkaEvent; при R2/R3 с владельцем-агентом — публикуется ReferralLeadAttachRequestedKafkaEvent; собственное состояние (реплику) consumer не меняет |
Контекст события
- Из метаданных события доступны: идентификатор сообщения (дедупликация),
AggregateId = UserId, тип агрегатаUser, время событияOccurredAt(= момент входа; используется какBoundAt/RegisteredAtсоздаваемой связи по R2). TenantIdу identity-событий отсутствует; tenant создаваемыхreferral-записей —TenantIdссылки (ADR-0052).- Actor = system (m2m ACL Kafka на топик
pin.identity.sign-ins.v1). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Транспортная идемпотентность — по ключу
pin.identity.user-signed-in:{MessageId}; бизнес-идемпотентность привязки — guard G5 + partial uniqueux_referral_bonds_referred_active(first-seen не применяется: R3/R4 журналInitialне пишут, а R2 допускает повторные входы того же пользователя).
Входные данные / payload события
Контракт события: user-signed-in.md. Ниже — только поля, которые consumer реально читает, и их использование.
| Поле payload | Тип | Обязательность | Как используется |
|---|---|---|---|
UserId | Guid | Да | ключ поиска реплики ReferralUserReplica; ReferredUserId связи/журнала; guard G3 (UserId != OwnerUserId ссылки); Kafka key |
ReferrerUrl | string? | Нет (additive) | slug триггера T2: null → процесс привязки не запускается (ack без эффекта); иначе резолв ссылки по Url |
OccurredAt | DateTimeOffset | Да | BoundAt/RegisteredAt создаваемой связи и журнала (момент входа) |
Условия правил (Type для G2, Status для G4, IsNameFilled и RegisteredAt для окна R2/R3)
в payload события входа отсутствуют — берутся из реплики ReferralUserReplica
(attach-rules.md), наполняемой consumer-ом user-upserted. Прочие поля
события (SessionId, SignInMethod, DevicePlatform, IsFirstSignIn) consumer referral не читает.
Алгоритм
Нормативный процесс привязки и матрица — attach-rules.md; здесь —
консюмер-специфика T2. Ветка привязки исполняется только при ReferrerUrl != null.
- Контекст события. Восстановить метаданные (идентификатор сообщения,
AggregateId = UserId, время); actor = system. Несовпадение версии контракта → warning, продолжить. Проверить транспортную идемпотентность по ключуpin.identity.user-signed-in:{MessageId}— дубль → ack без обработки. - Проверка входа.
UserId != Guid.Empty— иначе битый payload → DLQ (retry бессмыслен).ReferrerUrl == null→ процесс не запускается: ack (в отличие от T1, у consumer-а входа нет обязанности по реплике — делать нечего).ReferrerUrlнормализуется к lowercase и проверяется по^[a-z0-9][a-z0-9-]{2,63}$— мусор из cookie (untrusted вход) → warning + no-op ack. - Что читаем. По реплике и хранилищу
referral:- реплику пользователя
ReferralUserReplicaпоUserId— источник условий правил (Type,Status,IsNameFilled,RegisteredAt); отсутствует →REFERRAL_USER_REPLICA_MISSING→ retry с backoff (событиеuser-upsertedтого жеUserIdиз другого топика/группы ещё не применено или идёт backfill); - ссылку по
Url = slug, не удалённую — нет → guard G1: no-op ветки привязки + warning; - владельца ссылки из реплики (тип и
Statusдля INV-REF-002); отсутствует →REFERRAL_OWNER_REPLICA_MISSING→ retry; - активную связь пользователя (
ReferredUserId = UserId,Status = Active) — guard G5 + сериализация гонки с consumer-ом T1 (user-upserted), выполняющимся параллельно в своей группе.
- реплику пользователя
- Бизнес-правила.
- Guard-ы (из реплики): G2 (
Type = Investor), G3 (UserId != OwnerUserIdссылки), G4 (Status ∉ {Blocked, Archived}) — нарушение → no-op с warning (INV-REF-005/004). - Выбор правила (матрица): окно lazy-регистрации =
IsNameFilled == falseИЛИ (RegisteredAt != nullИnow() - RegisteredAt < Referral:LazyRegistrationWindow, default04:00:00);RegisteredAt == null→ окно истекло (fail-closed). Истинно → R2; иначе → R3. leadRequested = владелец Type ∈ {Agent, DeveloperManager} && Status == Active(R5/INV-REF-002) — общий для R2 и R3.- R2 при отсутствии активной связи (G5): создаётся связь (guard INV-REF-B2) —
ReferredUserId = UserId,ReferrerUserId = OwnerUserIdссылки,ReferralLinkId,TenantId = TenantIdссылки,BoundAt = OccurredAt,AppliedRule = R2; поднимается доменноеReferralBondCreatedEvent. - R3, либо R2 при существующей активной связи: связь не создаётся (INV-REF-003 / G5),
журнал не пишется — далее только лид по
leadRequested.
- Guard-ы (из реплики): G2 (
- Что меняется. Только в ветке «R2 + нет активной связи»: появляется связь и запись журнала
(
RegistrationType = Initial,AppliedRule = R2,LeadRequested = leadRequested, денормализацияLinkUrl,ReferrerUserId,TenantId,RegisteredAt = OccurredAt— INV-REF-R4). R3/G5-ветка — без изменений состояния (реплику consumer T2 не трогает). - Какие события фиксируются.
- при созданной связи (R2) —
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(R2/R3),BondId(создана в этом проходе) либоnull(R3, или R2 при существующей активной связи),RequestedAt; контракт events/lead-attach-requested.md.
- при созданной связи (R2) —
- Согласованность. Связь + журнал + отметка идемпотентности + исходящие события фиксируются в
одном согласованном изменении; offset подтверждается после фиксации. Конфликт partial unique
ux_referral_bonds_referred_active(гонка с T1) → идемпотентное завершение: связь уже создана конкурентом (G5-исход),ReferralBondCreatedKafkaEventне дублируется; лид — по INV-REF-002. - Результат. Успех → ack. Structured log:
UserId,AppliedRule(R2/R3), guard-исход (G1..G5/window),LinkId,BondId— без PII. Контролируемые ошибки (REFERRAL_USER_REPLICA_MISSING,REFERRAL_OWNER_REPLICA_MISSING, транзиентные) → retry; невосстановимые (битый payload) → DLQ.
Диаграмма
Промежуточное состояние и downstream
- Что видит пользователь. Привязка по T2 фиксируется асинхронно: между входом по ссылке и
коммитом связи (SLO < 1 мин, README.md) операция
get-my-referrer.md возвращает
hasReferrer = false, а партнёрская статистика владельца ещё не учитывает новую связь. После коммита обе операции становятся согласованными (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-signed-in:{MessageId}(отметка в одном изменении с результатом). Поведение дубля: ack без обработки. - Бизнес-ключ: guard G5 + partial unique
ux_referral_bonds_referred_active(ReferredUserId, INV-REF-001). First-seen по журналуInitialне применяется: R3/R4 журнал не пишут, а R2 допускает повторные входы — защита от двойной связи целиком на G5 + unique-индексе (проигравший гонку → идемпотентный no-op). Повтор R2/R3 при уже существующей активной связи: связь не создаётся, но лид отправляется идемпотентно (дедуп CRM по пареInvestorUserId+AgentUserId). - Replay: да — безопасен (G5 + unique-индекс не плодят связи; лиды идемпотентны на стороне CRM).
Runbook —
runbooks/referral-consumer-lag.md(реестр в README.md).
Retry и DLQ
| Сбой | Retry | DLQ | Ручное действие |
|---|---|---|---|
| Транзиентная ошибка хранилища / таймаут | Да — экспоненциальный backoff (offset не коммитится) | Нет | — |
Реплика пользователя отсутствует (REFERRAL_USER_REPLICA_MISSING) | Да — backoff до 15 мин (событие user-upserted того же UserId из другого топика/группы ещё не применено / идёт backfill) | Да, после исчерпания | Проверить lag группы referral-identity-users; после backfill-а реплик — redrive из DLQ |
Реплика владельца ссылки отсутствует (REFERRAL_OWNER_REPLICA_MISSING) | Да — backoff до 15 мин | Да, после исчерпания | То же |
Битый payload (UserId = Guid.Empty, десериализация) | Нет | Да | Разбор с командой identity (контракт), redrive после фикса producer-а |
| Конфликт unique-индексов привязки (гонка с T1) | Нет — идемпотентное завершение внутри обработки (шаг 7) | Нет | — |
| Несовпадение версии контракта | Не сбой: warn-and-proceed | Нет | Плановая миграция контракта |
Slug из ReferrerUrl не резолвится (G1) | Нет — no-op ветки привязки, warning | Нет | Рост метрики no-op G1 → alert (битые cookie), см. README.md |
ReferrerUrl == null | Не сбой: ack без эффекта | Нет | — |
Наблюдаемость и безопасность
- Logs: consumed / skipped-duplicate (
MessageId) / attach-outcome (AppliedRule = R2|R3| guard-код no-op) / replica-missing / failed. PII не логируется: ФИО — нет, телефон в этом consumer-е не читается. - Metrics: длительность обработки, retries, DLQ, дубликаты; счётчики срабатываний
R2/R3и no-op по guard-ам G1–G5; счётчикREFERRAL_USER_REPLICA_MISSING(индикатор рассинхрона групп); consumer lag (alert > 5 мин — SLO привязки < 1 мин от входа доReferralBondCreatedKafkaEvent). - Traces: span приёма события → span обработки → spans обращений к хранилищу; correlation из метаданных события; исходящие события получают свой trace.
- Безопасность: actor — system; доступ к топику
pin.identity.sign-ins.v1— m2m ACL Kafka; trusted/untrusted boundary —ReferrerUrl(slug из cookie 30 дней) валидируется по формату (шаг 2); tenant создаваемых записей — из ссылки (ADR-0052), tenant mismatch невозможен (identity-события не tenant-скоупированы).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): GET /api/investors/me/referrer — свой реферер пользователя
- Use Cases (3): UC-REF-004. Авторизация со ссылкой в окне lazy-регистрации → связь + лид (правило R2), UC-REF-005. Авторизация: «Имя заполнено И регистрация ≥ 4 ч» → только лид, без связи (правило R3), UC-REF-006. Ссылка от инвестора (не агента/застройщика) → связь без лида (модификатор R5)
- События (3): Событие «Запрошена карточка лида»: pin.referral.lead-attach-requested, Event Contract: pin.identity.user-signed-in (UserSignedInKafkaEvent), Event Contract: pin.referral.bond-created (ReferralBondCreatedKafkaEvent)
- Консюмеры (1): Consumer: ReferralUserUpsertedConsumer (pin.identity.users.v1)
Связанные документы
- Нормативный процесс и матрица правил (R2/R3/R5, guard-ы G1–G5): attach-rules.md; сущности: referral-bond.md, referral-registration.md, referral-link.md.
- Входной контракт: user-signed-in.md (требование поля
ReferrerUrl?— решение № 4 README.md). - Исходящие контракты: bond-created.md, lead-attach-requested.md.
- Соседний триггер T1: user-upserted.md (регистрация, правило R1 + реплика).