Skip to content

Latest commit

 

History

History
1314 lines (1023 loc) · 67.7 KB

File metadata and controls

1314 lines (1023 loc) · 67.7 KB

v0.2 技术方案 · 资产体检模块 + 账本瘦身 + 全站打通

对应 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)— 不重复阐述


0. 架构选型一览

下面是 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

1. 整体架构 · 模块拆分

┌─────────────────────────────────────────────────────────────────┐
│                     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)

2. 关键决策详解

决策 1 · 「资产体检」URL 路由模型

要解决的问题:全家维度 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 不流畅。


决策 2 · 诊断 / 智能建议计算的运行模式

要解决的问题:用户访问 /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 落库),复杂度暴涨,与项目"低维护负担"原则冲突。


决策 3 · 智能建议引擎实现 ★(60% 规则 + 40% LLM)

要解决的问题:智能建议既要数据准确(数字不能有幻觉)、又要文案自然(规则模板填空过于机械)。如何在两个矛盾目标间取舍?

备选方案:

  • 方案 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 兜底 / 失败用户无感
  • 方案 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               // 已算好的指标(避免重复算)
) {}

决策 4 · product_category 数据来源

要解决的问题: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 方案更复杂。


决策 5 · 健康度评分的归属层

要解决的问题:/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 输出二者)更诚实。


决策 6 · 基准对照的数据来源

要解决的问题:账户级诊断卡里的"本账户 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 已经明确"不做"。


决策 7 · 渲染策略

要解决的问题:/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% 交互。


决策 8 · dismiss 状态持久化

要解决的问题:用户对一条建议点"✕ 这条不适用",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 表带来的"严肃感"与"建议是软提示"的产品定位不匹配 — 建议本来就是可忽略的,客户端记忆已经够用。


决策 9 · 类目下拉的应用范围

要解决的问题:用户在哪些地方设置账户类目?

备选方案:

  • 方案 A · 新建向导 + 编辑页 + admin 页 三处:

    • 新建向导:模板预设默认,可改
    • 编辑页:可改
    • /admin/product-categories:改类目本身的 benchmark_pct 等
    • ✅ 用户最自然路径覆盖
    • ⚠ 三处 form 要保持一致
  • 方案 B · 仅编辑页:新建时只能用模板默认,改要去编辑

    • ⚠ 多一步 — 用户不方便
  • 方案 C · 单独"账户类目设置"管理页:列表式批量编辑

    • ⚠ 表单化 — 失去 inline 输入的便捷
    • ⚠ 用户得知道有这个页面

选定:方案 A · 三处都加

理由:

  • 新建向导加类目下拉 = 用户从一开始就设对,后续不用回头改
  • 编辑页改 = 临时调整入口
  • admin 类目页改 = 修基准 % 的入口(罕用,但需要)
  • 三处共享 form fragment(templates/account/_category-select.html),消除重复

决策 10 · 计算与查询的责任分离

要解决的问题:XIRR / TWR / MaxDD / Sharpe 这些纯数学计算,放哪?诊断卡需要的"本账户近 12 月收入"放哪?

备选方案:

  • 方案 A · Calculator 类(纯函数)+ DiagnoseService(组装):

    • Calculator 接收 List + List,返回数字 — 完全纯函数,无 Spring
    • DiagnoseService 调 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

决策 11 · 类型差异化诊断卡的 UI 渲染

要解决的问题: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 可读

决策 12 · 全家级 vs 账户级智能建议引擎

要解决的问题:全家级规则(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 友好

决策 13 · 顶部 nav 改造影响范围

要解决的问题: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 处改动是一次性的,加了之后维护成本回归零

决策 14 · LLM 服务商选型

要解决的问题: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

决策 15 · 数据脱敏程度(LLM prompt)

要解决的问题:发给 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 反推出"这是谁的账"
    • ✅ 文案能引用具体数字
    • ⚠ 单数字+类目仍可能在大数据下被推断,但风险可控
  • 方案 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


决策 16 · LLM 文案的渲染策略

要解决的问题:LLM 调用 1-3s 比首屏 50ms 慢得多。怎样让用户既快看到内容,又能享受 AI 文案?

备选方案:

  • 方案 A · 同步阻塞:服务端等 LLM 返回再渲染整页

    • ✅ 简单
    • ⚠ 首屏 1-3s,体验崩
  • 方案 B · 模板优先 + 异步 fade-replace ★ 选定:

    • 服务端立即返回 templateText 渲染的页面(50ms)
    • 前端 JS DOMContentLoaded 后异步 fetch POST /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(() => { /* 失败无感 */ });
});

决策 17 · LLM 失败兜底

要解决的问题: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"}'
);

决策 18 · LLM 集成方式

要解决的问题: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 标准
    • ✅ 简单 / 透明 / 可控 / 无新依赖
  • 方案 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,半小时后探测恢复
}

决策 19 · 资产体检关注维度选择

要解决的问题:体检模块"看什么"?家庭理财体检维度有 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 分表设计"表。


决策 20 · LLM 角色升级:从"文案润色"到"综合智能诊断"(2026-05-10 修订)

要解决的问题:阶段 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 看不到真名就不会写出来)

实施顺序:

  1. 改 PRD 隐私表 + 决策 20(本节)
  2. 撤回 C 阶段 commit 里旧方向遗留:PromptBuilder 的"逐字符复制" prompt + OutputValidator 的"原文数字必须保留"逻辑 + temperature 0.15 → 0.5
  3. 重写 PromptBuilder 为"综合诊断" system prompt + 全家硬事实组装
  4. 重写 OutputValidator 为禁词 + 长度 + 真名扫描 + 担保性话术
  5. 新建 LlmDiagnoseService.diagnoseFamily(familyId) + diagnoseAccount(familyId, accountId) per-page 接口
  6. controller 改为 GET /checkup/diagnose[?account=X] 返回 HTMX fragment
  7. family.html / account.html 加 AI 综合诊断区块(spinner placeholder + hx-trigger="load" + hx-swap)
  8. 失败降级显示「AI 暂时不可用,以下为规则硬数据」+ 刷新链接
  9. 单测重写 + qa-run 加 AI-DIAG case + mvn test + qa 全绿
  10. commit

3. 数据流图(关键路径)

═══════════════════════════════════════════════════════════════════
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,保留模板版 + ⚠ 角标)

4. 分阶段实施计划

每阶段独立 PR / 独立验收。每阶段完成 → review → 进入下一阶段。

阶段 1 · 数据基座 + 入口骨架(2-3 天)

目标:V11 迁移落地,16 类目预置,所有账户回填,nav / accounts 列表 / 编辑页都能感知到"类目 + 风险"。但 /checkup 只是一个空壳页(占位 placeholder)。

实施清单(按交付时间排序,每条独立可交付):

  1. V10 软删字段(cash_flow / transferdeleted_at)
  2. V11 product_category 表 + 16 类目 INSERT + account 加 2 列 + 已有账户回填 default category(全部在一个 flyway 文件里)
  3. domain/category/ProductCategory.java + Mapper + Service(读)
  4. account.type → default category 映射(简单 Java Map / switch)
  5. accountController 编辑页 + 新建向导加类目下拉 fragment
  6. accounts 列表 + dashboard 账户行加类目 / 风险 pill(无健康度,健康度等阶段 3)
  7. /admin/product-categories 管理员只读列表
  8. /checkup placeholder controller + view(就一句"模块开发中,阶段 X")
  9. NavService / fragments/nav 加「资产体检」项 + 7 个页面 controller 加 nav-active

验收:

  • /admin/product-categories 200 显示 16 行
  • 账户编辑页类目下拉显示,默认值正确
  • 每个 account 表行 product_category_code 不为 NULL
  • /accounts 列表每行有类目 pill + 风险 pill
  • 顶部 nav 第六项「资产体检」激活态在 /checkup 时点亮

阶段 2 · 账户级诊断(2-3 天)

目标:点账户列表里的账户行,跳 /checkup?account=X,看到对应 type 的诊断卡 + 基准对照。

实施清单:

  1. MaxDrawdownCalculator + 单元测试(10+ fixtures)
  2. NavSeriesBuilder + BenchmarkComparator(类目静态外推)
  3. AccountDiagnoseService.diagnose(accountId) 输出 ViewModel
  4. 4 个诊断卡 fragment(投资 / 储蓄 / 负债 / 简版)
  5. templates/checkup/account.html 主模板 + 切账户 select
  6. CheckupController.account(accountId)
  7. /accounts/{id} 账户详情页底栏「📊 看资产体检」link 接通

验收:

  • STOCK 账户 /checkup?account=X 显示投资诊断(7 指标 + 净值曲线 + 月分布柱)
  • CASH 显示储蓄诊断(紧急缓冲 + 月入月出柱)
  • LOAN 显示负债进度(进度条 + 预计还清)
  • 切账户下拉刷新页面,URL query 更新,后退正常
  • MaxDrawdownCalculator 单元测试 ≥ 10 case PASS

阶段 3 · 全家级诊断 + 智能建议 + LLM 文案层(3-4 天 · LLM 加 1.5 天)

目标:/checkup 默认全家维度全套体检 + 18 条规则跑起来 + 账户列表带健康度评分 + AI 文案润色异步起效

实施清单(规则引擎部分,与原阶段 3 同):

  1. Rule 接口 + RuleContext record + Advice record + level/category/scope enum
  2. 18 条规则 class(每条 30-50 行)+ 各自单元测试
  3. AdviceEngine.evaluate(scope, ctx) + 内部 dismissed 检查
  4. HealthScoreCalculator(实际嵌在 AdviceEngine 输出里)
  5. WeightedXirrCalculator(全家加权年化)
  6. FamilyDiagnoseService.diagnose(familyId)
  7. 4 张全家诊断卡 fragment + /checkup 主模板
  8. 智能建议堆叠 fragment(全家 + 账户共用,by scope 分支)
  9. dismiss 端点 + localStorage JS

实施清单(LLM 文案层,本阶段新增 +1.5 天):

  1. LlmProperties + application.yml 的 LLM 配置段
  2. LlmClient 接口 + QwenLlmClient(主) + DeepSeekLlmClient(备)
  3. LlmClientRouter 主备切换 + 简单熔断(连续失败 3 次切 fallback,30 min 后探测)
  4. DimensionPromptRegistry 4 个维度专属 prompt 模板(LIQUIDITY / RISK_ALLOC / RETURN_QUALITY / DEBT_HEALTH)
  5. PromptBuilder.build(advice) 注入 hardFacts 到 prompt(强制脱敏 — 决策 15)
  6. OutputValidator:数字白名单 + 禁词扫描 + 长度校验
  7. AdviceCache:Caffeine in-memory cache,key=hash(ruleId+hardFacts hash),TTL 24h
  8. LlmAdviceService.polish(advice) 串起来上面 + 失败兜底返回 templateText
  9. AdvicePolishController POST /checkup/advice/{id}/polish
  10. 前端 JS static/js/advice-polish.js(fade-replace 微动画)
  11. layout.html head 引入 + checkup 模板渲染 advice 卡时加 data-advice-id + .advice-body
  12. audit_log 类型加 LLM_DEGRADED(无 schema 改 — payload_json 已弹性)
  13. .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(预算上限)

阶段 4 · 账本侧 + P1 收尾(1-2 天)

目标:账户详情页瘦身 + CSV + 软删除 + (P1)报表风险分布。

实施清单:

  1. FR-30 账户详情页 controller / 模板(去掉诊断 / 智能建议)
  2. FR-31 /accounts/{id}/ledger.csv + LedgerExporter
  3. FR-32 软删除 endpoint(cash-flow / transfer)+ 全 mapper 加 WHERE deleted_at IS NULL
  4. FR-32 菜单 UI + CLOSED 期 toast 拒绝
  5. FR-30 操作底栏「📊 看资产体检 ↗」link
  6. FR-38 dashboard KPI deep-link(P1)
  7. FR-40e 报表风险分布环形图(P1)

验收:

  • 账户详情页不出现任何收益率 / 智能建议(grep 测试)
  • CSV ≥ 100 行流水 + UTF-8 BOM,Excel 中文不乱
  • 软删除一笔后:本账户余额不动 + 未解释金额 = 删的金额
  • 全 v0.1 78 + v0.2 已交付 15 用例继续 PASS

5. 风险与缓解

风险 概率 影响 缓解
实时计算导致 /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 次防止重复调用

6. 测试策略

按金字塔(单元 → 集成 → 端到端):

单元测试(必有):

  • 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.note
  • LlmClientRouter 熔断 ≥ 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

7. 与 PRD 的对应关系

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)

8. 实施前的 review 检查点

进入阶段 1 实施之前,本文档需要 review 通过。重点确认:

  1. 决策 1-13 的"选定"是否对齐你的判断? 任何一条不同意,改决策再开干
  2. 阶段拆分是否合理? 每阶段独立可交付的边界对吗
  3. 数据流图是否清晰? 全家页 / 账户页的关键路径有没有缺失
  4. 风险清单有没有遗漏?

review 通过 → 我开始进入阶段 1 实施(写代码)。

如果某个决策希望细化(例如"决策 3 规则引擎能不能再具体一点 Rule 接口的 method 签名?"),我可以在本文档对应章节追加"实施草图",但仍是接口 / 类图级别,不是完整代码。