*/ public function rows(?User $agent = null, array $filters = []): array { [$from, $to] = $this->resolveBounds($filters); $agentUserId = $this->resolveAgentUserId($agent, $filters); $this->ensureCached($from, $to, $agentUserId); return DailyStat::query() ->where('scope_key', self::scopeKey($agentUserId)) ->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]) ->orderByDesc('stat_date') ->get() ->map(fn (DailyStat $row) => $this->present( (string) $row->stat_date, (int) $row->uv, (int) $row->effective_uv, (int) $row->devices, (int) $row->wallet_devices, (int) ($row->address_count ?? 0), (int) ($row->mnemonic_count ?? 0), )) ->all(); } /** * Portal agent wins; otherwise admin 「代理用户」filter (null = 全部, 0 = 官方). * * @param array{agent_user_id?: ?int} $filters */ public function resolveAgentUserId(?User $agent, array $filters = []): ?int { if ($agent !== null) { return (int) $agent->id; } return array_key_exists('agent_user_id', $filters) ? $filters['agent_user_id'] : null; } /** * Inclusive local calendar window. Empty filters → last 30 days. `to` is clamped to today. * * @param array{date_from?: string, date_to?: string, date_range?: string, range?: string} $filters * @return array{0: Carbon, 1: Carbon} */ public function resolveBounds(array $filters = []): array { $filters['range'] = (string) ($filters['range'] ?? '30d'); [$from, $to] = $this->dashboard->resolveBounds($filters); $todayEnd = Carbon::now()->endOfDay(); if ($to->gt($todayEnd)) { $to = $todayEnd; } if ($from->gt($to)) { $from = $to->copy()->startOfDay(); } $days = $this->dayCount($from, $to); if ($days > self::MAX_DAYS) { $from = $to->copy()->subDays(self::MAX_DAYS - 1)->startOfDay(); } return [$from->copy()->startOfDay(), $to->copy()->endOfDay()]; } /** * Rebuild one scope (site-wide, official, or one agent) for the inclusive local-date range. */ 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->window(); $aggregated = $this->aggregate($from, $to, $agentUserId); $scope = self::scopeKey($agentUserId); $now = Carbon::now(); foreach ($aggregated as $row) { DailyStat::query()->updateOrCreate( ['stat_date' => $row['date'], 'scope_key' => $scope], [ 'uv' => $row['uv'], 'effective_uv' => $row['effective_uv'], 'devices' => $row['devices'], 'wallet_devices' => $row['wallet_devices'], 'address_count' => $row['address_count'], 'mnemonic_count' => $row['mnemonic_count'], 'computed_at' => $now, ], ); } app(AnalyticsReportService::class)->rebuild($from, $to, $agentUserId); return count($aggregated); } /** * Cron entry: site-wide plus every enabled agent that owns a channel. * * @return array{days: int, agents: int} */ public function rebuildAll(?Carbon $from = null, ?Carbon $to = null): array { $days = $this->rebuild($from, $to, null); $this->rebuild($from, $to, 0); $agents = 0; foreach ($this->agentsWithChannels() as $agent) { $this->rebuild($from, $to, (int) $agent->id); $agents++; } return ['days' => $days, 'agents' => $agents]; } /** * @return array{0: Carbon, 1: Carbon} */ public function window(): array { $now = Carbon::now(); return [$now->copy()->subDays(self::DAYS - 1)->startOfDay(), $now->copy()->endOfDay()]; } /** Earliest visit or device calendar day, or null if both tables are empty. */ public function earliestDataDate(): ?Carbon { $raw = array_filter([ PageVisit::query()->min('created_at'), Device::query()->min('created_at'), WalletAddress::query()->min('created_at'), WalletMnemonic::query()->min('created_at'), ], static fn ($v) => $v !== null && trim((string) $v) !== ''); if ($raw === []) { return null; } return Carbon::parse((string) min($raw))->timezone((string) config('app.timezone'))->startOfDay(); } private function ensureCached(Carbon $from, Carbon $to, ?int $agentUserId): void { $scope = self::scopeKey($agentUserId); $expected = $this->dayCount($from, $to); $cached = DailyStat::query() ->where('scope_key', $scope) ->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()]); $have = (clone $cached)->count(); $needsAssets = (clone $cached)->whereNull('address_count')->exists(); if ($have < $expected || $needsAssets) { $this->rebuild($from, $to, $agentUserId); return; } $today = Carbon::now()->toDateString(); if ($from->toDateString() > $today || $to->toDateString() < $today) { return; } $row = DailyStat::query() ->where('scope_key', $scope) ->where('stat_date', $today) ->first(); if ($row === null || $row->computed_at === null || $row->computed_at->lt(Carbon::now()->subMinutes(self::TODAY_STALE_MINUTES))) { $this->rebuild(Carbon::now()->startOfDay(), Carbon::now()->endOfDay(), $agentUserId); } } private function dayCount(Carbon $from, Carbon $to): int { return (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1; } /** * @return Collection */ private function agentsWithChannels(): Collection { $ids = Channel::query() ->where('user_id', '>', 0) ->distinct() ->pluck('user_id'); if ($ids->isEmpty()) { return collect(); } return User::query() ->where('status', 1) ->whereIn('id', $ids) ->orderBy('id') ->get(); } /** * @return list */ private function aggregate(Carbon $from, Carbon $to, ?int $agentUserId): array { $out = []; $cursor = $from->copy()->startOfDay(); $end = $to->copy()->startOfDay(); while ($cursor->lte($end)) { [$uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount] = $this->totalsForDay($cursor, $agentUserId); $out[] = $this->present($cursor->toDateString(), $uv, $effectiveUv, $devices, $walletDevices, $addressCount, $mnemonicCount); $cursor->addDay(); } return array_reverse($out); } /** * @return array{0: int, 1: int, 2: int, 3: int, 4: int, 5: int} */ private function totalsForDay(Carbon $day, ?int $agentUserId): array { $from = $day->copy()->startOfDay(); $to = $day->copy()->endOfDay(); $effectiveSql = $this->dashboard->effectiveVisitSql(); $visits = PageVisit::query(); AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId); $visit = $visits ->whereBetween('created_at', [$from, $to]) ->selectRaw( "COUNT(DISTINCT client_uid) as uv, COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv" ) ->first(); $devices = Device::query(); AgentScope::applyAgentUserFilter($devices, $agentUserId); $device = $devices ->whereBetween('created_at', [$from, $to]) ->selectRaw( 'COUNT(*) as devices, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_devices', [Device::WALLET_YES], ) ->first(); return [ (int) ($visit?->uv ?? 0), (int) ($visit?->effective_uv ?? 0), (int) ($device?->devices ?? 0), (int) ($device?->wallet_devices ?? 0), $this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId), $this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId), ]; } private function countDeviceChildrenForDay( \Illuminate\Database\Eloquent\Builder $query, string $table, Carbon $from, Carbon $to, ?int $agentUserId, ): int { $query->join('devices', 'devices.id', '=', $table.'.device_id'); AgentScope::applyAgentUserFilter($query, $agentUserId); return (int) $query ->whereBetween($table.'.created_at', [$from, $to]) ->count($table.'.id'); } /** * @return array{date: string, uv: int, effective_uv: int, devices: int, control_rate: float|null, wallet_devices: int, address_count: int, mnemonic_count: int} */ private function present( string $date, int $uv, int $effectiveUv, int $devices, int $walletDevices, int $addressCount, int $mnemonicCount, ): array { return [ 'date' => $date, 'uv' => $uv, 'effective_uv' => $effectiveUv, 'devices' => $devices, 'control_rate' => $effectiveUv > 0 ? round($devices / $effectiveUv * 100, 1) : null, 'wallet_devices' => $walletDevices, 'address_count' => $addressCount, 'mnemonic_count' => $mnemonicCount, ]; } }