支付异步回调返回 success 后,支付宝为何仍回调商户平台?
结论
支付宝是否停止重试,要看它实际收到的 HTTP 响应,而不是应用日志里记录了什么返回值。
即使业务代码执行了 return "success",只要支付宝最终收到的响应正文不是纯文本 success,或者出现请求超时、HTTP 状态异常、网关改写响应等情况,支付宝仍可能继续发送异步通知。
先确认这两项:
- HTTP 状态码是
200 - 响应正文准确等于
success,不能包含引号、JSON 包装、HTML、空格、换行、BOM 或其他字符
常见原因
1. success 被包装成 JSON
接口实际返回的内容可能是:
{
"result": "success"
}
也可能是:
"success"
这些响应虽然含有 success,但通常不符合支付宝要求的纯文本格式。
在 Spring MVC 中,如果方法返回统一响应对象,或者响应经过全局包装器处理,就容易出现这种情况。
2. HTTP 状态码不是 200
应用可能已经打印了“返回 success”,但之后又发生异常,导致最终响应变成:
HTTP/1.1 500 Internal Server Error
请求经过鉴权、限流或重定向后,也可能返回:
302 Found
401 Unauthorized
403 Forbidden
429 Too Many Requests
502 Bad Gateway
504 Gateway Timeout
支付宝看到的是最终 HTTP 响应,不会读取应用内部日志。
3. 接口处理时间过长
如果回调接口在返回前同步执行耗时操作,例如:
- 更新多个业务系统
- 调用第三方接口
- 发送短信或邮件
- 等待消息队列确认
- 等待长时间未释放的数据库锁
支付宝可能在收到响应前就已经超时。商户服务稍后即使执行了 return "success",支付宝也没有成功收到这个响应,因此仍会重试。
不同支付宝产品和接口版本的超时时间、重试策略可能不同,应以对应产品的官方文档为准。
4. 网关或代理修改了响应
请求经过 Nginx、API 网关、WAF、CDN 或负载均衡器时,响应内容或状态可能发生变化,例如:
- 自动追加错误页面
- 压缩或包装响应
- 强制跳转到 HTTPS
- 命中安全拦截规则
- 上游超时后返回网关错误
- 回调路径被登录鉴权拦截
所以不能只检查应用日志,还要查看入口代理和网关日志。
5. 回调请求没有到达当前查看的实例
在多实例部署中,支付宝通知可能被负载均衡到不同节点。某个节点成功返回了 success,不代表其他通知也得到了正确处理。
常见情况有:
- 部分节点仍在运行旧版本
- 不同节点的配置不一致
- 某个节点无法连接数据库
- 灰度环境和正式环境共用回调地址
- 日志只采集了部分实例
6. 返回内容中存在不可见字符
下面这些响应看起来很接近正确格式,但仍可能无法被识别:
success
success
success\n
success\r\n
success
最后一种情况可能含有 UTF-8 BOM。模板渲染、文件包含、调试输出或中间件都有可能引入这些字符。
7. 业务处理成功,但响应阶段发生异常
订单可能已经更新成功,但随后在记录日志、序列化响应或提交事务时发生异常。应用日志里已经出现“支付成功”,最终却没有正常返回 success。
因此,回调处理必须支持幂等。支付宝重复发送通知时,系统不能重复入账、发货或发券。
推荐排查步骤
第一步:记录完整的请求和最终响应
至少记录以下信息:
notify_idtrade_noout_trade_notrade_status- 请求到达时间
- 处理完成时间
- 当前服务器实例
- 最终 HTTP 状态码
- 最终响应正文
- 处理耗时
- 签名验证结果
敏感字段要按安全要求脱敏,日志中不能记录私钥或完整密钥材料。
只记录下面这样的日志远远不够:
准备返回 success
应尽量从网关层确认实际发出的状态码、响应大小和处理时间。
第二步:绕过统一响应包装
异步通知接口不要返回通用 JSON 对象,例如:
return Result.success();
也不要让全局响应处理器把字符串转换成:
{"code":0,"message":"success"}
最好为回调地址设置独立接口,并明确返回 text/plain。
第三步:检查网关访问日志
以 Nginx 为例,可以在访问日志中加入状态码、响应字节数和请求耗时:
log_format alipay_callback
'$remote_addr [$time_local] "$request" '
'status=$status bytes=$body_bytes_sent '
'request_time=$request_time '
'upstream_status=$upstream_status '
'upstream_time=$upstream_response_time';
access_log /var/log/nginx/alipay_callback.log alipay_callback;
在持续重试期间,重点检查:
- 是否出现非 200 状态码
request_time是否过长upstream_status是否为 502 或 504- 响应正文长度是否异常
- 请求是否被重定向到其他地址
第四步:检查回调地址是否被拦截
支付宝回调是服务器之间的通知,不能依赖浏览器登录状态。检查回调路径是否被以下机制阻断:
- 登录认证
- CSRF 校验
- 验证码
- 配置错误的 IP 白名单
- User-Agent 限制
- 请求频率限制
- 强制页面跳转
如果需要控制访问,应使用支付宝签名验证,不能要求支付宝携带商户平台的登录凭证。
第五步:缩短同步处理链路
可以按下面的顺序处理:
- 读取支付宝通知参数。
- 验证签名。
- 校验
app_id、商户身份、订单号、金额和交易状态。 - 通过数据库事务完成必要的订单状态变更。
- 记录该通知已经处理。
- 尽快返回纯文本
success。 - 将发货、通知、积分等耗时操作交给可靠的异步任务。
不能为了停止重试,在完成验证前直接返回 success。如果对伪造或错误通知返回成功,支付宝通常不会再次发送该通知,商户也可能失去重新处理的机会。
Spring Boot 示例
下面的示例说明响应格式和基本处理结构。签名验证所需参数应根据实际使用的支付宝 SDK 和产品接口确定。
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class AlipayNotifyController {
@PostMapping(
value = "/payment/alipay/notify",
produces = MediaType.TEXT_PLAIN_VALUE
)
public ResponseEntity<String> notify(HttpServletRequest request) {
Map<String, String> params = new HashMap<>();
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0) {
params.put(key, String.join(",", values));
}
});
try {
boolean signatureValid = verifyAlipaySignature(params);
if (!signatureValid) {
return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN)
.body("failure");
}
validateNotification(params);
processPaymentIdempotently(params);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN)
.body("success");
} catch (Exception exception) {
// 记录 trade_no、out_trade_no、实例名称及异常信息
// 不要记录私钥等敏感数据
return ResponseEntity.ok()
.contentType(MediaType.TEXT_PLAIN)
.body("failure");
}
}
private boolean verifyAlipaySignature(Map<String, String> params) {
// 使用实际接入的支付宝 SDK 验证签名
throw new UnsupportedOperationException("Implement with Alipay SDK");
}
private void validateNotification(Map<String, String> params) {
// 校验 app_id、seller_id、out_trade_no、total_amount、trade_status 等
}
private void processPaymentIdempotently(Map<String, String> params) {
// 使用 trade_no 或 out_trade_no 实现幂等更新
}
}
如果项目使用了全局响应包装器,需要明确排除这个回调接口,否则返回的字符串仍可能被转换成 JSON。
Servlet 示例
直接写入响应,可以更准确地控制返回内容:
response.setStatus(HttpServletResponse.SC_OK);
response.setCharacterEncoding("UTF-8");
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("success");
response.getWriter().flush();
写入前后不要输出其他内容,也不要执行:
response.sendRedirect("/success");
支付宝要求回调请求本身返回成功响应,而不是跳转后的页面。
PHP 示例
<?php
try {
$params = $_POST;
if (!verify_alipay_signature($params)) {
header('Content-Type: text/plain; charset=utf-8');
http_response_code(200);
echo 'failure';
exit;
}
validate_notification($params);
process_payment_idempotently($params);
header('Content-Type: text/plain; charset=utf-8');
http_response_code(200);
echo 'success';
exit;
} catch (Throwable $e) {
error_log($e->getMessage());
header('Content-Type: text/plain; charset=utf-8');
http_response_code(200);
echo 'failure';
exit;
}
PHP 文件的 <?php 之前不能出现空格或 BOM,也不要让调试信息、警告信息混入响应正文。
幂等处理要点
支付系统必须能正常处理重复通知。即使响应完全正确,受网络状态、并发请求或之前已经触发的重试影响,短时间内仍可能收到重复通知。
订单状态应通过条件更新或唯一约束修改,防止重复执行:
UPDATE payment_order
SET pay_status = 'PAID',
alipay_trade_no = :trade_no,
paid_at = CURRENT_TIMESTAMP
WHERE out_trade_no = :out_trade_no
AND pay_status = 'WAIT_PAY';
根据受影响的行数判断处理结果:
- 影响 1 行:本次完成了状态变更。
- 影响 0 行:查询订单,确认是否已经支付。
- 订单已支付且关键字段一致:视为重复通知,返回
success。 - 金额、商户身份或支付宝交易号不一致:记录告警,不要直接更新订单。
注意事项
- 不要通过浏览器访问回调地址来判断支付宝能否正常收到响应。
- 不要只看控制器的返回值,要检查支付宝入口处最终发出的 HTTP 响应。
- 验签失败、订单不存在或金额不一致时,不能为了停止通知直接返回
success。 - 对已经正确处理过的重复通知,应快速返回
success,不能再次发货或入账。 - 支付宝公钥、应用公钥和应用私钥的用途不同,验签配置错误会导致每次通知都失败。
- 如果日志确认每次响应都是 HTTP 200,正文也严格等于
success,但同一通知仍被长期重复发送,应整理notify_id、trade_no、通知时间、响应状态和网关日志,再联系对应支付宝产品的技术支持渠道核查。
备注:内容仅供参考。
