團購訂單有五種狀態。狀態本身不難,難的是每個狀態允許哪些操作——這件事若沒有明確定義,會散落成各處的 if 判斷。
| 狀態 | 意義 | 可下單/改單 | 可取消 | 可扣款 |
|---|---|---|---|---|
OPEN |
開團中 | ○ | ○ | ✗ |
CLOSED |
已截止 | ✗ | ○ | ○ |
SETTLED |
已結算 | ✗ | ✗ | ✗ |
CANCELLED |
已取消 | ✗ | ✗ | ✗ |
FAILED |
結算失敗 | ✗ | ✗ | ○(可以重試扣款) |
以流程來看每個狀態之間的關係與轉換大概如下圖:

※注意: SETTLED 不能回到任何狀態、CANCELLED 是終點、OPEN 不能直接跳到 SETTLED。
最初的設計只有「成功」與「失敗」兩種結果,用一個 boolean 就夠,問題是萬一「有人餘額不足」這件事:
CLOSED——CLOSED 意味著「尚未嘗試扣款」,但這張單已經試過了。強行塞進既有狀態的結果,是程式裡開始出現這種東西:
if (status == CLOSED && hasAttemptedPayment && !allDebited) { ... }
當你需要用「狀態 + 額外旗標」來描述一個情況,那個情況就應該是一個獨立狀態。 加一個列舉值的成本,遠低於讓每處判斷都得知道那些旗標的組合。
FAILED 獨立出來之後,重試邏輯只需要問一個問題:狀態是不是 FAILED 或 CLOSED?
每個會改變狀態的操作,第一件事就是檢查當前狀態是否允許:
if (order.getStatus() == OrderStatus.SETTLED) {
throw new ConflictException("該訂單已結算,無法進行付款");
}
if (order.getStatus() == OrderStatus.OPEN) {
throw new IllegalStateException("訂單尚未截止");
}
if (order.getStatus() != OrderStatus.CLOSED && order.getStatus() != OrderStatus.FAILED) {
throw new IllegalStateException("訂單狀態不允許結算");
}
訂單狀態使用enum,透過 MyBatis-Plus 的 @EnumValue 以字串形式存進資料庫(orders.status 為 VARCHAR(20))。
public enum OrderStatus {
OPEN("OPEN"), CLOSED("CLOSED"), SETTLED("SETTLED"),
CANCELLED("CANCELLED"), FAILED("FAILED");
@EnumValue
private final String value;
}
不用 TINYINT 存序數,字串的可讀性較高,且日後若在中間插入一個新狀態,所有既有資料的意義會整批位移,而且不會有任何錯誤提示。字串則是自我描述的——直接查資料庫就看得懂,也不怕重新排序。
代價是儲存空間略大與比較稍慢,對這個量級的資料完全不構成問題。