iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Modern Web

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

Day 13|該來跟前端培養下感情了吧,登登登登登登入!嗎?

  • 分享至 

  • xImage
  •  

書接上回。

經過前幾天的一番折騰,我們總算把登入這件事處理得差不多了。

Session 有了、Cookie 塞了,連環境變數都搬出了程式碼。

既然後端都準備得差不多了,那也是時候來處理一個被我們冷落很久的東西了。

前端?

對。

你終於想起來我們還有前端了?

咳咳,什麼叫「終於想起來」。

這叫先把地基打好,再開始裝潢。

你最好是。

先來想想登入頁要放什麼

總之,既然登入 API 都寫好了,那今天的目標應該很簡單。

做個登入頁,把帳號、密碼丟給後端,拿到 Session,收工。

就這?

就這。

甚至連登入頁要長怎樣,我都已經想好了。

學生嘛,跟舊系統差不多:

帳號:s399
職務:資訊股長
密碼:********

        登入

畢竟前面設計 Account 的時候,我們就是靠「帳號 + 職務」來區分不同帳號。

例如同樣都是 s399:

s399 + 一般學生
s399 + 班代表
s399 + 資訊股長

三組帳號名稱一樣,但職務不同、密碼也不同。

所以登入時讓使用者選個職務,很合理。

那老師呢?

老師?

老師的登入頁啊。

喔,一樣啊。

帳號、職務、密……

……

?

等一下。

老師的「職務」有什麼?

老師啊。

不是,我是說我們資料庫裡的 position。

老師、導師、資訊組長、設備組長、教學組長、註冊組長……

再加上其他行政職務……

……

然後全部塞進下拉選單?

好問題。
先不說好不好找,光想到那個下拉選單就不太舒服。

那不要選不就好了?

老師輸帳號密碼登入就好啦。

對啊。

帳號密碼就……

……

好像也不行。

又怎樣?

我們之前在 accounts 上是這樣限制的:

(account, position_id) UNIQUE

也就是說,資料庫真正保證唯一的從來都不是:

account

而是:

account + position

學生這樣完全沒問題。

因為我們本來就需要:

s399 + 一般學生
s399 + 班代表
s399 + 資訊股長

但老師如果登入時不選 position……

那我光靠一個帳號,根本沒有辦法從資料庫層保證只會找到一個人。

所以選職務,老師要在一大坨職務裡面找自己。

不選職務,你前面的唯一鍵又不保證帳號唯一。

……

對。

登入頁都還沒寫就炸了?

準確來說,

是我在想登入頁要怎麼寫的時候,發現 Day 5 埋的地雷終於炸了。

看來今天在跟前端培養感情以前,

我們得先回去處理一下前任留下來的感情債。

前任?

五天前的我。

UNIQUE(account, position_id) 到底哪裡出了問題?

先別急著把前任拖出來扁。

我們先回頭看看,當初為什麼會寫出這個東西。

(account, position_id) UNIQUE

還記得舊系統的帳號嗎?

同一個班級底下,會有一般學生、班代表、資訊股長三種帳號。

問題是,它們的帳號名稱還是一樣的。

假設今天是 s399:

s399 + 一般學生
s399 + 班代表
s399 + 資訊股長

如果我們只限制:

account UNIQUE

那第一個 s399 塞進去之後,剩下兩個就可以回家了。

所以當時我才把 position_id 一起拉進來,組成:

(account, position_id) UNIQUE

這樣一來,只要同一個帳號不能出現兩個相同的職務,就能同時保留三組帳號。

所以這設計沒錯啊?

對。

至少放在學生身上沒錯。

……喔。

對,問題就在這個「學生」上。

我們當初想的是:

帳號 + 職務 = 唯一的 Account

但更精確地說,其實應該是:

學生帳號 + 職務 = 唯一的學生 Account

我們解掉了學生共用帳號的問題,然後非常順手地把同一條規則套到了整張 accounts 上。

直到今天真的開始想:

「那老師到底要怎麼登入?」

這個假設才終於露餡。

所以不是 Unique Constraint 寫錯了?

是我們管太寬了?

Bingo。

那就讓學生跟老師用不同的規則?

既然問題是我們把學生的規則套到了所有帳號上,那解法好像也很直覺。

把它拆開不就好了?

學生需要:

account + position = UNIQUE

老師則只需要:

account = UNIQUE

好,問題解決,收工!

想得美。

我們現在只有一張 accounts。

總不能跟 PostgreSQL 說:

如果這個人是學生:
    UNIQUE(account, position_id)

如果這個人是老師:
    UNIQUE(account)

為什麼不行?

……

好問題。

好像真的可以。

?

不過在處理這兩條規則之前,我們還缺一個東西。

現在的 accounts 根本不知道自己是學生還是老師。

所以第一步,我們得先讓它知道。

最直接的方法,就是加一個欄位:

accounts
----------------
id
account
account_type
position_id
password_hash
group_id
display_name

其中 account_type 可以先有:

STUDENT
TEACHER

這樣我們終於能把剛才那兩條規則寫得更精確:

account_type = STUDENT
→ (account, position_id) 必須唯一

account_type = TEACHER
→ account 必須唯一

可是 UNIQUE 不是加在整個欄位上的嗎?

你要怎麼叫它只管學生或只管老師?

這就是今天第一個原本完全沒打算研究的東西了。

Partial Unique Index。

只管一部分資料的 Partial Index

一般的 Index 大概像這樣:

CREATE INDEX idx_accounts_account
ON accounts (account);

它會替整張 accounts 建 Index。

但 PostgreSQL 還有一種東西叫 Partial Index。

顧名思義:

我不要全部,我只要其中一部分。

例如:

CREATE INDEX idx_students_account
ON accounts (account)
WHERE account_type = 'student';

這個 Index 就只包含:

account_type = student

的資料。

喔,所以後面那個 WHERE 就是在決定哪些資料要進 Index?

對。

那如果再把它變成 UNIQUE INDEX 呢?

CREATE UNIQUE INDEX uq_accounts_teacher_account
ON accounts (account)
WHERE account_type = 'teacher';

意思就變成:

對所有 account_type = teacher 的資料來說,account 不准重複。

學生則可以:

CREATE UNIQUE INDEX uq_accounts_student_account_position_id
ON accounts (account, position_id)
WHERE account_type = 'student';

於是資料庫裡就會形成兩套規則。

學生:

student | s399 | 一般學生    ✓
student | s399 | 班代表      ✓
student | s399 | 資訊股長    ✓
student | s399 | 資訊股長    ✗

老師:

teacher | t001 | 教師        ✓
teacher | t001 | 資訊組長    ✗
teacher | t002 | 資訊組長    ✓

注意最後這裡。

老師其實還是有 position_id。

我們現在並沒有把 Position 從老師身上拔掉。

蛤?
那剛才是在忙什麼?

我們拔掉的是:

「Position 必須參與老師的登入識別」這個假設。

這兩件事情差很多。

老師登入之後,系統依然可能需要知道他的職務,來判斷他有哪些權限。

只是使用者不需要在登入頁告訴我:

「我是 t001,然後我是教師。」

因為 t001 對老師來說,本身就已經足以唯一識別 Account 了。

所以 Position 還是留著。
只是不用拿來證明「你是誰」?

Exactly。

而這也剛好碰到了一個很重要的差別。

Authentication 跟 Authorization 並不是同一件事。

Authentication 問的是:

你是誰?

Authorization 問的是:

你能做什麼?

我們現在改的是前者。

不是把整套權限系統拆掉重做。

不然今天這篇大概就不是跟前端培養感情了。

是跟 IAM 再婚。

把它寫進 SQLAlchemy

既然規則確定了,接下來就是把剛才的 SQL 搬進 Model。

原本的:

UniqueConstraint(
    "account",
    "position_id",
    name="uq_accounts_account_position_id",
)

可以退休了。

換成:

__table_args__ = (
    Index(
        "uq_accounts_student_account_position_id",
        "account",
        "position_id",
        unique=True,
        postgresql_where=text("account_type = 'student'"),
    ),
    Index(
        "uq_accounts_teacher_account",
        "account",
        unique=True,
        postgresql_where=text("account_type = 'teacher'"),
    ),
)

其中:

unique=True

代表這是一個 Unique Index。

而:

postgresql_where=text("account_type = 'student'")

則是告訴 SQLAlchemy:

這個 Index 只管學生。

老師同理。

這樣一來,原本那條管遍天下的:

UNIQUE(account, position_id)

就正式被拆成:

student → UNIQUE(account, position_id)
teacher → UNIQUE(account)

各管各的。

所以前任的感情債還完了?

還沒。

資料庫 Schema 改了,Migration 還在後面瞪著我們。

不准偷改舊 Migration

目前專案裡已經有:

20260920_0001_initial_authorization_schema
20260924_0002_add_sessions

最簡單的做法是什麼?

打開 0001,假裝我們從來沒有寫錯過。

……

你這個思想很危險。

Migration 記錄的不是:

「資料庫現在應該長怎樣。」

而是:

「資料庫是怎麼一步一步走到現在的。」

所以就算 0001 是我自己七天前寫的,也不能因為今天看它不爽,就直接回去竄改歷史。

我們要新增一個 Migration。

概念上做這幾件事:

1. 新增 account_type
2. 處理既有 Account 的 account_type
3. account_type 設成 NOT NULL
4. 移除舊的 UNIQUE(account, position_id)
5. 建立學生 Partial Unique Index
6. 建立教師 Partial Unique Index

而不是回去跟 0001 說:

你從出生開始就是 Partial Index。

七天前的自己也是自己,改一下有差嗎?

有。

尤其哪天資料庫真的不只你電腦上那一份的時候,差很多。

接著才輪到登入 API

資料庫終於知道學生跟老師的規則不同了。

那 API 當然也要知道。

原本的 LoginRequest 是:

class LoginRequest(BaseModel):
    account: str
    position_name: str
    password: str

也就是:

帳號     必填
職務     必填
密碼     必填

這就是問題的來源。

現在改成概念上:

class LoginRequest(BaseModel):
    account_type: AccountType
    account: str
    position_name: str | None = None
    password: str

學生送:

{
    "account_type": "student",
    "account": "s399",
    "position_name": "資訊股長",
    "password": "********"
}

老師則是:

{
    "account_type": "teacher",
    "account": "t001",
    "password": "********"
}

但這裡還有一件事情要注意。

position_name 變成 Optional,不代表學生也可以不傳。

對學生來說:

position_name = 必填

對老師來說:

position_name = 不參與登入

所以 Request 本身還是要驗證這條規則。

等等。
那登入是不是要寫兩套了?

不用。

真正不同的其實只有一小段。

怎麼找到 Account。

學生:

account
    +
position_name
    ↓
找到 position_id
    ↓
account + position_id
    ↓
Account

老師:

account
    ↓
Account

一旦兩邊都找到 Account,後面又完全一樣了。

                 ┌─ Student
                 │  account + position
Login Request ───┤
                 │
                 └─ Teacher
                    account
                      │
                      ▼
                   Account
                      │
                      ▼
                驗證 Password
                      │
                      ▼
                建立 Session
                      │
                      ▼
                Set-Cookie

所以我們完全沒有必要複製兩套:

verify_password(...)
create_session(...)
set_cookie(...)

只需要在前面分流,找到 Account 之後重新合流。

喔。
岔路只有前面一小段。

對。

而且這次改完之後,原本那些東西幾乎都不用碰。

Argon2 還是 Argon2。

Session 還是 Session。

Token 還是拿安全亂數產生。

資料庫還是只存 Token Hash。

Cookie 還是:

HttpOnly
Secure
SameSite=Lax

前幾天寫的東西沒有因為我們今天突然發現 Schema 有問題,就全部陪葬。

值得鼓掌。

主要是你今天終於沒有砍掉重練。

閉嘴。

測一下我們是不是真的修好了

這種東西只看程式碼說:

嗯,看起來可以。

我是不太放心的。

所以先從資料庫規則開始測。

第一個:

s399 + 一般學生
s399 + 資訊股長

可以同時存在。

通過。

第二個:

s399 + 一般學生
s399 + 一般學生

不行。

通過。

第三個比較重要。

假設:

t001 + 教師

已經存在。

再塞:

t001 + 主任

雖然 position_id 不同,但因為兩筆都是 Teacher,所以:

account = t001

重複。

資料庫拒絕。

通過。

好,所以 Partial Unique Index 真的有在幹活。

對。

接著測登入。

學生:

account_type = student
account = s399
position = 一般學生
password = 正確

登入成功。

少 position?

422

選錯 position?

401

老師:

account_type = teacher
account = t001
password = 正確

登入成功。

不用再選一次「教師」。

最後再試一個有趣的。

把老師 t001 假裝成學生:

account_type = student
account = t001
position = 教師
password = 正確

結果:

401

因為學生的查詢現在除了:

Account.account == account
Account.position_id == position_id

還會要求:

Account.account_type == AccountType.STUDENT

所以就算帳號、職務、密碼全部剛好對得上,

Teacher Account 也不會被 Student 的登入流程撈走。

很好。

這顆地雷總算拆完了。

所以我們今天寫了多少前端?

好了。
Schema 改了。
Partial Index 寫了。
Migration 做了。
Service 改了。
Login API 改了。
Test 也過了。

嗯。

那今天不是要跟前端培養感情嗎?

……

前端呢?

……

至少我們現在知道登入頁要放什麼了。

學生:

帳號
職務
密碼

老師:

帳號
密碼

所以你今天一行前端都還沒寫?

你不要講得這麼難聽。

這叫——

先處理好上一段感情留下來的問題,再開始下一段感情。

五天前的你?

對。

那現在感情債還完了?

還完了。

所以明天終於可以寫前端了?

……

可以了啦。


上一篇
Day 12|奇怪了,.env 不是有寫嗎?我去,沒用 dotenv……
下一篇
Day 14|這次真的來寫前端了,至於為什麼是 Angular?因為我想
系列文
重寫一套比我還老的系統:21 歲的校園文字廣播系統 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言