Day 13、14 已完成呼叫 Planner、排程與寫入資料庫的流程。不過使用者目前只能呼叫 /plans/generate 產生新計畫,無法查看詳情、修改標題、刪除錯誤計畫,也不能標記任務完成。
今天補齊使用者操作計畫與任務所需的端點,完成 CRUD(Create、Read、Update、Delete)。使用者可以檢視並調整 AI 生成的計畫。
POST /plansCRUD 裡的 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。
檔案位置: 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
檔案位置: 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
檔案位置: 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 參數測試起來比較直覺。
檔案位置: 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)
檔案位置: 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} 已刪除"}
檔案位置: 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 則是實際到期時間;只修改其中一個會造成時間資訊不一致。
啟動後端伺服器:
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 生成的計畫先是草案,使用者看過、給反饋,才正式核准生效,讓「人在迴路」這件事有清楚的規則可循,不再是誰都能把狀態隨便改成任何值。