FastAPI 资产管理系统实战:复杂 ORM 关联、Alembic 迁移与 N+1 查询优化

接上篇认证系统,本文带你用 SQLAlchemy 2.0 搞定多对多关系、数据库版本控制和高性能组合搜索

写在前面

上篇文章 我们搭好了一套 JWT 双 Token + RBAC 的认证系统。用户能注册、登录、刷新 Token 了。但这只是"骨架"------一个资产管理系统,怎么能没有资产呢?

第二阶段的需求很明确:

  1. 复杂的数据库关系:用户管资产(一对多),资产贴标签(多对多)
  2. 不写一行原生 SQL:全部用 ORM 完成
  3. 数据库版本控制:用 Alembic 替代手写 SQL 迁移
  4. 搜索不能慢:多条件组合查询,杜绝 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_idtag_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"
    )

几个设计选择的理由:

设计 理由
ipString(45) IPv4 最长 15 字符,IPv6 最长 45 字符。设小了将来要改表结构
configJSONB 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()

设计要点:

  1. 动态构建查询 :每个 if 独立判断,只有前端传了参数才叠加条件。避免了写一堆 if/elif 拼 SQL。
  2. ILIKE 模糊搜索 :PostgreSQL 的 ILIKE 是大小写不敏感的 LIKE。搜索"数据库"能匹配到"主数据库服务器"。
  3. 标签过滤用子查询 :先用 JOIN asset_tags JOIN tags 找到匹配的 asset_id,再在主查询中用 WHERE id IN (...)。这样主查询保持干净,不走复杂的多表 JOIN。
  4. 标签是 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) 是实现部分更新的关键------它只序列化客户端实际传了的字段。

总结

第二阶段完成后的能力清单:

  1. 复杂 ORM 关系:一对多(User→Asset)+ 多对多(Asset↔Tag),带合理的级联删除策略
  2. Alembic 版本控制 :数据库变更可追溯(alembic history)、可回滚(alembic downgrade
  3. 高性能搜索selectinload 消灭 N+1,动态构建组合查询
  4. 零原生 SQL:所有数据操作通过 ORM 完成

核心设计原则回顾

原则 体现
关注点分离 models/(表结构)≠ schemas/(API 格式)≠ api/(业务逻辑)
数据库版本控制 每次改模型 → alembic revision --autogeneratealembic upgrade head
性能优先 selectinload 批量预加载,绝不出现 N+1
安全第一 owner_id 从 JWT 获取不从前端传,hashed_password 绝不出现在响应中
可维护性 动态查询构建(叠加 if),迁移历史完整可追溯

如果你正在用 FastAPI + SQLAlchemy 2.0 构建一个需要复杂关联和版本控制的 API,希望这篇文章能帮你少踩几个坑。完整的项目代码和 README 教程在仓库中,每个文件都有详细的中文注释,欢迎参考。

相关推荐
aqi007 小时前
15天学会AI应用开发(八)使用向量数据库实现RAG功能
人工智能·python·大模型·ai编程·ai应用
Csvn8 小时前
`functools.lru_cache` —— 一行代码搞定缓存加速
后端·python
金銀銅鐵1 天前
[Python] 从《千字文》中随机挑选汉字
后端·python
cup111 天前
[技术复盘] Windows Python 打包实战:Nuitka 环境踩坑总结与 CI 自动化构建全指南
python·ai·环境变量·ci·nuitka·skill
aqi001 天前
15天学会AI应用开发(七)有了大模型为什么还要引入RAG
人工智能·python·大模型·ai编程·ai应用
金銀銅鐵1 天前
用 Python 实现 Take-Away 游戏
python·游戏
copyer_xyf1 天前
Agent 流程编排
后端·python·agent