银行扣款了,天塌了也要记下来——回调接口的原子记账设计
文章目录
- 银行扣款了,天塌了也要记下来——回调接口的原子记账设计
- 一、银行扣款和普通业务不一样
- 二、异常分类:不是所有失败都能一样处理
- 三、`BaoPanTimer`的教训:先判断是否已处理
- 四、`TRADE_LOG`的思路:不删数据,插一条负数
- 五、异常的优先级:先落地,再处理
- 六、扣款序号 + 回执确认 + 对账:三道防线
- 七、银行扣款和医保结算的区别
- 八、结语
一、银行扣款和普通业务不一样
普通的接口调用:请求来了→处理→返回。失败了可以重试,重试失败了可以告警,告警了可以人工介入。整个过程是可逆的——在最终确认之前,一切都可以回滚。
银行扣款不一样。银行告诉你"扣了150.36元",这150.36元已经从参保人的银行卡里划走了。你不能跟银行说"我这边出错了,你把钱退回去"——退钱要走退费流程,不是回调失败就能自动撤销的。
所以回调接口的设计原则不是"成功了才记",而是任何情况下都要记下来。
二、异常分类:不是所有失败都能一样处理
银行回调过来的信息通常是一条报文:批次号、扣款明细列表、总金额、回执时间。处理这条报文时可能遇到的异常:
| 异常类型 | 场景 | 能不能重试 | 记不记账 |
|---|---|---|---|
| 网络超时 | 报文到了但响应没返回 | 能,银行会重发 | 先记,重试成功后状态改"已确认" |
| 金额不一致 | 银行扣的金额和社保算的不一样 | 不能,要人工核对 | 记,标记"待核对" |
| 报文格式错误 | 能读但字段对不上 | 不能,要人工核对 | 记,存原始报文+错误原因 |
| 数据库挂了 | 处理到一半写不进去 | 能,恢复后重放 | 记,重放时判断"是否已记" |
| 重复回调 | 银行发了两次 | 不用处理 | 查回执确认表,已处理则跳过 |
关键点:除了重复回调可以跳过,其余所有情况都必须先把原始报文存下来。存下来之后,能不能自动处理、要不要人工核对——那是第二步的事。第一步不能丢数据。
三、BaoPanTimer的教训:先判断是否已处理
银行回调不是每次都准时到。有时候网络通了报文才到,有时候银行系统重发。社保系统里的BaoPanTimer就是干这个的——定时检查银行回执到了没,没到就重发请求,到了就处理。
处理之前有一个铁律:先查回执确认表,判断这批数据是否已经处理过。银行的扣款报文里每笔都有唯一的扣款序号,这个序号就是天然的幂等键——同一笔扣款不会重复入账。查回执确认表只是多一层保险:序号匹配上了就跳过,匹配不上才是新数据。
四、TRADE_LOG的思路:不删数据,插一条负数
银行的扣款记录入库后,万一发现某笔扣错了——比如同一个人被扣了两次——怎么办?
不是delete。是插入一条金额取负的记录,同时把原记录的 TRADE_LOG 标记为"已冲正"。
原记录: batchNo=20260521, personId=10001, amount=150.36 冲正记录: batchNo=20260521, personId=10001, amount=-150.36 TRADE_LOG: 原记录状态=3(已冲正),新记录=冲正流水号为什么delete不行?因为审计的时候,审计员要看的不只是"现在的余额是多少",还要看"这150.36是什么时候扣的、谁的卡、后来为什么退了"。
TRADE_LOG表不是给程序员用的——是给审计查账用的。银行对账单过来了,社保这边给你一张清单:每一笔扣款、每一笔冲正、每一条对账结果。差一分钱都能定位到具体哪笔记录。
五、异常的优先级:先落地,再处理
回到最初的场景——银行回调接口收到了扣款报文,处理流程应该是这样的:
收到报文 ├── ① 原始报文写入日志表(无论后续发生什么,这步必须完成) │ 字段:批次号、报文原文、接收时间、处理状态="待处理" ├── ② 解析报文,校验格式和金额 │ ├── 格式错误 → 日志表状态="格式错误",结束 │ └── 格式正确 → 继续 ├── ③ 查询回执确认表,判断是否重复 │ ├── 已处理 → 日志表状态="重复报文",结束 │ └── 未处理 → 继续 ├── ④ 金额校验:银行扣款金额 vs 社保应扣金额 │ ├── 一致 → 状态="自动处理" │ └── 不一致 → 状态="待人工核对",结束 ├── ⑤ 写入扣款记录到业务表,更新个人账户余额 └── ⑥ 回执确认表标记"已处理"每一步都可以失败,但第①步不能失败。如果连第①步都失败了——数据库连不上、磁盘满了——那回调接口应该返回失败,让银行重发。这和你之前的BaoPanTimer配合起来:社保这边没收到就重发,收到了就一定落地。
六、扣款序号 + 回执确认 + 对账:三道防线
银行回调处理有三层保障:
第一层:扣款序号。银行报文中每笔扣款有唯一序号,入库时它就是主键。同一笔扣款银行重发了,插入直接报主键冲突——物理上不可能重复入账。
第二层:回执确认表。比主键约束多一层业务语义——不仅知道"这条数据见过了",还知道"这批数据整个处理完了"。
第三层:每日对账。就算前两层都没拦住的异常,对账会兜底。每天银行给社保发对账文件,社保系统做两件事:
- 按批次对总数:
银行批次总金额 - 社保系统该批次已入账总金额 = 0?不等就查差异 - 按个体对明细:把银行报文里的每笔扣款和社保系统里的入账记录逐条比对,任何人被扣了社保没记、或者社保记了银行没扣,都能定位到具体的人
对账不是在"自动化处理"的链路里——它是异步的、离线的、独立的。正因为它不在处理链路里,它才能作为最终兜底。前面的序号冲突、回执确认都是实时防御,对账是事后校验。三道防线合在一起,才敢说"银行扣了的钱社保一定记了"。
七、银行扣款和医保结算的区别
银行扣款记录的只是"钱被划走了",不涉及业务规则校验。医保结算复杂得多——账户余额够不够、统筹支付封顶了没有、大病补充怎么算——算错了一个比例就出财务事故。
所以银行回调的记账是无条件记录,医保结算的记账是规则校验后记录。前者是"只要发生了就要记",后者是"满足条件才能记"。
但两者有一个共同点:记下来就不能删,只能冲正。这是TRADE_LOG设计贯穿所有资金类业务的根本原因——不管钱是怎么动的,动了就要留痕。
八、结语
银行扣款回调接口的设计,核心不是"怎么写代码",是**“异常情况下的数据不丢失”**。网络超时、金额不一致、数据库挂了、银行重发了——每一种情况都有一个最低要求:原始报文必须先落地。
落地了,后面的事可以慢慢处理——格式错了人工修正,金额不对人工核对,重复了跳过。但没有落地这一步,后面的所有处理都没有依据。财务对账的时候问"这笔钱银行说扣了,社保这边怎么没有",你拿不出任何记录——这就是事故。