这个标题t3code乍一看有点抽象但如果你最近常在技术社区晃悠应该能嗅到一丝熟悉的味道——T3那个被Theo带火的全栈技术栈缩写再加上code基本就能猜到这项目是干嘛的了。先说清楚t3code是我最近一个月从零肝完的一个代码片段管理与协作平台。名字听起来敷衍实际上是我拿T3 Stack这套技术组合Next.js、TypeScript、tRPC、Tailwind CSS、Prisma、NextAuth做的全栈实战项目定位是给开发者团队用的内部代码片段库支持片段上传、按团队分组、多人评论、带权限的收藏与分享。整个项目从前端页面到数据库约束全部走TypeScript类型打通算是我近年做得最通透的一次全栈开发。这篇东西不是教程是我做完之后想把关键决策、踩坑链路、以及那些文档里不会写的细节整理出来给正准备上T3这套组合的人当参考。如果你也在纠结要不要用tRPC替代REST、Prisma和NextAuth到底怎么协作、权限到底在层控制才不出事那这篇应该能帮你省掉不少试错时间。1. 选型阶段T3 Stack到底解决了什么问题1.1 项目需求一个开发者的代码片段协作工具先说需求来源。我们团队之前用的是一个很古老的Wiki代码片段靠页面归档找代码靠CtrlF权限基本等于摆设有人误删了片段只能翻备份。我当时的想法很简单搞一个内部工具让团队能结构化地存代码片段并且能控制谁能看、谁能改、谁能删。核心需求拆下来大概这么几条片段要支持多语言标签、多标签分类能按团队筛选每个片段要有独立详情页支持评论和收藏权限分三级公开全团队可看、受保护仅指定团队可看、私有仅创建者可看操作要留痕删除了不能彻底消失得能恢复整个东西要部署简单不能搞一堆微服务出来给自己找事说来惭愧这个需求如果放三五年前我大概率会选VueDjangoMySQL写一堆REST接口再手动维护一份前端类型定义。但这几年TypeScript用久了真的受不了后端改了字段、前端跑崩了才知道的状态。所以一开始我就锁定了全栈TypeScript的路线然后很自然就看到了T3 Stack。1.2 T3 Stack的组成与取舍逻辑T3 Stack不是一个新的框架而是一套被社区验证过的技术组合核心哲学是大胆假设、小心求证、按需引入。它默认推荐Next.js作为应用框架、tRPC做API层、Prisma做ORM、NextAuth做认证、Tailwind CSS做样式然后在这基础上把TypeScript的类型安全贯穿始终。我实际落地时这几个选型的理由是这样的组件选了它理由Next.jsApp Router版路由即文件、Server Component天然适配服务端渲染团队熟悉ReacttRPC最新版不写REST、不写GraphQL直接调用函数级别的API类型自动推断Prisma最新版Schema即文档迁移工具完善多对多关系写起来舒服NextAuthv4和Next.js生态深度绑定支持CredentialsGitHub双登录Tailwindv4写完不用再写CSS文件迭代快团队已经在用需要说明的是tRPC这套方案在国内团队里普及率还不算高很多人听都没听过。它最核心的底气是只要你的前端和后端都跑在TypeScript世界里让编译器帮你把API契约管好就不需要像OpenAPI那样额外维护一份接口文档。你可以理解成前后端共享一份类型定义调用API的时候像调用本地函数一样这种体验我用了之后是回不去的。2. t3code的架构落地页层与数据层如何切分2.1 工程初始化与目录规范初始化T3项目很简单装好Node环境后一行命令npx create-t3-applatest t3code脚手架会问你选哪些模块我勾了Next.js、tRPC、Prisma、NextAuth、Tailwind。有一点提醒一下它默认会把ESLint、Prettier一起配好别手贱删掉后面写代码真的会省心很多。初始化完之后的目录是脚手架替你做好的核心结构长这样src/ app/ # Next.js App Router的页面目录 pages/ # 旧版Pages Router脚手架默认保留 server/ # tRPC路由、Prisma实例、认证配置 styles/ # 全局样式我在这个基础上做了两处调整。第一删掉了src/pages因为我全部用App Router不需要Pages Router混着来。第二在src/server下面按模块切了文件夹而不是一个router.ts写到底src/server/ api/ # tRPC的rootRouter与子路由 db/ # Prisma Client单例 auth/ # NextAuth配置 middlewares/ # 权限中间件说实话这种目录划分没什么玄学纯粹是后面代码量大了好找人。tRPC的router文件如果只有一两个几百行还能忍到我这种有片段、评论、收藏、团队、用户五六个维度的项目不拆就是事故现场。2.2 tRPC路由设计聚合还是拆分这里是我整个项目里思考最久的部分。tRPC有两种组织方式聚合式所有逻辑写进一个rootRouter用嵌套命名区分模块拆分式每个业务域一个子路由procudure分类挂在对应子路由下我选了第二种原因很实际聚合式在项目初期写起来飞快但一旦多个模块要共享中间件或者互相调用文件会膨胀得极其恐怖。拆分式每个子路由独立维护中间件可以在子路由级别单独挂载避免全员都要过登录校验这种粗粒度权限。实际的rootRouter长这样简化版export const appRouter router({ snippet: snippetRouter, comment: commentRouter, collection: collectionRouter, team: teamRouter, user: userRouter, health: healthRouter, }); export type AppRouter typeof appRouter;前端调用的时候自动有一个类型完备的trpc对象帮你做代理比如创建片段const mutation trpc.snippet.create.useMutation(); await mutation.mutateAsync({ title: 防抖函数, language: typescript, code: export function debounce(fn, delay) {...}, visibility: TEAM, });注意这里language、visibility这些字段都是字面量联合类型写错了编译器直接红。我在开发中期加了一个字段的时候改完schema之后TS立刻把所有用到的地方全标红这种全链路可控感是REST方案给不了的。2.3 类型安全的链接方式从页面到数据流的闭环T3这套组合最爽的一环是不用再手写请求层。传统REST开发里前端拿数据要写一个fetch封装、定义返回类型、再写一个loading状态。在Next.js App Router时代更麻烦因为页面既是客户端又是服务端你得反复斟酌这个数据在Server Component里拿还是挂在tRPC的procedure里。我用下来的结论是能走Server Component的查询就不要走tRPC客户端查询tRPC专注在页面需要动态交互的场景。比如片段列表页首屏数据我直接放到Server Component里用Prisma查// app/snippets/page.tsx import { db } from /server/db; export default async function SnippetsPage() { const snippets await db.snippet.findMany({ where: { visibility: { not: PRIVATE } }, include: { tags: true, author: true }, orderBy: { createdAt: desc }, }); return SnippetList initialData{snippets} /; }而搜索、筛选、收藏这些用户点了才发生的操作才挂在tRPC的query/mutation上。好处很明显首屏没有先loading再请求的等待SEO友好同时动态交互那块又保持着全类型安全。这套搭配我强烈推荐比全部走客户端请求的体验好一个档次。3. Prisma与NextAuth最容易翻车的两个模块3.1 Prisma schema与多表关联设计Prisma最舒服的是Schema即数据库设计写起来像写配置文件但迁移能力是真实生产级的。t3code的核心Schema大概是这个画风model User { id String id default(cuid()) name String? email String? unique emailVerified DateTime? image String? accounts Account[] snippets Snippet[] comments Comment[] collections Collection[] teams TeamMember[] } model Team { id String id default(cuid()) name String unique members TeamMember[] snippets Snippet[] } model TeamMember { teamId String userId String role Role default(MEMBER) user User relation(fields: [userId], references: [id]) team Team relation(fields: [teamId], references: [id]) id([teamId, userId]) } model Snippet { id String id default(cuid()) title String code String language String visibility Visibility default(PUBLIC) authorId String author User relation(fields: [authorId], references: [id]) teamId String? team Team? relation(fields: [teamId], references: [id]) tags Tag[] comments Comment[] collections CollectionSnippet[] deletedAt DateTime? createdAt DateTime default(now()) updatedAt DateTime updatedAt }有几个设计决策想细说为什么User只有email没有password字段因为我登录用的Credentials方案密码存在Account表里由NextAuth负责。这个责任边界很清楚Prisma只管用户资料认证本身的会话归NextAuth管。为什么软删除用deletedAt而不是直接删记录需求里有误删要能恢复直接物理删除后面想救人就得从备份里捞太痛苦。Prisma支持updatedAt自动更新配合查询时过滤deletedAt: null恢复逻辑就是把deletedAt设为null成本极低。多对多关系注意点Tag和Snippet是隐式多对多即只要两边都定义了数组字段Prisma会自动生成中间表不用手动建model。但我建议如果中间表要存额外字段比如收藏时间就得显式建模像CollectionSnippet那样。否则后面想记录收藏时间就得改表麻烦得很。3.2 NextAuth会话集成session策略与JWTNextAuth是T3栈里文档看着简单、用起来满地坑的典型。一个最经典的坑就是默认的session策略是JWT还是database建议直接用JWT别碰数据库session。原因很简单database session需要在数据库建Session表每次查询都多一次IOJWT则把用户信息塞进签名的token里无状态、轻量。代价是登出后token在有效期内依然能用但对于内部协作工具这完全不是问题。我在集成时遇到过一个特别隐晦的坑NextAuth的session回调里拿不到jwt回调里塞进去的自定义字段。比如我想把用户的是否管理员塞进session在jwt回调里改了token前端Session却依然是旧结构。根因是回调执行顺序jwt回调先执行然后session回调才能从token里取数据。你需要同时改两个地方// src/server/auth.ts callbacks: { async jwt({ token, user }) { if (user) token.role (user as any).role; return token; }, async session({ session, token }) { session.user.role token.role; return session; }, }另外别忘了在src/types/next-auth.d.ts里扩展Session和JWT的类型不然TS又给你一路标红。3.3 种子数据权限初始化的坑开发环境需要种子数据我写了一个prisma/seed.ts创建测试用户、团队、片段。这里有一个非常容易被忽略的混乱源头NextAuth的Credentials Provider在创建用户时和种子脚本必须走同一套账号密码hash方案。我们先看Credentials Provider的校验它需要你自己比对明文CredentialsProvider({ name: credentials, credentials: { email: { label: 邮箱, type: email }, password: { label: 密码, type: password }, }, async authorize(credentials) { const user await db.user.findUnique({ where: { email: credentials.email }, }); if (!user) return null; // 因为演示项目我直接明文比较生产环境必须 hash const isValid credentials.password user.password; return isValid ? user : null; }, })但Prisma的User model我上面说了没有password字段种子脚本怎么为用户设置密码我的做法是专门加了一个passwordHash字段放在User表种子脚本生成时用bcryptjs加密authorize校验时用bcrypt.compare比对。这样两边逻辑统一。千万不要图省事在数据库里存明文密码后面接入真实用户时隐患极大。哪怕是一个内部工具也得按生产标准做这是职业习惯。4. 权限链路从数据库约束到UI控制的层层设防4.1 tRPC中间件体系权限是我这项目里最上心的部分。因为需求里有三级可见性一旦控制不好轻则越权查看重则数据泄露。我是这样层层设防的。tRPC的中间件和Express类似可以在每个procedure执行前插入逻辑。我定义了一个基础中间件必须登录const isAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { ...ctx, userId: ctx.session.user.id }, }); }); export const protectedProcedure t.procedure.use(isAuthed);这是第一层。第二层是针对团队资源的团队成员校验const isTeamMember (teamId: () string) t.middleware(async ({ ctx, next }) { const user ctx.session?.user; const membership await db.teamMember.findUnique({ where: { teamId_userId: { teamId: teamId(), userId: user?.id as string, }, }, }); if (!membership) { throw new TRPCError({ code: FORBIDDEN }); } return next({ ctx: { ...ctx, membership } }); });这套组合拳下来所有受保护片段的读写都必须先过两道关卡。顺序很重要先鉴权再鉴权到具体资源。4.2 服务端校验 vs 客户端提示很多教程里会写前端根据用户角色显示按钮但那只解决了用户看不看得到没解决用户能不能调接口。我始终坚持服务端校验才是真校验前端只是体验优化。实现方式是在tRPC的router里把权限判断和业务逻辑分两段。例如编辑片段update: protectedProcedure .input(z.object({ id: z.string(), title: z.string().min(1), code: z.string().min(1), })) .mutation(async ({ ctx, input }) { const snippet await db.snippet.findUnique({ where: { id: input.id } }); if (!snippet) { throw new TRPCError({ code: NOT_FOUND }); } // 核心权限校验要么是作者要么是团队管理员 if (snippet.authorId ! ctx.userId !ctx.membership?.isAdmin) { throw new TRPCError({ code: FORBIDDEN }); } return db.snippet.update({ where: { id: input.id }, data: ... }); })而前端的体验就是能拿到数据但按钮置灰或者调用时报错后弹提示。最怕的就是前端很自信地以为没显示按钮就是没权限后面测试时用Postman一捅就漏了。4.3 审计日志与软删除设计上面提到软删除这里展开聊。软删除最大的坑是所有查询都要记得过滤deletedAt稍不注意就会把已删除数据查出来。我这里的做法是封装了一个Prisma扩展const extendedDb prisma.$extends({ query: { snippet: { async findMany({ args, query }) { return query({ ...args, where: { deletedAt: null, ...args.where }, }); }, }, }, });这样所有对外查询默认带上未删除条件只有审计和管理员还原时才显式查询deletedAt。这类约束如果只靠开发人员自觉迟早出幺蛾子建议在数据访问层统一处理。审计日志方面我建了一张AuditLog表在tRPC的mutation成功后写入谁在什么时间干了什么。不搞复杂的消息队列就是同步写一条日志。对于内部工具来说这才是真正能拿来追溯的东西。5. 部署前夜性能优化与那些被忽视的配置5.1 打包体积分析与字体本地化项目功能都跑通后部署前最关心的就是体积和速度。我用next/bundle-analyzer看了一眼bundle发现问题最大的不是业务代码而是第三方库中的日期处理包和图标库被整包引入了。Next.js 13默认会做tree-shaking但有些CJS库就是改造不动。解决办法要么切换成按需加载要么换库我最后把几个大的纯工具库合并成了一个自己写的工具集体积直接砍掉30%。另外一个容易被忽视的点开发时用了Google Fonts构建时Next.js默认会在运行时去拉字体导致首屏涉及的接口请求多了一层外部依赖。部署到内网环境没有外网权限直接白屏拉不上来字体。解决方式是构建时把字体文件下载到本地用next/font/local引用彻底断了运行时外部请求。5.2 tRPC批量查询的N1问题tRPC用起来爽但远程函数调用就像远程银行转账——单笔没感觉循环100笔就慢到怀疑人生。我踩的一个典型坑是在片段列表页查评论数前端对每个片段发一次comment.count的query列表15个片段就是15个请求白白多了15个RTT。排查链路是这样的打开Network面板发现首页有16个tRPC请求第一反应是怎么这么多组件里看代码发现每个SnippetCard都独立调用了trpc.comment.getCount改成在列表页的查询里用Prisma的_count直接把评论数带出来const snippets await db.snippet.findMany({ include: { _count: { select: { comments: true } }, author: true, }, });请求数从16降成1。写tRPC的同学务必养成的习惯凡是可以聚合的查询在服务端一次性查出结构话数据再抛给前端。别把tRPC当成端到端直连数据库的魔法它只是帮你少写类型定义没有免掉网络开销。5.3 环境变量与CI的一次配置部署前我还处理了一个看似不起眼但折腾了一晚上的问题CI环境里跑prisma migrate deploy失败原因是CI环境没有数据库连接权限。工程配置里.env文件默认没有提交到git而CI机器上没有这个文件Prisma CLI就找不到DATABASE_URL。解决办法是在CI的yml里显式传递环境变量并在prisma generate之前先跑prisma migrate deploy。如果你用的是GitHub Actions在Settings - Secrets里配置好DATABASE_URL然后yml里这样写- name: Apply migrations run: npx prisma migrate deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }}顺手再给Prisma Client加一个generator配置里的previewFeatures这取决于你用的版本。反正CI这块大多数莫名其妙失败都是环境变量没传干净先查这步。6. 上线一个月后的复盘哪些设计扛住了哪些还要改说实话项目上线后并没有一劳永逸。一个月下来有几个观察挺有意思。扛住的部分类型安全是真的香。改了一次Snippet表加字段前端所有用到的地方标红逐个修完一次通过。软删除方案没有拖垮查询性能配合默认过滤规则也没出现过已删除内容复活的异常。tRPC的中间件体系让我加团队管理权限时几乎没动业务代码很轻松地插入了一层校验。没扛住的部分全文检索。当时图省事用了LIKE %keyword%数据量过万之后查一次能感知到慢。后面得换Postgres的全文索引或者接一个搜索服务。团队权限模型还是太简单了。上线后用户反馈我需要让某个成员只能看不能改、看能不能按标签限制可见性这是当时没细想的部分。NextAuth的Credentials方案对于内部工具够用但如果以后要接企业微信或飞书登录可能得换一套认证中间层。如果让我重来一遍我会在权限模型上多花一倍时间。代码片段管理工具看起来是CRUD 搜索真正做起来你会发现权限才是它的灵魂。最后分享一个小技巧上线之后我保留了调试用的health router每次排查部署问题时访问一下就能极快地确认服务在线、数据库连接正常、认证模块可用比看日志大海捞针舒服多了。t3code这个项目做到这里算告一段落但T3这套组合的探索还在继续后面有新的体会再来分享。
阅读完成 · 觉得有帮助?