stock-portfolio_byQwen3.6/Memory.md

495 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Omniledger 架构与开发记忆 (Memory)
## 执行 Task 95为 /api/cron/fetch-prices 及其他后端 API 注入 force-dynamic 声明,彻底阻断 Next.js 静态收集导致 Turbopack 克隆实例失败的构建期崩溃
- **架构红线**:所有涉及数据库写入或外部 API 调用的 Route Handler 必须显式声明 `export const dynamic = 'force-dynamic'``export const fetchCache = 'force-no-store'`,确保构建期不被 Next.js 静态预渲染引擎错误收集。
- **扫描结论**:已全面审计 `app/api/` 目录下全部 4 个对外提供服务的 route.ts 文件:
- `app/api/cron/fetch-prices/route.ts` ✓ 已声明
- `app/api/cron/fetch-rates/route.ts` ✓ 已声明
- `app/api/debug/snapshot/route.ts` ✓ 已声明
- `app/api/admin/rebuild-snapshots/route.ts` ✓ 已声明
- **重灾区验证**cron 目录下的 fetch-prices资产价格同步和 fetch-rates汇率同步均已打上动态标签构建期崩溃风险已清除。
## 修复 portfolio.ts 卖出交易时的平均成本分母 Bug将 totalBuyQuantity 替换为真实的当前 quantity彻底消除了频繁交易导致的本金虚高幽灵账目 (Task 88)
- **根因分析**:在 `src/actions/portfolio.ts``getPortfolioPositions()` 函数中SELL 交易的平均成本计算使用了 `holding.totalBuyQuantity`(历史累计买入总量)作为分母,而非 `holding.quantity`(卖出前的实际真实持仓量)。当同一资产存在多次"买-卖-买-卖"循环时,`totalBuyQuantity` 会不断累加而不再下降,导致分母远大于真实持仓量,平均成本被严重稀释,卖出时扣减的 `totalBuyCostNative/Cny` 不足,最终造成 Dashboard 投入本金虚高(幽灵本金)。
- **架构红线**:计算移动平均成本时,绝对禁止使用 `holding.totalBuyQuantity` 作为分母!必须使用发生交易前的实际持仓量 `holding.quantity`
- **SELL 侧重构**:完全重写 `else if (isSell)` 代码块Native 维度使用 `holding.totalBuyCostNative.div(holding.quantity)` 计算平均成本CNY 维度使用 `holding.totalBuyCostCny.div(holding.quantity)` 计算平均成本,按卖出数量精确扣减成本本金,确保法币与外币同步等比下降。
- **清仓重置兜底**:保留 `1e-8` 精度容差的清仓归零逻辑,防御浮点数精度残留。
- **验收标准**Dashboard 走势图今天节点5月3日的"投入本金"从错误的 267k 跌回 242k 左右与时光机JSON导出的历史底盘彻底咬合。
## 修复 portfolio.ts 实时核算引擎,将持仓法币成本的计算逻辑对齐为逐笔乘入历史汇率,彻底消灭大盘图表尾节点本金因汇率波动而变异的 Bug (Task 87)
- **根因分析**:在 `src/actions/portfolio.ts``getPortfolioPositions()` 函数中BUY 交易的法币成本计算存在脆弱的 fallback 逻辑——当 `tx.exchangeRate` 缺失时,代码会回退到当前实时汇率字典 (`rateMap`) 而非使用交易发生时的历史汇率导致跨期持仓的成本基准被当前汇率污染。SELL 交易的成本扣减逻辑与 BUY 侧不一致,使用了不同的平均成本推导路径。
- **架构红线**:绝不允许在最后一步用外币总成本乘以当前汇率!每笔买入的法币成本 = 数量 × 价格 × 该笔交易历史汇率 (`tx.exchangeRate`),禁止任何形式的全局汇率乘法。
- **BUY 侧重构**:移除 `rateMap` fallback 逻辑,强制使用 `tx.exchangeRate || '1'` 作为该笔交易的汇率(与 `reconstructPortfolioHistory` 中的正确算法对齐):`fiatCost = qty * price * txFx`,直接累加至 `totalBuyCostCny`
- **SELL 侧重构**:统一使用 `totalBuyCostCny / totalBuyQuantity` 作为平均法币成本,按卖出比例 `sellRatio = sellQty / totalBuyQuantity` 等比例扣减 `totalBuyCostCny``totalBuyCostNative`,保持汇率一致性。
- **Dashboard 验证**`app/dashboard/page.tsx` 的 `loadSnapshots()` 直接使用 `summary.totalCostCny` 写入快照,无多余运算;`net-worth-chart.tsx` 的 Tooltip 从 `_raw.totalCostCny` 读取,数据链路纯净。
- **验收标准**Dashboard 走势图今天节点的 Tooltip "投入本金" 从错误的 26 万多回落,与后端 API 输出的 `242239` 保持绝对一致。
## 开发基于 CSV 的历史汇率数据播种脚本,在 Schema 增加联合唯一约束,实装 BOM 头剔除与分批 Upsert 逻辑,确保海量历史金融数据的幂等安全写入 (Task 50)
## 开发基于 CSV 的历史汇率数据播种脚本,在 Schema 增加联合唯一约束,实装 BOM 头剔除与分批 Upsert 逻辑,确保海量历史金融数据的幂等安全写入 (Task 50)
-`src/db/schema.ts``exchangeRatesHistory` 表中新增联合唯一约束 `rate_time_unq`,基于 `(from_currency, to_currency, fetch_time)` 三列,防止重复写入,确保幂等性防线。
-`scripts/` 目录下创建 `seed-historical-rates.ts` 播种脚本,支持运行方式:`npx tsx scripts/seed-historical-rates.ts`。
- **CSV 解析逻辑**:使用 Node.js 原生 `fs.readFileSync` 读取 `scripts/rates.csv`,按换行符切割并剔除表头;**必须处理 BOM 头**`\uFEFF`)与空白符(`trim()`),确保字段纯净。
- **数据校验**:对 `rate` 字段执行 `parseFloat` 类型检查,对 `fetchTime` 执行 `Date` 解析有效性验证,非法行跳过并输出警告日志。
- **分批 Upsert 写入**:将解析好的 `records` 数组按 500 条/批次切割,使用 Drizzle 的 `onConflictDoUpdate` 执行批量插入;冲突时(基于联合唯一约束)更新 `rate` 字段为最新值,确保数据幂等安全。
- **验证**:脚本成功解析 1000 条有效记录USD→CNY 500 条 + HKD→CNY 500 条),分 2 个批次完成 Upsert数据库 `exchange_rates_history` 表已填充完整历史汇率数据。
## 大修快照生成引擎 (snapshots.ts),修复时光机重建历史时未乘汇率导致本币入库的致命 Bug并消灭日常快照中的反推本金逻辑 (Task 83)
## 大修快照生成引擎 (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 页面下次访问时强制读取最新数据库快照。
- 验收2026-05-01 节点总市值 `232,127.23`(极度接近目标 `232,232.52`)、投入本金 `242,239.25` 与重建数据完全吻合,走势图与底层 DB 实现实时对齐。
## 重构 PnL 聚合引擎,增加 tradeDate + createdAt 双重防碰撞排序,引入交易类型强转大写机制,并实装了清仓归零阻断器,彻底解决 T+0 交易残留 0 成本和幽灵持仓数量的致命 Bug (Task 76)
-`src/actions/portfolio.ts``getPortfolioPositions()` 函数中,将交易流水排序从单一 `executedAt` 升级为三重排序:`asc(executedAt) + asc(createdAt) + asc(id)`,彻底杜绝同一分钟内的 T+0 交易因时间戳碰撞导致的聚合乱序。
- **强制交易类型标准化**:在遍历循环的第一行注入 `String(tx.txType).toUpperCase().trim()` 处理,并兼容中文脏数据(`买入`/`卖出`),修复因大小写不一致或空格导致的类型匹配静默失效。
- **清仓归零阻断器 (Zero-Position Circuit Breaker)**:在 SELL 交易扣减数量后,增加 `holding.quantity.lte(new Big('1e-8'))` 检测,一旦清仓(含浮点灰尘),立即强制清零 `totalBuyCostCny`、`totalBuyCostNative`、`totalBuyQuantity`,但**保留 `realizedPnlCny``realizedPnlNative`**(已实现盈亏),确保低买高卖赚的钱不丢失。
- **验收标准**:清仓资产(如"沪上阿姨 02859")的持仓量归零、成本价清零不再出现负数或乱码、累计盈亏正确保留。
- CSV 导出和大盘概览自动受益于底层聚合修复,无需额外修改。
## 废弃 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)
- **汇率时间边界修复**:在 `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
- **架构红线**`accumulatedCost` / `totalCost` 在底层数据库已是法币 (CNY) 本位(交易录入时已乘以 `exchangeRate`**严禁再与 `snapshotFxRate` 相乘**。修复 `reconstructPortfolioHistory()` 中的 `posCostCny` 计算:从 `(metrics.marketValue - metrics.accumulatedPnl) * snapshotFxRate` 改为直接取 `metrics.accumulatedCost`,彻底消除 USD→CNY 再×FXRate 的双重折算暴击。
- 同步修复 `app/api/debug/snapshot/route.ts` X光机接口`calcCostCny` 从 `holding.totalCost * fxNum` 改为 `holding.totalCost`,确保调试接口与时光机引擎逻辑完全一致。
- **验收**META 的 `calculatedCostCny` 从虚高的 61 万量级回落到 4255 左右真实水平,美股 `snapshotFxRate` 成功抓取数据库 6.82 而非 7.22 兜底值。
## 重构时光机底层引擎,引入基于 lte 的历史价格/汇率向后穿透查询,解决数据断层导致的 0 价格黑洞与汇率串用 Bug (Task 72)
-`src/actions/snapshots.ts``reconstructPortfolioHistory()` 中,将汇率获取从"一次性全量加载"重构为"按天循环顶部动态构建":每天 `targetDate` 循环开始时调用 `buildDailyRatesMap(dateStr)`,查询 `exchange_rates_history``fetch_time <= targetDate` 的所有记录,按 `(fromCurrency, toCurrency)` 分组构建当日汇率字典O(1) 内存访问。
- **汇率兜底安全值**USD → 7.22HKD → 0.92CNY → 1确保新系统建的老账单查不到历史汇率时不会崩溃。
- **价格向后穿透修复**:废弃 `getEffectivePrice` 中查不到价格时回退到 `latestPrice` 的逻辑,改为新增 `getHistoricalPriceWithFallback(assetId, dateStr, fallbackCostPrice)` 函数,使用 `lte(date, targetDate)` + `orderBy(desc)` + `limit(1)` 查询历史价格;若该资产连一笔历史价格都没有(如 NXE将持仓成本价`totalCost / quantity`)作为 `snapshotPrice` 传入,保证市值不归零。
- **币种汇率精准匹配**:在资产计算循环中,严格根据 `baseCurrency``dailyRates` 字典中取值(如 USD 资产取 `dailyRates['USD']`HKD 资产取 `dailyRates['HKD']`),彻底杜绝 USD/HKD 汇率串用问题。
- 同步修复 `app/api/debug/snapshot/route.ts` X光机接口废弃原有的 `getHistoricalPrice`(未 await 执行导致恒返回 null全面接入 `buildDailyRatesMap` + `getHistoricalPriceWithFallback` 双引擎,确保调试接口与时光机引擎逻辑完全一致。
## 新增 /api/debug/snapshot X光透视接口用于针对特定日期的历史资产快照进行逐笔对账排查历史总资产异常波动的元凶 (Task 71)
-`app/api/debug/snapshot/route.ts` 创建 GET 接口,接收 `date``targetDate` 查询参数(默认 `2026-04-30`)。
- 复用 `src/actions/snapshots.ts``getHistoricalPositions()` 的核心持仓推演逻辑:从 `transactions` 表获取目标日期 23:59:59 之前的所有流水,按资产聚合计算 `quantity`(持仓量)和 `totalCost`累计投入成本SELL 时按平均成本扣减)。
- 对每个持仓资产,通过 `assetPricesHistory` 表按 `(assetId, date <= targetDate)` 降序 Limit 1 获取当日收盘价(断点结转),通过 `exchangeRatesHistory` 表按就近匹配策略获取当日汇率Base Currency → CNY
- 返回逐笔明细数组 `details`,每项包含 `symbol`、`quantity`、`snapshotPrice`、`snapshotFxRate`、`calculatedMarketValueCny`(持仓量 × 快照价 × 快照汇率)、`calculatedCostCny`(累计成本 × 快照汇率),以及汇总的 `totalMarketValue``totalCost`
- 结果按 `calculatedMarketValueCny` 降序排列,`snapshotPrice` 和 `snapshotFxRate` 经 Big.js 去零清洗,确保可读性。
- 访问 `http://localhost:3000/api/debug/snapshot?date=2026-04-30` 可验证2026-04-30 快照覆盖 19 个资产,总市值 961,037.69 CNY总成本 1,545,059.23 CNY。
## 优化 exportToCSV 功能,基于 type 和 baseCurrency 的交叉判定注入了'市场'属性分类列,便于在外部进行资产敞口分析 (Task 70)
## 优化 exportToCSV 功能,基于 type 和 baseCurrency 的交叉判定注入了'市场'属性分类列,便于在外部进行资产敞口分析 (Task 70)
-`app/dashboard/page.tsx``exportToCSV()` 函数中新增 `getMarketName(item)` 纯函数,实现市场维度的智能推导。
- 判定优先级:`type === 'CRYPTO'` → `baseCurrency` 硬核锚定 (USD→美股, HKD→港股, CNY/RMB→A股) → 正则兜底 (5位数字→港股, 60/00/30开头→A股) → 默认"其他市场"。
- CSV 表头在"代码"之后插入"市场"列,`rows` 映射严格对齐,确保 BTC 显示"虚拟币"、谷歌显示"美股"、小米显示"港股"、上海机场显示"A股"。
- 文件仍带 `\uFEFF` BOM 头Excel 打开中文不乱码。
## 优化 exportToCSV 数据净洗逻辑,利用防科学计数法的纯正则处理去除现价/成本价末尾的无意义零 (Task 69)
-`app/dashboard/page.tsx``exportToCSV()` 函数顶部注入 `stripTrailingZeros` 纯字符串去零工具函数。
- 该函数内置防御性设计:对 null/undefined/空值返回 `"0"`;仅对包含小数点的字符串执行正则处理(`/0+$/` 剥离尾随零 → `/\.$/` 剥离末尾小数点),彻底杜绝极小数值(如 `0.00000001`)被 `String()` 转换后触发科学计数法(`1e-8`)。
- 将 CSV 映射层中的"成本价"字段(`avgCostNative`)和"现价"字段(`latestPrice`)包裹 `stripTrailingZeros()`,使小米 `29.020` 输出为 `29.02`、整数价格 `29.00` 输出为 `29`,而"总市值""浮动盈亏""累计盈亏"等法币资产字段保留 `.toFixed(2)` 的两位小数格式以维持表格对齐。
## 新增持仓明细的已清仓资产显示开关(基于 1e-8 精度容差过滤),并实装注入 UTF-8 BOM 的客户端 CSV 导出功能 (Task 68)
-`src/actions/portfolio.ts``getPortfolioPositions()` 函数中新增 `includeCleared: boolean = false` 参数,将清仓判定从 `quantity === 0` 升级为 `Big.js``1e-8` 精度容差过滤(`holding.quantity.gt('1e-8')`),杜绝浮点数灰尘资产被错误保留或隐藏。
-`!includeCleared` 时,自动过滤掉持仓量 ≤ 1e-8 的已清仓资产;当 `includeCleared` 为 true 时,已清仓资产会被返回,其 `marketValue``floatingPnl` 为 0但**保留真实计算出的 `accumulatedPnl`(累计盈亏)字段**,确保历史盈亏数据不丢失。
-`app/dashboard/page.tsx` 的"持仓明细"卡片头部新增 Checkbox显示"👁 显示历史持仓")和 Button"📥 导出 CSV"Checkbox 切换时基于 `Big(pos.quantity).gt('1e-8')` 在前端动态过滤列表,无需额外请求。
- 编写 `exportToCSV()` 客户端导出函数:定义中文字段表头(资产名称、代码、持仓量、成本价、现价、总市值、浮动盈亏、累计盈亏),将当前表格数据映射为 CSV 格式,**注入 `\uFEFF` UTF-8 BOM 头**并创建 Blob 触发下载,彻底解决 Excel 打开中文乱码问题。
- 文件名格式为 `portfolio_details_YYYY-MM-DD.csv`,所有数值字段用双引号包裹防止逗号破坏 CSV 格式。
- 同步更新 `getPortfolioSummary(includeCleared)``recordDailySnapshot()` 以传递参数。
## 修复腾讯行情接口 URL 拼接逻辑,剔除导致数据残缺的 s_ (简易版) 前缀,确保所有市场强制获取包含时间戳的全量报文 (Task 67)
-`app/api/cron/fetch-prices/route.ts``fetchStockPrice()` 函数与 `src/actions/market.ts``getTencentSymbol()` 函数中,将美股资产的前缀映射从 `'s_us'` 强制重构为 `'us'`
- **根因分析:** 腾讯财经 gtimg 接口使用 `s_us` 前缀时返回的是"简易版"报文(仅 ~10 个字段),缺失 Index 30 的日期时间字段;使用 `us` 前缀时返回"全量版"报文60+ 个字段),包含完整的交易时间戳 `2026-05-01 16:00:06`
- 修改前的错误拼接:`https://sqt.gtimg.cn/q=s_usGOOG` → 10 字段,无日期 → 触发 `[Date Parse Fatal Error]`
- 修改后的正确拼接:`https://sqt.gtimg.cn/q=usGOOG` → 60+ 字段Index 30 含日期 → `parseMarketDate()` 成功解析 `2026-05-01`
- 其他市场前缀保持不变:港股 `hk` (如 `hk01810`)、A股沪市 `sh` (如 `sh600009`)、A股深市 `sz` (如 `sz002594`)。
- **验证:** `curl` 对比 `s_usGOOG` (10字段) vs `usGOOG` (60+字段)Cron 接口成功同步 21 条记录0 失败,控制台无任何 `[Date Parse Fatal Error]` 报错,美股日期正确解析为 `2026-05-01`
## 部署防弹级 parseMarketDate 解析引擎,增加 payload 脏前缀清洗逻辑,彻底解决美股日期解析崩溃触发 fallback 的幽灵 Bug (Task 66)
-`app/api/cron/fetch-prices/route.ts` 中一字不差地替换 `parseMarketDate(rawString: string)` 为工业级防污染版本。
- **核心修复:** 新增 `payload.includes('="')` 脏前缀清洗逻辑,从腾讯 gtimg 原始响应中提取 `="` 之后的纯净数据段,剔除结尾 `";`,从根本上消除 `v_usGOOG="...` 前缀导致的数组偏移与数据污染风险。
- 日期匹配顺序调整为:美股 (`-`) → 港股 (`/`) → A股 (`/^\d{8}/`),与 payload 清洗逻辑配合,确保跨市场日期提取零误判。
- 错误日志升级为 `console.error("[Date Parse Fatal Error]")`,致命错误时完整打印原始字符串便于调试;兜底逻辑保持不变。
- **验证:** `curl` 触发 Cron 接口成功21 条记录全部 upsert0 失败,控制台无任何 `[Date Parse Fatal Error]` 红字报错,美股日期字段正确解析为 `2026-05-01`
## 建立 asset_id 与 date 的联合唯一索引,重构三套跨市场日期解析正则,实现基于 onConflictDoUpdate 的价格历史幂等覆盖逻辑 (Task 65)
-`src/db/schema.ts``assetPricesHistory` 表中新增 `updateTime` 字段 (`timestamp('update_time').defaultNow()`),并将联合索引从 `uniqueIndex` 升级为 `unique()` 约束 (`unique().on(table.assetId, table.date)`),确保 `(assetId, date)` 在物理层严格唯一。
-`app/api/cron/fetch-prices/route.ts` 中一字不差地注入 `parseMarketDate(rawString: string)` 傻瓜式日期解析引擎:从腾讯财经 gtimg 原始响应的 Index 30 提取日期,支持 A股 (14位数字→YYYY-MM-DD)、港股 (斜杠分隔→YYYY-MM-DD)、美股 (横杠分隔→YYYY-MM-DD) 三种格式,含 try/catch 极端兜底。
- 废弃原有的 `SELECT → UPDATE/INSERT` 双步查询逻辑,全面替换为 Drizzle 的 `onConflictDoUpdate` UPSERT 语法:基于联合唯一约束,冲突时只更新 `price``updateTime`,实现完全幂等的价格覆盖。
- 移除不再使用的 `skippedCount` 计数器与 `eq` 导入,简化响应结构。
- 执行 `drizzle-kit push` 完成物理迁移,`curl` 测试确认:美股存入 `2026-05-01`港A股存入 `2026-04-30`;重复调用不产生新行,`update_time` 正确刷新。
## 升级时光机历史快照生成逻辑,引入就近汇率匹配策略 (Closest Rate Matching),消除因使用单一日结汇率导致的历史资产估值失真 (Task 64)
-`src/actions/snapshots.ts``reconstructPortfolioHistory()` 中,废弃从静态 `exchangeRates` 表获取当前汇率的旧逻辑,全面接入 `exchangeRatesHistory` 历史汇率时间序列表。
- **架构调整**:在 `dayLoop` 循环之前,一次性加载全部 `exchangeRatesHistory` 记录到内存,按 `(fromCurrency, toCurrency)` 键分组构建 `ratesCache``Map<string, RateRecord[]>`),每条记录已按 `fetchTime` 升序排列。
- **核心算法 `getClosestRateForDate(currencyPair, targetDateStr)`**:在有序数组中线性扫描,找到所有 `fetchTime <= targetDate` 的记录,返回最后一条(即最接近且小于等于目标日期的汇率),实现"就近匹配"策略。
- **汇率路由 `getHistoricalRate(from, to, dateStr)`**:优先查找直接汇率对(如 `USD_CNY`),若无则通过 USD 交叉换算(如 `HKD_USD` × `USD_CNY`),所有查找均基于目标日期的历史汇率,保持时间一致性。
- **循环内折算**:每个资产在每个交易日调用 `getHistoricalRate(baseCurrency, 'CNY', dateStr)` 获取当日历史汇率,替代之前静态的 `getRate(baseCurrency, 'CNY')`,确保 `posValueCny``posCostCny` 均使用真实历史汇率折算。
- **性能保障**汇率数据仅在循环外加载一次O(N) 初始化),循环内每次查找为 O(M) 线性扫描M 为每个币种对的汇率记录数,通常极小),无 N+1 查询问题。
- 成功重新构建 1248 天历史快照,所有日期的资产估值现在使用对应日期的真实汇率,消除历史回溯失真。
## 新增 exchange_rates_history 数据库表,并接入极速数据 (Jisu API) 建立每天自动追加的汇率时间序列抓取引擎 (Task 63a)
## 新增 exchange_rates_history 数据库表,并接入极速数据 (Jisu API) 建立每天自动追加的汇率时间序列抓取引擎 (Task 63a)
-`src/db/schema.ts` 中新增 `exchangeRatesHistory` 表定义:包含 `id` (uuid)、`fromCurrency`、`toCurrency` (固定 CNY)、`rate` (numeric(20,8) 高精度)、`fetchTime` (时间戳)、`createdAt`,支持 USD/CNY 与 HKD/CNY 双币种对的汇率历史追踪。
- 执行 `drizzle-kit push` 将新表推送到 PostgreSQL 数据库,确保表结构生效。
-`app/api/cron/fetch-rates/route.ts` 创建 Next.js Route Handler (GET),专供定时任务调用。
- **安全拦截:** 校验 `Authorization: Bearer ${process.env.CRON_SECRET}` 请求头,不匹配返回 401`CRON_SECRET``JISU_API_KEY` 未配置则返回 500。
- **极速数据 API 接入:** 并发请求 USD→CNY 与 HKD→CNY 的汇率接口 (`api.jisuapi.com/exchange/convert`),严格校验 `status === 0`,解析 `result.rate` 保持字符串形态入库。
- **容错设计:** 使用 `Promise.allSettled` 并发处理两个币种请求任一失败不会阻断另一个的入库DB 插入失败单独 catch 记录日志但不中断流程。
- **响应格式:** 返回 `{ success, timestamp, inserted, failed, details: { inserted: [{from,to,rate}], failed: [{from,error}] } }` 结构化 JSON。
- 新增环境变量 `JISU_API_KEY`(需在 `.env` 中配置极速数据 API 密钥)。
## 升级价格抓取引擎,实现从 gtimg 原始报文中提取 Index 30 的真实行情日期,并针对 US/SH/HK 三种日期格式执行标准化 YYYY-MM-DD 转换 (Task 61e)
## 升级价格抓取引擎,实现从 gtimg 原始报文中提取 Index 30 的真实行情日期,并针对 US/SH/HK 三种日期格式执行标准化 YYYY-MM-DD 转换 (Task 61e)
-`app/api/cron/fetch-prices/route.ts` 中新增 `parseMarketDate(rawString: string)` 核心工具函数,从腾讯财经 gtimg 原始响应(`~` 分隔)的 Index 30 提取真实交易日期。
- 支持三种市场日期格式标准化A股 `20260430161416`14位数字`2026-04-30`;港股 `2026/04/30 16:08:24`(斜杠分隔)→ `2026-04-30`;美股 `2026-05-01 09:31:00`(空格分隔)→ `2026-05-01`
- 重构 `fetchStockPrice` 函数返回值类型为 `{ price: string | null; rawResponse: string | null }`,保留完整原始响应供日期解析使用。
- 更新入库循环:每个 `assetId``date` 字段强制调用 `parseMarketDate(apiResponseString)` 获取,确保 `where` 查重条件与插入值使用同一解析日期,实现跨市场时间戳绝对准确。
- Crypto 资产保持原有 `dateStr`(当天日期)逻辑,因其通过币安 API 获取无内置日期字段。
## 彻底终结 404物理层文件系统审计与幽灵路由重建 (Task 61c)
## 彻底终结 404物理层文件系统审计与幽灵路由重建 (Task 61c)
- **根目录审计结论:** 项目使用根目录 `app/` 作为 Next.js App Router 的活跃根目录(而非 `src/app/``src/app/` 下残留的 `api/` 目录是幽灵路由的根源,导致 Next.js 无法挂载 `/api/cron/fetch-prices` 端点。
- **物理清除:** 已彻底删除 `src/app/api/cron/fetch-prices/route.ts` 及所有空父目录,消除错误的文件位置。
- **规范重建:** 在绝对正确的路径 `app/api/cron/fetch-prices/route.ts` 重新写入符合 Next.js App Router 规范的 Route Handler`export async function GET`),文件后缀为 `.ts`,目录结构严格遵循 `folder/route.ts` 规范。
- **通过物理审计清除了错误的 Next.js 路由文件命名,并重新严格对齐了 App Router 的文件夹/route.ts 规范。**
## 构建 /api/cron/fetch-prices 定时任务端点,实现针对活跃资产的行情抓取与按日期的幂等性 (Idempotent) 价格入库 (Task 61)
-`src/app/api/cron/fetch-prices/route.ts` 创建 Next.js Route Handler (GET),专供定时任务调用。
- **安全拦截:** 在 GET 方法顶部校验 `Authorization` 请求头 (`Bearer ${process.env.CRON_SECRET}`),不匹配则返回 401 Unauthorized`CRON_SECRET` 未配置则返回 500。
- **核心流程:** 查询 `assets` 表中 `STOCK``CRYPTO` 类型的活跃资产 → 遍历调用腾讯财经/币安 API 获取现价 → 生成当日日期字符串 (YYYY-MM-DD) → 幂等入库至 `asset_pricesHistory`
- **幂等性保障:** 先 `SELECT` 检查当天是否已存在该 `assetId` 的记录,存在则 `UPDATE` 价格,不存在则 `INSERT`,确保同一天同一资产绝不出现两条价格记录。
- **响应格式:** 返回 `{ success, date, synced, skipped, failed, details }` 结构化的 JSON 结果。
- 新增环境变量 `CRON_SECRET`(需在 `.env` 中配置),用于定时任务接口的认证密钥。
## 粉碎时光机中 marketValue 未乘汇率的致命双标幻觉,严格对齐历史快照与实时概览的 CNY 折算基准 (Task 59b)
- **核心认知纠正:** `calculateAssetMetrics` 输出的 `marketValue` **绝对不是 CNY**!它和 `totalInvested` 一样,都是原始币种 (Base Currency)。
- **致命 Bug** 之前的时光机逻辑中,`posCostCny` 使用 `metrics.totalInvested * fxRate` 折算,而 `posValueCny` 使用 `metrics.marketValue * fxRate` 折算。但由于 `totalInvested``finance.ts` 引擎中已经混入了 CNY 价格(如海尔的买入价格),导致投入本金被错误放大,在 4 月 30 日节点造成"投入本金"虚高、净盈亏显示为亏损 4 万的荒谬结果。
- **强制修复:** 在 `src/actions/snapshots.ts``reconstructPortfolioHistory()` 资产遍历循环中,`posCostCny` 改为 `(metrics.marketValue - metrics.accumulatedPnl) * fxRate` 推导,确保投入本金 = 市值 - 累计盈亏,逻辑自洽且与实时概览 (`recordDailySnapshot`) 的 CNY 折算基准完全对齐。
- **物理毁灭脏数据:** 在函数开头已执行 `DELETE FROM portfolio_snapshots`,重新跑时光机生成全新 1247 天快照,消除所有因汇率双标导致的错乱数据。
## 修复 calculateAssetMetrics 结果的汇率双重标准解析错误,重构 Live Overview 聚合基准 (Task 59)
- **核心认知纠正:** `calculateAssetMetrics` 引擎产出的所有数据(`marketValue`, `accumulatedPnl`, `floatingPnl`, `dilutedCost` 等)全都是原始基础币种 (Base Currency)!绝对不存在"部分已经是 CNY"的情况。
- 修复 `src/actions/snapshots.ts``reconstructPortfolioHistory()`:之前错误地将 `cnyPrice`(已折算的人民币价格)传入引擎,却只对 `totalInvested` 乘汇率、对 `marketValue` 不乘,形成"双标幻觉"。现改为传入原始币种价格 `priceStr`,然后无例外地将引擎输出的所有金额字段统一乘以 `fxRate` 得到 CNY确保 `posValueCny``posCostCny` 使用同一套汇率折算基准。
- 修复 `src/actions/portfolio.ts``getPortfolioSummary()`:废弃旧的 `cnyValue`/`pnlCny` 求和逻辑(旧逻辑基于 `calculateCnyValueFromPrice` 双路径计算,与引擎输出存在语义差异),改为从 `getPortfolioPositions()` 返回的 `marketValueCny`/`accumulatedPnlCny`/`floatingPnlCny` 字段在内存中累加,确保大盘汇总与底层明细使用同一套计算源(单一事实来源)。
- 同步修复 `recordDailySnapshot()``totalValueCny` 改为累加 `marketValueCny``totalCostCny` 改为 `marketValueCny - accumulatedPnlCny` 推导,与 `getPortfolioSummary` 保持一致。
- 彻底覆盖历史快照:重新执行 `reconstructPortfolioHistory()`,成功重构 1247 天历史快照数据,消除之前因混用汇率导致的错乱快照。
## 全局修复多币种聚合漏洞,强制叠加汇率乘数 (Task 58)
- 修复了跨币种资产直接相加导致的盈亏总额失真问题USD 盈利未乘以 ~7.23 汇率被当作 CNY 计算HKD 亏损同理。
-`src/actions/portfolio.ts``getPortfolioPositions()` 中,对每个资产获取 `exchangeRate`Base Currency → CNY将财务引擎 (`calculateAssetMetrics`) 产出的所有绝对金额字段(`marketValue`、`floatingPnl`、`accumulatedPnl`、`dilutedCost`)乘以汇率,映射为 `Cny` 结尾的新字段,确保 Dashboard 列表中的 CNY 聚合数据精确。
-`src/actions/snapshots.ts``reconstructPortfolioHistory()` 中,修复时光机逐日汇总逻辑:`metrics.marketValue` 已使用 CNY 价格计算可直接取用,`metrics.totalInvested` 基于原始币种价格需乘以 `exchangeRate` 折算为 CNY确保历史成本曲线正确。
- 重新触发时光机清洗,成功重构 1247 天历史快照数据。
## 重构历史快照生成逻辑,消除新旧算法断层 (Task 57)
- 将时光机重构逻辑全面接入 finance utils 引擎,清洗历史脏快照,消除新旧算法迭代导致的本金曲线断层。
-`src/utils/finance.ts``calculateAssetMetrics` 返回值中新增 `totalInvested` 字段,直接输出真实投入本金(含手续费),避免通过 `marketValue - accumulatedPnl` 间接推导导致的精度损失。
-`src/actions/snapshots.ts` 中废弃 `reconstructPortfolioHistory()` 的旧版 Day-by-Day 加减法逻辑,改为:对每一天 `currentDate`,获取该资产在 `currentDate` 及之前的所有交易流水 `historicalTx` 和历史收盘价 `historicalPrice`(断点结转),调用 `calculateAssetMetrics(historicalTx, historicalPrice)` 获取 `metrics.marketValue``metrics.totalInvested`,分别累加为当天的 `totalValueCny``totalCostCny`
- 重构后的 `reconstructPortfolioHistory()` 执行第一步调用 `db.delete(portfolioSnapshots)` 彻底清空旧的脏快照,然后从第一笔交易开始用新算法逐天重新生成,确保历史成本曲线平滑过渡、数值一致。
## 基础设施与底层架构
- 完成根目录的 Next.js 初始化、基础依赖安装与环境变量配置。
- 完成基于单例模式的数据库连接配置,并设定 Drizzle 迁移工具。
- 修复网络连接,成功将 tables 推送至 PostgreSQL 数据库。
- 统一规范 Git 全量提交机制 (`git add -A`),确立了严谨的代码版本控制防腐层。
## 数据库设计 (Schema)
- 成功定义资产枚举与 `assets` 表,支持跨资产标识。
- 完成核心 `transactions` (交易流水) 表的建立,并严格运用了 `numeric(36,18)` 的高精度配置。
- `assets` 表完成多次业务演进:新增 `latestPrice` (支持现价追踪)、`exchange` (显式交易所绑定) 以及 `name` (中文名称解析) 字段。
- `exchange_rates` (汇率表) 已建立,支持联合主键与跨币种交叉汇率架构。
- **引入 `portfolio_snapshots` 表**:用于每日记录投资组合快照,字段包括 `date` (唯一日期)、`total_value_cny` (当日总市值)、`total_cost_cny` (当日总投入本金),为历史净值走势图奠定底层数据结构。
- **新增 `asset_prices_history` 表**:用于存储手动导入的每日标的价格,字段包括 `assetId` (关联资产)、`price` (当日收盘价/净值numeric(36,18))、`date` (YYYY-MM-DD 格式)、`createdAt`,并对 `(assetId, date)` 建立联合唯一索引,为手动导入历史净值提供底层 Upsert 支持。
## 核心业务与服务端逻辑 (Server Actions)
- 完成高精度交易流水与资产的 Server Actions 开发,成功实现字符串级别的高精度防腐层拦截(基于 Zod & Big.js
- 补全资产与流水的全栈增删改查 (CRUD) 操作,`createTransaction` 现已支持根据 `exchange` 自动判定并锁定 `txCurrency`
- **估值与 P&L 引擎:** 完成底层估值引擎升级,打通交叉汇率换算逻辑;实现原币种 (Native) 与本位币 (CNY Base) 双轨制的历史成本追溯与真实盈亏 (P&L) 计算引擎。
- **快照记录引擎:** 新增 `src/actions/snapshots.ts``recordDailySnapshot()` 函数基于 `getPortfolioPositions()` 实时计算总市值与总成本,使用 `Asia/Shanghai` 时区获取当日日期,执行 Upsert 逻辑确保每天仅存一条记录;`getSnapshots()` 支持按日期范围与数量限制查询历史快照数据。
## 外部行情接口与网络 (Market Data Engines)
- **股票行情引擎:** 彻底抛弃低效海外接口,自主研发智能路由接入腾讯财经 (`qt.gtimg.cn`) 极速接口。引入原生 `ArrayBuffer``TextDecoder(gbk)` 彻底解决历史中文乱码问题,实现沪、深、港、美四大市场毫秒级实时同步。
- **Crypto 行情引擎:** 接入币安 (Binance) 公共 API构建全市场股票+加密货币)双轨双擎驱动架构。
- **网络防腐层:** 引入 `undici` 底层网络库并配置 `setGlobalDispatcher`,成功突破境内防火墙对境外 API 的直连封锁 (Timeout)。
## 前端架构与 UI/UX 体验
- 完成 shadcn/ui 初始化,集成 next-themes 支持暗黑模式,并拉取核心组件库。
- 完成 `/dashboard` 基础布局架构,接管根路由。
- 打通 `/dashboard/assets``/dashboard/transactions` 页面前后端数据流转,修复早期录入与 404 缺陷。
- 完成 UI 层高精度数据格式化,针对不同资产类型实现动态精度展示,清理因数据库 `numeric` 导致的尾随零问题。
- 引入 `recharts` 图表引擎,构建了基于实时 CNY 估值的资产分布环形图。
- 实装基于 Recharts 的历史净值面积图 (NetWorthChart),支持总市值与投入本金的双轨趋势对比。
- 优化表单交互:实装了交易所与币种的智能联动逻辑,并运用 `disabled` 属性实现了表单字段的只读防腐锁定。
## UX 与全局交互 (UI/UX)
- 引入 `sonner` 构建全局 Toast 消息通知系统覆盖行情同步、CRUD 操作的成功与异常提示。
- 重构 `<SyncButton />` 并将其提升至 Dashboard 首页,实现总资产大盘的全局一键实盘刷新。
## 修复记录
## 修复大盘走势图的字段绑定错误,将投入本金的渲染变量从总市值 (totalCnyValue) 修正为折算后的法币本金 (totalCnyValue - totalPnlCny),实现前后端财务数据的最终对齐 (Task 79)
- **根因分析**:在 `app/dashboard/page.tsx``loadSnapshots()` 函数中(第 172-192 行),今日快照的 `totalCostCny` 被错误地赋值为 `summary.totalCnyValue`(总市值),导致走势图中的"投入本金"曲线与"总市值"曲线完全重合。
- **具体修复**
-`getPortfolioSummary()` 返回的汇总数据中新增 `totalCostCny` 的推导计算:`totalCostCny = totalCnyValue - totalPnlCny`(投入本金 = 总市值 - 累计盈亏)。
-`lastSnapshot.totalCostCny``data.push({ totalCostCny: ... })` 两处错误赋值修正为使用该推导值。
- **验收**鼠标悬浮在历史节点上Tooltip 里的"投入本金"显示真实的累计投入成本(如 ¥5094.59),而非虚高的总市值(如 ¥704.65),净盈亏百分比回归正常比例。
- **影响范围**`app/dashboard/page.tsx` 的 `loadSnapshots()` 函数(`src/components/dashboard/net-worth-chart.tsx` 组件本身已正确使用 `totalCostCny`,无需修改)。
## 修复行情解析引擎的正则匹配规则,增加对 [.\-] 等特殊字符的支持,解决 BRK.B 等特殊股票代码解析失败导致现价归零的 Bug (Task 62)
- 修复了 `src/actions/market.ts``getTencentSymbol()` 函数的 `cleanSymbol` 正则过滤逻辑:将 `/[^0-9A-Z]/g` 升级为 `/[^0-9A-Z.\-]/g`,保留小数点 `.` 和连字符 `-`
- 修复了 `app/api/cron/fetch-prices/route.ts``fetchStockPrice()` 函数的同名正则过滤逻辑,保持一致。
- **根因:** 旧正则 `/[^0-9A-Z]/g` 会错误地将 `BRK.B` 过滤为 `BRKB`,导致腾讯行情 API 无法识别该股票代码,返回空数据或错误数据,最终使 Dashboard 显示 `$0.00`
- **验证:** 使用 `curl` 调用 `https://sqt.gtimg.cn/q=s_usBRK.B` 成功返回 `BRK.B` 现价 `$476.92`,确认修复生效。
## 修复 Drizzle ORM 的逻辑或语法错误,将错误的链式 .or() 改写为更具扩展性的 inArray() 语法
- 修复 `app/api/cron/fetch-prices/route.ts``.where(eq(assets.type, 'STOCK').or(eq(assets.type, 'CRYPTO')))` 的非法链式调用语法。
- 废弃错误的 `.where(eq(...).or(eq(...)))` 模式,改为使用 `inArray(assets.type, ['STOCK', 'CRYPTO'])`
- `inArray` 已从 `drizzle-orm` 引入,同时保留了 `eq` 的导入以兼容其他查询。
- `inArray` 写法在语义等價且更具扩展性,未来添加新资产类型(如 FUND, BOND只需在数组中追加枚举值即可。
- 解决了日期选择控件的时区偏移 Bug确保全球通用`src/libs/utils.ts` 中重写 `formatDateForDatetimeLocal()``parseDateTimeLocalToUTC_v2()` 函数,采用 `Intl.DateTimeFormat` 动态获取 `Asia/Shanghai` 时区偏移量,确保 UTC 时间到本地时间的双向转换精确无误,修复了用户选 10 点展示为 2 点的问题。修正了前端数据格式化逻辑,在 `src/lib/formatters.ts` 中增加空值/NaN 兜底处理,在 `src/app/dashboard/page.tsx` 中将平均成本与摊薄成本的显示条件从 `.gt(0)` 改为 `.ne(0)`,支持英特尔负成本等极端场景下的精确数字展示。
## 资产分布图表按市场维度升级 (Task 32)
- 优化资产分布图表,升级为按市场维度聚合展示,并增强了 Tooltip 的颜色指代与明细交互。
-`portfolio.ts` 中新增 `getMarketFromExchange()` 函数,将资产按交易所归类为 A股 (SSE/SZSE)、港股 (HKEX)、美股 (US)、虚拟币 (CRYPTO)。
- 新增 `marketAllocation` 聚合数据,按市场维度汇总 `totalCnyValue` 并计算占比,自动过滤已清仓资产。
- 升级 `AllocationChart` 组件数据源改为市场聚合数据为各市场设定固定品牌色A股红、港股黄、美股蓝、虚拟币绿并自定义 Tooltip 渲染内容,悬停时清晰展示 `[市场名称] [对应颜色块] [CNY 金额] [占比%]`
## 盈亏引擎重构 (Task 31)
- 重构盈亏计算引擎支持已实现盈亏统计交易按时间正序处理SELL 时基于当时平均成本计算该笔卖出的利润并累加至 `realizedPnlCny`
- 引入摊薄成本与平均成本双重指标:`avgCost = totalBuyCost / totalBuyQuantity`(平均成本);`dilutedCost = (totalBuyCost - realizedPnlCny) / currentQuantity`(摊薄成本,已实现盈亏会摊低持仓成本)。
- 新增持仓天数统计:`holdingDays = today - 第一次 BUY 的日期`(基于上海时区)。
- Dashboard 首页总览区分展示『持仓盈亏 (Unrealized P&L)』和『总盈亏 (Total P&L)』。
- 持仓卡片新增『平均成本』、『摊薄成本』和『持仓天数』展示。
## 分紅業務邏輯與成本算法修復 (Task 33)
- 重構了分紅的會計處理邏輯將其正確計入已實現盈虧DIVIDEND 不再增加持倉數量,而是按 `quantity * price * exchangeRate` 計算分紅金額並累加至 `realizedPnlCny`
- 新增 `totalDividendCny` 字段追蹤累計分紅金額。
- 修正攤薄成本算法:`dilutedCost = (totalBuyCostCny - realizedPnlCny - totalDividendCny) / currentQuantity`,確保極端情況下攤薄成本為負數時精確返回負數,絕不兜底為 0。
- 平均成本 `avgCost = totalBuyCostCny / totalBuyQuantity` 保持不變,僅在遍歷結束後計算。
## 修復平均成本顯示 Bug與分紅獨立統計 (Task 35)
- 修復了平均成本在前端顯示為空的問題:`totalBuyQuantity` 在 BUY 交易處理中從未累加,導致 `avgCost` 計算時除數為零而永遠返回 0。現在在 BUY 時正確執行 `totalBuyQuantity += quantity`
- 將分紅邏輯從已實現盈虧中剝離,建立獨立的累計分紅統計維度:新增 `accumulatedDividendsCny` 字段DIVIDEND 交易不再混入 `realizedPnlCny`,而是獨立累加至 `accumulatedDividendsCny`
- 重新定義總盈虧公式:`totalPnlCny = unrealizedPnlCny + realizedPnlCny + accumulatedDividendsCny`,確保分紅有獨立的統計維度且不會干擾平均成本計算。
## 升級 Dashboard 資產卡片 UI (Task 36)
- 升級了 Dashboard 資產卡片 UI新增累計分紅展示並優化了成本數據的格式化判斷邏輯。
- 修復了 `avgCostFormatted` 的判空邏輯,將 `Big.eq(0)` 修正為 `Big.eq('0')`,確保當 `pos.avgCost` 存在且不為 0 時能正確格式化,不再顯示 `¥-`
- 在資產卡片中新增「累計分紅」行,展示 `accumulatedDividendsCny` 數據,保持與其它 CNY 數據一致的 `opacity-50` 樣式。
## 修復幣種符號映射 Bug 與引擎數據擴充 (Task 40)
- 修復了币种符号映射错乱 Bug`getCurrencySymbol()` 中 CNY 和 HKD 被错误地映射为 `HK$`,现修正为 USD→$、HKD→HK$、CNY→¥。
- 在 portfolio 引擎中新增了单资产的流水明细数组:`Position` 接口新增 `transactions` 字段,`getPortfolioPositions()` 按资产名下发排序后的原始交易流水,支持前端展开查看。
## 流水交易類型中文化映射 (Task 42a)
-`app/dashboard/page.tsx` 中新增 `txTypeMap` 字典,將 `BUY`/`SELL`/`DIVIDEND`/`AIRDROP` 映射為對應中文(買入/賣出/分紅/空投)。
- 流水明細子表格中渲染 `tx.txType` 的邏輯替換為 `{txTypeMap[tx.txType] || tx.txType}`,保留原始值兜底。
## 利用 Big.js 剥离流水明细无意义尾随零 (Task 42b)
- 利用 `Big.js` 剥离了流水明细中无意义的尾随零,提升了高精度数据的可读性。
-`app/dashboard/page.tsx` 的流水明細子表格中,将 `tx.quantity`、`tx.price`、`tx.fee` 的渲染逻辑改为 `new Big(value).toString()`,安全剥离因数据库 `numeric(36,18)` 配置导致的如 `0.041000000000000000` 这类冗余尾随零。
## 盈亏红绿视觉规范 (Task 42c)
- 依据中文金融习惯(红涨绿跌),规范了盈亏数值的颜色与正负号显示。
- 移除了 `formatPnl()` 函数及概览行内硬编码拼接的 `+` 号前缀,正收益直接展示数值,负收益保留原生 `-` 号。
- 统一颜色逻辑:值 `> 0` 应用 `text-red-500`(红色),值 `< 0` 应用 `text-green-500`(绿色),值 `=== 0` 使用默认文字颜色。
- 括号内的百分比同步遵循相同逻辑,格式如 `$2447.48 (114.20%)`
## 修复快照读取引擎中的 Drizzle 语法错误
- 修复快照读取引擎中的 Drizzle 语法错误,全面改用类型安全的 desc 和 gte 操作符进行查询。
-`src/actions/snapshots.ts` 中引入 `desc``gte` 操作符,彻底替换原始 SQL 模板拼接(`sql`"${date}" DESC``),消除 `ReferenceError: date is not defined` 运行时错误。
- 使用 `desc(portfolioSnapshots.date)` 实现降序排列,使用 `gte(portfolioSnapshots.date, startDate)` 实现日期范围过滤,并添加 `.$dynamic()` 支持动态条件拼接。
## 执行 Task 90完成项目无状态 Docker 容器化改造。配置 standalone 模式、多阶段 Dockerfile 及 docker-compose 编排,实现外部 PgSQL 密钥的运行时动态注入隔离 (Task 90)
- **Next.js Standalone 模式**:在 `next.config.ts` 中增加 `output: 'standalone'` 属性,构建时自动生成 `/.next/standalone` 目录,仅包含运行所需的最小文件集,大幅缩减镜像体积。
- **.dockerignore 防腐层**:创建 `.dockerignore` 排除 `node_modules`、`.next`、`.git`、`.env` 等敏感和无用文件,防止污染镜像上下文。
- **三阶段多阶段构建 Dockerfile**
- **阶段 1 (deps)**:基于 `node:18-alpine` 安装依赖,使用 `npm ci` 实现锁死版本的确定性安装。
- **阶段 2 (builder)**:复用 deps 阶段的 `node_modules`,完整复制项目源码并执行 `npm run build`,禁用 Next.js 遥测 (`NEXT_TELEMETRY_DISABLED=1`)。
- **阶段 3 (runner)**:极简生产环境,仅复制 `.next/standalone`、`.next/static` 和 `public` 目录;创建非 root 用户 `nextjs` (uid: 1001) 实现安全降权;暴露 8080 端口并监听 `0.0.0.0`
- **Docker Compose 编排**`docker-compose.yml` 配置 `env_file: .env` 实现运行时环境变量动态注入(数据库 URL、CRON_SECRET 等敏感密钥不打包进镜像);配置 `healthcheck` 使用 `wget` 进行健康探测,每 30 秒检查一次。
- **架构红线**所有生产敏感配置数据库连接串、CRON_SECRET 等)必须通过 `.env` 文件在运行时注入,严禁硬编码或打包进 Docker 镜像层。
## 持倉引擎 Native 幣種算法重構 (Task 38)
- 重構底層盈虧引擎,全面轉向 Native 原生幣種計算,新增浮動/累計盈虧及百分比指標。
- 徹底分離 Native 與 CNY 計算:單隻股票的成本與盈虧全部改用 Native (原幣種) 進行計算。
- 新增 Native 成本指標:`totalBuyCostNative` (總買入成本)、`realizedPnlNative` (已實現盈虧)、`accumulatedDividendsNative` (累計分紅)。
- 新增 Native 成本均價:`avgCostNative = totalBuyCostNative / totalBuyQuantity`、`dilutedCostNative = (totalBuyCostNative - realizedPnlNative - accumulatedDividendsNative) / currentQuantity`。
- 新增浮動盈虧指標:`marketValueNative = latestPrice * currentQuantity`、`floatingPnlNative = marketValueNative - (avgCostNative * currentQuantity)`、`floatingPnlPercent = floatingPnlNative / (avgCostNative * currentQuantity) * 100`。
- 新增累計盈虧指標:`cumulativePnlNative = floatingPnlNative + realizedPnlNative + accumulatedDividendsNative`、`cumulativePnlPercent = cumulativePnlNative / totalBuyCostNative * 100`。
- SELL 交易的已實現盈虧計算從 CNY 基準改為 Native 基準CNY 計算保留用於前端兼容展示。
## Dashboard 流水下鑽明細與行內 CRUD (Task 41b)
- 完成 Dashboard 流水下鑽功能,支持在資產列表中直接查看、修改和刪除歷史交易流水。
- 在主行 `TableRow` 下方,根據 `expandedIds[pos.assetId]` 條件渲染第二個子行,使用 `<TableCell colSpan={8} className="p-0">` 確保子行佔滿整行寬度。
- 構建流水明細次級表格:遍歷 `pos.transactions` 數組,表頭為「交易日期 | 類型 | 價格/數量 | 手續費 | 備註 | 操作」,精確渲染每筆交易的歷史數據。
- 實裝 `UpdateTransactionDialog` 組件:「修改」按鈕打開彈窗並回顯該筆流水數據(數量、價格、手續費、幣種、執行時間),提交後調用 `updateTransaction` Action 並刷新頁面。
- 實裝行內刪除功能:「刪除」按鈕彈出確認對話框,確認後調用 `deleteTransaction` Action 並刷新頁面。
-`portfolio.ts``TransactionRecord` 接口中補充 `id``fee` 字段,並在下發數據時包含完整的流水 ID以支持行內 CRUD 操作。
- UI 優化:為展開的子表格添加左側彩色邊框 (`border-l-4 border-primary/20`) 與左側縮進 (`ml-2`) 增強層級感;展開/收起按鈕文字根據狀態動態切換(「收起」/「展開」)。
## Dashboard 表格化基礎重構 (Task 41)
- Dashboard 完成表格化基礎重構,徹底移除原有的卡片網格佈局,改用 shadcn/ui Table 組件構建高密度專業券商風格主表。
- 實裝了基於 ID 的行展開狀態管理邏輯:`useState<Record<string, boolean>>` 控制每行的展開/收起狀態,點擊行或展開按鈕觸發 `toggleExpand`
- 表頭嚴格對齊專業標準:名稱/代碼 | 現價 | 市值 | 持倉 | 攤薄/成本 | 浮動盈虧 | 累計盈虧 | 操作。
- 操作列包含展開/收起指示器與「添加」按鈕,點擊打開 `AddTransactionDialog` 並預選對應資產。
- 展開行內嵌交易記錄子表格,展示每筆交易的類型、數量、價格、手續費、幣種與日期。
- 重構 `AddTransactionDialog` 組件支持外部控制開關(`open`/`onOpenChange`/`defaultAssetId` props同時保持向後兼容內部狀態管理模式。
- Dashboard 頁面轉換為客戶端組件,使用 `useEffect` 在客戶端加載組合數據。
## 全面重構資產展示 UI (Task 39)
- UI 全面升級,復刻專業券商級數據排版,合併攤薄/成本,引入原生幣種盈虧百分比展示。
- 徹底清理所有帶 (CNY) 和 (USD) 混雜的舊布局,所有 Native 金額根據 `baseCurrency` 渲染正確貨幣符號USD→$、CNY/HKD→HK$、JPY→¥
- 資產卡片全新字段:現價、市值、持倉、攤薄/成本(合併為 `[dilutedCostNative] / [avgCostNative]` 格式)、浮動盈虧(帶百分比)、累計盈虧(帶百分比)、持倉天數。
- 盈虧顏色遵循中國市場慣例:大於 0 顯示紅色,小於 0 顯示綠色。
- 所有百分比保留 2 位小數0 值正常顯示 `0.00`
- 卡片佈局優化為響應式 `grid-cols-1 md:grid-cols-2 lg:grid-cols-3`
## 基於文本域粘貼的歷史價格批量導入功能 (Bulk Import, Task 49)
- 開發了基於文本域粘貼的歷史價格批量導入功能,支持從 Excel 快速複製錄入每日淨價。
-`src/actions/market.ts` 中新增 `importHistoricalPrices(assetId, data)` Server Action遍歷數據數組對 `assetPricesHistory` 表執行批量 Upsert基於 `(assetId, date)` 聯合唯一索引的 `onConflictDoUpdate`),衝突時用新價格覆蓋舊價格。
- 前端 `app/dashboard/page.tsx` 的持倉明細表「操作」列與展開區域均新增【導入價格】按鈕。
- 點擊按鈕彈出 Dialog內含 `<textarea>` 文本域,支持用戶從 Excel 直接複製粘貼,格式為 `YYYY-MM-DD, 價格`(每行一條)。
- 前端按換行和逗號解析文本,生成 `{date, price}` 數組後調用 `importHistoricalPrices` Action導入完成後 Toast 提示成功/失敗條數並刷新頁面。
## 历史净值重构引擎 - 底层查询辅助函数 (Task 50)
- 为历史净值重构引擎开发底层查询辅助函数,实现特定日期的持仓快照与基于降序 Limit 1 的价格断点结转逻辑。
-`src/actions/snapshots.ts` 中新增 `getHistoricalPositions(targetDate)` 函数:从 `transactions` 表查询所有 `executedAt <= targetDate` 的流水,按时间正序遍历,按资产聚合计算出该日期下的 `quantity`(当前持仓)和 `totalCost`累计投入本金SELL 时按平均成本扣减),过滤掉已清仓资产。
-`src/actions/snapshots.ts` 中新增 `getEffectivePrice(assetId, targetDate)` 函数:在 `assetPricesHistory` 表中查询指定 `assetId``date <= targetDate` 的记录,按照 `date` 降序排列 (`desc`) 并 `.limit(1)` 取第一条实现价格断点结转Forward-Fill逻辑——如果目标当天没有导入价格系统自动抓取该资产在目标日期之前「最新」的一次有效价格。
- 两个函数均使用 `Big.js` 进行高精度数值计算,为历史净值时光机功能提供底层数据支撑。
## 修复 Drizzle ORM 中 lte 操作符的语法调用错误 (Task 50d)
- 修复 Drizzle ORM 中 lte 操作符的语法调用错误,从链式调用更正为标准纯函数导入。
-`src/actions/snapshots.ts``getHistoricalPositions` 函数中,将 `transactions.executedAt.lte(targetDate)` 替换为 `lte(transactions.executedAt, targetDate)`
- 同时修复 `getSnapshots` 函数中的 `portfolioSnapshots.date.lte(endDate)``lte(portfolioSnapshots.date, endDate)`
- `lte` 操作符已从文件顶部 `drizzle-orm` 引入,无需额外添加导入。
## 净值时光机主引擎 - Day-by-Day 循环遍历重建 (Task 50b)
- 完成净值时光机主引擎,通过 Day-by-Day 循环遍历历史流水并结合断点结转价格,自动重建全量历史资产快照。
-`src/actions/snapshots.ts` 中新增 `reconstructPortfolioHistory()` 函数:查询 `transactions` 表找出最早的 `executedAt` 作为回溯起点,转换为 `Asia/Shanghai` 时区后以天为单位循环至今天。
- 循环体内调用 `getHistoricalPositions(currentDate)` 获取当天所有有持仓的资产(含持仓数量与累计本金),再调用 `getEffectivePrice(assetId, currentDate)` 获取各资产的有效价格(断点结转)。
- 引入汇率转换逻辑:预先加载 `assets` 表获取各资产的基础币种,加载 `exchangeRates` 表构建汇率映射,支持直接汇率与 USD 交叉换算,将各资产市值统一换算为 CNY。
- 使用 `Big.js` 确保所有金额计算的高精度,按天计算 `totalValueCny`(总市值)与 `totalCostCny`(总本金),并通过 Upsert 逻辑写入 `portfolioSnapshots` 表,确保每天仅存一条记录。
## Dashboard 首页实装"重构历史走势"功能按钮 (Task 50c)
-`app/dashboard/page.tsx` 的"总资产概览"卡片右上角挂载"重构历史走势"按钮 (Button variant="outline")。
- 点击按钮后调用 `reconstructPortfolioHistory()` Server Action启动 Day-by-Day 历史净值回溯引擎。
- 集成 Sonner Toast 通知:点击时显示 `toast.loading('正在重构历史走势...')`,完成后显示 `toast.success('重构成功,已填充 N 天历史数据')`,并自动刷新 `snapshots` 状态以更新 AreaChart 走势图。
- 按钮启用 `isPending` 防重复点击,重构期间显示"重构中..."并禁用按钮。
- 打通历史净值回溯全链路:用户从 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` 供兜底逻辑使用。
## 修复 getEffectivePrice 引擎中 Drizzle ORM 缺失 and(eq(assetId)) 的致命逻辑漏网
- 修复 `getEffectivePrice` 引擎中 Drizzle ORM 查询条件未使用 `and()` 复合操作符的致命逻辑漏网,确保历史断点结转价格精准匹配单一资产。
-`src/actions/snapshots.ts` 顶部从 `drizzle-orm` 引入 `and` 操作符,将 `getEffectivePrice``where` 子句从两个独立的 `.where()` 链式调用重构为 `and(eq(assetPricesHistory.assetId, assetId), lte(assetPricesHistory.date, dateStr))` 的显式复合条件。
- 修复后 `assetId` 条件与日期条件被绝对锁定在同一个 `AND` 逻辑块中,彻底杜绝了周末等非交易日历史节点价格跨资产串联的荒谬市值问题(如海尔被错误匹配 BTC 价格导致 3700 万市值)。
## 通过前端运行时覆盖策略完美对齐图表今日快照与实时总资产的汇率时差 (Task 54)
- 修复了 Dashboard 图表末端Today 节点)与页面顶部超大号"总资产"数字之间存在时差误差的问题:图表依赖数据库里几小时前的快照,而概览数字基于实时计算,导致两者不完全一致。
-`app/dashboard/page.tsx``loadSnapshots` 中引入前端运行时覆盖策略:先调用 `getPortfolioSummary()` 获取实时总览数据,再调用 `getSnapshots()` 获取历史快照,动态替换或追加今天的节点,确保图表末端与大数字 100% 严丝合缝。
- 具体逻辑:如果今天已有快照记录(`lastSnapshot.date === todayStr`),则将 `totalValueCny``totalCostCny` 覆盖为实时值;如果今天尚无快照,则直接追加一个实时点。
- 移除 `getSnapshots` 查询的视口限制:将 `src/actions/snapshots.ts``getSnapshots` 的默认 `limit` 从 365 改为无默认值,仅在显式传入 `limit` 参数时才应用 `.limit()`,前端调用处移除 `{ limit: 30 }` 参数,实现从第一笔交易至今的全量净值走势渲染。
- 前端 Dashboard 页面中两处 `getSnapshots` 调用(初始加载与重构历史后刷新)均已移除 `limit` 参数。
## 建立无副作用的 Utils 财务引擎 (Task 56a)
-`src/utils/` 目录下新建 `finance.ts`,实现纯函数财务计算器,不涉及任何数据库查询和后端 API。
- 文件顶部绝对禁止出现 `"use server"` 指令,确保为通用的前端/后端都能调用的纯函数。
- 引入 `big.js` 用于高精度计算,编写并导出 `calculateAssetMetrics` 函数。
- 核心算法:强制按时间升序排序流水,遍历推演 BUY/SELL/DIVIDEND 三种交易类型,支持加权均价计算与清仓重置逻辑。
- 输出六大核心财务指标:`holdings`(持仓量)、`averageCost`(平均成本)、`dilutedCost`(摊薄成本)、`floatingPnl`(浮动盈亏)、`accumulatedPnl`(累计盈亏)、`marketValue`(市值)。
## 打通 Dashboard 与 finance utils 的数据链路 (Task 56b)
-`src/actions/portfolio.ts` 顶部引入 `calculateAssetMetrics` 工具函数,实现财务引擎接入。
- 重构 `getPortfolioPositions` 的第二个循环:对每个资产调用 `calculateAssetMetrics(transactions, latestPrice)`,将返回的 `holdings`、`averageCost`、`dilutedCost`、`floatingPnl`、`accumulatedPnl`、`marketValue` 映射到 Position 对象的 Native 币种字段。
- Dashboard 表格字段精确对齐:现價→`latestPrice`、市值→`metrics.marketValue`、攤薄/成本→`metrics.dilutedCost / metrics.averageCost`、浮動盈虧→`metrics.floatingPnl`、累計盈虧→`metrics.accumulatedPnl`。
- 累计盈亏验证公式:`accumulatedPnl = marketValue + 卖出/分红现金 - 总投入`确保有卖出或分红记录的资产如英特尔、分红ETF数据精确。
## 修复 Cron API 的 404 挂载丢失问题 (Task 61b)
- 验证并确认 Next.js App Router API 路由已严格遵循规范:文件精确位于 `src/app/api/cron/fetch-prices/route.ts`,后缀为 `.ts`(非 `.tsx`)。
- 确认最外层正确导出 `export async function GET(request: Request)` 方法,包含 `Bearer ${process.env.CRON_SECRET}` 鉴权拦截与完整的 try/catch 错误处理。
- 确认无旧版 `pages/api/cron/fetch-prices` 残留文件导致路由冲突。
- 修复 Next.js App Router 规范下的 API 路由挂载问题,修正 route.ts 文件名与 GET 方法导出,解决 404 错误。
## 修复 getPortfolioPositions 中接入财务引擎时的变量作用域丢失与解构映射错误 (Task 56c)
- 修复了 `src/actions/portfolio.ts``getPortfolioPositions` 函数的 ReferenceError`avgCost is not defined` 和 `dilutedCost is not defined`
- 根本原因:在将财务引擎 (`calculateAssetMetrics`) 接入 portfolio 引擎时,`avgCost` 和 `dilutedCost` 变量名在结果对象装配环节被直接引用,但它们从未在本作用域中声明——它们实际上是 `metrics` 对象的属性 (`metrics.averageCost`, `metrics.dilutedCost`)。
- 核心修复:将 `avgCost: avgCost.toString()` 替换为 `avgCost: metrics.averageCost`,将 `dilutedCost: dilutedCost.toString()` 替换为 `dilutedCost: metrics.dilutedCost`
- 同时新增 `floatingPnl``accumulatedPnl` 字段映射到 Position 接口,补齐了财务引擎产出的六大核心指标中缺失的两个字段。
- 遵循 `metrics` 返回值已是 string 类型的规范,不再调用 `.toString()` 导致冗余转换。
## 重构 portfolio API废弃静态 asset.exchangeRate全面接入 exchange_rates_history 动态汇率流水表,通过 O(1) 内存字典提升跨币种折算精度与性能 (Task 63b)
-`src/actions/portfolio.ts` 顶部新增 `getLatestRatesMap()` 辅助函数:通过 Drizzle ORM 的 `orderBy(desc(fetchTime)).limit(1)` 分别查询 `exchangeRatesHistory` 表中 `USD→CNY``HKD→CNY` 的最新一条记录,组装为 `Record<string, Big>` 字典(`{ CNY: 1, USD: dbUsd?.rate || 7.2, HKD: dbHkd?.rate || 0.9 }`),内置查不到时的兜底安全值。
- 废弃 `getPortfolioPositions` 中对静态 `exchangeRates` 表的 N+1 查询:在函数顶部调用 `getLatestRatesMap()` 获取动态汇率字典,并将其转换为 `Map<string, string>``calculateCnyValueFromPrice` 等下游函数继续使用。
- 替换 PnL 映射逻辑中的静态汇率查找:将 `getRate(rateMap, holding.baseCurrency, 'CNY')` 改为直接从 `dynamicRateMap[holding.baseCurrency]` 取值,实现 O(1) 内存字典访问,消除数据库耦合。
- 架构收益:消除 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与底层对账数据完美一致。
## 精确定位 Client Component修复 net-worth-chart.tsx 中的 dataKey 与 Tooltip 绑定错误,彻底解决视图层与数据层本金单位不统一的问题 (Task 80)
- **根因分析**:在 `src/components/dashboard/net-worth-chart.tsx`Client Component净值走势图的投入本金曲线和 Tooltip 需要读取经过汇率折算后的法币本金字段(`totalCostCny`),而非原币种或未经折算的字段。
- **数据链路验证**:从数据库 `portfolio_snapshots.total_cost_cny` → Drizzle ORM 映射为 `totalCostCny``getSnapshots()` 返回 → `page.tsx``loadSnapshots()` 中计算 `totalCostCny = totalCnyValue - totalPnlCny` → 通过 props 传入 `NetWorthChart``chartData` 映射为 `totalCostCny``<Area dataKey="totalCostCny">` 渲染 + `CustomTooltip``payload[0].payload.totalCostCny` 读取。
- **修复验证**`Snapshot` 接口定义 `totalValueCny` / `totalCostCny``chartData` 映射使用 `totalValueCny` / `totalCostCny`(均 `parseFloat``<Area>` 的 `dataKey` 分别为 `totalValueCny``totalCostCny``CustomTooltip` 从 `data.totalValueCny` / `data.totalCostCny` 解构计算净盈亏。全链路字段名严格一致确保投入本金曲线显示真实的累计投入成本CNY 折算后)。
## 剔除 page.tsx 中违规的 `本金 = 市值 - 盈亏` 反向派生逻辑,确立 `盈亏 = 市值 - 本金` 的顺向金融计算流,彻底修复了走势图本金显示被旧 PnL 污染的架构 Bug (Task 81)
- **根因分析**:在 `app/dashboard/page.tsx``loadSnapshots()` 函数中(第 178 行),今日快照的 `totalCostCny` 被错误地通过 `new Big(summary.totalCnyValue).minus(new Big(summary.totalPnlCny)).toString()` 反向推导得出。这导致1) 本金被旧 PnL 数据污染失去底层真实性2) 违反了金融计算中"本金优先"的架构红线。
- **架构红线**:绝对禁止反推本金。本金 (`totalCostCny`) 必须直接读取数据库 snapshot 表或从 position 层级的 `totalCostCny` 字段正和累加,绝不允许做任何加减法。
- **具体修复**
-`src/actions/portfolio.ts``getPortfolioSummary()` 函数中新增 `totalCostCny` 的逐项累加计算:`totalCostCny = sum(pos.totalCostCny)`,并在返回值中暴露 `totalCostCny` 字段。
-`app/dashboard/page.tsx``loadSnapshots()` 函数中,删除 `new Big(summary.totalCnyValue).minus(new Big(summary.totalPnlCny))` 的反向推导代码,改为直接使用 `summary.totalCostCny` 作为快照的本金值。
- 净盈亏在 `NetWorthChart``CustomTooltip` 中通过 `pnl = totalValue - totalCost` 顺向派生,符合"盈亏 = 市值 - 本金"的金融计算规范。
- **验收标准**:鼠标悬浮在图表上,投入本金显示底层原汁原味的成本值(如 ¥5094.59),净盈亏自动修正为真实值(如 +¥472.12)。
## 暴力重构 NetWorthChart 数据绑定逻辑,添加对后端字段名 (snake_case vs camelCase) 的强力兼容,彻底消除前端 Tooltip 的旧账残影 (Task 82)
- **根因分析**`src/components/dashboard/net-worth-chart.tsx` 的 `CustomTooltip``chartData` 映射层仅依赖驼峰字段 (`totalCostCny`),但后端 Drizzle ORM 返回的原始数据可能包含蛇形字段 (`total_cost_cny`),导致 Tooltip 中本金显示错误值(如 704 而非真实的 5094
- **强制调试日志**:在组件入口注入 `console.log("【CHART DATA DEBUG】", snapshots[0])`,通过浏览器控制台 F12 直接查看原始数据结构,作为排查字段映射问题的终极武器。
- **数据映射层蛇形/驼峰双重兼容**:在 `chartData``map` 函数中,采用 `parseFloat(s.totalCostCny) || parseFloat(s.total_cost_cny || 0)` 的强制 fallback 逻辑,确保无论后端返回哪种命名风格都能正确解析。
- **Tooltip 防御性解构**`CustomTooltip` 中的值读取改为 `Number(dataNode.totalValueCny || dataNode._raw?.totalValueCny || 0) || 0`,通过 `_raw` 快照兜底读取,确保 Tooltip 永远能拿到本金和现值的真实数据。
- **Snapshot 接口扩展**:新增 `total_value_cny?: string``total_cost_cny?: string` 可选字段,`ChartDatum` 接口新增 `_raw: Snapshot` 字段用于 Tooltip 层 fallback。
- **验收标准**:控制台 `【CHART DATA DEBUG】` 打印出带真实本金(如 5094的字段Tooltip 中投入本金显示真实法币数字,彻底消除 704 旧账残影。
## 基于现有生产级 API 鉴权,补充开发了 scripts/trigger-rebuild.ts 本地触发脚本,实现了安全、隔离的本地时光机重置工作流 (Task 84)
- 在项目根目录创建 `scripts/trigger-rebuild.ts` 独立触发脚本,作为 `app/api/admin/rebuild-snapshots/route.ts` 的本地运维入口。
- 脚本通过 `dotenv` 强制加载 `.env.local``.env` 环境变量文件,优先读取 `REBUILD_SECRET`,降级读取 `CRON_SECRET`,确保鉴权密钥的安全获取。
- **核心逻辑**:脚本向 `http://localhost:8080/api/admin/rebuild-snapshots` 发送 POST 请求,携带 `Authorization: Bearer <secret>` 请求头,复用生产级 Bearer Token 强校验机制,未配置密钥时提前退出。
- **架构红线**`app/api/admin/rebuild-snapshots/route.ts` 中的生产级 POST + Bearer Token 强校验代码未被修改,保持原有的安全隔离设计。
- **运行方式**`npx tsx scripts/trigger-rebuild.ts`(需确保 `npm run dev` 在另一个终端运行且端口为 8080
- **设计收益**:本地开发者无需记忆 curl 命令或手动构造请求头,通过脚本即可安全触发历史快照重建,降低了运维门槛并保持了与生产鉴权机制的一致性。
## 修复 portfolio.ts 卖出核算机制,引入 costBasisQuantity 隔离空投等非交易流水对平均成本分母的污染,解决中文字符解析 bug实现实时引擎与时光机引擎投入本金的 100% 数学对齐 (Task 89)
- **根因分析**:在 `src/actions/portfolio.ts``getPortfolioPositions()` 函数中SELL 交易的平均成本计算使用 `holding.quantity`(含空投、分红扩仓的真实持仓量)作为分母,当资产存在 AIRDROP 等零成本扩仓流水时,`holding.quantity` 被无成本污染,导致平均成本 `totalBuyCost / holding.quantity` 被严重稀释。卖出时扣减的 `totalBuyCostNative/Cny` 不足,造成 Dashboard 投入本金虚高(如 267k vs 真实 242k
- **架构红线**:计算移动平均成本时,绝对禁止使用 `holding.quantity`(含非交易扩仓)作为分母!必须使用纯净的、仅由 BUY 交易驱动的成本计价基准量 `costBasisQuantity`
- **双轨隔离设计**:在 `holdings.set` 初始化结构中新增 `costBasisQuantity` 字段。BUY 时同步增加 `quantity``costBasisQuantity`AIRDROP 仅增加 `quantity`,绝不触碰 `costBasisQuantity`,完美隔绝污染。
- **SELL 侧双轨计算**Native 维度使用 `holding.totalBuyCostNative / holding.costBasisQuantity` 计算平均成本CNY 维度使用 `holding.totalBuyCostCny / holding.costBasisQuantity` 计算平均成本,按卖出数量精确扣减成本本金与 `costBasisQuantity`,确保法币与外币同步等比下降。
- **清仓重置兜底**`costBasisQuantity` 和 `quantity` 双独立归零逻辑,`1e-8` 精度容差下分别清零 `totalBuyCostCny`/`totalBuyCostNative`/`totalBuyQuantity` 与 `quantity`,防御浮点数精度残留。
- **中文字符解析修复**:将 `txType` 比较从 Unicode 转义序列 `\u5165\u4e70`/`\u5356\u51fa` 替换为明文中文 `'买入'`/`'卖出'`,并增加 `'入金'`/`'出金'` 别名,兼容合规的 CSV 导入脏数据。
- **验收标准**Dashboard 走势图今天节点5月3日的"投入本金"从虚假的 267k 回归到与 JSON 完全相同的 242,239两者达到 100% 完美的数学对齐。
## 执行 Task 93调整 next.config 配置,开启 eslint 与 typescript 的 ignoreDuringBuilds 豁免,实施开发与打包阶段的责任分离,解决 Docker 内部构建阻断问题
-`next.config.ts` 中新增 `eslint.ignoreDuringBuilds: true``typescript.ignoreBuildErrors: true`,显式告知 Next.js 在打包期间忽略静态检查。
- **架构级设计**:将代码审查责任移交至本地 IDE防止 Docker 部署被非致命警告阻断,实现开发阶段(本地 IDE 负责 lint/typecheck与打包阶段CICD 负责构建)的责任分离。