Правила реферальной привязки (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) |
| T4 | Admin-коррекция связи (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-op | INV-REF-005 |
| G3 | Не сам себе: UserId != link.OwnerUserId | Self-attach → no-op, лог warning | INV-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 RegistrationWithReferral | T1 (регистрация) | ReferrerUrl != null, first-seen UserId | Да | Да, если владелец агент/застройщик (INV-REF-002); иначе нет | Initial |
R2 AuthorizationLazyWindow | T2 (авторизация) | IsNameFilled = false ИЛИ now() - RegisteredAt < 4 ч | Да | Да, если владелец агент/застройщик; иначе нет | Initial |
R3 AuthorizationSelfRegistered | T2 (авторизация) | IsNameFilled = true И now() - RegisteredAt >= 4 ч | Нет (INV-REF-003: «пользователь зарегистрировался сам») | Да, если владелец агент/застройщик; иначе нет | нет |
R4 LinkOpenAuthorized | T3 (открытие ссылки) | актор авторизован, инвестор | Нет | Да, если владелец агент/застройщик; иначе нет | нет |
R5 NonAgentOwnerNoLead | модификатор R1–R4 | владелец ссылки не Agent и не DeveloperManager | по базовому правилу | Нет (INV-REF-002) | по базовому правилу |
R6 AdminRebind | T4 (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) растит лишьVisitsCountreferral-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-события |
R6 | Admin-коррекция (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 = Investor | guard G2 | no-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, здесь только принятая в реплику проекция.
| Поле | Значение |
|---|---|
| Контекст / Kind | referral / Replica (source of truth — identity) |
| Таблица | referral.users |
| Tenant isolation | TenantId = CompanyId пользователя; null — инвестор (ADR-0052) |
| Поле | Тип | Обязательность | Ограничения (PK/FK/index) | Источник (UserUpsertedKafkaEvent) | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | UserId | = id пользователя identity. |
Type | UserType | Да | хранится как varchar(32) | Type | Тип учётки (условия INV-REF-002/005). Unknown значение — сохранить строку, для правил трактовать как «не агент/застройщик» (fail-closed). |
CompanyId | Guid? | Нет | — | CompanyId | = TenantId реплики. |
Status | UserStatus | Да | хранится как varchar(32) | Status | Guard G4; unknown — как Blocked (fail closed). |
Name | FullName | Да | колонки name_first_name/name_last_name/name_middle_name, varchar(128) каждое | Name (FullNameSnapshot) | Для IsNameFilled, referrerFirstName (getMyReferrer), referrerDisplayName (validateReferralLink), admin-ФИО. |
PhoneNumber | string | Да | varchar(32) E.164 | PhoneNumber (referral — доверенный consumer, ACL топика) | Только admin-проекции; в логах маскируется +7***1234. |
IsNameFilled | bool | Да | вычисляемое при апсерте | Name.FirstName после trim непуст | Условие R2/R3 («Имя пустое»). |
RegisteredAt | DateTimeOffset? | Нет | — | время события с Version = 1; null, если first-seen пришёл с Version > 1 | Условие окна 4 ч; null → окно истекло (fail-closed). |
ReplicaVersion | long | Да | >= 1 | Version | Монотонный guard out-of-order (апсерт только при Version > ReplicaVersion). |
UpdatedAt | DateTimeOffset | Да | — | 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 ссылаются сюда и описывают только дельту входа).
- Контекст и идемпотентность. Для T1/T2 — восстановление контекста из метаданных события
(идентификатор сообщения, время,
UserId); бизнес-идемпотентность: T1 — first-seen поUserId(наличие записиInitialжурнала, INV-REF-R1) + идентификатор сообщения; T2 — идентификатор сообщения + G5; T3 — синхронный запрос, actor из контекста запроса. - Проверка входа. Формат slug (
^[a-z0-9][a-z0-9-]{2,63}$после lowercase) → warning + no-op при мусоре из cookie; guard-ы G1–G4 (существование ссылки; тип/статус пользователя — по реплике; отсутствие реплики → retry consumer-а — для T1 сам payload события содержит все поля). - Что читаем. Ссылку по
Url = slug, не удалённую; владельца из реплики (тип для INV-REF-002); пользователя (реплика; для T1 — из payload); активную связь пользователя (ReferredUserId,Status = Active) — с сериализацией гонки двойной привязки. - Бизнес-правила. Выбор правила по матрице (R1–R4 + модификатор R5); вычисление
IsNameFilled/окна 4 ч (Referral:LazyRegistrationWindow); маппинг поле-в-поле создаваемой связи:ReferredUserId = UserId,ReferrerUserId = link.OwnerUserId,ReferralLinkId = link.Id,TenantId = link.TenantId,BoundAt = OccurredAt(T3 —now()),AppliedRule= код правила; создание связи поднимает доменноеReferralBondCreatedEvent. - Что меняется. При связи: появляется связь и запись журнала (
Initial,AppliedRule,LeadRequested, денормализацияLinkUrl/ReferrerUserId/TenantId, привязка к связи). Порядок: связь → журнал. - Какие события фиксируются.
ReferralBondCreatedKafkaEvent(topicpin.referral.bonds.v1,ContractVersion = 1, key =ReferredUserId, поля — дом referral-bond.md); при INV-REF-002 —ReferralLeadAttachRequestedKafkaEvent(topicpin.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. - Согласованность. Связь + журнал + исходящие события фиксируются в одном согласованном
изменении. Конфликт partial unique
ux_referral_bonds_referred_active(гонка) → идемпотентное завершение (связь уже создана конкурентом). - Результат. 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).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: ReferralBondEntity, Entity / Aggregate: ReferralLinkEntity, Entity / Aggregate: ReferralRegistrationEntity
- API (13): DELETE /api/investors/me/referral-links/{id} — Удалить реферальную ссылку (deleteReferralLink), GET /api/admin/referrals/investors/{investorId}/referrals — Список приглашённых реферером (getInvestorReferrals, admin), GET /api/admin/referrals/links — Все реферальные ссылки платформы (admin, getReferralLinks), GET /api/admin/referrals/links/{linkId} — Одна реферальная ссылка (admin, getReferralLinkById), GET /api/admin/referrals/links/{linkId}/registrations — Журнал ссылки с PII (admin getLinkRegistrations), GET /api/investors/me/referrer — свой реферер пользователя, GET /api/investors/referral-links/{url}/validate — Публичная валидация slug + счётчик переходов, GET /api/v1/referral-links/{referralLinkId}/stats — Статистика одной реферальной ссылки, GET /internal/v1/referral-links/resolve — Internal: резолв реферальной ссылки по slug, POST /api/admin/referrals/bonds/{bondId}/rebind — коррекция связи (R6, Replace), POST /api/admin/referrals/bonds/{bondId}/revoke — Отзыв реферальной связи (adminRevokeBond), PUT /api/investors/me/referral-links/{id} — Обновить реферальную ссылку (+1)
- Use Cases (6): UC-REF-001. Создание реферальной ссылки с каналом и шаринг, UC-REF-002. Аноним открыл реферальную ссылку (cookie 30 дней, validateReferralLink), UC-REF-003. Регистрация по реферальной ссылке → связь + лид (правило R1), UC-REF-004. Авторизация со ссылкой в окне lazy-регистрации → связь + лид (правило R2), UC-REF-005. Авторизация: «Имя заполнено И регистрация ≥ 4 ч» → только лид, без связи (правило R3), UC-REF-006. Ссылка от инвестора (не агента/застройщика) → связь без лида (модификатор R5)
- События (2): Событие «Запрошена карточка лида»: pin.referral.lead-attach-requested, Event Contract: pin.referral.bond-created (ReferralBondCreatedKafkaEvent)
- Консюмеры (3): Consumer: ReferralRegisteredConsumer (pin.referral.lead-attachments.v1), Consumer: ReferralUserSignedInConsumer (pin.identity.sign-ins.v1), Consumer: ReferralUserUpsertedConsumer (pin.identity.users.v1)
- scenarios (1): UC-OVR-001. Запрос консультации у агента недвижимости
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Referral Service
Связанные документы
- Сущности: referral-link.md, referral-registration.md, referral-bond.md; README контекста: README.md.
- Триггеры: user-upserted.md, user-signed-in.md, UC-IDN-001.
- Потребители исходов: CRM-лиды —
docs/06-services/crm/README.md(единственность активной пары); выплаты —docs/06-services/rewards/README.md.