feat: redesign crypto market with synced favorites

This commit is contained in:
hajimi
2026-08-02 02:32:20 +08:00
parent b5b59d7bf1
commit cd054d4db6
9 changed files with 1078 additions and 210 deletions
+48 -1
View File
@@ -2,12 +2,59 @@
namespace app\api\controller;
use app\api\logic\CryptoLogic;
use app\api\validate\CryptoValidate;
use think\facade\Cache;
use think\facade\Log;
class CryptoController extends BaseApiController
{
public array $notNeedLogin = ['proxy'];
public array $notNeedLogin = ['proxy', 'market'];
/**
* 人民币加密行情与市场概览
*/
public function market()
{
try {
return $this->data(CryptoLogic::market());
} catch (\Throwable $e) {
Log::error('[CryptoMarket] controller failed: ' . $e->getMessage());
return $this->fail('行情加载失败,请稍后重试');
}
}
/**
* 当前账号的加密自选列表
*/
public function favorites()
{
return $this->data(['symbols' => CryptoLogic::favorites($this->userId)]);
}
/**
* 加入加密自选
*/
public function addFavorite()
{
$params = (new CryptoValidate())->post()->goCheck('favorite');
if (!CryptoLogic::addFavorite($this->userId, (string) $params['symbol'])) {
return $this->fail(CryptoLogic::getError());
}
return $this->success('已加入自选');
}
/**
* 取消加密自选
*/
public function cancelFavorite()
{
$params = (new CryptoValidate())->post()->goCheck('favorite');
if (!CryptoLogic::cancelFavorite($this->userId, (string) $params['symbol'])) {
return $this->fail(CryptoLogic::getError());
}
return $this->success('已取消自选');
}
/**
* Binance API 代理(解决客户端直连被墙问题)
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace app\api\logic;
use app\common\enum\YesNoEnum;
use app\common\logic\BaseLogic;
use app\common\model\crypto\CryptoFavorite;
use app\common\service\crypto\CryptoMarketService;
class CryptoLogic extends BaseLogic
{
public static function market(): array
{
return CryptoMarketService::snapshot();
}
public static function favorites(int $userId): array
{
$symbols = CryptoFavorite::where([
'user_id' => $userId,
'status' => YesNoEnum::YES,
])->order('create_time asc')->column('symbol');
return array_values(array_unique(array_map('strtoupper', $symbols)));
}
public static function addFavorite(int $userId, string $symbol): bool
{
$symbol = strtoupper(trim($symbol));
if (!CryptoMarketService::isSupportedSymbol($symbol)) {
self::setError('暂不支持该币种');
return false;
}
try {
$where = ['user_id' => $userId, 'symbol' => $symbol];
$favorite = CryptoFavorite::where($where)->findOrEmpty();
if ($favorite->isEmpty()) {
try {
CryptoFavorite::create($where + ['status' => YesNoEnum::YES]);
} catch (\Throwable $e) {
CryptoFavorite::update(['status' => YesNoEnum::YES], $where);
}
} else {
$favorite->status = YesNoEnum::YES;
$favorite->save();
}
return true;
} catch (\Throwable $e) {
self::setError('收藏失败,请稍后重试');
return false;
}
}
public static function cancelFavorite(int $userId, string $symbol): bool
{
try {
CryptoFavorite::update(
['status' => YesNoEnum::NO],
['user_id' => $userId, 'symbol' => strtoupper(trim($symbol))]
);
return true;
} catch (\Throwable $e) {
self::setError('取消收藏失败,请稍后重试');
return false;
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace app\api\validate;
use app\common\validate\BaseValidate;
class CryptoValidate extends BaseValidate
{
protected $rule = [
'symbol' => 'require|alphaNum|max:20',
];
protected $message = [
'symbol.require' => '币种不能为空',
'symbol.alphaNum' => '币种格式错误',
'symbol.max' => '币种格式错误',
];
public function sceneFavorite()
{
return $this->only(['symbol']);
}
}
@@ -0,0 +1,10 @@
<?php
namespace app\common\model\crypto;
use app\common\model\BaseModel;
class CryptoFavorite extends BaseModel
{
protected $name = 'crypto_favorite';
}
@@ -0,0 +1,184 @@
<?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);
}
}