2C2P 的退款流程遇到兩種會「卡住」的狀況:一種是商家錢包餘額不夠,系統只會無限空轉、不會停下來也不會通知任何人;另一種是資料庫查詢逾時,導致整批退款請求直接失敗中止,需要人力介入才能恢復。下面用「病歷表」的方式,把兩筆真實訂單的發病經過整理出來。

退款流程病歷紀錄 01REFUND INCIDENT CHART
訂單編號 TG CODE
TG240229L00048
退款單號 REFUND ID
124650
病灶 SYMPTOM
餘額不足 (Code 46)
主訴 CHIEF COMPLAINT
呼叫 Refund 時,2C2P 回應 46,Insufficient funds to perform refund.
生命徵象 VITAL SIGN
狀態停留於 RefundRequestProcessing · 重試次數:無上限 · 自癒能力:無
病程記錄 COURSE OF ILLNESS
  1. RefundQuery 回應 00,Successstatus=S(已 Settled),判定「可退款」
  2. 呼叫 Refund,2C2P 回應 46 餘額不足
  3. 狀態寫回 RefundRequestProcessing,永遠不會進入重試上限判斷
  4. 每一輪排程都重新呼叫一次 RefundQuery + Refund,持續打向 2C2P,直到商戶餘額被人工補足
  5. 下一輪 RefundQuery 顯示已退款成功,工程師手動 redo 後狀態才轉 Finish 並回壓大表
診斷 DX:46 追加主動通知商戶餘額不足的機制
退款流程病歷紀錄 02REFUND INCIDENT CHART
訂單編號 TG CODE
TG240510P00022 / TG240205L00095
退款單號 REFUND ID
129378 / 122952
病灶 SYMPTOM
DB 查詢逾時
主訴 CHIEF COMPLAINT
SalesOrderThirdPartyPaymentRepository.Get() 查詢逾時(Execution Timeout Expired)
生命徵象 VITAL SIGN
StatusUpdatedDateTime 停留於 2024-xx · 停滯時長:近 2 年 · 告警機制:無
病程記錄 COURSE OF ILLNESS
  1. 例外在 DoRefundRequestFinish 一開始就被拋出,連 RefundQuery 都還沒執行,整個 Task 中止
  2. 兩筆退款單狀態就此停滯,因無告警機制,無人發現
  3. 事後人工查詢才發現兩筆訂單其實各自「早有明確結果」:
  • 129378:2C2P 狀態為 V(已作廢,respDesc="No refund records")
  • 122952:2C2P refundList 中早已存在對應 tradesOrderSlaveCode 的退款紀錄(已退款完成)
診斷 DX:查詢無 Retry 保護,且未核對 Status/RefundList

這一段把 PaymentMiddleWareRefundRequestService 整條退款鏈路拆成三張圖:入口與分派(圖一)→ 2C2P 專屬的退款前置檢查(圖二)→ 2C2P 回應碼如何分派到最終狀態(圖三),每張圖都對應到不同的判斷分支

1
入口與分派
DoRefundRequestFinish → RefundByRequestId · SQL Timeout 分支 / IsQueryRefund 分支
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() 分支,而不是「只查詢不動作」的分支。
2
IsRefund() 內部判斷
TwoCTwoPPayChannelService · 決定要不要真正呼叫 Refund()

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
💡 圖二有三條出路,只有「狀態為已結算(status=="S")」這一條會真正呼叫 Refund() 送出退款請求;另外「RefundQuery 查詢本身就失敗」與「狀態不是已結算」這兩條路徑,refund 都只是本地端自己組出來的「假回應」,2C2P 完全沒有收到任何退款動作。

呼叫 Refund() 時實際帶給 PaymentMiddleware 的 PayloadRefund() Line 540-575):

欄位
TransactionId payment.SalesOrderThirdPartyPayment_TransactionId(原始付款交易編號)
Amount refundRequest.RefundRequest_Amount
Currency payment.SalesOrderThirdPartyPayment_CurrencyTypeDef
ExtendInfo.tradesOrderSlaveCode refundRequest.RefundRequest_TradesOrderSlaveCodeTwoCTwoPPayChannelService.GetRefundExtendInfo
3
圖三:回應碼分派到最終狀態
TwoCTwoPPlugin.Refund() → GetRefundRequestStatus → HandleRetryForRefundFailure
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 → FinishRefundPeriodExceeded / RefundPending → RefundRequestProcessing;其餘 → RefundRequestFailRefundPending 走的是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// TwoCTwoPPayChannelService.cs IsRefund() Line 104-134
public override bool IsRefund(salesOrderThirdPartyPayment, paytype, refundRequest, refund, RefundQueryFunc)
{
// 每次呼叫都會重新打一次 RefundQuery,即使已是第 N 次 redo
var refundQueryResult = RefundQueryFunc(paytype, salesOrderThirdPartyPayment, refundRequest);

if (refundQueryResult.ReturnCode != Success)
{
refund.ReturnCode = RefundFailed;
return false;
}

// 只檢查 status == "S"(Settled),完全沒有核對 refundList 是否已存在對應退款紀錄
var orderIsSettled = refundQueryResult.ExtendInfo?["status"] == "S";
if (orderIsSettled) { return true; } // → 外層才會真正呼叫 Refund()

refund.ReturnCode = RefundPending; // status != "S"(可能是 V 作廢,或尚未結算),一律視為 Pending
return false;
}
⚠ 這個方法把「訂單狀態不是 S」的所有情境(尚未結算 / 已作廢 V / 甚至已存在 refundList 退款紀錄)**全部一視同仁歸類為 RefundPending**,沒有進一步分辨「值得等待重試」與「其實已有明確結果、不該再重試」。

TwoCTwoPPlugin.cs — Refund() 與 RefundQuery() 的回應碼轉換

1
2
3
4
5
6
7
8
9
// TwoCTwoPPlugin.cs Refund() Line 313-320
var returnCode = apiResponse.RespCode switch
{
"00" => ReturnCodes.Success,
"12" => ReturnCodes.RefundFailed,
"44" => ReturnCodes.RefundPeriodExceeded,
"46" => ReturnCodes.RefundPending, // 餘額不足 → Pending,往上層一路變成 Processing,永遠不進 Fail 分支
_ => ReturnCodes.RefundFailed,
};
1
2
3
4
5
6
7
8
9
// TwoCTwoPPlugin.cs RefundQuery() Line 406-410
var returnCode = apiResponse.RespCode switch
{
"00" => ReturnCodes.Success, // 只代表「查詢動作本身成功」,不代表訂單已退款
_ => ReturnCodes.UnhandledException,
};
// apiResponse.Status ("S"=已結算 / "V"=作廢) 與 apiResponse.RefundList(明細清單)
// 都已完整解析進 ExtendInfo,但只有 status 被 TwoCTwoPPayChannelService.IsRefund 拿來做「是否可退款」的閘門判斷,
// RefundList 明細完全沒有被拿來核對「是否已經退過款」。

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
STAGE 1 Grouping 階段 CreateRefundRequestFinish · L138-171
  1. 透過 SP csp_GetThirdPartyPaymentRefundData 撈取尚未分群(待處理狀態)的 RefundRequest
  2. TradesOrderGroupId 分組
  3. 先把該批 RefundRequestIds 狀態押成 RefundRequestGrouping——目的是卡位,避免下一輪 Grouping 排程重複撈到同一批
  4. 建立對應 NMQ Task(如 TwoCTwoPRefundRequestFinish),把 taskData 送出
STAGE 2 Finish 階段 DoRefundRequestFinish · L186-217
  1. 非同步執行,真正呼叫 RefundQuery / Refund 打 2C2P API
  2. 依回應透過 UpdateRefundRequest → GetRefundRequestStatus 把狀態從 Grouping 回壓改為 Finish(成功)/RefundRequestProcessing(Pending,如 46)/RefundRequestFail
⚠ 若 Finish Job 中途失敗或結果是 Pending,狀態不會回到「待處理」,而是停在 GroupingProcessing。因為 IsContinue 只會跳過「非 Processing/Grouping」的狀態,所以下一輪 **Grouping 排程不會重新撈到它**(已經是 Grouping/Processing,不符合 SP 篩選的「待處理」條件),但下一輪 **Finish Job 若被重新觸發**(正常排程週期或人工 redo NMQ 訊息)會再次進來處理——這正是案例一「無限重試」實際發生的路徑:卡在 Processing 的單子要靠 Finish Job 本身被重複排程 / redo 才會反覆嘗試,而不是靠 Grouping Job。

案例一時間軸:TG240229L00048(餘額不足)

STEP 6IsRefund 內 RefundQuery
回應 00,Successstatus=S,判定可退款 True
STEP 7呼叫 Refund
2C2P 回應 46,Insufficient funds,映射為 RefundPending
STEP 9狀態回壓
GetRefundRequestStatusRefundPending 對應為 RefundRequestProcessing
STEP 10重試上限不生效
originalStatusProcessing 而非 Fail,無次數/天數上限
下一輪排程
IsContinue 判斷為 true,不跳過,重打一次 RefundQuery + Refund,如此反覆
商戶餘額補足
Refund 成功,狀態轉 Finish,工程師手動 redo 加速回壓大表

案例二鏈路對照:TG240510P00022 / TG240205L00095(DB Timeout)

STEP 1-2NMQ Job 觸發
解析 taskData,查詢 SalesOrderThirdPartyPayment
SQL Timeout
EntityCommandExecutionException 未被攔截,Task 直接失敗
卡住
退款單停留在 Processing/Grouping近 2 年未更新,無監控告警
人工查詢 129378
status=V 作廢,respDesc=No refund records,本來就無需退款
人工查詢 122952
status=SrefundList 已有對應退款紀錄,早已退款完成只是未同步
結論
即使 DB 不 Timeout,現有邏輯仍無法自動辨識這兩種情境
1
IsRefund 加入 RefundList / Status=V 二次核對
優先度:高

在呼叫 RefundQuery 後,除了 status == "S" 才判斷可退款外,應新增三種情境的判斷:

情境 判斷依據 建議處理
已作廢(Void) status == "V" 視為「原交易未成立,無需退款」,直接標記完成並記錄原因,不再進入 Pending 迴圈
已退款但未同步 refundList 中已存在對應 tradesOrderSlaveCode 且金額相符的紀錄 直接依 refundList 明細回寫 RefundRequest_TransactionId 並轉 Finish,不需要再呼叫一次 Refund
真正 Pending(如餘額不足) status == "S" 但 Refund 呼叫回應 46 等可重試錯誤碼,且 refundList 查無對應紀錄 依策略一的重試上限機制處理,超過上限即轉人工並告警
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 概念示意:TwoCTwoPPayChannelService.IsRefund 擴充
var refundQueryResult = RefundQueryFunc(paytype, salesOrderThirdPartyPayment, refundRequest);
var status = refundQueryResult.ExtendInfo?["status"]?.ToString();
var refundList = refundQueryResult.ExtendInfo?["refundList"] as List<RefundEntry>;

if (status == "V")
{
refund.ReturnCode = ReturnCodes.Success; // 已作廢,視為無需退款,直接結案
refund.ReturnMessage = "訂單已作廢,無需退款";
return false; // 不再呼叫 Refund
}

var alreadyRefunded = refundList?.Any(r =>
r.TradesOrderSlaveCode == refundRequest.RefundRequest_TradesOrderSlaveCode) == true;
if (alreadyRefunded)
{
refund.ReturnCode = ReturnCodes.Success; // 已存在退款紀錄,同步狀態即可
return false;
}
⚠ 若沒有這一步,案例二的兩筆訂單(129378 作廢、122952 已退款未同步)即使排除掉 DB Timeout,下一次重跑仍然會誤判為需要重新退款,白白浪費一次真實的 Refund 呼叫。
2
DB 查詢加上 Retry + 例外隔離
優先度:中

針對 DoRefundRequestFinish 內的 SalesOrderThirdPartyPaymentRepository.GetRefundRequestRepository.Get 導入 Polly 重試(例如 3 次、指數退避 1s/2s/4s),並在 RefundByRequestIdforeach 內針對單筆處理加上 try-catch,捕捉例外後記錄、告警並 continue,避免一筆異常拖累整批。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 概念示意
var retryPolicy = Policy
.Handle<SqlException>().Or<EntityCommandExecutionException>()
.WaitAndRetry(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (ex, ts, count, ctx) => _logger.Info($"查詢重試第 {count} 次:{ex.Message}"));

var salesOrderThirdPartyPayment = retryPolicy.Execute(
() => _salesOrderThirdPartyPaymentRepository.Get(entity.TradesOrderGroupId));

// RefundByRequestId foreach 內
try { /* 單筆退款處理邏輯 */ }
catch (Exception ex)
{
_logger.Error(ex, $"RefundRequestId={refundRequestId} 處理失敗,跳過此筆繼續下一筆");
NotifyOps($"退款單處理異常:{refundRequestId}");
continue;
}
💡 案例二的根本問題是「一筆 SQL Timeout 拖垮整批」;即使加了 Retry,仍要搭配 foreach 內的 try-catch,才能確保**單筆異常不會讓同一批次裡其他健康的 RefundRequest 也一起卡住**。
3
主動監控與自動化商戶通知
優先度:中

這一項同時涵蓋「系統面監控」與「餘額不足時的通知設計」,核心原則是讓卡住的退款單被主動發現,並讓需要處理的人(尤其是商戶本人)直接收到通知,而不是靠工程師手動轉達

  • 建立每日排程掃描 RefundRequest_StatusDef IN ('RefundRequestProcessing','RefundRequestGrouping')RefundRequest_StatusUpdatedDateTime 超過 N 天的異常清單,推播 Slack/Teams 並附上 Correlation ID、TGCode 方便直接查 Loki

  • 在 Grafana / Loki 建立 Dashboard,依 PayType 統計 RefundRequestProcessing 停留時間分布,及早發現異常堆積,而非等客訴或人工巡檢

  • DB 查詢連續失敗(策略三 retry 全部用盡)時,額外觸發一次性告警,避免第二次「靜默卡住兩年」

  • 餘額不足達重試上限時:不是丟一則 Slack 訊息給工程師了事,而是直接觸發商戶通知管線——商戶後台主動彈出「因帳戶餘額不足,OO 筆退款卡住,請儘速儲值」,或自動寄送 Email/簡訊給商戶帳務聯絡人,客服窗口收到的則是「已通知商戶」的追蹤摘要,而非「請去通知商戶」的待辦