最近手头有个内部小项目,代号叫t3code,需求本身不复杂:团队里的后端同学经常把调试用的 SQL、Shell 片段、正则表达式丢在各种群里,散落得到处都是,过两周再想找某个片段,基本只能靠翻聊天记录。所以我想做一个轻量的代码片段收集、标签检索、分享工具,顺便把整个开发流程跑通一遍。
技术选型上我没有太多犹豫,直接上了T3 Stack,也就是 Next.js + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth 这一套组合。实际开发下来,最大的体感是全链路类型安全带来的“少写很多胶水代码”,tRPC 让前端调用后端接口时自动能拿到类型推断,Prisma 让数据库表结构像 TypeScript 对象一样肉眼可读。这篇文章就把我从 0 到 1 搭建 t3code 的完整过程记录下来:每一步为什么这么选、关键代码长什么样、哪些地方容易踩坑。如果你也想搭一个类似的全栈小项目,这份记录可以直接照着抄。
1. t3code 项目定位:用 T3 Stack 做一个代码片段管理工具
1.1 需求清单与产品边界
先说清楚 t3code 到底要做什么。我最初整理需求时,只列了四件事:
- 支持把代码片段按标题、语言、标签进行存储和检索;
- 每个片段有可见性控制:私有、公开;
- 多用户登录,每个用户只能管理自己的私有片段;
- 能通过链接分享公开片段。
我特意没有加评论、收藏、复杂权限组这些功能。原因很简单:这是内部工具,上线第一天真正核心的动作是“存进去、捞出来”,其余功能都可以等验证之后再迭代。像这种自建小工具,最忌讳一开始就把 CRUD 做成 ERP。
为什么不自接用现成的 GitHub Gist 或者一些在线片段工具?一个原因是想彻底掌控数据,另一个原因是想拿真实业务把 T3 Stack 的完整链路跑一遍。团队内部工具往往没有太多历史包袱,是验证新技术栈最合适的场景,线上有问题也不会伤到真实用户。
1.2 T3 Stack 各成员到底在解决什么问题
很多人第一次看 T3 Stack,会觉得“这不就是把几个流行库打包凑在一起嘛”。实际用下来,每个成员都有明确分工,配合逻辑也很顺:
| 技术 | 角色 | 在 t3code 里的用途 |
|---|---|---|
| Next.js | 应用框架 | 页面、路由、服务端渲染、API 层宿主 |
| TypeScript | 类型系统 | 全链路类型保障,贯穿前后端 |
| Tailwind CSS | 样式方案 | 快速搭建页面 UI,不需要写一堆 CSS 类名 |
| tRPC | RPC 层 | 替代 REST API,前后端类型安全直连 |
| Prisma | ORM / 数据库 | 定义数据模型、迁移、数据库访问 |
| NextAuth | 鉴权 | GitHub 登录、Session 管理、受限路由保护 |
我最满意的其实是 tRPC 和 TypeScript 的组合。做传统 REST API 的时候,写一个接口至少要经历这么几步:在服务端定义 controller、在共享层维护 request/response 类型、在客户端埋请求函数时手动标注返回类型。哪怕前端和后端都已经用了 TypeScript,往往也要维护两份类型定义,API 一改,前端大概率还是拿到旧类型。
tRPC 的思路是直接把服务端函数“暴露”给客户端调用,类型推断从后端一路穿透到前端。业务代码改了一个字段,前端立刻出现类型错误,根本不需要去看接口文档。这一点对 t3code 这种小项目来说,省下的心智负担相当可观。
1.3 什么情况下不适合 T3 Stack
我不是 T3 Stack 的“无脑吹”。如果项目有以下特征,选型前需要掂量一下:
- 客户端不是 Web 浏览器,比如原生 App 要通过 REST/GraphQL 接入;
- 需要给第三方系统开放 API,RPC 形态对外部消费者不友好;
- 团队对 TypeScript 还不太熟,上来就要求全链路类型安全会增大初期阻力;
- 有大量长连接、实时推送场景,tRPC 需要额外配合 WebSocket,复杂度反而上来了。
t3code 的场景完完全全落在舒适区里:内网小团队、用户量小、前端类型清晰、纯浏览器访问。技术栈只要匹配场景就能放大效率,不用为了“炫技”硬上一个重型方案。
2. 初始化 t3code:create-t3-app 配置与工程结构解析
2.1 交互式 CLI 里每个选项怎么选
T3 Stack 有个现成脚手架create-t3-app,初始化很直接:
pnpm create t3-app@latest t3code这里要留意 Node.js 版本,建议 18+。CLI 启动后会有几个交互选项,我当时的选择是:
- Next.js App Router:新版脚手架默认选项。之前 T3 Stack 模板一直用的 Pages Router,现在 App Router 已经是主流,Server Component 生态越来越成熟,没必要逆着生态走。
- TypeScript:勾选,这是整个 T3 Stack 的基石。
- Tailwind CSS:勾选,后面样式全靠它。
- tRPC:勾选,不选就没有核心卖点了。
- Prisma:勾选,数据层必须要有。
- NextAuth.js:勾选,正好演示鉴权体系。
- ESLint / Prettier:建议都勾上,团队协作能少很多格式争论。
这些都是模板预设,大多数情况下直接回车就行。唯一要想一下的是包管理器,我这边统一用 pnpm,安装速度快而且磁盘占用小。初始化完成后按提示跑一次数据库迁移命令,脚手架就会把默认的 User 表建出来。
2.2 生成后的目录结构怎么看
工程创建完,先别急着改代码,把目录结构搞清楚。核心区域集中在src/下:
t3code/ ├─ prisma/ │ ├─ schema.prisma │ └─ seed.ts ├─ src/ │ ├─ app/ │ │ ├─ api/trpc/[trpc]/route.ts │ │ ├─ page.tsx │ │ ├─ layout.tsx │ │ └─ ... │ ├─ server/ │ │ ├─ api/root.ts │ │ ├─ api/routers/ │ │ ├─ auth.ts │ │ ├─ db.ts │ │ └─ trpc.ts │ └─ trpc/ │ ├─ client.ts │ ├─ react.tsx │ └─ server.tsprisma/schema.prisma是数据模型定义;src/server/api/root.ts是 tRPC router 的聚合入口,新写的子 router 都要挂到这里;src/server/api/routers/每个业务模块一个文件;src/server/trpc.ts是整个 tRPC 的中枢,定义公共 procedure、受保护 procedure 和 Context;src/trpc/client.ts等目录负责客户端调用。
这套目录本身也是社区实践沉淀过的结果:业务代码和基础设施代码分离,tRPC 的入口统一,新增业务模块时不需要改太多全局配置。
2.3 环境变量与安全兜底
初始化之后第一件事是配置环境变量。.env.example里已经列好了所有需要用到的项:
DATABASE_URL="postgresql://..." NEXTAUTH_SECRET="" NEXTAUTH_URL="http://localhost:3000" GITHUB_ID="" GITHUB_SECRET=""有几个地方特别容易踩雷:
DATABASE_URL如果是本地 PostgreSQL,记得要把数据库先建好,Prisma 不会帮你 create database;NEXTAUTH_SECRET不能留空,本地开发可以随便填个随机字符串,生产环境必须用安全随机值,后面部署章节会详细讲;GITHUB_ID和GITHUB_SECRET要去 GitHub OAuth Apps 页面注册一个应用,回调地址填http://localhost:3000/api/auth/callback/github。
安全性上我补充一点:千万不要把.env提交进 Git 仓库。.env.example可以提交,里面放占位符;真实密钥只保留在本地和部署平台的环境变量面板里。
3. 数据模型设计:Prisma Schema 与迁移实战
3.1 核心表结构:Snippet 与 Tag
t3code 的领域模型非常清晰,我拆成了三块:用户(脚手架自带)、片段(Snippet)、标签(Tag)。片段和对应用户是多对一关系,片段和标签是多对多关系。完整 schema 如下:
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] } model Snippet { id String @id @default(cuid()) title String @db.VarChar(120) content String @db.Text language String @db.VarChar(40) visibility Visibility @default(PRIVATE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) tags Tag[] @relation("SnippetTags") } model Tag { id String @id @default(cuid()) name String @unique @db.VarChar(40) snippets Snippet[] @relation("SnippetTags") } enum Visibility { PRIVATE PUBLIC }模板自带的 User 模型在脚手架里就存在,我在它下面补了一个snippets反向关系。这里用cuid()做主键而不是自增整数,原因很简单:公开分享时防猜测,用户拿到一个片段 ID 不能顺着顺序去遍历别人私有内容。
3.2 字段设计的取舍与索引意识
几个字段背后都有考虑:
language用字符串而不是枚举,因为代码语言种类太多,枚举维护起来很痛苦。代价是脏数据会存在,前端展示时再做一次归一化。对内部工具来说,灵活大于严格。visibility用了 Prisma 枚举,拿捏得非常死,只有PRIVATE和PUBLIC,避免传任意字符串进来。title限长 120 个字符,防止有人把整段代码贴进标题;content用@db.Text,不用担心长度。- 多对多关系用的是 Prisma 隐式关联表,代码里不需要手动建中间表模型,查询时 Prisma 会自动管理关联。如果需要给关联表加额外字段,再改成显式关系,目前用不着。
有一点要提醒:现在这个模型用户量小,所有条件查询都走慢点也无所谓。如果后续片段量上来,Snippet表最好给authorId + createdAt建复合索引,因为常见的列表查询都是“某用户按时间倒序拉数据”。建索引的写法在 Prisma 里是:
@@index([authorId, createdAt(sort: Desc)])模型设计完之后,跑迁移生成 SQL:
npx prisma migrate dev --name initmigrate dev在本地开发时用,它会自动同步数据库结构并生成 migration 文件。如果只需要生成客户端而不动数据库,用npx prisma generate就行。
3.3 种子数据脚本:让前端开发不再等后端
开发时最怕数据库是空的,页面打开一片空白,样式和交互都没法验证。所以我写了prisma/seed.ts,往库里塞几个演示片段和标签:
import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const user = await prisma.user.create({ data: { name: "demo", email: "demo@example.com", }, }); const jsTag = await prisma.tag.create({ data: { name: "javascript" } }); const sqlTag = await prisma.tag.create({ data: { name: "sql" } }); await prisma.snippet.createMany({ data: [ { title: "递归深拷贝", content: "JSON.parse(JSON.stringify(obj))", language: "javascript", visibility: "PUBLIC", authorId: user.id, }, { title: "查询最近 10 条记录", content: "SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;", language: "sql", visibility: "PRIVATE", authorId: user.id, }, ], }); } main().then(() => console.log("种子数据已写入"));运行pnpm prisma db seed后重启本地服务,页面就有内容可以看了。经验之谈:种子脚本里尽量造几组不同 visibility 的数据,前后端联调权限逻辑时会省很多事。
4. 接口层实现:tRPC 路由与类型安全 API 开发
4.1 从 trpc.ts 到 root.ts:理解 tRPC 的中枢设计
整个 tRPC 的入口在src/server/trpc.ts。脚手架自动生成的代码已经比较完善,我在此基础上补了受保护 procedure。先看基础框架:
import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, headers: opts.headers, }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { user: ctx.session.user, }, }); });理解这块的重点在于 Context。每次 tRPC 调用都会执行createTRPCContext,db和session就像“附带品”一样挂到了所有 procedure 上,不用每个接口单独再去查 session。protectedProcedure是在公共 procedure 上叠加了一个 middleware:如果没登录直接抛UNAUTHORIZED,请求在业务代码执行前就被拦下。这样做的好处是,受保护接口内部不用到处if (!ctx.user)判断。
然后看src/server/api/root.ts,所有子 router 在这里汇总:
import { snippetRouter } from "./routers/snippet"; import { tagRouter } from "./routers/tag"; import { createTRPCRouter } from "~/server/trpc"; export const appRouter = createTRPCRouter({ snippet: snippetRouter, tag: tagRouter, }); export type AppRouter = typeof appRouter;AppRouter类型是整个前端类型推断的来源,客户端在~/trpc/react.tsx里把它注入为泛型,之后所有trpc.xxx.yyy调用的入参和返回值类型都不用手动维护。
4.2 具体业务 Router:查询、创建、删除
先看查询接口。列表页需要支持按语言过滤、按关键词搜索、按标签过滤,同时区分可见性逻辑:
export const snippetRouter = createTRPCRouter({ getAll: publicProcedure .input( z.object({ tag: z.string().optional(), language: z.string().optional(), q: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { visibility: "PUBLIC", ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.q ? { title: { contains: input.q } } : {}), }, include: { tags: true, author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, }); }),这里统一只查visibility: PUBLIC,因为列表页想把公开片段展示给所有访问者,未登录也能看。等登录后,我可以在另一个查询里做“公开片段 + 自己的私有片段”合并。作为内部工具,先保持逻辑简单,避免一上来就把查询写成权限分支地狱。
创建接口放在protectedProcedure下面:
create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), content: z.string().min(1), language: z.string().max(40), visibility: z.enum(["PRIVATE", "PUBLIC"]).default("PRIVATE"), tagIds: z.array(z.string().cuid()).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, visibility: input.visibility, authorId: ctx.user.id, tags: input.tagIds?.length ? { connect: input.tagIds.map((id) => ({ id })) } : undefined, }, }); }),删除接口比创建更要小心,不能允许用户删别人的片段。方法很直接:先查记录判断authorId是否等于ctx.user.id,不相等就抛FORBIDDEN:
delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.findUnique({ where: { id: input.id }, select: { authorId: true }, }); if (!snippet) { throw new TRPCError({ code: "NOT_FOUND" }); } if (snippet.authorId !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } await ctx.db.snippet.delete({ where: { id: input.id } }); return { ok: true }; }),4.3 Zod 是 tRPC 的“门卫”,不是摆设
tRPC 官方推荐用 Zod 做输入校验。我一开始图省事想少写点校验,结果很快就翻车:有人把空字符串传进title,页面渲染出来一片空白,调样式时还以为是 CSS 问题。
Zod 带来的价值是双层的:
- 服务端:非法请求在业务层之前被拦截,保证数据库不进入脏数据;
- 客户端:前端能直接拿到类型定义,
useQuery里的入参对象字段名都不会拼错。
一个实用技巧:如果有多个地方都要校验同一个 ID,比如id: z.string().cuid(),就抽一个公共的inputSchema导出,各 router 直接复用,减少重复代码。Zod schema 的复用性和 TypeScript 类型一样重要。
5. 前端页面:Tailwind 布局与 tRPC 数据绑定
5.1 页面路由与组件拆解
t3code 的页面结构不复杂,我用 App Router 组织:
src/app/ ├─ page.tsx // 首页:片段列表 + 标签过滤 ├─ snippets/new/page.tsx // 新建片段 ├─ snippets/[id]/page.tsx // 片段详情 └─ layout.tsx // 整体布局 + 顶部导航组件方面拆了四块:
SnippetCard:列表中的卡片,展示标题、语言、标签、创建时间和作者;TagPill:标签小条,点击后按标签过滤;SearchBar:搜索输入框,控制列表关键词;NewSnippetForm:新建页的表单,负责把数据提交给 tRPC mutation。
这里的核心理念就是职责单一。卡片只管展示,表单只管收集和校验,列表页负责聚合所有状态。项目越往后越能体会到,组件边界清晰比“一个页面一个巨型组件”好维护得多。
5.2 数据请求:useQuery 与 useMutation 的组合
tRPC 客户端已经把请求封装成了 React Hook,使用体验近似 TanStack Query。先看列表页的核心逻辑:
const utils = trpc.useUtils(); const { data: snippets, isLoading } = trpc.snippet.getAll.useQuery({ tag: activeTag ?? undefined, });这行代码背后做完了三件事:发起请求、按缓存 key 存储结果、自动处理 loading/error 状态。基于这一点,表单提交后的缓存刷新也不需要手写 fetch:
const createSnippet = trpc.snippet.create.useMutation({ onSuccess: () => { utils.snippet.getAll.invalidate(); }, });invalidate()的作用是让对应 key 的缓存失效,下一次查询会重新拉数据。如果没有这一步,创建完片段列表不会自动出现新内容,你会误以为 mutation 失败了。
关于乐观更新,我没有在 t3code 里做,原因很简单:内部工具对“提交后立刻看到结果”没那么敏感,先保证逻辑正确,省掉乐观回滚之类的复杂状态管理。
5.3 Tailwind 实战中的几个常见陷阱
Tailwind 写页面确实快,但有两个点容易出问题。
第一是动态类名拼接。不要写成className={bg-${color}-500},Tailwind 在编译时只会扫描源码里出现的完整类名字符串,运行时拼出来的类名根本不会生成对应 CSS。正确做法是写完整的类名映射:
const colorClasses: Record<string, string> = { javascript: "bg-yellow-500", sql: "bg-blue-500", shell: "bg-green-500", };第二是暗色模式。如果用了class策略控制暗色主题,需要在tailwind.config.ts里把darkMode设为"class",并自行处理<html>上的 dark class。如果没做这一步,dark:前缀的样式全都不会生效。
列表页的响应式其实很简单:用栅格布局,在卡片容器上加grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3,屏幕窄的时候单列,宽屏时多列,不需要写任何断点判断逻辑。
6. 鉴权与部署:NextAuth 接入和 Vercel 上线
6.1 GitHub Provider 配置与回调地址
t3code 用 GitHub 登录,好处是团队里每个人都有 GitHub 账号,省去注册流程。配置位置在src/server/auth.ts,脚手架生成的authOptions里加上 GitHub Provider:
import GitHubProvider from "next-auth/providers/github"; export const authOptions: NextAuthOptions = { providers: [ GitHubProvider({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET!, }), ], session: { strategy: "jwt" }, };需要注意回调地址。本地开发时在 GitHub OAuth App 设置里填http://localhost:3000/api/auth/callback/github;部署上线后,要在同一个应用里再填一个生产环境回调地址,比如https://t3code.vercel.app/api/auth/callback/github。很多人上线后一直登录失败,十有八九就是第二个回调地址没加。
6.2 在 tRPC 中间件中读取会话
鉴权的关键路径已经在第 4 章提到:createTRPCContext里通过getServerSession(authOptions)获取会话,然后protectedProcedure根据会话是否存在决定放行还是抛错。整个流程的核心价值是,业务接口不需要知道自己“怎么拿到当前用户”,它只需要从ctx.user.id读取。
这里有个经验点:不要在客户端组件里调用getSession(),那会额外发一次网络请求,而且状态不同步。T3 Stack 内置的trpc.react.tsx提供了useSession,订阅的是 SessionProvider 里的状态,很多场景直接用它就够。如果实在要判断登录态,优先用服务端 Context 或 SessionProvider。
6.3 Vercel 部署的完整流程与几个大坑
部署到 Vercel 的步骤确实不多,但每步都不能漏:
- 把代码推送到 GitHub 仓库;
- 在 Vercel 里导入项目;
- 配置环境变量:
DATABASE_URL、GITHUB_ID、GITHUB_SECRET、NEXTAUTH_SECRET、NEXTAUTH_URL; - 部署后触发构建,Vercel 会自动执行
prisma generate; - 手动跑一次数据库迁移:
pnpm prisma migrate deploy。
构建脚本里我建议显式加上生成 Prisma Client 的步骤,避免在 Serverless 环境下 Prisma Client 没有正确生成导致报错:
"build": "prisma generate && next build"接下来是几个容易翻车的高频坑,我挨个踩过:
NEXTAUTH_SECRET太简单:生产环境一定要用强随机值,生成方式很简单:openssl rand -base64 32,然后填进环境变量。- Serverless 环境下数据库连接数被打爆:传统 Node 服务复用长连接没问题,但 Serverless 函数每次冷启动都会新建 PrismaClient,并发一高连接池就爆炸。解决思路有两个:一是全局单例,脚手架模板里已经在
src/server/db.ts做了 global 缓存;二是数据库侧建议选择支持连接池的服务,或直接开 Supabase 这类托管 PostgreSQL,省心。 - migration 重复执行:有些人把
prisma migrate deploy直接写进每次构建脚本,导致第二个实例跑迁移时发现 migration 已生效,直接报错。正确做法是在 Vercel 的 Build Command 里构建,在 Deploy 阶段手动或通过独立命令只在发布时跑一次 migrate。
上线之后别忘了做一次基本冒烟测试:未登录访问列表页、登录后创建私有片段、退出后验证私有内容不可见。内部工具安全等级不用拉满,但最基本的访问控制还是要保证。
7. 常见问题与排查:t3code 开发中踩过的 9 个坑
7.1 问题速查表
开发过程中我整理了一份问题排查表,后面处理同类问题时基本都是对着它先自查一遍。
| 症状 | 大概率原因 | 解决办法 |
|---|---|---|
| 登录跳转后永远回到首页,不返回callback | NEXTAUTH_URL没设置或配错环境 | 本地和线上分别配置正确的NEXTAUTH_URL |
| 创建片段后页面不刷新 | 忘记invalidate相关查询 | mutation 成功后调用utils.snippet.getAll.invalidate() |
Tailwind 的dark:类不生效 | darkMode没设成class | 在tailwind.config.ts里设置darkMode: "class" |
| 类名动态拼接导致样式丢失 | Tailwind 扫描不到完整类名 | 使用完整的类名字符串或映射表 |
客户端getSession()慢且状态不稳 | 不必要地发起了额外请求 | 改用useSession或服务端 Context |
| Prisma migration 报 shadow database 错误 | 本地数据库权限不足 | 检查数据库用户是否有建删库权限,或用prisma migrate diff生成 SQL 手动执行 |
| 删除接口返回 500 | 可能是关联数据约束问题 | 检查Snippet的 relation 的onDelete配置 |
| 部署后登录 401 | NEXTAUTH_SECRET未配置或不是强随机值 | 重新生成并配置新 secret |
| 页面布局乱掉 | 没做响应式断点,或者栅格类拼接错误 | 按列数断点写完整类名,比如grid-cols-1 md:grid-cols-2 |
这些问题绝大多数不是我发明的,而是社区里普遍出现的场景,但只有自己踩过一遍才能形成条件反射。
7.2 几个文档里看不到的实战细节
第一,Prisma Client 在 Serverless 环境下必须加 global 单例。脚手架模板确实已经做了,但我曾经因为“优化模板代码”把它改掉,结果部署到 Vercel 后连接数暴涨,数据库直接被挤爆。正确写法是用 globalThis 缓存 PrismaClient:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;第二,Zod schema 里的.string().cuid()校验是隐性的强制约定。很多人传一个普通字符串进来,服务端会默默拒绝,前端如果没有统一的错误提示,用户会以为“保存按钮坏了”。建议在表单提交失败时,统一用TRPCClientError里的 message 展示错误,而不是只看网络状态。
第三,tRPC 的 middleware 链式顺序是有讲究的。t.procedure.use(a).use(b)会形成洋葱模型,先执行 a,再执行 b,最后进入业务逻辑。如果你想在受保护 procedure 里再叠加某个运行上下文,比如“从 header 里取租户 ID”,一定要把这个 use 写在 protected 之前,否则拿不到用户信息就无从判断租户。
第四,多对多标签关联在 Prisma 里有时要手动置空。如果你想更新某个 Snippet 的标签集合,Prisma 的set操作可以一次覆盖:
await ctx.db.snippet.update({ where: { id: input.id }, data: { tags: { set: input.tagIds.map((id) => ({ id })) }, }, });这个比先 disconnect 再 connect 简单得多,也是很多使用 Prisma 做标签编辑功能的人容易忽略的点。
最后分享一条个人体会:像 t3code 这种内部小工具,最重要的是先把主链路跑通,而不是追求代码架构上的完美。我用 T3 Stack 最大的收获是类型安全保障和开发效率,它把很多本来要在 Postman 里测试、在接口文档里核对的工作,直接消解在了编译器里。如果你正准备做一个前端全栈项目,尤其是偏内部工具、管理后台这类场景,T3 Stack 是一个非常值得投入一天时间试一下的技术路线。