PAYATHON 2026

支付宝V3 APP支付下单接口验签出错如何排查?

支付小周

结论

在本地用应用私钥签名,并能通过应用公钥验签,只能说明这对密钥在数学上匹配,不能说明支付宝收到的签名请求没有问题。

V3 接口验签失败时,还需要检查这些环节:

  • 当前 app_id 配置的应用公钥是否与本地私钥对应。
  • 正式环境与沙箱环境、不同应用或配置模式是否混用。
  • V3 待签名字符串的格式、换行符、URI 或请求体是否正确。
  • 签名后发送的 JSON,是否与参与签名的 JSON 保持完全相同的字节内容。
  • Authorization 请求头中的签名是否正确完成 Base64 和 URL 编码。
  • 是否混用了普通公钥模式与证书模式,或者证书序列号计算有误。
  • HTTP 客户端、中间件、网关或 Flask 代理是否修改了 URI、请求体或请求头。
  • 使用的支付宝 SDK 是否兼容 Python 3.13,实际调用的是否为预期的 V3 签名实现。

排查时,不必反复验证”私钥和本地公钥是否成对”。更有用的做法是还原支付宝实际收到的 HTTP 请求,并逐字节核对待签名内容。

先确认应用和环境没有串用

依次核对:

  1. 请求中的 app_id 是否属于当前应用。
  2. 本地私钥对应的应用公钥是否已经上传到这个 app_id。
  3. 上传公钥后,应用配置是否已经保存并生效。
  4. 正式网关 https://openapi.alipay.com 是否使用正式应用的配置。
  5. 如果此前使用过沙箱,确认程序没有继续读取沙箱私钥、应用 ID 或证书。
  6. 在多配置、多租户或多进程部署中,确认运行时加载的配置与配置文件内容一致。

可以在日志中记录 app_id、网关域名和公钥指纹,但不要直接打印私钥。

使用 OpenSSL 从私钥重新导出公钥,再与支付宝开放平台中配置的应用公钥比较:

openssl pkey -in app_private_key.pem -pubout -out derived_public_key.pem
openssl pkey -pubin -in derived_public_key.pem -outform DER \
  | openssl dgst -sha256

如果从平台复制的公钥没有 PEM 头尾,可以先整理为以下格式:

-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----

不要混淆”支付宝公钥”和”应用公钥”。应用私钥用于请求签名,支付宝保存应用公钥并用它验证请求。支付宝公钥主要供商户验证支付宝返回的数据。

核对 V3 待签名字符串

V3 接口并非只对业务 JSON 签名。待签名内容通常还包括:

  • HTTP Method
  • 请求 URI
  • app_id
  • timestamp
  • nonce
  • 请求体

以 APP 支付下单为例,请求 URI 应为:

/v3/alipay/trade/app/pay

不要把完整域名、查询参数、末尾多余的 / 或反向代理改写后的路径当成 URI。HTTP Method 一般使用大写的 POST。

V3 待签名字符串的具体拼接规则,应以当前支付宝官方文档或所用官方 SDK 的实现为准。排查时,要以可见转义形式打印最终的待签名字符串:

logger.debug("sign_content=%r", sign_content)

%r 能暴露一些不易察觉的问题:

  • 使用了 \r\n,但协议要求 \n。
  • 多出一个空格或换行。
  • URI 末尾多了 /。
  • 请求体前后有空白字符。
  • timestamp 或 nonce 与请求头中的值不一致。

只打印”签名成功”或签名结果,无法帮助定位问题。完整待签名字符串及其字节摘要更有排查价值。

确保签名和发送使用同一份请求体

这是手写 V3 调用时很常见的问题。

下面两段 JSON 的业务含义相同,字节内容却不同:

{"out_trade_no":"202609090001","total_amount":"0.01"}
{
  "out_trade_no": "202609090001",
  "total_amount": "0.01"
}

如果第一段参与签名,实际发送的却是第二段,支付宝验签一定会失败。字段顺序、空格、换行、中文转义方式或金额的数据类型发生变化,也会产生相同的问题。

请求体应只序列化一次,并让签名和 HTTP 请求共用同一个字符串:

import json

biz_content = {
    "out_trade_no": "202609090001",
    "total_amount": "0.01",
    "subject": "测试订单",
    "product_code": "QUICK_MSECURITY_PAY",
}

body = json.dumps(
    biz_content,
    ensure_ascii=False,
    separators=(",", ":"),
)

body_bytes = body.encode("utf-8")

发送请求时使用 data=body_bytes。签名完成后,不要再通过 json=biz_content 让 HTTP 客户端重新序列化:

response = requests.post(
    url,
    data=body_bytes,
    headers=headers,
    timeout=30,
)

以下写法有风险:

# body 参与了签名,但 json=biz_content 会再次序列化
response = requests.post(
    url,
    json=biz_content,
    headers=headers,
)

即使两次序列化目前碰巧得到相同结果,也不应依赖这种行为。

检查签名算法和编码过程

请求签名通常使用 SHA256withRSA,即 RSA PKCS#1 v1.5 加 SHA-256。不要误用:

  • RSA-PSS
  • SHA-1
  • 对签名内容的十六进制摘要再次签名
  • 对 Base64 文本签名,而不是对原始待签名字节签名
  • 错误的字符编码

使用 cryptography 的基本签名示例如下:

import base64

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding


def sign_sha256withrsa(private_key_pem: bytes, sign_content: str) -> str:
    private_key = serialization.load_pem_private_key(
        private_key_pem,
        password=None,
    )

    signature = private_key.sign(
        sign_content.encode("utf-8"),
        padding.PKCS1v15(),
        hashes.SHA256(),
    )

    return base64.b64encode(signature).decode("ascii")

需要注意:

  1. sign_content 应使用 UTF-8 编码。
  2. Base64 结果不能包含额外换行。
  3. 写入 Authorization 请求头时,应按照 V3 协议正确转义。
  4. 不要擅自把普通 Base64 改成 Base64 URL-safe 编码。
  5. 不要对签名值重复进行 URL 编码。

手动拼接请求头时,+、/、= 等字符很容易被错误处理。优先使用支付宝当前维护的官方 SDK 生成认证头。如果必须自行实现,应对照官方协议检查线上最终发送的请求头,而不是只检查编码前的签名变量。

检查普通公钥模式与证书模式

这两种模式不能混用。

普通公钥模式通常需要:

  • 应用私钥
  • 支付宝公钥
  • 平台中配置的应用公钥

证书模式通常还需要:

  • 应用公钥证书
  • 支付宝公钥证书
  • 支付宝根证书
  • 应用公钥证书序列号
  • 支付宝根证书序列号

如果应用在开放平台启用了证书模式,请求认证信息中的证书序列号必须根据对应证书正确计算。常见错误有:

  • 使用普通应用公钥生成序列号。
  • 加载了其他应用的证书。
  • 证书已经更新,服务仍在使用旧私钥或旧证书。
  • 请求携带了 app_cert_sn,但它与当前应用证书不对应。
  • 在普通公钥模式下错误加入证书模式参数。

本地使用私钥和”从同一文件夹读取的公钥”成功验签,无法排除这些问题。

记录最终请求,而不是业务对象

可以临时增加诊断日志,至少记录以下内容:

import hashlib

logger.info("method=%s", method)
logger.info("request_uri=%s", request_uri)
logger.info("app_id=%s", app_id)
logger.info("timestamp=%s", timestamp)
logger.info("nonce=%s", nonce)
logger.info("body_repr=%r", body)
logger.info(
    "body_sha256=%s",
    hashlib.sha256(body.encode("utf-8")).hexdigest(),
)
logger.info("sign_content_repr=%r", sign_content)
logger.info(
    "sign_content_sha256=%s",
    hashlib.sha256(sign_content.encode("utf-8")).hexdigest(),
)

还要在真正发送请求的位置确认:

  • URL 中没有多余空格。题目展示的 URL 两侧存在空格,代码中必须去除。
  • Content-Type 符合接口要求,通常为 application/json。
  • Authorization 是最终发送的请求头,没有在生成后被中间件覆盖。
  • timestamp、nonce 和 app_id 与生成待签名字符串时完全一致。
  • 请求体长度和摘要在签名前后没有变化。

不要把私钥、完整签名、付款敏感信息或用户隐私写入生产日志。

排除 HTTP 客户端和中间层改写

如果代码中的数据都正确,可以捕获最终的出站请求进行比较。重点检查:

  • requests、httpx 或 SDK 是否重新编码了 JSON。
  • Flask 扩展是否包装或替换了 HTTP 客户端。
  • 代理是否修改了请求路径。
  • 网关是否解压、压缩或重写了请求体。
  • 请求重试时是否重新生成了时间戳或 nonce,却继续使用旧签名。
  • Header 值是否因换行、非 ASCII 字符或长度限制而被截断。

使用 requests 时,可以先构造 PreparedRequest,再查看真正准备发送的内容:

import requests

request = requests.Request(
    method="POST",
    url="https://openapi.alipay.com/v3/alipay/trade/app/pay",
    data=body_bytes,
    headers=headers,
)

prepared = request.prepare()

print(prepared.method)
print(prepared.path_url)
print(repr(prepared.body))
print(dict(prepared.headers))

with requests.Session() as session:
    response = session.send(prepared, timeout=30)

调试输出中应隐藏 Authorization 的完整签名和其他敏感数据。

检查 Python 3.13 兼容性

Python 3.13.5 和 Flask 3 本身通常不会改变 RSA 签名规则,但相关 SDK、加密库或 HTTP 库未必已经在该运行环境中完成兼容性验证。

先确认版本信息:

python --version
python -m pip show cryptography requests httpx
python -m pip show alipay-sdk-python

实际包名以项目使用的依赖为准。如果使用第三方支付宝 SDK,还要确认它是否真正支持 V3 接口。不能仅凭包名中含有 alipay,就认定它符合当前官方协议。

可以用一个受支持的 Python 版本建立最小环境进行对照测试。如果相同配置和请求体在另一个版本中能够成功,再检查 SDK 与加密依赖的兼容性。如果仍然失败,问题更可能出在应用配置或待签名内容上。

推荐的最小化排查顺序

  1. 重新确认网关、app_id 和运行时加载的私钥。
  2. 从私钥导出公钥,与当前应用实际配置的公钥比较。
  3. 确认当前使用的是普通公钥模式还是证书模式。
  4. 固定一份请求体字符串,只序列化一次。
  5. 打印 method、URI、timestamp、nonce 和待签名字符串的 %r。
  6. 使用同一份 body_bytes 完成签名和发送。
  7. 检查最终的 PreparedRequest,确认请求头和请求体没有被修改。
  8. 使用官方 SDK 或官方示例,对同一组业务参数发起请求作为对照。
  9. 如果仍然失败,保留支付宝返回的错误码、请求 ID、调用时间和应用 ID,通过支付宝开放平台的技术支持渠道查询服务端验签日志。

应优先检查两点:签名使用的请求体是否与实际发送的请求体完全一致,以及私钥对应的公钥是否配置在当前请求所用的 app_id 下。

备注:内容仅供参考。