feat(visitors): определение города и оператора по офлайн-базе + признак VPN/хостинга

Гео читается из локального файла (DB-IP Lite) — IP посетителя наружу не уходит.
Нет файла базы → город не определяется, учёт продолжает работать (fail-open).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-13 19:32:34 +03:00
parent dd5fb58990
commit 42adebcc5b
5 changed files with 352 additions and 1 deletions
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Services\Tracking;
use GeoIp2\Database\Reader;
/**
* Город/регион/оператор по IP из ОФЛАЙН-базы (DB-IP Lite). Наружу ничего не шлём
* IP посетителя не покидает сервер. Нет файла базы пустой результат (fail-open).
* Хостинг/VPN определяем по названию организации: у таких гостей город недостоверен.
* Spec: docs/superpowers/specs/2026-07-13-visitors-analytics-design.md §9
*/
class GeoResolver
{
private const DATACENTER_MARKERS = [
'hosting', 'cloud', 'server', 'data center', 'datacenter', 'vpn', 'vps', 'proxy',
'digitalocean', 'tencent', 'amazon', 'aws', 'google llc', 'microsoft', 'ovh',
'hetzner', 'vultr', 'linode', 'scaleway', 'alibaba', 'oracle', 'colocation', 'm247',
];
/** @return array{city:?string,region:?string,asn_org:?string,is_datacenter:bool} */
public function lookup(string $ip): array
{
$empty = ['city' => null, 'region' => null, 'asn_org' => null, 'is_datacenter' => false];
$cityDb = (string) config('services.geoip.city_db');
$asnDb = (string) config('services.geoip.asn_db');
$city = null;
$region = null;
$org = null;
try {
if ($cityDb !== '' && is_readable($cityDb)) {
$rec = (new Reader($cityDb))->city($ip);
$city = $rec->city->name;
$region = $rec->mostSpecificSubdivision->name;
}
if ($asnDb !== '' && is_readable($asnDb)) {
$org = (new Reader($asnDb))->asn($ip)->autonomousSystemOrganization;
}
} catch (\Throwable) {
return $empty; // приватный IP, не найден, битая база — учёт не ломаем
}
return [
'city' => $city,
'region' => $region,
'asn_org' => $org,
'is_datacenter' => self::looksLikeDatacenter($org),
];
}
public static function looksLikeDatacenter(?string $org): bool
{
$o = mb_strtolower((string) $org);
if ($o === '') {
return false;
}
foreach (self::DATACENTER_MARKERS as $m) {
if (str_contains($o, $m)) {
return true;
}
}
return false;
}
}
+1
View File
@@ -8,6 +8,7 @@
"require": {
"php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"geoip2/geoip2": "^3.3",
"laravel/framework": "^13.7",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
+246 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "da84c833d162bd54a2eff0f338eead8a",
"content-hash": "614b73070bef10a02153aeebf5ce1281",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@@ -212,6 +212,78 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/ca-bundle",
"version": "1.5.12",
"source": {
"type": "git",
"url": "https://github.com/composer/ca-bundle.git",
"reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78",
"reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-pcre": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8 || ^9",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\CaBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.",
"keywords": [
"cabundle",
"cacert",
"certificate",
"ssl",
"tls"
],
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues",
"source": "https://github.com/composer/ca-bundle/tree/1.5.12"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-05-19T11:26:22+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
@@ -890,6 +962,64 @@
],
"time": "2025-12-03T09:33:47+00:00"
},
{
"name": "geoip2/geoip2",
"version": "v3.3.0",
"source": {
"type": "git",
"url": "https://github.com/maxmind/GeoIP2-php.git",
"reference": "49fceddd694295e76e970a32848e03bb19e56b42"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/49fceddd694295e76e970a32848e03bb19e56b42",
"reference": "49fceddd694295e76e970a32848e03bb19e56b42",
"shasum": ""
},
"require": {
"ext-json": "*",
"maxmind-db/reader": "^1.13.0",
"maxmind/web-service-common": "~0.11",
"php": ">=8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "4.*"
},
"type": "library",
"autoload": {
"psr-4": {
"GeoIp2\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory J. Oschwald",
"email": "goschwald@maxmind.com",
"homepage": "https://www.maxmind.com/"
}
],
"description": "MaxMind GeoIP2 PHP API",
"homepage": "https://github.com/maxmind/GeoIP2-php",
"keywords": [
"IP",
"geoip",
"geoip2",
"geolocation",
"maxmind"
],
"support": {
"issues": "https://github.com/maxmind/GeoIP2-php/issues",
"source": "https://github.com/maxmind/GeoIP2-php/tree/v3.3.0"
},
"time": "2025-11-20T18:50:15+00:00"
},
{
"name": "graham-campbell/result-type",
"version": "v1.1.4",
@@ -2656,6 +2786,121 @@
},
"time": "2026-06-23T18:43:15+00:00"
},
{
"name": "maxmind-db/reader",
"version": "v1.13.1",
"source": {
"type": "git",
"url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git",
"reference": "2194f58d0f024ce923e685cdf92af3daf9951908"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/2194f58d0f024ce923e685cdf92af3daf9951908",
"reference": "2194f58d0f024ce923e685cdf92af3daf9951908",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"conflict": {
"ext-maxminddb": "<1.11.1 || >=2.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": ">=8.0.0,<10.0.0",
"squizlabs/php_codesniffer": "4.*"
},
"suggest": {
"ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder",
"ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder",
"ext-maxminddb": "A C-based database decoder that provides significantly faster lookups",
"maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)"
},
"type": "library",
"autoload": {
"psr-4": {
"MaxMind\\Db\\": "src/MaxMind/Db"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory J. Oschwald",
"email": "goschwald@maxmind.com",
"homepage": "https://www.maxmind.com/"
}
],
"description": "MaxMind DB Reader API",
"homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php",
"keywords": [
"database",
"geoip",
"geoip2",
"geolocation",
"maxmind"
],
"support": {
"issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues",
"source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.13.1"
},
"time": "2025-11-21T22:24:26+00:00"
},
{
"name": "maxmind/web-service-common",
"version": "v0.11.1",
"source": {
"type": "git",
"url": "https://github.com/maxmind/web-service-common-php.git",
"reference": "c309236b5a5555b96cf560089ec3cead12d845d2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/c309236b5a5555b96cf560089ec3cead12d845d2",
"reference": "c309236b5a5555b96cf560089ec3cead12d845d2",
"shasum": ""
},
"require": {
"composer/ca-bundle": "^1.0.3",
"ext-curl": "*",
"ext-json": "*",
"php": ">=8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpstan/phpstan": "*",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "4.*"
},
"type": "library",
"autoload": {
"psr-4": {
"MaxMind\\Exception\\": "src/Exception",
"MaxMind\\WebService\\": "src/WebService"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Gregory Oschwald",
"email": "goschwald@maxmind.com"
}
],
"description": "Internal MaxMind Web Service API",
"homepage": "https://github.com/maxmind/web-service-common-php",
"support": {
"issues": "https://github.com/maxmind/web-service-common-php/issues",
"source": "https://github.com/maxmind/web-service-common-php/tree/v0.11.1"
},
"time": "2026-01-13T17:56:03+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
+7
View File
@@ -152,6 +152,13 @@ return [
'counter_id' => env('METRIKA_COUNTER_ID'),
],
// Учёт посетителей: офлайн-базы гео (DB-IP Lite). Файлов нет → город не определяется,
// учёт продолжает работать. Обновление баз — раз в месяц, вручную.
'geoip' => [
'city_db' => env('GEOIP_CITY_DB', storage_path('geoip/dbip-city-lite.mmdb')),
'asn_db' => env('GEOIP_ASN_DB', storage_path('geoip/dbip-asn-lite.mmdb')),
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Services\Tracking\GeoResolver;
// GeoResolver читает пути к базам через config() → нужен поднятый контейнер приложения.
// Юнит-набор его не поднимает, поэтому явно подключаем TestCase (БД не трогаем).
uses(Tests\TestCase::class);
test('без гео-базы возвращает пустой результат и не падает', function () {
config()->set('services.geoip.city_db', '/nope/city.mmdb');
config()->set('services.geoip.asn_db', '/nope/asn.mmdb');
$res = (new GeoResolver())->lookup('8.8.8.8');
expect($res)->toMatchArray([
'city' => null, 'region' => null, 'asn_org' => null, 'is_datacenter' => false,
]);
});
test('организация-хостинг помечается как дата-центр/VPN', function () {
expect(GeoResolver::looksLikeDatacenter('Tencent Cloud Computing'))->toBeTrue();
expect(GeoResolver::looksLikeDatacenter('DigitalOcean, LLC'))->toBeTrue();
expect(GeoResolver::looksLikeDatacenter('NordVPN'))->toBeTrue();
expect(GeoResolver::looksLikeDatacenter('Tele2 Russia'))->toBeFalse();
expect(GeoResolver::looksLikeDatacenter('Rostelecom'))->toBeFalse();
expect(GeoResolver::looksLikeDatacenter(null))->toBeFalse();
});