PAYATHON 2026

支付宝网站支付回调失败、签名异常及URL编码问题

支付老李

结论

这三个现象大概率不是同一个问题:

  1. 支付宝通知失败,而 Postman 返回 200 success,通常说明公网请求与人工重放的请求存在差异。问题可能出在网络可达性、TLS、重定向、WAF、请求方法、响应内容或处理超时等环节。
  2. 正常的 RSA/RSA2 签名不会只有三个字符。$$ 更可能是展示、日志、模板解析或参数解析造成的失真,不能据此判断支付宝实际发送的签名。
  3. 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 请求体中。

推荐排查顺序

  1. 根据时间在网关、反向代理和应用层检查支付宝的真实请求是否到达。
  2. 核对请求方法、Content-Type、原始请求体和最终响应体。
  3. 检查 DNS、公网端口、HTTPS 证书链、WAF 和重定向。
  4. 确认回调接口不依赖登录态,也不会被 CSRF 校验拦截。
  5. 检查 sign 是否在日志展示、参数解析或二次解码过程中被修改。
  6. 使用证书模式对应的 SDK 验签接口校验完整参数集合。
  7. 校验订单号、金额、应用身份和交易状态。
  8. 完成幂等处理后,及时返回纯文本 success。
  9. 如果仍无法定位,关联分析同一时间点的支付宝通知记录、网关访问日志和应用异常日志。

排查时最需要保留的是”进入服务器时的原始请求”和”服务器实际返回的原始响应”。只看截图、解码后的参数或 Postman 重放结果,无法准确判断问题出在支付宝发送端、网络链路、Web 容器还是业务代码。

备注:内容仅供参考。