PAYATHON 2026

支付宝电脑端扫码支付使用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 ID
  • out_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 返回的以下内容:

  • code
  • msg
  • sub_code
  • sub_msg
  • body

还需要逐项检查:

  1. 页面响应是否明确声明 text/html;charset=UTF-8。
  2. 源代码、配置文件和数据库连接是否使用 UTF-8。
  3. subject 在写入 bizContent 前是否已经乱码。
  4. Web 服务器、反向代理或过滤器是否修改了响应编码。
  5. 公钥、私钥和 sign_type 是否匹配。
  6. properties.getCharset() 在所有位置是否都确实为 UTF-8。

支付请求、HTML 响应、Servlet 请求解析和验签都应统一使用 UTF-8。回调参数解析完成后,要保留原值参与验签;验签使用支付宝公钥;处理成功后,以纯文本返回 success。

备注:内容仅供参考。