包包baobaolin.com
date
entry
009
topic
integration
rev

付款回調不是通知,是一個寫入端點

金流商打過來的那個 POST 沒有身分、可以被任何人偽造、而且同一筆會重送。它必須做三件事——重算簽章、看懂狀態碼、冪等入帳。少任何一件,出事的地方都在真實金額上。

先講這個端點的性質。串綠界(ECPay)AIO 的時候,你會填一個 ReturnURL,付款完成後綠界的伺服器會 server-to-server POST 到那個網址, application/x-www-form-urlencoded。看起來像一個回報,但它實際上是:

  • 公開的——必須讓外部主機打得到,所以不能藏在內網或要求登入
  • 沒有身分的——沒有 API key、沒有 token、沒有 session
  • 可以被任何人送的——知道網址就能 POST 一份「已付款」過來
  • 會重送的——同一筆交易的回調不保證只來一次
  • 會改金額欄位的——它觸發的是「這張訂單標記為已付款」

把這五點放在一起看,就知道它不能當「通知」寫。它是一個沒有身分驗證、對外開放、會被重放的寫入端點。

一、驗簽:重算 CheckMacValue

身分是靠簽章建立的。綠界每一筆請求與回調都帶一個 CheckMacValue,你要用自己的 HashKey/HashIV 把它重算一次,不符就拒絕。演算法(AIO V5、SHA256):

  1. 取出除 CheckMacValue 外的所有參數,依參數名轉小寫後排序
  2. 串成 HashKey=KEY&a=1&b=2...&HashIV=IV——HashKey 在頭、HashIV 在尾
  3. 整串 URL encode,再轉小寫
  4. 把 .NET 風格的 encode 還原回去(下面詳述)
  5. SHA256 → 十六進位 → 轉大寫

第 4 步是踩坑集中區。綠界的 encode 行為來自 .NET,這幾個字元要還原:

%2d → -    %5f → _    %2e → .    %21 → !
%2a → *    %28 → (    %29 → )    %20 → +

Go 的實作大致長這樣:

func CheckMacValue(params map[string]string, hashKey, hashIV string) string {
    keys := make([]string, 0, len(params))
    for k := range params {
        if k != "CheckMacValue" { keys = append(keys, k) }
    }
    sort.Slice(keys, func(i, j int) bool {
        return strings.ToLower(keys[i]) < strings.ToLower(keys[j])
    })
    var b strings.Builder
    b.WriteString("HashKey=" + hashKey)
    for _, k := range keys { b.WriteString("&" + k + "=" + params[k]) }
    b.WriteString("&HashIV=" + hashIV)

    enc := strings.ToLower(url.QueryEscape(b.String()))
    enc = strings.NewReplacer(
        "%2d", "-", "%5f", "_", "%2e", ".", "%21", "!",
        "%2a", "*", "%28", "(", "%29", ")", "%20", "+",
    ).Replace(enc)

    sum := sha256.Sum256([]byte(enc))
    return strings.ToUpper(hex.EncodeToString(sum[:]))
}

收到 CheckMacValue Error (10200073) 的時候,九成是三件事之一:還原表漏了字元、排序沒有轉小寫、或某個值裡有沒處理到的特殊字元。這三個都不會給你更細的提示,只會給同一個錯誤碼。

開發階段用綠界官方公開的測試商店憑證(文件上就有,不是機密):MerchantID 3002607、 HashKey pwFHCqoQZGmho4w6、HashIV EkRm7iFT261dpevs,測試卡號 4311-9522-2222-2222、安全碼 222。正式那組是憑證,該進加密參數,不進 .env、不進 commit、不貼進對話。

二、狀態碼不是只有成功和失敗

簽章過了之後,第二個判斷是 RtnCode。直覺會寫成 if RtnCode == "1" { 入帳 },這對信用卡是對的,對 ATM 和超商代碼不是。

非同步的付款方式有兩次回調:第一次是「取號成功」(拿到虛擬帳號或超商代碼),第二次才是使用者真的去繳費之後的「入帳」。取號那次的 RtnCode 不是 1,如果你只認 1,就會把取號當失敗;如果你把所有回調都當入帳,就會在使用者還沒付錢時出貨。

所以訂單需要一個真的狀態機,最少三態:

created → pending_payment → paid
                        ↘ expired / failed

取號回調把訂單推到 pending_payment 並記下繳費期限;入帳回調才推到 paid。這件事在只測信用卡的時候完全不會浮現——它會在上線後第一個用超商付款的客人身上浮現。

三、冪等:同一筆會重送

重送的原因很多:網路逾時、你的服務剛好在重啟、或你回應的格式不對(下一節)。金流商的設計假設就是「沒收到明確成功就再送一次」,所以重複到達是正常行為,不是異常

處理方式是拿 MerchantTradeNo 當冪等鍵:已經是 paid 的訂單,收到第二次入帳回調就直接回應成功,不要再寫一次帳、不要再送一次出貨通知、不要再扣一次庫存。

-- 訂單表方向
orders(id, quote_id, merchant_trade_no UNIQUE, amount, status, paid_at)

merchant_trade_no 上那個 UNIQUE 是最後一道防線。它也順帶解決另一件事:綠界要求這個編號 20 碼內、英數、不可重複,重試的訂單必須換號(重複會拿到 10200052)。

回應格式寫錯,它會一直打你

處理完之後,回應必須是純文字的 1|OK。不是 JSON、不是 200 空 body、不是一段 HTML。

回錯了的後果是綠界認定這次通知失敗,於是重送——而你的處理其實已經成功了。結果是同一筆交易被重複送達,冪等沒寫好的話就會重複入帳,而後台還會顯示這筆通知失敗。

這跟 EMQX 把 200 空 body 當成拒絕是同一件事的另一個版本:對方要的是 body 裡的內容,不是狀態碼。回 200 不等於回答了。

金額不從前端來

一個獨立但同樣重要的點:TotalAmount 一定由伺服器端從報價或購物車重新算出來,絕不接受前端傳過來的數字。前端能傳的東西只有「哪一張報價單」。

附帶幾個規格細節:TotalAmount 必須是整數新台幣,有小數直接被拒; MerchantTradeDateyyyy/MM/dd HH:mm:ssEncryptType 固定 1。建立訂單不是呼叫 API,是產一段自動送出的 HTML form POST 到結帳端點。

本機怎麼測

綠界打不進 localhost,所以 ReturnURL 在開發時要有一個對外網址——ngrok http 8080 之類的臨時通道就夠。

另外測試環境的 ATM 虛擬帳號不會真的入帳,第二次回調要用綠界後台的「模擬付款」功能觸發。沒用過這個功能的話,很容易誤以為自己的入帳流程通了,實際上只驗過取號那一半。

值得多做的一件事

把每一次回調的原始 payload 原封不動存一份,連同到達時間與驗簽結果。這不是金流商要求的,是為了事後查得出來。

金流爭議的問題形狀通常是「這筆錢到底有沒有進來、什麼時候、對應哪張單」。如果你只存了處理後的訂單狀態,這些問題只能回答一半;存了原始 payload,就能重放當時收到的東西,包括那些你當時沒解析的欄位。這跟系統事後查不查得出來是同一個考量。

如果只記得一件事

把回調端點當成「來自陌生人的、會重複的寫入請求」來設計,而不是當成一封通知信。 驗簽建立身分、狀態機處理非同步、冪等鍵處理重放——這三件事各自對應一個它會咬你的特性,缺一個就是缺一道防線。

修訂紀錄

  1. 首次發布