Consumer: ReferralRegisteredConsumer (pin.referral.lead-attachments.v1)
Назначение
Консюмер Referral Registered обрабатывает приход инвестора по реферальной ссылке агента/застройщика и
материализует в crm карточку пути клиента: get-or-create ClientEntity в базе
агентства + get-or-create LeadEntity со Source = Referral для пары «агент ↔
инвестор» + запись в ленту лида (LeadEventEntity). На создание нового лида
публикуется LeadCreatedEvent (pin.crm.lead-created.v1) — «подтверждение реф-привязки» рефереру
(README).
Правила привязки (владелец ссылки — агент/застройщик по INV-REF-002; «Имя пустое / регистрация < 4 ч» —
окно R2; самостоятельная регистрация — R3; открытие ссылки — R4) исполняются на стороне referral —
CRM их не переисполняет: событие приходит только когда лид действительно нужен (см.
attach-rules.md, матрица R1–R4 + модификатор R5). CRM отвечает
за единственность активной пары «инвестор + агент» (дедупликация, глоссарий «Лид»).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| Consumer | Referral Registered |
| Обрабатываемое событие | pin.referral.lead-attach-requested |
| Topic | pin.referral.lead-attachments.v1 (владелец — referral; контракт — lead-attach-requested.md) |
| ContractVersion | 1; при несовпадении версии события — warn-and-proceed |
| Partitions / key | 6 / key = InvestorUserId (порядок запросов per-инвестор) |
| Consumer group | crm-referral-registered |
| Producer | referral (правила привязки R1–R4, attach-rules.md) |
| Эффект (событие → что меняется) | get-or-create клиента и лида (Source = Referral) для пары «агент ↔ инвестор» + запись в ленту лида; при новом лиде — LeadCreatedEvent |
Контекст события
- Из метаданных события доступны: идентификатор сообщения
MessageId(для дедупликации),CreatedAt, версия контракта, идентификатор инвестора (InvestorUserId). - Актор — системный контекст консюмера (не пользовательский):
CreatedByUserId/UpdatedByUserIdсоздаваемыхClientEntity/LeadEntity=null(система); запись выполняется в scopeTenantId = AgentCompanyId(записи компании агента, ADR-0052 — не кросс-tenant). - Корреляция сквозного трейса восстанавливается из метаданных события и пробрасывается в исходящее
событие
LeadCreatedEvent. - Бизнес-идемпотентность — по ключу
crm-referral-registered:{MessageId}; второй контур — бизнес-ключ пары (PrimaryInvestorUserId,ExecutorAgentUserId) черезux_leads_active_pair; третий — уникальностьLeadEventEntity.SourceMessageIdна ленте.
Входные данные / payload события
Контракт-дом — lead-attach-requested.md (полная таблица там; PII в payload нет). Ниже — только поля, которые consumer реально читает, и их маппинг.
| Поле payload | Тип | Обяз. | Как используется (маппинг) |
|---|---|---|---|
InvestorUserId | Guid | Да | Ключ дедупа клиента (ux_clients_tenant_investor) и пары лида; ClientEntity.InvestorUserId, LeadEntity.PrimaryInvestorUserId. |
AgentUserId | Guid | Да | ClientEntity.OriginAgentUserId, LeadEntity.OriginAgentUserId + начальный ExecutorAgentUserId (Origin = Executor при создании); вторая половина бизнес-ключа активной пары (ExecutorAgentUserId). |
AgentCompanyId | Guid? | Да* | TenantId создаваемых ClientEntity/LeadEntity (scope компании агента). null → аномалия (нарушение INV-REF-002 у producer) → DLQ (см. Retry). |
ReferralLinkId | Guid | Да | LeadEntity.ReferralLinkId (атрибуция) + payload события ленты. |
Channel | string (enum ReferralChannel) | Да | Атрибуция канала в payload события ленты; unknown → Other + warn. |
AppliedRule | string (enum ReferralAttachRule) | Да | R1/R2/R3/R4 — в payload события ленты; unknown → warn + трактовать как R1 (карточка всё равно создаётся). |
BondId | Guid? | Нет | Реферальная связь, если создана (null для R3/R4/повтора); в payload события ленты. |
RequestedAt | DateTimeOffset | Да | LeadEventEntity.OccurredAt (момент прихода лида). |
ФИО/телефон инвестора в payload отсутствуют — берутся из CRM-реплики pin.identity.users.v1
(дом — identity-user-upserted.md); см. шаги 2–3.
Алгоритм
- Контекст события. Восстановить метаданные события (идентификатор сообщения, идентификатор
инвестора, время, версия контракта); при несовпадении версии — warn-and-proceed (метрика
crm_referral_contract_mismatch_total). Проверить идемпотентность по ключуcrm-referral-registered:{MessageId}: если событие уже обработано → пропустить как дубль (ack). - Проверка входа.
InvestorUserId,AgentUserId,ReferralLinkIdзаданы;AgentCompanyIdзадан и не пуст (иначеCRM_REFERRAL_MISSING_TENANT— non-retriable → DLQ, аномалия producer);Channel/AppliedRuleс неизвестным значением enum → warn + fallback (Other/R1), событие не теряется. Проверить существование identity-реплики инвестора (источник ФИО/телефона): если её нет →CRM_REFERRAL_INVESTOR_REPLICA_MISSING— retriable (out-of-order: событиеpin.identity.users.v1ещё не применилось). Guard-ы привязки (self-attach,Type=Investor, активность владельца) уже проверены producer-ом (trusted boundary) — здесь не дублируются. - Что читаем.
- identity-реплику инвестора — источник ФИО/телефона для создания клиента.
- Клиента агентства по паре (
AgentCompanyId,InvestorUserId) среди живых карточек (INV-CLI-3); если не найден — резервный поиск по телефону инвестора среди живых карточек того же tenant (ручная карточка до регистрации, INV-CLI-1) для связывания. - Активный лид пары (
ExecutorAgentUserId,PrimaryInvestorUserId,Status = Active) — резолв по событийнымAgentUserId/InvestorUserId, с блокировкой на чтение — сериализация гонки двойной привязки (INV-CRM-001).
- Правила (get-or-create + маппинг поле-в-поле).
- Клиент. Найден → reuse; если найден по телефону с
InvestorUserId == null— связать карточку с инвестором. Не найден → создатьClientEntity(TenantId = AgentCompanyId,OriginAgentUserId,InvestorUserId, ФИО/телефон из реплики,Source = Referral,Status = Active,CreatedByUserId = null); поднимается доменноеClientCreated. - Лид (дедуп активной пары). Активный лид найден → reuse (идемпотентный повтор; несколько агентов на
одного инвестора — разные пары/лиды). Иначе создать лид (
Source = Referral,OriginAgentUserId+ начальныйExecutorAgentUserId(Origin = Executor при создании),PrimaryInvestorUserId,ReferralLinkId): фабрика проверяет INV-CRM-001, ставитTenantId = AgencyCompanyId,Status = Active, пустые хотелки (LeadWishes),ExpiresAt = CreatedAt + 1 месяц,CreatedByUserId = null; поднимаетсяLeadCreated; затем лид привязывается к клиенту как активный (INV-CLI-2 —ActiveLeadId). - Лента. Дописать в ленту лида
LeadEventEntityсSourceMessageId = MessageId(идемпотентность ленты),OccurredAt = RequestedAt, typed payload{ReferralLinkId, Channel, AppliedRule, BondId?}; обновитьLead.LastEventAt. КонкретныйLeadEventTypeи модель payload — дом lead.md (см. «Известные ограничения»).
- Клиент. Найден → reuse; если найден по телефону с
- Что меняется. Появляются/обновляются клиент, лид и событие ленты (порядок с учётом FK: клиент → лид
→ событие ленты). При reuse-ветке нового лида нет — только событие ленты. Все записи вместе с отметкой
идемпотентности и (при новом лиде) исходящим событием фиксируются в одном согласованном изменении (R8).
Конфликт
ux_leads_active_pair(гонка) → перечитать активный лид и завершиться идемпотентно (пару уже создал конкурент). Offset подтверждается только после успешной фиксации. - Какие события фиксируются. Только при создании нового лида —
LeadCreatedEvent(контракт), topicpin.crm.lead-created.v1,ContractVersion = 1, key =LeadId; payload из лида:LeadId,TenantId,OriginAgentUserId,ExecutorAgentUserId,PrimaryInvestorUserId,Source = Referral,CreatedAt. Reuse активного лида → интеграционное событие не публикуется (идемпотентный повтор). - Результат. Успех → ack. Retriable-код → retry по политике; non-retriable → DLQ + алерт. Метрики/логи — раздел «Наблюдаемость».
Диаграмма
Идемпотентность и replay
- На уровне консюмера: ключ
crm-referral-registered:{MessageId}— повторная доставка после сбоя не меняет состояние. - На бизнес-уровне (пара):
ux_leads_active_pairunique partial (AgentUserId,InvestorUserId) whereStatus='Active'— у пары одновременно один активный лид; повтор → reuse (INV-CRM-001). - На уровне ленты:
ux_lead_events_source_message_idunique — одноMessageIdне создаёт дубль строки ленты. - Replay: да. Повторный проход топика безопасен (get-or-create + три контура дедупа). Runbook —
crm/runbooks/.
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Восстановить зависимость (runbook) |
| Нет identity-реплики инвестора (out-of-order) | CRM_REFERRAL_INVESTOR_REPLICA_MISSING | 6 попыток, backoff 30s→16m | Да, после исчерпания | Проверить приход pin.identity.users.v1; replay из DLQ |
Пустой AgentCompanyId (нарушение INV-REF-002) | CRM_REFERRAL_MISSING_TENANT | Нет (non-retriable) | Да + алерт | Разбор с referral (почему лид без владельца-tenant) |
Конфликт телефона с чужой ручной карточкой (INV-CLI-1, разные InvestorUserId) | CRM_CLIENT_PHONE_DUPLICATE | Нет | Да + тикет заботы | Ручной merge карточек клиента |
Гонка активной пары (ux_leads_active_pair) | — | 3 попытки немедленно (перечитать) | После исчерпания | Расследование гонки |
Unknown enum Channel/AppliedRule | — | — (warn + fallback) | Нет | Обновить маппинг при релизе новых значений |
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/client-created/client-linked/lead-created/lead-reused/dead-letteredс полямиMessageId,InvestorUserId,AgentUserId,AppliedRule; PII (телефон/ФИО из реплики) — маскируется (+7***1234), в логи только id. - Metrics:
crm_referral_consume_duration_ms,crm_referral_leads_created_total,crm_referral_leads_reused_total,crm_referral_retries_total,crm_referral_dlq_total,crm_referral_lag_seconds,crm_referral_contract_mismatch_total. - Traces: span приёма события → span обработки → spans обращений к хранилищу; корреляция из метаданных события пробрасывается в исходящее
LeadCreatedEvent. - Alerts: лаг > 5 мин (README, Alerts); DLQ > 0; рост
client-phone-duplicate. - Безопасность: system-actor (не пользовательский контекст); запись строго в scope
TenantId = AgentCompanyId(ADR-0052) — застройщик не видит лид/клиентскую базу, инвестор не видит фиксаций; tenant отсутствует/пуст → fail closed (CRM_REFERRAL_MISSING_TENANT, DLQ). Доступ к топику — m2m ACL Kafka (толькоcrm).
Известные ограничения и assumptions
Имя топика — по producer-у (referral). Consumer подписан на реальный топик referral
pin.referral.lead-attachments.v1 (событие ReferralLeadAttachRequestedKafkaEvent, дом —
lead-attach-requested.md). Класс-потребитель на стороне
crm сохраняет доменное имя ReferralRegisteredConsumer (семантика «реф-регистрация → лид»), но топик и
тип события берутся из контракта referral (реконсилировано; ранее crm-сторона использовала неверное имя
топика — referral-registered вместо lead-attachments).
LeadEventType реф-регистрацииКонкретное значение enum и модель payload события ленты определяются
в доме lead.md; в текущем перечне LeadEventType отдельного значения под реф-приход
нет — это дельта, которую нужно зарегистрировать в lead.md (поля ReferralLinkId, Channel,
AppliedRule, BondId?), а не здесь.
- Источник ФИО/телефона. CRM-реплика
pin.identity.users.v1(дом — identity-user-upserted.md); при её отсутствии на момент реф-события — retriable (см. Retry). Правила привязки (окно 4 ч, «имя пустое», владелец-агент) — дом attach-rules.md, CRM их не вычисляет.
Acceptance Criteria
- AC-1 (новый клиент и лид). Дано: identity-реплика инвестора есть, клиента/активного лида пары нет. Когда:
событие реф-привязки. Тогда: создан
ClientEntity(Source = Referral,TenantId = AgentCompanyId), созданLeadEntity(Source = Referral,Status = Active,ExpiresAt = CreatedAt + 1 месяц), запись в ленту лида сSourceMessageId = MessageId, публикуетсяLeadCreatedEvent, ack. - AC-2 (переиспользование пары). Дано: активный лид пары уже существует. Когда: событие. Тогда: новый лид
не создаётся, добавляется только событие ленты,
LeadCreatedEventне публикуется (lead-reused), ack. - AC-3 (нет tenant владельца). Дано:
AgentCompanyIdпуст (нарушение INV-REF-002). Когда: событие. Тогда:CRM_REFERRAL_MISSING_TENANT(non-retriable) → DLQ + алерт. - AC-4 (нет identity-реплики). Дано: реплики инвестора ещё нет (out-of-order против
pin.identity.users.v1). Когда: событие. Тогда:CRM_REFERRAL_INVESTOR_REPLICA_MISSING— ретраи (30s→16m), после исчерпания DLQ. - AC-5 (связывание ручной карточки). Дано: клиента по паре нет, но есть живая карточка того же tenant по
телефону с
InvestorUserId == null. Когда: событие. Тогда: карточка связывается с инвестором (client-linked), лид создаётся/переиспользуется. - AC-6 (гонка активной пары). Дано: два события пары приходят конкурентно. Когда: обработка. Тогда:
единственность
ux_leads_active_pair— победитель создаёт лид, проигравший перечитывает и завершается идемпотентно (reuse), одноLeadCreatedEvent. - AC-7 (дубль). Дано: уже обработанный
MessageId. Когда: повторная доставка. Тогда:skipped-duplicate, без изменений, ack.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: LeadEntity
- API (1): GET /api/agent/crm/leads/{leadId}/events — Лента событий лида
- Use Cases (1): UC-CRM-001. Создание лида (заявка / реферал / вручную)
- События (1): Событие «Запрошена карточка лида»: pin.referral.lead-attach-requested
Связанные документы
- Шаблон: consumer-template.md; контракт события: lead-attach-requested.md.
- Целевые агрегаты: lead.md, client.md; производимое событие: lead-created.md; соседний consumer: identity-user-upserted.md.
- Правила привязки: attach-rules.md, referral README; crm README.
- ADR: 0052 tenancy, 0056 simplicity-first.