支付宝沙箱回调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 参数,只要支付宝公钥、签名模式或应用配置不匹配,验签仍会失败。
排查时可以分成两步:
- 确认
request.getParameterMap()能否读取完整的回调参数; - 参数存在后,再确认当前公钥、字符集和签名方式是否与沙箱应用一致。
确认第一步正常后,再继续检查验签配置,避免把“请求参数为空”和“密钥配置错误”当成同一个问题处理。
备注:内容仅供参考。
