如果你跟我一样,做全栈项目最烦躁的就是前后端类型各管各的——后端改了字段类型,前端不声不响就崩了,等联调的时候才从报错里一个个翻出来。t3code 是我用 T3 Stack(Next.js + TypeScript + Tailwind CSS + tRPC + Prisma)从零搭起来的一个代码片段分享工具,核心思路就是让类型定义从数据库一路贯穿到页面按钮,API 接口写完,前端调用处的类型提示自动全量生成,联调时间实打实砍掉了六成以上。
这篇文章会把 t3code 从选型、初始化、数据建模、tRPC 路由编写到部署排障的完整过程掰开揉碎讲一遍,里面包含了我实际开发中踩过的坑和真正的解决思路,适合已经掌握 React 基础、想体验端到端类型安全开发体验的前端工程师,也适合被前后端接口文档折磨过、想换一种协作方式的全栈开发者参考。
1. 项目定位:t3code 解决的不只是"粘代码"
1.1 传统全栈模式里最让人头疼的类型割裂
在开始讲 t3code 之前,先聊一个几乎所有全栈项目都会遇到的场景。前端用 ts 写了完美的 interface,后端在接口文档里说返回了另一个结构,两边靠什么对齐?靠人肉。就算用了 Swagger、OpenAPI 自动生成类型,也往往存在代码生成不及时、装饰器写法侵入业务代码、额外维护一份 schema 的问题。更别说很多项目连接口文档都是过期的。
我过去做过的几个项目里,最常见的翻车现场大概是这种:后端把id从 number 换成了 string,前端页面上还在用Number(row.id)做判断,等到某个用户点开详情页才发现全是 NaN,最后查了半天发现是类型没对齐。这类问题在 t3code 里基本被连根拔起,因为类型定义不再是"前端一份、后端一份",而是由代码自动推导的同一份。
1.2 T3 Stack 这套技术选型背后的逻辑
T3 Stack 是由 Theo 推广的一个全栈 TypeScript 技术栈组合,核心成员是 Next.js、TypeScript、Tailwind CSS 和 tRPC。它强调的不是炫技,而是"少而精":能用类型推导解决的不写胶水代码,能靠约定规避的不做复杂配置。t3code 选择这套组合的原因总结下来有三点:
第一,类型安全是真正端到端的。数据库模型定义在 Prisma Schema 里,字段类型经过 Zod 校验后进入 tRPC 路由,前端调用返回的每个字段都带着完整类型。这不是"代码提示很全"的小甜头,而是工程协作层面的变革——后端改了返回结构,前端编译立刻报错,不用等联调才发现。
第二,开发体验统一。Next.js 负责服务端渲染和路由,Tailwind 负责样式,tRPC 负责 API,全部跑在 TypeScript 生态内,不用在几种语言和配置之间反复横跳。
第三,社区生态成熟且有大量生产级实践。虽然"全栈 TypeScript"这个概念不算新鲜,但 T3 Stack 把现有工具的最佳实践拧成了一股绳,很多大公司内部项目都验证过这套路径的稳定性。
当然,T3 Stack 也不是没有代价。它在项目规模变大之后,tRPC 的 context 和 middleware 设计需要更精细,服务端渲染下的缓存策略也要手动调。这些问题我在后文会详细展开,尤其是 t3code 从单机 Demo 演进到多人协作时遇到的类型和性能瓶颈。
1.3 t3code 到底是个什么样的项目
t3code 本身是一个轻量的代码片段管理应用,功能上对标 Pastebin 和 GitHub Gist 的最小可用集:用户可以匿名或登录后创建片段,填写标题、语言类型和代码内容,片段支持公开分享,也可以标记为私密。听起来平平无奇,但它恰好覆盖了全栈开发里最有代表性的几个模块:
- 数据库建模与关系映射(用户、片段、标签)
- 认证与会话管理(NextAuth + Credentials/Provider)
- 服务端 API 逻辑(tRPC 的 query/mutation/infiniteQuery)
- 前端页面交互(编辑器、列表、详情页)
- 部署与运维配置(环境变量、构建、数据库迁移)
换句话说,t3code 就是 T3 Stack 的完整样例工程。你把它跑起来、改一遍、拆一遍,基本就掌握了这套技术栈的 80% 核心用法。
2. 核心链路拆解:从数据库到按钮的直通类型安全
2.1 tRPC 到底是怎么工作的
tRPC 的定位是"构建类型安全的 API 层,不需要 schema 定义、不需要代码生成、不需要额外的运行时依赖"。它最巧妙的地方在于利用了 TypeScript 的类型推导能力,把 router 的结构在编译期就映射成了客户端可用的类型。
在 t3code 里,我定义 API 的入口文件大致长这样:
// 服务端:定义 router import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const snippetRouter = createTRPCRouter({ bySlug: publicProcedure .input(z.object({ slug: z.string().min(1) })) .query(async ({ ctx, input }) => { return ctx.db.snippet.findUnique({ where: { slug: input.slug }, include: { author: true }, }); }), });然后客户端调用它的时候,不需要手写任何 HTTP 路径、不需要拼接参数、不需要定义返回类型:
// 客户端:调用 API const { data } = api.snippet.bySlug.useQuery({ slug: "t3code-intro" });data的类型会自动推导为 Prisma 返回的Snippet & { author: User } | null。如果服务端把author字段改成owner,前端调用处立刻出现类型错误。这种体验用一句话总结就是:API 像函数调用一样安全,却比函数调用更可控——因为权限、校验、缓存仍然收口在服务端。
我第一次跑通这个链路的时候,最大的感触不是"类型提示很爽",而是"那些在后端返回里多写了或漏写了字段的低级错误,以后再也不用手动背锅了"。类型系统直接把我变成了一名更小心的后端工程师。
2.2 Prisma 和 Zod 如何构成类型链路的底座
tRPC 负责类型推导的"传输层",但真正的源头是数据模型。t3code 里的 Prisma Schema 定义了核心实体:
model User { id String @id @default(cuid()) name String? email String? @unique snippets Snippet[] } model Snippet { id String @id @default(cuid()) slug String @unique title String content String language String @default("plaintext") private Boolean @default(false) author User? @relation(fields: [authorId], references: [id]) authorId String? createdAt DateTime @default(now()) }Prisma 有个巨大的优点:它是 schema 驱动的,数据库表结构、TypeScript 类型、迁移脚本都从同一份 schema 生成。你在 schema 里改一个字段,跑一次prisma migrate,类型定义就自动同步了,不可能出现"数据库有字段但代码里忘了加"的情况。
Zod 则是 tRPC 输入校验的标配。每个 procedure 的input都经过 Zod schema 解析,类型安全从"返回值的正确性"推延到了"入参的正确性"。比如创建片段时:
const createSchema = z.object({ title: z.string().min(1).max(120), content: z.string().min(1).max(50000), language: z.enum(["ts", "py", "js", "go", "rust"]), private: z.boolean().default(false), });如果前端传了非法枚举值,tRPC 会直接返回结构化错误信息,不需要在业务代码里写一堆if (xxx === undefined)的防御逻辑。Zod 的好处还体现在前后端共用上,同一个 schema 可以同时用于服务端输入校验和客户端表单验证,比如 React Hook Form 就能无缝对接。
2.3 Next.js App Router 与 tRPC 的边界问题
T3 Stack 早期默认用 Pages Router,但随着 Next.js 13 之后 App Router 逐渐成为主流,t3code 我选择了最新模板里的 App Router 方案。这里有个很重要的实践细节:服务端组件和客户端组件的边界一旦划错,tRPC 的 hooks 就没法用。
tRPC 的useQuery、useMutation这些 hooks 依赖 React Context,只能在客户端组件里调用。而 Next.js App Router 里所有组件默认是服务端组件,如果直接在一个服务端组件里调用api.snippet.bySlug.useQuery(),会直接收到 React 的错误提示。
我在 t3code 里采用的方案是在页面顶层用服务端组件做数据和元信息准备,然后根据场景决定用一个客户端包装组件来跑 tRPC 的 hooks:
// app/snippets/[slug]/page.tsx(服务端组件) import { SnippetViewer } from "~/app/_components/snippet-viewer"; export default async function SnippetPage() { // 服务端可以直接调 tRPC 的服务端 caller,不需要 hooks const caller = await createCaller(); const initialData = await caller.snippet.bySlug({ slug }); return <SnippetViewer initialData={initialData} />; }// _components/snippet-viewer.tsx(客户端组件) "use client"; import { api } from "~/trpc/react"; export function SnippetViewer({ initialData }) { const { data } = api.snippet.bySlug.useQuery( { slug }, { initialData } ); // 交互逻辑,比如复制按钮、点赞按钮 }把 tRPC hooks 的调用统一收敛到一个明确的"客户端组件层",既保留了 query 的缓存和更新能力,又不会让整个页面被迫降级为客户端渲染。这个模式我强烈建议刚上手的同学直接抄。
3. 实操过程:t3code 从初始化到第一个功能上线
3.1 环境准备与 create-t3-app 初始化
搭建 T3 Stack 项目最简单的方式就是用官方脚手架create-t3-app。我初始化 t3code 时的命令是:
pnpm create t3-app@latest t3code交互式选项里有几个关键选择值得说明一下。首先是包管理器,我用 pnpm 而不是 npm,因为 T3 Stack 依赖树浅、安装快,pnpm 的硬链接机制能节省大量磁盘空间,尤其是 Next.js 这种依赖动辄几百 MB 的项目。其次是数据库,我选了 SQLite 起步,后续再切换到 PostgreSQL——原因是本地开发时 SQLite 零配置,等部署到生产环境再通过环境变量切换连接串,Prisma 对这种切换的支持非常成熟。
初始化完成后,脚手架会自动生成 monorepo 风格的单仓结构,并且把 tRPC、NextAuth、Prisma 的 boilerplate 全部配好。项目根目录下会有prisma/schema.prisma、src/server/api/trpc.ts、src/trpc/server.ts和src/trpc/react.ts这几个关键文件。我建议新手此时不要急着改代码,先把pnpm dev跑起来,确认首页能正常渲染、tRPC 健康检查接口能返回数据。
这里有个很容易被忽略的坑:create-t3-app 会让你选择要不要启用 ESLint、Prettier 等代码检查工具,我建议全开。TypeScript 项目的类型安全如果只靠编辑器提示,很容易在 CI 环节出现"本地能跑,云端报错"的尴尬。早期就接入 lint 和 format,看起来多花了几分钟配置,后面能帮你省下几小时排查。
3.2 数据模型与路由定义
我的开发顺序是先定义 Prisma Schema,再写 tRPC router,最后接前端页面。这里面的逻辑是:类型源头必须先在数据库层固定下来,如果先写前端页面后补模型,就容易出现"页面要的字段数据库没有"的倒逼式改表。
t3code 的 snippetRouter 定义在src/server/api/routers/snippet.ts,核心 procedure 如下:
export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ cursor: z.string().nullish(), limit: z.number().min(1).max(50).default(10), language: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const items = await ctx.db.snippet.findMany({ where: { private: false, ...(input.language ? { language: input.language } : {}), }, orderBy: { createdAt: "desc" }, take: input.limit + 1, ...(input.cursor ? { cursor: { id: input.cursor } } : {}), }); let nextCursor: string | undefined; if (items.length > input.limit) { const next = items.pop(); nextCursor = next?.id; } return { items, nextCursor }; }), create: protectedProcedure .input(createSchema) .mutation(async ({ ctx, input }) => { return ctx.db.snippet.create({ data: { ...input, slug: generateSlug(), authorId: ctx.session.user.id, }, }); }), });list用的是无限滚动游标分页模型,create用了protectedProcedure,这要求用户必须登录才能创建片段。你注意protectedProcedure和publicProcedure的差异——前者会在执行 handler 之前先检查 session,通过 context 里注入的ctx.session来获取当前用户。这套基于 middleware 的权限控制是 tRPC 非常精华的设计,比在业务代码里到处if (!user) throw new Error()干净得多。
3.3 前端页面与类型安全调用
列表页的核心组件是SnippetList,它用useInfiniteQuery实现滚动加载:
"use client"; import { api } from "~/trpc/react"; export function SnippetList() { const utils = api.useUtils(); const { data, fetchNextPage, hasNextPage, isFetching } = api.snippet.list.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) => lastPage.nextCursor } ); return ( <div> {data?.pages.map((page) => page.items.map((snippet) => ( <SnippetCard key={snippet.id} snippet={snippet} /> )) )} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetching}> 加载更多 </button> )} </div> ); }调用useInfiniteQuery时,tRPC 会自动把输入中的cursor参数和响应中的nextCursor关联起来。第一次使用的同学可能会被这个抽象搞得有点懵,其实它的底层逻辑就是传统的游标分页,只不过 tRPC 帮你把"取下一页"的状态管理封装好了。
在创建片段页面,我用 React Hook Form 配合 Zod 做了表单校验。这样用户在提交前就能看到校验错误,即使用了非法输入,tRPC 后端也会再做一次拦截,安全性上是双保险。这就是我前面说的"同一个 schema 前后端复用"的典型场景。
3.4 认证与会话管理:NextAuth 的接入和坑
t3code 的认证方案是 NextAuth.js(Credentials Provider + GitHub Provider 双模式)。本地开发时我用 GitHub OAuth,因为 alipay、微信这类国内 OAuth 在 NextAuth 里没有官方适配器,对接成本高了一个数量级。
接入 GitHub Provider 需要到 GitHub 后台申请一个 OAuth App,把回调地址填成http://localhost:3000/api/auth/callback/github,然后拿到 Client ID 和 Client Secret,写入.env:
AUTH_SECRET="你的session密钥" AUTH_GITHUB_ID="..." AUTH_GITHUB_SECRET="..."NextAuth v5 的配置和 v4 有差异,create-t3-app 生成的模板已经是 v5 风格,会把主要配置放在src/auth.ts:
import NextAuth from "next-auth"; import GitHub from "next-auth/providers/github"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { db } from "~/server/db"; export const { handlers, signIn, signOut, auth } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub], callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, });这里最大的坑是 session 回调里拿到的user并不一定包含数据库 user 记录的id。尤其是使用 PrismaAdapter 时,session 数据默认从 Session 表里读,而 Session 表里存的是userId,不是user.id。正确做法是像上面这样在sessioncallback 里显式把 user id 挂到session.user.id上,否则你后面在 tRPC 的 context 里拿 session 时,很可能拿不到session.user.id,导致创建 snippet 报外键约束错误。
4. 踩坑实录与问题排查
4.1 tRPC 类型丢失的三个典型场景
t3code 开发过程中,我几次遇到"类型提示突然变成 any"的诡异现象。总结下来有三个典型触发场景。
第一个是用了as any或者@ts-ignore把类型断言炸掉了。不要笑,我在调试时为了快速验证逻辑手动跳过了一个类型报错,结果整个页面里所有相关字段的类型推导全部退化成any,排查了半天才发现是那行// @ts-ignore的家伙。这里的教训是:tRPC 的类型安全依赖于完整的类型链,任何一环出现any,链条就断了。
第二个是把 createTRPCRouter 的返回值单独导出后又手动改了类型。tRPC 的类型是通过typeof router推导到客户端 hooks 上的,如果你在中途做了映射或变换,类型推导就会丢失。
第三个是 tRPC 版本升级后旧模板混用。从 v10 到 v11 的迁移过程中,createTRPCProxyClient和createTRPCReact的角色发生了很大变化,我一开始直接把旧代码复制过来,结果客户端完全没有类型提示。解决方案是删除所有以trpc开头的新旧混合文件,重新用脚手架生成的模板覆盖。
4.2 App Router 下 "use client" 与服务端数据加载的边界问题
这是 Next.js 全栈开发里最容易翻车的地方,我在 t3code 里反复踩了三次。核心矛盾在于:tRPC hooks 是客户端行为,但数据和首屏渲染又最好放在服务端做。
第一次踩坑是我把一个SnippetViewer直接 import 进了page.tsx,但这个组件内部通过createTRPCProxyClient做了客户端调用。结果就是页面明明有服务端渲染能力,却因为组件树里存在客户端依赖,整条渲染链被迫降级成 CSR,首屏白屏时间从 200ms 变成 2 秒。
第二次踩坑是反向的,我在服务端组件里用了api.snippet.bySlug.useQuery(),直接报错useContext只能在客户端组件里使用。其实正确做法应该是我前面提到的:服务端组件通过 tRPC server caller 预取数据,再把initialData传给客户端组件,让客户端组件用initialData作为兜底数据进行 hydration,之后用户交互产生的更新再走客户端 query。
4.3 Prisma 迁移和 Seed 数据的实际处理
t3code 开发到一半时我改了 User 模型的字段,然后跑prisma migrate dev时遇到了一个常见问题:SQLite 对ALTER TABLE的支持有限,Prisma 会自动尝试重建表。如果数据库里已经有测试数据,重建过程可能因为外键约束失败。
我的处理方式是分两步走:
# 第一步:导出当前数据 npx prisma studio 手动导出相关表 # 第二步:重置数据库后重新迁移 npx prisma migrate reset --force npx prisma migrate dev npx prisma db seedprisma migrate reset是个强力指令,会直接清空数据库并按迁移脚本重建。除非是本地开发环境,否则不要在生产上执行。生产环境的 schema 变更最好用prisma migrate deploy配合预先生成的 SQL 文件做。
seed 脚本我写在prisma/seed.ts里,用prisma db seed触发。关键是要在package.json里配置好 seed 命令:
"prisma": { "seed": "tsx prisma/seed.ts" }不配这行的话,prisma db seed会直接报错找不到 seed 脚本,这个细节很容易被初学者跳过。
4.4 部署与构建阶段的环境变量问题
部署到 Vercel 时,我发现next build阶段如果有未定义的环境变量,Prisma 会直接构建失败。解决方法是把.env中所有 key 都抄到 Vercel 的 Environment Variables 设置里,并注意区分 Preview 和 Production 环境。
另一个细节是AUTH_SECRET。NextAuth v5 在 build 阶段会读取这个值来加密 session,如果部署平台没有设置AUTH_SECRET,构建虽然能过,但运行时用户登录会一直跳转回登录页。这个问题极其隐蔽,因为它不会在next build时报错,而是运行时才表现出来。
4.5 常见问题速查表
这里整理一份我在 t3code 开发中高频遇到问题的排查清单,实战价值非常高,基本可以当成故障手册用。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 客户端 API 调用没有类型提示 | 类型链中有any或@ts-ignore | 搜索代码中的as any并消除 |
| 页面渲染成纯 CSR | 服务端组件里引入了客户端组件且没有划分边界 | 用 server caller 预取 + 客户端组件接收 initialData |
| 登录后 session.user.id 为 undefined | session callback 没挂 user.id | 在 NextAuth 的 callbacks.session 里手动赋值 |
| 创建记录时外键约束失败 | 关联用户查询不到 session 中返回的 id | 检查 PrismaAdapter 的 user 映射是否正常 |
| build 阶段无法启动 Prisma Client | 环境变量缺失 | 确认 DATABASE_URL 和 AUTH_SECRET 已配置 |
| 无限滚动加载重复数据 | cursor 字段用错为createdAt | 游标必须用唯一字段 id,排序字段可另加 |
| tRPC procedure 报 input 校验错误 | 前端传入 undefined 或空字符串 | 使用 Zod 的.default()和.optional()定义默认值 |
5. 性能优化与安全加固经验
5.1 避免 N+1 查询和体积过大的返回
t3code 的列表页一开始每个卡片都要渲染作者头像和语言标签,Prisma 默认不会自动 include 关联表,所以我在findMany里没有写include时,每个 snippet 的 author 就需要额外查询一次,列表加载 10 条就变成了 1 + 10 次数据库查询。这类 N+1 问题在 Prisma 里最直接的解决方式是用include或select一次性把需要的关联数据拉取回来:
const snippets = await ctx.db.snippet.findMany({ include: { author: { select: { name: true, image: true } }, }, });但如果页面根本不需要展示 author 信息,更好的做法是不查它,而不是用 include 把它带回来。返回给前端的字段越少,类型安全带来的收益越明显,因为你不希望把数据库里所有字段都暴露给前端。这一点可以用select精确控制返回字段,既能降低带宽,也能减少隐私泄露面。
5.2 响应缓存与数据新鲜度的取舍
tRPC 的 query 默认不做数据缓存,每次组件重新挂载都会重新请求。为了平衡体验,我在 t3code 里给部分 query 增加了 staleTime:
api.snippet.list.useInfiniteQuery( { limit: 10 }, { staleTime: 30_000, } );staleTime: 30000表示 30 秒内数据不过期,在这段时间内重新进入页面会直接用缓存,不会发请求。这个数值要根据业务场景调整:代码片段列表并不要求秒级一致,所以 30 秒可以;但用户创建了一个新片段后,就需要立刻更新列表数据,我利用api.useUtils().invalidate()让列表自动失效。
const utils = api.useUtils(); const createMutation = api.snippet.create.useMutation({ onSuccess: async () => { await utils.snippet.list.invalidate(); }, });只要 mutation 成功,列表缓存自动失效并重新请求,页面上的新代码片段直接出现,不需要手动刷新。
5.3 部署架构与安全建议
t3code 部署我选择的是 Vercel + PlanetScale(或 Supabase 的 PostgreSQL 实例)。Vercel 对 Next.js 的支持是最好的,零配置就能实现自动部署和预览环境;数据库用托管 PostgreSQL 而不是部署自建,原因是 Prisma 和 PostgreSQL 的生产特性配合最成熟,比如 JSON 字段、枚举类型、全文索引,这些都是 SQLite 不具备的。
安全方面有几点必须做好:第一,.env文件绝不提交到 git 仓库,用.env.example记录所有 key 的名字和格式说明;第二,给数据库账号分配独立的、最小权限的连接串,不要用管理员账号跑生产环境;第三,tRPC 的公开 procedure 一定要做好输入校验,防止有人通过构造超大limit参数打爆数据库。
在写公开接口的时候,我养成了一个习惯:每个返回用户数据的 query 都先把ctx.session?.user.id和资源所有者做比对,确保私密片段不会泄露给未登录用户。Prisma 里可以用where: { private: false }轻松过滤,但如果哪天业务逻辑复杂到需要对每个字段单独做权限判断,建议在 tRPC middleware 里统一封装一个accessibleQuery方法,避免每个路由里重复写权限判断。
6. 从 t3code 里真正沉淀下来的工程方法论
6.1 单向数据流:类型定义的唯一来源
t3code 整个项目跑通之后,我最大的体会是:全栈类型安全的本质不是工具链有多炫,而是让每个类型定义都只有唯一来源。Prisma Schema 是数据库模型的唯一来源,Zod schema 是 API 入参的唯一来源,tRPC router 是 API 返回值的唯一来源。前端组件永远不应该自己定义一份与后端对应但彼此独立的 interface。
这个原则听起来简单,做起来容易破功。比如页面里经常会出现"临时聚合数据"的需求,开发者图省事直接定义了一个type SnippetWithAuthor = Snippet & { authorName: string },这个类型是后端没有的,所以它必然会在一两次迭代后与实际数据脱节。t3code 里我尽量避免这种情况:如果页面需要聚合数据,就把它定义在服务端 router 里,再通过 tRPC 返回。
6.2 如何把这套模式复用到其他项目
t3code 沉淀的这套开发模式可以非常方便地复制到任何全栈项目中。如果你正准备做一个新项目,我建议从三个层面复用:
第一,架构复用。用create-t3-app初始化,保留 tRPC + Prisma + NextAuth 的 boilerplate,在 Prisma Schema 里先做领域建模,再写 API 路由。
第二,工程规范复用。把 ESLint 严格模式、Prettier 自动格式化、tsc --noEmit的 pre-commit hook 都保留下来。类型安全不只是编辑器体验,更应该是 CI 红线——任何类型报错都不允许合并。
第三,场景化模板复用。t3code 里的无限滚动列表、表单校验、登录受保护的路由、服务端组件预取数据这四个核心模块,几乎可以覆盖 70% 的常见业务页面。把这四个模块抽成自己的一份模板,新项目仅仅是换领域模型而已。
6.3 后续可以怎么扩展
t3code 目前是一个功能完整的最小可用产品,但离生产级还有距离。我个人在规划中的扩展方向有三个:第一个是给代码片段加评论和回复,本质上是建立新的 Prisma 模型Comment和一组 mutation 路由;第二个是全文搜索,Prisma + PostgreSQL 可以直接用@@fulltext索引,配合 tRPC 做一个搜索 query;第三个是把积分的权限模式改成多租户,让一个部署实例支持多个团队各自管理私有代码片段。
如果你想把它真正做成一个有用户的产品,还需要加埋点、错误监控(比如 Sentry)、邮件通知这些模块,但这些组件都属于"非核心技术难点",把它们插进现有架构里都不费劲,因为类型安全的链路已经帮你把最容错的部分解决了。
最后再分享一个实际操作中的技巧:如果你打算长期维护这个项目,一定要在早期就把 API 的版本化考虑进去。t3code 的 router 名直接用了snippet、user,未来如果协议发生重大变化,建议改成snippetV1或者引入单独的路由文件做版本规划。否则一旦客户端开始依赖旧返回结构,后端改动就会造成连锁性的破坏,这一点在类型安全体系下尤其容易被忽略——因为类型全对,反而会给你一种"随便改都没事"的错觉,而运行时行为的变化并不是类型系统能覆盖的。