Payment · Stripe · 異常處理

3D Secure 驗證失敗處理

01

概述與問題來源

3D Secure 是信用卡的額外身份驗證機制。當持卡人在銀行驗證頁面操作失敗時,Stripe 回傳的其實是 requires_payment_method 狀態並附上 last_payment_errorpayment_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),並補強結案流程的防重複執行保護

02

事件時間軸

實際案例時序(同一筆交易)

01:16:55.049
建立付款方式

建立 payment_methodpayment_intent,流程正常啟動。

01:16:58.780
WaitingToPay

Stripe 回傳 next_action: redirect_to_url,使用者被導向銀行 3D 驗證頁面。

01:21:10.334
requires_action

仍需完成 3D Secure 驗證,amount_received = 0,系統持續等待用戶操作。

01:27:37.290
驗證失敗

Stripe 回報 payment_intent_authentication_failure,PMW 收到失敗事件。

01:27:37.350
API 取消成功

MWeb 呼叫 Stripe Cancel API 成功,預期訂單應轉入取消流程。

01:31:07.879
狀態異常

後續查詢 Stripe 回傳狀態變為 "canceled",PMW 無法解析此狀態,系統開始進入無限迴圈。

關鍵狀態轉換

建立付款
等待 3D 驗證
驗證失敗
API 取消
canceled 狀態異常
系統循環
03

技術問題分析

核心問題

問題有兩層,缺一不可:① 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 只是讓問題浮現、無法收斂
04

程式碼實證

對照 nineyi.payment.middleware 的 Stripe Plugin 與 nineyi.webstore.mobilewebmallPayChannelController / TradesOrderPaymentService,可以精確定位問題發生的程式碼位置。

PMWStripePlugin.GetThirdPartyQueryPaymentDetail

src/Plugins/NineYi.PaymentMiddleware.Plugins.Stripe/StripePlugin.cs

這個私有方法負責把 Stripe PaymentIntent.status 轉換成 PMW 標準 ReturnCode。可以看到判斷式只涵蓋 succeededrequires_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 人工處理分支,事件本質未變。

MWebTradesOrderPaymentService.FinishPayment

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;
}

這段程式碼本身沒有拋出例外,只是把訂單「晾在原地」等待人工處理——這解釋了為何前台會持續顯示處理中,而不是直接報錯。

MWebPayChannelController.InternalFinishPayment

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 機制進行資料庫狀態更新」的實際程式路徑。

MWebStripePayChannelService.IsRequiredCancelPaymentRequestOnReturnFailed

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常數名稱意義本案是否命中
0000Success付款成功
2001Expired付款逾期/已取消(建議:canceled 應對應此碼,MWeb 該分支不會呼叫 Cancel API)目前未命中,第 08 節建議改為命中
2003WaitingToPay待付款 / requires_action 等中間狀態驗證中階段命中
3000Failedrequires_payment_method + 有 last_payment_error3D 驗證失敗當下命中(會觸發 MWeb 呼叫 Cancel API)
9001UnhandledException狀態不在已知分支中(目前 canceled 命中此碼)✅ 命中,導致落入 Console 分支
9999UnknownException保留碼,事件敘述中提及但目前程式碼未直接產生此碼否(程式碼實際回傳 9001)
05

異常循環機制

Query 狀態
收到 "canceled"
PMW 回傳 9001(未處理錯誤)
MWeb 落入 Console 分支
前台 / Worker 重試
無限循環
🔁

最終結果: Redis Cache 過期、WebAPI 持續拋出 GetPayProcessDataProcessorException(因 PayProcessContextEntity 快取遺失),訂單處理卡死,只能由 Console 人工介入。

06

解決方案

立即處理方案

使用 Timeout 機制進行資料庫狀態更新(實務上即透過 PayChannel/InternalFinishPayment 由 Console 觸發 isFromWorker: trueFinishPayment):

  • 狀態重置:將訂單狀態設為 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)
08

根本觸發鏈與自動化根治設計

建議修改一: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 = TimeoutCancelExternalStoreCreditDeduction_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 寫成 FailUpdateThirdPartyPaymentWithRetry),而 FinishPayment 開頭又有守門:狀態非 WaitingToPay 就直接丟例外、不會再查詢 Stripe。這代表:本文情境(重試查詢仍能拿到 Stripe canceled)只可能發生在中斷點落在 UpdateThirdPartyPaymentWithRetry 提交之前——此時後面的 CancelExternalStoreCreditDeductionRefundPointRevertPromoCodePoolQuota 都還沒執行過,重跑不會重複。但如果中斷點落在之後(例如卡在 RefundPoint),下一次重試會被 FinishPayment 的守門擋下,丟出 TradesOrderThirdPartyPaymentException(NotWaitingToPay),症狀會變成另一種卡單,而不是本文描述的 canceled 迴圈——但只要日後步驟順序調整、流程非同步化、或多個 worker 併發處理同一筆訂單,這個「恰好安全」的隱性假設就會失效。

方案做法優缺點
A. 進度追蹤(Checkpoint / Saga) TradesOrderThirdPartyPayment 新增每一子步驟的完成標記(或獨立的 CancelStep 記錄表),每個子步驟成功後立即落地一筆,而不是只在最後寫一次總狀態。重跑時依標記跳過已完成步驟,只執行剩餘的。 改動集中在 TradesOrderPaymentService,不需動到下游各服務;但需新增資料結構、每步都要多一次 DB write。
B. 各子步驟自行冪等(Check-before-Act) CancelExternalStoreCreditDeductionRefundPointRevertPromoCodePoolQuotaCreateCancelRequest 等改為執行前先查「權威狀態」(外部購物金流水、點數退款紀錄、Promo Pool 配額紀錄、是否已有未關閉的取消申請單)確認尚未處理過才動作。 更貼近各服務職責、長期更穩健,且同時涵蓋 Failed 與 Expired 兩條路徑;但要動到多個下游服務(外部購物金 / 點數 / PromoCode Pool / 券中心 / 取消申請單),改動範圍較大。

建議順序: 先做 PMW/MWeb 的 Expired 對應(本節開頭建議修改一、二),從根本避免「canceled 重試」誤觸 Cancel API;接着針對 Failed 與 Expired 兩條路徑,短期用方案 A(進度追蹤)讓各自的結案流程可安全重跑;中長期再逐步把方案 B 的 Check-before-Act 補進各下游服務,讓整個取消流程無論從哪個步驟中斷、被重跑幾次都收斂到同一個正確結果。

Stripe · Paytypes 知識庫 | 來源:10-3D驗證失敗處理.md + PaymentMiddleware / MWeb 原始碼實證