支付成功回调地址收到一年前订单的回调事件
结论
订单已经支付成功,即使接口返回过 success,之后仍可能再次收到支付成功回调。支付回调通常采用“至少投递一次”机制,因此,重复通知不代表订单被重复支付。
业务系统应对支付回调做幂等处理。收到回调后,先验签并核对订单信息,再检查订单状态。对于已经处理成功的订单,只需返回支付平台规定的成功响应,不能再次发货、充值、记账或发放权益。
不过,时隔一年再次收到通知并不常见。除了平台重试,还需要排查人工补发、对账补偿、订单号复用、日志时间错误和伪造请求等情况。
为什么会再次收到回调
平台没有确认收到成功响应
业务接口执行了 return success,不代表支付平台一定成功收到了响应。常见情况包括:
- 返回响应时连接已经中断;
- 网关、负载均衡或 CDN 返回超时、
502、504; - 响应正文、状态码或
Content-Type不符合平台要求; - 应用日志记录了
success,但响应被代理层修改或拦截; - 接口处理时间过长,平台在收到响应前已经判定超时。
支付平台通常会按照既定策略重试。至于通知是否可能持续或延迟一年,需要查看具体平台的通知规则,不能只根据重试机制判断。
平台执行了补偿或重新通知
部分支付平台可能因为对账修复、历史数据补偿、人工操作或系统迁移而重新发送通知。是否存在这种机制,需要向对应平台核实,并提供商户号、订单号、平台交易号和本次通知时间。
商户订单号被重复使用
如果业务系统复用了旧订单号,新交易或测试请求可能被错误关联到一年前的订单。排查时应同时比较:
- 商户订单号;
- 支付平台交易号;
- 支付金额和币种;
- 商户号、应用 ID;
- 支付完成时间;
- 回调中的通知 ID 或事件 ID。
仅凭商户订单号,无法确认是不是同一笔支付。
请求并非来自支付平台
攻击者可能重放历史通知,也可能自行构造回调请求。如果系统没有严格验签,只根据请求参数更新订单,就可能造成重复发货或订单数据被篡改。
来源 IP 只能用于辅助判断,不能替代签名验证。如果对请求有疑问,应通过支付平台的订单查询 API 主动核实交易状态。
时间字段或日志存在误判
还需要确认“一年前”具体指订单创建时间、支付时间,还是回调参数中的时间。服务器时区错误、日志采集延迟、消息队列积压或历史日志重新导入,都可能导致时间判断出现偏差。
建议的排查顺序
1. 保存本次回调原始信息
至少保留以下内容:
- 原始请求体,不要重新序列化;
- 请求头和请求时间;
- 商户订单号、平台交易号;
- 通知 ID、事件 ID;
- 金额、币种和支付状态;
- 签名、时间戳、随机串;
- 接口最终返回的 HTTP 状态码和响应正文;
- 网关及应用服务器的访问日志。
日志中的敏感字段应脱敏。密钥、证书私钥和完整身份信息不得写入日志。
2. 验证签名和商户身份
按照对应支付平台的官方规则,使用原始请求体或平台规定的字段验签,同时核对商户号、应用 ID 和回调类型等信息。
验签失败时不能更新订单。此时应返回的状态码和响应正文,以支付平台协议为准。
3. 查询平台订单状态
调用平台提供的订单查询 API,根据平台交易号或商户订单号查询,并核对:
- 交易是否真实存在;
- 当前状态是否为支付成功;
- 支付金额和币种是否一致;
- 支付时间是否确实为一年前;
- 平台交易号是否与历史记录一致。
交易结果不能只依赖回调参数判断。
4. 检查历史响应链路
查看应用、反向代理、负载均衡和 API 网关日志,确认历史回调实际返回了什么。重点检查:
- 是否确实返回 HTTP
200; - 响应正文是否完全符合平台要求;
- 是否发生超时或连接重置;
- 是否先返回成功,随后又抛出异常;
- 是否存在多个回调地址或旧服务实例。
应用日志中的“已返回 success”只能证明代码执行到了相应位置,不能证明支付平台收到了有效确认。
5. 检查重复业务结果
查询订单关联的发货记录、余额流水、积分、优惠券和财务凭证,确认本次通知是否引起了重复处理。如果已经发生重复记账,应通过冲正或补偿流程修复,不能直接删除流水。
回调接口的幂等处理示例
以下是通用伪代码。验签方法、成功响应格式和字段名称都需要替换为支付平台的实际规定。
@PostMapping("/payment/callback")
public ResponseEntity<String> callback(
@RequestBody String rawBody,
@RequestHeader Map<String, String> headers) {
// 必须使用平台规定的验签算法和原始请求数据
PaymentEvent event = paymentVerifier.verifyAndParse(rawBody, headers);
if (event == null) {
return ResponseEntity.status(401).body("invalid signature");
}
paymentService.handleCallback(event);
// 必须替换成平台要求的状态码和响应正文
return ResponseEntity.ok("success");
}
业务处理应放在数据库事务中,并通过唯一约束或行锁防止并发重复执行:
@Transactional
public void handleCallback(PaymentEvent event) {
Order order = orderRepository.findByOrderNoForUpdate(event.getOrderNo())
.orElseThrow(() -> new IllegalStateException("order not found"));
if (!order.getMerchantId().equals(event.getMerchantId())) {
throw new IllegalStateException("merchant mismatch");
}
if (order.getAmount().compareTo(event.getAmount()) != 0) {
throw new IllegalStateException("amount mismatch");
}
if (!"SUCCESS".equals(event.getPaymentStatus())) {
return;
}
// 已支付订单直接结束,不能重复执行发货、充值等操作
if ("PAID".equals(order.getStatus())) {
return;
}
int updated = orderRepository.markPaidIfUnpaid(
order.getOrderNo(),
event.getTransactionId(),
event.getPaidAt()
);
if (updated == 1) {
fulfillmentService.createOnce(order.getOrderNo());
}
}
数据库还应设置唯一约束,作为最后一道防线:
ALTER TABLE payment_notification
ADD CONSTRAINT uk_payment_notification_event
UNIQUE (provider, merchant_id, event_id);
ALTER TABLE payment_transaction
ADD CONSTRAINT uk_payment_transaction_id
UNIQUE (provider, merchant_id, transaction_id);
ALTER TABLE fulfillment
ADD CONSTRAINT uk_fulfillment_order
UNIQUE (order_no, fulfillment_type);
如果平台没有稳定的 event_id,可以用平台交易号和事件类型组成幂等键。但在这样处理前,必须确认同一笔交易是否允许产生多个合法事件,不能直接去重。
注意事项
- 即使订单已经支付,也不能跳过验签。重复通知同样可能来自伪造请求。
- 不要先返回
success,再交给不可靠的异步任务处理业务。如果使用异步模式,应先将事件可靠地写入本地表或消息系统。 - 收到支付成功通知,不代表只能执行一次发货逻辑。发货、充值和积分等下游操作都需要各自实现幂等。
- 回调接口应尽快完成验签、落库和状态更新,耗时操作应交给可靠的异步流程。
- 对于时隔一年的通知,完成本地核查后,建议携带脱敏后的通知 ID、平台交易号、商户订单号和接收时间,向支付平台确认是否属于补偿或人工重发。
备注:内容仅供参考。