支付宝电脑端扫码支付使用GBK后异步回调验签失败
结论
subject 中包含中文,本身不会导致支付宝支付页面报错。更常见的原因不是支付宝“不支持 UTF-8”,而是以下环节使用了不同的字符集:
DefaultAlipayClient生成请求时使用的字符集- 应用服务器输出支付页面时的响应字符集
- Servlet 容器解析异步通知时的请求字符集
AlipaySignature.rsaCheckV1验签时传入的字符集
这些环节应统一使用 UTF-8。不要改用 GBK,也不要在回调参数已经解析成 Java String 后,再通过 getBytes() 和 new String() 二次转码。
异步通知验签还必须使用“支付宝公钥”,不能误用“应用公钥”。验签成功后,HTTP 响应正文应直接返回字符串 success,不能返回 JSON 或 Map。
为什么改成 GBK 后支付成功,回调却验签失败
支付宝签名针对的是按照约定字符集编码后的参数内容。接收参数后,只要其中任意字符发生变化,重新拼接的待验签字符串就会与支付宝签名时的内容不同,验签自然无法通过。
英文字符在 UTF-8、GBK 等常见字符集中通常采用相同的单字节表示,所以 subject 使用英文时不容易暴露问题。中文在不同字符集中的字节表示并不相同,一旦发生错误解码或二次转码,问题就会显现。
支付页面在 GBK 下看似正常,通常只是因为 GBK 恰好抵消了页面输出阶段的编码错误。这不能说明整个接口都应该使用 GBK。如果异步通知仍按另一套字符集解析,中文参数依然可能被破坏。
下面这段代码不能真正解决乱码:
valueStr = new String(
valueStr.getBytes(properties.getCharset()),
properties.getCharset()
);
源字符集和目标字符集相同时,这通常只是一次没有意义的编码和解码。如果 valueStr 已经乱码,这种处理也无法还原原始内容。实际使用的字符集如果与代码中填写的字符集不一致,还可能进一步改变待验签参数。
支付请求统一使用 UTF-8
可以定义一个统一的常量,并确认配置文件中的字符集也是 UTF-8:
private static final String CHARSET = "UTF-8";
private String getAlipayPage(Order order) throws AlipayApiException {
AlipayClient alipayClient = new DefaultAlipayClient(
properties.getGatewayUrl(),
properties.getAppId(),
properties.getAppPrivateKey(),
properties.getFormat(),
CHARSET,
properties.getAlipayPublicKey(),
properties.getSignType()
);
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
request.setNotifyUrl(properties.getNotifyUrl());
request.setReturnUrl(properties.getReturnUrl());
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", order.getZforderId());
bizContent.put("total_amount", order.getAmount());
bizContent.put("subject", "测试");
bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY");
request.setBizContent(bizContent.toString());
AlipayTradePagePayResponse response = alipayClient.pageExecute(request, "POST");
if (!response.isSuccess()) {
throw new IllegalStateException(
"生成支付宝支付页面失败,code=" + response.getCode()
+ ", msg=" + response.getMsg()
+ ", subCode=" + response.getSubCode()
+ ", subMsg=" + response.getSubMsg()
);
}
return response.getBody();
}
构造方法中的公钥参数应传入支付宝公钥。配置名称最好改成 alipayPublicKey,以免与应用公钥混淆。
正确输出支付页面
pageExecute() 返回的是一段 HTML 表单。即使 SDK 使用 UTF-8,如果服务器采用其他编码输出这段 HTML,中文仍可能显示异常,页面参数也可能出错。
可以通过 HttpServletResponse 直接输出,并明确指定 UTF-8:
@GetMapping("/alipay/page")
public void alipayPage(HttpServletResponse response, Order order)
throws IOException, AlipayApiException {
String html = getAlipayPage(order);
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write(html);
response.getWriter().flush();
}
如果通过 Spring MVC 直接返回 String,普通字符串可能被当作视图名称。此时可以使用 @ResponseBody,并保证响应的 Content-Type 是 text/html;charset=UTF-8。直接写入 HttpServletResponse 通常更清楚。
回调参数必须先按 UTF-8 解析
必须在第一次调用 getParameter() 或 getParameterMap() 之前设置字符集:
request.setCharacterEncoding("UTF-8");
也可以在 Spring Boot 中统一配置字符编码:
server.servlet.encoding.charset=UTF-8
server.servlet.encoding.enabled=true
server.servlet.encoding.force=true
另一种方式是配置 CharacterEncodingFilter。无论采用哪种方案,都要保证 Servlet 容器第一次解析请求参数时就使用正确的字符集。参数解析完成后再修复乱码,通常已经来不及了。
异步回调参考实现
回调接口应直接返回纯文本 success 或 fail:
@PostMapping(value = "/alipay/notify", produces = "text/plain;charset=UTF-8")
@ResponseBody
public String alipayNotify(HttpServletRequest request) {
final String charset = "UTF-8";
try {
request.setCharacterEncoding(charset);
Map<String, String> params = new HashMap<>();
Map<String, String[]> requestParams = request.getParameterMap();
for (Map.Entry<String, String[]> entry : requestParams.entrySet()) {
String[] values = entry.getValue();
String value = String.join(",", values);
params.put(entry.getKey(), value);
}
boolean signVerified = AlipaySignature.rsaCheckV1(
params,
properties.getAlipayPublicKey(),
charset,
properties.getSignType()
);
if (!signVerified) {
return "fail";
}
String outTradeNo = params.get("out_trade_no");
String tradeStatus = params.get("trade_status");
// 还应校验 app_id、seller_id、total_amount 等业务字段。
if (!properties.getAppId().equals(params.get("app_id"))) {
return "fail";
}
if ("TRADE_SUCCESS".equals(tradeStatus)
|| "TRADE_FINISHED".equals(tradeStatus)) {
// 根据 outTradeNo 查询本地订单。
// 核对通知金额、收款方等信息后,以幂等方式更新订单状态。
handlePaySuccess(outTradeNo, params);
}
return "success";
} catch (Exception e) {
// 记录异常时不要泄露私钥、完整签名串等敏感信息。
return "fail";
}
}
其中:
properties.getAlipayPublicKey()
必须返回支付宝公钥,而不是应用公钥。properties.getSignType() 也必须与支付宝应用配置一致,例如双方都使用 RSA2。
验签前不要对参数做以下处理:
- 修改中文内容
- 对参数值调用
trim() - 再次进行 URL 解码
- 随意把
+替换为空格 - 把空字符串改成
null - 使用错误的字符集重新构造字符串
- 向待验签参数集合中添加自定义字段
不要返回 Map
原代码向 paramsMap 添加 status 后返回:
paramsMap.put("status", "success");
return paramsMap;
Spring MVC 通常会将其序列化为类似下面的 JSON:
{"status":"success"}
支付宝异步通知要求响应正文是纯文本:
success
如果没有收到这个结果,支付宝可能会把本次通知视为失败并继续重试。因此,回调方法应返回 String,响应类型使用 text/plain。
验签成功后还需要做业务校验
验签成功只能说明通知内容确实由对应的支付宝私钥签发,不能代替订单校验。至少要检查以下内容:
app_id是否等于当前应用的 App IDout_trade_no是否存在,并且属于当前商户total_amount是否与本地订单金额一致seller_id或收款账号是否为预期收款方trade_status是否属于需要处理的成功状态- 同一订单是否已经处理过
比较金额时应使用 BigDecimal.compareTo(),不要使用 double,也不要要求字符串格式完全相同:
BigDecimal notifyAmount = new BigDecimal(params.get("total_amount"));
BigDecimal orderAmount = order.getAmount();
if (notifyAmount.compareTo(orderAmount) != 0) {
return "fail";
}
支付回调可能重复发送,所以订单更新必须具备幂等性。已经支付成功的订单再次收到相同通知时,应直接返回 success,不能重复扣减库存、发货或记账。
排查 UTF-8 页面报错的重点
统一使用 UTF-8 后,如果支付页面仍然报错,不要仅凭页面现象认定问题来自中文。应记录 SDK 返回的以下内容:
codemsgsub_codesub_msgbody
还需要逐项检查:
- 页面响应是否明确声明
text/html;charset=UTF-8。 - 源代码、配置文件和数据库连接是否使用 UTF-8。
subject在写入bizContent前是否已经乱码。- Web 服务器、反向代理或过滤器是否修改了响应编码。
- 公钥、私钥和
sign_type是否匹配。 properties.getCharset()在所有位置是否都确实为UTF-8。
支付请求、HTML 响应、Servlet 请求解析和验签都应统一使用 UTF-8。回调参数解析完成后,要保留原值参与验签;验签使用支付宝公钥;处理成功后,以纯文本返回 success。
备注:内容仅供参考。
