先講兩個詞,30 秒

狀態機:一個欄位記錄「這筆資料現在處於哪個階段」,而且階段之間有固定的走法。例如一筆訂單的 status 欄位可能是「待付款 → 已付款 → 出貨中 → 完成」,不能從「待付款」直接跳「完成」——這整套「有哪些階段 + 怎麼走」就是狀態機。

枚舉(enum):把這些階段的合法值集中定義在一個地方,例如 PaymentStatus 裡列出 PENDING(待付款)、PAID(已付款)……好處是不會有人手滑打錯字串。

好,正題。

一個真實的檢查:8 個支付狀態,幾個是活的?

我對自己電商後端的支付狀態機做了一次全 codebase 掃描。PaymentStatus 枚舉定義了 8 個態,看起來考慮周全:

狀態意思
PENDING待付款(付款單剛建立,客人還沒付)
PROCESSING處理中
PAID已付款
FAILED付款失敗
EXPIRED已過期(例如 ATM 轉帳有繳費期限,過了沒繳)
CANCELLED已取消
REFUNDED已退款
PARTIAL_REFUNDED部分退款(一張訂單退了其中幾件商品的錢)

然後我逐一去查每個狀態的寫入點——也就是「真的有某行 code 把 status 欄位設成這個值」的地方。查法是搜尋整個專案裡 status = PaymentStatus.某某 這種賦值語句。

結果:

  • 有 code 寫入的:PENDING(付款單建立時的初始值)、PAID(金流商回傳「付款成功」的通知進來時)、FAILED(金流商回傳失敗時)、REFUNDED(退款金額累計達到全額時)
  • 整個專案找不到任何一行寫入的:EXPIRED、CANCELLED、PARTIAL_REFUNDED、PROCESSING

8 個態,一半是死的。 具體來說:

  • 客人選 ATM 付款會拿到一組虛擬帳號和繳費期限。期限過了沒繳?沒有任何 code 把這筆付款標成 EXPIRED——它永遠停在 PENDING。
  • 客人取消訂單,訂單本身會標取消,但對應的付款單不會跟著變 CANCELLED
  • 客人買 3 件退 1 件,錢退了,但付款單維持 PAID 不動——PARTIAL_REFUNDED 這個態從系統上線到現在,一次都沒被寫入過。

物流那邊更誇張。訂單出貨時會按倉庫拆成多個包裹,每個包裹在資料庫是一筆「配送子單」,它的狀態機定義了 7 個階段:處理中 → 已出貨 → 運送中 → 派送中 → 已送達 → 已退回/已取消。掃完的結果:只有「處理中」這個初始值被寫過。也就是說,包裹實際到了客人手上,資料庫裡的它永遠停在「處理中」——整條物流進度是一條鋪好鐵軌、從來沒有火車開過的路線。

為什麼這會咬人

Dead state 不是「多定義了沒差」的無害冗餘,它會主動騙人,而且騙三種人:

1. 騙營運。 營運同事想看「有多少 ATM 逾期未繳的單」,在後台報表把狀態篩選設成 EXPIRED——永遠 0 筆。她以為沒有逾期單,實際上逾期單全部混在 PENDING 裡,跟「還沒到期、客人真的會去繳」的單長得一模一樣,沒人分得出來。「查出來是 0」跟「真的是 0」是兩回事,dead state 讓這兩件事看起來一樣。

2. 騙接手的工程師。 新同事讀 code,看到枚舉定義了 EXPIRED,合理假設系統會產生這個狀態,於是在自己寫的功能裡認真處理「如果付款已過期就如何如何」——那段 code 同樣永遠不會執行,變成下一層的死 code。錯誤的假設會繁殖。

3. 騙看架構圖的人。 狀態圖畫得越完整越專業,但圖上的箭頭有幾條真的有 code 在驅動,圖不會告訴你。你以為文件描述了系統,其實它描述的是某個人當初的願望

核心觀念:「定義」跟「接線」是兩件事

狀態機有兩層:

  • 宣告層:枚舉裡列了哪些值——這層在一個檔案裡,一眼看完。
  • 接線層:每個值有沒有 code 真的在寫入、轉移有沒有真的被觸發——這層散落在整個專案,不主動查就不會知道

多數團隊 code review 只看得到宣告層,因為它集中;接線層的洞是「不存在的 code」,而 review 看不到不存在的東西。

檢查方法很便宜,一個狀態值一條搜尋:

# 找 EXPIRED 的「寫入點」:
grep -rn "Status.EXPIRED" apps/
# 把結果裡三種「不算寫入」的排除掉:
#   1. 枚舉定義處本身(enums.py 那行)
#   2. migration 檔(資料庫欄位變更的歷史紀錄,不是業務邏輯)
#   3. 唯讀的地方(拿來查詢篩選、拿來顯示中文名稱——這些是「讀」不是「寫」)
# 剩下的才是真的有人把狀態設成 EXPIRED 的地方。一行都不剩 = dead state。

一個態一分鐘,8 個態不用十分鐘,你就知道自己的狀態機幾成是活的。

那要不要「先開好跑道」?

Dead state 不全是壞事——它常是刻意的預留:「逾期處理是下個月的功能,先把 EXPIRED 定義好」。這在資料庫層有實際好處:狀態欄位的合法值改動通常要跑 migration(資料庫結構變更),先定義好就不用之後再動一次。

預留本身合理。問題出在預留沒有標記:半年後沒人記得哪些態是「還沒接」、哪些是「已經接了」,於是回到上面三種騙局。

實務建議三條:

  1. 預留就註記。在枚舉定義處直接寫註解:# 尚未接線——逾期自動取消功能完成後啟用。一行字,讓「刻意預留」跟「忘了做」從此分得開。
  2. 營運介面只放活的態。後台報表的狀態下拉選單,dead state 不該出現——那是騙局的入口。選單從枚舉自動生成很方便,但方便的代價就是把死態一起端給營運。
  3. 大改前掃一次。要重構狀態機、或把系統拆成微服務之前,先掃出哪些態是活的——你要搬的是系統的實際行為,不是枚舉的宣言

反思

「這個系統支援哪些狀態?」——以後聽到這句話,記得追問一句:「定義的,還是接了線的?」

兩者的差距,就是你的文件跟真實系統之間的距離。而量這個距離,只要十分鐘的 grep。

相關