最近一段时间,我把一个内部编码练习与评测系统从传统的 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-app@latest 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 配合 Redis,t3code 为了演示和迭代方便,我在单个服务内直接跑了一个简单的检测函数。
这里贴一段 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.local,Prisma 读到的就是过期配置。
我的建议:项目初始化后马上统一 .env 配置,只用一份.env,把.env.local直接从模板里删掉。如果你用的是 pnpm 的 workspace,还要检查DATABASE_URL是否立在了根目录 env 而不是子包目录 env。数据库地址写错一个字符,等的就是半小时起步的排查时间。
4.2 tRPC 类型更新不及时导致的“幽灵”报错
开发中你大概率会遇到这种情况:改了 router 返回字段,保存文件,前端调用处的类型提示依然是旧内容,甚至编译报错说某个字段不存在。我实测下来,tRPC 的类型推导依赖 TypeScript 的语言服务,改了 server 代码后,客户端 TS 能感知到变化,但如果你用的是 VSCode,tsserver 有时候会缓存旧结果。解决方案很直接:改完 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 generate |
| mutation 在开发环境执行两次 | React StrictMode 二次调用 | 把 mutation 放到事件回调中触发,而不是 useEffect |
| 用户未登录时调用受保护路由报 401 | NextAuth session 未建立 | 确认 NextAuth 配置项和 provider 环境变量是否正确 |
prefetch后客户端还是 loading | React 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,核心顾虑是它不够大众化、社区资料相对较少,但就我长期维护的体验来说,只要团队能统一使用 TypeScript,tRPC 的学习曲线远低于为每个模块手写 OpenAPI spec 再生成类型。
如果你打算把 t3code 继续做下去,我建议优先考虑这几个方向:一是把评测逻辑从同步函数改成异步队列,用 Redis 做消息中转,让提交操作跟评测彻底解耦。二是加入审计日志,记录用户提交代码的时间、语言、运行结果,便于后面做相似度检测和排行榜。三是把权限体系从单一用户模型扩到“组织/团队”维度,让题目可以分组管理,对应现实场景里的班级或员工技能组。
最后分享一个实测后管用的操作习惯:每次提交代码前,先跑pnpm prisma generate && pnpm ts-check,确保 schema 类型和全局类型都没有失效,再进入联调阶段。这个小成本动作能帮你过滤掉接近七成的低级错误。t3code 的雏形虽然简单,但它让我感受到“类型安全”不再是一个口号,而是每一行代码里实实在在的确定感。