SkillSpace/archives/dev-pipeline-universal/references/fastapi-pitfalls.md
sinohqb 8dafa5bc52 Add archives, import dev-pipeline skill, and complete Hermes Agent spec
- Add archives/ directory for immutable external skill snapshots
  - Strict immutability rule: only README/CHANGELOG can be modified
  - First import: dev-pipeline-universal v1.0.0 (Hermes Agent)
- Import dev-pipeline-universal into hermes-agent/skills/ with CHANGELOG
- Update hermes-agent/publish/registry.json with imported skill
- Rewrite meta-skills/create-hermes-agent-skill/ with full spec:
  - SKILL.md frontmatter fields and validator constraints
  - Recommended body structure (Overview → When to Use → Pitfalls → Verification)
  - Progressive disclosure (3-level loading)
  - 8 writing quality principles
  - Official reference skills from GitHub repo
  - Tool chain docs (skill_manage, /learn, skill_view)
- Update README.md with archives/ in directory structure
2026-07-02 00:46:13 +08:00

8.9 KiB
Raw Blame History

FastAPI 实战陷阱与最佳实践

**适用于 dev-pipeline ④编码实现阶段。程序员(主程序员/结对程序员)在编写 FastAPI 代码时必须注意以下陷阱。


1. 路由注册顺序

问题

FastAPI 按注册顺序匹配路由。具体路由必须在参数化路由之前注册,否则会被错误匹配。

# ❌ 错误:/{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 编码:

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 <pkg>
  2. 更新 requirements.txtpip freeze | grep <pkg> >> requirements.txt 或手动添加)
  3. 结对程序员 review 时检查 requirements.txt 变更

4. SQLAlchemy N+1 查询

问题

遍历 ORM 对象时访问关联属性会触发额外 SQL 查询。

# ❌ 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 查询替代循环查询:

# ❌ 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 值被遗漏。

# ❌ 遗漏 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. 前端导出绕过认证

问题

<a> 标签直接访问 API 路径会跳过 axios 的 Authorization: Bearer 拦截器,导致 401。

// ❌ 错误:绕过认证
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 原则

多个导出函数应提取为通用函数:

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)时图表不刷新。

修复

import { onBeforeUnmount, onBeforeRouteUpdate } from 'vue-router'

// 销毁时释放
onBeforeUnmount(() => {
  chart?.dispose()
})

// 路由参数变化时重新加载
onBeforeRouteUpdate(() => {
  chart?.dispose()
  loadData()
})

8. 浮点精度

问题

Python 浮点累加可能产生尾数误差(如 49.9999999 而非 50.0)。

修复

# 在最终输出时统一 round
total = round(sum(values), 2)

10. Pydantic Schema 字段同步

问题

后端 API 返回字典中新增了字段,但 Pydantic response_model schema 中没有声明,导致字段被静默丢弃。前端收到 undefined,页面渲染异常。

# ❌ 后端返回了 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 管道,一个文件完成全部验证:

#!/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 导致比较偏差。

规范

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)