顧客按下取消,畫面顯示成功。庫存已還回去,下一位顧客也買走了。三秒後,金流送來付款成功通知。
後端該把訂單改回已付款,還是把這則通知當成過期訊息?前者答應出貨卻可能沒貨,後者讓系統漏記已經收進來的錢。
這裡需要同時保留兩件事:錢收到了,這張訂單卻不再能履約。本文選擇讓已取消的訂單維持取消,另外記下收款與退款責任。
一個 status 放不下這張訂單
沿用上一篇取消訂單的虛構商店:單倉、每張訂單一種商品,訂單成立時先預留庫存。現在加入一次全額付款。為了把問題看清楚,暫不處理分次付款、出貨後退貨或多家金流;案例數字與政策都是教學假設。
想像訂單 A 預留最後兩件商品。取消交易先提交,把 A 改成 cancelled,預留改成 released,兩件商品重新可售。訂單 B 隨即買走它們。此時 A 的付款通知才到。
如果付款 handler 只有一行 status = paid,A 會重新進入可出貨清單。把條件補成「只有 created 才能改 paid」,確實擋住了復活;但 handler 若因為更新零列就直接丟掉通知,客服查 A 時仍看不見收款。
因此要分開保存:
- 訂單的履約狀態:
created、paid、cancelled。這裡的paid代表本店已接受付款並保留履約資格。 - 金流的收款紀錄:哪個商戶帳號、哪筆付款、多少錢、什麼幣別,以及已確認收款的事實。
- 退款工作:哪筆收款需要退回,尚待送出、結果待確認,或已取得退款結果。
這裡「入帳」指保存可對帳的收款紀錄,沒有聲稱只靠這幾個欄位就完成會計分錄。即使退款成功,原來曾收款的紀錄也保留,退款另外對應它。
本篇的成功付款指金流確認款項已收取,不能拿「已取得信用卡授權」代替。Stripe 的退款文件就區分已成功付款與 requires_capture:尚未請款的授權需要取消,不能照已請款款項直接退款。實際介接時要依供應商定義轉成內部事件。
決定先後的是本店承諾,不是通知時間
顧客可能早就完成付款,只是通知晚到。本店資料庫無法讓已發生的遠端收款回到過去;比較兩個系統的時間戳,也無法把賣給 B 的商品拿回來。
這間商店選定以下政策:取消交易一旦提交,原訂單不再恢復履約;之後確認的收款,轉入退款處理。 這個界線由店家決定,並非金流替店家保證的順序。
相反地,若付款處理先在本店提交,A 會成為 paid,原預留仍保留。接著進來的「未付款訂單取消」必須拒絕,交給另行定義的已付款取消流程。本篇沒有設計後者,不能沿用未付款訂單直接釋放庫存的流程。
取消先提交
- A 已取消,預留已釋放,可以賣給 B。
- 付款通知晚到,保存收款。
- A 維持取消;收款與唯一退款待辦共同提交。
付款處理先提交
- A 已付款,收款已記錄,預留仍保留。
- 未付款取消取得鎖後重讀 A。
- 拒絕這條取消路徑;已付款取消須另訂規則。
這是本店履約政策。通知先後不代表金流實際收款的先後。
兩條路都從同一張訂單取得鎖。取消入口沿用上一篇的短交易;付款入口也在同一個 MySQL 主庫,以主鍵 SELECT ... FOR UPDATE 讀取訂單,再依目前狀態決定處置。只要所有入口遵守這項約定,同一張訂單的兩次決定便會排出先後。
MySQL 8.4 鎖定讀文件說明,FOR UPDATE 讀取最新可用資料並設排他鎖,鎖在提交或回滾後釋放。這要放在明確交易內;交易外先讀到 created,稍後直接更新,沒有同樣的保護。
通知本身還需要驗證。Stripe webhook 文件明確說明事件可能重送,而且不保證產生順序就是送達順序。本案例的入口先驗簽,以商戶帳號與事件 ID 持久保存收件紀錄,再回覆成功,由背景處理取得訂單鎖。若連收件紀錄都沒存成功,就不能先回成功而把通知弄丟。
收到錢與欠一筆退款,要一起寫下來
測試與正式環境在本案例分開存放。建立付款時,本店已保存金流付款 ID 與訂單的對應,金額使用最小貨幣單位的整數。處理通知時要核對帳號、環境、付款 ID、訂單、金額與幣別;驗簽成功只證明來源,不能取代這些比對。對不上的通知保留到異常佇列供核對,不修改訂單,也不自動退款。
通過比對後,付款處理的交易可以縮成以下規格。這是概念流程,沒有在本文執行真實 MySQL 或金流測試:
BEGIN
鎖定訂單 A,讀取目前狀態
依供應商、金流帳號與付款 ID 取得/建立唯一收款紀錄
記錄已確認收款,不覆寫先前保存的退款結果
A 是 created 且預留 held:改為 paid,預留繼續保留
A 是 cancelled 且預留 released:建立唯一全額退款待辦,A 不變
A 已是 paid:確認為同一筆付款,保持不變
狀態與預留對不上:整筆回滾;原始收件仍保留,錯誤標記另行保存
將這次收件標為已處理
COMMIT
收款紀錄、必要的退款待辦與收件處理結果使用同一條資料庫連線、同一筆交易。若寫下「收到錢」後,程序在建立退款待辦前當掉,交易回滾;尚未完成的收件仍會再被處理。若已提交才當掉,重跑會找到原收款與原退款待辦,不再建立第二份。
事件 ID 去重只擋同一則通知。不同事件仍可能指向同一筆付款,所以收款表另以「供應商、商戶帳號、付款 ID」設唯一鍵;本篇的一次全額退款,也以該收款 ID 設唯一待辦。這個限制不適用分次退款。若收到另一個未綁定的付款 ID,不能因為訂單已 paid 就默默當成重複,應保留並核對是否真的多收一筆。
金流延遲不會再改動 A 的預留,也不會從 B 手上拿走庫存。這正是將付款紀錄獨立出來的用途:接受新事實,同時遵守已經做出的取消承諾。
退款逾時,會再考驗一次這個決定
最直覺的寫法,是在付款交易內呼叫退款 API,成功才一起 COMMIT。問題是資料庫鎖擋不住遠端退款:金流可能已受理退款,HTTP 回覆卻在途中遺失。此時本店 ROLLBACK,遠端也不會把退款一起撤銷。
本案例採用資料庫裡的持久退款待辦。先提交,再由 worker 呼叫金流;暫時只需一個可重啟的背景程序,不必為此另建整套訊息平台。代價是退款不會立即完成,頁面與客服必須看得到處理中及失敗狀態。
每份待辦固定保存退款目的、付款 ID、金額與冪等鍵。呼叫逾時後進入「結果待確認」,後續查詢供應商退款資料,或在供應商保證的期限內,以同一鍵、同一參數恢復請求。不能每次換一個鍵重新退錢。Stripe 的冪等文件說明,API v1 的鍵可能在至少 24 小時後被清除;因此本地永久保存一個鍵,並不代表遠端永久替它去重。超出保證範圍又查不清結果時,停止自動重送,轉人工核對。
拿到退款物件也要看它的狀態。Stripe 文件列出 pending 與 failed 等情況。只有依供應商契約確認成功,才顯示退款完成;即使 worker 重跑或付款成功通知又送一次,也不得把已確認的退款狀態蓋回待退款。
假設尖峰每秒出現 2 筆這種晚到付款,金流暫停服務 10 分鐘,會累積約 2 筆/秒 × 600 秒 = 1,200 筆 待辦。這是教學估算,沒有做壓測。資料庫方案是否夠用,接著要量測的是最老待辦已等多久、恢復後能否清完,以及同一待辦有沒有被重複執行。若持續無法趕上流入速度,再評估增加受控 worker 或獨立佇列;換佇列也不會替代收款與退款的唯一識別。
取消成功,可以和收款成功同時成立
回到 A:取消已提交,庫存已賣給 B,付款通知到了。正確結果是 A 維持取消,A 的收款有紀錄,另有一份退款待辦;B 完全不受影響。資料庫鎖負責排定本店的決定,收款紀錄負責承認外部已發生的事。
另一種可行政策是先把訂單放入「取消確認中」,保留庫存,向金流確認能否取消付款後再答覆顧客。它能減少取消後退款,但顧客得等、庫存也被占住,而且查詢完到下一步之間仍可能發生付款,依然要處理競爭。本案例選擇較快結束訂單,明確接受晚到收款需要退款的成本。
驗收先看 webhook 已回 200、背景程序卻還沒動的空檔。讓收件紀錄提交後暫停程序,再重新啟動:同一筆未處理收件應被找回。接著在「收款已寫入、退款待辦尚未建立」的位置注入例外,確認那筆業務交易整筆回滾,收件仍待處理;恢復後只留下同一筆收款與一份退款待辦。否則 webhook 看起來送達了,錢也記了,卻沒有人負責退。
再用真實 MySQL 的獨立連線與同步屏障排出取消和付款的先後。取消先提交,讓 B 買走庫存,再放行付款處理;除了 A 維持 cancelled、退款待辦一份,也要核對 B 的 held 數量與 SKU 計數沒有變。反過來讓付款先提交,未付款取消就必須被拒絕,預留保持 held。最後用金流 sandbox 或可控替身丟棄退款回覆,再送另一個事件 ID、同付款 ID 的通知,檢查待辦 ID 與冪等鍵不變,畫面仍是結果待確認。這些是待執行的驗收,不是本文已測結果。
下一次檢查付款 handler,就從「訂單已取消」的分支開始:它是直接 return,還是仍會保存收款、建立退款待辦,最後才把收件標成已處理?這幾個寫入是否共同提交,比單看訂單沒有復活更重要。
練習
A 已取消,第一次付款通知建立了退款待辦。worker 呼叫退款後逾時;稍後第二則、不同事件 ID 的付款成功通知抵達,兩則都指向同一筆金流付款。
這時應新增哪些紀錄、沿用哪些識別?客服畫面能不能顯示「已退款」?寫出一個斷言,抓出只靠事件 ID 去重造成的重複退款。
參考思路
第二則收件可以另存,但必須找到原收款與原退款待辦,沿用原退款目的和冪等鍵。逾時只足以顯示退款結果待確認,不能顯示已退款。斷言兩個事件處理後,該收款只有一份全額退款待辦;再核對 worker 的重試仍指向同一操作,而非建立第二次退款。查到供應商的確定結果後,才更新退款狀態。
續篇〈退款待辦只拿 20 筆,為什麼資料庫仍然越查越慢?〉從這張持久待辦表出發,討論到期候選查詢與複合索引的驗收。
