做 Web 项目最磨人的环节,不是写代码,而是联调。写后端的人觉得接口都给你了,照着调就行;写前端的人觉得文档一句话带过,字段含义全靠猜。两边一来一回,几个星期的排期就这么磨掉了。我们团队做乐淘(LeTao)这个 Web 商城项目的时候,特意在开工之前就把前后端对接的接口文档立起来当“合同”用,后面所有开发、测试、联调都围着它转。今天就把这份文档的核心设计、关键接口约定和联调时踩过的坑整理出来,给正在做类似前后端分离项目的同学一个参考。
LeTao 是一个典型的 B2C 电商 Demo 项目,前端用的 Vue 全家桶,后端是 Spring Boot + MyBatis,中间走 RESTful 接口。这类项目的接口文档如果只是把 URL 列出来、参数写一遍,那跟没写差不多。真正好用的接口文档,得把数据格式、状态码、鉴权方式、分页规则、错误处理全部定死,让前后端各写各的也不会跑偏。下面我按我们实际落地的方案,把这份文档的内容和背后的设计思路完整拆一遍。
1. 项目背景与接口文档的整体设计思路
1.1 为什么在开发前先定接口文档
很多小团队做项目习惯先让后端把表建好、接口写完,再丢给前端去对接。这种模式在一个人全栈搞的时候没问题,但一旦前后端是两个人甚至两组人,就必然出现“前端等后端接口”或者“后端做完接口前端改三版”的情况。根源在于接口没有提前定好,或者定了但不够细。
LeTao 项目的做法是:先梳理业务模块,把用户、商品、购物车、订单这几条主链路画出来,然后花两天时间把接口文档完整写出来。这份文档不仅仅是“路径 + 参数”的清单,而是把所有字段类型、取值范围、嵌套结构、异常码都明确下来。前端同学拿到文档可以直接开 Mock 数据写页面,后端同学专心按文档实现,联调阶段基本就是走个过场,没有出现为了一个字段名争半天的情况。
1.2 RESTful 风格与接口命名约定
接口风格选了 RESTful,原因是它够直观。POST 表示创建,GET 表示查询,PUT 表示整体更新,DELETE 表示删除,一眼就能看出接口是干嘛的。比如商品列表是GET /api/products,创建订单是POST /api/orders,语义清楚,前端也不用去看长长的文档才能猜出接口用途。
但 RESTful 风格有个容易踩的坑:资源嵌套过深。有些团队喜欢把所有接口都挂到/api/user/order/detail/{id}这种路径下,看起来有层级感,实际上维护起来很痛苦。我们在 LeTao 里定了一条规矩——URL 里最多两层资源嵌套,超过两层的就扁平化或者直接用查询参数表达。比如“查询某个用户的所有订单”,用/api/orders?userId=xxx而不是/api/user/{userId}/orders。这样路径短、直观,后端路由也简单。
1.3 文档的呈现形式与维护节奏
接口文档用的是社区常见的 Markdown 格式,放在项目仓库的docs/api.md里,和代码一起管版本。为什么不用 Swagger?我们当时考虑过,Swagger 自动生成确实方便,但有个问题是它绑定代码实现,接口逻辑一旦变动,文档和代码容易“互相打架”。而 Markdown 文档是人为维护的,谁改了接口必须同步改文档,这个契约是强制的。配合 Postman Collection 和 Mock Server,整个流程很顺畅。
2. 基础约定与公共数据结构
2.1 基础 URL 与环境区分
LeTao 项目分了开发、测试、生产三套环境,基础 URL 分别是:
| 环境 | 基础 URL |
|---|---|
| 开发环境 | http://dev.api.letao.local |
| 测试环境 | http://test.api.letao.com |
| 生产环境 | https://api.letao.com |
前端在项目里通过环境变量切换 baseURL,后端则通过 Nginx 配置按请求头或域名转发。关键点是:接口路径中不要写死环境名或版本号前缀,比如/api/v1/products这种带环境或版本的设计,我们后来发现会导致测试和生产环境切换时还要改路径,非常麻烦。版本控制交给路径中的/api前缀和请求头中的Accept-Version来做就好。
2.2 统一响应包装
所有接口的响应体都统一用同一个 JSON 结构包装,避免前端对接每个接口都要写不同的解析逻辑。LeTao 的响应结构定成:
{ "code": 0, "message": "success", "data": {} }其中code是业务状态码,0表示成功,非0表示失败;message给前端提示文案用;data是真正的业务数据,可能是对象,也可能是数组,也可能是null。
这个结构的核心价值在于前端可以用统一拦截器处理错误。无论哪个接口报错,前端只要判断code !== 0,就统一弹出message提示,不需要每个接口单独写错误处理。为了这个效果,当时我们还特意要求后端——业务异常时 HTTP 状态码统一返 200,只有系统级异常才用 500。这样做的原因是,前端对 HTTP 状态码的感知远不如对业务码的感知直观,与其让前端在各种 400、500 之间做判断,不如后端把业务错误收敛到code字段里。
2.3 状态码语义化设计
业务状态码不是随便起的,LeTao 的状态码分了几段,每一段代表一类错误,这样看到码就知道问题出在哪:
| code 范围 | 含义 | 典型码 |
|---|---|---|
| 0 | 成功 | 0 |
| 10000 - 19999 | 通用错误(参数校验失败、未授权等) | 10001 参数错误,10002 未登录 |
| 20000 - 29999 | 用户相关错误 | 20001 账号不存在,20002 密码错误 |
| 30000 - 39999 | 商品相关错误 | 30001 商品不存在,30002 库存不足 |
| 40000 - 49999 | 订单相关错误 | 40001 订单不存在,40002 订单状态非法 |
分段设计的好处是,排查问题时能快速定位大类,还没看日志就能猜到大概是哪一层的错误。这个经验是从实际排障中总结出来的——第一版 LeTao 的状态码是随手编的,结果联调时看到4001还得去翻文档才知道是商品问题还是订单问题,后来才按业务域重排。
2.4 鉴权方案与 Token 传递方式
LeTao 的登录鉴权用的是 JWT(JSON Web Token)。流程是:用户登录成功后,后端签发一个有效期 2 小时的 Token 返回给前端;前端在后续请求的Authorization请求头里带上Bearer <token>。
这里有个细节很多项目会遗漏——Token 过期前需要静默刷新。我们给前端定了一个约定:当前端收到code: 10002(未登录或 Token 过期)时,不直接跳转登录页,而是调用/api/auth/refresh用长期有效的 Refresh Token 换取新的 Access Token,换成功就重放原请求,换失败再跳登录页。这套方案实现后,用户在实际使用中基本感知不到 Token 过期,体验好了很多。
2.5 分页、排序与字段命名约定
列表接口统一用page、pageSize两个查询参数,一页默认 20 条,最大不超过 100 条。响应里除了data是列表数组,还会返回总条数total,用于前端分页组件的渲染。
字段命名上,前后端约定统一使用小驼峰风格,比如userId、productName、createTime,避免后端user_id转前端userId这种不必要的转换。这个点看似小事,但 LeTao 第一版就是因为后端用了下划线命名、前端用驼峰引发了一大堆对接 bug。后来我们直接在文档里写死:所有 JSON 字段一律小驼峰,后端返回前统一做一次命名转换。
3. 核心业务模块的接口设计
3.1 用户模块接口
用户模块是 LeTao 的基础模块,核心接口有这么几个:注册、登录、获取当前用户信息、修改个人资料、修改密码。
注册接口的路径和请求体设计为:
POST /api/auth/register请求体:
{ "username": "leTao_user_001", "password": "plain_password_123", "phone": "13800138000", "code": "123456" }这里有个细节:密码字段在后端接到的其实是经过前端 RSA 加密后的密文,而不是明文。文档里特别标注了这一点,因为请求体的示例必须和实际格式一致,否则前端会用明文传,后端解密时直接报错。第一次联调时我们忽略了这个标注,前端拿着示例写的代码,结果上线前的安全测试发现了问题,之后才在文档里用醒目标注写明“所有涉及密码的接口,password 字段必须为非对称加密后的密文”。
登录接口是POST /api/auth/login,成功后的响应:
{ "code": 0, "message": "success", "data": { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 7200, "userInfo": { "userId": 101, "username": "leTao_user_001", "avatar": "https://cdn.letao.com/avatar/101.png" } } }很多前后端团队在“登录成功后到底返回什么”这个问题上经常含糊。LeTao 的做法是,登录接口一次性返回 Access Token、Refresh Token 和用户基本信息,前端不需要再额外调一次获取用户信息的接口。这样少了来回通信,页面跳转也更快。
3.2 商品模块接口
商品模块是 LeTao 的核心展示模块,最常被调用的接口是商品列表和商品详情。
商品列表接口设计为:
GET /api/products?categoryId=101&keyword=手机&sort=price_asc&page=1&pageSize=20查询参数很多,所以文档里对每个参数都做了表格说明:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| categoryId | Integer | 否 | 分类 ID,不传表示全部分类 |
| keyword | String | 否 | 搜索关键词,模糊匹配商品名称 |
| sort | String | 否 | 排序方式:default、price_asc、price_desc、sales_desc |
| page | Integer | 是 | 页码,从 1 开始 |
| pageSize | Integer | 是 | 每页条数,默认 20,最大 100 |
商品列表响应的data结构是:
{ "total": 156, "list": [ { "productId": "P1001", "productName": "复古蓝牙音箱", "mainImage": "https://cdn.letao.com/images/P1001/main.jpg", "price": 239.00, "sales": 1024, "stock": 86 } ] }列表接口明确要求:列表只返回核心字段,详情字段不放在列表里。比如商品详情页的富文本介绍、规格参数、售后说明这些大数据量字段,都不在列表接口返回,必须通过详情接口单独获取。这样做是为了控制列表接口的响应体积,提高首屏加载速度。前端如果图省事希望后端把详情也带出来,我们在文档里是坚决拒绝的,该拆的接口必须拆,不然列表越滚越慢。
商品详情接口是:
GET /api/products/{productId}详情里不仅包含封面图、价格等基础信息,还有多图列表imageList、规格specList和详情描述detail。注意这里的规格是一个数组,每个规格项包含规格名和可选值,比如“颜色”对应“黑色、白色、蓝色”。这种结构前端渲染规格选择器时非常自然,不需要再自己拼装数据。
3.3 购物车模块接口
购物车模块在 LeTao 里使用频率很高,但接口设计比订单简单。主要是四个接口:添加购物车、修改购物车商品数量、删除购物车商品、查看购物车。
添加购物车的接口设计为:
POST /api/cart/items请求体:
{ "productId": "P1001", "specId": "S2001", "quantity": 1 }这里要特别讲一下specId。LeTao 的商品规格不是把整个 JSON 塞给后端,而是每种规格组合在后端维护成一条规格记录,有一个独立的 ID。这样做的好处是,前端添加购物车时只需要提交specId和数量,后端根据specId定位到具体规格、价格和库存,避免了前端传一坨规格文本导致的数据不一致问题。这个方案是在项目设计文档评审时定下来的,实际联调中几乎没有因为“规格选错了”产生纠纷。
查看购物车接口响应里,每个商品项会带上“是否选中”的状态字段selected,因为前端结算按钮需要统计选中商品的总价。如果这个字段设计在商品实体里,就没法表达“同一商品在不同时间加入购物车”的差异性了,所以它必须挂在购物车条目上。
3.4 订单模块接口
订单模块是整个 LeTao 里最容易出问题的模块,因为涉及库存扣减、状态流转和支付流程。订单核心接口是:创建订单、支付、查询订单列表、订单详情、取消订单。
创建订单的接口设计:
POST /api/orders请求体:
{ "items": [ { "cartItemId": 1, "quantity": 1 } ], "receiverName": "张三", "receiverPhone": "13800138000", "receiverAddress": "某省某市某区某街道某号", "remark": "尽快发货" }创建订单的响应里有一个关键设计——orderId和payParams。创建订单成功后不会立即扣减库存,而是先创建订单,返回待支付状态的订单数据和一个支付参数对象,前端拿着payParams去调支付组件。这样设计的原因在于:如果创建订单就扣库存,用户无奈放弃支付后库存就白白锁住了。所以 LeTao 的库存扣减是分成两步:订单创建时锁定库存(防止超卖),支付成功后才真正扣减;订单超时未支付则自动释放锁定库存。
这里涉及一个前后端很容易扯皮的点——订单超时自动取消。订单创建后 15 分钟未支付,系统会自动取消并释放库存。前端必须监听订单状态变化,在轮询订单详情时发现状态变成“已取消”要主动提示用户。这个逻辑如果只在文档里写一句话,前端很容易忽略,所以我们专门在文档的“订单状态说明”部分用一个表格列了所有状态枚举:
| 状态值 | 状态名 | 含义 | 前端展示 |
|---|---|---|---|
| 0 | 待支付 | 订单已创建,未支付 | 去支付 |
| 1 | 已支付 | 支付成功,待发货 | 等待发货 |
| 2 | 已发货 | 商家已发货 | 查看物流 |
| 3 | 已完成 | 用户确认收货 | 评价 |
| 4 | 已取消 | 超时或用户主动取消 | 重新购买 |
| 5 | 退款中 | 用户申请退款 | 退款进度 |
有了这张表,前端处理订单状态流转就不慌了,后端也不用反复解释“这个状态下用户能不能点取消”。
3.5 支付与回调接口
支付是电商项目里最敏感的一环。LeTao 对接的是某个通用的第三方支付模拟服务,后端负责生成支付参数,前端负责唤起支付。整个支付流程的接口约定是:
- 前端调用创建订单接口,拿到
payParams; - 前端用
payParams唤起支付收银台; - 用户支付成功,第三方服务异步通知后端
/api/payments/notify; - 后端收到通知后更新订单状态,返回
success给第三方服务。
这里有一个关键约定:支付结果不能以前端收到的消息为准,必须以后端收到异步通知为准。在实际开发中,前端遇到的问题是支付回调可能延迟,用户在支付成功后页面没有立刻响应。我们给前端定的交互是:支付完成后不立即跳转,而是轮询查询订单状态,直到状态变为“已支付”再跳转支付成功页,超时则提示“支付结果确认中,请稍后查看订单列表”。这个兜底方案很实用,避免了“我明明付了钱但页面还显示待支付”造成的客诉。
4. 联调阶段的常见问题与排查实录
4.1 跨域问题:CORS 配置不当
联调第一天,前端就反馈所有请求都被浏览器拦截,控制台报 “CORS policy” 错误。排查后发现是后端的 CORS 配置只允许了http://localhost:8080这一个来源,而前端实际开发服务器跑在http://localhost:3000。这个场景非常典型,文档里虽然写了“开发环境需允许跨域”,但没写具体的允许来源清单。
解决办法是,后端的 CORS 配置改为从配置文件读取白名单列表,开发环境放行localhost下的所有端口,用正则匹配来源。生产环境则严格限制为正式域名。这个经验告诉我们,跨域配置要从项目启动第一天就放入配置中心,而不是写死在代码里。
4.2 参数命名不一致引发的字段丢失
联调时遇到一个诡异的问题:前端提交创建订单请求,后端一直报receiverName为 null。追了半天发现,后端实体类里定义的字段名是receiver_name(因为数据库列是下划线命名),而接口文档里写的是receiverName(小驼峰)。前端的 JSON 按文档传,后端按下划线字段接收,结果就是所有多词拼接的字段全部丢失。
这个问题的根治方案就是我前面提到的——全项目统一小驼峰命名,后端在全局配置里开启 MapUnderscoreToCamelCase,数据库下划线字段自动映射为驼峰属性。这样前端传什么,后端就收到什么,不会再出现命名错位。这件事后来被写进了 LeTao 的接口文档第一章,作为“红线约定”。
4.3 时间字段的时区与格式问题
订单列表里的createTime字段,后端返回的是2023-07-21T10:30:00Z(UTC 时间带时区偏移),前端直接拿来展示,结果页面显示的时间比本地时间慢了 8 小时。这里的问题是后端没有按约定返回“东八区时间”。
我们最终定的方案是:后端所有时间字段一律返回 Unix 时间戳(毫秒级),前端在展示时根据本地时区格式化成YYYY-MM-DD HH:mm:ss。这样从根本上规避了时区差异,前后端不会再因为格式化问题来回扯皮。文档里特别强调了这个约定,并且对所有返回时间字段的接口都做了示例说明。
4.4 列表接口的边界情况处理
商品列表接口在 “keyword 为空但有 categoryId” 和 “keyword 为空且 categoryId 为空” 两种场景下,后端返回的结果竟然不一样。前者正常返回分类下的商品,后者返回了全部商品,但前端以为空关键词必须返回空列表。排查后发现,后端的 SQL 在关键词为空时拼接了WHERE 1=1,而分类为空时没有拼接任何条件,导致查询逻辑不一致。
这类问题的经验是,接口参数的可选组合必须在文档里写清楚,比如“keyword 和 categoryId 都为空的请求可视为查询全部商品,返回推荐列表”。如果没有这个约定,前端会根据自己的业务语义添加一层可能错误的理解。LeTao 项目在后续接口评审时立了一个规则:凡是有两个及以上可选参数的接口,必须写明参数组合的边界处理方式。
4.5 联调工具与流程建议
最后说说联调的工具链。LeTao 项目用 Postman 管理接口用例,每个接口都有对应的 test case,前置脚本里自动从登录接口提取 Token 并设置到环境变量里。后端每完成一个接口,就同步把 Postman 用例跑通,前端在 Mock Server 没起来之前也能用 Postman 的 Mock 端点联调。
推荐的联调节奏是:
- 后端按接口文档完成核心接口,逐个在 Postman 里过一遍,保证响应格式完全符合文档;
- 前端在 Mock Server 上按文档字段开发页面,不依赖后端进度;
- 每周安排两次集中联调日,前后端坐在一起,按业务链路从登录到下单完整走一遍;
- 联调发现的所有问题,统一登记到项目看板,标明“接口文档是否需要修改”,文档变更走评审,不随意改。
这套流程走下来,LeTao 前后端联调用了两周就全部完成,比预期排期还快了两天。我个人在实际操作中最大的体会是:接口文档不是给谁看的“作业”,而是前后端之间的一份契约,写得越细、改得越严,后面联调时扯的皮就越少。如果你们团队还在为接口问题反复沟通,不妨先把文档立起来,把字段、格式、状态码都钉死,再动代码。这个习惯养成后,后面再做第二个第三个项目,会顺畅非常多。