很多后端教程教”怎么定义路由”——app.get('/users', handler),15 分钟跑通。
但”能跑”和”设计得好”是两回事。一个设计得好的 HTTP API,调用方不看文档也能猜出下一个接口的 URL 格式、重试会不会产生副作用、以及返回的 JSON 里会不会意外泄露不该出现的字段。
一、URL 嵌套多深该打住
嵌套是冲动,扁平是克制
数据库里 User 和 Post 是一对多关系——一个用户有多篇文章。直觉是把这种关系直接映射到 URL 上:
GET /users/1/posts/5/comments/3 ← 三层嵌套三层嵌套看起来”很 RESTful”——URL 忠实地反映了数据关系。但这条 URL 隐含了一个假设:评论只能通过”用户→文章→评论”这条路径来访问。 实际上你可能需要在很多场景下直接通过评论 ID 查询——“谁发了这条评论""这条评论被举报了多少次”——这些场景只需要评论 ID,不需要知道文章 ID 和用户 ID。
判断是否应该嵌套的标准不是”它们有没有关系”,而是子资源能否脱离父资源独立存在。
一篇帖子的评论如果脱离帖子就没有上下文,GET /posts/5/comments 是合理的嵌套。但一条评论本身有唯一 ID、有独立的生命周期(被点赞、被举报、被删除)——GET /comments/3 对于这些场景更直接。
实用规则:最多嵌套一层。 /posts/:id/comments 可以;/users/:id/posts/:pid/comments 要重新考虑。
什么时候用 RPC 风格的 action 端点
不是所有操作都能映射到 CRUD:
POST /posts/:id/publish ← 发布文章POST /orders/:id/cancel ← 取消订单这些操作有一个共同特征:副作用明显,不能安全重放。 发布文章不只是改 status 字段——它可能触发通知、更新索引、清理缓存。用 PUT /posts/:id + { status: 'published' } 会让调用方以为这是幂等的(PUT 是幂等方法),但实际上第一次和第二次执行的效果不同。
把这类操作设计成独立的 action 端点有三个好处:语义明确告诉调用方”这有副作用,别重试”;将来可以独立配置限流(发布比编辑更需要反滥用);鉴权逻辑自然分离(编辑可能需要审核,发布不需要)。
二、幂等性:不是八股,是重试安全的根基
同一个请求发两次,结果应该一样吗
场景:用户提交注册表单,网络卡了,响应没收到。浏览器弹出”是否重新提交?“用户点了是。
POST 不幂等——两次提交可能创建两个用户。如果后端只靠邮箱唯一约束兜底,第二个请求返回”邮箱已注册”,但用户看到的是”注册失败”——实际上第一次已经成功了,他应该直接登录。
幂等方法天然支持安全重试:
GET /posts/5请求 10 次——服务器状态不变PUT /posts/5请求 10 次——文章内容相同(客户端提供了完整表示)DELETE /posts/5请求 10 次——文章被删除一次,后续返回 404
PUT 和 PATCH 的区别不只是”全量 vs 部分”
PUT 的幂等性意味着客户端可以安全重试——请求超时就重发,不需要先检查服务器状态。PATCH 没有这个保证:两次相同的 PATCH { $inc: { viewCount: 1 } } 会产生不同的结果。
这个区别在选择更新接口的 HTTP 方法时是首要考虑因素——不是”请求体里传了多少字段”。
非幂等方法怎么处理重复请求
创建资源(POST)、状态转换(/publish)、支付——这些操作不能靠 HTTP 方法保证幂等。解决方案是幂等键:客户端在提交前生成一个 UUID,随请求一起发送。服务端在数据库里用唯一约束存储这个幂等键,重复请求返回第一次的结果而不是重复执行:
async function createPost(input, idempotencyKey: string) { const existing = await db.post.findUnique({ where: { idempotencyKey } }); if (existing) return existing; // 重复请求——返回第一次的结果
return db.post.create({ data: { ...input, idempotencyKey } });}关键:幂等键必须是数据库唯一约束。 代码里检查”是否已存在”挡不住并发——两个相同的幂等键可能同时通过检查、同时写入。数据库 @unique 约束让第二个写入失败,捕获异常,返回第一次的结果。
三、DTO 和数据库 Model 是两种类型
你见过把 password 返回给前端的 API 吗
这个问题本质不是”我忘调用 select 排除 password 了”,而是API 响应类型和数据库 Model 类型应该是两种不同的类型。
// 数据库查询结果(User Model)type User = { id: string; email: string; password: string; // ← 哈希值,绝对不能返回给前端};
// API 响应(UserDTO)—— password 不出现在这个类型里type UserDTO = { id: string; email: string; name: string | null;};将来你给 User 表加了 phone 字段。如果 Controller 直接把 Prisma 查询结果返回给前端,phone 也会泄漏出去——因为类型系统没有阻止这件事。但有了 UserDTO,TypeScript 会在编译期告诉你:toUserDTO 需要显式决定 phone 要不要暴露。
DTO 的真正价值不是”排除 password”——select 也能做到。它建立的是”数据库结构变更”的隔离带:数据库加了 10 个字段,DTO 不改,API 响应就不变——调用方不受影响。
错误码:双层结构
HTTP 状态码是给 HTTP 客户端看的(400 表示请求有问题、401 表示没登录)。但业务需要更细的区分——同样是 401,可能是 Token 过期(需要刷新)也可能是从未登录(需要跳登录页)。业务错误码是第二层信息:
const ErrorCode = { OK: 0, VALIDATION_ERROR: 40001, // 参数校验失败 UNAUTHORIZED: 40101, // 未登录 TOKEN_EXPIRED: 40102, // Token 过期 → 前端静默刷新 FORBIDDEN: 40301, // 无权限 NOT_FOUND: 40401, // 资源不存在 CONFLICT: 40901, // 资源冲突(如邮箱已注册) INTERNAL_ERROR: 50001 // 服务器内部错误};40101 和 40102 的 HTTP 状态码都是 401,但前端行为完全不同——前者跳登录页,后者静默刷新 Token。HTTP 状态码无法区分这两种情况,业务错误码可以。
生产环境和开发环境的错误返回策略不同:开发环境返回详细的错误信息(校验失败的字段、堆栈跟踪);生产环境只返回错误码 + 通用描述,详细信息记在服务端日志里。这不是靠 if (env === 'production') 硬编码,而是在错误处理器里统一判断。
小结
三个决策,每个都直接影响 API 的长期质量:
- URL 嵌套不超过一层——判断标准是子资源能否脱离父资源独立存在
- 幂等性决定重试安全性——GET/PUT/DELETE 天然安全,POST 需要幂等键且必须是数据库约束
- DTO 隔离数据库变更——不是防 password 泄漏,是防止表结构变更直接冲击 API 响应
下一篇:数据建模的核心决策(三)——约束应该放在数据库还是代码、级联删除的策略选择、以及 Schema 变更不该是自动化的。
Some information may be outdated