戰(zhàn):簽名驗(yàn)簽與回調(diào)冪等落地)
簡(jiǎn)介這份資源面向使用Java開發(fā)微信小程序支付的開發(fā)者聚焦微信支付V3版本的退款功能實(shí)現(xiàn)適合已具備一定后端基礎(chǔ)、需要快速落地退款流程的中高級(jí)開發(fā)者參考。壓縮包共4個(gè)文件以3個(gè)txt與1個(gè)properties配置為主分別承載V3支付Bean、Controller示例代碼、商戶參數(shù)配置及pom依賴說明整體約6KB輕量便于直接嵌入現(xiàn)有工程。內(nèi)容圍繞V3接口的簽名、退款請(qǐng)求封裝、回調(diào)通知驗(yàn)簽與錯(cuò)誤重試等關(guān)鍵環(huán)節(jié)展開可幫助讀者理清從獲取Access Token到處理退款結(jié)果的完整鏈路并對(duì)照示例快速搭建可調(diào)試的退款模塊。目前已有4693人學(xué)習(xí)下載適合作為小程序退款功能開發(fā)時(shí)的速查與排錯(cuò)參考。1. 小程序退款總翻車先看清 V3 這套簽名與證書的門道做過小程序支付的兄弟大概率都有同感收款接口跑通只是熱身退款才是真正讓人掉頭發(fā)的地方。wxpayV3.rar這個(gè)包給的不是一份泛泛的文檔而是一套能直接塞進(jìn) Spring Boot 工程的 Java 落地件——WechatPayV3Bean.txt管配置裝配WechatPayV3Controller.txt管退款與回調(diào)入口wechat_pay_v3.properties管商戶參數(shù)pom依賴.txt把該引的坐標(biāo)列清楚。它瞄準(zhǔn)的場(chǎng)景很具體小程序下單收款之后用戶申請(qǐng)退款后端要按微信支付 V3 的規(guī)矩把請(qǐng)求簽出去、把回調(diào)驗(yàn)回來、把狀態(tài)落庫。很多人第一次接 V3 會(huì)本能地去找access_token這是從 V2 帶過來的肌肉記憶。V3 的鑒權(quán)模型換了請(qǐng)求頭里帶Authorization: WECHATPAY2-SHA256-RSA2048用商戶私鑰對(duì)「方法URL時(shí)間戳隨機(jī)串請(qǐng)求體」拼出的串做 SHA256withRSA 簽名平臺(tái)證書則用來驗(yàn)微信回給你的內(nèi)容。這套機(jī)制決定了你本地必須備好三樣?xùn)|西商戶 API 私鑰、商戶證書序列號(hào)、平臺(tái)證書或平臺(tái)公鑰。少一樣簽名就過不去報(bào)出來的還是那種讓人一臉懵的 401。這份資源適合兩類人一類是剛接手小程序退款、被 V3 簽名卡住的 Java 后端另一類是手里有老 V2 代碼、想平滑遷到 V3 的維護(hù)者。它不教你小程序前端怎么畫退款按鈕也不替你做對(duì)賬它解決的是「后端怎么把一筆退款正確地發(fā)出去、收回來、記下來」。下面按配置、簽名、退款、回調(diào)、排坑、進(jìn)階的順序拆開講參數(shù)和坑都落到能抄的程度。2. 把 wxpayV3 拆開配置裝配與依賴坐標(biāo)怎么落2.1 四個(gè)文件各自的職責(zé)邊界拿到壓縮包先別急著往項(xiàng)目里拖先認(rèn)清每個(gè)文件是干嘛的不然改起來會(huì)互相打架。pom依賴.txt是坐標(biāo)清單V3 官方推薦用wechatpay-java這個(gè) SDK它把簽名、驗(yàn)簽、證書下載都封好了比手搓 HttpClient 穩(wěn)得多。wechat_pay_v3.properties是純參數(shù)文件商戶號(hào)、AppID、證書序列號(hào)、私鑰路徑、APIv3 密鑰、回調(diào)地址都在這。WechatPayV3Bean.txt是把這些參數(shù)讀進(jìn)來、裝配成 SDK 需要的配置對(duì)象和RSAAutoCertificateConfig。WechatPayV3Controller.txt是業(yè)務(wù)入口退款申請(qǐng)和退款回調(diào)兩個(gè)接口都在里面。常見做法是properties 只放環(huán)境相關(guān)的值Bean 里做一次性的初始化Controller 只關(guān)心業(yè)務(wù)參數(shù)和狀態(tài)流轉(zhuǎn)。這樣換環(huán)境測(cè)試/生產(chǎn)只動(dòng) properties不動(dòng)代碼。要注意的是 APIv3 密鑰和商戶私鑰是兩碼事——APIv3 密鑰用來解密回調(diào)里的敏感字段比如退款通知里的加密串商戶私鑰用來簽名請(qǐng)求別把兩者搞混這是新手最容易犯的錯(cuò)。2.2 依賴坐標(biāo)與配置項(xiàng)落地先把依賴引進(jìn)來。pom依賴.txt里核心就是官方 SDK版本按你項(xiàng)目實(shí)際鎖定的來別盲目追最新。!-- 微信支付 V3 官方 Java SDK -- dependency groupIdcom.github.wechatpay-apiv3/groupId artifactIdwechatpay-java/artifactId version0.2.12/version /dependency !-- 如果不用 SDK 自帶的 HTTP 客戶端可保留 okhttp -- dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency坐標(biāo)說明wechatpay-java負(fù)責(zé)簽名、驗(yàn)簽、平臺(tái)證書自動(dòng)更新是 V3 的核心okhttp是它底層用的 HTTP 客戶端一般隨 SDK 傳遞進(jìn)來顯式聲明是為了鎖版本避免和項(xiàng)目里其他組件沖突。如果你的工程已經(jīng)有 HttpClient 封裝也別硬塞兩套統(tǒng)一走 SDK 的HttpClientBuilder更省心。配置項(xiàng)落到 properties字段名按你項(xiàng)目習(xí)慣來關(guān)鍵是值別填錯(cuò)# 商戶號(hào)10 位數(shù)字 wxpay.mch-id1900000001 # 小程序 AppID wxpay.app-idwx1234567890abcdef # 商戶證書序列號(hào)在商戶平臺(tái) API 安全里能看到 wxpay.mch-serial-no4A3B2C1D... # 商戶 API 私鑰文件路徑apiclient_key.pem wxpay.private-key-path/opt/cert/apiclient_key.pem # APIv3 密鑰32 位用于解密回調(diào) wxpay.api-v3-keyyour32lengthapiv3keyhere000000 # 退款結(jié)果回調(diào)地址必須公網(wǎng)可達(dá)且是 https wxpay.refund-notify-urlhttps://your.domain/wxpay/refund/notify參數(shù)說明mch-serial-no是證書序列號(hào)不是商戶號(hào)兩個(gè)都是數(shù)字但含義完全不同填反了簽名直接失敗private-key-path指向的是apiclient_key.pem不是apiclient_cert.pem后者是證書本身api-v3-key必須正好 32 位短一位解密回調(diào)就拋異常refund-notify-url微信要求 https本地調(diào)試得靠?jī)?nèi)網(wǎng)穿透工具映射出去否則回調(diào)永遠(yuǎn)收不到。2.3 Bean 裝配一次初始化全局復(fù)用WechatPayV3Bean.txt的思路是把配置讀進(jìn)來構(gòu)建一個(gè)單例的RefundService或JsapiService。SDK 的RSAAutoCertificateConfig會(huì)自動(dòng)下載并輪換平臺(tái)證書省去手動(dòng)維護(hù)的麻煩。Configuration public class WechatPayV3Bean { Value(${wxpay.mch-id}) private String mchId; Value(${wxpay.mch-serial-no}) private String mchSerialNo; Value(${wxpay.private-key-path}) private String privateKeyPath; Value(${wxpay.api-v3-key}) private String apiV3Key; // 全局單例避免每次請(qǐng)求都重新加載私鑰 Bean public RSAAutoCertificateConfig rsaAutoCertificateConfig() throws IOException { return new RSAAutoCertificateConfig.Builder() .merchantId(mchId) .privateKey(Files.readString(Paths.get(privateKeyPath))) .merchantSerialNumber(mchSerialNo) .apiV3Key(apiV3Key) .build(); } Bean public RefundService refundService(RSAAutoCertificateConfig config) { return new RefundService.Builder().config(config).build(); } }邏輯說明RSAAutoCertificateConfig在啟動(dòng)時(shí)用商戶私鑰完成一次身份校驗(yàn)之后自動(dòng)拉取平臺(tái)證書并定時(shí)更新驗(yàn)簽時(shí)就不用你手動(dòng)指定平臺(tái)證書了。RefundService是 SDK 提供的退款專用服務(wù)類封裝了申請(qǐng)退款接口。參數(shù)上privateKey傳的是私鑰內(nèi)容字符串不是路徑所以這里用Files.readString讀出來apiV3Key用于解密回調(diào)務(wù)必和商戶平臺(tái)設(shè)置的一致。把這兩個(gè) Bean 做成單例很關(guān)鍵私鑰解析有開銷每次請(qǐng)求都 new 一個(gè)配置對(duì)象在高并發(fā)下會(huì)拖慢響應(yīng)。3. 退款請(qǐng)求怎么發(fā)簽名、參數(shù)與狀態(tài)判斷3.1 V3 簽名到底簽了什么V3 的簽名串是五行拼出來的HTTP 方法、URL帶 query 的路徑部分、時(shí)間戳、隨機(jī)串、請(qǐng)求體。用商戶私鑰對(duì)這串做 SHA256withRSA再 Base64塞進(jìn)Authorization頭。SDK 已經(jīng)把這步封好了你調(diào)refundService.create()時(shí)它自動(dòng)簽。但理解這串的意義在于排錯(cuò)如果報(bào) 401 簽名錯(cuò)誤八成是 URL 帶了域名、或者請(qǐng)求體被框架改過比如序列化多加了空格導(dǎo)致簽名串和實(shí)際發(fā)出去的不一致。常見坑是請(qǐng)求體被 Jackson 二次序列化。SDK 內(nèi)部用 Gson 序列化如果你在 Controller 里先把對(duì)象轉(zhuǎn)成 String 再傳進(jìn)去字段順序或空格一變簽名就對(duì)不上。正確做法是把業(yè)務(wù)對(duì)象直接交給 SDK讓它自己序列化。3.2 發(fā)起退款的完整調(diào)用WechatPayV3Controller.txt里的退款入口核心是組裝CreateRequest。下面這段是可直接抄的骨架RestController RequestMapping(/wxpay) public class WechatPayV3Controller { Autowired private RefundService refundService; PostMapping(/refund) public ResponseEntityString refund(RequestBody RefundDTO dto) throws Exception { // 商戶退款單號(hào)必須唯一重復(fù)請(qǐng)求微信會(huì)冪等返回 String outRefundNo RF System.currentTimeMillis(); CreateRequest req new CreateRequest.Builder() // 原支付訂單號(hào)二選一transaction_id 或 out_trade_no .outTradeNo(dto.getOutTradeNo()) // 退款單號(hào)自己生成用于對(duì)賬 .outRefundNo(outRefundNo) // 退款金額單位分不能大于原訂單金額 .amount(new AmountReq(dto.getRefundFen(), dto.getTotalFen(), CNY)) // 退款原因會(huì)展示給用戶 .reason(dto.getReason()) // 回調(diào)地址不傳則用配置里的默認(rèn)值 .notifyUrl(https://your.domain/wxpay/refund/notify) .build(); Refund refund refundService.create(req); // refund.getStatus() 常見值SUCCESS / PROCESSING / ABNORMAL / CLOSED return ResponseEntity.ok(refund.getStatus()); } }邏輯說明outTradeNo和transaction_id二選一前者是你下單時(shí)的商戶訂單號(hào)后者是微信側(cè)訂單號(hào)用哪個(gè)取決于你庫里存了哪個(gè)。outRefundNo必須全局唯一重復(fù)提交同一個(gè)退款單號(hào)微信會(huì)冪等處理不會(huì)重復(fù)退錢這也是重試機(jī)制的基礎(chǔ)。AmountReq三個(gè)參數(shù)分別是退款金額、原訂單總額、幣種單位都是分退款金額不能超過原訂單總額否則報(bào)參數(shù)錯(cuò)誤。notifyUrl建議顯式傳別依賴默認(rèn)配置多環(huán)境時(shí)容易串。3.3 退款狀態(tài)怎么讀refund.getStatus()返回的是字符串別用result_code那套 V2 的字段去判斷。V3 退款狀態(tài)主要有幾個(gè)值含義差別很大狀態(tài)值含義處理建議SUCCESS退款成功更新本地訂單為已退款PROCESSING退款處理中等待回調(diào)不要重復(fù)發(fā)起ABNORMAL退款異常需人工介入查退款單詳情CLOSED退款關(guān)閉通常因原訂單問題核對(duì)訂單狀態(tài)注意PROCESSING不是失敗很多新手看到不是 SUCCESS 就重試結(jié)果觸發(fā)風(fēng)控。正確姿勢(shì)是收到PROCESSING就等回調(diào)回調(diào)里再確認(rèn)最終狀態(tài)。退款到賬時(shí)間取決于用戶支付方式零錢通常較快銀行卡可能 T1 甚至更久別拿「沒立刻到賬」當(dāng) bug 查。4. 回調(diào)驗(yàn)簽與狀態(tài)落庫別讓通知變成黑匣子4.1 回調(diào)報(bào)文的結(jié)構(gòu)與解密微信退款完成后會(huì) POST 一個(gè) JSON 到你配置的notifyUrl報(bào)文分兩部分外層是id、create_time、event_type、resourceresource里又有algorithm、ciphertext、nonce、associated_data。真正的退款結(jié)果在ciphertext里用 APIv3 密鑰做 AES-256-GCM 解密才能看到。SDK 提供了NotificationParser幫你一步到位。PostMapping(/refund/notify) public MapString, String refundNotify(RequestBody String body, RequestHeader(Wechatpay-Signature) String signature, RequestHeader(Wechatpay-Timestamp) String timestamp, RequestHeader(Wechatpay-Nonce) String nonce, RequestHeader(Wechatpay-Serial) String serial) { MapString, String resp new HashMap(); try { // 構(gòu)造驗(yàn)簽參數(shù)SDK 會(huì)用平臺(tái)證書驗(yàn)簽并解密 RequestParam param new RequestParam.Builder() .serialNumber(serial) .nonce(nonce) .signature(signature) .timestamp(timestamp) .body(body) .build(); // RefundNotification 是解密后的退款結(jié)果對(duì)象 RefundNotification notification notificationParser.parse(param, RefundNotification.class); // 冪等先查本地是否已處理過該退款單 if (!refundRecordService.isProcessed(notification.getOutRefundNo())) { refundRecordService.updateStatus( notification.getOutRefundNo(), notification.getRefundStatus()); } resp.put(code, SUCCESS); resp.put(message, 成功); } catch (Exception e) { // 驗(yàn)簽失敗或解密失敗返回失敗讓微信重試 resp.put(code, FAIL); resp.put(message, e.getMessage()); } return resp; }邏輯說明四個(gè)請(qǐng)求頭缺一不可Wechatpay-Serial告訴 SDK 用哪張平臺(tái)證書驗(yàn)簽Wechatpay-Signature是簽名值。notificationParser.parse內(nèi)部先驗(yàn)簽再解密任何一步失敗都會(huì)拋異常此時(shí)必須返回FAIL微信會(huì)按策略重試。返回SUCCESS表示你已成功接收微信不再重試。參數(shù)上body必須是原始報(bào)文別在框架里做任何預(yù)處理否則驗(yàn)簽必掛。4.2 冪等與落庫回調(diào)可能重復(fù)推送微信不保證只發(fā)一次。所以處理邏輯必須先查outRefundNo是否已處理已處理直接返回成功。落庫時(shí)把退款單號(hào)、原訂單號(hào)、退款金額、狀態(tài)、回調(diào)時(shí)間都記下來方便對(duì)賬。常見做法是給out_refund_no加唯一索引靠數(shù)據(jù)庫兜底防重。提示回調(diào)接口不要做耗時(shí)操作比如發(fā)短信、調(diào)外部系統(tǒng)。微信對(duì)響應(yīng)時(shí)間有要求超時(shí)會(huì)判失敗并重試重試風(fēng)暴能把你的庫打崩。耗時(shí)邏輯丟到消息隊(duì)列異步處理。5. 退款排查五條血淚踩坑記錄5.1 簽名報(bào) 401但參數(shù)看著都對(duì)現(xiàn)象請(qǐng)求發(fā)出去返回 401提示簽名錯(cuò)誤可商戶號(hào)、序列號(hào)、私鑰路徑都核對(duì)過沒問題。原因多半是請(qǐng)求體被二次序列化或者 URL 里帶了域名。V3 簽名串里的 URL 只取路徑部分/v3/refund/domestic/refunds不含https://api.mch.weixin.qq.com。解決用 SDK 直接傳對(duì)象別自己轉(zhuǎn) String確認(rèn)簽名用的 URL 是純路徑。如果還不行把簽名串打日志逐行比對(duì)通常能一眼看出多出來的空格。5.2 回調(diào)收不到日志一片空白現(xiàn)象退款明明成功了本地回調(diào)接口沒任何日志。原因notifyUrl不是 https或者公網(wǎng)不可達(dá)或者被網(wǎng)關(guān)攔了。解決確認(rèn)地址是 https 且外網(wǎng)能訪問本地調(diào)試用內(nèi)網(wǎng)穿透映射檢查 Nginx 或安全組有沒有放行 POST微信回調(diào)只認(rèn) 200 且響應(yīng)體符合格式返回其他狀態(tài)碼會(huì)被判失敗。5.3 解密回調(diào)拋 AEADBadTagException現(xiàn)象驗(yàn)簽過了解密ciphertext時(shí)報(bào) AEAD 標(biāo)簽錯(cuò)誤。原因APIv3 密鑰填錯(cuò)或者密鑰長(zhǎng)度不是 32 位。解決去商戶平臺(tái)重新核對(duì) APIv3 密鑰注意它和 API 密鑰V2 用的那個(gè)不是一回事確認(rèn)配置文件里沒有多余空格長(zhǎng)度嚴(yán)格 32 位。5.4 重復(fù)退款用戶收到兩筆錢現(xiàn)象用戶反饋收到兩次退款。原因沒做冪等網(wǎng)絡(luò)超時(shí)后代碼重試生成了新的outRefundNo。解決outRefundNo用業(yè)務(wù)唯一鍵比如原訂單號(hào)退款序號(hào)生成別用時(shí)間戳重試時(shí)復(fù)用同一個(gè)退款單號(hào)微信會(huì)冪等返回?cái)?shù)據(jù)庫對(duì)out_refund_no加唯一約束。5.5 金額對(duì)不上退多了或退少了現(xiàn)象退款金額和預(yù)期差 100 倍。原因單位搞錯(cuò)V3 金額單位是分不是元。解決所有金額字段統(tǒng)一用分存儲(chǔ)和傳輸前端傳元的話在入口處乘 100 并轉(zhuǎn) int別用 double 做金額運(yùn)算浮點(diǎn)誤差會(huì)讓你對(duì)賬對(duì)到懷疑人生。6. 進(jìn)階把退款做成可重試、可對(duì)賬的閉環(huán)退款跑通只是及格線生產(chǎn)環(huán)境還得解決兩件事重試和對(duì)賬。重試不能無腦循環(huán)得區(qū)分錯(cuò)誤類型。簽名錯(cuò)誤、參數(shù)錯(cuò)誤這類是「重試也沒用」的直接告警網(wǎng)絡(luò)超時(shí)、SYSTEM_ERROR這類才值得重試且必須復(fù)用同一個(gè)outRefundNo。我一般會(huì)建一張退款任務(wù)表記錄每次請(qǐng)求的狀態(tài)和重試次數(shù)用定時(shí)任務(wù)掃描PROCESSING超過一定時(shí)間的單子去查退款詳情接口兜底。// 查詢退款單詳情用于回調(diào)丟失時(shí)兜底 GetMapping(/refund/query/{outRefundNo}) public Refund queryRefund(PathVariable String outRefundNo) throws Exception { QueryByOutRefundNoRequest req new QueryByOutRefundNoRequest.Builder() .outRefundNo(outRefundNo) .build(); // 返回對(duì)象里的 status 才是權(quán)威狀態(tài) return refundService.queryByOutRefundNo(req); }對(duì)賬則是每天拉一次微信的賬單和本地退款記錄逐筆比對(duì)。V3 有專門的賬單下載接口返回的是加密的 CSV用 APIv3 密鑰解密后解析。差異單子要能定位到具體是哪筆、差多少、什么狀態(tài)。這套閉環(huán)建起來之后退款出問題基本都能在當(dāng)天發(fā)現(xiàn)而不是等用戶投訴。還有個(gè)容易被忽略的點(diǎn)平臺(tái)證書會(huì)輪換。SDK 的RSAAutoCertificateConfig會(huì)自動(dòng)更新但如果你用的是手動(dòng)指定平臺(tái)證書的方式證書過期后驗(yàn)簽會(huì)突然全掛。所以能用自動(dòng)更新就別手動(dòng)。另外商戶私鑰文件權(quán)限要收緊別提交到代碼倉庫用配置中心或掛載卷注入。從那以后我每次接微信支付 V3都強(qiáng)制先把簽名串打日志、把回調(diào)冪等做掉、把金額單位統(tǒng)一成分為單位這三步走完再寫業(yè)務(wù)。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取