支付系统开发(三):对接微信支付与支付宝

上一篇完成了统一支付网关的设计,这篇进入真正的"脏活"——对接微信支付V3和支付宝开放平台。两套API风格迥异,踩坑不少,记录下来。

统一支付接口抽象

在对接具体渠道之前,先把抽象层定好。不管微信还是支付宝,对上层业务来说只有一个接口:

public interface PayChannel {
    /**
     * 统一下单
     */
    UnifiedOrderResult unifiedOrder(UnifiedOrderRequest request);

    /**
     * 查询订单
     */
    OrderQueryResult queryOrder(String outTradeNo);

    /**
     * 关闭订单
     */
    void closeOrder(String outTradeNo);

    /**
     * 处理异步回调
     */
    CallbackResult handleCallback(HttpServletRequest request);

    /**
     * 退款
     */
    RefundResult refund(RefundRequest request);
}

UnifiedOrderRequest 包含业务订单号、金额(分)、商品描述、回调URL等通用字段。每个渠道的实现类负责把通用参数转成各自的API格式。

微信支付V3 API对接

微信支付V3相比V2有了质的提升——用JSON替代了XML,签名方式从MD5换成了RSA。但复杂度也上来了。

签名机制

V3的签名流程:

  1. 构造签名串:HTTP方法\nURL\n时间戳\n随机串\n请求体\n
  2. 用商户API私钥(从证书文件加载)做SHA256withRSA签名
  3. 将签名放到Authorization头
public class WechatPaySigner {
    private final PrivateKey merchantPrivateKey;
    private final String merchantSerialNo;
    private final String mchId;

    public String sign(String method, String url, String body) {
        long timestamp = System.currentTimeMillis() / 1000;
        String nonceStr = UUID.randomUUID().toString().replace("-", "");

        // 构造签名串
        String signStr = String.join("\n",
            method, url, String.valueOf(timestamp), nonceStr, body, "");

        // RSA签名
        Signature signature = Signature.getInstance("SHA256withRSA");
        signature.initSign(merchantPrivateKey);
        signature.update(signStr.getBytes(StandardCharsets.UTF_8));
        String sign = Base64.getEncoder().encodeToString(signature.sign());

        // 组装Authorization
        return String.format(
            "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\","
            + "timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\"",
            mchId, nonceStr, timestamp, merchantSerialNo, sign);
    }
}

证书管理

微信支付V3需要管理两套证书:

  • 商户API证书:用于请求签名,从商户平台下载的apiclient_key.pem
  • 平台证书:用于验证回调签名,需要通过API定期下载

平台证书会轮换,所以必须做自动更新。我用了一个定时任务每12小时拉取一次:

@Scheduled(fixedRate = 12 * 3600 * 1000)
public void refreshPlatformCert() {
    // GET /v3/certificates
    // 响应中的证书用APIv3密钥解密(AES-256-GCM)
    String certPem = decryptAesGcm(encryptCertificate, apiV3Key);
    this.platformCert = loadX509Cert(certPem);
    log.info("微信平台证书刷新成功, serial={}", platformCert.getSerialNumber());
}

下单实现

JSAPI下单(小程序/公众号场景):

@Override
public UnifiedOrderResult unifiedOrder(UnifiedOrderRequest req) {
    Map<String, Object> body = new HashMap<>();
    body.put("appid", config.getAppId());
    body.put("mchid", config.getMchId());
    body.put("description", req.getDescription());
    body.put("out_trade_no", req.getOutTradeNo());
    body.put("notify_url", config.getNotifyUrl());
    body.put("amount", Map.of("total", req.getAmountInFen(), "currency", "CNY"));
    body.put("payer", Map.of("openid", req.getOpenId()));

    String jsonBody = objectMapper.writeValueAsString(body);
    String auth = signer.sign("POST", "/v3/pay/transactions/jsapi", jsonBody);

    HttpResponse resp = httpClient.post(
        "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi",
        jsonBody, Map.of("Authorization", auth));

    // 响应中拿到 prepay_id
    String prepayId = parseJson(resp.body()).getString("prepay_id");

    // 组装前端调起支付需要的参数
    return buildJsapiPayParams(prepayId);
}

支付宝开放平台对接

支付宝的开发体验比微信好一些,主要是因为有官方SDK,而且文档相对清晰。

RSA2签名

支付宝用RSA2(SHA256WithRSA)签名,流程和微信类似但细节不同:

public class AlipayChannelImpl implements PayChannel {
    private final AlipayClient alipayClient;

    public AlipayChannelImpl(AlipayConfig config) {
        this.alipayClient = new DefaultAlipayClient(
            "https://openapi.alipay.com/gateway.do",
            config.getAppId(),
            config.getPrivateKey(),    // 应用私钥
            "json", "UTF-8",
            config.getAlipayPublicKey(), // 支付宝公钥
            "RSA2");
    }

    @Override
    public UnifiedOrderResult unifiedOrder(UnifiedOrderRequest req) {
        AlipayTradeCreateRequest request = new AlipayTradeCreateRequest();
        request.setNotifyUrl(config.getNotifyUrl());

        Map<String, Object> bizContent = new HashMap<>();
        bizContent.put("out_trade_no", req.getOutTradeNo());
        bizContent.put("total_amount", formatYuan(req.getAmountInFen()));
        bizContent.put("subject", req.getDescription());
        bizContent.put("buyer_id", req.getBuyerId());
        request.setBizContent(objectMapper.writeValueAsString(bizContent));

        AlipayTradeCreateResponse resp = alipayClient.execute(request);
        if (!resp.isSuccess()) {
            throw new PayException("支付宝下单失败: " + resp.getSubMsg());
        }
        return UnifiedOrderResult.ofAlipay(resp.getTradeNo());
    }
}

支付宝的金额单位是元(字符串),微信是分(整数),这个差异在统一接口层就要处理好,否则100块钱变成100分=1块钱,客诉电话直接打爆。

支付回调处理

回调处理是支付系统最容易出bug的地方。核心原则:幂等、幂等、还是幂等

@PostMapping("/callback/wechat")
public String wechatCallback(HttpServletRequest request) {
    try {
        // 1. 验签 - 用微信平台证书验证
        if (!wechatVerifier.verify(request)) {
            log.warn("微信回调验签失败");
            return failResponse();
        }

        // 2. 解密通知体(AES-256-GCM)
        String plainText = decryptNotification(request);
        WechatPayNotification notification = parse(plainText);

        // 3. 幂等处理 - 用订单号做幂等键
        String outTradeNo = notification.getOutTradeNo();
        boolean processed = orderService.processPaymentResult(
            outTradeNo,
            notification.getTradeState(),
            notification.getTransactionId()
        );

        if (processed) {
            log.info("订单 {} 支付回调处理成功", outTradeNo);
        }
        // 不管是否已处理过,都返回成功(幂等)
        return successResponse();

    } catch (Exception e) {
        log.error("微信回调处理异常", e);
        return failResponse(); // 返回失败让微信重试
    }
}

processPaymentResult 内部用数据库乐观锁确保幂等:

public boolean processPaymentResult(String outTradeNo, String state, String transId) {
    // UPDATE orders SET status='PAID', channel_trade_no=?
    // WHERE out_trade_no=? AND status='PENDING'
    int rows = orderMapper.updatePayResult(outTradeNo, state, transId);
    if (rows == 0) {
        // 已经处理过了,幂等返回
        return false;
    }
    // 触发后续流程:发货通知、积分发放等
    eventPublisher.publish(new OrderPaidEvent(outTradeNo));
    return true;
}

掉单处理

网络不靠谱,回调可能丢。必须有主动查询的兜底机制:

  1. 定时轮询:每分钟扫描超过5分钟未收到回调的PENDING订单,主动去微信/支付宝查询
  2. 延迟队列:下单后往RocketMQ发一条延迟消息(延迟5分钟),消费时检查订单状态
  3. 对账:每天凌晨下载微信和支付宝的账单文件,和本地订单逐笔核对
@Scheduled(fixedRate = 60_000)
public void pollPendingOrders() {
    List<Order> pendingOrders = orderMapper.selectPendingBefore(
        LocalDateTime.now().minusMinutes(5));

    for (Order order : pendingOrders) {
        PayChannel channel = channelFactory.getChannel(order.getChannelType());
        OrderQueryResult result = channel.queryOrder(order.getOutTradeNo());

        if ("SUCCESS".equals(result.getTradeState())) {
            processPaymentResult(order.getOutTradeNo(),
                result.getTradeState(), result.getTransactionId());
        } else if ("CLOSED".equals(result.getTradeState())) {
            orderMapper.updateStatus(order.getOutTradeNo(), "CLOSED");
        }
        // NOTPAY/USERPAYING 状态继续等待
    }
}

踩过的坑

  1. 微信金额是分,支付宝金额是元:前面提过了,这个坑掉进去一次就够了
  2. 微信V3的时间戳是秒级,不是毫秒:签名验证怎么都不过,查了半天
  3. 支付宝异步通知要返回"success"这7个字符:不能有多余的空格或换行
  4. 微信的商户订单号不能重复使用:即使前一个订单已关闭,同一个out_trade_no也不能再用来下单
  5. 回调URL必须是HTTPS:本地开发用ngrok穿透

下一篇进入商城系统的开发。