
前段时间手头负责了一个内部项目代号就叫 t3code。名字起得随意其实含义很直白用 T3 技术栈做的代码管理工具。项目本身不算复杂——一个给开发团队用的代码片段与最佳实践管理平台支持发布片段、标签检索、打分评价、管理员审核。但真正有意思的是整个过程的技术决策我们从一开始就定下TypeScript 贯通全栈的基调最终落在了 Next.js tRPC Prisma Tailwind 这套组合上前后从搭建到上线花了六周。这篇文章就把完整的选型逻辑、落地细节、性能优化和踩坑记录都整理出来给正在评估 T3 Stack或者准备用这套组合做内部工具、中后台项目的朋友一个真实参考。1. t3code 是什么项目背景与要解决的问题1.1 团队代码知识散落的真实痛点t3code 这个项目的起因其实是所有研发团队都会遇到的一个老大难问题代码知识散落得到处都是。我们团队日常的片段、模板、踩坑记录分布在聊天记录、个人笔记、共享文档和一堆没人维护的 wiki 里。新同学入职想查公司统一的 axios 封装怎么写的要在三个平台翻二十分钟老同学写相似功能时经常凭记忆重写一遍风格和细节对不上。所以 t3code 要解决的就是把代码片段和使用场景沉淀成一个可检索、可复用、可讨论的集中地。核心功能很聚焦用户登录后发布代码片段支持多语言、多标签按标签、语言、关键词检索支持分页加载团队成员可以对片段打分、评论高质量内容会浮上来管理员负责审核、归档和删除低质量内容单看功能量级这不算一个高难度项目。但我们对它有四个硬性约束第一前后端所有数据类型必须共享绝不允许接口文档和代码脱节第二数据库模型改动时要第一时间暴露编译错误第三部署要足够简单一个小团队不能养一个专职运维第四后续要能平滑扩展成内部 API 服务给其他系统复用。这四个约束直接排除了很多常规做法。1.2 T3 这个命名到底指什么T3 不是某个公司或者产品的代号而是社区里一套技术组合的统称。它最早由 create-t3-app 这个脚手架推广开来命名来自三个以 T 开头的东西TypeScript、tRPC、Tailwind CSS。在此基础上社区约定俗成地配套了 Next.js 作为框架、Prisma 作为 ORM、NextAuth.js 做认证。我当时看到这套组合的第一反应是又一个大杂烩——毕竟热门的全栈方案太多了什么 MERN、Jamstack、Nuxt 全家桶。但真正把 t3code 的需求往这套组合上套了一遍之后我意识到它不是简单的技术堆叠而是围绕一个核心思想组织起来的端到端类型安全。数据库的字段、后端的接口、前端的调用全部由 TypeScript 类型系统串起来改一个字段名编译器直接帮你把该改的地方全部标出来。对 t3code 这种内部工具优先、多人维护、需求会快速迭代的项目来说这个特性不是锦上添花而是刚需。项目上线后我们确实尝到了甜头整个开发周期里因为没有前后端联调的类型不匹配问题光这一项就省了不少时间。1.3 这个项目适合谁参考如果你属于下面几类情况这篇复盘应该对你有用准备用 T3 Stack 做全栈项目想先看一遍真实落地过程的人做内部工具、管理后台、团队协作类产品正在纠结要不要上 tRPC 的人已经用 Next.js Prisma但还不确定 tRPC 值不值得引入的人想了解全栈类型安全怎么落地、有哪些坑的人接下来我按时间线把整个项目的关键节点拆开讲从选型、建模、实现到部署每一步都会解释为什么这么选。2. 技术选型复盘为什么是 Next.js、tRPC、Prisma 这一套2.1 TypeScript 贯穿全栈到底省了多少事先聊一个反直觉的结论在 t3code 项目里我们省下的最大成本不是写代码的时间而是对接口的时间。做过前后端分离项目的人都知道传统模式下最大的时间黑洞往往不是功能本身而是联调前端等后端出接口文档、字段名对不上、类型对不齐、某个返回值可能是 null 但前端没处理……TypeScript 贯穿全栈之后这个过程被压缩到了极致。t3code 里数据库表结构由 Prisma Schema 定义Prisma Client 根据 Schema 生成类型完整的查询 APItRPC 的 router 直接复用这些类型自动推导出 all 形式的输入输出类型前端调用 API 时从回调参数到返回结果全部有自动补全和编译期检查。也就是说从数据库到浏览器按钮中间所有环节的类型是同一个源头长出来的不存在两边各维护一份类型定义的问题。这个体验跟以前写 REST API 完全是两个维度。之前做接口我至少要维护 OpenAPI 文档、前后端各自的 interface、手动写请求封装现在这些都不用管。曾经我们花了几个小时重构 Snippet 模型加了几个字段、改了关联关系放在以前至少要拉一个会议通知前后端同步改而这次改动前端代码时编译器把报错点一个个列出来改完直接通过数据库迁移一跑功能就上线了。2.2 tRPC 为什么比 REST 和 GraphQL 更适合这个场景这是选型时间最长的一个话题。我们内部也争论过用 REST OpenAPI 不是更主流吗用 GraphQL 不是更灵活吗最后选择 tRPC核心原因有两个场景匹配和心智负担。先看 REST。REST 本身没有任何问题它是互联网的基石。但对于 t3code 这种内部项目REST 意味着要为每个资源写一套 controller配路由、配状态码、配 DTO 校验还要维护 OpenAPI 文档然后前端再根据文档生成 client。这套链路在大型对外系统里是必要的因为你要为第三方调用方提供稳定契约。可 t3code 的调用方只有我们自己这一大套流程全都是纯成本。再对比 GraphQL。GraphQL 的优势在于按需取数和复杂的多端聚合查询但代价是引入一层 Schema 语言SDL、resolver 结构、客户端缓存配置还有相当陡峭的学习曲线。对 t3code 这样一个模式相对固定、没有十几个端要服务的项目来说GraphQL 提供的灵活性是过剩的反而徒增复杂度。tRPC 的思路完全不同它不定义额外的接口描述语言而是把后端函数直接暴露给前端调用接口就是函数类型就是 TypeScript 的类型。前端导入一个 hook传入参数拿到的是带完整类型的返回值后端声明一个 query写出输入校验规则参数会自动校验。整个过程没有 schema 文件、没有代码生成步骤、没有运行时中间层。当然 tRPC 也有自己的适用边界。它更适合单一服务端 少量客户端的场景如果要做开放平台、多个异构客户端比如小程序 iOS Web那 REST/OpenAPI 或者 GraphQL 仍然是更稳妥的契约方案。这一点要在选型前想清楚。2.3 Prisma 与数据库建模的关系数据库访问层我们选了 Prisma。理由很直接它把表和关系的描述集中在一个 Schema 文件里通过prisma migrate管理迁移生成的 Client 自带类型推导和 tRPC 的配合几乎是无缝的。用 Prisma 写数据模型就是声明式的不用手写 SQL 建表语句也不用单独维护迁移 SQL。更重要的是它的关系查询体验在 tRPC 的 resolver 里一行include就能把关联数据一次性查出来返回给前端的对象结构直接就是嵌套好的。对比我之前用过的一些 ORMPrisma 在类型安全上的表现是最完整的。查询条件、排序字段、关联筛选都能在编译期校验写错一个字段名立刻报错而不是运行时才爆炸。对 t3code 这种频繁迭代数据模型的内部项目来说这个保障非常值钱。2.4 Tailwind 和 NextAuth省时间的配套Tailwind 其实没什么好犹豫的。t3code 是内部工具没有复杂的设计系统需求用 Tailwind 写工具类样式就在 JSX 旁边改起来快也不会因为 CSS 命名冲突打架。另外 Tailwind 的响应式工具类和暗色模式支持都很成熟我们后期加暗色主题只花了一个多小时。NextAuth.js 则承担了所有认证相关的脏活OAuth 登录流程、Session 管理、CSRF 防护、JWT/数据库会话策略。t3code 最开始只支持 GitHub OAuth 登录后面加了邮箱登录整个过程都是配置式的没有手工实现认证逻辑。不过 NextAuth 的配置项和回调函数确实容易让人懵我后面在踩坑章节会详细展开。3. 从零搭建 t3code脚手架、目录结构与数据模型3.1 用 create-t3-app 初始化项目T3 Stack 最大的入口就是 create-t3-app 脚手架它把技术选型从讨论变成了勾选。我当时用的命令pnpm create t3-applatest t3code命令行会交互式询问启用哪些模块。我的选择是Next.js 用 App RouterTypeScript 开启默认tRPC 开启Tailwind CSS 开启Prisma 开启NextAuth.js 开启ESLint / Prettier 保持默认有一点要注意脚手架生成的版本会跟随最新依赖尤其是 Next.js 和 tRPC 的 major 版本后续升级时一定要看官方迁移指南。我们项目跑到中途就遇到了 tRPC v10 到 v11 的升级冲击这个坑后面细说。初始化完成后项目自带了一套完整的基础代码包括 tRPC 的 server/client 封装、NextAuth 的默认配置、Prisma 的标准 schema 和示例路由。第一次跑pnpm dev就能看到首页再加上一个 POST 的 demo API 可以直接调用。3.2 目录结构与关注点分离脚手架默认生成的目录结构我在此基础上做了少量调整关键路径如下t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── components/ │ ├── env.mjs │ ├── pages/ (或 app/) │ │ ├── api/ │ │ │ └── auth/[...nextauth]/route.ts │ │ │ └── trpc/[trpc]/route.ts │ │ └── index.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts │ │ │ ├── routers/ │ │ │ │ ├── snippet.ts │ │ │ │ ├── tag.ts │ │ │ │ └── user.ts │ │ │ └── trpc.ts │ │ ├── auth.ts │ │ └── db.ts │ ├── styles/ │ └── trpc/ │ ├── server.ts │ ├── client.ts │ └── react.tsx └── .env这套结构的核心逻辑是服务端代码集中在 src/server前端可安全引用的 API 访问层集中在 src/trpc。在 App Router 模式下这个边界尤其重要服务端代码如果被客户端组件意外引用会造成 bundle 体积膨胀甚至安全泄露。env.mjs是脚手架额外提供的一个贴心设计——它用 Zod 校验所有环境变量启动时如果不满足格式要求会直接报错。这个设计帮我挡住了很多本地好好的部署就挂了的尴尬场景。3.3 Prisma 数据模型设计实例t3code 的核心数据模型有四个实体用户、代码片段、标签、评价。我把 Schema 的核心部分贴出来generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) name String? email String unique role String default(user) snippets Snippet[] reviews Review[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Snippet { id String id default(cuid()) title String content String language String status String default(pending) author User relation(fields: [authorId], references: [id]) authorId String tags SnippetTag[] reviews Review[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([language]) index([authorId, createdAt]) } model Tag { id String id default(cuid()) name String unique snippets SnippetTag[] } model SnippetTag { snippet Snippet relation(fields: [snippetId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) snippetId String tagId String id([snippetId, tagId]) } model Review { id String id default(cuid()) rating Int comment String? author User relation(fields: [authorId], references: [id]) authorId String snippet Snippet relation(fields: [snippetId], references: [id]) snippetId String createdAt DateTime default(now()) unique([snippetId, authorId]) index([snippetId]) }几个设计决策解释一下用户角色用普通 String 而不是 enum因为产品早期角色类型不稳定等固定了再迁移成 enum 也不迟用 Prisma 的话 enum 迁移成本也不高。多对多用中间表SnippetTag而不是直接在数组字段里存字符串。虽然 PostgreSQL 支持 string 数组但中间表让标签维度统计和按标签精确过滤都变得简单SQL 查询还能走索引。Review上加了unique([snippetId, authorId])保证一个用户对一个片段只能评分一次。这个约束如果不放在数据库层应用层的并发校验基本是漏的。3.4 环境变量用 Zod 兜底脚手架生成的env.mjs长这样import { z } from zod; const envSchema z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), NEXTAUTH_URL: z.string().url().optional(), GITHUB_ID: z.string().optional(), GITHUB_SECRET: z.string().optional(), }); export const env envSchema.parse(process.env);这套做法的好处是环境变量缺失、格式错误、密钥太短都会在应用启动时直接抛错。部署时如果某个生产环境变量忘了配你会在日志里立刻看到清晰的 Zod 错误消息而不是等到某个接口跑起来才 500。t3code 上线前就靠它抓住过一个漏配的NEXTAUTH_SECRET。4. 核心功能实现认证、接口与前端调用4.1 NextAuth 接入流程与 session 类型NextAuth 的接入本身是机械的配置 providers、设置authOptions、在src/pages/api/auth/[...nextauth]/route.ts挂载。真正的坑在 TypeScript 类型扩展。默认情况下session.user只有name、email、image这几个字段没有我们业务需要的id和role。为了让 tRPC 的 protected procedure 里访问ctx.session.user.id时类型正确需要扩展类型声明// src/types/next-auth.d.ts import { DefaultSession } from next-auth; declare module next-auth { interface Session { user: { id: string; role: string; } DefaultSession[user]; } }然后在authOptions的callbacks.session里把数据库里的用户信息塞进去callbacks: { async session({ session, user }) { if (session.user) { session.user.id user.id; const dbUser await db.user.findUnique({ where: { email: session.user.email ?? }, }); session.user.role dbUser?.role ?? user; } return session; }, }这个类型扩展 回调填充的模式是 NextAuth 集成里最值得先搞清楚的一块。官方的类型定义比较绕社区里不少项目都栽在这里——要么 session 里取不到 id要么类型一直报红。4.2 用 tRPC 写带鉴权的路由脚手架已经把底层的createTRPCContext、publicProcedure、protectedProcedure封装好了。其中protectedProcedure核心逻辑是从请求里解析 session没有就抛UNAUTHORIZED错误。我在它的基础上加了一个管理员专用 procedureexport const adminProcedure protectedProcedure.use(({ ctx, next }) { if (ctx.session.user.role ! admin) { throw new TRPCError({ code: FORBIDDEN }); } return next({ ctx }); });有了这几个 procedure 之后写业务路由就是很直白的事了。以 Snippet 的创建为例export const snippetRouter createTRPCRouter({ list: publicProcedure .input( z.object({ tag: z.string().optional(), language: z.string().optional(), cursor: z.string().optional(), limit: z.number().int().min(1).max(50).default(10), }), ) .query(async ({ ctx, input }) { const items await ctx.db.snippet.findMany({ where: { status: approved, language: input.language, tags: input.tag ? { some: { tagId: input.tag } } : undefined, }, include: { author: true, reviews: true, tags: { include: { tag: true } } }, orderBy: { createdAt: desc }, take: input.limit 1, cursor: input.cursor ? { id: input.cursor } : undefined, }); let nextCursor: string | undefined; if (items.length input.limit) { const extra items.pop(); nextCursor extra?.id; } return { items, nextCursor }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), language: z.string(), tagNames: z.array(z.string()).min(1).max(5), }), ) .mutation(async ({ ctx, input }) { const tagIds await Promise.all( input.tagNames.map(async (name) { return ctx.db.tag .upsert({ where: { name }, create: { name }, update: {}, }) .then((tag) tag.id); }), ); return ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.user.id, tags: { create: tagIds.map((tagId) ({ tagId })), }, }, }); }), approve: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.snippet.update({ where: { id: input.id }, data: { status: approved }, }); }), });你注意create里的一个细节标签用upsert而不是先查再建这避免了并发创建同名标签时的唯一约束冲突。这类细枝末节如果不提前想到联调阶段会浪费不少时间。4.3 前端类型推导的体验tRPC 前端最爽的部分是它消除了所有的手动 API 封装。列表页的代码大概长这样import { api } from ~/trpc/react; function SnippetList({ activeTag }: { activeTag?: string }) { const { data, fetchNextPage, hasNextPage, isFetching, } api.snippet.list.useInfiniteQuery( { tag: activeTag }, { getNextPageParam: (lastPage) lastPage.nextCursor }, ); const utils api.useUtils(); const createReview api.snippet.review.useMutation({ onSuccess: () { utils.snippet.list.invalidate(); }, }); // 渲染逻辑... }这里有几个值得说的点api.snippet.list的对象路径和 router 定义一一对应方法名、参数类型、返回值类型全部自动推导。翻页用useInfiniteQuery配合后端返回的nextCursor天然适合加载更多的场景不用自己手写状态机。提交评价后调用utils.snippet.list.invalidate()让相关列表自动失效并重新拉取不需要手动改本地缓存。React Query 的失效机制比我自己处理的任何缓存方案都干净。4.4 列表搜索与标签过滤的实现细节搜索和过滤是 t3code 的核心交互。实现上我们把关键词搜索放在数据库层做简单的contains模糊匹配标签和语言过滤则走索引字段// tRPC query 里叠加过滤条件 where: { status: approved, language: input.language, title: input.keyword ? { contains: input.keyword, mode: insensitive } : undefined, tags: input.tag ? { some: { tag: { name: input.tag, }, }, } : undefined, }前端这里有个体验优化搜索框做了 300ms 的防抖输入停止后再触发 query。在 React Query 的keepPreviousData配合下连续输入不会造成接口风暴数据加载中原有列表不会被清空视觉上也不会有闪烁。5. 部署上线与性能优化t3code 的真实落地方案5.1 部署方案选择为什么没有无脑上 VercelT3 Stack 社区默认推荐 Vercel确实 Next.js 部署到 Vercel 是零成本的事。但 t3code 选择了自托管 Docker PostgreSQL原因很实际第一公司内部一些服务要求数据留在自有机器上不支持推到第三方 PaaS第二我们除了 Web 应用后续还有内部 API 服务的扩展计划直接部署在一台云服务器上更方便统一管理第三自托管可以在网络层面和公司内网打通内网访问体验更好。最终部署拓扑不复杂一台 2 核 4G 的云服务器Docker Compose 编排两个服务——Node.js 的 Next.js 应用和 PostgreSQL 数据库。反向代理用 Nginx挂好 HTTPS 证书。services: web: build: . ports: - 3000:3000 environment: - DATABASE_URLpostgresql://t3code:${DB_PASSWORD}db:5432/t3code - NEXTAUTH_SECRET${NEXTAUTH_SECRET} - NEXTAUTH_URLhttps://t3code.internal.example.com depends_on: - db restart: unless-stopped db: image: postgres:16-alpine volumes: - pgdata:/var/lib/postgresql/data environment: - POSTGRES_USERt3code - POSTGRES_PASSWORD${DB_PASSWORD} restart: unless-stopped volumes: pgdata:这个配置里有两个容易被忽略的细节depends_on只能保证容器启动顺序不能保证数据库已就绪。我因此遇到过一次应用启动时连不上数据库直接崩掉最后给应用容器加了健康检查脚本等pg_isready通过后再启动。数据库连接串里用的是服务名db而不是 IPDocker Compose 内置 DNS 会解析到正确地址。本地开发和生产环境的DATABASE_URL不同别写死在schema.prisma里。5.2 环境变量、密钥与安全配置生产环境的环境变量管理我们的做法比较朴素但也够用.env.production文件由运维同学单独保管内容只包括非敏感的配置项所有密钥类信息数据库密码、NEXTAUTH_SECRET、OAuth Client Secret通过 CI/CD 的 secret 注入不落盘。NEXTAUTH_SECRET是特别要强调的一个配置。它用于签名 Session CookieJWT 模式长度至少 32 字节可以用这个命令生成openssl rand -base64 32一定不要把它提交到 Git 仓库也不要在多环境之间复用。之前见过有项目把开发环境的 secret 直接用于生产一旦泄露攻击者可以伪造管理员的 session cookie。5.3 数据库性能与缓存优化t3code 的数据量在初期非常小但为了不让它一开始就不健康我们还是做了一些预防性优化给Snippet的language、authorId createdAt建了复合索引给Review的snippetId建了索引。这些字段都是查询和排序的热点。列表接口开启 React Query 的staleTime默认 30 秒内不重新请求标签列表这类低频数据直接设置gcTime做长期缓存。静态资源JS/CSS由 Next.js 自动处理但我们在 Nginx 层加了Cache-Control: public, max-age31536000, immutable对带 hash 的资源开启长缓存。一个很实在的优化收益是首页加载的 LCP 时间从首次实测的 2.8 秒降到了 1.2 秒左右。主要贡献来自去掉不必要的客户端渲染、开启图片懒加载以及上面说的静态资源缓存。5.4 监控与日志自托管方案带来的额外成本是监控要自己解决。t3code 阶段我用了最轻量的方案应用日志走 stdoutDocker 的 json-file 日志驱动自动收集滚动保留 30 天。用 Uptime Kuma 做外部探活监控5 分钟一次挂了会发通知到企业群。Next.js 的错误上报暂时接的 Sentry通过环境变量动态开关线上开了后端错误的采集。这套组合虽然谈不上专业可观测性但对一个内部工具来说已经足够故障能发现、日志能回溯、错误能排查。6. 踩坑实录我在 t3code 上花掉的时间6.1 tRPC 版本升级带来的破坏性变更t3code 开发到一半的时候tRPC 从 v10 升到了 v11。当时 create-t3-app 默认生成的还是 v10但因为要装一个依赖我把 lockfile 一刷新tRPC 直接拉到了 v11然后全项目大面积报错。v11 的核心变化有几个影响比较大createReactQueryHooks改造为基于函数返回的 hook 集合不少内部 API 从createTRPCReact的返回方式上做了调整旧的/trpc/client路径也变了。当时排查的过程其实就是看官方迁移指南把trpc.ts里的初始化代码整体替换一遍再把靠近 tRPC 边界的那层调用代码微调。这件事给我两个教训。第一lockfile 一定要认真管理在非升级任务里不要轻易执行pnpm update这种宽泛命令。第二脚手架生成后立刻把依赖版本固定下来给tRPC、Next.js这类核心依赖锁好 minor 版本避免被动升级。6.2 Next.js App Router 与 tRPC 的兼容问题App Router 是 t3code 默认用的路由模式。它的服务端组件Server Components默认不能调用 tRPC 的客户端 hook因为那些 hook 依赖 React Query 的 Provider只能在客户端边界以内使用。我最初想省事直接在服务端组件里调用数据库查询绕开 tRPC。结果发现违背了端到端类型安全的初衷——服务端组件里用 Prisma 裸查固然快但界面层直接依赖数据库结构模型一改组件就要跟着改耦合度上来了。最终我采用的分层策略是页面骨架和 SEO 相关的内容用服务端组件渲染需要登录、交互、列表数据刷新的区块全部拆成客户端组件通过 tRPC hook 取数服务端组件如果需要初始数据通过 tRPC 的 server caller 方式调用同一套 router保证逻辑不重复// 服务端组件里调用 tRPC router import { createCaller } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; const ctx await createTRPCContext({ headers: await headers() }); const caller createCaller(ctx); const data await caller.snippet.list({ limit: 5 });这个方案的要点是同一套业务逻辑两种调用方式既满足首屏性能又不牺牲类型安全。6.3 Prisma 在容器环境下的连接管理问题上线后遇到的第一个线上故障是数据库连接数暴涨。原因很典型Prisma Client 默认会维护一个连接池而我在 Next.js 开发服务器热更新时反复创建 Client 实例每个进程各自持有连接池。上线后虽然有容器的单实例但应用频繁重启、连接没有及时释放加上活跃查询一多连接数直接打满 PostgreSQL 的默认上限。解决办法有两步在src/db.ts里用全局单例避免热更新环境下的重复实例化。脚手架其实已经带了这段逻辑但我在调整目录时不小心破坏过教训是要理解脚手架为什么那么写。在生产环境限制连接池大小DATABASE_URL里加上连接池参数DATABASE_URLpostgresql://user:passdb:5432/t3code?connection_limit5pool_timeout10把连接数从默认值压到 5对 t3code 的流量来说完全够用同时给数据库留出余量。6.4 测试与联调阶段的经验T3 脚手架默认不集成测试框架这块是我后来补的。我的选择是单元测试用 Vitest重点覆盖工具函数和 tRPC 路由里的纯业务逻辑。tRPC 的 caller 方式很方便测试直接把 context 里的 session 和 db 替换成 mock 对象调用 router 的 procedure断言输出。端到端测试用 Playwright覆盖了登录 → 发布片段 → 检索 → 评分这条主路径在每次部署前跑一遍。写测试的过程帮我发现了两个真实 bug一个是在upsert标签时没有处理空字符串另一个是在管理员审核通过后没有清掉缓存导致列表仍显示旧状态。这两类问题靠手测确实很难稳定复现。7. 项目复盘如果重新做 t3code我会在这些地方做调整7.1 做得对的地方可以迁移复用的经验整个项目下来我愿意保留并继续用的决策有这么几个第一从第一天就坚持端到端类型安全。这让我们在后续所有模型调整、功能迭代中几乎没有为类型对不上这件事返工过。如果你也在评估 T3 Stack我建议把类型安全当成核心收益来看而不是附带的便利。第二数据库层约束前置。唯一约束、索引、默认值都尽量在 Schema 里声明而不是依赖应用层自己保证。数据一致性问题上数据库永远是最可靠的防线。第三自托管部署碰到的问题都在可控范围内。虽然 Vercel 会更省心但 Docker Compose 这套方案对我们这种内部工具而言成本并不高而且可控性明显更好。7.2 我最后悔的几个决定反过来有几个决定如果重新选我会改前期没有第一时间固定核心依赖版本导致 tRPC 被动升级白白消耗了一天排查时间。任何脚手架项目第一个动作都应该是把版本锁好。没有早一点引入 Playwright 端到端测试。手测在功能少的时候还行功能一多回归成本立刻上来了。测试基础设施应该在项目第一天搭好而不是等功能稳定后补。日志和监控体系搭得太晚。上线前一周才加 Sentry导致前期有些偶发错误只能靠翻 Docker 日志效率很低。7.3 几个可以直接抄走的建议如果你打算启动一个类似 t3code 的项目这几个建议可能比看一百篇教程都实在初始化后先把pnpm-lock.yaml提交到仓库之后所有依赖变更都通过pnpm add具体指定包来完成不跑宽泛的 update。protectedProcedure和adminProcedure的分层一定要从第一天就做好后面每个新路由都按这个体系加权限逻辑才不会漏。Prisma 的连接池参数和环境变量校验env.mjs在本地开发阶段就该配好别拖到部署时才补否则你会在线上踩一遍我已经踩过的坑。给前端 hook 调用统一加上staleTime的合理配置这能在不删一个接口的情况下把大量重复请求直接降下来。t3code 上线到现在跑了将近半年团队里的使用频率一直不错每天都有新的片段沉淀进来。技术选型这件事本质上是在合适的场景里做合适的取舍。T3 Stack 不是万能的但对我来说它在一个内部工具项目里把开发体验类型安全和交付速度这三件事平衡得很到位。如果看完这篇复盘你也手痒拿 create-t3-app 起个实验项目跑通一个带登录、带数据库的小功能感受到那种类型从数据库一路连到按钮的顺畅感之后你会明白我为什么愿意花时间把这段过程记录下来。