users 與 jobs 兩張表、PBKDF2 密碼雜湊、JWT 登入,以及 get_current_user 這個「取得目前登入者」的依賴。但它還沒保護任何東西:Day 13 的檢測端點 /api/v1/conflicts/detect 誰都能用,任務也還存在記憶體裡。今天把兩邊接起來:
/api/v1/detect
jobs 表,伺服器重啟後還在第 2 週:檢測優化與生產系統
今天要完成:
POST /api/v1/detect(src/api_main.py):沒有 token 回 401jobs 表GET /api/v1/auth/jobs、GET /api/v1/auth/jobs/{job_id}(src/api_auth.py):他人的任務回 403frontend/App.jsx:註冊、登入、儀表板、檢測、任務列表tests/test_day16_api_auth.py:6 個測試,可重複執行,不會清空資料庫「加上登入」聽起來只是檢查 token,但實際上有三層:
| 層次 | 問題 | 今天的做法 |
|---|---|---|
| 認證(Authentication) | 你是誰? | Depends(get_current_user),沒有有效 token 回 401 |
| 歸屬 | 這個任務是誰的? | 建立任務時寫入 user_id = current_user.id |
| 授權(Authorization) | 你能看這個任務嗎? | 查詢時比對 job.user_id,不是自己的回 403 |
只做第一層是最常見的漏洞:大家都要登入,但登入後改一下網址裡的 job_id,就能看到別人的資料(OWASP 稱為 Broken Object Level Authorization)。
POST /api/v1/detect (Authorization: Bearer <token>)
→ get_current_user → normalize_constraints(格式錯誤回 400)
→ 寫入 jobs(status=processing, user_id=目前用戶)
→ 立即回傳 job_id
→ 背景:run_detection_with_db(自己開 Session)→ 檢測 → 更新 jobs.results
GET /api/v1/auth/jobs → 只查 user_id = 目前用戶 的任務
GET /api/v1/auth/jobs/{job_id} → 找不到 404;不是自己的 403;否則回傳結果
jobs 表中的任務、依用戶隔離的查詢結果src/api_main.py、src/api_auth.py、frontend/App.jsx、frontend/apiClient.js(修改);tests/test_day16_api_auth.py(新增)get_current_user 裡加上會話檢查,讓登出真正生效專案結構變化:
srs-review-agent/
├── src/
│ ├── api_main.py ← 修改:新增 /api/v1/detect、run_detection_with_db
│ └── api_auth.py ← 修改:新增 /jobs、/jobs/{job_id}
├── frontend/
│ ├── index.jsx ← 修改:預設改為啟動 App(VITE_PUBLIC_MODE=1 才是 Day 14 版)
│ ├── App.jsx ← 【新增】登入版根元件
│ └── apiClient.js ← 修改:新增 authApi、authRequest、tokenStore
└── tests/
└── test_day16_api_auth.py ← 【新增】6 個測試
今天不需要新的套件。
修改 src/api_main.py,新增 /api/v1/detect:
@app.post("/api/v1/detect", tags=["衝突檢測"])
async def detect_conflicts(
request: DetectionRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
constraints = request.constraints if isinstance(request.constraints, list) else []
if len(constraints) < 2:
raise HTTPException(status_code=400, detail="至少需要 2 個約束")
if len(constraints) > 500:
raise HTTPException(status_code=400, detail="最多 500 個約束")
try:
normalize_constraints(constraints) # 格式錯誤在提交時就回 400
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
job_id = str(uuid.uuid4())
db.add(Job(id=job_id, user_id=current_user.id, status="processing",
constraints=constraints))
db.commit()
# 不把請求的 db 傳給背景任務:它何時被 get_db() 關閉取決於 FastAPI 版本,
# 而且背景任務在 threadpool 的另一個執行緒執行,Session 不應跨執行緒共用
background_tasks.add_task(run_detection_with_db, job_id, constraints)
return {"job_id": job_id, "status": "queued", "message": "檢測任務已提交"}
和 Day 13 的 /api/v1/conflicts/detect 相比,只差在參數多了 current_user: User = Depends(get_current_user)。FastAPI 會先執行 get_current_user:沒有 token 或 token 無效就直接回 401,端點本身根本不會執行。
user_id=current_user.id 是之後做隔離的基礎:歸屬在建立時就要記錄,事後補不回來。
normalize_constraints() 沿用 Day 13 的函數,同時接受純文字 ["...", "..."](前端送的格式,自動編號 REQ-1…)與 [{id, text}](保留原本的編號)。
同一個檔案中的背景任務:
def run_detection_with_db(job_id: str, constraints: list):
db = SessionLocal()
try:
job = db.query(Job).filter(Job.id == job_id).first()
if job is None:
return
try:
detected = detector.detect_conflicts(
normalize_constraints(constraints), **detection_kwargs())
job.results = {
"conflicts_found": len(detected),
"conflicts": [{"req_id_1": c.req_id_1, "req_id_2": c.req_id_2,
"type": c.conflict_type.value, "severity": c.severity.value,
"description": c.description, "confidence": c.confidence,
"verified": c.verified} for c in detected],
}
job.status = "completed"
except Exception as e:
job.status = "failed"
job.error = str(e)
job.completed_at = datetime.utcnow()
db.commit()
finally:
db.close()
這段原本有三個問題,都是檢查時發現的:
add_task(run_detection_with_db, job_id, constraints, db),把請求的 db 傳進背景任務。Day 15 的 get_db() 會在 finally 關閉它,但「先關閉還是先跑背景任務」在 FastAPI 不同版本間改過。我用本文環境的 FastAPI 0.141.1 實測,順序是「背景任務執行 → Session 關閉」,所以原寫法在這個版本碰巧能動;但它依賴特定版本的行為,而且改成同步函數後,背景任務在 threadpool 的另一個執行緒執行,SQLAlchemy 的 Session 不應跨執行緒共用。現在改成背景任務自己 SessionLocal(),用完在 finally 關閉async def 卡住伺服器:和 Day 13 一樣,detect_conflicts() 是同步呼叫,使用 Ollama 時要十幾秒。寫成一般的 def,FastAPI 才會丟到 threadpool 執行{"id": f"REQ-{i+1}", "text": c} 組約束,送 [{id, text}] 時 text 變成一個 dict,任務直接失敗。改用 normalize_constraints()
job.status = "failed" 與 job.error 也會寫回資料庫,前端才能顯示失敗原因,而不是永遠停在 processing。
修改 src/api_auth.py,新增兩個端點:
@router.get("/jobs")
async def list_user_jobs(current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)):
jobs = (
db.query(Job)
.filter(Job.user_id == current_user.id)
.order_by(Job.created_at.desc()) # 最新的任務在最前面
.all()
)
return {
"user_id": current_user.id,
"total": len(jobs),
"jobs": [{"id": job.id, "status": job.status,
"created_at": _utc_iso(job.created_at),
"completed_at": _utc_iso(job.completed_at),
"constraints_count": len(job.constraints) if job.constraints else 0}
for job in jobs],
}
列表的隔離很直接:查詢條件永遠帶著 Job.user_id == current_user.id,不是自己的任務根本不會被撈出來。
任務詳情則要先找到任務,再檢查歸屬:
@router.get("/jobs/{job_id}")
async def get_user_job(job_id: str, current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)):
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="任務不存在")
if job.user_id != current_user.id:
raise HTTPException(status_code=403, detail="無權訪問此任務")
return {"id": job.id, "status": job.status,
"created_at": _utc_iso(job.created_at),
"completed_at": _utc_iso(job.completed_at),
"results": job.results, "error": job.error}
回傳的 results 與 Day 13 的 /api/v1/conflicts/{job_id} 格式相同,所以 Day 14 的 DetectPage 不用改就能讀。
_utc_iso() 是測試前端時發現的問題:
def _utc_iso(dt):
"""資料庫存的是不帶時區的 UTC 時間;輸出時補上 Z"""
return dt.isoformat() + "Z" if dt else None
jobs.created_at 用 datetime.utcnow() 寫入,是不帶時區資訊的 UTC 時間。原本直接輸出 2026-09-29T06:30:38.972438,瀏覽器的 new Date() 遇到沒有時區的字串會當成本地時間:
new Date("2026-09-29T06:30:38.972438") → 2026/9/29 上午6:30:38 ✗(台灣實際是下午 2:30)
new Date("2026-09-29T06:30:38.972438Z") → 2026/9/29 下午2:30:38 ✓
任務列表的「創建時間」因此比實際早了 8 小時。補上 Z 明確標示是 UTC,瀏覽器就會正確轉成使用者的時區。
建立 frontend/App.jsx。登入要用表單格式送出(Day 15 的 OAuth2 password flow):
const formDataUrlEncoded = new URLSearchParams()
formDataUrlEncoded.append('username', formData.email)
formDataUrlEncoded.append('password', formData.password)
response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formDataUrlEncoded
})
...
if (response.ok) {
tokenStore.set(data.access_token, data.refresh_token)
setCurrentUser(data.user)
setIsAuthenticated(true)
loadUserJobs()
}
登入後的「上傳文檔」頁直接重用 Day 14 的 DetectPage,只是換一個 API 客戶端:
<DetectPage api={authApi} onSubmitted={() => loadUserJobs()} />
authApi 定義在 frontend/apiClient.js,介面和 Day 14 的 publicApi 一樣是 submit / getJob,只是換成需要登入的端點:
export const authApi = {
submit: (lines) =>
authRequest('/detect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ constraints: lines }),
}),
getJob: (jobId) => authRequest(`/auth/jobs/${jobId}`),
}
authRequest() 會從 tokenStore(localStorage)讀出 token,加上 Authorization: Bearer ... 標頭;Day 17 會再讓它在 token 過期時自動刷新。authApi 必須是模組層級的常數。Day 14 提過,DetectPage 的輪詢 useEffect 依賴 api,每次 render 都產生新物件的話,輪詢會不斷重啟。
frontend/index.jsx 預設啟動 App,設定 VITE_PUBLIC_MODE=1 時才啟動 Day 14 的免登入版。任務列表、儀表板與側邊欄的完整代碼見 frontend/App.jsx。
檢查時還修了一個前端 bug:原本登入後呼叫 setToken(data.access_token) 更新 React state,緊接著呼叫 loadUserJobs()。但 state 更新要等下一次 render 才生效,loadUserJobs 讀到的還是舊的空 token,第一次載入任務列表一定是 401。現在 token 一律從 tokenStore 讀取,寫入後立刻就能拿到。
cd srs-review-agent
python3 -m pytest tests/test_day16_api_auth.py -v
tests/test_day16_api_auth.py::test_1_user_registration PASSED [ 16%]
tests/test_day16_api_auth.py::test_2_user_login PASSED [ 33%]
tests/test_day16_api_auth.py::test_3_protected_endpoints PASSED [ 50%]
tests/test_day16_api_auth.py::test_4_detect_with_auth PASSED [ 66%]
tests/test_day16_api_auth.py::test_5_user_isolation PASSED [ 83%]
tests/test_day16_api_auth.py::test_6_constraint_formats PASSED [100%]
============================== 6 passed in 0.40s ===============================
test_4_detect_with_auth:無 token 回 401;有 token 時任務 completed,並找到 REQ-1 ↔ REQ-2 這 1 個衝突test_5_user_isolation:用戶 2 的列表裡沒有用戶 1 的任務,直接存取回 403test_6_constraint_formats:[{id, text}] 保留原編號(REQ-2.1 ↔ REQ-4.3),格式錯誤回 400注意:原本的測試檔在直接執行時會對資料庫
drop_all,把./test.db整個清空重建。現在每次執行都用不重複的測試郵箱,不需要清空資料庫,可以重複執行。不想寫入開發資料庫時,可以設定DATABASE_URL=sqlite:////tmp/test_day16.db。
以下假設已經照 Day 15 註冊並登入 alice 與 bob,$ALICE、$BOB 分別是兩人的 access token。
1. 沒有 token 提交檢測
curl -X POST http://localhost:8000/api/v1/detect \
-H "Content-Type: application/json" -d '{"constraints": ["a", "b"]}'
# 401
2. alice 提交檢測
curl -X POST http://localhost:8000/api/v1/detect \
-H "Authorization: Bearer $ALICE" -H "Content-Type: application/json" \
-d '{"constraints": ["系統支持多用戶並行存取", "系統採用單用戶模式", "所有數據必須加密存儲"]}'
{"job_id": "e5b21b64-f0fb-4c16-9d10-4522283bb562", "status": "queued", "message": "檢測任務已提交"}
3. alice 查詢自己的任務
curl http://localhost:8000/api/v1/auth/jobs/e5b21b64-f0fb-4c16-9d10-4522283bb562 \
-H "Authorization: Bearer $ALICE"
{"id": "e5b21b64-f0fb-4c16-9d10-4522283bb562", "status": "completed",
"created_at": "2026-09-29T06:30:38.972438Z", "completed_at": "2026-09-29T06:30:38.973754Z",
"results": {"conflicts_found": 1, "conflicts": [
{"req_id_1": "REQ-1", "req_id_2": "REQ-2", "type": "邏輯矛盾", "severity": "高",
"description": "檢測到 多用戶 vs 單用戶", "confidence": 0.95, "verified": false}]},
"error": null}
4. 隔離
| 請求 | 結果 |
|---|---|
bob 查詢 /api/v1/auth/jobs |
total: 0,看不到 alice 的任務 |
bob 查詢 alice 的 /api/v1/auth/jobs/e5b21b64-... |
403「無權訪問此任務」 |
alice 查詢不存在的 /api/v1/auth/jobs/nonexistent |
404「任務不存在」 |
後端與前端都啟動後(uvicorn src.api_main:app --port 8000、cd frontend && npm run dev),用 Playwright 驅動 headless Chromium 跑完整流程:
200 POST /api/v1/auth/register
200 POST /api/v1/auth/login
200 GET /api/v1/auth/jobs
200 POST /api/v1/detect
200 GET /api/v1/auth/jobs
200 GET /api/v1/auth/jobs/<id>
註冊 → 登入 → 在「上傳文檔」頁輸入 系統支持多用戶並行存取、系統採用單用戶模式 → 顯示「發現 1 個衝突」,console 沒有錯誤。修正前,登入後的第一個 GET /api/v1/auth/jobs 是 401。
/api/v1/conflicts/* 仍然不需要登入,任何人都能用 GET /api/v1/conflicts 列出所有免登入任務。保留它是為了 Day 14 的免登入版與 Day 13 的測試;正式部署時應該關閉或加上認證git add src/api_main.py src/api_auth.py frontend/App.jsx frontend/apiClient.js \
frontend/index.jsx tests/test_day16_api_auth.py
git commit -m "Day 16: 需要登入的檢測端點、任務持久化、用戶隔離、登入版前端"
現在每個請求都需要 token,但 token 只有 15 分鐘效期,過期就得重新登入;而且 Day 15 看到,登出後 token 其實還能用。明天 Day 17 會加入刷新令牌(refresh token)與會話管理:access token 過期時自動換新,登出時真正作廢 token,閒置太久自動登出。