| 项 | 值 |
|---|---|
| 版本 | 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 应用。
一个家庭的资产通常分散在多个渠道:股票/基金账户(若干)、银行储蓄/活期(若干)、支付宝/微信钱包/理财、可能的房产或外汇等。日常无系统记录,导致:
- 看不到全局:无法回答"我们家现在共有多少钱"
- 看不到趋势:更无法回答"我们家这一年是变富了还是变穷了"
- 分不清来源:工资攒下来的钱和投资赚的钱混在一起,无从评估投资能力
- 无法异步协作:家庭成员时间错开,缺一个共同载体来"各填各的"
市面方案的问题:支付宝/雪球只能看自家平台,国内通用记账 App 不区分本金和收益,海外开源工具(YNAB / Firefly III / Beancount)中文支持弱或学习曲线高,Excel 缺少自动化(尤其是 XIRR)。
仅在以下三件事上,自建相对 Excel/飞书有真实增量价值:
- 自动 XIRR / 年化收益率
- 移动端友好填报(非技术成员能 5 分钟独立完成)
- 多成员协作(国内记账 App 普遍弱)
其余(记数字、汇总、画图)Excel 都能做。所以 v0.1 必须把这三件事做到位,其他一切先砍。
家庭全部成员连续 6 个数据周期完成填报,且每个成员每周期平均填报时间 ≤ 10 分钟。
低于这条线就视为产品失败 — 因为不可持续。
- 任意时刻能在 1 分钟内回答:"现在我们家共有多少钱?"
- 任意时刻能在 1 分钟内回答:"过去一年我们的工资攒下了多少、投资赚/亏了多少?"
- 任意账户的年化收益率(XIRR)随时可看
- 数据可一键导出 CSV,不被工具绑架
系统不内置"丈夫/妻子/孩子"等具体角色。角色只是成员的一个文本标签(可空),用于 UI 显示;系统逻辑只认"成员"这一抽象概念。
⚠️ 设计原则:任何代码、表结构、UI 不出现husband/wife之类的硬编码;只用通用的member/member_id/display_name/role_label。
系统首次启动时,通过种子 SQL 创建一个家庭 + 初始化两个成员(默认 display_name 写"丈夫"和"妻子",仅作示例,可在 /settings/members 页面任意改名)。这一步是初始化数据,不是产品逻辑。
家庭场景里通常有一个"技术担当"和一个"生活担当",前者容忍小 bug,后者对 UI 的要求高:
- 成员甲(假定为系统建设者本人):熟悉浏览器/手机操作,容忍偶尔小 bug,负责一部分账户
- 成员乙(假定为非技术使用者):主要在手机上,任何"不知道这格填什么"会立即流失,负责另一部分账户
设计 UI 时优先服务"成员乙"心智:她能独立完成 → 整体产品就立得住。
| # | 能力 | 备注 |
|---|---|---|
| 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 全面适配 |
完整 roadmap 见 § 6。
| 指标 | 目标值 | 测量方式 |
|---|---|---|
| 周期填报完成率 | ≥ 95%(连续 6 周期) | 该周期已完成待办 / 当周期应完成待办总数 |
| 非技术成员独立填报时长 | ≤ 5 分钟 | 系统记录该成员首次提交到最后提交的间隔 |
| 主动求助次数 | ≤ 1 次/周期 | 主观统计 |
| 系统可用性 | ≥ 99%(月度) | systemd uptime + nginx 5xx 监控 |
| 数据备份成功率 | 100% | 每周备份 cron job 结果检查 |
| 误差校验通过率 | 100% | NetChange = NetWorth(P) − NetWorth(P-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,未来无需迁移即可支持多家庭。
家庭级配置(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
每当一个新周期开始(月初 / 周一),系统自动为每个成员生成本期待办:
- 每个未归档账户生成 1 条"快照待办":
需要 member_M 填写 account_A 在周期 P 末的余额 - 被分配给
account.primary_owner_member_id;primary_owner为空的账户由"任意成员"完成均可 - 现金流(工资/消费)和转账不是待办 — 视实际发生情况由用户主动登记
每条待办状态:PENDING → DONE(任意成员提交对应快照即标记 DONE)。
成员的"我的待办"列表 = 当前 OPEN 周期里 status=PENDING 且(primary_owner=该成员 或 primary_owner=空)的快照待办。
设计要点:待办只为"应该填的快照"驱动,不为现金流。理由:不漏填快照才是数据完整性的关键;现金流没填只是"分类不细",不破坏数据。
用户对一个账户的本期数据有 4 种最终输入:
| 方式 | 触发 | 写入哪张表 |
|---|---|---|
| 填月末余额 | 在待办或 /entry 主动填 |
monthly_snapshot(改名 period_snapshot) |
| 轧差自动建议(新) | 余额提交后,系统计算"差额 = 新余额 − 上期末 − 已登记现金流 ± 已登记转账",若差额超过阈值,弹层引导用户分类 | 用户选择后写入 cash_flow 或 transfer |
| 直接登记现金流 | "+收入" / "+支出" 按钮 | cash_flow |
| 直接登记转账 | "添加转账" 按钮(联动式或独立式) | transfer |
后三种共同点:都先有"余额"做基线,差额引导是 v0.1 最重要的 UX 创新。
系统内置 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 补充进模板表。
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 账户的快照待办预填默认值:
新周期默认余额 = 上期末余额 + (上期末余额 − 上上期末余额)
= 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 分类(还在轧差引导框里) |
周期 P 开始(M 月 1 日 00:00)
│
▼
┌─────────────────────┐
│ 系统拉取 P-1 期末汇率 │
│ 系统生成本期所有成员 │
│ 的快照待办(N 条) │
└─────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ M 月 1 日 9:00:首轮提醒 │
│ · Dashboard 顶部 banner(v0.1 唯一渠道)│
└─────────────────────────────────────────┘
│
┌───────┴───────┐
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ 成员 A │ │ 成员 B │
│ 异步填报 │ │ 异步填报 │ ← 用户也可不等提醒主动来填
└─────────┘ └─────────┘
│ │
└───────┬───────┘
▼
┌─────────────────────────────┐
│ 系统检测到所有快照待办 = DONE │
│ → 周期 P-1 自动 CLOSED │
│ → 触发指标重算 │
│ → Dashboard 显示完整图表 │
└─────────────────────────────┘关键约束:不要求成员同时在线;系统状态对全体成员实时同步。
| 时间 | 触发事件 |
|---|---|
| 每月 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配置。
整个家庭只走一次。
- 登录(种子的两个账号之一):username 和初始密码由系统部署时插入到 SQL
- 落地页:家庭设置向导
- 第 1 步:确认家庭名称(默认"我们家")、本位币(默认 CNY)、周期类型(默认 MONTHLY)
- 第 2 步:确认/编辑成员列表 — 系统已种子 2 人,这里可以改名、调整 role_label
- 第 3 步:添加账户(账户模板向导)
- 用户挑选模板 → 自定义名称 → 选币种 → 选主要负责人 → 添加
- 可重复添加,直到点"完成"
- 第 4 步:落地 Dashboard(空数据 + 引导文案"去填本期数据")
- 成员 B 首次登录看到的是:已有的家庭、自己的待办列表;无需再走向导
成员 A、成员 B 流程一致,差异仅在于 UI 默认过滤"我的待办"。
- 收到提醒 → 点链接 → 落地
/entry?period=2026-04,默认mine=true(只看自己负责的待办) - 或主动访问 → Dashboard → 点"本期还有 X 个账户未填" → 同样落地
┌─────────────────────────────────────────────┐
│ 📒 招行储蓄卡-工资 (主理人:成员 A) │
│ ───────────────────────────────────────────│
│ 上期末余额 ¥45,000 │
│ │
│ 本期末余额 [ ¥ ] │
│ │
│ ┌─────────────── 提交后展开 ─────────────┐ │
│ │ 检测到本期变化 +¥13,200 │ │
│ │ 已登记的现金流/转账可解释:¥0 │ │
│ │ ⚠️ 还有 ¥13,200 未分类 │ │
│ │ │ │
│ │ 请告诉我这是什么: │ │
│ │ [+ 工资性收入] [+ 其他收入] │ │
│ │ [- 消费/支出] │ │
│ │ [↔ 来自其他账户的转账] │ │
│ │ [📈 投资收益(默认)] │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘- 用户填新余额(从招行 App 直接看到 ¥58,200,粘进来)
- 系统自动轧差:
Δ = 58200 − 45000 = +13200 - 减去已登记的解释(初次填:0)
- 弹引导:
未解释金额 ¥13,200,提供 4 个分类按钮 - 用户点"+ 工资性收入" → 弹快速输入(金额、备注),保存为
cash_flow(INCOME, 工资) - 系统再次轧差,显示
未解释金额 ¥0,本行变 ✓ 绿色 - 如果用户跳过分类直接关掉:差额默认归"投资收益",标软提示"未分类金额已自动归为投资收益,如有误请回来重选"
待办全部 DONE 后,系统页面顶部出现"提交本期"按钮 — 这是显式的"我做完了"信号,即使有些待办默认就是 DONE 状态(没新增需要填)。点击后该成员"本期完成"标记 = true。
系统执行:
- 锁定本期所有数据(进入 CLOSED 状态)
- 后台
MetricsRecomputeJob触发,重新计算所有 PnL / NetWorth / Allocation / XIRR - 下次任一成员登录时,Dashboard 横幅显示"本期已完成 ✅,过去 X 月平均年化 Y%"
场景:成员 A 月薪 ¥30,000 发到"招行工资卡",还有一笔季度奖金 ¥10,000 也发到这张卡。
操作:
- 进入
/entry,招行工资卡行 - 填新余额(打开招行 App 抄过来,假设 ¥75,000)
- 系统轧差
+¥30,000(假设上期末 ¥45,000,本期一切其他动作均为 0)— 等等,实际工资 + 奖金 = 40,000,差额可能还有消费等 - 假设用户开了招行 App 一看:本月入账 ¥40,000(工资 + 奖金),消费 ¥10,000
- 用户分两次点击引导:
- "+ 工资性收入" ¥30,000 类别"工资"
- "+ 工资性收入" ¥10,000 类别"奖金"(系统支持同账户同期多笔 cash_flow)
- "− 消费/支出" ¥10,000 类别"消费"
- 轧差器实时更新:差额从 +30,000 → 0 → -10,000 → +0,本行 ✓
或者更省事的做法:用户嫌麻烦,直接只点了"+ 工资性收入"输入 ¥40,000 + 备注"工资+奖金合并",剩余 ¥10,000 自动归投资损益。这种省事代价是:瀑布图把 ¥10,000 算进了"投资收益",年化 XIRR 偏高。
v0.1 接受这种妥协,用户自己掌握精度。
- 填新余额 = 上期末
- 轧差 = 0
- 不需要任何分类,直接 ✓
- 这正是"10 分钟"约束的支持 — 大量纯现金账户每月只需粘一个数
- 例:支付宝余额从 ¥3,000 → ¥5,500,用户不记得这 ¥2,500 是哪笔
- 选项 1:点"+ 其他收入"备注"不记得",标记后系统不算它进 XIRR 干扰
- 选项 2:跳过,默认归投资收益(理财账户合理;储蓄账户会让它"看起来很赚")
- UI 在储蓄/活期类账户上,如果差额被默认归投资收益,会有黄色警告"本账户类型为现金,异常的'投资收益' ¥2,500 通常说明有未登记的收入,确认?"
- 同上,系统提示分类:"− 消费/支出" 还是"↔ 转出到其他账户"
- 如果用户把它归"消费",符合家庭维度的资金流出
- 如果归"转账",需要选"转入到哪个账户"
- v0.1 不支持月中实时余额冻结
- 但
/entry?period=2026-05可以提前填(本月还在 OPEN),Dashboard 显示但标"本期数据未冻结" - 月底回来再次填 → 覆盖确认(显示"上次填过 ¥X,XXX,确认覆盖?")
- 打开
/entry?period=2026-03(选历史已 CLOSED 月) - 系统提示:"周期 2026-03 已关闭,修改将重新打开 + 重算之后所有指标。继续?"
- 修改 + 提交 → 重新触发
MetricsRecomputeJob,影响范围标黄
场景:房贷余额上期末 ¥1,000,000,本期月供 ¥9,500,其中本金部分约 ¥8,000(银行账单显示房贷余额变成 ¥992,000),利息部分 ¥1,500。
前提:已经在 /admin/account-templates 添加房贷账户时,把"默认还款来源"配为"招行储蓄卡-工资"。
操作:
- 进入新周期填报页
/entry - 房贷账户那一行,系统已经自动预填:
- 本期末余额:¥992,000(基于上期减 8000 的规律)
- 还款 transfer 草稿:招行 → 房贷 ¥8,000
- 用户打开招行 App / 银行账单查实际数字:本期房贷剩 ¥992,000(刚好对上)→ 直接确认 ✓
- (可选)切到招行卡那一行,登记 EXPENSE ¥1,500 类别"利息支出"。如果省略,这 ¥1,500 在招行卡的轧差差额里会被引导分类
- 招行卡的余额从 ¥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 秒登记利息;想快就跳过。
用户行为驱动:你正在更新某账户余额,系统发现差额像转账。
举例:股票账户 A 卖出 ¥10,000 提现到招行储蓄卡。
- 用户先更新"股票账户 A"余额(假设从 ¥80,000 → ¥70,000)
- 系统轧差
−¥10,000 - 弹引导:
未解释金额 −¥10,000 - 用户点"↔ 转出到其他账户"
- 弹层选目标账户"招行储蓄卡-工资",金额预填 ¥10,000,可改备注"季度调仓提现"
- 提交 → 写入
transfer(from=股票A, to=招行, amount=10000)→ 股票 A 行的轧差差额归零 ✓
- 系统轧差
- 用户接着更新"招行储蓄卡-工资"余额(假设从 ¥45,000 → ¥55,000)
- 系统轧差
+¥10,000 - 系统已识别这笔转账(同期、目标=招行的 transfer 已存在)→ 自动用它解释差额
- 弹引导:
未解释金额 ¥0(¥10,000 已由转账解释),本行直接 ✓
- 系统轧差
用户行为驱动:你直接想登记一笔从某账户出发的转账,不管余额还没填。
- 每个账户行右侧的"↔"快速图标按钮,出现在所有展示账户列表的页面:
- Dashboard 底部账户列表
- Accounts 桌面表格(操作列)
- Accounts 移动端卡片(底部按钮区)
- Entry 填报行(已存在,见 § 3.4)
- 点击 → 弹层:源账户预填为本行,选目标账户、金额、备注
- 提交 → 写入 transfer 表;之后填两端余额时,轧差器会用它解释差额
- 不在顶栏放"+ 转账"全局按钮 — 行级触发已能覆盖所有场景且更精确(源账户自动确定)
同一对账户、同金额、同周期、间隔 < 24 小时的 transfer 二次登记 → 触发 modal:"看起来像重复,确认要再登记一笔吗?"
例:招行美元活期 → 招行人民币活期(换汇)。
- v0.1 简化:只登记一笔 transfer(以源账户币种 + 金额为准)
- 因汇率引发的差异自动归"投资损益"
- v0.2 计划单独识别"汇率损益"
→ 走 cash_flow(EXPENSE),不要错记成 transfer。
房贷月供属于"钱离开家庭"(因为变成了房子的产权,而房子作为 PROPERTY 类账户单独估值)→ EXPENSE 而非 transfer。
- 当周期所有
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。
- 站内 banner(必有):Dashboard 顶部,红色,"本期还有 X 个账户未填:招行储蓄卡、华泰证券",点击直跳
/entry - v0.1 不做邮件 / 短信 / 微信推送;只靠登录后的 banner 提示。后续若需要再上(详见 § 6.3 路线图 v0.2)
- 缺漏的影响:
- NetWorth(P):该期跳过缺失账户,UI 标"
⚠️ 数据不完整" - PnL_total(P):同上
- 该账户的 XIRR:数据点 < 3 → 显示"—",数据缺失 > 50% → 显示"—"+
⚠️
- NetWorth(P):该期跳过缺失账户,UI 标"
| 场景 | 处理 |
|---|---|
| 成员忘记密码 | 系统管理员(任一成员)在 /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 周期时切换,必须先关闭当前周期 |
每条:ID · 标题 · 优先级 · 描述 · 验收标准。优先级 P0 = MVP 必备,P1 = MVP 包含但可降级。
描述:一个家庭、多个成员的两层结构。系统不内置任何角色枚举;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_atmember表,字段: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 隐藏"添加"按钮)- 不允许删除成员,只允许归档
- 前端处理:用户在
/admin/family选文件 → 浏览器<canvas>等比缩放到最长边 256px →canvas.toBlob('image/webp', 0.82)编码 → 直接 POST 给后端 - 后端处理:只做 3 件事:
- 校验 Content-Type 是
image/webp+ 文件头前 4 字节是RIFF+ size < 50 KB - 写到
/var/finance/uploads/family-{family_id}/logo.webp - 数据库存相对路径
family-{family_id}/logo.webp
- 校验 Content-Type 是
- 服务:Spring
ResourceHandler把/uploads/**映射到/var/finance/uploads/,响应带 1 年 cache - 删除:管理页"移除 logo"按钮 → 物理文件删除 + DB 置 NULL → header 落回 brand_text 纯文本
- 不做的事(v0.1):服务端 ImageIO 解码、SVG 支持、深度安全(MIME 嗅探/解压炸弹防护) — 因为前端已限定为 WebP 输出,体积也已小到 < 50KB,大幅降低后端处理风险
- 资产:
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 校验)
描述:首次进入 /accounts 时弹出向导,提供内置 12 个最常用账户模板(详见 § 2.5);支持自定义补足。
验收标准:
- 模板列表内置在
account_template表(部署时通过初始化 SQL 文件V2__seed_account_templates.sql灌入) - 模板字段:
code, display_name, type, default_currency, icon - 向导列表展示 12 项,带类型彩色 chip;末项"自定义账户"永远在最后
- 用户挑选后:自定义名称(必填)、币种(默认值,可改)、主要负责人(可空,下拉选成员)、显示顺序
- 一次可添加多个,直至点"完成"
描述:增、改、归档账户;不可删除。账户类型枚举 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 上区别显示:余额带"欠"标签 / 红色,按"负债"分组
描述:家庭级 period_type 配置项,默认 MONTHLY;支持切换 WEEKLY。
验收标准:
/settings/family显示当前周期类型 + 切换按钮- 切换前检查:存在 OPEN 周期时阻塞,提示先关闭
- 切换后:新周期按新类型生成
描述:周期开始时(月 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
描述:每个成员有"我的待办"页;也支持看全家进度。
验收标准:
/my-todos:按周期分组,展示该成员当期 PENDING 待办列表;点击直跳/entry/entry?period=YYYY-MM&mine=true:列表只展示分配给当前成员或 unassigned 的账户/entry?period=YYYY-MM&mine=false:展示全家所有账户(默认折叠他人的)- Dashboard 顶部:"本期进度 · 成员 A:3/4 · 成员 B:1/2"
描述:用户输入新余额,系统轧差出本期变化,引导分类未解释金额。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 的底边导致与备注错位
描述:每账户每期可有 0~多笔 INCOME / EXPENSE。
验收标准:
- 类别枚举:工资 / 奖金 / 其他收入 / 消费 / 还贷 / 转账给亲属 / 其他支出
- 类别可在
cash_flow_category配置表里加(由开发改 SQL,UI 不暴露) - 同账户同期可有多笔(例:工资 + 奖金分两笔)
描述:两条登记路径:轧差弹层联动登记、行级独立按钮(源账户预填)。详见 § 3.7。
验收标准:
- 每个账户行右侧的"↔"图标按钮(行级独立路径,出现在 Dashboard / Accounts / Entry 桌面表格 + Accounts 移动卡片;源账户自动预填为本行)
- 轧差弹层中"↔ 转账"按钮(联动路径)
- 同(from, to, amount, period)且 < 24 小时的二次提交 → modal 二次确认
- 跨币种 v0.1 简化:仅记 from 端币种和金额,差异自动入投资损益
描述:轧差出明显大额异常时(超过阈值),主动提示"看起来像转账"。
验收标准:
- 阈值:|Δ未解释| > ¥3,000(可配置)
- 提示样式:在引导按钮下方多一行"💡 看起来像账户间转账?"
- 不阻塞流程
描述:全员完成 → 周期 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 警示
描述:已关闭周期可手动重开以做修正。
验收标准:
/admin/periods列出所有周期 + 状态- CLOSED 周期点"重新打开",必填重开理由,写入
period_reopen_log(period_id, reopened_by, reopened_at, reason) - 重开后:所有
period_member_completion清除,等所有成员再次提交才能再次关闭
描述:登录后落地。信息层次按业界标准的 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,通过 HTMXhx-get="/dashboard?range=XX" hx-target="#chart-region"切换 - 红 banner:"本期还有 X 个账户未填",点击跳
/entry - 进度条:"成员 A:3/4 · 成员 B:1/2"
- 移动端响应式:KPI 卡支持横向 swipe,主图保留,中部双栏改纵向单图,账户列表卡片化
- 图表组件全部使用 Chart.js(避免 Reports 用的 ECharts 在首页加载,降低首屏成本)
描述:/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 局部刷新 - 移动端:每个图表全宽显示,垂直滚动
描述:本位币可配置(默认 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 警示
描述:/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
- 任意成员均可导出
描述:周期开始后第 1、5、10 天 9:00 重新生成"待填提醒",登录后 Dashboard 顶部红色 banner 显示。v0.1 不发邮件 / 短信 / 微信 — 推送通道留到 v0.2 再考虑。
验收标准:
- Spring
@Scheduledcron job 每周期初标记"提醒已生成" - 站内 banner 显示待填账户列表 + 一键跳
/entry - 缺漏数大于 0 时,Dashboard 始终红色;为 0 时收起 banner
描述:每周日凌晨 3 点 mysqldump,gzip,本地 + 异地(可选)。
验收标准:
- shell 脚本
deploy/backup.sh,systemd timer 触发 - 保留最近 8 周
- 失败写 audit_log,管理页 banner 警示
描述:负债类账户(房贷/车贷/信用卡)的差异化处理。详见 § 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
描述: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 错位
描述: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)
描述:把 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 不要降级 — 都是周期化记账的关键链路。
本节严格定义系统所有指标的公式、数据来源、边界处理和校验恒等式。所有指标都从 4 张原始表(snapshot / cash_flow / transfer / fx_rate)计算而来,不落库(除汇率)。
整个系统所有指标、图表、筛选,本质都是建在一张"大宽表"上的可视化与切片。这是 Star Schema(星型模式)的应用。所有 Dashboard / Reports 内容都从同一个数据视图投影聚合。
┌──────────────────────────┐
│ 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)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 表 |
| 度量 | 含义 | 计算来源 |
|---|---|---|
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)。
| 维度 | 取值 |
|---|---|
| account_id, period_id | FK |
| cash_flow_class | INCOME / EXPENSE(派生于 kind) |
| cash_flow_category | 工资 / 奖金 / 其他收入 / 利息 / 消费 / 还贷 / 转账给亲属 / 其他支出 |
| amount | 度量 |
| § | 指标 | 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) |
| 用户操作 | 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 |
- v0.1:不物化。服务层
FactViewService用 JPA 或纯 SQL,从 4 张原始表 join 出投影。数据量小(~150 行/年/家庭),内存里 SUM / GROUP BY 毋须缓存。 - v0.X(若 fact 行 > 10k):
- 第一步 — MySQL
CREATE VIEW account_period_fact AS ...封装 join,Java 直接SELECT FROM account_period_fact WHERE ... - 第二步 — 若 VIEW 慢,物化为
account_period_fact表,周期关闭时 job 增量同步
- 第一步 — MySQL
- 加新指标无新 SQL。先问"这是 fact view 上的什么 WHERE + GROUP BY",不允许特殊查询。
- 加新维度只动一处。例如未来加
account.tag,所有指标自动可按 tag 切片。 - 测试简化。测 fact view 投影正确,依赖它的所有指标自动正确。
- 跨页一致。Dashboard / Reports / 任意筛选,在数据语义上完全等价,只是 UI 形态不同。
- 审计可追溯。每个数字都能"反编译"出对应的 fact view 查询。
- 新增字段(原 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 原始表。
| 符号 | 含义 |
|---|---|
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} |
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 不单独折算汇率损益
PnL_base(A, P) = PnL(A, P) × FX(currency(A), P)PnL_total(P) = Σ PnL_base(A, P) for A ∈ Active(P)跳过 PnL = null 的账户,UI 标"
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手填
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 上单独"负债"区块展示。
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)) )求 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% |
家庭维度现金流序列:
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 表设计的核心价值。
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)。
SavingsRate(P) = (ExternalIncome(P) − ExternalExpense(P)) / ExternalIncome(P)边界:
ExternalIncome(P) = 0:不可算,UI 显示"—"(本期无收入)- 结果可以为负(消费 > 收入)
- 也可显示滚动 12 期版本:
avg12(SavingsRate)抹平节假日波动
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 折算成月数
DebtToAssetRatio(P) = TotalLiabilities(P) / TotalAssets(P)边界:TotalAssets = 0 时显示"—"(异常,系统应该不会到这种状态)。
展示:Dashboard KPI 卡用百分比;> 50% 标红色警告。
对时间窗口 [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 页堆叠柱图,每期一根柱(本金堆灰、收益堆蓝),回答"我们家变富了多少,其中工资攒下多少,投资赚了多少"。
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 上可看到两者并列
为方便实现,把所有指标按"颗粒度 + 时间形态"分类:
| # | 指标 | 颗粒度 | 形态 | 展示位置 |
|---|---|---|---|---|
| 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 折线 |
NetWorth(P) − NetWorth(P-1)
= ExternalIncome(P) − ExternalExpense(P) + InvestmentPnL(P)
= NetChange(P)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)。
- 跨账户转账漏登记 → 提示用户检查
- 某账户 snapshot 缺失 → 提示去填
- 跨币种汇率折算精度问题(< ¥1) → 容忍
- LOAN 账户余额变化未对应 transfer(用户跳过分类) → 不影响主恒等式(差额已归 PnL),但会让"投资损益"含本金还款,失真;UI 软提示
| 数据 | 来源 | 时机 |
|---|---|---|
余额 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 只为审计 + 通知,不为计算。
| 用途 | 类型 | 小数位 |
|---|---|---|
| 余额 / 现金流 / 转账 | DECIMAL(18, 2) |
2 |
| 汇率 | DECIMAL(18, 6) |
6 |
| 比例(储蓄率、负债率) | DECIMAL(8, 4) |
4 |
| 折算中间值 | BigDecimal,scale=6 |
6 |
| 显示值 | 四舍五入到 2 位 | — |
禁止:用 double / float 做金额计算(浮点误差会破坏 § 5.16 恒等式)。
| 不做的事 | 为什么不做 | 是否未来做 |
|---|---|---|
| 个股/单基金持仓级跟踪 | 与"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+ 可加"长期负债"模块 |
- 跨币种汇率损益不单独标识:转入转出的差异自动归"投资损益"。理由:简化;v0.2 拆出。
- 期内多笔现金流不区分时间:同期合并。理由:满足"10 分钟"约束。
- TWR 不单独计算:只算 XIRR(=DWR)。理由:家庭场景 IRR 更直观。
- 历史数据不可机器导入:从本期起点。理由:v0.1 简化;v0.2 加 CSV 导入。
- Dashboard 不实时(期颗粒度):总资产是上期末快照。理由:契合周期化记账定位。
- 没有撤销按钮:覆盖型修改 + 二次确认替代。理由:简单。
- 轧差引导可被跳过:用户可不分类直接关掉,差额自动归投资收益。理由:不强制;留信任靠提示。
- 周度周期模式 v0.1 仅"保留"不重点测:设计支持,但 v0.1 全部测试用例基于月度。理由:产品定位是月度;周度作未来开关。
- 房贷利息隐式计入投资损益:不强制用户拆分本金和利息。理由:简化;v0.2 自动识别 EXPENSE 中类别=利息的金额并拆出。
- TWR 假设外部现金流发生在期末:月度切片的简化算法。理由:月度颗粒度下精度足够;持仓级才需要日内 TWR。
- 图表用两个库(Chart.js + ECharts):Dashboard 用 Chart.js,Reports 按需 ECharts。理由:Chart.js 不擅长桑基/瀑布,但首屏只用它能压缩首屏成本;ECharts 在 Reports 页才加载。
数据 / 计算:
- 汇率损益从"投资损益"中独立拆出
- 房贷利息从"投资损益"中独立拆出(用
cash_flow.category = '利息'自动识别) - CSV 历史数据导入(配模板,让用户能补录过往年份)
- 滚动 12 期收益率指标
- 各账户 type 的累计收益贡献分解
展示 / 报表新增:
- 资产配置随时间堆叠面积图(招行资产时光机风格)
- 消费日历热力图(ECharts cal-heatmap)
- Reports 旭日图 / 树状图(分类下钻)
- Dashboard 卡片可拖拽 / 自定义
运维 / 协作:
- 数据备份 GPG 加密(异地存储更安全)
- 月度对比报告自动邮件 + PDF
- 微信公众号推送(若邮件不够用)
- 把"周度周期"作为正式支持模式(跑通全部测试)
- 账户模板表开放 admin 编辑(允许加自定义模板)
- 现金流类别开放 admin 编辑
- 持仓级颗粒度(可选模块,数据模型留位
holding/holding_snapshot);开启后才能做基准对比、归因、单券 XIRR - 长期负债的摊销表跟踪(房贷剩余年限、利率、本金/利息每月预测)
- 三人及以上成员 UI(孩子账户 / 父母代管)
- 目标管理("5 年后买房首付 ¥X",自动算"按当前进度能否达成")
- PWA 离线填报
- 收益归因到"行业/地域/币种"(需持仓级)
- 复杂的预算包络系统
- 个股级买卖逐笔记录
- 多租户给陌生家庭用(有限的多家庭数据模型只为代码整洁,不是产品方向)
- 自动对接券商 / 银行 API
- 银行账单 OCR
新功能进入下个版本前,必须通过:
- 不增加单期填报时间(或增量 < 1 分钟)
- 不让非技术成员看不懂(她能在 30 秒内理解功能用途)
- 用了真实数据 ≥ 6 期后才提需求(避免拍脑袋)
- 对应 Excel/飞书有明显不可能或太麻烦的对比理由
- 实现工作量 ≤ 8 小时(否则拆小)
- 连续 3 期任一成员未完成填报
- 非技术成员主动要求"用回 Excel"
- 出现"数据丢失且备份恢复失败"
- Dashboard XIRR 数字常被怀疑算错(信任崩塌)
- 单期填报实际平均时长 > 15 分钟
- 同周期手动重开 > 3 次
任一发生 → 暂停加新功能,做用户访谈 + 数据校验,先解决根因。
本章节为 v1.0 范围内的计划文档:横向对照海外/国内主流理财与记账 App,把它们使用的关键指标按"该指标解决什么问题、用什么图表呈现、属于实时值还是过程值"三个维度归类,给出家庭账房 v1.0 应纳入的增量指标清单。所有"决定不做的指标"在 § 7.4 留白说明,便于未来回看。
出发点:v0.1 已经覆盖净资产/总资产/总负债/紧急储备/负债率/储蓄率/XIRR/TWR/瀑布/桑基/本金vs收益分解/负债曲线等核心 13 项指标;v1.0 在不破坏"10 分钟/期"约束的前提下,精挑能补全"日常决策力"和"长期反思力"的少量增量。
| 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)。
"我们家现在多少钱、风险够不够安全。"
| 指标 | 含义 | v0.1 已做 | v1.0 增量 |
|---|---|---|---|
| 净资产 NetWorth | 总资产 − 总负债 | ✅ | — |
| 总资产 / 总负债 | 含义同字 | ✅ | — |
| 流动资产 | LIQUID(现金类) | ✅ | — |
| 半流动 / 不动 | SEMI_LIQUID / ILLIQUID | ❌ | ✅ KPI 卡或筛选器侧栏显示 |
| 配置占比 | 各 type 占总资产 % | ✅(环形) | — |
| 配置漂移 | 当前配置 vs 设定目标差 | ❌ | ✅(M1) |
| 紧急储备月数 | 流动资产 / 月均消费 | ✅ | — |
| 负债率 | 总负债 / 总资产 | ✅ | — |
| 流动性比率 | 流动资产 / 当月支出 | ❌ | ✅(M1) |
| 单一账户集中度 | Max(账户余额)/ 总资产 | ❌ | |
| 净资产同比/环比 | YoY / MoM 变化箭头 | ❌ | ✅ KPI 卡上的 ↑+8.3% 小标 |
"这一段时间我们家变好/变坏了多少。"
| 指标 | 含义 | v0.1 已做 | v1.0 增量 |
|---|---|---|---|
| 期间收入 | INCOME 合计 | ✅ | — |
| 期间支出 | EXPENSE 合计 | ✅ | — |
| 储蓄率 | (收入 − 支出)/ 收入 | ✅ | — |
| 净资产 ΔPeriod | 期末 − 期初 | ✅ | — |
| 净流入 | 收入 − 支出(剔除内部转账) | ✅ | — |
| 投资损益 | ΔNetWorth − 净流入 | ✅ | — |
| 累计本金 vs 累计收益 | 时序分解 | ✅(报表) | — |
| 收入流向 | 工资/奖金 → 各账户 | ✅(桑基) | — |
| 支出类别趋势 | 消费/还贷 各类别按月 | ❌ | ✅ M2 折线小图 |
| YTD 速览 | 年初至今汇总 | ❌ | ✅ M3 报表顶 KPI 条 |
| 储蓄率 12 期均线 | 滚动 12 期均值 | ❌ |
"以年化口径,我们的钱赚得快不快。"
| 指标 | 含义 | v0.1 已做 | v1.0 增量 |
|---|---|---|---|
| 简单收益率 | 期末/期初 − 1 | ✅(账户级) | — |
| 年化收益率 | 单期年化 | ✅ | — |
| XIRR(IRR / DWR) | 资金加权,带不规则现金流 | ✅(账户+家庭) | — |
| TWR | 时间加权,排除资金进出干扰 | ✅(家庭) | — |
| MWR | 资金加权,XIRR 的同义概念 | ✅(等价 XIRR) | — |
| 最大回撤 Max Drawdown | 历史最大下跌幅 | ❌ | ❌ 砍(账户级颗粒度信号弱) |
| Sharpe | 单位风险收益 | ❌ | ❌ 砍(需 σ,我们只有期颗粒度) |
| 复权净值 | 单基金净值 | ❌ | ❌ 砍(背离账户级颗粒度) |
| 相对基准超额 | vs 沪深300/标普500 | ❌ | ❌ 砍(需持仓级,v0.3+) |
"我们家抗风险能力还行吗?"
| 指标 | 含义 | v0.1 已做 | v1.0 增量 |
|---|---|---|---|
| 紧急储备月数 | 流动资产 / 月均支出 | ✅ | — |
| 负债率 | 总负债 / 总资产 | ✅ | — |
| 信用卡余额 / 工资 | 月度授信压力 | ❌ | |
| 单类型集中度 | Max(类型占比) | ❌ | ✅ M1 配置环形上的虚线警戒环 |
| 大额异动 | Δ | > 阈值的当期账户 |
"我们的财务习惯养得怎么样?"
| 指标 | 含义 | v0.1 已做 | v1.0 增量 |
|---|---|---|---|
| Age of Money(YNAB) | 平均资金停留天数 | ❌ | ❌ 不做(预算法语义,与本系统不符) |
| 复利曲线 | 按当前储蓄率推 N 年 | ❌ | ✅ M3 报表新增"未来推演卡片" |
| 储蓄率 vs 上年同期 | YoY | ❌ | ✅ M3 |
| 连续填报周期数 | 协作健康度 | ❌ |
"我今天/这周该做什么?"
| 指标 | 含义 | v0.1 已做 | v1.0 增量 |
|---|---|---|---|
| 本期未填账户数 | 当期红 banner | ✅ | — |
| 信用卡还款日(剩余天数) | 账户上加字段 due_day | ❌ | ✅ M2(账户加 due_day_of_month 字段) |
| 定期理财到期日 | 同 | ❌ | |
| 大额异动告警 | Δ | > 阈值 |
| 数据形态 | 图表类型 | 用例 | 库 | 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" 易读)
- 颜色语义统一:绿色正向 / 红锈负向 / 铜黄中性高亮 / 墨黑主体
| 类别 | 含义 | 取数 | 例子 | UI 位置 |
|---|---|---|---|---|
| 实时值(Snapshot) | 当前时刻的瞬时状态 | 取最近 CLOSED 周期(若 OPEN 则取 OPEN 末) | 净资产、各账户余额、配置占比、负债率、紧急储备月数 | KPI 卡、账户列表 |
| 过程值(Series) | 一段时间序列上的值 | 多周期数据按周期为 x 轴 | 净资产趋势、月度收入/支出、储蓄率折线、负债曲线 | 主图、报表 |
| 累计值(YTD/累计) | 从某起点到当前的求和 | 多周期累计 | YTD 工资合计、累计投资损益、累计净流入 | 报表顶部速览卡 |
| 比率/归一化 | 跨期可比的标量 | 公式归一 | XIRR、TWR、储蓄率、负债率 | KPI + 报表 |
v0.1 现状定位:
- ✅ 仪表盘以实时值为骨,过程值主图(净资产趋势)+ 收支对比图为辅
- ✅ 报表以过程值和比率为骨,累计值目前缺位(v1.0 补"YTD 速览")
⚠️ KPI 卡无"对比基准"小箭头(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 |
下列功能在 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。
| 候选指标 | 状态 | 理由 |
|---|---|---|
| Sharpe 比率 / 夏普 | ❌ 永远不做 | 期颗粒度无法计算 σ,需要日级数据 |
| 最大回撤 Max Drawdown | ❌ 永远不做 | 同上;且账户级颗粒度信号弱 |
| 复权净值 | ❌ 永远不做 | 需要持仓级,违反 § 6.1 边界 |
| 相对基准超额(vs 沪深300) | ❌ 永远不做 | 需持仓级 |
| Age of Money(YNAB) | ❌ 不做 | 预算包络法语义,与本系统的"先记账后归因"理念不符 |
| 多账户风险/收益散点 | ❌ 不做 | 颗粒度信号弱,可读性差 |
| 实时净资产推送 | ❌ 不做 | 周期化记账,实时无意义 |
延续 § 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/雷达等都是计算视图,不落库)
此节滚动维护:把每次基于真实使用反馈做的代码/文案/UX 修订记录在此,作为 v1.0 PRD 主体修订的素材源。
性能与缓存
- 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=IDURL 参数过滤单账户,顶部显示"已按账户筛选"banner;"我的待办"页跳转/entry时携带该参数。 - 接收方账户在填报页 row 内显式显示"↳ 已收到来自「招行」的划转 ¥200"(以及反向"↱ 已划出到 ..."),数据来源是
EntryRow.incoming/outgoing两个新字段,EntryService.toRow在线计算。 - 账户编辑改为 dedicated GET
/accounts/{id}/edit专属页(原行内<details>弹层是新建模式 UX,误导用户),按钮文案 "保存对账户的修改"。 - 新建/编辑账户的 type / currency 下拉中文化,例
人民币 (CNY)。
外部依赖与 cron
FxFetchJob跑在 cron0 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 视觉确认)。
- 转账接收方除"已收到"提示外,在
/my-todos增加"待你确认的划转"分组,让用户主动 ✓ / 调整。 /admin/fx增加"立即拉取"按钮(替代等到下月 1 号才生效)。- KPI 卡按 § 7.6 FR-23 加同比/环比箭头。
- 转账重复保护(24h 内同 from/to/amount/period 二次提交)目前抛 IllegalArgumentException → 5xx,UX 不友好;改为返回 200 + fragment 含 modal 二次确认。
§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)。
周期生命周期管理增强
-
立即开下一周期(测试 / 数据初始化场景):管理 → 周期 顶部新增按钮
🚀 立即开下一周期(无 OPEN 周期时主按钮、有 OPEN 周期时灰色次按钮)。POST/admin/periods/open-next→PeriodOpener.openNextNow(familyId):基于"最新一期" period_start 顺延一个月/一周得到下一期 start;复用既有createPeriodAndTodos同步生成 snapshot_todo + LOAN 预填(原 v0.1 仅@Scheduled每日 0:30 触发,本地测试不再需要等到月初)。 -
强制关闭账期(管理员代签场景):管理 → 周期 列表行右侧 OPEN 状态新增按钮
关闭账期(红色)。前端confirm二次确认。POST/admin/periods/{id}/force-close→PeriodService.forceClose:- 找出本期所有 PENDING 账户,UPSERT period_snapshot.end_balance = 上期末(延续语义,note="强制关账:延续上期末余额 X")
- 标 todo DONE
- 全员 INSERT IGNORE period_member_completion(代签)
- 标 status=CLOSED + 异步 MetricsRecomputeJob
- 写一条 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 覆盖消失) - 中文消息走
\uXXXXASCII 转义,绕过 Tomcat HTTP header 严格 ASCII 检查(否则 Tomcat 会 silently 删头) layout.htmlfooter 全局监听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 第一次加载,用户能看到顶部进度条流动而非黑屏
Loading 视觉升级"印章式"
- 顶部 3px 进度条:墨黑 → 黄铜 → 黄铜深 → 朱红 渐变,加 shimmer 流光扫过 1.4s/loop;阴影发光更鲜明(0 0 12px brass-deep + 0 0 6px rust)
- 中央全屏 overlay:140×140 印章 SVG 6 步动画(
~1.6s一个周期)- 外框 stroke 渐画(0~60%)
- 内框 stroke 渐画(15%~75%)
- 中间墨色矩形 scale 0→1 浮现(40%~100%)
- 黄铜"账"字 fade-in(55%~100%)
- 朱红印泥点 pop(60%~100%,从 .3 → 1.6 → 1)
- 外扩 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 默认错误处理
§2.4 开账语义重定义:所有账户默认延续上期末
- 之前:仅 LOAN 账户在
applyLoanPrefill设snapshot_todo.prefilled_balance(基于上期 + 上上期差值);其它账户开账时无任何 snapshot 记录,行为"待填"态 - 现在:
PeriodOpener.createPeriodAndTodos对每个未归档账户:- 计算
prefillBalance:LOAN =prev + (prev - prevPrev)(公式不变);其它账户 =prev - 写入
snapshot_todo.prefilled_balance(供前端预览) - 同时写入 period_snapshot.end_balance = prefillBalance,note="开账自动延续上期末余额 X"
- 行立刻 ✓(因 snapshot 存在)
- 计算
- 结果:用户开账后进填报页,所有账户默认全部已 ✓,只需修改有变化的账户(快捷按钮在余额上累加,或手填覆盖)
- 实测开下期后 13 个账户全部自动 prefill,note 全部正确
HTMX 刷新覆盖整块(row + ledger 联动)
- 原架构:row fragment 内含按钮 hx-target=
#entry-row-X;ledger 在 row 外部渲染。POST 提交后只换 row,ledger 列表不刷新 → 用户操作完看不到新流水条 - 新架构:
_row.html加block 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→ cronSun 03:00:00 RandomizedDelaySec=15m- 立即手动跑了一次,生成
/var/backup/finance/dump-2026-05-08.sql.gz12 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立即拉取按钮)
默认 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)、MustChangePasswordInterceptor、templates/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/ReportsController的accounts参数改为List<Long>(Spring 自动 multi-binding),内部 join 成 csv 复用既有parseAccountIds。- 模板新增
selectedAccountIdsmodel 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.sh与docs/qa-cases.md同步更新。
| 类 / 表 | 变更 | 影响 |
|---|---|---|
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 是计算视图,无落库 |
P0 全部完成 ✅;P1 中 FR-10 已做、FR-15 已可用(改 frankfurter API)、FR-17 仅 banner 不发外部消息(符合 PRD)。
§ 7.6 列出的 v1.0 增量(FR-23~33)是后续工作,v0.1 不强求。本批次维护已经把"原 PRD 写过但实现遗漏"的项目(强制改密、添加成员、多选筛选器、账户类型筛选)全部补完。