feat(api): 重构资产录入新增 exchange 字段,并接入腾讯财经 qt.gtimg 极速行情引擎
This commit is contained in:
parent
ce529928cc
commit
842a5fef8c
@ -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<typeof updatePriceSchema>
|
||||
} catch (error: unknown) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<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() {
|
||||
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() {
|
||||
</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
|
||||
control={form.control}
|
||||
name="baseCurrency"
|
||||
|
||||
@ -19,6 +19,7 @@ export const assets = pgTable("assets", {
|
||||
id: uuid("id").primaryKey().$defaultFn(() => 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" })
|
||||
|
||||
Loading…
Reference in New Issue
Block a user