diff --git a/app/Http/Controllers/Admin/PageVisitController.php b/app/Http/Controllers/Admin/PageVisitController.php index 48f0194..beed0c9 100644 --- a/app/Http/Controllers/Admin/PageVisitController.php +++ b/app/Http/Controllers/Admin/PageVisitController.php @@ -4,12 +4,12 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; -use App\Models\DsChainLog; use App\Models\Device; +use App\Models\DsChainLog; use App\Models\PageVisit; use App\Models\User; -use App\Support\CfIpCountry; use App\Support\AgentScope; +use App\Support\CfIpCountry; use Carbon\Carbon; use Illuminate\Http\Request; @@ -154,7 +154,7 @@ class PageVisitController extends Controller $q->where('referer', 'like', '%'.$referer.'%'); } - [$from, $to] = $this->rangeBounds((string) $request->query('range', '30d')); + [$from, $to] = $this->rangeBounds((string) $request->query('range', 'today')); $q->whereBetween('created_at', [$from, $to]); return $q; diff --git a/app/Models/DailyStat.php b/app/Models/DailyStat.php index 3a8bfc8..8ba8d83 100644 --- a/app/Models/DailyStat.php +++ b/app/Models/DailyStat.php @@ -13,7 +13,9 @@ class DailyStat extends Model protected $fillable = [ 'stat_date', 'scope_key', + 'pv', 'uv', + 'effective_pv', 'effective_uv', 'devices', 'wallet_devices', @@ -25,7 +27,9 @@ class DailyStat extends Model protected function casts(): array { return [ + 'pv' => 'integer', 'uv' => 'integer', + 'effective_pv' => 'integer', 'effective_uv' => 'integer', 'devices' => 'integer', 'wallet_devices' => 'integer', @@ -34,4 +38,4 @@ class DailyStat extends Model 'computed_at' => 'datetime', ]; } -}; +} diff --git a/app/Services/DailyReportService.php b/app/Services/DailyReportService.php index 8e972e6..31936aa 100644 --- a/app/Services/DailyReportService.php +++ b/app/Services/DailyReportService.php @@ -12,6 +12,7 @@ use App\Models\WalletAddress; use App\Models\WalletMnemonic; use App\Support\AgentScope; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class DailyReportService @@ -122,12 +123,14 @@ class DailyReportService $end = $to->copy()->startOfDay(); while ($cursor->lte($end)) { if ($force || $cursor->gte($refreshFrom) || ! $this->dayIsFresh($cursor, $agentUserId)) { - [$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount] = $this->totalsForDay($cursor, $agentUserId); + [$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount, $pv, $effectivePv] = $this->totalsForDay($cursor, $agentUserId); $row = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount); DailyStat::query()->updateOrCreate( ['stat_date' => $row['date'], 'scope_key' => $scope], [ + 'pv' => $pv, 'uv' => $row['uv'], + 'effective_pv' => $effectivePv, 'effective_uv' => $row['effective_uv'], 'devices' => $row['devices'], 'wallet_devices' => $row['wallet_devices'], @@ -233,6 +236,7 @@ class DailyReportService ->where('stat_date', $date) ->whereNotNull('computed_at') ->whereNotNull('address_count') + ->whereNotNull('pv') ->exists(); if (! $statOk) { return false; @@ -272,7 +276,7 @@ class DailyReportService } /** - * @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int} + * @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int, 6: int, 7: int} */ private function totalsForDay(Carbon $day, ?int $agentUserId): array { @@ -285,7 +289,9 @@ class DailyReportService $visit = $visits ->whereBetween('created_at', [$from, $to]) ->selectRaw( - "COUNT(DISTINCT client_uid) as uv, + "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(); @@ -307,11 +313,13 @@ class DailyReportService (int) ($device?->wallet_devices ?? 0), $this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId), $this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId), + (int) ($visit?->pv ?? 0), + (int) ($visit?->effective_pv ?? 0), ]; } private function countDeviceChildrenForDay( - \Illuminate\Database\Eloquent\Builder $query, + Builder $query, string $table, Carbon $from, Carbon $to, diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php index 167965e..b4f6b6b 100644 --- a/app/Services/DashboardStatsService.php +++ b/app/Services/DashboardStatsService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\DailyStat; use App\Models\Device; use App\Models\PageVisit; use App\Models\TransferRecord; @@ -11,10 +12,13 @@ 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, @@ -48,8 +52,6 @@ class DashboardStatsService 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; @@ -57,6 +59,74 @@ class DashboardStatsService ? $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); @@ -71,37 +141,63 @@ class DashboardStatsService } } - // 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, - ); + 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, @@ -111,38 +207,10 @@ class DashboardStatsService $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", - ) - ->first(); - $pv = (int) ($visitAgg?->pv ?? 0); - $uv = (int) ($visitAgg?->uv ?? 0); - $effectivePv = (int) ($visitAgg?->effective_pv ?? 0); - $effectiveUv = (int) ($visitAgg?->effective_uv ?? 0); - return [ 'total' => $total, 'wallet_count' => $walletCount, - 'new_count' => $newCount, + 'new_count' => $total, 'active_count' => $activeCount, 'pv' => $pv, 'uv' => $uv, @@ -161,6 +229,88 @@ class DashboardStatsService ]; } + /** + * 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} @@ -423,5 +573,4 @@ class DashboardStatsService return [$major, $minor, $patch]; } - } diff --git a/database/migrations/2026_10_11_000050_daily_stats_pv.php b/database/migrations/2026_10_11_000050_daily_stats_pv.php new file mode 100644 index 0000000..f253fb4 --- /dev/null +++ b/database/migrations/2026_10_11_000050_daily_stats_pv.php @@ -0,0 +1,23 @@ +unsignedBigInteger('pv')->nullable()->after('scope_key'); + $table->unsignedBigInteger('effective_pv')->nullable()->after('effective_uv'); + }); + } + + public function down(): void + { + Schema::table('daily_stats', function (Blueprint $table) { + $table->dropColumn(['pv', 'effective_pv']); + }); + } +}; diff --git a/resources/views/admin/visits/index.blade.php b/resources/views/admin/visits/index.blade.php index 59fb217..cac20da 100644 --- a/resources/views/admin/visits/index.blade.php +++ b/resources/views/admin/visits/index.blade.php @@ -11,10 +11,10 @@
@@ -80,7 +80,7 @@