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 请求验证与处理的完整时序流程

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,
    };
}
适用场景:无限滚动列表、实时更新的 Feed 流。如果需要跳转到指定页码,仍用 offset 分页。

字段选择

客户端不需要的字段,就不应该传过去

移动端网络慢,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));
}));
安全提醒:永远用白名单过滤字段,不要直接把用户输入传给 Prisma 的 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));
});
生产环境不要在 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。

常见问题

API 设计一旦发布就不要随便改。 不兼容的修改应该用新的版本号(如 /api/v2/users)。

问题一: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 让前后端协作更高效,注解即文档

下一章

接口设计好了,但谁来都能调吗?下一章学习用户认证与授权——区分"谁在请求"和"能不能做"。

1 / X