iT邦幫忙

2026 iThome 鐵人賽

DAY 15
1
AI Engineering

30天用 Claude Code + LangGraph 實作個人化 AI 學習教練系列 第 15 篇

Day 15:計畫 CRUD API - 讓使用者能真正控制 AI 的計畫

  • 分享至 

  • xImage
  •  

Day 13、14 已完成呼叫 Planner、排程與寫入資料庫的流程。不過使用者目前只能呼叫 /plans/generate 產生新計畫,無法查看詳情、修改標題、刪除錯誤計畫,也不能標記任務完成。

今天補齊使用者操作計畫與任務所需的端點,完成 CRUD(Create、Read、Update、Delete)。使用者可以檢視並調整 AI 生成的計畫。

為什麼沒有另外做一個 POST /plans

CRUD 裡的 Create,Day 13 的 POST /plans/generate 其實已經做掉了:呼叫 Planner 生成內容、寫進 plans 表,這就是「建立計畫」這件事。今天如果再加一個 POST /plans 讓使用者手動輸入標題、週數、任務清單,會變成兩條平行但邏輯完全不同的建立路徑,一條靠 AI 生成、一條靠使用者手打,維護起來只會製造混亂,而且產品需求(Day 1 PRD)從頭到尾談的都是「AI 幫使用者排計畫」,不是讓使用者自己手打計畫。所以今天的 Create 就是複用 Day 13 已經有的端點,不重複造一個。

沒有登入系統之前,怎麼做「權限檢查」

Day 27 才會加入 JWT 認證,在那之前,系統還沒辦法從 token 知道「現在是誰在呼叫 API」。今天先用一個簡化但方向正確的做法:呼叫端自己在請求裡帶上 user_id,後端拿這個 user_id 跟資料庫裡計畫真正的 user_id 比對,不一致就回 403。這跟 Day 13 PlanGenerateRequest 一直都要求帶 user_id 是同一個精神,只是今天真正拿這個欄位做「這是不是你的東西」的判斷,不再只是拿來查資料。

Day 27 把 JWT 接上之後,user_id 會從 token 解出來,不用使用者自己填,呼叫端造假 user_id 就沒辦法通過驗證,這是今天暫時的簡化,不是永久的設計。

四個端點的共通邏輯:先確認這是你的計畫

GET、PUT、DELETE 三個端點都要先做同一件事:查出這個 plan_id 對應的計畫,確認 plan.user_id 跟呼叫者的 user_id 一致。這段邏輯出現三次,抽成一個共用函式 _get_owned_plan,三個端點都呼叫它,找不到計畫回 404,找到但不是你的回 403。


實作步驟

步驟1:定義新的 Schema

檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 14 的內容,繼續往下加)
用途: 定義計畫詳情、更新、任務更新的請求與回應格式
依賴: pydantic

from datetime import datetime
from typing import Literal


class TaskOut(BaseModel):
    """單一任務的對外格式"""
    id: int
    day: int
    title: str
    estimated_hours: float
    status: str
    deadline: datetime

    class Config:
        from_attributes = True  # 允許直接從SQLAlchemy的Task物件轉換


class PlanDetailResponse(BaseModel):
    """GET /plans/{plan_id} 回傳的完整計畫內容"""
    plan_id: int
    title: str
    duration_weeks: int
    status: str
    tasks: list[TaskOut]


class PlanUpdateRequest(BaseModel):
    """PUT /plans/{plan_id} 的請求格式"""
    user_id: int  # Day27前先用這個做簡化的身分驗證,之後會換成JWT
    title: str | None = None
    status: str | None = None


class PlanUpdateResponse(BaseModel):
    """PUT /plans/{plan_id} 的回應格式"""
    plan_id: int
    title: str
    status: str


class TaskUpdateRequest(BaseModel):
    """PATCH /tasks/{task_id} 的請求格式"""
    user_id: int
    action: Literal["完成", "跳過", "延期"]
    postpone_days: int | None = Field(
        default=None, gt=0, description="action是延期時才需要,往後延幾天"
    )


class TaskUpdateResponse(BaseModel):
    """PATCH /tasks/{task_id} 的回應格式"""
    task_id: int
    status: str
    deadline: datetime

步驟2:寫共用的權限檢查函式

檔案位置: backend/main.py
狀態: 修改檔案(接續 Day 14 的內容,繼續往下加)
用途: 查出計畫並確認呼叫者就是這份計畫的擁有者
依賴: models, fastapi

def _get_owned_plan(plan_id: int, user_id: int, db: Session) -> Plan:
    """取出指定計畫,並確認這個計畫屬於呼叫者本人"""
    plan = db.query(Plan).filter(Plan.id == plan_id).first()
    if plan is None:
        raise HTTPException(status_code=404, detail="找不到這個計畫")
    if plan.user_id != user_id:
        raise HTTPException(status_code=403, detail="這不是你的計畫,無法操作")
    return plan

步驟3:寫 GET /plans/{plan_id}

檔案位置: backend/main.py
狀態: 修改檔案
用途: 查詢單一計畫的完整內容,包含所有任務
依賴: schemas, models

from schemas import (
    PlanDetailResponse,
    PlanUpdateRequest,
    PlanUpdateResponse,
    TaskUpdateRequest,
    TaskUpdateResponse,
)


@app.get("/plans/{plan_id}", response_model=PlanDetailResponse)
def get_plan(
    plan_id: int, user_id: int, db: Session = Depends(get_db)
) -> PlanDetailResponse:
    """查詢單一計畫的詳細內容,包含底下所有任務"""
    plan = _get_owned_plan(plan_id, user_id, db)
    tasks = db.query(Task).filter(Task.plan_id == plan_id).order_by(Task.day).all()

    return PlanDetailResponse(
        plan_id=plan.id,
        title=plan.title,
        duration_weeks=plan.duration_weeks,
        status=plan.status,
        tasks=tasks,
    )

user_id 沒有包在任何 Pydantic 模型裡,FastAPI 會自動把它當成 query string 參數,呼叫時要用 /plans/4?user_id=1 這種形式。GET 請求依慣例不帶 body,Swagger UI 對 GET 的 body 支援也不好,用 query 參數測試起來比較直覺。

步驟4:寫 PUT /plans/{plan_id}

檔案位置: backend/main.py
狀態: 修改檔案
用途: 更新計畫的標題或狀態
依賴: schemas, models

@app.put("/plans/{plan_id}", response_model=PlanUpdateResponse)
def update_plan(
    plan_id: int, payload: PlanUpdateRequest, db: Session = Depends(get_db)
) -> PlanUpdateResponse:
    """更新計畫的標題或狀態,兩個欄位都是可選的,帶了才更新"""
    plan = _get_owned_plan(plan_id, payload.user_id, db)

    if payload.title is not None:
        plan.title = payload.title
    if payload.status is not None:
        plan.status = payload.status

    db.commit()
    db.refresh(plan)

    return PlanUpdateResponse(plan_id=plan.id, title=plan.title, status=plan.status)

步驟5:寫 DELETE /plans/{plan_id}

檔案位置: backend/main.py
狀態: 修改檔案
用途: 刪除計畫,連同底下所有任務一起刪除
依賴: models

@app.delete("/plans/{plan_id}")
def delete_plan(plan_id: int, user_id: int, db: Session = Depends(get_db)) -> dict:
    """刪除計畫,並先刪掉底下所有任務,避免留下孤兒資料"""
    plan = _get_owned_plan(plan_id, user_id, db)

    db.query(Task).filter(Task.plan_id == plan_id).delete()
    db.delete(plan)
    db.commit()

    return {"message": f"計畫 {plan_id} 已刪除"}

步驟6:寫 PATCH /tasks/{task_id}

檔案位置: backend/main.py
狀態: 修改檔案
用途: 標記任務完成、跳過,或把任務延期
依賴: schemas, models

@app.patch("/tasks/{task_id}", response_model=TaskUpdateResponse)
def update_task(
    task_id: int, payload: TaskUpdateRequest, db: Session = Depends(get_db)
) -> TaskUpdateResponse:
    """標記任務完成、跳過,或把任務延期指定天數"""
    task = db.query(Task).filter(Task.id == task_id).first()
    if task is None:
        raise HTTPException(status_code=404, detail="找不到這個任務")

    plan = db.query(Plan).filter(Plan.id == task.plan_id).first()
    if plan.user_id != payload.user_id:
        raise HTTPException(status_code=403, detail="這不是你的任務,無法操作")

    if payload.action == "完成":
        task.status = "完成"
    elif payload.action == "跳過":
        task.status = "跳過"
    elif payload.action == "延期":
        if payload.postpone_days is None:
            raise HTTPException(status_code=400, detail="延期時要提供postpone_days")
        task.day += payload.postpone_days
        task.deadline += timedelta(days=payload.postpone_days)

    db.commit()
    db.refresh(task)

    return TaskUpdateResponse(task_id=task.id, status=task.status, deadline=task.deadline)

延期 會同時更新 day 和 deadline。day 用於 Day 14 排程演算法判斷任務週次,deadline 則是實際到期時間;只修改其中一個會造成時間資訊不一致。

步驟7:測試

啟動後端伺服器:

uvicorn main:app --reload

進入 http://127.0.0.1:8000/docs,用 Day 14 測試留下的 plan_id(假設是 4,user_id 是 1)依序測試:

1. 查詢計畫:GET /plans/4?user_id=1,應該回傳計畫詳情與所有任務。

2. 用別人的身分查詢(應該失敗):GET /plans/4?user_id=999,應該回傳 403 這不是你的計畫,無法操作。

3. 更新計畫標題:PUT /plans/4

{
  "user_id": 1,
  "title": "AWS SA 認證衝刺計畫",
  "status": "進行中"
}

4. 標記某個任務完成:先從步驟1的回應裡拿一個 task.id(假設是 14),呼叫 PATCH /tasks/14

{
  "user_id": 1,
  "action": "完成"
}

5. 把另一個任務延期3天:PATCH /tasks/15

{
  "user_id": 1,
  "action": "延期",
  "postpone_days": 3
}

呼叫完再用 GET /plans/4?user_id=1 確認任務15的 day 和 deadline 都往後移了3天。

6. 刪除計畫:DELETE /plans/4?user_id=1,回傳成功訊息後,再呼叫一次 GET /plans/4?user_id=1,應該變成 404 找不到這個計畫,確認真的刪乾淨了。


常見問題

為什麼刪除計畫要手動先刪 Task,不能直接刪 Plan 就好?

Day 4 定義 Plan 和 Task 的 relationship 時沒有加上 cascade="all, delete-orphan",這代表 SQLAlchemy 不會自動幫你連帶刪除子資料。如果只 db.delete(plan),tasks 表裡那些 plan_id 指向這個計畫的資料不會被清掉,會變成「指向不存在的計畫」的孤兒資料,之後查詢或統計都可能出錯。今天用 db.query(Task).filter(Task.plan_id == plan_id).delete() 手動先清乾淨,再刪計畫本身。

403 這不是你的計畫,無法操作,但我確定 user_id 是對的

檢查兩件事:一是 plan_id 有沒有打對,可能查到的是別人的計畫;二是 Day 13 生成計畫時,Plan.user_id 是用 payload.user_id 存進去的,如果你當初測試時用了不同的 user_id 生成這份計畫,現在當然要用同一個 user_id 才能通過檢查。這個檢查目前完全基於「呼叫者自己說自己是誰」,Day 27 加上 JWT 之後,才會變成真正防偽造的身分驗證。

跳過 和 完成 有什麼實際差異?

今天兩者都只是把 task.status 改成不同的字串,資料庫層面看起來很像。差異會在後面的天數顯現:Day 17 的 Daily Coach、Day 21 的能力追蹤,都會根據任務是「完成」還是「跳過」給出不同的反應(完成代表學會了,跳過代表使用者選擇不做這個任務,不代表學會)。今天先把這個狀態欄位正確地標記出來,之後的邏輯才有依據可以判斷。

PATCH /tasks/{task_id} 為什麼不像 plans 系列端點一樣,也用一個 _get_owned_task 共用函式?

plans 系列的三個端點(GET、PUT、DELETE)查的都是 Plan 本身、直接比對 Plan.user_id,邏輯完全一樣,抽成共用函式合理。但 PATCH /tasks/{task_id} 要先查 Task、再透過 task.plan_id 找到對應的 Plan,才能比對 user_id,查詢路徑不一樣,目前只有這一個端點需要這段邏輯,還不到需要抽共用函式的地步。如果之後任務相關的端點變多(例如 Day 18 的進度記錄也要做同樣的權限檢查),再回頭把這段邏輯抽出來也不遲。


進度回顧

今天完成計畫與任務的 CRUD 操作。使用者能查詢計畫詳情、修改標題與狀態、刪除計畫,也能將單一任務標記為完成、跳過或延期。權限檢查目前以呼叫端提供的 user_id 為依據。

系統現在是這樣的:

Day 1  ✓ 產品定義完成
Day 2  ✓ 開發環境準備
Day 3  ✓ 專案架構設計
Day 4  ✓ 資料庫設計
Day 5  ✓ SQLite 資料庫建置
Day 6  ✓ FastAPI 基礎
Day 7  ✓ 使用者檔案 API
Day 8  ✓ 理解 LLM Agent 的本質
Day 9  ✓ 連接 Ollama 本機模型
Day 10 ✓ LangGraph 最小範例
Day 11 ✓ Thread 與 State 管理
Day 12 ✓ 簡化的意圖路由
Day 13 ✓ 計畫生成 Agent
Day 14 ✓ 簡化的排程邏輯
Day 15 ✓ 計畫 CRUD API(今天)
Day 16 ⬜ 計畫審核與用戶反饋

今天的 PUT /plans/{plan_id} 雖然能改狀態,但狀態要改成什麼、什麼時候該改,完全靠呼叫端自己決定,系統沒有一套「計畫從草稿到正式生效」該走的流程。明天要把這件事變成一套真正的狀態機:AI 生成的計畫先是草案,使用者看過、給反饋,才正式核准生效,讓「人在迴路」這件事有清楚的規則可循,不再是誰都能把狀態隨便改成任何值。


上一篇
Day 14:簡化的排程邏輯 - 讓計畫真的排得進使用者的時間
下一篇
Day 16:計畫審核與用戶反饋
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
tsengyulun
iT邦新手 5 級 ‧ 2026-09-29 23:49:27

世紀大騙局

pst iT邦新手 5 級 ‧ 2026-09-30 16:25:56 檢舉

超騙啊你

我要留言

立即登入留言