支付回调别直接改订单状态:验签、幂等、事件解耦,qkl-finance 是这么接的
微信支付的回调能重试 8 次、每次都带同样的 out_trade_no,你要是直接在回调里"改订单状态 + 发消息 + 记流水",重复回调来了就是双倍积分、双倍通知。qkl-boot 的 qkl-finance 模块把回调这条路走顺了:验签、幂等、事件解耦、回调日志,一层套一层。
入口先分渠道:PayGateway 注册表
微信和支付宝的验签逻辑完全不一样,不能混在一个方法里。qkl-finance 用PayGatewayRegistry按 channel 注册各自的 gateway,回调入口只认渠道名。
publicinterfacePayGateway{Stringchannel();// wxpay / alipayPayNotifyResultverifyAndParse(Stringbody,Mapheaders);}@ComponentpublicclassPayGatewayRegistry{privatefinalMapgateways=newHashMap<>();publicPayGatewayRegistry(Listlist){list.forEach(g->gateways.put(g.channel(),g));}publicPayGatewayget(Stringchannel){returngateways.get(channel);}}WxPaySdkSupport和AlipaySdkSupport各自封装 SDK 验签,网关实现里只管"验签 + 解析成统一结构PayNotifyResult"。业务代码永远不碰 SDK 细节,哪天微信换验签协议,改一个 gateway 类就行。
幂等:回调日志表 + 唯一索引,重复回调直接忽略
验签过了,第一件事不是改订单,是查幂等。pay_callback_log表把每次回调都记下来,pay_no + channel做唯一键——同一个支付单的重复回调,第二次直接命中唯一索引,return。
@Transactional(rollbackFor=Exception.class)publicvoidhandleCallback(Stringchannel,PayNotifyResultresult){// 1. 幂等检查:同一支付单重复回调直接忽略PayCallbackLogdup=callbackLogMapper.selectOne(newLambdaQueryWrapper().eq(PayCallbackLog::getPayNo,result.getPayNo()).eq(PayCallbackLog::getChannel,channel));if(dup!=null){log.info("重复回调已忽略: channel={}, payNo={}",channel,result.getPayNo());return;}// 2. 先落回调日志(防下一步失败后对账无据)PayCallbackLoglog=newPayCallbackLog();log.setChannel(channel);log.setPayNo(result.getPayNo());log.setIdempotentKey(channel+":"+result.getPayNo());log.setSuccess(1);log.setBodySummary(result.summary());callbackLogMapper.insert(log);// 3. 发布支付成功事件,业务方各自监听PayPaidEventevent=newPayPaidEvent(this,result.getPayNo(),result.getAmount());applicationEventPublisher.publishEvent(event);}顺序很重要:先插回调日志,再发事件。日志插不进去(重复),后面全不执行;日志插进去了,事件发布失败可以靠日志重放对账。回调日志的bodySummary存原始报文摘要,errorMsg存失败原因——出问题对账的时候,这两列就是证据。
事件解耦:支付成功后的 N 件事,各自监听
订单改状态、用户发通知、流水记账、优惠券核销……这些都塞在回调里,回调方法会膨胀到 200 行,还耦合。qkl-finance 用 Spring 事件拆开:回调只负责"确认支付成功 + 发布 PayPaidEvent",剩下的各干各的。
@ComponentpublicclassPayPaidNotifyListener{@EventListenerpublicvoidonPaid(PayPaidEventevent){// 更新订单状态:paidorderMapper.updateStatus(event.getPayNo(),OrderStatus.PAID);// 发通知:微信模板消息notifyService.sendPaid(event.getPayNo());// 记流水:账户余额入账financeService.recordIncome(event.getPayNo(),event.getAmount());}}监听器里各自加 @Transactional 或者自己管事务——因为事件默认同步执行,监听器抛异常会回滚到发布点。想让某个监听失败不影响其他,用@TransactionalEventListener(phase = AFTER_COMMIT),发布方提交后再执行,真正解耦。
踩坑:回调验签失败先落日志,再决定要不要报警
现象:上线支付第二天,凌晨收到一条告警"支付回调验签失败",点进去一看是我们自己的测试环境用商户号发来的——测试环境换了证书,验签没过,但告警系统不知道。
排查:验签失败的日志只有一行verify failed,没有记录是哪个渠道、哪个商户、什么报文。想排查都无从下手。
定位:验签失败分两类:真攻击和配置错。配置错(证书过期、商户号不匹配)比攻击常见得多,但当时的代码把两类混成一个分支处理。
解决:验签失败也落回调日志(success=0 + errorMsg=验签失败 + bodySummary),再按失败原因分级:SIGN_ERROR(证书问题)记 warn,连续 3 次才告警;未知异常才直接告警。落日志这个动作本身也幂等——同 payNo 的失败回调第二次进来,日志更新不重复插。
// 验签失败也落日志,方便排查PayCallbackLoglog=newPayCallbackLog();log.setChannel(channel);log.setPayNo(payNo);log.setIdempotentKey(channel+":"+payNo);log.setSuccess(0);log.setErrorMsg("验签失败: "+e.getMessage());callbackLogMapper.insert(log);可以直接复用的清单
- 分渠道验签:PayGateway 接口 + Registry 注册表,业务代码不碰 SDK;换协议只改一个类。
- 幂等优先:
pay_no + channel唯一索引,重复回调先查后插直接忽略;幂等键别只用 payNo(多渠道可能撞号)。 - 先落日志再发事件:回调日志插成功才往后走,失败也有据可查;bodySummary/errorMsg 必记。
- 事件解耦:回调只发布 PayPaidEvent,订单/通知/流水各自监听;跨事务解耦用
@TransactionalEventListener(AFTER_COMMIT)。 - 验签失败也落库:分级告警,证书过期这类配置错别一上来就轰炸告警群。
- 上线后跑一次"重复回调压测":同一 payNo 重放 10 次,订单状态只能变一次。
项目源码:https://gitee.com/gzqkl/qkl-boot