fix(ledger): 修复价格变量泄漏与日期字符串比较陷阱,还原真实净值走势

This commit is contained in:
kennethcheng 2026-04-30 13:15:30 +08:00
parent 9622e0d828
commit 91e7485259
2 changed files with 35 additions and 11 deletions

View File

@ -160,3 +160,11 @@
- 集成 Sonner Toast 通知:点击时显示 `toast.loading('正在重构历史走势...')`,完成后显示 `toast.success('重构成功,已填充 N 天历史数据')`,并自动刷新 `snapshots` 状态以更新 AreaChart 走势图。 - 集成 Sonner Toast 通知:点击时显示 `toast.loading('正在重构历史走势...')`,完成后显示 `toast.success('重构成功,已填充 N 天历史数据')`,并自动刷新 `snapshots` 状态以更新 AreaChart 走势图。
- 按钮启用 `isPending` 防重复点击,重构期间显示"重构中..."并禁用按钮。 - 按钮启用 `isPending` 防重复点击,重构期间显示"重构中..."并禁用按钮。
- 打通历史净值回溯全链路:用户从 Dashboard 首页一键触发,底层引擎自动从最早交易日起逐天计算持仓与价格,填充 `portfolio_snapshots` 表,前端图表实时渲染历史波动曲线。 - 打通历史净值回溯全链路:用户从 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` 供兜底逻辑使用。

View File

@ -6,16 +6,20 @@ import { getPortfolioPositions } from './portfolio';
import { asc, desc, eq, gte, lte, sql } from 'drizzle-orm'; import { asc, desc, eq, gte, lte, sql } from 'drizzle-orm';
import Big from 'big.js'; 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 { function getTodayInShanghai(): string {
const now = new Date(); const now = new Date();
const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' }); const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
const utcDate = new Date(utcStr); const utcDate = new Date(utcStr);
const shanghaiOffset = 8 * 60 * 60 * 1000; const shanghaiOffset = 8 * 60 * 60 * 1000;
const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset); const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset);
const year = shanghaiDate.getFullYear(); return formatDateString(shanghaiDate);
const month = String(shanghaiDate.getMonth() + 1).padStart(2, '0');
const day = String(shanghaiDate.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} }
export async function recordDailySnapshot() { export async function recordDailySnapshot() {
@ -113,7 +117,7 @@ interface HistoricalPosition {
} }
export async function getHistoricalPositions(targetDate: Date): Promise<HistoricalPosition[]> { export async function getHistoricalPositions(targetDate: Date): Promise<HistoricalPosition[]> {
const dateStr = targetDate.toISOString().split('T')[0]; const dateStr = formatDateString(targetDate);
const allTransactions = await db const allTransactions = await db
.select({ .select({
@ -183,7 +187,7 @@ export async function getEffectivePrice(
assetId: string, assetId: string,
targetDate: Date targetDate: Date
): Promise<string | null> { ): Promise<string | null> {
const dateStr = targetDate.toISOString().split('T')[0]; const dateStr = formatDateString(targetDate);
const [record] = await db const [record] = await db
.select({ .select({
@ -227,11 +231,14 @@ export async function reconstructPortfolioHistory() {
.select({ .select({
id: assets.id, id: assets.id,
baseCurrency: assets.baseCurrency, baseCurrency: assets.baseCurrency,
latestPrice: assets.latestPrice,
}) })
.from(assets); .from(assets);
const assetBaseCurrencyMap = new Map<string, string>(); const assetBaseCurrencyMap = new Map<string, string>();
const assetLatestPriceMap = new Map<string, string>();
for (const a of allAssets) { for (const a of allAssets) {
assetBaseCurrencyMap.set(a.id, a.baseCurrency); assetBaseCurrencyMap.set(a.id, a.baseCurrency);
assetLatestPriceMap.set(a.id, a.latestPrice || '0');
} }
const allRates = await db const allRates = await db
@ -273,8 +280,8 @@ export async function reconstructPortfolioHistory() {
let daysReconstructed = 0; let daysReconstructed = 0;
while (currentDate.toISOString().split('T')[0] <= todayStr) { while (formatDateString(currentDate) <= todayStr) {
const dateStr = currentDate.toISOString().split('T')[0]; const dateStr = formatDateString(currentDate);
const positions = await getHistoricalPositions(currentDate); const positions = await getHistoricalPositions(currentDate);
@ -284,12 +291,21 @@ export async function reconstructPortfolioHistory() {
for (const pos of positions) { for (const pos of positions) {
const priceStr = await getEffectivePrice(pos.assetId, currentDate); const priceStr = await getEffectivePrice(pos.assetId, currentDate);
const baseCurrency = assetBaseCurrencyMap.get(pos.assetId) || 'USD'; const baseCurrency = assetBaseCurrencyMap.get(pos.assetId) || 'USD';
if (priceStr) {
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 cnyPrice = convertPriceToCny(priceStr, baseCurrency);
const price = new Big(cnyPrice); const price = new Big(cnyPrice);
const qty = new Big(pos.quantity); const qty = new Big(pos.quantity);
totalValueCny = totalValueCny.plus(price.times(qty)); totalValueCny = totalValueCny.plus(price.times(qty));
}
totalCostCny = totalCostCny.plus(pos.totalCost); totalCostCny = totalCostCny.plus(pos.totalCost);
} }