📚前言
📒FDE系列内容总纲:
🚄前置课程列表:
阶段一:
【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 里测试:
-
找到
POST /tickets→ Try it out -
请求体模板已经自动生成好,改改值 → Execute
-
试试故意删掉 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 是加约束的地方",实际用到时回来看这张表。
📤 响应模型:控制"吐回去"的数据
接口返回也应该有规矩。两个典型诉求:
-
别泄露内部字段:数据库里的内部备注、处理人手机号不能往外吐
-
保证输出格式稳定:调用方依赖你的字段结构,不能时有时无
用 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 时,定义清楚数据契约永远是开工第一件事。
📋 课后练习
-
加字段 :给工单模型增加
assignee(处理人,可选,默认空字符串)和tags(字符串列表,默认空列表,提示:list[str] = [])。更新响应模型并走一遍完整 CRUD。 -
统计接口 :实现
GET /tickets/stats/summary,返回各状态工单数(用 Day 24 的字典计数模式)。注意路径顺序问题------把它放在{ticket_id}路由前面,思考为什么。 -
架构拆分(选做) :把今天的单文件版本拆成
main.py + models.py + services.py三个文件,服务行为保持不变。拆完在/docs验证功能完全一致。
💭 今日反思
"数据契约先行"这个思路,跟你做实施时的哪份文档最像?(接口确认单?数据字段映射表?)
今天的 5 个接口里,哪个的边界情况最多(比如更新不存在的工单、删除已删除的工单)?把你能想到的异常输入列一个清单------这就是明天写测试的素材。
明天预告 :接口写完了,怎么证明它一直是对的?总不能每次上线前手动点 20 遍。明天学 pytest 单元测试 (让代码自动验证代码)、类型注解 (让 Bug 在敲代码时就暴露)、配置管理 (.env 文件管理环境差异)和 requirements.txt(依赖清单)。学完明天,你的项目就达到了"可交付、可维护"的门槛,第二周圆满收官!
FDE 学习系列教程 · 第二阶段 · 第 2 周 Day 4 · 完