案例背景:我们要做什么?
假设你接到一个需求:实现一组用户CRUD接口,并满足以下要求:
- 所有成功/失败响应格式统一,前端只需写一套解析逻辑
- 绝不能把数据库里的
hashed_password返回给前端 - 创建用户返回
201,删除成功返回204 - 用户列表接口要在响应头里带上总条数
X-Total-Count - 支持文件下载、实时日志推送、页面重定向
- 404、参数校验失败、业务规则冲突等错误,格式也要和成功体一致
如果你只会返回字典,代码大概长这样:
python
@app.get("/users/1")
def get_user():
return {"id": 1, "name": "Alice", "hashed_password": "secret"}
@app.post("/users")
def create_user():
return {"ok": True} # 状态码还是 200,格式也和查询接口不一样
本文将进行从零搭建app/目录下的完整案例,一步步替换掉这些能跑但不规范的写法。
项目结构
推荐创建一个和我的demo一样的目录
bash
app/
├── main.py # 入口,注册路由、异常处理器
├── core/
│ ├── response.py # 统一返回体 ApiResponse
│ ├── exceptions.py # 业务异常 BusinessException
│ └── handlers.py # 全局异常处理
├── schemas/
│ └── user.py # 用户数据模型
├── routers/
│ ├── users.py # 用户CRUD(本文重点)
│ ├── files.py # 文件下载
│ └── logs.py # 流式日志
└── static/files/sample.txt # 下载用的示例文件
一.定义数据模型,解决密码泄露问题
案例场景:数据库存的是完整的用户记录包含密码哈希,但API只能返回公开字段。 案例开始,先定义三套模型:
python
class UserDB(BaseModel):
"""模拟数据库记录,只在服务端内部使用"""
id: int
name: str
email: EmailStr
hashed_password: str
class UserOut(BaseModel):
"""对外 API 出参,不含密码"""
id: int
name: str
email: EmailStr
class UserCreate(BaseModel):
"""创建用户的入参"""
name: str = Field(..., min_length=1, max_length=50)
email: EmailStr
这是自定义数据模型DB模型与API模型分离,从设计上防止敏感字段流出。 值得注意的是使用Pydanticv2进行模型互转的时候从UserDB转成UserOut不能写成:
python
UserOut.model_validate(user_db)
必须先把模型转换成字典
python
def to_user_out(userL UserDB) -> UserOut:
return UserOut.model_validate(user.model_dump())
二.返回统一的返回体
前端通常需要返回接口格式统一,例如:
json
{
"code": 0,
"msg": "查询成功",
"data": {...}
}
可以实现返回格式的统一,定义如下:
python
class ApiResponse(BaseModel, Generic[T]):
code: int = 0
msg: str = "success"
data: T | None = None
def success(*, data=None, msg="success", code=0)
return ApiResponse(code=code, msg=msg, data=data)
为什么要使用泛型ApiResponse[T]?是因为在ApiResponse[UserOut]时,IDE和Swagger都知道data里是用户对象,写ApiResponse[list[UserOut]]时,data是用户列表。 后面所有用户接口的成功返回都通过return success(data=..., msg="...")完成。
三.查询单个用户response_model与自动序列化
举例:
python
@router.get("/{user_id}", response_model=ApiResponse[UserOut])
def get_user(user_id: int) -> ApiResponse[UserOut]:
if user_id <= 0:
raise HTTPException(status_code=404, detail="用户不存在")
user = _FAKE_USERS.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return success(data=to_user_out(user), msg="查询成功")
这里体现了自动类型序列化的核心机制
| 写的 | FastAPI 做的 |
|---|---|
response_model=ApiResponse[UserOut] |
按模型过滤、校验、序列化 JSON |
return success(data=to_user_out(user)) |
把Python对象转成符合契约的响应 |
to_user_out(user) |
确保data里只有 id/name/email |
| 运行结果如图所示: |
在上面的返回中hashed_password从未出现在响应中,这就是response_model+独立出参模型的价值。
四.查询用户列表-响应头与列表序列化
在下面的案例中除了返回用户列表,还需要在响应头告诉前端一共有多少条记录。
python
@router.get("", response_model=ApiResponse[list[UserOut]])
def list_users(response: Response) -> ApiResponse[list[UserOut]]:
users = [to_user_out(u) for u in _FAKE_USERS.values()]
response.headers["X-Total-Count"] = str(len(users))
return success(data=users, msg="查询成功")
这里用到了自定义响应头的第二种写法:注入Response对象,动态设置 header,下面是接口的返回结果,如图所示:
除了JSON响应体,响应头会有X-Total-Count:2
五.两种精简字段的写法
有些页面只需要id和name,不想暴露email,可以参考下面的两种写法:
python
@router.get("/{user_id}/brief", response_model=ApiResponse[UserBrief])
def get_user_brief(user_id: int):
...
return success(data=UserBrief(id=user.id, name=user.name), msg="查询成功")
上面的第一种写法UserBrief只定义了id与name,Swagger文档一目了然。 第二种写法是采用response_model_exclude排除字段:
python
@router.get(
"/{user_id}/exclude-demo",
response_model=UserOut,
response_model_exclude={"email"},
)
def get_user_exclude_email(user_id: int) -> UserOut:
...
return UserOut.model_validate(user.model_dump())
这个接口没有在ApiResponse里,直接返回UserOut,但排除了email字段。返回案例如图所示:
其中exclude参数还可以按照下表进行设置:
| 参数 | 作用 |
|---|---|
response_model_include |
白名单,只保留指定字段 |
response_model_exclude |
黑名单,排除指定字段 |
response_model_exclude_unset |
排除未赋值的字段 |
response_model_exclude_none |
排除值为None的字段 |
如果是需要长期维护接口,有限使用写法1。
六.创建用户与删除用户代码
创建资源成功,HTTP状态码应该是201,而不是默认的200,例如:
python
@router.post("", response_model=ApiResponse[UserOut], status_code=status.HTTP_201_CREATED)
def create_user(payload: UserCreate) -> ApiResponse[UserOut]:
...
return success(data=to_user_out(user), msg="创建成功")
推荐使用状态码枚举,例如:
python
from starlette import status
status.HTTP_200_OK # 查询成功
status.HTTP_201_CREATED # 创建成功
status.HTTP_204_NO_CONTENT # 删除成功,无响应体
status.HTTP_404_NOT_FOUND # 资源不存在
用枚举而不是裸写201,IDE有自动补全,也不容易写错,上面代码响应案例如图所示:
在删除成功时,通常不需要返回JSON body,例如:
python
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int) -> None:
if user_id not in _FAKE_USERS:
raise HTTPException(status_code=404, detail="用户不存在")
del _FAKE_USERS[user_id]
# 不需要 return 任何内容
七. 抛出异常
查询不存在的用户,不应该return {"code": 404, ...},而是直接抛异常,让全局处理器统一格式化。
python
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
八.业务异常
禁止注册特定邮箱,例如@blocked.com 域名的邮箱不允许注册。这是业务规则错误,不是HTTP层面的404。
python
class BusinessException(Exception):
def __init__(self, code: int, msg: str, http_status: int = 400):
self.code = code
self.msg = msg
self.http_status = http_status
在创建用户路由中使用:
python
if payload.email.endswith("@blocked.com"):
raise BusinessException(code=40001, msg="该邮箱域名已被禁止注册", http_status=403)
其返回示例为: 
九.全局异常处理
不管哪里出错,前端收到的格式都一样。 示例代码为:
python
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(BusinessException)
async def business_exception_handler(_, exc):
return JSONResponse(
status_code=exc.http_status,
content={"code": exc.code, "msg": exc.msg, "data": None},
)
@app.exception_handler(HTTPException)
async def http_exception_handler(_, exc):
return JSONResponse(
status_code=exc.status_code,
content={"code": exc.status_code, "msg": str(exc.detail), "data": None},
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(_, exc):
return JSONResponse(
status_code=422,
content={"code": 422, "msg": "参数校验失败", "data": exc.errors()},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(_, exc):
return JSONResponse(
status_code=500,
content={"code": 500, "msg": "服务器内部错误", "data": str(exc)},
)
需要在main.py中进行注册
python
register_exception_handlers(app)
实现的效果如下表所示:
| 触发方式 | HTTP 状态码 | 响应体 |
|---|---|---|
/api/users/-1获取不存在的用户 |
404 | {"code":404,"msg":"用户不存在","data":null} |
/api/users 传空name |
422 | {"code":422,"msg":"参数校验失败","data":[...]} |
注册@blocked.com邮箱 |
403 | {"code":40001,"msg":"该邮箱域名已被禁止注册","data":null} |
| 代码未捕获的异常 | 500 | {"code":500,"msg":"服务器内部错误","data":"..."} |
现在回头看第七节的404错误,已经被自动包装成统一格式,前端无需为每种错误写不同解析逻辑。
十.文件下载
提供一个示例文件供用户下载。这类响应不是JSON,不能用ApiResponse包装,提供下面的示例代码:
python
@router.get("/download")
def download_sample() -> FileResponse:
file_path = _FILES_DIR / "sample.txt"
return FileResponse(
path=file_path,
filename="示例文档.txt",
media_type="text/plain; charset=utf-8",
)
浏览器访问时,会弹出另存为对话框。filename参数决定了下载时显示的文件名。
十一.实时日志推送
运维页面需要实时看到服务日志,类似SSE(Server-Sent Events),代码示例为:
python
async def _log_event_generator():
lines = ["[INFO] 服务启动中...", "[INFO] 加载配置完成", ...]
for line in lines:
await asyncio.sleep(0.8)
yield f"data: {line}\n\n" # SSE 格式
yield "data: [DONE]\n\n"
@router.get("/stream")
async def stream_logs() -> StreamingResponse:
return StreamingResponse(
_log_event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
你会看到日志每隔0.8秒逐行输出,而不是等全部生成完才返回。这就是流式响应,适合大文件分块传输、实时推送、AI逐字输出等场景,响应效果如下: 
十二.页面重定向
例旧地址/api/redirect/demo需要跳转到Swagger文档页,示例代码:
python
@app.get("/api/redirect/demo", status_code=status.HTTP_302_FOUND)
def redirect_demo() -> RedirectResponse:
return RedirectResponse(url="/docs", status_code=status.HTTP_302_FOUND)
进行重定向需要根据情况,如下表所示:
| 状态码 | 语义 |
|---|---|
| 301 | 永久重定向 |
| 302 | 临时重定向 |
| 307 | 临时重定向,保持原始请求方法 |
响应头中会出现 Location: /docs,浏览器会自动跳转,如图所示: 
总结
回顾文章,从最简单的return {"key": "value"}出发,逐步构建了:
- 数据模型分层 (UserDB/UserOut/
UserBrief)------ 从设计上保护敏感字段 - 统一返回体 (ApiResponseT+
success())------ 前端一套解析逻辑 response_model自动序列化 ------ 响应契约+Swagger文档自动生成- 正确的 HTTP 状态码 ------201创建204删除、4xx 错误
- 响应头 ------
X-Total-Count传递元信息 - 专用 Response 类 ------文件下载、流式推送、重定向各有其类
- 异常驱动错误处理 ------
HTTPException/BusinessException+全局handler 统一格式
最终,接口不再是能跑就行,而是具备了生产环境应有的响应规范。