getMessage()); $stale = Cache::get(self::STALE_CACHE_KEY); if (self::isValidSnapshot($stale)) { $stale['stale'] = true; return $stale; } throw new RuntimeException('行情数据暂时不可用'); } } public static function supportedSymbols(): array { return array_values(array_unique(array_column(self::coinConfig(), 'symbol'))); } public static function isSupportedSymbol(string $symbol): bool { return in_array(strtoupper(trim($symbol)), self::supportedSymbols(), true); } private static function fetchSnapshot(): array { $coinConfig = self::coinConfig(); if (empty($coinConfig)) { throw new RuntimeException('加密行情币种配置不存在'); } $apiBase = rtrim((string) ConfigService::get( 'crypto_market', 'api_base', 'https://api.coingecko.com/api/v3' ), '/'); $ids = array_column($coinConfig, 'id'); $marketUrl = $apiBase . '/coins/markets?' . http_build_query([ 'vs_currency' => 'cny', 'ids' => implode(',', $ids), 'order' => 'market_cap_desc', 'per_page' => max(1, count($ids)), 'page' => 1, 'sparkline' => 'false', 'price_change_percentage' => '24h', ]); $marketRows = self::requestJson($marketUrl); $global = self::requestJson($apiBase . '/global'); $globalData = is_array($global['data'] ?? null) ? $global['data'] : []; $configById = []; foreach ($coinConfig as $item) { $configById[$item['id']] = $item; } $coins = []; foreach ($marketRows as $row) { $id = (string) ($row['id'] ?? ''); if (!isset($configById[$id])) { continue; } $meta = $configById[$id]; $coins[] = [ 'id' => $id, 'symbol' => $meta['symbol'], 'name' => (string) ($row['name'] ?? $meta['symbol']), 'name_zh' => $meta['name_zh'] ?: (string) ($row['name'] ?? $meta['symbol']), 'image' => (string) ($row['image'] ?? ''), 'current_price' => (float) ($row['current_price'] ?? 0), 'market_cap' => (float) ($row['market_cap'] ?? 0), 'market_cap_rank' => (int) ($row['market_cap_rank'] ?? 0), 'total_volume' => (float) ($row['total_volume'] ?? 0), 'price_change_percentage_24h' => round((float) ($row['price_change_percentage_24h'] ?? 0), 2), ]; } if (empty($coins)) { throw new RuntimeException('CoinGecko 未返回有效行情'); } $updatedAt = (int) ($globalData['updated_at'] ?? time()); return [ 'updated_at' => $updatedAt, 'updated_time' => date('H:i', $updatedAt), 'stale' => false, 'overview' => [ 'btc_market_cap_percentage' => round((float) ($globalData['market_cap_percentage']['btc'] ?? 0), 1), 'total_volume_cny' => (float) ($globalData['total_volume']['cny'] ?? 0), ], 'coins' => $coins, ]; } private static function coinConfig(): array { $config = ConfigService::get('crypto_market', 'coins', []); if (!is_array($config)) { return []; } $coins = []; foreach ($config as $item) { if (!is_array($item)) { continue; } $id = strtolower(trim((string) ($item['id'] ?? ''))); $symbol = strtoupper(trim((string) ($item['symbol'] ?? ''))); if (!preg_match('/^[a-z0-9-]+$/', $id) || !preg_match('/^[A-Z0-9]{2,20}$/', $symbol)) { continue; } $coins[] = [ 'id' => $id, 'symbol' => $symbol, 'name_zh' => trim((string) ($item['name_zh'] ?? '')), ]; } return $coins; } private static function requestJson(string $url): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_ENCODING => '', CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'User-Agent: SportEra/1.0', ], ]); $body = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error !== '') { throw new RuntimeException($error); } if ($httpCode !== 200) { throw new RuntimeException('CoinGecko HTTP ' . $httpCode); } $data = json_decode((string) $body, true); if (!is_array($data)) { throw new RuntimeException('CoinGecko 返回格式异常'); } return $data; } private static function isValidSnapshot($snapshot): bool { return is_array($snapshot) && !empty($snapshot['coins']) && is_array($snapshot['coins']) && is_array($snapshot['overview'] ?? null); } }