Skip to content

Latest commit

 

History

History
280 lines (206 loc) · 21.6 KB

File metadata and controls

280 lines (206 loc) · 21.6 KB

家庭账房 · v0.5 技术设计 (TDD)

对应 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 风险升级。


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/储蓄区双柱同源一致;空期不拉低均值

1. 文件改动清单

1.1 新建

文件 说明
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 宏观基准段

1.2 改动

文件 改动
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)

1.3 不删除

v0.5 全程新增 / 修复,不删 v0.4 任何东西(财富水位是 reports 加 section,储蓄区双柱保留)。


2. 关键决策详解

决策 41 · CPI/M2 数据获取 = seed + 年度增量

备选:① 全 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%)→ 真实拉取。

决策 42 · 历史平均 = 几何均值 · 默认剔极端值

备选:① 算术均值 ② 几何均值(全历史)③ 几何均值(剔极端值) 取舍:通胀复利,算术均值高估;全历史几何被 1988-95 恶性通胀(1994 CPI 24.1%)+ 1998-2002 通缩污染。 选定:三法并算全展示(全历史 / 剔极端值 default / 近10年),剔极端 = 掐头尾各 10% 离群年。负值年(通缩)正常参与几何(1+r 仍为正)。

决策 43 · 财富水位并入 /reports section

备选:① 新 /wealth-level 一级页 ② 并入 /reports section ③ 并入 dashboard 取舍:Reports 定位即"跑赢通胀/市场没",且已有 XIRR/基准/人赚钱赚 KPI;新 tab 把第 5 问拆散;dashboard 是快照视角不合时序对照。 选定 ②:_wealth-level.html fragment 插入 reports/index;dashboard 仅保留趋势线速览(FR-75)跳转 reports 深看。

决策 44 · 净流入统一 PMC 优先(修 FR-84)

备选:① 维持只读 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 两处同口径。

决策 45 · 股票扣款 = 账户币种 + FX

备选:① 同币种现金行扣(无 FX)② 账户币种 + FX ③ 家庭本位币 + FX 取舍:同币种最简但账户可能无该币种现金行;账户币种贴券商账户口径(富途 USD 账户买美股自然);家庭本位币会把账户层的钱搬到家庭层。 选定 ②(用户 D5):股数 × 成本 经 resolveFxRate 换到 account.currency,扣该币种现金行;无则建负值行;现金可负(不做非负校验)。

决策 46 · 股票买卖全对称 · 卖出按市价

备选:① 仅买入扣 ② 全对称 · 卖出按成本加回 ③ 全对称 · 卖出按市价加回 取舍:仅买入会让卖出后现金对不上;按成本加回则卖出当刻净值跳变(忽略浮盈);按市价加回 → 卖出当刻净值中性(浮盈已在卖前价值里)。 选定 ③(用户 D6):减仓/归档 减少股数 × 当前市价 × FX 加回;MANUAL 按 manualValue;仅对显式勾选过联动的持仓生效(向后兼容,不误伤纯手工老持仓)。

决策 47 · 股票联动 ledger = 再分配行不计收支

备选:① 记为收入/支出 ② 记为账户内转账 ③ 再分配行 · 不计收支 取舍:记收支会凭空改净资产(买入当刻净值不变);记转账语义是账户间,这里是账户内 holding 间。 选定 ③:类 v0.4.1 估值事件展示,标"再分配",明确排除出收支汇总 + 储蓄能力指标。

决策 48 · FIRE 支出派生触发 = 周期关闭 + 惰性兜底

备选:① 实时每次算 ② 仅访问目标页算 ③ 周期关闭重算 + 访问惰性兜底 取舍:实时算使目标随每笔录入抖动;仅访问算则 dashboard 目标条等其他入口拿到旧值。 选定 ③:周期关闭(月结落定)重算 AUTO 模式 FIRE 目标 → 重算 targetValue 落库;访问目标页时若 computed_at 过期再惰性补算。

决策 49 · FIRE 派生值回写同字段

备选:① 新增独立 computedMonthlyExpense 字段 ② 回写 paramsJson.monthlyExpense 同字段 + computed_at 取舍:新字段则进度/预测/AI/dashboard 每个消费方都要改读新字段;回写同字段则全部透明零改动。 选定 ②:AUTO 模式把派生值写回 monthlyExpense,加 computed_at 供 UI 显示"基于近12月·更新于"。切 AUTO→FIXED 时以最近派生值为种子。

决策 50 · 数据迁移向后兼容

  • V27 = ADD TABLE(macro_benchmark)· 对现有数据 0 影响
  • GoalParams 加 expense_mode 默认 FIXED → 老目标行为不变(JSON 缺 key 反序列化为 null → 视作 FIXED)
  • 股票联动:老持仓无"联动"标记 → 卖出不动现金(backward compat)
  • 净流入修复:纯 cash_flow 老数据走回退路径仍正确

3. 私密红线(承袭)

  • 宏观 CPI/M2 是公开数据,无私密问题
  • FR-77 喂 checkup 的是算好的双基准数字,PromptBuilder 仍不碰 phone/aksk/LLM-key(PrivacyIsolationTest 防回归不变)
  • LLM 不做数学:水位/收益率/均值全工程算,LLM 只解读(承 [[feedback_llm_no_math]])

4. 验收基线

  • mvn test 在 190 基础上 + FR-70~85 新单测(含 BenchmarkAverage 几何均值对照 / 人赚+钱赚=ΔNW 恒等式 / 股票 FX 扣减 / FIRE 空期排除)
  • qa-run v05-* + qa-e2e 财富水位/股票联动/FIRE 派生 真值校验
  • beta 部署 + 用户验收

5. v0.5.3 · 计算指标透明化(2026-06-03)

决策 51 · tooltip 真实数值 = 纯展示层 + 复用已算中间量

方案 取舍
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 = 仅口径)。

决策 52 · XIRR/TWR 不伪造算术步骤

迭代解(XIRR Newton 法)/ 几何均值(TWR)无法写成单条算式。tooltip 只列真实标量输入(期初/期末净资产、净流入、期数)+ 解得年化值,文字说明「数值求解」/「连乘开方」。诚实优先,不编造看似精确的中间步骤误导用户。

决策 53 · 双币种上下文

reports 页同时存在两种币种上下文:KPI 区(人赚/钱赚/XIRR/基准)走 viewCurrency;储蓄区(月均收支)走本位币(PMC 按本位币存、模板硬编码 ¥)。ReportsMetricInputs 同时带 viewCcy + baseCcy,各 key 用对应币种,避免一侧 view 一侧 base 漂移(承 v0.5.1 比值币种修复纪律)。

6. v0.5.4 · 目标 AI 月报修复(2026-06-03)

决策 54 · 月报脱敏「校验在代号、展示用真名」两段式

承袭 LlmDiagnoseService 既有口径(决策 20):OutputValidator.check(raw, realNames) 跑在含代号的 LLM 原文上(此时输出不含真名 → 不会因真名扫描误杀),通过后再 PromptBuilder.reverseMapping(raw, codenameToReal) 还原真名供前端展示。隐私边界是"喂给 LLM 的 prompt 不含真名"(prompt 端已满足),展示端还原不破坏该边界。原 GoalLlmService 漏了第二段 → 月报显代号,本次补齐。

决策 55 · 月报缓存 = DB 持久化(周期级)而非内存 TTL

checkup 诊断用内存 ConcurrentHashMap + 1h TTL(瞬时、可重算);目标月报改用 goal_ai_report 表持久化(UNIQUE upsert)。取舍:月报是账期级产物(一期一份、跨重启要留存、供历史回看),持久化比内存 TTL 更贴语义;"复用"= 详情页加载已存月报不重算,"刷新"= 用户点「重新生成」强制覆写(对齐 checkup ↻ forceRefresh)。不引入时间 TTL。

决策 56 · 仪表盘条带双 <a> 并列(不嵌套)

目标卡主链接(→ 详情)与「AI 阅读总结」入口(→ #ai-report 锚点)是两个并列 <a>(flex 布局),避免 <a> 嵌套非法 HTML;AI 入口 inline-SVG(book-open)承 [[feedback_no_emoji]]。

7. v0.5.5 · 报表「已关账快照」锚定(2026-06-03)

承 PRD §11(FR-94~97)。核心:报表四指标切片终点从 OPEN 期改为「最近已关账(≤今天)账期」,并在页头朱印透出。0 schema 改动,纯读查询 + 锚定逻辑 + 模板。

决策 57 · 锚定期查询 = 专用 mapper findLatestClosedAsOf

备选 取舍
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。

决策 58 · 锚定 fallback + closedSnapshot 标志(不抛异常)

  • 报表锚定 = findLatestClosedAsOf(今天):存在 → anchor=它, closedSnapshot=true;不存在 → fallback findCurrentOpen().or(findLatest(1)) 仅用于渲染页面外壳(账户范围 / FX / range 选择器),closedSnapshot=false
备选 取舍
A. fallback + flag(选定) 页面始终能渲染;banner 区按 flag 决定"快照"还是"空态"
B. 无 closed 直接抛/空白 全新家庭体验差
C. 无 closed 退回锚 OPEN 且照常算指标 又把 OPEN 拉回来,回到本次要修的老问题

为什么不 C:直接违背本次目的(去掉 OPEN 终点)。

决策 59 · 是否显示四指标 = closedSnapshot && periodCount≥2

  • 四指标需 ≥2 期(分解要上一期做基准;XIRR/TWR 需多点),reportsHasMetrics = closedSnapshot && slice.periodIds().size()>=2
  • OPEN 期天然被排除:range 终点 = 最近已关账期的 period_start,当前 OPEN(period_start 更晚)落在窗口外;未来 CLOSED 测试期(2032)被 asOf≤今天 挡掉。故无需在 slice 内再按 status 过滤(按日期窗口已天然排除,更简单)。

决策 60 · 印章 = 纯 CSS 竖排方印(不用 emoji / 不用图片)

备选 取舍
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}" 条件渲染;默认竖排方印(用户已选)。

决策 61 · 储蓄区不重锚(诚实边界)

储蓄能力区用 householdCashflowService(近 12 月有填月份均值),与"已关账快照"是不同口径,且自带「近 12 月有填数据月份」标注,不会被印章误导。不强行重锚——它衡量的是"攒钱习惯跨自然月",不是关账快照。透出文案聚焦"收益/净值口径以已关账为准",储蓄区保持原样。

决策 62 · 可测性 = 抽纯函数 ReportsAnchorResolver + qa 黑盒

  • 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-* 黑盒守护

边界 & backward-compat

  • 无任何账期:现 controller throw IllegalStateException("尚未创建周期") 保持(全新家庭至少有 1 个 OPEN,不会真无账期);closed=0 有 open → 状态 C 引导。
  • 多币种:印章/标签与币种无关;asOf 取服务器今天(Asia/Shanghai)。
  • range 选择器仍生效,只是窗口终点从 OPEN 改为最近已关账期。
  • 0 schema 改动;dashboard 完全不动。

8. v0.5.6 · 报表长文目录(2026-06-03)

承 PRD §12(FR-98)。纯前端 · 0 schema · dashboard 不动。

决策 63 · 升级现有 FAB,而非新建

现状已有右下角低调 FAB(v0.5.1)· 锚点(4 节)部分缺、无 scrollspy。本次复用其"按需展开 + 锚点跳转"内核,升级为 PC 常驻栏 + 手机 sheet + scrollspy + 树状层级 + 更全章节(7 节)。

决策 64 · scrollspy 用「滚动 + getBoundingClientRect」而非纯 IntersectionObserver

备选 取舍
A. scroll 监听 + rAF 节流 + 每帧取"顶部已越参考线(110px)的最后一节"(选定) 天然处理嵌套(子节在父节之后越线 → 子节胜)· 每次重查锚点 → HTMX 换 region 后自动适配(锚点节点被换掉也能重新找到)
B. 纯 IntersectionObserver "首个 intersecting" 嵌套时父+子同屏会一直选父、选不到子;且 region 被 HTMX swap 后 observe 的节点失效需重绑

为什么不 B:嵌套 + HTMX swap 两个坑;A 重查 + rAF 节流性能足够(节点数 < 10)。补 htmx:afterSettle 再 spy 一次即时刷新。

决策 65 · 手机 = 左上唤醒钮 + 底部 sheet(承 Wikipedia 2025)

唤醒钮置左上角(用户指定 · nav 下方);点开底部 sheet(拇指区 · 不丢阅读位)· 拖拽手柄 + × + Esc(不只手势 · a11y)· 触控 ≥44px · 不自动展开全部(Wikipedia 2025 实验:自动展开 −1.5% 留存)。

决策 66 · 树状层级 = 缩进 + 引导线 + 树枝(CSS · 嵌套就绪)

.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

9. v0.5.7 · 长文目录共用件(2026-06-04)

承 PRD §13(FR-99)。把 v0.5.6 reports 内联目录抽成共用件,推广到 dashboard + checkup。

决策 67 · 三分抽取(CSS / JS / 片段)而非整段复制

备选 取舍
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 验证可行)。

决策 68 · scrollspy 页面无关 + 锚点自带 scroll-margin

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)。

决策 69 · 改布局前先验 div 平衡(承 v0.5.6 教训)

v0.5.6 reports 因 _region 一个未闭合 <div>(41/40)导致 #reports-region 吞掉 aside、目录掉页底。本次接入前先 grep -c '<div' vs '</div>':dashboard 41/41、checkup 68/68,均平衡再加双栏 flex。