# FastAPI 实战陷阱与最佳实践 > **适用于 dev-pipeline ④编码实现阶段。程序员(主程序员/结对程序员)在编写 FastAPI 代码时必须注意以下陷阱。 --- ## 1. 路由注册顺序 ### 问题 FastAPI 按注册顺序匹配路由。**具体路由必须在参数化路由之前注册**,否则会被错误匹配。 ```python # ❌ 错误:/{personnel_id} 先注册,/workload 被匹配为 personnel_id="workload" @router.get("/{personnel_id}") # 第63行 def get_personnel(...) @router.get("/{personnel_id}/workload") # 第151行 → 永远匹配不到 def get_personnel_workload(...) # ✅ 正确:具体路由先注册 @router.get("/{personnel_id}/workload") # 先注册 def get_personnel_workload(...) @router.get("/{personnel_id}") # 后注册 def get_personnel(...) ``` ### 排查方法 当某个路由返回 404 但确信路径正确时: 1. 检查路由注册顺序(`grep -n "@router\." file.py`) 2. 确认具体路由(含固定路径段)在参数化路由之前 ### 同类陷阱 - `/milestones/all` 必须在 `/{project_id}/milestones` 之前 - `/tasks/all` 必须在 `/{project_id}/tasks` 之前 - `/export/projects` 必须在 `/{project_id}` 之前 --- ## 2. Excel 导出中文文件名编码 ### 问题 `Content-Disposition: attachment; filename=中文.xlsx` 中的中文字符在 Starlette TestClient 中触发 `UnicodeEncodeError`,某些浏览器也无法正确解析。 ### 修复 使用 RFC 5987 编码: ```python from urllib.parse import quote def _excel_response(wb, filename): output = io.BytesIO() wb.save(output) output.seek(0) encoded_filename = quote(filename) return StreamingResponse( output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}, ) ``` ### 测试注意事项 - Starlette TestClient 的 `UnicodeEncodeError` 是已知问题,测试中需 try/except 捕获 - 验证 Excel 内容时检查 `content[:2] == b"PK"`(ZIP 签名)即可 - 用 `openpyxl.load_workbook(io.BytesIO(resp.content))` 验证内容正确性 --- ## 3. 依赖声明 ### 问题 新增 Python 依赖(如 `openpyxl`)后忘记更新 `requirements.txt`,导致部署时崩溃。 ### 规则 每次④编码实现阶段新增依赖后,必须: 1. 确认依赖已安装(`pip list | grep `) 2. 更新 `requirements.txt`(`pip freeze | grep >> requirements.txt` 或手动添加) 3. 结对程序员 review 时检查 requirements.txt 变更 --- ## 4. SQLAlchemy N+1 查询 ### 问题 遍历 ORM 对象时访问关联属性会触发额外 SQL 查询。 ```python # ❌ N+1:对每个任务查一次 project for task in tasks: project_name = task.project.project_name # 触发额外 SQL # ✅ 修复:使用 joinedload 批量加载 from sqlalchemy.orm import joinedload tasks = db.query(ProjectTask).options(joinedload(ProjectTask.project)).all() ``` ### 批量查询替代方案 当无法使用 joinedload 时,用 `IN` 查询替代循环查询: ```python # ❌ N+1 for task in tasks: progress = db.query(TaskMonthlyProgress).filter( TaskMonthlyProgress.task_id == task.id ).first() # ✅ 批量 all_progress = db.query(TaskMonthlyProgress).filter( TaskMonthlyProgress.task_id.in_(task_ids) ).all() task_latest = {p.task_id: p for p in all_progress} ``` --- ## 5. SQLAlchemy NULL 处理 ### 问题 SQLAlchemy 中 `column < 100` 遇到 NULL 时返回 UNKNOWN(不匹配),导致 NULL 值被遗漏。 ```python # ❌ 遗漏 overall_progress IS NULL 的任务 tasks = db.query(ProjectTask).filter(ProjectTask.overall_progress < 100).all() # ✅ 正确:显式处理 NULL from sqlalchemy import or_ tasks = db.query(ProjectTask).filter( or_(ProjectTask.overall_progress < 100, ProjectTask.overall_progress.is_(None)) ).all() # 或使用 | 运算符(注意括号) tasks = db.query(ProjectTask).filter( (ProjectTask.overall_progress < 100) | (ProjectTask.overall_progress.is_(None)) ).all() ``` --- ## 6. 前端导出绕过认证 ### 问题 用 `` 标签直接访问 API 路径会跳过 axios 的 `Authorization: Bearer` 拦截器,导致 401。 ```javascript // ❌ 错误:绕过认证 const link = document.createElement('a') link.href = '/api/reports/export/projects' link.click() // ✅ 正确:通过 axios 获取 blob 后下载 api.get('/reports/export/projects', { responseType: 'blob' }).then(res => { const url = URL.createObjectURL(new Blob([res.data])) const link = document.createElement('a') link.href = url link.download = 'filename.xlsx' link.click() URL.revokeObjectURL(url) }) ``` ### DRY 原则 多个导出函数应提取为通用函数: ```javascript function downloadExcel(url, filename) { exporting.value = true api.get(url, { responseType: 'blob' }).then(res => { const blobUrl = URL.createObjectURL(new Blob([res.data])) const link = document.createElement('a') link.href = blobUrl link.download = filename link.click() URL.revokeObjectURL(blobUrl) }).finally(() => { exporting.value = false }) } ``` --- ## 7. ECharts 生命周期管理 ### 问题 Vue 组件销毁时未释放 ECharts 实例导致内存泄漏;路由参数变化(如 `/personnel/1` → `/personnel/2`)时图表不刷新。 ### 修复 ```javascript import { onBeforeUnmount, onBeforeRouteUpdate } from 'vue-router' // 销毁时释放 onBeforeUnmount(() => { chart?.dispose() }) // 路由参数变化时重新加载 onBeforeRouteUpdate(() => { chart?.dispose() loadData() }) ``` --- ## 8. 浮点精度 ### 问题 Python 浮点累加可能产生尾数误差(如 `49.9999999` 而非 `50.0`)。 ### 修复 ```python # 在最终输出时统一 round total = round(sum(values), 2) ``` --- ## 10. Pydantic Schema 字段同步 ### 问题 后端 API 返回字典中新增了字段,但 Pydantic `response_model` schema 中没有声明,导致字段被静默丢弃。前端收到 `undefined`,页面渲染异常。 ```python # ❌ 后端返回了 project_count,但 PersonnelOut schema 没有这个字段 class PersonnelOut(BaseModel): id: int name: str # ... 没有 project_count → 被 Pydantic 过滤掉 # ✅ 必须在 schema 中声明 class PersonnelOut(BaseModel): id: int name: str project_count: Optional[int] = 0 total_allocation: Optional[float] = 0.0 ``` ### 排查方法 当前端收到 `undefined` 但确信后端有返回时: 1. 去掉 `response_model` 参数,看原始返回是否包含该字段 2. 检查 schema 定义是否包含新字段 3. 注意 `from_attributes = True` 只影响 ORM→Pydantic 转换,不影响 dict→Pydantic 过滤 ### 关联陷阱 - 修改 `_build_personnel_dict` 等 helper 函数后,必须同步更新对应的 schema - 结对程序员 review 时重点检查:helper 返回字段 ↔ schema 字段 的一致性 --- ## 11. 部署验证脚本模式 ### 问题 部署验证时使用 shell 管道(`curl | python3 -c`)容易触发安全防护(命令超时/阻断),尤其是涉及变量引用和 URL 编码时。 ### 推荐方案 用 Python 脚本替代 shell 管道,一个文件完成全部验证: ```python #!/usr/bin/env python3 import urllib.request, json BASE = "http://127.0.0.1:8001" def api(method, path, data=None, token=None): url = BASE + path headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" body = json.dumps(data).encode() if data else None req = urllib.request.Request(url, data=body, headers=headers, method=method) resp = urllib.request.urlopen(req, timeout=10) return resp.status, resp.read() # 1. 登录 status, data = api("POST", "/api/auth/login", {"username": "admin", "password": "admin123"}) token = json.loads(data)["access_token"] # 2. 验证端点 status, data = api("GET", "/api/projects?project_type=创新项目", token=token) projects = json.loads(data) print(f"类型筛选: {len(projects)}个") # 3. 验证 Excel 导出 status, data = api("GET", "/api/reports/export/projects", token=token) assert data[:2] == b"PK" # ZIP 签名 import openpyxl, io wb = openpyxl.load_workbook(io.BytesIO(data)) print(f"导出: {wb.active.title}, {wb.active.max_row-1}行") ``` ### 优势 - 避免 shell 变量注入和管道超时 - 可在虚拟环境中直接运行 - 断言清晰,失败时立即知道哪个端点出问题 - 可复用(保存为 `scripts/verify_deploy.py`) --- ## 9. 时区一致性 ### 问题 项目中混用 timezone-naive 和 timezone-aware 的 datetime 导致比较偏差。 ### 规范 ```python from datetime import datetime, timezone # 统一使用 UTC aware datetime now = datetime.now(timezone.utc) # 解析字符串时也设为 aware from calendar import monthrange parts = month_str.split("-") y, m = int(parts[0]), int(parts[1]) last_day = monthrange(y, m)[1] progress_date = datetime(y, m, last_day, tzinfo=timezone.utc) ```