微信 APP 支付是移動應用商業化的核心環節。本文完整梳理從商戶進件、統一下單、簽名算法、回調處理到安全驗證的全流程實作,並針對關鍵安全點提供最佳實踐建議。
appid、mch_id、api_key(商戶金鑰)wechat:
pay:
appid: wx**************** # 開放平台應用 AppID
mchId: 1********** # 商戶號
apiKey: **************** # 32位商戶金鑰 (商戶平台-賬戶中心-API安全設置)
notifyUrl: https://api.example.com/pay/wechat/notify # 異步回調地址
tradeType: APP
App客戶端 → 你的後端 → 微信支付統一下單API → 返回prepay_id → 後端二次簽名 → App喚起微信支付 → 用戶支付 → 微信異步回調你的後端 → 後端驗證簽名/金額 → 業務處理 → 返回SUCCESS
@PostMapping("/unifiedorder")
public ResponseEntity<Map<String, Object>> unifiedOrder(@RequestBody OrderRequest req) {
// 1. 參數組裝
Map<String, String> params = new TreeMap<>(); // TreeMap 自動按 ASCII 排序
params.put("appid", config.getAppid());
params.put("mch_id", config.getMchId());
params.put("nonce_str", RandomUtil.randomString(32));
params.put("body", req.getBody());
params.put("detail", req.getDetail());
params.put("attach", req.getAttach());
params.put("out_trade_no", req.getOutTradeNo()); // 唯一訂單號
params.put("total_fee", String.valueOf(req.getTotalFee())); // 單位:分
params.put("spbill_create_ip", req.getClientIp());
params.put("time_start", DateUtil.format(new Date(), "yyyyMMddHHmmss"));
params.put("time_expire", DateUtil.format(DateUtil.offsetMinute(new Date(), 120), "yyyyMMddHHmmss"));
params.put("notify_url", config.getNotifyUrl());
params.put("trade_type", "APP");
// 2. 生成簽名
String sign = WXPayUtil.generateSignature(params, config.getApiKey());
params.put("sign", sign);
// 3. 發送 XML 請求
String xml = WXPayUtil.mapToXml(params);
String responseXml = HttpUtil.post(config.getUnifiedOrderUrl(), xml);
// 4. 解析回應
Map<String, String> result = WXPayUtil.xmlToMap(responseXml);
if ("SUCCESS".equals(result.get("return_code")) && "SUCCESS".equals(result.get("result_code"))) {
// 5. 二次簽名給 App
Map<String, String> appParams = new TreeMap<>();
appParams.put("appid", result.get("appid"));
appParams.put("partnerid", result.get("mch_id"));
appParams.put("prepayid", result.get("prepay_id"));
appParams.put("package", "Sign=WXPay");
appParams.put("noncestr", RandomUtil.randomString(32));
appParams.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
String paySign = WXPayUtil.generateSignature(appParams, config.getApiKey());
appParams.put("sign", paySign);
return ResponseEntity.ok(appParams);
}
throw new BusinessException("下單失敗: " + result.get("return_msg"));
}
public class WXPayUtil {
/** 生成簽名
* 1. 參數按 key ASCII 碼排序
* 2. 拼接 key1=value1&key2=value2...&key=API_KEY
* 3. MD5 加密並轉大寫
*/
public static String generateSignature(Map<String, String> params, String apiKey) {
StringBuilder sb = new StringBuilder();
params.entrySet().stream()
.filter(e -> e.getValue() != null && !e.getValue().isEmpty() && !"sign".equals(e.getKey()))
.forEach(e -> sb.append(e.getKey()).append('=').append(e.getValue()).append('&'));
sb.append("key=").append(apiKey);
return DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8)).toUpperCase();
}
/** XML <-> Map 雙向轉換 (CDATA 處理) */
public static String mapToXml(Map<String, String> map) { ... }
public static Map<String, String> xmlToMap(String xml) { ... }
}
@PostMapping("/notify")
public String notify(HttpServletRequest request) {
try {
// 1. 讀取原始 XML
String xml = IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> params = WXPayUtil.xmlToMap(xml);
// 2. 通訊層驗證
if (!"SUCCESS".equals(params.get("return_code"))) {
log.warn("微信通訊失敗: {}", params.get("return_msg"));
return WXPayUtil.mapToXml(Map.of("return_code", "SUCCESS", "return_msg", "OK"));
}
// 3. 業務層驗證
if (!"SUCCESS".equals(params.get("result_code"))) {
log.info("支付失敗: {}", params.get("err_code_des"));
return WXPayUtil.mapToXml(Map.of("return_code", "SUCCESS", "return_msg", "OK"));
}
// 4. 簽名驗證 (防偽造回調)
String receivedSign = params.remove("sign");
String calculatedSign = WXPayUtil.generateSignature(params, config.getApiKey());
if (!receivedSign.equals(calculatedSign)) {
log.error("簽名驗證失敗! 收到: {}, 計算: {}", receivedSign, calculatedSign);
throw new SecurityException("簽名驗證失敗");
}
// 5. 金額核對 (防篡改金額)
String outTradeNo = params.get("out_trade_no");
int totalFee = Integer.parseInt(params.get("total_fee"));
Order order = orderService.getByOutTradeNo(outTradeNo);
if (order == null || order.getTotalFee() != totalFee) {
log.error("金額不匹配! 訂單: {}, 回調金額: {}", order, totalFee);
throw new BusinessException("金額核對失敗");
}
// 6. 冪等性處理 (防重複回調)
if (order.getStatus() == OrderStatus.PAID) {
log.info("訂單已處理,忽略重複回調: {}", outTradeNo);
return WXPayUtil.mapToXml(Map.of("return_code", "SUCCESS", "return_msg", "OK"));
}
// 7. 業務邏輯: 更新訂單、發送通知、發放權益等
orderService.markPaid(outTradeNo, params.get("transaction_id"), new Date());
return WXPayUtil.mapToXml(Map.of("return_code", "SUCCESS", "return_msg", "OK"));
} catch (Exception e) {
log.error("回調處理異常", e);
return WXPayUtil.mapToXml(Map.of("return_code", "FAIL", "return_msg", "處理異常"));
}
}
| 風險點 | 防護措施 |
|---|---|
| API 金鑰洩露 | 配置中心加密存儲、定期輪換、不提交代碼倉庫 |
| 回調偽造 | 必須驗證簽名、驗證金額、驗證訂單狀態 |
| 重放攻擊 | 回調處理冪等設計、資料庫唯一索引約束 |
| 金額篡改 | 回調時核對 total_fee 與訂單金額一致 |
| 訂單號預測 | 使用 UUID/雪花算法生成不可預測 out_trade_no |
SYSTEMERROR:系統錯誤,重試OUT_TRADE_NO_USED:商戶訂單號重複,換單號重試NOTENOUGH:餘額不足ORDERPAID:訂單已支付,勿重複下單SIGNERROR:簽名錯誤,檢查參數排序、API Key、編碼微信 APP 支付核心在於「雙向簽名驗證」與「金額核對」。服務端需嚴格實現:統一下單→二次簽名→回調驗簽→金額核對→冪等處理→業務邏輯。建議封裝通用 WXPayClient 組件,降低業務代碼耦合,提升可維護性與安全性。