API 设计与验证
RESTful 规范 + Zod 验证——让接口既好用又安全
🎯 学习目标
学完本章,你将能够:
- 遵循 RESTful 原则设计资源化的 API 接口
- 使用 Zod 对请求数据进行类型安全验证
- 生成 OpenAPI/Swagger 文档
- 实现分页、排序和过滤功能
- 设计统一的响应格式和全局错误处理
你有没有遇到过
前端同事说"你这个接口返回的数据格式怎么又变了"?
这就是 API 设计不规范导致的。好的 API 应该像公共厕所的标识一样——不需要解释,看一眼就知道怎么用。
这一章你将学会:RESTful 设计规范、用 Zod 做请求验证、分页排序过滤、统一响应格式。
Part 1: RESTful
用 URL 表示资源,用 HTTP 方法表示操作
REST 不是框架,而是一套 API 设计的理念。它的核心思想:用 URL 表示资源,用 HTTP 方法表示操作。遵循 REST 规范的 API 天然具备可预测性——前端开发者看到 URL 就能猜到返回什么数据。这一部分将学习 HTTP 语义、状态码选择以及请求处理的完整流程。
REST 核心规则
资源 + HTTP 方法 = 语义清晰的接口
| HTTP 方法 | 路径 | 含义 |
|---|---|---|
| GET | /api/users | 获取用户列表 |
| GET | /api/users/:id | 获取单个用户 |
| POST | /api/users | 创建用户 |
| PUT | /api/users/:id | 全量更新用户 |
| PATCH | /api/users/:id | 部分更新用户 |
| DELETE | /api/users/:id | 删除用户 |
嵌套资源:GET /api/users/:id/posts 获取用户的文章,DELETE /api/posts/:id/comments/:cid 删除评论。
HTTP 状态码
用数字告诉客户端"发生了什么"
2xx 成功
- 200 OK —— 成功
- 201 Created —— 创建成功
- 204 No Content —— 删除成功
4xx 客户端错误
- 400 Bad Request —— 参数错误
- 401 Unauthorized —— 未认证
- 403 Forbidden —— 无权限
- 404 Not Found —— 资源不存在
- 409 Conflict —— 冲突
5xx 服务端错误
- 500 Internal Error —— 服务器错误
请求处理流程
一个 API 请求从进入到响应的完整路径

图:API 请求验证与处理的完整时序流程
【AI 生图提示词】请生成一张中文技术时序图,风格简洁、配色柔和、适合教学: - 画面主体:客户端发起 POST 请求后,经过 Zod 验证、路由处理、数据库操作的完整时序交互图 - 四个参与者(从左到右排列的竖线):客户端(蓝色)、Zod 验证层(橙色)、路由处理器(绿色)、数据库(紫色) - 主流程(验证失败分支):客户端 → Zod 验证层:实线箭头标注 "POST /api/users {body}",Zod 验证层 → 客户端:虚线箭头标注 "400 {error: 验证失败}",此分支用红色虚线框标注"验证失败" - 主流程(验证通过分支):客户端 → Zod 验证层:实线箭头标注 "POST /api/users {body}",Zod 验证层 → 路由处理器:实线箭头标注 "转发已验证数据",路由处理器 → 数据库:实线箭头标注 "插入数据",数据库 → 路由处理器:虚线箭头标注 "返回结果",路由处理器 → 客户端:虚线箭头标注 "201 {success, data}",此分支用绿色虚线框标注"验证通过" - 两个分支用 alt/else 结构的矩形框分组显示,左侧用括号标注 "alt 验证失败" 和 "else 验证通过" - 关键标注:请求方法 POST、路径 /api/users、状态码 400 和 201、响应字段 error/success/data - 配色建议:浅灰背景,客户端竖线蓝色,Zod 验证层橙色,路由处理器绿色,数据库紫色,失败分支用浅红底色,成功分支用浅绿底色,请求箭头用深色实线,响应箭头用虚线 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
Part 2: Zod 验证
永远不要信任客户端发来的数据
永远不要信任客户端发来的数据——用户可能绕过前端直接调用 API。Zod 是 TypeScript 生态中最好的验证库,它的核心理念是"先定义 Schema,再从 Schema 推断类型",让验证规则和类型定义永远保持同步。这一部分将学习 Schema 定义、验证中间件封装以及路径参数和查询参数的验证技巧。
定义 Schema
一个 Schema 同时完成验证和类型推断
$ npm install zod
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(2, "姓名至少2个字符").max(50),
email: z.string().email("邮箱格式不正确"),
password: z.string().min(6, "密码至少6个字符"),
role: z.enum(["user", "admin"]).default("user"),
});
// 从 Schema 推断 TS 类型——不需要手写 interface
type CreateUserInput = z.infer<typeof createUserSchema>;
// { name: string; email: string; password: string; role: "user" | "admin" }
Zod 的核心理念:Schema 是唯一的真相来源。类型从 Schema 自动推断,不会出现类型和验证规则不一致的问题。
验证中间件
把验证逻辑封装成中间件,任何路由都能复用
function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: "验证失败",
details: result.error.flatten().fieldErrors,
});
return;
}
req.body = result.data; // 替换为验证后的数据
next();
};
}
// 使用——简洁清晰
app.post("/api/users",
validate(createUserSchema),
asyncHandler(async (req, res) => {
// req.body 已经被 Zod 验证过,类型安全
const user = await userService.create(req.body);
res.status(201).json(user);
})
);
更多 Schema
路径参数、查询参数、部分更新都能验证
// 部分更新——所有字段变可选
const updateUserSchema = createUserSchema.partial();
// 路径参数验证
const idParam = z.object({ id: z.string().uuid() });
// 查询参数验证(coerce 把字符串转数字)
const paginationQuery = z.object({
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().positive().max(100).default(10),
sort: z.enum(["createdAt", "name"]).default("createdAt"),
order: z.enum(["asc", "desc"]).default("desc"),
});
z.coerce.number() 会自动把 URL 查询参数中的字符串转成数字。因为 URL 上所有值都是字符串,这个转换是必须的。
OpenAPI / Swagger 文档
好的 API 不需要口头解释,文档本身就是最好的说明
API 文档是前后端协作的桥梁。没有文档的 API 就像一个没有说明书的遥控器——按键都在,但你不知道哪个是开机。
为什么需要文档?
- 前后端并行开发,不必等接口写完
- 新成员快速上手,降低沟通成本
- 自动校验:请求/响应是否符合定义
工具链
- swagger-jsdoc —— 用 JSDoc 注释生成 OpenAPI 规范
- swagger-ui-express —— 提供可交互的 API 文档界面
Swagger 配置
在 Express 中集成自动生成的 API 文档
$ npm install swagger-jsdoc swagger-ui-express
import swaggerJSDoc from "swagger-jsdoc";
import swaggerUI from "swagger-ui-express";
const swaggerSpec = swaggerJSDoc({
definition: {
openapi: "3.0.0",
info: { title: "My API", version: "1.0.0" },
},
apis: ["./src/routes/*.js"], // 扫描路由文件中的 JSDoc 注释
});
app.use("/api-docs", swaggerUI.serve, swaggerUI.setup(swaggerSpec));
访问 http://localhost:3000/api-docs 即可看到自动生成的交互式文档页面。你可以在页面上直接发送请求测试接口。
注解示例
用 JSDoc 注释为 CRUD 端点生成文档
/**
* @openapi
* /api/users:
* post:
* summary: 创建用户
* tags: [Users]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name, email, password]
* properties:
* name:
* type: string
* example: 张三
* email:
* type: string
* example: zhang@example.com
* password:
* type: string
* example: "123456"
* responses:
* 201:
* description: 创建成功
* 400:
* description: 验证失败
*/
app.post("/api/users", validate(createUserSchema), handler);
注释写在路由上方,swagger-jsdoc 自动提取并生成 OpenAPI 规范,路由代码和文档永远在同一处,不会出现文档和代码不同步的问题。
API 版本策略
接口改了,旧客户端不能崩
URL 版本(推荐)
// 最直观、最常用
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
优点:一眼能看出版本,缓存友好。
Header 版本
// 通过请求头指定版本
app.use((req, res, next) => {
const ver = req.headers["api-version"]
|| "v1";
req.apiVersion = ver;
next();
});
优点:URL 不变。缺点:不直观,调试麻烦。
何时该版本化?
只有破坏性变更才需要新版本
| 变更类型 | 示例 | 需要版本化? |
|---|---|---|
| 新增字段 | 响应加了个 avatar | 不需要 |
| 新增可选参数 | 查询加了个 ?search | 不需要 |
| 删除字段 | 响应去掉了 nickname | 需要 |
| 改字段名 | name 改成 fullName | 需要 |
| 改行为逻辑 | 排序默认从 desc 改成 asc | 需要 |
Part 3: 分页与排序
数据量大的时候,不可能一次返回全部
数据量大的时候,不可能一次返回全部数据。分页是 API 设计的标配功能,它既减少了网络传输量,也降低了数据库查询压力。这一部分将学习基于偏移量的通用分页、过滤与搜索、游标分页以及字段选择——让你的列表 API 既灵活又高效。
通用分页
一个函数搞定所有模型的分页查询
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
async function paginate<T>(
model: any, // Prisma Model
options: { page: number; pageSize: number; where?: any; orderBy?: any }
): Promise<PaginatedResponse<T>> {
const { page, pageSize, where, orderBy } = options;
const [data, total] = await Promise.all([
model.findMany({ where, orderBy, skip: (page - 1) * pageSize, take: pageSize }),
model.count({ where }),
]);
return { data, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
}
// 使用
app.get("/api/posts", asyncHandler(async (req, res) => {
const query = paginationQuery.parse(req.query);
const result = await paginate(prisma.post, {
page: query.page,
pageSize: query.pageSize,
where: { published: true },
orderBy: { [query.sort]: query.order },
});
res.json(result);
}));
过滤与搜索
让客户端精确拿到想要的数据
列表 API 不应该只能返回全部数据。通过查询参数过滤,客户端可以精确获取所需数据:GET /api/users?status=active&role=admin。
// 过滤参数的 Zod Schema
const filterQuery = z.object({
status: z.enum(["active", "inactive"]).optional(),
role: z.enum(["user", "admin"]).optional(),
search: z.string().optional(), // 模糊搜索关键词
});
用 Zod 定义过滤参数有两个好处:类型安全——不会拼错字段名;白名单——只允许预定义的过滤条件,防止用户传入数据库中不该查的字段。
动态 Prisma where 子句
把查询参数安全地转成数据库查询条件
function buildWhere<T>(filters: Record<string, any>) {
const where: Record<string, any> = {};
// 精确匹配
if (filters.status) where.status = filters.status;
if (filters.role) where.role = filters.role;
// 模糊搜索
if (filters.search) {
where.OR = [
{ name: { contains: filters.search } },
{ email: { contains: filters.search } },
];
}
return where;
}
// 结合分页使用
app.get("/api/users", asyncHandler(async (req, res) => {
const query = paginationQuery.merge(filterQuery).parse(req.query);
const result = await paginate(prisma.user, {
page: query.page,
pageSize: query.pageSize,
where: buildWhere(query),
});
res.json(result);
}));
paginationQuery.merge(filterQuery) 可以合并多个 Zod Schema,让分页参数和过滤参数统一验证。
游标分页
offset 分页在数据量大时会变慢——游标分页解决这个问题
offset 分页的原理是 SKIP 10000 TAKE 10,数据库需要先跳过 10000 行才能返回结果。数据量越大越慢。游标分页用"上一页最后一条的 ID"作为起点,避免跳过操作。
// 请求:GET /api/posts?cursor=abc123&limit=10
async function cursorPaginate(model: any, options: {
cursor?: string; limit: number; where?: any;
}) {
const { cursor, limit, where = {} } = options;
const data = await model.findMany({
where,
take: limit + 1, // 多取一条判断是否还有下一页
...(cursor ? {
cursor: { id: cursor },
skip: 1, // 跳过 cursor 本身
} : {}),
orderBy: { id: "asc" },
});
const hasNextPage = data.length > limit;
const items = hasNextPage ? data.slice(0, -1) : data;
return {
data: items,
nextCursor: hasNextPage ? items[items.length - 1].id : null,
};
}
字段选择
客户端不需要的字段,就不应该传过去
移动端网络慢,SELECT * 浪费带宽。用 ?fields=name,email 让客户端指定需要的字段。
// 安全的字段选择——白名单机制
const ALLOWED_FIELDS = ["id", "name", "email", "role", "createdAt"] as const;
function buildSelect(fieldsParam?: string) {
if (!fieldsParam) return undefined; // 不传则返回全部字段
const requested = fieldsParam.split(",");
// 只保留白名单内的字段,防止泄露敏感数据
const safe = requested.filter(f =>
ALLOWED_FIELDS.includes(f as any)
);
return safe.length
? Object.fromEntries(safe.map(f => [f, true]))
: undefined;
}
// 使用
app.get("/api/users/:id", asyncHandler(async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: buildSelect(req.query.fields as string),
});
res.json(success(user));
}));
select。否则攻击者可能通过 ?fields=password 泄露敏感数据。
Part 4: 统一响应与 CORS
前端不应该去猜"这次返回的是 data 还是 result"
每个接口返回的数据格式应该一致。前端不应该去猜"这次返回的是 data 还是 result"。统一响应格式让前端的错误处理变得简单可靠,全局错误中间件确保没有任何异常会以裸堆栈的形式暴露给用户。此外,跨域配置(CORS)是 API 对外提供服务的基础设置,这一部分也会一并讲解。
响应封装
成功、分页、错误——三种场景统一格式
function success<T>(data: T, message = "ok") {
return { success: true, data, message, timestamp: new Date().toISOString() };
}
function paginated<T>(data: T[], pagination: object) {
return { success: true, data, pagination, timestamp: new Date().toISOString() };
}
function error(message: string, statusCode: number, details?: unknown) {
return { success: false, error: message, statusCode, details, timestamp: new Date().toISOString() };
}
// 使用
app.get("/api/users/:id", asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) return res.status(404).json(error("用户不存在", 404));
res.json(success(user));
}));
全局错误处理
Zod 验证错误也能被统一捕获
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json(error(err.message, err.statusCode));
}
if (err instanceof z.ZodError) {
return res.status(400).json(error("验证失败", 400, err.flatten().fieldErrors));
}
console.error(err);
res.status(500).json(error("服务器内部错误", 500));
});
CORS 配置
让浏览器允许跨域请求你的 API
前端 localhost:5173 调用后端 localhost:3000,浏览器会拦截——这就是跨域问题。CORS(跨域资源共享)告诉浏览器"这个源是安全的"。
import cors from "cors";
app.use(cors({
origin: [
"http://localhost:5173", // 开发环境前端
"https://myapp.com", // 生产环境
],
credentials: true, // 允许携带 Cookie / Authorization
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
}));
credentials: true 允许浏览器发送 Cookie 和 Authorization 头。如果不需要可以省略。生产环境不要用 origin: "*",这等于对所有网站开放你的 API。
常见问题
问题一:Zod 验证总是失败
原因:前端发送的数据格式和 Schema 定义不一致。比如前端发了 "page": "1",但 Schema 期望 number。
修复:查询参数用 z.coerce.number() 自动转换,Body 数据确保前端 Content-Type 是 application/json。
问题二:分页查询返回 totalPages 为 0
原因:where 条件太严格,查不到数据。
修复:先检查 where 条件,或用数据库工具直接查询确认数据存在。
问题三:验证中间件不生效
原因:忘记调用 next(),或中间件注册顺序不对。
修复:确认验证中间件在路由处理器之前注册,且内部调用了 next()。
试一试
练习一
任务:定义一个 Zod Schema 验证文章创建请求:title(最少1个字符)、content(最少10个字符)、tags(可选的字符串数组)。
// 提示:z.array(z.string()) 定义字符串数组
// 你的代码:
参考答案
const createPostSchema = z.object({
title: z.string().min(1, "标题不能为空"),
content: z.string().min(10, "内容至少10个字符"),
tags: z.array(z.string()).optional(),
});
练习二
任务:写一个 GET /api/posts 接口,支持分页查询。URL 参数:?page=1&pageSize=10。
// 提示:用 paginationQuery 验证 query 参数
// 你的代码:
参考答案
app.get("/api/posts", asyncHandler(async (req, res) => {
const query = paginationQuery.parse(req.query);
const result = await paginate(prisma.post, {
page: query.page,
pageSize: query.pageSize,
});
res.json(result);
}));
📝 要点总结
本章核心知识点
RESTful 设计
资源化 URL + 语义化 HTTP 方法 + 标准状态码 = 清晰的 API
Zod 验证
用 Zod Schema 定义输入约束,编译时 + 运行时双重保障
分页排序
offset 分页简单通用,游标分页适合大数据量,按需选择
API 文档
Swagger/OpenAPI 让前后端协作更高效,注解即文档
下一章
接口设计好了,但谁来都能调吗?下一章学习用户认证与授权——区分"谁在请求"和"能不能做"。