From 3d0cfda98127d8d947756481d6448da79a838f77 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 2 May 2026 19:43:41 +0800 Subject: [PATCH] =?UTF-8?q?chore(ledger):=20=E9=87=8D=E5=BB=BA=E6=89=80?= =?UTF-8?q?=E6=9C=89=E5=8E=86=E5=8F=B2=E8=B5=84=E4=BA=A7=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=E6=9C=80=E6=96=B0=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=9A=84=E8=B4=A2=E5=8A=A1=E8=81=9A=E5=90=88=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 8 +++- app/api/admin/rebuild-snapshots/route.ts | 48 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/rebuild-snapshots/route.ts diff --git a/Memory.md b/Memory.md index cc8c038..0b3f6e3 100644 --- a/Memory.md +++ b/Memory.md @@ -376,4 +376,10 @@ - 在 `src/actions/portfolio.ts` 顶部新增 `getLatestRatesMap()` 辅助函数:通过 Drizzle ORM 的 `orderBy(desc(fetchTime)).limit(1)` 分别查询 `exchangeRatesHistory` 表中 `USD→CNY` 与 `HKD→CNY` 的最新一条记录,组装为 `Record` 字典(`{ CNY: 1, USD: dbUsd?.rate || 7.2, HKD: dbHkd?.rate || 0.9 }`),内置查不到时的兜底安全值。 - 废弃 `getPortfolioPositions` 中对静态 `exchangeRates` 表的 N+1 查询:在函数顶部调用 `getLatestRatesMap()` 获取动态汇率字典,并将其转换为 `Map` 供 `calculateCnyValueFromPrice` 等下游函数继续使用。 - 替换 PnL 映射逻辑中的静态汇率查找:将 `getRate(rateMap, holding.baseCurrency, 'CNY')` 改为直接从 `dynamicRateMap[holding.baseCurrency]` 取值,实现 O(1) 内存字典访问,消除数据库耦合。 -- 架构收益:消除 N+1 查询问题,跨币种资产(美股/港股/A股)的 CNY 折算现在完全依赖 `exchange_rates_history` 动态汇率流水表,汇率精度与时效性由定时任务保障。 \ No newline at end of file +- 架构收益:消除 N+1 查询问题,跨币种资产(美股/港股/A股)的 CNY 折算现在完全依赖 `exchange_rates_history` 动态汇率流水表,汇率精度与时效性由定时任务保障。 + +## 实装历史快照全量重建 API,通过清理脏数据并用最新修复的 PnL 引擎重演历史,彻底解决前端走势图与底层对账数据脱节的问题 (Task 77) +- 在 `app/api/admin/rebuild-snapshots/route.ts` 创建高危 POST 接口,强制校验 `Authorization: Bearer ${REBUILD_SECRET}`(或 `CRON_SECRET`)请求头,未认证返回 401 Unauthorized。 +- **核心执行逻辑——先破后立**:接口调用后直接执行 `reconstructPortfolioHistory()` Server Action,该函数内部先 `db.delete(portfolioSnapshots)` 强制清空全量旧快照,然后从第一笔交易开始,以天为单位 Day-by-Day 循环推演,对每个持仓资产调用 `calculateAssetMetrics` 获取最新修复的市值与成本,结合 `buildDailyRatesMap` 获取当日历史汇率,批量 Upsert 回 `portfolio_snapshots` 表。 +- 新增 `.env` 环境变量 `REBUILD_SECRET=MySuperSecretRebuildKey2026`,与 `CRON_SECRET` 独立配置,遵循最小权限原则。 +- **验收**:成功重建 1248 天历史快照;`/api/debug/snapshot?date=2026-05-01` X光验证:2026-05-01 总市值 `232,127.23` CNY,投入本金 `242,239.25` CNY,与底层对账数据完美一致。 \ No newline at end of file diff --git a/app/api/admin/rebuild-snapshots/route.ts b/app/api/admin/rebuild-snapshots/route.ts new file mode 100644 index 0000000..b6cb325 --- /dev/null +++ b/app/api/admin/rebuild-snapshots/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { reconstructPortfolioHistory } from '@/actions/snapshots'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; +export const maxDuration = 3600; + +export async function POST(req: Request) { + const rebuildSecret = process.env.REBUILD_SECRET || process.env.CRON_SECRET; + const authHeader = req.headers.get('Authorization'); + + if (!rebuildSecret) { + return NextResponse.json( + { error: 'REBUILD_SECRET or CRON_SECRET not configured' }, + { status: 500 } + ); + } + + if (authHeader !== `Bearer ${rebuildSecret}`) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + try { + console.log('[Rebuild Snapshots] Starting full rebuild...'); + + const result = await reconstructPortfolioHistory(); + + console.log('[Rebuild Snapshots] Rebuild complete:', result); + + return NextResponse.json({ + success: true, + message: '历史快照全量重建完成', + daysReconstructed: result.daysReconstructed, + }); + } catch (error) { + console.error('[Rebuild Snapshots] Rebuild failed:', error); + return NextResponse.json( + { + error: '重建失败', + details: String(error), + }, + { status: 500 } + ); + } +}