使用PHP demo和SDK验签时异步回调支付成功验签失败
结论
“支付成功”通知验签失败,而“支付关闭”通知验签成功,通常不是因为两类通知采用了不同的验签规则。更常见的情况是,支付成功通知包含更多字段或特殊字符,回调数据在验签前被 PHP、Web 框架、网关或业务代码改动,导致实际参与验签的内容与支付平台签名时使用的内容不一致。
建议优先检查:
- 是否使用了
$_POST、反序列化后的数组或重新生成的 JSON,而非原始请求体。 - URL 编码是否被自动解码,尤其是
+是否被转换成空格。 - JSON 重新编码后,字段顺序、转义形式或小数格式是否发生变化。
- 验签前是否调用过
trim()、urldecode()、htmlspecialchars()、stripslashes()等函数。 - 是否遗漏了只在成功通知中出现的字段,或错误删除了空值字段。
- 回调使用的商户号、应用、公钥、证书序列号或签名算法是否匹配。
- 平台是否要求拼接时间戳、随机串等请求头,而代码只验证了业务正文。
关闭通知验签成功,只能说明密钥和基础流程可能没有问题,不能证明成功通知在进入验签方法前未被改动。
为什么支付成功通知更容易暴露问题
支付成功通知通常比关闭通知包含更多交易信息,如支付时间、金额、渠道信息、买家信息、优惠信息或嵌套 JSON。字段越多、结构越复杂,数据在传输和解析过程中发生变化的可能性就越大。
常见差异有:
- 支付成功通知包含中文、空格、
+、/、=、换行符等字符。 - 金额格式发生转换,例如字符串
"10.00"被转成数字10。 - JSON 中的
/、Unicode 字符或换行被重新转义。 - 成功通知含有可选字段,但本地拼接待签名字符串时遗漏了这些字段。
- 成功通知使用了其他证书签名,旧公钥或旧证书无法完成验证。
- 成功通知的业务正文经过加密,需要先验证通知签名,再按照平台规则解密。
排查时不应只比较“成功”和“关闭”两种状态,而要对比两类通知进入验签函数前的原始数据和验签参数。
排查步骤
1. 保存原始请求体
回调入口首先读取 php://input,并保留未经修改的原始字符串:
<?php
$rawBody = file_get_contents('php://input');
if ($rawBody === false) {
http_response_code(400);
exit('invalid request body');
}
不要通过 $_POST、框架 Request 对象或解析后的数组重新拼装正文。原始请求体通常只能可靠读取一次,最好在回调入口统一读取,再传给后续逻辑。
如果平台发送 JSON,下面两段内容的业务含义可能相同,但用于签名时未必等价:
{"amount":"10.00","status":"SUCCESS"}
{
"status": "SUCCESS",
"amount": 10
}
字段顺序、空白、小数形式和转义方式发生变化,都可能导致验签失败。
2. 确认平台规定的验签原文
不同支付平台对待验签内容的要求并不相同,必须以对应平台及当前 SDK 版本的文档为准。常见形式包括:
原始请求体
或者:
timestamp + "\n" + nonce + "\n" + rawBody + "\n"
有些平台要求先对表单字段排序,排除 sign 字段后再拼接。不能直接把一个平台的拼接规则用到另一个平台。
如果签名信息位于请求头,应直接读取相应请求头,例如:
<?php
$signature = $_SERVER['HTTP_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_NONCE'] ?? '';
$message = $timestamp . "\n"
. $nonce . "\n"
. $rawBody . "\n";
这里的 SIGNATURE、TIMESTAMP、NONCE 只是示意名称,实际名称应以支付平台文档为准。
3. 避免表单解码改变内容
如果回调的 Content-Type 是 application/x-www-form-urlencoded,PHP 会自动解析 $_POST。在解析过程中,+ 通常会变成空格:
原始值:abc+def==
解析后:abc def==
此时,如果使用 $_POST 中的值重新拼接待签名字符串,验签就可能失败。具体应按照平台规则处理:
- 直接使用原始请求体;
- 严格遵循平台指定的 URL 解码、字段排序和拼接规则。
没有明确要求时,不要重复调用:
urldecode($value);
rawurldecode($value);
重复解码也会改变数据。
4. 检查字段处理方式
以下操作都可能破坏签名原文:
$body = trim($rawBody);
$body = stripslashes($rawBody);
$body = htmlspecialchars($rawBody);
$body = json_encode(json_decode($rawBody, true));
尤其不要为了统一格式而先解码再编码 JSON:
// 不建议:重新编码后不再是平台发送的原始正文
$data = json_decode($rawBody, true);
$bodyForVerify = json_encode($data);
如果平台要求排序字段后再验签,还要逐项确认:
- 是否需要排除
sign、signature等签名字段; - 是否保留值为空字符串的字段;
- 是否忽略
null; - 键名是否区分大小写;
- 是否按字典序排序;
- 字段值是否需要进行 URL 编码;
- 拼接结果末尾是否含有
&或换行; - 字符集是否固定为 UTF-8。
这些细节必须以平台规则为准,不能凭经验判断。
5. 核对公钥、证书和算法
如果原始数据确认无误,再检查验签材料:
- 回调对应的商户号或应用 ID 是否正确;
- 使用的是平台公钥,还是商户自己的公钥;
- 在证书模式下,是否根据证书序列号选择了正确的平台证书;
- 平台是否轮换过证书;
- 测试环境和正式环境的密钥是否混用;
- 签名算法是
RSA-SHA256、RSA-SHA1,还是其他算法; - 签名值是否需要 Base64 解码;
- SDK 配置是否与实际回调所属渠道一致。
以 OpenSSL 验签为例,只有平台明确规定使用 RSA 和 SHA-256 时,才可采用类似代码:
<?php
$publicKeyPem = file_get_contents('/path/to/platform_public_key.pem');
$publicKey = openssl_pkey_get_public($publicKeyPem);
if ($publicKey === false) {
throw new RuntimeException('Invalid platform public key');
}
$signatureBinary = base64_decode($signature, true);
if ($signatureBinary === false) {
throw new RuntimeException('Invalid Base64 signature');
}
$result = openssl_verify(
$message,
$signatureBinary,
$publicKey,
OPENSSL_ALGO_SHA256
);
if ($result === 1) {
// 验签成功
} elseif ($result === 0) {
// 签名不匹配
} else {
throw new RuntimeException(openssl_error_string() ?: 'OpenSSL verify error');
}
这段代码只用于说明数据流。实际项目应优先使用支付平台 SDK 提供的验签接口,并采用文档指定的算法和公钥格式。
6. 对成功与关闭通知进行字节级比较
建议分别记录:
Content-Type;- 原始请求体长度;
- 原始请求体的 SHA-256 摘要;
- SDK 实际收到的待验签字符串;
- 签名值;
- 时间戳、随机串和证书序列号;
- 商户号、应用 ID 和通知类型;
- SDK 返回的错误信息及 OpenSSL 错误。
例如:
<?php
error_log('content_type=' . ($_SERVER['CONTENT_TYPE'] ?? ''));
error_log('body_length=' . strlen($rawBody));
error_log('body_sha256=' . hash('sha256', $rawBody));
error_log('body_base64=' . base64_encode($rawBody));
用 Base64 记录原文,更容易发现肉眼难以识别的差异,例如:
\r\n与\n;- 字符串末尾的换行;
- UTF-8 BOM;
+与空格;- 全角和半角字符;
- 不同的 JSON 转义形式。
生产环境日志必须脱敏,不能长期保存完整的支付信息、用户标识、密文或签名凭据。
推荐的回调处理结构
<?php
$rawBody = file_get_contents('php://input');
if ($rawBody === false || $rawBody === '') {
http_response_code(400);
exit('invalid body');
}
$signature = $_SERVER['HTTP_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_TIMESTAMP'] ?? '';
$nonce = $_SERVER['HTTP_NONCE'] ?? '';
try {
// 参数名称和调用方式应替换为实际支付 SDK 的接口。
$verified = $sdk->verifyNotification(
$rawBody,
$signature,
$timestamp,
$nonce
);
if (!$verified) {
http_response_code(400);
exit('invalid signature');
}
// 必须在验签成功后解析和处理业务数据。
$data = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// 校验商户号、订单号、金额、币种和业务状态。
// 业务处理应保证幂等,避免重复通知造成重复入账。
http_response_code(200);
echo 'success';
} catch (Throwable $e) {
error_log($e->getMessage());
http_response_code(500);
echo 'fail';
}
verifyNotification() 只是示意名称,不表示具体 SDK 一定提供该方法。应将平台文档中的回调验签示例与当前 SDK 版本逐项核对,避免混用不同版本的 Demo。
注意事项
验签必须在业务数据处理之前完成。即使订单号、金额或支付状态看起来正确,验签失败时也不能继续更新订单。
同时检查反向代理、API 网关、WAF 或框架中间件是否读取并重写了请求体。如果本地记录的原始请求已经与支付平台发送的内容不同,应从接入层开始排查,而不是继续调整 SDK 参数。
定位问题时,最好获取一条失败通知的完整原始 HTTP 请求,并在隔离环境中用同一个验签方法重放。如果原始请求能够通过验签,而线上失败,问题通常出在框架或中间件。如果重放后仍然失败,则应重点检查待验签字符串、公钥或证书、签名算法及 SDK 版本。
备注:内容仅供参考。