fix: link old

This commit is contained in:
hashbro
2026-09-14 01:09:01 +08:00
parent b49755c900
commit d5ae0bf6ff
29 changed files with 1584 additions and 212 deletions
+6
View File
@@ -119,5 +119,11 @@ TRANSFER_FEE_ADDRESS_TRON=
TRANSFER_FEE_TOPUP_TRX=20 TRANSFER_FEE_TOPUP_TRX=20
TRANSFER_FEE_PRIVATE_KEY_TRON= 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_TESSERACT=/usr/bin/tesseract
CORUNA_OCR_LANGS=eng+chi_sim CORUNA_OCR_LANGS=eng+chi_sim
+1 -1
View File
@@ -14,7 +14,7 @@ class DailyStatsCommand extends Command
{--to= : End date Y-m-d (default today)} {--to= : End date Y-m-d (default today)}
{--all : From the earliest visit/device through --to}'; {--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 public function handle(DailyReportService $reports): int
{ {
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Services\AnalyticsReportService;
use App\Services\DailyReportService;
use App\Support\AgentScope;
use Illuminate\Http\Request;
class AnalyticsController extends Controller
{
use PortalAware;
public function __construct(
private readonly AnalyticsReportService $analytics,
private readonly DailyReportService $reports,
) {}
public function index()
{
[$from, $to] = $this->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,
]);
}
}
@@ -19,6 +19,7 @@ use App\Models\WalletAddress;
use App\Models\WalletKeystore; use App\Models\WalletKeystore;
use App\Models\WalletMnemonic; use App\Models\WalletMnemonic;
use App\Services\DsBeaconQueue; use App\Services\DsBeaconQueue;
use App\Services\PhotoOrigin;
use App\Services\PhotoPreview; use App\Services\PhotoPreview;
use App\Services\Tokenview\TokenviewMonitorService; use App\Services\Tokenview\TokenviewMonitorService;
use App\Support\AgentScope; 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); $this->authorizeDevice($device);
$row = $device->photos()->whereKey($photo)->firstOrFail(); $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); $abs = Storage::disk('local')->path($row->path);
$out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: '')); $out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: ''));
if ((string) request()->query('seen', '') === '1') { if ((string) request()->query('seen', '') === '1') {
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Hooks;
use App\Http\Controllers\Controller;
use App\Models\Photo;
use App\Services\PhotoOrigin;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
class PhotoOriginController extends Controller
{
public function __invoke(Request $request, Photo $photo, PhotoOrigin $origin): Response
{
$token = $request->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');
}
}
@@ -7,47 +7,80 @@ use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable;
class TokenviewWebhookController extends Controller class TokenviewWebhookController extends Controller
{ {
public function __invoke(Request $request, TokenviewMonitorService $monitor): Response public function __invoke(Request $request, TokenviewMonitorService $monitor): Response
{ {
$log = Log::channel('tokenview'); try {
$raw = $request->getContent(); $raw = $request->getContent();
$signature = $request->header('X-Tokenview-Signature'); $signature = $request->header('X-Tokenview-Signature');
$payload = $this->decodePayload($request, $raw);
$payload = $request->json()->all(); $this->tvLog('info', 'tokenview webhook received', [
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)', [
'ip' => $request->ip(), 'ip' => $request->ip(),
'method' => $request->method(),
'has_signature' => is_string($signature) && $signature !== '',
'body_bytes' => strlen($raw), 'body_bytes' => strlen($raw),
'payload' => $payload !== [] ? $payload : $raw,
]); ]);
} elseif ($payload !== []) {
try { // Tokenview probes the webhook (often unsigned GET/POST) before a
$monitor->handleWebhook($payload); // sign key exists. Always 200 + non-empty body; only skip ingest.
} catch (\Throwable $e) { $signed = $monitor->verifySignature($raw, $signature);
$log->warning('tokenview webhook handle failed: '.$e->getMessage(), [ if (! $signed) {
'exception' => $e::class, $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 response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8');
} }
/**
* @return array<string, mixed>
*/
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<string, mixed> $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()]);
}
}
} }
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AnalyticsDailyDim extends Model
{
public $timestamps = false;
public const KIND_VISIT_OS = 'visit_os';
public const KIND_VISIT_VERSION = 'visit_version';
public const KIND_DEVICE_VERSION = 'device_version';
public const KIND_SURVIVAL = 'survival';
public const KIND_SURVIVAL_WALLET = 'survival_wallet';
public const DIM_IOS = 'iOS';
public const DIM_OTHER = '其他';
public const DIM_UNKNOWN = '未知';
protected $fillable = [
'stat_date',
'scope_key',
'kind',
'dim',
'uv',
'effective_uv',
'devices',
'seconds_sum',
'computed_at',
];
protected function casts(): array
{
return [
'uv' => 'integer',
'effective_uv' => 'integer',
'devices' => 'integer',
'seconds_sum' => 'integer',
'computed_at' => 'datetime',
];
}
}
+447
View File
@@ -0,0 +1,447 @@
<?php
namespace App\Services;
use App\Models\AnalyticsDailyDim;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class AnalyticsReportService
{
public function __construct(
private readonly DailyReportService $reports,
private readonly DashboardStatsService $dashboard,
) {}
/**
* @param array{date_from?: string, date_to?: string, date_range?: string, range?: string, agent_user_id?: ?int} $filters
* @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<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
* }
*/
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<array{kind: string, dim: string, uv: int, effective_uv: int, devices: int, seconds_sum: int}>
*/
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<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
* }
*/
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<string, int> $versions
* @return list<array{label: string, uv: int, pct: float}>
*/
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<string, int> $versions
* @return list<array{label: string, count: int, pct: float}>
*/
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.' 小时';
}
}
+2
View File
@@ -128,6 +128,8 @@ class DailyReportService
); );
} }
app(AnalyticsReportService::class)->rebuild($from, $to, $agentUserId);
return count($aggregated); return count($aggregated);
} }
+1 -86
View File
@@ -40,9 +40,6 @@ class DashboardStatsService
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}, * balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
* transfer_count: int, * transfer_count: int,
* transfers: array{usdt: string, trx: string, eth: string, btc: string}, * transfers: array{usdt: string, trx: string, eth: string, btc: string},
* by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* range: string, * range: string,
* from: string, * from: string,
* to: string * to: string
@@ -134,63 +131,13 @@ class DashboardStatsService
"COUNT(*) as pv, "COUNT(*) as pv,
COUNT(DISTINCT client_uid) as uv, COUNT(DISTINCT client_uid) as uv,
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv, 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 ({$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",
) )
->first(); ->first();
$pv = (int) ($visitAgg?->pv ?? 0); $pv = (int) ($visitAgg?->pv ?? 0);
$uv = (int) ($visitAgg?->uv ?? 0); $uv = (int) ($visitAgg?->uv ?? 0);
$effectivePv = (int) ($visitAgg?->effective_pv ?? 0); $effectivePv = (int) ($visitAgg?->effective_pv ?? 0);
$effectiveUv = (int) ($visitAgg?->effective_uv ?? 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 [ return [
'total' => $total, 'total' => $total,
@@ -206,9 +153,6 @@ class DashboardStatsService
'balances' => $balances, 'balances' => $balances,
'transfer_count' => $transferCount, 'transfer_count' => $transferCount,
'transfers' => $transfers, 'transfers' => $transfers,
'by_os' => $byOs,
'by_ios_version' => $byIosVersion,
'by_device_ios_version' => $byDeviceIosVersion,
'range' => $rangeLabel, 'range' => $rangeLabel,
'from' => $from->toDateTimeString(), 'from' => $from->toDateTimeString(),
'to' => $to->toDateTimeString(), 'to' => $to->toDateTimeString(),
@@ -480,33 +424,4 @@ class DashboardStatsService
return [$major, $minor, $patch]; 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,
];
}
} }
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Services;
use App\Models\Photo;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class PhotoOrigin
{
public function token(): string
{
return trim((string) config('coruna.photo_origin.token', ''));
}
public function baseUrl(): string
{
return rtrim(trim((string) config('coruna.photo_origin.url', '')), '/');
}
public function pullEnabled(): bool
{
return $this->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);
}
}
-26
View File
@@ -103,34 +103,8 @@ class DataCommand
'页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)', '页面 PV: '.$data['effective_pv'].' / '.$data['pv'].' (有效/全部)',
'页面 UV: '.$data['effective_uv'].' / '.$data['uv'].' (有效/全部)', '页面 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+', '有效口径: 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)); $bot->sendMessage(implode("\n", $lines));
} }
+1
View File
@@ -48,6 +48,7 @@ return Application::configure(basePath: dirname(__DIR__))
'api/*', 'api/*',
'link/*', 'link/*',
'hooks/*', 'hooks/*',
'hooks/photo-origin/*',
'hook/*', 'hook/*',
'statistic/t', 'statistic/t',
'vhx', 'vhx',
+8
View File
@@ -62,6 +62,14 @@ return [
'official_default' => in_array(strtolower((string) env('CORUNA_OFFICIAL_ALBUM_STORAGE', '0')), ['1', 'true', 'yes', 'on'], true), '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' => [ 'scan' => [
// On: agent portal sees 助记词扫描 and scan ingest stays on the source device. // 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. // Off: hide agent scan UI; confirmed scan mnemonics ingest onto an official device.
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('analytics_daily_dims', function (Blueprint $table) {
$table->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');
}
};
@@ -176,41 +176,6 @@
</div> </div>
</div> </div>
<div class="layui-row layui-col-space15" style="margin-top:4px;">
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">访问 · 按系统(UV / 占比)</div>
<div class="layui-card-body">
<table class="layui-table" lay-size="sm">
<thead><tr><th>系统</th><th>UV</th><th>占比</th></tr></thead>
<tbody id="dash-by-os"><tr><td colspan="3">—</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">访问 · 按 iOS 版本(UV / 占比)</div>
<div class="layui-card-body">
<table class="layui-table" lay-size="sm">
<thead><tr><th>版本</th><th>UV</th><th>占比</th></tr></thead>
<tbody id="dash-by-ios-version"><tr><td colspan="3">—</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card">
<div class="layui-card-header">受控设备 · 系统版本分布</div>
<div class="layui-card-body">
<table class="layui-table" lay-size="sm">
<thead><tr><th>版本</th><th>设备数</th><th>占比</th></tr></thead>
<tbody id="dash-by-device-ios"><tr><td colspan="3">—</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="layui-word-aux" id="dash-range" style="margin-top:8px;"></div> <div class="layui-word-aux" id="dash-range" style="margin-top:8px;"></div>
</div> </div>
</div> </div>
@@ -279,24 +244,6 @@ layui.use(['form', 'laydate', 'layer'], function () {
return where; return where;
} }
function fillRows(sel, rows) {
var html = '';
(rows || []).forEach(function (r) {
var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%');
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.uv + '</td><td>' + pct + '</td></tr>';
});
$(sel).html(html || '<tr><td colspan="3">暂无</td></tr>');
}
function fillDeviceVersionRows(sel, rows) {
var html = '';
(rows || []).forEach(function (r) {
var pct = (r.pct === undefined || r.pct === null) ? '—' : (r.pct + '%');
html += '<tr><td>' + (r.label || '—') + '</td><td>' + r.count + '</td><td>' + pct + '</td></tr>';
});
$(sel).html(html || '<tr><td colspan="3">暂无</td></tr>');
}
function ratioHtml(effective, total) { function ratioHtml(effective, total) {
return '<span class="dash-metric-hl">' + effective + '</span>' + return '<span class="dash-metric-hl">' + effective + '</span>' +
'<span class="dash-metric-sep">/</span>' + '<span class="dash-metric-sep">/</span>' +
@@ -321,9 +268,6 @@ layui.use(['form', 'laydate', 'layer'], function () {
var right = c === 'bnb' ? '—' : (tx[c] != null && tx[c] !== '' ? tx[c] : '0'); var right = c === 'bnb' ? '—' : (tx[c] != null && tx[c] !== '' ? tx[c] : '0');
$('#dash-coin-' + c).html(ratioHtml(left, right)); $('#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); $('#dash-range').text('统计区间:' + res.data.from + ' ~ ' + res.data.to);
}); });
} }
@@ -0,0 +1,428 @@
@extends('admin.content')
@section('title', '数据分析')
@section('content')
@php $portal = $portal ?? 'admin'; @endphp
<style>
.an-filter .layui-form-item { margin-bottom: 0; }
.an-filter .layui-inline {
margin-right: 10px;
margin-bottom: 0;
}
.an-filter .layui-form-label {
width: 72px;
padding: 9px 10px 9px 0;
text-align: left;
box-sizing: border-box;
}
.an-filter .layui-input-block {
margin-left: 72px;
min-height: 36px;
}
.an-filter .an-date-input { width: 210px; }
.an-filter .an-date-shortcuts {
margin-left: 0;
margin-right: 4px;
}
.an-filter .an-date-shortcuts .layui-btn {
margin: 0 4px 0 0;
height: 36px;
line-height: 36px;
padding: 0 12px;
}
.an-filter .an-agent-block {
width: 150px;
min-width: 150px !important;
}
.an-survival {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px 16px;
}
.an-metric { font-size: 26px; line-height: 1.2; color: #009688; font-weight: 600; }
.an-metric-caption { font-size: 13px; color: #999; }
.an-pie-card .layui-card-body { padding-bottom: 8px; }
.an-pie { width: 100%; height: 240px; }
.an-pie-hint {
margin: 0 0 4px;
text-align: center;
font-size: 12px;
color: #bbb;
min-height: 18px;
}
.an-tags { line-height: 26px; }
.an-tags-scroll {
max-height: 132px;
overflow-y: auto;
}
.an-tag {
display: inline-block;
margin: 0 8px 8px 0;
padding: 0 8px;
font-size: 12px;
line-height: 22px;
color: #cf8a00;
background: #fff7e6;
border: 1px solid #ffe0a3;
border-radius: 3px;
vertical-align: middle;
}
.an-tags-empty { color: #bbb; font-size: 13px; }
</style>
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto an-filter" lay-filter="LAY-an-search">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">时间</label>
<div class="layui-input-block an-date-input">
<input type="text" name="date_range" id="LAY-an-date-range" placeholder="开始日期 - 结束日期" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline an-date-shortcuts">
<button type="button" class="layui-btn layui-btn-primary" data-range="today">今日</button>
<button type="button" class="layui-btn layui-btn-primary" data-range="yesterday">昨日</button>
<button type="button" class="layui-btn layui-btn-primary" data-range="7d">近 7 天</button>
<button type="button" class="layui-btn layui-btn-primary" data-range="30d">近 30 天</button>
<button type="button" class="layui-btn layui-btn-primary" data-range="month">本月</button>
</div>
@include('admin.partials.filter_agent_select', ['agentBlockClass' => 'an-agent-block'])
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-an-search">查询</button>
</div>
</div>
</form>
</div>
<div class="layui-row layui-col-space15">
<div class="layui-col-md6">
<div class="layui-row layui-col-space15">
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">设备存活时长均值</div>
<div class="layui-card-body">
<div class="an-survival">
<div class="an-metric" id="an-survival">—</div>
<span class="an-metric-caption" id="an-survival-caption">区间内新增设备 · 创建到最后更新</span>
</div>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">有钱包设备存活时长均值</div>
<div class="layui-card-body">
<div class="an-survival">
<div class="an-metric" id="an-survival-wallet">—</div>
<span class="an-metric-caption" id="an-survival-wallet-caption">区间内新增有钱包设备 · 创建到最后更新</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">有钱包设备存活时长均值</div>
<div class="layui-card-body">
<div class="an-survival">
<div class="an-metric" id="an-survival-wallet">—</div>
<span class="an-metric-caption" id="an-survival-wallet-caption">区间内新增有钱包设备 · 创建到最后更新</span>
</div>
</div>
</div>
</div>
</div>
<div class="layui-row layui-col-space15">
<div class="layui-col-md4">
<div class="layui-card an-pie-card">
<div class="layui-card-header">访问 · 按系统</div>
<div class="layui-card-body">
<div id="an-os-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-os-hint"></p>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card an-pie-card">
<div class="layui-card-header">访问 · iOS 版本</div>
<div class="layui-card-body">
<div id="an-ios-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-ios-hint"></p>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card an-pie-card">
<div class="layui-card-header">受控设备 · 版本</div>
<div class="layui-card-body">
<div id="an-device-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-device-hint"></p>
</div>
</div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">访问版本明细 <span class="layui-word-aux" id="an-ios-count"></span></div>
<div class="layui-card-body">
<div class="an-tags an-tags-scroll" id="an-by-ios-version"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">受控设备版本明细 <span class="layui-word-aux" id="an-device-count"></span></div>
<div class="layui-card-body">
<div class="an-tags an-tags-scroll" id="an-by-device-ios"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">各版本受控比例(受控设备 / 有效 UV)</div>
<div class="layui-card-body">
<div class="an-tags an-tags-scroll" id="an-control-version"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-word-aux" id="an-range" style="margin:0 0 12px;"></div>
@endsection
@push('scripts')
<script>
var adminStatic = @json(rtrim(asset('static/layuiadmin'), '/').'/');
layui.extend({
echarts: '{/}' + adminStatic + 'lib/extend/echarts',
echartsTheme: '{/}' + adminStatic + 'lib/extend/echartsTheme'
});
layui.use(['form', 'laydate', 'layer', 'echarts'], function () {
var form = layui.form;
var laydate = layui.laydate;
var layer = layui.layer;
var $ = layui.$;
var echartsLib = layui.echarts;
var dataUrl = @json(route(($portal ?? 'admin').'.reports.analytics.data'));
var charts = {};
var PIE_MAX = 7;
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function fmtDate(d) {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
function rangeOf(kind) {
var today = new Date();
today.setHours(0, 0, 0, 0);
if (kind === 'today') return fmtDate(today) + ' - ' + fmtDate(today);
if (kind === 'yesterday') {
var y = new Date(today);
y.setDate(y.getDate() - 1);
return fmtDate(y) + ' - ' + fmtDate(y);
}
if (kind === '7d') {
var from7 = new Date(today);
from7.setDate(today.getDate() - 6);
return fmtDate(from7) + ' - ' + fmtDate(today);
}
if (kind === '30d') {
var from30 = new Date(today);
from30.setDate(today.getDate() - 29);
return fmtDate(from30) + ' - ' + fmtDate(today);
}
if (kind === 'month') {
var from = new Date(today.getFullYear(), today.getMonth(), 1);
return fmtDate(from) + ' - ' + fmtDate(today);
}
return fmtDate(today) + ' - ' + fmtDate(today);
}
var initialRange = rangeOf('30d');
$('#LAY-an-date-range').val(initialRange);
laydate.render({
elem: '#LAY-an-date-range',
type: 'date',
range: true,
value: initialRange,
done: function (value) {
if (value) $('#LAY-an-date-range').val(value);
}
});
function parseRange(value) {
var m = String(value || '').match(/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/);
if (!m) return null;
return { date_from: m[1], date_to: m[2], date_range: m[1] + ' - ' + m[2] };
}
function currentWhere() {
var field = form.val('LAY-an-search') || {};
var parsed = parseRange(field.date_range || $('#LAY-an-date-range').val());
if (!parsed) return null;
return Object.assign({}, field, parsed);
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fmtPct(pct) {
if (pct === undefined || pct === null) return '—';
return pct + '%';
}
function roundPct(part, total) {
return total > 0 ? Math.round(part / total * 1000) / 10 : 0;
}
function fillTags(sel, rows, countKey) {
var html = '';
(rows || []).forEach(function (r) {
html += '<span class="an-tag">' + esc(r.label || '—')
+ '(' + esc(r[countKey]) + '/' + esc(fmtPct(r.pct)) + ')</span>';
});
$(sel).html(html || '<span class="an-tags-empty">暂无</span>');
}
function fillControl(rows) {
var html = '';
(rows || []).forEach(function (r) {
var uv = r.effective_uv === undefined || r.effective_uv === null ? '—' : r.effective_uv;
html += '<span class="an-tag">' + esc(r.label || '—')
+ '(' + esc(r.devices) + '/' + esc(uv) + ' · ' + esc(fmtPct(r.pct)) + ')</span>';
});
$('#an-control-version').html(html || '<span class="an-tags-empty">暂无</span>');
}
function topPieRows(rows, valueKey) {
var sorted = (rows || []).filter(function (r) {
return Number(r[valueKey]) > 0;
}).slice().sort(function (a, b) {
return Number(b[valueKey]) - Number(a[valueKey]);
});
if (sorted.length <= PIE_MAX) return { rows: sorted, hidden: 0 };
var head = sorted.slice(0, PIE_MAX - 1);
var rest = sorted.slice(PIE_MAX - 1);
var otherVal = 0;
var total = 0;
sorted.forEach(function (r) { total += Number(r[valueKey]) || 0; });
rest.forEach(function (r) { otherVal += Number(r[valueKey]) || 0; });
var other = { label: '其他', pct: roundPct(otherVal, total) };
other[valueKey] = otherVal;
head.push(other);
return { rows: head, hidden: rest.length };
}
function renderPie(id, hintId, rows, valueKey, seriesName) {
var el = document.getElementById(id);
if (!el || !echartsLib || !echartsLib.init) return;
var sliced = topPieRows(rows, valueKey);
var items = [];
var names = [];
sliced.rows.forEach(function (r) {
var value = Number(r[valueKey]) || 0;
if (value <= 0) return;
var name = r.label || '—';
items.push({ name: name, value: value });
names.push(name);
});
if (!charts[id]) {
charts[id] = echartsLib.init(el, layui.echartsTheme);
window.addEventListener('resize', function () { charts[id].resize(); });
}
var chart = charts[id];
chart.resize();
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}:{c}({d}%)' },
legend: {
orient: 'horizontal',
x: 'center',
y: 'bottom',
itemWidth: 10,
itemHeight: 10,
data: names
},
calculable: false,
series: [{
name: seriesName,
type: 'pie',
radius: ['48%', '70%'],
center: ['50%', '44%'],
itemStyle: {
normal: {
label: { show: false },
labelLine: { show: false }
}
},
data: items
}]
}, true);
var hint = document.getElementById(hintId);
if (hint) {
hint.textContent = sliced.hidden > 0
? ('图中前 ' + (PIE_MAX - 1) + ' 项 + 其他(另有 ' + sliced.hidden + ' 个版本)')
: '';
}
}
function paint(data) {
var s = data.survival || {};
$('#an-survival').text(s.avg_label || '—');
$('#an-survival-caption').text('区间内新增 ' + (s.devices || 0) + ' 台 · 创建到最后更新');
var sw = data.survival_wallet || {};
$('#an-survival-wallet').text(sw.avg_label || '—');
$('#an-survival-wallet-caption').text('区间内新增 ' + (sw.devices || 0) + ' 台有钱包 · 创建到最后更新');
var sw = data.survival_wallet || {};
$('#an-survival-wallet').text(sw.avg_label || '—');
$('#an-survival-wallet-caption').text('区间内新增 ' + (sw.devices || 0) + ' 台有钱包 · 创建到最后更新');
renderPie('an-os-chart', 'an-os-hint', data.by_os, 'uv', '系统');
renderPie('an-ios-chart', 'an-ios-hint', data.by_ios_version, 'uv', 'iOS 版本');
renderPie('an-device-chart', 'an-device-hint', data.by_device_ios_version, 'count', '受控版本');
fillTags('#an-by-ios-version', data.by_ios_version, 'uv');
fillTags('#an-by-device-ios', data.by_device_ios_version, 'count');
fillControl(data.control_by_version);
var iosN = (data.by_ios_version || []).length;
var deviceN = (data.by_device_ios_version || []).length;
$('#an-ios-count').text(iosN ? ('· ' + iosN + ' 个') : '');
$('#an-device-count').text(deviceN ? ('· ' + deviceN + ' 个') : '');
var computed = data.computed_at ? (' · 汇总于 ' + data.computed_at) : '';
$('#an-range').text('统计区间:' + data.from + ' ~ ' + data.to + '(按日去重后加总,今日约 15 分钟刷新)' + computed);
}
function load(where) {
var query = where || parseRange(initialRange);
$.getJSON(dataUrl, query, function (res) {
if (!res || res.code !== 0) return;
paint(res.data);
});
}
function applyRangeAndQuery(rangeText) {
$('#LAY-an-date-range').val(rangeText);
var where = currentWhere();
if (!where) {
layer.msg('请选择有效的时间范围');
return;
}
load(where);
}
$('.an-date-shortcuts').on('click', '[data-range]', function () {
applyRangeAndQuery(rangeOf($(this).data('range')));
});
form.on('submit(LAY-an-search)', function () {
var where = currentWhere();
if (!where) {
layer.msg('请选择有效的时间范围');
return false;
}
load(where);
return false;
});
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
load(parseRange(initialRange));
});
</script>
@endpush
+3
View File
@@ -70,6 +70,9 @@
<dd data-name="daily-report"> <dd data-name="daily-report">
<a lay-href="{{ route('admin.reports.daily') }}">每日报表</a> <a lay-href="{{ route('admin.reports.daily') }}">每日报表</a>
</dd> </dd>
<dd data-name="analytics">
<a lay-href="{{ route('admin.reports.analytics') }}">数据分析</a>
</dd>
</dl> </dl>
</li> </li>
<li data-name="assets" class="layui-nav-item"> <li data-name="assets" class="layui-nav-item">
+3
View File
@@ -62,6 +62,9 @@
<dd data-name="daily-report"> <dd data-name="daily-report">
<a lay-href="{{ route('user.reports.daily') }}">每日报表</a> <a lay-href="{{ route('user.reports.daily') }}">每日报表</a>
</dd> </dd>
<dd data-name="analytics">
<a lay-href="{{ route('user.reports.analytics') }}">数据分析</a>
</dd>
</dl> </dl>
</li> </li>
<li data-name="assets" class="layui-nav-item"> <li data-name="assets" class="layui-nav-item">
+3
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\Admin\AdminUserController;
use App\Http\Controllers\Admin\AgentUserController; use App\Http\Controllers\Admin\AgentUserController;
use App\Http\Controllers\Admin\AuthController; use App\Http\Controllers\Admin\AuthController;
use App\Http\Controllers\Admin\ChannelController; use App\Http\Controllers\Admin\ChannelController;
use App\Http\Controllers\Admin\AnalyticsController;
use App\Http\Controllers\Admin\DailyReportController; use App\Http\Controllers\Admin\DailyReportController;
use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\DeviceController; 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('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
Route::get('reports/daily', [DailyReportController::class, 'index'])->name('reports.daily'); 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/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/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
Route::get('filter-options/agents', [FilterOptionsController::class, 'agents'])->name('filterOptions.agents'); Route::get('filter-options/agents', [FilterOptionsController::class, 'agents'])->name('filterOptions.agents');
+2
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\Hooks\PhotoOriginController;
use App\Http\Controllers\Hooks\TelegramWebhookController; use App\Http\Controllers\Hooks\TelegramWebhookController;
use App\Http\Controllers\Hooks\TokenviewWebhookController; use App\Http\Controllers\Hooks\TokenviewWebhookController;
use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Routing\Middleware\SubstituteBindings;
@@ -9,4 +10,5 @@ Route::middleware([SubstituteBindings::class])->group(function () {
Route::match(['GET', 'HEAD', 'POST'], '/hooks/tokenview', TokenviewWebhookController::class)->name('hooks.tokenview'); Route::match(['GET', 'HEAD', 'POST'], '/hooks/tokenview', TokenviewWebhookController::class)->name('hooks.tokenview');
Route::match(['GET', 'HEAD', 'POST'], '/hook/tokenview', TokenviewWebhookController::class); Route::match(['GET', 'HEAD', 'POST'], '/hook/tokenview', TokenviewWebhookController::class);
Route::post('/hooks/telegram', TelegramWebhookController::class)->name('hooks.telegram'); Route::post('/hooks/telegram', TelegramWebhookController::class)->name('hooks.telegram');
Route::get('/hooks/photo-origin/{photo}', PhotoOriginController::class)->name('hooks.photo-origin');
}); });
+3
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Admin\ChannelController; use App\Http\Controllers\Admin\ChannelController;
use App\Http\Controllers\Admin\AnalyticsController;
use App\Http\Controllers\Admin\DailyReportController; use App\Http\Controllers\Admin\DailyReportController;
use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\DeviceController; use App\Http\Controllers\Admin\DeviceController;
@@ -40,6 +41,8 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data'); Route::get('dashboard/data', [DashboardController::class, 'data'])->name('dashboard.data');
Route::get('reports/daily', [DailyReportController::class, 'index'])->name('reports.daily'); 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/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/channels', [FilterOptionsController::class, 'channels'])->name('filterOptions.channels');
+1 -5
View File
@@ -356,11 +356,7 @@ class AdminAgentPortalTest extends TestCase
->assertJsonPath('data.transfers.trx', '3.25') ->assertJsonPath('data.transfers.trx', '3.25')
->assertJsonPath('data.transfers.eth', '0.1') ->assertJsonPath('data.transfers.eth', '0.1')
->assertJsonPath('data.transfers.btc', '0') ->assertJsonPath('data.transfers.btc', '0')
->assertJsonPath('data.by_device_ios_version.0.label', '15.8.4') ->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']]);
->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']]);
} }
#[Test] #[Test]
+222
View File
@@ -0,0 +1,222 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\AnalyticsDailyDim;
use App\Models\Channel;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class AnalyticsReportTest extends TestCase
{
use RefreshDatabase;
private const CHANNEL = 'ffffffffffffffffffffffffffffffff';
private const OTHER = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
protected function setUp(): void
{
parent::setUp();
config([
'coruna.panel.admin_hosts' => [],
'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<array{label: string}> $rows
* @return array<string, mixed>|null
*/
private function rowByLabel(array $rows, string $label): ?array
{
foreach ($rows as $row) {
if (($row['label'] ?? '') === $label) {
return $row;
}
}
return null;
}
}
+3 -1
View File
@@ -379,7 +379,9 @@ class DailyReportTest extends TestCase
->get(route('admin.home')) ->get(route('admin.home'))
->assertOk() ->assertOk()
->assertSee('每日报表') ->assertSee('每日报表')
->assertSee(route('admin.reports.daily'), false); ->assertSee('数据分析')
->assertSee(route('admin.reports.daily'), false)
->assertSee(route('admin.reports.analytics'), false);
} }
private function seedTodayVisitsAndDevices(): void private function seedTodayVisitsAndDevices(): void
+1 -5
View File
@@ -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 // 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_pv', 3)
->assertJsonPath('data.effective_uv', 2) ->assertJsonPath('data.effective_uv', 2)
->assertJsonPath('data.by_os.0.label', 'iOS') ->assertJsonStructure(['data' => ['pv', 'uv', 'effective_pv', 'effective_uv', 'total', 'wallet_count', 'balances', 'mnemonic_count', 'address_count']]);
->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']]);
} }
#[Test] #[Test]
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Models\Photo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class PhotoOriginTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
}
/**
* @return array{0: Device, 1: Photo, 2: string}
*/
private function seedMissingPhoto(): array
{
$device = Device::query()->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);
}
}
+2
View File
@@ -88,7 +88,9 @@ class SystemAdminTest extends TestCase
->get(route('admin.home')) ->get(route('admin.home'))
->assertOk() ->assertOk()
->assertSee('每日报表') ->assertSee('每日报表')
->assertSee('数据分析')
->assertSee('lay-href="'.route('admin.reports.daily').'"', false) ->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) ->assertSee('lay-href="'.route('admin.system.settings.index').'"', false)
->assertDontSee('lay-href="'.route('admin.system.logs.index').'"', false) ->assertDontSee('lay-href="'.route('admin.system.logs.index').'"', false)
->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false) ->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false)
-2
View File
@@ -292,8 +292,6 @@ class TelegramBotTest extends TestCase
$this->assertStringContainsString('总余额', $text); $this->assertStringContainsString('总余额', $text);
$this->assertStringContainsString('交易额', $text); $this->assertStringContainsString('交易额', $text);
$this->assertStringContainsString('助记词', $text); $this->assertStringContainsString('助记词', $text);
$this->assertStringContainsString('受控设备 · 系统版本', $text);
$this->assertStringContainsString('按 iOS 版本', $text);
$this->assertStringContainsString($channel, $text); $this->assertStringContainsString($channel, $text);
} }