上一篇定義了信用卡訂單的完整狀態集合、每條合法轉換的觸發條件,以及五種常見的非法轉換;這些規則若只停留在文件或口頭共識,實務上很容易被繞過,一個急著修 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 → AUTHORIZED,Capture 對應 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...CHECK和UNIQUE約束確保即使應用層防護被繞過,資料庫也不會允許非法狀態或重複資料寫入。 - 樂觀鎖解決並發衝突:用版本號取代悲觀鎖,在低衝突機率的場景下能兼顧安全性和效能,但重試邏輯要處理好幾個容易踩坑的細節,包括 EF Core 的 Entity 卸除和退避策略。
下一篇會進入退款記錄的資料庫設計、Webhook 冪等性的完整實作,以及如何為信用卡和電子支付設計統一的支付閘道介面,把這兩篇定義的規則和防護機制應用到實際的工程場景中。
本文為個人學習筆記,持續更新中。