PAYATHON 2026

Go SDK 对接支付宝:H5正常但iOS客户端报40002签名错误

支付老李

结论

H5 支付也会校验签名。该 H5 请求能够正常进入支付流程,说明支付宝网关认可了请求签名,并非 H5 场景跳过了验签。

RSA2 签名具有确定性。使用相同私钥,对完全相同的待签名字节执行相同算法,结果一定相同。如果 SDK 生成的签名与官方工具不同,通常是因为两边使用的待签名字符串、私钥、字符编码或签名算法至少有一项不同。

iOS 返回:

{
  "code": "40002",
  "msg": "Invalid Arguments",
  "sub_code": "isv.invalid-signature"
}

这类问题通常不是 Go 的 RSA 算法出错,而是 App 支付订单串在签名完成后发生了变化。常见情况包括:

  • 服务端生成的是 H5 支付参数,不是 App 支付参数;
  • iOS 端重新解析、排序或拼接了订单串;
  • 参数被重复 URL 编码或提前 URL 解码;
  • + 被转换为空格,或 %2B、%2F、%3D 被错误处理;
  • 签名时使用的参数集合与最终发送的参数集合不一致;
  • 私钥对应的应用公钥没有配置到当前 app_id;
  • 应用配置了证书模式,但服务端仍按普通公钥模式初始化;
  • biz_content 在签名后被重新序列化。

正确的处理方式是由服务端完整生成 App 支付的 orderInfo,iOS 端将其原样交给支付宝客户端 SDK,不再拆解、补充参数或重新编码。

H5 签名为什么会与官方工具不同

不能直接用浏览器中的完整 URL 与官方工具的签名结果比较。浏览器地址是经过 URL 编码的传输形式,而支付宝签名使用的是编码前的规范化参数串。

以请求中的参数为例:

timestamp=2022-12-05+15%3A58%3A21

URL 解码后是:

timestamp=2022-12-05 15:58:21

签名值中的这些字符也会被编码:

+  -> %2B
/  -> %2F
=  -> %3D

如果将 URL 编码后的参数直接粘贴到签名工具中,得到的签名自然会不同。

生成待签名字符串时,一般应遵循以下规则:

  1. 排除 sign 参数;
  2. 按参数名的 ASCII 顺序排序;
  3. 按 key=value 形式使用 & 连接;
  4. 使用参数值的原文签名,不对 URL 编码后的值签名;
  5. 空值是否参与签名,必须与当前 SDK 的实现一致;
  6. 使用 UTF-8 字节和应用私钥进行 RSA2 签名。

待签名串在概念上类似:

app_id=2021003156606852&biz_content={"subject":"北京故宫","out_trade_no":"167022708965000010000150142","total_amount":"0.01","product_code":"QUICK_WAP_WAY"}&charset=utf-8&format=JSON&method=alipay.trade.wap.pay&sign_type=RSA2&timestamp=2022-12-05 15:58:21&version=1.0

真正参与签名的内容必须以 SDK 实际生成的字符串为准。JSON 的字段顺序、空格、转义形式和大小写都可能改变最终字节。

例如,SDK 签名时的 biz_content 是:

{"subject":"北京故宫","out_trade_no":"...","total_amount":"0.01"}

签名完成后却变成:

{"total_amount":"0.01","out_trade_no":"...","subject":"北京故宫"}

这两个 JSON 的业务含义虽然相同,但原始字节不同,签名仍会失效。

App 支付应使用专用下单方法

H5 支付和 App 支付使用的不是同一个接口。

H5 支付使用:

method=alipay.trade.wap.pay
product_code=QUICK_WAP_WAY

App 支付应使用:

method=alipay.trade.app.pay
product_code=QUICK_MSECURITY_PAY

不能将 alipay.trade.wap.pay 生成的 URL 直接交给 iOS 支付 SDK,也不能只替换 method 或 product_code。只要参数发生变化,就必须根据最终参数重新签名。

服务端最终交给 iOS 的内容应是一条完整的订单串,大致如下:

app_id=2021003156606852&biz_content=%7B...%7D&charset=utf-8&format=JSON&method=alipay.trade.app.pay&notify_url=https%3A%2F%2Fexample.com%2Falipay%2Fnotify&sign_type=RSA2&timestamp=2022-12-05+15%3A58%3A21&version=1.0&sign=...

具体字段顺序和编码形式应由 SDK 生成,不建议手工拼接。

不同版本的 smartwalle/alipay 可能使用不同的 API 名称和返回类型,应以项目实际锁定版本的文档为准。无论具体调用形式如何,业务参数至少要明确设置为 App 支付场景:

// 结构体和方法名需以项目实际使用的 smartwalle/alipay 版本为准。
pay.Subject = "北京故宫"
pay.OutTradeNo = "167022708965000010000150142"
pay.TotalAmount = "0.01"
pay.ProductCode = "QUICK_MSECURITY_PAY"

// 使用 alipay.trade.app.pay 对应的方法生成完整 orderInfo。
// orderInfo 必须由服务端直接返回给 iOS,客户端不要重新拼接。

iOS 端不要处理订单串

推荐使用下面的数据流:

Go 服务端生成完整 orderInfo
        ↓
通过 HTTPS 作为一个字符串返回
        ↓
iOS 读取该字符串
        ↓
原样传给支付宝 SDK

iOS 端不应执行以下操作:

把订单串转换成 Dictionary
重新排序参数
重新拼接 key=value
对整个订单串再做 percent-encoding
先 percent-decoding 再传给支付宝 SDK
把 + 替换为空格
修改 biz_content
自行追加 sign_type、method 或 app_id

如果接口通过 JSON 返回订单串,应由 JSON 序列化器处理字符串转义,不要手动替换反斜杠或百分号。例如:

{
  "orderInfo": "app_id=...&biz_content=...&sign=..."
}

客户端取出 orderInfo 的字符串值后,直接调用支付宝 SDK。

按这个顺序排查

1. 核对应用和密钥

确认以下内容都属于同一个支付宝应用:

  • 请求中的 app_id;
  • Go 服务端使用的应用私钥;
  • 支付宝开放平台中配置的应用公钥或应用公钥证书;
  • 当前运行环境使用的网关地址。

生产环境和沙箱环境的应用、密钥不能混用。

还需要分清两类公钥:

  • 应用公钥:与服务端的应用私钥配对,支付宝用它验证商户请求;
  • 支付宝公钥:商户用它验证支付宝返回的数据。

排查请求签名时,应使用与应用私钥匹配的应用公钥进行本地验签,不能用支付宝公钥验证自己生成的请求签名。

2. 打印真正的待签名字符串

不要只打印最终 URL。应在执行签名前记录:

sign_content=<实际参与 RSA2 签名的完整字符串>

同时记录:

app_id
method
sign_type
biz_content 原始字符串
最终 orderInfo

日志中不能暴露私钥,订单号等业务信息也要按照生产环境规范脱敏。

如果 SDK 没有直接暴露待签名字符串,可以在测试环境中暂时为签名入口增加调试日志,或使用 SDK 提供的调试能力。

3. 用完全相同的内容测试官方工具

将服务端日志中的 sign_content 原样复制到官方工具中:

  • 不要复制完整请求 URL;
  • 不要复制 URL 编码后的字符串;
  • 不要自行格式化 JSON;
  • 不要添加换行;
  • 使用同一把应用私钥;
  • 选择 RSA2,也就是 SHA-256 with RSA。

只要输入字节、私钥和算法完全相同,签名结果就应相同。

4. 对比服务端输出和 iOS 实际入参

分别记录:

服务端返回的 orderInfo
iOS 调用支付宝 SDK 前的 orderInfo

对两者进行逐字节比较,不要只靠肉眼检查。需要重点核对:

+ 与空格
%2B 与 +
%2F 与 /
%3D 与 =
%25 与 %
JSON 中的 \ 和 "
参数是否缺失
参数是否重复

如果两端字符串不同,问题就出在接口传输、JSON 解析或 iOS 端的加工过程中。

5. 检查是否发生重复编码

正确顺序通常是:

原始参数
→ 生成待签名字符串
→ RSA2 签名
→ 对参数值和 sign 做一次 URL 编码
→ 返回完整 orderInfo

常见的错误是对完整订单串再次进行 URL 编码:

完整 orderInfo
→ 再对整个字符串 URL 编码一次

这会把 % 再编码成 %25,例如:

%2F -> %252F

这样一来,支付宝收到的参数就不再是服务端签名时使用的内容。

6. 检查空参数

示例 H5 请求中存在以下空参数:

notify_url=
return_url=
product_code=

空参数是否进入待签名串,取决于 SDK 的规范化实现。不能在 SDK 签名时忽略空值,却在官方工具中使用另一套逻辑将空值加入待签名内容。

实际调用时,未使用的可选参数最好不要传入,必填业务参数则应提供合法值。App 支付的 product_code 应设置为:

QUICK_MSECURITY_PAY

7. 检查证书模式

如果支付宝应用启用了证书模式,初始化 SDK 时通常还需要正确加载:

  • 应用公钥证书;
  • 支付宝公钥证书;
  • 支付宝根证书。

证书模式和普通公钥模式不能混合配置,也不能只完成一部分配置。具体加载接口取决于当前使用的 smartwalle/alipay 版本。

使用 Go 标准库做本地验签

下面的代码可以验证私钥生成的签名是否能由对应的应用公钥通过校验。它不负责构造支付宝参数,只用于判断密钥与签名内容是否一致。

package main

import (
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/pem"
	"errors"
)

func verifyRSA2(signContent, signatureBase64 string, publicKeyPEM []byte) error {
	block, _ := pem.Decode(publicKeyPEM)
	if block == nil {
		return errors.New("invalid public key PEM")
	}

	var publicKey *rsa.PublicKey

	if key, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
		rsaKey, ok := key.(*rsa.PublicKey)
		if !ok {
			return errors.New("public key is not RSA")
		}
		publicKey = rsaKey
	} else {
		rsaKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
		if err != nil {
			return err
		}
		publicKey = rsaKey
	}

	signature, err := base64.StdEncoding.DecodeString(signatureBase64)
	if err != nil {
		return err
	}

	digest := sha256.Sum256([]byte(signContent))

	return rsa.VerifyPKCS1v15(
		publicKey,
		crypto.SHA256,
		digest[:],
		signature,
	)
}

调用时,signContent 必须是签名前的原始规范字符串。signatureBase64 必须先从 URL 参数中正确解码,publicKeyPEM 则应使用与应用私钥匹配的应用公钥。

如果本地验签失败,说明待签名字符串、签名值或密钥不匹配。如果本地验签成功,但支付宝仍返回 isv.invalid-signature,应继续检查:

  • 开放平台配置的应用公钥是否与本地公钥相同;
  • 请求中的 app_id 是否正确;
  • 证书模式是否一致;
  • 最终发送的参数是否与本地验签时完全相同。

目前还缺少的关键信息

原始问题没有提供 App 支付的完整订单串,仅根据 H5 URL 无法锁定唯一原因。要继续定位,至少需要以下三份内容:

1. Go 服务端生成 App 支付签名时的 sign_content
2. Go 服务端返回的完整 orderInfo
3. iOS 调用支付宝 SDK 前收到的完整 orderInfo

先逐字节比较第 2 项和第 3 项,确认两者完全一致。然后使用第 1 项和应用公钥进行本地验签,通常就能判断问题出在密钥配置、服务端参数构造,还是客户端对订单串的二次处理。

备注:内容仅供参考。