Перейти к основному содержимому

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
KindAggregateRoot
Source of truthreferral
Таблицаreferral.referral_links
Tenant isolationTenantId = CompanyId владельца на момент создания (ADR-0052): у инвестора null; изоляция — record-level по OwnerUserId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор ссылки (ReferralLinkListItem.id).
OwnerUserIdGuidДаindex ix_referral_links_owner_user_id; логическая ссылка на identity.users(id) (реплика ReferralUserReplica); жёсткого cross-context FK нетapp (actor)Владелец ссылки (реферер) — любой пользователь платформы.
TenantIdGuid?Нетindex ix_referral_links_tenant_id where not nullapp (из ReferralUserReplica.CompanyId владельца)CompanyId владельца на момент создания; null — инвестор (ADR-0052). Не меняется при смене компании владельца (историческая принадлежность).
UrlstringДаvarchar(64); unique ux_referral_links_url where deleted_at is null; формат ^[a-z0-9][a-z0-9-]{2,63}$, хранится в lowercaseapp (createReferralLink.url или генерация)Slug ссылки — глобально уникальный адрес. Если slug не задан — генерируется случайный (10 символов алфавита [a-z0-9], CSPRNG, повтор при коллизии до 3 раз; исчерпание 3 попыток при адресном пространстве 36^10 практически недостижимо — при нём операция завершается retriable-ошибкой сервера и повторяется клиентом, пользователю slug подставлять не нужно). Полный URL собирает фронт; бэк хранит только slug.
Descriptionstring?Нетvarchar(256)app (create/updateReferralLink.description)Описание канала/назначения ссылки (maxLength 256).
IsDefaultboolДаdefault false; partial unique ux_referral_links_owner_default (owner_user_id) where is_default and deleted_at is nullapp (getOrCreateDefaultReferralLink)Ссылка «по умолчанию» владельца. Ровно одна на владельца (INV-REF-L2).
ChannelReferralChannelДахранится как varchar(32), index ix_referral_links_channel, default Directapp (createReferralLink.channel)Канал распространения ссылки для метрик per-канал.
VisitsCountlongДа>= 0, default 0app (validateReferralLink инкрементирует)Счётчик открытий ссылки (переходов): каждый успешный validateReferralLink выполняет атомарный инкремент без загрузки агрегата (ADR-0056), условно по deleted_at is null (переход по soft-deleted ссылке счётчик не двигает — валидация вернёт isValid = false). Дедупликации по посетителю нет — счётчик отражает грязный вал переходов (повторные открытия/боты возможны; контроль накрутки — метрики/алерты, не бизнес-правило). База конверсии «переход → регистрация → связь».
CreatedAtDateTimeOffsetДаindex ix_referral_links_created_atappМомент создания (createdAt).
CreatedByUserIdGuid?Нетapp= OwnerUserId при самостоятельном создании; id администратора, если ссылка создана админ-панелью (ADR-0053).
UpdatedAtDateTimeOffsetДаappМомент последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?Нетapp (deleteReferralLink)Soft-delete. Удалённая ссылка не проходит validateReferralLink и не участвует в привязке; журнал и связи по ней остаются.
DeletedByUserIdGuid?НетappКто удалил.

Enum ReferralChannel

ЗначениеОписание
DirectПрямая передача ссылки (default).
TelegramПубликации/чаты Telegram.
WhatsAppРассылки/чаты WhatsApp.
VkПубликации VK.
InstagramПубликации Instagram.
YouTubeОписания/комментарии YouTube.
EmailEmail-рассылки.
SmsСМС-рассылки.
QrOfflineQR-коды на офлайн-носителях (буклеты, стенды шоурума).
BlogЛичный блог/сайт владельца.
PartnerПартнёрские площадки.
OtherПрочее (описание канала — в Description).

Unknown enum при чтении (после additive-расширения) — fallback Other + warning.

Производные (вычисляемые) показатели — не колонки

ПоказательКак вычисляетсяГде используется
registrationsCountCOUNT(*) по referral.referral_registrations c referral_link_id = Id (индекс ix_referral_registrations_referral_link_id)ReferralLinkListItem.registrationsCount, AdminReferralLinkEntry.registrationsCount
bondsCountCOUNT(*) по 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
ReferralRegistrationEntityreference (обратная)referral_registrations.referral_link_id → referral_links.id (FK)restrict (журнал живёт после soft-delete ссылки)
ReferralBondEntityreference (обратная)referral_bonds.referral_link_id → referral_links.id (FK)restrict
ReferralUserReplica (владелец)replicaOwnerUserId

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-REF-L1 UniqueUrlUrl глобально уникален среди не удалённых (без учёта регистра — хранение lowercase)unique partial index + проверка в createReferralLink/updateReferralLinkREFERRAL_LINK_URL_TAKEN
INV-REF-L2 SingleDefaultУ владельца не более одной ссылки IsDefault = truepartial unique index + проверка в getOrCreateDefaultReferralLink (upsert-семантика: существующая возвращается, новая создаётся атомарно)конфликт index → повторное чтение (идемпотентный retry)
INV-REF-L3 UrlFormatSlug соответствует ^[a-z0-9][a-z0-9-]{2,63}$ после нормализации к lowercase; зарезервированные slug-и (admin, api, www) запрещеныпроверка входа createReferralLink/updateReferralLinkValidationError
INV-REF-L4 OwnerOnlyMutationИзменять/удалять ссылку может только владелец (actor.UserId == OwnerUserId); админ — через admin-API (ADR-0053)guard операций update/deleteREFERRAL_LINK_NOT_FOUND (без раскрытия чужой ссылки)
INV-REF-L5 DefaultUndeletableСсылку IsDefault = true удалить нельзя (на неё завязан шаринг фронта по умолчанию)guard в deleteReferralLinkREFERRAL_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-unique ux_referral_links_url: проигравшая операция получает тот же REFERRAL_LINK_URL_TAKEN (в БД не окажется двух живых ссылок с одним slug).
  • Description — trim; пустая строка сохраняется как null; длина > 256 → ValidationError.
  • Channel — на записи допустимо только известное значение enum ReferralChannel → иначе ValidationError. Fallback Other относится только к чтению (после additive-расширения enum), не к записи.
  • IsDefault — не задаётся напрямую в create/update: единственный источник truegetOrCreateDefaultReferralLink (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-deleteId, OwnerUserId

Интеграционные Kafka-события у ссылки отсутствуют: другим контекстам сама ссылка не нужна — им нужны факты привязки (referral-bond.mdReferralBondCreatedKafkaEvent, attach-rules.mdReferralLeadAttachRequestedKafkaEvent).

Индексы, хранение, безопасность

АспектЗначение
Индексы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
Партиционирование / retentionnone; ссылки — бессрочно (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)
Auditactor-stamping + доменные события создания/изменения/удаления

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы