支付宝异步通知的文档很全,但真正卡人的地方不在文档里:验签返回 false,代码和抄来的示例几乎一样,工具验签却能过。先把问题分成三类,别一上来就怀疑私钥。
- 100% 验签失败:每一条通知都 false。基本都是公钥拿错了,或者模式用混了。
- 偶发失败:大部分成功,偶尔 false。通常是中文/特殊字符导致的编码问题,或者通知里出现了新字段。
- 验签通过但订单没变:最常见,代码没报错,日志里 sign ok,用户那边还是“未支付”。
下面按这个顺序排查。
一、先确认你手上的是哪个公钥
开放平台 → 应用 → 开发设置 → 接口加签方式,里面有两个东西很容易被当成一个:
- 应用公钥:你自己用密钥工具生成的密钥对里的公钥,上传给支付宝,用于你请求支付宝时,支付宝校验你的签名。
- 支付宝公钥:支付宝把自己的公钥给你,用于你校验支付宝返回和通知的签名。
验签用的必须是支付宝公钥。用应用公钥去验,结果必然是 false,而且不会报“公钥错误”,只是验签不过——这一步会消耗大量排查时间。
另外还有一层:sign_type 是 RSA 还是 RSA2,对应的支付宝公钥也不通用。如果日志里出现:
Signature length not correct: got 256 but was expecting 128
说明签名是 2048 位(RSA2/SHA256)而公钥是 1024 位(RSA),或者反过来。长度对不上,不是内容对不上。
二、公钥模式和公钥证书模式,验签代码不是一份
在「接口加签方式」里选的是公钥还是公钥证书,决定了:
| 项目 | 公钥模式 | 公钥证书模式 |
|---|---|---|
| 验签材料 | 支付宝公钥字符串 | 支付宝公钥证书(.crt,从证书里取公钥) |
| 请求额外参数 | 无 | 必须带 app_cert_sn、alipay_root_cert_sn |
| SDK 方法(Java 为例) | rsaCheckV1 | rsaCertCheckV1 |
| 常见错误 | 用了应用公钥 | 用错方法,或证书序列号算错 |
最坑的组合是:请求走证书模式(带了两个 sn),通知验签却调了 rsaCheckV1。请求能成功,回调一直 false。反过来也一样,看起来“只有回调有问题”。
app_cert_sn 不是随便填的,是按证书内容算出来的序列号(SDK 里有 AntCertUtil.getCertSN)。手算的常见错误是把证书 PEM 里的换行也参与哈希。
三、待验签串:官方三步,框架会替你多做一步
官方流程写得很明确(异步通知说明):
- 去掉
sign、sign_type,其余参数全部参与; - 每个值 URLDecode 一次,按参数名字典序排序,拼成
k=v&k=v; sign做 base64 解码,用支付宝公钥验签(RSA2 → SHA256withRSA)。
问题出在第 2 步。PHP 的 $_POST、Go 的 r.PostForm、绝大多数框架的 request 对象,读取时已经做过一次 URL 解码。你照着文档再 decode 一次,就变成双重解码:
- 原始值里的
%2B→ 第一次解码成+→ 第二次解码成空格; - 原始值里的
%2520之类更不用说了。
结果就是待验签串和支付宝签的那串不一致,验签失败。这类失败还有个特征:纯数字、纯英文参数的通知能过,一旦 subject/body/voucher_detail_list 里有中文或特殊字符就失败——所以它常被误判成“偶发”。
PHP 手写验签的正确姿势:
$params = $_POST; // 已被 PHP 解码一次,不要再 urldecode
$sign = $params['sign'];
$signType = $params['sign_type'] ?? 'RSA2';
unset($params['sign'], $params['sign_type']);
ksort($params); // 字典序
$pairs = [];
foreach ($params as $k => $v) {
if (is_array($v)) { // 极少见,但被攻击或参数重复时会出现
$v = implode(',', $v);
}
$pairs[] = $k . '=' . $v;
}
$signStr = implode('&', $pairs);
// 关键:表单 POST 里 base64 的 '+' 会被解成空格
$sign = str_replace(' ', '+', $sign);
$ok = openssl_verify(
$signStr,
base64_decode($sign),
$alipayPublicKey, // 注意:支付宝公钥
$signType === 'RSA2' ? OPENSSL_ALGO_SHA256 : OPENSSL_ALGO_SHA1
) === 1;
str_replace(' ', '+', $sign) 这一行不是玄学:application/x-www-form-urlencoded 里 + 代表空格,支付宝把 base64 签名原样放进来,PHP 解出来就成了空格。不换回来,base64 解码结果长度都不对。
Go 版本要注意取哪个字段:
func alipaySignString(v url.Values) string {
keys := make([]string, 0, len(v))
for k := range v {
if k == "sign" || k == "sign_type" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for i, k := range keys {
if i > 0 {
b.WriteByte('&')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(v.Get(k))
}
return b.String()
}
// 用 r.PostForm,不要用 r.Form
_ = r.ParseForm()
signStr := alipaySignString(r.PostForm)
r.Form 会把 URL query 参数合并进来。正常情况下 notify 请求没有 query,但只要链路上有人加了个 ?from=xx 或者做了转发,多出来的字段就会破坏签名串。用 r.PostForm 只拿 body。
还有个小坑:验签串里到底要不要剔除空值。请求签名时要求去掉空值参数,异步通知的文档只说要剔除 sign、sign_type。不要凭直觉剔除。如果用了 Laravel 这类带 ConvertEmptyStringsToNull 中间件的框架,本来该是 "" 的字段会变成 null,拼串时输出成空,和 k= 之间差一个字符,也可能失败。遇到这种就先打印原始 body 和拼出来的串,跟开放平台开发助手的「异步验签」工具逐字符对。
四、验签通过 ≠ 可以入账
官方在验签之后强制要求四项二次校验,任意一项不过就当异常通知忽略(异步验签失败排查):
out_trade_no在你库里真实存在;total_amount和下单时的金额一致(单位是元,两位小数,跟微信 v3 的“分”不是一个口径);seller_id(或seller_email)是这笔单对应的收款方;app_id是你的应用。
第 4 条在服务商(ISV)代调用场景有变体:通知里 app_id 是你自己的应用,auth_app_id 才是实际收款商户的应用。校验时应该是“auth_app_id 为空则比 app_id,否则比 auth_app_id”,只比 app_id 会把正常通知当成伪造的丢掉。这也是“验签通过但掉单”的一个来源。
另外状态本身也要判:只有 TRADE_SUCCESS(卖家开通了退款功能下的付款成功)和 TRADE_FINISHED(不支持退款的产品付款成功,或已过可退期)算支付成功。WAIT_BUYER_PAY、TRADE_CLOSED 默认不触发通知,但接口参数里存在这两个枚举,别写成 if ($status) { 发货 }。
五、回 success 的姿势:7 个字符,纯文本
支付宝判断通知成功的条件很粗暴:响应体里是 success 这 7 个字符。以下都会导致它认为失败并重发:
- 返回 JSON
{"code":"success"}; - 前面有 BOM、空格、
var_dump的调试输出; - 框架在
success后追加了模板尾部 HTML; - 返回 302 跳转(官方明确:重定向会让支付宝收不到 success);
- 状态码 500/404 或超时(默认超时很短,业务处理慢就会被打断)。
所以正确顺序是:先落库/入队,再 echo success,不要把“发短信、调第三方、生成发票”同步塞在回调里。处理超过几秒就大概率被重试,然后靠幂等扛住。
顺带一个被很多人忽略的事实:同一条通知重发时 notify_id 不变。这就是天然的去重键,比 out_trade_no 更精确——退款通知和支付通知可能共用同一个 out_trade_no。
六、没收到通知时,别指望支付宝会等到你来
重试节奏(中文文档):未收到 success 会立即重试 3 次,之后按 4m、10m、10m、1h、2h、6h、15h 的间隔重发,25 小时内共约 8 次后停止。英文文档里第一个间隔写的是 2m,两边不一致,以你后台实测的日志为准——这一点没做长时间对照测试,只能给到文档口径。
也就是说,通知链路上任何一段挂掉超过一天,这笔单就再也等不到第二次机会。所以:
- 必须接
alipay.trade.query(统一收单交易查询)作为兜底。订单超时关单前查一次,用户点“我已支付”时查一次,定时任务对WAIT_BUYER_PAY的单再扫一次。 - 查单返回成功但本地没有通知记录,就当成一次正常回调走同一套入库逻辑(同一把锁、同一个幂等键)。
- 本地有单、支付宝查不到(
TRADE_NOT_EXIST)才有资格关单。反过来会出现“用户付了钱、单被关掉”。
排障顺序(照着走)
- 打开开放平台,确认「接口加签方式」是公钥还是公钥证书。
- 打印一条真实通知的原始 body,存文件,别在框架里看已解码的数组。
- 用开发助手的「异步验签」导入这份原文和支付宝公钥,先证明公钥是对的。
- 工具过、代码不过 → 九成是待验签串问题:多解码一次、
+没换回、排序、空值处理、r.Form混入 query。 - 都过但掉单 → 查四项二次校验和状态枚举,特别是 ISV 的
auth_app_id。 - 长期兜底:
trade.query定时补偿 +notify_id去重 + 入队后再回success。
一个前提要说清:上面关于“框架二次解码”和“+ 变空格”的判断来自实际踩坑的共性,具体到你的框架版本仍建议拿原始 body 复现一次再改代码;重试间隔则都是文档口径,没有做跨天实测。