docs(specs): собственный учёт посетителей + раздел «Посетители» в админке

Точное разделение робот/браузер/человек (отклик страницы, а не запрос),
сквозная склейка лендинг→портал через куку на .liderra.ru, воронка и каналы
в админке. Метрику и Вебвизор не трогаем.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-13 18:17:21 +03:00
parent 6a51d34cc5
commit 74a8f717c9
@@ -0,0 +1,200 @@
# Собственный учёт посетителей и раздел «Посетители» в админке
**Дата:** 13.07.2026
**Статус:** согласовано владельцем (брейншторм 13.07.2026)
**Автор:** Claude Code (сессия «откуда заходят на liderra.ru»)
---
## 1. Зачем
13.07.2026 владелец разослал смс со ссылкой на liderra.ru и одновременно опубликовал
вакансию на hh.ru. Ни Яндекс.Метрика, ни логи nginx не дали ответа «какой канал сработал»:
- **Метрика врёт в большую сторону.** За 13.07 показала 11 посетителей; разбор логов показал,
что живых людей ~6–7, остальное — сканеры (FlowIQLabsBot с трёх адресов, GPTBot, облачный фон
Tencent/AWS), которых её фильтр не отсёк.
- **Ссылки без меток.** Переход из смс и ручной набор адреса с вакансии одинаково выглядят как
«прямой заход» — разделить их нельзя.
- **Цепочка рвётся между сайтом и порталом.** Лендинг (`liderra.ru`) и кабинет (`lk.liderra.ru`) —
разные хосты; связать «пришёл по смс» → «зарегистрировался» → «вошёл» сейчас невозможно.
- **Внешних переходов почти нет.** За 7 дней жизни лендинга (07–13.07) — 5 переходов из Яндекса,
0 живых с hh.ru (единственная запись `krasnoyarsk.hh.ru` — робот GPTBot).
Нужен собственный счётчик, который **точно** отделяет людей от роботов и показывает **сквозной путь**
от первого показа страницы до входа в кабинет.
## 2. Цель и границы
**Цель:** админ Лидерры за 10 секунд видит, какой канал приносит не клики, а клиентов, и на каком
шаге люди отваливаются.
**В объёме:**
- Учёт на лендинге `liderra.ru` и в портале `lk.liderra.ru` (включая страницу регистрации и вход).
- Сквозная склейка пути одного гостя между двумя доменами.
- Точное разделение «робот / браузер / живой человек».
- Раздел «Посетители» в админке: воронка, каналы, список визитов, активность в кабинете.
- Город и оператор посетителя по офлайн-базе, с честной пометкой «через VPN — город недостоверен».
- Имя домена (`$host`) в логах nginx.
**Вне объёма (не делаем):**
- Яндекс.Метрика и Вебвизор **не трогаем** — работают как есть, параллельно.
- Плашка «сайт использует куки» — владелец решил обойтись без неё (осознанный риск, п. 9).
- Содержимое действий клиента в кабинете (какие лиды/сделки смотрел) — только имена экранов.
- A/B-тесты, тепловые карты, записи экрана.
## 3. Как отличаем робота от человека
Роботы не исполняют JavaScript. Поэтому считаем **не запрос к серверу, а отклик страницы**.
| Ступень | Признак | Что отсекает |
|---|---|---|
| 1. Запрос HTML | строка в логе nginx | ничего — сюда попадают все, включая сканеры |
| 2. **Браузер** | страница выполнила JS и отправила событие `view` | FlowIQLabsBot, GPTBot, облачные сканеры, curl/python |
| 3. **Человек** | было взаимодействие: скролл / касание / движение мыши / клик → событие `alive` | редкие роботы-рендереры |
| 4. **Действие** | нажал CTA, открыл регистрацию, зарегистрировался, вошёл | — |
Дополнительный фильтр: если IP принадлежит хостингу/дата-центру (по ASN из офлайн-базы) — гость
помечается `is_datacenter=true`. Это и «через VPN», и маскирующийся робот. В отчёте такие видны
отдельно, в основные цифры воронки не входят.
**В базу пишутся только гости со ступени 2 и выше.** Роботы ступени 1 в базу не попадают вовсе —
их число берём из счётчика nginx-логов и показываем как «отсеяно роботов: N».
## 4. Метка гостя и склейка пути
- Первый отклик страницы (`POST /api/track`, событие `view`) получает от сервера куку
**`lid_vid`** — UUID гостя.
- Кука ставится **сервером** (`Set-Cookie`), домен **`.liderra.ru`**, срок 1 год,
`SameSite=Lax; Secure; HttpOnly` — читать её из JS не нужно, браузер шлёт её сам, а закрытая
от JS кука не утечёт через чужой скрипт.
- Домен `.liderra.ru` — родительский и для лендинга, и для `lk.liderra.ru`, поэтому кука видна
обоим: путь «лендинг → кабинет» склеивается сам.
- При успешной регистрации и при входе backend связывает `lid_vid` с `tenant_id` / `user_id`.
С этого момента в отчёте видно: «этот клиент пришёл 13.07 по смс».
## 5. События
| Событие | Кто пишет | Когда |
|---|---|---|
| `view` | JS лендинга / портала | страница загрузилась (браузер подтверждён) |
| `alive` | JS | первое взаимодействие (скролл/касание/мышь), один раз за визит |
| `cta_click` | JS лендинга | нажата «Попробовать бесплатно» / «Вход в личный кабинет» |
| `register_open` | JS портала | открыт экран регистрации |
| `register_done` | backend | регистрация успешна (`POST /api/auth/register`) |
| `login_done` | backend | вход успешен (`POST /api/auth/login`) |
| `portal_screen` | JS портала | открыт экран кабинета — пишем только имя маршрута |
Backend-события пишутся **в коде**, не через JS: их подделать нельзя, и они не теряются.
## 6. Данные
### 6.1. Таблица `site_visitors` — гость
| Поле | Тип | Смысл |
|---|---|---|
| `id` | uuid PK | = значение куки `lid_vid` |
| `first_seen_at` / `last_seen_at` | timestamptz | первый и последний контакт |
| `channel` | text | канал: `sms`, `hh`, `search`, `direct`, `referral`, … (правило — п. 7) |
| `utm_source/medium/campaign/content` | text NULL | метки из ссылки, как пришли |
| `referrer` | text NULL | откуда пришёл (заголовок или `document.referrer`) |
| `landing_path` | text | страница входа |
| `device` | text | `phone` / `desktop` / `tablet` |
| `user_agent` | text | как есть |
| `ip` | inet | **сырой IP** (решение владельца, см. п. 9) |
| `city` / `region` / `asn_org` | text NULL | из офлайн-базы |
| `is_datacenter` | bool | хостинг/VPN → город недостоверен |
| `is_human` | bool | было событие `alive` |
| `tenant_id` / `user_id` | NULL | проставляются при регистрации/входе |
### 6.2. Таблица `site_events` — шаги
`id`, `visitor_id` (FK), `event` (перечисление из п. 5), `occurred_at`, `host`
(`liderra.ru` / `lk.liderra.ru`), `path`, `screen` (для `portal_screen`), `meta` jsonb.
**Партиционирование** — помесячно (как принято в проекте; нарезчик партиций уже есть,
`--behind=1` учтён). **Срок хранения — 180 дней**, старые партиции удаляются джобом.
**RLS:** обе таблицы — глобальные, не клиентские. Доступ только ролям SaaS-админа;
клиентские роли (`crm_app_user`) не видят ничего. Запись — через приложение.
**ПДн:** `ip`, `user_agent`, `city` попадают под маскирование `pg_anonymizer` в дампах
(правило добавляется рядом с существующими).
## 7. Как определяем канал
Правило по убыванию приоритета:
1. **Метка в ссылке** (`utm_source`) — самый надёжный. Готовим ссылки: смс — `?utm_source=sms`,
вакансия — `?utm_source=hh`, Телеграм — `?utm_source=tg`.
2. **Откуда пришёл** (`referrer`): домен поисковика → `search`; `hh.ru``hh`; прочий сайт → `referral`.
3. Иначе — **`direct`** (прямой заход: набрал руками, из закладок, из мессенджера без метки).
Пока метки не разосланы, смс-трафик неотличим от прямого — это честно пишем в интерфейсе
подсказкой: «прямые заходы включают переходы из смс и мессенджеров».
## 8. Раздел «Посетители» в админке
Новый пункт меню рядом с «Клиентами» и «Лидами». Стиль — как у Командного центра:
переключатель периода (сегодня / 7 / 30 дней), Forest-палитра, Vuetify 3.
**Сверху — воронка одной полосой** (числа за период, проценты перехода, красным подсвечен шаг
с наибольшим отвалом):
```
Открыли сайт 42 → Живых 30 (71%) → Нажали кнопку 8 (27%) → Открыли регистрацию 5 (63%)
→ Зарегистрировались 2 (40%) → Вошли в кабинет 2 (100%)
```
**Плашка честности:** «Роботов отсеяно: 137» — чтобы цифрам можно было верить.
**Таблица каналов:** канал | людей | нажали кнопку | регистраций | входов | конверсия в клиента.
**Список визитов:** время, канал, телефон/компьютер, город (или «через VPN»), IP, цепочка шагов
(«сайт → кнопка → регистрация»); если гость стал клиентом — ссылка на его карточку.
**Активность в кабинете:** кто из клиентов заходил за период и какие экраны открывал (топ экранов).
**Пустое состояние:** «Пока никто не заходил» — без пустых таблиц с нулями.
## 9. Персональные данные и риски
- **IP хранится как есть** — решение владельца (13.07.2026). Следствия закрываем: маскирование
в дампах (`pg_anonymizer`), срок хранения 180 дней, доступ только SaaS-админу.
- **Плашки о куках нет** — решение владельца. Формально 152-ФЗ/ФЗ «О рекламе» рекомендуют
уведомление; практика штрафов за отсутствие куки-баннера в РФ пока не сложилась. Возврат к
плашке — отдельная получасовая задача.
- **Задним числом не работает.** Данные пойдут с момента выката; прошлую неделю восстановить нельзя.
- **VPN искажает город** — по словам владельца, до 2/3 гостей через VPN. Поэтому город
показывается с пометкой, а не как факт.
## 10. Изменения в инфраструктуре
- **nginx:** в формат лога добавить `$host` (сейчас лендинг от портала отличается только размером
ответа — так отчёты не построишь). Одна строка в `nginx.conf`, откат мгновенный.
- **CSP:** маячок стучится на свой же домен (`/api/track` отдаёт то же приложение) — проверить,
что `connect-src 'self'` разрешён в обоих nginx-блоках (liderra.ru и lk).
- **Гео-база:** офлайн-файл (IP2Location LITE / DB-IP), обновление раз в месяц, никаких обращений
к внешним сервисам (IP наружу не уходит).
## 11. Тесты
- **Pest:** запись событий, идемпотентность (двойной `view` не удваивает гостя), связка
`register_done``tenant_id`, отсев по ступеням, RLS (клиентская роль не видит таблиц),
правило определения канала (таблица случаев: utm / referrer / прямой).
- **Vitest:** раздел «Посетители» — воронка считает проценты, пустое состояние, таблица каналов.
- **Ручная проверка на проде после выката:** зайти с телефона и с компьютера, убедиться, что
гость появился, шаги записались, робот-запрос `curl` в базу не попал.
## 12. Порядок работ
1. Миграция: две таблицы + партиции + RLS + правило анонимизации.
2. Backend: `POST /api/track`, кука гостя, определение канала/устройства/гео, события регистрации и входа.
3. Лендинг: маячок (`view` / `alive` / `cta_click`).
4. Портал: маячок в роутере (`view`, `register_open`, `portal_screen`).
5. Админ-API + раздел «Посетители».
6. nginx `$host`, гео-база, джоб удаления старых партиций.
7. Выкат на боевой (только с разрешения владельца), проверка живым заходом.
Работа ведётся в отдельной ветке/воркстри — параллельно идут ещё 3 сессии с крупными фичами,
пересечений по файлам быть не должно.