Skip to content

Latest commit

 

History

History
2080 lines (1755 loc) · 91.4 KB

File metadata and controls

2080 lines (1755 loc) · 91.4 KB

已掌握足够上下文。开始产出 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 为准。


1. 上下文与约束

这一节存在的意义:把研发动手前的硬约束钉死,后面所有决策都要回头对照这几条。

产品约束(摘自 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)

2. 模块与包结构

这一节存在的意义:包边界一旦定下,后期跨包重构就贵。把 fact view 抽象、calculator 纯函数、controller 薄层在包结构上预先反映出来。

2.1 包树

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 响应头封装

2.2 各包职责一句话

写权 读权 不允许
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 编排)

3. 数据模型 — 完整 SQL DDL

这一节存在的意义:DDL 改不起。本节定型即是 v0.1 的"宪法",任何修改新增一个 V<n>__xxx.sql 文件,部署时由 deploy.sh 顺序执行。所有 ENUM 用字符串列 + CHECK,避免 MySQL 原生 ENUM(增删枚举值要 ALTER TABLE,可移植性差)。

3.1 表清单与 CREATE TABLE

V1__init.sql · 核心表

-- =========================================================
-- 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;

关于 cash_flow_kind 枚举

PRD 大纲提到一个独立 ENUM 表。决策:不建独立 ENUM 表,直接用 cash_flow.kind VARCHAR(8) CHECK ('INCOME','EXPENSE')理由:只有两种取值且不会再扩,独立表反而增加 join 成本。备选:若未来加 'ADJUSTMENT' 等,迁移到 cash_flow_kind 字典表(走 V?__add_cash_flow_kind.sql)。

3.2 索引清单

决策:外键自动索引 + 高频复合索引。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);

3.3 SQL 文件命名与执行约定

决策:版本化 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 用蛇形英文。

执行脚本(db/apply.sh)

#!/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

约束

  1. 已执行的 V<n>__ 文件永不修改;改动需新增 V<n+1>__。脚本通过 sha256 校验防止意外改动。
  2. 数据修复 / 一次性脚本同样走 V<n>__data_<topic>.sql(单次执行)。索引调整走 V<n>__index_<table>.sql
  3. 应用启动时不自动跑迁移;部署流程中由 deploy.sh 显式调 db/apply.sh(详见 § 10.6)。
  4. 单元 / 集成测试用 Testcontainers 时,可在 init script 里把 db/migration/V*__*.sql 顺序灌入 — 不复用 history 表。

3.4 派生 fact view 的实现选择

决策

v0.1 用 MyBatis Mapper + Java 投影(在 repository/FactMapper.java + mapper/FactMapper.xml + factview/FactProjector.java 三件套),不建 SQL VIEW、不物化。所有指标只走这个入口。

理由

  1. 数据量小:全家全年 < 200 行 snapshot,内存里 SUM/GROUP 毋须索引技巧。
  2. MyBatis 适配复杂查询:fact view 的 join 多、列多,XML 写得清楚比 derived JPQL 强;不上 JdbcTemplate 是因为 MyBatis 自带的 ResultMap、动态 SQL(<if> / <foreach>)对筛选条件有原生支持。
  3. 派生字段在 Java:account_classaccount_liquidity_orig × FX → _base 都在 FactProjector 用 switch 表达,IDE 可重构;不在 SQL CASE WHEN 里硬编码。
  4. 演化便利:数据增长后,把 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。


4. 服务层架构

这一节存在的意义:写规则的入口在哪、事务边界在哪、纯计算放哪。这三件事错位会导致 N+1 写放大、事务嵌套问题。

4.1 FactViewService — 唯一只读入口

决策

所有 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
}

用法示例(Controller)

@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";
}

4.2 各 Domain Service 的写规则

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 — 避免"漏审计"靠运气

4.3 事务边界

决策:事务在 service 方法上,不在 Controller 上

理由

  1. Controller 可能跨多个 service(例如 EntryController.submitBalance 需要轧差 + 写 todo),如果在 controller 上 @Transactional,任意 service 抛出会回滚整个 controller 的工作 — 绝大多数情况这是想要的。
  2. 外部 IO(如调用汇率 API、写文件系统)不应包在事务内 — @Transactional 默认 REQUIRED 会让 IO 失败连带回滚业务。
  3. 最佳实践是 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) {
        // 校验恒等式 · 写审计 · 写月度小结卡片 — 都在新事务里
    }
}

4.4 Calculation 子层 — 纯函数化

决策

com.family.finance.calc 下所有类禁止注入 Spring bean、禁止访问 DB。所有输入数据由调用方组装好。

理由

  1. 单元测试不需要 Spring context,毫秒级跑完。
  2. 算法可被复用(导出 CSV 计算 / 后台任务计算 / Web 渲染计算 都是同一份逻辑)。
  3. 错误归因清晰 — 数学问题不会和数据库问题混在一起。

接口示例

// 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)));
        }
    }
}

5. 关键流程的实现伪代码

这一节存在的意义:把 PRD 里描述的几条核心链路,一对一翻译成可粘贴的 Java/SQL,实现时不用再脑补。

5.1 月末填报提交 → 周期关闭判定 → MetricsRecomputeJob

// 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));
    }
}

5.2 LOAN 自动预填 — PeriodOpener cron

// 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);
    }
}

5.3 轧差引导 — Entry 实时计算未解释金额(HTMX 端点)

// 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>

5.4 XIRR — Brent 求解模板

依赖坐标:

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-math3</artifactId>
  <version>3.6.1</version>
</dependency>

调用见 § 4.4 的 XirrCalculator测试用例(PRD § 5.7 表格)在 § 9.1 列出。

5.5 跨账户转账 + 重复检测

// 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;
    }
}

5.6 TWR 月度切片连乘

// 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) {}
}

5.7 Dashboard 加载完整流程(筛选 → fact view → 多投影 → 渲染)

这一节存在的意义:这是 v0.1 最高频路径,所有指标共用同一份 fact slice 投影(避免多次 DB 往返)。流程错位会导致首屏慢 / 数字打架。

5.7.1 时序图

[用户:点击 1Y / 取消勾选 STOCK / 切到 USD]
        │
        ▼
HTMX hx-get="/dashboard?range=1Y&accounts=1,2,4,6,8,9&currency=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 事件,重新挂图

5.7.2 关键 DTO 定义

// 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
}

5.7.3 FactViewService 实现关键

@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);
    }
}

5.7.4 性能预算

步骤 预计耗时(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。

5.7.5 HTMX 分区刷新规则

<!-- 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,逻辑完全等价。


6. 认证与会话

这一节存在的意义:Spring Security 配错就是登录全失效或全裸奔。把配置定型,并把 RememberMe / 路径白名单 / 密码重置一次性写清楚。

6.1 SecurityConfig.java

@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")); }
}

6.2 RememberMe Cookie 安全注意

属性 设置 理由
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") 同步

6.3 密码重置流程

管理员 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=0

v0.1 简化:不做"忘记密码自助流程"。只能由家庭内任意成员触发重置。

6.4 路径白名单总览

路径 鉴权 说明
/login, /login?error, /login?logout 公开
/css/**, /js/**, /img/**, /favicon.ico 公开 静态
/uploads/** 公开 logo 资源(非敏感),由 ResourceHandler 暴露
/health 公开 systemd / nginx 健康检查
/change-password 已认证 + must_change_pw=1 强制
/admin/** 已认证(任意成员) 家庭内不分级
其余 已认证 默认 deny

7. 文件上传管道(Logo)— v0.1 简化版

这一节存在的意义:v0.1 把图像处理全推到前端,后端只做小尺寸 WebP 文件的接收 + 落盘。这样可以彻底避开服务端图像库的复杂依赖与 CVE 风险。

7.1 总体管道

[浏览器 / 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/

7.2 前端代码段(Vanilla JS,放在 admin-family.html 里)

<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>

7.3 后端代码段

// 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());
    }
}

7.4 简化掉的事(v0.1 不做)

不做的事 为什么不做 风险评估
服务端 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

7.5 安全 checklist(精简)

风险 缓解
上传巨文件 → 内存 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

8. Preview HTML → Thymeleaf 迁移策略

这一节存在的意义:preview 已花了大量 UI 设计成本,迁移时若 DOM 变了,审美/视觉回归会大量发生。明确"DOM 不变 + 仅替换静态值"。

8.1 拷贝原则

不变 仅替换
所有 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。

8.2 共享 fragment 命名与组织

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

8.3 HTMX 端点约定

维度 约定
URL 命名 局部刷新端点用 _前缀片段 文件名;返回 fragment 不返回整页
HTTP 谓词 改数据用 POST/PUT/DELETE;只读分页/筛选用 GET
响应头 HX-Trigger 用于触发联动 fragment 重渲(如提交 snapshot 后触发 recalc-kpis)
错误 HttpExceptionGlobalExceptionHandler 返 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

8.4 静态资源 — Tailwind via CDN vs 本地预编译

决策

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

9. 测试策略 — v0.1 极简版

这一节存在的意义:v0.1 是单人开发 + 家庭自用,不需要企业级覆盖率指标。把测试投入压到最低,只测最容易出错且影响信任度的部分:纯计算函数。其他靠手动跑一遍 preview-级别 happy path。

9.1 必写的单元测试(只这些)

用例数 必须验证的事
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 里捎带验证,不单独写单元测试。

9.2 集成测试 — 1 个端到端 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。

9.3 测试数据 fixture

所有测试共享 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

9.4 不做的事(v0.1 跳过)

跳过的项 为什么跳过
Controller 层每个端点都 MockMvc 单人单家庭,出错自己改即可,不值得为 80% 覆盖率写空架子测
Selenium / Playwright UI 测试 preview HTMLs 已是手动 visual contract,改了能直接看到
性能基线 / 压力测试 数据量 < 1k 行,不可能慢
JaCoCo 覆盖率门禁 v0.1 不设硬指标
提前防"未来扩展"的测试 YAGNI

底线:必跑的是 § 9.1 的 ~16 单测 + § 9.2 的 1 个 IT,跑通 = 可发布。其他出问题靠手动复现 + 补单测,不强求"先测后写"。


10. 本地开发与部署

这一节存在的意义:部署是项目最容易把所有人卡住的环节。一份能照抄的 systemd / nginx 片段,胜过百句"自己 Google"。

10.1 一次性环境准备

命令
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)"

10.2 启动顺序

本地开发:

# 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

10.3 systemd unit 文件

# /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

10.4 nginx location 片段

# /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;
}

10.5 备份脚本 + systemd timer

#!/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

10.6 升级流程

场景 步骤
仅代码改动(无 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 秒重启窗口。家庭场景每月触发记账,绝不可能撞上重启秒级窗口的概率;蓝绿引入复杂度不值。


11. 风险与开放问题

这一节存在的意义:把暂缓决策列出来作为"已知技术债",未来 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 foundUnsatisfiedLinkError
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 漏网概率低,但非零 接入第三方上传源 / 多家庭暴露

12. 2026-05-08 维护性变更摘要(配合 PRD §7.9)

仅记录本批次涉及的技术契约变化,详细背景见 PRD §7.9 第三批维护。

12.1 接口契约新增

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

12.2 数据模型新增字段

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,如 "现金" / "股票"

12.3 新增 Bean / Interceptor

Component 责任
MustChangePasswordInterceptor 检查 me.mustChangePw=true,白名单外路径全部 302 → /profile/password
CacheHeaderInterceptor 给动态 HTML 注入 no-cache(取代 Spring Security 全局 cacheControl)
ProfileController GET/POST /profile/password

12.4 数据库迁移

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

12.5 外部依赖变更

变更
汇率 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 数据点标签

12.6 缓存策略

路径 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)>