iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Modern Web

重寫一套比我還老的系統:21 歲的校園文字廣播系統系列 第 22 篇

Day 22|資料庫:你們學校幾點放學關我屁事

  • 分享至 

  • xImage
  •  

昨天,我們總算把廣播相關的資料表補得差不多了。

broadcasts、broadcast_targets、broadcast_confirmations、replies,該有的東西基本上都有了。

甚至連昨天一直在講的那棵樹,也真的種進 PostgreSQL 了。

所以今天終於可以寫 API 了?

不急。

又不急?

因為我突然發現一個問題。

我們昨天雖然把「資料要怎麼存」想得差不多了,但資料庫其實根本不知道這套系統要怎麼運作。

例如:

  • 下午 3 點前發的廣播,今天要確認。
  • 下午 3 點後發的,責任算到明天。
  • 下午 4:30 是確認期限。
  • 老師可以發廣播。
  • 一般學生不能確認。
  • 回覆不能跨班亂接。

問題來了。

PostgreSQL 怎麼會知道我們學校幾點放學?

你可以告訴它啊。

……

好像也是。

但這就是今天真正要處理的問題。

資料庫:你們學校幾點放學關我屁事

昨天做的事情,大部分都是 Database Constraint 很擅長的。

例如:

BroadcastTarget
PRIMARY KEY (broadcast_id, group_id)

這代表同一則廣播不能把同一個班級 Target 加兩次。

Foreign Key 也可以保證 author_id 不能指向一個根本不存在的 Account。

這些規則有一個共同點:

它們描述的是「資料長什麼樣」。

但:

下午 3 點後發出的廣播,確認期限是隔天下午 4:30。

這就不是單純看 Schema 能知道的事情了。

於是我們終於來到了 Service Layer。

如果說 Database 在管:

這筆資料可不可以存在?

那 Service Layer 管的大概就是:

這件事情在這套系統裡可不可以發生?

所以,下午 3 點到底是什麼?

先從昨天留下來最大的問題開始。

我們已經決定,每天的切點是:

15:00

15:00 前發出的廣播,確認責任算在今天;15:00 後發出的,則算到下一個週期。

例如:

10/06 14:59 發送
→ Deadline:10/06 16:30

10/06 15:00 發送
→ Deadline:10/07 16:30

看起來很簡單。

直到我想到:

如果星期五下午 3:30 發呢?

星期六下午 4:30?

你們星期六上課嗎?

那國定假日呢?

補班補課呢?

校慶呢?

……

閉嘴。

我們當然可以做 School Calendar,把國定假日、學校行事曆、特殊上課日全部塞進去。

聽起來非常完整。

也非常麻煩。

更重要的是——

我們真的需要嗎?

如果有老師星期五下午 3 點多突然發一則廣播,然後期待星期六下午 4:30 前資訊股長一定要登入確認……

我覺得問題可能不在系統。

所以最後決定非常簡單:

不管。

15:00 後就是下一個 Calendar Day。

星期五後面就是星期六,國定假日也照算。

有時候最簡單的 Business Rule,不是把現實世界建模得鉅細靡遺。

而是先決定:

哪些現實世界的複雜度,我根本不打算處理。

週期是責任,不是結界

接著又冒出另一個問題。

假設今天 15:20 發了一則廣播。

照剛才的規則:

確認週期:明天
Deadline:明天 16:30

那這則廣播什麼時候出現?

答案是:現在。

因為「明天才需要負責確認」跟「明天才能看到」根本是兩回事。

所以:

發布時間 → 決定何時看得到
確認週期 → 決定何時應該確認

兩件事情正式拆開。

同樣的道理,如果資訊股長今天 15:21 就順手把明天週期的廣播按掉,也完全沒問題。

沒有必要跳出一個:

不行喔,這是明天的工作,請你明天再來按。

神經病。

所以週期限制的是責任,不是操作權。

16:30 也不是結界

那超過 16:30 呢?

按照現在舊系統的行為:

還是可以按。

只是你遲到了。

所以 ack_deadline_at 是 Deadline,不是 Lock Time。

我們只需要比較:

confirmed_at <= ack_deadline_at
→ 準時確認

confirmed_at > ack_deadline_at
→ 逾期確認

如果 Deadline 都過了還沒按,就是逾期未確認。

也因此根本沒有必要再往資料庫塞一個 is_late。

它不是新的事實,只是既有資料算出來的結果。

欠上週的,不要拿來煩這週的我

再來是每週顯示。

如果上星期有一則廣播,399 到星期日都沒確認,到了星期一,要不要繼續出現在這星期的待確認清單?

最後決定:

不要。

上週就是上週,這週就是這週。

不然一個班只要哪天漏掉一則,之後每個星期打開系統都有一個紅點跟著你,頗有一種陰魂不散的感覺。

但回到上週的歷史紀錄,它依然會顯示:

逾期未確認

而且還是可以補按。

補按之後就變成:

逾期確認

所以:

上週欠的,不計入這週工作量;但上週欠過,系統不會忘記。

到底誰可以按確認?

我們目前學生帳號的職位權限大概是:

職位 查看 確認 回覆
一般學生 ✓ ✗ ✗
資訊股長 ✓ ✓ ✓
班代表 ✓ ✓ ✓

所以確認的判斷是:

這個 Account 所在的班級,是不是這則 Broadcast 的 Target?它的職位又有沒有確認權限?

而 Confirmation 本身仍然屬於:

Broadcast × Group

假設 399 資訊股長先按了,那 399 就已經確認。

至於到底是誰按的,則交給:

confirmed_by_account_id

留下紀錄。

責任狀態屬於班級,操作紀錄則留下實際執行的 Account。

老師又不住在每個班裡

Broadcast 的發送權限反而簡單很多。

目前先直接定:

account_type == TEACHER

才能發。

同時 Teacher 也不需要「屬於」某個班級。

例如:

Teacher
  ↓
Broadcast
  ├── 398
  ├── 399
  └── 400

完全合法。

不然行政或教師端如果還得先成為 399 的 Group Member 才能發廣播給 399,整件事情反而很奇怪。

所以班級 Group 是收件範圍,不是老師的權限 Domain。

另外,一則 Broadcast 至少要有一個 Target。

我們沒有 Draft。

按下發送,就是發送。

發出去的東西不要偷偷改

接著是一個我覺得很重要的決定:

Broadcast 發送後不可修改,也不可刪除。

假設老師發:

明天記得帶平板。

399 已經按確認。

結果老師偷偷改成:

明天記得帶平板跟 500 元。

那資料庫雖然還會告訴我們「399 已確認」,但 399 到底確認了哪個版本?

不知道。

Confirmation 的稽核意義直接被我們自己打爛。

所以乾脆:

發出去就不准改。

真的寫錯?

重發一則。

目前也不做 revision、superseded_by 之類的關係。

新的一則就是新的一則。

等等,那棵樹又有問題了

然後我們回頭看了一眼昨天才種好的 Reply Tree。

目前 replies 大概是:

replies
├── broadcast_id
├── author_id
├── content
└── ref_id

直到出現:

Broadcast #100
├── 398
├── 399
└── 400

399 的資訊股長回:

老師,明天需要帶平板嗎?

398 要不要看到?

不要。

每個班的 Reply 應該是獨立的。

一開始想說,那就在 replies 加個 group_id。

但仔細看昨天的 Schema,其實已經有一個東西完美描述:

「某則 Broadcast 發給某個 Group。」

就是:

broadcast_targets
-----------------
broadcast_id
group_id

所以 Reply 真正應該掛的不是單純的 Broadcast。

而是 BroadcastTarget。

Broadcast #100
│
├── Target 398
│   └── Reply Tree
├── Target 399
│   └── Reply Tree
└── Target 400
    └── Reply Tree

這樣 (broadcast_id, group_id) 就可以直接指向 broadcast_targets。

資料庫甚至可以直接保證:

你不能在一個根本沒收到這則廣播的班級底下憑空長出 Reply。

這比單純指向 groups.id 更精確。

那棵樹不能跨班長

既然 Reply 屬於 BroadcastTarget,那昨天的 Tree Invariant 也得升級。

如果 Reply 有 ref_id,Parent 必須同時滿足:

reply.broadcast_id == parent.broadcast_id
reply.group_id == parent.group_id

不能跨 Broadcast,也不能跨 Target Group。

不然我們前面辛苦把每個班的 Thread 隔離,結果 ref_id 一接,又全部長回去了。

看得到,不代表你可以講話

最後是 Reply 權限。

一般學生可以看自己班的 Reply Tree,但不能回覆。

資訊股長跟班代表可以。

Teacher 則比較有趣。

所有 Teacher 都可以查看所有 Broadcast,以及各個 Target 底下的 Reply。

但只有這則 Broadcast 的 Author可以回覆。

所以:

Teacher A 發 Broadcast #100

Teacher B 查看 → ✓
Teacher B 回覆 → ✗
Teacher A 回覆 → ✓

學生則只能存取自己班的 Thread。

這也讓 Read 跟 Write 正式分開:

看得到,不代表有權修改。

而 Reply 跟 Confirmation 也是兩回事。

你回了一句「收到」,不代表系統就幫你按確認。

確認就是確認,回覆就是回覆。

另外,Reply 也不跟著確認週期過期。

因為「週期」從頭到尾處理的都是確認責任,不是 Broadcast 或 Reply Thread 的死亡時間。

所以上週的廣播,照樣可以繼續回覆。

所以,Service Layer 到底在幹嘛?

繞了一大圈之後,今天其實沒有寫多少很炫的程式。

反而大部分時間都在回答:

「這種情況到底算什麼?」

但這些問題如果今天不回答,最後就一定會有人替我們回答。

可能是 Route。

可能是 Frontend。

也可能是半年後連我自己都不知道為什麼存在的一段 if。

所以 Database 跟 Service Layer 的分工其實變得很清楚。

Database 可以說:

這個 Reply 指向的 BroadcastTarget 必須存在。

Service Layer 則說:

你到底有沒有資格在這個 Target 裡 Reply?

Database 可以說:

同一個 Broadcast × Group 只能有一筆 Confirmation。

Service Layer則說:

這個 Account 到底能不能替這個 Group 確認?

Database 可以保存:

ack_deadline_at

但它根本不需要知道為什麼是那一天的 16:30。

資料庫:你們學校幾點放學關我屁事。

……

好啦。

確實不關你的事。

這些規則,就留給 Service Layer。

至於實作?

規格都定成這樣了,就交給 Agent 去把它寫完吧。

明天再來看看,要怎麼把這些規則真正接到 API 上。


上一篇
Day 21|等等,昨天那棵樹是不是少了幾根?
系列文
重寫一套比我還老的系統:21 歲的校園文字廣播系統 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言