tech-interview-handbook 的 Portal 应用解析:基于 T3 Stack 构建全类型安全的全栈架构
本文以 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-pdf、read-excel-file、xlsx(简历 PDF 与薪资 Excel 导入)、@supabase/supabase-js(前端文件存储)、react-dropzone、react-hook-form 等库,与 prisma/salaries.xlsx、prisma/companies.csv 等种子数据文件相互印证。
为什么项目里还有 .js 配置文件
原 README 的核心问题之一是:“Why are there .js files in here?”。其回答是遵循 T3-Axiom #3——类型安全不是可选项(Typesafety isn't optional)。由于并非所有框架和插件都支持 TypeScript 配置,部分配置文件必须写成 .js;但 Portal 通过两种手段维持了类型约束:
- 显式声明模块类型:文件名后缀区分
cjs(CommonJS)与mjs(ESM),取决于所用库支持的格式; @ts-check注释:所有 js 文件头部加上@ts-check,仍纳入 TypeScript 检查。
在仓库中可以逐一对应验证:
- tailwind.config.cjs 与 postcss.config.cjs —— Tailwind 与 PostCSS 官方只支持 CJS 配置,所以用
.cjs; - next.config.mjs —— 用 ESM 的
.mjs,并借助 JSDoc 泛型为defineNextConfig提供自动补全; - src/env/schema.mjs —— 首行即
// @ts-check,配合@type注解让纯 JS 文件拥有类型。
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.prisma 中datasource 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.mjs 取 env,保证任何拿不到合法环境变量的模块都无法初始化。
数据层: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 模型可以划分为两个阵营:
- NextAuth 必需模型:
Account、Session、User、VerificationToken(schema 中注释明确标注 “Necessary for NextAuth”),供 PrismaAdapter 持久化 OAuth 账户与会话; - 业务模型:
Todo、Company、Country/State/City等地理数据,以及 Resumes、Questions(题目、答案、评论、投票、encounter 记录)、Offers(offer、analysis)等实体。User模型通过大量关系字段(如questionsQuestionEncounters、OffersProfile、resumesComments)把这些业务实体与登录用户挂接。
migrations/ 目录下保留了自 20220928103800_init 以来 40 余个按时间戳命名的迁移(如 20221006024246_add_companies、20221117094655_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;
}
- 开发环境缓存
PrismaClient到global,避免 HMR 反复创建连接池; - 日志策略按环境区分:开发环境打印
query/error/warn,生产环境只输出error。
种子脚本
package.json 的 postinstall 会执行 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.json、states.json、cities.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.ts、offers-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);
其工作机制可拆解为:
- Provider:仅配置了 GitHub OAuth,凭据来自上文 Zod 校验过的
env.GITHUB_CLIENT_ID/SECRET; - Adapter:
PrismaAdapter(prisma)将Account/Session/User/VerificationToken四类记录写入 Prisma 数据库,因此登录状态在服务重启、多实例部署下依然有效; - session 回调:把
user.id写进 session,客户端组件即可从useSession()拿到用户 ID,再驱动trpc.<domain>.user.*这类用户态路由(例如todos.user.下的增删改); - 自定义登录页:
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.json 的 scripts,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 # 见上文种子脚本
构建期的类型与依赖体系还有两处值得说明:
- 共享 tsconfig:tsconfig.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.mjs、src/env/server.mjs | Zod 模式 + 启动即校验 |
| 框架配置 | next.config.mjs、tailwind.config.cjs、tsconfig.json | ESM/CJS 类型安全配置、共享 tsconfig/Tailwind |
| 数据层 | prisma/schema.prisma、src/server/db/client.ts | PostgreSQL + 单例客户端 + 迁移/种子 |
| API 层 | src/server/router/index.ts、src/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 参考实现的价值所在。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00