支付宝异步通知验签一直 false:多数是二次 URLDecode 和公钥拿错
线上支付回调接口的日志里,rsaCheckV1 返回 false。待签串打印出来逐字符对过,订单号、金额、trade_status 都对,就是不通过。支付宝按重试策略一遍遍推同一笔通知,订单始终停在待支付。
下面按实际排查的顺序写:待签串怎么拼、四个最常见的坑、用 openssl 手动复现,以及验签通过之后还得做的校验和应答。
待验签串怎么拼
- 在通知返回参数列表中,除去
sign、sign_type两个参数外,其余参数都是待验签参数。 - 将剩下参数进行 URLDecode,然后按字典序排序,拼成
k=v&k=v形式。 - 将
sign用 base64 解码为字节串。 - 用 RSA 验签函数(RSA2 对应 SHA256WithRSA,RSA 对应 SHA1WithRSA),传入待签串、base64 解码后的签名、支付宝公钥。
文档示例报文拼出来是这样:
body=大乐透2.1&buyer_id=2088102116773037&charset=utf-8&gmt_close=2016-07-19 14:10:46&gmt_payment=2016-07-19 14:10:47¬ify_time=2016-07-19 14:10:49¬ify_type=trade_status_sync&out_trade_no=0719141034-6418&refund_fee=0.00&subject=大乐透2.1&total_amount=2.00&trade_no=2016071921001003030200089909&trade_status=TRADE_SUCCESS&version=1.0
值是原样拼接,不做 URL 编码,也不加引号。注意 notify_time=2016-07-19 14:10:49 里那个空格——它是待签内容的一部分,不是排版。
文档另有一条 TIPS:生活号异步通知的待签串需要保留 sign_type 参数,支付通知不保留。两条规则不一样,从别的项目抄代码最容易在这里翻车。
坑一:二次 URLDecode
这是「一直 false」里出现频率最高的一个。
支付宝通知是 application/x-www-form-urlencoded 的 POST body。PHP 的 $_POST、Go 的 r.ParseForm()、Java 的 request.getParameter(),拿到的值框架已经替你 urldecode 过一次。这时候再照着文档写一遍 urldecode($v),就是解码两次。
后果很具体:值里带 + 或 %2B 的参数会变成空格。典型受害者是 passback_params——业务回传参数,文档要求商户先 UrlEncode 再传给支付宝,所以里面常有 %3d、%26 这类转义。文档示例里这个值是:
merchantBizType%3d3C%26merchantBizNo%3d2016010101111
二次解码后 %3d 变成了 =,待签串和支付宝签的那份就对不上,验签当然 false。
判断方法不复杂:把收到的原始 body 字符串打印出来(不经过框架解析的那种),和代码里实际拿去验签的 map 逐项对一遍,重点看 +、%、空格这三样的差别。
正确做法就一句话:从原始 body 解析参数,只解码一次,后面所有处理都用解码后的值,验签时不要再解码。Go 里用 url.ParseQuery(rawBody) 一次到位;PHP 里 $_POST 直接用,别加 urldecode;也不要图省事从 $_GET 或 query 里取值。
坑二:公钥拿成了「应用公钥」
文档和技术支持的排障口径里,这条都排在第一。
代码里的 alipay_public_key 必须是开放平台「查看支付宝公钥」那个位置拿到的支付宝公钥,不是用工具生成密钥对时得到的应用公钥。两者都是 RSA 公钥,PEM 格式看起来一模一样,用错的结果就是永远 false,而且不会有任何报错提示。
另一个变种是沙箱和生产混用。网关地址、app_id、支付宝公钥必须配套,沙箱环境验签时拿生产环境的支付宝公钥,同样一直 false。
走证书模式的话,验签读的是支付宝公钥证书 alipayCertPublicKey_RSA2.crt,不是应用公钥证书 appCertPublicKey_*.crt,也不是根证书 alipayRootCert.crt。三个文件长得很像,放错目录不会报错,只会验不过。
签名算法和公钥类型也要配套。文档列过这类 SDK 异常原文:
Signature length not correct: got 256 but was expecting 128——sign_type设了 RSA2,用的却是 RSA 类型的支付宝公钥。got 128 but was expecting 256—— 反过来。
RSA2 签名 256 字节,RSA 128 字节,长度一眼就能看出来是哪边配错了。
坑三:charset 与空值参数
通知里带 charset=utf-8,也可能是 gbk。构造待签串时必须用这个编码把参数拼成字节串再去验签。GBK 应用的 subject、body 里有中文时,用 UTF-8 拼出来必然 false。
技术支持给的排障技巧:把 subject、body 临时改成纯英文数字测一次。英文能过、中文不能过,问题就在编码,不在密钥。
空值参数是另一个隐蔽点。SDK 的 getSignCheckContentV1 实现里会跳过 trim 后为空的参数。自己实现时如果老老实实保留了 k=,拼出来的串和支付宝那份就不一样。稳妥做法是跟 SDK 保持一致:剔除 sign、sign_type,再剔除值为空的参数。
坑四:URL 里的 & 被吃、末尾多一个换行
两种「看起来一模一样」的差异,都很耗时间。
一是日志里的待签串从浏览器或 HTML 页面复制出来的,×tamp= 被解析成了 ×tamp=(× 是乘号实体),表现是参数名莫名其妙对不上。
二是用 echo 把待签串写进文件,末尾会多一个 \n。拿这个文件去 openssl 验签一定失败,换成 printf '%s' 就过。手动验签时踩这个坑的人不少。
手动用 openssl 验一遍
不信任 SDK 的返回值时,把待签串和签名落盘,用 openssl 复现一次:
# 1. 待签串:UTF-8、无 BOM、无末尾换行
printf '%s' "$SIGN_STR" > /tmp/alipay_body.txt
# 2. 签名 base64 解码
printf '%s' "$SIGN" | base64 -d > /tmp/alipay_sign.bin
# 3. 支付宝公钥必须是 PEM:有 -----BEGIN PUBLIC KEY----- 头尾
openssl dgst -sha256 -verify /tmp/alipay_pub.pem -signature /tmp/alipay_sign.bin /tmp/alipay_body.txt
输出 Verified OK,说明待签串和密钥都对,问题在业务代码的拼串逻辑。
输出 Verification Failure,按这个顺序排:待签串差异(用 wc -c 比长度,多一个字节都算不同)→ 用的是 SHA256(RSA2)还是 SHA1(RSA)→ 公钥到底是不是支付宝公钥。
公钥常常是从控制台复制出来的裸 base64 字符串,需要补 PEM 头尾,每 64 字符换行。PHP 里可以直接:
$pem = "-----BEGIN PUBLIC KEY-----\n"
. chunk_split($alipayPublicKey, 64, "\n")
. "-----END PUBLIC KEY-----\n";
$ok = openssl_verify($signStr, base64_decode($sign), $pem, OPENSSL_ALGO_SHA256);
Go 里是先解析再验:
pub, _ := x509.ParsePKIXPublicKey(pubBytes)
rsaPub := pub.(*rsa.PublicKey)
sum := sha256.Sum256([]byte(signStr))
err := rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, sum[:], sig)
验签过了也不代表这笔通知能用
文档第五步要求验签通过后再做四项校验,任何一项不过都当异常通知忽略:
out_trade_no是本站创建过的订单号;total_amount等于下单时的金额(单位是元,字符串两位小数);seller_id(或seller_email)是本商户,多商户多收款账号时尤其要查;app_id是自己的应用。
然后按 trade_status 分流。只有 TRADE_SUCCESS 和 TRADE_FINISHED 才算买家付款成功。TRADE_CLOSED 是未付款超时关闭,或者支付后全额退款,不能当成功处理。
应答与重试
- 文档要求应答体输出
success,纯文本,不加空格、不带 HTML。输出fail或其它内容会被判定失败并重发。 - 新版文档同时写明:HTTP 状态码 200 表示异步通知成功;404/500 视为服务器内部错误。实操上 HTTP 200 加正文
success一起给最稳,别只靠其中一个。 - 重试节奏:未收到 success 立即重试 3 次,之后间隔 4m、10m、10m、1h、2h、6h、15h。旧版文档表述为 25 小时内 8 次。
- 重试时
notify_id不变,可以拿它做一次去重;但真正的幂等键还是订单号加状态机,不要只依赖notify_id。 notify_url不能带空格、不能有 HTML 标签,也不能 302 重定向——重定向之后,支付宝拿不到success正文。
边界
本文只讲支付宝国内版(openapi.alipay.com)的异步通知,公钥模式和公钥证书模式都覆盖;不涉及国际版 Alipay Global、间连模式和生活号通知(生活号的待签串保留 sign_type)。
以上命令和结论来自支付宝开放平台文档与技术支持 FAQ,没有在真实商户号上逐条复现;sub_code 的具体取值、重试次数,以实际响应和线上日志为准。