stock-portfolio_byQwen3.6/app/api/cron/fetch-prices/route.ts

206 lines
6.1 KiB
TypeScript

import { NextResponse } from 'next/server';
import { db } from '@/db';
import { assets, assetPricesHistory } from '@/db/schema';
import { inArray } from 'drizzle-orm';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
function formatDateStr(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function parseMarketDate(rawString: string): string {
try {
// 1. 强制清洗脏前缀 (消除 v_usGOOG=" 导致的数组偏移与污染风险)
let payload = rawString;
if (payload.includes('="')) {
// 提取 =" 之后的内容,并剔除结尾可能存在的 ";
payload = payload.split('="')[1].replace(/";/g, '');
}
const parts = payload.split('~');
// 腾讯接口核心数据长度校验
if (parts.length < 31) throw new Error("Payload length insufficient");
const rawDate = parts[30];
if (!rawDate) throw new Error("Index 30 is undefined or empty");
// 2. 美股匹配 (2026-05-01 16:00:06)
if (rawDate.includes('-')) {
return rawDate.split(' ')[0];
}
// 3. 港股匹配 (2026/04/30 16:08:24)
if (rawDate.includes('/')) {
return rawDate.split(' ')[0].replace(/\//g, '-');
}
// 4. A股匹配 (20260430161416)
if (/^\d{8}/.test(rawDate)) {
return `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`;
}
throw new Error(`Unrecognized date format: ${rawDate}`);
} catch (e) {
// 致命错误暴露:必须打印出导致崩溃的原始字符串
console.error("[Date Parse Fatal Error] String:", rawString, "Error:", e);
// 最终兜底
const today = new Date();
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
}
}
async function fetchStockPrice(asset: { symbol: string; exchange: string | null }): Promise<{ price: string | null; rawResponse: string | null }> {
const cleanSymbol = asset.symbol.trim().toUpperCase().replace(/[^0-9A-Z.\-]/g, '');
let tCode: string;
switch (asset.exchange) {
case 'SSE':
tCode = 'sh' + cleanSymbol;
break;
case 'SZSE':
tCode = 'sz' + cleanSymbol;
break;
case 'HKEX':
tCode = 'hk' + cleanSymbol;
break;
case 'US':
default:
tCode = 's_us' + cleanSymbol;
break;
}
const response = await fetch(`https://sqt.gtimg.cn/q=${tCode}`, { cache: 'no-store' });
const arrayBuffer = await response.arrayBuffer();
const decoder = new TextDecoder('gbk');
const text = decoder.decode(arrayBuffer);
const match = text.match(/="([^"]+)"/);
if (match && match[1]) {
const dataArr = match[1].split('~');
const latestPrice = dataArr[3];
if (latestPrice && !isNaN(Number(latestPrice)) && Number(latestPrice) > 0) {
return { price: latestPrice, rawResponse: match[1] };
}
}
return { price: null, rawResponse: null };
}
async function fetchCryptoPrice(asset: { symbol: string }): Promise<string | null> {
const cryptoSymbol = asset.symbol.trim().toUpperCase() + 'USDT';
const response = await fetch(`https://api.binance.com/api/v3/ticker/price?symbol=${cryptoSymbol}`, { cache: 'no-store' });
if (response.ok) {
const data = await response.json();
if (data.price) {
return data.price;
}
}
return null;
}
export async function GET(req: Request) {
const cronSecret = process.env.CRON_SECRET;
const authHeader = req.headers.get('Authorization');
if (!cronSecret) {
return NextResponse.json(
{ error: 'CRON_SECRET not configured' },
{ status: 500 }
);
}
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
const proxyUrl = process.env.HTTPS_PROXY;
if (proxyUrl) {
const proxyAgent = new ProxyAgent(proxyUrl);
setGlobalDispatcher(proxyAgent);
}
const dateStr = formatDateStr(new Date());
const allAssets = await db
.select()
.from(assets)
.where(inArray(assets.type, ['STOCK', 'CRYPTO']));
if (allAssets.length === 0) {
return NextResponse.json({
success: true,
message: 'No active assets to sync',
date: dateStr,
synced: 0,
failed: 0,
});
}
let syncedCount = 0;
let failedCount = 0;
const results: Array<{ symbol: string; price: string | null; status: string }> = [];
for (const asset of allAssets) {
try {
let price: string | null = null;
let rawResponse: string | null = null;
if (asset.type === 'STOCK') {
const result = await fetchStockPrice(asset);
price = result.price;
rawResponse = result.rawResponse;
} else if (asset.type === 'CRYPTO') {
price = await fetchCryptoPrice(asset);
}
if (!price) {
failedCount++;
results.push({ symbol: asset.symbol, price: null, status: 'fetch_failed' });
console.warn(`[Cron] 获取 ${asset.symbol} 价格失败`);
continue;
}
const parsedDate = asset.type === 'STOCK' && rawResponse ? parseMarketDate(rawResponse) : dateStr;
await db.insert(assetPricesHistory)
.values({
assetId: asset.id,
date: parsedDate,
price: price.toString(),
updateTime: new Date()
})
.onConflictDoUpdate({
target: [assetPricesHistory.assetId, assetPricesHistory.date],
set: {
price: price.toString(),
updateTime: new Date()
}
});
syncedCount++;
results.push({ symbol: asset.symbol, price, status: 'upserted' });
} catch (error) {
failedCount++;
results.push({ symbol: asset.symbol, price: null, status: 'error' });
console.warn(`[Cron] 同步 ${asset.symbol} 失败:`, error);
}
}
return NextResponse.json({
success: true,
date: dateStr,
synced: syncedCount,
failed: failedCount,
details: results,
});
}