iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
Vibe Coding

做一個團購後端,順便搞懂那些事系列 第 29 篇

Day 29|儲值入帳:如何避免重複入帳與付款掉單?

  • 分享至 

  • xImage
  •  

使用者儲值時,系統會先建立一張狀態為 WAITING 的儲值單,再讓使用者前往金流付款。付款成功後,金流會透過回呼(callback)通知系統,系統確認付款結果後,才會將儲值單改成 SUCCESS 並增加餘額。

這個流程有兩個需要處理的問題:

  1. 同一筆付款可能被重複通知,造成重複入帳
  2. 付款通知也可能遺失,導致使用者已付款,系統卻沒有入帳

目前專案尚未串接真實金流,以下使用模擬金流 MockPayChannel 驗證程式邏輯。測試可以確認目前的入帳與關單機制,但實際付款期限、查詢結果與回呼行為仍需在串接真實金流後驗證。

問題一:如何避免同一筆儲值重複入帳?

金流可能因為沒有收到系統回覆而重送通知,也可能有兩個請求同時處理同一張儲值單。如果每次收到通知都直接增加餘額,就可能發生使用者只付一次錢,餘額卻增加兩次的情況。此外,回呼端點也可能收到偽造請求,因此不能只根據回呼內容判斷付款是否成功。

這邊專案的做法是系統會先向金流查詢付款狀態,再核對付款金額:

public void handleNotify(String rechargeId) {
    RechargeOrder order = rechargeOrderMapper.selectById(rechargeId);
    if (order == null) {
        log.warn("Notify for unknown recharge order {}", rechargeId);
        return;
    }
    syncWithChannel(order, AUTO, order.getUserId());
}

private boolean syncWithChannel(RechargeOrder order, Collection<RechargeStatus> allowedFrom, String operator) {
    ChannelPayment payment = payChannel.queryPayment(order.getRechargeId());
    if (payment.status() != ChannelPaymentStatus.PAID) {
        return false;
    }
    return creditIfAmountMatches(order, payment, allowedFrom, operator);
}

確認付款成功且金額相符後,還需要確保同一張儲值單只能入帳一次。這裡使用條件式更新:只有訂單狀態符合指定條件,才能將它改成 SUCCESS。

UPDATE recharge_orders
SET status = 'SUCCESS', paid_at = '2026-10-13 10:00:00'
WHERE recharge_id = 'r-001'
  AND status IN ('WAITING','CLOSED');  --視要處理的情境變動條件,情境如下方文字說明

只有訂單仍為 WAITING,這次更新才會成功。回呼與排程只允許從 WAITING 入帳;比較例外的狀況就是會計人工對單時則可以額外允許 CLOSED,處理已關單但後來確認付款成功的情況。

@Transactional
public boolean credit(String rechargeId, Collection<RechargeStatus> allowedFrom, String operator) {
    int claimed = rechargeOrderMapper.markPaidIfIn(rechargeId, LocalDateTime.now(clock), allowedFrom);
    if (claimed == 0) {
        return false;
    }

    RechargeOrder order = rechargeOrderMapper.selectById(rechargeId);
    userMapper.creditBalance(order.getUserId(), order.getAmount());
    ...  // 寫入一筆 RECHARGE 交易紀錄,記下來源儲值單
}

除了條件式更新,還有三個設計需要注意:

  1. 避免併發重複入帳: markPaidIfIn 先確認訂單狀態符合條件,才將它改為 SUCCESS。如果兩個請求同時處理同一張訂單,只有第一個成功更新的請求能繼續入帳;另一個請求會因為訂單狀態已改變,更新 0 列而停止。
  2. 避免重複寫入交易紀錄:transactions.recharge_id 設有唯一索引,確保同一張儲值單不能產生第二筆入帳交易紀錄。
  3. 避免長時間占用資料庫資源:向金流查詢付款狀態放在資料庫交易之外,避免金流回應緩慢時,長時間占用資料庫連線與資料列鎖。

問題二:回呼遺失,如何避免已付款卻沒有入帳?

如果使用者已經付款,但回呼在網路上遺失,儲值單就可能一直停在 WAITING。因此,系統不能只依賴回呼,也不能在本地訂單過期時直接關單。

解決方法:定期查單,並延後關單

目前專案設定金流付款期限為 10 分鐘,本地儲值單則在 15 分鐘後過期。在付款期限內,系統每 30 秒主動查詢一次付款狀態;即使查到未付款或查無此單,也會先保留 WAITING,讓使用者有時間完成付款。

到了本地過期時間,系統會再查一次,確認付款狀態後才決定是否關單:

ChannelPayment payment = payChannel.queryPayment(order.getRechargeId());
if (payment.status() == ChannelPaymentStatus.PAID) {
    creditIfAmountMatches(order, payment, AUTO, order.getUserId());
    continue;
}
closed += rechargeOrderMapper.closeIfWaiting(order.getRechargeId());

如果查詢失敗,系統不會直接關單,而是保留 WAITING,等下一輪排程再查一次。
另外,入帳與關單都會先確認訂單狀態是否符合條件:

  • 入帳:只有狀態符合允許條件時,才能改成 SUCCESS 並增加餘額。
  • 關單:只有訂單仍為 WAITING 時,才能改成 CLOSED。

如果兩個操作同時處理同一張訂單,只有一個操作能成功更新狀態,另一個就會因為狀態已改變而停止,避免同一張訂單重複入帳或同時入帳又關單。

整體流程如下:

https://ithelp.ithome.com.tw/upload/images/20261013/20168667mq5jIWswOy.png

[註]:目前的 10 分鐘與 15 分鐘都只是專案設定,尚未經過真實金流驗證。

如果儲值單已經是 CLOSED,事後才確認付款成功,就不能由目前的排程自動入帳,而是需要人工對單,再決定如何處理。

建立儲值單與測試驗證

建立儲值單時,先寫入本地資料庫,再向金流建立付款。如果金流建立失敗,就立即關閉本地訂單並回覆 HTTP 502,避免留下無法付款的 WAITING 訂單。

目前使用模擬金流 MockPayChannel 測試以下情境:

  1. 付款流程:正常付款、重複回呼、偽造回呼,以及付款成功但回呼遺失。
  2. 異常處理:查詢未付款或查無此單時保留 WAITING,等待後續重試。
  3. 併發處理:同一筆儲值單同時入帳,或入帳與關單同時發生,都只能有一個操作成功。

這些測試能驗證目前的入帳與關單機制,但仍無法取代真實金流的整合測試。

目前限制與真實金流還需要確認的事

目前仍有幾個待處理的問題:

  1. 金額不符:系統不會入帳,但訂單會一直停在 WAITING,需要人工確認與處理。
  2. 重複查單:多台機器可能同時查詢同一張訂單。條件式更新能防止重複入帳,但無法減少多餘的金流查詢。
  3. 安全防護不足:回呼端點尚未實作簽章驗證與限流。
  4. 查單失敗缺少告警:金流持續無法查詢時,系統會不斷重試,卻沒有重試上限或告警通知。
  5. 付款期限尚未驗證:目前暫定付款期限為 10 分鐘、本地訂單 15 分鐘後過期,但真實金流不一定接受商家指定期限,也需要確認付款期限後是否仍可能成功付款。

正式串接前,必須確認金流的付款期限、付款狀態更新延遲與查詢頻率限制,再決定本地的過期時間及異常處理方式,避免使用者已付款卻未入帳。


上一篇
Day 28|使用者重送「下單」,同一份餐點會被扣兩次款嗎?
下一篇
Day 30|AI 寫得比我懂得快
系列文
做一個團購後端,順便搞懂那些事 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言