手机H5支付subject中文导致invalid-signature验签出错
结论
问题不在于 subject 只能包含两个中文字符,而在于签名和发送请求时使用了不同的字符编码或 URL 编码方式。支付宝实际收到的参数字节与本地签名所用内容不一致,因此验签失败。
建议全程统一使用 UTF-8,并通过官方 SDK 构造和提交手机网站支付请求。签名时保留参数原始值,签名完成后再对请求参数进行 URL 编码。不要提前编码、重复编码,也不要手工拼接含有中文的请求地址。
原因分析
验签比对的是参数内容对应的字节序列。同一段中文经过 GBK 和 UTF-8 编码后,得到的字节并不相同。例如:
中文
遇到以下任一情况,服务端重新计算的签名都可能与请求中的 sign 不一致:
- 本地使用
GBK签名,但 HTTP 客户端以UTF-8发送请求。 - 配置中填写了
utf8,而 SDK 或接口实际要求UTF-8。 subject先经过URLEncoder.encode(),随后编码结果中的%E4%...又参与了签名。- 完成签名后,整个请求地址又被统一进行 URL 编码。
- Web 框架、网关或代理对中文参数做了二次转码。
- 手工拼接待签名字符串时,参数排序、空值处理或特殊字符处理不符合接口规则。
subject按字节截断时处理错误,截断位置落在 UTF-8 多字节字符中间。
如果 subject 超过两个中文字符就失败,通常说明编码或截断逻辑有问题。限制标题长度只能掩盖错误,无法解决验签问题。
推荐解决方式
1. 全链路统一使用 UTF-8
应用配置、源代码文件、JSON 序列化、签名、HTTP 请求和响应解析应使用同一个字符集:
UTF-8
不要混用以下编码或依赖系统默认设置:
UTF-8
GBK
GB2312
系统默认编码
Java 代码中应明确指定字符集:
byte[] data = content.getBytes(StandardCharsets.UTF_8);
不要写成:
byte[] data = content.getBytes();
后一种写法依赖系统默认字符集,运行结果可能随服务器操作系统或 JVM 启动参数变化。
2. 优先使用官方 SDK
以支付宝开放平台常见的手机网站支付接口为例,可以把 subject 写入业务模型,让 SDK 负责参数序列化、签名和请求编码:
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeWapPayModel;
import com.alipay.api.request.AlipayTradeWapPayRequest;
AlipayClient alipayClient = new DefaultAlipayClient(
gatewayUrl,
appId,
appPrivateKey,
"json",
"UTF-8",
alipayPublicKey,
"RSA2"
);
AlipayTradeWapPayModel model = new AlipayTradeWapPayModel();
model.setOutTradeNo(outTradeNo);
model.setTotalAmount("0.01");
model.setSubject("中文商品测试");
model.setProductCode("QUICK_WAP_WAY");
model.setQuitUrl(quitUrl);
AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
request.setBizModel(model);
request.setReturnUrl(returnUrl);
request.setNotifyUrl(notifyUrl);
try {
String form = alipayClient.pageExecute(request).getBody();
// 将 form 原样返回给浏览器,不要再次 URL 编码或修改其中的字段。
} catch (AlipayApiException e) {
throw new IllegalStateException("创建手机网站支付请求失败", e);
}
示例中的接口类和模型适用于常见的 alipay.trade.wap.pay 调用方式。实际项目应以当前使用的 SDK 和接口定义为准,不要混用不同版本 SDK 的参数拼接逻辑。
3. 如果必须手工签名,先签名,再做 URL 编码
处理顺序如下:
- 准备原始参数。
- 排除
sign及接口规则要求排除的其他字段。 - 按接口要求排序,生成待签名字符串。
- 将待签名字符串转换为明确的
UTF-8字节并签名。 - 分别对需要写入 URL 或表单的参数值进行 URL 编码。
- 发送请求。
示意代码:
String subject = "中文商品测试";
// 原始值参与签名,不要先对 subject 调用 URLEncoder.encode()
Map<String, String> params = new HashMap<>();
params.put("app_id", appId);
params.put("charset", "UTF-8");
params.put("sign_type", "RSA2");
params.put("subject", subject);
String contentToSign = buildSignContent(params);
String sign = rsa2Sign(
contentToSign.getBytes(StandardCharsets.UTF_8),
appPrivateKey
);
params.put("sign", sign);
// 仅在生成最终 HTTP 请求时编码每个参数值
String query = params.entrySet().stream()
.map(entry -> urlEncode(entry.getKey()) + "=" + urlEncode(entry.getValue()))
.collect(Collectors.joining("&"));
URL 编码函数也应明确使用 UTF-8:
private static String urlEncode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
具体参与签名的字段、排序方式和空值处理规则,应严格遵循所调用接口的签名规范,不能仅根据这段示意代码自行推断。
排查方法
发送请求前,可以记录以下内容。私钥、完整签名和敏感业务数据必须脱敏:
charset
sign_type
URL 编码前的 subject
subject 的 UTF-8 字节或十六进制表示
最终待签名字符串
URL 编码后的请求参数
排查时重点确认:
- 待签名字符串中的
subject是否仍是原始中文。 charset在配置、签名参数和实际请求中是否一致。subject中是否出现%25E4...。其中%25往往说明原来的%E4...又被编码了一次。subject是否先变成乱码,然后才参与签名。- 请求经过反向代理、网关或公共参数过滤器时,参数是否被重新编码。
- 项目是否同时使用了 SDK 签名和自定义签名拦截器。
biz_content是否在签名后被重新序列化,导致空格、转义符或字段顺序发生变化。
还可以分别使用纯英文、一个中文字符、三个中文字符,以及包含 &、+、空格的标题进行测试。如果英文正常而中文失败,问题通常出在字符集转换或 URL 编码环节。如果特殊字符失败,应重点检查参数拼接和重复编码。
密钥配置也要确认
修正编码问题后,如果仍然出现 invalid-signature,再检查以下配置:
- 签名使用的是否为当前应用对应的应用私钥。
- 验签配置的是否为平台公钥,或证书模式要求的证书,而不是应用公钥。
- 是否混用了
RSA和RSA2。 - 沙箱环境与生产环境的应用、网关和密钥是否发生交叉使用。
- 私钥格式是否完整,复制过程中是否丢失字符或混入不可见字符。
密钥错误通常会使所有请求都无法通过验签。如果只有中文或较长的 subject 会失败,编码和请求拼接更值得优先排查。
注意事项
不要把 subject 限制为两个字符,也不要通过拼音或 Unicode 转义绕过验签。这些做法只会隐藏编码错误,还可能影响订单信息的正常展示。
如果项目目前使用手工签名,建议删除自定义的参数拼接、编码和签名代码,改用官方 SDK 生成支付表单。SDK 生成的结果返回浏览器前,不应再由模板引擎或过滤器修改。
备注:内容仅供参考。