前端手摸手跑路之 AI 应用开发(三)

FastAPI 数据校验与分层

前一阶段已经打通 Vue 到 FastAPI 的请求链路,但后端仍然直接使用 dict 接收用户数据

这种写法能演示 HTTP,却不能准确表达接口需要哪些字段,也无法自动限制用户名和昵称的长度

这一阶段也只干两件事:先用 Pydantic 建立请求与响应边界,再把单文件代码拆成 Router、Schema 和 Service

最终希望得到的不是更多目录,而是一条职责清晰的数据流:

text 复制代码
HTTP JSON
→ Pydantic 请求校验
→ Router 处理 HTTP
→ Service 执行业务规则
→ Pydantic 响应处理
→ HTTP JSON

一、裸 dict 的问题

最初的创建用户接口如下:

python 复制代码
@app.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user(user: dict):
    return {
        "id": 1,
        "username": user["username"],
        "display_name": user["display_name"],
    }

user: dict 只能说明请求体应该是一个字典,无法表达更具体的约束:

text 复制代码
username 是否必填
username 是否必须是字符串
username 允许多长
display_name 能否为空
请求失败时应该返回什么

如果客户端缺少字段,代码可能在执行:

python 复制代码
user["username"]

时才抛出异常

这意味着无效数据已经进入路由函数,错误也不再是清晰的字段校验结果

二、Pydantic 负责运行时数据校验

前端已经定义 TypeScript 类型:

ts 复制代码
export type UserCreate = {
  username: string
  display_name: string
}

但 TypeScript 类型不会随着 HTTP 请求发送到服务器

网络中只有 JSON,任何客户端都可以绕过 Vue,直接向 FastAPI 发送数据

因此后端必须重新校验实际收到的内容

Pydantic 模型正是后端的运行时数据边界

text 复制代码
TypeScript
→ 前端开发和编译阶段检查

Pydantic
→ 后端运行时解析和校验

三、建立 Schema 目录

后端新增:

text 复制代码
app/
└─ schemas/
   ├─ __init__.py
   └─ users.py

使用 Git Bash 创建:

bash 复制代码
cd /d/code/ai-workspace-rebuild/backend  # 进入后端项目根目录

mkdir -p app/schemas  # 创建 schemas 目录,父目录不存在时一起创建

touch app/schemas/__init__.py  # 将 schemas 明确标记为 Python 包

touch app/schemas/users.py  # 创建用户 Schema 模块

schemas 只描述应用边界的数据结构,不负责数据库操作,也不实现用户名重复等业务规则

四、定义请求模型 UserCreate

app/schemas/users.py 中定义:

python 复制代码
from pydantic import BaseModel, Field


class UserCreate(BaseModel):
    username: str = Field(min_length=3, max_length=20)
    display_name: str = Field(min_length=1, max_length=50)

字段声明可以拆成三个部分:

python 复制代码
username: str = Field(min_length=3, max_length=20)
text 复制代码
username
→ 字段名

: str
→ Python 类型注解,声明字段值应该是字符串

= Field(...)
→ 增加运行时校验规则

五、user: UserCreate 首先给谁使用

路由参数改为:

python 复制代码
def create_user(user_data: UserCreate):

这个类型注解当然能帮助编辑器补全,但首先使用它的是 FastAPI 和 Pydantic

请求进入时会经历:

text 复制代码
读取 Request Body
→ 解析 JSON
→ 根据 UserCreate 校验字段
→ 创建 UserCreate 对象
→ 校验成功后调用路由函数

路由函数收到的不再是普通字典,而是 UserCreate 实例

因此字段访问方式从:

python 复制代码
user_data["username"]

改为:

python 复制代码
user_data.username

六、为什么校验失败返回 422

合法请求:

json 复制代码
{
  "username": "alice",
  "display_name": "Alice"
}

会通过 Pydantic 校验并进入路由函数

如果用户名只有两个字符:

json 复制代码
{
  "username": "ab",
  "display_name": "Alice"
}

会违反:

python 复制代码
Field(min_length=3, max_length=20)

FastAPI 自动返回:

http 复制代码
HTTP/1.1 422 Unprocessable Entity

错误响应大致如下:

json 复制代码
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["body", "username"],
      "msg": "String should have at least 3 characters"
    }
  ]
}

几个关键字段:

text 复制代码
loc
→ 错误位于请求体的 username 字段

msg
→ 给开发者阅读的错误说明

type
→ 程序可以识别的错误类型

最重要的边界是:422 通常发生在路由函数执行之前

text 复制代码
请求 JSON
→ Pydantic 校验失败
→ FastAPI 返回 422
→ create_user() 没有执行

七、请求模型与响应模型不能混用

创建请求和创建结果方向不同:

text 复制代码
UserCreate
→ Client → Server

UserResponse
→ Server → Client

响应模型定义为:

python 复制代码
class UserResponse(BaseModel):
    id: int
    username: str
    display_name: str

请求中没有 id,因为资源尚未创建

响应中带有 id,因为服务端已经生成资源标识

未来请求可能包含密码,响应模型则绝不能包含密码或密码哈希

即使两个模型目前字段相似,也应该按数据方向分开

八、response_model 约束响应方向

路由声明:

python 复制代码
@router.post(
    "",
    response_model=UserResponse,
    status_code=status.HTTP_201_CREATED,
)

response_model=UserResponse 表示 FastAPI 会在返回响应前按 UserResponse 处理数据

执行顺序:

text 复制代码
路由或 Service 返回 Python 对象
→ UserResponse 校验
→ 按响应模型序列化
→ 生成 JSON 响应

如果原始返回值中存在响应模型没有声明的额外字段,默认情况下不会把这些字段继续返回给客户端

这可以降低内部字段意外泄漏的风险,但不能代替正确的数据设计

不要先把密码加入返回对象,再期待 response_model 永远替业务代码兜底

九、为什么要拆分代码

功能少时,所有代码放在 main.py 仍然能运行

随着接口增加,单文件会同时承担:

  • 创建 FastAPI 应用
  • 配置 CORS
  • 声明 URL 和 HTTP 方法
  • 校验数据
  • 判断业务规则
  • 保存数据
  • 生成响应

问题不是文件行数本身,而是不同变化原因混在一起

例如修改 CORS、修改用户名规则和更换数据库,本来是三类不同工作,不应该长期集中在同一个函数中

当前拆成:

text 复制代码
Router
→ HTTP

Schema
→ 数据边界

Service
→ 业务规则

Repository 和数据库会在后续阶段加入

十、Service 负责业务规则

创建目录:

bash 复制代码
cd /d/code/ai-workspace-rebuild/backend  # 进入后端项目根目录

mkdir -p app/services  # 创建业务层目录

touch app/services/__init__.py  # 将 services 标记为 Python 包

touch app/services/users.py  # 创建用户业务模块

当前 Service 使用内存列表保存用户:

python 复制代码
from app.schemas.users import UserCreate, UserResponse


_users: list[UserResponse] = []


def create_user(user_data: UserCreate) -> UserResponse:
    username_exists = any(
        existing_user.username == user_data.username
        for existing_user in _users
    )

    if username_exists:
        raise ValueError("Username already exists")

    created_user = UserResponse(
        id=len(_users) + 1,
        username=user_data.username,
        display_name=user_data.display_name,
    )

    _users.append(created_user)

    return created_user

为什么变量叫 _users

开头的 _ 表示模块内部状态,不希望其他模块直接访问和修改

这是 Python 命名约定,不是强制的私有权限

为什么参数叫 user_data

user_data 表示客户端传入的创建数据,不是已经持久化的用户

后面接入 ORM 后,代码中会同时出现请求模型和数据库模型,明确命名能减少混淆

any() 在表达什么

python 复制代码
username_exists = any(
    existing_user.username == user_data.username
    for existing_user in _users
)

它逐个检查已有用户,只要找到一个相同用户名就返回 True

代码表达的不是如何循环,而是是否存在重复用户名

Service 中不应该出现什么

当前 Service 不导入:

text 复制代码
FastAPI
HTTPException
HTTP 状态码
Request
Response

因为用户名不能重复是业务规则,不属于 HTTP 协议

同一个 Service 将来可能被接口、命令行任务或 Agent Tool 复用

十一、422 与 409 的区别

用户名太短时,数据格式不符合请求模型:

text 复制代码
Pydantic 校验失败
→ 路由不执行
→ 返回 422

用户名长度合法但已经存在时,数据格式没有问题,只是与当前业务状态冲突:

text 复制代码
Pydantic 校验通过
→ Router 调用 Service
→ Service 发现用户名重复
→ 抛出业务错误
→ Router 映射成 409

两者可以概括为:

text 复制代码
422
→ 这份输入本身不符合接口结构

409
→ 输入结构合法,但与现有业务状态冲突

十二、Router 把业务错误翻译成 HTTP

Router 调用 Service:

python 复制代码
try:
    return create_user_service(user_data)
except ValueError as exc:
    raise HTTPException(
        status_code=status.HTTP_409_CONFLICT,
        detail=str(exc),
    ) from exc

Service 只表达:

text 复制代码
Username already exists

Router 决定它在 HTTP 接口中应该表现为:

http 复制代码
HTTP/1.1 409 Conflict
json 复制代码
{
  "detail": "Username already exists"
}

status 与具体状态码

下面的 status 是一个模块:

python 复制代码
from fastapi import status

不能直接写:

python 复制代码
HTTPException(status_code=status)

status_code 需要一个具体整数,应该使用:

python 复制代码
status.HTTP_409_CONFLICT

命名常量比直接写 409 更容易看懂

from exc 的作用

python 复制代码
raise HTTPException(...) from exc

它表示新的 HTTP 异常由原来的业务异常引起

日志和 traceback 因此能够保留完整的异常因果关系

十三、使用 APIRouter 拆分 HTTP 层

创建 Router 文件:

bash 复制代码
cd /d/code/ai-workspace-rebuild/backend  # 进入后端项目根目录

mkdir -p app/routers  # 创建路由目录

touch app/routers/__init__.py  # 将 routers 标记为 Python 包

touch app/routers/users.py  # 创建用户路由模块

APIRouter 是一组相关路由的集合,不是第二个 FastAPI 应用

python 复制代码
router = APIRouter(
    prefix="/users",
    tags=["users"],
)

参数含义:

text 复制代码
prefix="/users"
→ 所有用户路由共享 /users 前缀

tags=["users"]
→ Swagger 中将接口归入 users 分组

创建路由使用空路径:

python 复制代码
@router.post(
    "",
    response_model=UserResponse,
    status_code=status.HTTP_201_CREATED,
)

最终地址来自拼接:

text 复制代码
prefix="/users"
+
path=""
=
/users

十四、main.py 只负责组装应用

拆分后,app/main.py 不再直接导入用户 Schema 和 Service

python 复制代码
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.routers.users import router as users_router

app = FastAPI(title="AI workspace")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/health")
async def health():
    return {"status": "ok", "service": "backend"}


app.include_router(users_router)

router 在自己的模块中使用通用名称,导入主应用时改名为 users_router

python 复制代码
from app.routers.users import router as users_router

这样 main.py 将来同时注册多个 Router 时仍然清楚:

python 复制代码
app.include_router(users_router)

十五、当前完整用户 Router

python 复制代码
from fastapi import APIRouter, HTTPException, status

from app.schemas.users import UserCreate, UserResponse
from app.services.users import create_user as create_user_service

router = APIRouter(
    prefix="/users",
    tags=["users"],
)


@router.post(
    "",
    response_model=UserResponse,
    status_code=status.HTTP_201_CREATED,
)
def create_user(user_data: UserCreate) -> UserResponse:
    try:
        return create_user_service(user_data)
    except ValueError as exc:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=str(exc),
        ) from exc

这里使用同步 def,因为当前 Service 只操作普通 Python 列表,内部没有需要 await 的异步 I/O

FastAPI 同时支持同步和异步路由

text 复制代码
内部需要 await 调用异步 I/O
→ async def

同步数据库驱动、同步文件操作或普通同步流程
→ def

十六、最终项目结构

text 复制代码
backend/app/
├─ main.py
├─ routers/
│  ├─ __init__.py
│  └─ users.py
├─ schemas/
│  ├─ __init__.py
│  └─ users.py
└─ services/
   ├─ __init__.py
   └─ users.py

职责关系:

text 复制代码
main.py
→ 创建并组装 FastAPI 应用

schemas/users.py
→ 定义用户请求和响应的数据边界

routers/users.py
→ 处理路径、方法、状态码和异常映射

services/users.py
→ 处理用户名重复和创建用户的业务规则

十七、最终请求链路

合法用户第一次提交:

text 复制代码
POST /users
→ UserCreate 校验通过
→ Router 调用 Service
→ Service 检查用户名不重复
→ 创建 UserResponse
→ Router 返回结果
→ response_model 处理响应
→ HTTP 201

用户名长度不合法:

text 复制代码
POST /users
→ UserCreate 校验失败
→ Router 不执行
→ HTTP 422

合法用户名重复提交:

text 复制代码
POST /users
→ UserCreate 校验通过
→ Router 调用 Service
→ Service 发现用户名重复
→ ValueError
→ Router 转换为 HTTPException
→ HTTP 409

阶段结果

分层已经建立,但当前仍然是内存版本:

  • 用户保存在模块级 Python 列表中
  • FastAPI 重启后用户数据消失
  • 多个进程之间不能共享这份列表
  • id=len(_users)+1 不适合支持删除后的正式系统
  • ValueError 过于通用,后续可换成专门的业务异常
  • 还没有 Repository 数据访问层
  • 还没有 PostgreSQL、SQLAlchemy 和数据库唯一约束
  • 前端仍只显示简单错误,没有解析 422 字段详情
  • 前端响应仍使用 as User,没有运行时结构校验

后续逐步完善,下一阶段先搭建 Docker Compose 与 PostgreSQL,并通过删除、重建容器验证 named volume 的持久化能力

相关推荐
胡写代码1 小时前
若依 + MyBatis-Plus,分页为什么悄悄失效了?
java·后端
计算机魔术师1 小时前
OpenAI 宣称破解千禧年难题,数学家却说是「反例搜索」?
前端
志尊宝1 小时前
Vue3 零基础每日笔记(015):条件渲染 v-if 与 v-show——控制 DOM 生死还是控制显隐
前端·vue.js·笔记
掘金者阿豪1 小时前
OceanBase 和金仓怎么选?别只看分布式,复杂查询更考验架构取舍
前端·后端·架构
Bug修理工Bubble1 小时前
Swift ARC:一个对象到底什么时候才会被释放?
前端
cjz38991 小时前
Redis + Lua 实现秒杀优惠券 IP + 用户双维度令牌桶限流
后端
ClouGence1 小时前
GPT-6 做 UI 自动化测试:Demo 惊艳,但真的适合长期回归吗?
前端·chatgpt·测试
Shinomiya1 小时前
虚拟地址空间:从“地址一样但物理内存不同”说起
后端
孙启超1 小时前
【AI开发之Rust】第 1 课:认识 Rust 与开发环境 —— 装好工具链,跑通第一个程序
开发语言·人工智能·后端·ai·rust·安全架构