Skip to content

计息模块设计 ​

利息是账务系统里唯一"每天自动长出来"的数字。本篇把业务知识篇的计息三规则(按日计提/算头不算尾/罚息复利)下探到代码级。包括:口径配置化的规则引擎、分片游标的按日计提批、罚息复利的实现函数、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/360annual/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 新增(金融信贷域 · 模块设计层)。