对应 PRD:
prd/v0.5.md范围:v0.5 全部 FR(主线 A 财富水位 FR-7077+85 · 主线 B 股票现金联动 FR-7880 · 主线 C FIRE 支出自适应 FR-8183 · 缺陷修复 FR-84) 决策编号延续 v0.4(用到决策 40):本文档决策 41 起 假设熟读 v0.1v0.4 TDD · 不再赘述骨架 / fact_view / GoalProgressCalculator / DynamicScheduleConfig / FamilyConfigService
实施前状态(2026-05-29):v0.4.23 已上 prod · 190 单测 + 319 QA 全绿 v0.5 不动 v0.4 核心服务,只做:新数据层(macro_benchmark)+ 新指标层(水位计算)+ 净流入口径统一(修 bug)+ 股票持仓服务扩展 + Goal 支出口径扩展。 DB:V27 新建静态表 + GoalParams JSON 加 key(ADD COLUMN DEFAULT 风格)· 0 风险升级。
| 项 | 选择 | 备选 | 选定理由 |
|---|---|---|---|
| CPI/M2 数据获取 | 1990-2025 历史 seed 烤入 + 年度增量拉取 | 全 API 实时 / 全写死代码 | 历史值是不变事实,无需反复拉;只拉新年份 → 稳定 + 离线可用;接 v0.4.18 cron 框架 |
| 历史平均算法 | 几何均值 · 默认剔极端值 | 算术均值 / 中位数 | 通胀复利,几何才是等效年率;中国 CPI 有 1994=24% 极端年,剔极端防失真(用户明确担心) |
| 财富水位落点 | 并入 /reports 新 section |
新 /wealth-level 一级页 |
Reports 定位本就是"跑赢通胀/市场没"(时序+基准);已有人赚/钱赚 KPI 可复用;新 tab 会打散第 5 问(2026-05-29 用户纠正) |
| 净流入(人赚)口径 | PMC 优先 · cash_flow 回退 | 只 cash_flow(现状·bug)/ 只 PMC | 用户工资填 PMC;承 v0.4.3 B2 同纪律;守 人赚+钱赚=ΔNW |
| 股票扣款币种 | 账户币种 account.currency + FX |
同币种现金行 / 家庭本位币 | 账户口径最贴券商心智;FX 走 v0.3 resolveFxRate 链(用户 D5 选定) |
| 股票买卖对称 | 全对称 · 卖出按市价加回 | 仅买入扣 | 买卖一致才不漏算;市价加回使卖出当刻净值中性(用户 D6 选定) |
| 股票联动 ledger | 再分配行 · 不计收支 | 记为收入/支出 / 记为转账 | 买入当刻净值中性,计收支会凭空改净资产;再分配语义最准 |
| FIRE 支出派生触发 | 周期关闭重算 + 访问惰性兜底 | 实时每次算 / 仅访问算 | 周期关闭=月结落定的自然时刻;接现有指标重算钩子;实时算使目标抖动 |
| FIRE 派生值存储 | 回写 paramsJson.monthlyExpense 同字段 + computed_at |
新增独立字段 | 回写同字段 → 进度/预测/AI/dashboard 全部零改动透明读;computed_at 供 UI 显示基准 |
| 收支趋势数据源 | PMC findFamilyAggregateRecent · 仅真实填报期 |
account cash_flow | 与 FR-84/储蓄区双柱同源一致;空期不拉低均值 |
| 类 | 文件 | 说明 |
|---|---|---|
| migration | db/migration/V27__macro_benchmark.sql |
macro_benchmark 表 + 1990-2025 CPI/M2 seed |
| domain | domain/macro/MacroBenchmark.java |
year / cpiHeadline / m2Growth / source / fetchedAt |
| mapper | repository/MacroBenchmarkMapper.java |
findAll / findByYear / upsert / findRange |
| calc | calc/BenchmarkAverage.java |
纯函数:geometricMean / trimmedGeometricMean / recentAverage |
| calc | calc/WaterLevelCalculator.java |
累计因子 + CPI/M2 基准线 + 真实/相对社会收益 |
| service | service/macro/MacroBenchmarkService.java |
读取 + 三法均值 + 年度拉取(NBS/PBOC · 失败回退 seed) |
| service | service/macro/MacroFetchJob.java |
年度 cron(接 DynamicScheduleConfig) |
| 模板 | templates/reports/_wealth-level.html |
财富水位 section fragment(嵌入 reports/index) |
| 模板 | templates/admin/_macro.html 或入 integrations |
宏观基准段 |
| 文件 | 改动 |
|---|---|
factview/FactViewServiceImpl.java |
FR-84:principalVsReturnDecomposition + netInflowForPeriod 改 PMC 优先;新增水位计算入口 |
web/report/ReportsController.java |
注入水位数据 + 收支趋势 series(PMC) |
templates/reports/index.html |
插 _wealth-level :: section;储蓄区加收支趋势图 |
templates/reports/_savings.html |
加收支趋势 canvas(FR-85) |
web/dashboard/DashboardController.java + dashboard/_region.html |
CPI 线 → 真实 CPI + M2 线(FR-75) |
web/admin/IntegrationsController.java + integrations.html |
宏观基准段(FR-76) |
service/checkup/llm/PromptBuilder.java(收益维度调用方) |
喂双基准算好的数字(FR-77 · 不进私密红线) |
domain/stock/StockHolding.java 创建链 + service/stock/StockHoldingService.java |
买入扣现金 / 卖出加回(FR-78/79) |
web/stock/StockHoldingController.java + holding-new-auto.html |
「从账户现金划转」开关 + 成本必填(FR-78) |
service/AccountDetailService.java / ledger 渲染 |
再分配行(FR-80) |
domain/goal/GoalParams.java |
加 expense_mode / expense_window_months / expense_smoothing(FR-81) |
service/goal/GoalService.java + calc/GoalProgressCalculator.java |
AUTO 模式派生月支出 + 周期关闭重算钩子(FR-82/83) |
web/goal/GoalController.java + goal 表单模板 |
支出口径开关 + 移动靶沟通(FR-81/83) |
v0.5 全程新增 / 修复,不删 v0.4 任何东西(财富水位是 reports 加 section,储蓄区双柱保留)。
备选:① 全 API 实时拉 ② 全写死代码常量 ③ 历史 seed + 年度增量
取舍:历史 CPI/M2 是已公布的不变事实,反复拉 API 无意义且不稳(NBS/PBOC 接口限流/改版风险);写死代码则升级才能改、违背 v0.4.18 "运营参数进 DB" 纪律。
选定 ③:V27 seed 1990-2025(40 年 CPI / 35 年 M2),MacroFetchJob 每年拉最新完整年份(2026 拉 2025),失败回退 seed/手动。升级 v0.4 决策 34(原 CPI=family.cpi_assumption 假设值 2%)→ 真实拉取。
备选:① 算术均值 ② 几何均值(全历史)③ 几何均值(剔极端值) 取舍:通胀复利,算术均值高估;全历史几何被 1988-95 恶性通胀(1994 CPI 24.1%)+ 1998-2002 通缩污染。 选定:三法并算全展示(全历史 / 剔极端值 default / 近10年),剔极端 = 掐头尾各 10% 离群年。负值年(通缩)正常参与几何(1+r 仍为正)。
备选:① 新 /wealth-level 一级页 ② 并入 /reports section ③ 并入 dashboard
取舍:Reports 定位即"跑赢通胀/市场没",且已有 XIRR/基准/人赚钱赚 KPI;新 tab 把第 5 问拆散;dashboard 是快照视角不合时序对照。
选定 ②:_wealth-level.html fragment 插入 reports/index;dashboard 仅保留趋势线速览(FR-75)跳转 reports 深看。
备选:① 维持只读 cash_flow(现状 bug)② 只读 PMC ③ PMC 优先 · cash_flow 回退
取舍:用户工资填 PMC,只读 cash_flow → 净流入=0 + 钱赚被高估;只读 PMC 则丢失早期纯 cash_flow 老数据兼容。
选定 ③:逐期 PMC 优先,该期 PMC 空回退该期 cash_flow(承 v0.4.3 B2)。守 人赚 + 钱赚 = ΔNetWorth 恒等式(单测断言)。改 principalVsReturnDecomposition + netInflowForPeriod 两处同口径。
备选:① 同币种现金行扣(无 FX)② 账户币种 + FX ③ 家庭本位币 + FX
取舍:同币种最简但账户可能无该币种现金行;账户币种贴券商账户口径(富途 USD 账户买美股自然);家庭本位币会把账户层的钱搬到家庭层。
选定 ②(用户 D5):股数 × 成本 经 resolveFxRate 换到 account.currency,扣该币种现金行;无则建负值行;现金可负(不做非负校验)。
备选:① 仅买入扣 ② 全对称 · 卖出按成本加回 ③ 全对称 · 卖出按市价加回
取舍:仅买入会让卖出后现金对不上;按成本加回则卖出当刻净值跳变(忽略浮盈);按市价加回 → 卖出当刻净值中性(浮盈已在卖前价值里)。
选定 ③(用户 D6):减仓/归档 减少股数 × 当前市价 × FX 加回;MANUAL 按 manualValue;仅对显式勾选过联动的持仓生效(向后兼容,不误伤纯手工老持仓)。
备选:① 记为收入/支出 ② 记为账户内转账 ③ 再分配行 · 不计收支 取舍:记收支会凭空改净资产(买入当刻净值不变);记转账语义是账户间,这里是账户内 holding 间。 选定 ③:类 v0.4.1 估值事件展示,标"再分配",明确排除出收支汇总 + 储蓄能力指标。
备选:① 实时每次算 ② 仅访问目标页算 ③ 周期关闭重算 + 访问惰性兜底 取舍:实时算使目标随每笔录入抖动;仅访问算则 dashboard 目标条等其他入口拿到旧值。 选定 ③:周期关闭(月结落定)重算 AUTO 模式 FIRE 目标 → 重算 targetValue 落库;访问目标页时若 computed_at 过期再惰性补算。
备选:① 新增独立 computedMonthlyExpense 字段 ② 回写 paramsJson.monthlyExpense 同字段 + computed_at 取舍:新字段则进度/预测/AI/dashboard 每个消费方都要改读新字段;回写同字段则全部透明零改动。 选定 ②:AUTO 模式把派生值写回 monthlyExpense,加 computed_at 供 UI 显示"基于近12月·更新于"。切 AUTO→FIXED 时以最近派生值为种子。
- V27 = ADD TABLE(macro_benchmark)· 对现有数据 0 影响
- GoalParams 加 expense_mode 默认 FIXED → 老目标行为不变(JSON 缺 key 反序列化为 null → 视作 FIXED)
- 股票联动:老持仓无"联动"标记 → 卖出不动现金(backward compat)
- 净流入修复:纯 cash_flow 老数据走回退路径仍正确
- 宏观 CPI/M2 是公开数据,无私密问题
- FR-77 喂 checkup 的是算好的双基准数字,PromptBuilder 仍不碰 phone/aksk/LLM-key(PrivacyIsolationTest 防回归不变)
- LLM 不做数学:水位/收益率/均值全工程算,LLM 只解读(承 [[feedback_llm_no_math]])
- mvn test 在 190 基础上 + FR-70~85 新单测(含 BenchmarkAverage 几何均值对照 / 人赚+钱赚=ΔNW 恒等式 / 股票 FX 扣减 / FIRE 空期排除)
- qa-run v05-* + qa-e2e 财富水位/股票联动/FIRE 派生 真值校验
- beta 部署 + 用户验收
| 方案 | 取舍 |
|---|---|
A. 新建 MetricExplainService(纯展示层),复用 FactView/HouseholdCashflow 已算结果(选定) |
计算零改动 · 数值必与页面 KPI 同源一致 · 格式化集中可单测 · controller 只多一行注入 calc map |
| B. 在各 controller 内联拼串 | 逻辑散到 3 个 controller · 重复 · 难测 · 易和 KPI 显示口径漂移 |
C. Thymeleaf 模板里用 ${} 现算 |
✗ 历史上 SpEL 沙箱 ban 过 T(BigDecimal)(v0.5 _wealth-level 事故)· 模板做数学不可维护 |
实现:KpiSnapshot 加 4 个原本「算完即弃」的中间量(liquidAssets / avgExpense / prevNetWorth / lastNetInflow),让 MetricExplainService 能拼出「分子 ÷ 分母 = 结果」这类实算。_kpi-info 片段签名 i(text) → i(text, calc)(第二参 null = 仅口径)。
迭代解(XIRR Newton 法)/ 几何均值(TWR)无法写成单条算式。tooltip 只列真实标量输入(期初/期末净资产、净流入、期数)+ 解得年化值,文字说明「数值求解」/「连乘开方」。诚实优先,不编造看似精确的中间步骤误导用户。
reports 页同时存在两种币种上下文:KPI 区(人赚/钱赚/XIRR/基准)走 viewCurrency;储蓄区(月均收支)走本位币(PMC 按本位币存、模板硬编码 ¥)。ReportsMetricInputs 同时带 viewCcy + baseCcy,各 key 用对应币种,避免一侧 view 一侧 base 漂移(承 v0.5.1 比值币种修复纪律)。
承袭 LlmDiagnoseService 既有口径(决策 20):OutputValidator.check(raw, realNames) 跑在含代号的 LLM 原文上(此时输出不含真名 → 不会因真名扫描误杀),通过后再 PromptBuilder.reverseMapping(raw, codenameToReal) 还原真名供前端展示。隐私边界是"喂给 LLM 的 prompt 不含真名"(prompt 端已满足),展示端还原不破坏该边界。原 GoalLlmService 漏了第二段 → 月报显代号,本次补齐。
checkup 诊断用内存 ConcurrentHashMap + 1h TTL(瞬时、可重算);目标月报改用 goal_ai_report 表持久化(UNIQUE upsert)。取舍:月报是账期级产物(一期一份、跨重启要留存、供历史回看),持久化比内存 TTL 更贴语义;"复用"= 详情页加载已存月报不重算,"刷新"= 用户点「重新生成」强制覆写(对齐 checkup ↻ forceRefresh)。不引入时间 TTL。
目标卡主链接(→ 详情)与「AI 阅读总结」入口(→ #ai-report 锚点)是两个并列 <a>(flex 布局),避免 <a> 嵌套非法 HTML;AI 入口 inline-SVG(book-open)承 [[feedback_no_emoji]]。
承 PRD §11(FR-94~97)。核心:报表四指标切片终点从 OPEN 期改为「最近已关账(≤今天)账期」,并在页头朱印透出。0 schema 改动,纯读查询 + 锚定逻辑 + 模板。
| 备选 | 取舍 |
|---|---|
A. 新增 findLatestClosedAsOf(familyId, asOf):WHERE status='CLOSED' AND period_start<=#{asOf} ORDER BY period_start DESC LIMIT 1(选定) |
一条走索引查询 · 零 over-fetch · 不受未来期数量影响 |
B. 复用 findLatest(familyId, N) 在 Java 过滤 CLOSED + ≤today |
要猜 N · 未来测试期多时会漏掉真实最近关账期 |
C. 通用 findLatestByStatus(status) |
过度通用 · YAGNI |
为什么不 B:over-fetch + 脆弱;为什么不 C:只此一处用,YAGNI。
- 报表锚定 =
findLatestClosedAsOf(今天):存在 →anchor=它, closedSnapshot=true;不存在 → fallbackfindCurrentOpen().or(findLatest(1))仅用于渲染页面外壳(账户范围 / FX / range 选择器),closedSnapshot=false。
| 备选 | 取舍 |
|---|---|
| A. fallback + flag(选定) | 页面始终能渲染;banner 区按 flag 决定"快照"还是"空态" |
| B. 无 closed 直接抛/空白 | 全新家庭体验差 |
| C. 无 closed 退回锚 OPEN 且照常算指标 | 又把 OPEN 拉回来,回到本次要修的老问题 |
为什么不 C:直接违背本次目的(去掉 OPEN 终点)。
- 四指标需 ≥2 期(分解要上一期做基准;XIRR/TWR 需多点),
reportsHasMetrics = closedSnapshot && slice.periodIds().size()>=2。 - OPEN 期天然被排除:range 终点 = 最近已关账期的
period_start,当前 OPEN(period_start 更晚)落在窗口外;未来 CLOSED 测试期(2032)被asOf≤今天挡掉。故无需在 slice 内再按 status 过滤(按日期窗口已天然排除,更简单)。
| 备选 | 取舍 |
|---|---|
A. CSS box(border + 朱红 bg tint + rotate)+ writing-mode:vertical-rl 竖排「已关账」(选定) |
轻量 · 可主题化 · 与 preview 一致 · 加 .report-seal 到 style.css |
| B. inline SVG 图形印章 | 纯文字印章用 SVG 反而啰嗦 |
| C. emoji | 红线禁止([[feedback_no_emoji]]) |
th:if="${closedSnapshot}" 条件渲染;默认竖排方印(用户已选)。
储蓄能力区用 householdCashflowService(近 12 月有填月份均值),与"已关账快照"是不同口径,且自带「近 12 月有填数据月份」标注,不会被印章误导。不强行重锚——它衡量的是"攒钱习惯跨自然月",不是关账快照。透出文案聚焦"收益/净值口径以已关账为准",储蓄区保持原样。
- 抽
ReportsAnchorResolver.resolve(Optional<Period> latestClosed, Optional<Period> currentOpen, List<Period> latestFallback)→record AnchorChoice(Period anchor, boolean closedSnapshot)纯函数。单测:① 有 closed → 选它 + true;② 无 closed 有 open → open + false;③ 都无 → latestFallback + false。 - mapper SQL(
asOf≤今天/ 排除未来期)走 qa-e2e 真值 + qa-run 黑盒(/reports有数据时含印章「已关账」文案;无 ≥2 期时不显误导性 0)。 - dashboard 行为不变 · 加 1 条 qa 守 dashboard 仍锚最新一期(实时)。
PeriodMapper.findLatestClosedAsOf 新增 @Select
service/report/ReportsAnchorResolver.java 新增(纯函数 + AnchorChoice)
web/report/ReportsController.java anchorPeriod 改用 resolver + 注入 closedSnapshot/reportsAsOfLabel/reportsHasMetrics
templates/reports/_region.html 印章 + 说明行(条件渲染)+ banner 空态(— + 需≥2期)+ #3 tooltip 文案
static/css/style.css .report-seal
test/.../ReportsAnchorResolverTest.java 新增
scripts/qa-run.sh + docs/qa-cases.md v05-SNAP-* 黑盒守护- 无任何账期:现 controller
throw IllegalStateException("尚未创建周期")保持(全新家庭至少有 1 个 OPEN,不会真无账期);closed=0 有 open → 状态 C 引导。 - 多币种:印章/标签与币种无关;
asOf取服务器今天(Asia/Shanghai)。 - range 选择器仍生效,只是窗口终点从 OPEN 改为最近已关账期。
- 0 schema 改动;dashboard 完全不动。
承 PRD §12(FR-98)。纯前端 · 0 schema · dashboard 不动。
现状已有右下角低调 FAB(v0.5.1)· 锚点(4 节)部分缺、无 scrollspy。本次复用其"按需展开 + 锚点跳转"内核,升级为 PC 常驻栏 + 手机 sheet + scrollspy + 树状层级 + 更全章节(7 节)。
| 备选 | 取舍 |
|---|---|
| A. scroll 监听 + rAF 节流 + 每帧取"顶部已越参考线(110px)的最后一节"(选定) | 天然处理嵌套(子节在父节之后越线 → 子节胜)· 每次重查锚点 → HTMX 换 region 后自动适配(锚点节点被换掉也能重新找到) |
| B. 纯 IntersectionObserver "首个 intersecting" | 嵌套时父+子同屏会一直选父、选不到子;且 region 被 HTMX swap 后 observe 的节点失效需重绑 |
为什么不 B:嵌套 + HTMX swap 两个坑;A 重查 + rAF 节流性能足够(节点数 < 10)。补 htmx:afterSettle 再 spy 一次即时刷新。
唤醒钮置左上角(用户指定 · nav 下方);点开底部 sheet(拇指区 · 不丢阅读位)· 拖拽手柄 + × + Esc(不只手势 · a11y)· 触控 ≥44px · 不自动展开全部(Wikipedia 2025 实验:自动展开 −1.5% 留存)。
.toc-children 缩进 + border-left 竖向脊;子节 ::before 横向 tick 接到脊上;aria-current active 染朱铜、ancestor 父级淡显。当前各节平铺,未来加子节自动成树。锚点设 scroll-margin-top:80px。
templates/reports/index.html 重构:content+右栏 flex · 右栏树状 nav · 左上 FAB + 底部 sheet · scrollspy 脚本
templates/reports/_region.html 加 #sec-decompose / #sec-risk / #sec-accounts(+ scroll-margin)
static/css/style.css .toc-rail / .toc-node / .toc-children / .toc-fab / .toc-sheet
scripts/qa-run.sh + docs/qa-cases.md v05-TOC-1承 PRD §13(FR-99)。把 v0.5.6 reports 内联目录抽成共用件,推广到 dashboard + checkup。
| 备选 | 取舍 |
|---|---|
| A. CSS(style.css)+ JS(static/js/toc.js 页面无关)+ Thymeleaf 片段(_toc rail/mobile · items 参数)(选定) | 每页只 ~3 行接入 · 改一处全页生效 · JS 一份不重复 |
| B. 每页内联 markup+script(如 v0.5.6 reports 初版) | 3+ 页重复 ~40 行 · 改一处要改 N 处 |
items 经 th:with 内联 map literal 传入(${ { {label:..,href:..} } }),不改 controller(零 Java 改动 · 已 reports 验证可行)。
toc.js 只认 [data-toc-nav] a[href^=#] 与其指向 id,任何页通用;各 section 自带 id + scroll-margin-top:80px。条件 section(checkup advice 有/无两态)互斥同 id 保锚点恒解析;reports allocation-diff 缺失时 dead-anchor 无害(getElementById null → 跳过/no-op)。
v0.5.6 reports 因 _region 一个未闭合 <div>(41/40)导致 #reports-region 吞掉 aside、目录掉页底。本次接入前先 grep -c '<div' vs '</div>':dashboard 41/41、checkup 68/68,均平衡再加双栏 flex。