一年前的支付宝订单为何近期再次触发异步通知
结论
一年前已经完成的支付宝订单,最近又命中了异步通知接口,并不代表支付宝刚刚主动发送了一条新通知。
可能的原因有:
- 历史通知被网关、消息队列、补偿任务或人工操作重新投递。
- 有人或某个程序重放了之前保存的通知请求。
- 订单最近发生退款、关闭、争议处理等状态变化,相关产品再次发送了业务通知。
- 支付宝当时没有收到正确的
success响应,因此触发重试。不过,普通支付通知时隔一年仍在重试,通常不符合常见的重试行为,具体要以所用产品的官方通知规则为准。 - 通知接口遭到扫描或伪造调用。如果验签失败,就不能认定请求来自支付宝。
即使验签成功,也只能说明参数与签名能够匹配支付宝公钥,无法单凭这一点证明请求是在本次访问时由支付宝新生成的。如果有人完整重放历史上的合法通知,原有签名仍可能通过验证。
如何判断通知来源
先在服务器原始访问日志中找到这次请求,保留完整的 POST 参数、接收时间、请求头和链路日志,再重点检查这些字段:
out_trade_no:商户订单号。trade_no:支付宝交易号。trade_status:通知携带的交易状态。notify_time:通知声明的发送时间。gmt_payment:支付完成时间。app_id:是否属于当前应用。seller_id:是否属于当前收款账号。total_amount:是否与本地订单金额一致。sign、sign_type:用于验证通知签名。
可以按照下面的顺序判断:
- 使用支付宝公钥完成 RSA2 验签。
- 核对
app_id、seller_id、订单号和金额。 - 查询本地订单,确认相同状态是否已经处理。
- 必要时调用支付宝交易查询接口,核实当前交易状态,不要只凭通知修改订单。
- 检查反向代理、API 网关、消息队列和定时补偿任务,确认是否存在请求重放。
- 与一年前的通知日志进行对比。如果参数和签名完全相同,很可能是历史请求被重放。
来源 IP 只能作为排查线索,不能作为判断请求是否可信的依据。代理转发、网络架构变化和 IP 伪造风险都会影响判断,最终还是要看验签结果和订单数据是否一致。
正确处理方式
异步通知接口必须具备幂等性。同一订单、同一交易状态无论收到多少次通知,都只能产生一次业务结果。
处理流程可以是:
接收通知
-> 验签
-> 核对 app_id、seller_id、订单号和金额
-> 查询本地订单
-> 已处理则直接返回 success
-> 未处理则在事务中更新订单并记录通知
-> 提交成功后返回 success
如果验签失败、金额不一致或商户身份不匹配,应停止业务处理并记录安全日志。不能因为接口收到请求,就直接把订单改为已支付。
Java 验签示例
下面的示例使用支付宝 Java SDK 中常见的 AlipaySignature.rsaCheckV1 方法。实际包名、SDK 版本和配置方式要以项目当前使用的依赖为准。
import com.alipay.api.internal.util.AlipaySignature;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
public boolean verifyAlipayNotify(
HttpServletRequest request,
String alipayPublicKey,
String charset) throws Exception {
Map<String, String> params = new HashMap<>();
for (Map.Entry<String, String[]> entry :
request.getParameterMap().entrySet()) {
String[] values = entry.getValue();
params.put(
entry.getKey(),
values == null ? "" : String.join(",", values)
);
}
String signType = params.getOrDefault("sign_type", "RSA2");
return AlipaySignature.rsaCheckV1(
params,
alipayPublicKey,
charset,
signType
);
}
通过验签后,还要继续核对业务参数:
public void validateBusinessParams(
Map<String, String> params,
Order order,
String expectedAppId,
String expectedSellerId) {
if (!expectedAppId.equals(params.get("app_id"))) {
throw new IllegalArgumentException("app_id mismatch");
}
if (!expectedSellerId.equals(params.get("seller_id"))) {
throw new IllegalArgumentException("seller_id mismatch");
}
if (!order.getOutTradeNo().equals(params.get("out_trade_no"))) {
throw new IllegalArgumentException("out_trade_no mismatch");
}
if (order.getTotalAmount().compareTo(
new java.math.BigDecimal(params.get("total_amount"))) != 0) {
throw new IllegalArgumentException("total_amount mismatch");
}
}
金额不要转换成 double 后再比较,应使用 BigDecimal,以免出现浮点误差。
幂等处理示例
可以在支付通知记录表中建立唯一约束,例如将支付宝交易号和交易状态组合为幂等键:
CREATE UNIQUE INDEX uk_alipay_trade_status
ON payment_notify_record (trade_no, trade_status);
业务处理示意:
@Transactional
public void handleNotify(Map<String, String> params) {
String tradeNo = params.get("trade_no");
String outTradeNo = params.get("out_trade_no");
String tradeStatus = params.get("trade_status");
if (notifyRecordRepository.exists(tradeNo, tradeStatus)) {
return;
}
Order order = orderRepository.lockByOutTradeNo(outTradeNo);
if (order == null) {
throw new IllegalStateException("order not found");
}
if ("TRADE_SUCCESS".equals(tradeStatus)
|| "TRADE_FINISHED".equals(tradeStatus)) {
if (!order.isPaid()) {
order.markPaid(tradeNo);
orderRepository.save(order);
}
}
notifyRecordRepository.insert(tradeNo, tradeStatus, params);
}
只有数据库事务成功提交后,接口才能返回以下纯文本:
success
如果先返回 success,之后业务处理失败,支付宝通常不会针对本地处理失败自动补偿。反过来,如果业务已经处理成功,但接口返回了其他内容,就可能再次收到通知。
排查重点
如果确认收到的是一年前的支付成功通知,可以重点检查:
- API 网关是否配置了失败重试或流量回放。
- 消息队列是否存在积压、死信恢复或重新消费。
- 定时补偿程序是否重新请求过通知地址。
- 运维平台是否执行过历史请求重放。
- 是否有人通过抓包数据、日志参数或测试工具重新发送请求。
- 通知地址是否曾被其他系统共用。
- 本次请求中的
notify_time是否也在一年前。 - 本次参数和签名是否与历史通知完全一致。
如果 notify_time、签名和全部业务参数都与历史记录一致,更可能是旧请求被重放。如果通知时间是近期,而且交易查询结果也表明订单出现了新的状态变化,则需要继续核对对应支付宝产品的通知规则。
注意事项
- 不要因为
notify_time不够新就直接拒绝通知,否则可能误伤合法的延迟通知。时间只能用于风险判断。 - 不要把客户端同步跳转结果作为支付成功的依据。
- 验签使用支付宝公钥,不是应用私钥,也不是应用公钥。
- 验签前不要随意修改、解码或重新拼接参数。
- 日志中的敏感信息应当脱敏,同时保留足够的订单号、交易号、签名摘要和接收时间,以便追踪。
- 已完成订单再次收到通知时,应记录请求并直接返回
success,不能重复发货、重复记账或重复增加余额。 - 如需向支付宝技术支持确认,应提供
trade_no、out_trade_no、通知接收时间、notify_time、验签结果和请求链路日志。
备注:内容仅供参考。
