支付宝网站支付回调失败、签名异常及URL编码问题
结论
这三个现象大概率不是同一个问题:
- 支付宝通知失败,而 Postman 返回
200 success,通常说明公网请求与人工重放的请求存在差异。问题可能出在网络可达性、TLS、重定向、WAF、请求方法、响应内容或处理超时等环节。 - 正常的 RSA/RSA2 签名不会只有三个字符。
$$更可能是展示、日志、模板解析或参数解析造成的失真,不能据此判断支付宝实际发送的签名。 fund_bill_list是 JSON 字符串类型的表单参数。截图或request.getParameter()中出现{},不代表网络传输的原始内容没有经过 URL 编码。判断时应检查原始 HTTP 请求体,而不是解码后的参数值。
无论支付宝侧是否显示异常,商户服务端都必须自行完成验签、业务字段校验和幂等处理。
为什么 Postman 能成功,支付宝通知却失败
Postman 调通只能证明接口在当时的请求条件下可以返回成功,不能证明支付宝服务器也能以相同方式访问。
可以按下面的顺序检查。
1. 回调地址是否真正支持公网访问
支付宝服务器必须能直接访问 notify_url。常见问题有:
- 域名解析到了内网地址或错误的公网 IP。
- 服务器只允许特定来源 IP。
- 防火墙、安全组、WAF、CDN 或反向代理拦截了支付宝请求。
- 使用了非标准端口,但端口没有向公网开放。
- 域名已经购买,但 DNS、ICP备案、证书或服务部署尚未配置完成。
- 回调地址依赖登录态、Cookie、验证码或浏览器环境。
- 配置的是 HTTP 地址,但网关会强制跳转到 HTTPS。
- URL 发生
301、302、307或308跳转。
应从公网环境直接测试最终的 notify_url,同时确认整条链路没有重定向。
curl -i -X POST 'https://pay.example.com/alipay/notify' \
-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' \
--data 'out_trade_no=test&trade_status=TRADE_SUCCESS'
这个命令只能用于检查网络和接口行为,不能代替真实通知的验签过程。
2. HTTPS/TLS 配置是否完整
如果使用 HTTPS,需要检查:
- 证书是否过期。
- 域名是否与证书匹配。
- 中间证书链是否完整。
- 服务端是否对 TLS 协议和加密套件限制过严。
- 是否使用了自签名证书。
- CDN、负载均衡器与源站之间是否存在 TLS 配置错误。
浏览器或本机 Postman 能访问,不代表支付宝服务器一定能完成相同的 TLS 握手。
3. 接口是否及时返回纯文本 success
异步通知处理成功后,应返回:
success
不要返回 JSON、HTML、统一响应对象或其他提示内容。下面这些响应通常不符合支付宝对成功响应的要求:
{"code":200,"data":"success"}
success!
<html>success</html>
Spring Boot 可以显式返回纯文本:
@PostMapping(
value = "/alipay/notify",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE
)
@ResponseBody
public String alipayNotify(HttpServletRequest request) {
// 验签和业务处理
return "success";
}
还要确认:
- 最终 HTTP 状态码确实为
200。 - 响应体没有被网关包装。
- 响应中没有调试日志、BOM、空格或异常页面。
- 接口没有因处理时间过长而导致通知方超时。
- 业务处理完成前没有发生数据库锁等待或外部接口阻塞。
较稳妥的做法是先完成必要校验和本地幂等落库,再尽快返回 success,将耗时操作交给异步任务。
4. Postman 重放的内容是否与原请求完全一致
人工复制参数时,以下内容很容易发生变化:
+被转换为空格。%2B、%2F、%3D被提前解码。- 参数从 POST 表单变成了 URL 查询参数。
Content-Type不一致。- 字符集不一致。
- JSON 中的空格、转义字符或字段顺序发生变化。
- Postman 自动执行了一次 URL 编码。
排查时应记录原始请求方法、请求头、请求体、响应状态码和响应体。敏感字段需要脱敏,生产环境也不应长期输出完整的支付数据。
sign 只有三个字符并出现 $$ 的原因
RSA2 签名通常是较长的 Base64 字符串。如果只看到三个字符或异常的 $$,应先检查签名是否在展示或传递过程中被修改,而不是直接认定支付宝生成了三字符签名。
可能的原因有:
- 页面模板把
$当成变量或占位符处理。 - 日志框架、调试工具或监控平台截断、转义或脱敏了签名。
- 前端页面显示的不是原始
sign,而是格式化后的内容。 - 参数被错误地按照
$、&、=等字符拆分。 - 使用了错误的正则表达式进行替换。
- 请求体已经被读取一次,业务代码再次读取时只能拿到残缺内容。
application/x-www-form-urlencoded解码时,签名中的+被错误地转换成空格。- 手工拼接查询字符串时没有正确编码签名。
尤其要检查 +。Base64 签名可能包含 +,而表单解码会把未经正确编码的 + 解释为空格。不要自行拆分请求体,应优先使用 Web 框架提供的表单参数解析功能。
为什么支付宝没有报告签名异常
异步通知中的 sign 由支付宝提供给商户验签。支付宝是通知发送方,不会替商户检查参数是否在代理、容器或业务代码中遭到破坏。
签名验证失败应由商户服务端识别。验签失败时:
- 不更新订单状态。
- 不发货,也不充值。
- 不返回表示处理成功的
success。 - 记录可追踪且经过脱敏的错误日志。
证书模式只会改变公钥的获取和信任方式,不会取消回调验签。
证书模式下的验签示例
下面的示例展示基本流程,具体方法签名应以项目实际使用的支付宝 Java SDK 为准。
@PostMapping(
value = "/alipay/notify",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE
)
@ResponseBody
public String alipayNotify(HttpServletRequest request) {
Map<String, String> params = new HashMap<>();
request.getParameterMap().forEach((name, values) -> {
if (values != null && values.length > 0) {
params.put(name, String.join(",", values));
}
});
try {
boolean verified = AlipaySignature.rsaCertCheckV1(
params,
alipayPublicCertPath,
"UTF-8",
"RSA2"
);
if (!verified) {
return "failure";
}
String appId = params.get("app_id");
String outTradeNo = params.get("out_trade_no");
String tradeNo = params.get("trade_no");
String tradeStatus = params.get("trade_status");
String totalAmount = params.get("total_amount");
// 继续核对 app_id、商户订单号、订单金额、卖家身份等业务字段。
// 更新订单时必须使用 out_trade_no 或 trade_no 做幂等控制。
// 只有验签和业务处理均成功后才返回 success。
return "success";
} catch (AlipayApiException e) {
// 记录异常类型和请求追踪信息,避免记录完整敏感参数
return "failure";
}
}
不要先从参数中删除 sign、sign_type,再自行拼接字符串并调用不匹配的接口。框架已经解码过的参数也不要再次执行 URL 解码。
除验签外,至少还要核对:
app_id是否属于当前应用。out_trade_no是否存在且属于当前商户。total_amount是否与本地订单一致。- 收款方身份是否正确。
trade_status是否是业务允许处理的状态。- 同一通知重复到达时能否安全处理。
fund_bill_list 与 URL 编码
fund_bill_list 的值通常是 JSON 数组字符串,例如:
[{"amount":"10.00","fundChannel":"ALIPAYACCOUNT"}]
异步通知一般通过 application/x-www-form-urlencoded 表单提交。网络传输时,该值可能是:
fund_bill_list=%5B%7B%22amount%22%3A%2210.00%22%2C%22fundChannel%22%3A%22ALIPAYACCOUNT%22%7D%5D
Web 容器解码后,应用代码通过下面的方式获取参数,得到的自然是包含 {} 的 JSON:
String fundBillList = request.getParameter("fund_bill_list");
所以,截图中出现 {} 不能证明支付宝没有编码。要确认实际编码情况,需要查看请求进入服务器前的原始请求体,或者在反向代理层进行受控抓包。
为什么配置 Tomcat 允许 {} 后 Postman 才成功
如果调整的是 Tomcat 的 relaxedQueryChars 或类似配置,它主要影响 URL 查询字符串中特殊字符的解析。出现这种情况,通常说明 Postman 把 fund_bill_list 放进了 URL,或者发送了未经编码的原始查询参数,例如:
/alipay/notify?fund_bill_list=[{"amount":"10.00"}]
这与标准的 POST 表单请求并不等价。正确的 Postman 测试方式是:
- 请求方法选择
POST。 - Body 选择
x-www-form-urlencoded。 - 分别填写每个通知字段。
- 不要把完整参数直接拼接到 URL 后面。
- 让 Postman 按照表单规则完成编码。
不建议仅为测试请求而放宽整个容器对特殊字符的限制。放宽解析规则可能增加请求走私、路径解析差异或异常请求绕过的风险。如果确实需要调整配置,应限制影响范围,并先确认特殊字符究竟出现在查询字符串、路径还是 POST 请求体中。
推荐排查顺序
- 根据时间在网关、反向代理和应用层检查支付宝的真实请求是否到达。
- 核对请求方法、
Content-Type、原始请求体和最终响应体。 - 检查 DNS、公网端口、HTTPS 证书链、WAF 和重定向。
- 确认回调接口不依赖登录态,也不会被 CSRF 校验拦截。
- 检查
sign是否在日志展示、参数解析或二次解码过程中被修改。 - 使用证书模式对应的 SDK 验签接口校验完整参数集合。
- 校验订单号、金额、应用身份和交易状态。
- 完成幂等处理后,及时返回纯文本
success。 - 如果仍无法定位,关联分析同一时间点的支付宝通知记录、网关访问日志和应用异常日志。
排查时最需要保留的是”进入服务器时的原始请求”和”服务器实际返回的原始响应”。只看截图、解码后的参数或 Postman 重放结果,无法准确判断问题出在支付宝发送端、网络链路、Web 容器还是业务代码。
备注:内容仅供参考。
