
支付回调别直接改订单状态验签、幂等、事件解耦qkl-finance 是这么接的微信支付的回调能重试 8 次、每次都带同样的 out_trade_no你要是直接在回调里改订单状态 发消息 记流水重复回调来了就是双倍积分、双倍通知。qkl-boot 的 qkl-finance 模块把回调这条路走顺了验签、幂等、事件解耦、回调日志一层套一层。入口先分渠道PayGateway 注册表微信和支付宝的验签逻辑完全不一样不能混在一个方法里。qkl-finance 用PayGatewayRegistry按 channel 注册各自的 gateway回调入口只认渠道名。publicinterfacePayGateway{Stringchannel();// wxpay / alipayPayNotifyResultverifyAndParse(Stringbody,Mapheaders);}ComponentpublicclassPayGatewayRegistry{privatefinalMapgatewaysnewHashMap();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(rollbackForException.class)publicvoidhandleCallback(Stringchannel,PayNotifyResultresult){// 1. 幂等检查同一支付单重复回调直接忽略PayCallbackLogdupcallbackLogMapper.selectOne(newLambdaQueryWrapper().eq(PayCallbackLog::getPayNo,result.getPayNo()).eq(PayCallbackLog::getChannel,channel));if(dup!null){log.info(重复回调已忽略: channel{}, payNo{},channel,result.getPayNo());return;}// 2. 先落回调日志防下一步失败后对账无据PayCallbackLoglognewPayCallbackLog();log.setChannel(channel);log.setPayNo(result.getPayNo());log.setIdempotentKey(channel:result.getPayNo());log.setSuccess(1);log.setBodySummary(result.summary());callbackLogMapper.insert(log);// 3. 发布支付成功事件业务方各自监听PayPaidEventeventnewPayPaidEvent(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没有记录是哪个渠道、哪个商户、什么报文。想排查都无从下手。定位验签失败分两类真攻击和配置错。配置错证书过期、商户号不匹配比攻击常见得多但当时的代码把两类混成一个分支处理。解决验签失败也落回调日志success0 errorMsg验签失败 bodySummary再按失败原因分级SIGN_ERROR证书问题记 warn连续 3 次才告警未知异常才直接告警。落日志这个动作本身也幂等——同 payNo 的失败回调第二次进来日志更新不重复插。// 验签失败也落日志方便排查PayCallbackLoglognewPayCallbackLog();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