Skip to content

ТЗ — Реферальная программа (cms/referral)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: notal (notal/src/app/Services/) Статус: ТЗ к разработке

Назначение и возможности

Реферальные коды и ссылки для привлечения новых пользователей, атрибуция приглашённого к пригласившему и начисление вознаграждения по целевому событию коммерции. Вознаграждение проводится балансовыми транзакциями через cms/loyalty, с антифрод-проверками перед начислением.

  • Персональный реферальный код/ссылка на пользователя
  • Атрибуция приглашённого: cookie-переход по ссылке → привязка при регистрации
  • Начисление вознаграждения по событию (регистрация, первый заказ — слушает события коммерции)
  • Начисление через балансовые транзакции cms/loyalty (append-only, без прямого изменения баланса)
  • Антифрод-крючья: лимит начислений на пригласившего, проверка на самореферал, задержка начисления до подтверждения заказа
  • Отчёт по рефералам: приглашено / активировано / начислено

Зависимости и выключение

requires: cms/loyalty · слушает: события коммерции (регистрация, заказ)

Поведение при выключении: реферальные ссылки перестают атрибутироваться, новые начисления не создаются — уже начисленные бонусы в cms/loyalty не аннулируются, регистрация и оформление заказов работают без изменений.

cms/loyalty — жёсткая зависимость, не «suggests»: начисление вознаграждения не существует без append-only журнала лояльности, деградации «начисляем без записи» нет. Если cms/loyalty не установлен или выключен, enable модуля referral проваливает self-test (health-чек проверяет доступность сервиса cms/loyalty) и модуль остаётся в состоянии installed с понятной ошибкой «требуется cms/loyalty» — это осознанное решение по каналу 4 (обмен данными), не баг жизненного цикла.

Модель данных

ТаблицаКлючевые поляПримечание
cms_referral_codesid, user_id, code, is_active, external_id (nullable, unique)персональный реферальный код
cms_referral_visitsid, code_id, visitor_token, device_fingerprint_hash, ip_hash, visited_atпереход по реферальной ссылке до регистрации
cms_referral_attributionsid, code_id, referred_user_id, attributed_at, external_id (nullable, unique)привязка приглашённого к пригласившему
cms_referral_rewardsid, attribution_id, trigger_event, bonus_transaction_id, status, lock_version, external_id (nullable, unique)начисление вознаграждения, ссылка на транзакцию лояльности

FK user_id, code_id, attribution_id, bonus_transaction_idconstrained() + index(); status/trigger_event — PHP Enum; уникальный индекс code; уникальный индекс (code_id, referred_user_id) в cms_referral_attributions от повторной атрибуции; уникальный индекс (attribution_id, trigger_event) в cms_referral_rewards — ключ идемпотентности начисления (не время, см. «Крайние случаи»); lock_version — optimistic lock на cms_referral_rewards (единственная сущность модуля, редактируемая в админке — void начисления). external_id — только для legacy-импорта (§16 стандарта, см. «Донорский код»), у нативных записей всегда null.

ПДн-паспорт. Хранит: user_id (ссылка на пользователя ядра, без дублирования ПДн), visitor_token (анонимный, генерируется сервером, не содержит и не позволяет восстановить ПДн). device_fingerprint_hash/ip_hash — соленые хеши, не сырой IP/UA; нужны только антифрод-эвристике в окне self_referral_window_hours и удаляются вместе с visit-записью по ретеншну. Срок хранения: cms_referral_visits без атрибуции — referral.visit_retention_days (дефолт 90 дней), затем чистятся плановой job (purge-expired); cms_referral_attributions/cms_referral_rewards — бессрочно (append-only, финансовая история). Участие в 152-ФЗ: «выгрузить всё по субъекту» и «забыть по запросу» — хуки ядра над cms_referral_codes.user_id/ cms_referral_attributions.referred_user_id; при «забыть» также обезличиваются visitor_token/device_fingerprint_hash/ip_hash связанных с пользователем visit-строк (сами денежные записи cms_referral_rewards не удаляются — append-only, обезличивается только связь с личностью, не факт начисления).

Входные и выходные данные

Whitelist-принцип (§11 стандарта): всё, что не перечислено во «входах», модуль обязан отвергать — неизвестные query-параметры, поля вебхуков, лишние ключи импорта.

Входы

ИсточникПоляЧем валидируется
GET /api/v1/referral/my-codeнет входных полей, только authSanctum-токен (границы ядра)
Переход по ссылке ?ref=CODE / редирект /r/{code}code (query/route)формат кода — whitelist-regex через ReDoS-валидатор ядра; резолв только среди is_active=true
Событие ядра UserRegistereduser_id, cookie атрибуции (visitor_token) текущего запросасервис атрибуции проверяет непросроченный visitor_token → активный code_id; событие само по себе не несёт внешнего входа (издано ядром)
Событие коммерции OrderCompleted/OrderPaid (по reward_trigger)order_id, user_id, total_amountслушатель тонкий — валидирует факт по данным события, не по вводу пользователя, кладёт job начисления
Событие коммерции OrderCancelled/OrderRefundedorder_idсервис сторно ищет reward по order_id через цепочку attribution → reward
POST /api/v1/admin/referral/rewards/{id}/voidid (route), reason (body, обязателен)FormRequest whitelist, permission referral.manage, Idempotency-Key, lock_version (409 при конфликте)
cms:referral:import-legacy --source=<профиль>построчно по профилю: code, referrer_external_id, referred_external_id, reward_status, reward_amountсхема профиля импортёра + whitelist полей, --dry-run до применения

Выходы

ПотребительДанныеФормат
Личный кабинет (виджет «пригласи друга»)код/ссылка, список приглашённых со статусамиGET /api/v1/referral/my-code, конверт {data, meta}
cms/loyalty (requires, канал 4)user_id, сумма, ключ идемпотентности (attribution_id + trigger_event)прямой вызов публичного сервиса начисления/сторно, не запись в таблицы
Filament-отчётприглашено / активировано / начислено, антифрод-флагиkeyset-таблица в админке + CSV-экспорт
Шина событийReferralAttributed, ReferralRewardGranted и др. (см. «События и обмен»)канал 1, payload — см. таблицу событий
cms/healthметрики отставания очереди referral, всплеск suspected_fraudhealth-чек / Pulse

Настройки (группа referral)

КлючТипДефолтaffectsPageCacheОписание
referral.enabledbooltrueнетВключение реферальной программы
referral.reward_triggerstringfirst_orderнетСобытие начисления: registration или first_order
referral.reward_bonus_amountint100нетРазмер бонусного начисления пригласившему
referral.max_rewards_per_referrer_per_dayint20нетАнтифрод-лимит начислений в сутки на пригласившего
referral.accrual_kill_switchboolfalseнетKill-switch: аварийная остановка начислений без выключения модуля — атрибуция и антифрод-фиксация продолжают работать
referral.self_referral_window_hoursint24нетОкно антифрод-эвристики самореферала: совпадение device/IP в пределах окна
referral.max_attributions_per_device_per_dayint3нетЛимит новых атрибуций с одного устройства/IP в сутки — превышение помечает suspected_fraud, не жёсткий блок (эвристика с ложноположительными)
referral.attribution_cookie_ttl_daysint30нетСрок жизни cookie атрибуции до регистрации приглашённого
referral.visit_retention_daysint90нетРетеншн неатрибутированных cms_referral_visits (152-ФЗ, см. ПДн-паспорт)

Достижение max_rewards_per_referrer_per_day/max_attributions_per_device_per_day — понятная метка статуса (limited/suspected_fraud), не 500 и не тихий пропуск; лимиты видны в Filament-отчёте с причиной.

API

МетодПутьДоступНазначение
GET/api/v1/referral/my-codeauthРеферальный код/ссылка и статус приглашённых текущего пользователя
GET/api/v1/admin/referral/reportadmin (referral.view)Отчёт приглашено/активировано/начислено (keyset-пагинация)
POST/api/v1/admin/referral/rewards/{id}/voidadmin (referral.manage)Отмена (сторно) начисления при подтверждённом фроде — Idempotency-Key, lock_version → 409 при конфликте
GET/r/{code} (routes/web, вне /api/v1)публич., без rate-limit кроме общего anti-floodКороткий редирект: ставит cookie атрибуции и 302 на страницу, HTML-ответ страницы не участвует в этом запросе — обходит ловушку ?ref= в page-cache (см. «Крайние случаи»)

?ref=CODE в query-параметре обычной страницы остаётся поддержанным как fallback (шаренные ссылки без /r/), но основной канал для новых ссылок — /r/{code}.

Компоненты

Виджеты: блок «пригласи друга» с кодом/ссылкой и статусами приглашённых в кабинете — собственный JS/CSS ≤ 15 КБ gzip без сторонних библиотек, копирование Clipboard API с fallback на execCommand, шеринг Web Share API с graceful-скрытием кнопки при отсутствии поддержки, резерв высоты без CLS, кнопка «скопировать» — фокусируемый button с label, доступный с клавиатуры.

Filament: отчёт по рефералам с фильтром по периоду/статусу, список начислений с антифрод-флагами и массовыми действиями (approve/void пачкой), карточка кода с историей визитов. Команды: cms:referral:process-rewards --json, cms:referral:recount --json (агрегаты отчёта), cms:referral:repair --json (сверка rewardscms/loyalty), cms:referral:purge-expired --json (ретеншн visits), cms:referral:import-legacy --source=<профиль> --dry-run (§16, см. «Донорский код»).

Демо-контент: сидер создаёт демо-пользователя с кодом, visits и attributions в разных статусах (pending/granted/suspected_fraud) — блок виден в /_gallery и playground без ручного ввода данных.

События и обмен

СобытиеКогдаPayload
ReferralAttributedприглашённый привязан к кодуcode_id, referred_user_id
ReferralRewardGrantedвознаграждение начисленоattribution_id, bonus_transaction_id

Слушает: события регистрации пользователя и оформления первого заказа. Начисление вознаграждения проводится строго вызовом публичного сервиса cms/loyalty (requires), не прямой записью в его таблицы.

Таблица взаимодействий (пять каналов — обмен данными):

Сущность/модульКаналНаправлениеЧто происходит
cms/loyaltyrequires-сервис (канал 4)referral → loyaltyначисление/сторно баллов через публичный сервис (accrue/reverse), append-only журнал, не прямая запись
ядро: UserRegisteredсобытие (канал 1)ядро → referralпривязка visitor_token к referred_user_id, создание cms_referral_attributions
коммерция: OrderCompleted/OrderPaidсобытие (канал 1)коммерция → referralтриггер job начисления по reward_trigger
коммерция: OrderCancelled/OrderRefundedсобытие (канал 1)коммерция → referralтриггер сторно ранее начисленного reward
очередь referralочередь (канал 5)referral → referral (async)process-rewards: применение антифрод-лимитов, вызов cms/loyalty
cms/healthhealth-чек модуля (регистрация в манифесте)referral → cms/healthотставание очереди, всплеск suspected_fraud, доступность cms/loyalty
Filament / admin APIREST, внешний каналadmin → referralотчёт, массовые действия, void начисления
личный кабинет (зона 3)REST /api/v1/referral, внешний каналreferral → кабинеткод/ссылка, статусы приглашённых

Фоновая работа

Очередь referral: process-rewards — обработка начислений с задержкой до подтверждения заказа и применением антифрод-лимитов, запускается по событию и добивается по расписанию через ScheduleRegistrar. Джобы идемпотентны — повторная обработка не начисляет дважды: ключ идемпотентности — уникальный индекс (attribution_id, trigger_event) на cms_referral_rewards, джоба перед начислением делает firstOrCreate по этой паре и завершает работу без повторного вызова cms/loyalty, если запись уже существует (не по времени/created_at — ретрай после сбоя может прийти спустя часы).

Эксплуатация (ранбук). Метрики: referral.rewards_pending, referral.suspected_fraud_rate (доля за сутки), отставание очереди referral (Pulse). Алерты: отставание очереди > 15 мин, доля suspected_fraud > 20% начислений за день, cms/loyalty недоступен N подряд ретраев job.

СимптомЧто проверитьЧем чинить
Всплеск заблокированных начисленийcms:referral:report --json — разбивка по причине блокировки, концентрация device_fingerprint_hashFilament: массовый approve легитимных из списка suspected_fraud; при системном false-positive — увеличить max_attributions_per_device_per_day
Отставание обработки начисленийHorizon/queue:monitor referralcms:referral:process-rewards --json вручную, масштабировать воркеров очереди
Reward в granted, но нет транзакции в cms/loyalty (сбой между начислением и подтверждением)cms:referral:repair --dry-run --json — отчёт расхожденийcms:referral:repair --json идемпотентно доводит транзакцию до конца или помечает failed
cms/loyalty выключен managed-режимомcms:doctor --jsonвернуть cms/loyalty в enabled, затем cms:referral:process-rewards --json добивает очередь
Рост cms_referral_visits (диск)cms:referral:report --json — объём таблицыcms:referral:purge-expired --json — ретеншн неатрибутированных visits старше visit_retention_days

Бэкап/рестор. В бэкап — все 4 таблицы модуля целиком (cms_referral_rewards — финансово значимая история, обязательна). После рестора пересоздаётся командой: агрегаты отчёта — cms:referral:recount --json; сверка rewards↔loyalty — cms:referral:repair --json. Оба идемпотентны, без даунтайма, рестор без их прогона не считается завершённым.

Производительность и кеш

Ожидаемые объёмы: до нескольких тысяч активных кодов на сайт, десятки–сотни тысяч cms_referral_visits/мес на среднем трафике, attributions/rewards на порядок меньше (единицы процентов конверсии перехода в регистрацию). Горячие пути и бюджет запросов: GET /api/v1/referral/my-code — 1 запрос (код по user_id, индекс); GET /r/{code} — 1 запрос резолва кода + 1 insert visit, без записи в БД на каждый повторный визит того же visitor_token в течение TTL cookie (короткий кеш резолва кода, TTL секунды-минуты, инвалидация по событию изменения is_active).

Критичные индексы: unique code; visitor_token на visits/attributions (резолв на горячем пути); unique (code_id, referred_user_id) и (attribution_id, trigger_event) — см. «Модель данных»; BRIN по visited_at (append-heavy журнал); частичный индекс status = 'pending' на cms_referral_rewards под выборку джобой process-rewards.

Собственных тегов page-cache нет: блок «пригласи друга» и статус приглашённых персональны, вне общего page-cache (аналогично cms/loyalty). GET /r/{code} — не HTML-страница, вне page-cache в принципе; ?ref=CODE на обычной странице не должен превращать саму страницу в персональную (её HTML одинаков для всех) — см. «Крайние случаи».

Безопасность

Границы входа — только через 5 стандартизированных границ ядра: код в query/route — whitelist-regex через ReDoS-валидатор, не ad-hoc проверка в контроллере; visitor_token генерируется и подписывается сервером, не принимается от клиента как значение (исключает подделку атрибуции чужому коду); void-эндпоинт — FormRequest-whitelist. Rate-limit: GET /r/{code}/?ref= — per-IP (защита от перебора кодов и флуда visits от бота); admin-эндпоинты — под стандартным rate-limit ядра для /admin.

Самореферал: два уровня защиты. (1) Жёсткая проверка — код совпадает с user_id пригласившего, блокируется безусловно на уровне сервиса атрибуции. (2) Эвристика — несколько «новых» пользователей регистрируются по одному коду с одного device_fingerprint_hash/ip_hash в окне self_referral_window_hours: не жёсткий блок, а метка suspected_fraud с ручным разбором в Filament — эвристика даёт ложноположительные (общий Wi-Fi семьи/офиса, NAT), блокировать начисление без возможности пересмотра администратором запрещено.

Отмена начисления — только под referral.manage, обязательна причина (reason), сторно-операцией через публичный сервис cms/loyalty (не прямая запись). Все ПДн-поля — см. «Модель данных» (ПДн-паспорт); в логи и Pulse-метрики попадают только обезличенные идентификаторы, не сырые IP/UA.

Матрица ролей (permission × роль, дефолт):

PermissionАдминМенеджерРедакторStudio
referral.view (отчёт, список начислений)
referral.manage (void начисления, массовые действия)

Void — финансово значимая необратимая операция (сторно append-only записи), поэтому только повышенные роли (§11 стандарта); менеджер видит отчёт, но не может списывать/ отменять начисления.

UX-требования

Админ. Пустое состояние отчёта («ещё нет приглашённых/начислений» — подсказка поделиться ссылкой из демо-виджета). Массовые действия на списке начислений: approve/void пачкой отмеченных suspected_fraud-записей одним подтверждением. Человеческие ошибки: «нельзя отменить начисление — баллы уже списаны пользователем» вместо голого 422; «cms/loyalty недоступен — начисление отложено» вместо 500. Подтверждение необратимых операций: void — модальное окно с обязательным полем причины и явным предупреждением «отменить нельзя, будет создана сторно-запись».

Посетитель. Кабинет с кодом/ссылкой: копирование в один клик с визуальным подтверждением, шеринг через Web Share API/кнопки соцсетей, статус каждого приглашённого — бейджи «ожидает регистрации» / «зарегистрирован» / «принёс вознаграждение»; пустое состояние («у вас пока нет приглашённых») с CTA поделиться ссылкой — см. фронтенд-бюджет и a11y в «Компоненты».

Крайние случаи и типовые баги

  • Двойной сабмит формы регистрации по реферальной ссылке → атрибуция идемпотентна: unique (code_id, referred_user_id) — повторный сабмит не создаёт вторую привязку, второй запрос получает уже существующую атрибуцию, не ошибку.
  • Ретрай job начисления после сбоя между начислением и подтверждением (сеть/воркер упал после вызова cms/loyalty, но до фиксации bonus_transaction_id) → идемпотентность по уникальному ключу (attribution_id, trigger_event), не по времени: повторный запуск джобы видит существующую reward-запись и не создаёт вторую транзакцию.
  • Заказ, за который начислено вознаграждение, отменён/возвращён → сторно связанной cms_referral_rewards (status = reversed) и сторно связанной cms_loyalty_transactions через публичный сервис cms/loyalty — не удаление записей (append-only обеих сторон).
  • Выключение модуля между атрибуцией и постановкой job начисления в очередь → уже поставленные в очередь job'ы донабатываются (очередь не чистится при disable); новые атрибуции/job'ы не создаются, пока модуль выключен; при повторном включении накопленные pending-rewards обрабатываются штатно.
  • cms/loyalty отсутствует/выключен при попытке enable referral → self-test проваливается, referral остаётся installed с сообщением «требуется cms/loyalty» — осознанное решение (см. «Зависимости и выключение»), не «деградация без начислений».
  • cms/loyalty временно недоступен в рантайме (сбой сервиса/очереди) → job начисления ретраится с backoff по правилам ядра, после исчерпания — failed job + алерт cms/health; reward.status = pending, отчёт показывает как «в обработке», не теряется.
  • Пустые/огромные объёмы данных → 0 визитов — пустое состояние отчёта без ошибок; всплеск visits от бота (тысячи с одного IP) — max_attributions_per_device_per_day ограничивает атрибуцию, BRIN-индекс и visit_retention_days держат таблицу под контролем.
  • Ловушка измерения site_id/city_id: реферальный код принадлежит пользователю глобально (не привязан к сайту), но cms_referral_visits/cms_referral_attributions несут nullable site_id/city_id из RequestContext для мультисайт-инсталляций — отчёт сегментируется по сайту там, где он есть, и работает без правки кода там, где его нет (правило измерений §4 стандарта).
  • Противоречивая комбинация настроек: accrual_kill_switch = true включён, пока в очереди уже лежат job'ы начисления → job проверяет kill-switch в момент обработки, не только при постановке в очередь: находит его включённым — помечает reward.status = blocked_by_kill_switch (не молча дропает, не начисляет), атрибуция при этом продолжает фиксироваться.
  • Конкурентное редактирование: два админа одновременно нажимают void на одном начислении → lock_version (optimistic lock) на cms_referral_rewards, второй запрос получает 409 и понятное сообщение «начисление уже изменено, обновите список», не «последний победил» молча.
  • ?ref=CODE в query-параметре страницы vs page-cache ядра (принятое решение). Пайплайн ядра (запрос → page-cache (hit → ответ без БД) → RequestContext → …, core) при хите кеша не доходит до middleware приложения — модуль не может прочитать ?ref= на закешированном хите, а точка «side-effect по query-параметру раньше page-cache» в контрактах ядра не описана. Разрешение в этом ТЗ: основной канал — не query-параметр на обычной странице, а отдельный некешируемый роут GET /r/{code} (см. «API»): вне page-cache, ставит cookie и 302-редиректит на страницу, чей HTML не меняется и кешируется как обычно. ?ref= остаётся fallback для уже расшаренных ссылок без гарантии срабатывания на кеш-хите — задокументированное ограничение. Вопрос «нужен ли ядру пре-кеш хук для whitelist query-параметров» вне полномочий модуля, дублируется в открытые вопросы.

Донорский код

Что взятьПуть
Сервисы реферальной атрибуции и начисления вознагражденийnotal/src/app/Services/

Миграция legacy-данных. cms:referral:import-legacy --source=<профиль> — маппинг донорских таблиц (коды, история переходов, начисления notal) на cms_referral_codes/cms_referral_attributions/cms_referral_rewards. Идемпотентность — по nullable unique external_id на всех трёх таблицах: повторный прогон обновляет существующие по external_id, не дублирует. --dry-run — отчёт расхождений (сколько будет создано/обновлено/пропущено) без записи. Построчные ошибки — скачиваемый отчёт, молчаливый пропуск запрещён. Прогон на копии донорских данных — часть приёмки модуля (§16 стандарта, фаза 4).

Тесты и приёмка

  • [ ] Контрактный тест: переход по /r/{code} → регистрация → начисление по reward_trigger
  • [ ] Самореферал по user_id блокируется безусловно; эвристика device/IP помечает suspected_fraud, не блокирует жёстко (ложноположительные разбираются вручную)
  • [ ] Антифрод-лимиты max_rewards_per_referrer_per_day/max_attributions_per_device_per_day не превышаются
  • [ ] Начисление — строго через cms/loyalty, идемпотентно по (attribution_id, trigger_event): повторный запуск job после сбоя не создаёт вторую транзакцию
  • [ ] Отмена/возврат заказа создаёт сторно cms_referral_rewards и связанной транзакции cms/loyalty, без удаления записей
  • [ ] При выключении модуля новые атрибуции не создаются, ранее начисленные бонусы сохраняются
  • [ ] enable без установленного/включённого cms/loyalty проваливает self-test, модуль остаётся installed
  • [ ] accrual_kill_switch останавливает начисления без остановки атрибуции; проверка — в момент обработки job, не при постановке в очередь
  • [ ] Конкурентный void одного начисления двумя админами → второй запрос получает 409 (lock_version)
  • [ ] ?ref=CODE не делает HTML-страницу персональной/не ломает page-cache; /r/{code} не кешируется и корректно ставит cookie
  • [ ] Whitelist входов/выходов: неизвестный query-параметр/поле импорта отвергается, не игнорируется
  • [ ] ПДн-хуки «выгрузить всё»/«забыть по субъекту» отрабатывают на кодах/атрибуциях/visit-записях пользователя
  • [ ] Права referral.view/referral.manage разграничивают отчёт и отмену начислений по матрице ролей
  • [ ] cms:referral:import-legacy --dry-run идемпотентен, отчёт расхождений корректен на копии донорских данных
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут API; тестовая БД только referral_test, migrate:fresh запрещён

Внутренняя база знаний студии