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
+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);