feat(api): 重构资产录入新增 exchange 字段,并接入腾讯财经 qt.gtimg 极速行情引擎

This commit is contained in:
kennethcheng 2026-04-28 11:39:30 +08:00
parent ce529928cc
commit 842a5fef8c
4 changed files with 59 additions and 27 deletions

View File

@ -9,6 +9,7 @@ import { z } from 'zod';
const createAssetSchema = z.object({ const createAssetSchema = z.object({
symbol: z.string().min(1, 'Symbol is required'), symbol: z.string().min(1, 'Symbol is required'),
type: z.enum(['STOCK', 'CRYPTO', 'CASH']), type: z.enum(['STOCK', 'CRYPTO', 'CASH']),
exchange: z.string().optional(),
baseCurrency: z.string().min(2).max(10), baseCurrency: z.string().min(2).max(10),
}); });

View File

@ -5,13 +5,16 @@ import { assets } from '@/db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache'; 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 { switch (asset.exchange) {
const price = parseFloat(currentPrice); case 'SSE': return 'sh' + cleanSymbol;
const changePercent = (Math.random() * 4 - 2); case 'SZSE': return 'sz' + cleanSymbol;
const newPrice = price * (1 + changePercent / 100); case 'HKEX': return 'hk' + cleanSymbol;
return newPrice.toFixed(2); case 'US':
default: return 's_us' + cleanSymbol;
}
} }
export async function syncAllStockPrices() { export async function syncAllStockPrices() {
@ -24,30 +27,24 @@ export async function syncAllStockPrices() {
for (const asset of stockAssets) { for (const asset of stockAssets) {
try { try {
const response = await fetch(`https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${asset.symbol}&apikey=${API_KEY}`, { cache: 'no-store' }); const tCode = getTencentSymbol(asset);
const data = await response.json(); 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']) { const match = text.match(/="([^"]+)"/);
throw new Error('Alpha Vantage API 达到频率限制或未找到该股票'); if (match && match[1]) {
} const dataArr = match[1].split('~');
const latestPrice = dataArr[3];
const priceString = data['Global Quote']['05. price']; if (latestPrice && !isNaN(Number(latestPrice)) && Number(latestPrice) > 0) {
if (priceString) { await db.update(assets)
await db .set({ latestPrice: latestPrice })
.update(assets) .where(eq(assets.id, asset.id));
.set({ latestPrice: priceString }) successCount++;
.where(eq(assets.id, asset.id)); }
successCount++;
} }
} catch (error) { } catch (error) {
console.error(`Failed to fetch price for ${asset.symbol}:`, error); console.warn(`[行情引擎] 同步 ${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}`);
} }
} }

View File

@ -37,11 +37,19 @@ import { createAsset } from '@/actions/asset';
const addAssetSchema = z.object({ const addAssetSchema = z.object({
symbol: z.string().min(1, '资产代码不能为空'), symbol: z.string().min(1, '资产代码不能为空'),
type: z.enum(['STOCK', 'CRYPTO', 'CASH']), type: z.enum(['STOCK', 'CRYPTO', 'CASH']),
exchange: z.string().optional(),
baseCurrency: z.string().min(2, '基础币种至少2个字符'), baseCurrency: z.string().min(2, '基础币种至少2个字符'),
}); });
type AddAssetForm = z.infer<typeof addAssetSchema>; type AddAssetForm = z.infer<typeof addAssetSchema>;
const EXCHANGE_OPTIONS = [
{ value: 'US', label: '美股 (US)' },
{ value: 'HKEX', label: '港股 (HKEX)' },
{ value: 'SSE', label: '沪市 A 股 (SSE)' },
{ value: 'SZSE', label: '深市 A 股 (SZSE)' },
];
export function AddAssetDialog() { export function AddAssetDialog() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -52,6 +60,7 @@ export function AddAssetDialog() {
defaultValues: { defaultValues: {
symbol: '', symbol: '',
type: 'STOCK' as const, type: 'STOCK' as const,
exchange: 'US',
baseCurrency: '', baseCurrency: '',
}, },
}); });
@ -117,6 +126,30 @@ export function AddAssetDialog() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="exchange"
render={({ field }) => (
<FormItem>
<FormLabel> / (Exchange)</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择交易所" />
</SelectTrigger>
</FormControl>
<SelectContent>
{EXCHANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="baseCurrency" name="baseCurrency"

View File

@ -19,6 +19,7 @@ export const assets = pgTable("assets", {
id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()), id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
symbol: varchar("symbol", { length: 20 }).notNull().unique(), symbol: varchar("symbol", { length: 20 }).notNull().unique(),
type: assetTypeEnum("type").notNull(), type: assetTypeEnum("type").notNull(),
exchange: varchar("exchange", { length: 10 }).default('US'),
baseCurrency: varchar("base_currency", { length: 10 }).notNull(), baseCurrency: varchar("base_currency", { length: 10 }).notNull(),
latestPrice: numeric("latest_price", { precision: 36, scale: 18 }).default('0').notNull(), latestPrice: numeric("latest_price", { precision: 36, scale: 18 }).default('0').notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }) createdAt: timestamp("created_at", { withTimezone: true, mode: "date" })