去年我们线上出过一次事故:后端把 dueAt 从 string 改成 Date,OpenAPI 没更新,前端仍按字符串渲染,结果任务列表在 iOS Safari 上直接白屏 27 分钟,影响 1.2 万用户。复盘时发现,问题不在某个框架,而在契约没有版本、没有兼容性门禁、没有端到端校验。这篇文章只讲我们后来怎么把这类问题在 CI 阶段拦住。
我们选 Zod 而不是 io-ts,原因是 z.infer 推断更直观,且 zod-to-openapi 能直接生成文档。ts-rest 负责把契约绑定到 Fastify 和 React Query,避免两端各写一份类型。
// packages/contracts/src/task.ts
import { z } from 'zod';
import { initContract } from '@ts-rest/core';
const c = initContract();
export const TaskSchema = z.object({
id: z.string().uuid(),
title: z.string().trim().min(1).max(120),
status: z.enum(['todo', 'doing', 'done']),
dueAt: z.coerce.date().nullable(), // 明确 nullable,禁止 undefined
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type Task = z.infer<typeof TaskSchema>;
export const taskContract = c.router({
create: {
method: 'POST',
path: '/api/tasks',
body: TaskSchema.pick({ title: true, dueAt: true }),
responses: {
201: TaskSchema,
422: z.object({ code: z.literal('VALIDATION_ERROR'), issues: z.unknown() }),
},
},
});契约变更必须过 CI。我们用 zod-to-openapi 生成 openapi.new.json,和主干上的 openapi.base.json 做 oasdiff breaking,ERR 级别直接阻断合并。
# .github/workflows/contract.yml
- run: pnpm tsx scripts/gen-openapi.ts > openapi.new.json
- run: |
oasdiff breaking openapi.base.json openapi.new.json \
--fail-on ERR --format githubactions规则很硬:删除字段、收窄类型、新增必填字段 = ERR;新增可选字段 = WARN 可合。过去半年拦下 3 次破坏性变更,其中一次就是把 dueAt 从 nullable 改成 required。
全栈工程师容易忽略 POST 的幂等。用户双击、网络重试、移动端切后台再回来,都会重复创建。我们用 Idempotency-Key + Redis 做 24 小时去重。
// apps/api/src/plugins/idempotency.ts
export const idempotency: FastifyPluginAsync = async (app) => {
app.addHook('preHandler', async (req, reply) => {
if (req.method !== 'POST') return;
const key = req.headers['idempotency-key'];
if (typeof key !== 'string' || key.length < 16) {
return reply.code(400).send({ code: 'IDEMPOTENCY_KEY_REQUIRED' });
}
const scope = `${req.user.id}:${req.routeOptions.url}:${key}`;
const cached = await app.redis.get(scope);
if (cached) {
const { status, body } = JSON.parse(cached);
return reply.code(status).send(body);
}
reply.header('x-idempotency-scope', scope);
});
app.addHook('onSend', async (req, reply, payload) => {
const scope = reply.getHeader('x-idempotency-scope');
if (typeof scope === 'string' && reply.statusCode < 500) {
await app.redis.set(scope, JSON.stringify({
status: reply.statusCode,
body: payload,
}), 'EX', 86400);
}
return payload;
});
};上线后统计:每天拦截约 340 次重复创建,其中 78% 来自移动端双击。
React Query 的乐观更新必须能回滚,且要处理服务端返回的 updatedAt 覆盖本地。
export function useCreateTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CreateTaskInput) =>
fetch('/api/tasks', {
method: 'POST',
headers: {
'content-type': 'application/json',
'idempotency-key': crypto.randomUUID(),
},
body: JSON.stringify(input),
}).then(async (r) => {
if (!r.ok) throw new Error((await r.json()).code);
return TaskSchema.parse(await r.json());
}),
onMutate: async (input) => {
await qc.cancelQueries({ queryKey: ['tasks'] });
const prev = qc.getQueryData<Task[]>(['tasks']);
const optimistic: Task = {
id: `tmp-${crypto.randomUUID()}`,
title: input.title,
status: 'todo',
dueAt: input.dueAt ?? null,
createdAt: new Date(),
updatedAt: new Date(),
};
qc.setQueryData<Task[]>(['tasks'], (old = []) => [optimistic, ...old]);
return { prev };
},
onError: (_e, _v, ctx) => qc.setQueryData(['tasks'], ctx?.prev),
onSettled: () => qc.invalidateQueries({ queryKey: ['tasks'] }),
});
}引入契约门禁后,契约相关事故从每月 1.8 次降到 0;MTTR 从 27 分钟降到 4 分钟;重复创建率从 2.3% 降到 0.07%。代价是每个 PR 多 40 秒 CI 时间,以及开发者必须学 Zod 的 .nullable() 和 .optional() 区别。
全栈工程师的专业性,不是“前后端都会写”,而是在边界上建立不可绕过的约束。类型、幂等、门禁、指标,缺一不可。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。