- 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
316 lines
8.9 KiB
Markdown
316 lines
8.9 KiB
Markdown
# 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)
|
||
```
|