别让 AI 直接写接口:前后端联调前,先把这 4 份契约交给它
摘要很多 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是否需要去掉首尾空格也不知道前端是否允许传入默认状态。更稳的字段字典应该写成这样字段类型是否必填规则示例titlestring是去除首尾空格后长度 3-80支付页无法提交订单descriptionstring是长度 10-2000点击提交后页面一直转圈prioritystring是仅允许LOW、MEDIUM、HIGHHIGHreporterIdstring是当前登录用户 ID不由前端任意填写u_10086idempotencyKeystring是同一用户同一请求唯一长度 8-64ticket-20260705-001statusstring否创建时由服务端固定为OPENOPEN这里有一个很关键的细节不要把所有字段都开放给前端传入。像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_ERROR400字段格式不合法直接提示表单字段错误UNAUTHORIZED401用户未登录或登录失效跳转登录页FORBIDDEN403用户无创建权限展示无权限提示TICKET_NOT_FOUND404工单不存在返回列表或刷新数据INVALID_STATUS_TRANSITION409不允许从当前状态切换提示当前状态不可操作DUPLICATE_REQUEST409未采用幂等重放策略时的重复请求禁止重复提交INTERNAL_ERROR500未预期系统异常展示兜底提示并记录 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:RecordTicketStatus,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.tsimport{z}fromzod;exportconstticketPrioritySchemaz.enum([LOW,MEDIUM,HIGH,]);exportconstcreateTicketSchemaz.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 个字符),});exporttypeCreateTicketInputz.infertypeofcreateTicketSchema;这里做了几件事输入字段只保留客户端真正需要提交的字段reporterId不在请求体内status不在请求体内标题、描述、幂等键都有最小和最大长度priority不允许传任意字符串。这样 AI 后续生成 Controller 时就不会把“参数校验”写成一堆散乱的if判断。八、Service 层负责业务规则不要把规则塞进 Controller新建src/ticket/service.tsimport{createTicketSchema,typeCreateTicketInput,}from./schema;exporttypeTicketPriorityLOW|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):PromiseTicket|null;create(ticket:Ticket):PromiseTicket;}exportinterfaceCreateTicketResult{ticket:Ticket;idempotentReplay:boolean;}exportasyncfunctioncreateTicket(rawInput:unknown,reporterId:string,repository:TicketRepository):PromiseCreateTicketResult{constinput:CreateTicketInputcreateTicketSchema.parse(rawInput);constexistingawaitrepository.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(),};constcreatedawaitrepository.create(ticket);return{ticket:created,idempotentReplay:false,};}这段代码里几个边界比较明确参数规则在schema.ts幂等逻辑在service.tsreporterId从认证上下文传入创建状态由服务端固定为OPENRepository 只负责查和存不负责猜业务规则。这就是接口契约落地之后的好处AI 不需要猜“新工单默认是什么状态”因为契约已经写死了。九、测试不是最后补的先把关键行为钉住很多人写接口时测试只测“传正确参数能不能成功”。但真正容易出问题的是边界情况。新建src/ticket/service.test.tsimport{describe,expect,it}fromvitest;import{createTicket,typeTicket,typeTicketRepository,}from./service;classMemoryTicketRepositoryimplementsTicketRepository{privatereadonlytickets:Ticket[][];asyncfindByIdempotencyKey(reporterId:string,idempotencyKey:string):PromiseTicket|null{return(this.tickets.find((ticket)ticket.reporterIdreporterIdticket.idempotencyKeyidempotencyKey)??null);}asynccreate(ticket:Ticket):PromiseTicket{this.tickets.push(ticket);returnticket;}}describe(createTicket,(){it(应创建一条状态为 OPEN 的工单,async(){constrepositorynewMemoryTicketRepository();constresultawaitcreateTicket({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(){constrepositorynewMemoryTicketRepository();constpayload{title:支付页无法提交订单,description:用户点击提交订单后页面一直转圈控制台没有明显报错。,priority:HIGH,idempotencyKey:ticket-20260705-001,};constfirstResultawaitcreateTicket(payload,u_10086,repository);constsecondResultawaitcreateTicket(payload,u_10086,repository);expect(firstResult.ticket.id).toBe(secondResult.ticket.id);expect(secondResult.idempotentReplay).toBe(true);});it(标题长度不足时应拒绝创建,async(){constrepositorynewMemoryTicketRepository();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 会员充值平台可作为订阅充值流程的参考入口之一不是上述工具的官方网站或授权合作方。使用前建议看清套餐说明、账号要求和售后规则。