185 lines
6.2 KiB
PHP
185 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace app\common\service\crypto;
|
|
|
|
use app\common\service\ConfigService;
|
|
use RuntimeException;
|
|
use think\facade\Cache;
|
|
use think\facade\Log;
|
|
|
|
class CryptoMarketService
|
|
{
|
|
private const CACHE_KEY = 'crypto_market_snapshot';
|
|
private const STALE_CACHE_KEY = 'crypto_market_snapshot_stale';
|
|
|
|
public static function snapshot(): array
|
|
{
|
|
$cached = Cache::get(self::CACHE_KEY);
|
|
if (self::isValidSnapshot($cached)) {
|
|
return $cached;
|
|
}
|
|
|
|
try {
|
|
$snapshot = self::fetchSnapshot();
|
|
$cacheSeconds = max(30, min(300, (int) ConfigService::get('crypto_market', 'cache_seconds', 60)));
|
|
Cache::set(self::CACHE_KEY, $snapshot, $cacheSeconds);
|
|
Cache::set(self::STALE_CACHE_KEY, $snapshot, 86400);
|
|
return $snapshot;
|
|
} catch (\Throwable $e) {
|
|
Log::error('[CryptoMarket] fetch failed: ' . $e->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);
|
|
}
|
|
}
|