Тема
ТЗ — Реферальная программа (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_codes | id, user_id, code, is_active, external_id (nullable, unique) | персональный реферальный код |
cms_referral_visits | id, code_id, visitor_token, device_fingerprint_hash, ip_hash, visited_at | переход по реферальной ссылке до регистрации |
cms_referral_attributions | id, code_id, referred_user_id, attributed_at, external_id (nullable, unique) | привязка приглашённого к пригласившему |
cms_referral_rewards | id, attribution_id, trigger_event, bonus_transaction_id, status, lock_version, external_id (nullable, unique) | начисление вознаграждения, ссылка на транзакцию лояльности |
FK user_id, code_id, attribution_id, bonus_transaction_id — constrained() + 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 | нет входных полей, только auth | Sanctum-токен (границы ядра) |
Переход по ссылке ?ref=CODE / редирект /r/{code} | code (query/route) | формат кода — whitelist-regex через ReDoS-валидатор ядра; резолв только среди is_active=true |
Событие ядра UserRegistered | user_id, cookie атрибуции (visitor_token) текущего запроса | сервис атрибуции проверяет непросроченный visitor_token → активный code_id; событие само по себе не несёт внешнего входа (издано ядром) |
Событие коммерции OrderCompleted/OrderPaid (по reward_trigger) | order_id, user_id, total_amount | слушатель тонкий — валидирует факт по данным события, не по вводу пользователя, кладёт job начисления |
Событие коммерции OrderCancelled/OrderRefunded | order_id | сервис сторно ищет reward по order_id через цепочку attribution → reward |
POST /api/v1/admin/referral/rewards/{id}/void | id (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_fraud | health-чек / Pulse |
Настройки (группа referral)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
referral.enabled | bool | true | нет | Включение реферальной программы |
referral.reward_trigger | string | first_order | нет | Событие начисления: registration или first_order |
referral.reward_bonus_amount | int | 100 | нет | Размер бонусного начисления пригласившему |
referral.max_rewards_per_referrer_per_day | int | 20 | нет | Антифрод-лимит начислений в сутки на пригласившего |
referral.accrual_kill_switch | bool | false | нет | Kill-switch: аварийная остановка начислений без выключения модуля — атрибуция и антифрод-фиксация продолжают работать |
referral.self_referral_window_hours | int | 24 | нет | Окно антифрод-эвристики самореферала: совпадение device/IP в пределах окна |
referral.max_attributions_per_device_per_day | int | 3 | нет | Лимит новых атрибуций с одного устройства/IP в сутки — превышение помечает suspected_fraud, не жёсткий блок (эвристика с ложноположительными) |
referral.attribution_cookie_ttl_days | int | 30 | нет | Срок жизни cookie атрибуции до регистрации приглашённого |
referral.visit_retention_days | int | 90 | нет | Ретеншн неатрибутированных 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-code | auth | Реферальный код/ссылка и статус приглашённых текущего пользователя |
| GET | /api/v1/admin/referral/report | admin (referral.view) | Отчёт приглашено/активировано/начислено (keyset-пагинация) |
| POST | /api/v1/admin/referral/rewards/{id}/void | admin (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 (сверка rewards ↔ cms/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/loyalty | requires-сервис (канал 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/health | health-чек модуля (регистрация в манифесте) | referral → cms/health | отставание очереди, всплеск suspected_fraud, доступность cms/loyalty |
| Filament / admin API | REST, внешний канал | 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_hash | Filament: массовый approve легитимных из списка suspected_fraud; при системном false-positive — увеличить max_attributions_per_device_per_day |
| Отставание обработки начислений | Horizon/queue:monitor referral | cms: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отсутствует/выключен при попытке enablereferral→ 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несут nullablesite_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запрещён