摘要
很多 AI 生成的接口代码不是不能跑,而是到了前后端联调阶段才发现字段不一致、状态定义不清、错误码没人认、边界条件没测。更稳的做法不是一句“帮我写接口”,而是先定清字段字典、请求响应、错误码、状态流转和测试要求,再让 AI 在边界内生成代码。
很多人用 AI 写接口,最常见的提问是:
帮我写一个创建工单的接口。然后 AI 很快给出 Controller、Service、DTO、数据库实体,甚至连 Swagger 注解都顺手补齐。
看起来很高效。
但一进入联调,问题才开始出现:
- 前端传的是
priority,后端却接收level; - 前端以为创建成功后状态是
PENDING,后端默认写成了OPEN; - 重复提交时,接口到底应该报错,还是返回第一次创建的数据;
- 文件上传失败属于参数错误、业务错误还是系统错误;
- 工单关闭后能不能重新打开;
- 某个字段是必填、可选,还是“前端不传由后端自动补”;
- AI 生成的代码能跑,但测试根本没有覆盖重复请求和非法状态流转。
这类返工,通常不是代码能力问题。
而是接口真正开始写之前,契约没有定清楚。
AI 很适合生成接口骨架、字段校验、测试样例和重复代码,但前提是你先告诉它:哪些内容已经确定,哪些内容不能自行猜测。
这篇就用一个“创建工单接口”的例子,把一套可复用的接口契约流程拆开。
核心思路只有一句:
先让 AI 读懂接口规则,再让它写接口实现。
一、先别急着写 Controller:接口真正的第一步是消灭歧义
很多接口在需求文档里只写了一句:
新增一个创建工单的接口。这句话对于产品、前端、后端、测试来说,理解可能完全不同。
后端会想:
需要哪些字段?数据库怎么设计?是否要鉴权?前端会想:
提交成功后跳转哪里?失败要怎么提示?状态字段怎么展示?测试会想:
重复点击提交怎么办?标题为空怎么办?优先级传了非法值怎么办?而 AI 会想:
我可以根据常见经验给你补齐一套实现。问题就在这里。
“根据常见经验补齐”在 Demo 里没问题,在真实项目里却很危险。因为真实项目中,很多规则不是技术规则,而是业务规则。
例如“创建工单”这个动作,看起来很简单,至少会涉及下面这些决策:
| 问题 | 不确认会带来的后果 |
|---|---|
| 工单标题最短几位、最长几位? | 前端和后端校验不一致 |
| 优先级有哪些枚举值? | 数据库出现脏数据 |
| 是否允许匿名提交? | 权限边界混乱 |
| 是否支持重复请求? | 用户连续点击后产生多条工单 |
| 新工单默认是什么状态? | 前端展示与后端逻辑冲突 |
| 工单关闭后能否重新开启? | 状态机无法统一 |
| 失败时返回什么错误码? | 前端无法给用户正确提示 |
所以,写代码前要先把接口变成一份“可执行的约定”。
二、一个接口至少要先定清楚这 4 份契约
我现在让 AI 写接口前,通常会先准备这四部分内容:
- 字段字典
- 请求与响应示例
- 错误码约定
- 状态流转与测试边界
这四份内容不一定要写成很厚的文档。
但必须让前端、后端、测试和 AI 看到的是同一份规则。
三、第一份契约:字段字典,不要只写字段名
先看一个容易出问题的字段定义:
title:标题 priority:优先级 description:描述这对 AI 来说信息太少了。
它不知道priority是数字还是字符串,不知道description能不能为空,不知道title是否需要去掉首尾空格,也不知道前端是否允许传入默认状态。
更稳的字段字典应该写成这样:
| 字段 | 类型 | 是否必填 | 规则 | 示例 |
|---|---|---|---|---|
title | string | 是 | 去除首尾空格后长度 3-80 | 支付页无法提交订单 |
description | string | 是 | 长度 10-2000 | 点击提交后页面一直转圈 |
priority | string | 是 | 仅允许LOW、MEDIUM、HIGH | HIGH |
reporterId | string | 是 | 当前登录用户 ID,不由前端任意填写 | u_10086 |
idempotencyKey | string | 是 | 同一用户同一请求唯一,长度 8-64 | ticket-20260705-001 |
status | string | 否 | 创建时由服务端固定为OPEN | OPEN |
这里有一个很关键的细节:
不要把所有字段都开放给前端传入。
像status、createdAt、updatedAt、reporterId这类字段,很多时候应该由后端根据上下文生成,而不是让客户端自己决定。
否则 AI 很可能为了“让接口看起来完整”,把它们都塞进 Request Body,后面再补权限校验和状态限制,代码会越来越绕。
四、第二份契约:先写请求和响应,再写接口实现
下面是一份创建工单接口的请求约定。
POST /api/tickets Content-Type: application/json请求体:
{"title":"支付页无法提交订单","description":"用户点击提交订单后页面一直转圈,控制台没有明显报错。","priority":"HIGH","idempotencyKey":"ticket-20260705-001"}注意:这里没有让前端传reporterId,也没有让前端传status。
reporterId应该来自当前登录态,status则由后端创建时固定为OPEN。
成功响应:
{"code":"OK","data":{"id":"t_01JZK2N5M8A","title":"支付页无法提交订单","description":"用户点击提交订单后页面一直转圈,控制台没有明显报错。","priority":"HIGH","status":"OPEN","reporterId":"u_10086","createdAt":"2026-07-05T08:30:00.000Z"}}重复请求响应:
{"code":"OK","data":{"id":"t_01JZK2N5M8A","title":"支付页无法提交订单","description":"用户点击提交订单后页面一直转圈,控制台没有明显报错。","priority":"HIGH","status":"OPEN","reporterId":"u_10086","createdAt":"2026-07-05T08:30:00.000Z"},"meta":{"idempotentReplay":true}}这里的规则是:
同一个用户使用相同
idempotencyKey重复提交时,不重复创建工单,而是返回第一次创建的结果。
这一条如果不提前写清,AI 很可能生成两种完全不同的实现:
- 直接报“重复提交”错误;
- 每次都新建一条工单。
两种都不能说一定错,但必须由你的业务规则决定。
五、第三份契约:错误码不要临时拍脑袋
接口错误最容易被忽略。
因为很多后端写代码时,会先用:
400 参数错误 500 系统异常但前端联调时会发现,用户根本不知道该怎么处理。
例如下面这几种错误,HTTP 状态可能都可以是 400 或 409,但业务含义不一样:
| 错误码 | HTTP 状态 | 场景 | 前端处理建议 |
|---|---|---|---|
VALIDATION_ERROR | 400 | 字段格式不合法 | 直接提示表单字段错误 |
UNAUTHORIZED | 401 | 用户未登录或登录失效 | 跳转登录页 |
FORBIDDEN | 403 | 用户无创建权限 | 展示无权限提示 |
TICKET_NOT_FOUND | 404 | 工单不存在 | 返回列表或刷新数据 |
INVALID_STATUS_TRANSITION | 409 | 不允许从当前状态切换 | 提示当前状态不可操作 |
DUPLICATE_REQUEST | 409 | 未采用幂等重放策略时的重复请求 | 禁止重复提交 |
INTERNAL_ERROR | 500 | 未预期系统异常 | 展示兜底提示并记录 traceId |
重点不是错误码写得多漂亮。
重点是前后端要提前约定:
什么情况下返回什么错误,用户界面应该如何表现。
六、第四份契约:状态流转必须在写业务代码前固定
只要业务对象有状态,就不要只写一个status: string。
例如工单可能有这几个状态:
OPEN → IN_PROGRESS → RESOLVED → CLOSED但真实规则通常不只是这条直线。
比如:
OPEN → IN_PROGRESS OPEN → CLOSED IN_PROGRESS → RESOLVED IN_PROGRESS → OPEN RESOLVED → CLOSED RESOLVED → IN_PROGRESS CLOSED → 不允许继续流转用 TypeScript 表达出来,可以写得很直接:
exporttypeTicketStatus=|"OPEN"|"IN_PROGRESS"|"RESOLVED"|"CLOSED";constallowedTransitions:Record<TicketStatus,TicketStatus[]>={OPEN:["IN_PROGRESS","CLOSED"],IN_PROGRESS:["OPEN","RESOLVED"],RESOLVED:["IN_PROGRESS","CLOSED"],CLOSED:[],};exportfunctioncanTransition(from:TicketStatus,to:TicketStatus):boolean{returnallowedTransitions[from].includes(to);}这段代码看起来很普通,但它有两个价值:
- 业务规则不再散落在多个 Controller 和 Service 里;
- AI 后续生成状态修改接口时,有明确边界可遵守。
例如你让 AI 写“关闭工单接口”时,可以直接给它这条约束:
只允许从 OPEN、RESOLVED 状态关闭工单。 IN_PROGRESS 状态不能直接关闭,必须先解决或退回 OPEN。 CLOSED 状态不允许再次修改。这比“帮我写一个关闭工单接口”稳定得多。
七、把契约落到代码:用 Zod 先锁住输入边界
下面用 Node.js 18+、TypeScript、Zod 做一个简单示例。
安装依赖:
npminstallzodnpminstall-Dtypescript vitest @types/node新建src/ticket/schema.ts:
import{z}from"zod";exportconstticketPrioritySchema=z.enum(["LOW","MEDIUM","HIGH",]);exportconstcreateTicketSchema=z.object({title:z.string().trim().min(3,"标题至少需要 3 个字符").max(80,"标题不能超过 80 个字符"),description:z.string().trim().min(10,"描述至少需要 10 个字符").max(2000,"描述不能超过 2000 个字符"),priority:ticketPrioritySchema,idempotencyKey:z.string().trim().min(8,"幂等键至少需要 8 个字符").max(64,"幂等键不能超过 64 个字符"),});exporttypeCreateTicketInput=z.infer<typeofcreateTicketSchema>;这里做了几件事:
- 输入字段只保留客户端真正需要提交的字段;
reporterId不在请求体内;status不在请求体内;- 标题、描述、幂等键都有最小和最大长度;
priority不允许传任意字符串。
这样 AI 后续生成 Controller 时,就不会把“参数校验”写成一堆散乱的if判断。
八、Service 层负责业务规则,不要把规则塞进 Controller
新建src/ticket/service.ts:
import{createTicketSchema,typeCreateTicketInput,}from"./schema";exporttypeTicketPriority="LOW"|"MEDIUM"|"HIGH";exporttypeTicketStatus=|"OPEN"|"IN_PROGRESS"|"RESOLVED"|"CLOSED";exportinterfaceTicket{id:string;title:string;description:string;priority:TicketPriority;status:TicketStatus;reporterId:string;idempotencyKey:string;createdAt:string;}exportinterfaceTicketRepository{findByIdempotencyKey(reporterId:string,idempotencyKey:string):Promise<Ticket|null>;create(ticket:Ticket):Promise<Ticket>;}exportinterfaceCreateTicketResult{ticket:Ticket;idempotentReplay:boolean;}exportasyncfunctioncreateTicket(rawInput:unknown,reporterId:string,repository:TicketRepository):Promise<CreateTicketResult>{constinput:CreateTicketInput=createTicketSchema.parse(rawInput);constexisting=awaitrepository.findByIdempotencyKey(reporterId,input.idempotencyKey);if(existing){return{ticket:existing,idempotentReplay:true,};}constticket:Ticket={id:crypto.randomUUID(),title:input.title,description:input.description,priority:input.priority,status:"OPEN",reporterId,idempotencyKey:input.idempotencyKey,createdAt:newDate().toISOString(),};constcreated=awaitrepository.create(ticket);return{ticket:created,idempotentReplay:false,};}这段代码里,几个边界比较明确:
- 参数规则在
schema.ts; - 幂等逻辑在
service.ts; reporterId从认证上下文传入;- 创建状态由服务端固定为
OPEN; - Repository 只负责查和存,不负责猜业务规则。
这就是接口契约落地之后的好处:
AI 不需要猜“新工单默认是什么状态”,因为契约已经写死了。
九、测试不是最后补的:先把关键行为钉住
很多人写接口时,测试只测“传正确参数能不能成功”。
但真正容易出问题的,是边界情况。
新建src/ticket/service.test.ts:
import{describe,expect,it}from"vitest";import{createTicket,typeTicket,typeTicketRepository,}from"./service";classMemoryTicketRepositoryimplementsTicketRepository{privatereadonlytickets:Ticket[]=[];asyncfindByIdempotencyKey(reporterId:string,idempotencyKey:string):Promise<Ticket|null>{return(this.tickets.find((ticket)=>ticket.reporterId===reporterId&&ticket.idempotencyKey===idempotencyKey)??null);}asynccreate(ticket:Ticket):Promise<Ticket>{this.tickets.push(ticket);returnticket;}}describe("createTicket",()=>{it("应创建一条状态为 OPEN 的工单",async()=>{constrepository=newMemoryTicketRepository();constresult=awaitcreateTicket({title:"支付页无法提交订单",description:"用户点击提交订单后页面一直转圈,控制台没有明显报错。",priority:"HIGH",idempotencyKey:"ticket-20260705-001",},"u_10086",repository);expect(result.idempotentReplay).toBe(false);expect(result.ticket.status).toBe("OPEN");expect(result.ticket.reporterId).toBe("u_10086");});it("相同用户使用相同幂等键重复提交时,应返回第一次结果",async()=>{constrepository=newMemoryTicketRepository();constpayload={title:"支付页无法提交订单",description:"用户点击提交订单后页面一直转圈,控制台没有明显报错。",priority:"HIGH",idempotencyKey:"ticket-20260705-001",};constfirstResult=awaitcreateTicket(payload,"u_10086",repository);constsecondResult=awaitcreateTicket(payload,"u_10086",repository);expect(firstResult.ticket.id).toBe(secondResult.ticket.id);expect(secondResult.idempotentReplay).toBe(true);});it("标题长度不足时,应拒绝创建",async()=>{constrepository=newMemoryTicketRepository();awaitexpect(createTicket({title:"短",description:"用户点击提交订单后页面一直转圈,控制台没有明显报错。",priority:"HIGH",idempotencyKey:"ticket-20260705-001",},"u_10086",repository)).rejects.toThrow();});});运行:
npx vitest run这三条测试并不复杂,但它们已经固定了三个关键业务行为:
- 新工单创建后必须是
OPEN; - 相同幂等键不能重复创建;
- 非法字段不能进入业务层。
之后你再让 AI 扩展接口,例如增加附件、分配处理人、关闭工单、重新打开工单,至少不会把这些基础规则改掉。
十、给 AI 的提示词,也应该写成“约束 + 目标”
有了契约之后,不要再对 AI 说:
帮我写一个创建工单接口。换成下面这种写法会稳定得多:
请基于以下既定接口契约,生成创建工单接口的 Controller 层代码。 技术栈: - Node.js 18+ - TypeScript - Express - Zod - Vitest 已经确定的规则: 1. 客户端只传 title、description、priority、idempotencyKey; 2. reporterId 从登录态中读取,不允许客户端传入; 3. 工单创建时 status 必须固定为 OPEN; 4. priority 仅允许 LOW、MEDIUM、HIGH; 5. 同一 reporterId 和 idempotencyKey 重复提交时,返回第一次创建结果; 6. 不允许在 Controller 中直接写数据库逻辑; 7. 所有参数校验复用 createTicketSchema; 8. 返回格式统一为 code、data、meta; 9. 必须补充成功、参数错误、幂等重放三个测试场景; 10. 不要自行增加未定义字段和业务规则。 请先输出: 一、接口实现计划; 二、涉及文件清单; 三、测试清单; 四、可能需要人工确认的问题。 不要直接生成完整代码。这段提示词的重点不是“写得很长”。
而是把 AI 最容易脑补的地方提前堵住。
比如:
- 不允许自行增加字段;
- 不允许自行改状态规则;
- 不允许把数据库逻辑塞进 Controller;
- 不允许绕过现有校验;
- 不确定的地方先提问,不要自行判断。
十一、接口契约最容易漏掉的 5 个问题
最后补几个实际项目里非常常见,但需求初稿里经常没写的点。
1. 幂等键是按用户唯一,还是全局唯一?
如果只写“防重复提交”,AI 无法判断唯一范围。
例如:
同一用户 + 同一接口 + 同一幂等键唯一和:
整个系统内幂等键全局唯一实现方式完全不同。
2. 枚举值是否允许以后扩展?
例如现在优先级只有:
LOW / MEDIUM / HIGH以后可能加入:
URGENT前端和后端要提前约定:遇到未知枚举时,应该拒绝、降级显示,还是允许透传。
3. 删除到底是物理删除还是逻辑删除?
很多 AI 生成的 CRUD 接口会直接:
DELETEFROMticketsWHEREid=?但真实业务中,工单、订单、审批记录这类数据通常需要留痕。
这一点如果不提前写进契约,AI 很可能按最简单的实现来。
4. 时间字段使用什么时区和格式?
至少要约定:
接口统一返回 ISO 8601 UTC 时间; 前端负责按用户时区展示。否则一旦有海外用户、定时任务或跨时区服务器,排查问题会非常痛苦。
5. 接口失败时是否需要 traceId?
对业务复杂一点的系统,我会建议错误响应至少包含:
{"code":"INTERNAL_ERROR","message":"系统暂时无法处理请求,请稍后重试。","traceId":"trace_01JZK2N5M8A"}用户不用看到内部报错细节,但开发者可以通过traceId在日志系统里追踪请求。
这个规则越早定,后面越省事。
结尾
AI 写接口真正节省的,不是把几十行 Controller 自动补出来。
真正省时间的,是让它在一套明确契约里完成重复劳动:
- 根据字段字典生成校验;
- 根据请求响应生成 DTO;
- 根据错误码补齐异常处理;
- 根据状态机限制非法操作;
- 根据测试清单补边界用例;
- 根据现有代码结构输出最小改动方案。
接口规则不清的时候,AI 生成得越快,后续返工可能越快。
先把字段、返回值、错误码、状态流转和测试边界写清楚,再让 AI 写代码,才是更稳的 AI 编程工作流。
工具怎么用才是重点。后续如果长期使用 ChatGPT Plus、Claude Pro、Grok、Gemini Advanced、Cursor、Kiro 等工具,也可以了解 gpt985.com;它是第三方 AI 会员充值平台,可作为订阅充值流程的参考入口之一,不是上述工具的官方网站或授权合作方。使用前建议看清套餐说明、账号要求和售后规则。