PAYATHON 2026

使用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 版本。

备注:内容仅供参考。