Entity / Aggregate: ReferralLinkEntity
Дом полей агрегата «Реферальная ссылка» контекста referral. Все проекции (модели
ReferralLinkListItem, ReferralLinkLookupEntry, AdminReferralLinkEntry, payload событий, метрики
по каналам) ссылаются сюда и описывают только дельту.
Назначение
ReferralLinkEntity — именованная реферальная ссылка (slug) пользователя PIN для приглашения в
систему и отслеживания каналов привлечения. Владелец — любой пользователь платформы: инвестор,
агент, менеджер застройщика, сотрудник ПИН — реферальные ссылки может формировать любой пользователь
системы. У одного владельца может быть много ссылок по разным каналам (мониторинг, метрики
per-канал) + ровно одна ссылка «по умолчанию» (getOrCreateDefaultReferralLink).
Механика на фронте: slug ссылки попадает в cookie referralUrl (TTL 30 дней) и передаётся полем
referrerUrl при регистрации/входе контекста identity
(sign-up-or-sign-in.md). Правила привязки по
ссылке — attach-rules.md.
Классификация
| Поле | Значение |
|---|---|
| Контекст | referral |
| Kind | AggregateRoot |
| Source of truth | referral |
| Таблица | referral.referral_links |
| Tenant isolation | TenantId = CompanyId владельца на момент создания (ADR-0052): у инвестора null; изоляция — record-level по OwnerUserId |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор ссылки (ReferralLinkListItem.id). |
OwnerUserId | Guid | Да | index ix_referral_links_owner_user_id; логическая ссылка на identity.users(id) (реплика ReferralUserReplica); жёсткого cross-context FK нет | app (actor) | Владелец ссылки (реферер) — любой пользователь платформы. |
TenantId | Guid? | Нет | index ix_referral_links_tenant_id where not null | app (из ReferralUserReplica.CompanyId владельца) | CompanyId владельца на момент создания; null — инвестор (ADR-0052). Не меняется при смене компании владельца (историческая принадлежность). |
Url | string | Да | varchar(64); unique ux_referral_links_url where deleted_at is null; формат ^[a-z0-9][a-z0-9-]{2,63}$, хранится в lowercase | app (createReferralLink.url или генерация) | Slug ссылки — глобально уникальный адрес. Если slug не задан — генерируется случайный (10 символов алфавита [a-z0-9], CSPRNG, повтор при коллизии до 3 раз; исчерпание 3 попыток при адресном пространстве 36^10 практически недостижимо — при нём операция завершается retriable-ошибкой сервера и повторяется клиентом, пользователю slug подставлять не нужно). Полный URL собирает фронт; бэк хранит только slug. |
Description | string? | Нет | varchar(256) | app (create/updateReferralLink.description) | Описание канала/назначения ссылки (maxLength 256). |
IsDefault | bool | Да | default false; partial unique ux_referral_links_owner_default (owner_user_id) where is_default and deleted_at is null | app (getOrCreateDefaultReferralLink) | Ссылка «по умолчанию» владельца. Ровно одна на владельца (INV-REF-L2). |
Channel | ReferralChannel | Да | хранится как varchar(32), index ix_referral_links_channel, default Direct | app (createReferralLink.channel) | Канал распространения ссылки для метрик per-канал. |
VisitsCount | long | Да | >= 0, default 0 | app (validateReferralLink инкрементирует) | Счётчик открытий ссылки (переходов): каждый успешный validateReferralLink выполняет атомарный инкремент без загрузки агрегата (ADR-0056), условно по deleted_at is null (переход по soft-deleted ссылке счётчик не двигает — валидация вернёт isValid = false). Дедупликации по посетителю нет — счётчик отражает грязный вал переходов (повторные открытия/боты возможны; контроль накрутки — метрики/алерты, не бизнес-правило). База конверсии «переход → регистрация → связь». |
CreatedAt | DateTimeOffset | Да | index ix_referral_links_created_at | app | Момент создания (createdAt). |
CreatedByUserId | Guid? | Нет | — | app | = OwnerUserId при самостоятельном создании; id администратора, если ссылка создана админ-панелью (ADR-0053). |
UpdatedAt | DateTimeOffset | Да | — | app | Момент последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app (deleteReferralLink) | Soft-delete. Удалённая ссылка не проходит validateReferralLink и не участвует в привязке; журнал и связи по ней остаются. |
DeletedByUserId | Guid? | Нет | — | app | Кто удалил. |
Enum ReferralChannel
| Значение | Описание |
|---|---|
Direct | Прямая передача ссылки (default). |
Telegram | Публикации/чаты Telegram. |
WhatsApp | Рассылки/чаты WhatsApp. |
Vk | Публикации VK. |
Instagram | Публикации Instagram. |
YouTube | Описания/комментарии YouTube. |
Email | Email-рассылки. |
Sms | СМС-рассылки. |
QrOffline | QR-коды на офлайн-носителях (буклеты, стенды шоурума). |
Blog | Личный блог/сайт владельца. |
Partner | Партнёрские площадки. |
Other | Прочее (описание канала — в Description). |
Unknown enum при чтении (после additive-расширения) — fallback Other + warning.
Производные (вычисляемые) показатели — не колонки
| Показатель | Как вычисляется | Где используется |
|---|---|---|
registrationsCount | COUNT(*) по referral.referral_registrations c referral_link_id = Id (индекс ix_referral_registrations_referral_link_id) | ReferralLinkListItem.registrationsCount, AdminReferralLinkEntry.registrationsCount |
bondsCount | COUNT(*) по referral.referral_bonds c referral_link_id = Id and status = 'Active' | партнёрская статистика /profile/partner |
| Метрики per-канал | агрегация SUM(VisitsCount), COUNT(registrations), COUNT(bonds) по ссылкам владельца с группировкой по каналу | партнёрская статистика /profile/partner |
Денормализованные счётчики регистраций/связей на ссылке не ведутся: объёмы журнала на целевой
нагрузке (ADR-0056) позволяют считать COUNT по индексу. Единственный счётчик-колонка — VisitsCount
(переходы не имеют собственной таблицы-журнала — событие «открытие» не хранится построчно,
simplicity-first).
Value objects
VO не используются: все поля скалярные.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Delete behavior |
|---|---|---|---|
ReferralRegistrationEntity | reference (обратная) | referral_registrations.referral_link_id → referral_links.id (FK) | restrict (журнал живёт после soft-delete ссылки) |
ReferralBondEntity | reference (обратная) | referral_bonds.referral_link_id → referral_links.id (FK) | restrict |
ReferralUserReplica (владелец) | replica | OwnerUserId | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-REF-L1 UniqueUrl | Url глобально уникален среди не удалённых (без учёта регистра — хранение lowercase) | unique partial index + проверка в createReferralLink/updateReferralLink | REFERRAL_LINK_URL_TAKEN |
| INV-REF-L2 SingleDefault | У владельца не более одной ссылки IsDefault = true | partial unique index + проверка в getOrCreateDefaultReferralLink (upsert-семантика: существующая возвращается, новая создаётся атомарно) | конфликт index → повторное чтение (идемпотентный retry) |
| INV-REF-L3 UrlFormat | Slug соответствует ^[a-z0-9][a-z0-9-]{2,63}$ после нормализации к lowercase; зарезервированные slug-и (admin, api, www) запрещены | проверка входа createReferralLink/updateReferralLink | ValidationError |
| INV-REF-L4 OwnerOnlyMutation | Изменять/удалять ссылку может только владелец (actor.UserId == OwnerUserId); админ — через admin-API (ADR-0053) | guard операций update/delete | REFERRAL_LINK_NOT_FOUND (без раскрытия чужой ссылки) |
| INV-REF-L5 DefaultUndeletable | Ссылку IsDefault = true удалить нельзя (на неё завязан шаринг фронта по умолчанию) | guard в deleteReferralLink | REFERRAL_LINK_DEFAULT_UNDELETABLE |
| INV-REF-L6 ChannelImmutableUrl | Смена Url не рвёт исторические привязки: журнал и связи ссылаются по Id, а не по slug; slug из cookie резолвится в Id в момент привязки (см. attach-rules.md, edge case «ссылка переименована») | конструкция модели (FK по Id) | — |
Active = DeletedAt == null; Deleted = soft-delete. Восстановление удалённой ссылки не
предусмотрено (создаётся новая; slug освобождается — unique index partial).
Валидация и конкурентность записи
Правила проверяются на входе операций создания/изменения (createReferralLink, updateReferralLink,
getOrCreateDefaultReferralLink); для каждого поля — исход при нарушении:
Url— нормализуется к lowercase, проверяется по INV-REF-L3 (^[a-z0-9][a-z0-9-]{2,63}$, зарезервированныеadmin/api/wwwзапрещены) → нарушениеValidationError. Занятость среди неудалённых (INV-REF-L1) →REFERRAL_LINK_URL_TAKEN. Гонка двух конкурентных create/rename на один slug разрешается partial-uniqueux_referral_links_url: проигравшая операция получает тот жеREFERRAL_LINK_URL_TAKEN(в БД не окажется двух живых ссылок с одним slug).Description— trim; пустая строка сохраняется какnull; длина > 256 →ValidationError.Channel— на записи допустимо только известное значение enumReferralChannel→ иначеValidationError. FallbackOtherотносится только к чтению (после additive-расширения enum), не к записи.IsDefault— не задаётся напрямую в create/update: единственный источникtrue—getOrCreateDefaultReferralLink(INV-REF-L2, upsert-семантика). Ручного «переключить default» нет.VisitsCount— монотонно неубывающий: инкремент атомарен и независим от процесса привязки (счёт переходов не входит в транзакцию создания связи); не сбрасывается и не уменьшается, в т.ч. при soft-delete (переход по удалённой ссылке уже не инкрементирует — условиеdeleted_at is null).- Право и владение — изменять/удалять может только владелец (INV-REF-L4); чужая ссылка неотличима от
несуществующей →
REFERRAL_LINK_NOT_FOUND. Удаление default запрещено (INV-REF-L5) →REFERRAL_LINK_DEFAULT_UNDELETABLE.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
ReferralLinkCreatedEvent | доменное | Создание (createReferralLink, ветка создания getOrCreateDefaultReferralLink) | Id, OwnerUserId, Url, Channel, IsDefault |
ReferralLinkUpdatedEvent | доменное | updateReferralLink (смена Url и/или Description) | Id, старый/новый Url |
ReferralLinkDeletedEvent | доменное | Soft-delete | Id, OwnerUserId |
Интеграционные Kafka-события у ссылки отсутствуют: другим контекстам сама ссылка не нужна — им нужны
факты привязки (referral-bond.md → ReferralBondCreatedKafkaEvent,
attach-rules.md → ReferralLeadAttachRequestedKafkaEvent).
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK (id); ux_referral_links_url unique btree where deleted_at is null; ux_referral_links_owner_default unique btree (owner_user_id) where is_default and deleted_at is null; ix_referral_links_owner_user_id btree; ix_referral_links_tenant_id btree where not null; ix_referral_links_channel btree; ix_referral_links_created_at btree |
| Партиционирование / retention | none; ссылки — бессрочно (soft-delete): на них ссылаются журнал и связи для выплат |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую); VisitsCount — атомарный инкремент без блокировки |
| Permissions / PII | Сама ссылка PII не содержит. Self-операции — referral.links.manage-self / referral.links.view-self; admin-журнал (владелец: ownerName, ownerPhoneNumber из реплики, телефон маскируется в логах +7***1234) — referral.registrations.view-all (каталог permissions — permission.md) |
| Audit | actor-stamping + доменные события создания/изменения/удаления |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Правила реферальной привязки (attach-rules) — дом доменных правил, Entity / Aggregate: ReferralBondEntity, Entity / Aggregate: ReferralRegistrationEntity
- API (18): DELETE /api/investors/me/referral-links/{id} — Удалить реферальную ссылку (deleteReferralLink), 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/referral-links — Список моих реферальных ссылок (getMyReferralLinks), GET /api/investors/me/referral-links/{id}/registrations — Обезличенный журнал одной ссылки (getLinkRegistrations), GET /api/investors/me/referral-links/lookup — Пагинированный lookup своих ссылок, GET /api/investors/me/referral-links/lookup/{id} — Резолв реф-ссылки по id, GET /api/investors/me/referrer — свой реферер пользователя, GET /api/investors/referral-links/{url}/validate — Публичная валидация slug + счётчик переходов, GET /api/v1/partner/stats — Партнёрская сводка по каналам, GET /api/v1/referral-links/{referralLinkId}/stats — Статистика одной реферальной ссылки (+6)
- Use Cases (3): UC-REF-001. Создание реферальной ссылки с каналом и шаринг, UC-REF-002. Аноним открыл реферальную ссылку (cookie 30 дней, validateReferralLink), UC-REF-003. Регистрация по реферальной ссылке → связь + лид (правило R1)
- События (2): Событие «Запрошена карточка лида»: pin.referral.lead-attach-requested, Event Contract: pin.referral.bond-created (ReferralBondCreatedKafkaEvent)
- Консюмеры (2): Consumer: ReferralUserSignedInConsumer (pin.identity.sign-ins.v1), Consumer: ReferralUserUpsertedConsumer (pin.identity.users.v1)
- registries (1): Реестр сущностей PIN
- index.md (1): Referral Service
Связанные документы
- referral-registration.md, referral-bond.md, attach-rules.md, README.md.
- Операции:
referral/api/(getOrCreateDefaultReferralLink, createReferralLink, updateReferralLink, deleteReferralLink, getMyReferralLinks, lookupReferralLinkById, lookupReferralLinks, validateReferralLink + admin) — реестр prototype-mapping.md. - Фиксация slug при регистрации/входе —
identity: sign-up-or-sign-in.md, user.md (ReferrerUrl). - ADR-0052 (tenancy), ADR-0056 (simplicity-first).