本节目标:了解 API 上线后最常见的几类问题——脏数据、重复提交、错误处理、资源耗尽——以及该怎么跟 AI 描述这些问题让它帮你修。
小明打开 Drizzle Studio 检查数据,发现多了一条记录:标题是空的,年份是 0,导演 ID 指向一个不存在的导演。他很困惑——前端明明有输入框校验,标题为空时"提交"按钮是灰的,年份输入框只接受数字,怎么还能提交这种数据?
他问了一下朋友,朋友说:"我用 Postman 直接调你的 API 试了试,什么参数都没传,居然也能成功。"
小明这才明白:前端校验只能拦住"正常使用"的用户。任何人都可以绕过前端——打开浏览器开发者工具直接发请求、用 Postman 或 curl 调接口、甚至写个脚本批量调用。前端的输入框限制、按钮禁用、格式检查,在这些方式面前形同虚设。
这就像商场的门口有个保安,会提醒你"请走正门"。但如果有人从侧门、后门、甚至翻窗户进来,保安管不着。真正的安全检查在里面——每个柜台都会核实你的身份和购买资格。
前端校验是为了用户体验(即时反馈,不用等服务器响应就能告诉用户"标题不能为空"),后端校验是为了数据安全(守住底线,不管请求从哪里来,脏数据都进不了数据库)。两者缺一不可。
你跟 AI 说"给接口加参数校验",它大概率会用 Zod。Zod 是 TypeScript 生态中最流行的校验库,你不需要学它的语法,只需要知道三件事:
import { z } from 'zod'
// 定义规则:标题必须是 1-100 个字符的字符串,年份是 1888-2030 的整数
const createMovieSchema = z.object({
title: z.string().min(1, '标题不能为空').max(100, '标题太长了'),
year: z.number().int('年份必须是整数').min(1888, '年份不能早于 1888').max(2030),
directorId: z.number().int().positive('导演 ID 必须是正整数'),
})
// 在 API 里使用
export async function POST(request: Request) {
const body = await request.json()
const result = createMovieSchema.safeParse(body)
if (!result.success) {
// 校验失败,返回 400 和具体的错误信息
return Response.json(
{ success: false, error: { message: result.error.issues[0].message } },
{ status: 400 }
)
}
// result.data 是校验通过的安全数据,类型自动推导
const newMovie = await db.insert(movies).values(result.data).returning()
return Response.json({ success: true, data: newMovie[0] })
}
注意 safeParse 这个方法——它不会在校验失败时抛异常,而是返回一个包含 success 和 error 的对象,让你自己决定怎么处理。这比 try-catch 更可控。
小明问老师傅:"是不是每个字段都要加一堆校验规则?"
老师傅说:"看场景。核心原则是——不信任任何外部输入,但也不要过度校验。"
| 必须校验 | 原因 |
|---|---|
| 必填字段不能为空 | 空数据进数据库会导致各种下游问题 |
| 字符串长度限制 | 防止有人传一个 10MB 的字符串把内存撑爆 |
| 数字范围限制 | 评分不能是 -1 或 999,年份不能是 3000 |
| 枚举值校验 | 状态只能是 "draft"/"published"/"archived",不能是任意字符串 |
| 外键存在性 | directorId 指向的导演必须真实存在(数据库外键约束也会拦,但提前校验能给更友好的错误信息) |
| 不需要过度校验 | 原因 |
|---|---|
| 内部服务之间的调用 | 你自己的前端调你自己的后端,数据格式是你控制的,基本校验就够 |
| 已经有数据库约束的字段 | 如果数据库已经有 UNIQUE 约束,代码里不需要再查一遍"有没有重复" |
跟 AI 说:
"给所有 POST 和 PATCH 接口加上 Zod 参数校验。必填字段不能为空,字符串限制合理长度,数字限制合理范围。校验失败返回 400 和具体的错误信息,格式用
{ success: false, error: { message: '...' } }。"
小明的朋友又来反馈了:"我添加电影时填错了年份,页面弹了个'出错了'。我搜一部不存在的电影,也弹'出错了'。你的服务器挂了也弹'出错了'。到底是我的问题还是你的问题?我该重试还是该改输入?"
小明检查了一下代码,发现所有接口的错误处理都是这样的:
catch (error) {
return Response.json({ error: '出错了' }, { status: 500 })
}
不管什么错误——参数不对、数据不存在、数据库挂了——统统返回 500 和"出错了"。前端拿到这个响应,也只能显示一个通用的错误提示。用户完全不知道发生了什么、该怎么办。
你肯定见过"404 页面不存在"——点了一个过期链接、输错了网址,浏览器就会显示 404。HTTP 状态码就是这套分类系统,404 只是其中一个。你不需要记住所有状态码,只需要知道最常用的几个:
| 状态码 | 含义 | 什么时候用 | 用户该看到什么 |
|---|---|---|---|
| 200 | 成功 | 请求正常处理 | 正常显示数据 |
| 201 | 创建成功 | POST 创建了新资源 | "添加成功!" |
| 400 | 请求有误 | 参数校验失败、格式不对 | "标题不能为空"(具体的错误信息) |
| 404 | 资源不存在 | 查询的电影 ID 不存在 | "找不到这部电影" |
| 409 | 冲突 | 重复添加同一部电影 | "这部电影已经存在了" |
| 429 | 请求太频繁 | 短时间内发了太多请求 | "操作太频繁,请稍后再试" |
| 500 | 服务器错误 | 代码 bug、数据库挂了 | "服务暂时不可用,请稍后再试" |
关键原则:400 系列是"你(调用者)的问题",500 系列是"我(服务器)的问题"。
这个区分非常重要,因为它决定了前端该怎么处理:
这是一个安全问题。如果你的 500 错误返回了数据库连接字符串、SQL 语句、文件路径、堆栈跟踪等信息,攻击者可以利用这些信息找到你系统的弱点。
正确的做法:500 错误只返回通用提示("服务暂时不可用"),详细的错误信息记到服务器日志里,方便你自己排查。
跟序言里约定的响应格式一致,错误响应也应该有统一的结构:
不同类型错误的响应示例400 参数错误:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "标题不能为空",
"details": [
{ "field": "title", "message": "标题不能为空" },
{ "field": "year", "message": "年份必须是 1888-2030 之间的整数" }
]
}
}
404 资源不存在:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "找不到 ID 为 999 的电影"
}
}
500 服务器错误:
{
"success": false,
"error": {
"code": "INTERNAL_ERROR",
"message": "服务暂时不可用,请稍后再试"
}
}
注意 400 错误带了 details 数组,列出每个字段的具体问题。这样前端可以在对应的输入框旁边显示错误提示,而不是只在页面顶部弹一个笼统的"参数错误"。
跟 AI 说:
"统一所有接口的错误处理。参数校验失败返回 400,带上每个字段的具体错误信息。资源不存在返回 404。重复数据返回 409。服务器内部错误返回 500,只返回通用提示,详细错误记到日志。所有错误响应统一用
{ success: false, error: { code, message } }格式。"
缓存问题解决后,小明又加了一个功能:用户添加电影后,给所有关注了这个标签的人发一条通知。
功能上线后,朋友反馈:"添加电影变慢了,点完'提交'要等两秒才看到'添加成功'。以前是秒回的。"
小明检查了一下,发现问题出在通知逻辑上。添加电影的接口现在做了三件事:
这三步是串行执行的——第 1 步做完才做第 2 步,第 2 步做完才做第 3 步,全部做完才返回"添加成功"。用户要等所有通知都发完才能看到结果。
但用户关心的只是"电影有没有添加成功"。通知发没发、发给了谁,用户根本不需要等。
这就是阻塞 vs 非阻塞的区别。数据库写入是核心操作,必须等它完成才能告诉用户"成功了"。但发通知是附带操作,完全可以在返回响应之后再做——用户先看到"添加成功",通知在后台慢慢发。
Next.js 提供了一个叫 after() 的功能,专门干这件事:把不需要阻塞响应的操作(发通知、记日志、更新统计)推迟到响应发送之后执行。
跟 AI 说:
"添加电影的接口,数据库写入完成后立即返回成功响应。发通知、更新统计这些操作用 Next.js 的 after() 放到响应之后执行,不要阻塞用户。"
如果需要(比如"添加成功"或"标题不能为空"),就必须在响应之前完成。如果不需要(比如发通知、记日志、刷新缓存),就可以放到响应之后。加载了 next-best-practices Skill 的 Claude Code 会自动识别哪些操作适合用 after(),但你在审查 AI 方案时如果发现"接口变慢了",可以想想是不是有操作不该阻塞响应。
上线后遇到问题,最高效的排错方式是把完整的上下文丢给 AI。模糊的描述只会得到模糊的回答。
接口报错时:
"我的
POST /api/movies接口报了这个错:[粘贴完整报错信息,包括堆栈跟踪]。请求的 body 是 [粘贴请求内容]。帮我定位问题并修复。"
数据异常时:
"数据库 movies 表里出现了标题为空的记录(ID: 42, 43, 47)。正常情况下标题不应该为空。帮我排查是哪个接口没做校验导致的,然后:1)给接口加上 Zod 校验;2)清理这三条脏数据。"
性能问题时:
"我的电影列表接口在数据量到 5000 条后变得很慢,响应时间从 50ms 涨到了 3 秒。帮我分析原因——是缺索引、N+1 查询、还是其他问题?用 EXPLAIN ANALYZE 检查一下。"
间歇性问题时:
"我的 API 大部分时候正常,但每天晚上 8-10 点会偶尔返回 500 错误。报错信息是 [粘贴]。这个时间段是用户访问高峰。帮我排查是不是连接池不够用。"
关键是给具体数据。不要说"接口很慢",要说"响应时间从 50ms 涨到了 3 秒"。不要说"有时候报错",要说"每天晚上 8-10 点报错,报错信息是 xxx"。AI 拿到具体数据才能给出针对性的方案,否则只能给你一堆"可能是这个、可能是那个"的猜测。
接口能用、能扛了。但随着功能越加越多,你会发现接口本身也需要"管理"。去 让接口更好用 看看怎么把接口当产品来打磨。