PAYATHON 2026

支付宝沙箱回调POST请求form表单为空导致验签失败

支付阿杰

结论

支付宝异步通知的参数通常通过 application/x-www-form-urlencoded 格式放在 POST 请求体中。如果回调函数已经触发,但 form 表单为空,通常不是支付宝没有发送参数,而是服务端没有按表单格式解析请求,或者请求体在进入验签代码前,已被过滤器、拦截器或日志组件读取完毕。

排查时,先查看原始请求头和请求体,再确认框架使用了哪种参数读取方式。验签时必须使用支付宝回调中的全部原始参数,不能使用反序列化后的业务对象、JSON 数据或某个单独字段。

常见原因

将回调请求当成 JSON 处理

支付宝异步通知一般不是 JSON 请求。如果接口通过 @RequestBody 接收 JSON,或者只配置了 JSON 解析器,最终可能得到空对象。

例如,下面的写法通常不适合直接接收支付宝表单回调:

@PostMapping("/o/v1/pay/alipay/notify")
public String notify(@RequestBody AlipayNotifyDTO body) {
    // ...
}

可以改用 HttpServletRequest#getParameterMap()、@RequestParam,或者框架提供的表单参数接口读取。

请求体被提前读取

Servlet 请求体默认只能以流的方式读取一次。如果日志过滤器、鉴权过滤器、网关插件或自定义拦截器提前调用了:

request.getInputStream();

或者:

request.getReader();

后续的参数解析和验签代码可能无法再次获取正文。

这类问题通常有以下表现:

  • 回调接口可以正常进入;
  • 日志组件可能已经打印出请求体;
  • request.getParameterMap() 或业务层拿到的参数却是空的。

使用了错误的参数来源

POST 表单参数位于请求体中,不一定会出现在 URL 查询字符串里。因此:

request.getQueryString()

返回空值,不能说明支付宝没有发送数据。

前端开发工具显示的 “Form Data”,以及服务端框架中的 form、body 和 params,也可能指向不同的数据来源,不能混用。

Content-Type 不正确或被代理改写

服务端通常根据下面的请求头解析表单:

Content-Type: application/x-www-form-urlencoded

如果内网穿透工具、反向代理或网关修改或丢失了 Content-Type,框架可能不会自动生成表单参数。

内网穿透通常只负责转发请求,但仍需核对转发前后的以下信息:

  • 请求方法是否都是 POST;
  • Content-Type 是否保留;
  • Content-Length 或分块传输信息是否正常;
  • 请求体是否完整;
  • 是否发生了 301、302 或 307 重定向。

某些客户端遇到 301 或 302 后,可能把原来的 POST 请求改为 GET,导致请求体丢失。

接口经过了会消费请求体的组件

重点检查以下组件:

  • 请求日志过滤器;
  • 签名或鉴权过滤器;
  • XSS 过滤器;
  • 请求解密组件;
  • API 网关;
  • Web Application Firewall;
  • 自定义 HttpServletRequestWrapper;
  • Spring MVC 拦截器;
  • 全局异常处理或参数解析组件。

如果直接访问本地服务时参数正常,经过内网穿透后变为空,应先检查代理链路。如果本地模拟 POST 时参数也是空的,则先检查服务端代码、过滤器和参数解析配置。

推荐的排查步骤

1. 记录请求的基本信息

先不要进行支付宝验签。临时记录请求方法、内容类型和参数名称:

@PostMapping("/o/v1/pay/alipay/notify")
@ResponseBody
public String notify(HttpServletRequest request) {
    System.out.println("method = " + request.getMethod());
    System.out.println("contentType = " + request.getContentType());
    System.out.println("contentLength = " + request.getContentLengthLong());

    request.getParameterMap().forEach((key, values) ->
        System.out.println(key + " = " + Arrays.toString(values))
    );

    return "failure";
}

正常情况下,可以看到类似下面的字段:

trade_no
out_trade_no
trade_status
total_amount
seller_id
app_id
sign
sign_type
notify_id
notify_time

实际字段应以支付宝发送的通知为准,不要依赖固定的字段数量。

调试期间不要完整输出身份证号、手机号等敏感数据,也不要长期保存完整的签名参数。

2. 用表单请求直接测试回调接口

先绕过支付宝和内网穿透,直接请求本地接口:

curl -i -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'out_trade_no=test_order_001' \
  --data-urlencode 'trade_status=TRADE_SUCCESS' \
  'http://127.0.0.1:8080/o/v1/pay/alipay/notify'

然后请求公网地址:

curl -i -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'out_trade_no=test_order_001' \
  --data-urlencode 'trade_status=TRADE_SUCCESS' \
  'https://9681h5h164.zicp.fun/o/v1/pay/alipay/notify'

比较两次请求的结果:

  • 本地和公网都为空:检查 Controller、过滤器和参数解析配置;
  • 本地正常,公网为空:检查内网穿透、代理转发和重定向;
  • 两者都正常,只有支付宝通知为空:核对实际通知 URL、请求日志和支付宝沙箱配置。

测试参数没有真实签名,所以验签失败属于正常现象。这一步只用于确认服务端能否读取表单。

3. 检查是否发生重定向

执行:

curl -i -X POST \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'test=1' \
  'https://9681h5h164.zicp.fun/o/v1/pay/alipay/notify'

如果响应中出现:

HTTP/1.1 301 Moved Permanently

或者:

HTTP/1.1 302 Found
Location: ...

需要消除回调地址上的重定向。常见情况包括:

  • HTTP 自动跳转到 HTTPS;
  • 当前域名跳转到另一个域名;
  • URL 自动补全末尾斜杠;
  • 网关跳转到统一登录页面;
  • Controller 路径不匹配引发跳转。

支付宝的通知地址应直接命中处理接口,不能依赖浏览器式跳转。

4. 检查请求体是否被提前消费

搜索项目中是否调用了以下方法:

getInputStream()
getReader()

如果过滤器为了记录日志而读取了正文,应使用支持重复读取的请求包装器缓存正文,或者让验签逻辑直接使用第一次读取到的原始参数。

需要注意,Spring 的 ContentCachingRequestWrapper 在包装请求时不会自动读取正文。只有下游读取正文后,它才会生成缓存。因此,不能只在过滤器最前面包一层,然后立即获取缓存内容。

对于支付宝表单回调,更稳妥的方式通常是避免在上游手动读取原始输入流,直接在 Controller 中通过参数 API 读取。

Spring Boot 验签示例

下面的示例适用于普通公钥方式的典型场景。支付宝 SDK 使用的具体类和方法,应以项目实际采用的版本及签名模式为准。

import com.alipay.api.internal.util.AlipaySignature;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
public class AlipayNotifyController {

    private static final String ALIPAY_PUBLIC_KEY = "支付宝公钥";
    private static final String CHARSET = "UTF-8";
    private static final String SIGN_TYPE = "RSA2";

    @PostMapping(
        value = "/o/v1/pay/alipay/notify",
        consumes = "application/x-www-form-urlencoded"
    )
    @ResponseBody
    public String notify(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));
            }
        });

        if (params.isEmpty()) {
            return "failure";
        }

        try {
            boolean valid = AlipaySignature.rsaCheckV1(
                params,
                ALIPAY_PUBLIC_KEY,
                CHARSET,
                SIGN_TYPE
            );

            if (!valid) {
                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");
            String sellerId = params.get("seller_id");

            // 1. 校验 app_id、seller_id 等收款方信息
            // 2. 根据 out_trade_no 查询本地订单
            // 3. 校验订单金额 total_amount
            // 4. 判断 trade_status
            // 5. 以幂等方式更新订单状态
            // 6. 保存支付宝交易号 trade_no

            if ("TRADE_SUCCESS".equals(tradeStatus)
                    || "TRADE_FINISHED".equals(tradeStatus)) {
                return "success";
            }

            return "failure";
        } catch (Exception e) {
            return "failure";
        }
    }
}

如果项目仍使用 Spring Boot 2,请将:

import jakarta.servlet.http.HttpServletRequest;

改为:

import javax.servlet.http.HttpServletRequest;

拼装验签参数时,不要额外对参数值进行 URL 编码、解码、排序或 JSON 序列化。应尽量把框架解析出的原始通知参数直接交给支付宝 SDK。

更简洁的参数接收方式

Spring MVC 也可以直接接收表单参数:

@PostMapping("/o/v1/pay/alipay/notify")
@ResponseBody
public String notify(@RequestParam Map<String, String> params) {
    if (params.isEmpty()) {
        return "failure";
    }

    // 使用支付宝 SDK 验签
    return "success";
}

不过,排查问题时使用 HttpServletRequest#getParameterMap() 会更直观,也方便观察同名多值参数。

验签通过后仍需校验业务数据

签名有效只能说明通知内容在传输过程中没有被篡改,不能据此直接把订单标记为支付成功。至少还要校验:

  • app_id 是否属于当前应用;
  • seller_id 是否为预期收款账号;
  • out_trade_no 是否存在;
  • total_amount 是否与本地订单金额一致;
  • 订单是否已经处理;
  • trade_status 是否属于允许处理的状态;
  • 同一个 trade_no 是否被异常重复绑定。

支付宝可能重复发送回调,因此订单更新必须具备幂等性。可以通过带订单状态条件的更新、数据库唯一约束或事务锁,避免重复记账。

返回值注意事项

处理成功后,响应正文应直接返回:

success

处理失败时返回:

failure

不要返回 JSON:

{"code":200,"message":"success"}

响应中也不要带引号、HTML、额外空格或调试信息。只有验签和业务处理都成功后,才能返回 success。否则支付宝可能认为通知已经处理,后续无法再依靠重试恢复。

最容易遗漏的一点

沙箱环境和正式环境使用的应用配置、支付宝公钥及证书可能不同。即使服务端已经正确获取 form 参数,只要支付宝公钥、签名模式或应用配置不匹配,验签仍会失败。

排查时可以分成两步:

  1. 确认 request.getParameterMap() 能否读取完整的回调参数;
  2. 参数存在后,再确认当前公钥、字符集和签名方式是否与沙箱应用一致。

确认第一步正常后,再继续检查验签配置,避免把“请求参数为空”和“密钥配置错误”当成同一个问题处理。

备注:内容仅供参考。