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

Правила реферальной привязки (attach-rules) — дом доменных правил

Канонический дом правил привязки «пользователь пришёл по реферальной ссылке → реферальная связь? → лид?». Все сценарии, use-case-ы, consumer-документы и API-документы контекстов referral, identity, crm ссылаются сюда и не переописывают условия. Здесь же живут: реестр инвариантов INV-REF-00x, enum ReferralAttachRule и реплика пользователей ReferralUserReplica (данные для вычисления условий).

Metadata

ПолеЗначение
Контекстreferral
Тип документадоменные правила (decision table) + реплика-сущность
Статусdraft
Ownerкоманда referral
Область действияпроцесс привязки во всех триггерах (регистрация, авторизация, открытие ссылки); admin-коррекции

Термины и роли владельца ссылки

Условия оперируют типом пользователя-владельца ссылки (ReferralUserReplica.Type, значения — enum UserType из user.md):

Владелец ссылки (Type)«Агент/застройщик» для правилКомментарий
AgentДаАгент/риэлтор/менеджер агентства или брокера.
DeveloperManagerДаМенеджер застройщика. Человек, работающий и как агент, имеет учётку Agent; для правил важен тип учётки-владельца конкретной ссылки.
InvestorНетИнвестор: его ссылки создают связь (для выплат), но не лида.
PartnerManagerНетМенеджер партнёра — аналогично инвестору.
PinEmployeeНетСотрудник ПИН; сотрудники, работающие риэлторами, имеют учётку Agent.

«Имя пустое» — ReferralUserReplica.IsNameFilled = false: Name.FirstName после trim пуст (семантика lazy-регистрации, инвариант INV-11 user.md).

«Окно lazy-регистрации» — now() - RegisteredAt < 4 часа; порог — конфигурация Referral:LazyRegistrationWindow, default 04:00:00.

Триггеры процесса привязки

#ТриггерИсточникДанные триггера
T1Регистрация с реферальной ссылкойconsumer ReferralUserUpsertedConsumer, топик pin.identity.users.v1, first-seen UserId при ReferrerUrl != null (user-upserted.md)UserId, Type, Name (пустое?), ReferrerUrl (slug), время события (= момент регистрации при Version = 1)
T2Авторизация с реферальной ссылкойconsumer ReferralUserSignedInConsumer, топик pin.identity.sign-ins.v1 (user-signed-in.md); поле ReferrerUrl? в payload — identity подставляет его из подтверждения входа существующего пользователя (требование к identity зафиксировано в README.md)UserId, ReferrerUrl (slug из cookie 30 дней), время события (= момент входа); IsNameFilled/RegisteredAt — из ReferralUserReplica
T3Открытие ссылки авторизованным пользователемоперация validateReferralLink, когда запрос авторизован (actor.UserId != null)actor.UserId, url (slug)
T4Admin-коррекция связи (rebind/revoke)admin-API (ADR-0053)BondId / (ReferredUserId, новая ссылка), причина

Cookie referralUrl (TTL 30 дней) и подстановка referrerUrl при регистрации/входе — зона фронта и identity (sign-up-or-sign-in.md, confirm-sign-in.md); referral получает slug только событиями/вызовом T3.

Guard-предусловия (проверяются до матрицы, при любом триггере T1–T3)

#ПредусловиеНарушение → исходИнвариант
G1Ссылка резолвится по Url = slug, не удалена (referral-link.md)Ссылка не найдена/удалена → привязка не выполняется, лог warning (не ошибка пользователя)
G2Пользователь — инвестор: ReferralUserReplica.Type = InvestorИной тип → ни связи, ни лида; no-opINV-REF-005
G3Не сам себе: UserId != link.OwnerUserIdSelf-attach → no-op, лог warningINV-REF-004
G4Пользователь активен: ReferralUserReplica.Status ∉ {Blocked, Archived}no-op
G5Для создания связи: активной связи нет (ReferredUserId, Status = Active) по referral-bond.mdАктивная связь есть → связь не создаётся (лид — по матрице)INV-REF-001

Матрица правил (дом)

Колонка «Связь» — создание ReferralBondEntity (Active, для будущих выплат); «Лид» — публикация ReferralLeadAttachRequestedKafkaEvent → CRM создаёт карточку лида; «Журнал» — запись ReferralRegistrationEntity.

ПравилоТриггерУсловия (после guard-ов G1–G5)СвязьЛидЖурнал
R1 RegistrationWithReferralT1 (регистрация)ReferrerUrl != null, first-seen UserIdДаДа, если владелец агент/застройщик (INV-REF-002); иначе нетInitial
R2 AuthorizationLazyWindowT2 (авторизация)IsNameFilled = false ИЛИ now() - RegisteredAt < 4 чДаДа, если владелец агент/застройщик; иначе нетInitial
R3 AuthorizationSelfRegisteredT2 (авторизация)IsNameFilled = true И now() - RegisteredAt >= 4 чНет (INV-REF-003: «пользователь зарегистрировался сам»)Да, если владелец агент/застройщик; иначе нетнет
R4 LinkOpenAuthorizedT3 (открытие ссылки)актор авторизован, инвесторНетДа, если владелец агент/застройщик; иначе нетнет
R5 NonAgentOwnerNoLeadмодификатор R1–R4владелец ссылки не Agent и не DeveloperManagerпо базовому правилуНет (INV-REF-002)по базовому правилу
R6 AdminRebindT4 (admin)привилегированный актор админ-панелиДа (замена: старая → Replaced, INV-REF-B4)НетRebind

Уточнения:

  • RegisteredAt неизвестен (null в реплике — пользователь зарегистрирован до внедрения referral-реплики и first-seen пришёл с Version > 1) → окно считается истёкшим (fail-closed: связь по R2 не создаётся, работает R3).
  • R2/R3 при ReferrerUrl == null в событии входа — процесс не запускается вовсе.
  • Повторное срабатывание R2–R4 при уже существующей активной связи (G5): связь не создаётся, но лид создаётся — несколько агентов могут одновременно отслеживать одного инвестора; единственность активной пары «инвестор+агент» обеспечивает CRM (глоссарий «Лид»), referral шлёт запрос идемпотентно.
  • Дедупликация лидов: ReferralLeadAttachRequestedKafkaEvent идемпотентен по бизнес-ключу (InvestorUserId, AgentUserId) на стороне CRM — у пары одновременно только один активный лид.
  • След в статистике. Журнал ReferralRegistrationEntity пишут только правила со связью-или- регистрацией: R1/R2 (Initial) и R6 (Rebind). R3 и R4 журнал не пишут — открытие ссылки (T3/R4) растит лишь VisitsCount referral-link.md и (при владельце-агенте) запрашивает лид, но не увеличивает RegistrationsCount. Отсюда воронка в статистике каналов (get-partner-stats.md, get-link-stats.md): VisitsCount >= RegistrationsCount >= ActiveBondsCount, а LeadsRequestedCount может превышать RegistrationsCount (лид без записи журнала по R3/R4).

Enum ReferralAttachRule (дом; используется полями AppliedRule журнала и связи)

ЗначениеПравилоХранится в
R1Регистрация с реф-ссылкойжурнал Initial, связь
R2Авторизация в окне lazy-регистрациижурнал Initial, связь
R3Авторизация самостоятельно зарегистрировавшегосятолько payload lead-события
R4Открытие ссылки авторизованнымтолько payload lead-события
R6Admin-коррекция (rebind)журнал Rebind, связь

R5 — модификатор (не самостоятельное правило), в AppliedRule не записывается: его след — поле LeadRequested = false журнала. Хранение — varchar(16); unknown значение при чтении — warning + обработка как R1 (консервативно).

Реестр инвариантов INV-REF-00x

ИнвариантФормулировкаДом проверкиОшибка/исход
INV-REF-001 SingleActiveBondУ пользователя не более одной активной реферальной связи; автоматическая перепривязка запрещенаreferral-bond.md (partial unique + guard G5)идемпотентный no-op / REFERRAL_BOND_ALREADY_EXISTS
INV-REF-002 LeadRequiresAgentOwnerЛид создаётся только если владелец ссылки — Agent или DeveloperManagerматрица (R5), процесс привязкилид не запрашивается
INV-REF-003 SelfRegisteredNoBond«Имя заполнено И регистрация ≥ 4 ч» ⇒ связь не создаётсяматрица (R3)связь не создаётся, лид — по INV-REF-002
INV-REF-004 NoSelfAttachВладелец ссылки не может стать собственным рефераломguard G3 + создание связи (INV-REF-B2)no-op / REFERRAL_BOND_SELF_FORBIDDEN
INV-REF-005 InvestorOnlyПривязка и лид — только для пользователей Type = Investorguard G2no-op

Локальные инварианты сущностей (INV-REF-L*, INV-REF-R*, INV-REF-B*) — в домах referral-link.md, referral-registration.md, referral-bond.md.

Дерево решений

Реплика пользователей: ReferralUserReplica

Данные для вычисления условий (тип владельца, «Имя пустое», дата регистрации) и для admin-проекций (ФИО/телефон владельца и приглашённого). Наполняется consumer-ом pin.identity.users.v1; дом исходных полей — user.md, здесь только принятая в реплику проекция.

ПолеЗначение
Контекст / Kindreferral / Replica (source of truth — identity)
Таблицаreferral.users
Tenant isolationTenantId = CompanyId пользователя; null — инвестор (ADR-0052)
ПолеТипОбязательностьОграничения (PK/FK/index)Источник (UserUpsertedKafkaEvent)Описание
IdGuidДаPKUserId= id пользователя identity.
TypeUserTypeДахранится как varchar(32)TypeТип учётки (условия INV-REF-002/005). Unknown значение — сохранить строку, для правил трактовать как «не агент/застройщик» (fail-closed).
CompanyIdGuid?НетCompanyId= TenantId реплики.
StatusUserStatusДахранится как varchar(32)StatusGuard G4; unknown — как Blocked (fail closed).
NameFullNameДаколонки name_first_name/name_last_name/name_middle_name, varchar(128) каждоеName (FullNameSnapshot)Для IsNameFilled, referrerFirstName (getMyReferrer), referrerDisplayName (validateReferralLink), admin-ФИО.
PhoneNumberstringДаvarchar(32) E.164PhoneNumber (referral — доверенный consumer, ACL топика)Только admin-проекции; в логах маскируется +7***1234.
IsNameFilledboolДавычисляемое при апсертеName.FirstName после trim непустУсловие R2/R3 («Имя пустое»).
RegisteredAtDateTimeOffset?Нетвремя события с Version = 1; null, если first-seen пришёл с Version > 1Условие окна 4 ч; null → окно истекло (fail-closed).
ReplicaVersionlongДа>= 1VersionМонотонный guard out-of-order (апсерт только при Version > ReplicaVersion).
UpdatedAtDateTimeOffsetДаconsumerМомент последнего обновления реплики.

Email в реплику не реплицируется (его нет в payload user-upserted — PII-минимизация): поле email admin-проекций отдаётся null; при необходимости админ-панель читает identity напрямую (ADR-0053). Реплика не публикует событий и не имеет инвариантов состояния.

Процесс привязки — нормативный алгоритм

Канонический алгоритм для consumer-ов T1/T2 и операции T3 (их детальные документы referral/consumers/user-upserted.md, referral/consumers/user-signed-in.md, referral/api/public/validate-referral-link.md ссылаются сюда и описывают только дельту входа).

  1. Контекст и идемпотентность. Для T1/T2 — восстановление контекста из метаданных события (идентификатор сообщения, время, UserId); бизнес-идемпотентность: T1 — first-seen по UserId (наличие записи Initial журнала, INV-REF-R1) + идентификатор сообщения; T2 — идентификатор сообщения + G5; T3 — синхронный запрос, actor из контекста запроса.
  2. Проверка входа. Формат slug (^[a-z0-9][a-z0-9-]{2,63}$ после lowercase) → warning + no-op при мусоре из cookie; guard-ы G1–G4 (существование ссылки; тип/статус пользователя — по реплике; отсутствие реплики → retry consumer-а — для T1 сам payload события содержит все поля).
  3. Что читаем. Ссылку по Url = slug, не удалённую; владельца из реплики (тип для INV-REF-002); пользователя (реплика; для T1 — из payload); активную связь пользователя (ReferredUserId, Status = Active) — с сериализацией гонки двойной привязки.
  4. Бизнес-правила. Выбор правила по матрице (R1–R4 + модификатор R5); вычисление IsNameFilled/окна 4 ч (Referral:LazyRegistrationWindow); маппинг поле-в-поле создаваемой связи: ReferredUserId = UserId, ReferrerUserId = link.OwnerUserId, ReferralLinkId = link.Id, TenantId = link.TenantId, BoundAt = OccurredAt (T3 — now()), AppliedRule = код правила; создание связи поднимает доменное ReferralBondCreatedEvent.
  5. Что меняется. При связи: появляется связь и запись журнала (Initial, AppliedRule, LeadRequested, денормализация LinkUrl/ReferrerUserId/TenantId, привязка к связи). Порядок: связь → журнал.
  6. Какие события фиксируются. ReferralBondCreatedKafkaEvent (topic pin.referral.bonds.v1, ContractVersion = 1, key = ReferredUserId, поля — дом referral-bond.md); при INV-REF-002 — ReferralLeadAttachRequestedKafkaEvent (topic pin.referral.lead-attachments.v1, ContractVersion = 1, key = InvestorUserId; payload: InvestorUserId, AgentUserId = link.OwnerUserId, AgentCompanyId = link.TenantId, ReferralLinkId, Channel = link.Channel, AppliedRule, BondId? (null для R3/R4), RequestedAt). Детальные контракты — referral/events/bond-created.md, referral/events/lead-attach-requested.md.
  7. Согласованность. Связь + журнал + исходящие события фиксируются в одном согласованном изменении. Конфликт partial unique ux_referral_bonds_referred_active (гонка) → идемпотентное завершение (связь уже создана конкурентом).
  8. Результат. T1/T2 — ack события; T3 — ValidateReferralLinkResult (isValid, url, referrerDisplayName из имени владельца) + инкремент VisitsCount (см. referral-link.md).

Edge cases

КейсПоведение
Slug из cookie не существует / ссылка удаленаG1: no-op + warning; validateReferralLink вернёт isValid = false — фронт очищает cookie.
Ссылка переименована (updateReferralLink.url) между установкой cookie и регистрациейСтарый slug не резолвится (unique-хранение только текущего) → привязка не происходит. Осознанное ограничение модели; журнал и связи не рвутся (ссылка по Id, INV-REF-L6).
Владелец ссылки заблокирован/архивированСвязь создаётся (основание выплат оценивает rewards), лид — создаётся только для активного агента: guard owner.Status = Active добавляется к INV-REF-002.
Пользователь пришёл по собственной ссылкеG3/INV-REF-004: no-op.
Регистрация без ReferrerUrlПроцесс не запускается; журнал/связь/лид не создаются.
Повторная доставка события (at-least-once)T1: first-seen по журналу Initial + идентификатор сообщения; T2: G5 + дедуп CRM; повторных событий/записей нет.
Два конкурирующих триггера (регистрация + авторизация почти одновременно)Сериализация активной связи + partial unique INV-REF-001 → ровно одна связь.
RegisteredAt = null в репликеОкно 4 ч считается истёкшим → R3 (fail-closed для выплат).
Инвестор уже привязан к агенту A, пришёл по ссылке агента BСвязь остаётся с A (INV-REF-001); лид агенту B создаётся (несколько карточек лида у разных агентов).
Владелец — инвестор/партнёр (не агент/застройщик)Связь — да (R1/R2), лид — нет (R5/INV-REF-002).

Безопасность и наблюдаемость

  • Actor: T1/T2 — system (consumer, m2m ACL Kafka на топики identity); T3 — авторизованный пользователь (permission не требуется — валидация ссылки публична, привязка выполняется только для актора-инвестора); T4 — привилегированный админ (referral.registrations.view-all, отдельная админ-панель, ADR-0053).
  • Tenant/scope: создаваемые записи получают TenantId ссылки (ADR-0052); чтение — record-level по владельцу/приглашённому.
  • PII: телефон/ФИО живут в реплике; в логах правил — только id + маскированный телефон; slug и коды правил PII не содержат. Trusted/untrusted boundary: slug из cookie — untrusted вход (валидация формата, шаг 2).
  • Logs/metrics: на каждое срабатывание — structured log (AppliedRule, guard-исход, LinkId, ids без PII); метрики: счётчики срабатываний по правилам R1..R6, счётчик no-op по guard-ам, лаг consumer-ов; trace — на исходящих/consumer-записях. Dashboard/alerts — runbook контекста (referral/README.md, раздел Runtime).

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

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

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