支付宝 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;
自行验签时的正确流程
如果确实需要自行验签,应按以下顺序处理:
- 使用
application/x-www-form-urlencoded解析请求体。 - 分别对参数名和参数值解码一次。
- 取出
sign,并从待签名参数中排除sign和sign_type。 - 按参数名的字典序排序。
- 将参数写成
key=value,再用&连接。 - 不要再次对拼接结果进行 URL 编码或 URL 解码。
- 使用支付宝公钥和通知声明的签名算法验证签名。
示意代码:
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 对完整参数集合验签。
备注:内容仅供参考。
