PAYATHON 2026

支付宝沙箱预支付报 isv.invalid-signature 如何解决?

支付老李

结论

从返回信息判断,问题大概率不在 RSA2 密钥,而在请求参数的实际传输结果。

支付宝网关返回的验签字符串只有:

app_id=9021000142602452&charset=UTF-8&method=alipay.trade.precreate&sign_type=RSA2&timestamp=2024-11-27 18:40:16&version=1.0

这里缺少 biz_content。如果本地签名原文包含 biz_content,支付宝网关却没有解析到这个参数,双方使用的签名原文就不一致,因此会返回 isv.invalid-signature。

排查时应优先确认:

  • biz_content 是否确实作为请求参数发送;
  • 请求是否使用 application/x-www-form-urlencoded;
  • 参数是否在签名后被修改;
  • sign 是否经过正确的 URL 编码,尤其是其中的 +;
  • 是否存在重复 URL 编码;
  • 是否混用了沙箱应用、正式应用或其他应用的密钥。

为什么本地验证一致,支付宝仍然验签失败

“本地签名结果与工具一致”只能说明:

  1. 当前私钥可以正常签名;
  2. 对当前这份本地字符串,签名算法和结果一致。

这并不能证明支付宝收到的参数与本地签名时使用的参数完全相同。

支付宝验签时会:

  1. 接收并解析 HTTP 请求参数;
  2. 排除 sign 和空值参数;
  3. 按参数名排序;
  4. 根据实际收到的参数值重新生成待验签字符串;
  5. 使用应用配置的公钥验证 sign。

请求在传输过程中只要发生参数丢失、编码变化或参数值被修改,即使私钥正确,验签仍会失败。

本次返回中的”网关生成的验签字符串”直接反映了支付宝实际参与验签的参数,而不是客户端认为自己发送了哪些参数。该字符串缺少 biz_content,所以应先检查请求的构造和提交方式。

响应中的 & 是 JSON 文本经过 HTML 展示后的转义,实际分隔符仍然是 &。不要把 & 当成签名原文的一部分。

正确的请求结构

调用 alipay.trade.precreate 时,biz_content 应作为公共请求参数之一发送,其值是 JSON 字符串。例如:

app_id=9021000142602452
method=alipay.trade.precreate
format=JSON
charset=UTF-8
sign_type=RSA2
timestamp=2024-11-27 18:40:16
version=1.0
biz_content={"out_trade_no":"202411270001","total_amount":"0.01","subject":"沙箱测试订单"}
sign=BASE64_SIGNATURE

请求通常通过 POST 表单提交:

Content-Type: application/x-www-form-urlencoded;charset=UTF-8

不要将 biz_content 只放在普通 JSON 请求体中,同时又把公共参数放入 URL 查询字符串。支付宝网关能否从 JSON 请求体解析这些参数,取决于接口和 SDK 的调用方式。对于传统的支付宝 OpenAPI 网关调用,建议按表单参数提交,或者直接使用官方 SDK。

解决步骤

1. 对比网关验签字符串和本地签名原文

签名前记录完整的待签名字符串,但不要记录私钥。正常情况下,字符串中应包含 biz_content:

app_id=9021000142602452&biz_content={"out_trade_no":"202411270001","subject":"沙箱测试订单","total_amount":"0.01"}&charset=UTF-8&method=alipay.trade.precreate&sign_type=RSA2&timestamp=2024-11-27 18:40:16&version=1.0

如果本地字符串包含 biz_content,错误响应中的网关字符串却没有,说明支付宝网关没有正确接收到该参数。

2. 检查 Content-Type

错误示例:

Content-Type: application/json
{
  "biz_content": {
    "out_trade_no": "202411270001",
    "total_amount": "0.01",
    "subject": "沙箱测试订单"
  }
}

推荐使用以下传输形式:

Content-Type: application/x-www-form-urlencoded;charset=UTF-8
app_id=9021000142602452&method=alipay.trade.precreate&...&biz_content=%7B%22out_trade_no%22%3A...

这里展示的是 HTTP 传输后的编码结果。生成签名时,应使用 URL 编码前的参数值。

3. 确保签名后参数不再变化

以下情况都会导致验签失败:

  • 签名后重新序列化 biz_content;
  • 修改字段顺序、空格、转义符或数字格式;
  • 签名时金额是 "0.01",发送时变成 0.01;
  • 签名时包含某个可选参数,发送时却被 HTTP 客户端过滤;
  • 签名时没有某个参数,发送时拦截器又自动添加;
  • 签名使用 UTF-8,发送时却采用其他字符集。

两个 JSON 对象在业务语义上可能相同,但签名校验比较的是字符串对应的字节。例如,下面两个值不能混用:

{"subject":"测试","total_amount":"0.01"}
{ "total_amount": "0.01", "subject": "测试" }

应先将 biz_content 序列化为最终字符串,再用同一个字符串完成签名和发送。

4. 正确处理 URL 编码

签名流程应为:

原始参数值
  → 排序并拼接待签名字符串
  → RSA2 签名
  → Base64
  → 对整个表单进行 URL 编码
  → 发送请求

不要先对 biz_content 进行 URL 编码,再使用编码后的 %7B...%7D 内容签名,除非所用 SDK 明确要求这样处理。

sign 是 Base64 字符串,可能包含:

+ / =

如果手工拼接请求却没有进行 URL 编码,表单解析时可能将 + 转换为空格,导致签名损坏。应交给 HTTP 表单库统一编码,避免手工拼接。

5. 确认没有重复编码

重复编码也很常见。例如,{ 第一次编码后是 %7B,再次编码就会变成 %257B。

错误方式:

String encodedBizContent =
    URLEncoder.encode(bizContent, StandardCharsets.UTF_8);

form.put("biz_content", encodedBizContent);

// HTTP 客户端随后又对 form 进行一次编码

正确方式是将原始 JSON 字符串交给表单编码器:

form.put("biz_content", bizContent);

由 HTTP 客户端在发送时统一编码一次。

6. 核对沙箱环境和应用密钥

修复参数传输问题后如果仍然验签失败,再检查以下对应关系:

  • 请求地址是否为支付宝沙箱网关;
  • app_id 是否属于当前沙箱应用;
  • 签名私钥是否属于该应用当前配置的密钥对;
  • 支付宝开放平台配置的是否为”应用公钥”,而不是”支付宝公钥”;
  • 当前调用方式是普通公钥模式还是证书模式;
  • 是否刚更换过密钥,但程序仍在读取旧配置;
  • 私钥内容是否因环境变量、换行或配置中心转义而改变。

在普通公钥模式下,客户端使用应用私钥签名,支付宝使用开发者上传的应用公钥验签。支付宝公钥主要用于客户端验证支付宝返回结果的签名,不能与应用公钥混用。

Java 示例

建议优先使用支付宝官方 SDK,由 SDK 处理参数排序、签名和表单编码:

AlipayClient alipayClient = new DefaultAlipayClient(
    "https://openapi-sandbox.dl.alipaydev.com/gateway.do",
    appId,
    appPrivateKey,
    "json",
    "UTF-8",
    alipayPublicKey,
    "RSA2"
);

AlipayTradePrecreateRequest request =
    new AlipayTradePrecreateRequest();

Map<String, Object> biz = new LinkedHashMap<>();
biz.put("out_trade_no", "202411270001");
biz.put("total_amount", "0.01");
biz.put("subject", "沙箱测试订单");

String bizContent = objectMapper.writeValueAsString(biz);
request.setBizContent(bizContent);

AlipayTradePrecreateResponse response =
    alipayClient.execute(request);

System.out.println(response.getCode());
System.out.println(response.getSubCode());
System.out.println(response.getSubMsg());

如果需要手工调用,可以按以下方式组织参数。重点是待签名原文和实际发送参数必须来自同一份数据:

Map<String, String> params = new TreeMap<>();

params.put("app_id", appId);
params.put("method", "alipay.trade.precreate");
params.put("format", "JSON");
params.put("charset", "UTF-8");
params.put("sign_type", "RSA2");
params.put("timestamp", "2024-11-27 18:40:16");
params.put("version", "1.0");

String bizContent =
    "{\"out_trade_no\":\"202411270001\","
    + "\"total_amount\":\"0.01\","
    + "\"subject\":\"沙箱测试订单\"}";

params.put("biz_content", bizContent);

String contentToSign = params.entrySet().stream()
    .filter(entry ->
        entry.getValue() != null && !entry.getValue().isEmpty())
    .map(entry -> entry.getKey() + "=" + entry.getValue())
    .collect(Collectors.joining("&"));

String sign = rsa2Sign(contentToSign, appPrivateKey, "UTF-8");
params.put("sign", sign);

// 将 params 交给 application/x-www-form-urlencoded 表单编码器。
// 不要提前单独编码 biz_content 或 sign。

rsa2Sign 应使用 SHA256withRSA,再对签名结果进行 Base64 编码:

static String rsa2Sign(
        String content,
        PrivateKey privateKey,
        String charset) throws Exception {

    Signature signature = Signature.getInstance("SHA256withRSA");
    signature.initSign(privateKey);
    signature.update(content.getBytes(charset));

    return Base64.getEncoder().encodeToString(signature.sign());
}

实际项目还需要正确解析 PKCS#8 私钥并处理异常。如果官方 SDK 已经包含这些功能,不建议自行实现完整的签名流程。

如何快速确认问题已经修复

重新发起请求后,检查错误响应或请求日志:

  1. 支付宝网关的验签字符串中应包含 biz_content;
  2. 网关字符串中的 biz_content 应与本地签名原文逐字符一致;
  3. sign 传输后不应包含空格;
  4. 请求体中不应出现 %257B、%2522 等重复编码特征;
  5. 请求使用的 app_id、应用私钥和平台配置的应用公钥必须属于同一密钥对。

根据当前错误信息,最先应做的是将 biz_content 作为 application/x-www-form-urlencoded 表单参数发送,并确保签名和发送使用同一个、未经提前 URL 编码的 biz_content 字符串。

备注:内容仅供参考。