代付三次握手與 SDK 對接

修訂日期:2026-11-02 | Java:com.jeequan:jeepay-trade-sdk-java:1.0.0 | Go:github.com/jeequan/jeepay-trade-sdk-go | Payment API:https://ibzfancakcwlim.n96j1j.app | 服務端:/api/v2/transferOrder/precreate、/api/v2/transferOrder/confirm

接入前先確認三件事。 生產環境的挑戰碼有效期為 30 分鐘,確認必須在 challengeExpireAt 之前完成。金額傳 "1000.00" 這種國際單位字符串,不是分。回調地址只傳以 / 開頭的路徑,域名使用平台為商戶配置的回調域名。

1. 接口概述

三次握手把「鎖單」與「出款」拆開。商戶先提交代付要素並鎖定訂單,此時不動資金。平台再下發一次性挑戰碼。商戶核對回顯要素後,帶挑戰碼確認或拒絕。平台受理後異步出款,並以結果通知回傳終態。

1. 鎖單商戶呼叫 precreate。平台建立訂單,狀態 52,尚未出款。
2. 挑戰碼平台 POST 到商戶的挑戰碼路徑,下發一次性 challengeCode。
3. 確認商戶呼叫 confirm。通過則進入代付隊列(狀態 1);拒絕則關單(狀態 4)。
4. 終態平台 POST 結果通知。成功 2、失敗 3、關單 4。商戶回執後入賬,且必須冪等。

三條通道互不復用:

通道方向地址投遞回執
接口調用商戶 → 平台https://ibzfancakcwlim.n96j1j.app + 固定路徑POST JSON平台返回 JSON 信封
挑戰碼回調平台 → 商戶平台域名 + challengeCallBackUrl恆定 POST JSON響應體 SUCCESS
結果通知平台 → 商戶平台域名 + notifyUrl預設 form,可改 JSON響應體 SUCCESS

沒有拉取挑戰碼的接口。挑戰碼只從回調到達。收不到挑戰碼就無法完成第二次握手,訂單會在有效期加寬限期後被關單。

工作原理

  1. 商戶用自己的 RSA 私鑰對扁平 JSON 簽名,並帶上毫秒 timestamp 與一次性 nonce。
  2. 平台用商戶公鑰驗簽,校驗防重放,鎖定訂單要素,異步推送挑戰碼。
  3. 商戶用平台公鑰驗回調,核對單號與幣種,並按數值核對金額,再決定 reviewState。
  4. 確認時挑戰碼只可消費一次。通過後資金動作異步執行,終態只靠結果通知或查單收斂。

2. 技術規範

項目規範
協議HTTP。生產環境使用 HTTPS。不使用 SOAP。
風格REST 風格的 JSON API。上行固定 POST。
Content-Typeapplication/json;charset=UTF-8。服務端在 Content-Type 含 application/json 且非 GET 時讀 body。
字符編碼UTF-8
簽名只使用 RSA2,即 SHA256withRSA,簽名值 Base64。上行、響應明文、挑戰碼回調、結果通知都是 RSA2。
響應保護成功響應的業務數據放在 encryptData,用商戶公鑰做 RSA-OAEP 包裝 AES-256-GCM。
金額國際單位字符串,例如 "1000.00"。不使用「分」整數。
時間Unix 毫秒時間戳。日期字段在響應明文中為 yyyy-MM-dd HH:mm:ss。
應用識別V2 按 mchNo 取默認應用。上行不要傳 appId、version、reqTime。
Java SDKJDK 8 及以上。傳遞依賴只有 fastjson 1.2.83+。HTTP 使用 JDK HttpsURLConnection。
Go SDKGo 1.21 及以上。主模組只用標準庫。Gin 適配在獨立子模組 callback/ginx,不用就不引入。

3. 請求與響應格式

Body 是一層扁平 JSON。公共參數與業務參數同級,不要再包一層 data。

上行公共參數

每次 precreate、confirm、查單都要帶這五個字段。多帶 appId、version、reqTime 會被服務端丟棄,但它們若已進入簽名基串,驗簽必然失敗。

字段必填類型說明
mchNo是String(≤64)商戶號
signType是String固定 RSA2
timestamp是String毫秒 Unix 時間戳
nonce是String(≤64)一次性隨機串,建議 base64url(16 字節)。每次請求新生成
sign是StringRSA2 簽名,Base64。自身不參與基串

成功與失敗信封

{
  "code": 0,
  "msg": "SUCCESS",
  "encryptData": "{\"iv\":\"...\",\"ek\":\"...\",\"cipher\":\"...\"}",
  "sign": "MEUCIQ..."
}
{
  "code": 10130115,
  "msg": "ChallengeCode不存在或已过期"
}

code == 0 為成功。安全白名單接口成功時 data 為 null,業務數據只在 encryptData。失敗響應不加密。Java SDK 已內建「解密 → 驗簽 → 拆 data」,調用方拿到的是明文對象。

3.1 第一次握手 POST /api/v2/transferOrder/precreate

鎖定訂單要素並返回平台單號。同步響應不包含挑戰碼原文,挑戰碼只走回調。

字段必填長度說明
mchOrderNo是≤64商戶訂單號,同一商戶下不可重複。關單後也不能復用
ifCode是≤20支付接口代碼,由平台分配
entryType是≤20入賬方式,例如 API
amount是—國際單位字符串,例如 "1000.00"
currency是≤3幣種,例如 BRL
accountNo是≤64收款賬號
accountName是≤64收款人姓名
transferDesc是≤128轉賬備註
challengeCallBackUrl是≤256挑戰碼回調路徑,必須 / 開頭,不含協議與域名
notifyUrl否≤128結果通知路徑,規則同上。不傳則不發結果通知
bankName / bankCode否—開戶行名稱 / 銀行編碼
channelType否—渠道類型,不傳默認 BANK
subChannelId否—子渠道 ID。僅在平台要求時傳入
channelExtra否≤512渠道附加參數。只能在 precreate 傳,confirm 無法補傳
clientIp否≤128客戶端 IP
documentType / userInfoNo / name否—證件類型、證件號、姓名兜底
extParam否建議 ≤300商戶擴展參數,建議不超過 300 字符。過長時平台可能無法原樣回傳
{
  "mchOrderNo": "MO20260728001",
  "ifCode": "testpay",
  "entryType": "API",
  "amount": "1000.00",
  "currency": "BRL",
  "accountNo": "12345678901",
  "accountName": "Maria Silva",
  "transferDesc": "Pagamento",
  "challengeCallBackUrl": "/jeepay/challenge",
  "notifyUrl": "/jeepay/notify",
  "mchNo": "M0000000001",
  "signType": "RSA2",
  "timestamp": "1785000000000",
  "nonce": "Zm9vYmFyYmF6cXV4",
  "sign": "<Base64 RSA2>"
}

解密後的 data:

{
  "transferId": "T1785000000001",
  "mchNo": "M0000000001",
  "appId": "A1785000000001",
  "mchOrderNo": "MO20260728001",
  "amount": "1000.00",
  "currency": "BRL",
  "state": 52,
  "stateStr": "已锁定,待二次握手确认",
  "challengeExpireAt": 1785018000000,
  "ifCode": "testpay",
  "entryType": "API",
  "accountNo": "12345678901",
  "accountName": "Maria Silva",
  "createdAt": "2026-07-28 13:45:00"
}

state = 52 表示已鎖定、待確認。challengeExpireAt 是挑戰碼過期的毫秒時間戳,只在本接口下發,審批剩餘時間以它為準。transferId 是後續確認要回傳的平台單號。

3.2 挑戰碼回調(平台 → 商戶)

地址為「平台配置的回調域名」接上 challengeCallBackUrl。Content-Type 恆為 application/json。除 sign 外全部字段參與簽名。

字段說明
mchNo / appId / mchOrderNo商戶號、應用、商戶訂單號
platformOrderNo平台單號,等於 transferId
amount / currency平台下發前去掉小數尾端的 0。下單 "1000.00" 時,回調金額可能是 "1000"。與本地訂單核對時按數值比較,兩種寫法是同一金額。用字串相等會把同一筆金額判成不一致
challengeCode一次性挑戰碼原文。確認時原樣回傳。不要寫入日誌
challengeExpireAt過期毫秒時間戳
timestamp / nonce防重放。兩者都必填,參與簽名。缺一即拒絕
sign平台私鑰 RSA2 簽名
{
  "mchNo": "M0000000001",
  "appId": "A1785000000001",
  "mchOrderNo": "MO20260728001",
  "platformOrderNo": "T1785000000001",
  "amount": "1000",
  "currency": "BRL",
  "challengeCode": "a1b2c3d4e5f6",
  "challengeExpireAt": "1785018000000",
  "timestamp": "1785000000123",
  "nonce": "Zm9vYmFyYmF6cXV4",
  "sign": "MEUCIQ..."
}

回執必須是響應體 SUCCESS(大小寫不敏感)。返回 ok、200、空體或 JSON 都會被判失敗並重投。第 n 次失敗延遲 n×10 秒,最多 10 次;挑戰碼過期即停止投遞。報文不含 payType 與 state。

Java SDK 的 verifyBinding 與 Go SDK 的 VerifyBinding 都按數值比較金額。兩側都能解析成十進制數時用數值;解析失敗才退回字串相等。自行核對時使用同一規則。幣種比較忽略大小寫。

3.3 第二次握手 POST /api/v2/transferOrder/confirm

字段必填說明
transferId是第一次握手返回的平台單號
approveChallenge是挑戰碼原文
reviewState是1 通過,0 拒絕。缺省不會當成通過
reviewRemark否審批備註。拒絕時寫入訂單 errMsg
{
  "transferId": "T1785000000001",
  "approveChallenge": "a1b2c3d4e5f6",
  "reviewState": 1,
  "reviewRemark": "審批通過",
  "mchNo": "M0000000001",
  "signType": "RSA2",
  "timestamp": "1785000009000",
  "nonce": "cXV4YmF6Zm9vYmFy",
  "sign": "<Base64 RSA2>"
}

通過時解密後 state = 1(轉賬中,已受理)。拒絕時 state = 4(已關單)。本接口不下發 challengeExpireAt。

平台依序檢查:訂單存在、屬於本商戶、狀態為 52、挑戰碼有效,然後才接受審批結果。同一筆確認只會成功一次。挑戰碼校驗失敗會立即作廢,必須用新的 mchOrderNo 重新 precreate。

3.4 第三次握手:結果通知(平台 → 商戶)

只在到達終態時發送,同一訂單只通知一次成功送達。地址為「平台配置的回調域名」接上 notifyUrl。除 sign 外全部字段參與簽名,包含 reviewRemark。

字段說明
transferId平台單號
mchNo / appId / mchOrderNo商戶號、應用、商戶訂單號
ifCode / entryType支付接口、入賬方式
amount / currency國際單位金額、幣種。金額同樣會去掉小數尾端的 0,與本地訂單核對時按數值比較
accountNo / accountName / bankName收款信息
state終態:2 成功、3 失敗、4 關單
channelOrderNo / channelExtra渠道單號、渠道擴展
errCode / errMsg失敗原因。拒絕時 errMsg 為 reviewRemark
successTime / createdAt / reqTime / nonce毫秒時間戳。reqTime 與 nonce 都必填,用於防重放;缺一即拒絕
reviewRemark / extParam / sign審批備註、擴展參數、平台私鑰 RSA2 簽名

結果通知預設以 form 表單送達。如需 JSON,請向平台申請。回執同樣是 SUCCESS。第 n 次失敗延遲 n×30 秒,默認最多 6 次。超過次數後平台不再投遞,必須改走查單。notifyUrl 為空則完全不發通知。

3.5 查單兜底 POST /api/v2/transfer/query

查單使用同一套 RSA2 信封與響應加密。業務字段只需 mchOrderNo 與 transferId 至少一個。SDK 入口是 client.query().queryTransferOrder(...)。

查單只用於確認訂單狀態。入賬金額以下單金額與結果通知為準,不要用查單返回的 amount 入賬。訂單不存在時 state 可能為空,不要把它當成 0。

4. 認證與安全

密鑰歸屬

密鑰格式誰持有用途
商戶私鑰PKCS#8 Base64只留在商戶服務端上行簽名;解密 encryptData
商戶公鑰X.509 SubjectPublicKeyInfo Base64提交給平台平台驗上行簽名;加密下發響應
平台公鑰X.509 Base64平台發給商戶驗響應、挑戰碼回調、結果通知

全流程共用一套 RSA 密鑰,不按下單、確認拆分。密鑰長度 2048 bit。PEM 頭尾與換行可以保留,SDK 會剝離。PKCS#1(BEGIN RSA PRIVATE KEY)不能使用,需先轉成 PKCS#8。

接入前請平台開啟該商戶的 RSA2,並配置回調域名(含協議,例如 https://mch.example.com)。回調路徑每筆訂單在 precreate 傳入,不必事先報備。

簽名算法

上行請求、解密後的響應明文、挑戰碼回調、結果通知使用同一套基串:

  1. 去掉 sign,以及值為 null 或空串的字段。
  2. 每項拼成 key=value&。每一項結尾都帶 &,整串也以 & 結尾。
  3. 對拼好的整串做大小寫不敏感排序,不是先按 key 排序再拼接。
  4. 對 UTF-8 基串做 SHA256withRSA,結果 Base64。
accountName=Maria Silva&accountNo=12345678901&amount=1000.00&challengeCallBackUrl=/jeepay/challenge&currency=BRL&entryType=API&ifCode=testpay&mchNo=M0000000001&mchOrderNo=MO20260728001&nonce=Zm9vYmFyYmF6cXV4&notifyUrl=/jeepay/notify&signType=RSA2&timestamp=1785000000000&transferDesc=Pagamento&

值按原樣字符串拼接。簽名基串裡的金額保持送出時的寫法,不要補零、去零或加千分位。這只約束簽名。回調金額與本地訂單核對時按數值比較。先簽名再往 body 追加字段,會造成基串與報文不一致。

簽名方式只接受 RSA2。signType 固定傳 RSA2。基串只含報文字段,末尾不拼接私鑰。

響應加密

encryptData 是 JSON 字符串,解析後包含:

字段內容
ek用商戶公鑰、RSA/ECB/OAEP(SHA-256 + MGF1-SHA256)加密的 AES 密鑰,Base64
ivAES-GCM 的 12 字節 IV,Base64
cipherAES-256-GCM(tag 128 bit)加密的 data 明文,Base64

順序不可顛倒:解析信封 → 商戶私鑰解開 ek → AES-GCM 解開 cipher → 用平台公鑰對明文驗 sign。對密文驗簽一定失敗。Java 的 OAEPWithSHA-256AndMGF1Padding 若不顯式傳 OAEPParameterSpec,MGF1 會落到 SHA-1,解密報 Padding error。

precreate、confirm 與查單解密後的業務數據都是 JSON 對象,驗簽時按上一節逐字段排序。請直接使用解密得到的明文,不要先解析再重新序列化。

防重放與回調安全

時間窗口

窗口默認影響
挑戰碼有效期生產環境 30 分鐘以響應與回調中的 challengeExpireAt 為準。過期後 confirm 失敗
逾時關單有效期結束後再寬限 300 秒仍未確認的訂單會關單(state=4),並發送結果通知
SDK 等待挑戰碼取等待上限與剩餘有效期的較小值生產有效期為 30 分鐘。方式 A 請按自己的閘道超時設定等待上限;沿用 60 秒會在挑戰碼仍有效時提前返回
HTTP 連接 / 讀取10 秒 / 30 秒SDK 自帶 HTTP 客戶端

5. 錯誤處理

code = 0 表示本次接口調用成功,不表示出款成功。出款結果看訂單 state。非 0 時以 msg 為原因。SDK 把非 0 包成 JeepaySdkException,getCode() 即平台 code。

訂單狀態

碼含義出現時機
52已鎖定,待二次確認precreate 成功
1轉賬中,已受理confirm 通過
2轉賬成功(終態)結果通知
3轉賬失敗(終態)結果通知
4已關單(終態)確認拒絕,或待確認超時
5 / 51平台審核中平台尚未給出終態
6已駁回(終態)平台駁回
7出款前失敗(終態)訂單未完成出款

平台錯誤碼

成功時 code 為 0。代付接口的業務錯誤碼為 8 位、以 4003 開頭,請按 code 分支。不要再使用舊的 11、130xxx、140001、9999。

挑戰碼不存在或已過期是目前唯一的例外,仍返回 10130115,msg 為 ChallengeCode不存在或已过期。收到這個碼後必須用新的商戶訂單號重新發起第一次握手。

codemsg處理建議
0SUCCESS接口成功。繼續看 state
40030001mchNo 不存在核對商戶號
40030002mchNo 被禁用聯繫平台
40030004amount 格式錯誤改用國際單位字符串,例如 "1000.00"
40030005currency 不支持使用平台已開通的幣種
40030006ifCode 不存在或被禁用向平台確認支付接口代碼
40030007bankCode 不支持向平台確認銀行編碼
40030012參數錯誤見下方握手場景。修正後用新的 nonce 重試
40030013獲取商戶應用信息失敗聯繫平台檢查默認應用
40030014商戶下單幣種不支持改用該商戶已開通的幣種
40030022RSA2 公鑰未配置把與私鑰成對的公鑰提交給平台
40030025驗簽失敗對照簽名基串,並確認平台保存的是當前公鑰
40030026不支持的簽名方式請平台開啟 RSA2,signType 固定傳 RSA2
40030101訂單號已存在更換 mchOrderNo
40030102超出代付單筆限額調低金額
40030103超出代付每日限額次日或調整額度後再下新單
40030104訂單不存在使用 precreate 返回的 transferId
40030105轉賬金額小於單筆最小限額提高金額
40030106下單頻率超過最大頻次降低頻率後重試
40030107 / 40030108當前商戶或代理商戶禁止下單聯繫平台
40030110當前商戶未開通 API 代付聯繫平台開通
40030111商戶餘額不足確認錢包後再下新單
40030112商戶餘額被凍結聯繫平台
40030114轉賬備註不能為空補上 transferDesc
40030115入賬類型不支持核對 entryType
40030116當前支付服務繁忙,請稍後再試稍後用新單號重試
40030201 / 40030202費率未配置或手續費配置異常聯繫平台
40030207 / 40030208 / 40030209未綁定通道、通道未開啟或無可用渠道聯繫平台
40030211訂單金額不在通道允許範圍調整金額
40030212訂單金額不足以覆蓋手續費提高金額
40030301 / 40030302商戶無該幣種錢包或無錢包聯繫平台開通錢包
40030901系統異常結果未知。查單或等通知,不要盲目重發同一筆
40030902平台配置異常聯繫平台
10130115ChallengeCode不存在或已过期挑戰碼已作廢。用新訂單號重走第一次握手

同為 40030012 的握手場景

下列情況的 HTTP code 都是 40030012,msg 都是「參數錯誤」。請用自己的請求內容判斷原因。

場景處理
漏了 timestamp、nonce 或其他必填字段補齊後用新的 nonce 重試
回調地址傳了完整 URL,或平台尚未配置回調域名改成以 / 開頭的路徑,並把域名交給平台
reviewState 缺失或不是 0/1顯式傳 1 或 0
訂單不屬於該商戶停止處理該單
重複確認、訂單已關單,或確認正在處理不要換挑戰碼重發。以結果通知或查單的終態為準

SDK 與網絡錯誤

confirm 超時或連接失敗時,結果未知,平台可能已經受理。保留原 transferId 與同一挑戰碼重試。若平台已處理,會返回 40030012,不會重複出款。最終以結果通知或查單為準。本地憑據寫入後狀態未收斂時,SDK 會拋異常要求人工核對,不要用重試掩蓋。
現象處理
平台響應驗簽失敗核對平台公鑰。確認驗的是解密後明文,不是 encryptData
解密 Padding errorOAEP 顯式指定 SHA-256 與 MGF1-SHA256;確認公鑰與私鑰成對
nonce 重複 / 報文已過期平台重投屬正常。商戶時鐘做 NTP 校準。多實例注入共享 nonce 存儲
報文缺少 nonce 或 timestamp原始報文在轉發時被改寫或截斷把平台送來的報文原樣交給驗簽,不要自行補字段或關閉校驗
等待挑戰碼回調超時回調端點未就緒、多實例沒有共享存儲,或等待上限短於生產環境的 30 分鐘有效期
回顯要素不一致當成安全事件。不要用該挑戰碼確認

6. 使用示例:Java SDK

SDK 負責與服務端逐字節一致的簽名、驗簽、解密與防重放。三種方式能力相同,差別只在誰編排流程。Go 見第 7 節。PHP、Python、Node、.NET 按第 3、4 節自行實現。

方式入口適用
A 全自動client.transfer(req)下單線程可以阻塞等待,沒有人工審批
B1 分步託管client.splitHandshake()有異步審批,希望 SDK 保存挑戰碼與本地狀態
B2 低階編排client.handshake()已有審批單表,自己保存憑據
C 自串流程client.toolkit()必須走自有 HTTP 客戶端,只借用加簽驗簽
<dependency>
    <groupId>com.jeequan</groupId>
    <artifactId>jeepay-trade-sdk-java</artifactId>
    <version>1.0.0</version>
</dependency>

生成密鑰

java -cp jeepay-trade-sdk-java-1.0.0.jar com.jeequan.jeepay.trade.sdk.toolkit.RsaKeyGenTool
RsaKeyGenTool.Keys keys = RsaKeyGenTool.generate();
String privateKey = keys.getPrivateKey(); // PKCS#8,只留在商戶側
String publicKey  = keys.getPublicKey();  // X.509,提交給平台

6.1 方式 A:全自動

transfer() 內部完成 precreate、阻塞等待挑戰碼、核對回顯、再以 reviewState=1 確認。回調必須由另一個線程進入,且端點要先於下單可用。多實例要把 challengeRepository 與 nonceStore 換成 Redis。

JeepaySecureClient client = JeepaySecureClient.builder()
        .mchNo("M0000000001")
        .privateKey(merchantPrivateKeyPkcs8)
        .platformPublicKey(platformPublicKey)
        .gatewayUrl("https://ibzfancakcwlim.n96j1j.app")
        .challengeWaitTimeoutMs(300_000) // 呼叫方等待上限;生產挑戰碼有效期為 30 分鐘
        .replayWindowSec(300)
        .build();

TransferOrderRequest req = new TransferOrderRequest();
req.setMchOrderNo("MO20260728001");
req.setIfCode("testpay");
req.setEntryType("API");
req.setAmount("1000.00");
req.setCurrency("BRL");
req.setAccountNo("12345678901");
req.setAccountName("Maria Silva");
req.setTransferDesc("Pagamento");
req.setChallengeCallBackUrl("/jeepay/challenge");
req.setNotifyUrl("/jeepay/notify");

try {
    TransferOrderResponse rsp = client.transfer(req);
    if (rsp.isAccepted()) {
        // state == 1,已受理,終態等結果通知
    }
} catch (JeepaySdkException e) {
    if (e.getCode() != null) {
        // 平台已明確拒絕,可按 msg 決策
    } else {
        // 網絡或本地錯誤,結果未知,查單或等通知
    }
}

6.2 方式 B1:半自動,SDK 託管憑據

生產環境使用 JDBC 或實現了 AtomicKeyValueRepository 的 Redis。內存存儲在進程重啟後會丟失待確認記錄。onChallengeArrived 只做派發,必須冪等,不要在回調線程裡跑審批。

SplitHandshakeFlow flow = client.splitHandshake();
TransferOrderResponse pre = flow.precreate(req);
// pre.getState()==52,pre.remainingChallengeMs() 是審批剩餘時間

// 審批通過後,任意線程都可以確認
flow.approve(mchOrderNo);                  // reviewState=1,返回 state=1
// flow.reject(mchOrderNo, "收款人姓名不符"); // reviewState=0,關單 state=4

flow.expireStale(200); // 定時收拾本地超窗記錄,不呼叫平台

生產環境的挑戰碼有效期為 30 分鐘。審批必須在 challengeExpireAt 之前完成;超過 30 分鐘時,應先審批、再 precreate、拿到挑戰碼後立即確認。

方式 B2:自己保存憑據

HandshakeOrchestrator hs = client.handshake();
TransferOrderResponse pre = hs.precreate(req);

CallbackResult cb = client.toolkit().callback().parseAndVerify(rawBody);
if (!cb.isTrusted()) {
    return "FAIL";
}
hs.verifyBinding(cb, localPlatformOrderId, localMchOrderNo, localAmount, localCurrency);
// 保存 TransferConfirmRequest.from(cb),挑戰碼建議加密

TransferOrderResponse ok = hs.approve(savedRequest);
方式 B2 與方式 C 不要使用 callbackHandler().handle()。那個入口只會把挑戰碼路由給方式 A 的等待線程或方式 B1 的本地記錄。沒有路由目標時會回 FAIL。

6.3 方式 C:只借用 Toolkit

JeepayToolkit kit = client.toolkit();
Map<String, Object> params = new LinkedHashMap<>();
params.put("mchOrderNo", "MO20260728001");
params.put("ifCode", "testpay");
params.put("entryType", "API");
params.put("amount", "1000.00");
params.put("currency", "BRL");
params.put("accountNo", "12345678901");
params.put("accountName", "Maria Silva");
params.put("transferDesc", "Pagamento");
params.put("challengeCallBackUrl", "/jeepay/challenge");
params.put("notifyUrl", "/jeepay/notify");
params.put("mchNo", mchNo);
params.put("signType", "RSA2");
params.put("timestamp", String.valueOf(kit.genTimestamp()));
params.put("nonce", kit.genNonce());
params.put("sign", kit.sign(params));

String resp = myHttp.postJson(gateway + "/api/v2/transferOrder/precreate", JSON.toJSONString(params));
JSONObject json = JSON.parseObject(resp);
String plain = CryptoTool.decryptEnvelope(json.getString("encryptData"), privateKey);
boolean ok = SignTool.verify(JSON.parseObject(plain), json.getString("sign"), platformPublicKey);

JeepayToolkit.of(mchNo, privateKey) 會關閉平台驗簽。需要驗證回調與通知時,使用 client.toolkit(),或直接呼叫 SignTool.verify。

6.4 兩個回調端點

@PostMapping("/jeepay/challenge")
public String onChallenge(@RequestBody String rawBody) {
    return client.callbackHandler().handle(rawBody); // 方式 A / B1
}

@PostMapping(value = "/jeepay/notify", consumes = MediaType.ALL_VALUE)
public String onNotify(HttpServletRequest request) throws IOException {
    NotifyResult r = client.notifyHandler().handle(rawPayload(request));
    if (r.isTrusted()) {
        settleService.settleIdempotent(
                r.getMchOrderNo(), r.getPlatformOrderId(),
                r.isSuccess(), r.getErrCode(), r.getErrMsg());
    }
    return r.getReplyBody(); // SUCCESS 或 FAIL
}

結果通知默認是 form。若過濾器先讀過參數,@RequestBody 可能拿到空串。JSON 直接讀 body;form 與 query 則從參數表還原成一層 JSON 再交給 SDK,避免二次 URL 解碼改變簽名基串。

入賬以 mchOrderNo 或 transferId 去重。通知可能重投。不可信报文回 FAIL,讓平台重試。不要無條件返回 SUCCESS。

6.5 查單示例

TransferQueryResponse q = client.query()
        .queryTransferOrder(TransferQueryRequest.ofMchOrderNo("MO20260728001"));
// 用 q.getState() 判斷終態。不要用 q.getAmount() 對賬。

6.6 聯調清單

7. 使用示例:Go SDK

協議、簽名、信封與錯誤碼與第 3 至 5 節相同,只使用 RSA2。服務端路徑也相同:/api/v2/transferOrder/precreate、/api/v2/transferOrder/confirm、/api/v2/transfer/query。下面只寫 Go 的組裝方式。

模組 github.com/jeequan/jeepay-trade-sdk-go,Go 1.21 及以上。主模組沒有第三方依賴。JeepayClient 可以併發使用,全域放一個實例。

go get github.com/jeequan/jeepay-trade-sdk-go
import (
    "github.com/jeequan/jeepay-trade-sdk-go/callback"
    "github.com/jeequan/jeepay-trade-sdk-go/client"
    "github.com/jeequan/jeepay-trade-sdk-go/common/consts"
    "github.com/jeequan/jeepay-trade-sdk-go/common/entity"
    "github.com/jeequan/jeepay-trade-sdk-go/request"
    "github.com/jeequan/jeepay-trade-sdk-go/sdk/cryptox"
    "github.com/jeequan/jeepay-trade-sdk-go/sdk/payloadx"
    "github.com/jeequan/jeepay-trade-sdk-go/sdk/replayx"
    "github.com/jeequan/jeepay-trade-sdk-go/sdk/signx"
)

平台單號在 SDK 裡叫 PlatformOrderID。上行報文仍是 transferId,挑戰碼回調仍是 platformOrderNo,由 SDK 對應。

方式入口適用
A 全自動request.NewRequest(cli).Transfer下單協程可以阻塞等待,沒有人工審批
B1 分步託管request.NewSplitFlow有異步審批,希望 SDK 保存挑戰碼與本地狀態
B2 低階編排request.NewRequest 的 Precreate / Approve已有審批單表,自己保存憑據
C 自串流程signx、cryptox、payloadx、replayx必須走自有 HTTP 客戶端,只借用加簽驗簽

生成密鑰與客戶端

go run ./examples/keygen
priv, pub, err := cryptox.GenerateKeys(2048) // 低於 2048 位直接返回錯誤
// priv:PKCS#8 Base64,只留在商戶側
// pub :X.509 Base64,提交給平台

PEM 頭尾與換行可以保留,SDK 會剝離。私鑰使用 PKCS#8。

cli, err := client.NewClient(client.SDKConfig{
    ServerHost:        "https://ibzfancakcwlim.n96j1j.app", // 結尾有沒有 / 都可以
    MchNo:             "M0000000001",
    PrivateKey:        merchantPrivateKeyPKCS8,
    PlatformPublicKey: platformPublicKey,
}, client.WithReplayWindowSec(300))
api := request.NewRequest(cli)

生產環境保持平台驗簽開啟。WithVerifyPlatformSign 同時管響應驗簽和下行驗簽,關掉等於全關。ServerHost、MchNo、PrivateKey 為空時,NewClient 直接返回錯誤。

7.1 方式 A:全自動

Transfer 內部完成 precreate、阻塞等待挑戰碼、用數值核對金額與幣種、再以 reviewState=1 確認。實際等待時間是 min(WithChallengeWaitTimeout, 剩餘有效期)。預設等待上限 60 秒;生產挑戰碼有效期為 30 分鐘,沿用 60 秒會在挑戰碼仍有效時提前返回。請按自己的閘道超時用 WithChallengeWaitTimeout 調整。

回調必須由另一個協程進入,端點要先於下單可用。多實例要注入共享的挑戰碼存儲,否則回調落到別的實例就會等到超時。多實例同時要注入 WithNonceStore。Go SDK 在漏配共享 nonce 存儲時不會打警告。

req := &entity.TransferOrderRequest{
    MchOrderNo:           "MO20260728001",
    IfCode:               "testpay",
    EntryType:            "API",
    Amount:               "1000.00",
    Currency:             "BRL",
    AccountNo:            "12345678901",
    AccountName:          "Maria Silva",
    TransferDesc:         "Pagamento",
    ChallengeCallBackURL: "/jeepay/challenge",
    NotifyURL:            "/jeepay/notify",
}
rsp, err := api.Transfer(ctx, req)
if err != nil {
    // 平台業務錯誤是 *entity.APIError,Code 即第 5 節的 code
    return
}
if rsp.IsAccepted() { // state == 1
    // 已受理,終態等結果通知
}

rsp.ChallengeExpireAt 是 *int64 毫秒時間戳,只在 precreate 響應裡有。rsp.RemainingChallengeMs() 是剩餘毫秒;平台沒下發時為 nil。

7.2 方式 B:半自動

B1 與 B2 都把 precreate 和 confirm 拆開。差別是挑戰碼由誰保存。審批必須在 challengeExpireAt 之前完成。生產有效期為 30 分鐘;超過 30 分鐘時,先審批,再 precreate,拿到挑戰碼後立即確認。

B1 NewSplitFlowB2 NewRequest
憑據SDK 保存。生產用 store/sqlstore 或實現了原子比較交換的 Redis自己的審批單表
回顯核對落庫前由 SDK 完成。不一致回 FAIL,不落庫自己呼叫 VerifyBinding
確認入參只需要 mchOrderNo*entity.TransferConfirmRequest
回調manager.HandleChallengecli.ParseCallback。不要用 HandleChallenge

內存存儲在進程重啟後會丟失待確認記錄。查不到記錄時 Find 返回 (nil, nil);只有存儲故障才返回 error。同一 mchOrderNo 重複登記返回 entity.ErrDuplicateRegistration。

B1

flow := request.NewSplitFlow(cli, pendingStore)
pre, err := flow.Precreate(ctx, req)
// pre.State == consts.StateLockedPendingConfirm (52)
if left := pre.RemainingChallengeMs(); left != nil {
    log.Printf("留給審批的時間: %dms", *left)
}
// 挑戰碼回調裡呼叫 manager.HandleChallenge。
// 到達後 SDK 同步呼叫你註冊的處理函數:
// func(ctx context.Context, rec *consts.PendingConfirm) error
// 這個函數必須冪等,只派發審批,不要在回調裡做審批本身。
rs, err := flow.Approve(ctx, mchOrderNo)                 // reviewState=1,state=1
// rs, err := flow.Reject(ctx, mchOrderNo, "收款人姓名不符") // reviewState=0,state=4

本地狀態從 PRECREATED 到 PENDING_APPROVAL,確認時先佔用 CONFIRMING,再進入 CONFIRMED 或 REJECTED。超時進 EXPIRED。flow.ExpireStale(ctx, 200) 只清理本地超窗記錄,不呼叫平台。

B2

pre, err := api.Precreate(ctx, req)
cb := cli.ParseCallback(ctx, rawBody)
if !cb.Trusted {
    return consts.ReplyFail
}
if err := api.VerifyBinding(cb, pre.PlatformOrderID, pre.MchOrderNo, pre.Amount, pre.Currency); err != nil {
    return consts.ReplyFail // 回顯不一致,不要確認
}
// 保存 cb.ApproveChallenge,建議加密。確認時用本地 precreate 的單號。
rq := &entity.TransferConfirmRequest{
    PlatformOrderID:  pre.PlatformOrderID,
    ApproveChallenge: savedChallenge,
}
rs, err := api.Approve(ctx, rq) // 等同 Confirm(ctx, rq, 1, "")

手上同時有 precreate 響應和回調對象時,用 api.ConfirmWithPrecreate(pre, cb, 1, "")。單號以 precreate 響應為準,回調只提供挑戰碼。VerifyBinding 按十進制數值比較金額,1000 與 1000.00 視為相同;幣種忽略大小寫。

7.3 方式 C:只借用安全函數

參與簽名的 JSON 用 payloadx.UnmarshalObject。不要用 json.Unmarshal,數字會變成 float64,大整數和小數格式會和平台基串不一致。時間戳用 replayx.GenTimestamp(),單位是毫秒。time.Now().Unix() 是秒。

params := map[string]interface{}{
    "mchOrderNo":           "MO20260728001",
    "ifCode":               "testpay",
    "entryType":            "API",
    "amount":               "1000.00",
    "currency":             "BRL",
    "accountNo":            "12345678901",
    "accountName":          "Maria Silva",
    "transferDesc":         "Pagamento",
    "challengeCallBackUrl": "/jeepay/challenge",
    "notifyUrl":            "/jeepay/notify",
    "mchNo":                "M0000000001",
    "signType":             "RSA2",
    "timestamp":            strconv.FormatInt(replayx.GenTimestamp(), 10),
    "nonce":                replayx.GenNonce(),
}
sign, err := signx.Sign(params, merchantPrivateKeyPKCS8)
params["sign"] = sign
// POST JSON 到 ServerHost + "/api/v2/transferOrder/precreate"
// 先簽名再往 body 追加字段,基串會和報文不一致

resp, err := payloadx.UnmarshalObject(responseBody)
plain, err := cryptox.DecryptEnvelope(payloadx.GetString(resp, "encryptData"), merchantPrivateKeyPKCS8)
data, err := payloadx.UnmarshalValue(plain)
ok := signx.Verify(data.(map[string]interface{}), payloadx.GetString(resp, "sign"), platformPublicKey)

基串請用 signx.BuildSignContent。它按 Java 的 UTF-16 大小寫不敏感順序排序。Go 的 sort.Strings 是 UTF-8 字節序,中文和補充平面字元會和平台不一致。第 4 節的樣例基串必須逐字節相同。

下行建議仍用 cli.ParseCallback / cli.ParseNotify。自己寫時順序是:先验簽,再查必填字段,再看是否過期,最後才消費 nonce。字段殘缺的報文不應先佔用 nonce。

cryptox.DecryptEnvelope 解開的是響應信封,OAEP 為 SHA-256 + MGF1-SHA256。cryptox.Encrypt / Decrypt 是短字段工具,MGF1 使用 SHA-1,兩者不能混用。

7.4 回調端點

方式 A / B1 的挑戰碼回調把原始 body 交給 manager.HandleChallenge,並原樣寫出回執。未命中等待中的下單或 B1 記錄時回 FAIL。方式 B2 / C 用 cli.ParseCallback,自己決定回執。

manager := callback.NewCallbackManager(cli,
    callback.WithRequest(api),              // 必須是發起 Transfer 的那個實例
    callback.WithPendingConfirmStore(store), // 必須是 NewSplitFlow 用的那個存儲
)
http.HandleFunc("/jeepay/challenge", func(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    cb := manager.HandleChallenge(r.Context(), string(body))
    io.WriteString(w, cb.ReplyBody) // SUCCESS 或 FAIL
})

Trusted == false 表示驗簽、防重放、必填字段或回顯核對未通過,不能拿裡面的挑戰碼去確認。FailReason 以綁定不一致前綴開頭時,是挑戰碼被挪到另一筆訂單。ProcessingError != nil 表示報文可信,但存儲或審批派發失敗,回執是 FAIL,平台會重投。

結果通知預設是 form。中間件如果先讀過表單,body 會是空的。JSON 直接讀 body;form 與 query 還原成一層 JSON 再交給 SDK,避免二次 URL 解碼。

http.HandleFunc("/jeepay/notify", func(w http.ResponseWriter, r *http.Request) {
    raw, err := rawPayload(r)
    if err != nil {
        io.WriteString(w, consts.ReplyFail)
        return
    }
    res := manager.HandleNotify(r.Context(), raw)
    if res.Trusted && res.ProcessingError == nil && res.IsFinal() {
        settle(res.MchOrderNo, res.PlatformOrderID, res.IsSuccess(), res.ErrCode, res.ErrMsg)
    }
    io.WriteString(w, res.ReplyBody)
})

通知報文不含 stateStr 與 payType,對應字段是空值。用 res.State 與 res.IsSuccess() 判斷。終態是 2、3、4、6、7。入賬以 MchOrderNo 或 PlatformOrderID 去重。

7.5 查單與餘額

查單路徑是 POST /api/v2/transfer/query。餘額路徑是 POST /api/v2/mch/balance/query。兩者使用同一套 RSA2 信封。mchOrderNo 與 transferId 至少傳一個。訂單不存在時 State 可能為 nil,不要把它當成 0。入賬金額以下單金額與結果通知為準,不要用查單返回的 Amount 入賬。

q := request.NewQuery(cli)
rs, err := q.QueryTransferOrder(ctx, &entity.TransferQueryRequest{MchOrderNo: "MO20260728001"})
if err != nil {
    return err
}
if rs.IsSuccess() {
    // 出款成功。結果通知隨後仍可能到達,入賬必須冪等
} else if rs.IsFinal() {
    // 已到終態但未成功
}
bs, err := q.QueryMchBalance(ctx, &entity.MchBalanceQueryRequest{Currency: "BRL"})
if b := bs.Get("BRL"); b != nil && b.IsUsable() {
    // b.AvailableBalanceAmount 是國際單位字符串
}

7.6 Go 對接時多留意的幾點

文中的商戶號、收款賬號、單號、挑戰碼與簽名占位符都是虛構示例,不可用於真實交易。私鑰只保存在商戶服務端,不要寫入日誌、前端或本文件。