From 91e748525945aae759acdc44323076f00d39bc6a Mon Sep 17 00:00:00 2001 From: kennethcheng Date: Thu, 30 Apr 2026 13:15:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(ledger):=20=E4=BF=AE=E5=A4=8D=E4=BB=B7?= =?UTF-8?q?=E6=A0=BC=E5=8F=98=E9=87=8F=E6=B3=84=E6=BC=8F=E4=B8=8E=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=AF=94=E8=BE=83=E9=99=B7?= =?UTF-8?q?=E9=98=B1=EF=BC=8C=E8=BF=98=E5=8E=9F=E7=9C=9F=E5=AE=9E=E5=87=80?= =?UTF-8?q?=E5=80=BC=E8=B5=B0=E5=8A=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Memory.md | 10 +++++++++- src/actions/snapshots.ts | 36 ++++++++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/Memory.md b/Memory.md index 2badcad..1959b15 100644 --- a/Memory.md +++ b/Memory.md @@ -159,4 +159,12 @@ - 点击按钮后调用 `reconstructPortfolioHistory()` Server Action,启动 Day-by-Day 历史净值回溯引擎。 - 集成 Sonner Toast 通知:点击时显示 `toast.loading('正在重构历史走势...')`,完成后显示 `toast.success('重构成功,已填充 N 天历史数据')`,并自动刷新 `snapshots` 状态以更新 AreaChart 走势图。 - 按钮启用 `isPending` 防重复点击,重构期间显示"重构中..."并禁用按钮。 -- 打通历史净值回溯全链路:用户从 Dashboard 首页一键触发,底层引擎自动从最早交易日起逐天计算持仓与价格,填充 `portfolio_snapshots` 表,前端图表实时渲染历史波动曲线。 \ No newline at end of file +- 打通历史净值回溯全链路:用户从 Dashboard 首页一键触发,底层引擎自动从最早交易日起逐天计算持仓与价格,填充 `portfolio_snapshots` 表,前端图表实时渲染历史波动曲线。 + +## 修复时光机引擎的变量泄漏与日期补零问题 (Task 51) +- 修复了时光机循环引擎中的变量作用域泄漏导致错误复用 BTC 价格的 Bug,并标准化了 YYYY-MM-DD 日期补零逻辑以修复 SQL 字符串对比错误。 +- 在 `src/actions/snapshots.ts` 中新增 `formatDateString(date)` 辅助函数,使用 `padStart(2, '0')` 严格保证月份和日期补零,替代各处散落的 `toISOString().split('T')[0]` 调用。 +- 修复 `getHistoricalPositions` 和 `getEffectivePrice` 中的日期字符串生成逻辑:统一使用 `formatDateString(targetDate)`,确保 Drizzle 的 `lte` 查询中左右两边日期字符串格式一致(均为 `YYYY-MM-DD`),修复了 UTC 与本地时区混用导致的查询偏移。 +- 修复 `reconstructPortfolioHistory` 主循环:while 条件与 `dateStr` 生成均改用 `formatDateString(currentDate)`,确保与 `getTodayInShanghai()` 返回格式完全一致。 +- 在资产遍历循环中增加兜底防御逻辑:当 `getEffectivePrice` 返回 null 时,使用 `assetLatestPriceMap` 中缓存的 `latestPrice` 作为兜底价格,避免价格变量为 undefined 或沿用上一资产的值;同时修正了 `totalCostCny` 在 `priceStr` 为空时不应累加的 Bug。 +- 在 `allAssets` 查询中新增 `latestPrice` 字段,构建 `assetLatestPriceMap` 供兜底逻辑使用。 \ No newline at end of file diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts index 5fdeca4..016a9e0 100644 --- a/src/actions/snapshots.ts +++ b/src/actions/snapshots.ts @@ -6,16 +6,20 @@ import { getPortfolioPositions } from './portfolio'; import { asc, desc, eq, gte, lte, sql } from 'drizzle-orm'; import Big from 'big.js'; +function formatDateString(date: Date): string { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + function getTodayInShanghai(): string { const now = new Date(); const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' }); const utcDate = new Date(utcStr); const shanghaiOffset = 8 * 60 * 60 * 1000; const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset); - const year = shanghaiDate.getFullYear(); - const month = String(shanghaiDate.getMonth() + 1).padStart(2, '0'); - const day = String(shanghaiDate.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return formatDateString(shanghaiDate); } export async function recordDailySnapshot() { @@ -113,7 +117,7 @@ interface HistoricalPosition { } export async function getHistoricalPositions(targetDate: Date): Promise { - const dateStr = targetDate.toISOString().split('T')[0]; + const dateStr = formatDateString(targetDate); const allTransactions = await db .select({ @@ -183,7 +187,7 @@ export async function getEffectivePrice( assetId: string, targetDate: Date ): Promise { - const dateStr = targetDate.toISOString().split('T')[0]; + const dateStr = formatDateString(targetDate); const [record] = await db .select({ @@ -227,11 +231,14 @@ export async function reconstructPortfolioHistory() { .select({ id: assets.id, baseCurrency: assets.baseCurrency, + latestPrice: assets.latestPrice, }) .from(assets); const assetBaseCurrencyMap = new Map(); + const assetLatestPriceMap = new Map(); for (const a of allAssets) { assetBaseCurrencyMap.set(a.id, a.baseCurrency); + assetLatestPriceMap.set(a.id, a.latestPrice || '0'); } const allRates = await db @@ -273,8 +280,8 @@ export async function reconstructPortfolioHistory() { let daysReconstructed = 0; - while (currentDate.toISOString().split('T')[0] <= todayStr) { - const dateStr = currentDate.toISOString().split('T')[0]; + while (formatDateString(currentDate) <= todayStr) { + const dateStr = formatDateString(currentDate); const positions = await getHistoricalPositions(currentDate); @@ -284,12 +291,21 @@ export async function reconstructPortfolioHistory() { for (const pos of positions) { const priceStr = await getEffectivePrice(pos.assetId, currentDate); const baseCurrency = assetBaseCurrencyMap.get(pos.assetId) || 'USD'; - if (priceStr) { - const cnyPrice = convertPriceToCny(priceStr, baseCurrency); + + if (!priceStr) { + const fallbackPrice = assetLatestPriceMap.get(pos.assetId) || '0'; + const cnyPrice = convertPriceToCny(fallbackPrice, baseCurrency); const price = new Big(cnyPrice); const qty = new Big(pos.quantity); totalValueCny = totalValueCny.plus(price.times(qty)); + totalCostCny = totalCostCny.plus(pos.totalCost); + continue; } + + const cnyPrice = convertPriceToCny(priceStr, baseCurrency); + const price = new Big(cnyPrice); + const qty = new Big(pos.quantity); + totalValueCny = totalValueCny.plus(price.times(qty)); totalCostCny = totalCostCny.plus(pos.totalCost); }