【FDE系列】阶段2:Day 29:FastAPI 进阶 — Pydantic 模型与完整 CRUD 实战

📚前言

📒FDE系列内容总纲:

【大纲】FDE 前沿部署工程师学习系列教程-CSDN博客

🚄前置课程列表:

阶段一:

【FDE系列】阶段1Day 1:AI 层级关系 --- 四个嵌套的圈-CSDN博客

【FDE系列】阶段1Day 2:AI 三阶段发展史 --- 会认 → 会判断 → 会创造-CSDN博客

【FDE系列】阶段1Day 3:符号 AI vs 机器学习 --- 两条路线的本质区别-CSDN博客

【FDE系列】阶段1Day 4:Transformer 的历史意义 --- 2017 年的分水岭-CSDN博客

【FDE系列】阶段1Day 5:本周复习与自测 --- 检验你的 AI 认知地基-CSDN博客

【FDE系列】阶段1Day 6:Transformer 架构 --- 一张图纸盖出千千万万栋楼-CSDN博客

【FDE系列】阶段1Day 7:LLM 本质 --- 文字接龙机器-CSDN博客

【FDE系列】阶段1Day 8:Token --- 模型眼中的最小单位-CSDN博客

【FDE系列】阶段1Day 9:AI 幻觉 --- 为什么会一本正经地胡说八道-CSDN博客

【FDE系列】阶段1Day 10:上下文窗口 --- 模型的记忆力上限 + 本周复习-CSDN博客

【FDE系列】阶段1Day 11:Prompt --- 给模型立规矩-CSDN博客

【FDE系列】阶段1Day 12:Memory --- 让模型记住上下文

【FDE系列】阶段1Day 13:RAG --- 给模型配图书管理员-CSDN博客

【FDE系列】阶段1Day 14:Tool Use --- 让模型动手操作-CSDN博客

【FDE系列】阶段1Day 15:MCP --- 统一的工具接口标准 + 第三周复习-CSDN博客

【FDE系列】阶段1Day 16:什么是 FDE --- 把 AI 变成客户结果的人-CSDN博客

【FDE系列】阶段1Day 17:FDE vs 传统实施 --- 三大本质区别-CSDN博客

【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客

【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客

【FDE系列】阶段1Day 20:阶段总结与产出物 --- 第一阶段收官-CSDN博客


阶段二:

【FDE系列】阶段2:Day 21:Python 环境搭建 --- 写出你的第一行代码-CSDN博客

【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 --- Python 的"记忆"和"判断"-CSDN博客

【FDE系列】阶段2:Day 23:循环与函数 --- 让代码跑 100 遍、把逻辑打包复用-CSDN博客

【FDE系列】阶段2:Day 24:数据结构 --- 列表、字典、集合、元组-CSDN博客

【FDE系列】阶段2:Day 25:文件读写与 JSON --- 让程序连通外部数据(第一周收官)-CSDN博客

【FDE系列】阶段2:Day 26:模块化编程 --- 把代码拆成"抽屉柜"-CSDN博客

【FDE系列】阶段2:Day 27:异常处理与日志 --- 让程序"摔不烂、查得到"-CSDN博客

【FDE系列】阶段2:Day 28:FastAPI 入门 --- 把你的函数变成 API 服务-CSDN博客


🚀阶段2·Day 29:FastAPI 进阶 --- Pydantic 模型与完整 CRUD 实战

FDE 学习系列教程 · 第二阶段 · 第 2 周 · Day 4 预计时长:3 小时 | 难度:★★★★☆ | 前置知识:Day 28(FastAPI 路由基础)
📌 一句话目标:用 Pydantic 模型定义数据契约,实现工单的增删改查全套接口,理解请求体、响应模型、状态码与分层结构------交付一个能完整演示的 API 服务。

🧑‍🤝‍🧑 老哥开场白

昨天的服务只能"查"。今天补齐"增、改、删",做一个完整的工单管理 API

但先解决一个新问题:新增工单时,调用方要发来一堆数据(标题、优先级、描述、上报人......)。这些数据放在哪?怎么检查必填项漏没漏、优先级是不是合法值?

靠手写 if 判断?一个接口 20 个字段时你会想死。

答案是 Pydantic------FastAPI 的数据校验底座。你用一个类"声明数据长什么样",校验、转换、文档、报错全部自动化。

👉 今天是第二周最硬核的一天,新概念三个:请求体模型、响应模型、完整 CRUD 链路。难度上来了别慌,今天的代码是一个整体,跟着敲完再回头看,每一块都很清楚。

📨 请求体:POST 发来的数据放哪

GET 请求参数全在网址里。但新增一条工单要提交很多字段,塞网址里既不安全也不现实------这些数据放在 请求体(Request Body) 里,就是一段 JSON。

调用方发来的东西长这样:

复制代码
{
  "title": "注塑机A3温度报警",
  "priority": "高",
  "description": "温度持续超过90度",
  "reporter": "赵工"
}

问题来了:

复制代码
❌ title 没传怎么办?
❌ priority 传了个"超级高"这种瞎编的值怎么办?
❌ description 传成数字 123 怎么办?

Pydantic 出场。

🧬 Pydantic 模型:给数据立"规矩"

定义模型

复制代码
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


# 继承 BaseModel,声明"工单应该长什么样"
class TicketCreate(BaseModel):
    title: str               # 标题:必须是字符串,且必填
    priority: str = "中"     # 优先级:字符串,不传时默认"中"
    description: str = ""    # 描述:字符串,可空
    reporter: str            # 上报人:必填

这个类同时干了四件事:

复制代码
1. 校验     缺了 title?类型不对?→ 自动返回 422,错误详情精确到字段
2. 转换     传 "82" 而字段声明 int?→ 自动帮你转(能转的前提下)
3. 文档     /docs 页面自动展示这个 JSON 结构和示例
4. 提示     写代码时有自动补全,点出 ticket.title 不担心拼错

接收请求体:POST 接口

复制代码
TICKETS = []      # 先用内存列表当"数据库"

@app.post("/tickets", status_code=201)
def create_ticket(ticket: TicketCreate):
    new_id = len(TICKETS) + 1
    record = {"id": new_id, **ticket.model_dump(), "status": "待处理"}
    TICKETS.append(record)
    return record

要点:

  • 参数 ticket: TicketCreate------FastAPI 看到参数是 Pydantic 模型,就知道"去请求体里取 JSON 并按规矩校验"

  • status_code=201:创建成功按 HTTP 惯例返回 201 Created,而不是普通 200

  • ticket.model_dump():把模型转回字典 (Pydantic v2 写法;v1 老项目里叫 .dict(),看到要认识)

启动服务,在 /docs 里测试:

  1. 找到 POST /tickets → Try it out

  2. 请求体模板已经自动生成好,改改值 → Execute

  3. 试试故意删掉 title 字段再提交 → 收到精确的 422 报错:

    {
    "detail": [{
    "type": "missing",
    "loc": ["body", "title"],
    "msg": "Field required"
    }]
    }

📌 体会这个威力:你只声明了数据的"形状",所有校验代码都是零行。 这就是为什么声明式框架在工程上完胜手工校验。

🎚️ 字段约束:让规矩更细

光有类型不够,业务规则更常见:

复制代码
from pydantic import BaseModel, Field


class TicketCreate(BaseModel):
    title: str = Field(min_length=2, max_length=100)
    priority: str = Field(default="中", pattern="^(高|中|低)$")
    description: str = ""
    reporter: str = Field(min_length=1)
约束 效果
min_length / max_length 字符串长度区间
pattern 必须匹配正则(这里只允许 高/中/低)
default 默认值
gt / lt / ge / le 数字的 > / < / ≥ / ≤

数字字段的例子(设备上报场景):

复制代码
class SensorReading(BaseModel):
    device_id: str
    temperature: float = Field(ge=-50, le=200)     # 合理温度区间
    vibration: float = Field(default=0, ge=0)

传个 temperature: 999 → 422 直接打回。昨天用 raise ValueError 手工防的范围问题,今天在门口就被框架拦住了。

这块确实硬核,一次消化不了正常,先记住"Field 是加约束的地方",实际用到时回来看这张表。

📤 响应模型:控制"吐回去"的数据

接口返回也应该有规矩。两个典型诉求:

  1. 别泄露内部字段:数据库里的内部备注、处理人手机号不能往外吐

  2. 保证输出格式稳定:调用方依赖你的字段结构,不能时有时无

response_model 解决:

复制代码
class TicketResponse(BaseModel):
    id: int
    title: str
    priority: str
    status: str
    reporter: str
    # 故意不声明内部字段,即使数据里有也不会返回


@app.get("/tickets/{ticket_id}", response_model=TicketResponse)
def get_ticket(ticket_id: int):
    for t in TICKETS:
        if t["id"] == ticket_id:
            return t            # 内部字段会被自动过滤
    raise HTTPException(status_code=404, detail="工单不存在")

请求模型 vs 响应模型,为什么分开定义?

  TicketCreate(进门安检):不要 id(系统生成)、不要 status(默认待处理)
  TicketResponse(出门着装):要有 id 和 status,但不暴露内部字段

  进和出的规矩不一样,所以是两个模型。

🛠️ FDE 实战:工单管理完整 CRUD

新建 tickets_api/main.py,一次写全五个接口:

复制代码
"""工单管理 API:完整 CRUD(内存存储版)"""
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI(title="FDE 工单系统 API", version="1.0.0")


# ---------- 数据模型(契约层)----------
class TicketCreate(BaseModel):
    title: str = Field(min_length=2, max_length=100)
    priority: str = Field(default="中", pattern="^(高|中|低)$")
    description: str = ""
    reporter: str = Field(min_length=1)


class TicketUpdate(BaseModel):
    # 更新时全部可选------传啥改啥(PATCH 语义)
    title: str | None = Field(default=None, min_length=2, max_length=100)
    priority: str | None = Field(default=None, pattern="^(高|中|低)$")
    status: str | None = Field(default=None,
                               pattern="^(待处理|处理中|已解决|已关闭)$")
    description: str | None = None


class TicketResponse(BaseModel):
    id: int
    title: str
    priority: str
    status: str
    description: str
    reporter: str


# ---------- 内存"数据库"(下周换成 MySQL)----------
TICKETS: list[dict] = [
    {"id": 1, "title": "注塑机A3温度报警", "priority": "高",
     "status": "处理中", "description": "温度91℃", "reporter": "赵工"},
    {"id": 2, "title": "冲压机B1例行保养", "priority": "低",
     "status": "已解决", "description": "已加注润滑油", "reporter": "李工"},
]


# ---------- CRUD 接口 ----------

# Create:新增
@app.post("/tickets", response_model=TicketResponse, status_code=201,
          tags=["工单"])
def create_ticket(payload: TicketCreate):
    new_id = max((t["id"] for t in TICKETS), default=0) + 1
    record = {"id": new_id, "status": "待处理",
              "description": "", **payload.model_dump()}
    TICKETS.append(record)
    return record


# Read:列表(带状态筛选)
@app.get("/tickets", tags=["工单"])
def list_tickets(status: str | None = None,
                 priority: str | None = None):
    result = TICKETS
    if status:
        result = [t for t in result if t["status"] == status]
    if priority:
        result = [t for t in result if t["priority"] == priority]
    return {"count": len(result), "items": result}


# Read:单条
@app.get("/tickets/{ticket_id}", response_model=TicketResponse, tags=["工单"])
def get_ticket(ticket_id: int):
    for t in TICKETS:
        if t["id"] == ticket_id:
            return t
    raise HTTPException(404, detail=f"工单 {ticket_id} 不存在")


# Update:修改(传啥改啥)
@app.put("/tickets/{ticket_id}", response_model=TicketResponse, tags=["工单"])
def update_ticket(ticket_id: int, payload: TicketUpdate):
    for t in TICKETS:
        if t["id"] == ticket_id:
            changes = payload.model_dump(exclude_unset=True)
            # exclude_unset=True:只有调用方真正传了的字段才在字典里
            t.update(changes)
            return t
    raise HTTPException(404, detail=f"工单 {ticket_id} 不存在")


# Delete:删除
@app.delete("/tickets/{ticket_id}", tags=["工单"])
def delete_ticket(ticket_id: int):
    for i, t in enumerate(TICKETS):
        if t["id"] == ticket_id:
            TICKETS.pop(i)
            return {"message": f"工单 {ticket_id} 已删除"}
    raise HTTPException(404, detail=f"工单 {ticket_id} 不存在")

全链路验收(在 /docs 里按顺序点)

步骤 操作 预期
1 GET /tickets 返回 2 条种子数据
2 POST /tickets 传合法工单 201,id 自动变 3,status 默认"待处理"
3 POST 时 priority 传 "特急" 422,正则校验拦下
4 POST 时只传 reporter 422,提示 title 必填
5 GET /tickets/3 看到刚创建的工单
6 PUT /tickets/3 只传 {"status": "处理中"} 只改状态,其他字段不变
7 GET /tickets?status=处理中 筛选生效
8 DELETE /tickets/3 删除成功
9 GET /tickets/3 404

💡 特别体会第 6 步的 exclude_unset=True:没有它,调用方没传的字段会以 None 覆盖原数据,工单标题瞬间消失。这个小参数是"部分更新"接口的灵魂。

注意一个经典坑:路径要排顺序

如果同时有 /tickets/{ticket_id}/tickets/stats 这种固定路径,固定路径要写在动态路径前面 ,否则 stats 会被当成 {ticket_id} 去匹配。现在先记住,以后踩了不慌。

🏗️ 把昨天学的工程化用上

单文件教学方便看,真实项目应该按 Day 26 的方式分层。你的工单项目推荐长这样:

复制代码
tickets_api/
├── main.py              # 入口:创建 app、挂载路由
├── models.py            # Pydantic 模型(数据契约)
├── services.py          # 业务逻辑(CRUD 操作数据的函数)
├── data_store.py        # 数据存取(现在是内存,下周换 MySQL)
└── exceptions.py        # 自定义异常(可选)

分层的核心收益:接口层只负责收发,业务逻辑在 services 里,数据在 data_store 里 。下周把内存存储换成 SQL 数据库时,只改 data_store.py 一个文件------接口和业务逻辑毫发无伤。这就是 Day 26"职责分离"在 Web 项目里的延续。

一个示意(不用全敲,理解结构):

复制代码
# services.py ------ 业务逻辑不碰 FastAPI,纯 Python,可单独测试
def create_ticket(payload: dict) -> dict:
    new_id = ...
    record = ...
    return record

# main.py ------ 薄薄一层,只做 HTTP 翻译
@app.post("/tickets", status_code=201)
def create_ticket(payload: TicketCreate):
    return ticket_service.create_ticket(payload.model_dump())

📌 记住这个判断标准:如果你把 FastAPI 换成别的框架,业务逻辑应该基本不用改。 业务逻辑和框架绑死,是新手项目最常见的架构问题。明天的 pytest 练习里,你会尝到"逻辑层独立"带来的测试便利。

📝 本课小结

知识点 一句话记住
请求体 POST/PUT 发来的 JSON,用 Pydantic 模型接收
BaseModel 继承它定义数据形状,自动校验+文档+补全
Field 加约束:长度、正则、范围、默认值
model_dump() 模型转字典(v1 老写法 .dict()
response_model 控制输出字段,防泄露、稳格式
201 / 404 创建成功 201,资源不存在 404
exclude_unset 部分更新时只改调用方真正传了的字段
CRUD POST 增 / GET 查 / PUT 改 / DELETE 删
分层 接口层 / 业务层 / 数据层各管各的

💡 今天最核心的认知 :Pydantic 模型是接口的契约------进门按 Create 模型安检,出门按 Response 模型着装。契约写清楚了,前后端(或两个系统)就能并行开发、互不猜疑。FDE 对接客户 ERP/MES 时,定义清楚数据契约永远是开工第一件事。


📋 课后练习

  1. 加字段 :给工单模型增加 assignee(处理人,可选,默认空字符串)和 tags(字符串列表,默认空列表,提示:list[str] = [])。更新响应模型并走一遍完整 CRUD。

  2. 统计接口 :实现 GET /tickets/stats/summary,返回各状态工单数(用 Day 24 的字典计数模式)。注意路径顺序问题------把它放在 {ticket_id} 路由前面,思考为什么。

  3. 架构拆分(选做) :把今天的单文件版本拆成 main.py + models.py + services.py 三个文件,服务行为保持不变。拆完在 /docs 验证功能完全一致。


💭 今日反思

  1. "数据契约先行"这个思路,跟你做实施时的哪份文档最像?(接口确认单?数据字段映射表?)

  2. 今天的 5 个接口里,哪个的边界情况最多(比如更新不存在的工单、删除已删除的工单)?把你能想到的异常输入列一个清单------这就是明天写测试的素材。


明天预告 :接口写完了,怎么证明它一直是对的?总不能每次上线前手动点 20 遍。明天学 pytest 单元测试 (让代码自动验证代码)、类型注解 (让 Bug 在敲代码时就暴露)、配置管理 (.env 文件管理环境差异)和 requirements.txt(依赖清单)。学完明天,你的项目就达到了"可交付、可维护"的门槛,第二周圆满收官!


FDE 学习系列教程 · 第二阶段 · 第 2 周 Day 4 · 完

相关推荐
gCode Teacher 格码致知1 小时前
Pandas教学-6:coerce详解
python·pandas
梦在远山后2 小时前
Electron 与 FastAPI 如何完成流式 Agent 对话
python·langchain·agent
时速GEO系统2 小时前
初元 AI V1.1 版本升级,首个正式版发布一周・实现生成、部署、上线、优化全流程自动化闭环
人工智能·数据挖掘·node.js
大衛說2 小时前
12 · 文件 I/O 与序列化
python
咕泡科技2 小时前
咕泡科技FDE系列最新产品重磅发布!
人工智能·大模型·ai落地·fde·前沿部署工程师
犀利豆2 小时前
为什么全世界的 AI 都画不好一只骑自行车的鹈鹕
人工智能·llm·aigc
小静AI工程实验室2 小时前
Python 爬虫中文乱码排查:严格解码、UTF-8 BOM 与 JSON 转义的 12 项实验
字符编码·爬虫·python
天天被压力2 小时前
【跨市场数据实战 #08】可转债折价机会怎么筛:3个接口抓比价、列表和实时盘口
java·人工智能·python
weixin199701080162 小时前
[特殊字符]️《从0到1搭多平台二手ERP中台:闲鱼+淘宝+京东+拼多多+Mercari统一调度》(附Python源码)
python