Appearance
计息模块设计
利息是账务系统里唯一"每天自动长出来"的数字。本篇把业务知识篇的计息三规则(按日计提/算头不算尾/罚息复利)下探到代码级。包括:口径配置化的规则引擎、分片游标的按日计提批、罚息复利的实现函数、BigDecimal 与 DECIMAL 的精度纪律。业务口径的来龙去脉见账务系统设计-复式记账与日切,冲抵与逾期处理时序见还款系统设计。
计息规则引擎:口径即配置,定价即快照
利息争议的根源几乎都不是算错,而是口径漂移。规则引擎的第一原则:口径是数据不是代码。换算基准、复利基数、舍入模式全部配置化,且放款时固化到借据(定价快照),产品改价不影响存量:
sql
CREATE TABLE loan_rate_plan ( -- 产品级计息口径
product_code VARCHAR(32) NOT NULL,
plan_type VARCHAR(16) NOT NULL, -- NORMAL / PENALTY
annual_rate DECIMAL(8,6) NOT NULL, -- 0.072000 = 年化 7.2%
day_count SMALLINT NOT NULL, -- 日利率分母:360 / 365 / 366(ACT)
accrual_basis VARCHAR(24) NOT NULL, -- 计息基数:PRINCIPAL / PRINCIPAL_PLUS_ACCRUED(复利)
round_mode VARCHAR(8) NOT NULL DEFAULT 'HALF_UP',
effective_date DATE NOT NULL,
UNIQUE KEY uk_prod_type_eff (product_code, plan_type, effective_date)
);
-- 借据表上的定价快照(放款事务内从 rate_plan 拷贝,此后只读)
-- loan: normal_rate DECIMAL(8,6), penalty_rate DECIMAL(8,6), day_count, accrual_basis ...日利率换算与闰年:金融惯例按 360 天计息(月度整数化),但口径必须显式选择,闰年效应各不相同:
| 口径(day_count) | 日利率 | 闰年 2/29 当天 | 谁吃亏 |
|---|---|---|---|
| 30/360 | annual/360,每月按 30 天 | 不计息(2 月固定 30 天) | 客户(少一天) |
| ACT/365(固定 365) | annual/365 | 照常计息,全年 366 天 | 客户(多付一天) |
| ACT/ACT(实际/实际) | annual/当年实际天数 | 分母变 366,日利率变小 | 机构(单日少收) |
算头不算尾的天数计算——放款日起息、结清日不计息,代码上就是把结束日期设为排他区间:
java
/** 计息天数:算头不算尾,[start, end)。6/1 放款 6/11 结清 → 10 天,结清日不计息。 */
long days = ChronoUnit.DAYS.between(loan.getStartDate(), settleOrToday); // 10
if (days < 0) throw new BizException(...); // 倒起息防御:还款日期早于放款日期直接拒绝天数差一天 × 全量客户 = 监管处罚的金额来源。所以口径三处一致(合同/代码/对账预期)在模块层的落法是:day_count 与区间语义只存在于 loan_rate_plan 一处。计息、试算、对账预期都从它推导,不允许第三份手工常量。
按日计提批任务:分片游标扫描与断点续跑
计提(accrual)挂在日切流程第 1 步(见业务知识篇),亿级借据单线程扫不完。按 id 范围切成 N 片并行,每片用游标扫描流式处理:
loan 全表 ──按 id 范围均匀切 N 片──▶ shard_0 │ shard_1 │ ... │ shard_N-1 (多 worker 并行)
│
每片内循环(游标扫描,不一次载入): ▼
┌─────────────────────────────────────────────────────┐
│ SELECT id, principal_outstanding, ... FROM loan │
│ WHERE id BETWEEN #{shardStart} AND #{shardEnd} │
│ AND id > #{cursor} │
│ ORDER BY id LIMIT 500; │
│ └─ 逐条: 算当日利息 → 本地事务{插计提凭证+流水} │
│ └─ UPDATE batch_cursor SET last_id = 本批最大id │
│ WHERE task_date = #{今日} AND shard_no = #{本片};│ ← 每批提交一次断点
└────── rows < 500 → 本片完成 ──────────────────────────┘断点表与幂等双保险:
sql
CREATE TABLE batch_cursor (
task_date DATE NOT NULL, -- 会计日期(软切:按交易日期戳,非自然日)
job_name VARCHAR(32) NOT NULL, -- LOAN_ACCRUAL
shard_no INT NOT NULL,
last_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL, -- RUNNING / DONE
PRIMARY KEY (task_date, job_name, shard_no)
);
-- 计提幂等的最后防线:同一借据同一会计日只允许一条计提流水
UNIQUE KEY uk_accrual (loan_id, accrual_date)推演三种失败点:批内单条失败(如某借据数据异常)——跳过并记异常清单,游标继续推进。不让一颗老鼠药停全批(日终有 SLA)。worker 中途崩溃——重启按 (task_date, shard_no) 读 last_id 续跑,已提交批次不重算。cursor 更新与计提不在一个事务导致重复。重算的批撞 uk_accrual 唯一键,DuplicateKey 转 skip,正确性不受影响(游标只是加速,唯一键才是正确性)。这延续了"应用层机制管效率、数据库约束管正确"的分工,与额度中心的 version 之辩同一哲学。
罚息与复利的实现
计息主函数是纯函数(快照进、金额出,无副作用),单测覆盖全部口径组合:
java
/** 某会计日的单日计提额。snapshot 为借据在该日 00:00 的状态快照。 */
public BigDecimal accrue(LoanSnapshot loan, LocalDate day) {
// 逾期切换:罚息利率按"当日 00:00 状态"取——今天到期未还,今天就算罚息,
// 口径写进合同(宽限期有无决定是 D+0 还是 D+1 切换,一处配置)
RatePlan plan = loan.isOverdueAsOf(day) ? loan.penaltyPlan() : loan.normalPlan();
// 日利率:高精度中间值(10 位),入账才 setScale(2)
BigDecimal dailyRate = plan.annualRate()
.divide(BigDecimal.valueOf(plan.dayCount()), 10, RoundingMode.HALF_UP);
// 计息基数:复利开关参数化——正常利息多数合同不计复利,
// 罚息是否对欠息再计息按产品配置(PRINCIPAL / PRINCIPAL_PLUS_ACCRUED)
BigDecimal base = plan.basis() == PRINCIPAL
? loan.principalOutstanding()
: loan.principalOutstanding().add(loan.accruedInterest());
BigDecimal interest = base.multiply(dailyRate).setScale(2, RoundingMode.HALF_UP);
capCheck(loan, day, interest); // 上限校验在计息引擎内:本金+累计利息+本日 ≤ 司法/产品上限
return interest; // 超限截断 + 告警,监管红线不靠营销页自觉
}
private void capCheck(LoanSnapshot loan, LocalDate day, BigDecimal todayInterest) {
BigDecimal total = loan.principalOutstanding()
.add(loan.accruedInterest()).add(todayInterest);
if (total.compareTo(loan.capAmount()) > 0) { // compareTo,永不 equals
log.warn("计息触顶截断: loanNo={}, total={}, cap={}", ...);
throw new CapExceededException(...); // 截断方案进人工差异单,不静默吞
}
}与账务的衔接:每天的计提额落"借 应收利息(资产)/ 贷 利息收入(损益)"凭证。应还利息 = 计提流水累加,还款只做冲抵。所以本模块只产生计提凭证,不碰任何余额字段,余额全部是流水的推导值。
精度处理:BigDecimal 与 DECIMAL 的纪律
精度纪律不是风格问题,是资金正确性问题。全链路强制的六条:
java
new BigDecimal("0.072") // ✓ 字符串构造。✗ new BigDecimal(0.072) 把 double 的
// 二进制循环小数误差原样带进来
a.compareTo(b) == 0 // ✓ 数值比较。✗ a.equals(b):scale 敏感,2.0 != 2.00
a.divide(b, 10, HALF_UP) // ✓ 三参除法。✗ a.divide(b):除不尽直接抛
// ArithmeticException(1/3 场景必炸)
x.stripTrailingZeros() // ⚠ 坑:100.00 → 1E+2 科学计数法;入库前统一 setScale,
// 展示用 toPlainString()
double / float // ✗ 全链路禁止:0.1 是二进制循环小数,误差 × 亿级借据 × 365 天
BigDecimal.valueOf(doubleVal) // ⚠ 仅允许出现在边界(接收外部 double 时),内部一律字符串/数值构造数据库侧的分工:中间量高精度,入账两位。计提流水/利率用 DECIMAL(20,8),余额与凭证金额用 DECIMAL(16,2)。求和放数据库做(MySQL SUM(DECIMAL) 无精度损失),Java 侧接收结果映射为 BigDecimal,禁止在 Java 里对 double 列表求和。舍入模式按场景固定并写进口径配置:
| 场景 | 模式 | 理由 |
|---|---|---|
| 单日计提入账 | HALF_UP,scale=2 | 会计惯例四舍五入到分;舍入点必须在入账点,逐日可审计 |
| 等额本息逐期拆分 | 每期 HALF_UP,末期吸尾 | Σ各期 = 合同总额恒等;尾差(±几分)塞进最后一期 |
| 月度/报表汇总 | 明细求和后不重算 | 报表数必须等于明细累加,重算必出"对不上 1 分" |
| 罚息上限截断 | DOWN | 超限部分向对客户有利方向舍去 |
| 联合贷资方份额分摊 | HALF_EVEN + 尾差规则入合同 | 银行家舍入降低累计偏差;尾差归属合同约定(见清结算与资方对接) |
追问链
Q1:为什么普遍用 360 而不是 365 做分母,闰年多出的一天怎么算? 360 是历史惯例(每月 30 天的手工算账遗产),好处是月利息整数化、报价直观。代价是 ACT 口径下闰年 366 天客户多付一天。关键不是选哪个,而是:口径是合同条款,选了就三处一致(合同/代码/对账预期)。换口径是一次重定价事件而不是改个常量。面试官在这里等的答案就是"口径即配置 + 定价快照 + 三处一致"。
Q2:计提批跑到一半挂了,怎么保证不多记也不少记? 三层:游标让重启从断点续(效率层)。uk_accrual(loan_id, accrual_date) 唯一键让任何形式的重复执行撞墙转 skip(正确性层)。日终试算平衡 + 账证核对兜住"整个批次漏跑"(存量层,见业务知识篇四层核对)。推演要能说清"cursor 和计提不在一个事务"这个最微妙点的归宿——唯一键。
Q3:为什么不用存储过程/触发器在数据库里算利息? 利息逻辑是核心资产:要单测覆盖口径矩阵、要版本化评审、要随监管规则迭代。存储过程三样全缺,且在分库分表下不可移植。数据库只做它擅长的:行存取、事务、DECIMAL 求和;业务计算收敛在应用层纯函数。
Q4:逾期切换罚息的那一天,到底算正常利息还是罚息? 按"当日 00:00 状态"取利率——今天该还没还,今天整日就是罚息(有无宽限期决定 D+0 还是 D+1,写进合同)。反着取(日终状态)会出现"当天下午还款成功,当天却已按罚息计提"的客诉。这个边界答案没有对错,但必须能意识到它是口径问题并给出一致的处理点。
Q5:为什么要逐日 setScale(2),攒一个月一起舍入误差更小? 逐日入账是审计要求:每天的计提凭证独立成立、可单独冲销,舍入点必须与入账点重合。攒批舍入意味着"中间 29 天的数在账上不存在",冲销、对账、监管按日报表全都无据可依。误差大小不是目标,可审计性才是——这是资金系统与普通业务系统的价值观分野。
Q6:还款冲抵时发现应还利息和计提累计对不上 1 分钱,信谁? 信计提流水累加(流水是唯一事实),查冲抵侧的舍入/并发冲抵路径。1 分钱的差异也走差错单(对账以分为单位判等)。因为"允许小误差"的口子一开,日积月累就是复利级的账实漂移。
面试官视角小结
计息模块的面试通常从"算头不算尾是什么意思"这类口径题开始,但真正的区分度在三层。第一层,能不能把口径说成配置而不是硬编码,并主动提到定价快照(改价不影响存量)。第二层,能不能设计一个亿级借据的日终批。分片、游标、断点续跑、幂等唯一键,以及最关键的"游标失效时正确性由谁兜"的推演。
第三层,精度纪律。BigDecimal 六条禁令能脱口而出且说得出每条背后的事故(divide 不带 scale 抛异常、equals 的 scale 陷阱、double 构造误差)。这是"写过资金代码"和"读过资金文章"的分界。加分项是主动把计息接到账务不变量上:计提只产凭证不碰余额,利率上限校验做在引擎里。说明你理解利息在整个资金体系中的位置,而不只是一个数学函数。
2026-10 新增(金融信贷域 · 模块设计层)。