已掌握足够上下文。开始产出 TDD 文档。
| 项 | 值 |
|---|---|
| 文档 | 家庭资产管理系统 · 技术设计文档(TDD) |
| 版本 | v0.1 · DRAFT |
| 创建时间 | 2026-05-06 |
| 关联 PRD | /root/financial-management/prd/v0.1.md(v0.1 已封板) |
| 关联预览 | /root/financial-management/preview/index.html(15 页 Tailwind/Chart.js/ECharts 静态稿) |
| 状态 | 进入研发实施;后续微调走 PR review,大改走 v0.2 |
| 阅读对象 | 实现工程师(自己 + 协作者) |
| 阅读时长 | 30~40 分钟 |
本 TDD 不重复 PRD 中已写明的内容(产品定义 / 周期模型 / 计算口径 / FR 列表),只解决"贵到改不起"的实现问题:库选型、DDL、包结构、事务边界、文件管道、迁移与部署。任何与 PRD 冲突的地方,以 PRD 为准。
这一节存在的意义:把研发动手前的硬约束钉死,后面所有决策都要回头对照这几条。
产品约束(摘自 PRD):
- 一个家庭(v0.1 仅 1 个 family,但表结构留
family_id字段以备未来) - 2 名成员(v0.1 UI 不暴露"加成员",数据模型支持 N)
- 月度周期为主,周度为开关项(详见 PRD § 2.2)
- "10 分钟/期"硬约束 — 任何架构动作不应让此预算抖动
- 数据量上限:2 人 × ~15 账户 × 12 期/年 ≈ 180 行/年 snapshot,生命周期可达 20 年仍 < 4k 行;不需缓存层
技术约束:
| 类别 | 选型 | 不引入的东西 |
|---|---|---|
| 后端 | Spring Boot 3.3.x · Java 21(virtual threads 备用) | Spring WebFlux、Kotlin |
| ORM | MyBatis 3.5 + mybatis-spring-boot-starter 3.0 | Spring Data JPA、jOOQ、Hibernate |
| 视图 | Thymeleaf 3.1 + HTMX 1.9 | React/Vue/SPA |
| 图表 | Chart.js 4(Dashboard)+ ECharts 5(Reports 按需) | D3 |
| 安全 | Spring Security 6.3 + bcrypt + RememberMe(30d) | OAuth、2FA |
| DB | MySQL 8.0 · 版本化 SQL 文件由 deploy.sh 顺序执行(无 Flyway/Liquibase) | PostgreSQL、SQLite、迁移工具 |
| 测试 | JUnit 5 + Testcontainers MySQL + MockMvc + Apache Commons Math 3.6.1 | Cucumber、Selenium |
| 图像 | Apache Tika(MIME 嗅探)+ imgscalr 4.2 + scrimage-webp 4.x(WebP 编码) | ImageMagick CLI |
| 部署 | jar(app.jar)+ systemd + 已有 nginx(TLS 在用户更上游处终结,本服务接收 HTTP) |
Docker、K8s、消息队列、Redis |
性能约束(SLO):
- Dashboard 首屏 P95 < 1.0s(本地 LAN)
- 单页 HTMX 局部刷新 P95 < 200ms
MetricsRecomputeJob异步,UI 不等- 备份每周一次,RPO ≤ 7 天(可接受)
安全约束:
- 公网请求由用户上游(域名服务/CDN)终结 TLS,经其反代到本机 nginx 时已是 HTTP;本服务直接接收 HTTP,不在 Spring 层启用 SSL/Secure-only
- 上传仅图片(WebP/JPEG/PNG/SVG),受 § 7 安全清单约束
- 所有写操作记审计日志(详见 PRD § FR-20 /
/admin/audit)
这一节存在的意义:包边界一旦定下,后期跨包重构就贵。把 fact view 抽象、calculator 纯函数、controller 薄层在包结构上预先反映出来。
com.family.finance
├── FinanceApplication.java # @SpringBootApplication 启动类
│
├── config/ # 配置类(横切)
│ ├── SecurityConfig.java # SecurityFilterChain · RememberMe · 路径白名单
│ ├── WebMvcConfig.java # ResourceHandler /uploads/** · MessageSource · LocaleResolver
│ ├── AsyncConfig.java # @EnableAsync · TaskExecutor("metrics","fx")
│ ├── SchedulingConfig.java # @EnableScheduling
│ ├── JpaConfig.java # AuditingEntityListener、事务管理器
│ ├── ImageConfig.java # MaxFileSize · MultipartResolver
│ └── AppProperties.java # @ConfigurationProperties("app")
│
├── domain/ # 纯 POJO 实体 / 值对象 / 枚举(无 ORM 注解)
│ ├── family/ Family.java
│ ├── member/ Member.java
│ ├── account/ Account.java AccountTemplate.java AccountType.java AccountClass.java AccountLiquidity.java
│ ├── period/ Period.java PeriodStatus.java PeriodType.java PeriodMemberCompletion.java
│ ├── snapshot/ PeriodSnapshot.java SnapshotTodo.java TodoStatus.java
│ ├── flow/ CashFlow.java CashFlowKind.java CashFlowCategory.java
│ ├── transfer/ Transfer.java
│ ├── fx/ FxRate.java
│ └── audit/ AuditLog.java AuditLogType.java
│
├── repository/ # MyBatis @Mapper 接口(写 + 读)
│ ├── FamilyMapper.java # 简单 CRUD 用 @Insert/@Select 注解写
│ ├── MemberMapper.java
│ ├── AccountMapper.java
│ ├── PeriodMapper.java
│ ├── SnapshotMapper.java
│ ├── CashFlowMapper.java
│ ├── TransferMapper.java
│ ├── FxMapper.java
│ ├── AuditMapper.java
│ └── FactMapper.java # ★ FactView 唯一查询入口(配 mapper/FactMapper.xml)
│
├── factview/ # ★ 大宽表抽象(PRD § 5.0)
│ ├── FactViewService.java # 接口 — 所有指标的唯一数据来源
│ ├── FactViewServiceImpl.java
│ ├── dto/
│ │ ├── AccountPeriodFact.java # one row per (account × period)
│ │ ├── FactFilter.java # 筛选条件值对象(成员/类型/账户ID/期窗口)
│ │ ├── ViewCurrency.java # FR-22 显示币种
│ │ └── projection/
│ │ ├── KpiSnapshot.java
│ │ ├── TrendPoint.java
│ │ └── AllocationSlice.java
│ └── slice/ # 各种"切片"(FactView 上的 GROUP BY)
│ ├── KpiSlice.java
│ ├── TrendSlice.java
│ ├── AllocationSlice.java
│ └── WaterfallSlice.java
│
├── service/ # 写规则(domain service)
│ ├── account/ AccountService.java AccountTemplateService.java
│ ├── period/ PeriodService.java PeriodOpener.java PeriodCloser.java PeriodReopener.java
│ ├── snapshot/ SnapshotService.java # ★ 余额录入 + 轧差引擎
│ ├── flow/ CashFlowService.java
│ ├── transfer/ TransferService.java # ★ 重复检测、跨币种简化
│ ├── fx/ FxRateService.java FxFetchJob.java
│ ├── loan/ LoanPrefillService.java # ★ LOAN 预填(PRD § 2.6 / FR-19)
│ ├── todo/ TodoService.java
│ ├── auth/ MemberAuthService.java # bcrypt · 临时密码生成
│ ├── audit/ AuditLogService.java # 审计日志写入器
│ ├── upload/ LogoUploadController.java # ★ Logo 上传(前端压缩,详见 § 7)
│ ├── export/ CsvExportService.java # ZIP 导出
│ └── recompute/ MetricsRecomputeJob.java # @Async,周期关闭后触发
│
├── calc/ # ★ 纯函数计算层(无 DB · 易测)
│ ├── PnlCalculator.java # PRD § 5.2-5.5
│ ├── XirrCalculator.java # BrentSolver 包装
│ ├── TwrCalculator.java # 月度切片连乘
│ ├── AllocationCalculator.java
│ ├── EmergencyFundCalculator.java
│ ├── DebtRatioCalculator.java
│ └── IdentityVerifier.java # PRD § 5.16 主恒等式校验
│
├── web/ # Controller 层(薄)
│ ├── advice/ GlobalExceptionHandler.java GlobalModelAttributes.java
│ ├── dashboard/ DashboardController.java DashboardFragmentController.java
│ ├── entry/ EntryController.java # ★ 轧差实时端点(HTMX)
│ ├── todo/ MyTodosController.java
│ ├── account/ AccountController.java AccountTemplateController.java
│ ├── report/ ReportController.java
│ ├── admin/ AdminController.java AdminFamilyController.java
│ │ AdminMembersController.java AdminPeriodsController.java
│ │ AdminFxController.java AdminBackupController.java AdminAuditController.java
│ ├── auth/ LoginController.java PasswordResetController.java
│ ├── export/ ExportController.java
│ └── api/ # JSON 端点(若需 — v0.1 几乎不用)
│
└── util/
├── MoneyMath.java # BigDecimal 加减 · 半向偶数舍入
├── DateRanges.java # 周期起止日的工具
├── PathSafety.java # 上传路径合法性
└── HtmxResponses.java # HTMX 响应头封装| 包 | 写权 | 读权 | 不允许 |
|---|---|---|---|
domain |
— | — | 业务规则、IO、注入 service |
repository |
仅基础 CRUD | 表行 → 实体 | 跨表 join · 业务逻辑 |
repository.jdbc |
— | 复杂只读 SQL · 投影 DTO | 写操作 |
factview |
— | 通过 repository.jdbc 读 |
直接 join 原始表(必须经 fact 投影) |
service.* |
单聚合事务边界 | 通过自己的 repo | 跨服务直接调 repo |
calc.* |
— | — | DB · IO · 注入 service(纯函数) |
web.* |
通过 service | 通过 factview / service | 直接 SQL · 直接调 calc(经 service 编排) |
这一节存在的意义:DDL 改不起。本节定型即是 v0.1 的"宪法",任何修改新增一个
V<n>__xxx.sql文件,部署时由deploy.sh顺序执行。所有 ENUM 用字符串列 + CHECK,避免 MySQL 原生 ENUM(增删枚举值要 ALTER TABLE,可移植性差)。
-- =========================================================
-- family — 顶层租户(v0.1 仅 1 行)
-- =========================================================
CREATE TABLE family (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(60) NOT NULL,
brand_text VARCHAR(60) NOT NULL DEFAULT '账房',
logo_path VARCHAR(255) NULL, -- 相对 /var/finance/uploads/
base_currency CHAR(3) NOT NULL DEFAULT 'CNY',
period_type VARCHAR(8) NOT NULL DEFAULT 'MONTHLY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT pk_family PRIMARY KEY (id),
CONSTRAINT ck_family_currency CHECK (base_currency IN ('CNY','USD','HKD')),
CONSTRAINT ck_family_period CHECK (period_type IN ('MONTHLY','WEEKLY'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- =========================================================
-- member — 成员
-- =========================================================
CREATE TABLE member (
id BIGINT NOT NULL AUTO_INCREMENT,
family_id BIGINT NOT NULL,
username VARCHAR(40) NOT NULL,
password_hash CHAR(60) NOT NULL, -- bcrypt 固定 60
display_name VARCHAR(40) NOT NULL,
role_label VARCHAR(20) NULL, -- 仅 UI 文本
must_change_pw TINYINT(1) NOT NULL DEFAULT 0,
archived_at DATETIME(3) NULL,
last_login_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT pk_member PRIMARY KEY (id),
CONSTRAINT uk_member_username UNIQUE (username),
CONSTRAINT fk_member_family FOREIGN KEY (family_id) REFERENCES family(id)
) ENGINE=InnoDB;
-- =========================================================
-- account_template — 内置账户模板(v0.1 灌入 13 条 + "自定义" 1 条)
-- =========================================================
CREATE TABLE account_template (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(40) NOT NULL, -- 业务码,如 'cmb_savings'
display_name VARCHAR(60) NOT NULL,
type VARCHAR(10) NOT NULL,
default_currency CHAR(3) NOT NULL DEFAULT 'CNY',
icon VARCHAR(40) NULL,
sort_order INT NOT NULL DEFAULT 0,
is_custom_slot TINYINT(1) NOT NULL DEFAULT 0, -- "自定义账户"占位项
CONSTRAINT pk_account_template PRIMARY KEY (id),
CONSTRAINT uk_account_template UNIQUE (code),
CONSTRAINT ck_account_template_type CHECK (type IN ('STOCK','CASH','WEALTH','PROPERTY','LOAN','OTHER'))
) ENGINE=InnoDB;
-- =========================================================
-- account — 账户
-- =========================================================
CREATE TABLE account (
id BIGINT NOT NULL AUTO_INCREMENT,
family_id BIGINT NOT NULL,
template_id BIGINT NULL, -- 自定义账户为 NULL
display_name VARCHAR(80) NOT NULL,
type VARCHAR(10) NOT NULL,
currency CHAR(3) NOT NULL,
primary_owner_member_id BIGINT NULL,
default_payment_source_account_id BIGINT NULL, -- LOAN 专属
display_order INT NOT NULL DEFAULT 0,
archived_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT pk_account PRIMARY KEY (id),
CONSTRAINT fk_account_family FOREIGN KEY (family_id) REFERENCES family(id),
CONSTRAINT fk_account_template FOREIGN KEY (template_id) REFERENCES account_template(id),
CONSTRAINT fk_account_owner FOREIGN KEY (primary_owner_member_id) REFERENCES member(id),
CONSTRAINT fk_account_payment_source FOREIGN KEY (default_payment_source_account_id) REFERENCES account(id),
CONSTRAINT ck_account_type CHECK (type IN ('STOCK','CASH','WEALTH','PROPERTY','LOAN','OTHER')),
CONSTRAINT ck_account_currency CHECK (currency IN ('CNY','USD','HKD'))
) ENGINE=InnoDB;
-- =========================================================
-- period — 周期(由 cron 创建,每月/每周一次)
-- =========================================================
CREATE TABLE period (
id BIGINT NOT NULL AUTO_INCREMENT,
family_id BIGINT NOT NULL,
period_type VARCHAR(8) NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
status VARCHAR(8) NOT NULL DEFAULT 'OPEN',
closed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT pk_period PRIMARY KEY (id),
CONSTRAINT uk_period_natural UNIQUE (family_id, period_type, period_start),
CONSTRAINT fk_period_family FOREIGN KEY (family_id) REFERENCES family(id),
CONSTRAINT ck_period_status CHECK (status IN ('OPEN','CLOSED')),
CONSTRAINT ck_period_type CHECK (period_type IN ('MONTHLY','WEEKLY'))
) ENGINE=InnoDB;
-- =========================================================
-- period_snapshot — 期末余额(账户 × 周期 1:1)
-- =========================================================
CREATE TABLE period_snapshot (
id BIGINT NOT NULL AUTO_INCREMENT,
period_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
end_balance DECIMAL(18,2) NOT NULL, -- LOAN 类型为负
submitted_by BIGINT NOT NULL, -- member.id
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
note VARCHAR(255) NULL,
CONSTRAINT pk_period_snapshot PRIMARY KEY (id),
CONSTRAINT uk_period_snapshot UNIQUE (period_id, account_id),
CONSTRAINT fk_period_snapshot_p FOREIGN KEY (period_id) REFERENCES period(id),
CONSTRAINT fk_period_snapshot_a FOREIGN KEY (account_id) REFERENCES account(id),
CONSTRAINT fk_period_snapshot_by FOREIGN KEY (submitted_by) REFERENCES member(id)
) ENGINE=InnoDB;
-- =========================================================
-- cash_flow — 现金流(账户 × 周期 1:N)
-- =========================================================
CREATE TABLE cash_flow (
id BIGINT NOT NULL AUTO_INCREMENT,
period_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
kind VARCHAR(8) NOT NULL, -- INCOME / EXPENSE
category_code VARCHAR(40) NOT NULL, -- 软引用 cash_flow_category
amount DECIMAL(18,2) NOT NULL, -- 始终正数,语义由 kind 决定
occurred_at DATE NULL, -- 可选,默认期末
note VARCHAR(255) NULL,
submitted_by BIGINT NOT NULL,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT pk_cash_flow PRIMARY KEY (id),
CONSTRAINT fk_cash_flow_p FOREIGN KEY (period_id) REFERENCES period(id),
CONSTRAINT fk_cash_flow_a FOREIGN KEY (account_id) REFERENCES account(id),
CONSTRAINT fk_cash_flow_by FOREIGN KEY (submitted_by) REFERENCES member(id),
CONSTRAINT ck_cash_flow_kind CHECK (kind IN ('INCOME','EXPENSE')),
CONSTRAINT ck_cash_flow_amount CHECK (amount > 0)
) ENGINE=InnoDB;
-- =========================================================
-- cash_flow_category — 类别字典(v0.1 只读;开发改 SQL 加)
-- =========================================================
CREATE TABLE cash_flow_category (
code VARCHAR(40) NOT NULL,
display_name VARCHAR(40) NOT NULL,
kind VARCHAR(8) NOT NULL, -- INCOME / EXPENSE / BOTH
sort_order INT NOT NULL DEFAULT 0,
CONSTRAINT pk_cash_flow_category PRIMARY KEY (code),
CONSTRAINT ck_cash_flow_cat_kind CHECK (kind IN ('INCOME','EXPENSE','BOTH'))
) ENGINE=InnoDB;
-- =========================================================
-- transfer — 跨账户转账(账户 × 周期 1:N)
-- =========================================================
CREATE TABLE transfer (
id BIGINT NOT NULL AUTO_INCREMENT,
period_id BIGINT NOT NULL,
from_account_id BIGINT NOT NULL,
to_account_id BIGINT NOT NULL,
amount DECIMAL(18,2) NOT NULL, -- 始终正数,以 from 端币种计
occurred_at DATE NULL,
note VARCHAR(255) NULL,
submitted_by BIGINT NOT NULL,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
is_draft TINYINT(1) NOT NULL DEFAULT 0, -- LOAN 自动预填的草稿
CONSTRAINT pk_transfer PRIMARY KEY (id),
CONSTRAINT fk_transfer_p FOREIGN KEY (period_id) REFERENCES period(id),
CONSTRAINT fk_transfer_from FOREIGN KEY (from_account_id) REFERENCES account(id),
CONSTRAINT fk_transfer_to FOREIGN KEY (to_account_id) REFERENCES account(id),
CONSTRAINT fk_transfer_by FOREIGN KEY (submitted_by) REFERENCES member(id),
CONSTRAINT ck_transfer_distinct CHECK (from_account_id <> to_account_id),
CONSTRAINT ck_transfer_amount CHECK (amount > 0)
) ENGINE=InnoDB;
-- =========================================================
-- fx_rate — 期末汇率
-- =========================================================
CREATE TABLE fx_rate (
id BIGINT NOT NULL AUTO_INCREMENT,
family_id BIGINT NOT NULL,
base_currency CHAR(3) NOT NULL,
quote_currency CHAR(3) NOT NULL,
period_id BIGINT NOT NULL,
rate DECIMAL(18,6) NOT NULL, -- 1 quote = rate × base
source VARCHAR(40) NOT NULL, -- 'exchangerate.host' / 'manual'
fetched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT pk_fx_rate PRIMARY KEY (id),
CONSTRAINT uk_fx_rate_natural UNIQUE (family_id, base_currency, quote_currency, period_id),
CONSTRAINT fk_fx_rate_family FOREIGN KEY (family_id) REFERENCES family(id),
CONSTRAINT fk_fx_rate_period FOREIGN KEY (period_id) REFERENCES period(id),
CONSTRAINT ck_fx_rate_positive CHECK (rate > 0)
) ENGINE=InnoDB;
-- =========================================================
-- snapshot_todo — 待办(每期为每个未归档账户生成 1 条)
-- =========================================================
CREATE TABLE snapshot_todo (
id BIGINT NOT NULL AUTO_INCREMENT,
period_id BIGINT NOT NULL,
account_id BIGINT NOT NULL,
assigned_member_id BIGINT NULL, -- = account.primary_owner_member_id
status VARCHAR(8) NOT NULL DEFAULT 'PENDING',
done_at DATETIME(3) NULL,
done_by_member_id BIGINT NULL,
prefilled_balance DECIMAL(18,2) NULL, -- LOAN 自动预填值
prefilled_transfer_id BIGINT NULL, -- LOAN 自动预填的草稿 transfer
CONSTRAINT pk_snapshot_todo PRIMARY KEY (id),
CONSTRAINT uk_snapshot_todo_natural UNIQUE (period_id, account_id),
CONSTRAINT fk_snapshot_todo_p FOREIGN KEY (period_id) REFERENCES period(id),
CONSTRAINT fk_snapshot_todo_a FOREIGN KEY (account_id) REFERENCES account(id),
CONSTRAINT fk_snapshot_todo_assignee FOREIGN KEY (assigned_member_id) REFERENCES member(id),
CONSTRAINT fk_snapshot_todo_done_by FOREIGN KEY (done_by_member_id) REFERENCES member(id),
CONSTRAINT fk_snapshot_todo_pre_xfer FOREIGN KEY (prefilled_transfer_id) REFERENCES transfer(id),
CONSTRAINT ck_snapshot_todo_status CHECK (status IN ('PENDING','DONE'))
) ENGINE=InnoDB;
-- =========================================================
-- period_member_completion — 成员"我已记完"显式信号
-- =========================================================
CREATE TABLE period_member_completion (
id BIGINT NOT NULL AUTO_INCREMENT,
period_id BIGINT NOT NULL,
member_id BIGINT NOT NULL,
completed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT pk_pmc PRIMARY KEY (id),
CONSTRAINT uk_pmc UNIQUE (period_id, member_id),
CONSTRAINT fk_pmc_p FOREIGN KEY (period_id) REFERENCES period(id),
CONSTRAINT fk_pmc_m FOREIGN KEY (member_id) REFERENCES member(id)
) ENGINE=InnoDB;
-- =========================================================
-- audit_log — 审计日志(所有可配置项的修改、周期重开、密码重置、cron 失败)
-- =========================================================
CREATE TABLE audit_log (
id BIGINT NOT NULL AUTO_INCREMENT,
family_id BIGINT NOT NULL,
actor_member_id BIGINT NULL, -- 系统操作时为 NULL
type VARCHAR(40) NOT NULL, -- PERIOD_REOPEN / FAMILY_UPDATE / ...
target_type VARCHAR(40) NULL, -- 'period' / 'account' / ...
target_id BIGINT NULL,
summary VARCHAR(255) NOT NULL,
payload_json JSON NULL, -- 变更详情(from→to)
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT pk_audit_log PRIMARY KEY (id),
CONSTRAINT fk_audit_family FOREIGN KEY (family_id) REFERENCES family(id),
CONSTRAINT fk_audit_actor FOREIGN KEY (actor_member_id) REFERENCES member(id)
) ENGINE=InnoDB;
-- =========================================================
-- persistent_logins — Spring Security RememberMe(JdbcTokenRepositoryImpl 标准建表)
-- =========================================================
CREATE TABLE persistent_logins (
series VARCHAR(64) NOT NULL,
username VARCHAR(64) NOT NULL,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (series)
) ENGINE=InnoDB;PRD 大纲提到一个独立 ENUM 表。决策:不建独立 ENUM 表,直接用 cash_flow.kind VARCHAR(8) CHECK ('INCOME','EXPENSE')。理由:只有两种取值且不会再扩,独立表反而增加 join 成本。备选:若未来加 'ADJUSTMENT' 等,迁移到 cash_flow_kind 字典表(走 V?__add_cash_flow_kind.sql)。
决策:外键自动索引 + 高频复合索引。MySQL InnoDB 对单表写入 < 1k QPS 不需要进一步调优。
-- 外键自动索引由 InnoDB 创建,无需重复
-- 高频:按期 × 账户读 fact
CREATE INDEX ix_period_snapshot_period ON period_snapshot(period_id);
CREATE INDEX ix_cash_flow_period_account ON cash_flow(period_id, account_id);
CREATE INDEX ix_transfer_period_from ON transfer(period_id, from_account_id);
CREATE INDEX ix_transfer_period_to ON transfer(period_id, to_account_id);
-- Dashboard 趋势:按家庭 × 期段读
CREATE INDEX ix_period_family_start ON period(family_id, period_start);
-- 待办筛选:成员视角 + 状态
CREATE INDEX ix_todo_period_assignee_status ON snapshot_todo(period_id, assigned_member_id, status);
-- 审计日志按家庭 × 时间倒序
CREATE INDEX ix_audit_family_created ON audit_log(family_id, created_at DESC);
-- 重复转账检测(同 period × from × to × amount)
CREATE INDEX ix_transfer_dup_check ON transfer(period_id, from_account_id, to_account_id, amount);
-- 汇率查询
CREATE INDEX ix_fx_rate_period ON fx_rate(period_id, quote_currency);决策:版本化 SQL 文件 + 一个 shell 脚本顺序执行;不引入 Flyway/Liquibase 等迁移工具。
db/migration/
V1__init.sql -- §3.1 全部 CREATE TABLE + 索引
V2__seed.sql -- 1 个 family + 2 个 member + 14 个 account_template + 8 个 cash_flow_category
V3__add_<feature>.sql -- 后续每次 schema 改动一个版本号命名规则:V<major>__<topic>.sql,major 单调递增、不留空号、topic 用蛇形英文。
#!/bin/bash
# 用法:DB_USER=app DB_PASS=xxx DB_NAME=finance ./db/apply.sh
# 思路:维护一张 schema_history 表,记录已执行的 V*__ 文件名 + 时间 + checksum;
# 每次启动遍历 db/migration/V*__*.sql,跳过已执行的,新文件按文件名排序顺序执行。
set -euo pipefail
# 1) 确保 history 表存在(幂等)
mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" <<EOF
CREATE TABLE IF NOT EXISTS schema_history (
filename VARCHAR(255) PRIMARY KEY,
checksum CHAR(64) NOT NULL,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
EOF
# 2) 遍历 V*__*.sql,排序后逐个 apply
for f in db/migration/V*__*.sql; do
name=$(basename "$f")
expected=$(sha256sum "$f" | cut -d' ' -f1)
applied=$(mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -sN -e \
"SELECT checksum FROM schema_history WHERE filename='$name'")
if [ -z "$applied" ]; then
echo "→ applying $name"
mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$f"
mysql -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" -e \
"INSERT INTO schema_history (filename, checksum) VALUES ('$name', '$expected')"
elif [ "$applied" != "$expected" ]; then
echo "✗ $name 校验失败:已执行版本 checksum=$applied,当前文件 checksum=$expected — 不允许修改已发布版本"
exit 1
else
echo "✓ $name (skipped)"
fi
done- 已执行的
V<n>__文件永不修改;改动需新增V<n+1>__。脚本通过 sha256 校验防止意外改动。 - 数据修复 / 一次性脚本同样走
V<n>__data_<topic>.sql(单次执行)。索引调整走V<n>__index_<table>.sql。 - 应用启动时不自动跑迁移;部署流程中由
deploy.sh显式调db/apply.sh(详见 § 10.6)。 - 单元 / 集成测试用 Testcontainers 时,可在 init script 里把
db/migration/V*__*.sql顺序灌入 — 不复用 history 表。
v0.1 用 MyBatis Mapper + Java 投影(在 repository/FactMapper.java + mapper/FactMapper.xml + factview/FactProjector.java 三件套),不建 SQL VIEW、不物化。所有指标只走这个入口。
- 数据量小:全家全年 < 200 行 snapshot,内存里 SUM/GROUP 毋须索引技巧。
- MyBatis 适配复杂查询:fact view 的 join 多、列多,XML 写得清楚比 derived JPQL 强;不上 JdbcTemplate 是因为 MyBatis 自带的 ResultMap、动态 SQL(
<if>/<foreach>)对筛选条件有原生支持。 - 派生字段在 Java:
account_class、account_liquidity、_orig × FX → _base都在FactProjector用 switch 表达,IDE 可重构;不在 SQL CASE WHEN 里硬编码。 - 演化便利:数据增长后,把 base 查询下推到 SQL
CREATE VIEW account_period_fact AS ...,Mapper XML 改为SELECT FROM v_fact,上层无感。
| 备选 | 优 | 劣 | 何时切换 |
|---|---|---|---|
| MyBatis @Select 注解(无 XML) | 单文件 | 复杂 SQL 在注解里难维护 | 不切换 |
MySQL CREATE VIEW |
一处定义全 SQL 复用 | view 内 CASE WHEN 难重构 | fact 行 > 10k 且 Java 投影成 CPU 瓶颈 |
| 物化表 + 触发器 | 查询超快 | 一致性维护贵 | 只读多写少 + 数据 > 100k 行 |
// repository/FactMapper.java —— MyBatis Mapper 接口
@Mapper
public interface FactMapper {
/** 返回 base 列;派生字段由 FactProjector 投影出最终 record */
List<FactBaseRow> queryBase(FactFilter filter);
}// domain/factview/FactBaseRow.java —— MyBatis 直接装 ResultMap 的扁平 POJO
public record FactBaseRow(
Long accountId, String accountName, String accountType, String accountCurrency,
Long ownerId, Long periodId, LocalDate periodStart, LocalDate periodEnd,
BigDecimal endBalance, // 可空
BigDecimal incomeOrig, // SUM,空时返回 0
BigDecimal expenseOrig,
BigDecimal transferInOrig,
BigDecimal transferOutOrig,
BigDecimal fxToBase // 可空(同币时)
) {}<!-- src/main/resources/mapper/FactMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.family.finance.repository.FactMapper">
<resultMap id="FactBaseRowMap" type="com.family.finance.domain.factview.FactBaseRow">
<constructor>
<idArg column="account_id" javaType="java.lang.Long"/>
<arg column="account_name" javaType="java.lang.String"/>
<arg column="account_type" javaType="java.lang.String"/>
<arg column="account_currency" javaType="java.lang.String"/>
<arg column="owner_id" javaType="java.lang.Long"/>
<arg column="period_id" javaType="java.lang.Long"/>
<arg column="period_start" javaType="java.time.LocalDate"/>
<arg column="period_end" javaType="java.time.LocalDate"/>
<arg column="end_balance" javaType="java.math.BigDecimal"/>
<arg column="income_orig" javaType="java.math.BigDecimal"/>
<arg column="expense_orig" javaType="java.math.BigDecimal"/>
<arg column="transfer_in_orig" javaType="java.math.BigDecimal"/>
<arg column="transfer_out_orig" javaType="java.math.BigDecimal"/>
<arg column="fx_to_base" javaType="java.math.BigDecimal"/>
</constructor>
</resultMap>
<select id="queryBase" resultMap="FactBaseRowMap">
SELECT
a.id AS account_id,
a.display_name AS account_name,
a.type AS account_type,
a.currency AS account_currency,
a.primary_owner_member_id AS owner_id,
p.id AS period_id,
p.period_start AS period_start,
p.period_end AS period_end,
ps.end_balance AS end_balance,
COALESCE(SUM(CASE WHEN cf.kind='INCOME' THEN cf.amount END),0) AS income_orig,
COALESCE(SUM(CASE WHEN cf.kind='EXPENSE' THEN cf.amount END),0) AS expense_orig,
COALESCE(SUM(CASE WHEN tin.id IS NOT NULL THEN tin.amount END),0) AS transfer_in_orig,
COALESCE(SUM(CASE WHEN tout.id IS NOT NULL THEN tout.amount END),0) AS transfer_out_orig,
fx.rate AS fx_to_base
FROM account a
JOIN period p ON p.family_id = a.family_id AND p.period_type = #{periodType}
LEFT JOIN period_snapshot ps ON ps.account_id = a.id AND ps.period_id = p.id
LEFT JOIN cash_flow cf ON cf.account_id = a.id AND cf.period_id = p.id
LEFT JOIN transfer tin ON tin.to_account_id = a.id AND tin.period_id = p.id AND tin.is_draft=0
LEFT JOIN transfer tout ON tout.from_account_id= a.id AND tout.period_id= p.id AND tout.is_draft=0
LEFT JOIN fx_rate fx ON fx.family_id = a.family_id
AND fx.base_currency = #{viewCurrency}
AND fx.quote_currency = a.currency
AND fx.period_id = p.id
WHERE a.family_id = #{familyId}
AND p.period_start BETWEEN #{rangeStart} AND #{rangeEnd}
<if test="!includeArchived">AND a.archived_at IS NULL</if>
<if test="accountIds != null and !accountIds.isEmpty()">
AND a.id IN
<foreach collection="accountIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
GROUP BY a.id, p.id, ps.end_balance, fx.rate
ORDER BY p.period_start, a.display_order
</select>
</mapper>// factview/FactProjector.java —— 派生字段在 Java 里算
public final class FactProjector {
public static AccountPeriodFact project(FactBaseRow r) {
var t = AccountType.valueOf(r.accountType());
var fx = r.fxToBase() != null ? r.fxToBase() : BigDecimal.ONE;
var endOrig = r.endBalance();
return new AccountPeriodFact(
r.accountId(), r.accountName(), t,
classOf(t), // ASSET / LIABILITY
liquidityOf(t), // LIQUID / SEMI_LIQUID / ILLIQUID / NA
Currency.of(r.accountCurrency()),
r.ownerId(),
r.periodId(), r.periodStart(), r.periodEnd(),
endOrig,
endOrig == null ? null : MoneyMath.scale2(endOrig.multiply(fx)),
r.incomeOrig(), MoneyMath.scale2(r.incomeOrig().multiply(fx)),
r.expenseOrig(), MoneyMath.scale2(r.expenseOrig().multiply(fx)),
r.transferInOrig(), MoneyMath.scale2(r.transferInOrig().multiply(fx)),
r.transferOutOrig(), MoneyMath.scale2(r.transferOutOrig().multiply(fx)),
fx
);
}
static AccountClass classOf(AccountType t) {
return t == AccountType.LOAN ? AccountClass.LIABILITY : AccountClass.ASSET;
}
static AccountLiquidity liquidityOf(AccountType t) {
return switch (t) {
case CASH -> AccountLiquidity.LIQUID;
case WEALTH, STOCK -> AccountLiquidity.SEMI_LIQUID;
case PROPERTY -> AccountLiquidity.ILLIQUID;
case LOAN, OTHER -> AccountLiquidity.NA;
};
}
}其他写操作的 Mapper(
AccountMapper/SnapshotMapper/CashFlowMapper/TransferMapper/PeriodMapper/FxMapper/MemberMapper/AuditMapper)结构同上 — interface + XML;简单 CRUD 用@Insert/@Update/@Select注解写即可,无需 XML。
这一节存在的意义:写规则的入口在哪、事务边界在哪、纯计算放哪。这三件事错位会导致 N+1 写放大、事务嵌套问题。
所有 Dashboard / Reports / 任何"能在 fact view 上表达"的查询,都强制经过 FactViewService。Controller 不允许直接调 FactMapper。
public interface FactViewService {
/** 默认窗口 + 默认筛选 = 当前家庭、当前周期类型、12 期(月度)/13 周(周度) */
FactSlice loadDefault(Long familyId);
/** 任意窗口 + 任意筛选 */
FactSlice load(FactFilter filter);
/* --------- 投影 API:都是从 FactSlice 派生,不再访问 DB --------- */
KpiSnapshot kpis(FactSlice slice);
List<TrendPoint> netWorthTrend(FactSlice slice);
List<AllocationSlice> allocationByType(FactSlice slice, Long periodId);
List<WaterfallSegment> incomeExpenseWaterfall(FactSlice slice, Long periodId);
BigDecimal familyXirr(FactSlice slice);
BigDecimal familyTwr(FactSlice slice);
Map<Long, BigDecimal> accountXirr(FactSlice slice); // accountId -> annualized
}@GetMapping("/")
public String dashboard(@AuthenticationPrincipal MemberPrincipal me,
@RequestParam(required = false) String range,
@RequestParam(required = false) String accounts,
@RequestParam(required = false) String currency,
Model model) {
FactFilter f = FactFilter.builder()
.familyId(me.familyId())
.periodType(me.familyPeriodType())
.timeRange(TimeRange.parse(range, "1Y"))
.accountIds(parseCsv(accounts)) // FR-21
.viewCurrency(ViewCurrency.parse(currency, me.familyBaseCurrency())) // FR-22
.build();
FactSlice slice = factView.load(f);
model.addAttribute("kpis", factView.kpis(slice));
model.addAttribute("trend", factView.netWorthTrend(slice));
model.addAttribute("allocation", factView.allocationByType(slice, slice.lastPeriodId()));
return "dashboard/index";
}| Service | 触发位置 | 写表 | 关键不变量 |
|---|---|---|---|
AccountService |
/admin/accounts POST/PUT |
account |
LOAN 类型必须可空设 default_payment_source;归档不可级联删快照 |
SnapshotService |
/entry POST · 轧差弹层 |
period_snapshot + 必要时 cash_flow / transfer + snapshot_todo.status |
一次提交在同一事务内完成"写余额 + 写分类 + 标 todo done" |
CashFlowService |
/entry/cash-flow |
cash_flow |
amount > 0;period 必须 OPEN(已 CLOSED 拒绝写,提示重开) |
TransferService |
/entry/transfer 或 行级"↔" |
transfer |
同(period, from, to, amount) 24h 内重复 → 二次确认;from ≠ to |
LoanPrefillService |
PeriodOpener 内调 |
snapshot_todo.prefilled_* + 草稿 transfer(is_draft=1) |
仅 LOAN 账户;首期账户无上期则跳过 |
PeriodService |
cron + /admin/periods |
period + snapshot_todo |
自动关闭只在所有 todo=DONE 且 全成员 completion 存在时触发 |
PeriodReopener |
/admin/periods |
period.status + period_member_completion 删 + audit_log |
必填重开理由 |
MetricsRecomputeJob |
@EventListener(PeriodClosedEvent) @Async |
仅 audit_log 与"通知" |
不写指标缓存(指标实时算) |
AuditLogService |
任意写 | audit_log |
由调用方显式调,而非 AOP — 避免"漏审计"靠运气 |
决策:事务在 service 方法上,不在 Controller 上。
- Controller 可能跨多个 service(例如
EntryController.submitBalance需要轧差 + 写 todo),如果在 controller 上@Transactional,任意 service 抛出会回滚整个 controller 的工作 — 绝大多数情况这是想要的。 - 但外部 IO(如调用汇率 API、写文件系统)不应包在事务内 —
@Transactional默认REQUIRED会让 IO 失败连带回滚业务。 - 最佳实践是 service 层标
@Transactional,Controller 层不标;外部 HTTP 调用 / 文件 IO 放在@TransactionalEventListener(AFTER_COMMIT)里,事务外执行。
@Service
public class SnapshotService {
@Transactional
public SubmitResult submit(SubmitBalanceCommand cmd) {
// 1. 写 period_snapshot(upsert)
// 2. 写用户分类的 cash_flow / transfer
// 3. 标记 snapshot_todo.status = DONE
// 4. 计算未解释金额 → 返回给 UI
// 5. 发布 SnapshotSubmittedEvent(供 PeriodCloser 监听)
eventPublisher.publishEvent(new SnapshotSubmittedEvent(cmd.periodId(), cmd.memberId()));
return new SubmitResult(...);
}
}
@Component
public class PeriodCloser {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void maybeCloseAfterSubmit(SnapshotSubmittedEvent e) {
// AFTER_COMMIT:确保上面的写都已落地
if (allTodosDone(e.periodId()) && allMembersCompleted(e.periodId())) {
periodService.close(e.periodId()); // 内部 @Transactional 单独事务
eventPublisher.publishEvent(new PeriodClosedEvent(e.periodId()));
}
}
}
@Component
public class MetricsRecomputeJob {
@Async("metricsExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPeriodClosed(PeriodClosedEvent e) {
// 校验恒等式 · 写审计 · 写月度小结卡片 — 都在新事务里
}
}com.family.finance.calc 下所有类禁止注入 Spring bean、禁止访问 DB。所有输入数据由调用方组装好。
- 单元测试不需要 Spring context,毫秒级跑完。
- 算法可被复用(导出 CSV 计算 / 后台任务计算 / Web 渲染计算 都是同一份逻辑)。
- 错误归因清晰 — 数学问题不会和数据库问题混在一起。
// calc/PnlCalculator.java
public final class PnlCalculator {
private PnlCalculator() {}
/** PRD § 5.2 · 账户期度投资损益(原币) */
public static BigDecimal periodPnl(BigDecimal endThis,
BigDecimal endPrev,
BigDecimal income,
BigDecimal expense,
BigDecimal transferIn,
BigDecimal transferOut) {
if (endPrev == null || endThis == null) return null;
var netExternal = nz(income).subtract(nz(expense));
var netTransfer = nz(transferIn).subtract(nz(transferOut));
return endThis.subtract(endPrev).subtract(netExternal).subtract(netTransfer);
}
private static BigDecimal nz(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; }
}// calc/XirrCalculator.java
public final class XirrCalculator {
private static final double LOWER = -0.99, UPPER = 10.0;
private static final double TOL = 1e-7;
public static OptionalDouble annualizedXirr(List<CashFlowPoint> flows) {
if (flows.size() < 2) return OptionalDouble.empty();
var anchor = flows.get(0).date();
UnivariateFunction f = r -> flows.stream()
.mapToDouble(p -> p.amount().doubleValue()
/ Math.pow(1.0 + r, ChronoUnit.DAYS.between(anchor, p.date()) / 365.0))
.sum();
try {
var solver = new BrentSolver(TOL);
return OptionalDouble.of(solver.solve(100, f, LOWER, UPPER));
} catch (NoBracketingException | TooManyEvaluationsException ex) {
return OptionalDouble.empty();
}
}
public record CashFlowPoint(LocalDate date, BigDecimal amount) {}
}// calc/IdentityVerifier.java
public final class IdentityVerifier {
/** PRD § 5.16 主恒等式 */
public static void assertMain(BigDecimal netWorthThis, BigDecimal netWorthPrev,
BigDecimal extIncome, BigDecimal extExpense, BigDecimal pnl) {
var lhs = netWorthThis.subtract(netWorthPrev);
var rhs = extIncome.subtract(extExpense).add(pnl);
if (lhs.subtract(rhs).abs().compareTo(new BigDecimal("0.01")) > 0) {
throw new DataInconsistencyException(
"主恒等式违反: lhs=%s, rhs=%s, diff=%s".formatted(lhs, rhs, lhs.subtract(rhs)));
}
}
}这一节存在的意义:把 PRD 里描述的几条核心链路,一对一翻译成可粘贴的 Java/SQL,实现时不用再脑补。
// EntryController.java
@PostMapping("/entry/snapshot")
public String submit(@AuthenticationPrincipal MemberPrincipal me,
@Valid @ModelAttribute SubmitBalanceForm form,
Model model) {
var cmd = SubmitBalanceCommand.of(me, form);
SubmitResult r = snapshotService.submit(cmd); // ① 主事务,见 4.3
model.addAttribute("unexplained", r.unexplained());
model.addAttribute("hints", r.classificationHints());
return "entry/_row"; // HTMX 局部刷新
}
// SnapshotService.java
@Transactional
public SubmitResult submit(SubmitBalanceCommand cmd) {
// 1) 鉴权 + 周期 OPEN 校验
var period = periodRepo.findByIdOpenOrThrow(cmd.periodId());
var account = accountRepo.findByIdOrThrow(cmd.accountId());
Authz.assertSameFamily(cmd.actor(), account);
// 2) upsert 余额
snapshotRepo.upsertEndBalance(cmd.periodId(), cmd.accountId(), cmd.endBalance(), cmd.actor().id());
// 3) 用户的分类一并写入(若提交时随附)
cmd.cashFlowsToCreate().forEach(cf -> cashFlowService.create(cf, cmd.actor()));
cmd.transfersToCreate().forEach(tx -> transferService.create(tx, cmd.actor()));
// 4) 实时轧差(读视图)
BigDecimal unexplained = reconcile(cmd.periodId(), cmd.accountId());
// 5) 标记 todo
todoRepo.markDoneIfPresent(cmd.periodId(), cmd.accountId(), cmd.actor().id());
// 6) 发布事件,事务提交后由 PeriodCloser 监听
events.publishEvent(new SnapshotSubmittedEvent(cmd.periodId(), cmd.actor().id()));
return new SubmitResult(unexplained, hintsFor(account, unexplained));
}-- SnapshotMapper.upsertEndBalance 对应的 SQL(MyBatis XML)
INSERT INTO period_snapshot(period_id, account_id, end_balance, submitted_by)
VALUES (:periodId, :accountId, :endBalance, :memberId)
ON DUPLICATE KEY UPDATE
end_balance = VALUES(end_balance),
submitted_by = VALUES(submitted_by),
submitted_at = CURRENT_TIMESTAMP(3);// PeriodCloser 见 4.3,这里贴 close() 的核心
@Transactional
public void close(Long periodId) {
int updated = jdbc.update("""
UPDATE period
SET status = 'CLOSED', closed_at = CURRENT_TIMESTAMP(3)
WHERE id = :id AND status = 'OPEN'
""", Map.of("id", periodId));
if (updated == 1) {
auditLog.record(AuditLogType.PERIOD_AUTO_CLOSE, "period", periodId, "全员完成自动关闭");
events.publishEvent(new PeriodClosedEvent(periodId));
}
}// service/period/PeriodOpener.java
@Component
@RequiredArgsConstructor
public class PeriodOpener {
@Scheduled(cron = "${app.cron.period-opener:0 30 0 * * *}") // 每天 0:30
public void openIfDue() {
for (Family fam : familyRepo.findAll()) {
LocalDate today = LocalDate.now(fam.zoneId());
if (!isPeriodStartDate(fam.periodType(), today)) continue;
createPeriodAndTodos(fam, today);
}
}
@Transactional
void createPeriodAndTodos(Family fam, LocalDate today) {
Period p = periodService.openNew(fam, today);
for (Account a : accountRepo.findActiveByFamily(fam.id())) {
SnapshotTodo todo = todoRepo.create(p.id(), a.id(), a.primaryOwnerMemberId());
if (a.type() == AccountType.LOAN) {
loanPrefillService.prefill(p, a, todo);
}
}
}
}
// service/loan/LoanPrefillService.java
@Service @RequiredArgsConstructor
public class LoanPrefillService {
@Transactional
public void prefill(Period current, Account loan, SnapshotTodo todo) {
// 余额预填 = 2 × prev − prevPrev
BigDecimal prev = lastBalance(loan.id(), beforePeriodId(current.id(), 1)).orElse(null);
BigDecimal prevPrev = lastBalance(loan.id(), beforePeriodId(current.id(), 2)).orElse(null);
if (prev == null) return; // 首期 LOAN,跳过
BigDecimal predicted = prevPrev == null
? prev
: prev.add(prev).subtract(prevPrev);
todo.setPrefilledBalance(predicted);
// 转账草稿:仅当配置了 default_payment_source 且能算出"上期变化量"
if (loan.defaultPaymentSourceAccountId() != null && prevPrev != null) {
BigDecimal deltaAbs = prev.subtract(prevPrev).abs();
Transfer draft = transferService.createDraft(
current.id(),
loan.defaultPaymentSourceAccountId(),
loan.id(),
deltaAbs);
todo.setPrefilledTransferId(draft.id());
}
todoRepo.save(todo);
}
}// EntryController.java
/** HTMX:用户输入新余额时实时回填未解释金额面板 */
@PostMapping("/entry/reconcile")
public String reconcile(@RequestParam Long periodId,
@RequestParam Long accountId,
@RequestParam BigDecimal endBalance,
Model m) {
var ctx = entryQueryDao.loadReconcileContext(periodId, accountId);
BigDecimal prev = ctx.prevEndBalance(); // 可空
BigDecimal income = ctx.totalIncome();
BigDecimal expense = ctx.totalExpense();
BigDecimal tin = ctx.totalTransferIn();
BigDecimal tout = ctx.totalTransferOut();
BigDecimal delta = prev == null ? BigDecimal.ZERO : endBalance.subtract(prev);
BigDecimal unexplained = delta
.subtract(income).add(expense)
.subtract(tin).add(tout);
m.addAttribute("unexplained", unexplained);
m.addAttribute("suggestTransfer",
unexplained.abs().compareTo(ctx.transferHintThreshold()) > 0);
m.addAttribute("warnLoanFlip",
ctx.accountType() == AccountType.LOAN && delta.signum() == ctx.unusualLoanDirection());
return "entry/_reconcile-panel";
}<!-- Thymeleaf · entry/_reconcile-panel.html · HTMX 局部 -->
<div id="reconcile-panel" class="...">
<p>未解释金额 <span class="num" th:text="${#numbers.formatDecimal(unexplained,1,2)}"></span></p>
<div th:if="${unexplained != 0}">
<button th:hx-post="@{/entry/cash-flow(periodId=${periodId},accountId=${accountId},kind=INCOME,category=salary)}"
hx-target="#reconcile-panel">+ 工资</button>
<button th:hx-post="@{/entry/cash-flow(periodId=${periodId},accountId=${accountId},kind=EXPENSE,category=consume)}"
hx-target="#reconcile-panel">- 消费</button>
<button th:hx-get="@{/entry/transfer-modal(periodId=${periodId},fromAccountId=${accountId},amount=${unexplained})}"
hx-target="#modal">↔ 转账</button>
</div>
<small th:if="${suggestTransfer}">💡 看起来像账户间转账?</small>
</div>依赖坐标:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-math3</artifactId>
<version>3.6.1</version>
</dependency>调用见 § 4.4 的 XirrCalculator。测试用例(PRD § 5.7 表格)在 § 9.1 列出。
// service/transfer/TransferService.java
@Service @RequiredArgsConstructor
public class TransferService {
@Transactional
public Transfer create(CreateTransferCommand cmd, MemberPrincipal actor) {
if (cmd.fromAccountId().equals(cmd.toAccountId()))
throw new IllegalArgumentException("源/目的账户不可相同");
// 重复检测:同(period, from, to, amount) 24h 内
boolean dup = jdbc.queryForObject("""
SELECT EXISTS(
SELECT 1 FROM transfer
WHERE period_id = :p AND from_account_id = :f AND to_account_id = :t
AND amount = :a AND submitted_at >= NOW() - INTERVAL 24 HOUR
AND is_draft = 0)
""", Map.of("p", cmd.periodId(), "f", cmd.fromAccountId(),
"t", cmd.toAccountId(), "a", cmd.amount()), Boolean.class);
if (dup && !cmd.confirmDuplicate()) {
throw new DuplicateTransferException("看起来像重复登记,请确认");
}
Transfer t = transferRepo.save(Transfer.of(cmd, actor.id()));
auditLog.record(AuditLogType.TRANSFER_CREATE, "transfer", t.id(),
"%s → %s ¥%s".formatted(cmd.fromAccountId(), cmd.toAccountId(), cmd.amount()));
return t;
}
}// calc/TwrCalculator.java
public final class TwrCalculator {
public static OptionalDouble cumulative(List<TwrPoint> series) {
BigDecimal product = BigDecimal.ONE;
boolean any = false;
for (TwrPoint p : series) {
if (p.startValue() == null || p.startValue().signum() <= 0) continue;
// (endValue − netInflow − startValue) / startValue
BigDecimal num = p.endValue().subtract(p.netInflow()).subtract(p.startValue());
BigDecimal r = num.divide(p.startValue(), 10, RoundingMode.HALF_EVEN);
product = product.multiply(BigDecimal.ONE.add(r));
any = true;
}
return any ? OptionalDouble.of(product.subtract(BigDecimal.ONE).doubleValue())
: OptionalDouble.empty();
}
public static OptionalDouble annualized(double cumulative, int monthsCount) {
if (monthsCount < 12) return OptionalDouble.empty();
return OptionalDouble.of(Math.pow(1 + cumulative, 12.0 / monthsCount) - 1);
}
public record TwrPoint(BigDecimal startValue, BigDecimal endValue, BigDecimal netInflow) {}
}这一节存在的意义:这是 v0.1 最高频路径,所有指标共用同一份 fact slice 投影(避免多次 DB 往返)。流程错位会导致首屏慢 / 数字打架。
[用户:点击 1Y / 取消勾选 STOCK / 切到 USD]
│
▼
HTMX hx-get="/dashboard?range=1Y&accounts=1,2,4,6,8,9¤cy=USD"
hx-target="#dashboard-region"
│
▼
[DashboardController.dashboard]
1) 解析参数 → 构造 FactFilter
2) factView.load(filter) → FactSlice(11 账户 × 12 期 ≈ 132 行,内存)
3) 一次性产出所有 widget 数据:
kpis = factView.kpis(slice)
trend = factView.netWorthTrend(slice)
alloc = factView.allocationByType(slice, slice.lastPeriodId())
waterfall= factView.incomeExpenseWaterfall(slice)
sparks = factView.accountSparks(slice)
xirr = factView.familyXirr(slice)
4) Model attribute 注入 → return "dashboard/_region :: region"
(Thymeleaf fragment,只渲染 #dashboard-region)
│
▼
[浏览器:HTMX swap inner HTML]
Chart.js 监听 htmx:afterSwap 事件,重新挂图// dto/FactFilter.java —— 不可变值对象
public record FactFilter(
long familyId,
PeriodType periodType, // MONTHLY / WEEKLY,从 family 表读
LocalDate rangeStart,
LocalDate rangeEnd,
boolean includeArchived,
@Nullable List<Long> accountIds, // null 或空 = 全部 11 账户;否则按列表筛
Currency viewCurrency // FR-22 显示币种;影响 fx_rate 选择
) {
public static FactFilter forDashboard(MemberPrincipal me, String range, String accountsCsv, String currency) {
var tr = TimeRange.parse(range, "1Y");
return new FactFilter(
me.familyId(), me.familyPeriodType(),
tr.start(me.today(), me.familyPeriodType()),
tr.end(me.today(), me.familyPeriodType()),
false,
parseCsvOrNull(accountsCsv),
Currency.parseOr(currency, me.familyBaseCurrency())
);
}
}
// dto/FactSlice.java —— FactViewService.load 的返回值;后续投影从这里取
public record FactSlice(
FactFilter filter,
List<AccountPeriodFact> rows, // 已折算到 viewCurrency,投影函数无需再做 FX
List<Long> periodIds, // 已排序 [P_first, ..., P_last]
Long lastPeriodId
) {
public Map<Long, List<AccountPeriodFact>> byAccount() { ... } // 内存索引 1
public Map<Long, List<AccountPeriodFact>> byPeriod() { ... } // 内存索引 2
}@Service
@RequiredArgsConstructor
public class FactViewServiceImpl implements FactViewService {
private final FactMapper factMapper; // MyBatis 入口
@Override
public FactSlice load(FactFilter f) {
// 1) MyBatis 查 base 行(已经按 viewCurrency 关联 fx_rate)
List<FactBaseRow> base = factMapper.queryBase(f);
// 2) Java 投影派生字段
List<AccountPeriodFact> rows = base.stream()
.map(FactProjector::project)
.toList();
// 3) 按时间排序周期 ID
List<Long> pids = rows.stream()
.map(AccountPeriodFact::periodId).distinct()
.sorted(Comparator.comparing(pid -> rows.stream()
.filter(r -> r.periodId() == pid).findFirst().get().periodStart()))
.toList();
return new FactSlice(f, rows, pids, pids.isEmpty() ? null : pids.get(pids.size()-1));
}
@Override
public KpiSnapshot kpis(FactSlice s) {
var lastP = s.lastPeriodId();
var prevP = s.periodIds().size() < 2 ? null : s.periodIds().get(s.periodIds().size() - 2);
// 全部从 s.rows 内存过滤,无 DB 调用
BigDecimal netWorth = sumBase(s, lastP, r -> true);
BigDecimal netWorthPrev = prevP == null ? null : sumBase(s, prevP, r -> true);
BigDecimal totalAssets = sumBase(s, lastP, r -> r.accountClass() == AccountClass.ASSET);
BigDecimal totalLiab = sumBase(s, lastP, r -> r.accountClass() == AccountClass.LIABILITY).abs();
BigDecimal liquidAsset = sumBase(s, lastP,
r -> r.accountLiquidity() == AccountLiquidity.LIQUID);
BigDecimal avg12Expense = avgBase(s, 12, r -> true, AccountPeriodFact::expenseBase);
return new KpiSnapshot(
netWorth, deltaPct(netWorth, netWorthPrev),
totalAssets, totalLiab,
avg12Expense.signum() == 0 ? null
: liquidAsset.divide(avg12Expense, 1, RoundingMode.HALF_UP),
totalAssets.signum() == 0 ? null
: totalLiab.divide(totalAssets, 4, RoundingMode.HALF_UP)
);
}
@Override
public List<TrendPoint> netWorthTrend(FactSlice s) {
return s.periodIds().stream()
.map(pid -> new TrendPoint(pid, sumBase(s, pid, r -> true)))
.toList();
}
/* allocationByType / incomeExpenseWaterfall / familyXirr / familyTwr 同样 — 都 sum/group s.rows */
/* ---- 内存级聚合工具 ---- */
private BigDecimal sumBase(FactSlice s, Long pid, Predicate<AccountPeriodFact> filter) {
return s.rows().stream()
.filter(r -> r.periodId() == pid)
.filter(filter)
.map(AccountPeriodFact::endBalanceBase)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}| 步骤 | 预计耗时(JIT 暖) | 备注 |
|---|---|---|
| MyBatis 查 base 行 | < 10 ms | ~150 行,无索引压力 |
| FactProjector 投影 | < 1 ms | 纯 Java |
| 6 个投影函数全部跑 | < 5 ms | 都是 stream sum/group on 150 rows |
| Thymeleaf 渲染 + Chart.js JSON | < 30 ms | |
| 总响应 | < 50 ms | 远低于 SLO P95 200ms |
结论:不需要任何缓存。后续即使数据量增长 10×,延迟仍 < 500ms。
<!-- dashboard/index.html -->
<body>
...sticky header...
<div id="filter-bar">...</div>
<div id="dashboard-region"
hx-get="/dashboard"
hx-trigger="filterChange from:#filter-bar"
hx-swap="innerHTML">
<!-- 服务端首次渲染 + 后续筛选切换都用同一个 fragment -->
<th:block th:replace="~{dashboard/_region :: region}"></th:block>
</div>
</body><!-- dashboard/_region.html (fragment) -->
<th:block th:fragment="region">
<th:block th:replace="~{dashboard/_kpis :: kpis(${kpis})}"></th:block>
<th:block th:replace="~{dashboard/_trend :: trend(${trend})}"></th:block>
<th:block th:replace="~{dashboard/_allocation :: alloc(${alloc})}"></th:block>
...
</th:block>约定:任何受筛选影响的视图都包在 #dashboard-region 内;Filter bar 改值后 dispatchEvent('filterChange'),HTMX 收到事件,带上当前所有筛选参数 GET /dashboard,服务端只返回 fragment HTML,客户端 swap 整块。
Reports 页同款思路:
#reports-region+ 8 个子 fragment,逻辑完全等价。
这一节存在的意义:Spring Security 配错就是登录全失效或全裸奔。把配置定型,并把 RememberMe / 路径白名单 / 密码重置一次性写清楚。
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // 强度 12,登录约 200ms
}
@Bean
public PersistentTokenRepository tokenRepository(DataSource ds) {
var repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(ds); // 用 V1 中已建的 persistent_logins 表
return repo;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
MemberAuthService memberAuthService,
PersistentTokenRepository tokenRepo) throws Exception {
http
.authorizeHttpRequests(a -> a
.requestMatchers("/login", "/css/**", "/js/**", "/img/**", "/uploads/**", "/favicon.ico", "/health").permitAll()
.anyRequest().authenticated())
.formLogin(f -> f
.loginPage("/login").loginProcessingUrl("/login")
.defaultSuccessUrl("/", false)
.failureUrl("/login?error"))
.logout(l -> l
.logoutUrl("/logout").logoutSuccessUrl("/login?logout")
.deleteCookies("JSESSIONID","remember-me"))
.rememberMe(r -> r
.tokenRepository(tokenRepo)
.tokenValiditySeconds(60 * 60 * 24 * 30) // 30 天
.key(rememberMeKey())) // 部署时由配置注入,32 位随机串
.sessionManagement(s -> s
.sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::changeSessionId)
.maximumSessions(5)) // 每成员最多 5 端
.csrf(c -> c.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.headers(h -> h
.contentSecurityPolicy(p -> p.policyDirectives(
"default-src 'self'; "
+ "img-src 'self' data:; "
+ "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
+ "font-src 'self' https://fonts.gstatic.com; "
+ "frame-ancestors 'none'"))
.referrerPolicy(r -> r.policy(ReferrerPolicy.SAME_ORIGIN)));
http.userDetailsService(memberAuthService);
return http.build();
}
/** 部署时 application.yml 注入,绝不写死代码 */
private String rememberMeKey() { return Objects.requireNonNull(System.getenv("REMEMBER_ME_KEY")); }
}| 属性 | 设置 | 理由 |
|---|---|---|
Secure |
false(v0.1) | TLS 在更上游 CDN/代理处终结,本服务收到的就是 HTTP;若未来需要直连 HTTPS,改 server.servlet.session.cookie.secure=true |
HttpOnly |
true | 禁 JS 访问,防 XSS |
SameSite |
Lax |
RememberMe 只在自家域名跳转生效;与登录后跳转兼容 |
| 有效期 | 30 天 | PRD 既定 |
| 续期 | 每次成功登录刷新 series | JdbcTokenRepositoryImpl 默认行为 |
| 撤销 | /logout 删 row |
deleteCookies("remember-me") 同步 |
管理员 A 在 /admin/members
→ 点【重置密码】(成员 B 那一行)
→ 后端:
tempPwd = SecureRandom 8 字符 [a-zA-Z0-9]
member.password_hash = bcrypt(tempPwd)
member.must_change_pw = 1
audit_log: PASSWORD_RESET
→ 弹层一次性显示 tempPwd(管理员当面/线下告知)
成员 B 下次登录:
→ 用 tempPwd 登录成功
→ 中间件检查 must_change_pw=1 → 强制重定向 /change-password
→ 改完 → must_change_pw=0v0.1 简化:不做"忘记密码自助流程"。只能由家庭内任意成员触发重置。
| 路径 | 鉴权 | 说明 |
|---|---|---|
/login, /login?error, /login?logout |
公开 | |
/css/**, /js/**, /img/**, /favicon.ico |
公开 | 静态 |
/uploads/** |
公开 | logo 资源(非敏感),由 ResourceHandler 暴露 |
/health |
公开 | systemd / nginx 健康检查 |
/change-password |
已认证 + must_change_pw=1 强制 |
|
/admin/** |
已认证(任意成员) | 家庭内不分级 |
| 其余 | 已认证 | 默认 deny |
这一节存在的意义:v0.1 把图像处理全推到前端,后端只做小尺寸 WebP 文件的接收 + 落盘。这样可以彻底避开服务端图像库的复杂依赖与 CVE 风险。
[浏览器 / admin/family]
选 file → <canvas> 等比缩放至最长边 256px
→ canvas.toBlob('image/webp', 0.82)
→ 多半 < 12 KB,POST 给后端
│
▼
[后端 Controller]
1. Spring MaxUploadSize 拦截 (max-file-size=200KB,留 17× 余量)
2. 校验 Content-Type == 'image/webp'
3. 校验前 4 字节 magic == "RIFF"(防伪造 webp)
4. Files.write 到 /var/finance/uploads/family-{id}/logo.webp(覆盖)
5. UPDATE family SET logo_path='family-{id}/logo.webp' WHERE id=…
│
▼
[展示]
Thymeleaf:<img th:src="@{|/uploads/${family.logoPath}|}">
Spring ResourceHandler 把 /uploads/** 映射到 /var/finance/uploads/<input type="file" id="logoFile" accept="image/jpeg,image/png,image/webp">
<canvas id="logoPreview" width="256" height="256"></canvas>
<button id="logoUpload">上传</button>
<script>
const fileEl = document.getElementById('logoFile');
const canvas = document.getElementById('logoPreview');
const btn = document.getElementById('logoUpload');
let lastBlob = null;
fileEl.addEventListener('change', async () => {
const file = fileEl.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) { alert('原图 > 5MB'); return; }
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode();
const max = 256;
const r = Math.min(max / img.width, max / img.height, 1);
canvas.width = Math.round(img.width * r);
canvas.height = Math.round(img.height * r);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
lastBlob = await new Promise(res => canvas.toBlob(res, 'image/webp', 0.82));
console.log('压缩后 WebP', lastBlob.size, '字节');
});
btn.addEventListener('click', async () => {
if (!lastBlob) return;
const fd = new FormData();
fd.append('logo', lastBlob, 'logo.webp');
const r = await fetch('/admin/family/logo', { method: 'POST', body: fd });
if (!r.ok) alert('上传失败:' + r.status);
else location.reload();
});
</script>// controller/admin/LogoUploadController.java
@RestController
@RequestMapping("/admin/family/logo")
@RequiredArgsConstructor
public class LogoUploadController {
private final FamilyService familyService;
private final AppProperties props;
@PostMapping
public ResponseEntity<?> upload(@RequestParam("logo") MultipartFile file,
@AuthenticationPrincipal MemberPrincipal me) throws IOException {
// 1) 大小已被 Spring 拦截,这里再次防御
if (file.getSize() == 0 || file.getSize() > 200 * 1024)
return ResponseEntity.badRequest().body("size out of range");
// 2) Content-Type 必须是 image/webp(浏览器 canvas.toBlob 强制写正确)
if (!"image/webp".equalsIgnoreCase(file.getContentType()))
return ResponseEntity.badRequest().body("must be image/webp");
// 3) 校验 RIFF magic bytes(前 4 字节)— 极轻量防伪造
try (var in = file.getInputStream()) {
byte[] head = in.readNBytes(4);
if (head.length < 4 || head[0]!='R' || head[1]!='I' || head[2]!='F' || head[3]!='F')
return ResponseEntity.badRequest().body("not a webp");
}
// 4) 写入家庭专属目录
Path dir = Paths.get(props.uploadRoot(), "family-" + me.familyId());
Files.createDirectories(dir);
Path target = dir.resolve("logo.webp");
try (var in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
// 5) 路径校验防 traversal(冗余防御)
Path root = Paths.get(props.uploadRoot()).toAbsolutePath().normalize();
if (!target.toAbsolutePath().normalize().startsWith(root))
throw new IllegalStateException("path escape");
// 6) 写 DB
familyService.updateLogoPath(me.familyId(), "family-" + me.familyId() + "/logo.webp");
return ResponseEntity.ok().build();
}
}# application.yml(对应配置)
app:
upload-root: /var/finance/uploads
spring:
servlet:
multipart:
max-file-size: 200KB
max-request-size: 256KB// config/WebMvcConfig.java —— 静态资源暴露
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final AppProperties props;
@Override
public void addResourceHandlers(ResourceHandlerRegistry r) {
r.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + props.uploadRoot() + "/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(7)).cachePublic());
}
}| 不做的事 | 为什么不做 | 风险评估 |
|---|---|---|
| 服务端 ImageIO 解码 / 缩放 | 前端 canvas 已完成 | 风险 ↓:不再依赖 JDK ImageIO CVE |
| Apache Tika MIME 嗅探 | 文件上限 200KB,即使伪造 magic 也无法构成炸弹 | 低风险 |
| imgscalr / scrimage-webp 等图像库 | 不引入 = 没有依赖维护成本 | 减少 ~3MB jar |
| SVG 上传支持 | 浏览器 canvas 不能直接编码 SVG → WebP;且 SVG 含可执行内容 | 用户改用 PNG/JPG |
| jsoup XSS 清洗 | 同上,不接受 SVG | — |
| 风险 | 缓解 |
|---|---|
| 上传巨文件 → 内存 OOM | multipart.max-file-size=200KB(Spring 在 buffer 阶段拒绝) |
| Path traversal | 文件名硬编码 logo.webp,用户输入完全不参与磁盘路径 |
| 伪造 Content-Type | 校验前 4 字节 = RIFF |
| 文件落盘后被静态返回 | 内容已是 WebP;若伪造为 HTML,浏览器仍按 image/webp MIME 渲染,无 XSS 风险 |
| 文件名冲突 | 同家庭固定文件名 logo.webp,新上传覆盖旧的 |
| 目录权限 | /var/finance/uploads 属主 finance:finance,mode 750 |
| 不再依赖 JDK ImageIO | 通过 v0.1 简化,所有图像 CVE 影响为 0 |
这一节存在的意义:preview 已花了大量 UI 设计成本,迁移时若 DOM 变了,审美/视觉回归会大量发生。明确"DOM 不变 + 仅替换静态值"。
| 不变 | 仅替换 |
|---|---|
| 所有 class、所有 div 嵌套、所有 SVG、所有 inline style | 静态文字 → th:text="${...}" |
| 所有 Tailwind utility | 静态金额 → th:text="${#numbers.formatDecimal(value,1,2)}" |
所有 <canvas> 容器与 id |
<a href> → th:href="@{...}" |
头部 <head> 中的字体/CDN 链接 |
表头/导航文案 → MessageSource(为后续 i18n 铺路) |
| Chart.js 与 ECharts 的 script 文本结构 | 图表 dataset 数据 → 后端注入 JSON(th:inline="javascript" + [[${...}]]) |
强约束:不拆 class,不重组 DOM;有审美顾虑回到 preview HTML 同步改,然后再次 copy。
src/main/resources/templates/
├── layout/
│ ├── base.html # <html><head>...<body><main th:replace="${content}">
│ ├── head-fonts.html # 字体 CDN + Tailwind config script
│ └── chart-defaults.html # Chart.js 全局默认色板
├── fragments/
│ ├── topbar.html # th:fragment="topbar(active, currentPeriodLabel, principal)"
│ ├── hamburger.html # th:fragment="hamburger(active)" 移动端
│ ├── filter-bar.html # th:fragment="filter(filterState)" FR-21 + FR-22
│ ├── kpi-card.html # th:fragment="card(eyebrow,value,delta,deltaSign,unit)"
│ ├── account-row.html # th:fragment="row(account, sparkline)"
│ ├── reconcile-panel.html # 用于 Entry HTMX 局部刷新
│ └── period-banner.html # 顶部红色"未结账"banner
├── dashboard/
│ ├── index.html # 落地页全文
│ ├── _trend-chart.html # HTMX 切换时间 range 时局部替换
│ └── _allocation.html
├── entry/
│ ├── desktop.html
│ ├── mobile.html
│ ├── _row.html # HTMX:提交后返回单行
│ └── _transfer-modal.html
├── reports/
│ ├── index.html
│ ├── _xirr-table.html
│ └── _waterfall.html # ECharts(按需加载)
├── admin/
│ ├── index.html
│ ├── family.html
│ ├── members.html
│ ├── periods.html
│ ├── fx.html
│ ├── backup.html
│ └── audit.html
├── auth/
│ ├── login.html
│ └── change-password.html
└── error/
├── 4xx.html
└── 5xx.html| 维度 | 约定 |
|---|---|
| URL 命名 | 局部刷新端点用 _前缀片段 文件名;返回 fragment 不返回整页 |
| HTTP 谓词 | 改数据用 POST/PUT/DELETE;只读分页/筛选用 GET |
| 响应头 | HX-Trigger 用于触发联动 fragment 重渲(如提交 snapshot 后触发 recalc-kpis) |
| 错误 | 抛 HttpException → GlobalExceptionHandler 返 fragment <div class="error"> + HX-Reswap: outerHTML |
| CSRF | HTMX 1.9 默认不带 CSRF token;在 base.html 中:<meta name="csrf-token" th:content="${_csrf.token}"> + JS 一行 htmx.config.headers['X-CSRF-TOKEN'] = ... |
| 加载态 | Tailwind class htmx-indicator 已在 preview style.css,沿用 |
示例端点表:
| 端点 | 方法 | 作用 | 返回片段 |
|---|---|---|---|
/entry/reconcile |
POST | 实时计算未解释金额 | entry/_reconcile-panel |
/entry/snapshot |
POST | 提交单账户余额 | entry/_row |
/entry/cash-flow |
POST | 添加现金流分类 | entry/_reconcile-panel(刷未解释) |
/entry/transfer-modal |
GET | 弹出转账输入弹层 | entry/_transfer-modal |
/entry/transfer |
POST | 提交转账 | entry/_reconcile-panel |
/dashboard/range |
GET | 切换时间范围 | dashboard/_trend-chart |
/dashboard/filter |
POST | 应用账户筛选 | dashboard/index :: #content(整个内容区) |
/admin/periods/{id}/reopen |
POST | 重开周期 | admin/_periods-table-row |
v0.1 沿用 preview 的 CDN(https://cdn.tailwindcss.com),不引入 npm/postcss/购物车式构建链。
| 维度 | CDN | 本地预编译 |
|---|---|---|
| 学习/构建复杂度 | 0 | 中(需要 Node + tailwind.config.js + build script) |
| 体积 | 全量 ~3MB JS,被浏览器缓存 | 仅本项目用到的 ~30KB 压缩 CSS |
| 可控性 | 弱(随 CDN 维护) | 强 |
| 离线 | 否 | 是 |
| 内网部署 | 需穿透出网 | 不依赖外网 |
家庭场景 + 内网部署用户(本人 / 家人)走代理可外网,CDN 成本可控。触发切到本地编译的条件:① 部署到完全离线网络;② Tailwind v4 broken changes 导致 CDN 不稳;③ 首屏 P95 > 1.5s 且证明是 CDN 拖累。
# v0.X 切换时一次性引入
npm i -D tailwindcss postcss autoprefixer
npx tailwindcss -i ./src/main/frontend/app.css -o ./src/main/resources/static/css/app.css --minify --watch这一节存在的意义:v0.1 是单人开发 + 家庭自用,不需要企业级覆盖率指标。把测试投入压到最低,只测最容易出错且影响信任度的部分:纯计算函数。其他靠手动跑一遍 preview-级别 happy path。
| 类 | 用例数 | 必须验证的事 |
|---|---|---|
PnlCalculatorTest |
4-5 | 主恒等式成立;LOAN 余额变化等于还款 transfer;跨币种折算正确;首期 PnL = null |
XirrCalculatorTest |
4 | PRD § 5.7 测试用例 1-4 必过(年化 10% / 16% / 0% / 0%) |
TwrCalculatorTest |
2 | NetWorth(P-1) > 0 时算出连乘;NetWorth 全程不变时返回 0 |
IdentityVerifierTest |
2 | 通过 / 容差外抛错 |
FactProjectorTest |
3 | 派生 class、liquidity、_orig × FX → _base 三条派生路径 |
~16 个用例,纯 Java 不依赖 DB,跑得飞快。其余如 LoanPrefillService 等业务逻辑,在 § 9.2 的 happy path 里捎带验证,不单独写单元测试。
@Testcontainers
@SpringBootTest
@AutoConfigureMockMvc
class HappyPathIT {
@Container static final MySQLContainer<?> mysql =
new MySQLContainer<>("mysql:8.0.35")
.withDatabaseName("finance_test")
.withInitScript("db/test-init.sh") // 顺序跑 V1__/V2__/F_test_zhang.sql
.withReuse(true);
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", mysql::getJdbcUrl);
r.add("spring.datasource.username", mysql::getUsername);
r.add("spring.datasource.password", mysql::getPassword);
}
@Test
void e2e_filing_to_close() {
// 1) Alice登录 → /entry → 提交所有 4 个账户(MockMvc)
// 2) Bob登录 → /entry → 提交所有 3 个账户
// 3) 验证 period 自动 CLOSED
// 4) 验证主恒等式校验通过
// 5) 验证 audit_log 写了关闭事件
}
}只这一个 IT,覆盖最关键的 month-end → close 链路。其他流程(轧差引导、LOAN 预填、转账重复、周期重开)v0.1 通过手动 preview 验证,出问题再补 IT。
所有测试共享
src/test/resources/db/F_test_zhang.sql— 张家 12 期完整数据。生成方式:用 Excel 表格列好真实可校验的数字,导出为 INSERT 语句即可,不用程序构造。
-- F_test_zhang.sql 关键骨架(完整版 ~150 行 INSERT)
INSERT INTO family VALUES (1, '张家', '账房', 'CNY', 'MONTHLY', NOW());
INSERT INTO member VALUES
(1, 1, 'zhangwei', '$2a$12$bcryptHash...', 'Alice', '丈夫', NULL, NOW()),
(2, 1, 'lijing', '$2a$12$bcryptHash...', 'Bob', '妻子', NULL, NOW());
-- 9 账户 + 12 期 + 简化 snapshot/cash_flow/transfer/fx_rate
-- 只确保主恒等式成立即可,不必塞满所有边界 case| 跳过的项 | 为什么跳过 |
|---|---|
| Controller 层每个端点都 MockMvc | 单人单家庭,出错自己改即可,不值得为 80% 覆盖率写空架子测 |
| Selenium / Playwright UI 测试 | preview HTMLs 已是手动 visual contract,改了能直接看到 |
| 性能基线 / 压力测试 | 数据量 < 1k 行,不可能慢 |
| JaCoCo 覆盖率门禁 | v0.1 不设硬指标 |
| 提前防"未来扩展"的测试 | YAGNI |
底线:必跑的是 § 9.1 的 ~16 单测 + § 9.2 的 1 个 IT,跑通 = 可发布。其他出问题靠手动复现 + 补单测,不强求"先测后写"。
这一节存在的意义:部署是项目最容易把所有人卡住的环节。一份能照抄的 systemd / nginx 片段,胜过百句"自己 Google"。
| 项 | 命令 |
|---|---|
| JDK 21 | apt install openjdk-21-jdk-headless(Ubuntu 24.04)或 SDKMAN |
| Maven | apt install maven(只本地构建用) |
| MySQL 8 | apt install mysql-server-8.0(本地开发可用 docker run mysql:8.0 跳过) |
| Node(可选) | 仅在需要切到本地编译 Tailwind 时需要 |
设置 REMEMBER_ME_KEY |
`echo "REMEMBER_ME_KEY=$(openssl rand -hex 32)" |
本地开发:
# 1. 启动 MySQL(若不用 docker)
sudo systemctl start mysql
# 2. 创建数据库 + 用户
mysql -uroot -e "CREATE DATABASE finance CHARACTER SET utf8mb4;
CREATE USER 'finance'@'localhost' IDENTIFIED BY 'finance';
GRANT ALL ON finance.* TO 'finance'@'localhost';"
# 3. 先 ./db/apply.sh 顺序执行 V*__ SQL 文件(初次或升级)
# 4. 启动 Spring Boot(profile=dev)
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev生产:
# 一次部署后续每次升级都是相同的 4 步
sudo systemctl stop finance
sudo cp target/finance-0.1.x.jar /opt/finance/app.jar
sudo systemctl start finance
sudo systemctl status finance# /etc/systemd/system/finance.service
[Unit]
Description=Family Finance Web Application
After=network-online.target mysql.service
Wants=network-online.target
Requires=mysql.service
[Service]
Type=simple
User=finance
Group=finance
WorkingDirectory=/opt/finance
EnvironmentFile=/etc/finance.env
ExecStart=/usr/lib/jvm/java-21-openjdk-amd64/bin/java \
-Xms256m -Xmx768m \
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 \
-Djava.awt.headless=true \
-Dfile.encoding=UTF-8 \
-Dspring.profiles.active=prod \
-jar /opt/finance/app.jar
Restart=on-failure
RestartSec=5s
# 资源限制(防异常占满)
LimitNOFILE=65536
LimitNPROC=512
MemoryMax=1G
TasksMax=256
# 文件系统约束
ReadWritePaths=/var/finance /var/log/finance
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
StandardOutput=append:/var/log/finance/app.log
StandardError=append:/var/log/finance/app.err.log
[Install]
WantedBy=multi-user.target# /etc/nginx/sites-available/finance.conf
# 注:TLS 由用户上游(域名/CDN)已终结,本机 nginx 仅接 HTTP
server {
listen 80;
server_name finance.example.com;
client_max_body_size 256k; # 与 Spring multipart 对齐(logo 上限 200KB)
# 静态文件:首选 nginx 直发,绕过 Java
location /uploads/ {
alias /var/finance/uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location /health {
proxy_pass http://127.0.0.1:8080/health;
access_log off;
}
}
server {
listen 80;
server_name finance.example.com;
return 301 https://$host$request_uri;
}#!/usr/bin/env bash
# /opt/finance/deploy/backup.sh
set -euo pipefail
BACKUP_DIR=/var/finance/backup
KEEP_WEEKS=8
TS=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"
# 1) 数据库 dump
mysqldump --single-transaction --quick --no-tablespaces \
-u finance -p"${MYSQL_PWD:-finance}" finance \
| gzip -9 > "$BACKUP_DIR/finance-db-$TS.sql.gz"
# 2) 上传目录(logo)
tar -czf "$BACKUP_DIR/finance-uploads-$TS.tar.gz" -C /var/finance uploads
# 3) 异地(可选,通过 rclone)
if [ -n "${REMOTE_BACKUP:-}" ]; then
rclone copy "$BACKUP_DIR/finance-db-$TS.sql.gz" "$REMOTE_BACKUP/" || true
rclone copy "$BACKUP_DIR/finance-uploads-$TS.tar.gz" "$REMOTE_BACKUP/" || true
fi
# 4) 清理过期
find "$BACKUP_DIR" -name 'finance-*' -mtime +$((KEEP_WEEKS*7)) -delete
# 5) 失败仅写 log + 退出码非 0;systemd OnFailure= 可触发管理员手动检查通知
echo "[$(date)] backup ok ($TS)" >> /var/log/finance/backup.log# /etc/systemd/system/finance-backup.service
[Unit]
Description=Finance DB & Uploads Backup
# v0.1 失败仅写 systemd journal + 退出非 0;管理员 systemctl status 时可见
[Service]
Type=oneshot
User=finance
EnvironmentFile=/etc/finance.env
ExecStart=/opt/finance/deploy/backup.sh
# /etc/systemd/system/finance-backup.timer
[Unit]
Description=Run finance backup weekly
[Timer]
OnCalendar=Sun 03:00
Persistent=true
[Install]
WantedBy=timers.target| 场景 | 步骤 |
|---|---|
| 仅代码改动(无 schema 变化) | systemctl restart finance,Spring Boot 启动会被 nginx 反代 5xx 接住 ~3 秒 |
| 含 V__xxx.sql 迁移 | ① 触发本周备份 → ② systemctl stop finance → ③ ./db/apply.sh(顺序跑新增的 V*__ 文件)→ ④ 替换 jar → ⑤ systemctl start finance → ⑥ 监控 log 30s |
| 紧急回滚 | ① systemctl stop finance → ② 恢复旧 jar → ③ 若 schema 已迁移:从最近备份恢复 DB(`gunzip < dump.sql.gz |
决策:v0.1 不做蓝绿部署,接受 5~10 秒重启窗口。家庭场景每月触发记账,绝不可能撞上重启秒级窗口的概率;蓝绿引入复杂度不值。
这一节存在的意义:把暂缓决策列出来作为"已知技术债",未来 v0.2 优先回看。每条注明影响 + 触发后果。
| # | 项 | 当前选择 | 影响 | 触发再回炉的信号 |
|---|---|---|---|---|
| 1 | fact view 未物化 | Java 投影 + 实时 SQL | 数据量小不痛;> 10k 行后 Dashboard 加载会变慢 | 某次 Dashboard 加载 P95 > 1s;或单家庭 fact 行 > 10k |
| 2 | WebP 库 | scrimage-webp(内置 native binary fallback) | macOS Apple Silicon / 异构 CPU 上首次启动可能慢或缺二进制 | 部署机首次上传报 cwebp not found 或 UnsatisfiedLinkError |
| 3 | Tailwind via CDN | 不引入构建链 | 离线环境部署不可用;CDN 抖动影响首屏 | 决定部署到内网无外网;或 CDN 单次故障 > 1 小时 |
| 4 | TWR / XIRR 假设外部流发生在期末 | 月度颗粒度的简化 | 月内有大额加仓 / 减仓时 TWR 偏差小 1-3% | 用户主动质疑某月年化 — 需要日内现金流数据(v0.3 持仓级) |
| 5 | 重置密码无邮件自助 | 仅"管理员触发 + 显示一次性临时密码" | 两人都忘了密码 → 必须 SSH 改 SQL | 触发一次"两人都登录不了"的事故 |
| 6 | WEEKLY 周期未充分测试 | 数据模型支持,UI 切换可见,但全部测试 fixture 基于 MONTHLY | 切到 WEEKLY 时可能首次报错 | 6 个月内有人尝试切换;或 v0.2 启动时 |
| 7 | 房贷利息隐式归投资损益 | 用户不主动登记 EXPENSE-利息时,差额自动归 PnL | "投资损益"含本金还款外的利息支出,XIRR 高估 1~2% | 用户主动询问;或某月 PnL 显著异常 |
| 8 | 重算只为审计/通知,指标实时算 | 不缓存中间值 | 单家庭场景充分;若引入"对比同期家庭"等聚合视图,实时算会贵 | v0.2/v0.3 上多家庭/多目标对比 |
| 9 | 没做 RememberMe series 主动失效 | 仅靠 30 天 TTL + 显式 logout | 设备丢失时只能等 30 天;或 SQL 里 delete from persistent_logins | 一次"手机丢失"事件 |
| 10 | logo SVG 仅 jsoup 白名单清洗,不做沙箱渲染 | 信任 jsoup whitelist | 复杂 SVG payload 漏网概率低,但非零 | 接入第三方上传源 / 多家庭暴露 |
仅记录本批次涉及的技术契约变化,详细背景见 PRD §7.9 第三批维护。
| HTTP | 路径 | 说明 |
|---|---|---|
| GET / POST | /profile/password |
用户改密(强制 / 主动);POST 成功后 SecurityContextHolder.clearContext() + 跳 /login?passwordChanged |
| POST | /admin/members |
添加成员;返回 redirect:/admin/members + flash tempPassword(只显示一次) |
| GET | /dashboard?accounts=N&accounts=M&... |
多选账户筛选(form GET multi-checkbox);Spring 自动 multi-binding |
| GET | /reports?accounts=N&accounts=M&... |
同上 |
| GET | /accounts?type=CASH |
账户列表按类型过滤 |
| GET | /img/default-logo.svg |
默认 logo;cache 1 年 immutable |
| Class | 字段 | 含义 |
|---|---|---|
EntryRow |
incoming: List<TransferRef> |
接收方"已收到来自 X 的 Y"明细 |
EntryRow |
outgoing: List<TransferRef> |
发送方"已划出到 X 的 Y"明细 |
EntryRow |
ledger: List<LedgerEntry> |
本期所有流水合并视图 |
EntryRow.LedgerEntry (record) |
kind/occurredAt/amount/amountSignedLabel/label/note | — |
EntryRow.LedgerKind (enum) |
SNAPSHOT/INCOME/EXPENSE/TRANSFER_IN/TRANSFER_OUT | — |
EntryRow.TransferRef (record) |
counterpartyName/amount/amountLabel | — |
AccountType |
getLabel(): String |
中文 label,如 "现金" / "股票" |
| Component | 责任 |
|---|---|
MustChangePasswordInterceptor |
检查 me.mustChangePw=true,白名单外路径全部 302 → /profile/password |
CacheHeaderInterceptor |
给动态 HTML 注入 no-cache(取代 Spring Security 全局 cacheControl) |
ProfileController |
GET/POST /profile/password |
| Version | 说明 |
|---|---|
V7__clear_orphan_logo.sql |
UPDATE family SET logo_path=NULL WHERE logo_path='family-1/logo.webp'(种子缺文件) |
V8__clear_must_change_pw_for_seeded.sql |
UPDATE member SET must_change_pw=0 WHERE password_hash NOT LIKE 'PLACEHOLDER%' AND must_change_pw=1 |
| 项 | 变更 |
|---|---|
| 汇率 API | api.exchangerate.host(2024 后收费 + 需 access_key)→ api.frankfurter.dev(免费 + 欧洲央行数据);路径 /v1/latest?base=&symbols=;响应字段相同;source 字段记 frankfurter.dev |
| Tailwind / HTMX / Chart.js / ECharts / chartjs-plugin-datalabels | CDN → 本地 /static/vendor/,所有引用挂 ?v=${buildVersion} |
chartjs-plugin-datalabels@2.2.0 |
新增,用于 Chart.js 数据点标签 |
| 路径 | Cache-Control |
|---|---|
/vendor/** /css/** /img/** |
max-age=31536000, public, immutable |
/uploads/** |
max-age=604800, public(7 天) |
| 其它 HTML | no-cache, no-store, must-revalidate(由 CacheHeaderInterceptor 注入) |
模板引用挂 ?v=${buildVersion};buildVersion 来自 spring-boot-maven-plugin build-info goal 生成的 META-INF/build-info.properties,格式 <version>-<base36(buildTimeMs)>。