From 842a5fef8c3b951ce3892d7554ef56459beec39c Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Tue, 28 Apr 2026 11:39:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E9=87=8D=E6=9E=84=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=BD=95=E5=85=A5=E6=96=B0=E5=A2=9E=20exchange=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=EF=BC=8C=E5=B9=B6=E6=8E=A5=E5=85=A5=E8=85=BE?= =?UTF-8?q?=E8=AE=AF=E8=B4=A2=E7=BB=8F=20qt.gtimg=20=E6=9E=81=E9=80=9F?= =?UTF-8?q?=E8=A1=8C=E6=83=85=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/actions/asset.ts | 3 +- src/actions/market.ts | 49 ++++++++++------------ src/components/assets/add-asset-dialog.tsx | 33 +++++++++++++++ src/db/schema.ts | 1 + 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/actions/asset.ts b/src/actions/asset.ts index 85a4ad1..3ecb030 100644 --- a/src/actions/asset.ts +++ b/src/actions/asset.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; const createAssetSchema = z.object({ symbol: z.string().min(1, 'Symbol is required'), type: z.enum(['STOCK', 'CRYPTO', 'CASH']), + exchange: z.string().optional(), baseCurrency: z.string().min(2).max(10), }); @@ -56,4 +57,4 @@ export async function updateAssetPrice(params: z.infer } catch (error: unknown) { throw error; } -} \ No newline at end of file +} diff --git a/src/actions/market.ts b/src/actions/market.ts index 32c8dfc..1c5a3c8 100644 --- a/src/actions/market.ts +++ b/src/actions/market.ts @@ -5,13 +5,16 @@ import { assets } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { revalidatePath } from 'next/cache'; -const API_KEY = process.env.ALPHA_VANTAGE_API_KEY; +export function getTencentSymbol(asset: { symbol: string; exchange: string | null }): string { + const cleanSymbol = asset.symbol.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''); -function generateRandomPrice(currentPrice: string): string { - const price = parseFloat(currentPrice); - const changePercent = (Math.random() * 4 - 2); - const newPrice = price * (1 + changePercent / 100); - return newPrice.toFixed(2); + switch (asset.exchange) { + case 'SSE': return 'sh' + cleanSymbol; + case 'SZSE': return 'sz' + cleanSymbol; + case 'HKEX': return 'hk' + cleanSymbol; + case 'US': + default: return 's_us' + cleanSymbol; + } } export async function syncAllStockPrices() { @@ -24,30 +27,24 @@ export async function syncAllStockPrices() { for (const asset of stockAssets) { try { - const response = await fetch(`https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${asset.symbol}&apikey=${API_KEY}`, { cache: 'no-store' }); - const data = await response.json(); + const tCode = getTencentSymbol(asset); + const response = await fetch(`https://qt.gtimg.cn/q=${tCode}`, { cache: 'no-store' }); + const text = await response.text(); - if (data['Information'] || data['Note'] || !data['Global Quote']) { - throw new Error('Alpha Vantage API 达到频率限制或未找到该股票'); - } + const match = text.match(/="([^"]+)"/); + if (match && match[1]) { + const dataArr = match[1].split('~'); + const latestPrice = dataArr[3]; - const priceString = data['Global Quote']['05. price']; - if (priceString) { - await db - .update(assets) - .set({ latestPrice: priceString }) - .where(eq(assets.id, asset.id)); - successCount++; + if (latestPrice && !isNaN(Number(latestPrice)) && Number(latestPrice) > 0) { + await db.update(assets) + .set({ latestPrice: latestPrice }) + .where(eq(assets.id, asset.id)); + successCount++; + } } } catch (error) { - console.error(`Failed to fetch price for ${asset.symbol}:`, error); - const currentPrice = asset.latestPrice || '0.00'; - const simulatedPrice = generateRandomPrice(currentPrice); - await db - .update(assets) - .set({ latestPrice: simulatedPrice }) - .where(eq(assets.id, asset.id)); - console.warn(`[熔断] ${asset.symbol} 使用波动模拟器更新价格为 ${simulatedPrice}`); + console.warn(`[行情引擎] 同步 ${asset.symbol} 失败,保持原价:`, error); } } diff --git a/src/components/assets/add-asset-dialog.tsx b/src/components/assets/add-asset-dialog.tsx index 0545ddd..39d36a8 100644 --- a/src/components/assets/add-asset-dialog.tsx +++ b/src/components/assets/add-asset-dialog.tsx @@ -37,11 +37,19 @@ import { createAsset } from '@/actions/asset'; const addAssetSchema = z.object({ symbol: z.string().min(1, '资产代码不能为空'), type: z.enum(['STOCK', 'CRYPTO', 'CASH']), + exchange: z.string().optional(), baseCurrency: z.string().min(2, '基础币种至少2个字符'), }); type AddAssetForm = z.infer; +const EXCHANGE_OPTIONS = [ + { value: 'US', label: '美股 (US)' }, + { value: 'HKEX', label: '港股 (HKEX)' }, + { value: 'SSE', label: '沪市 A 股 (SSE)' }, + { value: 'SZSE', label: '深市 A 股 (SZSE)' }, +]; + export function AddAssetDialog() { const [open, setOpen] = useState(false); const [isPending, startTransition] = useTransition(); @@ -52,6 +60,7 @@ export function AddAssetDialog() { defaultValues: { symbol: '', type: 'STOCK' as const, + exchange: 'US', baseCurrency: '', }, }); @@ -117,6 +126,30 @@ export function AddAssetDialog() { )} /> + ( + + 交易所 / 市场 (Exchange) + + + + )} + /> crypto.randomUUID()), symbol: varchar("symbol", { length: 20 }).notNull().unique(), type: assetTypeEnum("type").notNull(), + exchange: varchar("exchange", { length: 10 }).default('US'), baseCurrency: varchar("base_currency", { length: 10 }).notNull(), latestPrice: numeric("latest_price", { precision: 36, scale: 18 }).default('0').notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: "date" })