feat: 改造为开箱即用模板 — MySQL/MongoDB(Beanie) 一键切换 + AGENTS.md 开发规范

- src/ 按域组织骨架,示例域 items 双后端实现(SQLAlchemy2.0 async / Beanie 2.x)
- DB_BACKEND 环境变量切换,router 只依赖 Repo Protocol
- alembic 迁移(含初始 items 迁移)、uv 依赖管理、ruff、pytest 异步测试
- Dockerfile + docker-compose(app/mysql/mongo)
- AGENTS.md 重写为本模板开发规范;原最佳实践文档归档 docs/
- 验证:mongodb/mysql 双后端 pytest 全绿 + uvicorn 真实冒烟通过
This commit is contained in:
2026-08-21 21:23:26 +08:00
parent 5e00aa6095
commit 981bee5a46
34 changed files with 3579 additions and 1256 deletions

513
AGENTS.md
View File

@@ -1,447 +1,144 @@
# FastAPI Best Practices for AI Agents
# AGENTS.md — fastapi-template 开发规范
A machine-readable companion to [README.md](./README.md) for AI coding agents
working in FastAPI projects. Same rules, restructured for fast pattern matching:
version pins, Do/Don't blocks, anti-patterns, and a quick-reference table.
本文件是 AI Agent / 开发者在本仓库工作的**必读规范**。先读这个,再动手。
通用 FastAPI 最佳实践参考 [docs/AGENTS_GENERIC.md](./docs/AGENTS_GENERIC.md) 与 [docs/BEST_PRACTICES_ZH.md](./docs/BEST_PRACTICES_ZH.md)。
## Compatibility Matrix
## 0. 这个模板是什么
Pin to these versions or newer. Examples in this file assume them.
FastAPI 生产级项目骨架。**核心特性:`DB_BACKEND` 一个环境变量切换 MySQL / MongoDB业务代码零改动**。
任何修改都不得破坏这个特性——router/schemas 永远不允许 import 具体 DB 实现。
| Dependency | Minimum | Notes |
|------------------|-----------|------------------------------------------------------|
| Python | 3.11 | Required for `StrEnum` and `X \| Y` union syntax |
| FastAPI | 0.115 | `Annotated[T, Depends(...)]` is the idiomatic form |
| Pydantic | 2.7 | v1 APIs (`json_encoders`, `.dict()`) are removed |
| pydantic-settings| 2.4 | Lives in a separate package since Pydantic v2 |
| SQLAlchemy | 2.0 | Use the async API (`AsyncSession`, `async_sessionmaker`) |
| Alembic | 1.13 | Async-aware migrations |
| httpx | 0.27 | Use `ASGITransport` for in-process tests |
| PyJWT | 2.9 | Use this, not the unmaintained `python-jose` |
| ruff | 0.6 | Replaces black, isort, autoflake |
## 1. 技术栈基线
## Project Structure
| 项 | 版本 | 说明 |
|---|---|---|
| Python | ≥3.11 | 用 `X \| Y``StrEnum` |
| FastAPI | ≥0.115 | 依赖注入一律 `Annotated[T, Depends(...)]` |
| Pydantic | v2 | 禁用 v1 API`.dict()` / `json_encoders` |
| SQLAlchemy | 2.0 async | `AsyncSession` + `async_sessionmaker`,禁 sync session |
| Beanie | ≥1.27 | MongoDB ODM |
| 包管理 | **uv** | 禁 pip/poetry/conda加依赖 `uv add xxx` |
| Lint | ruff | 提交前 `uv run ruff check --fix .` 必须零告警 |
| 测试 | pytest + pytest-asyncio + httpx | `asyncio_mode=auto` |
Organize by domain, not by file type. One package per bounded context.
## 2. 目录结构铁律
按**域domain**组织,不按文件类型。一个域一个包,照抄 `src/items/`
```
src/
├── {domain}/ # e.g., auth/, posts/, aws/
│ ├── router.py # API endpoints
│ ├── schemas.py # Pydantic models
├── models.py # SQLAlchemy ORM models
│ ├── service.py # Business logic
│ ├── dependencies.py # Route dependencies
├── config.py # Domain-scoped BaseSettings
│ ├── constants.py # Constants and error codes
│ ├── exceptions.py # Domain-specific exceptions
│ └── utils.py # Helper functions
├── config.py # Global BaseSettings
├── models.py # Shared Pydantic / ORM bases
├── exceptions.py # Global exceptions
├── database.py # Async engine + session factory
└── main.py # FastAPI app + lifespan
src/{domain}/
├── router.py # 路由:只做参数校验和调用 repo禁写 SQL/查询
├── schemas.py # Pydantic 契约:与 DB 无关,前后端共用语义
├── dependencies.py # 定义 Repo Protocol + 按 DB_BACKEND 选实现
├── mysql.py # MySQL ORM 模型 + MySQLXxxRepo
├── mongo.py # Beanie Document + MongoXxxRepo
├── service.py # (可选)跨 repo 的复杂业务逻辑
├── constants.py # (可选)常量、错误码
└── exceptions.py # (可选)域内异常,继承 src.exceptions.AppError
```
**Cross-domain imports**: always use the explicit module name. Never `from src.auth import *`.
- 新增域后必做三件事:① `src/mongo.py::_collect_documents()` 登记 Document
`alembic/env.py` import mysql 模型;③ `src/main.py` include_router。
- 跨域 import 必须显式模块名:`from src.items import constants as item_constants`,禁 `import *`
- 全局共享的东西才放 `src/`config / exceptions / database / mongo
## 3. 双数据库后端规范(本模板灵魂)
```python
from src.auth import constants as auth_constants
from src.notifications import service as notification_service
from src.posts.constants import ErrorCode as PostsErrorCode
# ✅ router 只依赖 Protocol
async def get_item(item_id: str, repo: ItemRepoDep): ...
# ✅ dependencies.py 模块加载时选定实现
get_item_repo = _get_mysql_repo if settings.DB_BACKEND == "mysql" else _get_mongo_repo
# ❌ 禁止在 router/schemas/service 里出现
from src.items.mysql import Item # 不许!
from src.items.mongo import ItemDoc # 不许!
```
## Async Routes
- 两个 repo 实现**同一 Protocol**,方法签名逐字一致。
- schemas 的 `id` 统一用 `str`MySQL int 自增 / Mongo ObjectId 都转 str
转换在各 repo 的 `to_out()` 里完成。
- MySQL 改表结构 → 必须 `alembic revision --autogenerate` 生成迁移,禁手改生产库。
- Mongo 新增 Document → 只登记 `_collect_documents()`beanie 自动建索引。
- `src/database.py``src/mongo.py` 只在 lifespan / dependencies 里被引用;
非对应后端模式下它们可以 import 但**不得产生连接**engine 惰性,别主动 connect
### Decision rule
## 4. 异步纪律
| Route does this | Use |
|----------------------------------------|-------------|
| `await`-able non-blocking I/O | `async def` |
| Blocking I/O (no async client exists) | `def` (sync, runs in threadpool) |
| Mix of both | `async def` + `run_in_threadpool` for the blocking part |
| CPU-bound work (>50 ms compute) | Offload to a worker process (Celery / RQ / Arq) |
### Do / Don't
| 场景 | 写法 |
|---|---|
| await 的 I/O | `async def` |
| 只有同步 SDK | `def`FastAPI 自动进线程池)或 `run_in_threadpool` |
| CPU 密集 >50ms | 扔任务队列,别放请求路径 |
```python
# DON'T — blocking call inside async route freezes the entire event loop
# ❌ async 路由里调用阻塞库 = 冻结整个事件循环
@router.get("/bad")
async def bad():
time.sleep(10) # blocks every request on this worker
return {"ok": True}
time.sleep(5)
# DO — sync route lets FastAPI run it in a threadpool
@router.get("/sync-ok")
def sync_ok():
time.sleep(10) # blocks one threadpool worker, not the loop
return {"ok": True}
# DO — async route with awaitable sleep
@router.get("/async-ok")
async def async_ok():
await asyncio.sleep(10) # yields control, loop keeps serving requests
return {"ok": True}
# DO — async route that has to call a sync library
from fastapi.concurrency import run_in_threadpool
@router.get("/wrap")
async def wrap():
result = await run_in_threadpool(legacy_sync_client.fetch, "id")
return result
#
@router.get("/ok")
def ok():
time.sleep(5)
```
### Threadpool caveats
- Default Starlette threadpool size is 40. Saturating it slows every sync route.
- Threads cost more than coroutines. Don't use sync routes "just because."
## 5. 配置与异常
## Pydantic
- 配置只走 `src/config.py``settings`pydantic-settings域级配置放 `{domain}/config.py``env_prefix="XXX_"`
- 禁在代码里硬编码 DSN / 密钥;`.env` 不入库,`.env.example` 必须同步更新。
- 业务异常继承 `AppError`404 用 `NotFoundError`;统一由 `register_exception_handlers` 输出 `{"detail": ...}`
- 禁裸 `except:`,禁 `except Exception: pass`
### Use built-in validators
```python
from enum import StrEnum
from pydantic import AnyUrl, BaseModel, EmailStr, Field
class MusicBand(StrEnum):
AEROSMITH = "AEROSMITH"
QUEEN = "QUEEN"
ACDC = "AC/DC"
class UserCreate(BaseModel):
first_name: str = Field(min_length=1, max_length=128)
username: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_-]+$")
email: EmailStr
age: int = Field(ge=18) # required, must be >= 18
favorite_band: MusicBand | None = None
website: AnyUrl | None = None
```
> **Don't** write `Field(ge=18, default=None)`. The constraint and the default contradict
> each other. Decide: required (`Field(ge=18)`) or optional (`int | None = Field(default=None, ge=18)`).
### Custom base model — modern serialization
`json_encoders` is deprecated in Pydantic v2. Use `@field_serializer` for per-field rules,
or annotate a custom type with `PlainSerializer`.
## 6. Pydantic 规范
```python
from datetime import datetime
from zoneinfo import ZoneInfo
from pydantic import BaseModel, ConfigDict, field_serializer
# ✅ 约束写清楚required 和 optional 二选一,别自相矛盾
name: str = Field(min_length=1, max_length=128)
age: int | None = Field(default=None, ge=0) # 可选
age: int = Field(ge=18) # 必填
class CustomModel(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@field_serializer("*", when_used="json", check_fields=False)
def _serialize_datetimes(self, value):
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=ZoneInfo("UTC"))
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
return value
# ❌ 矛盾写法
age: int = Field(ge=18, default=None)
```
### Split BaseSettings by domain
- 序列化定制用 `@field_serializer`,不用 `json_encoders`
- 入参模型XxxCreate/XxxUpdate与出参模型XxxOut分开`XxxUpdate` 全字段可选 + `model_dump(exclude_unset=True)`
`pydantic-settings` is its own package since Pydantic v2.
## 7. 测试规范
```python
# src/auth/config.py
from datetime import timedelta
from pydantic_settings import BaseSettings, SettingsConfigDict
- 测试从第一天就是异步:`httpx.AsyncClient` + `ASGITransport`,见 `tests/conftest.py`
- 测试库与开发库隔离Mongo 用 `app_test`MySQL 用 `app_test`
- 每个域至少覆盖create / get / list / update / delete + 404 路径。
- 跑 MySQL 后端测试:`DB_BACKEND=mysql uv run pytest`
## 8. Git 规范
class AuthConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="AUTH_", env_file=".env", extra="ignore")
- 提交信息Conventional Commits —— `feat: xxx` / `fix: xxx` / `refactor:` / `docs:` / `test:` / `chore:`
- 主分支 `main`;功能开发开 `feature/xxx` 分支,提 PR 合并。
- 提交前自检三连:`uv run ruff check --fix . && uv run pytest`,全绿才推。
JWT_ALG: str
JWT_SECRET: str
JWT_EXP_MINUTES: int = 5
REFRESH_TOKEN_KEY: str
REFRESH_TOKEN_EXP: timedelta = timedelta(days=30)
SECURE_COOKIES: bool = True
## 9. 常用命令速查
auth_settings = AuthConfig()
```bash
uv sync # 装依赖
uv add fastapi # 加依赖dev 依赖uv add --dev xxx
uv run uvicorn src.main:app --reload # 开发起服务
uv run pytest # 测试
uv run ruff check --fix . # lint
uv run alembic revision --autogenerate -m "msg" # MySQL 迁移
docker compose up -d --build # 容器化整套
```
## Dependencies
## 10. 反模式清单Review 时逐条核对)
### Use Annotated, not default-arg `Depends(...)`
`Annotated[T, Depends(...)]` is the idiomatic form since FastAPI 0.95 and avoids
gotchas with default values.
```python
# DO — modern Annotated form
from typing import Annotated
from fastapi import Depends
PostDep = Annotated[dict, Depends(valid_post_id)]
@router.get("/posts/{post_id}")
async def get_post(post: PostDep):
return post
# Avoid — default-argument form (still works, but legacy)
@router.get("/posts/{post_id}")
async def get_post(post: dict = Depends(valid_post_id)):
return post
```
### Validate inside dependencies (not just inject)
```python
async def valid_post_id(post_id: UUID4) -> dict:
post = await service.get_by_id(post_id)
if not post:
raise PostNotFound()
return post
```
### Chain dependencies for reuse
```python
async def valid_owned_post(
post: Annotated[dict, Depends(valid_post_id)],
token_data: Annotated[dict, Depends(parse_jwt_data)],
) -> dict:
if post["creator_id"] != token_data["user_id"]:
raise UserNotOwner()
return post
```
### Rules
- Dependencies are **cached per request**. Same `Depends(x)` called 5 times in one request → `x` runs once.
- Prefer `async def` dependencies. Sync deps run in the threadpool — wasted overhead for small CPU-only checks.
- Use **the same path-variable name** across endpoints when you want to share a dependency (e.g. `profile_id` in both `/profiles/{profile_id}` and `/creators/{profile_id}`).
## Authentication — JWT
Use **`PyJWT`**, not `python-jose` (unmaintained).
```python
import jwt # PyJWT
from jwt.exceptions import InvalidTokenError
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALG])
except InvalidTokenError as exc:
raise InvalidCredentials() from exc
```
## Database — SQLAlchemy 2.0 async
Prefer SQLAlchemy 2.0's async API. `encode/databases` is in maintenance mode — don't pick it for new projects.
```python
# src/database.py
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
engine = create_async_engine(str(settings.DATABASE_URL), pool_pre_ping=True)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncSession:
async with SessionFactory() as session:
yield session
```
### Naming conventions
- `lower_case_snake`
- Singular tables: `post`, `user`, `post_like`
- Group with prefix: `payment_account`, `payment_bill`
- `_at` suffix for `datetime`, `_date` suffix for `date`
- Use the same FK column name everywhere it appears (`profile_id`, not `user_id` in some tables and `profile_id` in others)
### Index naming convention
```python
from sqlalchemy import MetaData
POSTGRES_INDEXES_NAMING_CONVENTION = {
"ix": "%(column_0_label)s_idx",
"uq": "%(table_name)s_%(column_0_name)s_key",
"ck": "%(table_name)s_%(constraint_name)s_check",
"fk": "%(table_name)s_%(column_0_name)s_fkey",
"pk": "%(table_name)s_pkey",
}
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
```
### SQL-first, Pydantic-second
- Do joins, aggregation, and JSON shaping in SQL — Postgres is faster than CPython at this.
- Hydrate the result into Pydantic only for response validation, not for transformation.
## Background work — BackgroundTasks vs Celery
| Use BackgroundTasks when… | Use Celery / Arq / RQ when… |
|------------------------------------------|--------------------------------------------|
| Task is < 1 second | Task takes seconds to minutes |
| Failure can be silently dropped | You need retries, dead-letter, or visibility|
| Task is in-process (send email, log row) | Task is CPU-heavy or needs a separate pool |
| You don't need scheduling | You need cron, ETA, or rate limiting |
```python
from fastapi import BackgroundTasks
@router.post("/signup")
async def signup(data: SignupIn, bg: BackgroundTasks):
user = await service.create_user(data)
bg.add_task(send_welcome_email, user.email) # fire-and-forget, in-process
return user
```
> BackgroundTasks run **after the response is sent, in the same worker process**. If the
> worker dies, the task is lost. There is no retry. Don't use them for anything you'd
> page on.
## Testing
### Async client from day one
```python
import pytest
from httpx import AsyncClient, ASGITransport
from src.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.mark.asyncio
async def test_create_post(client: AsyncClient):
resp = await client.post("/posts", json={"title": "hi"})
assert resp.status_code == 201
```
> **Don't** use `async_asgi_testclient` — it's unmaintained. The example above (httpx +
> `ASGITransport`) is the supported path.
### Override dependencies in tests
Don't monkeypatch internals. Use FastAPI's built-in `dependency_overrides`.
```python
from src.auth.dependencies import parse_jwt_data
from src.main import app
def fake_user():
return {"user_id": "00000000-0000-0000-0000-000000000001"}
@pytest.fixture(autouse=True)
def _override_auth():
app.dependency_overrides[parse_jwt_data] = fake_user
yield
app.dependency_overrides.clear()
```
## Migrations (Alembic)
- Migrations must be static and reversible.
- Use the async template: `alembic init -t async migrations`
- Descriptive filenames:
```ini
# alembic.ini
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
```
→ `2026-04-14_add_post_content_idx.py`
## API documentation
### Hide docs outside selected envs
```python
from fastapi import FastAPI
from src.config import settings
SHOW_DOCS_IN = {"local", "staging"}
app_kwargs = {"title": "My API"}
if settings.ENVIRONMENT not in SHOW_DOCS_IN:
app_kwargs["openapi_url"] = None # disables /docs and /redoc
app = FastAPI(**app_kwargs)
```
### Document endpoints fully
```python
from fastapi import APIRouter, status
router = APIRouter()
@router.post(
"/items",
response_model=ItemResponse,
status_code=status.HTTP_201_CREATED,
summary="Create an item",
description="Creates an item owned by the authenticated user.",
tags=["items"],
responses={
status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse, "description": "Validation error"},
status.HTTP_409_CONFLICT: {"model": ErrorResponse, "description": "Slug already exists"},
},
)
async def create_item(payload: ItemCreate) -> ItemResponse: ...
```
## Linting
```shell
ruff check --fix src
ruff format src
```
Add to a pre-commit hook or run in CI. Ruff replaces black + isort + autoflake + most of flake8.
---
## Anti-patterns — common AI-agent mistakes
If you're an agent reviewing a diff, check for these. Each is a real failure mode I've
seen agents introduce.
| Anti-pattern | Why it's wrong | Fix |
|---|---|---|
| `requests.get(...)` inside `async def` | Blocks the event loop. `requests` is sync. | Use `httpx.AsyncClient` or `await run_in_threadpool(requests.get, ...)`. |
| `time.sleep` / `open()` / sync DB driver inside `async def` | Same — blocks the loop. | Use the async equivalent (`asyncio.sleep`, `aiofiles`, async driver). |
| `from jose import jwt` | `python-jose` is unmaintained. | `import jwt` (PyJWT). |
| `from async_asgi_testclient import TestClient` | Unmaintained. | `httpx.AsyncClient` + `ASGITransport`. |
| `model_config = ConfigDict(json_encoders={...})` | Deprecated in Pydantic v2. | `@field_serializer` or `Annotated[T, PlainSerializer(...)]`. |
| `Field(ge=18, default=None)` | Constraint contradicts the default. | Pick required or optional, not both. |
| `def get_user(id: int = Depends(...))` (default-arg form) | Legacy; gotchas with default values. | `user: Annotated[User, Depends(...)]`. |
| Catching `Exception` around a route's body | Hides bugs and turns 500s into silent 200s. | Catch the specific exception class; raise `HTTPException` with a meaningful status. |
| `BackgroundTasks` for anything you'd page on | No retry, dies with the worker. | Use Celery / Arq / RQ. |
| Calling a sync ORM session inside `async def` | Blocks the loop, may deadlock the pool. | Use `AsyncSession`. |
| Returning a Pydantic model and *also* setting `response_model=` to that same class | Model gets constructed twice (validate + serialize). | Either return a `dict`/ORM row and let `response_model` validate, or drop `response_model` and trust the return type. |
| Importing across domains via deep paths (`from src.auth.service.user import ...`) | Tight coupling, hard to refactor. | `from src.auth import service as auth_service`. |
| Reusing one `BaseSettings` for the whole app | Hard to reason about, every domain reads every var. | One `BaseSettings` per domain. |
| Mocking the database in integration tests | Mock/prod divergence eventually fires in prod. | Use a real DB (testcontainers, ephemeral schema) and `dependency_overrides` for auth/external services. |
## Quick reference
| Scenario | Solution |
|--------------------------------------|---------------------------------------------------|
| Non-blocking I/O | `async def` route with `await` |
| Blocking I/O (no async client) | `def` route (sync, runs in threadpool) |
| Sync library inside async route | `await run_in_threadpool(fn, *args)` |
| CPU-intensive work | Celery / Arq / RQ worker process |
| Request validation against DB | Dependency that loads + validates + returns |
| Reuse validation across routes | Chain dependencies |
| Inject dependency in modern style | `Annotated[T, Depends(...)]` |
| Per-request dep caching | Default behavior — same `Depends(x)` runs once |
| Per-domain config | One `BaseSettings` subclass per domain |
| Custom datetime serialization | `@field_serializer` |
| Fire-and-forget short task | `BackgroundTasks` |
| Reliable / scheduled / heavy task | Celery / Arq / RQ |
| JWT decode | `PyJWT` (`import jwt`) |
| Async DB | SQLAlchemy 2.0 async (`AsyncSession`) |
| HTTP test client | `httpx.AsyncClient` + `ASGITransport` |
| Swap dep in tests | `app.dependency_overrides[dep] = fake` |
| Lint + format | `ruff check --fix` + `ruff format` |
- [ ] router 里出现 SQL / `ItemDoc.find()` 等具体 DB 调用
- [ ] async 路由里调用 requests / time.sleep 等阻塞库
- [ ] `print` 调试残留(用 `logging`
- [ ] 硬编码连接串 / 密钥
- [ ] `from x import *`、裸 except
- [ ] 改了 MySQL 模型没生成 alembic 迁移
- [ ] 新增 Mongo Document 没登记 `_collect_documents()`
- [ ] 新域只在单一后端实现(两个 repo 必须成对)
- [ ] 提交信息 freestyle非 Conventional Commits