前兩篇分別建立了狀態機的規則骨架(第一篇)和防護機制(第二篇),這篇文章則要回答最後一個問題:這套狀態機在實際的工程場景中怎麼被使用?

三個最常見的應用場景是退款、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 物件的方法(AuthorizeCaptureVoidSettle)來驅動狀態轉換;這代表無論未來新增哪一種支付方式,狀態機的規則永遠只有一份,不會因為新增閘道而需要重複實作或修改核心邏輯;其中 JkoPayGateway 選擇連續呼叫 AuthorizeCapture,是第一篇和第二篇定義的規則在新場景下的直接應用,而不是繞過規則另外設計一套。


三篇文章的核心原則回顧

這個系列從狀態定義走到防護機制,再到實際的工程應用,幾個原則貫穿全程:

  • 狀態的語義比名稱重要AUTHORIZED 的核心含義是「額度已凍結、資金尚未移動」,這個語義決定了 Void 和 Refund 的分流邏輯。
  • 規則要靠三層防護落地:Domain 層的 Guard clause、資料庫的 CONSTRAINT...CHECK 、樂觀/悲觀鎖的併發控制,三者缺一不可。
  • 退款和 Webhook 都需要冪等性,但解決的是不同問題:退款的 idempotency_key 防止「同一個意圖被重複執行」,Webhook 的 event_id 防止「同一個事件被重複處理」,兩者概念雖然相似,但作用的場景不同。
  • 單元測試是狀態機規則的活文件:把每個非法轉換寫成測試案例,讓規則不會因為未來的重構而被意外破壞。
  • 統一介面讓規則只維護一份:不論底層是信用卡的兩段式流程還是電子支付的一段式流程,都透過同一個 Domain 物件驅動狀態轉換,新增支付方式不需要重新設計狀態機。

理解狀態機的完整設計,是打造可靠支付系統的起點;下一個值得深入的主題是 Outbox Pattern,確保「狀態更新」和「Webhook 通知送出」在同一個原子操作內完成,解決「資料庫寫入成功,但下游通知沒送出去」這個支付系統中棘手的可靠性問題之一。


本文為個人學習筆記,持續更新中。