使用者儲值時,系統會先建立一張狀態為 WAITING 的儲值單,再讓使用者前往金流付款。付款成功後,金流會透過回呼(callback)通知系統,系統確認付款結果後,才會將儲值單改成 SUCCESS 並增加餘額。
這個流程有兩個需要處理的問題:
目前專案尚未串接真實金流,以下使用模擬金流
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 交易紀錄,記下來源儲值單
}
除了條件式更新,還有三個設計需要注意:
markPaidIfIn 先確認訂單狀態符合條件,才將它改為 SUCCESS。如果兩個請求同時處理同一張訂單,只有第一個成功更新的請求能繼續入帳;另一個請求會因為訂單狀態已改變,更新 0 列而停止。transactions.recharge_id 設有唯一索引,確保同一張儲值單不能產生第二筆入帳交易紀錄。如果使用者已經付款,但回呼在網路上遺失,儲值單就可能一直停在 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。如果兩個操作同時處理同一張訂單,只有一個操作能成功更新狀態,另一個就會因為狀態已改變而停止,避免同一張訂單重複入帳或同時入帳又關單。
整體流程如下:

[註]:目前的 10 分鐘與 15 分鐘都只是專案設定,尚未經過真實金流驗證。
如果儲值單已經是 CLOSED,事後才確認付款成功,就不能由目前的排程自動入帳,而是需要人工對單,再決定如何處理。
建立儲值單時,先寫入本地資料庫,再向金流建立付款。如果金流建立失敗,就立即關閉本地訂單並回覆 HTTP 502,避免留下無法付款的 WAITING 訂單。
目前使用模擬金流 MockPayChannel 測試以下情境:
WAITING,等待後續重試。這些測試能驗證目前的入帳與關單機制,但仍無法取代真實金流的整合測試。
目前仍有幾個待處理的問題:
WAITING,需要人工確認與處理。正式串接前,必須確認金流的付款期限、付款狀態更新延遲與查詢頻率限制,再決定本地的過期時間及異常處理方式,避免使用者已付款卻未入帳。