REST 写腻了?vibe 项目的 GraphQL 与 tRPC 选型实战
AI 写 REST 又快又糙,改个字段名,前后端一起崩。这篇实战指南给一人 TS 团队一个明确立场:默认 tRPC,白拿端到端类型安全;只有出现多端消费、公开 API、聚合网关三个信号才上 GraphQL。含可直接跑的 tRPC + Next.js App Router 落地、GraphQL schema 优先与 codegen、AI 必踩 N+1 的 DataLoader 标准解法、公开 API 的深度限制与复杂度计分护栏、middleware vs directive 鉴权写法、选型决策树,以及 REST → tRPC 渐进式迁移 5 步 SOP。

先说一个你大概率已经踩过的坑:让 AI 给你写一套 REST 接口,它半小时给你吐出 20 个路由,Postman 一测全通,你很满意。两周后你把 Post 表的 title 改成 headline,后端改完、前端漏改一个页面,线上直接白屏——而 TypeScript 全程一声不吭,因为前后端之间隔着一层 HTTP JSON,类型在序列化那一刻就断了。最气人的是排查过程:你先怀疑是缓存,再怀疑是 AI 把组件写坏了,最后才发现是三年前(两周前)改的那个字段名。这种"改一处、崩一处、查半天"的循环,就是 REST + AI 组合的日常税。
这就是 vibe coding 时代 API 层的真实矛盾:AI 写 REST 又快又糙,类型一改,前后端一起崩。你缺的不是更多的接口,而是一层能在编译期就把"前后端对不上"拦下来的 API 设计。本篇给你一个明确立场:一人 TS 全栈团队,默认选 tRPC;同时讲清楚 GraphQL 什么时候真值得上,以及 AI 生成这两种代码时必踩的两个专属大坑(N+1 和 over-fetching),每个坑都给能直接抄的代码级解法。
本文 8 节的阅读路线:先看第 1 节的对比表和默认推荐,如果你已经在用 tRPC,直接跳到第 4、5 节看 AI 专属坑;如果在纠结要不要上 GraphQL,第 7 节的决策树是为你画的;已经有一坨 REST 想迁移的,第 8 节是 5 步 SOP。每一节的代码都是能直接跑的形状,复制粘贴后按你的表结构改字段名即可。
三协议一张表:REST / tRPC / GraphQL 的取舍
先把三者在四个维度的差异摆平,避免"我听说 GraphQL 很火"式的选型:
| 维度 | REST | tRPC | GraphQL |
|---|---|---|---|
| 类型安全 | 靠手写类型或 OpenAPI codegen,最容易漂移:后端改了字段,前端类型文件没人记得重新生成 | 端到端 TS 类型推导:router 类型直接 typeof 导出给客户端,改一个字段,编译器替你抓全链路 | schema 强类型 + codegen:类型安全在 schema 层,前后端都对 schema 负责 |
| 心智负担 | 最低,人人会,调试工具最多 | 低(仅限 TS 全栈):没有 schema 文件要维护,没有 codegen 步骤 | 高:schema 设计、resolver、DataLoader、查询护栏,每一项都是新概念 |
| 调试友好度 | curl / Postman 直接打,CDN 与 HTTP 缓存开箱即用 | HTTP 层是批量 POST,可读性差一截;靠 tRPC Panel 或客户端 devtools | GraphiQL 自文档、所见即所得;但错误藏在 data.errors 里,监控要单独做 |
| 生态成熟度 | 最高:网关、缓存、限流全是现成方案 | TS 生态内成熟(Next.js / React / Express 适配器齐全),多语言客户端弱 | 多端生态最成熟:iOS / Android / Web 客户端、网关、持久化查询都很全 |
| AI 生成友好度 | AI 写得最顺,但类型漂移的坑也最深 | AI 写 router 很顺,且改字段有编译器兜底 | AI 写的 resolver 几乎必踩 N+1(第 4 节细讲) |
给一人团队的默认推荐只有一句话:TS 全栈 + 前后端同一拨人(就是你自己)→ 默认 tRPC。REST 留给两种场景:第三方 webhook 回调、需要强 HTTP 缓存的公开读接口(这类接口的版本与废弃流程,见《API 别翻车:vibe 项目的版本管理与废弃 SOP》,本篇不重复)。GraphQL 则是"出现特定信号才上"的重武器,信号清单在第 7 节。
为什么默认 tRPC 而不是"更流行"的 GraphQL?算一笔账:GraphQL 的 schema 设计 + codegen 管线 + DataLoader + 查询护栏,一个人要搭 2~3 天,且以后每个新功能都要先写 schema 再写 resolver;而 tRPC 的类型安全是白送的——你写 router 函数的同时类型就有了。对单人团队,省下来的不是代码量,是"多一套要维护的心智模型"。
再补一个 AI 时代的特有视角:AI 写 REST 时类型漂移几乎是必然的。它的工作流是"先写 handler,再补一个 types.ts",而这两份代码之间没有任何机器可验证的链接——后端把 limit 从 number 改成 string,前端的 types.ts 不会自动变,AI 也不会主动去改一个"看起来没坏"的文件。tRPC 把"类型即实现"焊死了:类型是从 router 推导出来的,实现变了类型必变,AI 再怎么局部改代码也绕不开编译器。这是给 AI 干活划的一条硬边界:让机器检查机器,而不是让人检查机器。
最后说说 REST 的反杀场景,免得有人觉得我在开"REST 葬礼":你的 API 要暴露给 Python 脚本、Go 服务、硬件设备调用时,tRPC 的 TS 锁死就是缺点;你的读接口 QPS 很高且适合 CDN 缓存时,REST 的 GET 可缓存性是 GraphQL 和 tRPC(默认 POST)都比不了的。选型没有鄙视链,只有"谁付成本"的问题——单人团队付不起的成本,就别选。
tRPC 落地:router 定义与 Next.js App Router 集成
tRPC 的核心就三样东西:初始化、router、procedure。先看一个真实可跑的最小落地:
// server/trpc.ts —— 初始化一次,全项目复用
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<{ db: Db; session: Session | null }>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/post.ts —— 业务 router:函数即接口
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const postRouter = router({
list: publicProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(20) }))
.query(async ({ input, ctx }) => {
return ctx.db.post.findMany({
take: input.limit,
orderBy: { createdAt: 'desc' },
});
}),
create: publicProcedure
.input(z.object({
title: z.string().min(1).max(120),
body: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({ data: { ...input, authorId: ctx.session!.user.id } });
}),
});
// server/routers/_app.ts —— 拼成总 router,并导出类型
import { router } from '../trpc';
import { postRouter } from './post';
export const appRouter = router({ post: postRouter });
// 关键一行:类型从实现里直接推导出来,没有任何 codegen
export type AppRouter = typeof appRouter;
Next.js App Router 的集成只需要一个 catch-all 路由:
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { db } from '@/server/db';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({ db, session: null }), // 这里接你的鉴权逻辑
});
export { handler as GET, handler as POST };
客户端侧(React Query 风格,用 @trpc/react-query):
// 组件里直接调用,input 和返回值全是类型化的
import { trpc } from '@/lib/trpc';
function PostList() {
const { data, isLoading } = trpc.post.list.useQuery({ limit: 10 });
// data 自动推导为 Post[],limit 传字符串会直接编译报错
const create = trpc.post.create.useMutation();
// ...
}
端到端类型安全的实测效果才是 tRPC 真正值钱的地方。做个实验:把 router 里 create 的 input 从 title 改名为 headline,保存的瞬间,编辑器里所有调用 trpc.post.create.useMutation() 并传 { title } 的地方全部飘红——编译器替你完成了全链路回归。对比 REST:后端改字段名,前端要靠全局搜索 "title" 字符串去碰运气,漏一处就是线上事故。AI 时代这个差异会被放大:AI 重构代码时改名最积极,有 tRPC 兜底你才敢让它放手改。
一个实操建议:router 按业务域拆文件(post.ts、user.ts、billing.ts),但 AppRouter 永远只有一个。拆太碎会导致客户端 import 路径混乱,单人项目按"页面聚合"拆就够了。
三个 tRPC 实战细节,AI 不会主动告诉你:
- httpBatchLink 会合并请求。客户端 100ms 内的多次
useQuery会被打包成一个 HTTP POST 发出去。好处是减少请求数,代价是单个请求体变大、调试时网络面板里看到的是一坨批量载荷。开发期可以用httpLink(不合并)换可读性,上线再切回 batch。 - Server Components 里别走 HTTP。Next.js App Router 的 RSC 里调 tRPC 不需要发 HTTP 请求,用
createCaller直接调 router 函数,省一次网络往返:const caller = appRouter.createCaller(ctx); const posts = await caller.post.list({ limit: 10 });。类型照样全,速度更快。 - AI 写 tRPC 的两个常见坑:一是 zod schema 与 Prisma 模型不一致——比如 Prisma 里
title是可选,zod 写成z.string()必填,用户不填标题就 400,AI 还会信誓旦旦说"校验更严格是好事";二是 procedure 命名太泛(getData、updateThing),三个月后你自己都看不懂。命名规范现在就定:资源.动作(post.list、post.create),AI 生成时在 prompt 里写死这条规则。
顺便说一句 tRPC 不是银弹,它有两个硬伤你要提前知道:一是锁死 TypeScript——哪天你要给 Python 脚本或 Go 服务暴露接口,tRPC 客户端没有现成 SDK(社区有 trpc-openapi 插件能把 router 导出成 REST + OpenAPI 文档,算是个逃生舱,但类型安全的优势就没了);二是默认走 POST,HTTP 缓存、CDN 边缘缓存这些 REST 的免费午餐吃不到。所以第 1 节才说:公开读接口、高 QPS 缓存场景,继续用 REST,别为了统一技术栈而统一。
GraphQL 落地:schema 优先与渐进式引入
如果你命中了第 7 节的切换信号,GraphQL 的正确姿势是 schema 优先:先写 schema,再写 resolver,最后 codegen 出 TS 类型。顺序反了(先写 resolver 再补 schema)是 AI 最常见的写法,结果就是 schema 与实现两张皮。
# schema.graphql —— 先有契约,再有实现
type Post {
id: ID!
title: String!
body: String!
author: User!
createdAt: String!
}
type User {
id: ID!
name: String!
}
type Query {
posts(limit: Int = 20): [Post!]!
post(id: ID!): Post
}
type Mutation {
createPost(title: String!, body: String!): Post!
}
// resolvers.ts —— resolver 只做"把 schema 字段填上"
export const resolvers = {
Query: {
posts: (_: unknown, { limit }: { limit: number }, { db }: Ctx) =>
db.post.findMany({ take: limit, orderBy: { createdAt: 'desc' } }),
},
Mutation: {
createPost: (_: unknown, { title, body }: { title: string; body: string }, { db, user }: Ctx) =>
db.post.create({ data: { title, body, authorId: user.id } }),
},
};
前端类型靠 codegen 生成,而不是手写:
// codegen.ts —— @graphql-codegen/cli 配置
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: './schema.graphql',
documents: 'app/**/*.{ts,tsx}', // 扫描前端的 graphql`` 模板字符串
generates: {
'lib/gql/': { preset: 'client' }, // 生成类型安全的 document helper
},
};
export default config;
// 前端:查询语句本身被类型检查,字段写错直接报错
import { graphql } from '@/lib/gql';
const PostsQuery = graphql(`
query Posts($limit: Int) {
posts(limit: $limit) {
id
title
author { name }
}
}
`);
// useQuery(PostsQuery, { variables: { limit: 10 } }) —— variables 和返回都 typed
与现有 REST 共存的渐进式引入是单人团队唯一现实的路径:不要重写。把 GraphQL 服务挂在 /api/graphql(或独立端口),resolver 内部直接复用你现有的 service 函数——GraphQL 只是多了一层"查询编排",数据逻辑不动。先迁 1~2 个"多端字段需求差异大"的查询(比如移动端要的精简版 feed),跑稳两周再扩大。REST 路由保持不动,等 GraphQL 覆盖到位后再按废弃 SOP 下掉(流程见《API 别翻车:vibe 项目的版本管理与废弃 SOP》)。
codegen 的工程化配置别漏了两项:一是 package.json 里加 "gql:watch": "graphql-codegen --watch",schema 一改前端类型自动重生成,否则你早晚会遇到"schema 改了但类型还是旧的"灵异事件;二是 fragment 共置(colocation)——每个组件在自己的文件里声明它要的字段 fragment,页面级查询用 ...Component_fragment 拼起来:
// components/PostCard.tsx —— 组件声明自己的数据需求
export const PostCardFragment = graphql(`
fragment PostCard_post on Post {
id
title
author { name }
}
`);
// app/feed/page.tsx —— 页面查询只负责拼装
const FeedQuery = graphql(`
query Feed($limit: Int) {
posts(limit: $limit) {
...PostCard_post
}
}
`);
这样改 PostCard 要多一个字段时,只改它自己的 fragment,codegen 会告诉你拼装后的查询类型变了——组件与数据的绑定关系第一次有了机器可验证的形态。这是 GraphQL 官网首页也在强调的 data colocation 思想,AI 写组件时把这段模式喂给它,能少很多"字段对不上"的返工。
服务端框架选哪个?单人项目直接按这个优先级:GraphQL Yoga > Apollo Server。Yoga 基于 graphql-js、无历史包袱、插件写起来简单,上面第 5 节的 depth limit 和复杂度插件都是几行代码的事;Apollo Server 生态最大、资料最多,但 v4 之后配置变啰嗦,一个人维护时"资料多"不如"代码少"实在。还有一条中间路线 Pothos(code-first:用 TS 代码写 schema,类型自动推导),适合"想要 GraphQL 的灵活性、又不想手写 schema 文件"的人,但 code-first 的 schema 可读性不如 schema-first,公开 API 场景下不推荐——第三方开发者要看的是 schema 文件,不是你的 TS 代码。
一句话总结选型成本:tRPC 的类型安全是写代码时白送的;GraphQL 的类型安全是靠"schema 优先 + codegen 管线"这个纪律换来的。纪律需要人执行,一个人时,纪律越少越好——这就是默认 tRPC 的底层逻辑。
AI 专属坑之一:N+1,AI 生成的 resolver 为什么必踩
这是 AI 写 GraphQL 时最稳定复现的坑。看一段 AI 极大概率会给你生成的 resolver:
// AI 的"直觉写法":每个字段各自查库,看起来完全正确
export const resolvers = {
Query: {
posts: () => db.post.findMany({ take: 20 }),
},
Post: {
author: (post: Post) =>
db.user.findUnique({ where: { id: post.authorId } }), // ← 坑在这里
},
};
前端查 20 篇文章带作者名:posts { title author { name } }。执行过程是:1 次查 post 列表 + 20 次逐个查 user = 21 次数据库往返。这就是 N+1。为什么 AI 必踩?因为它的训练数据里"字段 resolver 各自查库"是最常见的示例写法,而批量加载需要跨 resolver 共享状态(context 里的 loader),这超出了"看函数写函数"的局部模式。更阴险的是:开发环境数据量小,N+1 完全无感,上线后列表页一翻页数据库直接被打爆。
标准解法是 DataLoader(graphql/dataloader,MIT 协议,Facebook 2010 年内部 Loader 思想的开源实现):把同一次事件循环 tick 内的多次 load(id) 合并成一次批量查询。
import DataLoader from 'dataloader';
// 每个请求创建一个 loader(关键:per-request,不能跨请求共享,
// 否则 A 用户可能读到 B 用户的缓存——官方 README 明确警告过这一点)
export function createLoaders(db: Db) {
return {
userById: new DataLoader(async (ids: readonly number[]) => {
const users = await db.user.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(users.map((u) => [u.id, u]));
// 必须遵守的契约:返回数组长度与 keys 等长,且顺序一一对应
return ids.map((id) => byId.get(id) ?? new Error(`User ${id} not found`));
}),
};
}
// resolver 改成走 loader:一行改动,21 次查询变 2 次
export const resolvers = {
Query: {
posts: (_: unknown, __: unknown, { db }: Ctx) => db.post.findMany({ take: 20 }),
},
Post: {
author: (post: Post, _: unknown, { loaders }: Ctx) =>
loaders.userById.load(post.authorId),
},
};
原理一句话:DataLoader 把同一 tick 内的 20 次 load() 收集起来,只调一次 batch 函数(一次 WHERE id IN (...)),再把结果按 key 拆回去分发。附带还送了单请求内的去重缓存。
AI 生成代码自查清单(让 AI 写完 resolver 后逐条过一遍,或者直接把这段贴给 AI 当 review prompt):
- 所有嵌套字段 resolver(如
Post.author、User.posts)是否走了 DataLoader?凡是直接db.xxx.findUnique的,一律标红。 - loader 是否在每请求 context 里新建?全局单例的 loader = 跨用户缓存污染。
- batch 函数返回数组是否与 keys 等长且顺序对应?顺序错位是最难查的 bug, symptom 是"偶发串数据"。
- mutation 之后是否
loader.clear(key)了?DataLoader 的缓存只在单请求内有效,同一请求里先查后改再查会读到脏数据。 - 本地压测:列表接口返回 50 条时,用数据库日志数一下实际 SQL 条数,超过 3 条就回去改。
DataLoader 还有两个进阶用法,AI 几乎 never 会主动用,但生产环境迟早要补:
prime预热:如果你在顶层 resolver 里已经一次性查出了关联数据(比如findMany({ include: { author: true } })),可以用loader.prime(post.authorId, post.author)把结果塞进缓存,后续嵌套 resolver 的load()直接命中,零额外查询。注意 prime 只在"你确定数据新鲜"时用,写操作之后记得clear。- N+1 的 REST 变体:这个坑不只 GraphQL 有。AI 写的 tRPC / REST 代码里同样常见:
for (const post of posts) { post.author = await db.user.findUnique(...) }——循环里 await,串行 N+1,比 GraphQL 版更慢(连并发都没蹭上)。review AI 代码时看到"循环 + await + 查库"三连,直接标红,改成Promise.all或一次IN查询。
AI 专属坑之二:over-fetching,给公开 API 加护栏
GraphQL 把"查什么"交给了客户端,这是它灵活的原因,也是它危险的原因。AI 生成的 schema 里如果 User.posts 又能套 Post.author,再套 User.posts……一个恶意查询可以写成 20 层嵌套,一次请求拖垮数据库。REST 时代你至少能靠"端点固定"控制爆炸半径,GraphQL 把这个半径交给了调用方——公开 API 必须自己把半径收回来。
护栏有两层,两个都要上:
第一层:查询深度限制。直接掐死嵌套层数,一行配置:
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)], // 超过 7 层直接拒绝,连执行都不执行
});
7 这个数字怎么来的?数一下你正常业务最深的查询(feed → post → author → avatar 大概 4~5 层),再留 2 层余量。定死之后写进文档,客户端超了就自己拆查询。
第二层:复杂度计分(cost analysis)。深度限制防不住"宽"查询:posts(limit: 10000) { ... 30 个字段 } 只有 2 层深,但能把数据库读穿。用 graphql-query-complexity 给每个字段定价:
import { getComplexity, simpleEstimator } from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)],
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
query: document,
variables: request.variables,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
});
if (complexity > 500) {
throw new GraphQLError(`Query too complex: ${complexity} (max 500)`);
}
},
};
},
},
],
});
更进一步的打法是给"贵字段"单独定价:比如 posts 按 limit 参数乘系数计分,search 这类全文检索字段直接定 50 分。预算(上面示例的 500)按你的数据库压测结果调:先压出单实例 QPS 拐点,再反推每个查询允许的复杂度上限。
贵字段定价的具体写法(用 fieldExtensions 声明,比在插件里写 if 清爽):
import { makeExecutableSchema } from '@graphql-tools/schema';
const typeDefs = /* GraphQL */ `
type Query {
posts(limit: Int = 20): [Post!]!
search(keyword: String!): [Post!]!
}
`;
const resolvers = {
Query: {
posts: {
// 复杂度 = limit 参数值 × 2:查 10 条计 20 分,查 500 条直接超预算
extensions: { complexity: ({ args }: any) => (args.limit ?? 20) * 2 },
resolve: (_: unknown, { limit }: { limit: number }, { db }: Ctx) =>
db.post.findMany({ take: limit }),
},
search: {
extensions: { complexity: 50 }, // 全文检索固定 50 分,贵就贵得明明白白
resolve: (_: unknown, { keyword }: { keyword: string }, { db }: Ctx) =>
db.post.search(keyword),
},
},
};
如果是公开 API(第三方调用的那种),护栏再加一档:持久化查询(persisted queries)allowlist——生产环境只接受预先注册过的查询哈希,任意手写查询直接拒绝。这是 Stripe、GitHub 这类公开 GraphQL API 的标准做法:灵活性收归你审核过的查询集合,攻击面直接清零。单人项目可以用 @graphql-tools/persisted-query 这类现成方案,半天能接完。
记住顺序:先 depth limit(5 分钟),再复杂度计分(半天),最后 persisted queries(公开 API 才需要)。别一上来就搞 allowlist,会把你自己的迭代速度也锁死。
护栏之外,还有一层很多人漏掉的:速率限制(rate limiting)要按"复杂度"而不是按"请求数"限。传统 REST 网关按 IP 限"每分钟 100 次请求",搬到 GraphQL 上就失灵了——1 次复杂查询顶 100 次简单查询。正确做法是按复杂度扣配额:每个 token 每分钟 10000 分复杂度预算,简单查询一次扣几分,复杂查询一次扣几百分。这样攻击者没法用"合法次数内发超大查询"绕过限制。实现上可以在第 2 层的插件里顺手做:算出 complexity 后,从 Redis 里扣减该 token 的剩余额度,扣完直接 429。AI 生成限流代码时默认都是按次数限的,这条要你明确提。
认证与鉴权:tRPC middleware vs GraphQL directive
两种技术栈挂鉴权的位置不一样,但思路一致:在框架层统一拦截,不要散落在每个 resolver / procedure 里手写 if (!user) throw。AI 最爱干的就是后者——每个函数里复制粘贴一段鉴权,漏一处就是越权。
tRPC:middleware + procedure 组合。
// server/trpc.ts —— 在 t 后面追加
import { TRPCError } from '@trpc/server';
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '请先登录' });
}
// 把 user 写进 ctx,后续 procedure 直接用,不用再判空
return next({ ctx: { ...ctx, user: ctx.session.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
// 业务侧:一行声明,鉴权完成
export const postRouter = router({
create: protectedProcedure
.input(z.object({ title: z.string().min(1).max(120) }))
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({ data: { ...input, authorId: ctx.user.id } });
}),
});
GraphQL:directive 声明式鉴权。
# schema 里声明意图
directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { USER ADMIN }
type Mutation {
createPost(title: String!, body: String!): Post! @auth
deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
}
// directive 实现:集中一处做角色检查
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
function authDirective(directiveName: string) {
return {
authDirectiveTypeDefs: `directive @${directiveName}(requires: Role = USER) on FIELD_DEFINITION`,
authDirectiveTransformer: (schema: GraphQLSchema) =>
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const dir = getDirective(schema, fieldConfig, directiveName)?.[0];
if (!dir) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async (source, args, ctx, info) => {
if (!ctx.user) throw new GraphQLError('请先登录');
if (dir.requires === 'ADMIN' && ctx.user.role !== 'ADMIN') {
throw new GraphQLError('需要管理员权限');
}
return resolve(source, args, ctx, info);
};
return fieldConfig;
},
}),
};
}
划界说明:权限模型本身(角色怎么分、权限怎么存)不在本篇展开,直接复用《从一个人到一个团队:vibe 项目的 RBAC 权限模型落地实战》的结论——那篇给的四角色最小模型(owner / admin / member / viewer)+ Prisma 表结构可以直接抄。本篇只解决"鉴权代码挂在 API 层的哪个钩子上"这个问题:tRPC 挂 middleware,GraphQL 挂 directive,两者都是"声明一次、处处生效",杜绝散落的手写 if。
一个容易忽略的细节:GraphQL 的字段级鉴权要注意 @auth 只保护了字段被"直接查询"的情况。如果 Post.author 嵌套展开时带了敏感字段(比如 User.email),要在 schema 层就把敏感字段拆到需要更高权限的 type 里,或者在 resolver 里二次检查。AI 生成 schema 时几乎从不考虑这一点,review 时重点看。
tRPC 侧的一个组合技巧:middleware 可以链式叠加,角色鉴权不用每个 procedure 重写:
// adminProcedure = 登录检查 + 角色检查,两行声明一个新 procedure
const isAdmin = t.middleware(({ ctx, next }) => {
if (ctx.user.role !== 'ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: '需要管理员权限' });
}
return next();
});
export const adminProcedure = protectedProcedure.use(isAdmin);
// 业务侧:adminRouter = router({ deletePost: adminProcedure.mutation(...) })
最后是测试建议,两种栈通用:鉴权逻辑必须有负向测试——未登录调 protectedProcedure 期望 UNAUTHORIZED、普通用户调 admin 接口期望 FORBIDDEN、GraphQL 侧带错误 token 查 @auth 字段期望报错。AI 写测试时只会写"正常登录能调通"的正向用例,负向用例要你明确要求它补。这三条测了,越权漏洞就堵住了 90%。
还有一个两边通用的地基:ctx 里的 session 从哪来。标准做法是在 createContext 里解析请求头里的 token(或 cookie),查一次用户表,把 { user, loaders } 塞进 context——注意 DataLoader 的实例也是在这里 per-request 创建的(第 4 节的原因)。AI 经常把"解析 token"写进每个 middleware,把"建 loader"写成全局单例,两个都是错的:token 解析只做一次放 context,loader 每个请求新建。context 是 API 层的"地基",地基歪了上面全歪。
决策树:默认 tRPC,哪 3 个信号出现才切 GraphQL
前面所有讨论收敛成一张图。规则很简单:默认 tRPC,只有当下面三个信号出现任意一个,才值得付出 GraphQL 的额外成本(schema 维护 + DataLoader + 查询护栏,第 4、5 节那些全是成本)。
三个信号逐个解释:
- 信号 ①:多端消费。Web、iOS、Android 三个客户端要的字段差异大到"一个 REST/tRPC 接口要么 over-fetch 要么开 N 个版本"。GraphQL 的"客户端点菜"在这里是真省事:移动端拿精简字段,Web 拿完整字段,后端只维护一套 resolver。
- 信号 ②:公开 API。你要做开放平台、给第三方提供调用。GraphQL 的自描述 schema(introspection)+ 强类型是天然的 SDK 替代品,第三方开发者体验好一个档次。但记住第 5 节的护栏一个不能少,公开 = 被攻击面。
- 信号 ③:聚合网关。你有多个 REST 服务 / 数据源(比如主业务库 + 第三方 SaaS + 旧系统),前端不想调 5 个接口拼数据。GraphQL 当 BFF(Backend For Frontend)聚合层,一次查询扇出到多个下游,这是它最经典的架构位置。
三个信号都没出现?那就别折腾。tRPC + 好好设计的 router,撑到日活几万没有任何问题。选型最大的成本不是技术本身,是你为"可能用得上"提前支付的学习和维护成本——单人团队付不起这个。
再给三个信号各配一个"反例",防止你被信号骗了去上 GraphQL:
- 你有 Web + 小程序两个端,但字段差异只是"小程序少要两个字段"——这不叫多端消费,这叫给小程序单独写个精简版 tRPC procedure,半小时的事。
- 你的"公开 API"其实只有你自己的另一个 side project 在调——这不叫开放平台,这叫内部调用,tRPC + 共享
AppRouter类型就能解决。 - 你的"多个数据源"其实就一个 Postgres 加一个 Redis——这不叫聚合网关,这叫在 service 层拼一下数据。等你真有 3 个以上异构数据源再谈 BFF。
判断标准一句话:信号成立的前提是"用 tRPC/REST 会痛",而不是"用 GraphQL 会爽"。痛是成本,爽是幻觉。
补一个 REST 的位置:非 TS 客户端(比如要给 Python 脚本调用)、强依赖 HTTP 缓存的读多写少接口、已经有 OpenAPI 生态的场景,REST 依然是正确选择,不用觉得"不够潮"。
迁移 SOP:REST → tRPC 渐进式替换 5 步
最后给已经有一坨 REST、想换 tRPC 的人一个 5 步 SOP。核心原则:先新后旧、双跑验证、再删旧路由——任何"停机重写"在单人项目里都是自杀。
第 1 步:新功能只写 tRPC。从今天起,所有新接口走 app/api/trpc,不再新增 REST 路由。先让 tRPC 的水管通起来(第 2 节的集成代码半小时能跑通),旧代码一根不动。
第 2 步:逐个迁移高频路由,复用原 service 函数。挑调用量最高的 3~5 个 REST 路由(一般是列表、详情、创建),把它们 handler 里的业务逻辑原样搬进 tRPC procedure:
// 迁移前:app/api/posts/route.ts(REST)
export async function GET(req: Request) {
const posts = await postService.list(parseLimit(req));
return Response.json(posts);
}
// 迁移后:server/routers/post.ts(tRPC),service 函数原样复用
list: publicProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(20) }))
.query(({ input }) => postService.list(input.limit),
// postService.list 就是原来 REST handler 调的那个函数,一行不改
注意 zod 的 .input() 顺手替代了原来手写的参数校验——原来散在各 handler 里的 if (!limit) return 400 可以删了,这是迁移的附带收益。
第 3 步:双跑 1~2 周,对比验证。新老接口同时在线,前端先切到 tRPC,对比三件事:返回数据 diff(抽样日志对比)、错误率(tRPC 的 onError 回调里打点)、P99 延迟(tRPC 的 httpBatchLink 会把多次调用合并成一个 HTTP 请求,延迟通常更低,但要实测确认)。
第 4 步:旧 REST 路由打废弃标记。确认无误后,给旧路由加 Deprecation: true 响应头(Sundown 日期写清楚),前端确认没有调用方后进入观察期。废弃公告的写法、双跑观察期的指标口径,按《API 别翻车:vibe 项目的版本管理与废弃 SOP》那套模板走,本篇不重复造轮子。
第 5 步:观察期结束,删除旧路由。删代码前最后检查一遍:全局搜索旧路径字符串、查网关/监控里是否还有流量、确认移动端发版已覆盖。删完跑一遍全量类型检查(tsc --noEmit)——tRPC 的好处在这里又体现一次:删掉的路由如果还有前端在调,编译直接报错,不用靠人肉搜索。
整个 SOP 跑下来,一个中等体量的 vibe 项目(30~50 个 REST 路由)大约 2~3 周可以迁完,且全程线上无感。记住顺序不能乱:先让新水管通水,再拆旧水管。凡是想"周末重写一版,周一直接切"的,最后都在周一凌晨回滚——别问我怎么知道的。
附:迁移回滚预案(第 3 步双跑期间如果翻车,按这个顺序处理):
- 前端 tRPC 调用全部走统一的
trpcclient 实例,回滚 = 把出问题的页面切回旧 REST 调用,一处改 import 即可——所以第 2 步迁移时别把旧 REST handler 删了,留着当逃生通道。 - 双跑期间给 tRPC 响应打标(header 或日志字段
x-api-layer: trpc),监控里能一眼区分两种水管的错误率,定位时不用猜。 - 数据写操作(mutation)先迁读接口、后迁写接口:读坏了最多白屏,写坏了是脏数据。AI 排迁移顺序时经常按文件字母序排,这个优先级要你亲手定。
收个尾之前,先给一个能直接用的东西:把本文的规范浓缩成一段 prompt 模板,以后让 AI 写 API 代码时贴在前面,能挡掉 80% 的返工。
API 规范(tRPC 项目,严格遵守):
1. 所有接口走 server/routers/ 下的 tRPC router,命名一律 资源.动作
(post.list / post.create),禁止新增 REST 路由。
2. 每个 procedure 必须写 zod .input() 校验,zod 字段与 Prisma 模型逐一
对齐:Prisma 可选则 zod .optional(),禁止"更严格是好事"式加码。
3. 需要登录的接口用 protectedProcedure,管理员接口用 adminProcedure,
禁止在函数体内手写 if (!user) 鉴权。
4. 返回数据禁止在循环里 await 查库;需要关联数据时一次查出或用
DataLoader,列表接口先数 SQL 条数再交差。
5. 改字段名时先改 router,编译器报错的地方就是要同步改的前端,
全部改完、tsc --noEmit 通过才算做完。
收个尾。API 选型在 vibe coding 时代有个新权重:以前选型看的是"团队熟悉什么",现在要看的是"AI 写这种代码时,机器能不能替我兜底"。tRPC 兜底靠编译器,GraphQL 兜底靠 schema 纪律 + DataLoader + 查询护栏三件套,REST 基本靠人肉。一个人 + 一个 AI 的团队,人肉是最稀缺的资源——所以默认 tRPC,信号来了再上 GraphQL,REST 留给它真正擅长的地盘。这个排序不是技术优劣,是成本账。
评论 (0)
相关文章

AI 一晚上就能写出桌面应用,但 macOS 公证、Windows 签名证书、自动更新全是流程型脏活。本文给一条从打包到用户收到的完整跑道:Electron vs Tauri 决策、Developer ID 签名+公证全流程、OV/EV 与 Azure 云签名、双端自动更新、四个分发渠道、CI 打包流水线,以及发版前 12 项检查清单。

私信不是发个消息那么简单。本文带你走完 vibe 项目私信系统的完整实战:三种会话形态取舍、三表数据模型与陌生人消息请求状态机、WebSocket 投递与幂等去重、已读水位线、反骚扰四件套、附件签名 URL、推送静默聚合、删除语义辨析,以及 12 条上线必测用例。

问卷、报名表、申请表……当你的 vibe 项目长出第三个表单,手写维护就开始失控。这篇实战指南带你从零搭一个表单构建器:JSON Schema 做数据契约、UI Schema 管布局、类型到组件的注册表做渲染分发、规则引擎做条件逻辑,再到拖拽搭建器与 schema 版本化——附 AJV/zod 选型判断与上线 checklist。