From 481dc6e183fa32cb514bb74990a302a69d66e30e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 13 Jul 2026 19:46:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(visitors):=20=D0=BC=D0=B0=D1=8F=D1=87?= =?UTF-8?q?=D0=BE=D0=BA=20POST=20/api/track=20+=20=D0=BA=D1=83=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B3=D0=BE=D1=81=D1=82=D1=8F=20=D0=BD=D0=B0=20.liderra.ru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Http/Controllers/Api/TrackController.php | 54 +++++++++ app/app/Providers/AppServiceProvider.php | 4 + app/app/Services/Tracking/VisitTracker.php | 107 ++++++++++++++++++ app/bootstrap/app.php | 3 + app/routes/web.php | 6 + app/tests/Feature/TrackEndpointTest.php | 74 ++++++++++++ 6 files changed, 248 insertions(+) create mode 100644 app/app/Http/Controllers/Api/TrackController.php create mode 100644 app/app/Services/Tracking/VisitTracker.php create mode 100644 app/tests/Feature/TrackEndpointTest.php diff --git a/app/app/Http/Controllers/Api/TrackController.php b/app/app/Http/Controllers/Api/TrackController.php new file mode 100644 index 00000000..b9971552 --- /dev/null +++ b/app/app/Http/Controllers/Api/TrackController.php @@ -0,0 +1,54 @@ +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' + ); + } +} diff --git a/app/app/Providers/AppServiceProvider.php b/app/app/Providers/AppServiceProvider.php index f58d7066..295e84cd 100644 --- a/app/app/Providers/AppServiceProvider.php +++ b/app/app/Providers/AppServiceProvider.php @@ -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 ') diff --git a/app/app/Services/Tracking/VisitTracker.php b/app/app/Services/Tracking/VisitTracker.php new file mode 100644 index 00000000..90443400 --- /dev/null +++ b/app/app/Services/Tracking/VisitTracker.php @@ -0,0 +1,107 @@ + $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(), + ]); + } +} diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index a10f2fcb..42a97e3a 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -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 { diff --git a/app/routes/web.php b/app/routes/web.php index 06ad5e69..c0432cdf 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -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'); diff --git a/app/tests/Feature/TrackEndpointTest.php b/app/tests/Feature/TrackEndpointTest.php new file mode 100644 index 00000000..bec3d616 --- /dev/null +++ b/app/tests/Feature/TrackEndpointTest.php @@ -0,0 +1,74 @@ +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); +});