feat: new channel
This commit is contained in:
@@ -45,7 +45,7 @@ class ChannelProjectService
|
||||
$builderType = $this->normalizeBuilderType($builderType);
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
return $this->generateNew($channelId);
|
||||
return $this->generateNew($channelId, $supportTemplate);
|
||||
}
|
||||
|
||||
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
|
||||
@@ -72,9 +72,9 @@ class ChannelProjectService
|
||||
if ($code === null) {
|
||||
return;
|
||||
}
|
||||
$zip = $this->newChannelZipPath($code);
|
||||
if (is_file($zip) && ! unlink($zip)) {
|
||||
throw new RuntimeException('删除渠道安装包失败: '.$zip);
|
||||
$dir = $this->newChannelDirPath($code);
|
||||
if (is_dir($dir) && ! $this->removeDirectory($dir)) {
|
||||
throw new RuntimeException('删除渠道资源失败: '.$dir);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -163,8 +163,8 @@ class ChannelProjectService
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild shared /details and staged weifile from `c`.
|
||||
* Only used by `php artisan xxbb:build`, not when creating a channel.
|
||||
* Rebuild shared /details and staged weifile from shared channel `c`.
|
||||
* Channel create always runs this with XXBB_CHANNEL_C from env (no random).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -188,46 +188,53 @@ class ChannelProjectService
|
||||
$cmd[] = '--random-c';
|
||||
} else {
|
||||
$c = $this->normalizeSharedChannelC($channelC);
|
||||
if ($c !== null) {
|
||||
$cmd[] = '--channel-c';
|
||||
$cmd[] = $c;
|
||||
if ($c === null) {
|
||||
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
|
||||
}
|
||||
$cmd[] = '--channel-c';
|
||||
$cmd[] = $c;
|
||||
}
|
||||
|
||||
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
}
|
||||
|
||||
private function generateNew(string $channelId): array
|
||||
private function generateNew(string $channelId, string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE): array
|
||||
{
|
||||
$channelId = Channel::normalizeNewChannelId($channelId);
|
||||
if ($channelId === null) {
|
||||
throw new RuntimeException('新版渠道 ID 必须是 8 位字母或数字(例如 FAFA9988)');
|
||||
throw new RuntimeException('新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)');
|
||||
}
|
||||
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
|
||||
|
||||
// Shared details + staged weifile always rebuilt from env XXBB_CHANNEL_C (never random).
|
||||
$this->buildSharedArtifacts($this->requireEnvChannelC(), false);
|
||||
|
||||
$weifileSrc = $this->stagedWeifileDir();
|
||||
if (! is_file($weifileSrc.DIRECTORY_SEPARATOR.'index.js')) {
|
||||
throw new RuntimeException('请先运行构建脚本: php artisan xxbb:build');
|
||||
}
|
||||
|
||||
$zipPath = $this->newChannelZipPath($channelId);
|
||||
$this->ensureDirectory(dirname($zipPath), '新版渠道产物目录');
|
||||
$detailsSrc = $this->artifactRoot().DIRECTORY_SEPARATOR.'details';
|
||||
$channelOut = $this->newChannelDirPath($channelId);
|
||||
$this->ensureDirectory(dirname($channelOut), '新版渠道产物目录');
|
||||
|
||||
$cmd = [
|
||||
$this->pythonBinary(self::BUILDER_NEW),
|
||||
$this->builderScript('pack_channel.py', self::BUILDER_NEW),
|
||||
'--channel-code',
|
||||
'--channel-ver',
|
||||
$channelId,
|
||||
'--state-root',
|
||||
$this->stateRoot(self::BUILDER_NEW),
|
||||
'--weifile-src',
|
||||
$weifileSrc,
|
||||
'--zip-out',
|
||||
$zipPath,
|
||||
'--details-src',
|
||||
$detailsSrc,
|
||||
'--channel-out',
|
||||
$channelOut,
|
||||
'--landing-template',
|
||||
$supportTemplate,
|
||||
];
|
||||
|
||||
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
$seeds = $this->loadNewLabSeeds();
|
||||
$relativeZip = $this->newChannelZipRelative($channelId);
|
||||
$landing = (string) ($result['landing_path'] ?? '/channel/'.$channelId.'/weifile/weifile.html');
|
||||
$detailsPath = (string) ($result['details_path'] ?? '/channel/'.$channelId.'/details/');
|
||||
|
||||
return [
|
||||
'channel_id' => $channelId,
|
||||
@@ -243,10 +250,12 @@ class ChannelProjectService
|
||||
],
|
||||
'seeds_initialized' => false,
|
||||
'sync_rebuilt' => false,
|
||||
'support_path' => '',
|
||||
'support_path' => $landing,
|
||||
'weifile_path' => null,
|
||||
'daily_path' => '/details/',
|
||||
'zip_path' => '/'.$relativeZip,
|
||||
'daily_path' => $detailsPath,
|
||||
'channel_dir' => '/channel/'.$channelId,
|
||||
'show_alias' => (string) ($result['show_alias'] ?? '/c/'.$channelId.'/show.htm'),
|
||||
'support_template' => (string) ($result['landing_template'] ?? $supportTemplate),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -255,17 +264,37 @@ class ChannelProjectService
|
||||
return $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'out'.DIRECTORY_SEPARATOR.'weifile';
|
||||
}
|
||||
|
||||
public function newChannelZipPath(string $channelId): string
|
||||
public function newChannelDirPath(string $channelId): string
|
||||
{
|
||||
return $this->artifactRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $this->newChannelZipRelative($channelId));
|
||||
}
|
||||
|
||||
public function newChannelZipRelative(string $channelId): string
|
||||
{
|
||||
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
|
||||
$code = Channel::normalizeNewChannelId($channelId) ?? $channelId;
|
||||
|
||||
return $dir.'/'.$code.'.zip';
|
||||
return $this->artifactRoot().DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$code;
|
||||
}
|
||||
|
||||
private function removeDirectory(string $dir): bool
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return true;
|
||||
}
|
||||
$items = scandir($dir);
|
||||
if ($items === false) {
|
||||
return false;
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$item;
|
||||
if (is_dir($path)) {
|
||||
if (! $this->removeDirectory($path)) {
|
||||
return false;
|
||||
}
|
||||
} elseif (! unlink($path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,6 +328,16 @@ class ChannelProjectService
|
||||
];
|
||||
}
|
||||
|
||||
private function requireEnvChannelC(): string
|
||||
{
|
||||
$c = $this->normalizeSharedChannelC(null);
|
||||
if ($c === null) {
|
||||
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
|
||||
}
|
||||
|
||||
return $c;
|
||||
}
|
||||
|
||||
private function normalizeSharedChannelC(?string $channelC): ?string
|
||||
{
|
||||
$c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Services;
|
||||
use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -22,14 +24,19 @@ class DashboardStatsService
|
||||
* } $filters
|
||||
* @return array{
|
||||
* total: int,
|
||||
* wallet_count: int,
|
||||
* new_count: int,
|
||||
* active_count: int,
|
||||
* pv: int,
|
||||
* uv: int,
|
||||
* effective_pv: int,
|
||||
* effective_uv: int,
|
||||
* by_os: list<array{label: string, pv: int, uv: int, pct: float}>,
|
||||
* by_ios_version: list<array{label: string, pv: int, uv: int, pct: float}>,
|
||||
* mnemonic_count: int,
|
||||
* address_count: int,
|
||||
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
|
||||
* by_os: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
|
||||
* range: string,
|
||||
* from: string,
|
||||
* to: string
|
||||
@@ -62,8 +69,26 @@ class DashboardStatsService
|
||||
}
|
||||
|
||||
$total = (clone $base)->count();
|
||||
$walletCount = (clone $base)->where('has_wallet', Device::WALLET_YES)->count();
|
||||
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
|
||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||
$mnemonicCount = $this->countDeviceChildren(
|
||||
WalletMnemonic::query(),
|
||||
'wallet_mnemonics',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
);
|
||||
$addressCount = $this->countDeviceChildren(
|
||||
WalletAddress::query(),
|
||||
'wallet_addresses',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
);
|
||||
$balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact);
|
||||
|
||||
$visits = PageVisit::query();
|
||||
AgentScope::applyChannelIdScope($visits, $agent);
|
||||
@@ -86,53 +111,151 @@ class DashboardStatsService
|
||||
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$iosVisits = (clone $visits)->where('os', 'iOS');
|
||||
$iosPv = (clone $iosVisits)->count();
|
||||
$iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$otherVisits = (clone $visits)->where(function (Builder $q) {
|
||||
$q->whereNull('os')->orWhere('os', '!=', 'iOS');
|
||||
});
|
||||
$otherPv = (clone $otherVisits)->count();
|
||||
$otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$byOs = [
|
||||
$this->row('iOS', $iosPv, $iosUv, $pv),
|
||||
$this->row('其他', $otherPv, $otherUv, $pv),
|
||||
$this->visitRow('iOS', $iosUv, $uv),
|
||||
$this->visitRow('其他', $otherUv, $uv),
|
||||
];
|
||||
|
||||
$byIosVersion = (clone $iosVisits)
|
||||
$versionGroups = (clone $iosVisits)
|
||||
->select('os_version')
|
||||
->selectRaw('COUNT(*) as pv')
|
||||
->selectRaw('COUNT(DISTINCT client_uid) as uv')
|
||||
->groupBy('os_version')
|
||||
->orderByDesc('pv')
|
||||
->orderByDesc('uv')
|
||||
->limit(20)
|
||||
->get();
|
||||
// Denominator = sum of per-version UVs (not global DISTINCT).
|
||||
// Same visitor may appear under multiple versions; using DISTINCT total would
|
||||
// make every overlapping bucket show ~100%.
|
||||
$versionUvSum = (int) $versionGroups->sum('uv');
|
||||
$byIosVersion = $versionGroups
|
||||
->map(fn ($r) => $this->visitRow(
|
||||
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
|
||||
(int) $r->uv,
|
||||
$versionUvSum > 0 ? $versionUvSum : $iosUv,
|
||||
))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$byDeviceIosVersion = (clone $base)
|
||||
->select('ios_version')
|
||||
->selectRaw('COUNT(*) as cnt')
|
||||
->groupBy('ios_version')
|
||||
->orderByDesc('cnt')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn ($r) => $this->row(
|
||||
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
|
||||
(int) $r->pv,
|
||||
(int) $r->uv,
|
||||
$iosPv > 0 ? $iosPv : $pv,
|
||||
))
|
||||
->map(function ($r) use ($total) {
|
||||
$label = trim((string) ($r->ios_version ?? ''));
|
||||
|
||||
return $this->deviceRow(
|
||||
$label !== '' ? $label : '未知',
|
||||
(int) $r->cnt,
|
||||
$total,
|
||||
);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'wallet_count' => $walletCount,
|
||||
'new_count' => $newCount,
|
||||
'active_count' => $activeCount,
|
||||
'pv' => $pv,
|
||||
'uv' => $uv,
|
||||
'effective_pv' => $effectivePv,
|
||||
'effective_uv' => $effectiveUv,
|
||||
'mnemonic_count' => $mnemonicCount,
|
||||
'address_count' => $addressCount,
|
||||
'balances' => $balances,
|
||||
'by_os' => $byOs,
|
||||
'by_ios_version' => $byIosVersion,
|
||||
'by_device_ios_version' => $byDeviceIosVersion,
|
||||
'range' => $range,
|
||||
'from' => $from->toDateTimeString(),
|
||||
'to' => $to->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a device-child table and apply the same channel / agent scope as device totals.
|
||||
*/
|
||||
private function scopedDeviceChildren(
|
||||
Builder $query,
|
||||
string $table,
|
||||
?User $agent,
|
||||
?int $agentUserId,
|
||||
string $channelId,
|
||||
bool $channelExact,
|
||||
): Builder {
|
||||
$query->join('devices', 'devices.id', '=', $table.'.device_id');
|
||||
AgentScope::applyDeviceChannelScope($query, $agent);
|
||||
if ($agent === null) {
|
||||
AgentScope::applyAgentUserFilter($query, $agentUserId);
|
||||
}
|
||||
if ($channelId !== '') {
|
||||
if ($channelExact) {
|
||||
$query->where('devices.channel_id', $channelId);
|
||||
} else {
|
||||
$query->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function countDeviceChildren(
|
||||
Builder $query,
|
||||
string $table,
|
||||
?User $agent,
|
||||
?int $agentUserId,
|
||||
string $channelId,
|
||||
bool $channelExact,
|
||||
): int {
|
||||
return $this->scopedDeviceChildren($query, $table, $agent, $agentUserId, $channelId, $channelExact)
|
||||
->count($table.'.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum coin columns across wallet_addresses for devices in the same scope as dashboard totals.
|
||||
* Not limited by the time range (current holdings).
|
||||
*
|
||||
* @return array{usdt: string, trx: string, eth: string, btc: string, bnb: string}
|
||||
*/
|
||||
private function sumBalances(?User $agent, ?int $agentUserId, string $channelId, bool $channelExact): array
|
||||
{
|
||||
$q = $this->scopedDeviceChildren(
|
||||
WalletAddress::query(),
|
||||
'wallet_addresses',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
);
|
||||
|
||||
$selects = [];
|
||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
|
||||
}
|
||||
$row = $q->selectRaw(implode(', ', $selects))->first();
|
||||
|
||||
$out = [];
|
||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||
$raw = $row?->{$col} ?? 0;
|
||||
$formatted = WalletAddress::formatAmount($col, $raw);
|
||||
$out[$col] = $formatted === '' ? '0' : $formatted;
|
||||
}
|
||||
|
||||
/** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map telegram /data day token to dashboard range key.
|
||||
* Empty / omitted → today.
|
||||
@@ -209,17 +332,32 @@ class DashboardStatsService
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, pv: int, uv: int, pct: float}
|
||||
* Visit breakdown row. pct = uv / $totalUv (UV share).
|
||||
*
|
||||
* @return array{label: string, uv: int, pct: float}
|
||||
*/
|
||||
private function row(string $label, int $pv, int $uv, int $totalPv): array
|
||||
private function visitRow(string $label, int $uv, int $totalUv): array
|
||||
{
|
||||
$pct = $totalPv > 0 ? round(($pv / $totalPv) * 100, 1) : 0.0;
|
||||
$pct = $totalUv > 0 ? round(($uv / $totalUv) * 100, 1) : 0.0;
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'pv' => $pv,
|
||||
'uv' => $uv,
|
||||
'pct' => $pct,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, count: int, pct: float}
|
||||
*/
|
||||
private function deviceRow(string $label, int $count, int $total): array
|
||||
{
|
||||
$pct = $total > 0 ? round(($count / $total) * 100, 1) : 0.0;
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'count' => $count,
|
||||
'pct' => $pct,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class FilterOptionsService
|
||||
{
|
||||
public const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
public const CACHE_CHANNELS_ADMIN = 'filter_options:channels:admin';
|
||||
|
||||
public const CACHE_AGENTS = 'filter_options:agents';
|
||||
|
||||
public static function channelsCacheKeyForAgent(int $agentId): string
|
||||
{
|
||||
return 'filter_options:channels:agent:'.$agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{value: string, label: string}>
|
||||
*/
|
||||
public function channelOptions(?User $agent = null): array
|
||||
{
|
||||
if ($agent !== null) {
|
||||
$key = self::channelsCacheKeyForAgent((int) $agent->id);
|
||||
|
||||
return Cache::remember($key, self::CACHE_TTL_SECONDS, function () use ($agent) {
|
||||
return $this->mapChannels(
|
||||
Channel::query()
|
||||
->where('user_id', (int) $agent->id)
|
||||
->orderByDesc('id')
|
||||
->get(['channel_id', 'remark'])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return Cache::remember(self::CACHE_CHANNELS_ADMIN, self::CACHE_TTL_SECONDS, function () {
|
||||
return $this->mapChannels(
|
||||
Channel::query()->orderByDesc('id')->get(['channel_id', 'remark'])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{value: string, label: string}>
|
||||
*/
|
||||
public function agentOptions(): array
|
||||
{
|
||||
return Cache::remember(self::CACHE_AGENTS, self::CACHE_TTL_SECONDS, function () {
|
||||
return User::query()
|
||||
->orderBy('username')
|
||||
->get(['id', 'username', 'comment'])
|
||||
->map(function (User $u) {
|
||||
$name = trim((string) $u->username);
|
||||
$comment = trim((string) ($u->comment ?? ''));
|
||||
|
||||
return [
|
||||
'value' => (string) $u->id,
|
||||
'label' => $comment !== '' ? $name.'/'.$comment : $name,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function forgetAll(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_CHANNELS_ADMIN);
|
||||
Cache::forget(self::CACHE_AGENTS);
|
||||
}
|
||||
|
||||
public function forgetChannels(?int $agentUserId = null): void
|
||||
{
|
||||
Cache::forget(self::CACHE_CHANNELS_ADMIN);
|
||||
if ($agentUserId !== null && $agentUserId > 0) {
|
||||
Cache::forget(self::channelsCacheKeyForAgent($agentUserId));
|
||||
}
|
||||
}
|
||||
|
||||
public function forgetAgents(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_AGENTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, Channel> $channels
|
||||
* @return list<array{value: string, label: string}>
|
||||
*/
|
||||
private function mapChannels($channels): array
|
||||
{
|
||||
return $channels->map(function (Channel $c) {
|
||||
$id = (string) $c->channel_id;
|
||||
$remark = trim((string) ($c->remark ?? ''));
|
||||
|
||||
return [
|
||||
'value' => $id,
|
||||
'label' => $remark !== '' ? $id.'/'.$remark : $id,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user