接上篇认证系统,本文带你用 SQLAlchemy 2.0 搞定多对多关系、数据库版本控制和高性能组合搜索
写在前面
上篇文章 我们搭好了一套 JWT 双 Token + RBAC 的认证系统。用户能注册、登录、刷新 Token 了。但这只是"骨架"------一个资产管理系统,怎么能没有资产呢?
第二阶段的需求很明确:
- 复杂的数据库关系:用户管资产(一对多),资产贴标签(多对多)
- 不写一行原生 SQL:全部用 ORM 完成
- 数据库版本控制:用 Alembic 替代手写 SQL 迁移
- 搜索不能慢:多条件组合查询,杜绝 N+1 问题
这篇文章完整记录我是怎么一步步做的。如果你也在用 FastAPI + SQLAlchemy 2.0 做项目,希望这篇文章能帮到你。
第一步:搞清楚表之间的关系
在写代码之前,先画出表关系的草图:
bash
┌──────────┐ ┌───────────────┐ ┌──────────┐
│ users │ 1───多 │ assets │ 多───多 │ tags │
│ │────────→│ │←────────│ │
│ id │ │ id │ asset_ │ id │
│ username │ │ name │ tags │ name │
│ password │ │ ip │ (中间表) │ │
│ role │ │ status │ └──────────┘
└──────────┘ │ config (JSON) │
│ description │
│ owner_id (FK) │
└───────────────┘
三个实体之间的关系:
- User → Asset(一对多):一个用户可以管理多台服务器。比如管理员"张三"负责 10 台机器。
- Asset ↔ Tag(多对多):一台服务器可以有"生产环境"+"数据库"两个标签;"数据库"这个标签也可以贴在多台服务器上。
- asset_tags(中间表) :多对多关系必须要一张中间表来记录"哪个资产有哪些标签"。它只有两列:
asset_id和tag_id,组成联合主键。
新手容易犯的错 :把标签直接存成资产表的一个字段,比如
tags = "生产环境,数据库"。这会导致搜索标签时只能用LIKE '%数据库%',又慢又容易出错。
第二步:用 SQLAlchemy 2.0 定义模型
2.1 先把 Base 提取出来
原来的 Base 定义在 models/user.py 里。现在我们要新建 Asset 和 Tag 模型,如果它们都从 user.py 导入 Base,代码结构会很乱。
新建 models/base.py,只放一行:
python
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
所有模型都从 models.base 导入 Base。干净。
2.2 最棘手的部分:多对多 + 级联删除
打开 models/tag.py,先看中间表的定义:
python
from sqlalchemy import Table, Column, Integer, ForeignKey
# 多对多关联表 --- 用纯 Table,不用 ORM 模型类
asset_tags = Table(
"asset_tags",
Base.metadata,
Column("asset_id", Integer, ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
这里有几个关键决策,让我逐一解释:
为什么用 Table 而不是 ORM 类?
中间表只存两个外键,没有额外的业务字段(比如关联时间、操作人等)。用 Table 更轻量,SQLAlchemy 不会为它创建 Python 对象。
ondelete="CASCADE" 到底删什么?
这是我踩过的坑。ondelete="CASCADE" 是告诉 PostgreSQL:当父表(assets 或 tags)中的行被删除时,自动删除中间表中引用它的行。
但重点来了------它不会穿透到另一侧的实体:
删除一个 Asset
→ asset_tags 中相关行自动删除 ✅
→ Tag 本身不受影响 ✅
删除一个 Tag
→ asset_tags 中相关行自动删除 ✅
→ Asset 本身不受影响 ✅
这就像一个关注关系------你取关一个博主,只是解除了关注关系,博主本人还在。
联合主键有什么用?
两列一起做主键,意味着同一个 (asset_id, tag_id) 组合不能重复出现。一台服务器不能贴两次同一个标签------这符合业务逻辑。
2.3 Tag 模型
python
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(50), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# 多对多反向关系
assets: Mapped[list["Asset"]] = relationship(
"Asset",
secondary=asset_tags, # ← 指定中间表
back_populates="tags", # ← 指向 Asset.tags
lazy="selectin", # ← 批量预加载
)
几个你可能想问的点:
Mapped[list["Asset"]]为什么用字符串? 这是 Python 的"前向引用"(forward reference)。当 Python 读到这行时,Asset类可能还没加载。用字符串"Asset"可以推迟解析,避免NameError。secondary=asset_tags:告诉 SQLAlchemy "Tag 和 Asset 的多对多关系是通过 asset_tags 这张中间表实现的"。lazy="selectin":加载策略。后面会详细讲它为什么能消灭 N+1。
2.4 Asset 模型
python
from sqlalchemy.dialects.postgresql import JSONB
class Asset(Base):
__tablename__ = "assets"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
ip: Mapped[str | None] = mapped_column(String(45)) # IPv6 最长 45 字符
description: Mapped[str | None] = mapped_column(String(500)) # 资产描述
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
config: Mapped[dict | None] = mapped_column(JSONB) # PostgreSQL 专属
# 时间戳:created_at 插入时自动填,updated_at 每次更新自动改
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
# 多对一:属于哪个用户
owner_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
owner: Mapped["User"] = relationship("User", back_populates="assets")
# 多对多:有哪些标签
tags: Mapped[list["Tag"]] = relationship(
"Tag", secondary=asset_tags, back_populates="assets", lazy="selectin"
)
几个设计选择的理由:
| 设计 | 理由 |
|---|---|
ip 用 String(45) |
IPv4 最长 15 字符,IPv6 最长 45 字符。设小了将来要改表结构 |
config 用 JSONB |
PostgreSQL 专属的二进制 JSON,比普通 JSON 查询更快、支持索引 |
status 加索引 |
搜索接口要按状态过滤,加了索引快几十倍 |
onupdate=func.now() |
每次 UPDATE 行时 PostgreSQL 自动更新 updated_at,不需要 Python 代码手动维护 |
2.5 别忘了这一步------否则 Alembic 检测不到表!
在 models/__init__.py 中导入所有模型:
python
from models.base import Base
from models.user import User
from models.asset import Asset
from models.tag import Tag, asset_tags
__all__ = ["Base", "User", "Asset", "Tag", "asset_tags"]
为什么这行代码这么重要? Alembic 的 env.py 里有这么一行:
python
from models import Base
target_metadata = Base.metadata
Alembic 通过扫描 Base.metadata 来发现所有表。如果一个模型类从来没有被 import 过,它就不会注册到 Base.metadata 中,Alembic 就看不到它。轻则生成不了迁移,重则会认为"这张表该删掉",生成 DROP TABLE 语句------那是灾难。
经验法则 :每新建一个模型文件,就在
models/__init__.py里加一行 import。养成肌肉记忆。
第三步:用 Alembic 管理数据库版本
3.1 为什么不能用 create_all 了?
第一阶段的 main.py 里我们写了:
python
# 第一阶段的做法 ------ 简陋但能用
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
这在原型阶段可以,但有三个致命问题:
| 问题 | 后果 |
|---|---|
| 改模型后要手动删表重建 | 数据全部丢失! |
| 不知道数据库当前是什么版本 | 开发环境和生产环境表结构不一致 |
| 出问题无法回滚 | 只能手动写 SQL 补救 |
Alembic 就是数据库的 Git。 每次改模型,它自动生成迁移脚本,记录变更历史,随时可以回滚。
3.2 Alembic 的三个核心文件
不用 alembic init(目录已经有了),直接手动创建三个文件:
alembic.ini(项目根目录) :告诉 Alembic 迁移脚本放哪、用什么日志格式。注意不要在这写数据库连接串 ------统一从 core/config.py 读,避免两处配置不一致。
alembic/env.py(最关键的配置):
python
# 🔑 把项目根目录加入 Python 搜索路径
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# 🔑 导入所有模型(这里会执行 models/__init__.py)
from models import Base
target_metadata = Base.metadata # 告诉 Alembic 跟踪哪些表
# 🔑 在线模式:连数据库并执行迁移
async def run_async_migrations():
connectable = create_async_engine(
settings.DATABASE_URL,
poolclass=pool.NullPool, # 迁移脚本不需要连接池
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # 🔑 检测列类型的变更!
)
with context.begin_transaction():
context.run_migrations()
compare_type=True 很重要 。默认情况下 Alembic 只检测"新增/删除列"。开了这个选项,它还能检测"列的类型变了"------比如你把 String(200) 改成 String(500),Alembic 会生成 ALTER COLUMN 语句。
alembic/script.py.mako:迁移脚本的模板。用默认的 Mako 模板即可。
3.3 生成第一个迁移
bash
# 确保 PostgreSQL 在运行
docker compose up -d
# 自动对比模型和数据库,生成迁移脚本
alembic revision --autogenerate -m "v1: users, assets, tags"
# 输出:
# INFO Detected added table 'tags'
# INFO Detected added table 'users'
# INFO Detected added table 'assets'
# INFO Detected added table 'asset_tags'
# Generating .../alembic/versions/20260621_0129_8f87e913e2dd_v1_users_assets_tags.py ... done
# 应用到数据库
alembic upgrade head
# 验证
alembic current
# 输出: 8f87e913e2dd (head)
生成的迁移脚本大概长这样:
python
def upgrade() -> None:
op.create_table('tags', ...)
op.create_table('users', ...)
op.create_table('assets', ...,
sa.Column('config', postgresql.JSONB(), ...), # ← 自动识别了 JSONB
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_table('asset_tags', ...,
sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'),
)
def downgrade() -> None:
op.drop_table('asset_tags') # ← 先删依赖表
op.drop_table('assets')
op.drop_table('users')
op.drop_table('tags')
注意 downgrade() 按依赖关系的逆序 删除表------先删 asset_tags(它依赖 assets 和 tags),再删 assets 和 tags。这避免了外键约束报错。
3.4 演示一次 Schema 变更
业务需求变了------资产需要加一个"描述"字段。来看看 Alembic 的完整流程:
bash
# 1. 修改模型:在 Asset 类中加一行
# description: Mapped[str | None] = mapped_column(String(500), default=None)
# 2. 生成迁移
$ alembic revision --autogenerate -m "v2: add description to assets"
# INFO Detected added column 'assets.description'
# Generating .../20260621_0131_63687e2e05b8_v2_add_description_to_assets.py ... done
# 3. 看看生成的脚本
$ cat alembic/versions/*v2*.py
def upgrade():
op.add_column('assets', sa.Column('description', sa.String(length=500), nullable=True))
def downgrade():
op.drop_column('assets', 'description')
# 4. 应用到数据库
$ alembic upgrade head
# INFO Running upgrade 8f87e913e2dd -> 63687e2e05b8
# 5. 查看版本链
$ alembic history
# 8f87e913e2dd -> 63687e2e05b8 (head), v2: add description to assets
# <base> -> 8f87e913e2dd, v1: users, assets, tags
版本历史一目了然:<base> → V1 → V2 (head)。如果你想回滚 V2:
bash
alembic downgrade -1 # 回到 V1,description 列被删除
alembic upgrade head # 重新应用 V2
3.5 日常开发的标准流程
bash
改了 models/*.py
│
▼
alembic revision --autogenerate -m "描述你的改动"
│
▼
检查 alembic/versions/ 里生成的脚本是否正确
│
▼
alembic upgrade head
│
▼
alembic current # 确认版本
第四步:搜索接口------多条件过滤 + 消灭 N+1
4.1 N+1 问题是什么?
假设数据库里有 100 个资产,每个资产有 3 个标签。如果你不做优化,SQLAlchemy 默认会这样查:
sql
❌ N+1 查询(101 条 SQL)
SELECT * FROM assets LIMIT 100 ← 查资产(第 1 条)
SELECT * FROM tags WHERE asset_id = 1 ← 查第 1 个资产的标签
SELECT * FROM tags WHERE asset_id = 2 ← 查第 2 个资产的标签
SELECT * FROM tags WHERE asset_id = 3 ← 查第 3 个资产的标签
...(总共 101 条 SQL!)
100 个资产 = 100 条额外的标签查询 = 101 条 SQL。这就是著名的 N+1 问题。
4.2 selectinload:用 2 条 SQL 搞定
python
from sqlalchemy.orm import selectinload
stmt = select(Asset).options(selectinload(Asset.tags))
selectinload 告诉 SQLAlchemy:"先查出所有资产,然后一次性查出它们的所有标签":
sql
✅ selectinload(2 条 SQL)
SELECT * FROM assets LIMIT 100 ← 查资产
SELECT * FROM tags WHERE id IN (1, 3, 5, 7, ...) ← 批量查所有标签
(总共 2 条 SQL,不管多少资产!)
原理很简单:SQLAlchemy 收集了所有资产的 ID,然后用一条 WHERE id IN (...) 批量加载标签。这是 O(2) 而不是 O(N+1)。
selectinload vs joinedload 怎么选?
| selectinload | joinedload | |
|---|---|---|
| SQL 数量 | 2 条 | 1 条(LEFT JOIN) |
| 适用场景 | 一对多、多对多集合 | 多对一单对象 |
| 优点 | 不分页时无笛卡尔积 | 一条 SQL 完成 |
| 缺点 | 多一条 SQL | 多对多时产生重复行 |
对于 Asset→Tag 这种多对多关系,selectinload 是最佳选择。joinedload 在这里会产生笛卡尔积,导致主查询出现重复的 Asset 行。
4.3 .unique() ------ 容易被忽略但必须加
python
result = await session.execute(stmt)
return result.scalars().unique().all()
# ^^^^^^^^ ← 少写这个会出 Bug
为什么需要 .unique()?因为 selectinload 内部用 LEFT JOIN 加载关联数据。多对多关系中,一个 Asset 如果有 3 个 Tag,JOIN 后会产生 3 行。.unique() 按主键去重,确保每个 Asset 只返回一次。
踩坑提醒 :我一开始漏了
.unique(),API 返回了重复的资产。排查了半天才发现是多对多 JOIN 导致的。
4.4 完整的搜索接口
python
@router.get("/search", response_model=list[AssetRead])
async def search_assets(
name: str | None = Query(default=None, description="名称模糊搜索"),
status: str | None = Query(default=None, description="状态精确匹配"),
tags: str | None = Query(default=None, description="标签过滤,逗号分隔"),
owner_id: int | None = Query(default=None),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
):
# 基础查询:预加载标签
stmt = select(Asset).options(selectinload(Asset.tags))
# 🔑 动态叠加过滤条件
if name:
stmt = stmt.where(Asset.name.ilike(f"%{name}%")) # ILIKE: 大小写不敏感
if status:
stmt = stmt.where(Asset.status == status)
if owner_id is not None:
stmt = stmt.where(Asset.owner_id == owner_id)
if tags:
tag_names = [t.strip() for t in tags.split(",") if t.strip()]
# 子查询:找到拥有任一指定标签的资产 ID
tag_filter = (
select(Asset.id)
.join(Asset.tags)
.where(Tag.name.in_(tag_names))
)
stmt = stmt.where(Asset.id.in_(tag_filter))
stmt = stmt.offset(offset).limit(limit).order_by(Asset.id.desc())
result = await session.execute(stmt)
return result.scalars().unique().all()
设计要点:
- 动态构建查询 :每个
if独立判断,只有前端传了参数才叠加条件。避免了写一堆if/elif拼 SQL。 - ILIKE 模糊搜索 :PostgreSQL 的
ILIKE是大小写不敏感的 LIKE。搜索"数据库"能匹配到"主数据库服务器"。 - 标签过滤用子查询 :先用
JOIN asset_tags JOIN tags找到匹配的 asset_id,再在主查询中用WHERE id IN (...)。这样主查询保持干净,不走复杂的多表 JOIN。 - 标签是 OR 逻辑 :传入
tags=生产环境,数据库,资产拥有任一标签即命中。如果想改成 AND,可以改用HAVING COUNT(DISTINCT tags.name) = N。
4.5 从日志验证:你真的消灭了 N+1 吗?
core/db.py 里设置了 echo=True,所有 SQL 都会打印到控制台。调用搜索接口后,观察日志:
vbnet
✅ 正确(2 条主查询 + 1 条标签预加载)
INFO sqlalchemy.engine.Engine SELECT assets.id, assets.name, ...
FROM assets WHERE assets.name ILIKE '%数据库%' ← 主查询
INFO sqlalchemy.engine.Engine SELECT tags.id, ...
FROM tags JOIN asset_tags ON ... WHERE assets.id IN (1, 2) ← 批量预加载
❌ 错误(N+1 的典型特征)
INFO sqlalchemy.engine.Engine SELECT assets.id, ... FROM assets
INFO sqlalchemy.engine.Engine SELECT tags ... WHERE asset_id = 1
INFO sqlalchemy.engine.Engine SELECT tags ... WHERE asset_id = 2
INFO sqlalchemy.engine.Engine SELECT tags ... WHERE asset_id = 3
...(逐条查询!)
判断标准 :如果你看到大量 WHERE asset_id = N 的重复查询,就是 N+1。一行 WHERE id IN (1,2,3,...) 才是正确的。
第五步:Pydantic Schema ------ API 的数据合同
ORM 模型是对数据库的,Schema 是对前端的。它们必须分开:
python
# ===== 创建资产 =====
class AssetCreate(BaseModel):
name: str
ip: str | None = None
description: str | None = None
status: str = "active"
config: dict[str, Any] | None = None
tag_ids: list[int] = [] # 传标签 ID 即可,不需要传整个标签对象
# ===== 返回资产 =====
class AssetRead(BaseModel):
id: int
name: str
ip: str | None
description: str | None
status: str
config: dict[str, Any] | None
created_at: datetime
updated_at: datetime
owner_id: int
tags: list[TagRead] = [] # 完整返回标签信息
model_config = ConfigDict(from_attributes=True) # ← 允许从 ORM 对象创建
from_attributes=True (旧版叫 orm_mode=True)让 Pydantic 能直接从 SQLAlchemy ORM 对象读取属性:
python
asset = await session.get(Asset, 1) # ORM 对象
return AssetRead.model_validate(asset) # 自动转成 JSON
部分更新的秘诀 :model_dump(exclude_unset=True)。客户端只传 {"name": "新名字"} 时,它只返回 {"name": "新名字"},不会把未传的字段变成 None:
python
body = AssetUpdate(name="新名字") # 只传了 name
update_data = body.model_dump(exclude_unset=True)
# → {"name": "新名字"} ← 只有这一个!
# 而不是 {"name": "新名字", "ip": None, "status": None, ...}
第六步:完整的 curl 测试
启动服务后,跑一遍完整流程:
bash
# 0. 初始化数据库
docker compose up -d
alembic upgrade head
# 1. 注册 + 登录
curl -X POST http://localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123","role":"admin"}'
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# 2. 创建标签
curl -X POST http://localhost:8000/api/v1/tags/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"生产环境"}'
curl -X POST http://localhost:8000/api/v1/tags/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"数据库"}'
curl -X POST http://localhost:8000/api/v1/tags/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"核心服务"}'
# 3. 创建资产(带标签)
curl -X POST http://localhost:8000/api/v1/assets/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name":"主数据库服务器",
"ip":"10.0.0.1",
"status":"active",
"description":"核心业务的 PostgreSQL 主库",
"config":{"cpu":16,"ram":"64GB","disk":"1TB"},
"tag_ids":[1,2,3]
}'
curl -X POST http://localhost:8000/api/v1/assets/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name":"备份数据库服务器",
"ip":"10.0.0.2",
"status":"active",
"config":{"cpu":8,"ram":"32GB"},
"tag_ids":[2,3]
}'
# 4. 🔍 搜索:组合条件
# 按名称模糊搜索
curl -s "http://localhost:8000/api/v1/assets/search?name=数据库" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 按状态 + 标签组合
curl -s "http://localhost:8000/api/v1/assets/search?status=active&tags=数据库,核心服务" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 5. 部分更新
curl -X PUT http://localhost:8000/api/v1/assets/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"description":"更新后的描述"}'
# 6. 验证级联删除
curl -X DELETE http://localhost:8000/api/v1/tags/1 \
-H "Authorization: Bearer $TOKEN"
# → HTTP 204。标签删了,但资产 #1 还在,只是不再有"生产环境"标签
踩坑记录
坑 1:Mac 上本地 PostgreSQL 和 Docker PostgreSQL 抢端口
Mac 上用 Homebrew 装过 PostgreSQL 的话,它会占住 localhost:5432。Docker 容器的端口映射会失效,导致 asyncpg 连到了本地 PostgreSQL 而不是 Docker 里的。
解决方法 :.env 里用 127.0.0.1 替代 localhost,或者停掉本地 PostgreSQL:
bash
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/homebrew.mxcl.postgresql@*.plist
坑 2:selectinload 后忘记 .unique()
表现:API 返回的数据里有重复的 Asset。排查后发现是多对多 JOIN 产生的重复行。
修复:result.scalars().unique().all() ------ 加上 .unique()。
坑 3:Alembic 生成空迁移
原因:models/__init__.py 里没 import 新模型,Alembic 检测不到。或者数据库已经和模型完全一致。
检查方法:确保 models/__init__.py 导入了所有模型文件。
坑 4:model_dump 和旧版 dict() 的区别
Pydantic v2 把 .dict() 改成了 .model_dump()。.model_dump(exclude_unset=True) 是实现部分更新的关键------它只序列化客户端实际传了的字段。
总结
第二阶段完成后的能力清单:
- 复杂 ORM 关系:一对多(User→Asset)+ 多对多(Asset↔Tag),带合理的级联删除策略
- Alembic 版本控制 :数据库变更可追溯(
alembic history)、可回滚(alembic downgrade) - 高性能搜索 :
selectinload消灭 N+1,动态构建组合查询 - 零原生 SQL:所有数据操作通过 ORM 完成
核心设计原则回顾
| 原则 | 体现 |
|---|---|
| 关注点分离 | models/(表结构)≠ schemas/(API 格式)≠ api/(业务逻辑) |
| 数据库版本控制 | 每次改模型 → alembic revision --autogenerate → alembic upgrade head |
| 性能优先 | selectinload 批量预加载,绝不出现 N+1 |
| 安全第一 | owner_id 从 JWT 获取不从前端传,hashed_password 绝不出现在响应中 |
| 可维护性 | 动态查询构建(叠加 if),迁移历史完整可追溯 |
如果你正在用 FastAPI + SQLAlchemy 2.0 构建一个需要复杂关联和版本控制的 API,希望这篇文章能帮你少踩几个坑。完整的项目代码和 README 教程在仓库中,每个文件都有详细的中文注释,欢迎参考。