PAYATHON 2026

支付切换后异步回调验签失败如何排查

支付小周

结论

仅凭题目给出的回调参数无法完成验签,因为其中缺少 sign 和 sign_type。异步通知验签需要支付宝实际 POST 的全部参数,以及与当前接入方式对应的支付宝公钥或支付宝公钥证书。

切换支付接入方式后出现验签失败,常见原因有:

  • 采集的回调参数不完整,遗漏了 sign 或 sign_type。
  • 仍在使用旧接入方式对应的支付宝公钥。
  • 将应用公钥或应用私钥当成了支付宝公钥。
  • 混用了普通公钥模式和证书模式。
  • 验签前重新序列化、转义或修改了参数值。
  • 使用的字符集或签名算法与实际通知不一致。

当前示例没有签名值,因此无法确定是哪个字段导致验签失败,也无法在本地复现验签结果。

正确的排查方法

1. 保存原始回调请求

先记录支付宝服务器实际发送的以下内容:

  • HTTP Method
  • Content-Type
  • 原始请求体
  • 解析后的全部参数名
  • sign
  • sign_type
  • 当前使用的公钥或证书序列号

异步通知通常通过表单提交。只记录业务参数无法复现验签问题。

日志中至少应该能看到:

sign=实际签名值
sign_type=RSA2

题目给出的参数以 version=1.0 结束,没有 sign 和 sign_type。如果实际请求中也缺少这两个字段,应检查网关、反向代理、回调参数绑定对象或日志脱敏逻辑是否将其丢弃。

2. 直接使用回调参数 Map 验签

不要根据数据库中的订单字段重新拼装验签字符串,也不要只选择看起来有用的参数。

应读取回调请求的全部参数,按原值放入 Map<String, String>,再交给支付宝 SDK。SDK 会按照签名规则处理 sign、sign_type、参数排序和拼接。

3. 确认使用的是支付宝公钥

验签需要使用支付宝公钥,不能使用以下密钥或证书:

  • 应用私钥
  • 应用公钥
  • 商户自己的证书公钥
  • 支付宝开放平台中其他应用的公钥

如果更换了应用、签约主体或接入方式,需要重新确认通知所属的 app_id。本次回调中的值是:

app_id=2021005194677787

验签使用的支付宝公钥或支付宝公钥证书,必须与该应用当前配置的接入模式一致。

4. 区分普通公钥模式和证书模式

如果当前应用使用普通公钥模式,应传入支付宝开放平台提供的支付宝公钥:

AlipaySignature.rsaCheckV1(
    params,
    alipayPublicKey,
    charset,
    signType
);

如果当前应用使用证书模式,应通过支付宝公钥证书验签:

AlipaySignature.rsaCertCheckV1(
    params,
    alipayPublicCertPath,
    charset,
    signType
);

证书模式下不能继续使用旧的普通支付宝公钥,也不能将 appCertPublicKey_*.crt 当成支付宝公钥证书。验签需要的是类似下面的文件:

alipayCertPublicKey_RSA2.crt

具体文件名以实际下载的证书包为准。

Java 验签示例

普通公钥模式

import com.alipay.api.internal.util.AlipaySignature;

import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;

public boolean verifyNotify(
        HttpServletRequest request,
        String alipayPublicKey
) throws Exception {
    Map<String, String> params = new HashMap<>();

    Map<String, String[]> requestParams = request.getParameterMap();
    for (Map.Entry<String, String[]> entry : requestParams.entrySet()) {
        String[] values = entry.getValue();
        String value = values == null ? "" : String.join(",", values);
        params.put(entry.getKey(), value);
    }

    String charset = params.getOrDefault("charset", "UTF-8");
    String signType = params.getOrDefault("sign_type", "RSA2");

    if (!params.containsKey("sign") || params.get("sign").isEmpty()) {
        throw new IllegalArgumentException("异步通知缺少 sign 参数");
    }

    return AlipaySignature.rsaCheckV1(
        params,
        alipayPublicKey,
        charset,
        signType
    );
}

证书模式

import com.alipay.api.internal.util.AlipaySignature;

boolean verified = AlipaySignature.rsaCertCheckV1(
    params,
    "/path/to/alipayCertPublicKey_RSA2.crt",
    params.getOrDefault("charset", "UTF-8"),
    params.getOrDefault("sign_type", "RSA2")
);

不同版本 SDK 的方法签名可能不同,应以项目实际依赖版本提供的 API 为准,不要仅为套用示例而更换 SDK 版本。

不使用 SDK 时如何构造待验签内容

建议优先使用官方 SDK。如果确实需要手工验签,通常要按以下规则构造待验签字符串:

  1. 排除 sign 和 sign_type。
  2. 排除值为空的参数。
  3. 按参数名进行字典序排序。
  4. 按 key=value 的形式拼接。
  5. 使用 & 连接各参数。
  6. RSA2 使用 SHA256withRSA。
  7. 使用支付宝公钥验证 sign。

示意代码如下:

import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Base64;
import java.util.Map;
import java.util.stream.Collectors;

public boolean verifyRsa2(
        Map<String, String> params,
        PublicKey alipayPublicKey
) throws Exception {
    String signValue = params.get("sign");
    if (signValue == null || signValue.isEmpty()) {
        throw new IllegalArgumentException("缺少 sign 参数");
    }

    String content = params.entrySet().stream()
        .filter(entry -> !"sign".equals(entry.getKey()))
        .filter(entry -> !"sign_type".equals(entry.getKey()))
        .filter(entry -> entry.getValue() != null)
        .filter(entry -> !entry.getValue().isEmpty())
        .sorted(Map.Entry.comparingByKey())
        .map(entry -> entry.getKey() + "=" + entry.getValue())
        .collect(Collectors.joining("&"));

    Signature verifier = Signature.getInstance("SHA256withRSA");
    verifier.initVerify(alipayPublicKey);
    verifier.update(content.getBytes(StandardCharsets.UTF_8));

    return verifier.verify(Base64.getDecoder().decode(signValue));
}

手工实现很容易在字符编码、空值过滤、参数排序和证书解析等环节出错,适合用于定位问题,不建议代替成熟的 SDK。

重点检查参数是否被改写

题目中的以下参数在框架绑定、JSON 解析或日志输出过程中尤其容易发生变化:

fund_bill_list=[{"amount":"0.01","fundChannel":"ALIPAYACCOUNT"}]
subject=技能等级考证培训20250925
gmt_create=2025-09-25 11:33:09
gmt_payment=2025-09-25 11:33:14

验签前不要执行以下操作:

  • 将 fund_bill_list 解析为对象后重新生成 JSON。
  • 改变 JSON 字段顺序、空格或转义形式。
  • 对 subject 再次进行 URL 编码或解码。
  • 删除时间值中的空格。
  • 将 0.01 转为 0.0100。
  • 将 0.00 转为 0。
  • 对参数执行 trim()。
  • 根据业务对象重新生成参数 Map。

例如,下面两个值的业务含义可能相同,但签名计算会将它们视为不同的字符串:

[{"amount":"0.01","fundChannel":"ALIPAYACCOUNT"}]
[{"fundChannel":"ALIPAYACCOUNT","amount":"0.01"}]

验签必须使用请求解析后得到的原始参数值。

编码问题

回调中声明的字符集是:

charset=UTF-8

因此验签也应使用 UTF-8,同时检查:

  • Web 容器是否使用 UTF-8 读取请求参数。
  • 请求体是否曾按其他字符集解码。
  • 网关是否修改了中文参数。
  • 是否对已经解码的值再次执行 URL Decode。

如果验签日志中的 subject 出现乱码,问题发生在验签之前,应先修复请求解码流程。

建议增加的诊断日志

生产环境不能打印私钥,但可以在受控日志中记录:

notify_app_id
sign_type
charset
sign_present
verify_mode
alipay_cert_sn
sorted_parameter_names
verify_result

为了比较参数差异,还可以在测试环境记录最终待验签字符串的 SHA-256 摘要:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

String digest = HexFormat.of().formatHex(
    MessageDigest.getInstance("SHA-256")
        .digest(content.getBytes(StandardCharsets.UTF_8))
);

通过摘要可以比较不同节点构造的验签内容,同时避免直接暴露完整的业务数据。

验签通过后仍需进行业务校验

签名通过只能说明通知没有被篡改,不表示系统可以直接更新订单。还需要检查:

  • app_id 是否为预期应用。
  • seller_id 是否为当前收款账号。
  • out_trade_no 是否存在并属于当前商户。
  • total_amount 是否与本地订单金额一致。
  • trade_status 是否处于允许处理的状态。
  • 相同的 trade_no 或 notify_id 是否已经处理。

支付宝可能重复发送异步通知,因此订单更新必须具备幂等性。处理成功后,应按接口要求返回成功响应。验签失败时不能更新支付状态,也不能为了停止重试而返回成功。

针对当前参数,首先应获取完整的原始回调,确认其中是否包含 sign 和 sign_type。然后核对应用 2021005194677787 当前使用的是普通公钥模式还是证书模式,并使用对应的支付宝公钥材料直接调用 SDK 验签。

备注:内容仅供参考。