分类:后端开发 · 作者:管理员 · 发布于 2026-09-28 · 阅读 0 · 标签:Spring Boot / 微信支付 / 回调幂等 / API安全 / 分布式锁

一、场景与痛点

在电商中台系统中,微信支付是目前最主流的在线支付方式。用户在前端发起支付后,微信支付服务端会异步向商户后台发送支付结果通知(即"回调")。这个看似简单的流程,实则暗藏多个技术陷阱:

  • 通知重试与幂等:微信支付回调最多会重试 9 次(间隔递增),如果接口不是幂等的,同一笔订单可能被重复处理多次,导致库存多扣、订单状态覆盖错误甚至资金损失。
  • 签名验证:回调接口暴露在公网,任何人都能伪造 POST 请求。必须严格验证微信签名,防止恶意请求修改订单状态。
  • 回调与前端跳转竞态:用户支付成功后浏览器同步跳转到"支付成功页",此时回调可能还没到达。前端不能依赖跳转参数做最终状态判断,必须以异步通知为准。
  • 通知安全与重放攻击:通知数据中包含商户订单号、支付金额等敏感信息,需要验签 + 防重放的组合保障。

本文以 Spring Boot 3.x + 微信支付 v3 API 为例,完整展示回调处理的安全设计与实现。

二、依赖准备

微信支付 v3 推荐使用官方 SDK wechatpay-apache-httpclient,它封装了签名生成、验签和 HTTP 请求等基础能力。

<!-- pom.xml -->
<dependency>
    <groupId>com.github.wechatpay-apiv3</groupId>
    <artifactId>wechatpay-apache-httpclient</artifactId>
    <version>0.4.10</version>
</dependency>

<!-- 需配合 Apache HttpClient 5.x -->
<dependency>
    <groupId>org.apache.httpcomponents.client5</groupId>
    <artifactId>httpclient5</artifactId>
</dependency>

同时需要准备好商户平台下载的证书序列号、API v3 密钥,以及通过 CertificateDownloader 下载的平台证书(用于验签)。

三、回调接收接口设计

微信支付以 POST 方式回调,Content-Type 为 application/json。我们把它设计为两个独立切面:准入校验(签名、时间戳、随机串) → 业务处理(幂等、订单更新)。

3.1 配置类

@Configuration
public class WechatPayConfig {

    @Value("${wechat.pay.mch-id}")
    private String mchId;

    @Value("${wechat.pay.api-v3-key}")
    private String apiV3Key;

    @Value("${wechat.pay.merchant-serial-no}")
    private String merchantSerialNo;

    @Value("${wechat.pay.private-key-path}")
    private String privateKeyPath;

    @Bean
    public HttpClient httpClient() throws Exception {
        // 加载商户私钥
        PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey(
                new FileInputStream(privateKeyPath));
        // 构建自动签名 HttpClient
        HttpClients httpClients = new HttpClients();
        return httpClients.createHttpClient(merchantPrivateKey,
                merchantSerialNo, mchId);
    }

    @Bean
    public AutoUpdateCertificatesVerifier verifier() throws Exception {
        // 自动更新平台证书(推荐)
        return new AutoUpdateCertificatesVerifier(
                httpClient(), apiV3Key.getBytes(StandardCharsets.UTF_8));
    }
}

3.2 回调 DTO

@Data
public class WechatPayCallbackRequest {
    private String id;               // 通知 ID
    private String createTime;       // 通知创建时间
    private String eventType;        // 事件类型(TRANSACTION.SUCCESS)
    private String resourceType;     // 资源类型(encrypt-resource)
    private Resource resource;       // 加密资源对象

    @Data
    public static class Resource {
        private String algorithm;    // 加密算法(AEAD_AES_256_GCM)
        private String ciphertext;   // 密文
        private String associatedData;
        private String nonce;
        private String originalType; // transaction
    }
}

@Getter
@Setter
public class PaymentCallbackResponse {
    private String code;    // SUCCESS / FAIL
    private String message;
}

3.3 核心回调处理

@RestController
@Slf4j
public class WechatPayCallbackController {

    @Autowired
    private AutoUpdateCertificatesVerifier verifier;

    @Autowired
    private OrderService orderService;

    @PostMapping("/api/payment/wx-notify")
    public ResponseEntity<PaymentCallbackResponse> handleNotify(
            HttpServletRequest request,
            @RequestHeader("Wechatpay-Signature") String signature,
            @RequestHeader("Wechatpay-Timestamp") String timestamp,
            @RequestHeader("Wechatpay-Nonce") String nonce,
            @RequestHeader("Wechatpay-Serial") String serial) {

        // ----------------- 第一步:防重放攻击 -----------------
        long now = System.currentTimeMillis() / 1000;
        long reqTs = Long.parseLong(timestamp);
        // 只接受 5 分钟内的通知(防止重放攻击)
        if (now - reqTs > 300) {
            log.warn("回调时间戳超限,视为重放攻击: {}", timestamp);
            return ResponseEntity.ok(
                    new PaymentCallbackResponse("FAIL", "timestamp expired"));
        }

        // ----------------- 第二步:签名验证 -----------------
        String body;
        try {
            body = request.getReader().lines()
                    .collect(Collectors.joining(System.lineSeparator()));
        } catch (IOException e) {
            log.error("读取回调 body 失败", e);
            return ResponseEntity.status(500).body(
                    new PaymentCallbackResponse("FAIL", "read body error"));
        }

        // 构建待验签字符串
        String signStr = timestamp + "\n" + nonce + "\n" + body + "\n";
        // 获取对应序列号的平台证书
        X509Certificate certificate = verifier.getCertificate(serial);
        if (certificate == null) {
            log.warn("未找到序列号 {} 对应的平台证书", serial);
            return ResponseEntity.ok(
                    new PaymentCallbackResponse("FAIL", "cert not found"));
        }
        // 执行验签
        boolean verified = verify(certificate, signStr, signature);
        if (!verified) {
            log.warn("微信回调签名验证失败, serial={}", serial);
            return ResponseEntity.ok(
                    new PaymentCallbackResponse("FAIL", "signature invalid"));
        }

        // ----------------- 第三步:解密资源 -----------------
        WechatPayCallbackRequest callbackDTO = JSON.parseObject(body,
                WechatPayCallbackRequest.class);
        if (!"TRANSACTION.SUCCESS".equals(callbackDTO.getEventType())) {
            // 非支付成功事件,直接返回成功
            return ResponseEntity.ok(
                    new PaymentCallbackResponse("SUCCESS", "ignored"));
        }

        String plaintext;
        try {
            plaintext = AesUtil.decryptToString(
                    apiV3Key.getBytes(StandardCharsets.UTF_8),
                    callbackDTO.getResource().getAssociatedData(),
                    callbackDTO.getResource().getNonce(),
                    callbackDTO.getResource().getCiphertext());
        } catch (Exception e) {
            log.error("回调资源解密失败", e);
            return ResponseEntity.ok(
                    new PaymentCallbackResponse("FAIL", "decrypt error"));
        }

        // 解析解密后的支付订单数据
        JSONObject payResult = JSON.parseObject(plaintext);
        String outTradeNo = payResult.getString("out_trade_no");
        String transactionId = payResult.getString("transaction_id");
        Integer totalFee = payResult.getInteger("total_fee"); // 单位:分

        // ----------------- 第四步:幂等处理 + 业务更新 -----------------
        try {
            orderService.processPaymentCallback(outTradeNo,
                    transactionId, totalFee);
        } catch (Exception e) {
            log.error("订单 {} 回调处理异常", outTradeNo, e);
            // 返回非 200 状态码,微信会重试
            return ResponseEntity.status(500).body(
                    new PaymentCallbackResponse("FAIL", "process error"));
        }

        // 返回 SUCCESS 告知微信不再重试
        return ResponseEntity.ok(
                new PaymentCallbackResponse("SUCCESS", "ok"));
    }

    private boolean verify(X509Certificate certificate,
                           String signStr, String signature) {
        try {
            Signature sign = Signature.getInstance("SHA256withRSA");
            sign.initVerify(certificate);
            sign.update(signStr.getBytes(StandardCharsets.UTF_8));
            return sign.verify(Base64.getDecoder().decode(signature));
        } catch (Exception e) {
            log.error("验签异常", e);
            return false;
        }
    }
}

四、幂等处理的关键实现

微信支付的通知存在以下特征:

  • 同一笔订单可能收到多次相同回调
  • 不同通知的 id(通知 ID)不同但 out_trade_no 相同
  • 回调与用户主动查询并存(前端轮询"查订单"接口)

因此幂等不能依赖通知 ID 去重,必须以订单维度加分布式锁 + 状态机判断。

@Service
@Slf4j
public class OrderService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private OrderMapper orderMapper;

    private static final String PAY_LOCK_KEY = "pay:lock:";
    private static final int LOCK_SECONDS = 30;

    @Transactional(rollbackFor = Exception.class)
    public void processPaymentCallback(String outTradeNo,
                                       String transactionId,
                                       Integer totalFee) {
        // ---------- 第一步:分布式锁 ----------
        String lockKey = PAY_LOCK_KEY + outTradeNo;
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "1", Duration.ofSeconds(LOCK_SECONDS));
        if (Boolean.FALSE.equals(locked)) {
            // 有另一个线程正在处理,这里直接抛异常让微信重试
            // 更好的做法是等待锁释放,但简单场景下重试更安全
            throw new BusinessException("订单 " + outTradeNo + " 正在处理中");
        }
        try {
            // ---------- 第二步:状态机判断 ----------
            Order order = orderMapper.selectByOutTradeNo(outTradeNo);
            if (order == null) {
                log.warn("回调订单 {} 不存在", outTradeNo);
                return; // 直接返回 SUCCESS,不再重试
            }

            // 订单已经支付完成,直接跳过(幂等)
            if ("PAID".equals(order.getStatus())
                    || "REFUNDING".equals(order.getStatus())) {
                log.info("订单 {} 已支付,跳过重复回调", outTradeNo);
                return;
            }

            // 金额核对:微信返回的金额与本地订单金额必须一致
            if (!totalFee.equals(order.getActualAmount())) {
                log.error("订单 {} 金额不一致: 微信={}, 本地={}",
                        outTradeNo, totalFee, order.getActualAmount());
                // 严重异常,人工介入
                alertSystem("PAY_AMOUNT_MISMATCH", outTradeNo);
                return;
            }

            // ---------- 第三步:更新订单 ----------
            int updated = orderMapper.updatePaySuccess(
                    outTradeNo,
                    transactionId,
                    LocalDateTime.now());

            if (updated == 0) {
                log.warn("订单 {} 更新失败(并发冲突), 等待重试", outTradeNo);
                throw new BusinessException("订单更新冲突");
            }

            // ---------- 第四步:后置处理 ----------
            // 1. 发送订单支付成功 MQ 消息(用于积分赠送、短信通知等)
            // 2. 清除订单超时延迟任务(如果有 Redis 延迟队列)
            // 3. 记录支付流水日志

            log.info("订单 {} 支付回调处理成功, transactionId={}",
                    outTradeNo, transactionId);

        } finally {
            // 注意:锁不能提前释放,要在事务提交后才释放
            // 这里用「事务同步」机制确保锁在事务提交后释放
            TransactionSynchronizationManager.registerSynchronization(
                    new TransactionSynchronization() {
                        @Override
                        public void afterCommit() {
                            redisTemplate.delete(lockKey);
                        }
                    });
        }
    }
}

五、踩坑记录

5.1 锁释放时机错误

现象:在 finally 块直接释放锁,导致事务还未提交,下一个重试请求就进来了,读到的是旧状态,重复创建支付流水。

解决:使用 TransactionSynchronizationManager.registerSynchronization 注册在 afterCommit 阶段释放锁,确保事务可见性。

5.2 验签失败误返回 200

现象:验签失败返回了 HTTP 200 + "SUCCESS",微信认为回调成功不再重试,实际业务没处理。

解决:签验失败必须返回 非 200 状态码(建议 500 或在 200 body 中带 "FAIL"),让微信继续重试。

5.3 忽略 resource 解密异常

现象:解密失败时返回 200 + "SUCCESS",导致丢失支付回调,用户已付款但订单未变为已支付。

解决:解密失败必须返回 "FAIL" 让重试。同时监控告警解密失败次数。

5.4 回调与页面同步跳转的时序竞态

现象:用户支付成功后浏览器跳转到成功页,前端直接调用"查订单接口"发现仍是"待支付"(回调还没到)。用户以为支付失败。

解决:前端在支付成功页启动 轮询(3秒/次,最长 30 秒),直到订单状态变为已支付或超时。后端查询接口本身也做"兜底查询"——如果在缓存中查到该订单已收到过回调处理请求但还在事务中,返回"处理中"状态让前端继续轮询。

六、最佳实践总结

  • 严格验签:每次回调都必须用平台证书 + Wechatpay-Signature 验签,缺一不可。验签失败不允许进入业务逻辑。
  • 防重放:验证 Wechatpay-Timestamp 距离当前时间不超过 5 分钟,防止攻击者截获通知后重放。
  • 幂等是硬要求:基于订单维度的分布式锁 + 状态机判断,确保同一笔订单最多被处理一次。
  • 失败必须重试:任何业务处理异常都不要吞掉,返回 FAIL 让微信重试;连续失败需告警人工介入。
  • 金额二次核对:回调中的支付金额必须与本地订单金额逐分核对,不一致说明严重异常。
  • 事务与锁的生命周期匹配:确保分布式锁在事务提交后才释放,避免脏读。