7.2 当接口出了问题 转载

来源:https://github.com/datawhalechina/vibe-vibe

本节目标:了解 API 上线后最常见的几类问题——脏数据、重复提交、错误处理、资源耗尽——以及该怎么跟 AI 描述这些问题让它帮你修。

数据库里多了一条空标题的电影

小明打开 Drizzle Studio 检查数据,发现多了一条记录:标题是空的,年份是 0,导演 ID 指向一个不存在的导演。他很困惑——前端明明有输入框校验,标题为空时"提交"按钮是灰的,年份输入框只接受数字,怎么还能提交这种数据?

他问了一下朋友,朋友说:"我用 Postman 直接调你的 API 试了试,什么参数都没传,居然也能成功。"

小明这才明白:前端校验只能拦住"正常使用"的用户。任何人都可以绕过前端——打开浏览器开发者工具直接发请求、用 Postman 或 curl 调接口、甚至写个脚本批量调用。前端的输入框限制、按钮禁用、格式检查,在这些方式面前形同虚设。

这就像商场的门口有个保安,会提醒你"请走正门"。但如果有人从侧门、后门、甚至翻窗户进来,保安管不着。真正的安全检查在里面——每个柜台都会核实你的身份和购买资格。

前端校验是为了用户体验(即时反馈,不用等服务器响应就能告诉用户"标题不能为空"),后端校验是为了数据安全(守住底线,不管请求从哪里来,脏数据都进不了数据库)。两者缺一不可。

Zod——AI 会用的校验库

你跟 AI 说"给接口加参数校验",它大概率会用 Zod。Zod 是 TypeScript 生态中最流行的校验库,你不需要学它的语法,只需要知道三件事:

  • 它做什么:定义"数据应该长什么样"的规则(比如标题必须是 1-100 个字符的字符串,年份必须是 1888-2030 之间的整数),然后自动校验传入的数据
  • 校验失败怎么办:返回具体的错误信息给前端,告诉它哪个字段不对、为什么不对
  • 为什么用它而不是手写 if-else:Zod 把校验规则和 TypeScript 类型合二为一——定义了校验规则,TypeScript 类型自动推导出来,不用写两遍。而且 Zod 的错误信息格式统一,前端处理起来方便
好奇 Zod 长什么样?展开看看,不看也没关系
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 这个方法——它不会在校验失败时抛异常,而是返回一个包含 successerror 的对象,让你自己决定怎么处理。这比 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 和"出错了"。前端拿到这个响应,也只能显示一个通用的错误提示。用户完全不知道发生了什么、该怎么办。

HTTP 状态码:错误的分类系统

你肯定见过"404 页面不存在"——点了一个过期链接、输错了网址,浏览器就会显示 404。HTTP 状态码就是这套分类系统,404 只是其中一个。你不需要记住所有状态码,只需要知道最常用的几个:

状态码 含义 什么时候用 用户该看到什么
200 成功 请求正常处理 正常显示数据
201 创建成功 POST 创建了新资源 "添加成功!"
400 请求有误 参数校验失败、格式不对 "标题不能为空"(具体的错误信息)
404 资源不存在 查询的电影 ID 不存在 "找不到这部电影"
409 冲突 重复添加同一部电影 "这部电影已经存在了"
429 请求太频繁 短时间内发了太多请求 "操作太频繁,请稍后再试"
500 服务器错误 代码 bug、数据库挂了 "服务暂时不可用,请稍后再试"

关键原则:400 系列是"你(调用者)的问题",500 系列是"我(服务器)的问题"

这个区分非常重要,因为它决定了前端该怎么处理:

  • 400 系列:用户可以自己修正。显示具体的错误信息,引导用户改正输入。比如"标题不能为空"——用户填上标题就能重新提交。
  • 500 系列:用户做什么都没用,只能等。显示"服务暂时不可用,请稍后再试"就行。不要把内部错误信息暴露给用户——"PostgreSQL connection refused at 10.0.0.5:5432"这种信息对用户没用,还可能泄露服务器内部架构。
🚫 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. 查询所有关注了相关标签的用户(几百毫秒)
  3. 给每个用户发通知(一两秒)

这三步是串行执行的——第 1 步做完才做第 2 步,第 2 步做完才做第 3 步,全部做完才返回"添加成功"。用户要等所有通知都发完才能看到结果。

但用户关心的只是"电影有没有添加成功"。通知发没发、发给了谁,用户根本不需要等。

这就是阻塞 vs 非阻塞的区别。数据库写入是核心操作,必须等它完成才能告诉用户"成功了"。但发通知是附带操作,完全可以在返回响应之后再做——用户先看到"添加成功",通知在后台慢慢发。

Next.js 提供了一个叫 after() 的功能,专门干这件事:把不需要阻塞响应的操作(发通知、记日志、更新统计)推迟到响应发送之后执行。

跟 AI 说:

"添加电影的接口,数据库写入完成后立即返回成功响应。发通知、更新统计这些操作用 Next.js 的 after() 放到响应之后执行,不要阻塞用户。"

💡 判断标准:这个操作的结果需要告诉用户吗?

如果需要(比如"添加成功"或"标题不能为空"),就必须在响应之前完成。如果不需要(比如发通知、记日志、刷新缓存),就可以放到响应之后。加载了 next-best-practices Skill 的 Claude Code 会自动识别哪些操作适合用 after(),但你在审查 AI 方案时如果发现"接口变慢了",可以想想是不是有操作不该阻塞响应。

排错的 Prompt 模板

上线后遇到问题,最高效的排错方式是把完整的上下文丢给 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 拿到具体数据才能给出针对性的方案,否则只能给你一堆"可能是这个、可能是那个"的猜测。

ℹ️ 下一步

接口能用、能扛了。但随着功能越加越多,你会发现接口本身也需要"管理"。去 让接口更好用 看看怎么把接口当产品来打磨。

最后编辑:Alex 2026-03-05 11:39:51