基于T3 Stack的代码片段管理平台设计与实践 大概三个月前我在本地调试一个日志清洗脚本翻了七八个开源仓库愣是没找到一段可以直接复用又足够精简的实现。要么是项目太老依赖一堆要么是写得像天书注释比代码还难懂。当时我就在想与其每次到处翻仓库不如自己搞一个轻量的代码片段库把平时写过的好东西全部收进去最好还能一键分享给同事甚至直接在浏览器里跑一遍验证效果。于是就有了这个代号叫t3code的项目。t3code 是一个基于T3 StackNext.js TypeScript tRPC Prisma TailwindCSS构建的代码片段管理与在线分享平台。它不追求成为一个多庞大的笔记系统只专注做三件事快速保存代码片段、给片段打标签并全文检索、通过一个公开链接把片段发给任何人支持匿名阅读和评论。如果你也日常被代码复用折磨或者想在公司内部搭一套轻量的代码沉淀工具这篇复盘应该能帮你少踩不少坑。1. 为什么我决定用 T3 Stack 做 t3code而不是老一套的 REST 独立前端1.1 一开始我也觉得 Next.js 全家桶有点重说实话刚开始立项时我心里是有犹豫的。一个代码片段工具最传统的做法是 Express 后端 一个 Vue/React 前端API 设计好两边各写各的。这个方案我熟得很20 分钟内就能把 CRUD 骨架搭出来。但真开始写的时候我发现自己要维护两套类型定义数据库模型一套前端接口请求响应参数一套中间还要手动保证字段名一致。项目小的时候还能靠自觉等代码片段关联上标签、评论、用户收藏、搜索条件之后字段一多心智负担立刻上来。我那时候看了一眼 T3 Stack 的推荐组合发现它其实不是重而是把类型安全这件事贯彻到了每一层。Next.js 负责页面渲染和 API 路由tRPC 负责前端直接调用后端函数Prisma 负责数据库 schema 和类型生成Zod 负责在函数入口做数据校验Tailwind 负责样式。整条链路上只要你在 Prisma schema 里改一个字段名前端代码里立刻就会有类型报错。这种改一处全线亮红灯的体验实际上是在帮我对冲重构风险。1.2 t3code 要解决的核心问题在动工之前我先把需求收敛成了四类核心问题。第一保存要快。我不希望像写博客一样还要排版、起标题、配图。保存片段的交互必须是一步到位贴进代码框按一下快捷键自动提取当前语言的注释作为标题再选几个标签搞定。第二检索要准。代码片段多了以后靠人肉分类基本废了。我要的是全文搜索包括代码内容、注释、标签名、语言。搜 readStream 就得把涉及文件读取流的前后端片段全列出来。第三分享要轻。分享出去的时候不需要接收方注册账号打开链接就能看到格式化后的代码能复制能评论。这个看起来简单但匿名评论对权限模型、反垃圾策略都会带来额外的工作量。第四数据要可控。所有片段数据最终要能导出不能被平台绑定死。至少要提供 JSON 导出和还原能力。基于这四点t3code 的功能边界就定了。它不做在线编辑器不做多人实时协同不做流程审批也不做复杂的工作区。市面上很多类似工具死掉就是因为把自己撑得太大。1.3 T3 Stack 到底解决了什么没有解决什么我对 T3 Stack 的认知经历过一轮矫正。网上经常说 T3 是全栈信仰但实际上它并没有创造新框架只是把一个经过检验的技术栈组合固定下来。它解决的问题是开发链路中的类型断裂而不是业务复杂度。举个最直接的例子。老办法里前端调后端接口时通常要自己写fetch(/api/posts/1)然后对响应做any断言既丑又不安全。t3code 里用 tRPC 之后是这样调的const post trpc.post.get.useQuery({ id: clv123 });如果post.get的入参不是{ id: string }而是{ slug: string }编辑器里立刻就会报错不用等到运行时炸。这个价值在协作开发里尤其明显新同事接手代码时看类型定义就能大概猜出后端函数输入输出不太需要反复看接口文档。但 T3 Stack 没有解决 Novice 常见的那些问题数据库索引要自己建、缓存策略要自己设计、接口鉴权要自己写还有部署环境的 Node 版本、上传文件存储位置、图片 CDN 路径这些全都得自己处理。后面我在部署阶段踩了几个坑基本都和框架没帮你做的那 20%有关这个到第五章细说。2. 数据建模与存储代码片段、标签和渲染快照2.1 Prisma 模型设计把 tags 放到关联表而不是 JSON 字段第一版 schema 我偷了个懒把tags设计成了String[]直接放在片段表里。用起来确实快Prisma 原生支持字符串数组PostgreSQL 底层就是一个数组字段。但过了两周我发现搜索哪些代码片段用了 Node.js 标签时SQL 要写成WHERE Node.js ANY(tags)如果还想统计标签使用频率就只能全表扫描后在前端算性能很快会出问题。后来我改成标准的标签关联表用一张中间表建立片段和标签的多对多关系model Snippet { id String id default(cuid()) title String language String content String db.Text description String default() isPublic Boolean default(true) createdAt DateTime default(now()) updatedAt DateTime updatedAt userId String user User relation(fields: [userId], references: [id]) tags SnippetTag[] comments SnippetComment[] index([userId, isPublic]) index([language]) } model Tag { id String id default(cuid()) name String unique snippets SnippetTag[] } model SnippetTag { snippet Snippet relation(fields: [snippetId], references: [id], onDelete: Cascade) snippetId String tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) tagId String id([snippetId, tagId]) index([tagId]) }多对多关系带来一个好处标签自动去重、自动补全。前端输入标签时我可以直接查询Tag表把已存在的标签展示为建议项用户点一下就绑定。后面做统计报表比如最常用的 20 个标签也就是一条groupBy的事不用再折腾解析字符串数组。不建议把标签存成 JSON 字段的唯一例外是你明确知道标签是纯展示属性完全不参与检索、统计和关系筛选。但代码片段这种天然需要搜索场景的内容用 JSON 存标签基本是自埋地雷。2.2 为什么不直接把代码文本塞进 MySQL先声明一点t3code 用的主数据库是 PostgreSQL。原因倒没有多玄学主要是「全文检索」和「JSONB」这两块PostgreSQL 用起来更顺手。做代码片段搜索MySQL 的LIKE %keyword%在数据量到几万条以后就会明显吃力而 PostgreSQL 的tsvector/tsquery配合 GIN 索引可以把中文和代码检索压到几十毫秒。不过「全文搜代码」这个需求我一开始就知道数据库全文索引只是及格线不是满分答案。原因很简单代码里全是符号、::、?.,这些 token 会被分词器拆得乱七八糟。所以 t3code 的搜索方案是双轨制标题、描述、标签走 PostgreSQL 的tsvector索引代码内容走一个轻量级的倒排索引服务。我一开始试过直接把代码content字段丢进 Postgres GIN 索引结果搜useEffect(() {这种带括号的字符串时查出来的内容根本不相关。后来我做了折中将代码内容清洗一遍提取出字符串、标识符、函数名生成一份搜索快照字段。搜索时同时查内容快照和原始内容相关性权重向标题和标签倾斜。实测下来日常查询的响应时间稳定在 100ms 以内完全够用。2.3 导入导出与快照渲染代码片段除了有原文本我还额外存了一份htmlSnapshot。为什么存这个因为分享页要支持多种代码主题高亮而浏览器端做高亮比如用 Shiki 或 Prism需要时间。每次有人打开分享链接如果都要在浏览器里重新跑一遍 tokenize一百个并发访客就会吃掉不少 CPU。做法是这样保存片段时在后端用 Shiki 按当前语言做一次代码高亮生成静态 HTML 字符串存入字段前端展示时直接用dangerouslySetInnerHTML渲染。高亮逻辑变了就全量重跑刷新快照。代价是每次代码更新时多一次渲染计算换来阅读端零计算打开这笔账非常划算。下面是 Prisma schema 里快照相关字段model Snippet { // ... searchSnapshot String default() db.Text htmlSnapshot String default() db.Text highlightTheme String default(github-light) }导入导出方面我提供了一个工作区 JSON格式包含片段正文、标签、语言、创建时间。导出按钮在后端直接把查询结果序列化成一个 JSON 文件下发。导入时则是一个事务先按tagName批量 upsert 标签再插入片段记录最后在中间表里绑定关系。如果中间某一步失败整个导入事务回滚避免出现片段进来了但标签丢了一半的脏数据。经验代码片段的原文一定要用Text类型不要用Varchar(255)。我见过有人把长日志处理函数硬塞进 255 字符字段直接截断导致复制出去的代码根本无法运行。3. tRPC 的一体化查询让前后端类型真正共用3.1 Router 划分与类型推导t3code 的 tRPC 路由我划分成五块snippet、tag、comment、user、stats。每个文件里放一个独立 router然后在根 router 里合并。import { createTRPCRouter } from ~/server/api/trpc; import { snippetRouter } from ./routers/snippet; import { commentRouter } from ./routers/comment; import { tagRouter } from ./routers/tag; import { statsRouter } from ./routers/stats; import { userRouter } from ./routers/user; export const appRouter createTRPCRouter({ snippet: snippetRouter, comment: commentRouter, tag: tagRouter, stats: statsRouter, user: userRouter, });前端调用时最理想的状态是连类型导入都不用写。t3code 里我把类型导出集中到~/utils/api页面组件里这么用import { api } from ~/utils/api; const snippets api.snippet.getMine.useQuery({ page, pageSize, query });如果你是从 REST 走过来的第一次见到这个 API 会有点恍惚没有axios、没有fetch、没有try/catch代码看起来像是在调用一个本地函数。实际上 tRPC 底层还是 HTTP 请求默认走 POST JSON只是把类型信息通过 TypeScript 推导串起来了。它不产生额外运行时开销类型文件在编译期就被擦除了。3.2 Zod 校验在服务端入口t3code 的每一个 mutation 入参都经过 Zod 校验。这其实是 T3 社区约定俗成的做法Schema 既用来做运行时校验又用来做类型推导。import { z } from zod; const snippetInput z.object({ title: z.string().min(1).max(200), content: z.string().min(1).max(500_000), language: z.string().max(50), tags: z.array(z.string().max(50)).min(0).max(10), isPublic: z.boolean().default(true), }); export const snippetRouter createTRPCRouter({ create: protectedProcedure .input(snippetInput) .mutation(async ({ ctx, input }) { // ... }), });相同的一条输入定义可以用在前端表单上直接用z.infertypeof snippetInput就能拿到表单类型。这样表单校验和后端校验用的是同一套规则不会出现前端允许空标题、后端拒绝空标题这种撕裂情况。我后来把校验规则从函数体内独立出来放到一个schema.ts文件里统一维护。因为 Zod 对象是引用类型在 Server 和 Client 同时引入时要留意打包边界。t3code 的做法是包含 Zod 的 schema 文件放到src/shared/schemas.ts这个目录下的代码可被前后端同时引用而包含prisma、jwt、bcrypt之类的服务端代码不导出给前端。3.3 SSR 下的缓存策略实际踩过的一个坑T3 默认页面渲染有两种模式服务端渲染SSR和静态生成SSG。t3code 的首页是我的最近代码片段按理说应该用 SSR这样打开页面时数据已经注入了首屏看不到 loadingSEO 也好。但问题来了tRPC 在 SSR 时会把查询结果序列化到 React Query 的 hydration 数据里。如果这个结果包含Date类型Next.js 在serialize阶段会直接抛错——不是JSON.stringify的报错而是未能序列化数据的晦涩异常。第一次遇到的时候我排查了很久最后发现是createdAt是DateTime类型返回给前端的时候是一个 Date 对象React Query 的官方推荐做法是将日期统一转成 ISO 字符串。所以我定了一个规矩tRPC 的 output 里不允许直接返回数据库的 Date 对象。要么在 select 阶段用format: iso处理要么在 router 返回前统一toISOString()。宁可多写一行转换也不让类型推导把 Date 偷偷带到前端。另外SSR 模式下的缓存也让我措手不及。Next.js 默认会缓存部分没有cookies()或headers()读取的页面渲染结果。t3code 有的页面数据跟当前用户绑定就必须显式声明export const dynamic force-dynamic否则会出现用户 A 看到用户 B 片段这种低概率但极其糟糕的线上事故。我排查这个问题时翻了很多文档最后总结出来的经验是只要页面读取了 tRPC 中依赖 session 的查询就一定要加动态渲染标记别指望框架自动判断。4. 权限、协作和限流公开分享容易防滥用很难4.1 公开链接 vs 私有可见性t3code 的核心场景是分享所以可见性设计我拆成了三档public、unlisted、private。public会出现在社区的公开列表里unlisted生成一个不可猜测的长链接知道地址的人可以看但不会公开展示private只有自己和管理员能看到。unlisted这个档位是我仔细想过后才加的。很多人只想发给同事或朋友看并不想让所有路过首页的人都看到但也不想登录注册产生账号门槛。一个 128 位随机字符串的链接在没有被人转发的情况下几乎不可能被恶意枚举。配合后面的限流这个方案在防滥用方面够用了。权限判断的逻辑很简单但容易写错。核心就一条规则function canViewSnippet(snippet: Snippet, user: User | null): boolean { if (snippet.isPublic) return true; if (snippet.visibility private) { return user ! null (snippet.userId user.id || user.role ADMIN); } // unlisted return true; }注意unlisted不校验任何权限因为安全模型的基础是链接不可猜测。如果你在unlisted情况下还需要校验分享人身份那就是走传统的密码链接分享了复杂度会指数级上升。4.2 编辑权限设计owner / editor / viewer单用户工具的本义是有私密收藏但为了让公司内部使用更顺畅我加了编辑者角色。协作关系的粒度没有做得很细就三个角色owner 可以编辑、删除、转移所有权editor 可以编辑内容但不能删除viewer 只能看。我用一张单独的SnippetMember表来存权限关系而不是直接给 snippet 表加ownerIdeditorId。原因是一段 snippet 未来很可能被多个人同时收藏、转发如果权限成员直接堆在 snippet 里查询逻辑会变成数组比较很难写索引。model SnippetMember { snippetId String userId String role Role default(VIEWER) id([snippetId, userId]) index([userId, role]) }服务端做一次联合查询先查SnippetMember里用户对片段的角色再合并用户自身的 owner 权限。整个判断逻辑不超过 20 行但注意要在数据库层面做唯一性约束id([snippetId, userId])防止重复插入造成权限混乱。4.3 匿名评论与滥用防护匿名评论是 t3code 分享功能里最让人纠结的设计。好处是降低反馈门槛坏处是垃圾广告、辱骂、脚本刷屏全靠自己挡。我的方案分三层。第一层是频率限制。用 Upstash Redis 存一个计数器IP snippetId维度下10 分钟最多评论 10 条。对正常用户完全无感但脚本刷屏会被卡死。const key rate:comment:${snippetId}:${ip}; const count await redis.incr(key); if (count 10) { throw new TRPCError({ code: TOO_MANY_REQUESTS }); } await redis.expire(key, 600);第二层是内容过滤。简单关键词列表直接拦截比如明显广告词、色情词。这块不追求完美能挡住 90% 的无脑广告就行。注意不要对中文关键词做严格的全词匹配中文分词和变体太复杂宁可多误杀一点。第三层是举报机制。任何评论都可以点举报二次确认后标记spam用户自己可以选择隐藏垃圾评论。我不会直接删帖因为误判也是一次弱权力行为留出申诉通道更稳妥。再往上的方案是接入第三方内容安全服务很多企业级工具都这么干。但 t3code 的核心是非营利小项目我用频率限制 关键词 举报三层已经足够。这一点我认为是个人独立项目最务实的做法。5. 部署与后期运维Vercel 不适合一切5.1 部署方案Docker 云服务器t3code 刚完成时我毫不犹豫就部署到了 Vercel因为它和 Next.js 配合确实简单。跑了不到一周就发现问题Vercel 的 serverless 函数冷启动对于 tRPC 这种高频小请求太不友好了。代码片段保存、评论、查询这些都是毫秒级的小接口但冷启动动不动就 300ms 甚至到 1s体验直接崩坏。于是我把 t3code 迁到一台 2C4G 的云服务器上用 Docker Compose 跑三个容器应用、PostgreSQL、Redis。配置文件大概是这个样子services: web: build: . ports: - 3000:3000 environment: DATABASE_URL: postgres://user:passpostgres:5432/t3code REDIS_URL: redis://redis:6379 depends_on: - postgres - redis restart: unless-stopped healthcheck: test: [CMD, curl, -f, http://localhost:3000/api/health] interval: 30s retries: 3 postgres: image: postgres:16-alpine volumes: - pgdata:/var/lib/postgresql/data restart: unless-stopped redis: image: redis:7-alpine restart: unless-stopped最让我意外的是Docker 部署过程中最大的坑不是容器网络而是Node.js 原生模块在 Alpine 镜像下的编译。bcrypt、sharp这些带原生 binding 的依赖在node:20-alpine里经常没有预编译二进制需要现场编译没有 build tools 就直接失败。我最后直接用 Debian 版本的 node 镜像虽然镜像体积大了两百兆但省了无数折腾时间。如果只是小项目自己用别跟 Alpine 硬刚性价比不高。5.2 监控数据、错误上报应用的监控我分两层基础设施层和应用层。基础设施层用uptime-kuma做 HTTP 探活每 60 秒探测一次/api/health服务挂了就推送到 Telegram。应用层用 Sentry前后端都接。这个组合基本无需花钱个人项目承载一两万日活没什么压力。Sentry 的接入需要注意一个点sourcemap 上传要做隐私过滤。因为 t3code 的客户端产物包含源码映射直接上传会把后端路由路径、注释、环境变量名都暴露出来。我在sentry.client.config.ts里限制了上传范围只保留src/pages和src/components等前端代码不让服务端 router 的源码 map 泄漏到前端。还有数据库连接池大小要单独说。我一开始没配Prisma的连接池上限默认连接池是 CPU 核数的num_cpus * 2 1这台 2C4G 的机器的默认值是 5。并发一旦上来连接池满了就直接导致应用假死。后来我把DATABASE_URL换成pgbouncer中转同时把 Prisma 连接池大小设为 3。注意这个数字不是越大越好PostgreSQL 默认最大连接数才 100每个应用实例分太多多个服务一挤就挂了。5.3 迁移、备份、SEO 静态生成细节数据备份我写了个 cron 任务每天凌晨 3 点执行pg_dump备份全库压缩后传到对象存储保留最近 30 份。第一次做数据恢复演练时我才发现备份能不能用全靠演练所以又加了一条自动化脚本每月 1 号自动把最新备份恢复到本地测试库如果恢复失败就发告警通知。SEO 方面公开的 snippet 页面向搜索引擎开放unlisted和private页面在robotsmeta 里禁止索引。这个不要写在robots.txt里因为robots.txt是站点级别的如果 unlisted 链接避免了搜索引擎爬虫那么所有页面都会被禁。正确做法是在页面渲染时的metadata里动态设置robots: noindexexport const metadata: Metadata { robots: { index: false, follow: false, }, };另外Next.js 14 之后metadata是异步解析接口如果直接在 Server Component 里 return 普通对象会丢。我因为这个在 t3code 的 snippet 详情页折腾了大半个晚上最后看类型定义才发现metadata需要返回PromiseMetadata或直接同步Metadata但访问动态路由参数时要用generateMetadata。这是框架细节但它直接影响 SEO 效果值得记一笔。注意不要把/api前缀路径交给 Next.js 的默认缓存。tRPC 的请求都走这个前缀如果需要缓存请在 tRPC 调用层用 React Query 的staleTime控制而不是去开 HTTP 层缓存否则用户登录态会非常容易串。6. 上线三个月我复盘出的几条经验t3code 从动工到上线差不多用了两周前两周数据增长不快但后面慢慢在公司内部有了二十几个活跃用户。这个过程让我对独立开发全栈工具有了几层新的认知。第一类型安全不是银弹但它能帮你砍掉一整类 bug。t3code 上线三个月前后端接口字段不一致导致的线上故障基本为零。这在 REST 时代几乎不可能尤其是像片段标签评论用户这种嵌套结构光手写类型就容易错到怀疑人生。第二数据库 schema 改起来远比功能新增痛苦。我前面说的tags从数组改关联表就让我重写了搜索逻辑、导入导出逻辑、统计逻辑三块代码。如果你的工具也涉及代码片段这种天然多标签的数据一开始就老老实实建关联表别图省事。第三没有所谓的最佳实践只有对场景的取舍。T3 Stack 教会我的不是框架用法而是把什么放在哪一层的判断力。代码高亮放到后端渲染成快照、搜索双轨制、频率限制放 Redis 而不是数据库、匿名评论配合举报机制……这些东西没写在任何官方文档里但在真实跑完流量之后你自然会知道哪里需要加固。如果你也想做一个类似的代码片段管理工具我的建议是先按照 t3code 的最小链路跑通一条再回头去补权限、索引和监控。把公开分享、搜索、标签这三件事做好就已经超越了大多数满地跑的代码笔记应用。剩下的就让时间和真实用户帮你做决策吧。