前端部署在 Vercel、后端自建在云服务器,两个团队隔着一层网络协作——这是前后端分离开发的基本盘。上线第一个月,”接口对不上”成了返工最集中的原因——后端返回 model_code,前端拿 modelCode 取值拿到 undefined;后端觉得加了必填字段没通知,前端一调就崩。这种问题查起来特别磨人,因为两边都觉得自己没错。后来我们搭了一套”OpenAPI 自动生成 + TypeScript 全量类型 + 契约测试 + 联调流程”的组合方案,把类型安全从前端到后端、从编译期到 CI 全程约束起来。下面把这套链路完整拆开讲。
一、协作模式
先想清楚一个前提:前后端协作的痛点不在”沟通”,而在”契约不透明”。代码先写、文档后补的老路走不通,因为文档永远是滞后的。我们把顺序彻底调转,改成”接口定义先行”。
这套思路我们一开始也不适应。老习惯是先写代码,跑通了再补接口文档,文档里写的字段经常和真实返回对不上,前端照着文档写,一调就 404 或者字段取出来是 undefined。改成接口定义先行之后,变化的是”讨论”被提前了:字段叫什么、必不必须、返回什么结构,在写第一行业务代码前就被双方来回改过好几轮,真正写代码时反而没什么可吵的。
1. 接口定义先行
任何新接口,先写 OpenAPI 规范(YAML),双方 review 通过再写代码。这一步看起来多花了半天,实际省下的是后面几天联调的时间。
# api-spec/eval-session.yaml
openapi: 3.0.3
info:
title: 评测平台 API
version: 1.0.0
paths:
/api/eval/session:
post:
summary: 创建评测
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/EvalCreateRequest'
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/BaseResponse_EvalSession'
components:
schemas:
EvalCreateRequest:
type: object
required: [question, modelCodes]
properties:
question:
type: string
minLength: 1
modelCodes:
type: array
items: { type: string }
minItems: 1
EvalSession:
type: object
properties:
id: { type: integer, format: int64 }
question: { type: string }
status:
type: string
enum: [pending, running, done, error]
items:
type: array
items: { $ref: '#/components/schemas/EvalItem' }
这段 spec 解决”创建评测”这一个接口的契约问题:请求里必须带 question 和至少一个 modelCodes,响应结构也一次性定死。字段的 required 列表尤其重要,漏写一个 required,生成出来的类型就把必填当成了可选,等于埋雷。
平台把这个文件放 api-spec/ 目录,前后端共用。改成这种方式之前,我们常因为”我觉得字段是这么传的”吵起来,现在一切以 spec 为准,谁有疑问就翻文件。
2. 后端实现
后端用 Spring Boot + Knife4j 实现接口,注解同时生成 OpenAPI,保证 spec 和实现不脱节。
@Tag(name = "评测")
@RestController
@RequestMapping("/api/eval")
public class EvalSessionController {
@Operation(summary = "创建评测")
@PostMapping("/session")
public BaseResponse<EvalSession> create(
@RequestBody @Valid EvalCreateRequest req) {
return ResultUtils.success(evalService.create(req));
}
}
@Schema(description = "创建评测请求")
public class EvalCreateRequest {
@Schema(description = "问题", requiredMode = RequiredMode.REQUIRED)
@NotBlank
private String question;
@Schema(description = "模型编码列表", requiredMode = RequiredMode.REQUIRED)
@NotEmpty
private List<String> modelCodes;
}
这里有个易错点:@Schema 里的 requiredMode 和 @NotBlank 这类校验注解尽量保持一致,否则 spec 说可选、校验却强制,前端按 spec 生成了可选类型,调用时漏传就 400。我们统一以”注解为唯一事实来源”。
Knife4j 启动后 /v3/api-docs 返回完整 OpenAPI spec。前端直接从这个地址拉取,保证拿到的一定是后端当前实现的版本,而不是某个私有文档里的过期内容。
3. 前端自动生成类型
前端用 openapi-typescript 工具,把 OpenAPI spec 自动转成 TypeScript 类型文件:
npx openapi-typescript https://api.example.com/v3/api-docs -o src/types/api-generated.ts
// 生成的 types/api-generated.ts
export interface paths {
'/api/eval/session': {
post: {
requestBody: {
content: {
'application/json': EvalCreateRequest
}
}
responses: {
200: {
content: {
'application/json': BaseResponse_EvalSession
}
}
}
}
}
}
export interface EvalCreateRequest {
question: string
modelCodes: string[]
}
生成的文件是纯类型,零运行时开销,前端 import 之后 tsc 就能全程检查。手动写类型和自动生成类型的差别,我们用一张表看得很清楚:
| 对比维度 | 手写类型 | openapi-typescript 自动生成 |
|---|---|---|
| 同步时效 | 靠人自觉,容易过期 | spec 一变,重新生成即同步 |
| 字段遗漏 | 容易漏新字段 | required 全量导出 |
| 维护成本 | 前后端各维护一份 | 单一来源派生 |
| 出错时机 | 运行时才发现 | 编译期报错 |
结论是明显的:只要走 OpenAPI,类型就自动生成,手工维护的那份类型只在历史代码里存在。前端拿到类型后,字段名、可选性、枚举值全部由 spec 说了算,谁改字段谁负责更新 spec,这个闭环跑起来之后,类型对不上的问题基本绝迹。
生成类型还有一个附带好处:接口变更时,git diff 里能直接看到类型文件的变化,review 的人一眼就能判断这处改动是否影响自己负责的页面。我们后来甚至把”类型文件有变更但没跑 tsc”设成 CI 拦截条件,把编译期检查变成了团队默认动作,不用谁在群里喊话提醒。
二、API 客户端封装
类型生成了还不够,调用处也得吃到类型红利。我们封装了一个类型化客户端,把”路径 + 方法 + 请求体 + 响应体”全部和 paths 类型绑定。
// api/client.ts
import type { paths } from '@/types/api-generated'
// 提取响应类型
type GetResponse<P extends keyof paths, M extends 'get' | 'post'> =
paths[P][M] extends { responses: { 200: { content: { 'application/json': infer R } } } }
? R extends { data: infer D } ? D : R
: never
// 类型化 HTTP 客户端
async function call<P extends keyof paths, M extends 'get' | 'post'>(
method: M,
path: P,
body?: any
): Promise<GetResponse<P, M>> {
const res = await http.request({
method,
url: path,
data: body
})
return res.data.data
}
// 业务 API
export const evalApi = {
createSession: (req: paths['/api/eval/session']['post']['requestBody']['content']['application/json']) =>
call('POST', '/api/eval/session', req),
getSession: (id: number) =>
call('GET', `/api/eval/session/${id}`)
}
这段封装解决”响应体类型推断”的问题:call 的返回值从 paths 里提取出对应接口的 200 响应类型,业务侧拿到手就直接是 EvalSession,不用再手写 any 或二次断言。
调用完全类型安全:
const session = await evalApi.createSession({
question: '翻译 Hello',
modelCodes: ['gpt-4o', 'claude'] // 漏传字段会 TS 报错
})
// session 类型自动推断为 EvalSession
session.id // ✅ 有提示
session.unknowField // ❌ TS 报错
效果很直接:session.unknowField 这种拼写错误在编辑器里就标红,根本走不到联调阶段。我们团队之前习惯用 any 先顶着,现在 code review 里看到 any 就会被打回,逼着大家吃满类型红利。
三、Contract Test
类型安全管得住编译期,管不住运行时。后端某次重构把响应里的 items 改成 list,前端类型没同步,编译照样过,运行就崩。契约测试就是为这种场景设计的——前端 mock 后端响应,后端验证调用是否符合契约,两边用 Pact 对同一个”契约文件”各自验证。
// 前端契约测试
import { Pact } from '@pact-foundation/pact'
const provider = new Pact({
consumer: 'eval-web',
provider: 'eval-api'
})
describe('Eval API', () => {
test('创建评测', async () => {
await provider.addInteraction({
uponReceiving: '创建评测请求',
withRequest: {
method: 'POST',
path: '/api/eval/session',
body: { question: '...', modelCodes: ['gpt-4o'] }
},
willRespondWith: {
status: 200,
body: { code: 0, data: { id: 1, question: '...', status: 'pending' } }
}
})
const result = await evalApi.createSession({...})
expect(result.id).toBe(1)
await provider.verify()
})
})
前端测试把”期望的请求形状”和”期望的响应形状”写死在交互里,跑完 verify() 会生成一份契约文件。
// 后端契约验证
@PactFolder("../pacts")
@Provider("eval-api")
public class EvalApiContractTest {
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void pactVerificationTest(PactVerificationContext context) {
context.verifyInteraction();
}
}
后端拿着同一份契约文件验证真实实现,如果字段形状对不上,测试直接失败。这一套跑下来的价值,是”接口偷偷改”这件事从人为察觉变成了 CI 拦截。
CI 上跑:前后端契约测试都通过才算”接口对齐”。我们最初觉得加契约测试是负担,但经历过一次后端把响应包了一层 data 导致前端全挂的事故后,没人再提”要不要去掉”。
对比过才明白,契约测试和单元测试的定位不一样。单测管的是”我的代码对不对”,契约测试管的是”我们俩的约定有没有被破坏”,前者发现不了对方偷偷改结构,后者专门盯着这一点。初期我们觉得多维护一份契约文件是负担,后来发现它把”接口偷偷改”从靠人盯变成了机器拦截,这笔账怎么算都划算。
四、Mock 数据
前端开发不能等后端就绪。我们用 MSW(Mock Service Worker)拦截请求,在浏览器层面返回 mock 数据,开发期完全不依赖后端。
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.post('/api/eval/session', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
code: 0,
data: {
id: 12345,
question: body.question,
status: 'pending',
items: []
}
})
})
]
这个方案的妙处在于 mock 数据是照着 spec 写的,前端开发时用的字段结构和真实接口完全一致,等后端好了切个开关就无缝换真接口,不用改一行业务代码。
开发期完全不需要后端。刚开始我们给前端同学准备的是”后端写死几个假接口”,前端等后端部署才能跑起来;切到 MSW 后,前端接口被规范约束着 mock,反而比早期更贴近真实结构。
五、联调流程
工具链解决了”类型对不对”,流程解决”什么时候该做什么”。我们把新接口从需求到上线的路径拆成了 11 步,每一步都有明确的产出物和负责人:
1. PM 写需求
2. 后端出 OpenAPI spec(YAML)
3. 前端 review spec,提字段调整
4. 双方 review 通过
5. 后端实现 + 单测
6. 前端实现(用 mock 数据)
7. 联调(前端连后端 dev 环境)
8. 双方 code review
9. 部署 staging
10. QA 测试
11. 部署生产
每一步都明确,没有”接口对不上”这种含糊问题。注意第 3、4 步——字段调整在 spec 阶段就消化掉,这是整条流程最省时间的一环。后来我们甚至规定 spec review 必须双方在同一个文档里留 comment,杜绝”微信里说改就改了,别人不知道”。
六、版本管理
接口有 breaking change 时,我们走 URL 路径版本化(/api/v2/...),这是最直观、兼容性负担最轻的方案。
/api/v1/eval/session
/api/v2/eval/session # 新版
老版本保留 6 个月,前端逐步迁移。这个 6 个月的窗口给了两边足够的缓冲,不用某次发布全体联动。
非 breaking change(加字段、字段可选)直接用,不用版本号。我们内部的判断标准就两条:字段是否新增(允许)、字段是否必填/改名/改类型(禁止)。标准定清楚后,版本号涨不涨不再靠拍脑袋。
版本化方案我们也对比过几种。Header 里塞版本号、Query 参数带 version、URL 路径分级,三种都试过,最后留下 URL 路径版本化,原因很朴素:它肉眼可见,抓包、看文档、配网关都直观,不需要额外约定传递规则。代价是 URL 会变长,但对内部接口来说,这点代价换来的是两侧都少背一套约定。
七、错误码规范
前后端约定统一错误码,前端按业务码分派处理逻辑,而不是靠字符串匹配错误信息。
// 前端按业务码做不同处理
if (res.code === 40100) {
router.push('/login') // 未登录
} else if (res.code === 40300) {
ElMessage.error('无权限')
} else if (res.code === 42900) {
ElMessage.error('调用过于频繁')
} else {
ElMessage.error(res.message || '系统异常')
}
错误码字典存 docs/error-codes.md,所有错误码集中维护。这个文档我们起初没写,前端同事遇到 40300 靠猜,后来把字典和代码对齐,谁新增错误码都要在文档里补一行,前端处理逻辑才不会漏。
错误码用数字还是字符串,我们也纠结过。字符串可读性好,但前端分支判断容易拼错,而且错误信息一旦改了措辞,前端逻辑可能跟着失灵。数字码配合字典文档,前端只按码分派,文案展示单独走 i18n,两边各改各的,互不牵制。
八、TypeScript 共享类型
有一部分类型不属于某个接口,而是业务领域概念(评测会话、评测项)。我们把它提取到独立 package,前后端共用。
packages/
├── shared-types/ # 前后端共享类型
│ ├── src/
│ │ ├── index.ts
│ │ ├── model.ts
│ │ └── api.ts
│ └── package.json
├── web/ # 前端
└── server/ # 后端
共享类型不依赖任何框架,纯 TS interface:
// packages/shared-types/src/model.ts
export interface EvalSession {
id: number
question: string
status: 'pending' | 'running' | 'done' | 'error'
items: EvalItem[]
}
export interface EvalItem {
id: number
modelCode: string
output: string
score?: number
}
这个 package 是纯类型包,不引入运行时依赖,前端和后端都能直接引用。使用场景要克制:只有跨端共享的领域模型放进来,UI 状态、临时计算结果的类型留在各自项目里,否则共享包会变成垃圾桶。
后端用 Jackson 序列化为 JSON,前端直接用。类型定义一次,两边消费,至少省掉了双份维护。
九、与后端的接口约定
除了类型,还有一批”隐性约定”:命名风格、时间格式、数字精度。这些不写在 spec 里,但写错一样出事故。
1. 字段命名
后端用 snake_case(数据库风格),前端用 camelCase(JS 风格)。后端 Controller 层做转换:
@JsonProperty("modelCode") // 给前端 camelCase
private String model_code; // DB 字段是 snake_case
或者后端全 camelCase 输出,前端直接用。我们最终选了后者——统一 camelCase,省掉双向转换的中间环节,前端拿到的字段名和 TypeScript 类型完全一致。数据库里的 snake_case 只活在 DAO 层,出了 Controller 就是 camelCase,边界清晰。
这一步没做好,代价比想象中大。早期有一段接口后端直接吐 snake_case,前端拿到手还得写一层 map 函数做驼峰转换,页面多了之后这种 map 越写越脏,命名约定成了隐性债务。统一 camelCase 之后,新增接口不需要任何转换层,生成类型即用即走,新同学接手也不用先问”这个字段为什么长这样”。
2. 时间格式
统一 ISO 8601:
2026-08-20T14:30:00+08:00
后端用 @JsonFormat(shape = JsonFormat.Shape.STRING) 控制。踩过的坑是有的接口返回毫秒时间戳,有的返回 ISO 字符串,前端解析器换来换去,统计折线图时差八个小时。定死一种格式后,所有历史接口逐步迁移,新接口一律 ISO 8601。
3. 数字精度
金额用字符串返回,避免 JS 精度丢失:
@JsonSerialize(using = ToStringSerializer.class)
private BigDecimal fee; // 输出 "12.34" 而非 12.34
JS 的 Number 对 0.1 这类小数的表示有误差,金额累加多次后会出现 12.339999999。字符串返回后前端解析成 decimal 库或直接展示,都不会出问题。这条约定是从一次对账事故里逼出来的。
十、CI 中的接口检查
本地能过不算数,CI 里跑通了才算”接口对齐”。我们的 CI 加了三个检查环节:
# GitHub Actions
- name: Backend OpenAPI generate
run: |
cd server
mvn spring-boot:run &
sleep 30
curl http://localhost:8080/v3/api-docs > openapi.json
- name: TypeScript type check
run: |
cd web
npx openapi-typescript ../openapi.json -o src/types/api-generated.ts
npx tsc --noEmit
- name: Contract test
run: |
cd web
pnpm test:contract
这套 CI 的作用是”让规范成为单一事实来源,前后端都从它生成代码”。后端每次提交,先起服务拉真实 spec,再让前端重新生成类型并跑 tsc;前端每次提交跑契约测试。任何一边动了接口而不改 spec,CI 当场拦截,把错误挡在合并之前。
这套 CI 刚上线时也翻过车:后端服务启动慢,sleep 30 秒不够,spec 拉下来是空的,类型生成直接失败。后来改成先健康检查再拉取,加了个重试循环,稳定性才上来。回头看,CI 里跑的每一步都是”人与规范之间的保险丝”,越早把检查自动化,就越少靠人肉保证。
十一、踩过的坑
把散落在各节的经验集中成一份避坑清单:
- 字段对不上:后端返回
model_code,前端用modelCode拿到 undefined。统一命名约定。 - 时间格式混乱:后端返回时间戳,前端期望 ISO 字符串。统一格式。
- 金额精度:后端返回 12.34 数字,前端累加变 12.339999999。后端用 string。
- 空值语义:
nullvsundefinedvs 缺失字段。约定清楚。 - 分页字段:
page/sizevsoffset/limit。统一一套。 - 错误信息:后端返英文,前端直接显示。统一中文 + code。
- breaking change 偷偷上线:后端加必填字段,前端崩溃。CI 跑 contract test 拦截。
这些坑的共同点是:单个都看着小,但都会让”接口对不上”重现。我们的经验是别靠自觉,全部落到工具和规范里,人只负责按流程走。
十二、落地实践清单
最后把整套链路收成可执行的清单:
- 任何新接口先写 OpenAPI 规范;
- 前后端都用规范生成代码(不手写);
- 类型共享在 packages/shared-types;
- Pact 契约测试在 CI 跑;
- breaking change 用 URL 版本号;
- 错误码统一管理;
- 字段命名 / 时间格式 / 数字精度有规范文档。
按这个清单执行,前后端协作从”靠吼”变成”靠流程”。到这里,评测平台前后端全链路类型约束就闭环了,剩下的是执行问题,不是方案问题。
常见问题(FAQ)
Q1:自动生成类型可靠吗?
可靠。openapi-typescript 是事实标准,主流公司都在用。
Q2:什么时候手写类型?
非 API 类型(UI state、临时计算结果)手写。
Q3:契约测试值得做吗?
值得。前后端发布独立,契约测试保证不会”接口偷偷改”。