From 189266c5e39f7c7e9cf732cb5917bcef64320b36 Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Sat, 2 May 2026 22:33:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E4=BF=AE=E5=A4=8D=E6=97=B6?= =?UTF-8?q?=E5=85=89=E6=9C=BA=E5=BC=95=E6=93=8E=E6=9C=AC=E9=87=91=E6=9C=AA?= =?UTF-8?q?=E6=8A=98=E7=AE=97=E6=B1=87=E7=8E=87=20bug=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E5=8E=86=E5=8F=B2=E8=B4=A2=E5=8A=A1=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 7 +++++++ src/actions/snapshots.ts | 33 ++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Memory.md b/Memory.md index 024e30f..37b5b11 100644 --- a/Memory.md +++ b/Memory.md @@ -1,5 +1,12 @@ # Omniledger 架构与开发记忆 (Memory) +## 大修快照生成引擎 (snapshots.ts),修复时光机重建历史时未乘汇率导致本币入库的致命 Bug,并消灭日常快照中的反推本金逻辑 (Task 83) +- **Bug 1 - 时光机汇率缺失**:在 `src/actions/snapshots.ts` 的 `reconstructPortfolioHistory()` 中,`historicalTx` 查询的 `select` 遗漏了 `exchangeRate` 字段,导致 `posCostCny` 计算直接使用 `metrics.accumulatedCost`(未经汇率折算的本币值),造成历史投入本金严重失真。 +- **修复方案**:在 `historicalTx` 的 select 中追加 `exchangeRate: transactions.exchangeRate`;彻底重写 `posCostCny` 计算逻辑:从交易流水中按时间顺序遍历 BUY/SELL,对每笔交易使用 `qty * price * exchangeRate` 手动计算真实法币成本,SELL 时按当前累计法币成本 ÷ 当前数量得出的平均成本扣减,杜绝 `metrics.accumulatedCost` 直接入库。 +- **Bug 2 - 日常快照反推本金**:`recordDailySnapshot()` 使用 `mv.minus(ap)`(市值 - 累计盈亏)反推本金,违反"绝对禁止反推本金"的架构红线,且会被旧 PnL 数据污染。 +- **修复方案**:将 `totalCostCny` 计算改为直接累加底层 `totalCostCny` 字段:`positions.reduce((sum, pos) => sum.plus(new Big(pos.totalCostCny || '0')), new Big(0))`,确保本金数据原汁原味。 +- **执行与验收**:成功执行 `scripts/reconstruct.ts` 全量重建 1248 天历史快照;数据库 `portfolio_snapshots` 表已覆写完毕,2022/12/12 节点投入本金精确显示为 `5094.59`。 + ## 通过引入 force-dynamic 和 revalidatePath 彻底剥离 Next.js 默认缓存机制,确保走势图等核心财务 UI 与底层数据库的 0 延迟一致性 (Task 78) - 在 `app/layout.tsx`(根布局)和 `app/dashboard/layout.tsx`(Dashboard 布局)顶部强制声明 `export const dynamic = 'force-dynamic'` 与 `export const revalidate = 0`,确保整棵 Server Component 树绝不缓存财务大盘数据。 - 在 `app/api/admin/rebuild-snapshots/route.ts` 中引入 `revalidatePath('/dashboard', 'page')` 与 `revalidatePath('/', 'layout')`,在历史快照全量重建并批量 INSERT 入库完成后、返回 Response 之前执行缓存清盘钩子,使 Dashboard 页面下次访问时强制读取最新数据库快照。 diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts index 740cf03..0372d91 100644 --- a/src/actions/snapshots.ts +++ b/src/actions/snapshots.ts @@ -32,13 +32,8 @@ export async function recordDailySnapshot() { new Big(0) ).toString(); - // 推导真实投入本金 CNY = 市值 - 累计盈亏 const totalCostCny = positions.reduce( - (sum, pos) => { - const mv = new Big(pos.marketValueCny || '0'); - const ap = new Big(pos.accumulatedPnlCny || '0'); - return sum.plus(mv.minus(ap)); - }, + (sum, pos) => sum.plus(new Big(pos.totalCostCny || '0')), new Big(0) ).toString(); @@ -357,6 +352,7 @@ export async function reconstructPortfolioHistory() { quantity: transactions.quantity, price: transactions.price, fee: transactions.fee, + exchangeRate: transactions.exchangeRate, }) .from(transactions) .where(lte(transactions.executedAt, currentDate)) @@ -399,7 +395,30 @@ export async function reconstructPortfolioHistory() { const metrics = calculateAssetMetrics(assetTxs, priceStrForMetrics); const posValueCny = new Big(metrics.marketValue).times(snapshotFxRate); - const posCostCny = new Big(metrics.accumulatedCost || '0'); + + // 使用交易时的真实汇率计算法币本金,而非直接用 metrics.accumulatedCost + let calculatedFiatCost = new Big(0); + const rawTxs = historicalTx.filter(t => t.assetId === assetId && (t.txType === 'BUY' || t.txType === 'SELL' || t.txType === 'DIVIDEND')); + let currentQty = new Big(0); + for (const tx of rawTxs) { + const qty = new Big(tx.quantity); + const fx = new Big(tx.exchangeRate || '1'); + const price = new Big(tx.price); + + if (tx.txType === 'BUY') { + currentQty = currentQty.plus(qty); + calculatedFiatCost = calculatedFiatCost.plus(qty.times(price).times(fx)); + } else if (tx.txType === 'SELL') { + let avgFiatCostPerUnit = new Big(0); + if (currentQty.gt(0)) { + avgFiatCostPerUnit = calculatedFiatCost.div(currentQty); + } + calculatedFiatCost = calculatedFiatCost.minus(avgFiatCostPerUnit.times(qty)); + currentQty = currentQty.minus(qty); + } + } + + const posCostCny = calculatedFiatCost.gt(0) ? calculatedFiatCost : new Big(0); totalValueCny = totalValueCny.plus(posValueCny); totalCostCny = totalCostCny.plus(posCostCny);