196 lines
5.7 KiB
TypeScript
196 lines
5.7 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 {
|
|
const parts = rawString.split('~');
|
|
const rawDate = parts[30];
|
|
|
|
if (!rawDate) throw new Error("Missing date part");
|
|
|
|
// 1. A股 (20260430161416) -> 截取前8位并拼接
|
|
if (/^\d{14}$/.test(rawDate)) {
|
|
return `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`;
|
|
}
|
|
|
|
// 2. 港股 (2026/04/30 16:08:24) -> 截取日期并替换斜杠
|
|
if (rawDate.includes('/')) {
|
|
return rawDate.split(' ')[0].replace(/\//g, '-');
|
|
}
|
|
|
|
// 3. 美股 (2026-05-01 09:31:00) -> 直接截取日期部分
|
|
if (rawDate.includes('-')) {
|
|
return rawDate.split(' ')[0];
|
|
}
|
|
|
|
throw new Error(`Unrecognized date format: ${rawDate}`);
|
|
} catch (e) {
|
|
console.warn("Date parse fallback triggered: ", 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,
|
|
});
|
|
}
|