fix(ledger): 修复时区黑洞,强制使用绝对字符串边界获取精准历史汇率

This commit is contained in:
kennethcheng 2026-05-02 17:54:10 +08:00
parent 7cdee75bb9
commit a5daa6a751
2 changed files with 63 additions and 52 deletions

View File

@ -1,5 +1,12 @@
# Omniledger 架构与开发记忆 (Memory) # Omniledger 架构与开发记忆 (Memory)
## 废弃 JS Date 对象隐式比较,采用 SQL 字符串绝对边界 (YYYY-MM-DD 23:59:59) 重构汇率查询逻辑,彻底解决时区偏移导致的真实汇率读取失败问题 (Task 75)
- 在 `src/actions/snapshots.ts``buildDailyRatesMap` 函数中,**彻底废弃**基于 `new Date(targetDateStr + 'T23:59:59.999')` 的 JS Date 对象比较逻辑。
- **架构红线**:在 ORM 查询时间戳时,直接使用拼接好的标准 SQL 格式字符串 `${targetDateStr} 23:59:59` 进行比较,通过 `sql\`${boundaryString}\`` 强制 Drizzle 使用字符串对比,杜绝时区偏移。
- **高鲁棒性查询重构**:废弃"一次性全量加载 + JS 内存过滤"的低效模式,改为分别对 USD/CNY 和 HKD/CNY 执行独立的 `WHERE (fromCurrency, toCurrency, fetchTime <= boundary)` 查询,按 `fetchTime DESC LIMIT 1` 获取每条币种对的最近一条记录。
- **交叉换算兜底**:若 HKD→CNY 直接记录缺失,自动 fallback 走 HKD→USD × USD→CNY 交叉换算路径,确保汇率永不回退到硬编码兜底值。
- **防盲点日志**:在 return 前注入 `console.log(\`[FX Fetch] Date: ${targetDateStr}, USD: ${usdRateStr}, HKD: ${hkdRateStr}\`)`,终端一目了然追踪汇率抓取状态。
## 修复时光机引擎1. 将汇率查询条件延展至 23:59:59 以解决跨日边界导致的数据穿透失败2. 修复对已结算法币成本进行双重汇率乘法的严重财务逻辑 Bug (Task 74) ## 修复时光机引擎1. 将汇率查询条件延展至 23:59:59 以解决跨日边界导致的数据穿透失败2. 修复对已结算法币成本进行双重汇率乘法的严重财务逻辑 Bug (Task 74)
- **汇率时间边界修复**:在 `src/actions/snapshots.ts``app/api/debug/snapshot/route.ts``buildDailyRatesMap` 函数中,将 `getClosestRateForDate` 内部的时间比较边界从 `targetDateStr + 'T00:00:00Z'` 延展至 `targetDateStr + 'T23:59:59.999'`。修复根因:初始 SQL 查询使用 `lte(fetchTime, 23:59:59)` 拉取全天数据,但内层循环用次日 00:00:00 做 `<=` 截断,导致跨日时区边界下汇率数据穿透失败,美元汇率回退到 7.22 兜底值而非数据库真实的 6.82。 - **汇率时间边界修复**:在 `src/actions/snapshots.ts``app/api/debug/snapshot/route.ts``buildDailyRatesMap` 函数中,将 `getClosestRateForDate` 内部的时间比较边界从 `targetDateStr + 'T00:00:00Z'` 延展至 `targetDateStr + 'T23:59:59.999'`。修复根因:初始 SQL 查询使用 `lte(fetchTime, 23:59:59)` 拉取全天数据,但内层循环用次日 00:00:00 做 `<=` 截断,导致跨日时区边界下汇率数据穿透失败,美元汇率回退到 7.22 兜底值而非数据库真实的 6.82。
- **双重汇率乘法修复**:在 `src/utils/finance.ts``calculateAssetMetrics` 返回值中新增 `accumulatedCost` 字段(`totalInvested - totalRealized`代表持仓净成本Base Currency - **双重汇率乘法修复**:在 `src/utils/finance.ts``calculateAssetMetrics` 返回值中新增 `accumulatedCost` 字段(`totalInvested - totalRealized`代表持仓净成本Base Currency

View File

@ -213,71 +213,75 @@ export async function getEffectivePrice(
return record?.price ?? null; return record?.price ?? null;
} }
interface RateRecord {
rate: string;
fetchTime: Date;
}
async function buildDailyRatesMap(targetDateStr: string): Promise<Record<string, Big>> { async function buildDailyRatesMap(targetDateStr: string): Promise<Record<string, Big>> {
const allRates = await db const boundaryString = `${targetDateStr} 23:59:59`;
// 获取 USD/CNY — 取目标时间点之前最后一条 USD->CNY 记录
const usdRecords = await db
.select({ .select({
fromCurrency: exchangeRatesHistory.fromCurrency,
toCurrency: exchangeRatesHistory.toCurrency,
rate: exchangeRatesHistory.rate, rate: exchangeRatesHistory.rate,
fetchTime: exchangeRatesHistory.fetchTime, fetchTime: exchangeRatesHistory.fetchTime,
}) })
.from(exchangeRatesHistory) .from(exchangeRatesHistory)
.where(lte(exchangeRatesHistory.fetchTime, new Date(targetDateStr + 'T23:59:59'))) .where(
.orderBy(asc(exchangeRatesHistory.fetchTime)); and(
eq(exchangeRatesHistory.fromCurrency, 'USD'),
eq(exchangeRatesHistory.toCurrency, 'CNY'),
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
)
)
.orderBy(desc(exchangeRatesHistory.fetchTime))
.limit(1);
const ratesCache = new Map<string, RateRecord[]>(); // 获取 HKD/CNY — 取目标时间点之前最后一条 HKD->CNY 记录
for (const rec of allRates) { const hkdRecords = await db
const key = `${rec.fromCurrency}_${rec.toCurrency}`; .select({
if (!ratesCache.has(key)) { rate: exchangeRatesHistory.rate,
ratesCache.set(key, []); fetchTime: exchangeRatesHistory.fetchTime,
})
.from(exchangeRatesHistory)
.where(
and(
eq(exchangeRatesHistory.fromCurrency, 'HKD'),
eq(exchangeRatesHistory.toCurrency, 'CNY'),
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
)
)
.orderBy(desc(exchangeRatesHistory.fetchTime))
.limit(1);
// 若 HKD->CNY 不存在,尝试走 HKD->USD 再 USD->CNY 的交叉换算
let hkdRateStr: string | null = hkdRecords[0]?.rate ?? null;
if (!hkdRateStr) {
const hkdUsdRecords = await db
.select({
rate: exchangeRatesHistory.rate,
fetchTime: exchangeRatesHistory.fetchTime,
})
.from(exchangeRatesHistory)
.where(
and(
eq(exchangeRatesHistory.fromCurrency, 'HKD'),
eq(exchangeRatesHistory.toCurrency, 'USD'),
lte(exchangeRatesHistory.fetchTime, sql`${boundaryString}`)
)
)
.orderBy(desc(exchangeRatesHistory.fetchTime))
.limit(1);
const usdToCnyRate = usdRecords[0]?.rate ?? null;
if (hkdUsdRecords[0]?.rate && usdToCnyRate) {
hkdRateStr = new Big(hkdUsdRecords[0].rate).times(new Big(usdToCnyRate)).toString();
} }
ratesCache.get(key)!.push({ rate: rec.rate, fetchTime: rec.fetchTime });
} }
function getClosestRateForDate(currencyPair: string): string | null { const usdRateStr = usdRecords[0]?.rate ?? null;
const records = ratesCache.get(currencyPair);
if (!records || records.length === 0) return null;
const endOfDay = new Date(targetDateStr + 'T23:59:59.999');
let closest: RateRecord | null = null;
for (const rec of records) {
if (rec.fetchTime <= endOfDay) {
closest = rec;
} else {
break;
}
}
return closest?.rate ?? null;
}
function resolveRate(from: string, to: string): string | null { console.log(`[FX Fetch] Date: ${targetDateStr}, USD: ${usdRateStr}, HKD: ${hkdRateStr}`);
const directKey = `${from}_${to}`;
const directRate = getClosestRateForDate(directKey);
if (directRate) return directRate;
const usdKey = `USD_${to}`;
const usdRate = getClosestRateForDate(usdKey);
if (!usdRate) return null;
if (from === 'USD') return usdRate;
const fromToUsdKey = `${from}_USD`;
const fromToUsdRate = getClosestRateForDate(fromToUsdKey);
if (fromToUsdRate) {
return new Big(fromToUsdRate).times(new Big(usdRate)).toString();
}
return null;
}
const hkdRate = resolveRate('HKD', 'CNY');
const usdRate = resolveRate('USD', 'CNY');
return { return {
USD: new Big(usdRate || '7.22'), USD: new Big(usdRateStr || '7.22'),
HKD: new Big(hkdRate || '0.92'), HKD: new Big(hkdRateStr || '0.92'),
CNY: new Big(1), CNY: new Big(1),
}; };
} }