PHP 调用 alipay.trade.wap.pay 时签名校验失败
结论
out_trade_no 应按字符串处理,但不需要手工拼接双引号。生成 biz_content 时,json_encode() 会自动为字符串加上双引号。
签名失败通常不是因为“字符串不能签名”,而是参与签名的 biz_content 与实际 POST 给支付宝的内容不完全一致。示例中的 product_code 是 QUICK_MSECURITY_PAY,适用于 alipay.trade.app.pay。调用 alipay.trade.wap.pay 时,应改为 QUICK_WAP_WAY。
如果 APP 需要调用原生支付宝支付,应使用 alipay.trade.app.pay。只有确实需要在 APP 内打开手机网站支付页面时,才使用 alipay.trade.wap.pay。
为什么数字和字符串会导致验签结果不同
下面两个 JSON 的含义和字节内容都不相同:
{"out_trade_no":1642406976}
{"out_trade_no":"1642406976"}
RSA2 签名针对完整字符串的字节序列。签名完成后,只要内容出现以下任一变化,验签就可能失败:
- 数字变成字符串,或字符串变成数字
- JSON 被重新编码
- 空格、斜杠或 Unicode 转义方式发生变化
- 参数经过
htmlspecialchars()等 HTML 转义 - 在签名前进行了 URL 编码
- POST 时重新拼装了
biz_content - 参数首尾增加或删除了空格
因此,不能先用数字形式生成签名,再把它改成带双引号的字符串提交。正确的做法是先确定最终业务参数,只生成一次 biz_content,然后在签名和提交时复用同一个字符串。
正确的数据类型和参数值
商户订单号是标识符,不用于数值计算,应始终保存为字符串:
$outTradeNo = '1642406520';
不要手工添加 JSON 双引号:
$outTradeNo = '"1642406520"';
也不要保留无意义的首尾空格:
$outTradeNo = 'M 1642406520 ';
订单号最好只使用稳定、可控的字符,例如数字、字母、下划线和连字符:
$outTradeNo = 'M1642406520';
具体长度和字符限制以当前接口文档中的 out_trade_no 参数说明为准。
PHP 请求示例
下面的示例保留了关键处理过程。实际项目应优先使用支付宝官方 PHP SDK,由 SDK 处理排序、签名和请求参数编码。
<?php
$bizData = [
'out_trade_no' => (string) '1642406520',
'total_amount' => '0.01',
'subject' => 'test_s',
'product_code' => 'QUICK_WAP_WAY',
'quit_url' => 'https://example.com/pay/quit',
];
// 只生成一次,后面签名和 POST 必须复用该变量。
$bizContent = json_encode(
$bizData,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
if ($bizContent === false) {
throw new RuntimeException('biz_content JSON 编码失败:' . json_last_error_msg());
}
$params = [
'app_id' => '你的 app_id',
'method' => 'alipay.trade.wap.pay',
'format' => 'JSON',
'charset' => 'utf-8',
'sign_type' => 'RSA2',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0',
'notify_url' => 'https://example.com/alipay/notify',
'return_url' => 'https://example.com/alipay/return',
'biz_content' => $bizContent,
];
$signContent = buildSignContent($params);
$params['sign'] = rsa2Sign($signContent, $privateKey);
// URL 编码只在签名完成后进行。
$postBody = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$ch = curl_init('https://openapi.alipay.com/gateway.do');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postBody,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded;charset=utf-8',
],
]);
$response = curl_exec($ch);
if ($response === false) {
throw new RuntimeException('请求失败:' . curl_error($ch));
}
curl_close($ch);
echo $response;
function buildSignContent(array $params): string
{
unset($params['sign']);
ksort($params);
$parts = [];
foreach ($params as $key => $value) {
if ($key === 'sign_type' || $value === '' || $value === null) {
continue;
}
// 此处必须使用原始值,不能 urlencode 或 htmlspecialchars。
$parts[] = $key . '=' . $value;
}
return implode('&', $parts);
}
function rsa2Sign(string $content, string $privateKey): string
{
$key = openssl_pkey_get_private($privateKey);
if ($key === false) {
throw new RuntimeException('无法加载应用私钥');
}
$success = openssl_sign(
$content,
$signature,
$key,
OPENSSL_ALGO_SHA256
);
if (!$success) {
throw new RuntimeException('RSA2 签名失败');
}
return base64_encode($signature);
}
私钥必须是 OpenSSL 能够识别的 PEM 内容,例如:
$privateKey = <<<'KEY'
-----BEGIN PRIVATE KEY-----
应用私钥内容
-----END PRIVATE KEY-----
KEY;
如果现有私钥只有 Base64 内容,需要按照密钥格式补上正确的 PEM 头尾。PRIVATE KEY 和 RSA PRIVATE KEY 两种格式不能混用。
APP 支付与 WAP 支付不要混用
这两类接口通常采用不同的组合:
| 使用场景 | API | product_code |
|---|---|---|
| APP 原生支付 | alipay.trade.app.pay | QUICK_MSECURITY_PAY |
| 手机网站支付 | alipay.trade.wap.pay | QUICK_WAP_WAY |
下面的组合存在接口类型不匹配的问题:
{
"product_code": "QUICK_MSECURITY_PAY"
}
method=alipay.trade.wap.pay
调用 alipay.trade.wap.pay 时,应改为:
{
"product_code": "QUICK_WAP_WAY"
}
WAP 支付通常返回供浏览器跳转或提交的页面内容。如果 APP 把响应当成普通 JSON 接口处理,也可能出现“页面出错”的现象。
排查签名时应比较什么
不要根据错误页面中的 HTML 文本反推验签字符串,也不要用 htmlspecialchars_decode() 修正请求。HTML 转义属于页面展示层,与支付宝实际参与验签的原始请求内容不是一回事。
发送请求前,可以记录以下内容:
error_log('biz_content=' . $bizContent);
error_log('sign_content=' . $signContent);
error_log('post_body=' . $postBody);
检查时重点确认:
sign_content中的biz_content与 POST 参数解码后的biz_content完全一致。- 签名前没有调用
urlencode()、htmlspecialchars()或addslashes()。 http_build_query()只在签名完成后调用。- 生成签名后没有再次执行
json_encode()。 - 使用应用私钥签名,并使用对应的应用公钥验签。
charset、sign_type和网关环境配置一致。- 没有混用沙箱应用、正式应用及其密钥。
处理时只需遵循一条原则:先将 out_trade_no 设为普通 PHP 字符串,再生成唯一一份 biz_content。签名使用这份原始字符串,最终 POST 时也原样使用它。
备注:内容仅供参考。
