GET /api/investors/referral-links/{url}/validate — Публичная валидация slug + счётчик переходов
Дом item-модели ValidateReferralLinkResult (на неё ссылаются
../internal/resolve-referral-link.md,
get-link-stats.md, attach-rules.md шаг 8) и точка
входа триггера T3 (правило R4 LinkOpenAuthorized) процесса привязки.
Назначение
Аноним или авторизованный пользователь открывает реферальную ссылку — фронт вызывает эту операцию,
чтобы показать «вас пригласил <имя>» и решить, класть ли slug в cookie перед входом/регистрацией.
Помимо валидации выполняются два побочных эффекта без изменения полей ответа: (1) атомарный
инкремент ReferralLinkEntity.VisitsCount (база конверсии «переход → регистрация → связь»); (2) при
авторизованном инвесторе и включённом флаге — запуск нормативного процесса привязки по правилу R4
(связь не создаётся, лид — если владелец агент).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | referral |
| API area | public |
| Feature group | referral-links |
| Статус | draft |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Anonymous (публичный метод); JWT опционален — авторизованный инвестор включает путь T3 |
| Auth policy / Permissions | none; rate limit по IP (защита от перебора slug) |
| Scope | не применяется (аноним вне организации) |
| Record-level | не применяется — ссылка ищется по глобальному slug (INV-REF-L1) |
| Tenant isolation | не применяется к выборке; TenantId ссылки наружу не отдаётся |
| Audit | none (read + некритичный инкремент счётчика; T3 — через событие) |
| Trusted/untrusted boundary | url — untrusted (slug из пользовательской cookie referralUrl): нормализация lowercase + формат ^[a-z0-9][a-z0-9-]{2,63}$ (INV-REF-L3) до обращения к хранилищу; несоответствие → isValid=false, не 400 |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/investors/referral-links/{url}/validate |
| Success status | 200 — валидна/невалидна/не найдена всегда даёт 200 с флагом isValid |
| Idempotency | не применяется |
| Correlation | OpenTelemetry trace (traceparent) |
GET с намеренным побочным эффектом (инкремент
VisitsCount, опц. T3) — осознанное отклонение от «GET безопасен» ради обратной совместимости маршрута: инкремент — некритичная аналитика (потеря/двойной счёт допустимы, ADR-0056), T3 идемпотентен через дедуп лида в CRM.
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
Url | route:{url} | string | Да | нормализация Trim().ToLowerInvariant(); формат ^[a-z0-9][a-z0-9-]{2,63}$ (INV-REF-L3); несоответствие/длина > 64 → isValid=false (не 400 — мусор из cookie штатен) | untrusted (cookie-origin slug); в логах — усечённо и только при isValid=false |
ActorUserId | identity claims (sub) | Guid? | Нет | JWT опционален; != null + инвестор + флаг Referral:LinkOpenAttachEnabled → путь T3 | — |
Body и query-параметры отсутствуют.
Модель ответа ValidateReferralLinkResult
Канонический дом item-модели. Наружу отдаётся только публичное имя реферера — фамилия/телефон/TenantId
не раскрываются (PII-минимизация).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
isValid | bool | Да | computed | — | true — ссылка найдена и не удалена (DeletedAt == null); false — невалидный формат / не найдена / soft-deleted (не различаются — не раскрываем историю slug). |
url | string | Да | echo нормализованного slug | — | Эхо запрошенного slug (lowercase) — фронт сверяет с cookie и очищает её при isValid=false. |
referrerDisplayName | string? | Нет | ReferralUserReplica.Name.FirstName владельца | публичное имя (только FirstName) | Имя пригласившего для баннера «вас пригласил …». null при isValid=false, отсутствии реплики (лаг consumer-а) или пустом FirstName (lazy-регистрация). |
Предусловия и постусловия
- Предусловия. Актор — аноним либо авторизованный (JWT опционален); права не требуются.
url— untrusted slug из cookiereferralUrl. Путь T3 активен только при включённом флагеReferral:LinkOpenAttachEnabled(default off, README.md). - Постусловия. При найденной ссылке
VisitsCountинкрементирован атомарно (best-effort: потеря/ двойной счёт допустимы, ADR-0056). По правилу R4 связь и запись журнала не создаются (ReferralBondCreatedKafkaEventне публикуется). Если сработал T3 и владелец —Agent/DeveloperManagerв статусеActive(INV-REF-002) — опубликованReferralLeadAttachRequestedKafkaEvent(AppliedRule = R4,BondId = null). При невалидном формате / не найденной / soft-deleted ссылке состояние не меняется: инкремента и событий нет.
Алгоритм
Путь T3 делегирует нормативному процессу привязки (attach-rules.md, «Процесс привязки»); здесь — только дельта входа T3 (синхронный, actor из контекста запроса).
- Контекст и доступ. Актор —
Anonymousлибо авторизованный (ActorUserIdможет бытьnull); права не требуются. Нормализацияslug = Url.Trim().ToLowerInvariant(). - Проверка входа (soft). Формат slug (
^[a-z0-9][a-z0-9-]{2,63}$) и длина ≤ 64 проверяются как soft-исход, а не hard-ошибка: несоответствие → короткое замыкание сisValid=false,url=slug,referrerDisplayName=null(хранилище не трогаем; 400 не возвращается — untrusted cookie-slug). - Что читаем. Ссылку по
Url = slug, не удалённую (DeletedAt == null). Не найдена →isValid=false,referrerDisplayName=null, счётчик не инкрементируется. Найдена — дополнительно владельца из репликиReferralUserReplica(дляreferrerDisplayName; допускаетсяnullпри лаге реплики). - Правила и запуск T3.
isValid=true;referrerDisplayName= имя владельца (null, если владелец не найден илиFirstNameпуст). Путь T3 запускается, если актор авторизован, флагReferral:LinkOpenAttachEnabledвключён и выполнены guard-ы: G2 (актор —Investor), G3 (ActorUserId != OwnerUserId), G4 (актор активен). При запуске применяется правило R4: связь и журнал не создаются (матрица attach-rules); признак лидаleadEligible— владелец агент/застройщик (шаг 6). Флаг выключен / актор аноним / не инвестор / self → T3 не запускается, остаётся чистая валидация. - Инкремент счётчика. При найденной ссылке — атомарный
visits_count = visits_count + 1без загрузки агрегата (ADR-0056). Связь и запись журнала по R4 не добавляются. - Событие лида (только при T3 + владелец-агент). Если путь T3 сработал и владелец активен
и его тип ∈ {
Agent,DeveloperManager} (INV-REF-002) — публикуетсяReferralLeadAttachRequestedKafkaEvent(topicpin.referral.lead-attachments.v1,ContractVersion = 1, key =ActorUserId; payload по attach-rules шаг 6:AppliedRule=R4,BondId=null,AgentUserId=OwnerUserId,AgentCompanyId=TenantIdссылки,ReferralLinkId,Channel; контракт — events/lead-attach-requested.md).ReferralBondCreatedKafkaEventне публикуется (R4 связь не создаёт). Иначе событий нет. - Согласованность. Инкремент
VisitsCountи (опц.) публикация lead-attach фиксируются вместе. Чистый путь (аноним): сбой инкремента логируется warning-ом и не роняет ответ валидации (счётчик — некритичная аналитика). Путь T3: сбой сохранения →REFERRAL_ATTACH_PERSIST_FAILED(500), клиент/шлюз ретраит — повторный инкремент допустим, лид дедуплицируется CRM по паре «инвестор+агент». - Результат.
ValidateReferralLinkResult { isValid, url = slug, referrerDisplayName }→200. Все исходы —200. Masking:referrerDisplayName— только имя; фамилия/телефон/tenant не отдаются.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | ReferralLeadAttachRequestedKafkaEvent → pin.referral.lead-attachments.v1 (только при T3 + владелец-агент); иначе нет |
| Проекции / счётчики | атомарный инкремент колонки VisitsCount (потребители — get-link-stats.md, get-partner-stats.md) |
| Уведомления | опосредованно: по lead-attach CRM создаёт карточку лида; уведомление агенту — вне referral |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
TOO_MANY_REQUESTS | 429 | Превышен per-IP rate limit (защита от перебора slug); заголовок Retry-After | «Слишком много запросов» | retry после Retry-After |
REFERRAL_LINK_STORE_UNAVAILABLE | 503 | Хранилище недоступно при чтении ссылки/реплики | «Сервис временно недоступен» | retry |
REFERRAL_ATTACH_PERSIST_FAILED | 500 | Сбой сохранения на пути T3 (инкремент + lead-attach) | — | retry (лид дедуплицируется CRM; повторный инкремент допустим) |
Невалидный формат / не найденная / soft-deleted ссылка — не ошибки: 200 c isValid=false
(фронт очищает cookie). 400 из этой операции не возвращается by design (untrusted cookie-slug). Сбой
инкремента на чистом (анонимном) пути — logged warning, ответ валидации отдаётся (best-effort счётчик,
ADR-0056).
Критерии приёмки
- Given аноним открывает валидный slug существующей ссылки, When валидация, Then
200,isValid = true,referrerDisplayName= имя владельца из реплики (nullпри лаге реплики или пустомFirstName),VisitsCountинкрементирован (best-effort), событий нет. - Given мусорный по формату slug из cookie, When валидация, Then
200,isValid = false,url= эхо slug,referrerDisplayName = null; хранилище не читается, инкремента нет,400не возвращается (untrusted cookie-slug). - Given slug не найден / soft-deleted, When валидация, Then
200,isValid = false, инкремента и событий нет. - Given авторизованный инвестор, флаг T3 включён, владелец —
Agent/DeveloperManager(Active), guard-ы G2–G4 пройдены, When валидация, Then200,isValid = true,VisitsCount+1, опубликованReferralLeadAttachRequestedKafkaEvent(AppliedRule = R4,BondId = null); связь иReferralBondCreatedKafkaEvent— нет (R4). - Given авторизованный инвестор, флаг включён, владелец — инвестор/партнёр (не агент), When
валидация, Then
200, инкремент есть, лид не запрашивается (R5/INV-REF-002). - Given флаг T3 выключен или актор аноним/не инвестор или self (
ActorUserId == OwnerUserId), When валидация, Then T3 не запускается — чистая валидация, только инкремент. - Given хранилище недоступно при чтении, When валидация, Then
REFERRAL_LINK_STORE_UNAVAILABLE(503), retry. - Given путь T3 и сбой сохранения, When валидация, Then
REFERRAL_ATTACH_PERSIST_FAILED(500), retry; лид дедуплицируется CRM по паре «инвестор+агент», повторный инкремент допустим. - Given сбой инкремента на анонимном пути, When валидация, Then ответ всё равно
200(счётчик best-effort, warning в лог). - Given один IP превышает лимит запросов (перебор slug), When валидация, Then
TOO_MANY_REQUESTS(429) сRetry-After— хранилище не читается, инкремента и T3 нет.
Совместимость и наблюдаемость
- Контракт
ValidateReferralLinkResult(isValid/url/referrerDisplayName) стабилен; новые optional поля — additive; удаление/переименование/смена типа — breaking (migration + rollout). Побочные эффекты (VisitsCount, T3) — поведенческое расширение; T3 под feature flagReferral:LinkOpenAttachEnabled(default off до релиза, README.md). - Unknown
Channel/Typeвладельца при чтении — fallback (Other/ «не агент», fail-closed, attach-rules.md) — наisValid/referrerDisplayNameне влияет. - Logs:
validate resolved/not-found/invalid-format,LinkId,isValid, actor (anon/authed),t3_fired+AppliedRule=R4+ guard-исходы; slug — только приisValid=false, усечённо; PII нет. Metrics:referral_link_validate_total{result, t3_fired},referral_link_visits_incremented_total,referral_link_validate_rate_limited_total(перебор slug по IP); traces — OpenTelemetry. Dashboard —referral-overview; алерт «рост invalid-format» = битые slug в cookie, алерт «рост 429 c одного IP» = перебор slug (README.md, Alerts).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (8): DELETE /api/investors/me/referral-links/{id} — Удалить реферальную ссылку (deleteReferralLink), GET /api/investors/me/referral-links/lookup — Пагинированный lookup своих ссылок, GET /api/investors/me/referral-links/lookup/{id} — Резолв реф-ссылки по id, GET /api/v1/partner/stats — Партнёрская сводка по каналам, GET /api/v1/referral-links/{referralLinkId}/stats — Статистика одной реферальной ссылки, GET /internal/v1/referral-links/resolve — Internal: резолв реферальной ссылки по slug, PUT /api/investors/me/referral-links/{id} — Обновить реферальную ссылку, Referral API — реестр операций и общие контрактные модели
- Use Cases (1): UC-REF-002. Аноним открыл реферальную ссылку (cookie 30 дней, validateReferralLink)
- События (1): Событие «Запрошена карточка лида»: pin.referral.lead-attach-requested
- registries (1): Реестр API-методов PIN
Связанные документы
- Дома: referral-link.md (
Url/VisitsCount, INV-REF-L1/L3), attach-rules.md (триггер T3 / правило R4, guard-ы G1–G5, INV-REF-002,ReferralUserReplica, нормативный процесс привязки — шаг 8); событие events/lead-attach-requested.md. - Конверт ответа: identity/api/README.md; реестр операций: api/README.md.
- Соседи: ../internal/resolve-referral-link.md (m2m-аналог без side effects),
get-link-stats.md / get-partner-stats.md (потребители
VisitsCount); фиксация slug в cookie — фронт. - ADR-0052 (tenancy), ADR-0056 (simplicity-first).