diff --git a/docs/sql/create_crypto_favorite_and_market_config.sql b/docs/sql/create_crypto_favorite_and_market_config.sql new file mode 100644 index 0000000..cfc9314 --- /dev/null +++ b/docs/sql/create_crypto_favorite_and_market_config.sql @@ -0,0 +1,58 @@ +CREATE TABLE IF NOT EXISTS `la_crypto_favorite` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `user_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户ID', + `symbol` varchar(20) NOT NULL DEFAULT '' COMMENT '币种代码', + `status` tinyint UNSIGNED NOT NULL DEFAULT 1 COMMENT '自选状态 0-取消 1-自选', + `create_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建时间', + `update_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新时间', + `delete_time` int NULL DEFAULT NULL COMMENT '删除时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_symbol` (`user_id`, `symbol`), + KEY `idx_user_status` (`user_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户加密行情自选表'; + +INSERT INTO `la_config` (`type`, `name`, `value`, `create_time`, `update_time`) +SELECT 'crypto_market', 'api_base', 'https://api.coingecko.com/api/v3', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_config` WHERE `type` = 'crypto_market' AND `name` = 'api_base' +); + +INSERT INTO `la_config` (`type`, `name`, `value`, `create_time`, `update_time`) +SELECT 'crypto_market', 'cache_seconds', '60', UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_config` WHERE `type` = 'crypto_market' AND `name` = 'cache_seconds' +); + +SET @crypto_market_coins = JSON_ARRAY( + JSON_OBJECT('id', 'bitcoin', 'symbol', 'BTC', 'name_zh', '比特币'), + JSON_OBJECT('id', 'ethereum', 'symbol', 'ETH', 'name_zh', '以太坊'), + JSON_OBJECT('id', 'binancecoin', 'symbol', 'BNB', 'name_zh', '币安币'), + JSON_OBJECT('id', 'solana', 'symbol', 'SOL', 'name_zh', 'Solana'), + JSON_OBJECT('id', 'ripple', 'symbol', 'XRP', 'name_zh', '瑞波币'), + JSON_OBJECT('id', 'cardano', 'symbol', 'ADA', 'name_zh', '艾达币'), + JSON_OBJECT('id', 'dogecoin', 'symbol', 'DOGE', 'name_zh', '狗狗币'), + JSON_OBJECT('id', 'tron', 'symbol', 'TRX', 'name_zh', '波场'), + JSON_OBJECT('id', 'avalanche-2', 'symbol', 'AVAX', 'name_zh', 'Avalanche'), + JSON_OBJECT('id', 'chainlink', 'symbol', 'LINK', 'name_zh', 'Chainlink'), + JSON_OBJECT('id', 'the-open-network', 'symbol', 'TON', 'name_zh', 'Toncoin'), + JSON_OBJECT('id', 'shiba-inu', 'symbol', 'SHIB', 'name_zh', '柴犬币'), + JSON_OBJECT('id', 'polkadot', 'symbol', 'DOT', 'name_zh', '波卡币'), + JSON_OBJECT('id', 'sui', 'symbol', 'SUI', 'name_zh', 'Sui'), + JSON_OBJECT('id', 'near', 'symbol', 'NEAR', 'name_zh', 'NEAR'), + JSON_OBJECT('id', 'litecoin', 'symbol', 'LTC', 'name_zh', '莱特币'), + JSON_OBJECT('id', 'bitcoin-cash', 'symbol', 'BCH', 'name_zh', '比特币现金'), + JSON_OBJECT('id', 'uniswap', 'symbol', 'UNI', 'name_zh', 'Uniswap'), + JSON_OBJECT('id', 'aave', 'symbol', 'AAVE', 'name_zh', 'Aave'), + JSON_OBJECT('id', 'ethereum-classic', 'symbol', 'ETC', 'name_zh', '以太坊经典') +); + +INSERT INTO `la_config` (`type`, `name`, `value`, `create_time`, `update_time`) +SELECT 'crypto_market', 'coins', CAST(@crypto_market_coins AS CHAR), UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +WHERE NOT EXISTS ( + SELECT 1 FROM `la_config` WHERE `type` = 'crypto_market' AND `name` = 'coins' +); + +UPDATE `la_decorate_tabbar` +SET `name` = '行情', `update_time` = UNIX_TIMESTAMP() +WHERE `link` LIKE '%pages%crypto%crypto%' + AND `name` <> '行情'; diff --git a/server/app/api/controller/CryptoController.php b/server/app/api/controller/CryptoController.php index a76222b..2a23fa5 100644 --- a/server/app/api/controller/CryptoController.php +++ b/server/app/api/controller/CryptoController.php @@ -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 代理(解决客户端直连被墙问题) diff --git a/server/app/api/logic/CryptoLogic.php b/server/app/api/logic/CryptoLogic.php new file mode 100644 index 0000000..b4c2c7a --- /dev/null +++ b/server/app/api/logic/CryptoLogic.php @@ -0,0 +1,68 @@ + $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; + } + } +} diff --git a/server/app/api/validate/CryptoValidate.php b/server/app/api/validate/CryptoValidate.php new file mode 100644 index 0000000..a769517 --- /dev/null +++ b/server/app/api/validate/CryptoValidate.php @@ -0,0 +1,23 @@ + 'require|alphaNum|max:20', + ]; + + protected $message = [ + 'symbol.require' => '币种不能为空', + 'symbol.alphaNum' => '币种格式错误', + 'symbol.max' => '币种格式错误', + ]; + + public function sceneFavorite() + { + return $this->only(['symbol']); + } +} diff --git a/server/app/common/model/crypto/CryptoFavorite.php b/server/app/common/model/crypto/CryptoFavorite.php new file mode 100644 index 0000000..aa91012 --- /dev/null +++ b/server/app/common/model/crypto/CryptoFavorite.php @@ -0,0 +1,10 @@ +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); + } +} diff --git a/uniapp/src/api/crypto.ts b/uniapp/src/api/crypto.ts index 80f89f3..23205b9 100644 --- a/uniapp/src/api/crypto.ts +++ b/uniapp/src/api/crypto.ts @@ -1,4 +1,5 @@ import appConfig from '@/config' +import request from '@/utils/request' const PROXY_BASE = `${appConfig.baseUrl}${appConfig.urlPrefix}/crypto/proxy` @@ -165,6 +166,46 @@ export interface CoinTicker { lowPrice: string } +export interface CryptoMarketCoin { + id: string + symbol: string + name: string + name_zh: string + image: string + current_price: number + market_cap: number + market_cap_rank: number + total_volume: number + price_change_percentage_24h: number +} + +export interface CryptoMarketSnapshot { + updated_at: number + updated_time: string + stale: boolean + overview: { + btc_market_cap_percentage: number + total_volume_cny: number + } + coins: CryptoMarketCoin[] +} + +export function getCryptoMarket() { + return request.get({ url: '/crypto/market' }, { ignoreCancel: true }) +} + +export function getCryptoFavorites() { + return request.get<{ symbols: string[] }>({ url: '/crypto/favorites' }, { isAuth: true, ignoreCancel: true }) +} + +export function addCryptoFavorite(symbol: string) { + return request.post({ url: '/crypto/addFavorite', data: { symbol } }, { isAuth: true }) +} + +export function cancelCryptoFavorite(symbol: string) { + return request.post({ url: '/crypto/cancelFavorite', data: { symbol } }, { isAuth: true }) +} + export function formatPrice(price: string | number): string { const p = typeof price === 'string' ? parseFloat(price) : price if (isNaN(p)) return '0.00' diff --git a/uniapp/src/pages.json b/uniapp/src/pages.json index 20c84fd..b4604e1 100644 --- a/uniapp/src/pages.json +++ b/uniapp/src/pages.json @@ -518,7 +518,7 @@ "iconPath": "static/images/tabbar/crypto.png", "selectedIconPath": "static/images/tabbar/crypto_s.png", "pagePath": "pages/crypto/crypto", - "text": "加密行情" + "text": "行情" }, { "iconPath": "static/images/tabbar/lottery.png", diff --git a/uniapp/src/pages/crypto/crypto.vue b/uniapp/src/pages/crypto/crypto.vue index 3caf9a5..7d6783f 100644 --- a/uniapp/src/pages/crypto/crypto.vue +++ b/uniapp/src/pages/crypto/crypto.vue @@ -1,126 +1,336 @@