对应 PRD:
prd/v0.2.md§ FR-30 / FR-36-38 / FR-40 系列 范围:v0.2 待启动部分(已交付 FR-33/34/39 见tech-design/v0.2.md) 本文档不是代码清单,而是架构方案 — 每个关键决策列出备选 + 取舍 + 选定理由 关联 v0.1 基础设施(Spring Boot 3.3 / Thymeleaf / HTMX / MyBatis / MySQL)— 不重复阐述
下面是 v0.2 待启动部分的 19 个关键决策。每个决策都在后面对应章节展开(备选 / 取舍 / 选定理由)。
| # | 决策点 | 选定 | 主要替代 |
|---|---|---|---|
| 1 | 「资产体检」URL 路由模型 | query 参数切换 /checkup + /checkup?account=X |
path 嵌套 / 双独立路由 |
| 2 | 诊断 / 智能建议计算的运行模式 | 请求时实时计算 + 短期内存缓存 | 周期关闭时预计算落库 / 数据库 view |
| 3 | 智能建议引擎实现 | 60% 规则 + 40% LLM 文案 · 三层架构 · OutputValidator 双锁 | 纯规则模板填空 / 纯 LLM 生成 |
| 4 | product_category 数据来源 |
flyway 静态预置 + admin 微调表内字段 | 配置文件 sync / 完全 admin CRUD / 代码 enum |
| 5 | 健康度评分的归属层 | AdviceEngine 输出副产物 | 独立 HealthScoreService |
| 6 | 基准对照的数据来源 | 类目静态 benchmark_pct(线性外推) | 实时拉指数 / 历史 NAV 快照表 |
| 7 | 渲染策略 | SSR Thymeleaf + 局部 HTMX 刷新(沿用 v0.1) | 完全 SPA / 完全 SSR 无 HTMX |
| 8 | dismiss 状态持久化 | localStorage 客户端兜底 | DB 表 / hybrid |
| 9 | 类目下拉的应用范围 | 新建向导 + 编辑页 + admin 页 三处 | 仅编辑页 / 单独类目设置页 |
| 10 | 计算与查询的责任分离 | Calculator(纯函数)+ DiagnoseService(组装) | 全在 Service / 全在 Mapper SQL |
| 11 | 类型差异化诊断卡的 UI 渲染 | 多个 fragment + Thymeleaf 条件 include | 一个大模板 + 大量 th:if |
| 12 | 全家级 vs 账户级建议引擎 | 单一引擎 + scope 字段(扫两次) | 两套独立引擎 |
| 13 | 顶部 nav 改造影响范围 | 改 fragments/nav 单点 + 7 个页面 controller 加 active | 复制 nav fragment 多份 |
| 14 | LLM 服务商选型 | Qwen-Turbo 主 + DeepSeek-v4-flash 备(策略模式封装) | OpenAI / Claude(海外)/ Kimi(贵 10×)/ Ollama 本地 |
| 15 | 数据脱敏程度(LLM prompt) | 半脱敏 — 类目代码 + 风险等级 + 数字 ✓;账户名 / 主理人 / 流水备注 ✗ | 完全脱敏 / 明文不脱敏 |
| 16 | LLM 文案的渲染策略 | 模板优先 + 异步 fade-replace(用户先看到 templateText,LLM 返回后替换) | 同步阻塞 / cache 命中即用 |
| 17 | LLM 失败兜底 | 降级 templateText + audit_log,用户无感 | 显示错误 / N 次重试再降级 |
| 18 | LLM 集成方式 | 直接 HTTP API(OpenAI 兼容协议) | 通过 ACP/MCP 调本地 claude-code/codex / Spring AI 框架 |
| 19 | 资产体检关注维度 | 4 维度核心:流动性 / 风险与配置 / 收益质量 / 负债健康 | 储蓄率 / 现金流稳定性 / 历史趋势 / 币种 / 税务 → 留 v0.3 |
┌─────────────────────────────────────────────────────────────────┐
│ v0.2 待启动部分 · 模块拆分 │
└─────────────────────────────────────────────────────────────────┘
新模块 com.family.finance.checkup
├─ web/
│ ├─ CheckupController — /checkup + /checkup?account=X
│ └─ AdvicePolishController — POST /checkup/advice/{id}/polish(LLM 异步润色端点)
├─ service/
│ ├─ FamilyDiagnoseService — 全家级诊断(配置 / 流动性 / 风险 / 收益)
│ ├─ AccountDiagnoseService — 账户级诊断(类型差异化)
│ ├─ AdviceEngine — 规则引擎(全家 + 账户共用 · 输出 hardFacts + templateText)
│ ├─ HealthScoreCalculator — 账户健康度评分(基于 advice level)
│ ├─ BenchmarkComparator — 基准对照 NAV 序列生成
│ ├─ llm/ — ★ LLM 文案润色层(决策 3 / 14-18)
│ │ ├─ LlmAdviceService — 入口,组装 prompt + 调 LLM + 走 Validator + cache
│ │ ├─ PromptBuilder — 按维度选 prompt 模板,注入 hardFacts
│ │ ├─ DimensionPromptRegistry — 4 维度专属 prompt 模板(LIQUIDITY / RISK_ALLOC / RETURN_QUALITY / DEBT_HEALTH)
│ │ ├─ OutputValidator — 数字白名单 + 禁词扫描 + 长度校验
│ │ ├─ LlmClient (interface) — 抽象 OpenAI 兼容协议(send + retry + timeout)
│ │ ├─ QwenLlmClient — Qwen-Turbo 实现(主)
│ │ ├─ DeepSeekLlmClient — DeepSeek-v4-flash 实现(备)
│ │ ├─ LlmClientRouter — 主备切换 + 熔断(失败 N 次自动切 fallback)
│ │ └─ AdviceCache — Redis-less in-memory cache(key = hash(ruleId + hardFacts), TTL 24h)
│ └─ rules/ — 18 条规则,每条一个 @Component class
│ ├─ Rule.java — 接口
│ ├─ liquidity/LiqRule1.java / LiqRule2.java / EffRule1.java / FamLiqRule1.java
│ ├─ risk/RiskRule1.java / RiskRule2.java / FamConRule1.java / FamConRule2.java / FamRiskRule1.java
│ ├─ return/RetRule1.java / RetRule2.java / RetRule3.java / FamAlcRule1.java
│ └─ debt/PrgRule1.java / PrgRule2.java
├─ domain/
│ ├─ Advice.java — record(level / category / scope / accountId /
│ │ headline / hardFacts / templateText / polishedText)
│ ├─ AdviceLevel.java — OK / INFO / WARN / DANGER
│ ├─ AdviceScope.java — FAMILY / ACCOUNT
│ └─ AdviceCategory.java — LIQUIDITY / RISK_ALLOC / RETURN_QUALITY / DEBT_HEALTH
新增配置类
└─ config/LlmProperties.java — @ConfigurationProperties("llm") · 主备 endpoint / model / api-key
新计算热点 com.family.finance.calc
├─ MaxDrawdownCalculator — 最大回撤(月颗粒)
├─ WeightedXirrCalculator — 全家加权年化(已有 XirrCalculator + 新加权层)
└─ NavSeriesBuilder — 账户净值序列生成器(剔除现金流)
新数据模型 com.family.finance.domain.category
├─ ProductCategory.java — 16 类目定义
├─ ProductCategoryMapper — 读类目 + admin 微调
└─ AccountCategoryAssignment — account.product_category_code 字段
旧模块改造(账本侧 / 全站打通)
├─ web/account/AccountController ← 加 /accounts/{id} 详情(账本视角,瘦身)
├─ web/account/LedgerCsvController ← 新加 /accounts/{id}/ledger.csv
├─ entry/EntryController ← 软删 endpoint
├─ web/dashboard/DashboardController ← KPI deep link 锚点(P1)
├─ NavService ← 顶部 nav 加「资产体检」激活态联动
└─ templates/fragments/nav.html ← 加 1 项 link
关键边界:
checkup模块只读已有数据(account / period_snapshot / cash_flow / transfer / fx_rate / product_category),不写- 唯一写操作:
product_category由 admin 微调时(罕见)— 走单独 admin 端点 - 计算结果不落库(决策 #2);所有指标实时算
- 旧
entry/account/report模块不动业务逻辑,只在数据层加WHERE deleted_at IS NULL(决策对应 FR-32)
要解决的问题:全家维度 vs 账户维度的视角切换,如何在 URL 里编码?既要"刷新不丢状态、链接可分享、浏览器后退自然",又要避免重复路由。
备选方案:
-
方案 A · query 参数切换:
/checkup默认全家;/checkup?account=5账户视角- ✅ 单 controller method,简单
- ✅ 链接可分享、刷新保留状态
- ✅ 后退按钮自然(浏览器 history 自动管理)
- ⚠ URL 看起来"非 RESTful"(query 通常用于 filter)
- ⚠ controller 内部要分支
-
方案 B · path 嵌套:
/checkup全家;/checkup/{accountId}账户视角- ✅ "更 RESTful"
- ⚠ 两个 method,一些代码重复(顶部 nav 激活态、布局)
- ⚠ 数字 ID 暴露在路径上 — 与 v0.1
/accounts/{id}风格一致,不算大问题
-
方案 C · 双独立路由:
/checkup全家;/accounts/{id}/checkup账户视角- ✅ 路径"嵌套到账户下",语义最自然
- ⚠ 两套独立 controller / 两个不同的 nav-active 标签
- ⚠ 切换账户时 URL 在两个 namespace 之间跳,后退栈乱
- ⚠ 全家页和账户页视觉差别很大,把它们当"账户子页"反而误导
选定:方案 A · query 参数。
理由:
- v0.2 的核心矛盾是"两种视角自由切换",query 切换最贴合心智(同一页面、不同筛选)
- 单 controller method 减少代码重复,nav-active 也只一个标签
checkup - "RESTful"的批评对家庭 SaaS 不重要;URL 美感 << 维护成本
- 方案 C 的语义虽然好,但
/accounts/{id}/checkup让"全家维度"无路可走(不能放在 /accounts 下,因为它跨账户),会出现路径不对称,反而割裂
未选 B 的"为什么不":嵌套方案要求账户视角必须有 accountId 在 path,但跳转时(全家 → 选账户)需要 redirect,后退会经过中间状态,UX 不流畅。
要解决的问题:用户访问 /checkup 时,18 条规则 + 4 张全家诊断 + 账户级诊断 + XIRR/TWR/MaxDD 计算 — 是每次实时算?还是预计算?
计算量估算:
- 全家页:~5 张 KPI(全家加权年化 + 加权基准是最重的,O(账户数 × 月数)=10×24=240 操作)+ 4 张诊断卡(每张 O(账户数))+ 11+4=18 条规则(每条 O(账户数 + 规则)≈ 100 操作 / 条)
- 账户页:7 个投资指标(XIRR Brent 求根 ~ 30 次迭代 + TWR + MaxDD + Sharpe)+ 11 条规则
- 单次首屏总计 < 50ms 在 i5 上完全可接受
备选方案:
-
方案 A · 请求时实时计算 + 短期内存缓存:每次请求重算,加 5 分钟内存 cache key 为
(familyId, periodId, accountId)- ✅ 数据始终最新
- ✅ 实施最简单(无落库 schema、无后台任务)
- ✅ 一页计算 < 100ms,实时性可接受
- ✅ 缓存 hit 率高(用户连续点击)
- ⚠ 大家庭 / 多账户时 CPU 暴涨 — 但本系统单家庭 < 20 账户,无问题
- ⚠ 缓存失效在用户填报后 → 加事件总线 invalidate
-
方案 B · 周期关闭时预计算落库:CLOSED period_metrics 表 cache 各类指标
- ✅ 查询快(O(1) 读表)
- ⚠ 引入新表 + 维护逻辑(每次 close 跑、每次 reopen 失效)
- ⚠ 类目变更后所有历史结果都要重算 — 复杂
- ⚠ 本期(OPEN)数据仍要实时算,逻辑分两套
- ⚠ v0.1 已有
metrics_recompute_log概念 — 但范围不同(v0.1 算的是 dashboard KPI,体检指标更细)
-
方案 C · 数据库 view / materialized view:把诊断指标做成 SQL view
- ⚠ XIRR / 最大回撤 等数学函数 SQL 难写
- ⚠ MySQL 不支持 materialized view,模拟成本高
- ⚠ 计算逻辑藏在 SQL 里,单元测试难
-
方案 D · 后台预计算任务 + 落库 + 增量:scheduled job 每日跑全家诊断,落库;请求时只读
- ✅ 体感最快
- ⚠ 多一个后台任务 + 一份缓存表 + 失效逻辑
- ⚠ 用户在 OPEN 期填报实时变化,预计算失效频繁
选定:方案 A · 实时计算 + 内存 cache。
理由:
- 项目数据量天然小(单家庭 / 单数字账户 / 月颗粒)
- 计算复杂度低(O(账户数 × 月数 × 规则数) ≈ 几千次乘加)
- 预计算的复杂度收益不抵消其维护成本(cache invalidation 是 CS 二大难题之一)
- v0.1 dashboard 已经验证"实时算"在本规模下毫无性能问题
- 缓存内存 5 分钟 TTL 已足够覆盖"连续点击"场景,浪费 < 1MB 堆
未选 B 的"为什么不":为了节约 50ms 引入新表 + 失效协议 + 双模式(OPEN 实时 / CLOSED 落库),复杂度暴涨,与项目"低维护负担"原则冲突。
要解决的问题:智能建议既要数据准确(数字不能有幻觉)、又要文案自然(规则模板填空过于机械)。如何在两个矛盾目标间取舍?
备选方案:
-
方案 A · 纯规则引擎 + 模板填空(原 PRD 早期方案):每条规则有固定模板
String.format- ✅ 0 数字幻觉 / 0 外部依赖 / 完全可预测
- ⚠ 文案僵硬 — 同样阈值每月输出一字不差
- ⚠ 缺少 AI 时代的产品差异化价值
-
方案 B · 纯 LLM 生成:整个 advice 的 headline / body / suggestion 都由 LLM 生成
- ✅ 文案自然
- ⚠ 数字幻觉(致命) — LLM 偶尔把 ¥73,200 说成 ¥730,200 / 编造未给的百分比
- ⚠ 不可重现 — 同输入两次输出不一致
- ⚠ 隐私 — 全家账本送第三方
- ⚠ 审计性差 — 用户问"为什么这条建议"难答
- ⚠ 本地化偏见 — LLM 训练数据偏西方理财思路,A 股/房产判断未必准
-
方案 C · 60% 规则 + 40% LLM 混合(三层架构) ★ 选定:
- 层 1 · 规则引擎:接口 + 一规则一类 + Spring 收集(继承原方案 A 优点)
- 18 条规则,每条 30-50 行 Java,@Component 自动注入
- 输出
Advice { level, category, scope, hardFacts, templateText } - 规则只算硬数字 + level + 模板填空,不调 LLM
- 层 2 · LLM 文案润色(决策 14-18 详)
- 在 prompt 里注入 hardFacts(类目 / 数字 / 风险 / 推荐金额)
- prompt 锁定:"严禁计算 / 严禁引用未给数字 / 60-80 字 / 现代投顾语调"
- DeepSeek 或 Qwen 输出 polishedText
- 层 3 · OutputValidator 双锁:
- 正则提取 LLM 输出所有数字 → 必须 ∈ hardFacts 白名单(±5% 容差)
- 禁词扫描(余额宝 / 510300 / 师傅 / 打理 等)
- 长度校验 50-100 字
- 任一不通过 → 降级 templateText + audit_log
- 关键属性:数字 100% 来自规则引擎 / LLM 只动文字 / Validator 兜底 / 失败用户无感
- 层 1 · 规则引擎:接口 + 一规则一类 + Spring 收集(继承原方案 A 优点)
-
方案 D · 配置驱动(YAML DSL):规则用 DSL 描述
- ⚠ 复杂规则(MaxDD / 净值序列)DSL 写不动
- ⚠ DSL 解析器 = 新基础设施
-
方案 E · Drools 等规则框架
- ⚠ 引入 30+MB 依赖,违反"无新依赖"原则
选定:方案 C · 三层架构(规则 + LLM + Validator)。
理由:
- 兼顾数字准确性(方案 A 的优势)+ 文案自然性(方案 B 的优势)
- LLM 不参与计算 — 数字幻觉风险被工程化锁死
- Prompt 半脱敏(决策 15)保证隐私
- 失败兜底 templateText(决策 17)保证用户无感
- 切厂商成本 < 30 行 Java(决策 14 + 18)
- 与项目"无新依赖"原则相容(只 + HTTP 调用,无 SDK 引入)
为什么不全 LLM:数字幻觉对家庭账本不可妥协(¥73k vs ¥730k 的灾难风险),且隐私 / 审计 / 本地化偏见 / 不可重现 都是硬伤。即使把所有数字喂进 prompt,LLM 仍可能润色时改数字,需要 Validator 兜底,这就退化到方案 C。
为什么不全规则:用户已明确反馈"纯模板违背 AI 建议初衷",60-80 字的文案,模板版与 LLM 版的自然度差距用户能感知到。
Rule 接口签名草图(给意向,不是最终代码):
public interface Rule {
String id(); // "LIQ-1"
AdviceCategory category(); // LIQUIDITY / RISK_ALLOC / RETURN_QUALITY / DEBT_HEALTH
AdviceScope scope(); // FAMILY / ACCOUNT
Optional<Advice> evaluate(RuleContext ctx); // null = 不命中
}
public record Advice(
String ruleId,
AdviceLevel level,
AdviceCategory category,
AdviceScope scope,
Long accountId, // null = 全家级
String headline, // "流动性过剩"
Map<String, Object> hardFacts, // {balance: 73200, monthlyExpense: 21800, ...}
String templateText, // 规则填空版兜底文本
Optional<String> polishedText // LLM 润色版,失败时 null
) {}
public record RuleContext(
Family family,
List<Account> accounts,
Account currentAccount,
Period currentPeriod,
Map<Long, AccountMetrics> metrics // 已算好的指标(避免重复算)
) {}要解决的问题:16 个类目从哪来?是写死代码,还是数据库,还是配置文件?用户能改吗?
备选方案:
-
方案 A · flyway 静态预置 + admin 微调表内字段:V11 INSERT 16 行;admin 页面只能改
benchmark_pct / display_order / display_name等字段;不允许 INSERT/DELETE- ✅ 类目"代码"(
A_STOCK等)固定 — 规则代码可以引用 - ✅ 基准 % 可微调(用户认为 A 股长期年化是 7% 而非 8%)
- ✅ 数据迁移走 flyway,版本化管理
- ⚠ 用户增加新类目要改代码 + 加迁移文件 — 但 v0.2 范围里 16 个够用
- ✅ 类目"代码"(
-
方案 B · 配置文件 + 启动时 sync:
product-categories.yml描述,启动时 upsert DB- ✅ 改类目改 yml 即可,启动后自动同步
- ⚠ DB 与配置文件之间双重事实源,管理混乱
- ⚠ admin 页面里改了基准 %,下次启动会被 yml 覆盖 — 反直觉
-
方案 C · 完全 admin CRUD:所有 CRUD 走 UI
- ✅ 用户最大灵活
- ⚠ 用户能删
A_STOCK类目 → 已用此类目的账户外键报错 - ⚠ 增 / 删 类目需要保护机制(锁定 / 软删) — 复杂度高
- ⚠ "代码引用类目"和"用户改类目"产生矛盾
-
方案 D · 代码 enum + 不入库:
enum ProductCategory { A_STOCK, ... },benchmark 写死代码- ✅ 类型安全极强
- ⚠ 改基准 % 要 deploy
- ⚠ admin 页面能力为零
选定:方案 A · flyway 预置 + admin 微调字段。
理由:
- "代码 + DB 联动"的常见架构 — 类目代码稳定(规则引擎可引用),基准 / 显示名 / 顺序可微调
- 从 v0.1 数据迁移传统延续(account_template 也是 flyway 预置 + admin 修改字段)
- 后期需要加新类目时,加 V12 迁移即可,清晰可审计
applicable_types字段允许"软扩展"(同一类目对应多个 account.type)
未选 C 的"为什么不":完全开放 CRUD 在小项目里收益小风险大 — 一旦用户删了 A_STOCK,所有用此类目的账户外键级联失败;加锁定机制反而比 A 方案更复杂。
要解决的问题:/checkup 全家页的"账户列表"每行显示健康度评分(0-100)。这个数字怎么算?哪个 service 出?
计算口径(已在 PRD FR-40a 里定义):
score = 100 - 命中规则的扣分(DANGER -25 / WARN -10 / INFO -3 / OK 不扣)
备选方案:
-
方案 A · AdviceEngine 输出副产物:engine 评估 advice 时顺手算 score,通过
Map<Long accountId, Integer score>一并返回- ✅ 单一计算路径,数字与建议永远一致
- ✅ 共享 RuleContext + metrics,零额外计算开销
- ⚠ AdviceEngine 输出包了一层,API 略复杂
-
方案 B · 独立 HealthScoreCalculator service:
HealthScoreCalculator.compute(account, advices)输入是同一组 advices,输出 score- ✅ 关注点分离 — 评分逻辑可独立改阈值(WARN -10 改 -15)
- ⚠ 必须先调 AdviceEngine 拿到 advices 才能算 score — 隐式依赖
- ⚠ 如果 advices 为空(规则未跑),score 默认 100 — 与"未评估"难区分
-
方案 C · 嵌入每个 Rule 自报扣分值:
Rule.scorePenalty()接口- ⚠ 与 advice level 重复(level 已暗含扣分倾向)
- ⚠ 规则作者要双重判断 — 易不一致
选定:方案 A · AdviceEngine 输出副产物。
理由:
- 评分本质是"建议命中情况的总结",归属 engine 最自然
- 数字与建议同源 — 用户看到"账户列表显示 78,但点进去诊断卡建议又是另一回事"的不一致不会发生
- API 设计:
AdviceEngineResult { List<Advice> advices, Map<Long, Integer> scoresByAccount } - 评分公式集中在 Engine 一处,改阈值改一处
未选 B 的"为什么不":虽然关注点分离听上去好,但"必须先有 advices 才能评分"已经是隐性耦合,把它显式化(同一个 service 输出二者)更诚实。
要解决的问题:账户级诊断卡里的"本账户 vs 沪深 300"双线对比图,基准 NAV 序列从哪来?
备选方案:
-
方案 A · 类目静态 benchmark_pct + 线性外推:
product_category.benchmark_pct = 8.0(年化 8%),按"复利月度增长"推算月度 NAV 序列- 公式:
nav(month i) = nav(0) × (1 + benchmark_pct/100)^(i/12) - ✅ 零外部依赖,完全 self-contained
- ✅ 与"年化基准"概念一致 — 用户能理解
- ⚠ 不反映基准实际波动 — 基准是平滑直线,本账户曲线穿过它
- ⚠ 不能用于 Sharpe vs 基准 这种"波动相关"对比
- 公式:
-
方案 B · 实时拉真实指数:每日 cron 拉沪深 300 / 标普 / QQQ / 中债指数,落库
- ✅ 完全真实
- ⚠ 需要外部 API(akshare / 雪球 / Tushare),稳定性 / 限流问题
- ⚠ 多基准要多 cron job + 新表
benchmark_daily_nav - ⚠ 项目"无外部依赖"原则被破坏
- ⚠ v0.2 范围之外(已在 §5 不做清单标注)
-
方案 C · 历史 NAV 快照表:启动时 / 每月手填一次基准月末值
- ⚠ 用户要手动维护多个基准的月末值 — 违反"10 分钟/月"
- ⚠ 基准缺失时段诊断无法显示
选定:方案 A · 类目静态 benchmark_pct + 线性外推。
理由:
- v0.2 时间范围内零外部依赖、零新表、零用户负担
- 家庭场景下"年化基准"是用户熟知的概念,平滑直线对应"假设按基准平稳增长" — 教学价值反而高
- 实际指数曲线带来的额外信息(波动 / 拐点)对家庭"看健康"价值有限,而对"日内择时"价值高(我们不做)
- v0.3+ 接外部时,可以平滑替换 BenchmarkComparator 实现,UI 不动
未选 B 的"为什么不":外部 API 稳定性 / 限流 / 数据源选择 / 多市场覆盖 / 时区处理 / 周末填充 都是大坑,一个家庭项目不该承担这些;v0.2 PRD §5 已经明确"不做"。
要解决的问题:/checkup 是大数据页(KPI / 多个 chart / 建议堆 / 列表),用 Thymeleaf 一次渲染?还是 SPA?还是 HTMX 局部?
备选方案:
-
方案 A · SSR Thymeleaf + 局部 HTMX 刷新(沿用 v0.1):全页面 SSR;切换账户用 HTMX
hx-get="/checkup?account=X"刷局部- ✅ 与 v0.1 完全一致,零学习成本
- ✅ 首屏 SEO / 性能好(对家庭项目意义不大,但符合简单原则)
- ✅ 状态在 URL,后退按钮自然
- ⚠ 切换账户必须刷整个 main 区(避免 chart 残留),不是真正的 SPA 流畅感
- ⚠ 大量 Chart.js 实例化在同一页,首屏 JS 较重
-
方案 B · 完全 SPA:Vue / React + JSON API
- ⚠ 与 v0.1 完全脱节,所有 controller 都要拆 API + 模板两套
- ⚠ build 工具链(Vite / webpack)— 项目"无 build 流程"原则破坏
- ⚠ 家庭项目维护成本爆炸
-
方案 C · 完全 SSR + 整页跳转:不用 HTMX,切账户就刷整页
- ✅ 极简
- ⚠ 顶部 nav / 顶栏每次重渲染 — 闪屏
- ⚠ Chart.js 每次重新加载 — 慢
选定:方案 A · SSR + HTMX 局部。
理由:
- 与 v0.1 完全延续 — 项目风格统一
- 切换账户只刷 main(
<main id="checkup-region" hx-target>),顶栏不动 → 不闪屏 - Chart.js 实例化在 main 区内,刷新时 destroy + 重建,简单
- URL query 改动 + History API + HTMX 配合,后退按钮兼容
未选 B 的"为什么不":家庭项目最重要的是 long-term 维护成本,SPA 引入构建链 / 双层 API / 状态管理 / 路由库 — 对一个 dashboard 类应用是杀鸡用牛刀。v0.1 实测 HTMX 已经覆盖 90% 交互。
要解决的问题:用户对一条建议点"✕ 这条不适用",30 天内不再显示。状态存哪?
备选方案:
-
方案 A · localStorage 客户端:浏览器存
dismissed_advice_{scope}_{idOrFamily}_{ruleId}+ 时间戳- ✅ 零后端开销 — 不加表、不加端点
- ✅ 跨设备不同步是合理的(在 A 设备 dismiss 不代表在 B 也不想看)
- ⚠ 清浏览器数据后丢失 — 用户偶尔会再次看到 dismiss 过的
- ⚠ 全家共享账号(妻子 + 自己同一 username)时,A dismiss 的 B 也不见 — 但不严重
-
方案 B · 数据库表
advice_dismissal:DB 持久化- ✅ 跨设备同步
- ⚠ 加表 + 加 endpoint + 加清理 cron(30 天后过期)
- ⚠ admin 看到的"为什么这条建议不显示"调试困难
-
方案 C · hybrid:DB 主存 + localStorage 缓存,登录时 sync
- ⚠ 双源一致性问题
- ⚠ 复杂度爆炸
选定:方案 A · localStorage 客户端。
理由:
- 家庭项目跨设备需求弱(妻子用手机 / 自己用桌面,各看各的 dismiss 不冲突)
- 30 天 TTL 客户端实现简单(
Date.now() - dismissedAt > 30*86400000) - 后端零负担,与 v0.2 已交付的 wechat / pwa dismiss 模式一致(已记忆这种实现)
- 万一 localStorage 丢失,代价是"再 dismiss 一次",非永久损失
未选 B 的"为什么不":DB 表带来的"严肃感"与"建议是软提示"的产品定位不匹配 — 建议本来就是可忽略的,客户端记忆已经够用。
要解决的问题:用户在哪些地方设置账户类目?
备选方案:
-
方案 A · 新建向导 + 编辑页 + admin 页 三处:
- 新建向导:模板预设默认,可改
- 编辑页:可改
/admin/product-categories:改类目本身的 benchmark_pct 等- ✅ 用户最自然路径覆盖
- ⚠ 三处 form 要保持一致
-
方案 B · 仅编辑页:新建时只能用模板默认,改要去编辑
- ⚠ 多一步 — 用户不方便
-
方案 C · 单独"账户类目设置"管理页:列表式批量编辑
- ⚠ 表单化 — 失去 inline 输入的便捷
- ⚠ 用户得知道有这个页面
选定:方案 A · 三处都加。
理由:
- 新建向导加类目下拉 = 用户从一开始就设对,后续不用回头改
- 编辑页改 = 临时调整入口
- admin 类目页改 = 修基准 % 的入口(罕用,但需要)
- 三处共享 form fragment(
templates/account/_category-select.html),消除重复
要解决的问题:XIRR / TWR / MaxDD / Sharpe 这些纯数学计算,放哪?诊断卡需要的"本账户近 12 月收入"放哪?
备选方案:
-
方案 A · Calculator 类(纯函数)+ DiagnoseService(组装):
Calculator接收 List + List,返回数字 — 完全纯函数,无 SpringDiagnoseService调 Mapper 拿数据,调 Calculator 算指标,组装 ViewModel- ✅ Calculator 单测最干净 — 输入 / 输出明确,无 mock
- ✅ 服务层解耦数据访问 / 计算
- ✅ 已有 v0.1
XirrCalculator / TwrCalculator都按这个模式
-
方案 B · 都在 Service 里:
DiagnoseService.computeXirr(accountId)- ⚠ 单测要 mock Mapper — 测计算变成测连接
- ⚠ 计算与数据查询耦合,改 SQL 影响计算
-
方案 C · 计算下推到 Mapper SQL:用 SQL 函数 / 窗口函数
- ⚠ XIRR(Brent 求根)、最大回撤 SQL 难写
- ⚠ MySQL 兼容性 — 移植到其它 DB 时全废
选定:方案 A · Calculator 纯函数 + Service 组装。
理由:
- 与 v0.1 现有架构延续
- Calculator 是纯计算 → 最容易写最有价值的单测
- Service 集中"取数 + 算 + 拼 ViewModel"的责任,符合 DDD application service 模式
- 数学库未来可独立抽出来 reuse
要解决的问题:STOCK/WEALTH 看投资诊断;CASH 看储蓄;LOAN 看负债;PROPERTY/OTHER 看简版。一套模板?四套?
备选方案:
-
方案 A · 多个 fragment + Thymeleaf 条件 include:
templates/checkup/_diagnose-investment.html(STOCK/WEALTH)templates/checkup/_diagnose-savings.html(CASH)templates/checkup/_diagnose-loan.html(LOAN)templates/checkup/_diagnose-simple.html(PROPERTY/OTHER)- 主模板按
account.type选 fragment include - ✅ 各类型布局完全自由,不互相干扰
- ✅ 加新类型(v0.3 加加密钱包?)只新建 fragment
- ⚠ 4 个文件管理
-
方案 B · 一个大模板 + th:if 切换:
templates/checkup/_diagnose.html一个文件,大量th:if="${account.type == 'STOCK' || ...}"- ⚠ 文件 > 500 行,可读性差
- ⚠ 每加一行 check 都要扫"我影响哪些类型"
-
方案 C · 客户端渲染:JSON API + 前端 switch
- ⚠ 项目用 SSR(决策 7),不切换
选定:方案 A · 多 fragment + 条件 include。
理由:
- v0.1 entry 页已有此模式(
_row.html+ 子 fragments) — 风格延续 - 类型彼此独立性强,fragment 化清晰
- 单 fragment ~ 80-150 行,远比共享模板 + th:if 可读
要解决的问题:全家级规则(FAM-*)和账户级规则(LIQ-* 等)用同一个 Engine?还是各一套?
备选方案:
-
方案 A · 单一引擎 + scope 字段:
Rule接口里scope() = FAMILY 或 ACCOUNT;Engine 跑两次:- 第一次:
scope == FAMILY的规则,RuleContext 不带 currentAccount - 第二次:对每个账户跑
scope == ACCOUNT的规则,RuleContext 带 currentAccount - ✅ 一套基础设施 — 接口 / 测试模式 / Spring 注入
- ✅ Rule 实现类按文件名前缀(
Fam*/Liq*)区分,人也容易找 - ✅ Advice record 含 scope 字段,前端按 scope 分组渲染
- 第一次:
-
方案 B · 两套独立 Engine:
FamilyAdviceEngine+AccountAdviceEngine- ⚠ 两套 Engine 接口几乎一样 — 维护重复
- ⚠ 共享逻辑(level 排序 / dismiss 检查)双倍代码
选定:方案 A · 单一引擎 + scope 字段。
理由:
- 规则的"基础设施" — 接口 / 排序 / 排序 / dismiss / advice record / 渲染流程 — 完全可复用
- scope 不同只是 RuleContext 的"是否含 currentAccount"差别
- 一处实现,18 条规则就地分散在
rules/目录,git diff 友好
要解决的问题:templates/fragments/nav.html 加一项「资产体检」,但 7 个页面的 controller 必须传入 nav-active="checkup" 才能正确高亮 — 影响范围?
备选方案:
-
方案 A · 改 fragments/nav 单点 + 7 个 controller 加 active:
- fragments/nav.html 加一行 link
- DashboardController / EntryController / TodoController / AccountsController / ReportsController / CheckupController(新) / AdminController 各自传
nav-active - ✅ 单点添加,激活态统一管理
- ⚠ 7 个 controller 都要扫一遍
-
方案 B · 复制 nav 多份:每个页面独立 nav 模板
- ⚠ 加 nav 项要改 7 处
- ⚠ 与 v0.1 fragments 共享原则违背
-
方案 C · controller 不传 active,前端 JS 看 location.pathname 自定:
- ⚠ JS 跑前会有"无激活态"闪烁
- ⚠ 与 SSR 思路矛盾
选定:方案 A · 单点 + 7 处 controller 加 active。
理由:
- v0.1 已经是这个模式(
active='dashboard'等),延续即可 - 单点 fragment 修改,激活态由各 controller 显式声明,可读
- 7 处改动是一次性的,加了之后维护成本回归零
要解决的问题:LLM 文案润色用哪家服务商?中国大陆服务器 + 月调用 < 200 次 + 月成本 < ¥1 + 中文文案润色为主。
备选方案(基于 2026-05-09 异步调研 · 详见 docs/llm-vendor-comparison.md):
| 维度 | DeepSeek-v4-flash | Qwen-Turbo ★ | Kimi (Moonshot) | Claude Haiku | OpenAI gpt-4o-mini | Ollama 本地 |
|---|---|---|---|---|---|---|
| 100 次调用成本 | ¥0.12 | ¥0.036 | ¥0.36-0.64 | ~$0.075 | ~$0.05 | 0 + GPU |
| 默认数据训练 | ⚠ 是,需手动关 | ✓ 否 | ✓ 否 | ✓ 否 | ⚠ | ✓ 否 |
| 国内访问稳定 | ✓ | ✓ | ✓ | ⚠ 需代理 | ⚠ 需代理 | ✓ |
| OpenAI 兼容 | ✓ | ✓ | ✓ | 部分 | ✓(原始) | 自建 |
| 中文能力 | SuperCLUE 第一 | C-Eval/CMMLU 长期前列 | 中上 | 强 | 强 | 中下 |
| 限流(基础档) | 动态 / 高峰 429 | 1200 RPM | 免费档 3 RPM 不可用 | 50 RPM | 500 RPM | 无 |
选定:Qwen-Turbo 主 + DeepSeek-v4-flash 备。
理由:
- 价格王:qwen-turbo ¥0.036/100 次,单家庭月 < ¥0.1
- 限流稳:qwen-turbo 1200 RPM 远超场景需求;DeepSeek 的"动态限流 + 高峰 429"作为主用风险大,降级为 fallback 合适
- 数据合规:Qwen 默认不用于训练,符合家庭账本"不被用作模型素材"的承诺;DeepSeek 默认会被训练,需要手动在 console 关 "Improve the model"(实施时记得关)
- OpenAI 兼容:两家协议完全相同,base_url + api_key + model 三参数即可切换
- 中文能力:对 60-80 字的短文本润色,Qwen-Turbo 远超够用;若发现质量不够,平滑切 qwen-plus(协议同 + 价格 ¥0.10/100 次)
为什么不主用 DeepSeek:虽然之前推荐过,但调研后发现 (1) 高峰限流严重影响 SLA;(2) 默认训练数据;(3) 单价 3-4× 高于 Qwen。三因素叠加,降级为 fallback 是更稳的工程取舍。
为什么不 Kimi:moonshot-v1-8k 月成本是 Qwen 的 10 倍,本场景用不到 Kimi 的长文本(60-80 字)+ 严格 JSON Schema 优势,性价比差。
为什么不 Claude / OpenAI:海外 API 在中国大陆需要稳定代理,引入运维复杂度。中文能力上 Qwen + DeepSeek 已够本场景。
配置示例(/etc/finance.env):
LLM_PRIMARY_ENDPOINT=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_PRIMARY_MODEL=qwen-turbo-latest
LLM_PRIMARY_API_KEY=sk-xxx
LLM_FALLBACK_ENDPOINT=https://api.deepseek.com
LLM_FALLBACK_MODEL=deepseek-v4-flash
LLM_FALLBACK_API_KEY=sk-yyy
LLM_TIMEOUT_MS=8000
LLM_MAX_TOKENS=200
LLM_TEMPERATURE=0.6
要解决的问题:发给 LLM 的 prompt 里,哪些字段可见、哪些不可见?平衡"文案准确"与"隐私保护"。
备选方案:
-
方案 A · 完全脱敏:只传抽象指标
{asset_type: liquid_low_risk, balance_unit: 14.2_months_of_expense}- ✅ 隐私最佳
- ⚠ LLM 缺乏具体数字 → 文案空洞("您的流动性资产较多" — 比模板还差)
-
方案 B · 半脱敏(选定):类目代码 + 风险等级 + 数字 ✓;账户名 / 主理人 / 流水备注 / 家庭名 ✗
- ✅ LLM 看到
{category: CASH_DEPOSIT, risk: 1, balance: 73200, monthlyExpense: 21800, ...}— 财务画像可见 - ✅ 身份完全匿名 — 没人能根据 prompt 反推出"这是谁的账"
- ✅ 文案能引用具体数字
- ⚠ 单数字+类目仍可能在大数据下被推断,但风险可控
- ✅ LLM 看到
-
方案 C · 明文不脱敏:传"招行储蓄卡-工资 ¥73,200"
- ⚠ 账户名 + 金额 = 身份指纹
- ⚠ 违反"家庭内部"原则
选定:方案 B · 半脱敏。
理由:
- 文案准确度 + 隐私保护 的最佳平衡点
- LLM 的"自然度"主要来自类目语境(知道是货币基金 vs A 股)+ 数字范围,不需要账户名
- 发给厂商的 prompt 即使被记录(方案 A 防御性最强,但成本是文案空洞),也无法关联到具体家庭
- 实施上一个
PromptStripper在调用前 strip 所有 PII 字段即可
实施草图:
public class PromptContext {
String ruleId;
String categoryCode; // CASH_DEPOSIT(代码,非中文名)
int riskLevel; // 1-6
Map<String, Number> hardFacts; // {balance: 73200, ...}
// 不含:accountName / ownerName / familyName / cashFlowNote
}代码 review 时强制扫描 LLM prompt 构建链路,确保不出现 account.displayName / owner.name / family.name / cashFlow.note。
要解决的问题:LLM 调用 1-3s 比首屏 50ms 慢得多。怎样让用户既快看到内容,又能享受 AI 文案?
备选方案:
-
方案 A · 同步阻塞:服务端等 LLM 返回再渲染整页
- ✅ 简单
- ⚠ 首屏 1-3s,体验崩
-
方案 B · 模板优先 + 异步 fade-replace ★ 选定:
- 服务端立即返回 templateText 渲染的页面(50ms)
- 前端 JS
DOMContentLoaded后异步 fetchPOST /checkup/advice/{id}/polish - 后端走 cache(命中 100ms)/ miss(调 LLM 1-3s + Validator + 写 cache)
- 前端拿到 polishedText 后,模板版 fade-out → AI 版 fade-in(transition 300ms)
- 用户体验:"账房在思考,变得更顺了"
- ✅ 首屏快 / cache 命中后续快 / LLM 失败用户无感(看到模板版)
-
方案 C · cache 命中即用,miss 显示模板 + 角落 spinner:
- 与 B 类似,但更显式(spinner)
- ⚠ Spinner 增加视觉噪音
选定:方案 B · 模板优先 + 异步 fade-replace。
理由:
- 首屏永远 < 50ms,与决策 2 的"实时计算"语义一致
- LLM 体验是"加分项",不阻塞主路径
- fade-replace 微动画提供"账房在思考"的产品语义,用户能感知 AI 在工作
- cache 命中率高(同样 ruleId + hardFacts hash)→ 多数情况第二次访问已经 100ms 完成
前端 JS 草图:
// 在 templates/checkup/_advice-card.html 已经渲染完模板版后
document.querySelectorAll('[data-advice-id]').forEach(card => {
const id = card.dataset.adviceId;
fetch(`/checkup/advice/${id}/polish`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d?.polishedText) {
const body = card.querySelector('.advice-body');
body.style.transition = 'opacity .3s';
body.style.opacity = '0';
setTimeout(() => {
body.textContent = d.polishedText;
body.style.opacity = '1';
card.querySelector('.ai-badge').classList.add('ai-active');
}, 300);
}
})
.catch(() => { /* 失败无感 */ });
});要解决的问题:LLM 失败有 3 种(API 超时 / 数字幻觉 / 禁词命中),用户怎么感知?
备选方案:
-
方案 A · 显示错误:"AI 不可用"
- ⚠ 用户一脸懵 — 这建议是不是没用?
-
方案 B · N 次重试再降级:重试 3 次还不行才降级
- ⚠ 增加首屏等待 — 失败的话最久 12-15s
-
方案 C · 立即降级 templateText + audit_log,用户无感 ★ 选定
- 超时:5s 没响应直接降级
- Validator 失败:返回 templateText
- 网络错误:同上
- 所有失败案例进 audit_log:
audit_log type=LLM_DEGRADED, reason, original_response, prompt_hash - 前端看到的是 templateText + ⚠模板 角标(决策 16 已展示)
- 后台分析失败案例 → 改进 prompt 模板
选定:方案 C。
理由:
- 用户体验"无感降级"是 AI 增强类功能的金标准
- audit_log 让我们能持续改进 prompt(否则失败黑盒)
- 5s 超时硬上限避免阻塞前端 JS
前端视觉:在 AI 徽章位置显示 ⚠ 角标 + tooltip "LLM 暂不可用,显示模板版"(checkup-family.html 第 3 张卡已展示这种样式)。
audit_log 字段:
-- 利用现有 audit_log 表(v0.1 已有),type 列加新枚举值
INSERT INTO audit_log (type, payload_json) VALUES (
'LLM_DEGRADED',
'{"ruleId":"LIQ-EFF-1","reason":"VALIDATOR_NUMBER_HALLUCINATION","original":"...","prompt_hash":"abc123"}'
);要解决的问题:Java app 怎么调 LLM API?
备选方案:
-
方案 A · 直接 HTTP API(OpenAI 兼容协议) ★ 选定
- Spring
RestTemplate/WebClient直接 POST 到/v1/chat/completions - DeepSeek / Qwen / Kimi / 智谱 / OpenAI 全兼容此协议
- 切厂商:改 base_url + api_key + model = 4 行配置
- 控制粒度:temperature / max_tokens / response_format / stop sequences 全自己拍
- 失败 / 重试 / cache / audit 都直接走 Java 标准
- ✅ 简单 / 透明 / 可控 / 无新依赖
- Spring
-
方案 B · 通过 ACP / MCP 调本地 claude-code / codex 进程:
- ⚠ 阻抗失配:claude-code 是人机交互工具(单 session 长会话),不是 backend gateway
- ⚠ 冷启动 1-2s + 单进程几百 MB
- ⚠ 绕一圈(finance → claude-code → Anthropic)= 多两层失败点
- ⚠ 隐私失控 — claude-code 自带 system prompt / hook / cwd / git context 可能被注入
- ⚠ CLI 无 stable backend API,升级易 break
-
方案 C · Spring AI / LangChain4j 抽象层:
- ✅ 多模型抽象 + 内置 retry / streaming
- ⚠ 引入新依赖 + 学习抽象层的 prompt API
- ⚠ 单一调用场景杀鸡牛刀
选定:方案 A · 直接 HTTP API。
理由:
- 与项目"无新依赖、HTTP 都直接 RestTemplate"风格一致
- 最透明的隐私边界(prompt 里有什么就发什么,无第三方注入)
- 切厂商最低成本,封装策略模式 30 行 Java
- 不依赖任何"重生态",10 年后仍能维护
LlmClient 接口草图:
public interface LlmClient {
String name(); // "qwen" / "deepseek"
String complete(PromptContext ctx) throws LlmException;
}
@Component
public class QwenLlmClient implements LlmClient {
@Value("${llm.primary.endpoint}") String endpoint;
@Value("${llm.primary.api-key}") String apiKey;
@Value("${llm.primary.model}") String model;
// RestTemplate POST /v1/chat/completions
}
@Component
public class LlmClientRouter {
private final QwenLlmClient primary;
private final DeepSeekLlmClient fallback;
private final CircuitBreaker breaker;
// 失败连续 N 次自动切 fallback,半小时后探测恢复
}要解决的问题:体检模块"看什么"?家庭理财体检维度有 10+ 种,v0.2 选几个?
备选方案:
-
方案 A · 全部 10 个维度:流动性 / 风险 / 配置 / 集中度 / 收益 / 储蓄率 / 现金流稳定性 / 历史趋势 / 币种 / 税务 / 负债健康 / ...
- ⚠ 信号稀释 + 假阳性 + 数据不足
-
方案 B · 4 维度核心 ★ 选定
- 流动性(Liquidity)
- 风险与配置(Risk & Allocation,融合集中度)
- 收益质量(Return Quality)
- 负债健康(Debt Health)
选定:方案 B · 4 维度核心(详见 PRD §FR-40)。
理由:
- 形成"完整诊断叙事":应急 → 风险 → 收益 → 负债,用户一眼读完
- 数据成熟度:4 维度都有 v0.1 数据基础;储蓄率/现金流需要 12+ 月历史
- 完美对应 FR-40a 4 张诊断卡 + 18 条规则
- 搁置维度多有"假阳性 / 中国场景弱 / 已被覆盖" 三类问题
每个维度的术语词汇表 / 标准模式 / 禁用词 见 PRD §FR-40c "4 个维度的 prompt 分表设计"表。
要解决的问题:阶段 3 上线后,实测发现"60% 规则 + 40% 润色"模式下 LLM 价值偏低 —— 用户能感觉到 AI 只是在"换词",体感上和纯模板差别不大。同时 OutputValidator 锁数字 100% 一致的策略经常误伤合规润色(如把 "2.1 个月" 改成 "2 个月" 被拒)。需要重新评估 LLM 在体检模块里的角色定位。
备选方案:
-
方案 A · 维持"60% 规则 + 40% 润色"(旧方向)
- LLM 看到一条 advice,只能换词,不能引入新数字、新结论
- OutputValidator 锁原文每个数字必须保留(±5% 容差)
- 用户点「✨ AI 润色」按钮才触发
- ✗ 价值有限,用户感知不到 AI 在"诊断";只是换种说法
- ✗ Validator 太严苛,LLM 合规润色经常误伤
- ✗ 跨规则推理无能力(单条 advice 看不到全局)
-
方案 B · 纯 LLM 端到端(从规则引擎也撤掉,LLM 自己看资产快照写诊断)
- 数字幻觉风险高(把 ¥73,200 写成 ¥730,200)
- 不可重现 / 审计差
- ✗ 家庭账本场景不可接受
-
方案 C · "60% 规则硬底 + 40% AI 综合诊断" ★ 选定(2026-05-10)
- 规则便签卡(第一层)继续保留,UI 永远显示
- 在便签卡组下方新增「AI 综合诊断」长文区块
- LLM 看到完整全家画像(所有命中规则 + 全家硬数字 + 各账户硬事实)
- LLM 自由综合判断:跨规则推理、跨账户建议、规则没覆盖的视角
- OutputValidator 简化:禁词 + 长度 + 不编造金额(规则给的数字必须能在输出里追溯到来源,但允许引入推理性新数字如"0.5pp"年化差)
- 进 /checkup 时自动 fetch + spinner placeholder + fade-in,不需要用户点按钮
- 失败时显示「AI 暂时不可用,以下为规则硬数据」占位,不静默隐藏
选定:方案 C · 60% 规则硬底 + 40% AI 综合诊断
理由:
- 保留规则的硬底:便签卡的数字 100% 来自工程算,审计性、可重现性、零幻觉风险全部保留
- 释放 LLM 的真实价值:综合判断 / 跨规则推理是 LLM 在这个场景里唯一能做而工程算法做不到的事;不让它做这个,就等于在工程已能做的事上找点装饰性词换
- 失败兜底坚固:AI 挂了用户仍能看到完整规则便签卡组,产品不停摆;明示降级(占位文案)而非静默隐藏,给用户透明预期
- 用户主动 vs 被动权衡:旧方向"按钮触发"(被动)用户感知不到 AI 价值;新方向"进页就看到"(主动)用户立刻知道有 AI 在帮忙诊断
- token 成本可控:经过 cache + 实际调用频次约束,家庭场景月成本 ≈ ¥0.05,年成本 < ¥1
不选 A 的核心理由:LLM 价值未被释放,产品定位上"AI 智能诊断"沦为"AI 换种说法",用户感知不到差异。 不选 B 的核心理由:数字幻觉风险 + 审计性差,家庭账本场景不可接受。
实施差异化清单(相对方案 A):
| 项 | 方案 A(旧) | 方案 C(新选定) |
|---|---|---|
| LLM 调用时机 | 用户点「✨ AI 润色」按钮触发 | 进 /checkup 自动 fetch |
| LLM 输入 | 单条 advice 的硬事实 | 全家完整快照 + 命中规则集合 |
| LLM 输出 | 60-100 字单条润色文案 | 200-500 字综合诊断长文 |
| LLM 能否引入新数字 | ✗ 不能(锁原文每数字一致) | ✓ 可以(允许推理性数字,如"0.5pp 差") |
| LLM 能否跨规则推理 | ✗ 不能(只看一条 advice) | ✓ 可以 |
| OutputValidator | 锁原文每个数字 + 禁词 + 长度 50-100 字 | 禁词 + 长度 150-600 字 + 真名扫描 + 担保性话术拦截 |
| Endpoint | POST /checkup/advice/{ruleId}/polish(per-advice) |
GET /checkup/diagnose[?account=X](per-page) |
| 失败兜底 | 静默不润色,显示模板 | 显示「AI 暂时不可用」占位 + 刷新链接 |
| 主模型 | qwen-turbo(廉价、足够润色) | qwen-plus(综合诊断需更强) |
| temperature | 0.15(保数字精确) | 0.5(允许更多发挥) |
| 单次成本 | ≈ ¥0.0003 | ≈ ¥0.0015-0.003 |
| 月调用次数 | 用户主动按需 0-2 次 | 进页 + cache 后约 5-15 次 |
| 月成本 | < ¥0.01 | ≈ ¥0.05 |
隐私边界变更(2026-05-10 修订):
| 项 | 旧方向 | 新方向 |
|---|---|---|
| 资产数字 / 占比 / 类目代号 | ✅ 上传 | ✅ 上传 |
| 规则 ID + 模板文本 | ✅ 上传 | ✅ 上传 |
| 账户名("招行储蓄卡-工资") | ❌ 不上传 | ✅ 改为上传 |
| 流水备注 | ❌ 不上传 | ✅ 改为上传 |
| 家庭名 | ❌ 不上传 | ✅ 改为上传 |
| 主理人真名 | ❌ 不上传 | ❌ 唯一脱敏项 — 映射成员A/B/C(按 member.id ASC) |
| 身份证 / 手机号 / 邮箱 / 地址 | ❌ 不上传 | ❌ 系统层面就没存这些字段,无所谓上传问题 |
为什么放宽:用户(家庭管理员)显式承诺"家庭资产数据(账户/流水/指标/家庭名)不需脱敏"。AI 综合诊断需要看到完整画像才能跨账户推理,任何不必要的人为脱敏都会削弱诊断质量。但自然人姓名是最高敏感 PII,即使其他都上传了,真名仍走稳定映射(成员A/B/C)留在本地。
真名映射技术细节:
PromptBuilder.buildContext(family):把 family.members 按id ASC排序,生成Map<Long, String>真名→代号(成员A=ASCII 65 起)PromptBuilder.maskName(text, mapping):对所有可能含真名的字段(account.owner_label / cash_flow.note / 家庭文案模板)做替换LlmDiagnoseService在 LLM 输出后做反映射(mapping反向),把"成员A"还原为真名再给前端- Validator 第一道:LLM 输出扫描原始真名,命中即拒(防御深度,理论上 LLM 看不到真名就不会写出来)
实施顺序:
- 改 PRD 隐私表 + 决策 20(本节)
- 撤回 C 阶段 commit 里旧方向遗留:PromptBuilder 的"逐字符复制" prompt + OutputValidator 的"原文数字必须保留"逻辑 + temperature 0.15 → 0.5
- 重写 PromptBuilder 为"综合诊断" system prompt + 全家硬事实组装
- 重写 OutputValidator 为禁词 + 长度 + 真名扫描 + 担保性话术
- 新建
LlmDiagnoseService.diagnoseFamily(familyId)+diagnoseAccount(familyId, accountId)per-page 接口 - controller 改为
GET /checkup/diagnose[?account=X]返回 HTMX fragment - family.html / account.html 加 AI 综合诊断区块(spinner placeholder + hx-trigger="load" + hx-swap)
- 失败降级显示「AI 暂时不可用,以下为规则硬数据」+ 刷新链接
- 单测重写 + qa-run 加 AI-DIAG case + mvn test + qa 全绿
- commit
═══════════════════════════════════════════════════════════════════
GET /checkup (全家维度)
═══════════════════════════════════════════════════════════════════
Browser → CheckupController.family(model)
│
▼
FamilyDiagnoseService.diagnose(familyId)
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
AccountMapper PeriodMapper ProductCategoryMapper
(账户列表 + 类目) (本期 + 近 12 月) (类目 / 风险 / 基准)
│ │ │
└────────────────────┼────────────────────┘
▼
Map<accountId, AccountMetrics>
(每账户的 XIRR / 余额 / 流入 / 净值序列)
│
┌───────────┼───────────┐
▼ ▼ ▼
AllocationView LiquidityView ReturnView
(4 张诊断卡的 ViewModel)
│
▼
AdviceEngine.evaluate(scope=FAMILY, ctx)
│
List<Advice>(全家级,5 条)+
Map<accountId, Integer> scores
│
▼
FamilyCheckupViewModel
│
▼
templates/checkup/family.html(SSR)
│
▼
HTML response
═══════════════════════════════════════════════════════════════════
GET /checkup?account=5 (账户维度)
═══════════════════════════════════════════════════════════════════
Browser → CheckupController.account(accountId=5, model)
│
▼
AccountDiagnoseService.diagnose(accountId)
│
account.type = STOCK
│
▼
NavSeriesBuilder + XirrCalculator + TwrCalculator
+ MaxDrawdownCalculator + SharpeCalculator + Histogram
│
▼
BenchmarkComparator(线性外推)
│
▼
AccountMetrics(7 个投资指标)
│
▼
AdviceEngine.evaluate(scope=ACCOUNT, ctx with account)
│
List<Advice>(账户级,3 条)
│
▼
AccountCheckupViewModel
│
▼
templates/checkup/account.html
└─ th:include _diagnose-investment(STOCK)
│
▼
HTML response(含 templateText 版的智能建议)
═══════════════════════════════════════════════════════════════════
LLM 文案润色异步分支(决策 16 · 模板优先 + 异步 fade-replace)
═══════════════════════════════════════════════════════════════════
Browser DOMContentLoaded → JS 遍历每个 advice 卡 → fetch /checkup/advice/{id}/polish
│
▼
AdvicePolishController.polish(adviceId)
│
▼
AdviceCache.get(hash(ruleId+hardFacts))
│
┌───────────────┼───────────────┐
▼ ▼ ▼
cache hit cache miss cache miss + LLM 失败
│ │ │
│ ▼ │
│ PromptBuilder.build() │
│ (按 dimension 选模板) │
│ │ │
│ ▼ │
│ LlmClientRouter.send() │
│ (Qwen 主 / DeepSeek 备) │
│ │ │
│ ┌────────┴────────┐ │
│ ▼ ▼ │
│ raw text 网络/超时 ─┤
│ │ │ │
│ ▼ │ │
│ OutputValidator │ │
│ · 数字白名单 │ │
│ · 禁词扫描 │ │
│ · 长度校验 │ │
│ │ │ │
│ ┌──┴──┐ │ │
│ ▼ ▼ │ │
│ 通过 失败 │ │
│ │ │ │ │
│ │ └──────────────┴──────┤
│ ▼ │
│ 写 cache TTL 24h │
│ │ │
│ │ ▼
│ │ audit_log: LLM_DEGRADED
│ │ │
│ ▼ ▼
│ {polishedText: "..."} {polishedText: null}
│ │ │
└───┴───────────────┬───────────┘
▼
返回 JSON 给浏览器
│
▼
JS fade-replace templateText → polishedText
(如果 polishedText null,保留模板版 + ⚠ 角标)
每阶段独立 PR / 独立验收。每阶段完成 → review → 进入下一阶段。
目标:V11 迁移落地,16 类目预置,所有账户回填,nav / accounts 列表 / 编辑页都能感知到"类目 + 风险"。但 /checkup 只是一个空壳页(占位 placeholder)。
实施清单(按交付时间排序,每条独立可交付):
- V10 软删字段(
cash_flow / transfer加deleted_at) - V11 product_category 表 + 16 类目 INSERT + account 加 2 列 + 已有账户回填 default category(全部在一个 flyway 文件里)
domain/category/ProductCategory.java+Mapper+Service(读)account.type → default category映射(简单 Java Map / switch)- accountController 编辑页 + 新建向导加类目下拉 fragment
- accounts 列表 + dashboard 账户行加类目 / 风险 pill(无健康度,健康度等阶段 3)
/admin/product-categories管理员只读列表/checkupplaceholder controller + view(就一句"模块开发中,阶段 X")- NavService / fragments/nav 加「资产体检」项 + 7 个页面 controller 加 nav-active
验收:
/admin/product-categories200 显示 16 行- 账户编辑页类目下拉显示,默认值正确
- 每个 account 表行 product_category_code 不为 NULL
/accounts列表每行有类目 pill + 风险 pill- 顶部 nav 第六项「资产体检」激活态在 /checkup 时点亮
目标:点账户列表里的账户行,跳 /checkup?account=X,看到对应 type 的诊断卡 + 基准对照。
实施清单:
MaxDrawdownCalculator+ 单元测试(10+ fixtures)NavSeriesBuilder+BenchmarkComparator(类目静态外推)AccountDiagnoseService.diagnose(accountId)输出 ViewModel- 4 个诊断卡 fragment(投资 / 储蓄 / 负债 / 简版)
templates/checkup/account.html主模板 + 切账户 select- CheckupController.account(accountId)
/accounts/{id}账户详情页底栏「📊 看资产体检」link 接通
验收:
- STOCK 账户
/checkup?account=X显示投资诊断(7 指标 + 净值曲线 + 月分布柱) - CASH 显示储蓄诊断(紧急缓冲 + 月入月出柱)
- LOAN 显示负债进度(进度条 + 预计还清)
- 切账户下拉刷新页面,URL query 更新,后退正常
- MaxDrawdownCalculator 单元测试 ≥ 10 case PASS
目标:/checkup 默认全家维度全套体检 + 18 条规则跑起来 + 账户列表带健康度评分 + AI 文案润色异步起效。
实施清单(规则引擎部分,与原阶段 3 同):
Rule接口 +RuleContextrecord +Advicerecord + level/category/scope enum- 18 条规则 class(每条 30-50 行)+ 各自单元测试
AdviceEngine.evaluate(scope, ctx)+ 内部 dismissed 检查HealthScoreCalculator(实际嵌在 AdviceEngine 输出里)WeightedXirrCalculator(全家加权年化)FamilyDiagnoseService.diagnose(familyId)- 4 张全家诊断卡 fragment +
/checkup主模板 - 智能建议堆叠 fragment(全家 + 账户共用,by scope 分支)
- dismiss 端点 + localStorage JS
实施清单(LLM 文案层,本阶段新增 +1.5 天):
LlmProperties+application.yml的 LLM 配置段LlmClient接口 +QwenLlmClient(主) +DeepSeekLlmClient(备)LlmClientRouter主备切换 + 简单熔断(连续失败 3 次切 fallback,30 min 后探测)DimensionPromptRegistry4 个维度专属 prompt 模板(LIQUIDITY / RISK_ALLOC / RETURN_QUALITY / DEBT_HEALTH)PromptBuilder.build(advice)注入 hardFacts 到 prompt(强制脱敏 — 决策 15)OutputValidator:数字白名单 + 禁词扫描 + 长度校验AdviceCache:Caffeine in-memory cache,key=hash(ruleId+hardFacts hash),TTL 24hLlmAdviceService.polish(advice)串起来上面 + 失败兜底返回 templateTextAdvicePolishControllerPOST /checkup/advice/{id}/polish- 前端 JS
static/js/advice-polish.js(fade-replace 微动画) - layout.html head 引入 + checkup 模板渲染 advice 卡时加
data-advice-id+.advice-body - audit_log 类型加
LLM_DEGRADED(无 schema 改 — payload_json 已弹性) - .env / Spring 配置文档
验收:
- 命中规则的账户在全家页能看到健康度数字
- 高风险账户(★★★★★ 占比 > 60%)触发 FAM-RISK-1 显示红色 banner
- DANGER / WARN / INFO / OK 排序正确
- 点 ✕ 后 30 天内本规则不显示
- AI 文案 fade-replace 在 5 个真实 advice 上验证通过
- OutputValidator 拦截 ≥ 95% 的故意构造数字幻觉(模拟测试)
- fallback 切换 在 Qwen 故意 503 时 5s 内切到 DeepSeek 不影响 UI
- dismiss prompt 不含账户名 / 主理人 / 流水备注(代码 review 强制 grep)
- 单家庭单月 LLM 调用成本 < ¥1(预算上限)
目标:账户详情页瘦身 + CSV + 软删除 + (P1)报表风险分布。
实施清单:
- FR-30 账户详情页 controller / 模板(去掉诊断 / 智能建议)
- FR-31
/accounts/{id}/ledger.csv+ LedgerExporter - FR-32 软删除 endpoint(cash-flow / transfer)+ 全 mapper 加
WHERE deleted_at IS NULL - FR-32
⋮菜单 UI + CLOSED 期 toast 拒绝 - FR-30 操作底栏「📊 看资产体检 ↗」link
- FR-38 dashboard KPI deep-link(P1)
- FR-40e 报表风险分布环形图(P1)
验收:
- 账户详情页不出现任何收益率 / 智能建议(grep 测试)
- CSV ≥ 100 行流水 + UTF-8 BOM,Excel 中文不乱
- 软删除一笔后:本账户余额不动 + 未解释金额 = 删的金额
- 全 v0.1 78 + v0.2 已交付 15 用例继续 PASS
| 风险 | 概率 | 影响 | 缓解 |
|---|---|---|---|
| 实时计算导致 /checkup 慢 | 低 | 大家庭 / 多账户首屏 > 1s | 内存 cache + JMH 微测;真出现就上方案 B 预计算 |
| 类目体系不匹配用户实际持仓 | 中 | 诊断不准 | admin 微调基准 % + 自定义 category_code 留 v0.3 |
| 规则误判(漏 / 多) | 中 | 用户对建议信任下降 | dismiss 机制兜底;UAT 阶段每条规则 case 测 |
| 健康度评分公式公议性差 | 中 | 用户怀疑数字怎么来 | 在 UI 角标显示"= 100 - 命中规则扣分(WARN -10 ...)" |
| 加权年化 XIRR 多账户合并精度 | 低 | 数字误差 ±0.5% | 单元测试 fixture 含 3-5 账户混合 case |
| 切换账户 URL query 后退栈混乱 | 低 | 后退按钮表现怪异 | 用 history.pushState 而非 replaceState;HTMX hx-push-url=true |
| Chart.js 多实例内存泄漏 | 低 | 长期 standalone PWA 累积 | HTMX 切换时 chart.destroy() |
| LLM 数字幻觉绕过 Validator | 低 | 用户看到错误数字 | 白名单 ±5% 容差 + 单元测试故意构造幻觉数据;失败案例 audit_log 持续改 prompt |
| LLM API 失败率 > 5% | 中 | 用户经常看到模板版 | 主备切换 + 熔断 + 监控告警;若主用 Qwen 不稳改 DeepSeek 主 |
| LLM 数据被用作训练 | 中 | 隐私泄漏 | DeepSeek console 关 "Improve the model" 一次性配置;Qwen 默认不训练 |
| Prompt 漏掉账户名 strip | 低 | 部分 PII 上传 LLM | PromptBuilder 单元测试断言 strip;代码 review 强制 grep account.displayName |
| LLM 月成本超预算 | 低 | 单家庭 > ¥10/月 | cache 命中率监控,> 80% 即合规;失败重试上限 3 次防止重复调用 |
按金字塔(单元 → 集成 → 端到端):
单元测试(必有):
MaxDrawdownCalculator≥ 10 fixture(单调上升 / 单调下降 / V 字 / W 字 / 复杂震荡 / 全 0 / 1 个点 / 极小波动)WeightedXirrCalculator≥ 5 fixture- 每条
Rule至少 2 case(命中 + 不命中);LIQ-1 / FAM-RISK-1 等阈值边界 case BenchmarkComparator验证起点 1.000 + 复利公式HealthScoreCalculator边界(全 OK = 100 / 全 DANGER = 100-25*N)OutputValidator≥ 8 fixture:含数字幻觉 / 禁词命中 / 长度超限 / 通过 / 边界容差(±5%)PromptBuilder≥ 4 fixture:断言不含 account.displayName / owner.name / family.name / cashFlow.noteLlmClientRouter熔断 ≥ 3 fixture:连续失败切 fallback / 超时 / 恢复探测
集成测试(MyBatis + 真 MySQL via Testcontainer):
/checkup访问 → 返回 ViewModel 不抛错/checkup?account=X各 type 走通- 软删 cash_flow 后 mapper SELECT 不出现该行
- V11 迁移 + 已有数据回填后,所有 account.product_category_code 不为 NULL
- AdvicePolishController mock LLM 返回固定文本 → 验证 cache 写入 + Validator 调用 + audit_log
端到端(curl 加进 ~/qa-run.sh):
- 沿用现有 93 用例不回归
- 新增 ≥ 25 用例(每个新 endpoint + 每张诊断卡渲染检查 + 每个 nav 激活态)
- AI 润色 endpoint mock 用例:200 + JSON
{polishedText, ...};失败时 200 +{polishedText: null}不报 5xx
| PRD FR | 本文档章节 |
|---|---|
| FR-30 账户详情瘦身 | 阶段 4 · 第 1 项 |
| FR-31 CSV | 阶段 4 · 第 2 项 |
| FR-32 软删 | 阶段 4 · 第 3-4 项 |
| FR-36 顶部 nav | 阶段 1 · 第 9 项 / 决策 13 |
| FR-37 列表 pill + 体检入口 | 阶段 1 · 第 6 项 |
| FR-38 KPI deep link | 阶段 4 · 第 6 项(P1) |
| FR-40 主框架 | 阶段 1+3 / 决策 1 / 决策 7 / 决策 19 |
| FR-40 四大维度选择 | 决策 19 |
| FR-40a 全家诊断 | 阶段 3 · 第 6-7 项 / 决策 5 |
| FR-40b 账户诊断 | 阶段 2 · 第 3-6 项 / 决策 11 |
| FR-40c 规则引擎 | 阶段 3 · 第 1-3 项 / 决策 3(规则层)/ 决策 12 |
| FR-40c AI 文案润色层 | 阶段 3 · 第 10-22 项 / 决策 3(LLM 层)/ 决策 14-18 |
| FR-40d 类目+基准 | 阶段 1 · 第 2-7 项 + 阶段 2 · 第 2 项 / 决策 4 / 决策 6 |
| FR-40e 报表风险分布 | 阶段 4 · 第 7 项(P1) |
进入阶段 1 实施之前,本文档需要 review 通过。重点确认:
- 决策 1-13 的"选定"是否对齐你的判断? 任何一条不同意,改决策再开干
- 阶段拆分是否合理? 每阶段独立可交付的边界对吗
- 数据流图是否清晰? 全家页 / 账户页的关键路径有没有缺失
- 风险清单有没有遗漏?
review 通过 → 我开始进入阶段 1 实施(写代码)。
如果某个决策希望细化(例如"决策 3 规则引擎能不能再具体一点 Rule 接口的 method 签名?"),我可以在本文档对应章节追加"实施草图",但仍是接口 / 类图级别,不是完整代码。