PAYATHON 2026

支付宝 APP 异步通知自验签失败,notify_time 应如何解码?

支付阿杰

结论

支付宝异步通知通常通过 application/x-www-form-urlencoded 提交。在这种编码中,+ 代表空格,因此参与验签的 notify_time 通常是:

notify_time=2021-07-02 00:45:21

不过,不能只手动替换 notify_time。验签时必须使用异步通知中的完整参数集合,按照表单编码规则解码一次,再排除 sign 和 sign_type,排序后拼接。任何字段经过重复解码、重新编码、截断或修改,都可能导致验签失败。

为什么 + 会变成空格

URL 查询编码和表单编码对 + 的解释不同:

  • 使用 application/x-www-form-urlencoded 解码时,+ 表示空格。
  • %2B 解码后才是字面意义上的加号。
  • %20 解码后也是空格。

因此,应先查看服务器实际收到的原始请求体。假设内容是:

notify_time=2021-07-02+00%3A45%3A21

正确的解码结果为:

2021-07-02 00:45:21

如果原始请求体是:

notify_time=2021-07-02%2B00%3A45%3A21

解码结果才会保留 +:

2021-07-02+00:45:21

应根据原始报文判断,不能尝试两种字符串来碰运气。支付宝通知时间通常以空格分隔日期和时间,但验签时仍要以实际收到并按表单规则解码后的值为准。

推荐的验签方式

不要单独处理 notify_time,也不要自行挑选几个看起来重要的参数进行拼装。更稳妥的做法是把请求中的全部表单参数交给支付宝 SDK 验签。

Java Servlet 示例:

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

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

public boolean verifyAlipayNotify(HttpServletRequest request) 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();

        // 支付宝通知参数通常只有一个值。
        // 保留该写法主要用于兼容 Servlet 参数结构。
        String value = String.join(",", values);
        params.put(entry.getKey(), value);
    }

    return AlipaySignature.rsaCheckV1(
        params,
        ALIPAY_PUBLIC_KEY,
        "UTF-8",
        "RSA2"
    );
}

request.getParameterMap() 已经按照表单规则完成解码,此时不要再次调用 URLDecoder.decode(),否则可能造成二次解码。

如果项目仍在使用 javax.servlet,只需修改导入:

import javax.servlet.http.HttpServletRequest;

自行验签时的正确流程

如果确实需要自行验签,应按以下顺序处理:

  1. 使用 application/x-www-form-urlencoded 解析请求体。
  2. 分别对参数名和参数值解码一次。
  3. 取出 sign,并从待签名参数中排除 sign 和 sign_type。
  4. 按参数名的字典序排序。
  5. 将参数写成 key=value,再用 & 连接。
  6. 不要再次对拼接结果进行 URL 编码或 URL 解码。
  7. 使用支付宝公钥和通知声明的签名算法验证签名。

示意代码:

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

public boolean verify(
        Map<String, String> decodedParams,
        PublicKey alipayPublicKey
) throws Exception {
    String sign = decodedParams.get("sign");
    String signType = decodedParams.get("sign_type");

    if (sign == null || signType == null) {
        return false;
    }

    Map<String, String> signParams = new TreeMap<>(decodedParams);
    signParams.remove("sign");
    signParams.remove("sign_type");

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

    String signatureAlgorithm;
    if ("RSA2".equalsIgnoreCase(signType)) {
        signatureAlgorithm = "SHA256withRSA";
    } else if ("RSA".equalsIgnoreCase(signType)) {
        signatureAlgorithm = "SHA1withRSA";
    } else {
        throw new IllegalArgumentException("Unsupported sign_type: " + signType);
    }

    Signature verifier = Signature.getInstance(signatureAlgorithm);
    verifier.initVerify(alipayPublicKey);
    verifier.update(signContent.getBytes(StandardCharsets.UTF_8));

    byte[] signatureBytes = Base64.getDecoder().decode(sign);
    return verifier.verify(signatureBytes);
}

这段代码用于说明验签步骤。生产环境中应优先使用与当前接入方式匹配的支付宝 SDK,以免在空值处理、字符集或参数拼接规则上出现细微偏差。

验签工具也失败时如何排查

1. 保存原始请求体和解析后的参数

至少记录以下内容:

Content-Type
原始请求体
解析后的参数 Map
最终待验签字符串
sign
sign_type
charset
app_id

记录时要做好脱敏并限制日志访问权限。排查的重点是确认服务器实际收到的内容,与粘贴到验签工具中的内容完全一致。

不要只在日志中保存格式化后的 JSON。异步通知一般不是 JSON,转换过程可能已经改变了参数值。

2. 检查是否发生二次 URL 解码

下面的处理会破坏参数:

String value = request.getParameter("notify_time");
value = URLDecoder.decode(value, "UTF-8"); // 不应再次解码

例如,原始值中的 %2B 第一次解码后会变成 +。如果再次按表单规则解码,+ 可能又会变成空格。

也不要先对整个请求体执行 URL 解码,再按照 & 和 = 拆分。参数值中的 %26、%3D 可能提前变成 &、=,从而破坏参数边界。正确做法是先解析表单结构,再分别解码参数名和参数值。

3. 检查 sign 是否被破坏

sign 是 Base64 字符串,可能含有 +、/ 和 =。手工解析请求体时,如果错误处理了 +,或者删除了末尾的 =,签名值本身就会发生变化。

不要对 sign 做以下处理:

trim 后再拼装
删除 =
把空格直接当成合法签名字符
再次 URL 编码
再次 URL 解码

如果框架按照表单规则解析,并且发送方编码正确,签名中的字面 + 应在原始报文中写成 %2B,解析后再恢复为 +。

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

验签需要使用支付宝公钥,不能使用应用公钥或应用私钥。

还要确认密钥模式与当前应用配置一致:

  • 普通公钥模式应使用对应应用配置中的支付宝公钥。
  • 公钥证书模式应按证书模式加载和验证,不能直接换成另一份文本公钥。
  • 沙箱环境和正式环境的配置不能混用。
  • 不同应用或不同密钥配置对应的支付宝公钥也不能混用。

公钥内容看起来正确并不足以排除问题。还应核对通知中的 app_id、调用环境,以及开放平台中该应用当前生效的密钥配置。

5. 检查签名算法和字符集

如果通知参数是:

sign_type=RSA2

对应算法应为:

SHA256withRSA

如果通知参数是:

sign_type=RSA

通常对应:

SHA1withRSA

字符集一般按照通知和应用配置使用 UTF-8。将待验签字符串转换为字节时,字符集必须与签名时一致。尤其要检查 subject、body、passback_params 等可能包含中文或特殊字符的字段。

6. 不要修改任何业务字段

以下操作都会改变待验签字符串:

  • 将空格统一替换成 +
  • 格式化日期
  • 对金额做数值转换,例如把 1.00 改成 1
  • 重新序列化 JSON 字符串
  • 删除字段首尾空格
  • 改变字段大小写
  • 在空字符串、null 和缺失字段之间进行转换
  • 只选择部分字段验签

只有验签通过后,才能继续核对 app_id、out_trade_no、total_amount、seller_id 和交易状态等业务信息。

建议的定位方法

排查时,可以逐字比较以下三组内容:

A. 原始请求体
B. SDK 实际使用的参数 Map
C. 最终待验签字符串

如果 SDK 和支付宝验签工具都验证失败,常见原因通常不在 notify_time 本身,而在以下环节:

  • 粘贴到工具中的参数被浏览器、日志系统或编辑器改写。
  • sign 被错误解码。
  • 其他通知字段被遗漏或修改。
  • 使用了错误应用、错误环境或错误密钥模式下的支付宝公钥。
  • RSA、RSA2 或字符集配置不一致。

因此,notify_time 应按表单编码规则解码成实际值,常见结果是带空格的 2021-07-02 00:45:21。要解决验签失败,应保留完整的原始通知,确保所有参数只解码一次,并直接使用支付宝 SDK 对完整参数集合验签。

备注:内容仅供参考。