首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Spring MVC 微信支付回調處理最佳實踐:XML 解析、簽名驗證、冪等性保證

     編輯:Java 相關知識

微信支付回調特點與挑戰

  • HTTP POST 請求,Body 為 XML 格式
  • 無查詢參數,不能使用 @RequestParam
  • 需在 5 秒內響應,否則微信重試
  • 必須驗證簽名防止偽造通知
  • 業務處理必須冪等 (重複通知不重複處理)

核心控制器實現

 params = XmlUtil.xmlToMap(xmlBody);
        
        // 3. 極早驗證簽名 (防偽造)
        if (!wxPayService.verifySign(params)) {
            log.warn("簽名驗證失敗: {}", params);
            return buildFailXml("簽名無效");
        }
        
        // 4. 業務邏輯處理 (異步執行,快速響應微信)
        CompletableFuture.runAsync(() -> processPaymentResult(params));
        
        // 5. 立即返回成功,避免微信重試
        return buildSuccessXml();
    }
    
    private void processPaymentResult(Map params) {
        String outTradeNo = params.get("out_trade_no"); // 商戶訂單號
        String transactionId = params.get("transaction_id"); // 微信訂單號
        String resultCode = params.get("result_code"); // SUCCESS/FAIL
        
        try {
            // 核心:冪等性檢查 (Redis 分佈式鎖或數據庫唯一索引)
            if (!orderService.markPaidIfNotProcessed(outTradeNo, transactionId)) {
                log.info("訂單已處理,跳過: {}", outTradeNo);
                return;
            }
            
            if ("SUCCESS".equals(resultCode)) {
                orderService.handlePaymentSuccess(outTradeNo, transactionId, params);
            } else {
                orderService.handlePaymentFail(outTradeNo, params.get("err_code_des"));
            }
        } catch (Exception e) {
            log.error("處理支付結果異常: {}", outTradeNo, e);
            // 可選:發送告警、進入人工處理隊列
        }
    }
    
    private String buildSuccessXml() {
        return "";
    }
    private String buildFailXml(String msg) {
        return "";
    }
}

關鍵工具類:XML 與 Map 互轉

public class XmlUtil {
    /** XML 字符串轉 Map (屬性值去除 CDATA) */
    public static Map xmlToMap(String xml) {
        Map map = new HashMap<>();
        try {
            Document doc = DocumentHelper.parseText(xml);
            Element root = doc.getRootElement();
            for (Element element : root.elements()) {
                map.put(element.getName(), element.getTextTrim());
            }
        } catch (DocumentException e) {
            throw new RuntimeException("XML 解析失敗", e);
        }
        return map;
    }
    
    /** Map 轉 XML (自動包裹 CDATA) */
    public static String mapToXml(Map params) {
        Document doc = DocumentHelper.createDocument();
        Element root = doc.addElement("xml");
        for (Map.Entry entry : params.entrySet()) {
            Element el = root.addElement(entry.getKey());
            el.addCDATA(entry.getValue());
        }
        return doc.asXML();
    }
}

簽名驗證核心邏輯

 params) {
        String receivedSign = params.remove("sign"); // 取出並移除 sign 字段
        if (receivedSign == null) return false;
        
        // 1. 參數按 ASCII 排序
        String signString = params.entrySet().stream()
            .filter(e -> e.getValue() != null && !e.getValue().isEmpty())
            .sorted(Map.Entry.comparingByKey())
            .map(e -> e.getKey() + "=" + e.getValue())
            .collect(Collectors.joining("&"));
        
        // 2. 拼接商戶密鑰
        signString += "&key=" + apiKey;
        
        // 3. MD5 加密並轉大寫
        String calculatedSign = DigestUtils.md5DigestAsHex(signString.getBytes(StandardCharsets.UTF_8)).toUpperCase();
        
        return calculatedSign.equals(receivedSign);
    }
    
    /** 統一下單等主動請求的簽名生成 */
    public String generateSign(Map params) {
        String signString = params.entrySet().stream()
            .filter(e -> e.getValue() != null && !e.getValue().isEmpty())
            .sorted(Map.Entry.comparingByKey())
            .map(e -> e.getKey() + "=" + e.getValue())
            .collect(Collectors.joining("&")) + "&key=" + apiKey;
        return DigestUtils.md5DigestAsHex(signString.getBytes(StandardCharsets.UTF_8)).toUpperCase();
    }
}

冪等性保證:三層防線

層級實現方式代碼示例
1. 數據庫唯一約束payment_log 表 transaction_id 唯一索引ALTER TABLE payment_log ADD UNIQUE KEY uk_txn_id (transaction_id);
2. Redis 分佈式鎖處理前加鎖,處理完釋放redisTemplate.opsForValue().setIfAbsent("pay:lock:" + outTradeNo, "1", 30, TimeUnit.SECONDS)
3. 業務狀態機檢查僅處理「待支付」狀態訂單if (order.getStatus() != OrderStatus.PENDING) return false;
) conn -> conn.set(lockKey.getBytes(), "1".getBytes(), 
                Expiration.seconds(30), RedisStringCommands.SetOption.ifAbsent()));
        
        if (!acquired) return false; // 正在處理中
        
        try {
            // 雙重檢查:數據庫狀態
            Order order = orderMapper.selectByOutTradeNo(outTradeNo);
            if (order == null || order.getStatus() != OrderStatus.PENDING) {
                return false;
            }
            
            // 記錄支付流水 (唯一索引防重)
            PaymentLog log = new PaymentLog();
            log.setOutTradeNo(outTradeNo);
            log.setTransactionId(transactionId);
            log.setStatus(PaymentStatus.SUCCESS);
            paymentLogMapper.insert(log); // 唯一索引衝突拋異常
            
            return true; // 獲得處理權
        } catch (DuplicateKeyException e) {
            return false; // 已存在流水
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
}

常見坑點與避坑指南

  • @RequestBody 直接綁定實體類:會導致簽名驗證失敗 (字段順序、CDATA 處理不一致) → 先讀原始流解析 Map 驗簽
  • 同步處理耗時業務:超過 5 秒微信重試 → CompletableFuture 異步處理,立即響應 SUCCESS
  • 忽略 result_code:return_code=SUCCESS 只表示通信成功,必須判斷 result_code=SUCCESS 才是支付成功
  • 金額未校驗:需核對 total_fee 與訂單金額一致,防止金額篡改
  • 測試環境無法接收回調:使用內網穿透 (ngrok、frp、cpolar) 映射公網域名

總結

微信支付回調處理核心三步:讀原始 XML → 驗簽名 → 異步冪等處理。關鍵點:不能用 @RequestBody、必須驗簽、必須冪等、必須秒級響應。生產環境建議引入可靠消息隊列 (RocketMQ/Kafka) 做異步處理,配合數據庫唯一索引 + Redis 分佈式鎖 雙重冪等保障。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved