上一篇定義了信用卡訂單的完整狀態集合、每條合法轉換的觸發條件,以及五種常見的非法轉換;這些規則若只停留在文件或口頭共識,實務上很容易被繞過,一個急著修 bug 的工程師可能直接在資料庫改一筆 status,或是漏掉某個 edge case 的檢查。

本文接續上一篇,先說明電子支付的狀態機和信用卡有哪些關鍵差異,再進入這個系列最核心的工程主題:如何在 Domain 層、資料庫層、併發控制三個層次,把規則做成物理上無法繞過的防護。


電子支付的狀態機差異

電子支付(街口、LINE Pay、全支付等)的狀態機比信用卡簡潔,因為「授權即扣款」不存在分開的 Capture 步驟;但這個簡潔性會帶來另一個複雜性:不同的付款模式有不同的狀態流轉。

錢包餘額或綁信用卡模式

使用者確認付款後,平台內部立即扣減餘額或發出信用卡授權請求,結果幾乎是即時的。這個模式下的狀態流轉是:

INITIATED → PAID → SETTLED → REFUNDED

PAID 等同信用卡的 CAPTURED,但因為授權和扣款是同一個動作,AUTHORIZED 這個中間狀態不存在。

綁定銀行帳戶模式

當使用者綁定的是銀行帳戶(而非信用卡),扣款請求需要送到銀行處理,而銀行的確認可能是非同步的,也就是平台送出扣款請求後,可能要等幾分鐘到幾個小時才能確認結果。這個模式下需要一個額外的中間狀態:

INITIATED → PENDING → PAID → SETTLED → REFUNDED
                    ↘ FAILED

PENDING 代表扣款請求已送出,但尚未收到銀行確認,系統收到的第一個 Webhook 可能是「扣款請求已受理」,而不是「扣款成功」;這個細節如果沒處理好,很容易在業務上造成誤會:商家以為錢進來了,但其實銀行還沒確認。

工程師視角的關鍵差異

電子支付最重要的工程差異不是狀態數量,而是狀態轉換的觸發方式;信用卡的許多轉換(Capture、Void)是商家主動發起的,電子支付的多數轉換是由平台 Webhook 被動觸發的,這代表系統設計重心要從「主動呼叫 API」轉向「可靠地接收和處理 Webhook 事件」。


三層防護架構

不論是信用卡還是電子支付都同樣適用三層防護原則,雖然狀態轉換的觸發方式不同,但防止非法轉換的機制是一致的;狀態機的設計不能只停在「寫 if/else 判斷」,而是要在三個層次都建立防護。

Domain 層(Rich Domain Model)

把狀態轉換的邏輯封裝在 Order 這個 Domain 物件裡,而不是散落在各個 Service 方法中,這個做法叫 Rich Domain Model,好處是非法轉換在呼叫當下就拋出例外,不需要每個 Service 方法都寫一遍防護邏輯。

 1public enum OrderStatus
 2{
 3    Initiated, Authorized, Captured,
 4    Settled, Failed, Voided, Refunded, Disputed
 5}
 6
 7public class Order
 8{
 9    public Guid Id { get; private set; }
10    public OrderStatus Status { get; private set; }
11    public decimal AuthorizedAmount { get; private set; }
12    public string? AuthorizationCode { get; private set; }
13    public int Version { get; private set; } // 用於 Optimistic Locking
14
15    public void Authorize(string approvalCode, decimal amount)
16    {
17        Guard(OrderStatus.Initiated);
18        AuthorizationCode = approvalCode;
19        AuthorizedAmount = amount;
20        Status = OrderStatus.Authorized;
21    }
22
23    public void Capture(decimal captureAmount)
24    {
25        Guard(OrderStatus.Authorized);
26        if (captureAmount > AuthorizedAmount)
27            throw new InvalidOperationException(
28                $"Capture {captureAmount} 超過授權金額 {AuthorizedAmount}");
29        Status = OrderStatus.Captured;
30    }
31
32    public void Void()
33    {
34        Guard(OrderStatus.Authorized); // 只能在 Authorized 狀態 Void
35        Status = OrderStatus.Voided;
36    }
37
38    public void Settle()
39    {
40        Guard(OrderStatus.Captured);
41        Status = OrderStatus.Settled;
42    }
43
44    public void Fail()
45    {
46        // Initiated 或 Authorized 都可能 Fail
47        if (Status != OrderStatus.Initiated && Status != OrderStatus.Authorized)
48            throw new InvalidOperationException($"無法從 {Status} 轉換為 Failed");
49        Status = OrderStatus.Failed;
50    }
51
52    public void MarkDisputed()
53    {
54        Guard(OrderStatus.Settled);
55        Status = OrderStatus.Disputed;
56    }
57
58    private void Guard(OrderStatus required)
59    {
60        if (Status != required)
61            throw new InvalidOperationException(
62                $"預期狀態 {required},實際狀態 {Status}");
63    }
64}

注意幾個設計細節:所有屬性都用 private set,確保外部不能直接修改狀態;Guard 方法讓每個轉換方法只需一行防護;Version 欄位為第三層的 Optimistic Locking 預留位置。這個 Order 類別裡的每個方法,都直接對應第一篇定義的合法轉換——Authorize 對應 INITIATED → AUTHORIZEDCapture 對應 AUTHORIZED → CAPTURED,以此類推。

資料庫 CONSTRAINT…CHECK

即使應用層有防護,也可能有直接對資料庫操作、或繞過 Domain 物件的情況,這時候可以在資料庫加上 CONSTRAINT...CHECK 作為一道防線:

 1-- 限制 status 只能是合法的值
 2ALTER TABLE orders
 3  ADD CONSTRAINT chk_valid_status
 4  CHECK (status IN (
 5    'initiated', 'authorized', 'captured',
 6    'settled', 'failed', 'voided', 'refunded', 'disputed'
 7  ));
 8
 9-- 防止同一筆授權碼被重複 Capture
10ALTER TABLE orders
11  ADD CONSTRAINT uq_authorization_code
12  UNIQUE (authorization_code);

這個 UNIQUE (authorization_code) 約束是第一篇有提到「同一筆授權不能重複 Capture」的具體防護實作,即使 Domain 層的檢查因為某種原因被繞過,資料庫也會直接拒絕重複寫入。

樂觀鎖(Optimistic Locking)防止併發衝突

在高併發場景下(例如同一時間兩個請求都嘗試對同一筆訂單執行 Capture),如果沒有併發控制,可能發生兩個請求都通過 Domain 層的 Guard 檢查、卻造成重複扣款的問題;樂觀鎖(Optimistic Locking)的核心概念是讀取資料時不加鎖,但更新時帶上「讀到時的版本號」,讓資料庫保證只有一個請求能成功,後到的那個請求發現版本號已被前一個請求改掉,就知道有衝突發生。

資料庫層的版本號更新

1-- 更新時帶版本號,只有版本號匹配的更新才會成功
2UPDATE orders
3SET status      = 'captured',
4    version     = version + 1
5WHERE id        = @id
6  AND version   = @expected_version;
7
8-- affected rows = 1 → 更新成功,沒有併發衝突
9-- affected rows = 0 → 版本號不匹配,代表這筆資料已被其他請求修改過

EF Core 的設定方式

在 EF Core 中,將 Version 欄位標記為 Concurrency Token,EF Core 就會在每次 SaveChanges 時自動把版本號塞進 WHERE 條件,並在 affected rows = 0 時拋出 DbUpdateConcurrencyException

 1// 方式一:在 Entity 上標記 Timestamp(適用 SQL Server rowversion)
 2public class Order
 3{
 4    public Guid Id { get; private set; }
 5    public OrderStatus Status { get; private set; }
 6
 7    [Timestamp]
 8    public byte[] RowVersion { get; private set; } = null!;
 9}
10
11// 方式二:用整數版本號(適用 PostgreSQL、MySQL 等)
12// 在 DbContext 的 OnModelCreating 中設定
13protected override void OnModelCreating(ModelBuilder modelBuilder)
14{
15    modelBuilder.Entity<Order>()
16        .Property(o => o.Version)
17        .IsConcurrencyToken();
18}

應用層的衝突捕捉與處理

EF Core 拋出 DbUpdateConcurrencyException 後需要決定怎麼應對,支付系統的標準做法是:捕捉例外、重新從資料庫讀取最新狀態、判斷衝突是否已被解決,再決定是否重試或直接回傳衝突錯誤。

 1public async Task<Result> CaptureOrderAsync(Guid orderId, decimal amount)
 2{
 3    const int maxRetries = 3;
 4
 5    for (int attempt = 0; attempt < maxRetries; attempt++)
 6    {
 7        try
 8        {
 9            // 每次重試都重新讀取最新狀態(帶版本號)
10            var order = await _db.Orders.FindAsync(orderId);
11
12            if (order is null)
13                return Result.Failure("訂單不存在");
14
15            // Domain 層檢查合法性(若狀態不對,這裡就拋例外)
16            order.Capture(amount);
17
18            // EF Core 在 SaveChanges 時自動加上版本號 WHERE 條件
19            await _db.SaveChangesAsync();
20
21            return Result.Success();
22        }
23        catch (DbUpdateConcurrencyException ex)
24        {
25            // 版本號衝突:有另一個請求搶先更新了這筆資料
26            if (attempt == maxRetries - 1)
27            {
28                // 重試次數用盡,回傳衝突錯誤讓呼叫端處理
29                return Result.Failure("訂單狀態衝突,請稍後再試");
30            }
31
32            // 重試前先重置 EF Core 對這個 Entity 的追蹤狀態
33            // 否則下一次 FindAsync 會拿到快取的舊資料
34            ex.Entries.Single().State = EntityState.Detached;
35
36            // 短暫等待後重試(Jitter 避免所有請求同時重試)
37            await Task.Delay(TimeSpan.FromMilliseconds(50 * (attempt + 1)));
38        }
39        catch (InvalidOperationException ex)
40        {
41            // Domain 層的 Guard 拋出例外(例如狀態不是 AUTHORIZED)
42            // 這不是併發問題,直接回傳錯誤,不重試
43            return Result.Failure(ex.Message);
44        }
45    }
46
47    return Result.Failure("Capture 失敗,請稍後再試");
48}

這段程式碼有幾個細節值得注意,ex.Entries.Single().State = EntityState.Detached 很容易被遺漏,如果不把 Entity 從 EF Core 的 ChangeTracker 卸除,下一輪迴圈的 FindAsync 還是會從快取拿到舊的物件,帶著舊版本號再次嘗試更新,永遠不會成功;重試等待時間可以用 50ms * (attempt + 1) 做線性退避(Linear Backoff),若能夠加上隨機 Random.Shared.Next(0, 30)ms 效果更好,能避免多個請求同時重試造成的雪崩。

樂觀鎖 vs 悲觀鎖的選擇

這個問題業界並沒有一致的答案,不過實務上可以用兩個問題幫助判斷:這筆資料被同時搶著寫入的機率高不高?如果衝突機率低(例如本文的 Capture 場景,同一筆訂單被兩個請求同時操作是少見情況)樂觀鎖的重試成本通常較為划算,因為它不需要在每次操作都付出加鎖開銷;如果衝突機率高、或任何一次寫入錯誤的代價極大(例如帳戶餘額扣款這類需要嚴格序列化的操作),悲觀鎖(Pessimistic locking)較能確保操作期間的獨佔存取,用犧牲並發度換取正確性上的可預測性。

這裡有個例外情況:退款時需要確認累積退款金額不超過訂單金額,這個「讀取後計算再寫入」的操作如果發生併發(例如客服和消費者同時申請退款),屬於衝突後果較嚴重的場景,這時可以考慮對退款操作使用 SELECT FOR UPDATE,或在應用層做分散式鎖,而不是預設套用樂觀鎖;退款金額的驗證邏輯會在第三篇完整展開。


這篇文章把第一篇定義的規則,轉化成三層可執行的防護機制:

  • 電子支付的狀態機比信用卡簡潔,但觸發方式不同:信用卡的轉換多由商家主動呼叫 API 觸發,電子支付的轉換多由平台 Webhook 被動觸發,這個差異會直接影響系統的設計重心。
  • Domain 層是第一道防線:用 Rich Domain Model 把轉換邏輯封裝進 Order 物件,讓非法轉換在呼叫當下就拋出例外,不需要在每個 Service 方法裡重複寫防護邏輯。
  • 資料庫層是最後一道防線CONSTRAINT...CHECKUNIQUE 約束確保即使應用層防護被繞過,資料庫也不會允許非法狀態或重複資料寫入。
  • 樂觀鎖解決並發衝突:用版本號取代悲觀鎖,在低衝突機率的場景下能兼顧安全性和效能,但重試邏輯要處理好幾個容易踩坑的細節,包括 EF Core 的 Entity 卸除和退避策略。

下一篇會進入退款記錄的資料庫設計、Webhook 冪等性的完整實作,以及如何為信用卡和電子支付設計統一的支付閘道介面,把這兩篇定義的規則和防護機制應用到實際的工程場景中。


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