4.5 数据校验三道防线——为什么只做前端校验很危险

2 阅读4分钟

模块四:后端 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

六、只做前端校验会发生什么?三个真实场景

  1. 超长正文:恶意请求写入极大 content_md,数据库膨胀,列表接口变慢——API 长度校验本可挡在第一公里。
  2. 标签爆炸tagNames 传几千个元素——数组长度上限必须在服务端。
  3. 重复点击提交:双 POST 插入两条几乎相同笔记——需要 幂等键 或 UI debounce + 服务端去重策略(进阶)。

七、与 AI 协作的校验清单(贴进 PRD)

  • 每个写接口必须有 Zod schema示例 JSON
  • 错误码表(VALIDATION_ERRORUNAUTHORIZEDCONFLICT)。
  • 字段上限(长度、数组长度、数值范围)写死数字,不要让 AI 猜。
  • DB 约束与 Zod 上限一致,否则用户会看到随机 500。

八、思考题

  1. 为什么数据库约束不能完全替代 API 校验?
  2. details: parsed.error.flatten() 可能泄露哪些信息给攻击者?生产环境要如何处理?
  3. 如何在 Zod 中表达「标签名只允许中文、英文、数字与短横线」?

九、本节小结

  • 前端校验优化体验,API 校验保证真相,数据库约束兜底一致性
  • Zod safeParse + 统一错误体,是 Next.js Route Handler 的最佳拍档。
  • Postgres 错误码映射成用户可理解的 HTTP 语义。
  • 与 AI 协作时,用上限数字 + 错误码表约束生成结果。

十、下一讲预告

第06讲:文件上传与 AI 集成——从 Markdown 编辑到 AI 总结的完整实现
我们将接入安全的文件上传(UploadThing 或受控的 multipart 解析)、把图片放到对象存储或本地开发目录,并用 Vercel AI SDK 实现流式返回的「笔记摘要与自动标签」——让你看到 AI-native 功能如何在 API 层收口成本与风险。