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 教程在仓库中,每个文件都有详细的中文注释,欢迎参考。

相关推荐
笨鸟先飞,勤能补拙6 小时前
AI 赋能网络安全:技术全景、成熟度评估与实战案例
人工智能·python·安全·web安全·网络安全·sqlite·github
长和信泰光伏储能6 小时前
京津冀光伏发电:绿色能源的未来之路
python·能源
浦信仿真大讲堂7 小时前
从重复操作到自动化闭环:如何让 CST 与 Python 真正协同起来
python·自动化·cst·仿真软件·达索软件
Gu Gu Study7 小时前
ScoutLoop开放域深度研究引擎(agent的初步设计想法)
人工智能·python
卷无止境8 小时前
写代码这件事,到底该讲究点什么?
后端·python
卷无止境8 小时前
循环复杂度到底在算什么,Python 代码怎么才能写得让人一看就懂
后端·python
lpfasd1238 小时前
MediaCrawler 项目深度分析
chrome·python·chrome devtools
Dxy12393102169 小时前
Python项目打包成EXE完整教程(PyInstaller实战避坑)
开发语言·python
bamb009 小时前
一个项目带你入门AI应用开发01
python
0566469 小时前
Python康复训练——常用标准库
开发语言·python·学习