# Visitor tracking

## Что это

Сквозной идентификатор посетителя (`visitor_uid`) — строка вида `visitor_<timestamp>_<rand>`, которую фронт генерирует при первом заходе, кладёт в `localStorage` под ключом `visitor_uid` и затем подкладывает в заказ. Используется для атрибуции «посетитель ↔ покупка» (поле `purchase.visitor_uid`).

## Где живёт

### Фронт

- `src/visitor/index.ts` — генерация и сохранение `visitor_uid` в `localStorage` (TTL 30 дней). При первом заходе шлёт `POST /api_v2/visitor/create`. **Если `visitor_uid` уже в localStorage — запрос на бэк не идёт.**
- `src/visitor/index.ts:54` подключается импортом `import '@components/visitor'` в **каждой теме**:
  - `themes/mebelmarket/src/index.ts:54`
  - `themes/mebelmag/src/index.ts:76`
  - `themes/freshshop/src/index.ts:93`
  - `themes/mebelmall/src/index.ts:78`
  - `themes/mebelbazar/src/index.ts:67`
  - `themes/mebelmart/src/index.ts:112`
  - `themes/cartzilla/src/index.ts:54`
  - `themes/hormebel/src/index.ts:67`
  - `themes/dommebeli/src/index.ts:98`
  - `themes/stolkomebeli/src/index.ts:52`
- `src/cart/utils.ts:13` `getVisitorUid()` — читает `visitor_uid` из `localStorage`.
- `src/cart/components/cart-widget.tsx:102` — при оформлении заказа добавляет `visitor_uid` в payload `POST /api/.../create-purchase`.

### Бэк

- `modules/api_v2/controllers/VisitorController.php` — CRUD по таблице `visitor` (создание, поиск, обновление, удаление, листинг, агрегаты по домену).
- `controllers/ApiController.php:831` (`actionCreateQuickPurchase`) и `:916` (`actionCreatePurchase`) — берут `$data->visitor_uid` из payload и кладут в `Purchase.visitor_uid`. Это **единственное** место, где значение реально нужно бизнесу.
- `models/Visitor.php` — ActiveRecord для таблицы `visitor`.
- `migrations/m251219_000000_create_visitor_table.php` — схема. Индексы: `PRIMARY (id)`, `UNIQUE (visitor_uid)`, `(ip_address)`, `(created_at)`.
- `migrations/m251220_184516_add_ref_uid_column_to_visitor_table.php` — добавляет `ref_uid` (реферальный uid).
- `migrations/m251219_120150_add_visitor_uid_column_to_purchase_table.php` — добавляет `purchase.visitor_uid` + индекс.

### Связь visitor ↔ purchase

**Foreign key между `purchase.visitor_uid` и `visitor.visitor_uid` отсутствует.** Это две независимые строковые колонки с обычными индексами. MySQL никак не enforce-ит, что значение в `purchase.visitor_uid` существует в `visitor`. Это значит: запись в `visitor` фактически **не нужна** для корректного оформления заказа — uid берётся из `localStorage` на фронте и попадает в `purchase` напрямую. Таблица `visitor` нужна **только если** где-то на бэке/в админке используется join `purchase ↔ visitor` ради метаданных (`user_agent`, `ip_address`, `screen_size`). Сейчас такой логики в коде нет.

## Текущий статус (2026-05-20): запись отключена

В `modules/api_v2/controllers/VisitorController.php` добавлен рубильник:

```php
const TRACKING_ENABLED = false;
```

Обе пишущие точки (`actionCreate`, `actionFindOrCreate`) проверяют этот флаг и возвращают `200 {ok: true, data: {visitor_uid}}` без записи в БД. Фронт спокойно обрабатывает такой ответ — `visitor_uid` всё равно остаётся в `localStorage` и продолжает попадать в `purchase.visitor_uid` при оформлении заказа.

### Почему отключили

На VPS `31.129.100.230` (см. `[[project_vps_deploy]]`) таблица `visitor` распухла до 1 786 469 строк, из них только **1 148 строк (≈0.06%) реально связаны с покупками**. Остальные 99.94% — это, по всей видимости, заходы ботов (краулеры без `localStorage` = каждый запрос = «новый посетитель»). Чистка батчевым `DELETE` упиралась в `Lock wait timeout exceeded` (см. `commands/CleanController.php::actionVisitorTable`), потому что каждая транзакция держала row + gap-локи в индексе `visitor_uid`, а параллельный live-трафик постоянно вставлял в этот же диапазон. Отключение записи решает проблему «в корне»: таблица перестаёт расти, чистка идёт в комфортном темпе без конкурентов за лок.

### Что не сломалось

- Оформление заказов — работает, `purchase.visitor_uid` пишется как раньше (из localStorage).
- Реферальная атрибуция (`ref_uid` через `?ref_uid=...`) — тоже работает, она опирается на `localStorage`, а не на таблицу `visitor`.
- Админка — не использовала данные из `visitor` нигде, кроме CRUD-эндпоинтов самого `VisitorController` (которые сейчас никем не вызываются из UI).

### Что временно «потерялось»

- Метаданные новых посетителей (`user_agent`, `ip_address`, `screen_size`) больше не сохраняются.
- `actionStatsByDomain` (`/api_v2/visitor/stats-by-domain`) — отчёт «сколько уникальных посетителей по доменам за сутки» перестанет показывать новые данные (старые остаются, но не пополняются).

## Планы

Принять решение **до конца Q2 2026**: оставляем функционал в каком-то виде или вырезаем целиком.

### Вариант A — оставить и сделать «правильно»

Если бизнесу нужна аналитика по UA/IP/домену, включаем запись обратно (`TRACKING_ENABLED = true`) + закрываем известные проблемы:

1. **Фильтр ботов в `actionCreate`** — `jaybizzle/crawler-detect` или ручная регулярка по `User-Agent` (`Googlebot|YandexBot|Bytespider|AhrefsBot|SemrushBot|PetalBot|...`). По прикидке режет 80–95% мусора.
2. **Буфер на Redis + cron каждые 5 минут.** `actionCreate` пишет в `LPUSH visitor:buffer JSON`, отдельная CLI-команда `visitor/flush-buffer` раз в 5 минут делает `INSERT IGNORE INTO visitor (...) VALUES (...), (...), ...` сотнями за раз. Batched INSERT в InnoDB на 1–2 порядка быстрее одиночных (один fsync на batch, один лок-эпизод). Требует Redis (сейчас в `docker-compose.yml` его нет, см. `docs/cache.md`).
3. **Rate-limit на IP.** Простой `Yii::$app->cache->get("visitor-rl-{ip}")` с TTL 60 c — один запрос в минуту на IP. Без Redis работает на FileCache.
4. **Уменьшить набор индексов.** Из `(ip_address)` и `(created_at)` реально читаются единичные аналитические запросы — на 1.7M строк эти индексы стоят больше, чем приносят. Кандидаты на дроп.

### Вариант B — выпилить целиком

Если выяснится, что метаданные `visitor` нигде не нужны:

1. Удалить файлы фронта: `src/visitor/`, `src/referral/` (если ref_uid тоже не нужен), импорт `import '@components/visitor'` в 10 темах.
2. Удалить колонку `purchase.visitor_uid` (миграция-обратная для `m251219_120150_add_visitor_uid_column_to_purchase_table`) и связанный код в `controllers/ApiController.php:831, 916`.
3. Удалить контроллер `modules/api_v2/controllers/VisitorController.php`, модель `models/Visitor.php`, таблицу `visitor` (миграция-обратная для `m251219_000000_create_visitor_table`).
4. Удалить чистящую команду `commands/CleanController.php::actionVisitorTable`.

Это самый чистый итог, но требует подтверждения, что атрибуция «посетитель ↔ покупка» по `visitor_uid` нигде в отчётах не используется (в `adminka.store` синхронизации тоже — см. `[[project_adminka_store_sync]]`).

### Промежуточный шаг — диагностика

Перед выбором варианта прогнать на боевой базе (после следующего включения трекинга):

```sql
SELECT SUBSTRING_INDEX(user_agent, ' ', 3) AS ua, COUNT(*) c
FROM visitor GROUP BY ua ORDER BY c DESC LIMIT 20;
```

Если в топе известные краулеры — однозначно нужен фильтр ботов (вариант A.1). Если в топе нормальные браузеры — обсуждаем буферизацию (A.2).

## Очистка таблицы

CLI-команда `commands/CleanController.php::actionVisitorTable` удаляет из `visitor` все строки, у которых `visitor_uid` не упоминается ни в одном `purchase.visitor_uid`. Стратегия:

1. `SELECT v.id FROM visitor v LEFT JOIN purchase p ON p.visitor_uid = v.visitor_uid WHERE p.visitor_uid IS NULL LIMIT N`.
2. `DELETE FROM visitor WHERE id IN (...)`.

Не используется коррелированный `NOT EXISTS` + `DELETE … LIMIT N` (старая версия — словила `Lock wait timeout` на 50k батче). Сессия выставляет `READ-COMMITTED` + `innodb_lock_wait_timeout = 120`, чтобы не было gap-локов и не упирались параллельные INSERT'ы (актуально только если `TRACKING_ENABLED = true`).

Запуск:

```bash
php yii clean/visitor-table --batch=5000 --sleep=0
php yii clean/visitor-table --dry-run=1   # посмотреть, сколько будет удалено
```
