iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
AI Engineering

30 天打造我的 AI 開發工作流:從需求分析到上線系列 第 15

Day 15|資料模型:哪些業務規則真的擋得住,哪些只是寫在註解裡

  • 分享至 

  • xImage
  •  

前言

規格裡寫著「這是資料庫層級的保證,不只是 service 檢查」,今天就用四條規則去驗證這句話。


昨天說到

昨天把 323 行的規格拆成幾份文件,其中 data-model.md 裡面有一節叫〈幾個刻意的選擇〉,一共五條,每一條都在解釋:

為什麼這個欄位要這樣設計?

那一節是訪談那一輪 AI 自己整理出來的,今天要一條一條驗證。


今天要解決的問題

先看其中一條規定:

version_decisions
    FK (version_id, user_id) → version_required_confirmers

規格對它的說明是:

所以「非名單內的人不可能留下確認紀錄」是資料庫層級的保證,不只是 service 檢查。

這句話有兩個值得注意的地方。

  • 區分了層級
    同一條業務規則,寫在 service 裡和寫在資料庫裡,能擋住的範圍不一樣。service 的檢查,下一個人新增一支 endpoint、忘了呼叫那段檢查,就可能繞過去;資料庫 constraint 則是在寫入資料時直接介入,不管是 ORM、Repository,甚至直接下 SQL,只要碰到那個 constraint,就必須遵守。

  • 這是一個可以被驗證的宣稱
    它不是「我們會小心處理」這種驗不出來的句子,如果真的做不到,資料庫會直接擋下來。今天會用四條規則去驗證規則是否有實行。

這系列前面幾天其實一直在推同一條線:能被落實的規則,是那些可以被寫成檢查的規則。D13 又補了一層:寫得出檢查,不代表不同文件之間不會互相矛盾。今天再往下走一層:

檢查寫在哪一層,決定它到底擋得住誰。


寫在不同層級的效果

我先把常見的幾種情況攤開來看:

落在哪一層 擋得住 擋不住
資料庫約束 所有寫入路徑,包含手動 SQL、未來的批次腳本 有權限修改 schema / constraint 的人
service 檢查 走正常流程的呼叫 繞過 service 的新 endpoint、repository、直接操作 Repository / DB
路由與 API 結構 新增 endpoint 的人(如果有測試盯著 直接連資料庫的人
註解/docstring 會讀註解、而且願意遵守的人 其他所有情況

由上往下,強度遞減。

這張表的重點不是「所有規則都應該推到資料庫」,有些規則本來就不適合放進 DB,可能是因為成本太高,也可能是因為會把資料庫 schema 綁死在某個應用流程上。真正重要的是:

要知道每一條規則目前在哪一層被保證。


實際操作:繞過 service,直接對資料庫下 SQL

驗證方式很直接,先建立一組最小資料,然後用 raw SQL 做四件業務規則明令禁止的事情。

整個過程:

  • 不經過 FastAPI
  • 不經過 Service
  • 不經過 ORM 的業務驗證
def attempt(label, sql, params):
    try:
        with eng.begin() as c:
            c.execute(text(sql), params)
        print(f"  ⚠️  {label}\n      → 寫進去了,沒有任何一層擋住\n")
    except IntegrityError as e:
        first = str(e.orig).strip().split("\n")[0]
        print(f"  ✅ {label}\n      → {first}\n")

四次嘗試:

直接對資料庫下 SQL,完全繞過 service 層:

  ✅ 名單外的人留下確認紀錄
      → insert or update on table "version_decisions" violates foreign key
        constraint "fk_decision_is_required_confirmer"

  ✅ 同一份記錄開第二個待確認版本
      → duplicate key value violates unique constraint
        "uq_note_versions_single_pending"

  ✅ 塞一個不存在的狀態
      → new row for relation "note_versions" violates check constraint
        "version_status"

  ⚠️  改掉已送出版本的內容
      → 寫進去了,沒有任何一層擋住

最後這個版本的內容:偷改的標題 / 偷改的內容

前三條完全如規格所述:第一條是 FK、第二條是 partial unique index、第三條則是 CHECK Constraint。

第二條值得多講一句:它不是 Unique Constraint,是帶 WHERE 條件的唯一索引——「同一份記錄只能有一個待確認版本」這種帶條件的唯一性,在 PostgreSQL 只寫得成 index,寫不成 constraint。已退回、已生效的舊版本不受它管。

第四條則是我刻意加進來的對照組,規格寫著:已送出的版本不可修改。我原本預期它也會被擋住,結果沒有。


踩到的坑

症狀:第四次嘗試直接成功。已經送出的版本,標題和內容被 UPDATE 改掉,資料庫沒擋下。

而這條規則是 CLAUDE.md「不可違反的業務規則」中的第一條:

記錄一旦送出就唯讀,不分後續狀態(待確認、已退回、已生效皆然)。

真正的原因:不是漏了一個 constraint,是這條規則沒有被寫成資料庫層的機制。

models/version.py 的 docstring 長這樣:

class NoteVersion(Base, IdMixin, TimestampMixin):
    """A submitted version. Content is frozen at INSERT and never updated."""

「frozen at INSERT and never updated」是一句陳述,不是一個機制。它描述的是「我們打算這樣用」,讀起來卻像「資料庫已經保證這件事情」——而它落在最底下那一層:註解。對資料完整性來說,強度是零。

怎麼修
在資料庫裡放一個守衛,任何人要改那張表,都得先過它。

這個守衛在 PostgreSQL 裡叫 trigger(觸發器)。它是一段存在資料庫裡的小程式,設定成「每次有人要 UPDATE 這張表,先執行我」。

比對「改之前」和「改之後」,
  如果 標題 / 內容 / 版本號 / 作者 / 送出時間 任何一個變了  → 報錯,整筆取消
  如果只有 狀態 / 決議時間 變了                            → 放行

守衛只鎖內容,不鎖狀態。

加上去之後再測一次,同一行 UPDATE 就被擋下來了:

UPDATE note_versions SET title='偷改的標題' ...
→ ERROR: note_versions content is immutable once submitted

那什麼時候該推到資料庫?

可以用三個問題判斷:

一、兩個請求同時進來,這條規則還成立嗎?

如果答案是「必須成立」,單純靠 Service 的「先查再寫」就不夠。

例如:一個 Meeting 只能有一個 pending version。

兩個請求同時查詢時,都可能得到「目前沒有 pending」,接著各自 INSERT。這時候就應該讓 Database 用 unique constraint 之類的機制,從資料層保證規則永遠成立。

二、違反這條規則,會不會造成不可逆的資料遺失?

如果會,就值得考慮把保護往 Database 推。

例如:已送出的 version 不可以修改。

如果有人直接 UPDATE 把內容蓋掉,而系統又沒有保存舊版本,原本的內容可能就永久消失。這種規則不能只靠「希望 Service 不要改」,而應該思考更強的資料層保護。

相反地,像是 Reject 時必須填寫理由,如果漏掉了,資料本身沒有消失,之後仍然可以補上。這種規則通常留在 Service 就足夠。

三、這條規則是在描述「資料長什麼樣子」,還是「流程怎麼走」?

如果是在描述資料形狀:

  • status 只能是特定幾個值
  • FK 必須對得上
  • 某些資料不能重複

這些是 Database 原生就很擅長保護的事情。

如果是在描述流程與權限:

  • 誰可以做
  • 什麼狀態才能做什麼
  • confirm 之後下一個狀態是什麼
  • reject 之後要回到哪個狀態

因為流程會變,所以這些通常放在 Service 比較合理。

並非二選一

像這個系統的「同時只能有一個待確認版本」,submit_draft 裡兩層都寫了:

# 第一層:service 先查,查到就給一個人看得懂的錯誤
if version_repository.by_status(
    db, note_id, VersionStatus.PENDING_CONFIRMATION
) is not None:
    raise errors.pending_version_exists()
...
try:
    version = version_repository.create(...)
    db.commit()
except IntegrityError:
    # 第二層:有人擠在我們的「檢查」與「寫入」之間,資料庫的 index 擋下來了
    db.rollback()
    raise errors.pending_version_exists() from None

資料庫和 service 兩條路徑收斂成同一個錯誤碼。


小結

  • 同一條業務規則,落在資料庫約束 / service 檢查 / 路由表的形狀 / 註解,能擋住的範圍差很多。
  • 規格裡「這是資料庫層級的保證」驗證方式:建一組最小資料,用 raw SQL 做規格說你做不到的事,看會不會被擋。
  • 這次四條規則裡,三條真的被資料庫 constraint 擋住;第四條則直接寫進去了。而那條是「不可違反的業務規則」的第一條——它的保證寫在 docstring 裡。
  • 決定放哪一層:併發下要不要成立違反之後救不救得回來它描述的是資料的形狀還是流程的順序
  • 所以重點不是「全部推到資料庫」,而是規格要寫明每一條守在哪一層、那一層能防什麼、不能防什麼。沒寫的話,下一個人只會看到一句「已經有在擋了」。

明天:規格四份都齊了,但怎麼知道 AI 有沒有幻覺、有沒有把待決事項悄悄變成已決定?交付給開發之前先過一輪自檢。


上一篇
Day 14|規格四件套的職責邊界
下一篇
Day 16|API 契約先行,與交付前的規格自檢
系列文
30 天打造我的 AI 開發工作流:從需求分析到上線18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言