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

316 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <pkg>`
2. 更新 `requirements.txt``pip freeze | grep <pkg> >> 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. 前端导出绕过认证
### 问题
`<a>` 标签直接访问 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)
```