☰
T3 Stack全栈开发实战:用Next.js、tRPC与Prisma构建代码片段管理工具
2026/10/28 16:28:42 网站建设 项目流程

最近手头有个内部小项目,代号叫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 类名
tRPCRPC 层替代 REST API,前后端类型安全直连
PrismaORM / 数据库定义数据模型、迁移、数据库访问
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.ts

prisma/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 init

migrate 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 的步骤确实不多,但每步都不能漏:

  1. 把代码推送到 GitHub 仓库;
  2. 在 Vercel 里导入项目;
  3. 配置环境变量:DATABASE_URL、GITHUB_ID、GITHUB_SECRET、NEXTAUTH_SECRET、NEXTAUTH_URL;
  4. 部署后触发构建,Vercel 会自动执行prisma generate;
  5. 手动跑一次数据库迁移: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 问题速查表

开发过程中我整理了一份问题排查表,后面处理同类问题时基本都是对着它先自查一遍。

症状大概率原因解决办法
登录跳转后永远回到首页,不返回callbackNEXTAUTH_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配置
部署后登录 401NEXTAUTH_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 是一个非常值得投入一天时间试一下的技术路线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询