首页
/ tech-interview-handbook 的 Portal 应用解析:基于 T3 Stack 构建全类型安全的全栈架构

tech-interview-handbook 的 Portal 应用解析:基于 T3 Stack 构建全类型安全的全栈架构

2026-09-06 20:15:01作者:伍霜盼Ellen

本文以 tech-interview-handbook 仓库中的 apps/portal 应用为核心,完整解读其 T3 Stack(Next.js + Prisma + tRPC + TailwindCSS + NextAuth)技术架构:从「为什么项目里还有 .js 配置文件」这一 T3 设计哲学讲起,深入环境变量 Zod 校验、Prisma 数据库层、tRPC 路由组织方式、NextAuth 认证配置,直至 Vercel 部署流程。读完本文,你可以复刻该 Portal 应用的分层结构与工程化配置,并掌握各关键配置文件在源码中的真实落地位置。

Portal 是什么:一个 T3 Stack 应用

apps/portal 是 tech-interview-handbook 项目中的核心 Web 应用(面试题库、Offer 数据、简历审阅等功能的承载端),它是一个按照 init.tips 所定义的 T3-Stack 引导(bootstrap)创建的应用。这一点可以从 package.json 中的元数据得到印证:

"ct3aMetadata": {
  "initVersion": "5.13.1"
}

ct3aMetadata 字段表明该应用最初由 create-t3-app(ct3a)v5.13.1 初始化,随后在真实业务上迭代。package.json 中的依赖清单即对应 README 中提到的四大核心技术与若干业务扩展:

技术 版本(dependencies) 在 Portal 中的角色
Next.js next: 14.2.4 全栈框架,Pages Router + API Routes
React / React DOM 18.2.0 视图层
Prisma @prisma/client: ^4.4.0 ORM / 数据访问层(PostgreSQL)
tRPC @trpc/*: ^9.27.2 端到端类型安全的 API 层
NextAuth next-auth: ~4.10.3 + @next-auth/prisma-adapter 会话与登录(GitHub OAuth)
TailwindCSS 经 PostCSS 集成 原子化样式
Zod ^3.18.0 环境变量与数据校验
superjson ^1.10.0 tRPC 的 Date/Map 等复杂类型序列化

业务侧还引入了 react-pdfread-excel-filexlsx(简历 PDF 与薪资 Excel 导入)、@supabase/supabase-js(前端文件存储)、react-dropzonereact-hook-form 等库,与 prisma/salaries.xlsxprisma/companies.csv 等种子数据文件相互印证。

为什么项目里还有 .js 配置文件

原 README 的核心问题之一是:“Why are there .js files in here?”。其回答是遵循 T3-Axiom #3——类型安全不是可选项(Typesafety isn't optional)。由于并非所有框架和插件都支持 TypeScript 配置,部分配置文件必须写成 .js;但 Portal 通过两种手段维持了类型约束:

  1. 显式声明模块类型:文件名后缀区分 cjs(CommonJS)与 mjs(ESM),取决于所用库支持的格式;
  2. @ts-check 注释:所有 js 文件头部加上 @ts-check,仍纳入 TypeScript 检查。

在仓库中可以逐一对应验证:

next.config.mjs 的完整内容也很能说明 T3 的「最小但类型安全」取向:

import './src/env/server.mjs'; // 启动时即校验环境变量,非法则构建失败

/**
 * @template {import('next').NextConfig} T
 * @param {T} config
 * @constraint {{import('next').NextConfig}}
 */
function defineNextConfig(config) {
  return config;
}

export default defineNextConfig({
  experimental: { esmExternals: 'loose' },
  reactStrictMode: true,
  swcMinify: true,
});

首行 import './src/env/server.mjs' 是关键:它让环境变量校验先于 Next.js 配置加载执行,环境变量不合法时应用直接无法启动/构建,这正是 T3 Stack「fail fast」思想的落地。

环境变量:Zod 模式定义 + 运行时校验

T3 Stack 对环境变量处理的标准做法在 Portal 中完整保留,核心文件是 src/env/schema.mjs。服务端必填变量被定义为:

export const serverSchema = z.object({
  DATABASE_URL: z.string().url(),          // PostgreSQL 连接串
  GITHUB_CLIENT_ID: z.string(),           // GitHub OAuth 应用 ID
  GITHUB_CLIENT_SECRET: z.string(),      // GitHub OAuth 应用密钥
  NEXTAUTH_SECRET: z.string(),           // NextAuth 会话签名密钥
  NEXTAUTH_URL: z.string().url(),        // 应用公网地址
  NODE_ENV: z.enum(['development', 'test', 'production']),
  SUPABASE_ANON_KEY: z.string(),          // 前端文件存储
  SUPABASE_URL: z.string().url(),
});

要点说明:

  • DATABASE_URL:对应 prisma/schema.prismadatasource db { provider = "postgresql" url = env("DATABASE_URL") },是本地开发的第一前置条件;
  • GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET:被 src/pages/api/auth/[...nextauth].ts 中的 GitHubProvider 直接消费;
  • NEXTAUTH_SECRET / NEXTAUTH_URL:NextAuth 会话签名与回调 URL 所必需;
  • 客户端变量约定:文件内注释明确「要暴露给客户端的变量必须以 NEXT_PUBLIC_ 为前缀」,当前 clientSchema 为空(全部注释占位),说明 Portal 目前没有向浏览器暴露任何自定义环境变量,敏感信息一律留在服务端。

src/env/ 目录共三个文件:schema.mjs(模式定义)、server.mjs(服务端解析 + 校验导出)、client.mjs(客户端解析)。tRPC 上下文与 Prisma 客户端均从 ~/env/server.mjsenv,保证任何拿不到合法环境变量的模块都无法初始化。

数据层:Prisma 模式、单例客户端与种子脚本

Schema 与数据源

prisma/schema.prisma 声明了 PostgreSQL 数据源与客户端生成器:

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["interactiveTransactions"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

其中 interactiveTransactions 预览特性为 tRPC 的 prisma.middleware 事务支持提供了基础。Schema 模型可以划分为两个阵营:

  1. NextAuth 必需模型AccountSessionUserVerificationToken(schema 中注释明确标注 “Necessary for NextAuth”),供 PrismaAdapter 持久化 OAuth 账户与会话;
  2. 业务模型TodoCompanyCountry/State/City 等地理数据,以及 Resumes、Questions(题目、答案、评论、投票、encounter 记录)、Offers(offer、analysis)等实体。User 模型通过大量关系字段(如 questionsQuestionEncountersOffersProfileresumesComments)把这些业务实体与登录用户挂接。

migrations/ 目录下保留了自 20220928103800_init 以来 40 余个按时间戳命名的迁移(如 20221006024246_add_companies20221117094655_add_offersadmin_table),可完整追溯 Portal 数据模型的演化历史;migration_lock.toml 记录了锁定的 provider。

Prisma 客户端单例

prisma 客户端 采用了 Next.js 热更新场景下的经典写法:

declare global {
  var prisma: PrismaClient | undefined;
}

export const prisma =
  global.prisma ||
  new PrismaClient({
    log:
      env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  });

if (env.NODE_ENV !== 'production') {
  global.prisma = prisma;
}
  • 开发环境缓存 PrismaClientglobal,避免 HMR 反复创建连接池;
  • 日志策略按环境区分:开发环境打印 query/error/warn,生产环境只输出 error

种子脚本

package.jsonpostinstall 会执行 prisma generate 生成客户端,并提供了一组 ts-node 种子命令:

pnpm seed              # ts-node prisma/seed.ts            地理数据(国家/州/城市)
pnpm seed-salaries     # ts-node prisma/seed-salaries.ts   薪资数据
pnpm seed-analysis     # ts-node prisma/seed-analysis.ts   分析数据
pnpm seed-questions    # ts-node prisma/seed-questions.ts  题目数据
pnpm seed-companies    # ts-node prisma/seed-companies.ts  公司数据

prisma/seed.ts 为例,它加载 prisma/data/countries.jsonstates.jsoncities.json,按 Country → State → City 的顺序 createMany 入库,并用 skipDuplicates: true 保证可重复执行(幂等)。

API 层:tRPC 路由的组织方式

Portal 没有使用 REST 风格的手工 API 定义,而是用 tRPC v9 的 appRouter 集中管理。入口 src/server/router/index.ts 展示了其组织约定:

export const appRouter = createRouter()
  .transformer(superjson)      // 支持 Date、Map 等复杂类型的序列化
  // All keys should be delimited by a period and end with a period.
  .merge('auth.', protectedExampleRouter)
  .merge('user.', userRouter)
  .merge('todos.', todosRouter)
  .merge('todos.user.', todosUserRouter)
  .merge('companies.', companiesRouter)
  .merge('locations.', locationsRouter)
  .merge('resumes.resume.', resumesRouter)
  // ... questions / offers 相关子路由
  .merge('offers.admin.', offerAdminRouter);

export type AppRouter = typeof appRouter;

几个值得注意的工程细节:

  • 点号命名约定:源码注释明确规定「所有 key 用点分隔,且以点结尾」(如 resumes.comments.votes.user.)。这使得客户端调用形如 trpc.resumes.resume.user.get.query(),路由名即 URL 路径,可读性极强;
  • superjson 转换器:保证 Date 等 Prisma 返回类型在浏览器端无损往返;
  • AppRouter 类型导出:客户端通过 trpc.create<AppRouter>() 获得对全部查询/变异的自动补全与类型检查,这就是 T3 Stack「端到端类型安全」的核心收益;
  • 按域分文件questions/offers/resumes/ 子目录下每个聚合一个 router 文件(如 questions-answer-router.tsoffers-profile-router.ts),与 src/components 下的组件目录结构镜像对应。

HTTP 入口是 src/pages/api/trpc/[trpc].ts,它把 Next.js API Route 挂接到 appRouter 上;浏览器端则通过 src/utils/trpc.ts 封装的 @trpc/next 集成(配合 react-query)发起类型化请求。

认证:NextAuth + PrismaAdapter + GitHub OAuth

认证链路的核心文件是 src/pages/api/auth/[...nextauth].ts

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),          // 会话/账户持久化到 PostgreSQL
  callbacks: {
    session({ session, user }) {
      if (session.user != null) {
        session.user.id = user.id;          // 把 user.id 注入 session
      }
      return session;
    },
  },
  pages: { signIn: '/login' },             // 自定义登录页
  providers: [
    GitHubProvider({
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    }),
  ],
};

export default NextAuth(authOptions);

其工作机制可拆解为:

  1. Provider:仅配置了 GitHub OAuth,凭据来自上文 Zod 校验过的 env.GITHUB_CLIENT_ID/SECRET
  2. AdapterPrismaAdapter(prisma)Account/Session/User/VerificationToken 四类记录写入 Prisma 数据库,因此登录状态在服务重启、多实例部署下依然有效;
  3. session 回调:把 user.id 写进 session,客户端组件即可从 useSession() 拿到用户 ID,再驱动 trpc.<domain>.user.* 这类用户态路由(例如 todos.user. 下的增删改);
  4. 自定义登录页pages.signIn 指向 /login,对应 src/pages/login.tsx

路由保护方面,原 README「Useful resources」中推荐的 NextAuth 服务端路由保护方式,在本仓库中体现为 src/server/common/get-server-auth-session.ts(服务端获取会话)以及 src/server/router/protected-example-router.ts(在 tRPC 层校验 ctx.session 的示例路由)。前端则配合 src/pages/middleware 保护思路 与登录/注册页完成未登录跳转。

开发工作流:命令速查

结合 package.jsonscripts,Portal 的日常开发命令如下(仓库根目录为 pnpm workspace,workspace 定义见 pnpm-workspace.yaml,覆盖 apps/*packages/*):

# 在 apps/portal 目录下
pnpm dev            # next dev 启动开发服务器
pnpm build          # next build 生产构建
pnpm start          # next start 运行生产构建
pnpm lint           # 基于 vite-plus 的 lint(vp lint next.config.mjs src)
pnpm tsc            # 类型检查
pnpm seed           # 见上文种子脚本

构建期的类型与依赖体系还有两处值得说明:

  • 共享 tsconfigtsconfig.json extends "@tih/tsconfig/nextjs.json",该包来自 packages/tsconfig workspace 包;同时配置了路径别名 "~/*": ["*"](相对 src),因此源码中 ~/env/server.mjs~/server/db/client 这类导入均解析到 src 下;
  • 共享 tailwind 配置tailwind.config.cjs 复用 packages/tailwind-config/tailwind.config.js,并额外把 packages/ui 的组件源码加入 content 扫描范围——注释解释这样做是为了「直接提取 UI 包样式,而非导入其生成好的 CSS,以避免样式顺序问题」;
  • ts-node 兼容:tsconfig 中单独配置了 "ts-node": { "transpileOnly": true, "compilerOptions": { "module": "CommonJS" } },正是为了让 pnpm seed* 系列脚本能在 CommonJS 模式下直接运行 TS 种子文件。

部署:Vercel 与 Docker

原 README 推荐两类部署方式。

Vercel

官方推荐的步骤是:将代码推送到 GitHub 仓库 → 用 GitHub 账号登录 Vercel → 创建项目并导入该仓库 → 添加环境变量(对应 schema.mjs 中的 8 个服务端变量)→ 点击 Deploy;此后每次 push 都会自动重新部署。

仓库中的 vercel.json 是这套流程的唯一定制配置:

{
  "github": { "silent": true }
}

即不在 GitHub 侧生成部署状态检查,部署状态只由 Vercel 平台跟踪。由于 DATABASE_URL 指向外部 PostgreSQL,且 postinstall 会触发 prisma generate,在 Vercel 环境变量的配置中必须与本地 .env 保持一致(尤其 NEXTAUTH_URL 要填最终公网地址)。

Docker

README 同时指出可以将该 T3 Stack 容器化部署,具体做法参考 create-t3-app 官方的 Docker 部署文档(外部链接,此处不展开)。仓库当前未内置 Dockerfile,容器化属于可选路径。

小结:从 Portal 看 T3 Stack 的分层

回顾 Portal 的结构,T3 Stack 的分层在 tech-interview-handbook 这个项目里体现得非常完整:

关键文件 职责
环境变量 src/env/schema.mjssrc/env/server.mjs Zod 模式 + 启动即校验
框架配置 next.config.mjstailwind.config.cjstsconfig.json ESM/CJS 类型安全配置、共享 tsconfig/Tailwind
数据层 prisma/schema.prismasrc/server/db/client.ts PostgreSQL + 单例客户端 + 迁移/种子
API 层 src/server/router/index.tssrc/pages/api/trpc/[trpc].ts tRPC 路由聚合 + superjson + 类型导出
认证 src/pages/api/auth/[...nextauth].ts NextAuth + PrismaAdapter + GitHub OAuth
部署 vercel.json Vercel 自动部署配置

如果你想在自己的项目里复刻这套架构,最短路径是:用 create-t3-app 初始化 → 按 schema.mjs 的范式定义环境变量 → 用 Prisma 建立数据模型与迁移 → 按点号命名约定组织 tRPC 路由 → 接入 PrismaAdapter 完成认证 → 用 vercel.json 对接 Vercel。Portal 的每个环节都有对应的源码文件可供对照,这也是它作为 T3 Stack 参考实现的价值所在。

登录后查看全文
热门项目推荐
相关项目推荐