前兩篇分別建立了狀態機的規則骨架(第一篇)和防護機制(第二篇),這篇文章則要回答最後一個問題:這套狀態機在實際的工程場景中怎麼被使用?
三個最常見的應用場景是退款、Webhook 事件觸發以及多支付方式的整合,這三者也是支付後端工程師日常工作中最頻繁接觸的部分,也是本系列的最後一篇。
退款記錄的資料庫設計
如第一篇所說,退款不應覆蓋原訂單,應在獨立的 refunds 表建立記錄,這個設計有以下幾個考量點:
1CREATE TABLE refunds (
2 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 order_id UUID NOT NULL REFERENCES orders(id),
4 amount DECIMAL(12, 2) NOT NULL,
5 reason VARCHAR(255),
6 status VARCHAR(20) NOT NULL
7 CHECK (status IN ('pending', 'succeeded', 'failed')),
8 gateway_refund_id VARCHAR(100), -- 支付閘道回傳的退款 ID
9 idempotency_key VARCHAR(100) NOT NULL, -- 防止重複發起退款
10 initiated_by VARCHAR(50), -- 'merchant' 或 'customer'
11 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12 settled_at TIMESTAMPTZ, -- 退款實際入帳時間
13 UNIQUE (idempotency_key)
14);
使用這個結構,可以很容易查詢「一筆訂單的累積退款金額是否超過訂單金額」,這是防止退款超額的重要防護:
1public async Task<Result> RefundAsync(
2 Guid orderId, decimal amount, string idempotencyKey)
3{
4 // 冪等性檢查:同一個 idempotencyKey 不重複建立退款記錄
5 var existing = await _db.Refunds
6 .FirstOrDefaultAsync(r => r.IdempotencyKey == idempotencyKey);
7 if (existing != null)
8 return Result.Success(existing.Id); // 直接回傳先前的結果,不重複扣款
9
10 var order = await _db.Orders.FindAsync(orderId);
11 var totalRefunded = await _db.Refunds
12 .Where(r => r.OrderId == orderId && r.Status == "succeeded")
13 .SumAsync(r => r.Amount);
14
15 if (totalRefunded + amount > order.CapturedAmount)
16 return Result.Failure("退款金額超過訂單可退金額");
17
18 // 建立退款記錄並呼叫 Gateway 退款 API
19 var refund = new Refund
20 {
21 OrderId = orderId,
22 Amount = amount,
23 Status = "pending",
24 IdempotencyKey = idempotencyKey
25 };
26 _db.Refunds.Add(refund);
27 await _db.SaveChangesAsync();
28
29 return Result.Success(refund.Id);
30}
idempotency_key 這個欄位值得特別說明,退款的呼叫端(可能是客服後台,也可能是消費者自助退款頁面)在網路不穩定時常常會重複送出請求,如果呼叫端每次重試都用同一把 idempotencyKey(通常由前端在第一次點擊時產生一個 UUID 並在重試時沿用),系統就能保證同一個退款意圖只會被執行一次,即使這支 API 被呼叫了很多次;這和第二篇提到的併發控制是不同層次的問題,併發控制解決「兩個不同的請求同時發生」,冪等鍵解決「同一個請求被重複送出」。
Webhook 冪等性:狀態轉換的可靠觸發
狀態轉換最常見的觸發來源是 Webhook,支付閘道或電子支付平台推送事件通知系統,這帶來一個必須解決的問題:Webhook 一定會有重試機制。
當平台送出 Webhook 後沒有在短時間內收到 200 回應,它就會重新送一次,可能重試 3 到 10 次不等;如果系統每次收到 Webhook 都執行狀態轉換,就會發生「同一個事件觸發多次轉換」的問題,解決方式是以 Webhook 事件的唯一 ID(event_id)作為冪等鍵:
1[HttpPost("webhook/payment")]
2public async Task<IActionResult> ReceiveWebhook([FromBody] WebhookPayload payload)
3{
4 // 第一步:驗證簽名,確認這個請求真的來自支付平台
5 if (!VerifySignature(payload, Request.Headers["X-Signature"]))
6 return Unauthorized();
7
8 // 第二步:立即回 200,非同步處理(避免超時重試)
9 await _queue.EnqueueAsync(payload);
10 return Ok();
11}
12
13// 背景 Worker 處理
14public async Task ProcessWebhookAsync(WebhookPayload payload)
15{
16 // 第三步:冪等性檢查,防止重複處理
17 using var tx = await _db.BeginTransactionAsync();
18
19 var alreadyProcessed = await _db.WebhookEvents
20 .AnyAsync(e => e.EventId == payload.EventId);
21
22 if (alreadyProcessed)
23 return; // 靜默忽略重複事件,不拋錯
24
25 // 第四步:在同一個 Transaction 內寫入事件記錄並執行狀態轉換
26 await _db.WebhookEvents.AddAsync(new WebhookEvent
27 {
28 EventId = payload.EventId,
29 ProcessedAt = DateTime.UtcNow
30 });
31
32 var order = await _db.Orders.FindAsync(payload.OrderId);
33 order.Settle(); // 呼叫第二篇 Domain 層定義的轉換方法
34
35 await tx.CommitAsync();
36}
這個設計有三個關鍵點:立即回 200 避免平台超時重試、用 event_id 做資料重複消除、在同一個 Transaction 內寫入事件記錄和更新訂單狀態(用以確保原子性);注意這裡呼叫的 order.Settle() 就是第二篇 Order Domain 物件裡定義的方法,Webhook 處理層不直接操作 status 欄位,而是透過 Domain 層的方法,讓 Guard 檢查依然生效。
狀態機的單元測試
第二篇建立的 Rich Domain Model 有一個額外的好處:狀態轉換的邏輯全部集中在 Order 類別裡,讓單元測試變得直接,不需要啟動資料庫或 Web API 就能驗證所有規則。
1public class OrderStateMachineTests
2{
3 [Fact]
4 public void Capture_WhenNotAuthorized_ShouldThrow()
5 {
6 var order = new Order(); // 預設狀態 Initiated
7
8 var ex = Assert.Throws<InvalidOperationException>(
9 () => order.Capture(1000m));
10
11 Assert.Contains("預期狀態 Authorized", ex.Message);
12 }
13
14 [Fact]
15 public void Capture_AmountExceedsAuthorized_ShouldThrow()
16 {
17 var order = new Order();
18 order.Authorize("A12345", authorizedAmount: 1000m);
19
20 var ex = Assert.Throws<InvalidOperationException>(
21 () => order.Capture(1500m)); // 超過授權金額
22
23 Assert.Contains("超過授權金額", ex.Message);
24 }
25
26 [Fact]
27 public void Void_AfterCapture_ShouldThrow()
28 {
29 var order = new Order();
30 order.Authorize("A12345", 1000m);
31 order.Capture(1000m);
32
33 // Capture 之後不能 Void,對應第一篇的非法轉換規則
34 Assert.Throws<InvalidOperationException>(() => order.Void());
35 }
36
37 [Fact]
38 public void FullLifecycle_AuthorizeToSettle_ShouldSucceed()
39 {
40 var order = new Order();
41 order.Authorize("A12345", 1000m);
42 order.Capture(1000m);
43 order.Settle();
44
45 Assert.Equal(OrderStatus.Settled, order.Status);
46 }
47}
這組測試把第一篇列出的每一種非法轉換都寫成一個測試案例,確保狀態機規則不會在未來的程式碼修改中被意外破壞;每當有新的功能模組需要新增、或是需要重構訂單邏輯時,就能用這組測試抓出任何違反狀態機規則的變更。
為多種支付方式設計統一介面
如果系統需要同時支援信用卡和電子支付,不應該對每個支付方式寫死邏輯,而應抽象出一個 IPaymentGateway 介面,讓每個支付方式各自實作:
1public interface IPaymentGateway
2{
3 Task<AuthResult> AuthorizeAsync(AuthRequest request);
4 Task<CaptureResult> CaptureAsync(string authCode, decimal amount);
5 Task<VoidResult> VoidAsync(string authCode);
6 Task<RefundResult> RefundAsync(string transactionId, decimal amount);
7}
不同支付方式的實作類別,各自把底層 API 的差異封裝起來,並負責呼叫 Order 的 Domain 方法來驅動狀態轉換:
1public class CreditCardGateway : IPaymentGateway
2{
3 public async Task<AuthResult> AuthorizeAsync(AuthRequest request)
4 {
5 var response = await _visaClient.SendAuthorizationAsync(request);
6 if (response.IsApproved)
7 {
8 // 呼叫 Domain 層方法,驅動 INITIATED → AUTHORIZED
9 request.Order.Authorize(response.ApprovalCode, request.Amount);
10 }
11 else
12 {
13 request.Order.Fail();
14 }
15 return new AuthResult(response.IsApproved, response.ApprovalCode);
16 }
17
18 public async Task<CaptureResult> CaptureAsync(string authCode, decimal amount)
19 {
20 // 走完整的信用卡 Capture 流程
21 var response = await _visaClient.SendCaptureAsync(authCode, amount);
22 return new CaptureResult(response.Success);
23 }
24}
25
26public class JkoPayGateway : IPaymentGateway
27{
28 public async Task<AuthResult> AuthorizeAsync(AuthRequest request)
29 {
30 // 街口的授權即扣款,一次 API 呼叫完成
31 var response = await _jkoClient.SendPaymentAsync(request);
32 if (response.IsPaid)
33 {
34 // 電子支付模式:連續呼叫 Authorize + Capture
35 // 對應第一篇提到「授權即扣款」的狀態語義
36 request.Order.Authorize(response.TransactionId, request.Amount);
37 request.Order.Capture(request.Amount);
38 }
39 else
40 {
41 request.Order.Fail();
42 }
43 return new AuthResult(response.IsPaid, response.TransactionId);
44 }
45
46 public Task<CaptureResult> CaptureAsync(string authCode, decimal amount)
47 {
48 // 街口沒有獨立的 Capture 步驟,直接回傳成功
49 return Task.FromResult(new CaptureResult(true));
50 }
51}
這個設計的關鍵在於:不管底層是信用卡的兩段式流程,還是電子支付的一段式流程,最終都是透過同一個 Order Domain 物件的方法(Authorize、Capture、Void、Settle)來驅動狀態轉換;這代表無論未來新增哪一種支付方式,狀態機的規則永遠只有一份,不會因為新增閘道而需要重複實作或修改核心邏輯;其中 JkoPayGateway 選擇連續呼叫 Authorize 和 Capture,是第一篇和第二篇定義的規則在新場景下的直接應用,而不是繞過規則另外設計一套。
三篇文章的核心原則回顧
這個系列從狀態定義走到防護機制,再到實際的工程應用,幾個原則貫穿全程:
- 狀態的語義比名稱重要:
AUTHORIZED的核心含義是「額度已凍結、資金尚未移動」,這個語義決定了 Void 和 Refund 的分流邏輯。 - 規則要靠三層防護落地:Domain 層的 Guard clause、資料庫的
CONSTRAINT...CHECK、樂觀/悲觀鎖的併發控制,三者缺一不可。 - 退款和 Webhook 都需要冪等性,但解決的是不同問題:退款的
idempotency_key防止「同一個意圖被重複執行」,Webhook 的event_id防止「同一個事件被重複處理」,兩者概念雖然相似,但作用的場景不同。 - 單元測試是狀態機規則的活文件:把每個非法轉換寫成測試案例,讓規則不會因為未來的重構而被意外破壞。
- 統一介面讓規則只維護一份:不論底層是信用卡的兩段式流程還是電子支付的一段式流程,都透過同一個 Domain 物件驅動狀態轉換,新增支付方式不需要重新設計狀態機。
理解狀態機的完整設計,是打造可靠支付系統的起點;下一個值得深入的主題是 Outbox Pattern,確保「狀態更新」和「Webhook 通知送出」在同一個原子操作內完成,解決「資料庫寫入成功,但下游通知沒送出去」這個支付系統中棘手的可靠性問題之一。
本文為個人學習筆記,持續更新中。