先說結論:差別不在功能表
信用卡、ATM 虛擬帳號、超商代碼、超商條碼、分期、定期定額 —— 這些兩家都有。所以拿功能表來選,你會選不出來,然後隨便挑一家,再在實作階段發現真正的差別。
真正會影響你工期與風險的差別只有四類:
- 加密與簽章的組法 —— 決定你第一週會不會卡在一個看不出原因的參數錯誤。
- 測試環境怎麼拿 —— 決定你能不能今天下午就開始做技術驗證。
- 非同步結果怎麼處理 —— 決定你上線之後會不會出事,這是唯一一條會賠錢的。
- 物流與後台設定的語意 —— 決定你會不會為了一個誤解,多架一台伺服器、或者不小心把所有客人擋在門外。
加密:一邊是 AES-128-CBC,一邊是 AES-256-GCM
綠界的站內付 2.0 用的是 AES-128-CBC。PayUni 的 UPP 用的是 AES-256-GCM。差別不只是位元數 —— CBC 只產生密文,而 GCM 除了密文還會多產生一段 16 bytes 的驗證標籤(authentication tag),PayUni 要求你把這兩段分開放進同一個欄位裡。
EncryptInfo 的組法大致是這樣:
// 參數先組成 URL query string(key=value&key=value),不是 JSON
plain = build_query(params)
cipher, tag = AES_256_GCM(plain, key = HashKey, iv = HashIV)
EncryptInfo = hex( base64(cipher) + ":::" + base64(tag) )
HashInfo = UPPER( SHA256( HashKey + EncryptInfo + HashIV ) )
整串 base64 用「三個冒號」接起來之後,再整個轉成十六進位。最容易錯的是第三行,而且照 PHP 的 SDK 改寫時不會發現:PHP 的加密函式會把 tag 從一個獨立參數還給你,兩段本來就是分開的。Node、Deno 與瀏覽器共用的 Web Crypto 不是這樣:
crypto.subtle.encrypt 回傳的是 ciphertext 後面直接接著 tag 的一整包。tag 是最後 16 bytes,你必須自己切下來,前面剩下的才是 cipher。
buf = await crypto.subtle.encrypt(alg, key, plain)
bytes = new Uint8Array(buf)
cipher = bytes.slice(0, -16) // 前面是密文
tag = bytes.slice(-16) // 最後 16 bytes 是驗證標籤
沒切開的話,你送出去的東西格式完全合法 —— 是十六進位字串、有那三個冒號、長度也合理 —— 對方就是解不開,回給你的訊息通常只會說參數有誤。這種錯最貴,因為它一點都不像加密問題:你會先去翻參數表、翻文件、懷疑金鑰抄錯,最後才想到去看 tag。
另外兩個同樣會產生「症狀像金鑰錯」的細節:
HashInfo一定要轉大寫。小寫的 SHA-256 也是一個完全合法的十六進位字串,比對就是不會過。- 版本號(
Version)只放在外層表單,不要放進EncryptInfo裡;而商店代號(MerID)剛好相反,它要放在EncryptInfo裡面。這兩個欄位的位置弄反,症狀跟金鑰錯一模一樣。
綠界那邊因為沒有 tag 這一層,組裝步驟少一個環節,第一次串比較快通。這是取捨不是優劣:GCM 本身帶完整性驗證,安全性模型比較新,代價是實作時多一個你必須知道的步驟 —— 而你只要知道一次。
沙盒:你今天能不能開始測
這一條會直接影響你的評估時程,但幾乎沒有人在選型時想到它。
綠界有公用的測試帳號,任何人拿到那組資料就能立刻在測試環境跑完整流程。意思是你可以在還沒跟任何業務聯絡之前,先花一個下午把技術可行性驗完,再決定要不要往下談。
PayUni 的沙盒要到另一個網域另外註冊,而且沙盒帳號與正式帳號完全獨立、不互通,金鑰是兩組。你不能拿正式後台的金鑰去打沙盒,也不能反過來。
實務上的意思是:如果你排的是「這週先做技術驗證」,要先留一段申請時間 —— 那是行政流程不是工程時間,但一樣會吃掉甘特圖。反過來看,兩組金鑰完全隔離也是好事:測試環境不可能誤用到正式金鑰,而那種事故是會真的扣到錢的。
不用刷卡,也能確認金鑰是對的
這一招兩家都適用,是我們現在接每一組新金鑰的第一步。
串接初期最浪費時間的循環是:組好參數、送出、被拒,然後不知道到底是金鑰錯、參數錯、還是後台功能沒開通。三個變因綁在一起,只能一個一個試。
有一個成本極低的做法可以把「金鑰對不對」單獨切出來驗證:對交易查詢端點,送一個根本不存在的訂單編號。
- 回「查無此筆訂單」
- 金鑰是對的。對方成功解開了你的加密,只是資料庫裡查不到那一筆單。金鑰、IV、簽章組法、參數命名全部正確。
- 回解密失敗/驗證失敗
- 金鑰或組法有問題。而且這時候你可以確定問題只在加密這一層,不用再去懷疑業務參數。
一次真實刷卡都不用,也不會留下需要作廢的測試交易。客戶自己申請金鑰、用截圖傳過來的那種情況(很常見),這一步三分鐘內就能確定他有沒有傳錯行、有沒有把測試金鑰當成正式金鑰。
UNKNOWN 絕對不能當成付款失敗
這一段如果你只能記住一件事,就記這個。前面幾條踩到最多損失一天工時,這一條會賠錢。
刷卡是一個非同步流程。金流商把授權請求送出去之後,如果銀行在大約 60 秒內沒有回應,它不會把交易判成失敗,而是回一個「我還不知道」的狀態 —— 在 PayUni 是 UNKNOWN 或 UNAPPROVED,交易狀態碼 8。真正的結果會由後續的 Notify 補送過來,或是你在大約 15 分鐘後主動查詢才拿得到。
如果你的程式把這個狀態當成失敗處理,會發生下面這一串:
- 訂單被標記為付款失敗。
- 「未付款自動取消」的排程掃到它,取消訂單、把庫存還回去。
- 幾分鐘後,金流商補送一個付款成功的通知進來。
- 你的系統現在同時有一筆已取消的訂單、一筆真實的收款,跟一份對不起來的庫存 —— 而消費者那邊,卡是真的被刷了。
更糟的是它通常不會在測試時被發現:測試環境的銀行回應很快,你永遠拿不到那個狀態。它只在正式環境、銀行系統忙的時候出現 —— 而那通常就是你檔期最大的那一天。
收到 UNKNOWN 時,訂單要維持在「處理中/保留」,不要釋放庫存、不要標記失敗。最終狀態一律由 Notify 或延遲查詢決定。
落到程式碼裡是四件事:
- 訂單狀態機要有一個明確的中間態,而不是只有「已付款」跟「未付款」兩格。
- 前端導到一個「付款結果確認中」的頁面,明白告訴消費者不要重複付款。這一頁的文案比你想像中重要 —— 沒有它,客人就會再刷一次。
- Notify 的處理必須冪等:同一筆通知送三次,結果要跟送一次一樣。用金流商的交易序號當去重鍵,不要用你自己的訂單編號。
- 自動取消的排程要把中間態排除在外,否則它會跟後來補送的成功通知打架。
這個坑跟你選哪一家沒有關係 —— 根因是銀行授權本來就是非同步的,任何金流都一樣。差別只在於你有沒有在動手之前先把訂單狀態機畫出來。
規格書有寫,但你一定會漏掉的五條
這五條都白紙黑字寫在文件裡。會漏掉不是因為沒讀,是因為它們散在不同章節,而且每一條都要等到某個特定情境才會爆。
- MerTradeNo(訂單編號)
- 長度上限大約落在 20 到 25 個英數字元(依端點而異),而且十分鐘內不可以重複。一般網站的訂單編號常常帶連字號、帶完整日期,長度早就超過,不能直接用。做法是另產一組短碼給金流商,再用一張對照表對回你自己的訂單;消費者重試付款時要換新的短碼。
- UsrMail(消費者信箱)
- 空值不可以送,會被判成格式錯誤。有 email 才帶這個欄位,沒有就整個不要組進去。所有選填欄位都用「有值才加入」的寫法,不要先給預設空字串 —— 這個習慣可以一次擋掉一整類格式錯誤。
- ExpireDate(繳費期限)
- 超商上限是 7 天,ATM 虛擬帳號可以到 180 天。而且如果你設超過 7 天,整合式支付頁上的超商付款選項會直接不顯示 —— 不會報錯,就是消失。有人回報「怎麼沒有超商可以選」的時候,先看這個欄位。
- Notify / Return URL
- 只接受 80 與 443 埠。開發時用其他埠開隧道測回呼,通知會收不到,而且你不會拿到任何錯誤 —— 就是安靜地沒有。
- 分期期數
- 送出去的期數必須是你在金流後台實際開通的期數,送了沒開通的期數會失敗。而且手續費率是隨期數跳升的:一次付清大約 2.8%,到 12 期會來到 6.5% 左右(實際數字以你簽的合約為準)。要不要開高期數是毛利決策,不是技術決策 —— 先跟決定價格的人確認,再把選單寫死。
物流:我們多架了一台伺服器,其實可以不用
這一段是我們自己的誤判。寫出來是希望你不要重走這一趟。
金流商的物流通常有兩條路:
- 幕後 API —— 由你的伺服器直接呼叫,建立物流單、取得門市資料、補印單據。
- 整合式支付頁帶物流參數 —— 把物流需求隨著付款請求一起送出,消費者在金流商的頁面上自己選門市、填收件人,由金流商建單。
幕後 API 有一個很硬的限制:只接受你事先登記的固定來源 IP。而現在的網站大多跑在 serverless 上,對外 IP 是浮動的、隨時可能換。於是我們的結論是「那就架一台有固定 IP 的機器當中繼站,把請求轉發過去」—— 也真的架了,連 HTTPS 憑證都設好了。
後來才發現,我們要的那個功能,第二條路就做得到。走整合式支付頁的話,整個流程是瀏覽器的表單提交,來源 IP 是消費者自己的,根本不涉及來源 IP 白名單,也就不需要中繼站。
在決定要不要為了一個限制去架基礎設施之前,先確認同一件事有沒有「走瀏覽器」的做法。多一台機器就是多一個要監控、要續憑證、會半夜掛掉的東西。
需要幕後 API 的情境是真的存在的 —— 後台批次建單、客服補印、非消費者觸發的物流動作,那些都沒有瀏覽器可以借。但如果你只是要讓消費者在結帳時選一間門市,你不需要那台機器。
後台那個「IP 交易限制」不是白名單
承上。你在金流後台會看到一個叫「IP 交易限制」的設定欄位。如果你剛好正在處理固定 IP 的問題,很自然會把它讀成「API 來源白名單 —— 把我的伺服器 IP 填進去,我就能呼叫」。
它不是。它的語意是只有名單上的 IP 可以完成交易,是一個限制消費者付款來源的防詐功能。
所以如果你把自己的伺服器 IP 填進去並且啟用,結果是:全世界的真實客人都無法付款,只有從那個 IP 出去的請求可以。而且這種錯很可能在測試時不會被發現 —— 你自己測是通的,客人一來就全掛,然後你會先去看程式碼。
通則:不確定一個後台欄位的語意時,寧可先問客服,也不要憑名字推測就打開它。 金流後台的設定大多是「立即生效、影響所有真實交易」,那不是一個適合用試的方式學習的地方。
執行環境也會咬你:text/html 被改成 text/plain
串金流最常見的做法之一,是後端組好一段「自動送出的 HTML 表單」直接回給瀏覽器,讓它自己 POST 到金流商的付款頁。這在一般的 Node 伺服器上完全沒問題,範例程式碼也多半是這樣寫的。
但我們把這段邏輯放在邊緣執行環境(Supabase Edge Functions)時發現,它會把回應的 Content-Type 從 text/html 強制改成 text/plain。結果是瀏覽器把整段 HTML 當成純文字顯示 —— 使用者看到的是一頁原始碼,不是付款頁。
這不是金流商的問題,也不是你程式碼的問題,是執行環境的行為。解法很簡單:
後端只回 JSON(付款網址 + 所有欄位),由前端組表單再送出。
這個寫法還有兩個附帶好處:前端可以在送出前先顯示「轉跳中」,避免使用者以為當掉了;而且同一支 API 換到任何執行環境都能用。
更一般化的教訓是:金流的問題不一定出在金流。 序列化、時區、Content-Type、字元編碼、執行環境的預設值,這些都會偽裝成串接錯誤。
最後一個坑跟金流無關,但害我們最久
付款送出的那個 handler 用 React 的 useCallback 包起來,相依陣列漏了幾個 state。
症狀是:宅配訂單送到金流商的收件地址是空的,超商取貨的旗標沒帶到。看起來完全像是串接寫錯 —— 參數對不上、欄位漏掉 —— 我們也真的往那個方向找了很久。實際上是 handler 的閉包裡拿到的是上一次 render 的舊值:使用者選了超商、填了地址,但送出去的是他還沒填之前的狀態。
只要你在 handler 裡讀了某個 state,它就必須出現在相依陣列裡。改 handler 用到的 state,一定要同步補相依陣列。
而更務實的防線是:在真正送出之前,把最終 payload 完整記錄一次(去掉敏感欄位),然後比對「畫面上是什麼」與「送出去的是什麼」。這一步會讓這一整類 bug 在五分鐘內現形,而不是兩天。
那到底要選哪一家
不要問哪一家比較好 —— 兩家都是正常運作的服務商,該有的功能都有,文件也都算完整。問這三個問題:
- 你的時程有多緊? 如果這週就要做技術驗證,公開測試帳號會讓你快一些;如果評估期本來就有兩三週,多一道沙盒申請流程完全不影響。
- 你的執行環境是什麼? serverless 或邊緣執行環境要特別注意三件事:加密函式庫的行為(GCM 的 tag)、回應的 Content-Type、以及有沒有需要固定出口 IP 的功能。這三件事會決定你的實作長什麼樣,比 API 好不好看重要得多。
- 你真正需要的功能組合是什麼? 物流、分期、定期定額、代收代付,每一項在兩家的開通流程、審核時間、費率都不一樣,而這些多半是商務條件不是技術條件。先跟業務問清楚,再開始寫程式。
真正決定專案順不順的,從來不是功能表上有沒有那一格,而是上面那些規格書沒有標紅字的地方。