所以,今天該來幹點正事了。
終於要串 API 了嗎?
什麼叫終於?我也就是昨天偷懶了一下好嗎?
我去不早說。
何意味?
總之,前幾天登入頁的 UI 已經差不多弄好了。
帳號可以填、密碼可以打、按鈕也可以按。
看起來一切都很正常。
除了按下登入之後——
什麼都不會發生。
那不就是不能登入嗎?
至少它長得很像可以登入。
有什麼用?
咳。
所以今天的目標也很簡單:
把前端跟後端真的接起來。
前面花了這麼多天搞 Session、Cookie、HttpOnly,甚至連登入 API 都已經在後端躺好了。
也是時候讓前端真的把帳號密碼送過去了。
既然要串 API,第一件事自然就是——
把 API 丟給 Agent,然後叫它自己看。
?
不是,我是認真的。
現在前端跟後端是兩個獨立的專案。如果直接跟 Agent 說:
「幫我把登入 API 串起來。」
那它至少得先知道幾件事情:
當然,我可以把這些東西全部手動寫進 Prompt。
但問題是——
FastAPI 明明就已經幫我寫好了。
FastAPI 本身會根據我們定義的 Route、Pydantic Schema 等資訊,自動產生一份 OpenAPI Specification(OpenAPI 規格)。
而我們平常看到的 /docs,也就是 Swagger UI,其實就是把這份規格拿來生出一個方便人類閱讀、測試 API 的介面。
換句話說,與其讓我重新跟 Agent 描述一次:
「登入是 POST,路徑是什麼什麼,然後 Body 裡面有什麼什麼……」
不如直接讓後端自己說。
所以你要把 Swagger UI 丟給它?
呃,也不用。
Swagger UI 是給人看的。
真正有意思的是它背後那份:
/openapi.json
這裡面才是整個 API 的機器可讀規格。
於是事情突然變得簡單很多。
後端負責定義 API,OpenAPI 負責描述 API,Agent 負責照著規格串 API。
至少理論上是這樣。
「理論上」?
……
你不要急。
我們的後端部署在 localhost:8000 上,所以我們先來訪問 localhost:8000/openapi.json 吧。
如果是給人看的話可以訪問 /docs。
{"openapi":"3.1.0","info":{"title":"FastAPI","version":"0.1.0"},"paths":{"/auth/login":{"post":......中間省略無數多字{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer"}}}}
看不懂。
那你拿它幹嘛?
我看不懂沒關係。
Agent 看得懂就好。
因為這份 JSON 裡面其實已經描述了後端有哪些 Endpoint(端點)、接受哪些資料、預期會回傳哪些資料,以及規格中定義的 Response。
所以比起我自己重新描述一次 API,再祈禱中間沒有漏掉什麼,不如直接把這份 OpenAPI 規格交給 Agent。
然後告訴它:
「這是目前後端的 OpenAPI 規格。請根據這份規格與現有 Angular 專案,規劃登入 API 的串接方式,先不要修改程式碼。」
這樣至少第一步,我們不用讓 Agent 通靈。
所以接下來就能一鍵完成了?
理論上。
又來?
因為很不幸地——
API 看得懂,不代表串起來就一定能動。

看起來問題不大。
但現在還有一個非常現實的問題。
資料庫裡根本沒有帳號。
那你要登入誰?
不知道。
6。
咳,總不能為了測個登入,現在跑去做一套註冊系統吧。
所以我們先準備一份 Seed Data(種子資料),塞幾個開發環境會用到的帳號進去。
畢竟我們現在的目標只有一個——
先讓這顆登入按鈕真的能登入。
Seed Data(種子資料),簡單來說,就是預先準備一批讓系統可以開始運作的資料。
像我們現在資料庫雖然 Schema 都建好了,但裡面連個「一般學生」都沒有,更別說可以拿來登入的帳號。
所以我讓 Agent 幫忙補了一個手動執行的 Seed:
python -m database.seed
預設會先建立幾筆開發環境需要的基礎資料:
群組
└── 320
職務
├── 一般學生
├── 班代表
├── 資訊股長
└── 教師
不過這些東西還是不能登入。
你擱這擱這呢?
你急什麼。
畢竟正式環境可不能執行個 Seed,就憑空冒出幾個知道密碼的帳號。
所以測試帳號另外放在 --dev-account:
python -m database.seed --dev-account
密碼則從環境變數讀取:
SEED_DEV_PASSWORD=********
這樣才會額外建立開發用的學生與教師帳號。
而且這個 Seed 可以重複執行。
已經存在的資料就直接跳過,不會因為我手滑又執行一次,就突然多出:
一般學生
一般學生
一般學生
一般學生
四個一般學生不是很正常嗎?
……
是職務。
總之,先把 Migration 跑到最新版:
alembic upgrade head
再把 Seed 塞進去:
python -m database.seed --dev-account
現在,資料庫裡終於真的有一個可以拿來登入的帳號了。
那我們終於可以回來做今天原本要做的事情——
登入。

成功!
就這?
對阿。
你搞了這麼多天,最後就多了一個「歡迎,teacher」?
欸你不要小看這幾個字。
雖然畫面上只有一個框框,但剛剛按下登入之後,背後其實已經繞了一大圈。
Angular 先把登入需要的資料送到 /auth/login,後端找到對應的帳號、驗證密碼,建立 Session,再把 Session Token 塞進 HttpOnly Cookie。
接著前端再呼叫 /auth/me,瀏覽器自動把 Cookie 帶回去,後端才終於告訴前端:
「對,這傢伙是 teacher。」
然後才有了現在看到的——
歡迎,teacher。
所以這個框框其實很貴?
含金量很高的好嗎。
而這幾個字的背後,其實不只跑了一個 Request。
先從前端開始看。
前幾天我們已經把登入頁畫好了,所以今天真正新增的東西,其實沒有想像中複雜。
我先把跟登入有關的 API 包成一個 AuthService:
login(request: LoginRequest): Observable<AccountProfile> {
return this.http
.post<void>('/auth/login', request, { withCredentials: true })
.pipe(switchMap(() => this.me()));
}
me(): Observable<AccountProfile> {
return this.http.get<AccountProfile>(
'/auth/me',
{ withCredentials: true }
);
}
第一眼看起來好像就是:
登入,結束。
但仔細看的話,這裡其實偷偷做了兩次 Request。
你登入一次打兩次 API?
對。
第一次是:
POST /auth/login
Angular 把我們剛才輸入的帳號、身分類型、職務和密碼送到後端。
FastAPI 找到對應帳號、驗證密碼,接著建立一筆 Session。
如果一切正常,Response 裡還會出現一個我們前幾天講到爛掉的東西:
Set-Cookie: __Host-session=...
不過 Angular 並不需要知道裡面那串 Token 是什麼。
甚至,它不應該知道。
因為我們前面已經把這顆 Cookie 設成了 HttpOnly。
Token 交給瀏覽器保管,前端 JavaScript 不碰它。
那你怎麼知道現在登入的是誰?
好問題。
這就是第二個 Request 出場的地方。
這個 Request 就藏在剛才那段程式的這裡:
.pipe(switchMap(() => this.me()));
這又是什麼魔法?
先不管 switchMap。
我們先看後面那個 this.me()。
me(): Observable<AccountProfile> {
return this.http.get<AccountProfile>(
'/auth/me',
{ withCredentials: true }
);
}
當 /auth/login 成功之後,前端馬上又送出第二個 Request:
GET /auth/me
這次我們沒有再傳帳號,也沒有再傳密碼。
甚至連剛才那串 Session Token 都沒有自己塞進 Request。
那後端怎麼知道你是誰?
瀏覽器知道。
還記得剛才 /auth/login 回來的:
Set-Cookie: __Host-session=...
嗎?
瀏覽器收到之後,就已經把這顆 Cookie 保存起來了。
而我們在 Request 裡又設定了:
withCredentials: true
因此呼叫 /auth/me 時,符合 Cookie 規則的情況下,瀏覽器就會自動把 Session Cookie 一起帶回去。
後端拿到 Cookie 裡的 Token,再把它做 SHA-256 雜湊,拿雜湊後的結果去資料庫尋找還有效的 Session。
找到 Session 之後,就可以一路找到它屬於哪個 Account、掛在哪個 Position 和 Group,最後組成一份 AccountProfile 回來:
export interface AccountProfile {
id: string;
account: string;
position_name: string;
group_name: string;
display_name: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
所以整個流程其實是:
POST /auth/login
↓
驗證帳號密碼
↓
建立 Session
↓
Set-Cookie
↓
瀏覽器保存 Cookie
↓
GET /auth/me
↓
瀏覽器帶上 Cookie
↓
後端找到 Session 與 Account
↓
回傳 AccountProfile
等等,所以登入 API 自己不告訴你「你是 teacher」?
沒錯。
/auth/login 在這裡負責的是:
「證明你是你,然後建立登入狀態。」
而 /auth/me 負責的是:
「既然你已經登入了,那告訴我你是誰。」
兩件事情拆開之後,之後其他頁面如果想知道目前登入的是誰,也不用重新登入一次,只要再問 /auth/me 就好了。
至於剛才那個:
switchMap(() => this.me())
做的事情其實也就很好理解了。
登入成功之後,接著去取得目前使用者資料,再把最後拿到的 AccountProfile 往後傳。
於是 Login Component 最後收到的就不是 Session Token,而是使用者資料:
next: (profile) => {
this.welcomeName.set(
profile.display_name?.trim() || profile.account
);
this.isSubmitting.set(false);
}
我們的開發帳號沒有設定 display_name,所以最後就退回使用 account。
也就是:
teacher
於是繞了這麼大一圈之後——
歡迎,teacher。
好,這框框好像真的有貴一點。
你終於懂了。