2C2P-Refund
2C2P 的退款流程遇到兩種會「卡住」的狀況:一種是商家錢包餘額不夠,系統只會無限空轉、不會停下來也不會通知任何人;另一種是資料庫查詢逾時,導致整批退款請求直接失敗中止,需要人力介入才能恢復。下面用「病歷表」的方式,把兩筆真實訂單的發病經過整理出來。
46,Insufficient funds to perform refund.RefundRequestProcessing · 重試次數:無上限 · 自癒能力:無- RefundQuery 回應
00,Success且status=S(已 Settled),判定「可退款」 - 呼叫 Refund,2C2P 回應
46 餘額不足 - 狀態寫回
RefundRequestProcessing,永遠不會進入重試上限判斷 - 每一輪排程都重新呼叫一次 RefundQuery + Refund,持續打向 2C2P,直到商戶餘額被人工補足
- 下一輪 RefundQuery 顯示已退款成功,工程師手動 redo 後狀態才轉 Finish 並回壓大表
SalesOrderThirdPartyPaymentRepository.Get() 查詢逾時(Execution Timeout Expired)2024-xx · 停滯時長:近 2 年 · 告警機制:無- 例外在
DoRefundRequestFinish一開始就被拋出,連 RefundQuery 都還沒執行,整個 Task 中止 - 兩筆退款單狀態就此停滯,因無告警機制,無人發現
- 事後人工查詢才發現兩筆訂單其實各自「早有明確結果」:
129378:2C2P 狀態為V(已作廢,respDesc="No refund records")122952:2C2PrefundList中早已存在對應tradesOrderSlaveCode的退款紀錄(已退款完成)
這一段把 PaymentMiddleWareRefundRequestService 整條退款鏈路拆成三張圖:入口與分派(圖一)→ 2C2P 專屬的退款前置檢查(圖二)→ 2C2P 回應碼如何分派到最終狀態(圖三),每張圖都對應到不同的判斷分支
flowchart TD
A["解析 taskData\n取得 TradesOrderGroupId / RefundRequestIds / PayType"] --> B["查詢 SalesOrderThirdPartyPayment\n⚠ 無 retry / timeout / try-catch"]
B -->|"SQL Timeout\n⚠ 案例二"| B1["EntityCommandExecutionException\n直接拋出,Task 整個失敗\n批次內所有 RefundRequestId 都未被處理"]
B -->|查詢成功| C["CanGroupingRefund()?\nTwoCTwoP → false"]
C -->|false| D["RefundByRequestId\nforeach RefundRequestId"]
D --> E{"IsContinue(status)?\n狀態是否為\nProcessing/Grouping"}
E -->|"否(Finish/Fail等)"| E1["continue\n跳過此筆,處理下一筆"]
E -->|"是"| F{"IsQueryRefund(refundRequest)?\nTwoCTwoP 未覆寫\n恆為 false"}
F -->|"true\n(其他金流可能用到)"| G1["直接呼叫 RefundQuery\n只查詢、不送出退款"]
F -->|"false(2C2P 走此路)"| G2["進入 IsRefund()\n見圖二"]
G1 --> H["refund = RefundQuery 結果"]
G2 --> H
H --> I["ChangeRefundRequestStatus\nTwoCTwoP 未覆寫,原樣不變"]
I --> J["UpdateRefundRequest\n見圖三"]
style B1 fill:#a3441e,color:#fff
style F fill:#3d6b66,color:#fff
style E fill:#3d6b66,color:#fff
| 節點 | 位置 | 說明 |
|---|---|---|
| 查詢 SalesOrderThirdPartyPayment | DoRefundRequestFinish(Line 197-198) |
單次 EF 查詢、沒有 retry / timeout 保護機制、沒有 try-catch。SQL Server 忙碌時(案例二)直接丟出例外,整個 Task 失敗,此時連任何一筆 RefundRequest 都還沒被讀取或處理。 |
| IsContinue | IsContinue(Line 527-530) |
只要目前狀態非 RefundRequestProcessing / RefundRequestGrouping 就跳過;反過來說,只要狀態還是 Processing,每一輪排程都會無條件重新進入處理。 |
| IsQueryRefund | RefundByRequestId(Line 354) |
2C2P 沒有覆寫此方法,沿用 AbstractPayChannelService 預設值 false,因此永遠會走到 IsRefund() 分支,而不是「只查詢不動作」的分支。 |
IsRefund() 每次被呼叫都會先重新打一次 RefundQuery,再依回應內容決定要不要真正呼叫 Refund():
flowchart TD
A["IsRefund 被呼叫"] --> B["先呼叫 RefundQuery\n(每次都重打,即使已是第 N 次 redo)"]
B --> C{"RefundQueryResult\nReturnCode == Success?"}
C -->|"否\n(查詢動作本身失敗)"| C1["refund.ReturnCode = RefundFailed\nIsRefund 回傳 false\n⚠ 不呼叫 Refund()"]
C -->|"是"| D{"ExtendInfo.status\n== \"S\"(已 Settled)?"}
D -->|"否\n可能是尚未結算 / V 作廢\n⚠ 未核對 refundList"| D1["refund.ReturnCode = RefundPending\nIsRefund 回傳 false\n⚠ 不呼叫 Refund()"]
D -->|"是"| D2["IsRefund 回傳 true\n→ 外層真正呼叫 Refund()\n見圖三"]
style C1 fill:#a3441e,color:#fff
style D1 fill:#b8860b,color:#fff
style D2 fill:#4f7942,color:#fff
Refund() 送出退款請求;另外「RefundQuery 查詢本身就失敗」與「狀態不是已結算」這兩條路徑,refund 都只是本地端自己組出來的「假回應」,2C2P 完全沒有收到任何退款動作。
呼叫 Refund() 時實際帶給 PaymentMiddleware 的 Payload(Refund() Line 540-575):
| 欄位 | 值 |
|---|---|
TransactionId |
payment.SalesOrderThirdPartyPayment_TransactionId(原始付款交易編號) |
Amount |
refundRequest.RefundRequest_Amount |
Currency |
payment.SalesOrderThirdPartyPayment_CurrencyTypeDef |
ExtendInfo.tradesOrderSlaveCode |
refundRequest.RefundRequest_TradesOrderSlaveCode(TwoCTwoPPayChannelService.GetRefundExtendInfo) |
flowchart TD
A["Refund() 呼叫 PaymentMiddleware\nTwoCTwoPPlugin.Refund()"] --> B{"2C2P RespCode"}
B -->|"00 Success"| B1["ReturnCodes.Success"]
B -->|"12 Transaction in progress"| B2["ReturnCodes.RefundFailed"]
B -->|"44 逾期"| B3["ReturnCodes.RefundPeriodExceeded"]
B -->|"46 餘額不足"| B4["ReturnCodes.RefundPending\n⚠ 案例一根因"]
B -->|"其他"| B5["ReturnCodes.RefundFailed"]
B1 --> C["GetRefundRequestStatus"]
B2 --> C
B3 --> C
B4 --> C
B5 --> C
C -->|"Success"| D1["RefundRequestEnum.Finish\n回壓大表 RefundFinish"]
C -->|"RefundPeriodExceeded\n或 RefundPending"| D2["RefundRequestEnum.\nRefundRequestProcessing\n⚠ 等下一輪重新進圖一"]
C -->|"其他(含 12 / 其他碼)"| D3["RefundRequestEnum.\nRefundRequestFail"]
D3 --> E{"HandleRetryForRefundFailure\n付款類型是否在白名單?\n(Razer 系列 / KPay)"}
E -->|"是,且 3 天內\n且訊息含可重試碼"| E1["改回 RefundRequestProcessing\n重試"]
E -->|"否(含 2C2P)\n或已逾 3 天"| E2["維持 RefundRequestFail\n結案(無成功退款)"]
D2 -.->|"⚠ 不會被 HandleRetryForRefundFailure 攔截\n因為 originalStatus 不是 Fail"| D2
style B4 fill:#b8860b,color:#fff
style D2 fill:#b8860b,color:#fff
style D1 fill:#4f7942,color:#fff
style E2 fill:#a3441e,color:#fff
| 分派點 | 位置 | 說明 |
|---|---|---|
| RespCode → ReturnCodes | TwoCTwoPPlugin.Refund()(Line 313-320) |
2C2P 的 46(餘額不足)被映射為 RefundPending,與 44(逾期)走同一條路徑,但語意完全不同:44 是「真的不能退了」,46 卻是「可能等等就能退」。 |
| GetRefundRequestStatus | Line 504-518 | Success → Finish;RefundPeriodExceeded / RefundPending → RefundRequestProcessing;其餘 → RefundRequestFail。RefundPending 走的是 Processing 分支,不會進到 Fail 分支。 |
| HandleRetryForRefundFailure | Line 950-997 | 此重試上限機制(3 天內 + 白名單錯誤碼)只在 originalStatus 已經是 Fail 時才會被檢查,且白名單只涵蓋 Razer 系列與 KPay。由於 2C2P 的 46 在上一步已經走 Processing 分支,永遠不會進入這個判斷式——這是結構性的、而非單純「忘記把 2C2P 加進白名單」。 |
| 狀態回到 Processing 之後 | — | 狀態回到 Processing 後,下一輪排程會重新從圖一的 IsContinue 開始,再次進入圖二重打 RefundQuery,滿足 status==”S” 甚至會再打一次真正的 Refund——這就是案例一「無限重試」的完整迴圈。 |
TwoCTwoPPayChannelService.cs — IsRefund() 的退款前置檢查
1 | // TwoCTwoPPayChannelService.cs IsRefund() Line 104-134 |
RefundPending**,沒有進一步分辨「值得等待重試」與「其實已有明確結果、不該再重試」。
TwoCTwoPPlugin.cs — Refund() 與 RefundQuery() 的回應碼轉換
1 | // TwoCTwoPPlugin.cs Refund() Line 313-320 |
1 | // TwoCTwoPPlugin.cs RefundQuery() Line 406-410 |
Grouping 與 Finish 兩階段是怎麼銜接的
退款流程實際上分成兩個獨立排程階段:
sequenceDiagram
participant G as Grouping Job
PaymentMiddleWareRefundRequestGroupingProcess
participant DB as RefundRequest 資料表
participant F as Finish Job
TwoCTwoPRefundRequestFinish
participant P as 2C2P / PaymentMiddleware
G->>DB: 撈取待處理 RefundRequest
(csp_GetThirdPartyPaymentRefundData)
G->>DB: 依 TradesOrderGroupId 分組
G->>DB: 押狀態為 RefundRequestGrouping(卡位,避免重複撈取)
G->>F: 建立 NMQ Task
{RefundRequestIds, TradesOrderGroupId, PayType}
F->>P: RefundQuery / Refund
P-->>F: ReturnCode(Success / Pending / Fail)
F->>DB: UpdateRefundRequest 回壓狀態
Finish / Processing / Fail
- 透過 SP
csp_GetThirdPartyPaymentRefundData撈取尚未分群(待處理狀態)的RefundRequest - 依
TradesOrderGroupId分組 - 先把該批 RefundRequestIds 狀態押成
RefundRequestGrouping——目的是卡位,避免下一輪 Grouping 排程重複撈到同一批 - 建立對應 NMQ Task(如
TwoCTwoPRefundRequestFinish),把 taskData 送出
- 非同步執行,真正呼叫
RefundQuery/Refund打 2C2P API - 依回應透過
UpdateRefundRequest → GetRefundRequestStatus把狀態從Grouping回壓改為Finish(成功)/RefundRequestProcessing(Pending,如 46)/RefundRequestFail
Grouping 或 Processing。因為 IsContinue 只會跳過「非 Processing/Grouping」的狀態,所以下一輪 **Grouping 排程不會重新撈到它**(已經是 Grouping/Processing,不符合 SP 篩選的「待處理」條件),但下一輪 **Finish Job 若被重新觸發**(正常排程週期或人工 redo NMQ 訊息)會再次進來處理——這正是案例一「無限重試」實際發生的路徑:卡在 Processing 的單子要靠 Finish Job 本身被重複排程 / redo 才會反覆嘗試,而不是靠 Grouping Job。
案例一時間軸:TG240229L00048(餘額不足)
00,Success,status=S,判定可退款 True46,Insufficient funds,映射為 RefundPendingGetRefundRequestStatus 將 RefundPending 對應為 RefundRequestProcessingoriginalStatus 是 Processing 而非 Fail,無次數/天數上限IsContinue 判斷為 true,不跳過,重打一次 RefundQuery + Refund,如此反覆Finish,工程師手動 redo 加速回壓大表案例二鏈路對照:TG240510P00022 / TG240205L00095(DB Timeout)
SalesOrderThirdPartyPaymentEntityCommandExecutionException 未被攔截,Task 直接失敗Processing/Grouping,近 2 年未更新,無監控告警status=V 作廢,respDesc=No refund records,本來就無需退款status=S 且 refundList 已有對應退款紀錄,早已退款完成只是未同步在呼叫 RefundQuery 後,除了 status == "S" 才判斷可退款外,應新增三種情境的判斷:
| 情境 | 判斷依據 | 建議處理 |
|---|---|---|
| 已作廢(Void) | status == "V" |
視為「原交易未成立,無需退款」,直接標記完成並記錄原因,不再進入 Pending 迴圈 |
| 已退款但未同步 | refundList 中已存在對應 tradesOrderSlaveCode 且金額相符的紀錄 |
直接依 refundList 明細回寫 RefundRequest_TransactionId 並轉 Finish,不需要再呼叫一次 Refund |
| 真正 Pending(如餘額不足) | status == "S" 但 Refund 呼叫回應 46 等可重試錯誤碼,且 refundList 查無對應紀錄 |
依策略一的重試上限機制處理,超過上限即轉人工並告警 |
1 | // 概念示意:TwoCTwoPPayChannelService.IsRefund 擴充 |
針對 DoRefundRequestFinish 內的 SalesOrderThirdPartyPaymentRepository.Get、RefundRequestRepository.Get 導入 Polly 重試(例如 3 次、指數退避 1s/2s/4s),並在 RefundByRequestId 的 foreach 內針對單筆處理加上 try-catch,捕捉例外後記錄、告警並 continue,避免一筆異常拖累整批。
1 | // 概念示意 |
這一項同時涵蓋「系統面監控」與「餘額不足時的通知設計」,核心原則是讓卡住的退款單被主動發現,並讓需要處理的人(尤其是商戶本人)直接收到通知,而不是靠工程師手動轉達:
建立每日排程掃描
RefundRequest_StatusDef IN ('RefundRequestProcessing','RefundRequestGrouping')且RefundRequest_StatusUpdatedDateTime超過 N 天的異常清單,推播 Slack/Teams 並附上 Correlation ID、TGCode 方便直接查 Loki在 Grafana / Loki 建立 Dashboard,依 PayType 統計
RefundRequestProcessing停留時間分布,及早發現異常堆積,而非等客訴或人工巡檢DB 查詢連續失敗(策略三 retry 全部用盡)時,額外觸發一次性告警,避免第二次「靜默卡住兩年」
餘額不足達重試上限時:不是丟一則 Slack 訊息給工程師了事,而是直接觸發商戶通知管線——商戶後台主動彈出「因帳戶餘額不足,OO 筆退款卡住,請儘速儲值」,或自動寄送 Email/簡訊給商戶帳務聯絡人,客服窗口收到的則是「已通知商戶」的追蹤摘要,而非「請去通知商戶」的待辦


