news 2026/10/26 8:50:06

T3 Stack实战:用tRPC+Prisma构建类型安全的编码评测系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
T3 Stack实战:用tRPC+Prisma构建类型安全的编码评测系统

最近一段时间,我把一个内部编码练习与评测系统从传统的 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 generate

migrate 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
用户未登录时调用受保护路由报 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,核心顾虑是它不够大众化、社区资料相对较少,但就我长期维护的体验来说,只要团队能统一使用 TypeScript,tRPC 的学习曲线远低于为每个模块手写 OpenAPI spec 再生成类型。

如果你打算把 t3code 继续做下去,我建议优先考虑这几个方向:一是把评测逻辑从同步函数改成异步队列,用 Redis 做消息中转,让提交操作跟评测彻底解耦。二是加入审计日志,记录用户提交代码的时间、语言、运行结果,便于后面做相似度检测和排行榜。三是把权限体系从单一用户模型扩到“组织/团队”维度,让题目可以分组管理,对应现实场景里的班级或员工技能组。

最后分享一个实测后管用的操作习惯:每次提交代码前,先跑pnpm prisma generate && pnpm ts-check,确保 schema 类型和全局类型都没有失效,再进入联调阶段。这个小成本动作能帮你过滤掉接近七成的低级错误。t3code 的雏形虽然简单,但它让我感受到“类型安全”不再是一个口号,而是每一行代码里实实在在的确定感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 8:49:16

模型输入边界治理:从512截断故障看MLOps全链路契约

1. “3起内部模型事件,边界卡在同一处”——这不是巧合,是系统性信号“3起内部模型事件,边界卡在同一处”,这句话刚在团队晨会里被抛出来时,我手里的咖啡差点洒在笔记本上。不是因为震惊,而是太熟悉了——这…

作者头像 李华
网站建设 2026/10/26 8:48:54

T3 Stack实战复盘:从选型到部署的踩坑与收获

“t3code”这个代号,在我这边圈子里的第一反应,多半是 T3 Stack 那一套东西。不是硬件,也不是某个编码协议,而是由 create-t3-app 带火的那套 TypeScript 全栈组合:Next.js TypeScript Tailwind CSS tRPC。这套组合…

作者头像 李华
网站建设 2026/10/26 8:47:49

Codesys凸轮点修改全流程:编辑、校验、快照与回滚

简介:这份案例包面向工业自动化领域的运动控制开发者与PLC编程学习者,聚焦在Codesys环境下调整与优化凸轮曲线这一实际问题,适合已具备一定IEC 61131-3编程基础、希望深入掌握凸轮点修改技术的工程师。包内以CamDemo等示例程序为核心&#xf…

作者头像 李华
网站建设 2026/10/26 8:47:02

STM32 ADC多通道采集:轮询、EOC中断与DMA双缓冲避坑

简介:围绕STM32G431RBT6的ADC直接采集与DMA传输,这份资料面向嵌入式开发初学者和需要上手STM32G4模拟外设的工程师,重点解决ADC单次读取寄存器与DMA连续搬运两种采集方式的配置理解。内容涉及ADC1/ADC2多通道特性、采样时间与分辨率设置、转换…

作者头像 李华
网站建设 2026/10/26 8:46:58

STM32G431多通道ADC采集:采样时间、DMA与溢出排查

简介:面向STM32G431RBT6的ADC采集工程包,适合具备一定HAL库基础的嵌入式开发者、竞赛学生与课程设计者,用于理解直接读取与DMA搬运两种采样路径的差异。包内共1254个文件,以C源码和头文件为主体,辅以汇编启动文件、工程…

作者头像 李华
网站建设 2026/10/26 8:46:58

专科生AI论文高效检索与阅读工具指南

1. 专科生如何高效获取AI论文资源作为一名在人工智能领域摸爬滚打多年的从业者,我深知专科生在学术资源获取上的困境。今天要分享的这个工具,正是为了解决这个痛点而生——千笔写作工具,一个专为专科生设计的AI论文资源平台。记得我刚入行时&…

作者头像 李华