, * by_ios_version: list, * range: string, * from: string, * to: string * } */ public function collect(array $filters = []): array { $range = (string) ($filters['range'] ?? '30d'); [$from, $to] = $this->rangeBounds($range); $channelId = trim((string) ($filters['channel_id'] ?? '')); $channelExact = (bool) ($filters['channel_exact'] ?? false); $agent = $filters['agent'] ?? null; $agentUserId = (int) ($filters['agent_user_id'] ?? 0); $base = Device::query(); AgentScope::applyDeviceChannelScope($base, $agent); if ($agent === null && $agentUserId > 0) { AgentScope::applyAgentUserFilter($base, $agentUserId); } if ($channelId !== '') { if ($channelExact) { $base->where('channel_id', $channelId); } else { $base->where('channel_id', 'like', '%'.$channelId.'%'); } } $total = (clone $base)->count(); $newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count(); $activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count(); $visits = PageVisit::query(); AgentScope::applyChannelIdScope($visits, $agent); if ($agent === null && $agentUserId > 0) { 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'); $iosPv = (clone $iosVisits)->count(); $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'); }); $otherPv = (clone $otherVisits)->count(); $otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate'); $byOs = [ $this->row('iOS', $iosPv, $iosUv, $pv), $this->row('其他', $otherPv, $otherUv, $pv), ]; $byIosVersion = (clone $iosVisits) ->select('os_version') ->selectRaw('COUNT(*) as pv') ->selectRaw('COUNT(DISTINCT client_uid) as uv') ->groupBy('os_version') ->orderByDesc('pv') ->limit(20) ->get() ->map(fn ($r) => $this->row( ((string) ($r->os_version ?? '')) !== '' ? (string) $r->os_version : 'Unknown', (int) $r->pv, (int) $r->uv, $iosPv > 0 ? $iosPv : $pv, )) ->values() ->all(); return [ 'total' => $total, 'new_count' => $newCount, 'active_count' => $activeCount, 'pv' => $pv, 'uv' => $uv, 'effective_pv' => $effectivePv, 'effective_uv' => $effectiveUv, 'by_os' => $byOs, 'by_ios_version' => $byIosVersion, 'range' => $range, 'from' => $from->toDateTimeString(), 'to' => $to->toDateTimeString(), ]; } /** * 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()], }; } /** * Effective traffic: Safari on iOS ≤ 17.2 (includes 17.2.x patch builds; excludes 17.3+). */ private function applyEffectiveFilter(Builder $query): void { $query->where('os', 'iOS') ->where('browser', 'Safari') ->whereNotNull('os_version') ->where('os_version', '!=', ''); $driver = DB::connection()->getDriverName(); if ($driver === 'sqlite') { // major < 17 OR (major = 17 AND minor ≤ 2) $query->whereRaw( "(CAST(os_version AS INTEGER) < 17) OR ( CAST(os_version AS INTEGER) = 17 AND CAST( CASE WHEN INSTR(os_version, '.') > 0 THEN SUBSTR(os_version, INSTR(os_version, '.') + 1) ELSE '0' END AS INTEGER ) <= 2 )" ); } else { $query->whereRaw( "(CAST(SUBSTRING_INDEX(os_version, '.', 1) AS UNSIGNED) < 17) OR ( CAST(SUBSTRING_INDEX(os_version, '.', 1) AS UNSIGNED) = 17 AND CAST( SUBSTRING_INDEX(SUBSTRING_INDEX(CONCAT(os_version, '.0'), '.', 2), '.', -1) AS UNSIGNED ) <= 2 )" ); } } /** * @return array{label: string, pv: int, uv: int, pct: float} */ private function row(string $label, int $pv, int $uv, int $totalPv): array { $pct = $totalPv > 0 ? round(($pv / $totalPv) * 100, 1) : 0.0; return [ 'label' => $label, 'pv' => $pv, 'uv' => $uv, 'pct' => $pct, ]; } }