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:
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ')
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user