feat(visitors): маячок POST /api/track + кука гостя на .liderra.ru

Task 4 плана 2026-07-13-visitors-analytics.md: VisitTracker (создание/обновление
гостя, запись событий, привязка к клиенту) + публичный TrackController@store,
маршрут POST /api/track (throttle:track 60/мин), лимитер в AppServiceProvider,
CSRF-исключение api/track в bootstrap/app.php.

Отклонения от плана (тест-харнесс, не прод-код):
- getCookie('lid_vid', false) в тесте убрал decrypt=false — с withCookie() это
  давало двойное шифрование (withCookie сам шифрует plain-значение).
- добавлен withCredentials() перед withCookie()+postJson/getJson — Laravel
  тест-клиент по умолчанию не шлёт cookie на JSON-запросы (как XHR
  credentials:'omit'); реальный маячок шлёт fetch с credentials:'include',
  так что прод не затронут.

Pest: 5/5 (TrackEndpointTest). Pint: app/Services/Tracking + TrackController.
This commit is contained in:
Дмитрий
2026-07-13 19:46:48 +03:00
parent ab840ffe2a
commit 481dc6e183
6 changed files with 248 additions and 0 deletions
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Tracking\VisitTracker;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Публичный приёмник маячка. Робот сюда не постучится: запрос шлёт JS страницы.
* Кука lid_vid на домен .liderra.ru, поэтому лендинг и кабинет видят одного гостя.
* Spec: docs/superpowers/specs/2026-07-13-visitors-analytics-design.md §4-5
*/
class TrackController extends Controller
{
public function store(Request $request, VisitTracker $tracker): Response
{
$data = $request->validate([
'event' => 'required|string|in:'.implode(',', VisitTracker::EVENTS),
'host' => 'nullable|string|max:64',
'path' => 'nullable|string|max:500',
'screen' => 'nullable|string|max:64',
'referrer' => 'nullable|string|max:1000',
'utm' => 'nullable|array',
'utm.*' => 'nullable|string|max:128',
]);
// register_done / login_done пишет только backend — с фронта не принимаем.
if (in_array($data['event'], ['register_done', 'login_done'], true)) {
abort(422, 'событие пишется сервером');
}
$vid = $tracker->ensureVisitor($request->cookie('lid_vid'), [
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'referrer' => $data['referrer'] ?? $request->header('referer'),
'path' => $data['path'] ?? null,
'utm' => $data['utm'] ?? [],
]);
$tracker->record($vid, $data['event'], [
'host' => $data['host'] ?? null,
'path' => $data['path'] ?? null,
'screen' => $data['screen'] ?? null,
]);
return response()->noContent()->cookie(
'lid_vid', $vid, 60 * 24 * 365, '/', '.liderra.ru', true, true, false, 'lax'
);
}
}
+4
View File
@@ -120,6 +120,10 @@ class AppServiceProvider extends ServiceProvider
// прикрыт per-источник лимитом 120/мин ПЕРЕД ApiKeyAuth — режет brute/DoS
// по ключам и снимает нагрузку bcrypt/DB до аутентификации. Ключ лимитера —
// сам Bearer-ключ (sha256, «per ключ»); без заголовка — fallback на IP.
// track (spec 2026-07-13): публичный маячок учёта посетителей — 60/мин с IP,
// защита от накрутки счётчика.
RateLimiter::for('track', fn (Request $request) => Limit::perMinute(60)->by($request->ip() ?: 'unknown'));
RateLimiter::for('api-v1', function (Request $request) {
$header = (string) $request->header('Authorization', '');
$bearer = str_starts_with($header, 'Bearer ')
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Services\Tracking;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Единая точка записи посетителя и его шагов. Пишет через pgsql_supplier
* (crm_supplier_worker, BYPASSRLS) как supplier-системные таблицы.
* Гость создаётся ОДИН раз, дальше только обновляется last_seen_at / is_human /
* привязка к клиенту. Канал первого захода не перезаписываем важен источник,
* который человека привёл.
*/
class VisitTracker
{
public const EVENTS = ['view', 'alive', 'cta_click', 'register_open', 'register_done', 'login_done', 'portal_screen'];
public function __construct(
private ChannelResolver $channels = new ChannelResolver,
private GeoResolver $geo = new GeoResolver,
) {}
private function db()
{
return DB::connection('pgsql_supplier');
}
/**
* Создаёт гостя, если его нет. Возвращает id гостя.
*
* @param array<string,string|null> $utm
*/
public function ensureVisitor(?string $vid, array $ctx): string
{
$vid = ($vid !== null && Str::isUuid($vid)) ? $vid : (string) Str::uuid();
$exists = $this->db()->table('site_visitors')->where('id', $vid)->exists();
if ($exists) {
$this->db()->table('site_visitors')->where('id', $vid)->update(['last_seen_at' => now()]);
return $vid;
}
$ip = (string) ($ctx['ip'] ?? '');
$geo = $ip !== '' ? $this->geo->lookup($ip) : ['city' => null, 'region' => null, 'asn_org' => null, 'is_datacenter' => false];
$utm = (array) ($ctx['utm'] ?? []);
$this->db()->table('site_visitors')->insert([
'id' => $vid,
'first_seen_at' => now(),
'last_seen_at' => now(),
'channel' => $this->channels->channel($utm, $ctx['referrer'] ?? null),
'utm_source' => $utm['utm_source'] ?? null,
'utm_medium' => $utm['utm_medium'] ?? null,
'utm_campaign' => $utm['utm_campaign'] ?? null,
'utm_content' => $utm['utm_content'] ?? null,
'referrer' => $ctx['referrer'] ?? null,
'landing_path' => $ctx['path'] ?? null,
'device' => $this->channels->device($ctx['user_agent'] ?? null),
'user_agent' => $ctx['user_agent'] ?? null,
'ip' => $ip !== '' ? $ip : null,
'city' => $geo['city'],
'region' => $geo['region'],
'asn_org' => $geo['asn_org'],
'is_datacenter' => $geo['is_datacenter'],
'is_human' => false,
]);
return $vid;
}
/** Записать шаг. Неизвестное событие — исключение (валидация выше по стеку). */
public function record(string $visitorId, string $event, array $ctx = []): void
{
if (! in_array($event, self::EVENTS, true)) {
throw new \InvalidArgumentException("unknown event: {$event}");
}
$this->db()->table('site_events')->insert([
'visitor_id' => $visitorId,
'event' => $event,
'occurred_at' => now(),
'host' => $ctx['host'] ?? null,
'path' => $ctx['path'] ?? null,
'screen' => $ctx['screen'] ?? null,
'meta' => json_encode($ctx['meta'] ?? [], JSON_UNESCAPED_UNICODE),
]);
if ($event === 'alive') {
$this->db()->table('site_visitors')->where('id', $visitorId)->update(['is_human' => true]);
}
}
/** Привязать гостя к клиенту (регистрация/вход). Гость с этого момента «стал клиентом». */
public function attachUser(string $visitorId, int $tenantId, int $userId): void
{
$this->db()->table('site_visitors')->where('id', $visitorId)->update([
'tenant_id' => $tenantId,
'user_id' => $userId,
'is_human' => true,
'last_seen_at' => now(),
]);
}
}
+3
View File
@@ -55,8 +55,11 @@ return Application::configure(basePath: dirname(__DIR__))
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
// CSRF — запросы приходят от внешних CRM-систем без сессии браузера.
// Авторизация — через webhook_token в URL + (на prod) HMAC.
// Маячок учёта посетителей (spec 2026-07-13) — публичный POST без сессии/CSRF-
// токена (шлёт JS лендинга/портала, иногда до инициализации SPA-сессии).
$middleware->validateCsrfTokens(except: [
'api/webhook/*',
'api/track',
]);
})
->withExceptions(function (Exceptions $exceptions): void {
+6
View File
@@ -390,6 +390,12 @@ Route::post('/api/webhook/payment', 'App\Http\Controllers\Api\PaymentWebhookCont
// Публичная (без auth) тарифная сетка — для страницы цен и модерации ЮKassa.
Route::get('/api/public/pricing', 'App\Http\Controllers\Api\PublicPricingController@index');
// Маячок учёта посетителей (spec 2026-07-13). Публичный, без auth: его шлёт JS
// лендинга и портала. Робот JS не исполняет → в базу не попадает.
// throttle:track — 60/мин с адреса, защита от накрутки.
Route::post('/api/track', 'App\Http\Controllers\Api\TrackController@store')
->middleware('throttle:track');
// 2FA setup wizard — все эндпоинты под auth:sanctum (только для уже залогиненных).
Route::prefix('/api/2fa')->middleware('auth:sanctum')->group(function () {
Route::post('/init', 'App\Http\Controllers\Api\TwoFactorSetupController@init');
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
test('первое событие view создаёт гостя и ставит куку', function () {
$r = $this->postJson('/api/track', [
'event' => 'view',
'host' => 'liderra.ru',
'path' => '/',
'referrer' => 'https://krasnoyarsk.hh.ru/vacancy/1',
], ['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X)']);
$r->assertStatus(204);
$r->assertCookie('lid_vid');
$v = DB::table('site_visitors')->first();
expect($v->channel)->toBe('hh');
expect($v->device)->toBe('phone');
expect($v->is_human)->toBeFalse(); // пока только браузер, не человек
expect(DB::table('site_events')->where('event', 'view')->count())->toBe(1);
});
test('utm-метка из ссылки определяет канал', function () {
$this->postJson('/api/track', [
'event' => 'view', 'host' => 'liderra.ru', 'path' => '/',
'utm' => ['utm_source' => 'sms', 'utm_campaign' => 'rassylka-13-07'],
])->assertStatus(204);
$v = DB::table('site_visitors')->first();
expect($v->channel)->toBe('sms');
expect($v->utm_campaign)->toBe('rassylka-13-07');
});
test('повторный view с той же кукой не создаёт второго гостя', function () {
$first = $this->postJson('/api/track', ['event' => 'view', 'host' => 'liderra.ru', 'path' => '/']);
// getCookie() без второго аргумента — decrypt=true (по умолчанию), возвращает
// ПЛОСКОЕ значение (снят слой encrypt+CookieValuePrefix). withCookie() ниже сам
// шифрует заново — если взять уже-зашифрованное значение (decrypt=false), получится
// двойное шифрование и сервер cookie не распознает (заведёт нового гостя).
$vid = $first->getCookie('lid_vid')->getValue();
// withCredentials(): postJson/getJson по умолчанию НЕ прикладывают cookie
// (как XHR с credentials:'omit') — withCookie() без него молча теряется.
// В браузере маячок шлёт fetch с credentials:'include', поэтому куку видит всегда.
$this->withCredentials()->withCookie('lid_vid', $vid)
->postJson('/api/track', ['event' => 'view', 'host' => 'lk.liderra.ru', 'path' => '/login'])
->assertStatus(204);
expect(DB::table('site_visitors')->count())->toBe(1);
expect(DB::table('site_events')->count())->toBe(2);
});
test('событие alive помечает гостя как живого человека', function () {
$first = $this->postJson('/api/track', ['event' => 'view', 'host' => 'liderra.ru', 'path' => '/']);
$vid = $first->getCookie('lid_vid')->getValue();
$this->withCredentials()->withCookie('lid_vid', $vid)
->postJson('/api/track', ['event' => 'alive', 'host' => 'liderra.ru', 'path' => '/'])
->assertStatus(204);
expect((bool) DB::table('site_visitors')->first()->is_human)->toBeTrue();
});
test('неизвестное событие отклоняется', function () {
$this->postJson('/api/track', ['event' => 'drop_table', 'host' => 'liderra.ru'])
->assertStatus(422);
expect(DB::table('site_events')->count())->toBe(0);
});