Stripe 3D Secure 驗證失敗處理:從 canceled 卡死到根本觸發鏈設計
3D Secure 驗證失敗處理
概述與問題來源
3D Secure 是信用卡的額外身份驗證機制。當持卡人在銀行驗證頁面操作失敗時,Stripe 回傳的其實是 requires_payment_method 狀態並附上 last_payment_error(payment_intent_authentication_failure),PMW 會把它判讀為 Failed 交給 MWeb。
canceled 並不是 3D 驗證失敗當下 Stripe 自然轉入的狀態,而是 MWeb 收到 Failed 後,依規則主動呼叫 Stripe Cancel API 所造成的結果
因此真正的問題出在呼叫 Cancel API 成功、Stripe 端已轉為 canceled 之後,MWeb 用來結案的 CancelTradeOrder(退點數、還購物金、回補庫存、轉單…)中途被中斷,本地訂單狀態沒能同步更新,仍停留在 WaitingToPay。當系統下一次重新查詢時,才會「撞見」Stripe 已是 canceled 的終態
而這個狀態剛好又不在 PMW 查詢邏輯的處理分支中,於是 PMW 與 MWeb 雙邊都無法辨識,訂單就此卡死。
問題監控來源: Slack 3D 驗證失敗問題追蹤討論串 · 分類:資料庫更新異常 / 狀態處理錯誤
💥 影響
訂單卡在 WaitingToPay,Redis Cache 過期後 WebAPI 持續拋出 GetPayProcessDataProcessorException,需仰賴 Console 人工介入才能結案。
🔍 根因
並非「Stripe 直接回傳 canceled 沒人處理」這麼單純,而是「MWeb 呼叫 Cancel API 後、CancelTradeOrder 結案流程中途中斷,造成 Stripe 與 DB 狀態不同步」;下一次重新查詢才撞見 PMW 無分支可判讀的 canceled
🛠️ 解法
短期以 Timeout 機制強制轉單;長期需讓 PMW 把 canceled 對應到 Expired(天生不會重複呼叫 Cancel API),並補強結案流程的防重複執行保護
事件時間軸
實際案例時序(同一筆交易)
建立 payment_method 與 payment_intent,流程正常啟動。
Stripe 回傳 next_action: redirect_to_url,使用者被導向銀行 3D 驗證頁面。
仍需完成 3D Secure 驗證,amount_received = 0,系統持續等待用戶操作。
Stripe 回報 payment_intent_authentication_failure,PMW 收到失敗事件。
MWeb 呼叫 Stripe Cancel API 成功,預期訂單應轉入取消流程。
後續查詢 Stripe 回傳狀態變為 "canceled",PMW 無法解析此狀態,系統開始進入無限迴圈。
關鍵狀態轉換
技術問題分析
核心問題
問題有兩層,缺一不可:① MWeb 的 CancelTradeOrder 結案流程中途中斷,導致 Stripe 已是 canceled 但本地 DB 仍是 WaitingToPay;② PMW 對 canceled 狀態沒有對應的判讀邏輯,讓這個不同步狀態被重新查詢到時無法收斂
- MWeb 收到 Failed(3D 驗證失敗)後呼叫 Cancel API,Stripe 端成功轉為 canceled,但後續 CancelTradeOrder(結案)中斷,DB 訂單狀態沒能同步為 Fail
- 下一次重新查詢時,PMW 將 canceled 歸類為「未處理錯誤」,回傳非明確結果給 MWeb
- MWeb 收到未預期的回應,進入「由 Console 處理」的分支,資料庫訂單狀態持續停留在
WaitingToPay
系統行為異常對照
| 組件 | 預期行為 | 實際行為 | 問題原因 |
|---|---|---|---|
| MWeb(結案階段) | 呼叫 Cancel API 成功後,完整執行 CancelTradeOrder 並同步本地訂單狀態 | CancelTradeOrder 中途中斷,DB 狀態未同步為 Fail | 結案流程非原子化,中斷後無補償機制 |
| PMW(重查階段) | 明確處理 "canceled" 狀態並回傳可判讀的 ReturnCode | 落入 else 分支,回傳「未處理錯誤」 | 狀態判斷邏輯中缺少 canceled 分支 |
| MWeb(重查後) | 依 ReturnCode 更新訂單狀態為取消 | 進入「由 Console 處理」的等待迴圈 | 收到 PMW 未分類的錯誤回應,找不到對應處理路徑 |
| 資料庫 | 狀態更新為 Canceled / Fail | 維持 WaitingToPay | 源頭是結案中斷,PMW 無法辨識 canceled 只是讓問題浮現、無法收斂 |
程式碼實證
對照 nineyi.payment.middleware 的 Stripe Plugin 與 nineyi.webstore.mobilewebmall 的 PayChannelController / TradesOrderPaymentService,可以精確定位問題發生的程式碼位置。
src/Plugins/NineYi.PaymentMiddleware.Plugins.Stripe/StripePlugin.cs
這個私有方法負責把 Stripe PaymentIntent.status 轉換成 PMW 標準 ReturnCode。可以看到判斷式只涵蓋 succeeded、requires_payment_method(含 last_payment_error)、requires_action / requires_confirmation 三類,canceled 完全沒有出現在任何條件分支中,只能落入最後的 else。
private (string returnCode, string returnMessage, IDictionary<string, object> extendInfo) GetThirdPartyQueryPaymentDetail(...) { var status = paymentIntentResponseEntity.status; if (status == "succeeded") { /* ReturnCodes.Success */ } else if (status == "requires_payment_method" && last_payment_error != null) { return (ReturnCodes.Failed, ...); } else if (status == "requires_action" || status == "requires_payment_method" || status == "requires_confirmation") { return (ReturnCodes.WaitingToPay, status, null); } else // ⚠️ "canceled" 落到這裡,未被顯式處理 { _logger.LogWarning($"Payment Exception. \nPaymentIntentResponseEntity: {json}"); return (ReturnCodes.UnhandledException, status, null); // "9001" } }
與原始事故紀錄的差異: 事件描述中提到的是「錯誤碼 9999」,但依目前程式碼,canceled 實際落入的是 ReturnCodes.UnhandledException = "9001"(ReturnCodes.UnknownException = "9999" 是另一個保留碼)。無論是 9001 或 9999,兩者在 MWeb 端都不屬於「可辨識」的 ReturnCode,最終效果相同——訂單都會落入 Console 人工處理分支,事件本質未變。
WebStore/Frontend/BLV2/ThirdPartyPay/TradesOrderPaymentService.cs
MWeb 依 PMW 回傳的 ReturnCode 分派後續動作:Success 轉單、Expired 逾時處理、Failed 取消訂單、Worker 情境下的 WaitingToPay 判斷逾時。除此之外的任何 ReturnCode(包含本案的 9001)都會落入最後的 else,也就是文件中所說的「由 Console 處理」。
else { //// For this scenario, order will process by the console //// ReturnCode from Payment Middleware Response included: //// - 2003 : WaitingToPay 待付款 //// - 9000 : PayChannelError //// - 9999 : Payment Middleware Exception _logger.Info($"Payment Middleware 回傳資訊 - ReturnCode: {resultEntity.ReturnCode}, ReturnMessage: ..."); _logger.Info("訂單將由 Console 進行處理"); resultEntity.Message = Translation...OrderInProcessing; }
這段程式碼本身沒有拋出例外,只是把訂單「晾在原地」等待人工處理——這解釋了為何前台會持續顯示處理中,而不是直接報錯。
WebStore/WebAPI/Controllers/PayChannelController.cs
此 API 註解明確標示「提供內部 Console 使用」,僅允許來自公司內網(IsFromCompany())的請求呼叫,用途正是本文「解決方案」章節提到的「強制轉單」動作——由客服 / RD 在 Console 手動觸發 FinishPayment(isFromWorker: true) 重新跑一次結果判斷流程。
[HttpPost] [Route("PayChannel/InternalFinishPayment")] public JsonResult InternalFinishPayment(InternalFinishPaymentRequestEntity request) { if (this.IsFromCompany() == false) { return this.Json(ApiResultEntity<object>.Success(new { })); } var payType = PayChannelHelper.SplitPayProfileType(request.PayProfileType); var result = _tradesOrderPaymentService.FinishPayment( request.ShopId, request.MemberId, payType.PayChannel, payType.PayMethod, request.TradesOrderGroupCode, request.UniqueKey, string.Empty, isFromWorker: true ); return this.Json(ApiResultEntity<object>.Success(new { result.ReturnCode, result.ReturnMessage, result.Message })); }
關鍵: 由於呼叫時固定帶入 isFromWorker: true,即使查詢結果仍是 WaitingToPay,也會進入 FinishPayment 內「Worker 逾時判斷」分支,只要超過 Timeout 秒數就會強制取消付款請求並結束訂單,這正是「Timeout 機制進行資料庫狀態更新」的實際程式路徑。
WebStore/Frontend/BLV2/PayChannel/StripePayChannelService.cs
這是本案「第一次」進入 Failed 分支時,真正觸發 Cancel API 呼叫的判斷式。只有當 Stripe 狀態為 requires_payment_method 且錯誤碼為 payment_intent_authentication_failure(也就是 3D 驗證失敗)時才會回傳 true:
public bool IsRequiredCancelPaymentRequestOnReturnFailed(PayProcessContextEntity context, QueryPaymentResultEntity queryResult) { string stripeStatus = queryResult.ExtendInfo.GetValueOrDefault("status")?.ToString() ?? ""; string stripeErrorCode = queryResult.ExtendInfo.GetValueOrDefault("last_payment_error_code")?.ToString() ?? ""; return stripeStatus == "requires_payment_method" && stripeErrorCode == "payment_intent_authentication_failure"; }
命中後 FinishPayment 會呼叫 CancelPayment(context) → PMW StripePlugin.Cancel() → 呼叫 Stripe CancelPaymentIntentAsync,成功後 Stripe 端的 PaymentIntent 就會真正轉為 canceled。但緊接著的 CancelTradeOrder(...)(結案:退點數、還券、回補庫存、轉單…)若在這一步中斷(例外、NMQ 逾時、Retry 失敗等),本地訂單狀態就會停留在 WaitingToPay,而 Stripe 端已經是 canceled。
完整觸發鏈: ① 第一次查詢 → requires_payment_method + payment_intent_authentication_failure → PMW 回 Failed(3000) → ② MWeb 命中本判斷式 → 呼叫 Cancel API → Stripe 端成功轉為 canceled → ③ CancelTradeOrder 執行中斷,DB 訂單狀態未落地 → ④ 下一次重試查詢 → Stripe 這次直接回 canceled(不再是 requires_payment_method)→ PMW 因無對應分支回傳 UnhandledException(9001) → MWeb 也無法識別 → 卡入「由 Console 處理」。
ReturnCode 對照(本案相關)
| ReturnCode | 常數名稱 | 意義 | 本案是否命中 |
|---|---|---|---|
0000 | Success | 付款成功 | — |
2001 | Expired | 付款逾期/已取消(建議:canceled 應對應此碼,MWeb 該分支不會呼叫 Cancel API) | 目前未命中,第 08 節建議改為命中 |
2003 | WaitingToPay | 待付款 / requires_action 等中間狀態 | 驗證中階段命中 |
3000 | Failed | requires_payment_method + 有 last_payment_error | 3D 驗證失敗當下命中(會觸發 MWeb 呼叫 Cancel API) |
9001 | UnhandledException | 狀態不在已知分支中(目前 canceled 命中此碼) | ✅ 命中,導致落入 Console 分支 |
9999 | UnknownException | 保留碼,事件敘述中提及但目前程式碼未直接產生此碼 | 否(程式碼實際回傳 9001) |
異常循環機制
最終結果: Redis Cache 過期、WebAPI 持續拋出 GetPayProcessDataProcessorException(因 PayProcessContextEntity 快取遺失),訂單處理卡死,只能由 Console 人工介入。
解決方案
立即處理方案
使用 Timeout 機制進行資料庫狀態更新(實務上即透過 PayChannel/InternalFinishPayment 由 Console 觸發 isFromWorker: true 的 FinishPayment):
- 狀態重置:將訂單狀態設為 Timeout
- 資料壓制:更新資料庫中的訂單狀態
- 強制轉單:執行轉單流程,完成訂單結案
長期修復方案
| 修復項目 | 修復內容 | 優先級 |
|---|---|---|
| PMW 狀態處理 | 在 GetThirdPartyQueryPaymentDetail 加入 "canceled" 狀態的顯式判斷,對應 Expired(而非 Failed,避免 MWeb 重複呼叫 Cancel API,詳見第 08 節設計) | 高 |
| 取消申請單防重複 | DoProcessOnReturnForTimeoutTradesOrder 呼叫的 CreateCancelRequest 目前無條件新增,需先檢查是否已有未關閉的取消申請單(詳見第 08 節設計) | 高 |
| 子步驟可安全重跑 | 依第 08 節方案 A/B,讓 CancelTradeOrder(Failed 分支)具備進度追蹤或各步驟自行冪等,避免自動補跑造成重複退款 / 重複還購物金 | 高 |
| 錯誤處理機制 | 改善 MWeb 對未分類 ReturnCode 的自動回復機制,避免單純停在「由 Console 處理」 | 中 |
| 監控告警 | 加強 3D 驗證失敗率與 canceled 狀態出現次數的監控 | 中 |
| 文件更新 | 更新異常處理操作手冊,同步修正 ReturnCode 描述(9001 vs 9999) | 低 |
根本觸發鏈與自動化根治設計
建議修改一:PMW 顯式處理 canceled 狀態,對應 Expired 而非 Failed
src/Plugins/NineYi.PaymentMiddleware.Plugins.Stripe/StripePlugin.cs · GetThirdPartyQueryPaymentDetail
else if (status == StripeStatusConstants.Cancelled) // "canceled" { // Stripe 端已是終態,且此時通常已無 last_payment_error 可帶(因為是先前流程呼叫 Cancel 造成的)。 // 對應 Expired 而非 Failed,讓 MWeb 走「逾時/已取消」處理路徑—— // 這條路徑天生不會再呼叫 Cancel API,避免對已終態的 PaymentIntent 重複取消。 var extendInfo = new Dictionary<string, object>(); extendInfo.Add("status", status); return (ReturnCodes.Expired, status, extendInfo); } else { // 其餘真正未知的狀態才落入 UnhandledException ... }
建議修改二:確認 MWeb Expired 分支的既有行為(不需額外改動即可避免重複 Cancel)
WebStore/Frontend/BLV2/ThirdPartyPay/TradesOrderPaymentService.cs · FinishPayment / DoProcessOnReturnForTimeoutTradesOrder
else if (resultEntity.ReturnCode == PaymentMiddlewareReturnCodeConstants.Expired) { //// 付款逾時,不關閉第三方訂單 thirdPartyPaymentEntity.IsClosed = false; this.EnsureRequestNotDuplicated(memberId, k); this.DoProcessOnReturnForTimeoutTradesOrder(context, thirdPartyPaymentEntity, payChannelService); // ⚠️ 注意:這條路徑完全沒有呼叫 CancelPayment / Cancel API, // 所以把 canceled 對應到 Expired,天生規避了「重複打 Cancel API」的風險, // 不需要像 Failed 分支那樣額外去改 IsRequiredCancelPaymentRequestOnReturnFailed。 }
DoProcessOnReturnForTimeoutTradesOrder 內部依序執行:更新 TradesOrderThirdPartyPayment_StatusDef = Timeout → CancelExternalStoreCreditDeduction → _cancelRequestService.CreateCancelRequest(...)(建立取消申請單)→ UpdateOrderSlaveFlow。註解也明確寫著「補庫存、退點、退券於 CancelOrderProcess 進行」——也就是說退點 / 退券 / 補庫存被拆到後續非同步的取消單處理流程執行,而不是像 CancelTradeOrder 一樣同步全部做完。
效果: 只要修改 PMW 這一處,MWeb 完全不需要改動任何 Cancel 相關判斷式,就能保證「查到 canceled」這件事永遠不會再多打一次 Stripe Cancel API。這比原本「對應 Failed + 額外修改 IsRequiredCancelPaymentRequestOnReturnFailed」更安全,因為它不依賴兩處程式碼同時改對,而是天生走一條不含 Cancel 呼叫的路徑。
仍要注意的殘留風險: CancelExternalStoreCreditDeduction(同前面分析,靠靜態旗標判斷,非真正冪等)與 _cancelRequestService.CreateCancelRequest(...)(無條件新增一筆取消申請單,未檢查是否已存在同一 TradesOrderGroupId 的待處理取消單)在這條路徑上一樣不是天生防重複的。改用 Expired 只解決了「不會重複呼叫第三方 Cancel API」這一項,若 DoProcessOnReturnForTimeoutTradesOrder 本身也被重跑,仍可能建立重複的取消申請單。建議在 CreateCancelRequest 前加一個查詢:若該 TradesOrderGroupId 已存在未關閉(CancelRequest_IsClosed = false)的取消申請單,就不要再建立新的一筆。
建議修改三:CancelTradeOrder(Failed 分支專用)必須是可安全重跑的(而非「猜中斷點在哪裡」)
重新檢視後發現:整個 CancelTradeOrder 直接重跑「目前恰好安全、但不是被設計保證的」。 CancelTradeOrder 的第一行就是把 TradesOrderThirdPartyPayment_StatusDef 寫成 Fail(UpdateThirdPartyPaymentWithRetry),而 FinishPayment 開頭又有守門:狀態非 WaitingToPay 就直接丟例外、不會再查詢 Stripe。這代表:本文情境(重試查詢仍能拿到 Stripe canceled)只可能發生在中斷點落在 UpdateThirdPartyPaymentWithRetry 提交之前——此時後面的 CancelExternalStoreCreditDeduction、RefundPoint、RevertPromoCodePoolQuota 都還沒執行過,重跑不會重複。但如果中斷點落在之後(例如卡在 RefundPoint),下一次重試會被 FinishPayment 的守門擋下,丟出 TradesOrderThirdPartyPaymentException(NotWaitingToPay),症狀會變成另一種卡單,而不是本文描述的 canceled 迴圈——但只要日後步驟順序調整、流程非同步化、或多個 worker 併發處理同一筆訂單,這個「恰好安全」的隱性假設就會失效。
| 方案 | 做法 | 優缺點 |
|---|---|---|
| A. 進度追蹤(Checkpoint / Saga) | 在 TradesOrderThirdPartyPayment 新增每一子步驟的完成標記(或獨立的 CancelStep 記錄表),每個子步驟成功後立即落地一筆,而不是只在最後寫一次總狀態。重跑時依標記跳過已完成步驟,只執行剩餘的。 |
改動集中在 TradesOrderPaymentService,不需動到下游各服務;但需新增資料結構、每步都要多一次 DB write。 |
| B. 各子步驟自行冪等(Check-before-Act) | 把 CancelExternalStoreCreditDeduction、RefundPoint、RevertPromoCodePoolQuota、CreateCancelRequest 等改為執行前先查「權威狀態」(外部購物金流水、點數退款紀錄、Promo Pool 配額紀錄、是否已有未關閉的取消申請單)確認尚未處理過才動作。 |
更貼近各服務職責、長期更穩健,且同時涵蓋 Failed 與 Expired 兩條路徑;但要動到多個下游服務(外部購物金 / 點數 / PromoCode Pool / 券中心 / 取消申請單),改動範圍較大。 |
建議順序: 先做 PMW/MWeb 的 Expired 對應(本節開頭建議修改一、二),從根本避免「canceled 重試」誤觸 Cancel API;接着針對 Failed 與 Expired 兩條路徑,短期用方案 A(進度追蹤)讓各自的結案流程可安全重跑;中長期再逐步把方案 B 的 Check-before-Act 補進各下游服務,讓整個取消流程無論從哪個步驟中斷、被重跑幾次都收斂到同一個正確結果。


