模块四:后端 API 与数据管理 | 第05讲:数据校验三道防线——为什么只做前端校验很危险
本讲定位:用「前端 → API → 数据库」三层防线,把脏数据、攻击面与联调成本一并打下去。
项目锚点:VibeNote 笔记创建/更新/标签字段。
阅读线索:课程内容《5.4 数据校验三道防线:为什么只做前端校验很危险》。
一、开篇:前端校验是 UX,不是安全
只做前端校验,等于在门上贴「请勿闯入」——对正常用户有用,对脚本无效。攻击者可以用:
curl/ Postman 直接调用你的 API- 修改浏览器里被禁用的按钮状态
- 旧版本客户端绕过新前端逻辑
正确认知:前端校验负责即时反馈与减少无效请求;服务端校验才是安全与一致性的真相来源;数据库约束是最后一道物理闸。
flowchart LR
subgraph L1["第一道:前端"]
F1[Zod/react-hook-form]
F2[禁用提交/字段提示]
end
subgraph L2["第二道:API"]
A1[Zod 解析 JSON]
A2[业务规则]
end
subgraph L3["第三道:数据库"]
D1[NOT NULL/UNIQUE]
D2[外键/检查约束]
end
F1 --> A1
A1 --> D1
二、威胁建模:脏数据从哪混进来?
| 来源 | 例子 | 后果 |
|---|---|---|
| 直接 API 调用 | title 传 5MB 字符串 | 性能与存储被拖死 |
| 旧客户端 | 缺少新字段 | undefined 入库 |
| 并发竞态 | 重复标签名 | 逻辑混乱 |
| 恶意脚本 | SQL 片段(若拼接) | 经典注入(ORM 大幅降低但仍需规范) |
目标:即使客户端完全不可信,系统仍保持可预期的状态。
三、第一道防线:前端校验(体验优先)
用 Zod + 表单(React Hook Form 等)在提交前拦截:
// shared/note.ts(前后端可共享)
import { z } from "zod";
export const NoteCreateSchema = z.object({
title: z.string().trim().min(1, "标题不能为空").max(200),
contentMd: z.string().max(200_000, "正文过长"),
tagNames: z.array(z.string().trim().min(1).max(40)).max(20),
});
export type NoteCreateInput = z.infer<typeof NoteCreateSchema>;
前端提示:title 空、tagNames 太多、正文超长——用户不必等网络往返。
四、第二道防线:API Route Handler(必须)
关键原则:request.json() 之后第一件事是 schema.safeParse。
// app/api/notes/route.ts(节选)
import { NextResponse } from "next/server";
import { NoteCreateSchema } from "@/shared/note";
import { auth } from "@/auth";
import { createNoteWithTags } from "@/db/queries/notes";
export async function POST(req: Request) {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json(
{ success: false, error: { code: "UNAUTHORIZED", message: "未登录" } },
{ status: 401 }
);
}
const json = await req.json().catch(() => null);
const parsed = NoteCreateSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: {
code: "VALIDATION_ERROR",
message: "参数不合法",
details: parsed.error.flatten(),
},
},
{ status: 400 }
);
}
const note = await createNoteWithTags({
userId,
title: parsed.data.title,
contentMd: parsed.data.contentMd,
tagNames: parsed.data.tagNames,
});
return NextResponse.json({ success: true, data: note }, { status: 201 });
}
为什么返回 flatten():前端可以把字段级错误映射到输入框;比只返回字符串更易用。
4.1 统一错误体(建议)
type ApiError = {
success: false;
error: {
code: string;
message: string;
details?: unknown;
};
};
配合监控:code 聚合错误类型,比纯文案可靠。
五、第三道防线:数据库约束(兜底)
即使 API 有 bug,数据库也应拒绝非法状态:
title NOT NULL、长度可用CHECK (char_length(title) <= 200)UNIQUE (user_id, name)防重复标签- 外键保证
note_tags不挂空引用
ALTER TABLE notes
ADD CONSTRAINT notes_title_len CHECK (char_length(title) BETWEEN 1 AND 200);
ALTER TABLE notes
ADD CONSTRAINT notes_content_len CHECK (char_length(content_md) <= 200000);
注意:约束失败时 Drizzle 会抛驱动错误——要在 API 层映射为 409/400 而不是直接 500。
import { PostgresError } from "postgres";
function mapDbError(e: unknown) {
if (e instanceof PostgresError && e.code === "23505") {
return NextResponse.json(
{ success: false, error: { code: "CONFLICT", message: "资源冲突" } },
{ status: 409 }
);
}
return NextResponse.json(
{ success: false, error: { code: "INTERNAL", message: "服务异常" } },
{ status: 500 }
);
}
sequenceDiagram
participant C as Client
participant A as API
participant Z as Zod
participant D as DB
C->>A: POST /api/notes
A->>Z: safeParse(body)
alt 校验失败
Z-->>A: error.flatten()
A-->>C: 400 + details
else 校验通过
A->>D: INSERT...
alt 约束冲突
D-->>A: 23505
A-->>C: 409 CONFLICT
else 成功
D-->>A: row
A-->>C: 201 + data
end
end
六、只做前端校验会发生什么?三个真实场景
- 超长正文:恶意请求写入极大
content_md,数据库膨胀,列表接口变慢——API 长度校验本可挡在第一公里。 - 标签爆炸:
tagNames传几千个元素——数组长度上限必须在服务端。 - 重复点击提交:双 POST 插入两条几乎相同笔记——需要 幂等键 或 UI debounce + 服务端去重策略(进阶)。
七、与 AI 协作的校验清单(贴进 PRD)
- 每个写接口必须有 Zod schema 与 示例 JSON。
- 错误码表(
VALIDATION_ERROR、UNAUTHORIZED、CONFLICT)。 - 字段上限(长度、数组长度、数值范围)写死数字,不要让 AI 猜。
- DB 约束与 Zod 上限一致,否则用户会看到随机 500。
八、思考题
- 为什么数据库约束不能完全替代 API 校验?
details: parsed.error.flatten()可能泄露哪些信息给攻击者?生产环境要如何处理?- 如何在 Zod 中表达「标签名只允许中文、英文、数字与短横线」?
九、本节小结
- 前端校验优化体验,API 校验保证真相,数据库约束兜底一致性。
- Zod
safeParse+ 统一错误体,是 Next.js Route Handler 的最佳拍档。 - 把 Postgres 错误码映射成用户可理解的 HTTP 语义。
- 与 AI 协作时,用上限数字 + 错误码表约束生成结果。
十、下一讲预告
第06讲:文件上传与 AI 集成——从 Markdown 编辑到 AI 总结的完整实现
我们将接入安全的文件上传(UploadThing 或受控的 multipart 解析)、把图片放到对象存储或本地开发目录,并用 Vercel AI SDK 实现流式返回的「笔记摘要与自动标签」——让你看到 AI-native 功能如何在 API 层收口成本与风险。