Appearance
还款系统设计
定位:钱放出去只是开始,收得回来、收得分毫不差才是信贷的完整周期。还款系统的三个硬问题:三种还款入口怎么收敛到一条出口管线。一笔钱进来先冲谁。批量代扣几千笔里有一笔失败怎么办。冲抵与借据的业务语义见信贷业务全景与领域模型。记账与计息规则见账务系统设计-复式记账与日切。资金渠道与内部户见清结算与资方对接。
一、还款方式矩阵:三个入口,一条出口
| 维度 | 主动还款 | 自动代扣(批扣) | 提前结清 |
|---|---|---|---|
| 触发方 | 用户发起 | 我方日终批量发起 | 用户发起 |
| 金额确定 | 用户输入(≥ 当期最低) | 系统按应还清单生成 | 系统试算(试算单有时效) |
| 渠道 | 收银台/余额/银行卡 | 代收接口/批扣文件 | 收银台一次性支付 |
| 失败语义 | 用户自行重试 | 进重试批次 + 触达提醒 | 试算单过期重算 |
| 幂等键 | 还款单号 | 批次号 + 明细序号 | 还款单号 |
| 特殊约束 | — | 扣款时间窗(避开夜间) | 利息算头不算尾、违约金可减免 |
三种入口全部收敛到同一条出口管线——入口多元化、出口唯一化:
主动还款 ─┐
自动代扣 ─┼─▶ 还款单 ─▶ 渠道扣款/入账确认 ─▶ 账务分录 ─▶ 冲抵引擎 ─▶ 借据/额度事件
提前结清 ─┘ (唯一幂等键) (以渠道终态为准) (账务先行) (顺序配置化)出口唯一的价值:冲抵、记账、事件、对账只实现一次。任何新还款方式(余额、他人代还、新支付渠道)只是新增入口适配器,资金正确性核心零改动。
二、冲抵顺序引擎:配置化的费用→利息→本金
1)应还的拆分模型
"欠多少钱"不是一个数,是四类科目余额的组合,冲抵的对象是拆分行而不是借据总余额:
sql
CREATE TABLE iou_receivable ( -- 借据应还拆分: 冲抵的对象
id BIGINT PRIMARY KEY,
iou_id BIGINT NOT NULL,
item_type VARCHAR(16) NOT NULL, -- PRINCIPAL/INTEREST/PENALTY/FEE
period_no INT NOT NULL, -- 期号
receivable DECIMAL(16,2) NOT NULL, -- 应还
settled_amt DECIMAL(16,2) NOT NULL DEFAULT 0, -- 已冲抵
UNIQUE KEY uk_iou_item (iou_id, item_type, period_no)
);
CREATE TABLE offset_rule_config ( -- 冲抵顺序: 配置化, 按产品 + 口径版本
id BIGINT PRIMARY KEY,
product_code VARCHAR(32) NOT NULL,
rule_version VARCHAR(16) NOT NULL, -- 监管/合同口径版本
seq INT NOT NULL, -- 1,2,3… 先冲谁
item_type VARCHAR(16) NOT NULL,
effective_at DATETIME NOT NULL, -- 存量借据按立据时点版本执行
UNIQUE KEY uk_prod_ver_seq (product_code, rule_version, seq)
);2)冲抵算法
java
// 还款金额按配置顺序逐项冲抵; 每笔冲抵 = 一条账务分录 + 一行 settled_amt 条件更新
BigDecimal rest = repayAmount;
for (OffsetRule rule : rules) { // seq 升序: FEE→PENALTY→INTEREST→PRINCIPAL
BigDecimal owe = r.getReceivable().subtract(r.getSettledAmt());
BigDecimal hit = rest.min(owe);
if (hit.signum() > 0) { offsetItem(r, hit); rest = rest.subtract(hit); }
if (rest.signum() == 0) break;
}
if (rest.signum() > 0) depositToSuspense(rest); // 溢缴款挂暂收, 不冲"还没发生的账"- 顺序为什么配置化:监管口径会调整(费用/罚息/利息/本金的先后有政策窗口)。产品间有差异、合同有约定。写死代码意味着每次口径调整都是发版,且存量借据必须按立据时点的口径执行。所以顺序必须带版本号(与清结算与资方对接里清分规则按政策版本回放是同一问题);
- 本金最后冲不是商务选择而是风险常识。本金在,利息罚息才有计息基数。本金先冲完,后续计息成了无源之水;
- 尾差规则写进配置:最后一期应还 = 剩余全部,凑整误差落在指定期次。禁止"差一分钱结不了清"。
三、部分还款时序
用户 还款域 渠道 账务域 借据/额度
│ ①还款 3000 │ │ │ │
├──────────────▶│ 立还款单 PROCESSING│ │ │
│ ◀──待支付──────┤ │ │ │
│ ②收银台支付 ├─────────────────▶│ 代收 │ │
│ │ ◀──支付成功回调─────┤ (验签+三层幂等) │ │
│ │ ③记账: 借 备付金 / 贷 应收还款 │ │
│ ├─────────────────────────────────▶│ 凭证落库(借贷平衡)│
│ │ ④冲抵: FEE 0 → PENALTY 0 → INTEREST 300 → PRINCIPAL 2700
│ │ ⑤事件: 还款成功 ──▶ 借据应还更新 ──▶ 额度释放 2700 │
│ ◀──还款成功────┤ ⑥剩余应还在: 借据仍 ACTIVE, 计息照常推进 │纪律:账务先行——渠道终态确认后先记账再冲抵;冲抵失败不回滚分录,进补偿队列异步重试。资金已收是渠道终态背书的事实,冲抵只是业务视图更新。先账务后业务的取舍见信贷业务全景与领域模型追问 Q2。
四、全额结清时序
结清 = 一笔把应还清零的交易,难点在试算口径的时效性:
①试算: 结清额 = 本金余额 + 当期利息 + Σ罚息 + 提前结清违约金(减免需审批留痕)
口径: 利息算到 T 日(算头不算尾, 见账务篇计息规则); 试算单当日有效, 过期重算
②支付: 用户按试算额支付(金额锁定, 多退少不补)
③冲抵: 金额恰好清零 → 借据 CAS: ACTIVE→SETTLED(伴生 settled_at 原子回填)
④联动: 额度释放(循环额度 available 回补) / 结清证明异步生成 / 推送通知
⑤兜底: 渠道延迟到账跨日 → 重算利息出现差额 → 差额挂暂收人工核, 不自动"凑整结清"提前结清的两个资金敏感点:利息只算到结清日(多收一天都是客诉加监管风险)。违约金减免是产品配置且必须留审批痕迹。减免没有留痕,稽核时就是"资金去向不明"。
五、批量代扣设计:批扣文件 → 执行 → 结果回写
T日 22:00 计息/应还批完成 ──▶ 生成批扣清单(到期未还, 剔除当日已主动还款者)
按渠道/银行拆批, 生成批次清单三要素(笔数/金额/校验和)
T+1日 08:30 批次状态机 CREATED → EXECUTING(限速执行, N 笔/秒)
09:00 逐笔代扣指令 ──┬─ 成功 ──▶ 走统一冲抵出口(第三节同一条管线)
└─ 失败 ──▶ 记录渠道失败码, 绝不立即盲重
11:00 结果回写: 渠道结果文件/回调 ──▶ 逐笔勾对(指令 vs 回执, 差异进对账)
13:00 失败分类: 余额不足→傍晚二扣 / 卡异常→短信引导主动还 / 渠道故障→熔断顺延sql
CREATE TABLE batch_deduct_batch ( -- 批次: 状态机 + 清单三要素
id BIGINT PRIMARY KEY,
biz_date DATE NOT NULL,
channel_code VARCHAR(32) NOT NULL,
total_cnt INT NOT NULL,
total_amt DECIMAL(16,2) NOT NULL, -- 与明细 SUM 逐日勾对
status VARCHAR(16) NOT NULL, -- CREATED/EXECUTING/PARTIAL/DONE/FAILED
UNIQUE KEY uk_date_channel (biz_date, channel_code)
);
CREATE TABLE batch_deduct_item ( -- 明细: 幂等键 = 批次 + 明细
id BIGINT PRIMARY KEY,
batch_id BIGINT NOT NULL,
iou_id BIGINT NOT NULL,
amount DECIMAL(16,2) NOT NULL,
status VARCHAR(16) NOT NULL, -- INIT/SUCCESS/FAIL/RETRIED
fail_code VARCHAR(32),
UNIQUE KEY uk_batch_item (batch_id, id)
);批扣三条纪律:
- 时间窗:银行代收有窗口约束,夜间扣款既是投诉源失败率也高。
- 限速:渠道合同 QPS 上限,失败率突升自动熔断顺延。
- 部分成功是常态:批次状态 PARTIAL,只重推失败行,成功行绝不再扣。批次协议同清结算与资方对接的文件批次三要素。
六、失败重试与补偿
| 失败类型 | 判定 | 处理 |
|---|---|---|
| 余额不足 | 明确失败码 | 傍晚二次批扣(可配置)→ 次日提醒,不无限重试 |
| 渠道超时 | 无终态 | 先查单后重试:查单确认失败才允许再扣;查不到挂"在途"进对账 |
| 卡状态异常(冻结/销户) | 明确失败码 | 不重试,转短信/推送引导主动还款 |
| 渠道批量故障 | 失败率 > 阈值 | 熔断整批顺延;已扣成功的不回滚 |
两条铁律:
- 重复扣款防御:任何重试前必须拿到前次指令的终态。拿不到就查单,查单也超时就挂起人工。"超时盲重试"是还款域第一大资损来源。幂等三层与查单补单的同构见幂等与对账-资金安全底座;
- 多扣必退:主动还款与批扣并发导致超额冲抵时,溢缴款挂暂收并原路退回。T+1 对账核"暂收余额 = 待退款清单"。
七、异常与降级
| 故障 | 预案 |
|---|---|
| 冲抵引擎不可用 | 入账照常(资金事实优先),冲抵事件积压异步补,用户侧展示"处理中" |
| 账务不可用 | 停收新还款(拒绝进钱比记错钱安全),在途资金挂暂收,恢复后补记 |
| 批量任务中途失败 | 批次状态机断点续跑(按明细状态扫描),不整批重来 |
| 渠道结果文件未到 | 超时告警 + 逐笔查单兜底;差异累积进对账,绝不按"全失败"处理 |
八、追问链
Q1:先冲抵还是先记账? 先记账。资金到账是渠道终态背书的事实,冲抵是业务视图。反过来先冲抵后记账,记账失败就会出现"借据说还了、账上说没收钱"。四层核对里的账证核对立刻抓出来。账务是唯一资金事实源(见账务系统设计-复式记账与日切)。
Q2:还款金额大于全部应还,多出来的钱怎么办? 溢缴款挂暂收科目,原路退回或按产品配置抵充未来账单。绝不能把多还的钱冲进"还没发生的利息"——没发生的账不存在,冲了就是预收,计息口径全乱。
Q3:主动还款和批扣同一秒并发同一借据? 两层防御。入口层:批扣清单生成时剔除当日已主动还款的借据(事前规避)。冲抵层:每笔冲抵按应还余额条件更新,第二笔到时应还已被冲掉,自然滑向暂收/退款(事中收敛)。并发窗口内的多扣靠退款兜底,不追求分布式锁互斥。
Q4:为什么冲抵顺序把费用和罚息排在利息本金前面? 监管通常要求先收已产生的费用与罚息(防止"先还本"让罚息基数长期挂账)。这也是出借人利益保护的一致口径。但关键是"通常"——不同时期口径有差异。所以它必须是带版本号的配置而不是代码常量。配置化的成本就是为存量借据保留口径版本。
Q5:结清试算额和实际到账差了一分钱/晚了一小时怎么办? 金额差一分:差额挂暂收,人工核后红冲或补收,不允许"凑整结清"。时间差过夜:利息按实际到账日重算(算头不算尾),多收部分自动退。试算单必须有时效——无限有效的试算单等于把计息口径冻结在过去。
Q6:批扣为什么不全量并发打渠道? 渠道合同有 QPS 上限、批量接口有窗口约束,且失败率随并发毛刺上升。限速执行加失败率熔断,既是"对渠道的礼貌"也是对自己的保护。一次打挂渠道,当天所有还款都进不了账。
九、面试官视角小结
面试官在还款设计里检查四件事:
- 入口出口观:能不能把多入口收敛到唯一资金管线——每个入口各写一套冲抵逻辑是事故温床;
- 冲抵顺序:说得出"费用→罚息→利息→本金",更要说得出"为什么配置化、为什么带版本";
- 时序纪律:账务先行、先查单后重试、部分成功不回滚——三句都是资金安全的一线纪律;
- 批量的工程感:批次状态机、限速、熔断、断点续跑——说明真跑过日终批量,不是只画过在线链路。
一句话总结:入口多元出口一,账务先行冲抵配,先查终态再重试,批扣限速留退路。
2026-10 新增(金融信贷域 · 系统设计层)。