fix: link old
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.' 小时';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,8 @@ class DailyReportService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app(AnalyticsReportService::class)->rebuild($from, $to, $agentUserId);
|
||||||
|
|
||||||
return count($aggregated);
|
return count($aggregated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user