Referral Service
Назначение
referral владеет реферальной/партнёрской системой PIN: реферальные ссылки пользователей по каналам
(мониторинг, метрики), журнал регистраций по ссылкам, реферальные связи «кто привёл кого» (основание
будущих выплат) и доменные правила привязки «ссылка → связь → лид». Сервис отдаёт пользователю
управление своими ссылками и партнёрскую статистику, администратору платформы — журнал всех регистраций
и ручные коррекции связей, а соседним контекстам (crm, rewards, notifications) — события о
созданных и закрытых связях.
Metadata
| Поле | Значение |
|---|---|
| Статус | draft |
| Owning team | команда referral |
| Domain | привлечение/партнёрская программа (deals-цепочка, service-boundaries.md) |
| Data classification | Mixed: ссылки/журнал — Internal; реплика пользователей — PII (ФИО, телефон) |
| Runtime criticality | medium (недоступность не блокирует вход/каталог; ломает привязку и партнёрскую статистику) |
Ownership
| Artifact | Owner/Rule |
|---|---|
| PostgreSQL schema | referral |
| API areas | public (self-service ссылки/журнал/реферер), admin (журнал всех, коррекции связей), internal (m2m-резолв slug) |
| Owned entities | ReferralLinkEntity, ReferralRegistrationEntity, ReferralBondEntity, ReferralUserReplica (replica) |
| Produced events | ReferralBondCreatedKafkaEvent, ReferralBondClosedKafkaEvent (pin.referral.bonds.v1); ReferralLeadAttachRequestedKafkaEvent (pin.referral.lead-attachments.v1) |
| Consumed events | UserUpsertedKafkaEvent, UserBlockedKafkaEvent (pin.identity.users.v1); UserSignedInKafkaEvent (pin.identity.sign-ins.v1) |
| Search indexes | none |
| Analytics facts | none (метрики каналов — SQL-агрегаты по журналу/счётчикам, ADR-0056) |
Ответственность И Границы
- Делает: управление реферальными ссылками (slug, канал, default-ссылка); валидацию/резолв ссылки
и счёт переходов; исполнение правил привязки (domain/attach-rules.md) по
событиям identity; ведение журнала регистраций и реферальных связей; публикацию событий «связь
создана» и «нужен лид»; партнёрскую статистику владельца (
/profile/partner); admin-журнал и admin-коррекции связей. - Не делает: расчёт и выплату вознаграждений — контекст
rewards: ставки реферального вознаграждения — referral-rate-rule.md, реферальные начисления поBondId— referral-accrual.md, реплика связей — referral-bond.md; создание/жизненный цикл карточки лида (контекстcrm— единственность активной пары «инвестор+агент»); фиксациюreferrerUrlпри регистрации/входе и cookie 30 дней (фронт +identity); контакты/чаты (messenger); СМС/уведомления (notifications). - Source of truth: ссылки, журнал регистраций, реферальные связи, правила привязки.
- Реплики/projections:
ReferralUserReplica(проекцияpin.identity.users.v1— тип/статус/ ФИО/телефон/дата регистрации); реплика связей вrewards— по событиямpin.referral.bonds.v1(consumer — referral-bond.md).
Зависимости
| Зависимость | Тип | Зачем Нужна | Failure mode |
|---|---|---|---|
PostgreSQL (schema referral) | db | Все сущности | Сервис недоступен |
Kafka pin.identity.users.v1 | queue (consume) | Триггер T1 (регистрация с ReferrerUrl) + реплика пользователей | Привязка/реплика отстают; consumer догоняет (at-least-once, идемпотентность first-seen) |
Kafka pin.identity.sign-ins.v1 | queue (consume) | Триггер T2 (авторизация со slug; поле ReferrerUrl) | Привязка при входе отстаёт |
| Kafka producer | queue (produce) | События pin.referral.* в crm/rewards/notifications/messenger | События копятся до доставки после восстановления |
identity (косвенно) | service | Источник учёток и slug referrerUrl; синхронных вызовов нет | — |
Проекты
src/services/referral/
Pin.Referral.Api/
Pin.Referral.Application/
Pin.Referral.Domain/
Pin.Referral.Infrastructure/
Pin.Referral.Background/
Pin.Referral.Migrator/
Pin.Referral.Contracts/
Namespace по агрегатам — Pin.Referral.ReferralLinks, Pin.Referral.ReferralRegistrations,
Pin.Referral.ReferralBonds (правило границ service-boundaries.md).
Основные Сущности
| Entity | Назначение | Source Of Truth | Документ |
|---|---|---|---|
ReferralLinkEntity | Ссылка (slug) пользователя по каналу; default-ссылка; счётчик переходов | referral | domain/referral-link.md |
ReferralRegistrationEntity | Append-only журнал регистраций по ссылкам (Initial/Rebind) | referral | domain/referral-registration.md |
ReferralBondEntity | Действующая связь «кто привёл кого, когда, через какую ссылку» — основание выплат | referral | domain/referral-bond.md |
ReferralUserReplica | Реплика пользователей identity для условий правил и admin-проекций | identity | domain/attach-rules.md (раздел «Реплика») |
Правила привязки + ReferralAttachRule + INV-REF-00x | Decision table «связь?/лид?» | referral | domain/attach-rules.md |
ER-обзор
API
Карта областей; каждая операция документируется отдельным файлом (реестр — api/README.md).
- Public (self-service; permissions
referral.links.manage-self/referral.links.view-self/referral.registrations.view-self):api/public/get-or-create-default-referral-link.md— идемпотентная выдача/создание default-ссылки;api/public/create-referral-link.md— создание (slug ≤ 64 опц., description ≤ 256,channel);api/public/update-referral-link.md— смена slug/описания;api/public/delete-referral-link.md— soft-delete (кроме default, INV-REF-L5);api/public/get-my-referral-links.md— список своих (ReferralLinkListItem+registrationsCount; paging Offset/Take ≤ 1000);api/public/get-my-referrer.md— свой реферер;api/public/lookup-referral-link-by-id.md,api/public/lookup-referral-links.md— lookup для выпадающих списков;api/public/validate-referral-link.md— валидация slug (аноним:isValid/referrerDisplayName; инкрементVisitsCount, триггер T3 для авторизованного инвестора);api/public/get-link-registrations.md,api/public/get-all-registrations.md— обезличенный журнал своих ссылок (ReferralRegistrationItem);api/public/get-partner-stats.md— партнёрская сводка по каналам для/profile/partner(переходы/регистрации/связи perChannel);api/public/get-link-stats.md— детальная статистика одной ссылки (переходы/регистрации/запрошенные лиды/связи).
- Admin (permission
referral.registrations.view-all):api/admin/get-referral-links.md(AdminReferralLinkEntry: владелец, телефон, счётчик),api/admin/get-referral-link-by-id.md,api/admin/get-link-registrations.md(AdminReferralRegistrationEntryс ФИО/телефоном),api/admin/get-investor-referrals.md(AdminReferredInvestorEntry,boundAt);api/admin/rebind-bond.md(R6,Replace),api/admin/revoke-bond.md(Revoke).
- Internal (m2m):
api/internal/resolve-referral-link.md— синхронный read-only резолв slug дляidentity/crm(владелец, тип/статус,OwnerIsLeadEligible; PII не отдаёт); межконтекстные мутации остаются событийными. - Callbacks: not applicable.
Events И Consumers
| Type | Name | Topic | Назначение | Link |
|---|---|---|---|---|
| Produced | ReferralBondCreatedKafkaEvent | pin.referral.bonds.v1 | Связь создана → rewards (основание выплат), notifications (уведомление рефереру), messenger (контакт) | events/bond-created.md; поля — domain/referral-bond.md |
| Produced | ReferralBondClosedKafkaEvent | pin.referral.bonds.v1 | Admin-коррекция (Replaced/Revoked) → rewards останавливает начисления | events/bond-closed.md; поля — domain/referral-bond.md |
| Produced | ReferralLeadAttachRequestedKafkaEvent | pin.referral.lead-attachments.v1 | Запрос создания карточки лида → crm (дедуп пары «инвестор+агент») | events/lead-attach-requested.md; payload — domain/attach-rules.md шаг 6 |
| Consumed | UserUpsertedKafkaEvent | pin.identity.users.v1 | Триггер T1 (привязка при регистрации, ReferrerUrl) + апсерт ReferralUserReplica | consumers/user-upserted.md; правила — domain/attach-rules.md |
| Consumed | UserBlockedKafkaEvent | pin.identity.users.v1 | Обновление Status реплики (guard G4, лиды заблокированного владельца) | consumers/user-upserted.md (тот же топик/consumer) |
| Consumed | UserSignedInKafkaEvent | pin.identity.sign-ins.v1 | Триггер T2 (привязка при авторизации; требует поля ReferrerUrl? — см. решения) | consumers/user-signed-in.md |
Доставка и отказоустойчивость событий
Единая политика доставки для всех потоков (детали ретраев/DLQ — в consumer-документах и runbook
runbooks/referral-consumer-lag.md):
- Исходящие (produced). Публикуются транзакционным outbox в одном согласованном изменении со сменой
состояния агрегата (связь/журнал) — гарантия «не потеряется» (at-least-once). Порядок per-ключ
(
ReferredUserIdдляpin.referral.bonds.v1,InvestorUserIdдляpin.referral.lead-attachments.v1) и монотонныйVersion-guard позволяют потребителю отбросить устаревшее/переигранное сообщение. При rebind в одном коммите публикуются оба события связи — потребитель видитclosedстрого раньшеcreated(общий топик + ключ). - Входящие (consumed). At-least-once + идемпотентность: T1 — first-seen по журналу
Initial(INV-REF-R1), апсерт реплики — поReplicaVersion(Version > ReplicaVersion), T2 — guard G5. Повторная доставка не плодит связей/записей/событий. - Retry/DLQ. Транзиентная ошибка (недоступность БД, конфликт версий) → ограниченные повторы с
backoff; при исчерпании — в DLQ топика без блокировки очереди (порядок по ключу сохраняется для
остальных пользователей). Сообщение с неизвестной/несовместимой схемой (poison) → сразу в DLQ + алерт;
разбор — по runbook. Out-of-order событие (устаревший
Version) — не ошибка: no-op с логом. - Деградация. Недоступность consumer-потока откладывает привязку/реплику, но не блокирует вход/
каталог (Runtime criticality medium); после восстановления consumer догоняет lag. Отсутствие реплики
на момент чтения admin-выдач — graceful degradation (пустое ФИО/
nullтелефон), не отказ.
Кросс-сервисные контракты
Ожидает от identity (Kafka, at-least-once, монотонный Version на пользователя):
UserUpsertedKafkaEvent(pin.identity.users.v1, ключUserId,ContractVersion = 1): поляUserId,Type,CompanyId,Status,Name(FullNameSnapshot),PhoneNumber,ReferrerUrl?,Version; приVersion = 1время события = момент регистрации (источникRegisteredAtреплики). НаполняетReferralUserReplica; триггер T1 — на first-seenUserIdприReferrerUrl != null.UserSignedInKafkaEvent(pin.identity.sign-ins.v1, ключUserId): additive-полеReferrerUrl?(slug из подтверждения входа) — требование к identity (решение № 4), без bumpContractVersion; триггер T2.UserBlockedKafkaEvent(pin.identity.users.v1): обновляетStatusреплики (guard G4).- Гарантии/деградация: порядок per-
UserIdпо ключу; идемпотентность — first-seen по журналуInitialReplicaVersion(апсерт только приVersion > ReplicaVersion); отсутствие/устаревание реплики →RegisteredAt = nullтрактуется как «окно 4 ч истекло» (fail-closed), unknownType/Status→ «не агент/застройщик»/Blocked.
Предоставляет соседям (публикуется в одном согласованном изменении со сменой состояния агрегата):
ReferralBondCreatedKafkaEvent/ReferralBondClosedKafkaEvent(pin.referral.bonds.v1, ключReferredUserId,ContractVersion = 1, монотонныйVersion-guard): строгий порядок per-инвестор (при rebindclosedраньшеcreated) гарантируется общим топиком и ключом. Потребители —rewards(завести/остановить начисления поBondId),notifications(СМС рефереру),messenger(контакт).ReferralLeadAttachRequestedKafkaEvent(pin.referral.lead-attachments.v1, ключInvestorUserId,ContractVersion = 1): идемпотентен по паре (InvestorUserId,AgentUserId) — CRM дедуплицирует лид; может публиковаться и без создания связи (правила R3/R4).- Окно eventual-consistency: между сменой связи и реакцией
rewards/CRM — лаг доставки (SLO привязки < 1 мин); в промежутке пользователь видит уже обновлённую связь вgetMyReferrer, а начисления/лид подтягиваются асинхронно.
Синхронно предоставляет read-only резолв slug (api/internal/resolve-referral-link.md) для
identity/crm: PII не отдаёт, owner-поля могут отставать на лаг реплики (вызывающий трактует их как
подсказку — финальное решение «связь/лид» остаётся за referral).
Runtime И Эксплуатация
| Область | Требование |
|---|---|
| Health checks | /health/live, /health/ready (PostgreSQL, Kafka consumer lag), /health/startup (Migrator применён) |
| SLO/SLA | Привязка после регистрации — < 1 мин (СМС рефереру, правило user-upserted.md); public API p95 < 300 мс |
| Dashboard | referral-overview (срабатывания правил R1–R6, конверсия переход→регистрация→связь per канал, consumer lag) |
| Alerts | consumer lag > 5 мин; рост no-op по G1 (битые slug в cookie); ошибки доставки событий |
| Runbooks | runbooks/referral-consumer-lag.md, runbooks/referral-bond-corrections.md |
| Feature flags | Referral:LazyRegistrationWindow (default 04:00:00); Referral:LinkOpenAttachEnabled (триггер T3, default off) |
Безопасность
- Actor types: пользователь (self-service, любой тип учётки), аноним (
validateReferralLink), system (consumers, m2m ACL Kafka), администратор платформы (admin API). - Permission model:
referral.links.manage-self,referral.links.view-self,referral.registrations.view-self,referral.registrations.view-all— каталог permission.md. - Sensitive data classes: ФИО/телефон в
ReferralUserReplica(PII; телефон в логах —+7***1234); публичные выдачи журнала — обезличенные (толькоreferredInvestorId), PII — только admin. - Audit: журнал регистраций append-only; связи — версия + actor-stamping + события коррекций; ссылки — actor-stamping + доменные события.
- Tenant isolation:
TenantId = CompanyIdвладельца/реферера,nullу инвестора (ADR-0052); self-выдачи — record-level поOwnerUserId/ReferredUserId; кросс-tenant — только платформенные permissions.
Принятые Решения
| # | Решение | Обоснование |
|---|---|---|
| 1 | Три сущности + реплика; правила привязки — отдельный дом attach-rules.md (decision table R1–R6, инварианты INV-REF-001…005) | Сценарии/консюмеры ссылаются на один дом |
| 2 | Журнал регистраций пишется 1:1 с созданием/заменой связи (Initial/Rebind); лид без связи (R3/R4) журнал не пишет | Сохраняет семантику registrationsCount; след лида — в CRM |
| 3 | Автоматический Rebind отключён; перепривязка — только admin-коррекция (R6, Replace + Replaced) | Связь — основание денежных выплат: смена реферера без ручного контроля — фрод-риск |
| 4 | Требование к identity: additive-поле ReferrerUrl? в UserSignedInKafkaEvent (pin.identity.sign-ins.v1) — slug из confirmSignIn существующего пользователя | Иначе триггер T2 нереализуем событийно; additive-only — без bump ContractVersion (user-signed-in.md) |
| 5 | RegisteredAt в реплике из времени события; неизвестен → окно 4 ч истекло (fail-closed) | Не переплачивать выплаты при неполных данных |
| 6 | Метрики каналов — SQL-агрегаты по журналу/связям + единственный счётчик-колонка VisitsCount; без ClickHouse/Redis | ADR-0056 simplicity-first: ~1 млн регистраций — PostgreSQL + индексы |
| 7 | События выплат не публикуются из referral; rewards ведёт реплику связей по pin.referral.bonds.v1 (referral-bond.md) и при завершении сделки создаёт реферальное начисление по BondId (referral-accrual.md) со ставкой из referral-rate-rule.md | Границы: referral — факт связи, rewards — деньги |
Известные Ограничения И Assumptions
- Смена slug ссылки инвалидирует ранее розданные cookie со старым slug (резолв только текущего slug; история slug-ов не ведётся) — осознанное упрощение (INV-REF-L6).
- Правило T3 (лид при открытии ссылки) включается флагом после согласования с CRM (объём авто-лидов).
- Деньги по связям живут в
rewards: реферальное начисление создаётся при завершении сделки приведённого инвестора (referral-accrual.md); закрытие связи прекращает новые начисления, уже созданные сохраняются (referral-bond.md).
Связанные Документы
- Домены: domain/referral-link.md, domain/referral-registration.md, domain/referral-bond.md, domain/attach-rules.md.
- Обзор: technical-assignment.md (строка
referral), service-boundaries.md (поток «Реферальная привязка»), glossary.md. - Соседние контексты: identity (события/
ReferrerUrl),docs/06-services/crm/README.md(лиды),docs/06-services/rewards/README.md(выплаты),docs/06-services/notifications/README.md(уведомление рефереру). - ADR: 0052 (tenancy), 0053 (admin), 0056 (simplicity-first).