信用卡分期快照:一個被 AI 設計錯的欄位
從 computed field 到 materialized snapshot——一個 Bug 如何揭示領域模型的本質錯誤
advance-cycles 跑完之後,帳單上的分期金額變成了 0。
某張信用卡帳單原本包含一般消費和好幾筆分期。推進週期後,分期金額全部歸零,帳單總額瞬間縮水,可動用金額憑空多出好幾千塊。都是假的。
這個 Bug 最後花了 18 個 commit 修復。但真正的問題不是 Bug 本身,而是它揭示了一個設計上的錯誤:分期金額不該是動態計算的。
動態計算怎麼壞掉的
先講背景。這個金流系統裡,信用卡帳單(CreditCardBill)需要知道「這張帳單包含多少分期金額」。原始設計是每次讀取帳單時,即時查詢 Installment 表:
找出所有 current_period_date 落在帳單結帳區間的分期
加總它們的月繳金額
聽起來合理。問題出在 advance-cycles。
系統有一個週期推進功能:每次開啟時,自動把已到期的收入和支出推進到下一個週期。分期付款也在推進範圍內——current_period_date 會從這個月跳到下個月,remaining_periods 減一。
推進完,這個月的帳單再去查「哪些分期的 current_period_date 在我的結帳區間內」——沒了。日期已經跳到下個月。動態計算回傳 0。
不是 Bug,是概念錯誤
第一反應是「那就在推進前先快取分期金額」。但這種修法治標不治本。
想想現實世界:你收到信用卡帳單,上面印著分期金額。你繳完錢。下個月又刷了新的分期。請問——上個月那張帳單上的分期金額會變嗎?
不會。帳單是時間點快照。銀行不會因為你後來新增了一筆分期,回頭去修改已出帳的數字。
動態計算的隱含假設是「帳單金額可以隨時重算」。這在概念上就錯了。帳單一旦產生,它的內容就該凍結。後續的分期變動只影響未來的帳單,不影響已存在的。
Snapshot 方案
修正方向很清楚:在 CreditCardBill 上加一個 installment_amount 欄位,帳單建立時計算一次並存入。之後讀取帳單直接用這個值,不再即時查詢。
但「建立時算一次」不夠。有五個時間點需要觸發重算:
1. 帳單建立時——基本款。算出當期分期總額,存入。
2. 新增分期時——使用者今天買了一台手機,選 12 期。如果這筆分期的第一期落在某張未繳帳單的結帳區間,那張帳單的 installment_amount 必須更新。
3. 刪除分期時——輸入錯誤被刪除,對應帳單金額連動。
4. 分期金額變更時——父 Obligation 的月繳金額修改了,所有未繳帳單的 snapshot 都要重算。這是影響範圍最大的 trigger,因為一筆分期可能橫跨多張帳單。
5. Carry-forward 時——帳單部分繳款,未繳金額結轉到下期。結轉後原帳單的分期金額不變(已凍結),但新帳單可能需要重新計算。
不需要重算的情況也要明確:advance-cycles 推進分期週期時不觸發重算。這正是整個修復的核心——推進只改變未來的帳期歸屬,不應該影響已存在的帳單快照。
18 個 Commit 的修復
即使是 hotfix,我還是先寫了一份設計文件。列出 root cause、5 個 trigger point、新測試案例、需修改的既有測試、受影響的檔案清單。然後才開始動手。
修復分成清晰的階段:
- Model 加欄位 + migration
- Service 加
recalculate_bill_installment_snapshot()函式 - 帳單建立、分期增刪、金額變更、carry-forward——逐一掛上 snapshot 重算
- 所有讀取點(forecast、dashboard、API response)改用存儲值
- Backfill script 處理已存在的舊帳單
- 清理殘留的動態計算 import,加 NULL 防禦
每一步都有對應的測試。test_advance_cycles_does_not_change_snapshot 是最關鍵的一個——它驗證了週期推進不會動到已存在帳單的快照值。這正是最初 Bug 的迴歸測試。
防重複計算:更深的問題
修復 snapshot 的過程中,我注意到一個更根本的設計挑戰:怎麼確保同一筆分期付款不被重複扣減?
在這個系統裡,分期付款有雙重身份。它是一筆 Obligation(固定支出),也會出現在 CreditCardBill 的 installment_amount 裡。如果 forecast 計算時兩邊都扣,同一筆錢就被算了兩次。
解法是在 Obligation 上標記 type=installment。forecast 引擎加總義務支出時,明確排除這個類型:
Obligation.type != "installment"
分期的錢只透過 CreditCardBill 這條路徑進入 forecast。Obligation 那邊的記錄純粹管理週期推進和剩餘期數,不參與金額計算。
企業會計系統用複式簿記解決這類問題——每筆交易有明確的借方和貸方,數學上不可能重複。但個人金流工具沒有這個框架。同一筆錢在不同視角下有不同身份:它是「信用卡消費」、是「生活費」、也是「食品類支出」。工具的職責是在這些重疊的身份中劃出互斥的計算管道。
AI 犯的錯跟人不一樣
回頭看,這個設計錯誤很有意思。
如果是人類工程師,大概不會選動態計算。「帳單金額不會回溯改變」是金融領域的常識,有信用卡經驗的人直覺就知道。但 AI 從技術角度出發——動態計算實作更簡潔,不需要管理快取一致性,也不需要列舉所有 trigger point。它選了一個技術上合理、但領域概念上錯誤的方案。
而且 AI 自己寫的 advance-cycles 邏輯打敗了 AI 自己設計的動態計算。然後 AI 自己發現了 Bug,自己寫了修復的設計文件,自己完成了 18 個 commit 的修復。完整的「自產自銷自修」循環。
這讓我學到一件事:AI 在技術執行上的速度和完整度遠超人類,但它缺少領域直覺。那種「這在現實中不是這樣運作的」判斷,仍然需要人來把關。
設計文件可以經過 6 輪審查,AI 可以扮演金融專家、DBA、安全官。但最後抓到概念錯誤的,是在 Dashboard 上看到可動用金額突然多了好幾千、覺得「不對吧」的那個人。
這篇文章的完整開發紀實——72 小時從零到公開發布的故事——在這裡。