Skip to content

Latest commit

 

History

History
2104 lines (1588 loc) · 122 KB

File metadata and controls

2104 lines (1588 loc) · 122 KB

家庭资产管理系统 PRD — v0.1

版本 v0.1(MVP)· 已封板
状态 🔒 2026-05-06 封板,进入研发 · 后续变更走 v0.2.md 而非修改本文件
创建时间 2026-05-06
关联技术设计 /root/financial-management/tech-design/v0.1.md
关联预览稿 /root/financial-management/preview/index.html(15 页)
早期设计草稿(已被本文件取代) /root/.claude/plans/clever-wishing-moler.md

一句话产品定义

一个让家庭成员在每个数据周期(默认每月一次、10 分钟内、异步)完成家庭资产录入,并自动算出"哪部分是工资、哪部分是投资收益"的轻量自建 Web 应用。


目录

  1. 产品概述
  2. 系统模型与核心概念
  3. 用户流程
  4. 功能需求
  5. 指标计算口径
  6. 不做的事 + 后续展望

1. 产品概述

1.1 背景

一个家庭的资产通常分散在多个渠道:股票/基金账户(若干)、银行储蓄/活期(若干)、支付宝/微信钱包/理财、可能的房产或外汇等。日常无系统记录,导致:

  • 看不到全局:无法回答"我们家现在共有多少钱"
  • 看不到趋势:更无法回答"我们家这一年是变富了还是变穷了"
  • 分不清来源:工资攒下来的钱和投资赚的钱混在一起,无从评估投资能力
  • 无法异步协作:家庭成员时间错开,缺一个共同载体来"各填各的"

市面方案的问题:支付宝/雪球只能看自家平台,国内通用记账 App 不区分本金和收益,海外开源工具(YNAB / Firefly III / Beancount)中文支持弱或学习曲线高,Excel 缺少自动化(尤其是 XIRR)。

1.2 为什么自建

仅在以下三件事上,自建相对 Excel/飞书有真实增量价值:

  1. 自动 XIRR / 年化收益率
  2. 移动端友好填报(非技术成员能 5 分钟独立完成)
  3. 多成员协作(国内记账 App 普遍弱)

其余(记数字、汇总、画图)Excel 都能做。所以 v0.1 必须把这三件事做到位,其他一切先砍。

1.3 目标

北极星指标

家庭全部成员连续 6 个数据周期完成填报,且每个成员每周期平均填报时间 ≤ 10 分钟。

低于这条线就视为产品失败 — 因为不可持续。

次级目标

  • 任意时刻能在 1 分钟内回答:"现在我们家共有多少钱?"
  • 任意时刻能在 1 分钟内回答:"过去一年我们的工资攒下了多少、投资赚/亏了多少?"
  • 任意账户的年化收益率(XIRR)随时可看
  • 数据可一键导出 CSV,不被工具绑架

1.4 用户与角色

抽象模型(不写死)

系统不内置"丈夫/妻子/孩子"等具体角色。角色只是成员的一个文本标签(可空),用于 UI 显示;系统逻辑只认"成员"这一抽象概念。

⚠️ 设计原则:任何代码、表结构、UI 不出现 husband/wife 之类的硬编码;只用通用的 member / member_id / display_name / role_label

v0.1 种子初始化

系统首次启动时,通过种子 SQL 创建一个家庭 + 初始化两个成员(默认 display_name 写"丈夫"和"妻子",仅作示例,可在 /settings/members 页面任意改名)。这一步是初始化数据,不是产品逻辑。

真实使用画像(供设计参考,不写入代码)

家庭场景里通常有一个"技术担当"和一个"生活担当",前者容忍小 bug,后者对 UI 的要求高:

  • 成员甲(假定为系统建设者本人):熟悉浏览器/手机操作,容忍偶尔小 bug,负责一部分账户
  • 成员乙(假定为非技术使用者):主要在手机上,任何"不知道这格填什么"会立即流失,负责另一部分账户

设计 UI 时优先服务"成员乙"心智:她能独立完成 → 整体产品就立得住。

1.5 v0.1 边界

In Scope

# 能力 备注
1 家庭/成员管理(配置类) 一个家庭 + 多个成员;种子默认 2 人
2 账户模板向导 + 自定义账户 精简到 13 个最常用模板;可自定义
3 6 种账户类型(含负债类 LOAN) STOCK / CASH / WEALTH / PROPERTY / LOAN / OTHER;LOAN 余额负数,自然冲抵净资产
4 周期配置(月/周可切换) v0.1 默认月度;周度作为 UI 可切换的选项
5 周期内"待办"自动生成 每周期为每位成员 + 每个账户生成一条待办
6 月末/期末余额录入(余额驱动) 用户只填新余额,系统自动轧差出本期变化,引导用户分类未解释的部分
7 贷款类账户智能预填 新周期默认填充 = 上期末余额 + 上期变化量;若配置了"默认还款来源账户",自动预填还款 transfer 草稿
8 外部现金流登记(INCOME / EXPENSE,带类别) 工资/奖金/消费/还贷/利息/其他
9 跨账户转账登记(联动式 + 独立式两种入口) 由轧差差额引导,或主动新增
10 周期自动关闭 + 指标自动重算 所有成员完成 → 周期关闭 → metrics 重算
11 Dashboard:净资产 / 总资产 / 总负债 / 趋势 / 配置 / 当期进度 首页
12 报表:账户级 XIRR / 月度收支瀑布图
13 账户筛选器 + 显示币种切换(Dashboard / Reports) 顶部 sticky · 实时重算
14 多币种(本位币可配置;v0.1 默认 CNY,支持 USD / HKD) 自动拉期末汇率
15 多成员协作(各登录、共享数据、权限相同) Spring Security
16 统一管理页 /admin(含家庭品牌名 + logo 上传/压缩) 所有可配置项的入口
17 CSV 导出 + 数据库定期备份
18 核心页面移动端响应 Dashboard / Entry / Accounts / Reports / Admin 全面适配

Out of Scope

完整 roadmap 见 § 6

1.6 成功的衡量(具体可观测)

指标 目标值 测量方式
周期填报完成率 ≥ 95%(连续 6 周期) 该周期已完成待办 / 当周期应完成待办总数
非技术成员独立填报时长 ≤ 5 分钟 系统记录该成员首次提交到最后提交的间隔
主动求助次数 ≤ 1 次/周期 主观统计
系统可用性 ≥ 99%(月度) systemd uptime + nginx 5xx 监控
数据备份成功率 100% 每周备份 cron job 结果检查
误差校验通过率 100% NetChange = NetWorth(P) − NetWorth(P-1) 恒等式不能违反

2. 系统模型与核心概念

2.1 三层结构(数据归属)

                ┌────────────────┐
                │     Family      │  顶层(租户)
                │  · base_currency │
                │  · period_type   │
                └─────┬──────┬─────┘
                      │      │
          ┌───────────┘      └───────────┐
          ▼                              ▼
    ┌────────────┐                 ┌────────────┐
    │   Member   │                 │   Account  │
    │ · username │  ◀━ 担任 ━━━━━▶ │ · name     │
    │ · display  │  primary_owner  │ · type     │
    └────────────┘                 │ · currency │
                                   └─────┬──────┘
                                         │
                  ┌──────────────────────┼──────────────────────┐
                  ▼                      ▼                      ▼
          ┌────────────┐         ┌────────────┐         ┌────────────┐
          │  Snapshot  │         │  CashFlow  │         │  Transfer  │
          │ (period 末 │         │ (本期外部   │         │ (本期内部   │
          │  余额)     │         │  收支)     │         │  转账)     │
          └────────────┘         └────────────┘         └────────────┘

关键设计决策:

  • 资产(账户)归属家庭,不归成员个人。这避免"妻子的房产"在妻子退出系统时数据归属问题。
  • 成员可通过 account.primary_owner_member_id(可空)担任某账户的"主要负责人",作用是在"我的待办"页过滤显示;不是权限,所有成员都能看到全部账户
  • 系统是单家庭多租户的,但 v0.1 只初始化 1 个家庭。表结构留好 family_id,未来无需迁移即可支持多家庭。

2.2 周期(Period)模型

周期类型

家庭级配置(family.period_type):

类型 周期长度 周期起止规则
月度 MONTHLY(默认) 1 个月 公历每月 1 日 00:00:00 ~ 月末 23:59:59
周度 WEEKLY 7 天 周一 00:00:00 ~ 周日 23:59:59

切换 period_type家庭级一次性决策,切换会触发"是否清空所有未关闭周期的待办"的二次确认(避免历史数据混乱)。v0.1 默认 MONTHLY;WEEKLY 仅作为 UI 切换选项保留,后续如果 6 个月内没人切就考虑下一版剔除。

周期标识

每个周期由 (family_id, period_type, period_start) 唯一标识。例如:

  • (1, MONTHLY, 2026-04-01) 代表 2026 年 4 月的月度周期
  • (1, WEEKLY, 2026-05-04) 代表 2026 年 5 月 4 日(周一)开始的周度周期

周期状态机

        ┌─────────┐                      ┌──────────┐
        │  OPEN   │  全员完成应填待办     │  CLOSED  │
        │ (待填)  │  ────────────────▶   │ (已关闭) │
        └─────────┘                      └──────────┘
             ▲                                 │
             │     需要补录/修正:手动重开      │
             └─────────────────────────────────┘
  • OPEN:可填写、可修改;指标实时算但 UI 标"本期数据未冻结"
  • CLOSED:进入历史,所有成员完成的瞬间自动转入;此后修改需先"重开周期"(留 audit 日志)
  • 系统当前周期默认 OPEN;过去周期只要应填待办全部完成即 CLOSED

2.3 待办(Todo)模型

每当一个新周期开始(月初 / 周一),系统自动为每个成员生成本期待办:

  • 每个未归档账户生成 1 条"快照待办":需要 member_M 填写 account_A 在周期 P 末的余额
  • 被分配给 account.primary_owner_member_id;primary_owner 为空的账户由"任意成员"完成均可
  • 现金流(工资/消费)和转账不是待办 — 视实际发生情况由用户主动登记

每条待办状态:PENDINGDONE(任意成员提交对应快照即标记 DONE)。

成员的"我的待办"列表 = 当前 OPEN 周期里 status=PENDING 且(primary_owner=该成员 或 primary_owner=空)的快照待办。

设计要点:待办只为"应该填的快照"驱动,不为现金流。理由:不漏填快照才是数据完整性的关键;现金流没填只是"分类不细",不破坏数据。

2.4 入账方式(Entry Methods)

用户对一个账户的本期数据有 4 种最终输入:

方式 触发 写入哪张表
填月末余额 在待办或 /entry 主动填 monthly_snapshot(改名 period_snapshot)
轧差自动建议(新) 余额提交后,系统计算"差额 = 新余额 − 上期末 − 已登记现金流 ± 已登记转账",若差额超过阈值,弹层引导用户分类 用户选择后写入 cash_flowtransfer
直接登记现金流 "+收入" / "+支出" 按钮 cash_flow
直接登记转账 "添加转账" 按钮(联动式或独立式) transfer

后三种共同点:都先有"余额"做基线,差额引导是 v0.1 最重要的 UX 创新。

2.5 账户模板(Account Templates)

系统内置 13 个最常用模板,覆盖主流家庭场景的 80%;其余通过"自定义账户"补足。首次进入 /accounts 时弹出"添加账户向导":

# 模板 类型 默认币种 备注
1 招商银行 CASH CNY
2 工商银行 CASH CNY
3 建设银行 CASH CNY
4 中国银行 CASH CNY
5 信用卡(通用) LOAN CNY 本质是短期贷款;具体行名在自定义名里写
6 支付宝(余额+余额宝) CASH CNY
7 微信(零钱+零钱通) CASH CNY
8 证券账户(通用) STOCK CNY 用户在自定义名里写券商,如"华泰证券-沪深"或"富途-港股"
9 蚂蚁财富 / 京东金融(基金理财) WEALTH CNY
10 银行理财 / R3 以下产品 WEALTH CNY
11 住宅(房产) PROPERTY CNY 估值由用户每期手填
12 贷款(房贷/车贷) LOAN CNY 长期负债;支持配置"默认还款来源账户"以便自动预填月供 transfer
13 自定义账户 任选 任选 完全自填,适用于加密货币、黄金、外币卡、借出款等

字段:每个模板预置 code, display_name, type, default_currency, icon(icon v0.1 可选)。

使用流程:用户挑选模板 → 自定义显示名(必填,如"招行-工资卡"区别于"招行-备用卡")→ 调整币种 → 选主要负责人(可空)→ 调整顺序 → 添加。可重复添加直至点"完成"。

为什么不内置更多券商/银行:券商账户用户通常只有 1-2 个,具体名称(华泰/东财/富途/雪球...)写在自定义名里即可;银行也类似,4 大行 + 招行覆盖最常见,其他用第 12 项"自定义"补。精简模板的代价是首次设置时多敲几个字,收益是向导列表不长、不眼花 — 与"每月 10 分钟"哲学一致(首次设置不在这个预算内,但仍要不"劝退"非技术成员)。

未来扩展:account_template 表设计为可加行,如果某模板被频繁用"自定义"绕过(运行半年后看日志),可在 v0.2 补充进模板表。

2.6 负债账户(LOAN)专项设计

LOAN(贷款类)是 v0.1 的一等公民,与 STOCK/CASH/WEALTH/PROPERTY/OTHER 并列。涉及房贷、车贷、信用卡欠款、其他借款。

数据模型差异

字段 普通账户 LOAN 账户
end_balance 存储 正数 负数(代表欠款)
UI 显示 直接显示 显示绝对值 + "欠"前缀 / 红色
计入 NetWorth +balance +balance(因为是负数,自然冲抵)
计入资产配置环形图 (单独显示在"负债"区)
默认还款来源账户 可选字段 default_payment_source_account_id,FK 到本家庭某 CASH 账户

余额变化的语义

普通账户的余额变化由"外部流入流出 + 投资损益"驱动。LOAN 账户的余额变化主要由"还款"驱动 — 这本质上是一笔从 CASH 账户到 LOAN 账户的 transfer:

还房贷 8000 元(纯本金部分),实际操作:
  招行卡余额  −¥8,000 (T_out)
  房贷余额   +¥8,000 (T_in)  ← 在 LOAN 账户上,负余额"减少"=数值变大,即 -1,000,000 → -992,000
  
模型上记一笔 transfer(from=招行, to=房贷, amount=8000)

提示:实际月供通常含本金 + 利息(例如 9500 = 本金 8000 + 利息 1500)。v0.1 简化:

  • 用户登记 transfer 8000(本金部分,影响双边余额)
  • 用户在招行卡上额外登记 EXPENSE 1500 类别"利息支出"(可选,精度更高)
  • 如果用户嫌麻烦只填一笔 transfer 9500,贷款余额减 9500,会让 LOAN 账户"超额还款"(本金不可能减这么多)— 这种情况系统会软警告

智能自动预填(LOAN 类型独有)

每当新周期开始,系统为 LOAN 账户的快照待办预填默认值:

新周期默认余额 = 上期末余额 + (上期末余额 − 上上期末余额)
              = 2 × 上期末 − 上上期末

直觉:如果上期减 8000,本期默认也减 8000。

同时,如果该 LOAN 账户配置了 default_payment_source_account_id,系统自动创建一笔 transfer 草稿:

  • from = 默认还款来源账户
  • to = 该 LOAN 账户
  • amount = |上期变化量|
  • 状态 = 草稿(待用户在填报时确认或修改)

用户操作示例

接 § 1 例:房贷 100w,上期月供本金 8000。本期填报时:

┌─────────────────────────────────────────────┐
│ 💳 房贷-招行                  (主理人:成员 A)│
│ ───────────────────────────────────────────│
│ 上期末欠款  ¥1,000,000                      │
│                                             │
│ 本期末欠款  [    ¥992,000  ]    ← 预填      │
│                                             │
│ 💡 自动预填了从【招行储蓄卡-工资】          │
│    转入 ¥8,000 的还款记录,                 │
│    余额变化和还款金额会保持一致。           │
│                                             │
│ [✏️ 调整余额] [✏️ 调整还款金额] [✓ 确认]    │
└─────────────────────────────────────────────┘

用户实际查银行 App,看到房贷剩 ¥992,200,把数字改成 992,200,系统联动把 transfer 改成 7,800(余额变化 = 还款金额,保持一致)。提交 → 同时写入:

  • period_snapshot(房贷, 本期, -992200)
  • transfer(from=招行卡, to=房贷, amount=7800, period=本期)

边界与异常

场景 处理
首次填该 LOAN(没有上期数据) 不预填,正常引导分类
没配 default_payment_source_account_id 不预填 transfer,只预填余额(基于上期变化)
用户改余额但忘改 transfer(不一致) 提交时弹软警告:"余额变化 ¥7,800 与还款金额 ¥8,000 不一致,差额 ¥200 将归投资损益(可能是利息或对账误差)。继续?"
提前还款(变化量 ≫ 上期) 软警告:"本期变化超过上期 N 倍,可能是提前还款?"
信用卡场景(欠款增加 = 消费) 余额变化方向相反,引导从 transfer 改为 EXPENSE 分类(还在轧差引导框里)

3. 用户流程

3.1 周期总览(以月度为例)

        周期 P 开始(M 月 1 日 00:00)
                │
                ▼
        ┌─────────────────────┐
        │ 系统拉取 P-1 期末汇率 │
        │ 系统生成本期所有成员    │
        │ 的快照待办(N 条)     │
        └─────────────────────┘
                │
                ▼
        ┌─────────────────────────────────────────┐
        │  M 月 1 日 9:00:首轮提醒                │
        │   · Dashboard 顶部 banner(v0.1 唯一渠道)│
        └─────────────────────────────────────────┘
                │
        ┌───────┴───────┐
        │               │
        ▼               ▼
   ┌─────────┐    ┌─────────┐
   │ 成员 A   │    │ 成员 B   │
   │ 异步填报 │    │ 异步填报 │  ← 用户也可不等提醒主动来填
   └─────────┘    └─────────┘
        │               │
        └───────┬───────┘
                ▼
        ┌─────────────────────────────┐
        │ 系统检测到所有快照待办 = DONE │
        │ → 周期 P-1 自动 CLOSED         │
        │ → 触发指标重算                │
        │ → Dashboard 显示完整图表      │
        └─────────────────────────────┘

关键约束:不要求成员同时在线;系统状态对全体成员实时同步。

3.2 时间锚点(月度模式)

时间 触发事件
每月 1 日 凌晨 2:00 拉取上月末汇率(USD→CNY、HKD→CNY);生成新周期所有快照待办
每月 1 日 9:00 首轮提醒(仅站内 banner,登录后可见)
每月 5 日 9:00 第二轮提醒(若仍有 PENDING 待办)
每月 10 日 9:00 第三轮提醒 + UI 标"⚠️ 数据迟报"
每月 15 日 + 视为迟填,XIRR 时间精度可能受影响

周度模式的锚点:周一 8:00 生成 + 首轮提醒,周三 8:00 二轮,周日 8:00 三轮。所有 cron 表达式可在 application.yml 配置。

3.3 首次使用流程(初始化 + 账户模板向导)

整个家庭只走一次。

  1. 登录(种子的两个账号之一):username 和初始密码由系统部署时插入到 SQL
  2. 落地页:家庭设置向导
    • 第 1 步:确认家庭名称(默认"我们家")、本位币(默认 CNY)、周期类型(默认 MONTHLY)
    • 第 2 步:确认/编辑成员列表 — 系统已种子 2 人,这里可以改名、调整 role_label
    • 第 3 步:添加账户(账户模板向导)
      • 用户挑选模板 → 自定义名称 → 选币种 → 选主要负责人 → 添加
      • 可重复添加,直到点"完成"
    • 第 4 步:落地 Dashboard(空数据 + 引导文案"去填本期数据")
  3. 成员 B 首次登录看到的是:已有的家庭、自己的待办列表;无需再走向导

3.4 成员填报流程(余额驱动 + 自动轧差)

成员 A、成员 B 流程一致,差异仅在于 UI 默认过滤"我的待办"。

入口

  • 收到提醒 → 点链接 → 落地 /entry?period=2026-04,默认 mine=true(只看自己负责的待办)
  • 或主动访问 → Dashboard → 点"本期还有 X 个账户未填" → 同样落地

单账户填报(以"招行储蓄卡-工资"为例)

┌─────────────────────────────────────────────┐
│ 📒 招行储蓄卡-工资   (主理人:成员 A)         │
│ ───────────────────────────────────────────│
│ 上期末余额  ¥45,000                         │
│                                             │
│ 本期末余额  [           ¥                ] │
│                                             │
│ ┌─────────────── 提交后展开 ─────────────┐  │
│ │ 检测到本期变化 +¥13,200                │  │
│ │ 已登记的现金流/转账可解释:¥0           │  │
│ │ ⚠️ 还有 ¥13,200 未分类                  │  │
│ │                                         │  │
│ │ 请告诉我这是什么:                       │  │
│ │  [+ 工资性收入] [+ 其他收入]            │  │
│ │  [- 消费/支出]                          │  │
│ │  [↔ 来自其他账户的转账]                 │  │
│ │  [📈 投资收益(默认)]                    │  │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘

流程

  1. 用户填新余额(从招行 App 直接看到 ¥58,200,粘进来)
  2. 系统自动轧差:Δ = 58200 − 45000 = +13200
  3. 减去已登记的解释(初次填:0)
  4. 弹引导:未解释金额 ¥13,200,提供 4 个分类按钮
  5. 用户点"+ 工资性收入" → 弹快速输入(金额、备注),保存为 cash_flow(INCOME, 工资)
  6. 系统再次轧差,显示未解释金额 ¥0,本行变 ✓ 绿色
  7. 如果用户跳过分类直接关掉:差额默认归"投资收益",标软提示"未分类金额已自动归为投资收益,如有误请回来重选"

"我已经记账完毕"按钮

待办全部 DONE 后,系统页面顶部出现"提交本期"按钮 — 这是显式的"我做完了"信号,即使有些待办默认就是 DONE 状态(没新增需要填)。点击后该成员"本期完成"标记 = true。

当所有成员都标记完成

系统执行:

  1. 锁定本期所有数据(进入 CLOSED 状态)
  2. 后台 MetricsRecomputeJob 触发,重新计算所有 PnL / NetWorth / Allocation / XIRR
  3. 下次任一成员登录时,Dashboard 横幅显示"本期已完成 ✅,过去 X 月平均年化 Y%"

3.5 具体场景一:登记本月薪资

场景:成员 A 月薪 ¥30,000 发到"招行工资卡",还有一笔季度奖金 ¥10,000 也发到这张卡。

操作:

  • 进入 /entry,招行工资卡行
  • 填新余额(打开招行 App 抄过来,假设 ¥75,000)
  • 系统轧差 +¥30,000(假设上期末 ¥45,000,本期一切其他动作均为 0)— 等等,实际工资 + 奖金 = 40,000,差额可能还有消费等
  • 假设用户开了招行 App 一看:本月入账 ¥40,000(工资 + 奖金),消费 ¥10,000
  • 用户分两次点击引导:
    1. "+ 工资性收入" ¥30,000 类别"工资"
    2. "+ 工资性收入" ¥10,000 类别"奖金"(系统支持同账户同期多笔 cash_flow)
    3. "− 消费/支出" ¥10,000 类别"消费"
  • 轧差器实时更新:差额从 +30,000 → 0 → -10,000 → +0,本行 ✓

或者更省事的做法:用户嫌麻烦,直接只点了"+ 工资性收入"输入 ¥40,000 + 备注"工资+奖金合并",剩余 ¥10,000 自动归投资损益。这种省事代价是:瀑布图把 ¥10,000 算进了"投资收益",年化 XIRR 偏高。

v0.1 接受这种妥协,用户自己掌握精度。

3.6 具体场景二:更新某个余额(不变 / 增加 / 减少)

情况 A · 余额没变(纯现金账户睡了一个月)

  • 填新余额 = 上期末
  • 轧差 = 0
  • 不需要任何分类,直接 ✓
  • 这正是"10 分钟"约束的支持 — 大量纯现金账户每月只需粘一个数

情况 B · 余额增加但用户记不清原因

  • 例:支付宝余额从 ¥3,000 → ¥5,500,用户不记得这 ¥2,500 是哪笔
  • 选项 1:点"+ 其他收入"备注"不记得",标记后系统不算它进 XIRR 干扰
  • 选项 2:跳过,默认归投资收益(理财账户合理;储蓄账户会让它"看起来很赚")
  • UI 在储蓄/活期类账户上,如果差额被默认归投资收益,会有黄色警告"本账户类型为现金,异常的'投资收益' ¥2,500 通常说明有未登记的收入,确认?"

情况 C · 余额减少

  • 同上,系统提示分类:"− 消费/支出" 还是"↔ 转出到其他账户"
  • 如果用户把它归"消费",符合家庭维度的资金流出
  • 如果归"转账",需要选"转入到哪个账户"

情况 D · 月中突然想看一下当前总资产

  • v0.1 不支持月中实时余额冻结
  • /entry?period=2026-05 可以提前填(本月还在 OPEN),Dashboard 显示但标"本期数据未冻结"
  • 月底回来再次填 → 覆盖确认(显示"上次填过 ¥X,XXX,确认覆盖?")

情况 E · 发现历史月份填错了

  • 打开 /entry?period=2026-03(选历史已 CLOSED 月)
  • 系统提示:"周期 2026-03 已关闭,修改将重新打开 + 重算之后所有指标。继续?"
  • 修改 + 提交 → 重新触发 MetricsRecomputeJob,影响范围标黄

3.7 具体场景三:房贷月供登记(LOAN 类型完整流程)

场景:房贷余额上期末 ¥1,000,000,本期月供 ¥9,500,其中本金部分约 ¥8,000(银行账单显示房贷余额变成 ¥992,000),利息部分 ¥1,500。

前提:已经在 /admin/account-templates 添加房贷账户时,把"默认还款来源"配为"招行储蓄卡-工资"。

操作:

  1. 进入新周期填报页 /entry
  2. 房贷账户那一行,系统已经自动预填:
    • 本期末余额:¥992,000(基于上期减 8000 的规律)
    • 还款 transfer 草稿:招行 → 房贷 ¥8,000
  3. 用户打开招行 App / 银行账单查实际数字:本期房贷剩 ¥992,000(刚好对上)→ 直接确认 ✓
  4. (可选)切到招行卡那一行,登记 EXPENSE ¥1,500 类别"利息支出"。如果省略,这 ¥1,500 在招行卡的轧差差额里会被引导分类
  5. 招行卡的余额从 ¥45,000 → 假设 ¥35,500
    • 系统轧差:Δ = -9500,已登记 transfer = -8000,EXPENSE = -1500 → 未解释 = 0 ✓

如果懒省事,只填房贷余额不登记利息:

  • 房贷 transfer = 8,000(自动 ✓)
  • 招行卡轧差 −9,500,扣除 transfer 8,000 后未解释 −¥1,500
  • 用户跳过分类 → ¥1,500 自动归"投资损益"
  • 代价:招行卡的"投资损益"看起来负 ¥1,500(实际是利息支出),家庭维度瀑布图把利息混在投资里

v0.1 接受这种妥协,精度由用户掌握。 想精确就花 30 秒登记利息;想快就跳过。

3.8 具体场景四:跨账户转账(资产转移)

路径 A · 联动式(由轧差引导,推荐)

用户行为驱动:你正在更新某账户余额,系统发现差额像转账。

举例:股票账户 A 卖出 ¥10,000 提现到招行储蓄卡。

  1. 用户先更新"股票账户 A"余额(假设从 ¥80,000 → ¥70,000)
    • 系统轧差 −¥10,000
    • 弹引导:未解释金额 −¥10,000
    • 用户点"↔ 转出到其他账户"
    • 弹层选目标账户"招行储蓄卡-工资",金额预填 ¥10,000,可改备注"季度调仓提现"
    • 提交 → 写入 transfer(from=股票A, to=招行, amount=10000) → 股票 A 行的轧差差额归零 ✓
  2. 用户接着更新"招行储蓄卡-工资"余额(假设从 ¥45,000 → ¥55,000)
    • 系统轧差 +¥10,000
    • 系统已识别这笔转账(同期、目标=招行的 transfer 已存在)→ 自动用它解释差额
    • 弹引导:未解释金额 ¥0(¥10,000 已由转账解释),本行直接 ✓

路径 B · 行级独立式(主动登记)

用户行为驱动:你直接想登记一笔从某账户出发的转账,不管余额还没填。

  • 每个账户行右侧的"↔"快速图标按钮,出现在所有展示账户列表的页面:
    • Dashboard 底部账户列表
    • Accounts 桌面表格(操作列)
    • Accounts 移动端卡片(底部按钮区)
    • Entry 填报行(已存在,见 § 3.4)
  • 点击 → 弹层:源账户预填为本行,选目标账户、金额、备注
  • 提交 → 写入 transfer 表;之后填两端余额时,轧差器会用它解释差额
  • 不在顶栏放"+ 转账"全局按钮 — 行级触发已能覆盖所有场景且更精确(源账户自动确定)

重复登记保护

同一对账户、同金额、同周期、间隔 < 24 小时的 transfer 二次登记 → 触发 modal:"看起来像重复,确认要再登记一笔吗?"

跨币种转账

例:招行美元活期 → 招行人民币活期(换汇)。

  • v0.1 简化:只登记一笔 transfer(以源账户币种 + 金额为准)
  • 因汇率引发的差异自动归"投资损益"
  • v0.2 计划单独识别"汇率损益"

非转账的资金流出(消费、还贷、给亲属)

→ 走 cash_flow(EXPENSE),不要错记成 transfer。 房贷月供属于"钱离开家庭"(因为变成了房子的产权,而房子作为 PROPERTY 类账户单独估值)→ EXPENSE 而非 transfer。

3.9 周期关闭与自动重算

自动关闭条件

  • 当周期所有 PENDING 快照待办都变成 DONE,且
  • 所有成员都点了"提交本期"按钮(显式信号),

→ 系统自动把周期从 OPEN 切到 CLOSED。

自动重算

  • 周期关闭瞬间,触发 MetricsRecomputeJob(异步,Spring @Async)
  • 重算范围:该周期及之后所有 PnL / NetWorth / Allocation / XIRR(因为修改可能向后传递)
  • 用户视角:Dashboard 加载时实时计算最新指标,所以"重算"实际上不影响读视图,主要为了:
    • 写入审计日志(便于排查异常)
    • 写入站内"月度小结"卡片(下次登录可见)

手动重开

  • 路径:已 CLOSED 周期 → /admin/periods → 选择周期 → "重新打开"按钮
  • 二次确认:"重开周期 2026-03 将允许修改其数据,所有之后的指标会重算。继续?"
  • 重开后状态回到 OPEN,审计日志记录谁、何时、为什么(必填备注)
  • 修改完后所有成员重新点"提交本期"再次关闭

计算的实际触发时机

场景 是否触发重算
用户填一个新余额 否(读视图实时算)
周期从 OPEN → CLOSED 是(MetricsRecomputeJob)
周期从 CLOSED → OPEN(修改) 否(立即生效,因为读视图实时算)
修改导致再次自动关闭 是(再次重算)
用户手动点"重新计算" 是(应急按钮,正常用不到)

设计意图:指标计算永远是从原始 4 张表实时算出来的,不依赖缓存。MetricsRecomputeJob 只为了写日志 / 触发通知,不是为了存中间值。这避免了"缓存不一致"类 bug。

3.10 提醒与缺漏处理

  • 站内 banner(必有):Dashboard 顶部,红色,"本期还有 X 个账户未填:招行储蓄卡、华泰证券",点击直跳 /entry
  • v0.1 不做邮件 / 短信 / 微信推送;只靠登录后的 banner 提示。后续若需要再上(详见 § 6.3 路线图 v0.2)
  • 缺漏的影响:
    • NetWorth(P):该期跳过缺失账户,UI 标"⚠️ 数据不完整"
    • PnL_total(P):同上
    • 该账户的 XIRR:数据点 < 3 → 显示"—",数据缺失 > 50% → 显示"—"+ ⚠️

3.11 异常场景

场景 处理
成员忘记密码 系统管理员(任一成员)在 /admin/members 触发"重置密码",生成临时密码,屏幕上显示一次 + 当面/微信告诉对方;v0.1 不发邮件
服务器挂了 systemd Restart=on-failure;每周备份
汇率 API 挂了 当期汇率字段空,UI 标"⚠️ 本期汇率缺失",/admin/fx 手填
同账户同期填报两次 后填覆盖前填,modal 二次确认
成员误删账户 v0.1 不允许"删除",只能"归档";归档可一键恢复
余额填 0 弹提示:"该账户本期余额为 0,确认账户已清空或销户?"
成员退出家庭(v0.1 不支持) v0.1 不暴露此操作;v0.3 之后再说
切换 period_type(月→周) 不允许在已有 OPEN 周期时切换,必须先关闭当前周期

4. 功能需求

每条:ID · 标题 · 优先级 · 描述 · 验收标准。优先级 P0 = MVP 必备,P1 = MVP 包含但可降级。

FR-1 · 家庭与成员管理(配置类) · P0

描述:一个家庭、多个成员的两层结构。系统不内置任何角色枚举;role_label 仅是文本标签。家庭可自定义品牌名(显示在每页左上角)和 logo 图片。

验收标准:

  • family 表,字段:id, name, base_currency, period_type, brand_text VARCHAR(60) DEFAULT '账房', logo_path VARCHAR(255) NULL, logo_preset VARCHAR(16) NOT NULL DEFAULT 'icon2', created_at
  • member 表,字段:id, family_id, username UNIQUE, password_hash, display_name, role_label NULL, archived_at NULL, created_at
  • v0.1 只有 1 个 family;数据库种子插入 1 个 family + 2 个 member,display_name 默认"丈夫"和"妻子",可在 /admin/members 改;brand_text 默认"账房",可改
  • 任意成员均可在 /admin/family 编辑家庭名、品牌名、上传 logo
  • 任意成员均可在 /admin/members 编辑成员显示名、角色标签
  • /admin/members 可加成员(预留 v0.3 三人以上,v0.1 UI 隐藏"添加"按钮)
  • 不允许删除成员,只允许归档

Logo 上传规则(v0.1 简化版 · 前端压缩)

  • 前端处理:用户在 /admin/family 选文件 → 浏览器 <canvas> 等比缩放到最长边 256px → canvas.toBlob('image/webp', 0.82) 编码 → 直接 POST 给后端
  • 后端处理:只做 3 件事:
    1. 校验 Content-Type 是 image/webp + 文件头前 4 字节是 RIFF + size < 50 KB
    2. 写到 /var/finance/uploads/family-{family_id}/logo.webp
    3. 数据库存相对路径 family-{family_id}/logo.webp
  • 服务:Spring ResourceHandler/uploads/** 映射到 /var/finance/uploads/,响应带 1 年 cache
  • 删除:管理页"移除 logo"按钮 → 物理文件删除 + DB 置 NULL → header 落回 brand_text 纯文本
  • 不做的事(v0.1):服务端 ImageIO 解码、SVG 支持、深度安全(MIME 嗅探/解压炸弹防护) — 因为前端已限定为 WebP 输出,体积也已小到 < 50KB,大幅降低后端处理风险

4 套预设图标(2026-05-10 v0.2 升级 · 默认 icon2)

  • 资产:src/main/resources/static/img/presets/icon-{1..4}-{96,180,192,512}.png,共 16 张 PNG。源图 1254×1254 由 tools/PresetIconGen.java + bash deploy/gen-presets.sh 一次性缩放生成
  • DB 字段:family.logo_preset VARCHAR(16) NOT NULL DEFAULT 'icon2',迁移见 db/migration/V12__family_logo_preset.sql
  • /admin/family UI:在原"自定义网页 logo"区上方叠一排 4 缩略图(含 hover/active 状态),点击 = POST /admin/family/logo/preset 切换
  • 优先级:
    • 预设赢一切统一:点击预设按钮 = UPDATE logo_preset=#, logo_path=NULL,确保 web favicon + iOS apple-touch-icon + PWA manifest 全用同一张
    • 上传 WebP = UPDATE logo_path=#,preset 不动(web 头部 / favicon 用 webp,iOS / manifest 仍用 preset — WebP 不适合 iOS apple-touch-icon)
  • manifest 动态化:删 static/manifest.webmanifest,改用 ManifestController @GetMapping("/manifest.webmanifest") 按当前会话 family.logoPreset 拼 icon URL;未登录回退 icon2
  • iOS 缓存说明:apple-touch-icon 一旦添加到主屏被 iOS OS 级缓存,后端切换 preset 后需用户手动删除主屏图标重新添加才能看到新图(无解,文档说明)
  • 回归底线:9 条自动化用例 v02-LOGO-1~10(16 PNG 全 200 + manifest 动态 + dashboard favicon + apple-touch + nav logo + admin gallery + 切预设全链路 + 自定义并存 + 切预设清 path + 非法 preset 校验)

FR-2 · 账户模板向导 · P0

描述:首次进入 /accounts 时弹出向导,提供内置 12 个最常用账户模板(详见 § 2.5);支持自定义补足。

验收标准:

  • 模板列表内置在 account_template 表(部署时通过初始化 SQL 文件 V2__seed_account_templates.sql 灌入)
  • 模板字段:code, display_name, type, default_currency, icon
  • 向导列表展示 12 项,带类型彩色 chip;末项"自定义账户"永远在最后
  • 用户挑选后:自定义名称(必填)、币种(默认值,可改)、主要负责人(可空,下拉选成员)、显示顺序
  • 一次可添加多个,直至点"完成"

FR-3 · 账户管理 · P0

描述:增、改、归档账户;不可删除。账户类型枚举 6 种:STOCK / CASH / WEALTH / PROPERTY / LOAN / OTHER

验收标准:

  • /accounts 列出本家庭全部未归档账户,按 display_order 排序
  • 编辑:名称、类型、币种、主要负责人(可改可清空)
  • LOAN 类型独有字段:default_payment_source_account_id(可空,FK 到本家庭某非 LOAN 账户),用于自动预填还款 transfer
  • 归档(archived_at = NOW()):不再出现在新周期待办里;历史指标仍含
  • 归档可一键恢复(archived_at = NULL)
  • LOAN 账户在 UI 上区别显示:余额带"欠"标签 / 红色,按"负债"分组

FR-4 · 周期配置 · P0

描述:家庭级 period_type 配置项,默认 MONTHLY;支持切换 WEEKLY。

验收标准:

  • /settings/family 显示当前周期类型 + 切换按钮
  • 切换前检查:存在 OPEN 周期时阻塞,提示先关闭
  • 切换后:新周期按新类型生成

FR-5 · 周期与待办自动生成 · P0

描述:周期开始时(月 1 日 / 周一)自动建立 period 记录 + 为每个未归档账户生成快照待办。

验收标准:

  • period 表:id, family_id, period_type, period_start DATE, period_end DATE, status ENUM('OPEN','CLOSED'), closed_at NULL, UNIQUE(family_id, period_type, period_start)
  • snapshot_todo 表:id, period_id, account_id, assigned_member_id NULL, status ENUM('PENDING','DONE'), done_at NULL, done_by_member_id NULL
  • Spring @Scheduled 每天 0:30 跑 PeriodOpener 检查:今天是周期起始日 → 创建周期 + 待办
  • 每个 account 在新周期生成 1 条 snapshot_todo,assigned_member_id = account.primary_owner_member_id

FR-6 · 我的待办与全员视图 · P0

描述:每个成员有"我的待办"页;也支持看全家进度。

验收标准:

  • /my-todos:按周期分组,展示该成员当期 PENDING 待办列表;点击直跳 /entry
  • /entry?period=YYYY-MM&mine=true:列表只展示分配给当前成员或 unassigned 的账户
  • /entry?period=YYYY-MM&mine=false:展示全家所有账户(默认折叠他人的)
  • Dashboard 顶部:"本期进度 · 成员 A:3/4 · 成员 B:1/2"

FR-7 · 月末/期末余额录入(余额驱动) · P0

描述:用户输入新余额,系统轧差出本期变化,引导分类未解释金额。LOAN 类型有专属预填逻辑(详见 § 2.6)。

验收标准:

  • 输入新余额后,系统计算 Δ = 新余额 − 上期末余额(若上期无数据,Δ 视为初始投入,不引导分类)
  • 已登记现金流和转账参与抵消:未解释金额 = Δ − 已登记 cash_flow 净额 ± 已登记 transfer 净额
  • 引导按钮:+ 工资性收入 + 其他收入 - 消费/支出 ↔ 转账 📈 投资收益
  • 用户分类后立即写入对应表;未解释金额实时刷新
  • 未解释金额 = 0 时,本行变 ✓
  • 用户跳过分类:差额默认归投资收益,但 UI 留软提示;CASH / LOAN 类型额外标黄"该类型账户出现意外变化通常意味着漏登记了收入/支出/还款"
  • LOAN 专属:新周期生成待办时,系统按"上期变化量"预填新余额(prev + Δ_prev);若该账户配置了 default_payment_source_account_id,同时预填一笔 transfer 草稿;用户调整余额时 transfer 金额联动调整以保持一致
  • 快速转账按钮:每个账户行(桌面表格 + 移动卡片)右侧均带"↔ 快速转账"图标按钮,点击直接弹出转账登记弹层,源账户预填为本行,无需先展开本行的轧差面板
  • 输入框 UX(2026-05-10):本期余额 input 加 onfocus="this.select()"(手机点击直接覆写,无需先全选删除);右侧追加 ✕ 一键清空 SVG 按钮,提升手机录入效率
  • 输入框对齐(2026-05-10 v2):本期余额 / 备注共用 h-9 统一高度,各自带独立 eyebrow 小标(本期余额 / 备注),保证 items-end 对齐时两个 input 底边 + 顶 eyebrow 完全平齐;「参考 · 上期末」从 label 内迁出,作为 form 内独立 caption 行,避免拉低 余额 label 的底边导致与备注错位

FR-8 · 现金流登记 · P0

描述:每账户每期可有 0~多笔 INCOME / EXPENSE。

验收标准:

  • 类别枚举:工资 / 奖金 / 其他收入 / 消费 / 还贷 / 转账给亲属 / 其他支出
  • 类别可在 cash_flow_category 配置表里加(由开发改 SQL,UI 不暴露)
  • 同账户同期可有多笔(例:工资 + 奖金分两笔)

FR-9 · 跨账户转账 · P0

描述:两条登记路径:轧差弹层联动登记、行级独立按钮(源账户预填)。详见 § 3.7。

验收标准:

  • 每个账户行右侧的"↔"图标按钮(行级独立路径,出现在 Dashboard / Accounts / Entry 桌面表格 + Accounts 移动卡片;源账户自动预填为本行)
  • 轧差弹层中"↔ 转账"按钮(联动路径)
  • 同(from, to, amount, period)且 < 24 小时的二次提交 → modal 二次确认
  • 跨币种 v0.1 简化:仅记 from 端币种和金额,差异自动入投资损益

FR-10 · 智能转账推断提示 · P1

描述:轧差出明显大额异常时(超过阈值),主动提示"看起来像转账"。

验收标准:

  • 阈值:|Δ未解释| > ¥3,000(可配置)
  • 提示样式:在引导按钮下方多一行"💡 看起来像账户间转账?"
  • 不阻塞流程

FR-11 · 周期自动关闭 + 重算触发 · P0

描述:全员完成 → 周期 CLOSED → 触发 MetricsRecomputeJob

验收标准:

  • "提交本期"按钮:成员的 period_member_completion(period_id, member_id, completed_at) 写入
  • 检测点:每次提交时检查 所有 snapshot_todo.status = DONE所有未归档成员都有 period_member_completion
  • 满足 → period.status = CLOSED, closed_at = NOW
  • 异步触发 MetricsRecomputeJob(Spring @Async),写 metrics_recompute_log
  • 失败重试 3 次,仍失败写 audit_log,Dashboard 顶部红色 banner 警示

FR-12 · 周期重开 · P0

描述:已关闭周期可手动重开以做修正。

验收标准:

  • /admin/periods 列出所有周期 + 状态
  • CLOSED 周期点"重新打开",必填重开理由,写入 period_reopen_log(period_id, reopened_by, reopened_at, reason)
  • 重开后:所有 period_member_completion 清除,等所有成员再次提交才能再次关闭

FR-13 · Dashboard · P0

描述:登录后落地。信息层次按业界标准的 4 层布局(KPI 卡 → 主图 → 中部对比 → 底部明细)。基于调研对 Maybe Finance / Monarch / Empower / Ghostfolio / 招行资产时光机的对照,选了对家庭低频记账场景最有用的指标。

所有数字、所有图表、所有列表都受 § FR-21 账户筛选器实时控制(默认包含家庭下所有账户)。

信息层次:

┌────────────────────────────────────────────────────────────┐
│  顶部 KPI 卡区(5 张,带同比/环比小箭头)                  │
│  [净资产]  [总资产]  [总负债]  [紧急储备月数]  [负债率]    │
├────────────────────────────────────────────────────────────┤
│  主图区:净资产趋势(默认 12 期,Tab:1M/3M/6M/YTD/1Y/ALL) │
├──────────────────────────────┬─────────────────────────────┤
│  中部左:月度收入 vs 支出 +   │  中部右:当前资产配置环形    │
│         储蓄率(柱+折线)     │         (不含 LOAN)         │
├──────────────────────────────┴─────────────────────────────┤
│  按账户分布(2026-05-10 新增)· 横向 bar,资产 → 右,LOAN → 左,│
│   0 轴居中。颜色按 AccountType 区分。每条 bar 末端 datalabels  │
│   显示金额。可视化复杂资产结构,直观看到大额持仓与负债敞口。   │
├────────────────────────────────────────────────────────────┤
│  底部:账户列表(每行带 sparkline 余额迷你图,可点击下钻)  │
├────────────────────────────────────────────────────────────┤
│  红 banner:本期还有 X 个账户未填                         │
│  进度条:成员 A 3/4 · 成员 B 1/2                          │
└────────────────────────────────────────────────────────────┘

KPI 卡定义(5 张,均含本期值 + 同比/环比小箭头):

KPI 含义 计算
净资产 总资产 − 总负债 NetWorth(P)
总资产 不含 LOAN TotalAssets(P) = Σ B(A,P)×FX where type(A) ≠ LOAN
总负债 仅 LOAN `TotalLiabilities(P) = Σ
紧急储备月数 流动资产 / 滚动 12 期月均支出 Σ B(A,P)×FX (CASH/WEALTH) / avg12(ExternalExpense)
负债率 总负债 / 总资产 TotalLiabilities(P) / TotalAssets(P) × 100%

验收标准:

  • 上述 5 KPI 卡 + 趋势主图 + 收支柱状 + 配置环形 + 账户 sparkline 列表全部可见
  • 时间范围 Tab:1M / 3M / 6M / YTD / 1Y / ALL,通过 HTMX hx-get="/dashboard?range=XX" hx-target="#chart-region" 切换
  • 红 banner:"本期还有 X 个账户未填",点击跳 /entry
  • 进度条:"成员 A:3/4 · 成员 B:1/2"
  • 移动端响应式:KPI 卡支持横向 swipe,主图保留,中部双栏改纵向单图,账户列表卡片化
  • 图表组件全部使用 Chart.js(避免 Reports 用的 ECharts 在首页加载,降低首屏成本)

FR-14 · 报表 · P0

描述:/reports 页面 — 重图表区,展示家庭维度的收益与现金流深度分析。前端组件用 ECharts(Chart.js 不擅长桑基/瀑布),按需加载,不影响 Dashboard 首屏。所有报表受 § FR-21 账户筛选器实时控制

报表清单:

报表 用途 图表类型
账户级收益率表 各账户的累计收益率 / 12 期年化 / XIRR 表格 + sparkline HTML + Chart.js
家庭维度 XIRR / TWR 家庭全口径年化(资金加权 + 时间加权双口径) KPI + 折线 Chart.js
月度收支瀑布图 上期净资产 → +工资 → −消费 → ±投资损益 → 本期净资产 瀑布柱状 ECharts
收入流向桑基图 工资 / 奖金 / 其他收入 → 各账户去向 桑基 ECharts
月度收支对比柱 近 12 期收入 vs 支出,叠加储蓄率折线 分组柱 + 折线 Chart.js
累计资产增量分解 净资产增长拆为"本金累计净流入"和"投资累计损益" 堆叠柱 Chart.js
负债下降曲线 各 LOAN 账户余额随时间下降的曲线 折线 Chart.js
多币种汇率折算明细表 每期使用的汇率 + 折算前后金额 表格

验收标准:

  • 账户级收益率表:列 = 账户 / 累计收益率 / 12 期年化(若周期=月即 1 年年化)/ 累计 XIRR;每行带 sparkline;数据点 < 12 显示"未满 1 期"+ 累计
  • 家庭 XIRR + TWR 双指标,在 KPI 卡上各占一格;TWR 用月度切片连乘公式(详见 § 5)
  • 瀑布图:默认近 12 期(月度=12 月,周度=12 周);四段色块(基线灰 / 收入绿 / 支出红 / 投资蓝)
  • 桑基图:节点分两层(收入类别 → 账户);只算外部 INCOME(转账不算)
  • 月度收支对比柱:绿(收入)+ 红(支出);上方折线表示储蓄率
  • 累计增量分解:每期堆叠两根色块(本金 / 投资损益);x 轴时间
  • 负债下降曲线:每个 LOAN 账户一条线,共享 y 轴
  • 鼠标悬停显示具体数字 + 当期占比
  • 点击某期 → 展开该期所有账户的明细 modal
  • 时间范围 Tab(同 Dashboard):1M / 3M / 6M / YTD / 1Y / ALL,HTMX 局部刷新
  • 移动端:每个图表全宽显示,垂直滚动

FR-15 · 多币种支持 · P1

描述:本位币可配置(默认 CNY),支持 USD / HKD;期末汇率自动拉。

验收标准:

  • family.base_currency 字段
  • fx_rate(family_id, base_currency, quote_currency, period_start, rate, source)
  • FxFetchJob:每周期开始时拉前一期末汇率(exchangerate.host)
  • /admin/fx 页面手填覆盖
  • 失败写 audit_log,管理页 banner 警示

FR-16 · CSV 导出 · P0

描述:/export.csv 一键打包 4 张表 + 元数据为 ZIP。

验收标准:

  • 文件:finance-export-YYYYMMDD-HHmmss.zip
  • 包含:families.csv, members.csv, accounts.csv, snapshots.csv, cash_flows.csv, transfers.csv, fx_rates.csv, periods.csv, README.txt
  • UTF-8 with BOM
  • 任意成员均可导出

FR-17 · 站内提醒(仅 banner)· P1

描述:周期开始后第 1、5、10 天 9:00 重新生成"待填提醒",登录后 Dashboard 顶部红色 banner 显示。v0.1 不发邮件 / 短信 / 微信 — 推送通道留到 v0.2 再考虑。

验收标准:

  • Spring @Scheduled cron job 每周期初标记"提醒已生成"
  • 站内 banner 显示待填账户列表 + 一键跳 /entry
  • 缺漏数大于 0 时,Dashboard 始终红色;为 0 时收起 banner

FR-18 · 数据备份 · P0

描述:每周日凌晨 3 点 mysqldump,gzip,本地 + 异地(可选)。

验收标准:

  • shell 脚本 deploy/backup.sh,systemd timer 触发
  • 保留最近 8 周
  • 失败写 audit_log,管理页 banner 警示

FR-19 · LOAN 类型专属逻辑 · P0

描述:负债类账户(房贷/车贷/信用卡)的差异化处理。详见 § 2.6。

验收标准:

  • 数据库存储:LOAN 类型账户的 end_balance 为负数;UI 用绝对值 + "欠"标签 / 红色显示
  • NetWorth 计算:LOAN 余额自然冲抵(因为是负数);TotalAssets 排除 LOAN;TotalLiabilities = Σ |B(LOAN, P)| × FX
  • 资产配置环形图:仅含非 LOAN 类型;LOAN 单独在"负债"区块展示
  • 账户编辑表单:LOAN 类型显示额外字段"默认还款来源账户"(下拉选本家庭非 LOAN 账户,可空)
  • 新周期生成待办时,LOAN 账户自动预填:
    • 余额 = prev + Δ_prev
    • 还款 transfer 草稿(若配了 default_payment_source)
  • 用户调整余额时,transfer 金额联动等比调整以保持余额变化 = 还款金额
  • 异常检测:本期变化量 > 上期 × 阈值(默认 3 倍) → 软警告"可能是提前还款?"
  • 余额变化方向异常(信用卡欠款增加)→ 引导改用 EXPENSE 分类而非 transfer

FR-22 · 显示币种切换(Dashboard / Reports)· P1

描述:Dashboard 与 Reports 顶部加一个"显示币种"切换器,默认 = family.base_currency(初始 CNY)。用户可临时切换为 USD / HKD,所有 KPI / 图表 / 表格按当期汇率重新渲染。底层数据不变,只是视图层换分母

位置:与账户筛选器同行,靠右。

控件:显示: CNY ▼ chip(button + dropdown);下拉选项 = 本位币(置顶,标"默认")+ 其他启用币种(默认 USD / HKD)。

实现:

  • 选择存在 sessionStorage(浏览器关闭即丢失)
  • 后端响应里 KPI / 图表数据始终带"原币种 + 折算币种 + 折算率",前端按 view_currency 取对应值
  • 不影响TWR / XIRR / 比例等无量纲指标
  • 不影响外汇账户原币种列(展示用 48,200 USD ($CNY 348k),即使切到 USD 显示主)

验收标准:

  • 默认进入 = 本位币
  • 切到 USD 后,所有"¥"前缀变 "$"、所有数字按月末 USD 汇率折算
  • 比例类指标(储蓄率、负债率)不受影响
  • 切换瞬时(无 loading)
  • 历史月份用历史汇率(每月各自的 fx_rate 表),不是统一今天的汇率(避免回顾性失真)
  • fxFallback 兜底(2026-05-10 BUG-FIX 加入):若 fx_rate 表缺该 (base, quote, period) 行 → 优先即时调 frankfurter API 拉取并入库,拉成功则正常显示 USD/HKD 数字;只有拉失败才回退到 family.base_currency 展示并 toast 提示「当期 CNY 对 USD 汇率未配置(自动拉取也失败),请联系管理员去 /admin/fx 手填。当前仍以 CNY 展示」(toast 自动消失,不阻塞操作;active tab 仍保持 base 币种,与用户实际看到的数据一致)。禁止只换符号不换数值
  • 核心算式校验:FactMapper.queryBase 的 fx CASE 必须保证 account_balance_orig × fx_to_base = view_currency_value。约定:fx_rate.rate 表示「1 base = rate × quote」,因此当 fx_inverse(base=a.currency, quote=viewCurrency)命中时直接乘 rate;fx_direct 命中时取 1/rate两次回归(v0.1 期 + 2026-05-10 v0.2 期)都因 CASE 公式倒挂导致整表数字 ×7 错位

FR-21 · 账户筛选器(Dashboard / Reports)· P0

描述:Dashboard 与 Reports 顶部固定一个"账户筛选器"组件。默认勾选家庭下所有未归档账户(包含全员)。用户可临时收窄范围(如"只看我"、"只看股票"、"排除房产")。所有 KPI、图表、表格、sparkline 实时跟随筛选结果重算。

位置:Dashboard 与 Reports 页眉之下、KPI 卡之上,sticky 跟随滚动。

默认状态(折叠态):

┌────────────────────────────────────────────────────────────────┐
│ ◯ 账户范围 · 全部 11 个                                  [▼ 展开] │
└────────────────────────────────────────────────────────────────┘

筛选活动时变成:● 账户范围 · 11 中已选 7 个 · STOCK + WEALTH ONLY [▼] [↺ 重置]

展开态:

  • 顶部一行:"快速预设" chips
    • 成员视角:全部 / 仅我 / Alice / Bob / 共同
    • 资产/负债:全部 / 仅资产 / 仅负债
    • 类型:STOCK / CASH / WEALTH / PROPERTY / LOAN / OTHER(每个 chip 标对应账户数)
  • 下面一行 search 框
  • 列表:逐账户复选框 +(类型 chip / 主理人 / 当前余额)
  • 底部:全选 / 全清 / 应用 N 个 按钮

会话内记忆:筛选状态存到 sessionStorage(浏览器关闭即丢失)。不持久化到数据库 — 避免成员之间互相影响,且符合"临时视角"的语义。

交互:

  • 复选框勾选后,顶部的"应用 N 个"按钮高亮;点击后 HTMX hx-post 提交筛选参数,所有图表/表格 hx-target 全局刷新
  • 重置按钮回到"全部"(默认态)
  • URL 参数 ?accounts=1,3,5,7 编码当前选中账户 ID,可分享和书签

和 LOAN 的关系:筛选器默认包含所有账户(资产 + 负债)。如果用户只勾选资产类账户,Dashboard 的"总负债"KPI 显示"—"+ tooltip"已被筛选排除"。

验收标准:

  • 默认进入 Dashboard / Reports 看到的是全家全口径
  • 任意筛选立即重算所有指标(实时,无 loading 旋转)
  • 筛选器与时间范围 Tab(1M/3M/...)正交,互不影响
  • 筛选状态在 Dashboard 与 Reports 之间不串(各页独立 sessionStorage key)

FR-20 · 统一管理页 /admin · P0

描述:把 PRD 中所有"可配置项"集中到一个管理页,通过左侧导航分子页维护。任意成员均可访问(家庭内部不分级)。

子页结构:

子页 URL 内容 优先级
/admin/family 家庭名、品牌名、logo 上传(前端 Canvas 压缩为 WebP 后再上传,服务端只校验 + 存)、本位币、周期类型(切换需先关掉 OPEN 周期) P0
/admin/members 成员列表;改名、role_label、归档、重置密码 P0
/admin/accounts 账户列表 + 增改归档(同 /accounts,这里作为入口聚合) P0
/admin/account-templates 13 个内置模板列表(v0.1 只读)+ 各模板被使用次数统计 P1
/admin/cash-flow-categories 现金流类别列表(v0.1 只读 7 类:工资/奖金/其他收入/消费/还贷/利息/转账给亲属/其他支出) P1
/admin/periods 周期列表 + 状态 + 每个周期的"重新打开"按钮(必填理由) P0
/admin/reminders 站内提醒开关、cron 时间锚点(默认 1/5/10 日 9:00)— v0.1 不含邮件渠道 P1
/admin/fx 汇率表手填 + API 状态;失败月份高亮 P1
/admin/backup 备份保留周数(默认 8)、异地存储开关 + URL、最近 4 次备份状态 P0
/admin/audit 审计日志:周期重开记录、metrics 重算记录、密码重置记录、失败的 cron job P0
/admin/calc-tweaks 数值阈值:智能转账提示阈值(默认 ¥3,000)、LOAN 异常变化阈值(默认 3 倍)、未解释金额自动归类的阈值(默认 0.01) P1

验收标准:

  • /admin 是导航入口页,左侧菜单 + 右侧内容区
  • 每个子页保存后,变更即时生效(无需重启应用)— 通过 Spring @RefreshScope 或自实现的配置监听
  • 任意编辑写 audit log:who / when / what changed / from → to
  • 切换周期类型(MONTHLY ↔ WEEKLY):前置检查存在 OPEN 周期 → 阻塞 + 提示先关闭
  • 重置密码:生成临时密码,屏幕显示一次,要求成员下次登录强制改;v0.1 不发邮件,管理员当面/微信告诉对方
  • v0.1 标注 P1 的子页可暴露但仅展示("即将开放编辑"),不强求 v0.1 出全套编辑能力

优先级总结

优先级 数量
P0 16 FR-1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 16, 18, 19, 20
P1 4 FR-10, 15, 17

P1 中工期紧时可降级(FR-10 智能推断、FR-15 多币种、FR-17 提醒 cron)。 P0 不要降级 — 都是周期化记账的关键链路。


5. 指标计算口径

本节严格定义系统所有指标的公式、数据来源、边界处理和校验恒等式。所有指标都从 4 张原始表(snapshot / cash_flow / transfer / fx_rate)计算而来,不落库(除汇率)。

5.0 Wide Fact View · 大宽表抽象

整个系统所有指标、图表、筛选,本质都是建在一张"大宽表"上的可视化与切片。这是 Star Schema(星型模式)的应用。所有 Dashboard / Reports 内容都从同一个数据视图投影聚合

5.0.1 概念图

                        ┌──────────────────────────┐
                        │  fact_account_period     │
                        │  one row per             │
                        │  (account × period)      │
                        └──────────┬───────────────┘
              ┌────────────────────┼─────────────────────┐
              │                    │                     │
       ┌──────▼──────┐     ┌──────▼──────┐     ┌────────▼─────┐
       │ dim_account │     │ dim_period  │     │ dim_member   │
       └─────────────┘     └─────────────┘     └──────────────┘
              │
       ┌──────▼──────┐
       │ dim_currency│  (其 fx_rate 与 period 二维联合)
       └─────────────┘

附属事实:fact_cash_flow(account × period × kind × category × line_no)
         fact_transfer (period × from_account × to_account × line_no)

5.0.2 维度清单

dim_account — 账户维度

字段 取值 / 派生 来源
account_id PK
account_name 显示名
account_type STOCK / CASH / WEALTH / PROPERTY / LOAN / OTHER
account_class ASSET / LIABILITY 派生:LOAN → LIABILITY,其他 → ASSET
account_liquidity LIQUID / SEMI_LIQUID / ILLIQUID / N_A 派生:CASH→L, WEALTH→SL(若 is_locked 则 IL), STOCK→SL, PROPERTY→IL, LOAN/OTHER→NA
account_currency CNY / USD / HKD
primary_owner_member_id FK 到 dim_member
default_payment_source_account_id LOAN 专属 FK
is_archived bool archived_at IS NOT NULL

dim_period — 周期维度

字段 取值
period_id PK
period_type MONTHLY / WEEKLY
period_start / period_end 日期范围
year / quarter / month / week_of_year 子粒度(派生)
status OPEN / CLOSED
closed_at nullable

dim_member — 成员维度

字段 取值
member_id PK
display_name 文本
role_label 文本(可空)

dim_currency — 币种维度(× period 才有 fx_rate)

字段 取值
currency_code CNY / USD / HKD
currency_symbol ¥ / $ / HK$
fx_rate_to_base(period) 该周期末折本位币率,来自 fx_rate 表

5.0.3 Measures(度量)— 每个都有 _orig 和 _base 双版

度量 含义 计算来源
end_balance 期末余额 period_snapshot.end_balance(LOAN 为负)
external_income INCOME 之和 Σ cash_flow.amount WHERE kind = 'INCOME'
external_expense EXPENSE 之和 Σ cash_flow.amount WHERE kind = 'EXPENSE'
net_external 外部净流入 external_income − external_expense
transfer_in 转入 Σ transfer.amount WHERE to_account = this
transfer_out 转出 Σ transfer.amount WHERE from_account = this
net_transfer 内部净流入 transfer_in − transfer_out
period_pnl 投资损益 end_balance(P) − end_balance(P-1) − net_external − net_transfer

_base 版本 = _orig × FX(currency, period)

5.0.4 附属事实:cash_flow 的子维度(只在桑基/类别分析用)

维度 取值
account_id, period_id FK
cash_flow_class INCOME / EXPENSE(派生于 kind)
cash_flow_category 工资 / 奖金 / 其他收入 / 利息 / 消费 / 还贷 / 转账给亲属 / 其他支出
amount 度量

5.0.5 所有指标重写为 fact view 上的查询

§ 指标 WHERE GROUP BY 聚合
5.2 账户期度 PnL(原币) account = A AND period = P period_pnl_orig
5.3 账户期度 PnL(本位) 同上 period_pnl_base
5.4 家庭期度损益 period = P SUM(period_pnl_base)
5.5 NetWorth period = P SUM(end_balance_base)
5.5 TotalAssets period = P AND class = ASSET SUM(end_balance_base)
5.5 TotalLiabilities period = P AND class = LIABILITY SUM(ABS(end_balance_base))
5.6 Allocation 比例 period = P AND class = ASSET account_type SUM(end_balance_base) / TotalAssets
5.7 账户级 XIRR account = A period XIRR(net_external_orig + 期初/期末)
5.8 家庭维度 XIRR (任意筛选 N) period XIRR(SUM(net_external_base) + NetWorth 端点)
5.9 期度收支瀑布 period = P external_income_base / external_expense_base / period_pnl_base
5.10 SavingsRate period = P net_external_base / external_income_base
5.11 EmergencyFundMonths period = P AND liquidity = LIQUID SUM(end_balance_base) ÷ avg12(external_expense_base)
5.12 DebtToAsset period = P TotalLiabilities / TotalAssets
5.13 累计本金 vs 收益 period ∈ window period SUM(net_external_base) / SUM(period_pnl_base)
5.14 家庭 TWR (任意筛选) period ∏(1 + period_return)

5.0.6 FR-21 筛选器 ↔ 维度对照(用例化)

用户操作 fact view 上的等价 WHERE
(默认 · 全部)
快速预设 · 仅我 primary_owner_member_id = current_member
快速预设 · Alice主理 primary_owner_member_id = 1
快速预设 · 共同 primary_owner_member_id IS NULL
快速预设 · 仅资产 account_class = ASSET
快速预设 · 仅负债 account_class = LIABILITY
类型 · STOCK account_type = 'STOCK'
逐账户勾选 account_id IN (selected_ids)
FR-22 显示币种切换 改变 measure 的 _base 折算分母:measure × FX(view_currency, period)
时间范围 1Y period.period_end >= NOW − 12 months

5.0.7 实现策略

  • v0.1:不物化。服务层 FactViewService 用 JPA 或纯 SQL,从 4 张原始表 join 出投影。数据量小(~150 行/年/家庭),内存里 SUM / GROUP BY 毋须缓存。
  • v0.X(若 fact 行 > 10k):
    1. 第一步 — MySQL CREATE VIEW account_period_fact AS ... 封装 join,Java 直接 SELECT FROM account_period_fact WHERE ...
    2. 第二步 — 若 VIEW 慢,物化为 account_period_fact 表,周期关闭时 job 增量同步

5.0.8 抽象的关键收益

  1. 加新指标无新 SQL。先问"这是 fact view 上的什么 WHERE + GROUP BY",不允许特殊查询。
  2. 加新维度只动一处。例如未来加 account.tag,所有指标自动可按 tag 切片。
  3. 测试简化。测 fact view 投影正确,依赖它的所有指标自动正确。
  4. 跨页一致。Dashboard / Reports / 任意筛选,在数据语义上完全等价,只是 UI 形态不同。
  5. 审计可追溯。每个数字都能"反编译"出对应的 fact view 查询。

5.0.9 下游对 PRD 的影响

  • 新增字段(原 dim_account 已有,这里只是显式化派生):
    • account.account_class — 不存,运行时派生
    • account.account_liquidity — 不存,运行时派生(account.is_locked 字段为 v0.2 加项,v0.1 默认所有 WEALTH 视为 SEMI_LIQUID)
  • § 5.1 之后所有公式仍以可读符号呈现(B(A,P) / I(A,P) 等),但实现时一律走 fact view 投影,不允许在指标实现里直接 join 原始表

5.1 符号约定

符号 含义
P 周期(由 family.period_type + period_start 唯一确定)
P-1 上一个周期
A 账户(account)
C 币种
currency(A) 账户 A 的币种
type(A) 账户 A 的类型(STOCK/CASH/WEALTH/PROPERTY/LOAN/OTHER)
B(A, P) 账户 A 在周期 P 末的余额(原币种,从 period_snapshot.end_balance);LOAN 类型为负值
I(A, P) 账户 A 在周期 P 的 INCOME 类 cash_flow 之和(原币种)
E(A, P) 账户 A 在周期 P 的 EXPENSE 类 cash_flow 之和(原币种)
T_in(A, P) 周期 P 内,转账目标 = A 的金额之和
T_out(A, P) 周期 P 内,转账源头 = A 的金额之和
FX(C, P) 周期 P 末,1 单位 C 折成本位币的汇率
Active(P) 周期 P 时未归档的账户集合
Asset(P) 周期 P 时未归档的非 LOAN 账户集合 = {A ∈ Active(P) : type(A) ≠ LOAN}
Liab(P) 周期 P 时未归档的 LOAN 账户集合 = {A ∈ Active(P) : type(A) = LOAN}

5.2 指标 1 · 账户期度投资损益(原币种)

PnL(A, P) = B(A, P) − B(A, P-1) − [I(A, P) − E(A, P)] − [T_in(A, P) − T_out(A, P)]

直觉:本期余额变化中,扣掉外部流入流出和内部转账,剩下是市场带来的。

与"轧差引导"的对应关系:

  • 用户入账时看到的"未解释金额"= B − B_prev − 已登记的 (I−E) − 已登记的 (T_in−T_out)
  • 用户分类完毕时,未解释金额 = 0,意味着:PnL = 用户分类的"投资收益"部分(归到 cash_flow 否则就是 0)
  • 等价地,如果用户跳过分类,未解释金额自动归 PnL

边界:

  • B(A, P-1) 不存在(账户首期或缺数据):PnL = null,UI 显示"—"
  • 跨币种转账:v0.1 不单独折算汇率损益

5.3 指标 2 · 账户期度投资损益(折本位币)

PnL_base(A, P) = PnL(A, P) × FX(currency(A), P)

5.4 指标 3 · 期度家庭总损益

PnL_total(P) = Σ PnL_base(A, P)  for A ∈ Active(P)

跳过 PnL = null 的账户,UI 标"⚠️"。

5.5 指标 4 · 期度家庭净资产 / 总资产 / 总负债

NetWorth(P)         = Σ [ B(A, P) × FX(currency(A), P) ]   for A ∈ Active(P)
TotalAssets(P)      = Σ [ B(A, P) × FX(currency(A), P) ]   for A ∈ Asset(P)
TotalLiabilities(P) = Σ [ |B(A, P)| × FX(currency(A), P) ] for A ∈ Liab(P)

校验:NetWorth(P) ≡ TotalAssets(P) − TotalLiabilities(P)

直觉:LOAN 账户 B 存为负数,所以 Σ B 自然得到"资产 − 负债"。但 KPI 卡需要分别展示,所以拆出 TotalAssets / TotalLiabilities(LOAN 用绝对值)。

边界:

  • 某账户 B(A, P) 缺失:跳过该账户,UI 标"⚠️"
  • 某币种 FX(C, P) 缺失:整期 NetWorth 标红"⚠️ 汇率缺失",提示去 /admin/fx 手填

5.6 指标 5 · 资产配置(只算资产、不含负债)

Allocation(P, t) = Σ [ B(A, P) × FX(currency(A), P) ]   for A ∈ Asset(P) where type(A) = t
比例(P, t)       = Allocation(P, t) / TotalAssets(P)

注意:分母用 TotalAssets(不含 LOAN)而不是 NetWorth。环形图扇区 ∈ {STOCK, CASH, WEALTH, PROPERTY, OTHER},不含 LOAN。LOAN 在 UI 上单独"负债"区块展示。

5.7 指标 6 · 账户级 XIRR

XIRR = 不规则现金流的内部收益率,年化。

现金流序列构造

对账户 A 在时间窗口 [P_first, P_last] 内:

CF = []
CF.append( (date=P_first 末日, amount = -B(A, P_first)) )

for P in (P_first, P_last]:
    netExternal = I(A, P) − E(A, P) + T_in(A, P) − T_out(A, P)
    if netExternal != 0:
        CF.append( (date=P 末日, amount = -netExternal) )

CF.append( (date=P_last 末日, amount = +B(A, P_last)) )

XIRR 求解

r 使得:Σ CF_i.amount / (1 + r)^((CF_i.date − CF_0.date) / 365.0) = 0

实现:Apache Commons Math BrentSolver,区间 [-0.99, 10.0],最大迭代 100,容差 1e-7。

边界

情况 处理
数据点 < 12 期(月度=12 月,周度=12 周) 改算累计收益率 = (终值 / 初值) − 1,UI 标"未满 12 期"
Brent 求根失败 返回 null,显示"—",tooltip 解释
B(A, P_first) = 0 且首期有大额流入 把首笔大额流入作为初始投入(first-flow-as-baseline)
数据缺失 > 50% 周期 返回 null,显示"—"+ ⚠️

测试用例(必须通过)

Case 输入 期望 XIRR(±0.5%)
1 1 期初投 ¥10,000,12 期后值 ¥11,000,无中间流 ≈ 10%
2 1 期初投 ¥10,000,7 期加仓 ¥5,000,12 期后值 ¥17,000(累计 +¥2,000) ≈ 16%
3 纯持有 ¥10,000 → ¥10,000,12 期无变化 = 0%
4 1 期投 ¥10,000,3 期赎回 ¥10,000,3 期再投 ¥10,000,12 期后值 ¥10,000 ≈ 0%

5.8 指标 7 · 总资产级 XIRR

家庭维度现金流序列:
  CF_0 = (P_first 末日, amount = -NetWorth(P_first))
  for each P ∈ (P_first, P_last]:
      familyExternal_base(P) = Σ [ (I(A,P) − E(A,P)) × FX(currency(A), P) ]
                              for A ∈ Active(P)
      // 跨账户 transfer 在家庭维度互相抵消,不出现
      if familyExternal_base(P) != 0:
          CF.append( (P 末日, -familyExternal_base(P)) )
  CF_last = (P_last 末日, +NetWorth(P_last))

关键:跨账户 transfer 在家庭维度互相抵消,这是 transfer 表设计的核心价值。

5.9 指标 8 · 期度收支瀑布

ExternalIncome(P)  = Σ [ I(A, P) × FX(currency(A), P) ]
ExternalExpense(P) = Σ [ E(A, P) × FX(currency(A), P) ]
InvestmentPnL(P)   = PnL_total(P)
NetChange(P)       = ExternalIncome(P) − ExternalExpense(P) + InvestmentPnL(P)

瀑布图四根柱:基线(上期 NetWorth)→ +工资 → −消费 → ±投资 → 终点(本期 NetWorth)。

5.10 指标 9 · 储蓄率(SavingsRate)

SavingsRate(P) = (ExternalIncome(P) − ExternalExpense(P)) / ExternalIncome(P)

边界:

  • ExternalIncome(P) = 0:不可算,UI 显示"—"(本期无收入)
  • 结果可以为负(消费 > 收入)
  • 也可显示滚动 12 期版本:avg12(SavingsRate) 抹平节假日波动

5.11 指标 10 · 紧急储备月数(EmergencyFundMonths)

LiquidAssets(P)         = Σ [ B(A, P) × FX(currency(A), P) ]   for A ∈ Asset(P) where type(A) ∈ {CASH, WEALTH}
AvgMonthlyExpense12(P)  = avg12 [ ExternalExpense(P-i) ]       for i = 0..11
EmergencyFundMonths(P)  = LiquidAssets(P) / AvgMonthlyExpense12(P)

含义:不再有任何收入的情况下,流动资产能撑几个月。业界 4-6 月被视为安全。

边界:

  • 数据点 < 6 个月:用已有月份平均代替 12 月,UI 标"基于 N 月数据估算"
  • AvgMonthlyExpense12(P) = 0(系统刚启动无历史):显示"—"
  • 周期为 WEEKLY 时,公式按周计算后再 / 4.33 折算成月数

5.12 指标 11 · 负债率(DebtToAssetRatio)

DebtToAssetRatio(P) = TotalLiabilities(P) / TotalAssets(P)

边界:TotalAssets = 0 时显示"—"(异常,系统应该不会到这种状态)。

展示:Dashboard KPI 卡用百分比;> 50% 标红色警告。

5.13 指标 12 · 累计资产增量分解(本金 vs 收益)

对时间窗口 [P_first, P_last]:
  CumulativeNetFlow = Σ familyExternal_base(P)  for P ∈ (P_first, P_last]
  CumulativePnL    = Σ PnL_total(P)            for P ∈ (P_first, P_last]
  
  校验:NetWorth(P_last) − NetWorth(P_first) ≡ CumulativeNetFlow + CumulativePnL

用途:Reports 页堆叠柱图,每期一根柱(本金堆灰、收益堆蓝),回答"我们家变富了多少,其中工资攒下多少,投资赚了多少"。

5.14 指标 13 · 家庭维度 TWR(时间加权收益率)

XIRR 是资金加权(钱多的时段贡献大);TWR 剔除加仓减仓的影响,只反映"资产组合本身"的表现。两者并存,用户都关心。

月度切片连乘公式

对每个完整周期 P:
  Net_inflow(P)    = familyExternal_base(P)
  EndValue(P)      = NetWorth(P)
  StartValue(P)    = NetWorth(P-1)
  
  // 假设外部现金流发生在期末(月度颗粒度下的简化)
  PeriodReturn(P)  = (EndValue(P) − Net_inflow(P)) / StartValue(P) − 1
                   = (NetWorth(P) − Net_inflow(P) − NetWorth(P-1)) / NetWorth(P-1)

TWR_total[P_first, P_last] = ∏ (1 + PeriodReturn(P)) − 1     for P ∈ (P_first, P_last]
TWR_annualized              = (1 + TWR_total)^(12/N) − 1     where N = 周期数(月度)

边界:

  • NetWorth(P-1) ≤ 0:该期跳过(LOAN 大于 Asset 时净资产为负,TWR 不可算)
  • 数据点 < 12 期:不年化,只展示累计 TWR + "未满 1 年"标记
  • 与 XIRR 的差异:TWR 通常 ≠ XIRR;用户在同一 KPI 上可看到两者并列

5.15 总结:指标全清单

为方便实现,把所有指标按"颗粒度 + 时间形态"分类:

# 指标 颗粒度 形态 展示位置
1 NetWorth 家庭 时序 + 截面 Dashboard 主图 + KPI
2 TotalAssets 家庭 截面 Dashboard KPI
3 TotalLiabilities 家庭 截面 Dashboard KPI
4 EmergencyFundMonths 家庭 截面 Dashboard KPI
5 DebtToAssetRatio 家庭 截面 Dashboard KPI
6 Allocation 比例 家庭(按 type) 截面 Dashboard 环形
7 ExternalIncome / Expense 家庭 时序 Dashboard 中部柱 + Reports 桑基
8 SavingsRate 家庭 时序 Dashboard 中部折线叠加
9 InvestmentPnL 家庭 时序 Reports 瀑布
10 NetChange 家庭 时序 Reports 瀑布
11 XIRR 家庭 + 账户 单值 Reports KPI + 表格
12 TWR 家庭 单值 Reports KPI
13 累计本金 vs 收益分解 家庭 时序堆叠 Reports 堆叠柱
14 账户余额 sparkline 账户 时序 Dashboard 列表
15 LOAN 余额下降曲线 账户(LOAN) 时序 Reports 折线

5.16 校验恒等式

主恒等式(每期都必须成立)

NetWorth(P) − NetWorth(P-1)
  = ExternalIncome(P) − ExternalExpense(P) + InvestmentPnL(P)
  = NetChange(P)

LOAN 衍生恒等式

TotalLiabilities(P) − TotalLiabilities(P-1)
  = − [ Σ T_in(L, P) − Σ T_out(L, P) ]   for L ∈ Liab(P)
    + [ Σ I(L, P) − Σ E(L, P) ]           for L ∈ Liab(P)  // 罕见,LOAN 通常无现金流
    + LoanInterestImplicit(P)

直觉:负债减少 = 还款 transfer 进来 − 利息累积(我们 v0.1 把 LoanInterestImplicit 含在投资损益里;v0.2 拆出)。

实现

PnlCalculator.assertIdentity(P)MetricsRecomputeJob 末尾执行,主恒等式误差 > ¥0.01 抛 DataInconsistencyException + 写日志 + UI 标红。LOAN 衍生恒等式仅记录到日志,不抛错(因为利息隐式计入 PnL 是 v0.1 的妥协,不算 bug)。

常见主恒等式违反原因

  1. 跨账户转账漏登记 → 提示用户检查
  2. 某账户 snapshot 缺失 → 提示去填
  3. 跨币种汇率折算精度问题(< ¥1) → 容忍
  4. LOAN 账户余额变化未对应 transfer(用户跳过分类) → 不影响主恒等式(差额已归 PnL),但会让"投资损益"含本金还款,失真;UI 软提示

5.17 数据来源与计算时机

数据 来源 时机
余额 B 用户填(period_snapshot) 用户提交
现金流 I, E 用户填(cash_flow) 用户提交
转账 T_in, T_out 用户填(transfer) 用户提交
汇率 FX exchangerate.host(fx_rate) 周期初 cron + 手填覆盖
所有指标(PnL, NetWorth, Allocation, XIRR, TWR, 储蓄率, 紧急储备, 负债率) 4 张原始表实时算 Dashboard / Reports 请求时

性能:数据量小(2 人 × ~10 账户 × 12 月/年 = 一年 ~120 行 snapshot),全部在内存里 SQL+Java 计算,毋须缓存。MetricsRecomputeJob 只为审计 + 通知,不为计算。

5.18 精度与数值类型

用途 类型 小数位
余额 / 现金流 / 转账 DECIMAL(18, 2) 2
汇率 DECIMAL(18, 6) 6
比例(储蓄率、负债率) DECIMAL(8, 4) 4
折算中间值 BigDecimal,scale=6 6
显示值 四舍五入到 2 位

禁止:用 double / float 做金额计算(浮点误差会破坏 § 5.16 恒等式)。


6. 不做的事 + 后续展望

6.1 v0.1 明确不做(Non-Goals)

不做的事 为什么不做 是否未来做
个股/单基金持仓级跟踪 与"10 分钟/期"硬约束冲突;单券分析继续在券商/支付宝 App 看 永久不做
定投计划与提醒 同上,且属于"决策辅助"不属于"记账" v0.3+ 视情况
预算/包络法(YNAB 风) 我们做"看钱去哪了",不做"管钱怎么花" 永久不做
券商 / 银行 API 自动同步 国内 API 普遍不开放或需企业资质 永久不做
银行账单 OCR 余额驱动+轧差引导已经够轻量;OCR 反增加修正负担 永久不做
三人及以上家庭成员的 UI 数据模型支持,v0.1 UI 不暴露"添加成员"按钮 v0.3 加孩子时再说
家庭间数据共享(多家庭) 数据模型留 family_id,但 v0.1 不暴露多家庭 永久不做(给陌生用户用是另一个产品)
原生 iOS/Android App 响应式 Web 已够 永久不做
邮件 / 短信 / 微信公众号通知 v0.1 仅 Dashboard banner;运营成本 + 配置复杂度都不值 v0.1 投入 v0.2 视情况(优先邮件)
双因素认证 / OAuth 受信家庭场景,密码 + 服务器防火墙够用 永久不做
数据加密(磁盘以外) MySQL 存在受信服务器内 v0.2 增强备份加密
移动端 PWA / 离线填报 v0.1 假设填报时有网络 v0.3+
复杂权限分级 家庭内部不分级 永久不做
资产负债表 / 损益表(会计风) 受众非财务专业 永久不做
投资标的对标(沪深 300、CSI) 账户级颗粒度无法做精确对标 永久不做(需持仓级)
房贷/车贷的还款计划展示(摊销表) 月度 EXPENSE 已够;不做摊销表 v0.3+ 可加"长期负债"模块

6.2 v0.1 可接受的妥协(有意识的限制)

  1. 跨币种汇率损益不单独标识:转入转出的差异自动归"投资损益"。理由:简化;v0.2 拆出。
  2. 期内多笔现金流不区分时间:同期合并。理由:满足"10 分钟"约束。
  3. TWR 不单独计算:只算 XIRR(=DWR)。理由:家庭场景 IRR 更直观。
  4. 历史数据不可机器导入:从本期起点。理由:v0.1 简化;v0.2 加 CSV 导入。
  5. Dashboard 不实时(期颗粒度):总资产是上期末快照。理由:契合周期化记账定位。
  6. 没有撤销按钮:覆盖型修改 + 二次确认替代。理由:简单。
  7. 轧差引导可被跳过:用户可不分类直接关掉,差额自动归投资收益。理由:不强制;留信任靠提示。
  8. 周度周期模式 v0.1 仅"保留"不重点测:设计支持,但 v0.1 全部测试用例基于月度。理由:产品定位是月度;周度作未来开关。
  9. 房贷利息隐式计入投资损益:不强制用户拆分本金和利息。理由:简化;v0.2 自动识别 EXPENSE 中类别=利息的金额并拆出。
  10. TWR 假设外部现金流发生在期末:月度切片的简化算法。理由:月度颗粒度下精度足够;持仓级才需要日内 TWR。
  11. 图表用两个库(Chart.js + ECharts):Dashboard 用 Chart.js,Reports 按需 ECharts。理由:Chart.js 不擅长桑基/瀑布,但首屏只用它能压缩首屏成本;ECharts 在 Reports 页才加载。

6.3 路线图

v0.2(v0.1 稳定运行 6 月后回看)

数据 / 计算:

  • 汇率损益从"投资损益"中独立拆出
  • 房贷利息从"投资损益"中独立拆出(用 cash_flow.category = '利息' 自动识别)
  • CSV 历史数据导入(配模板,让用户能补录过往年份)
  • 滚动 12 期收益率指标
  • 各账户 type 的累计收益贡献分解

展示 / 报表新增:

  • 资产配置随时间堆叠面积图(招行资产时光机风格)
  • 消费日历热力图(ECharts cal-heatmap)
  • Reports 旭日图 / 树状图(分类下钻)
  • Dashboard 卡片可拖拽 / 自定义

运维 / 协作:

  • 数据备份 GPG 加密(异地存储更安全)
  • 月度对比报告自动邮件 + PDF
  • 微信公众号推送(若邮件不够用)
  • 把"周度周期"作为正式支持模式(跑通全部测试)
  • 账户模板表开放 admin 编辑(允许加自定义模板)
  • 现金流类别开放 admin 编辑

v0.3(更远)

  • 持仓级颗粒度(可选模块,数据模型留位 holding / holding_snapshot);开启后才能做基准对比、归因、单券 XIRR
  • 长期负债的摊销表跟踪(房贷剩余年限、利率、本金/利息每月预测)
  • 三人及以上成员 UI(孩子账户 / 父母代管)
  • 目标管理("5 年后买房首付 ¥X",自动算"按当前进度能否达成")
  • PWA 离线填报
  • 收益归因到"行业/地域/币种"(需持仓级)

永远不做

  • 复杂的预算包络系统
  • 个股级买卖逐笔记录
  • 多租户给陌生家庭用(有限的多家庭数据模型只为代码整洁,不是产品方向)
  • 自动对接券商 / 银行 API
  • 银行账单 OCR

6.4 评估"是否要进 v0.x"的 checklist

新功能进入下个版本前,必须通过:

  • 不增加单期填报时间(或增量 < 1 分钟)
  • 不让非技术成员看不懂(她能在 30 秒内理解功能用途)
  • 用了真实数据 ≥ 6 期后才提需求(避免拍脑袋)
  • 对应 Excel/飞书有明显不可能或太麻烦的对比理由
  • 实现工作量 ≤ 8 小时(否则拆小)

6.5 失败信号(触发就要回炉)

  • 连续 3 期任一成员未完成填报
  • 非技术成员主动要求"用回 Excel"
  • 出现"数据丢失且备份恢复失败"
  • Dashboard XIRR 数字常被怀疑算错(信任崩塌)
  • 单期填报实际平均时长 > 15 分钟
  • 同周期手动重开 > 3 次

任一发生 → 暂停加新功能,做用户访谈 + 数据校验,先解决根因。


7. 市场调研:同类产品的指标体系(v1.0 演进参考)

本章节为 v1.0 范围内的计划文档:横向对照海外/国内主流理财与记账 App,把它们使用的关键指标按"该指标解决什么问题、用什么图表呈现、属于实时值还是过程值"三个维度归类,给出家庭账房 v1.0 应纳入的增量指标清单。所有"决定不做的指标"在 § 7.4 留白说明,便于未来回看。

出发点:v0.1 已经覆盖净资产/总资产/总负债/紧急储备/负债率/储蓄率/XIRR/TWR/瀑布/桑基/本金vs收益分解/负债曲线等核心 13 项指标;v1.0 在不破坏"10 分钟/期"约束的前提下,精挑能补全"日常决策力"和"长期反思力"的少量增量。

7.1 调研对象与定位

App 国别 类型 重点借鉴
Personal Capital / Empower 资产管理 Net Worth、Asset Allocation、Investment Checkup、You Index(组合 vs 基准)
Mint(已停)/ Monarch Money 综合记账 Cash Flow Sankey、Subscription Tracker、Goals、Net Worth Timeline
YNAB 预算法 Age of Money、Pay-yourself-first、Rule-based 储蓄
Maybe Finance(开源) 综合 Account-level XIRR/TWR、Asset Class allocation drift、Cashflow waterfall
Ghostfolio(开源) 投资组合 TWR / MWR / IRR、Allocation by class/sector/country、FIRE 计算器
招商银行 App "资产时光机" 银行内嵌 资产趋势堆叠面积、月度收支、家庭资产、年化粗算
蛋卷基金 / 雪球组合 基金/股票 复权净值、累计收益、最大回撤、相关性、Sharpe
支付宝财富 / 简理财 综合理财 总资产、今日收益、累计收益、XIRR、定期到期日历
随手记 / Money Lover / MoneyWiz 中 / 国际 纯记账 支出分类、月度趋势、预算执行、信用卡还款日

结论:海外侧重收益归因和长期回顾;国内侧重今日收益和到期提醒;综合记账类侧重消费分类和预算。家庭账房 v1.0 的取舍:借鉴海外的归因深度,吸收国内的"今日 / 到期"提醒,但不做精细预算(YAGNI)。

7.2 指标分类(按"解决什么问题"归)

A · 存量指标(余额型 · 实时值)

"我们家现在多少钱、风险够不够安全。"

指标 含义 v0.1 已做 v1.0 增量
净资产 NetWorth 总资产 − 总负债
总资产 / 总负债 含义同字
流动资产 LIQUID(现金类)
半流动 / 不动 SEMI_LIQUID / ILLIQUID ✅ KPI 卡或筛选器侧栏显示
配置占比 各 type 占总资产 % ✅(环形)
配置漂移 当前配置 vs 设定目标差 ✅(M1)
紧急储备月数 流动资产 / 月均消费
负债率 总负债 / 总资产
流动性比率 流动资产 / 当月支出 ✅(M1)
单一账户集中度 Max(账户余额)/ 总资产 ⚠️ 仅在 LOAN > 50% 时 banner 提示
净资产同比/环比 YoY / MoM 变化箭头 ✅ KPI 卡上的 ↑+8.3% 小标

B · 流量指标(过程型 · 时间序列)

"这一段时间我们家变好/变坏了多少。"

指标 含义 v0.1 已做 v1.0 增量
期间收入 INCOME 合计
期间支出 EXPENSE 合计
储蓄率 (收入 − 支出)/ 收入
净资产 ΔPeriod 期末 − 期初
净流入 收入 − 支出(剔除内部转账)
投资损益 ΔNetWorth − 净流入
累计本金 vs 累计收益 时序分解 ✅(报表)
收入流向 工资/奖金 → 各账户 ✅(桑基)
支出类别趋势 消费/还贷 各类别按月 ✅ M2 折线小图
YTD 速览 年初至今汇总 ✅ M3 报表顶 KPI 条
储蓄率 12 期均线 滚动 12 期均值 ⚠️ 已有 sparkline 可加

C · 收益率指标(归一化 · 跨期对比)

"以年化口径,我们的钱赚得快不快。"

指标 含义 v0.1 已做 v1.0 增量
简单收益率 期末/期初 − 1 ✅(账户级)
年化收益率 单期年化
XIRR(IRR / DWR) 资金加权,带不规则现金流 ✅(账户+家庭)
TWR 时间加权,排除资金进出干扰 ✅(家庭)
MWR 资金加权,XIRR 的同义概念 ✅(等价 XIRR)
最大回撤 Max Drawdown 历史最大下跌幅 ❌ 砍(账户级颗粒度信号弱)
Sharpe 单位风险收益 ❌ 砍(需 σ,我们只有期颗粒度)
复权净值 单基金净值 ❌ 砍(背离账户级颗粒度)
相对基准超额 vs 沪深300/标普500 ❌ 砍(需持仓级,v0.3+)

D · 风险指标

"我们家抗风险能力还行吗?"

指标 含义 v0.1 已做 v1.0 增量
紧急储备月数 流动资产 / 月均支出
负债率 总负债 / 总资产
信用卡余额 / 工资 月度授信压力 ⚠️ 选做
单类型集中度 Max(类型占比) ✅ M1 配置环形上的虚线警戒环
大额异动 Δ > 阈值的当期账户

E · 行为/养成指标

"我们的财务习惯养得怎么样?"

指标 含义 v0.1 已做 v1.0 增量
Age of Money(YNAB) 平均资金停留天数 ❌ 不做(预算法语义,与本系统不符)
复利曲线 按当前储蓄率推 N 年 ✅ M3 报表新增"未来推演卡片"
储蓄率 vs 上年同期 YoY ✅ M3
连续填报周期数 协作健康度 ⚠️ 仅 dashboard 角标显示

F · 提醒/到期指标

"我今天/这周该做什么?"

指标 含义 v0.1 已做 v1.0 增量
本期未填账户数 当期红 banner
信用卡还款日(剩余天数) 账户上加字段 due_day ✅ M2(账户加 due_day_of_month 字段)
定期理财到期日 ⚠️ 选做(账户加 maturity_date)
大额异动告警 Δ > 阈值

7.3 指标 → 图表/展现形式映射

数据形态 图表类型 用例 v0.1 v1.0
单一数字 + 同环比 KPI 卡 净资产、储蓄率、紧急储备月数 纯 HTML ⚠️ 无箭头 ✅ 加 ↑↓%
时间序列 折线 / 面积 净资产趋势、负债下降、累计本金 vs 累计收益 Chart.js
多 series 分组 堆叠柱 / 分组柱 月度收入/支出对比、按类别消费分布 Chart.js ⚠️ ✅ 类别分布
整体占比 环形 / 饼 资产配置(类型/币种/账户) Chart.js
流向归因 桑基 收入 → 账户、跨账户资金流 ECharts
起点 → 终点逐项归因 瀑布 上期净资产 + 收入 − 支出 ± 损益 = 本期净资产 ECharts
横扫高密度对比 表格 + sparkline 各账户当前/收益率/趋势小图 HTML + Chart.js
单值进度 进度条 本期填报进度、储蓄率达标 纯 HTML
时间 × 类别热力 日历热力图 消费日历(支出按日聚合) ECharts ✅ M2
多维风险一览 雷达图 流动性 / 负债率 / 集中度 / 紧急储备 / 储蓄率 5 维 Chart.js ✅ M3 报表新增
推演 多段折线 + 阴影区 "保持当前储蓄率 5/10/20 年后净资产" Chart.js ✅ M3
多组合对比 散点 / 箱线 多账户风险收益散点 ❌ 不做(背离颗粒度)

显示原则(v1.0 起强制):

  • KPI 卡必带"对比基准"(同比 / 环比 / 目标);单纯数字不展示
  • 任何 ≥ 2 series 的图表必须有"同期对比"切换(本期 vs 上期 / 同月去年)
  • 数据点 ≤ 12 个的折线/柱状图,直接在点上展示数值(已用 chartjs-plugin-datalabels)
  • 所有金额展示带币种符号 + 单位("¥73.2 万" 比 "732,000" 易读)
  • 颜色语义统一:绿色正向 / 红锈负向 / 铜黄中性高亮 / 墨黑主体

7.4 实时值 vs 过程值 · 设计原则

类别 含义 取数 例子 UI 位置
实时值(Snapshot) 当前时刻的瞬时状态 取最近 CLOSED 周期(若 OPEN 则取 OPEN 末) 净资产、各账户余额、配置占比、负债率、紧急储备月数 KPI 卡、账户列表
过程值(Series) 一段时间序列上的值 多周期数据按周期为 x 轴 净资产趋势、月度收入/支出、储蓄率折线、负债曲线 主图、报表
累计值(YTD/累计) 从某起点到当前的求和 多周期累计 YTD 工资合计、累计投资损益、累计净流入 报表顶部速览卡
比率/归一化 跨期可比的标量 公式归一 XIRR、TWR、储蓄率、负债率 KPI + 报表

v0.1 现状定位:

  • ✅ 仪表盘以实时值为骨,过程值主图(净资产趋势)+ 收支对比图为辅
  • ✅ 报表以过程值比率为骨,累计值目前缺位(v1.0 补"YTD 速览")
  • ⚠️ KPI 卡无"对比基准"小箭头(v1.0 必须补)

7.5 v0.1 已交付指标 vs v1.0 增量(对照)

指标 v0.1 v1.0
净资产 / 总资产 / 总负债 + 同比/环比箭头
紧急储备月数 / 负债率 + 同比箭头
储蓄率(单期 + 12 期均) ⚠️ 单期 + 12 期均线
净资产趋势(线 + 数据点标签)
月度收入/支出/储蓄率 + 类别 hover 下钻
资产配置环形 + 配置漂移条(当前 vs 12 期前)
账户级 sparkline ✅(Dashboard) 移到 Reports
家庭 XIRR / TWR ✅(Reports)
账户级 XIRR ✅(Reports)
月度收支瀑布
收入流向桑基
累计本金 vs 累计收益分解
负债下降曲线
汇率明细
YTD 速览(年初至今) 新增
类别消费趋势(支出分类按月) 新增
消费日历热力图 新增(Reports)
风险雷达图(5 维) 新增(Reports)
复利推演(N 年后净资产) 新增(Reports)
配置漂移条 新增(Dashboard)
KPI 同比/环比箭头 新增(Dashboard)
信用卡还款日提醒 新增(账户加 due_day)
大额异动 banner ⚠️ 软提示 升级为 banner

7.6 v1.0 新增功能编号(挂入 § 4)

下列功能在 v1.0 被纳入 § 4 功能需求,作为 FR-23 起的新增编号(占位声明,详细验收标准在 v1.0 PRD 修订时填写):

ID 标题 优先级 一句话
FR-23 KPI 同比/环比箭头 P0 5 张 KPI 卡每张右下角加 "↑+8.3%" 小标(对比上期 + 上年同期切换)
FR-24 YTD 速览卡片 P0 Reports 顶部新增"年初至今:工资 ¥X / 投资 ±¥Y / 储蓄率 Z% / ΔNetWorth ¥W"
FR-25 配置漂移条 P1 Dashboard 环形图旁新增"vs 12 期前 ±%"对照条
FR-26 类别消费趋势 P1 Reports 加"消费按类别近 12 期堆叠面积"(由 cash_flow_category 分组)
FR-27 消费日历热力图 P1 Reports 全年消费按日上色热力图(ECharts cal-heatmap)
FR-28 风险雷达图 P1 Reports KPI 区下加 5 维雷达(流动性/负债率/集中度/紧急储备/储蓄率)
FR-29 复利推演 P1 Reports 末加"按当前储蓄率推 5/10/20 年净资产"折线
FR-30 大额异动 banner P0 Dashboard 顶部新增"本期 X 个账户出现
FR-31 信用卡还款日提醒 P1 账户加 due_day_of_month 字段,Dashboard 顶部 banner "X 张卡 N 天内到期还款"
FR-32 账户级表格挪到 Reports P0 Dashboard 移除"账户列表"section,在 Reports 加"账户横扫表"(name/type/余额/期内 Δ/累计 XIRR/sparkline)
FR-33 砍 Reports 月度收支对比柱 P0 删除与 Dashboard 重复的"近 12 期收入 vs 支出柱状",其位置由 § FR-24 YTD 速览替代

总计:11 项增量,其中 P0 = 5、P1 = 6。

7.7 v1.0 不做(延后到 v1.1+ 或永远不做)

候选指标 状态 理由
Sharpe 比率 / 夏普 ❌ 永远不做 期颗粒度无法计算 σ,需要日级数据
最大回撤 Max Drawdown ❌ 永远不做 同上;且账户级颗粒度信号弱
复权净值 ❌ 永远不做 需要持仓级,违反 § 6.1 边界
相对基准超额(vs 沪深300) ❌ 永远不做 需持仓级
Age of Money(YNAB) ❌ 不做 预算包络法语义,与本系统的"先记账后归因"理念不符
多账户风险/收益散点 ❌ 不做 颗粒度信号弱,可读性差
实时净资产推送 ❌ 不做 周期化记账,实时无意义

7.8 度量"v1.0 是否成功"的指标

延续 § 1.6 的可观测口径,v1.0 验收看:

  • 每月填报时长仍 ≤ 10 分钟(新增 banner 不阻塞填报路径)
  • Dashboard 首屏加载 ≤ 1.5s(新增 4 个图表后,vendor JS 已 long-cache,不影响)
  • Reports 全部图表渲染完成 ≤ 3s(YTD 卡 + 雷达 + 复利推演 + 类别趋势 + 日历热力图)
  • 11 项 v1.0 新功能至少 9 项被夫妻俩主动使用(被使用 = 30 天内访问 ≥ 1 次)
  • 不引入新数据写入路径(全部从已有 4 张表派生)
  • CSV 导出包不增加表数量(YTD/雷达等都是计算视图,不落库)

7.9 v0.1 维护性变更日志(beta 阶段累积)

此节滚动维护:把每次基于真实使用反馈做的代码/文案/UX 修订记录在此,作为 v1.0 PRD 主体修订的素材源。

2026-05-08 第二批维护(产品打磨)

性能与缓存

  • Spring Security 默认全局 Cache-Control: no-cache, no-store 改为按路径精细控制:/vendor/** /css/** /img/**max-age=1y, public, immutable,HTML 仍 no-cache。模板里所有静态资源引用挂 ?v=${buildVersion} 失效缓存,buildVersion 取 spring-boot-maven-plugin build-info goal 生成的版本号 + 构建时间。
  • 引入 CacheHeaderInterceptor 给动态响应注入 no-cache;新增 WebMvcConfig.addInterceptors 排除静态路径。

前端依赖本地化(墙后可用)

  • cdn.tailwindcss.com / unpkg.com/htmx.org / cdn.jsdelivr.net/chart.js,echarts 全部下到 static/vendor/,模板改本地引用。SecurityConfig/vendor/** /img/** 白名单。
  • 新增 chartjs-plugin-datalabels(vendor/chartjs-plugin-datalabels.min.js)用于在数据点上展示数值。

视觉与图表

  • 新增默认 logo /img/default-logo.svg(印章风,墨色 + 黄铜印泥),family.logoPath = null 时回退显示;/admin/family 预览同步显示。
  • Dashboard 净资产趋势 / 收入支出 / 资产配置三张图统一加图表标题(含当前显示币种),并按"数据点 ≤ 12 必显示数值"的原则全部启用 datalabels。
  • 收入/支出/储蓄率 tooltip itemSort 强制储蓄率排在浮层最上方。
  • Reports 五张图(瀑布、桑基、本金 vs 收益、收支对比、负债曲线)统一加币种标题 + 数据标签 + 金额 tick formatter(¥X.X 万 短格式)。
  • 账户类型 pill 在所有页面统一显示为 中文 (英文)(如 现金 (CASH)),AccountType enum 增加 getLabel() 方法。
  • 资产配置饼图 label 改用换行格式 现金类\n(CASH),前端 split('\n') 给 Chart.js 自动多行渲染。

填报体验

  • "转账"全部改名为"账户间划转"(降低与"消费"的歧义)。
  • 填报页支持 ?account=ID URL 参数过滤单账户,顶部显示"已按账户筛选"banner;"我的待办"页跳转 /entry 时携带该参数。
  • 接收方账户在填报页 row 内显式显示"↳ 已收到来自「招行」的划转 ¥200"(以及反向"↱ 已划出到 ..."),数据来源是 EntryRow.incoming / outgoing 两个新字段,EntryService.toRow 在线计算。
  • 账户编辑改为 dedicated GET /accounts/{id}/edit 专属页(原行内 <details> 弹层是新建模式 UX,误导用户),按钮文案 "保存对账户的修改"。
  • 新建/编辑账户的 type / currency 下拉中文化,例 人民币 (CNY)

外部依赖与 cron

  • FxFetchJob 跑在 cron 0 30 2 1 * ?(每月 1 日 02:30 Asia/Shanghai)— 不是每天。设计上贴合"月度周期"颗粒度。
  • 汇率 API 从 api.exchangerate.host(2024 年起改为收费 + 需 access_key)切换到 api.frankfurter.dev(完全免费、无 key、欧洲央行参考汇率)。请求路径 /v1/latest?base=CNY&symbols=USD,HKD。响应字段保持兼容({rates:{USD:0.xxx,HKD:1.xxx}})。source 字段改为 frankfurter.dev
  • 之前 DB 里现有的 USD/HKD 汇率全部 source=manual / manual-seed,均为种子数据;切换后 /admin/fx 手填仍可覆盖,cron 自动拉取以 frankfurter 为准。

文案清理(避免技术泄露)

  • 全站清除 v0.1 不开放...v0.1 只读 · v0.2 ...v0.3+ 等带版本号的"路线图剧透"文案;统一改为面向用户的中性表达(如"当前为系统内置;后续版本支持自定义编辑")。
  • 登录页"忘了口令"块文案重写,不再暴露具体 admin 路径。
  • 删除 templates/home.html 中遗留的"v0.1 STEP 1 占位页"内容(该模板已不被任何 controller 使用)。

QA

  • docs/qa-cases.md + /tmp/qa-run.sh 自动化回归脚本(70+ 用例,curl 黑盒)持续保持 PASS 66 / FAIL 0 / SKIP 1(SKIP 是 logo 视觉确认)。

后续待办挂入 v1.0 计划

  • 转账接收方除"已收到"提示外,在 /my-todos 增加"待你确认的划转"分组,让用户主动 ✓ / 调整。
  • /admin/fx 增加"立即拉取"按钮(替代等到下月 1 号才生效)。
  • KPI 卡按 § 7.6 FR-23 加同比/环比箭头。
  • 转账重复保护(24h 内同 from/to/amount/period 二次提交)目前抛 IllegalArgumentException → 5xx,UX 不友好;改为返回 200 + fragment 含 modal 二次确认。

2026-05-08 第四批维护(快捷录入语义补完 + 备份/汇率 真实化)

§2.4 / FR-7~9 语义重定义(2026-05-08 修订)

基本概念:本期余额是单一权威值。所有快捷按钮(+ 收入 / - 支出 / ↔ 划转)都是对当前余额做累加调整,不再有"自动平衡 if absent"二态。

进入填报页时:本期余额输入框默认预填上期末值(让"余额不变"零成本——直接提交即可 ✓)。

用户操作 → 系统行为(每次都同时三件事):

操作 余额变化 流水 显示字段更新
+ 收入 X 当前余额 += X cash_flow INCOME +1 条 收入累加 X
- 支出 X 当前余额 -= X cash_flow EXPENSE +1 条 支出累加 X
↔ 划转到 B,X A 余额 -= X,B 余额 += X transfer +1 条 A 转出累加 X,B 转入累加 X
在余额输入框填 N → 提交 当前余额 = N(直接覆盖) unexplained = N − 上期末 − 收入 + 支出 − 转入 + 转出

6 个展示字段语义:

字段 公式 一句话
上期末 上一周期 period_snapshot.end_balance 上月底实测余额(基线)
本期余额 当前 period_snapshot.end_balance(单一权威值) 这个月底应被采信的余额
收入 Σ cash_flow where kind=INCOME 外部资金流入合计
支出 Σ cash_flow where kind=EXPENSE 外部资金流出合计
转入 Σ transfer where to_account=本 系统内其他账户划入合计
转出 Σ transfer where from_account=本 划出到其他账户合计
未解释 本期余额 − 上期末 − 收入 + 支出 − 转入 + 转出 无法被流水解释的差额。STOCK/WEALTH 自动归"投资损益";CASH 标黄"漏登提示"

5 种典型用户路径:

场景 用户操作 余额变化 未解释
1. 余额不变 进入页面后直接提交(输入框已预填上期值) 上期末 → 上期末 0 ✓
2. 余额变化原因不明 直接在输入框填新余额,提交 上期末 → newBalance newBalance − 上期末(归"投资损益"或标"漏登")
3. A→B 转 500 A 行点 ↔ 划转到 B,500 A: 余额 -500;B: 余额 +500 0(A、B 都 ✓)
4. 收入 4000(快捷) + 收入 4000 余额 +4000 0 ✓
5. 反复叠加 + 校准 上期 10000 → +收入 100 → -支出 1000 → 校准 4000 → +收入 200 10000→10100→9100→4000→4200 始终 −5100(校准后定格;后续快捷不影响)

审计:每次 +/-/划转 写一条 audit_log (SNAPSHOT_WRITE),记录"原余额 → 新余额"的变化原因(如"+收入 100 · 余额 10000 → 10100");用户手填新余额另写一条(SNAPSHOT_WRITE)。

2026-05-08 第六批维护(关账 / 开新期 / 全局 UX)

周期生命周期管理增强

  • 立即开下一周期(测试 / 数据初始化场景):管理 → 周期 顶部新增按钮 🚀 立即开下一周期(无 OPEN 周期时主按钮、有 OPEN 周期时灰色次按钮)。POST /admin/periods/open-nextPeriodOpener.openNextNow(familyId):基于"最新一期" period_start 顺延一个月/一周得到下一期 start;复用既有 createPeriodAndTodos 同步生成 snapshot_todo + LOAN 预填(原 v0.1 仅 @Scheduled 每日 0:30 触发,本地测试不再需要等到月初)。

  • 强制关闭账期(管理员代签场景):管理 → 周期 列表行右侧 OPEN 状态新增按钮 关闭账期(红色)。前端 confirm 二次确认。POST /admin/periods/{id}/force-closePeriodService.forceClose:

    1. 找出本期所有 PENDING 账户,UPSERT period_snapshot.end_balance = 上期末(延续语义,note="强制关账:延续上期末余额 X")
    2. 标 todo DONE
    3. 全员 INSERT IGNORE period_member_completion(代签)
    4. 标 status=CLOSED + 异步 MetricsRecomputeJob
    5. 写一条 PERIOD_CLOSE 类型 audit_log,含"代填 N 个账户"摘要

    典型用例:某成员长期未填,管理员决定按"上期末延续"快速关账推进流程,后续可重开 + 修改;实测 13 个 PENDING todo → 全部代填 + CLOSED + metrics_recompute_log 新增 1 行。

全局 Toast(HTMX HX-Trigger)

  • 新增 ToastErrorAdvice (@ControllerAdvice):捕获 IllegalStateException / IllegalArgumentException,对 HTMX 请求(HX-Request 头)返回 200 + 空 body + HX-Trigger: {"showToast":{...}} 头 + HX-Reswap: none(避免 hx-target 行被空 body 覆盖消失)
  • 中文消息走 \uXXXX ASCII 转义,绕过 Tomcat HTTP header 严格 ASCII 检查(否则 Tomcat 会 silently 删头)
  • layout.html footer 全局监听 body.addEventListener('showToast', ...),右上角弹 4 秒后淡出 toast。颜色按 level 分:red(error)/ yellow(warn)/ green(success)/ ink(info)
  • 副作用:24h 重复转账保护从原 500 抛异常改为 200 + toast 友好提示;已 CLOSED 周期点 +/-/划转/校准 也走 toast"周期已关闭,请先重开再修改"

全局 Loading 进度条

  • layout.html <head> 注入顶部 2px 渐变进度条(黄铜色 → 黄铜深色 + 发光阴影,GitHub / Vercel 风格)
  • 触发时机:首屏(requestAnimationFrame → 30%,DOMContentLoaded → 70%,window.load → 100% → 隐藏);跨页 <a> 跳转 + form submit + htmx:beforeRequest 都重新启动到 40%;htmx:afterRequest → 100% → 隐藏
  • 用户体验:首屏感觉网页"在动",而非"卡死";即使 vendor JS 1.7MB 第一次加载,用户能看到顶部进度条流动而非黑屏

2026-05-08 第七批维护(印章 Loading + Ledger 渲染 + LOAN 解耦)

Loading 视觉升级"印章式"

  • 顶部 3px 进度条:墨黑 → 黄铜 → 黄铜深 → 朱红 渐变,加 shimmer 流光扫过 1.4s/loop;阴影发光更鲜明(0 0 12px brass-deep + 0 0 6px rust)
  • 中央全屏 overlay:140×140 印章 SVG 6 步动画(~1.6s 一个周期)
    1. 外框 stroke 渐画(0~60%)
    2. 内框 stroke 渐画(15%~75%)
    3. 中间墨色矩形 scale 0→1 浮现(40%~100%)
    4. 黄铜"账"字 fade-in(55%~100%)
    5. 朱红印泥点 pop(60%~100%,从 .3 → 1.6 → 1)
    6. 外扩 1px brass 描边圆环波纹(100%~)
  • window.load 后整体淡出 .55s(给印章动画完整 1.2s 显示时间)
  • 兜底:任何资源 hang 也最多遮 3.5s
  • 跨页 a 跳转 / form submit / htmx 请求时 progress 重启到 40%,完成时 100% → 淡出

Toast 居中顶部 + 高 z-index

  • 容器从右上角 top-4 right-4 改为 居中顶部 top-6 left-1/2 -translate-x-1/2;z-index: 10000(高于 nav 30 + progress 9999)避免被遮蔽
  • toast 元素加 border-2 + rounded + shadow-2xl,出现时从顶部 -12px 滑入 + 淡入 .3s

Ledger 流水显示修复(避开 Thymeleaf record-nested-List bug)

  • Thymeleaf 在"each + replace + 嵌套 record List 字段"组合下偶发 row.ledger() 解析为 null,导致填报页流水列表不渲染
  • Controller 端新增 renderLedgerHtml(EntryRow) 把整段 ledger HTML 预拼好(li 行 + Tailwind class + 颜色 + 时间 + 备注),塞到 model.ledgerHtmlByAccount: Map<String, String>
  • 模板用 th:utext="${ledgerHtmlByAccount.get(row.account.id + '')}" 直接注入,完全绕开嵌套表达式
  • key 用 String 防 SpEL Long/Integer 自动转型导致 Map.get miss

Reports/ALL 边界修复

  • TwrCalculator.annualized:历史区间累计亏损 ≥ 100% 时 base = 1+x ≤ 0,Math.pow 返回 NaN → BigDecimal.valueOf(NaN) 抛 NumberFormatException;改为 base ≤ 0 或结果 NaN/Infinite 直接 Optional.empty(),前端显示 "—"
  • 影响 /reports?range=ALL 在历史亏损巨大时不再 500

LOAN 账户解耦(产品口径修订)

  • EntryService.adjustLoanDraft 在用户修改 LOAN 余额时自动调整 / 确认一笔从 default_payment_source 的 transfer,会"暗中"改对方账户余额。产品反馈:LOAN 余额修改应与其它账户解耦
  • 撤销自动联动逻辑(方法保留为 no-op,兼容既有调用方)
  • LOAN 唯一保留的特殊性:PeriodOpener.applyLoanPrefill 在新周期开启时根据上期 + 上上期差值 prefill 预填本期余额;还款 transfer 草稿仍生成,但用户在 LOAN 行需主动确认 / 修改后才落地
  • LOAN 通过 AccountClass.LIABILITY(派生自 type=LOAN)继续参与净资产计算 = TotalAssets − TotalLiabilities

周期状态视觉翻正

  • OPEN 从黄铜色改为 绿色(forest) "OPEN · 进行中"(代表"进行中是好状态")
  • CLOSED 从绿色改为 朱红色(rust) "CLOSED · 已结束"(警示已结束,不可随意改)
  • /admin/periods 的 当前周期卡片同步改色

Favicon

  • <link rel="icon" type="image/svg+xml">:用户上传过 logo → /uploads/family-{id}/logo.webp,否则 → /img/default-logo.svg(默认印章 SVG)
  • 同步 alternate icon + apple-touch-icon

ToastErrorAdvice 缩窄到只拦 POST 写操作

  • 原版本捕获所有方法的 IllegalArgumentException / IllegalStateException,导致 GET 页面渲染异常(如 reports/ALL 的 NaN bug)被吞成空 body,QA 不易定位
  • 改为仅 HX-Request + 非 GET 方法时返回 toast;否则原异常 rethrow 走 Spring 默认错误处理

2026-05-08 第八批维护(开账自动延续 + 整块刷新 + 仪表盘实时)

§2.4 开账语义重定义:所有账户默认延续上期末

  • 之前:仅 LOAN 账户在 applyLoanPrefillsnapshot_todo.prefilled_balance(基于上期 + 上上期差值);其它账户开账时无任何 snapshot 记录,行为"待填"态
  • 现在:PeriodOpener.createPeriodAndTodos每个未归档账户:
    1. 计算 prefillBalance:LOAN = prev + (prev - prevPrev) (公式不变);其它账户 = prev
    2. 写入 snapshot_todo.prefilled_balance(供前端预览)
    3. 同时写入 period_snapshot.end_balance = prefillBalance,note="开账自动延续上期末余额 X"
    4. 行立刻 ✓(因 snapshot 存在)
  • 结果:用户开账后进填报页,所有账户默认全部已 ✓,只需修改有变化的账户(快捷按钮在余额上累加,或手填覆盖)
  • 实测开下期后 13 个账户全部自动 prefill,note 全部正确

HTMX 刷新覆盖整块(row + ledger 联动)

  • 原架构:row fragment 内含按钮 hx-target=#entry-row-X;ledger 在 row 外部渲染。POST 提交后只换 row,ledger 列表不刷新 → 用户操作完看不到新流水条
  • 新架构:_row.htmlblock fragment(wrapper div id=entry-block-X),内含 row fragment + 预渲染的 ledger HTML。所有按钮 hx-target 改为 #entry-block-X,response 也返回 block 整块
  • POST /cash-flow /balance /transfer 现在统一返回 entry/_row :: block(row=..., oob=null),含完整 entry-block(row + 流水列表)
  • GET /entry/{id}/refresh 也返回 block,供 HX-Trigger=refresh-row-{id} 自动刷新使用
  • 划转 A→B 后:A 行 swap、B 行被 HX-Trigger 触发自我刷新;两端 ledger 列表都立即出现"+ 划入 / - 划出" 新条目

手动刷新 ⟳ 按钮

  • 每个账户行右上角的余额数字旁加圆形 按钮(7×7 px,带 hover 黄铜色高亮)
  • 点击 → hx-get /entry/{id}/refresh → swap entry-block 整块 → 用户主动同步最新数据 / 流水

仪表盘"实时"刷新

  • dashboard-region 新增 hx-trigger:
    • visibilitychange[document.visibilityState === 'visible'] from:document delay:200ms —— 用户从填报页 tab 切回 dashboard 时,200ms 延迟自动刷新(避免抖动)
    • every 90s —— 即使用户停留在 dashboard,每 90 秒静默自刷一次
  • 后端 dashboard route 本就实时计算(KpiSnapshot/Allocation/AccountPerformance 全部从 DB 实时聚合,不缓存),所以 hx-trigger 只是触发"再请求一次",服务端立即返回最新值
  • 用户体验:在填报页提交后,切回 dashboard 半秒内 KPI 数字、趋势线、配置环形等全部更新

QA 同步

  • 5 步快捷场景 QA 数据 seed 改为直接 INSERT/UPSERT 当期 snapshot=10000(避免 PeriodOpener 自动 prefill 干扰起点)

HX-Trigger refresh 链路

POST /entry/{id}/balance ∣ /cash-flow ∣ /transfer 后,服务端在 HTTP 头加 HX-Trigger: refresh-row-{accountId}(转账场景下额外加目标行的 trigger)。客户端 row 元素声明 hx-trigger="refresh-row-{id} from:body" hx-get="/entry/{id}/refresh",自动触发一次 GET 拉回最新 fragment,确保流水列表 details 完整渲染(规避 Thymeleaf fragment 嵌套渲染 row.ledger 在 POST 路径下偶发 null 的已知问题)。

备份模块从"伪装在跑"变成"真在跑"

之前 backup_log 8 行全是 V5 种子假数据,deploy/backup.sh 没装到 /opt/finance/,finance-backup.timer 也没注册。本批次:

  • backup.sh 复制到 /opt/finance/deploy/backup.sh(加了 --no-tablespaces 静音 mysqldump warning)
  • /etc/systemd/system/finance-backup.{service,timer}(用 root 跑、source /etc/finance.env)
  • systemctl enable --now finance-backup.timer → cron Sun 03:00:00 RandomizedDelaySec=15m
  • 立即手动跑了一次,生成 /var/backup/finance/dump-2026-05-08.sql.gz 12 KB,backup_log 写入 SUCCESS
  • /etc/finance.env 加 BACKUP_DIR / RETENTION_DAYS / FAMILY_ID
  • 删除 OSS / 阿里云 等远端假文案;/admin/backup 改为"本地磁盘 /var/backup/finance/"
  • v0.1 仅本地保留;远端推送(GPG 加密 / 对象存储)挂入 v0.2 计划

汇率自动拉取的真实状态

  • ✅ FxFetchJob @Scheduled cron 0 30 2 1 * ? Asia/Shanghai = 每月 1 日 02:30(贴月度颗粒度,不是每天)
  • ✅ Spring @EnableScheduling 已启用,job 真注册
  • ✅ API 已切到 api.frankfurter.dev(免费、无 access_key、ECB 数据);响应字段兼容
  • ⚠️ DB 现存所有 USD/HKD 都是 source=manual / manual-seed 的种子或手填,没有 source=frankfurter.dev 的 cron 自动拉取记录(系统时钟 5/8 还没到 6/1 触发点)
  • 想立即验证:POST /admin/fx/fetch 手动触发(controller 已存在,模板缺按钮 — 待 v1.0 加 /admin/fx 立即拉取按钮)

2026-05-08 第三批维护(产品打磨补全)

默认 logo + 兜底

  • 重做 /img/default-logo.svg 为"双坡屋顶 + 黄铜色 ¥ 硬币"印章风(原 SVG 辨识度差);DB 种子 V7 把 family.logo_path 已写但物理文件缺失的孤儿值清回 NULL,让逻辑走默认 SVG 分支;<img>onerror="this.src='/img/default-logo.svg'" 兜底,即使用户上传后又删了文件也不会破图。

填报页本期流水明细(FR-7/8/9 配套)

  • EntryRow 新增 incoming / outgoing / ledger 三个字段:incoming 是接收方"已收到来自 X 的 ¥Y"明细列表、outgoing 是发送方"已划出到 X"明细列表、ledger 是本期所有流水合并视图(SNAPSHOT / INCOME / EXPENSE / TRANSFER_IN / TRANSFER_OUT 五类按 occurredAt 排序);EntryService.toRow 在线计算,无需新表。
  • entry/_row.html 展开账户卡片底部新增 <details>"本期流水 · N 笔" 列表,首行类型 + 金额 + 类别/对方 + 时间 + 备注;不分页(单账户单期预期 < 30 条,直接全量);跨期历史走 /reports/period/{id} 下钻页,职责分离。
  • 每条流水的 occurredAt 来自 cash_flow.submitted_at / transfer.submitted_at / period_snapshot.submitted_at,真实记录"什么时候被填的"。

强制改密(原 PRD 文案有但代码缺失)

  • 新增 ProfileController (GET/POST /profile/password)、MustChangePasswordInterceptortemplates/profile/password.html
  • 用户首次登录或被管理员重置密码后(member.must_change_pw=1),除了 /profile/password /logout /login /health + 静态资源外,所有动态请求都被 302 跳到改密页;改密成功后清 SecurityContext 强制重新登录。
  • DevSeedRunner 把 dev profile 的种子用户标记为 must_change_pw=0(避免本地开发 / QA 跑测被拦);V8 migration 同步把已有种子成员的标志清零。

添加成员(原 PRD 标 v0.3,提前到 v0.1)

  • AdminController 加 POST /admin/members;AdminService 加 createMember(...):校验 username 唯一、bcrypt 12 位临时密码、must_change_pw=1,屏幕一次性显示临时密码。
  • templates/admin/members.html 把原"+ 添加成员(后续版本)"禁用按钮改为可用 <details> 弹层 form(用户名 / 显示名 / 角色标签 + 一次性临时密码提示)。
  • PRD § FR-1 既有验收标准"v0.1 UI 隐藏添加成员按钮"作废,新增成员是 v0.1 P0 功能。

Dashboard / Reports 账户筛选器改多选(FR-21 verbatim)

  • 原实现把每个账户作为"只看此账户"的单选链接,与 PRD § FR-21"复选框 + 应用 N 个"不符。
  • 重写 dashboard/_region.html 的展开筛选器为 <form method="get"> + <input type="checkbox" name="accounts" value="{id}"> 多选 + "全选/全清/重置/应用筛选"按钮。
  • DashboardController / ReportsControlleraccounts 参数改为 List<Long>(Spring 自动 multi-binding),内部 join 成 csv 复用既有 parseAccountIds
  • 模板新增 selectedAccountIds model attribute 用于 checkbox checked 状态预填。

账户列表按类型筛选生效

  • /accounts 页面的"筛选 · 全部 / CASH / STOCK / ..."只是静态 <span class="pill"> 标签,完全没接后端
  • 重写为 <a href="?type=CASH"> 链接;AccountController.index?type= 参数,从 model.rows 中过滤;选中类型 pill 高亮 pill-ink;"显示已归档"链接保留当前 type 参数。
  • 各 type 标签按 中文 (英文) 格式显示。

密码字段加"显示"切换

  • 登录页 + 改密页所有密码 input 旁加"显示"按钮(纯 inline JS,切换 input.type=password ↔ text,同步按钮文案"显示" ↔ "隐藏")。
  • tabindex="-1" 不抢 form 焦点,不影响 Enter 提交。

文案 / 视觉细节

  • "转账"全部改"账户间划转"(消除与"消费/还款"的歧义)。
  • 各 admin 子页清掉 v0.1 不开放 / v0.3+ 等版本剧透文案;templates/home.html 占位页内容清空。
  • templates/auth/login.html 底部版本字串改为"ANNO YYYY · 家庭账房",不再露出 buildVersion 给非技术家人。
  • ANNO 年份使用 Thymeleaf #temporals.createNow() 自动跟当年。

QA 用例同步

  • 新增 FR1-7(/admin/members 含"添加成员"入口)、FR1-8(/profile/password 改密页可访问)。
  • 总用例从 66 → 68 PASS / 0 FAIL / 0 SKIP(logo 视觉确认改为通过 admin/family 默认 logo 渲染验证)。
  • /tmp/qa-run.shdocs/qa-cases.md 同步更新。

跨变更引入的字段 / 接口契约(供 TDD 修订)

类 / 表 变更 影响
member.must_change_pw 实际开始被 MustChangePasswordInterceptor 读取并强制 redirect 既有字段,语义首次落地
EntryRow record 新增 incoming / outgoing / ledger 字段 模板需用 null-safe ${row.ledger != null ? ...}
EntryRow.LedgerKind 新枚举 _row.html 模板按 name() 分支着色
account.type 筛选 /accounts?type=CASH query 参数生效 URL pattern 更新
accounts 参数语义 DashboardController/ReportsController 接收 List<Long> URL 改为 ?accounts=1&accounts=2&...(原 ?accounts=1,2兼容性):form GET multi-checkbox
不新增 / 不修改 DB 表 ledger 是计算视图,无落库

v0.1 已实现 vs 缺失的最终核对(2026-05-08 终态)

P0 全部完成 ✅;P1 中 FR-10 已做、FR-15 已可用(改 frankfurter API)、FR-17 仅 banner 不发外部消息(符合 PRD)。

§ 7.6 列出的 v1.0 增量(FR-23~33)是后续工作,v0.1 不强求。本批次维护已经把"原 PRD 写过但实现遗漏"的项目(强制改密、添加成员、多选筛选器、账户类型筛选)全部补完。