
最近一段时间我把一个内部编码练习与评测系统从传统的 Express API 重构成了 t3code。这个 t3code 不是哪个公司的产品而是我基于现代 Web 开发圈里讨论度很高的 T3 Stack 搭出来的一套全栈应用实践前端用 Next.js 渲染接口层不走 REST 而是用 tRPC数据库操作交给 Prisma登录认证直接接 NextAuth。作为一个常年在接口文档里翻字段、经常因为一个 null 空指针浪费两小时的工程师这套组合给我的最大感受就是类型安全能一路从数据库推到组件里开发时不再来回切换心智。这篇文章我把 t3code 从创建项目到实现核心评测功能的全过程拆开来讲包括选型背后的取舍、关键步骤、参数选择和地方配合的坑适合那些正在寻找一条清晰全栈路径的开发者参考也适合想做编码练习类应用的团队抄作业。1. t3code 搭建前的链路设计与技术选型拆解1.1 为什么是 t3code这套组合背后的取舍逻辑很多朋友看到 T3 Stack 的第一反应是“又是新框架”其实它并不是一个框架更准确地说是一套互相搭配的模式合集。t3code 选它的核心理由就是想让“类型”贯穿整个业务链路。传统前后端分离项目里后端定义好接口前端拿到 JSON 之后再自己补一个 interface两边字段一多就很容易出现不一致数据库加了中间件字段、后端改了返回结构、前端还按老结构渲染这种问题靠约定是挡不住的必须靠工具链强制。T3 Stack 的解法是把 RPC 调用包装得像本地函数一样。前端写一个challenge.getAll()tRPC 就知道后端对应的router里有一个getAllprocedure类型直接从服务端定义推导到客户端。我在 t3code 里实践下来最明显的收益有两个一是接口文档基本不用手写了因为类型定义本身就是文档二是赶项目节奏时写完 prisma schema 和 router 的返回值前端编辑器里立刻能感知到数据形状。如果你处理过前后端各自维护一份 API 类型定义的项目应该能体会这种“直接减掉一层维护工作”的爽感。什么场景适合用 t3code 这套东西我的判断是团队不大前端和后端同属一个小组产品需要快速迭代并且没有历史包袱。如果你所在的公司已经有严格控制的外部 API 标准或者前后端团队相隔很远、完全由不同部门维护tRPC 的收益就会被削弱。t3code 更贴近“一个工程师或一个全栈小组从头做一个小而完整的业务系统”的形态比如内部工具、教学平台、个人项目 MVP这些场景下类型安全和开发效率优先级最高。1.2 初始化 t3code 的技术选型配置t3code 的初始化我建议直接用官方脚手架省得自己手动把各个库拼起来再踩一遍版本兼容问题。我在项目里执行的是pnpm create t3-applatest t3code交互界面会让你选择需要的模块。我勾选的组合是 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth。刚开始用 T3 Stack 的同学容易把每个包都勾上其实不需要的模块最好别勾比如如果你的系统没有文件上传需求就不需要选 uploadThing没有考虑未来接入 Stripe 就暂时不选 stripe。少一个模块未来升级和维护时就少一处负担。一个很重要的配置细节是数据库选型。t3code 做的是编码训练平台需要存用户、题目、提交记录、评测状态后续可能还要查排行榜所以我直接选了 PostgreSQL而不是开发时默认的 SQLite。SQLite 好处是零配置但生产环境的并发写和事务处理能力不够稳尤其是评测系统的提交记录一旦增多SQLite 的锁机制会很让人头疼。脚手架会自动生成.env文件里面最关键的环境变量就是DATABASE_URL我当时遇到的第一问题就出在这里后面我会单独讲排障。技术选型确定后需要确认目录结构。脚手架生成的标准结构里src/server/api/routers放你的路由定义src/server/db放 Prisma client 实例src/app放前端页面。tRPC 的 router 是集中式管理的你需要在根 router 里把各个子 router 挂载起来。初期看这个目录可能觉得绕但实操两轮之后就习惯了这种“后端路由显式集中在 server 层”的好处是整个 API 面一眼就能看全。2. t3code 核心实现先定义数据模型再打通 tRPC 路由2.1 用 Prisma schema 先搭好数据库结构传统开发流程里我习惯先画页面再写接口但 t3code 让我改变了顺序先写 Prisma schema跑一次迁移再回来写页面和 tRPC 路由。因为 Prisma schema 是类型的源头一旦核心数据模型定了前面和后面的类型都能自动推导出来。t3code 里的核心业务是编码挑战和提交记录所以我设计了这么几个模型model User { id String id default(cuid()) name String? email String? unique submissions Submission[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Challenge { id String id default(cuid()) slug String unique title String description String difficulty String template String solution String submissions Submission[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Submission { id String id default(cuid()) code String status String default(PENDING) userId String challengeId String user User relation(fields: [userId], references: [id]) challenge Challenge relation(fields: [challengeId], references: [id]) createdAt DateTime default(now()) updatedAt DateTime updatedAt }这里有个设计心得Challenge.solution直接以字符串存数据库只适合做内部版或不涉及用户上传代码评测的场景。真实上线要考虑把标准答案拆到独立表并且做权限控制避免客户端通过 Prisma 查询直接把答案拉走。t3code 阶段我把它简化了是为了让模型关系更清晰让初学者能看到“用户提交 - 题目”这两个主要关联。写好 schema 后跑一次生成和迁移pnpm prisma migrate dev --name init pnpm prisma generatemigrate dev会创建 migration 记录并同步数据库结构generate会把 Client 类型重新生成到node_modules/.prisma。这里很多新手会漏掉第二步导致 TS 的类型提示还是旧的所以每次改完 schema我建议把这两条命令连着跑并且在编辑器里重新加载 TypeScript 服务。2.2 从 tRPC 路由到前端调用的完整链路t3code 的 API 层通过createTRPCRouter组织一个业务模块建一个子 router最终在根 router 里汇聚。拿挑战列表这个场景来说后端路由大概长这样import { z } from zod; import { createTRPCRouter, protectedProcedure, publicProcedure } from ~/server/api/trpc; import { prisma } from ~/server/db; export const challengeRouter createTRPCRouter({ getAll: publicProcedure.query(async () { return await prisma.challenge.findMany({ orderBy: { createdAt: desc }, select: { id: true, slug: true, title: true, difficulty: true, description: true, }, }); }), getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) { return await prisma.challenge.findUnique({ where: { id: input.id }, }); }), submit: protectedProcedure .input( z.object({ challengeId: z.string(), code: z.string().min(1), }) ) .mutation(async ({ ctx, input }) { const submission await prisma.submission.create({ data: { challengeId: input.challengeId, userId: ctx.session.user.id, code: input.code, status: PENDING, }, }); return submission; }), });这段代码里有两个容易忽略的地方。第一是protectedProcedure它要求用户必须登录未登录会直接抛错。t3code 里提交代码做评测必须知道是谁提交的所以这里强制绑定 session 用户。第二是zod的输入校验tRPC 在服务端对这个input做完整 schema 校验非法数据根本不会进入 prisma 层。前端调用对应的写法是import { api } from ~/trpc/react; const { data: challenges, isLoading } api.challenge.getAll.useQuery(); const submitMutation api.challenge.submit.useMutation({ onSuccess: () { // 刷新列表或跳转 }, });从开发者视角看useQuery和useMutation就像在操作一个本地函数没有手写 fetch、没有手动处理响应类型返回的数据类型完全由后端定义推导。这就是 t3code 整个链路让人舒服的地方Prisma 保证数据库和 TypeScript 一致tRPC 保证后端返回和前端调用一致中间少掉的 DTO 定义工作换来的是更少的联调成本和更早的编译报错。3. 实操过程在 t3code 里从空页面到可提交的编码关卡3.1 用 tRPC 实现挑战管理后端的核心流程做编码训练系统最关键的就是“判断用户提交的代码是否正确”。t3code 里的核心流程是前端把用户写的代码提交到后端后端保存之后触发一个评测程序最后把评测状态返回给前端。这段流程我把它拆成三个环节。第一步是校验数据。提交的代码可能为空、可能格式不对这些脏数据最好在 tRPC 入口直接拦截。第二步是写数据库。保存提交记录后返回一个带PENDING状态的记录给前端。第三步才是执行评测。真实的评测系统一般会丢到队列里异步执行比如用 BullMQ 配合 Redist3code 为了演示和迭代方便我在单个服务内直接跑了一个简单的检测函数。这里贴一段 submit 后面评测逻辑的简化写法function runSimpleJudge(solution: string, userCode: string): ACCEPTED | WRONG_ANSWER { const normalize (s: string) s.replace(/\s/g, ).trim(); return normalize(solution) normalize(userCode) ? ACCEPTED : WRONG_ANSWER; }这个实现非常粗糙正规场景下应该通过隔离容器执行用户代码并比对标准输入输出。但作为版本最大的教训是初学者容易在一个提交接口里塞太多逻辑导致用户提交一个请求后端跑评测跑了好几秒前端一直等待。正确的做法是提交接口尽快返回评测走异步队列前端轮询或 WebSocket 订阅结果。t3code 的架构可以平滑扩展成这个方向只要把runSimpleJudge替换成消息队列消费者即可。我当时还卡过一个细节protectedProcedure里拿ctx.session.user.id一开始直接写成了ctx.session.user结果类型报错说可能为 null。原因是 session 类型定义里 user 是可选字段。这里正确的写法是让 NextAuth 的 type augmentation 把session.user.id标记为必填否则每次用都要做空值判断。t3code 我加了src/types/next-auth.d.ts来做全局类型扩展这个问题才算彻底解决。3.2 在页面里串起前后端服务端组件预取与交互更新t3code 使用的是 Next.js App Router页面既有服务端渲染需求也有客户端交互需求。挑战列表页我希望首次访问时直接拿到数据不要出现先加载一圈转圈再出内容的体验所以走了服务端预取方案。tRPC 官方提供了createServerSideHelpers配合 React Query 的dehydrate可以把 server 端查好的数据序列化到客户端缓存。实际代码效果是这样import { createServerSideHelpers } from trpc/react-query/server; import { appRouter } from ~/server/api/root; import { createTRPCContext } from ~/server/api/trpc; import superjson from superjson; export const getServerSideHelpers async () { const helpers createServerSideHelpers({ router: appRouter, ctx: await createTRPCContext(), transformer: superjson, }); return helpers; };在页面组件里先调用helpers.challenge.getAll.prefetch()再同步返回页面这样客户端首次渲染时 React Query 缓存里已经有数据useQuery会直接命中缓存不会出现 loading 闪烁。这里很多朋友会踩一个坑server 端预取数据和客户端实际useQuery用的 queryKey 对不上导致缓存 miss。解决办法是确保两边使用同一个transformers和queryClient配置我是把自己定义的api组件库统一管理这套配置而不是在页面里另起炉灶。交互部分我用的是受控组件加useMutation的组合。用户在详情页写完代码点击提交前端调用api.challenge.submit.useMutation()发送成功后用一个 toast 提示评测状态再通过utils.challenge.getById.invalidate()刷新详情页上的提交记录列表。React Query 的失效机制会把对应 key 的缓存标记为 stale下次组件重新挂载或 focus 时会自动重新请求。这套模式比手动window.location.reload()不知道优雅多少倍。4. t3code 日常开发中的坑与排障技巧实录4.1 环境变量导致 Prisma 连接失败t3code 跑起来后我遇到的第一个问题是PrismaClientInitializationError日志提示找不到了数据库连接字符串。后来发现是脚手架生成的.env和.env.local文件上传到了配置。Next.js 默认对.env.local优先级最高如果你两个文件都写但把旧库地址留在.env.localPrisma 读到的就是过期配置。我的建议项目初始化后马上统一 .env 配置只用一份.env把.env.local直接从模板里删掉。如果你用的是 pnpm 的 workspace还要检查DATABASE_URL是否立在了根目录 env 而不是子包目录 env。数据库地址写错一个字符等的就是半小时起步的排查时间。4.2 tRPC 类型更新不及时导致的“幽灵”报错开发中你大概率会遇到这种情况改了 router 返回字段保存文件前端调用处的类型提示依然是旧内容甚至编译报错说某个字段不存在。我实测下来tRPC 的类型推导依赖 TypeScript 的语言服务改了 server 代码后客户端 TS 能感知到变化但如果你用的是 VSCodetsserver 有时候会缓存旧结果。解决方案很直接改完 tRPC router 后手动重启一下 TypeScript Server命令面板里执行 “TypeScript: Restart TS Server” 即可。如果是 JetBrains 系 IDE则是 “Invalidate Caches”。另外还有一个高频坑改了 Prisma schema 之后忘记prisma generate导致 tRPC router 里查询返回的类型还是旧模型。我强迫自己每次跑完migrate dev之后立刻跑prisma generate要不然后面接二连三的报错都会让人误判为 tRPC 问题。4.3 Next.js 严格模式导致 mutation 执行两次开发环境里React 的严格模式会故意把 useEffect 执行两次用来暴露副作用问题。如果你在 useEffect 里直接触发了 mutation你会发现提交记录创建了两次数据库里多出一条重复数据。t3code 里我没用 useEffect 包裹 mutation而是通过按钮事件或表单提交来触发所以避开了这个问题。如果你的代码是“页面加载后自动调一次 mutation”的设计一定要在 useEffect 里做防抖或加 ref 锁否则严格模式下必踩。4.4 常见问题速查表问题现象排查方向处理方法Prisma 报找不到DATABASE_URL环境变量文件名或作用域问题统一使用.env确认 pnpm workspace 的 env 读取位置重启 dev server前端调用useQuery拿到的类型长期不更新TS Server 缓存重启 TS Server 或重启 IDE改了 schema 后router 里类型报错Prisma Client 未重新生成执行pnpm prisma generatemutation 在开发环境执行两次React StrictMode 二次调用把 mutation 放到事件回调中触发而不是 useEffect用户未登录时调用受保护路由报 401NextAuth session 未建立确认 NextAuth 配置项和 provider 环境变量是否正确prefetch后客户端还是 loadingReact Query 缓存 key 不一致确认 transformer 和 queryClient 使用统一配置4.5 t3code 部署时容易忽视的两个细节t3code 我最终是部署到一台轻量服务器上的部署过程中有两个细节踩得比较深。第一个是 Next.js 的 standalone 输出模式。默认构建完的.next目录如果直接集齐一堆 node_modules 部署包会特别大迁移困难。在next.config.ts里加output: standalone之后构建产出的.next/standalone目录体积大幅缩小而且自带启动服务所需的最小依赖配一个pm2或 systemd 就能跑。第二个是 Node 版本。T3 App 对 Node 版本要求比较新我一开始服务器上有多个 Node 版本跑next build会遇到一些因为版本太老导致的 OpenSSL 错误或 fetch API 缺失。干脆用 nvm 固定一个 LTS 版本并且在部署脚本第一行加了nvm use。这个习惯帮我省掉了不少重复排障时间。还有一件事要提醒生产环境记得把NEXTAUTH_SECRET改成高强度随机串不要直接用脚手架生成的那个默认值否则 session 的安全性等于没设防。5. t3code 的扩展思路与我在实际使用中的体会t3code 这套骨架跑通之后我最大的体会是“类型的价值不在于写的时候有多爽而是你在三天后回过头改一个字段所有受影响的位置整齐地出现在编译错误里”。这比任何注释文档都可靠。很多团队不愿意切换到 tRPC核心顾虑是它不够大众化、社区资料相对较少但就我长期维护的体验来说只要团队能统一使用 TypeScripttRPC 的学习曲线远低于为每个模块手写 OpenAPI spec 再生成类型。如果你打算把 t3code 继续做下去我建议优先考虑这几个方向一是把评测逻辑从同步函数改成异步队列用 Redis 做消息中转让提交操作跟评测彻底解耦。二是加入审计日志记录用户提交代码的时间、语言、运行结果便于后面做相似度检测和排行榜。三是把权限体系从单一用户模型扩到“组织/团队”维度让题目可以分组管理对应现实场景里的班级或员工技能组。最后分享一个实测后管用的操作习惯每次提交代码前先跑pnpm prisma generate pnpm ts-check确保 schema 类型和全局类型都没有失效再进入联调阶段。这个小成本动作能帮你过滤掉接近七成的低级错误。t3code 的雏形虽然简单但它让我感受到“类型安全”不再是一个口号而是每一行代码里实实在在的确定感。