别让 AI 直接写接口:前后端联调前,先把这 4 份契约交给它

📅 2026/7/6 3:41:18 👁️ 阅读次数 📝 编程学习
别让 AI 直接写接口:前后端联调前,先把这 4 份契约交给它

摘要

很多 AI 生成的接口代码不是不能跑,而是到了前后端联调阶段才发现字段不一致、状态定义不清、错误码没人认、边界条件没测。更稳的做法不是一句“帮我写接口”,而是先定清字段字典、请求响应、错误码、状态流转和测试要求,再让 AI 在边界内生成代码。


很多人用 AI 写接口,最常见的提问是:

帮我写一个创建工单的接口。

然后 AI 很快给出 Controller、Service、DTO、数据库实体,甚至连 Swagger 注解都顺手补齐。

看起来很高效。

但一进入联调,问题才开始出现:

  • 前端传的是priority,后端却接收level
  • 前端以为创建成功后状态是PENDING,后端默认写成了OPEN
  • 重复提交时,接口到底应该报错,还是返回第一次创建的数据;
  • 文件上传失败属于参数错误、业务错误还是系统错误;
  • 工单关闭后能不能重新打开;
  • 某个字段是必填、可选,还是“前端不传由后端自动补”;
  • AI 生成的代码能跑,但测试根本没有覆盖重复请求和非法状态流转。

这类返工,通常不是代码能力问题。

而是接口真正开始写之前,契约没有定清楚。

AI 很适合生成接口骨架、字段校验、测试样例和重复代码,但前提是你先告诉它:哪些内容已经确定,哪些内容不能自行猜测。

这篇就用一个“创建工单接口”的例子,把一套可复用的接口契约流程拆开。

核心思路只有一句:

先让 AI 读懂接口规则,再让它写接口实现。


一、先别急着写 Controller:接口真正的第一步是消灭歧义

很多接口在需求文档里只写了一句:

新增一个创建工单的接口。

这句话对于产品、前端、后端、测试来说,理解可能完全不同。

后端会想:

需要哪些字段?数据库怎么设计?是否要鉴权?

前端会想:

提交成功后跳转哪里?失败要怎么提示?状态字段怎么展示?

测试会想:

重复点击提交怎么办?标题为空怎么办?优先级传了非法值怎么办?

而 AI 会想:

我可以根据常见经验给你补齐一套实现。

问题就在这里。

“根据常见经验补齐”在 Demo 里没问题,在真实项目里却很危险。因为真实项目中,很多规则不是技术规则,而是业务规则。

例如“创建工单”这个动作,看起来很简单,至少会涉及下面这些决策:

问题不确认会带来的后果
工单标题最短几位、最长几位?前端和后端校验不一致
优先级有哪些枚举值?数据库出现脏数据
是否允许匿名提交?权限边界混乱
是否支持重复请求?用户连续点击后产生多条工单
新工单默认是什么状态?前端展示与后端逻辑冲突
工单关闭后能否重新开启?状态机无法统一
失败时返回什么错误码?前端无法给用户正确提示

所以,写代码前要先把接口变成一份“可执行的约定”。


二、一个接口至少要先定清楚这 4 份契约

我现在让 AI 写接口前,通常会先准备这四部分内容:

  1. 字段字典
  2. 请求与响应示例
  3. 错误码约定
  4. 状态流转与测试边界

这四份内容不一定要写成很厚的文档。

但必须让前端、后端、测试和 AI 看到的是同一份规则。


三、第一份契约:字段字典,不要只写字段名

先看一个容易出问题的字段定义:

title:标题 priority:优先级 description:描述

这对 AI 来说信息太少了。

它不知道priority是数字还是字符串,不知道description能不能为空,不知道title是否需要去掉首尾空格,也不知道前端是否允许传入默认状态。

更稳的字段字典应该写成这样:

字段类型是否必填规则示例
titlestring去除首尾空格后长度 3-80支付页无法提交订单
descriptionstring长度 10-2000点击提交后页面一直转圈
prioritystring仅允许LOWMEDIUMHIGHHIGH
reporterIdstring当前登录用户 ID,不由前端任意填写u_10086
idempotencyKeystring同一用户同一请求唯一,长度 8-64ticket-20260705-001
statusstring创建时由服务端固定为OPENOPEN

这里有一个很关键的细节:

不要把所有字段都开放给前端传入。

statuscreatedAtupdatedAtreporterId这类字段,很多时候应该由后端根据上下文生成,而不是让客户端自己决定。

否则 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: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);}

这段代码看起来很普通,但它有两个价值:

  1. 业务规则不再散落在多个 Controller 和 Service 里;
  2. 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

这三条测试并不复杂,但它们已经固定了三个关键业务行为:

  1. 新工单创建后必须是OPEN
  2. 相同幂等键不能重复创建;
  3. 非法字段不能进入业务层。

之后你再让 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 会员充值平台,可作为订阅充值流程的参考入口之一,不是上述工具的官方网站或授权合作方。使用前建议看清套餐说明、账号要求和售后规则。