代付三次握手與 SDK 對接
challengeExpireAt 之前完成。金額傳 "1000.00" 這種國際單位字符串,不是分。回調地址只傳以 / 開頭的路徑,域名使用平台為商戶配置的回調域名。
1. 接口概述
三次握手把「鎖單」與「出款」拆開。商戶先提交代付要素並鎖定訂單,此時不動資金。平台再下發一次性挑戰碼。商戶核對回顯要素後,帶挑戰碼確認或拒絕。平台受理後異步出款,並以結果通知回傳終態。
precreate。平台建立訂單,狀態 52,尚未出款。challengeCode。confirm。通過則進入代付隊列(狀態 1);拒絕則關單(狀態 4)。2、失敗 3、關單 4。商戶回執後入賬,且必須冪等。三條通道互不復用:
| 通道 | 方向 | 地址 | 投遞 | 回執 |
|---|---|---|---|---|
| 接口調用 | 商戶 → 平台 | https://ibzfancakcwlim.n96j1j.app + 固定路徑 | POST JSON | 平台返回 JSON 信封 |
| 挑戰碼回調 | 平台 → 商戶 | 平台域名 + challengeCallBackUrl | 恆定 POST JSON | 響應體 SUCCESS |
| 結果通知 | 平台 → 商戶 | 平台域名 + notifyUrl | 預設 form,可改 JSON | 響應體 SUCCESS |
沒有拉取挑戰碼的接口。挑戰碼只從回調到達。收不到挑戰碼就無法完成第二次握手,訂單會在有效期加寬限期後被關單。
工作原理
- 商戶用自己的 RSA 私鑰對扁平 JSON 簽名,並帶上毫秒
timestamp與一次性nonce。 - 平台用商戶公鑰驗簽,校驗防重放,鎖定訂單要素,異步推送挑戰碼。
- 商戶用平台公鑰驗回調,核對單號與幣種,並按數值核對金額,再決定
reviewState。 - 確認時挑戰碼只可消費一次。通過後資金動作異步執行,終態只靠結果通知或查單收斂。
2. 技術規範
| 項目 | 規範 |
|---|---|
| 協議 | HTTP。生產環境使用 HTTPS。不使用 SOAP。 |
| 風格 | REST 風格的 JSON API。上行固定 POST。 |
| Content-Type | application/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 SDK | JDK 8 及以上。傳遞依賴只有 fastjson 1.2.83+。HTTP 使用 JDK HttpsURLConnection。 |
| Go SDK | Go 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 | 是 | String | RSA2 簽名,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 傳入,不必事先報備。
簽名算法
上行請求、解密後的響應明文、挑戰碼回調、結果通知使用同一套基串:
- 去掉
sign,以及值為 null 或空串的字段。 - 每項拼成
key=value&。每一項結尾都帶&,整串也以&結尾。 - 對拼好的整串做大小寫不敏感排序,不是先按 key 排序再拼接。
- 對 UTF-8 基串做
SHA256withRSA,結果 Base64。
accountName=Maria Silva&accountNo=12345678901&amount=1000.00&challengeCallBackUrl=/jeepay/challenge¤cy=BRL&entryType=API&ifCode=testpay&mchNo=M0000000001&mchOrderNo=MO20260728001&nonce=Zm9vYmFyYmF6cXV4¬ifyUrl=/jeepay/notify&signType=RSA2×tamp=1785000000000&transferDesc=Pagamento&
值按原樣字符串拼接。簽名基串裡的金額保持送出時的寫法,不要補零、去零或加千分位。這只約束簽名。回調金額與本地訂單核對時按數值比較。先簽名再往 body 追加字段,會造成基串與報文不一致。
簽名方式只接受 RSA2。signType 固定傳 RSA2。基串只含報文字段,末尾不拼接私鑰。
響應加密
encryptData 是 JSON 字符串,解析後包含:
| 字段 | 內容 |
|---|---|
ek | 用商戶公鑰、RSA/ECB/OAEP(SHA-256 + MGF1-SHA256)加密的 AES 密鑰,Base64 |
iv | AES-GCM 的 12 字節 IV,Base64 |
cipher | AES-256-GCM(tag 128 bit)加密的 data 明文,Base64 |
順序不可顛倒:解析信封 → 商戶私鑰解開 ek → AES-GCM 解開 cipher → 用平台公鑰對明文驗 sign。對密文驗簽一定失敗。Java 的 OAEPWithSHA-256AndMGF1Padding 若不顯式傳 OAEPParameterSpec,MGF1 會落到 SHA-1,解密報 Padding error。
precreate、confirm 與查單解密後的業務數據都是 JSON 對象,驗簽時按上一節逐字段排序。請直接使用解密得到的明文,不要先解析再重新序列化。
防重放與回調安全
- 上行
timestamp與nonce必填,參與簽名。平台默認接受的時間偏差為 ±300 秒。每個nonce只能使用一次。 - 下行同樣校驗時效與 nonce。挑戰碼回調必須帶
timestamp與nonce;結果通知必須帶reqTime與nonce。缺任一項即拒絕。 - 多實例必須把 nonce 存到 Redis 等共享存儲,否則另一個實例會放行重放。
- 回調端點必須公網 HTTPS、證書有效、不做登錄攔截、盡快返回。平台不會攜帶商戶 token。
- 回調域名固定在平台配置,路徑不允許寫成完整 URL,避免平台被導向任意主機。
- 挑戰碼是資金憑據。落庫應加密,日誌不要打印原文。
時間窗口
| 窗口 | 默認 | 影響 |
|---|---|---|
| 挑戰碼有效期 | 生產環境 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不存在或已过期。收到這個碼後必須用新的商戶訂單號重新發起第一次握手。
| code | msg | 處理建議 |
|---|---|---|
| 0 | SUCCESS | 接口成功。繼續看 state |
| 40030001 | mchNo 不存在 | 核對商戶號 |
| 40030002 | mchNo 被禁用 | 聯繫平台 |
| 40030004 | amount 格式錯誤 | 改用國際單位字符串,例如 "1000.00" |
| 40030005 | currency 不支持 | 使用平台已開通的幣種 |
| 40030006 | ifCode 不存在或被禁用 | 向平台確認支付接口代碼 |
| 40030007 | bankCode 不支持 | 向平台確認銀行編碼 |
| 40030012 | 參數錯誤 | 見下方握手場景。修正後用新的 nonce 重試 |
| 40030013 | 獲取商戶應用信息失敗 | 聯繫平台檢查默認應用 |
| 40030014 | 商戶下單幣種不支持 | 改用該商戶已開通的幣種 |
| 40030022 | RSA2 公鑰未配置 | 把與私鑰成對的公鑰提交給平台 |
| 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 | 平台配置異常 | 聯繫平台 |
| 10130115 | ChallengeCode不存在或已过期 | 挑戰碼已作廢。用新訂單號重走第一次握手 |
同為 40030012 的握手場景
下列情況的 HTTP code 都是 40030012,msg 都是「參數錯誤」。請用自己的請求內容判斷原因。
| 場景 | 處理 |
|---|---|
漏了 timestamp、nonce 或其他必填字段 | 補齊後用新的 nonce 重試 |
| 回調地址傳了完整 URL,或平台尚未配置回調域名 | 改成以 / 開頭的路徑,並把域名交給平台 |
reviewState 缺失或不是 0/1 | 顯式傳 1 或 0 |
| 訂單不屬於該商戶 | 停止處理該單 |
| 重複確認、訂單已關單,或確認正在處理 | 不要換挑戰碼重發。以結果通知或查單的終態為準 |
SDK 與網絡錯誤
confirm 超時或連接失敗時,結果未知,平台可能已經受理。保留原 transferId 與同一挑戰碼重試。若平台已處理,會返回 40030012,不會重複出款。最終以結果通知或查單為準。本地憑據寫入後狀態未收斂時,SDK 會拋異常要求人工核對,不要用重試掩蓋。
| 現象 | 處理 | |
|---|---|---|
| 平台響應驗簽失敗 | 核對平台公鑰。確認驗的是解密後明文,不是 encryptData | |
| 解密 Padding error | OAEP 顯式指定 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);
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 聯調清單
- 私鑰為 PKCS#8,公鑰為 X.509,本地自簽自驗通過。
- 用第 4 節的樣例參數,基串逐字節一致;timestamp 為毫秒,nonce 每次不同。
- 平台已寫入商戶公鑰、
RSA2、回調域名,並已提供平台公鑰。 - 兩個端點公網 HTTPS 可達,不在登錄攔截之後,回執是純文本
SUCCESS。 - precreate 返回
state=52且帶challengeExpireAt。 - 挑戰碼回調驗簽通過。單號、幣種與本地一致。金額按數值一致,
"1000"與"1000.00"通過。 reviewState=1返回state=1;reviewState=0返回state=4。- 結果通知驗簽通過後只入賬一次。篡改任一字段應回
FAIL。 - 錯誤挑戰碼失敗後,再傳正確值仍然失敗。過期後必須換新訂單號。
- 已按響應中的
challengeExpireAt確認有效期。生產環境為 30 分鐘。
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 NewSplitFlow | B2 NewRequest | |
|---|---|---|
| 憑據 | SDK 保存。生產用 store/sqlstore 或實現了原子比較交換的 Redis | 自己的審批單表 |
| 回顯核對 | 落庫前由 SDK 完成。不一致回 FAIL,不落庫 | 自己呼叫 VerifyBinding |
| 確認入參 | 只需要 mchOrderNo | *entity.TransferConfirmRequest |
| 回調 | manager.HandleChallenge | cli.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 對接時多留意的幾點
- 回調路徑只接受以
/開頭的路徑。完整 URL、缺少前導/、//host/path、反斜杠都會在本地被Validate拒絕。 - 上行
timestamp與nonce由JeepayClient.Post自動附帶,單位是毫秒。每個 nonce 只能使用一次。 - 下行挑戰碼回調必須有
timestamp與nonce;結果通知必須有reqTime與nonce。缺一即拒絕。 - Go 自帶 HTTP 客戶端的連接超時預設 10 秒,讀取超時預設 30 秒。讀取超時覆蓋整次請求。換成
WithHTTPDoer後,這兩項改由自己的客戶端決定。 - 狀態碼與第 5 節相同。終態用
consts.IsFinalState,成功終態只用consts.IsSuccessState(僅狀態 2)。 - 聯調清單沿用第 6.6 節。另請確認
signx.BuildSignContent與第 4 節樣例基串逐字節一致,並且參與簽名的 JSON 沒有經過encoding/json的預設數字解析。