From 85583b7e06cc58efcd43324780d0c64d5e47c4a5 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Fri, 1 May 2026 07:13:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=96=B0=E5=A2=9E=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E6=8A=93=E5=8F=96=E8=A1=8C=E6=83=85=20API=EF=BC=8C?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=9F=BA=E4=BA=8E=E6=97=A5=E6=9C=9F=E7=9A=84?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E6=80=A7=E4=BB=B7=E6=A0=BC=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 8 ++ src/app/api/cron/fetch-prices/route.ts | 175 +++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/app/api/cron/fetch-prices/route.ts diff --git a/Memory.md b/Memory.md index 88cbfd0..936ea0b 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,13 @@ # Omniledger 架构与开发记忆 (Memory) +## 构建 /api/cron/fetch-prices 定时任务端点,实现针对活跃资产的行情抓取与按日期的幂等性 (Idempotent) 价格入库 (Task 61) +- 在 `src/app/api/cron/fetch-prices/route.ts` 创建 Next.js Route Handler (GET),专供定时任务调用。 +- **安全拦截:** 在 GET 方法顶部校验 `Authorization` 请求头 (`Bearer ${process.env.CRON_SECRET}`),不匹配则返回 401 Unauthorized;若 `CRON_SECRET` 未配置则返回 500。 +- **核心流程:** 查询 `assets` 表中 `STOCK` 和 `CRYPTO` 类型的活跃资产 → 遍历调用腾讯财经/币安 API 获取现价 → 生成当日日期字符串 (YYYY-MM-DD) → 幂等入库至 `asset_pricesHistory`。 +- **幂等性保障:** 先 `SELECT` 检查当天是否已存在该 `assetId` 的记录,存在则 `UPDATE` 价格,不存在则 `INSERT`,确保同一天同一资产绝不出现两条价格记录。 +- **响应格式:** 返回 `{ success, date, synced, skipped, failed, details }` 结构化的 JSON 结果。 +- 新增环境变量 `CRON_SECRET`(需在 `.env` 中配置),用于定时任务接口的认证密钥。 + ## 粉碎时光机中 marketValue 未乘汇率的致命双标幻觉,严格对齐历史快照与实时概览的 CNY 折算基准 (Task 59b) - **核心认知纠正:** `calculateAssetMetrics` 输出的 `marketValue` **绝对不是 CNY**!它和 `totalInvested` 一样,都是原始币种 (Base Currency)。 - **致命 Bug:** 之前的时光机逻辑中,`posCostCny` 使用 `metrics.totalInvested * fxRate` 折算,而 `posValueCny` 使用 `metrics.marketValue * fxRate` 折算。但由于 `totalInvested` 在 `finance.ts` 引擎中已经混入了 CNY 价格(如海尔的买入价格),导致投入本金被错误放大,在 4 月 30 日节点造成"投入本金"虚高、净盈亏显示为亏损 4 万的荒谬结果。 diff --git a/src/app/api/cron/fetch-prices/route.ts b/src/app/api/cron/fetch-prices/route.ts new file mode 100644 index 0000000..90b8280 --- /dev/null +++ b/src/app/api/cron/fetch-prices/route.ts @@ -0,0 +1,175 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db'; +import { assets, assetPricesHistory } from '@/db/schema'; +import { eq } 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}`; +} + +async function fetchStockPrice(asset: { symbol: string; exchange: string | null }): Promise { + 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://qt.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 latestPrice; + } + } + return null; +} + +async function fetchCryptoPrice(asset: { symbol: string }): Promise { + 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(eq(assets.type, 'STOCK').or(eq(assets.type, 'CRYPTO'))); + + if (allAssets.length === 0) { + return NextResponse.json({ + success: true, + message: 'No active assets to sync', + date: dateStr, + synced: 0, + skipped: 0, + failed: 0, + }); + } + + let syncedCount = 0; + let skippedCount = 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; + + if (asset.type === 'STOCK') { + price = await fetchStockPrice(asset); + } 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 existing = await db + .select() + .from(assetPricesHistory) + .where( + eq(assetPricesHistory.assetId, asset.id) + ) + .then((rows) => + rows.filter((row) => row.date === dateStr) + ); + + if (existing.length > 0) { + await db + .update(assetPricesHistory) + .set({ price }) + .where( + eq(assetPricesHistory.id, existing[0].id) + ); + skippedCount++; + results.push({ symbol: asset.symbol, price, status: 'updated' }); + } else { + await db + .insert(assetPricesHistory) + .values({ + assetId: asset.id, + price, + date: dateStr, + }); + syncedCount++; + results.push({ symbol: asset.symbol, price, status: 'inserted' }); + } + } 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, + skipped: skippedCount, + failed: failedCount, + details: results, + }); +}