iT邦幫忙

2026 iThome 鐵人賽

DAY 7
1

昨天 FastAPI 應用已經能跑,也能查到 Day 5 建立的測試資料。不過那些資料仍是用腳本預先寫入的。

今天要做第一支讓使用者實際填寫資料的 API。使用者可以告訴系統自己的程度、每週可投入時間與學習主題;系統會把資訊存起來,供 Day 13 生成計畫時使用。

今天完成後,你會有:

  • POST /users/profile:建立或更新學習檔案
  • GET /users/profile:查詢學習檔案
  • 完整的輸入驗證(程度只能是初級/中級/高級,時間不能是負數)
  • 找不到資料或使用者不存在時,回傳清楚的錯誤訊息

API 設計思路:RESTful 是什麼

RESTful 是一套「用網址和方法表達你要做什麼」的慣例。

想像成圖書館的服務窗口:同一個窗口(網址)根據你遞的單子種類(HTTP 方法)做不同的事。遞「借書單」(POST)就是新增一筆借閱紀錄,遞「查詢單」(GET)就是查資料,不會另外開一個窗口叫「新增借閱窗口」。

對應到今天:

POST /users/profile   → 新增或更新一份學習檔案
GET  /users/profile   → 查詢一份學習檔案

同一個網址 /users/profile,方法不同,做的事情不同。這是 RESTful 最基本也最常用的模式。

今天先簡化:還沒有登入系統

Day 27 才會做真正的登入認證(JWT)。今天還沒有「系統怎麼知道你是誰」的機制,所以先用最簡單的方式代替:呼叫 API 時自己帶上 user_id。等 Day 27 做完登入,user_id 會改成從登入資訊自動判斷,不用使用者自己填,但資料庫結構和邏輯不用改。


實作步驟

步驟1:定義輸入與輸出格式(schemas.py)

FastAPI 搭配 Pydantic 可以清楚定義「使用者能送什麼進來」「系統會回什麼出去」,格式不對會自動被擋下來。

檔案位置: backend/schemas.py
狀態: 新增檔案
用途: 定義 API 請求與回應的資料格式(Pydantic Schema)
依賴: pydantic(已隨 fastapi 安裝)

from typing import Literal
from pydantic import BaseModel, Field


class ProfileIn(BaseModel):
    """建立或更新學習檔案時,前端要送過來的格式"""
    user_id: int
    level: Literal["初級", "中級", "高級"]
    available_hours: float = Field(gt=0, description="每週可用時間,必須大於 0")
    learning_topic: str = Field(min_length=1, description="想學的主題")


class ProfileOut(BaseModel):
    """回傳給前端的學習檔案格式"""
    id: int
    user_id: int
    level: str
    available_hours: float
    learning_topic: str

    class Config:
        from_attributes = True  # 允許直接從 SQLAlchemy Model 轉換

Literal["初級", "中級", "高級"]level 只能填這三個值中的一個,填別的字串會直接被拒絕,不用自己寫 if-else 檢查。Field(gt=0)available_hours 一定要大於 0。

步驟2:寫 POST /users/profile - 建立或更新

檔案位置: backend/main.py
狀態: 修改檔案(接續Day 6的內容,繼續往下加)
用途: 新增建立或更新學習檔案的POST端點
依賴: fastapi, schemas, models

from fastapi import HTTPException
from schemas import ProfileIn, ProfileOut
from models import Profile, User


@app.post("/users/profile", response_model=ProfileOut)
def create_or_update_profile(
    payload: ProfileIn, db: Session = Depends(get_db)
) -> Profile:
    """建立或更新使用者的學習檔案,若已存在則覆蓋更新"""
    user = db.query(User).filter(User.id == payload.user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="找不到這個使用者")

    profile = db.query(Profile).filter(Profile.user_id == payload.user_id).first()

    if profile is None:
        # 第一次建立
        profile = Profile(
            user_id=payload.user_id,
            level=payload.level,
            available_hours=payload.available_hours,
            learning_topic=payload.learning_topic,
        )
        db.add(profile)
    else:
        # 已存在,更新內容
        profile.level = payload.level
        profile.available_hours = payload.available_hours
        profile.learning_topic = payload.learning_topic

    db.commit()
    db.refresh(profile)
    return profile

程式會先確認 user_id 存在,不存在就回傳 404。接著檢查使用者是否已有檔案:有就更新,沒有就新增。這是簡單的「建立或更新」(upsert)做法。

步驟3:寫 GET /users/profile - 查詢

檔案位置: backend/main.py
狀態: 修改檔案(接續步驟2,繼續往下加)
用途: 新增查詢學習檔案的GET端點
依賴: fastapi, schemas, models

@app.get("/users/profile", response_model=ProfileOut)
def get_profile(user_id: int, db: Session = Depends(get_db)) -> Profile:
    """查詢指定使用者的學習檔案"""
    profile = db.query(Profile).filter(Profile.user_id == user_id).first()
    if profile is None:
        raise HTTPException(status_code=404, detail="這個使用者還沒有建立學習檔案")
    return profile

user_id: int 沒有寫在路徑裡,FastAPI 會自動把它當成網址參數(query parameter),呼叫時網址會長這樣:/users/profile?user_id=1

步驟4:測試 POST - 建立檔案

/docs 頁面測試最不容易出錯:

  1. 進入 http://127.0.0.1:8000/docs
  2. 找到 POST /users/profile,按「Try it out」
  3. 輸入內容(假設 Day 5 的測試用戶 user_id 是 1):
    {
      "user_id": 1,
      "level": "初級",
      "available_hours": 10,
      "learning_topic": "AWS Solutions Architect 認證"
    }
    
  4. 按「Execute」,Response body 應該回傳建立好的檔案,包含自動產生的 id

也可以用 Python 一行測試:

python -c "import requests; print(requests.post('http://127.0.0.1:8000/users/profile', json={'user_id': 1, 'level': '初級', 'available_hours': 10, 'learning_topic': 'AWS Solutions Architect 認證'}).json())"

步驟5:測試 GET - 查詢剛剛建立的檔案

/docs 找到 GET /users/profile,按「Try it out」,user_id1,按「Execute」,應該看到剛剛建立的資料原封不動地回來。

步驟6:測試輸入驗證有沒有生效

回到 POST /users/profile,故意送一個錯的 level

{
  "user_id": 1,
  "level": "超級高手",
  "available_hours": 10,
  "learning_topic": "測試"
}

按「Execute」應該收到 422 Unprocessable Entity,錯誤訊息會告訴你 level 只能是三個選項之一。再試著把 available_hours 填成 -5,也應該被擋下來。這表示 API 已確實驗證輸入資料。


常見問題

POST 回傳 {"detail": "找不到這個使用者"}

user_id 對不到真實用戶。先確認 Day 5 有跑過 python init_db.py,或用 GET /users/count 確認資料庫裡真的有用戶。

response_model=ProfileOut 是做什麼的?

限制回傳的欄位只能是 ProfileOut 定義的那些,就算 SQLAlchemy 的 Profile 物件裡有其他欄位,也不會意外洩漏出去。

為什麼查詢用 user_id 而不是 profile_id

因為使用者只會關心「我的檔案」,不需要知道也不用記自己的 profile_id,用 user_id 查更符合實際使用情境。

同一個 user_id 建立第二次檔案,資料會怎樣?

會被覆蓋更新成第二次送的內容,不會產生兩筆重複資料,這就是步驟2的 upsert 邏輯。

Literal 和資料庫裡的 level 欄位型別不一致會怎樣?

不會,Profile.levelString,能接受任何字串。Literal 是在 API 這一層先擋掉不合法的值,資料庫本身不知道也不需要知道這個限制。


進度回顧

今天完成使用者學習檔案 API。POST /users/profile 能建立或更新檔案,GET /users/profile 能查詢資料;不符合格式的輸入會在進入資料庫前被擋下。

目前進度:

Day 1 ✓ 產品定義完成
Day 2 ✓ 開發環境準備
Day 3 ✓ 專案架構設計
Day 4 ✓ 資料庫設計
Day 5 ✓ SQLite 資料庫建置
Day 6 ✓ FastAPI 基礎
Day 7 ✓ 使用者檔案 API(今天)
Day 8 ⬜ 理解 LLM Agent 的本質

使用者現在可以把目標與可投入時間交給系統,不再依賴測試腳本裡的固定資料。明天(Day 8)會先釐清 Agent 的概念,以及為什麼後續要使用 LangGraph。


上一篇
Day 6:FastAPI 基礎
下一篇
Day 8:理解 LLM Agent 的本質
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
tsengyulun
iT邦新手 5 級 ‧ 2026-09-21 15:43:22

好強!記得帶牙刷

pst iT邦新手 5 級 ‧ 2026-09-22 00:15:55 檢舉

我會記得的

我要留言

立即登入留言