用最潮的技术栈,打造最稳的单词管理应用,一次掌握 BAAS、ORM、UI 组件化与数据清洗的核心心法。
前言:我们到底在做什么?
今天我们要聊的不是一个简单的 Todo List demo,而是一个实实在在的、具备多端能力的"单词大师"后台管理系统与 H5 应用。这个项目不仅涵盖了业务功能的实现,更重要的是,它像一条线,把现代前端工程化中的几个关键"珠子"------shadcn/ui 、Supabase 、Drizzle ORM 、数据清洗 以及 Conventional Commits------完美地串联了起来。
通过这个项目,你会理解如何利用云端 BAAS 服务快速启动后端,如何通过 ORM 以面向对象的方式操作数据库,如何借助优秀的组件库和 AI 工具高效地完成业务迭代,以及如何通过规范化的 Git 提交让团队协作如丝般顺滑。接下来,我将带你逐一剖析项目中的每一个技术决策和实现细节。
一、技术全景与核心概念:为什么是它们?
1. 应用形态:后台管理系统 + H5 应用
项目采用了"管理后台 + H5 应用"的多端开发模式。后台面向管理员,负责数据生产(单词书录入、管理);H5 应用面向终端用户,负责数据消费(背单词)。这种分离设计让业务逻辑更清晰,也便于未来的独立扩展。
2. 设计初衷:用最少的成本,构建最稳的应用
项目的核心目标是打造一个"单词后台管理系统",用于维护单词书和管理员。为了实现这个目标,我们在技术选型上做了以下考量:
- Next.js:作为 React 全栈框架,它提供了 App Router、API Routes 和服务端渲染能力,让前后端代码在同一个项目中无缝协作,极大地降低了项目启动的复杂度。
- shadcn/ui :这不是一个传统的"组件库",而是一套"组件工厂"。它不提供
npm install的臃肿包,而是让你将源码直接复制到项目中。这意味着你可以完全掌控组件的样式和行为,没有黑盒,定制性极强。配合 Tailwind CSS ,我们可以在className中自由组合样式,实现了"样式即代码"的语义化与原子化,这对 AI 编码也极其友好。 - Supabase :作为开源的 Firebase 替代品,它本质上是把 PostgreSQL 数据库包装成了一套完整的 BAAS(Backend as a Service) 平台。它提供了数据库、认证、存储等开箱即用的能力。我们看重的不仅是它强大的关系型数据库能力,还因为它未来可以无缝支持 向量数据库 功能,为后续的语义搜索等 AI 特性留足了想象空间。性能、安全、可扩展,而部署成本几乎为零。
- Drizzle ORM :这是项目的数据层核心。ORM(Object Relational Mapping)负责将数据库的表结构映射为 TypeScript 的类或对象。这意味着我们不再需要编写原始的 SQL 语句,而是通过
user.save()这样的面向对象方法来完成数据库操作。Drizzle 不仅类型安全,还在性能和灵活性上优于传统的 ORM,是目前社区公认的 Next.js 最佳数据库伴侣。
3. 项目架构图

二、痛点与场景:我们解决了什么?
在实际业务中,开发一个管理系统通常会遇到以下痛点:
- 后端启动慢:搭建一个简单的 CRUD 接口也需要配置路由、数据库连接、编写 SQL,重复且枯燥。
- 数据库操作复杂:手写 SQL 容易出现语法错误和安全漏洞(如 SQL 注入),且难以维护。
- UI 定制化成本高:选用的 UI 库(如 Ant Design)虽然功能强大,但样式定制往往需要大量的 CSS 覆盖,升级风险高。
- 数据迁移和清洗困难:如何将现有的 JSON 数据(如从 GitHub 下载的单词库)导入到新数据库中?数据格式如何校验和转换?
- Git 提交信息混乱 :团队中每个人的提交风格不一,
git log看起来一团乱麻,难以追踪变更目的,也无法自动化生成版本日志。
我们的项目正是为了解决这些痛点而生的:
- 利用 Supabase ,我们不需要自己搭建和维护数据库服务器,一行
DATABASE_URL就能连接,BAAS 让我们专注于业务。 - 通过 Drizzle ORM ,我们不再写 SQL,而是用 TypeScript 对象定义
Schema,用db.insert().values()替代INSERT INTO,代码更优雅,类型更安全。 - 借助 shadcn/ui,我们直接获取了美观且完全可定制的 UI 源码,按需加载,无需处理复杂的样式冲突。
- 针对数据清洗,我们让 AI 编写了一个 Node.js 脚本,将 178kb 的 JSON 文件转换为 CSV 格式,避免了直接在上下文窗口处理大文件带来的 token 开销,高效且成本极低。
- 引入 Conventional Commits 规范,让每次提交都清晰可辨,为自动化发版铺平道路。
三、重难点剖析:核心原理与设计思路
重难点 1:基于 Next.js App Router 的权限控制与路由守卫
设计思考:系统需要区分"系统管理员"和"普通管理员"。普通管理员不应该看到"管理员管理"菜单,也不应该能调用相关 API。这个需求要求我们在前端界面渲染和后端接口访问两个层面都做权限拦截。
如何攻克:
- 前端守卫 :利用 Next.js 的 Middleware 机制。在
src/middleware.ts中检查请求的cookie或session,判断用户是否登录。如果未登录,重定向到/signin。对于/admin-users路由,我们不仅要检查登录态,还要从 Session 中读取用户的角色(Role),如果是普通管理员,则重定向到/books页面或返回 403 错误。 - 后端守卫 :在 API Route 的
handler函数中,我们不能仅依赖前端 。必须从请求头中获取用户的 Session ID,查询数据库验证其角色。对于/api/admin-users下的所有接口,都要先执行一个withAdminAuth的高阶函数,只有role === 'SYSTEM_ADMIN'才允许放行。
typescript
javascript
// src/lib/auth.ts (核心鉴权逻辑示例)
import { cookies } from 'next/headers';
import { db } from '@/db';
import { adminSessions, adminUsers } from '@/db/schema';
import { eq, and, gt } from 'drizzle-orm';
export async function getSession() {
const sessionId = cookies().get('session_id')?.value;
if (!sessionId) return null;
// 查询 session 并关联用户,同时判断是否过期 (有效期7天)
const session = await db.query.adminSessions.findFirst({
where: and(
eq(adminSessions.id, sessionId),
gt(adminSessions.expiresAt, new Date())
),
with: {
user: true, // 关联查询用户信息
},
});
return session;
}
export async function requireAuth() {
const session = await getSession();
if (!session) throw new Error('Unauthorized');
return session;
}
export async function requireSystemAdmin() {
const session = await requireAuth();
if (session.user.role !== 'SYSTEM_ADMIN') {
throw new Error('Forbidden: System Admin only');
}
return session;
}
重难点 2:Drizzle ORM 的 Schema 定义与级联删除
设计思考 :我们有两张核心表:books(单词书)和 words(单词)。它们通过 bookId 关联。业务规则要求:删除一本单词书时,其下的所有单词也必须被删除。这在关系型数据库中通过"级联删除"实现。
如何攻克:
- 表关系映射 :在
db/schema.ts中,我们定义books和words表。 - 外键约束 :在
words表中定义bookId字段,并通过references(() => books.bookId)将其定义为外键。 - 级联删除 :在
references链中,使用onDelete: 'cascade'。这告诉 Drizzle 和数据库,当books表中的记录被删除时,自动删除所有bookId匹配的words记录。
typescript
css
// db/schema.ts
import { pgTable, text, integer, json, bigint, timestamp, primaryKey } from 'drizzle-orm/pg-core';
export const books = pgTable('books', {
id: bigint('id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
title: text('title').notNull(),
description: text('description'),
coverUrl: text('cover_url'),
wordCount: integer('word_count').default(0),
bookId: text('book_id').unique().notNull(), // 关联标识
createdAt: timestamp('created_at').defaultNow(),
});
export const words = pgTable('words', {
id: bigint('id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
wordRank: integer('word_rank'),
headWord: text('head_word'),
content: json('content'), // 存储复杂结构
bookId: text('book_id').references(() => books.bookId, {
onDelete: 'cascade', // 核心:级联删除
}),
});
重难点 3:数据清洗脚本的生成与执行
设计思考 :项目资料库提供了 178kb 的 JSON 单词数据。直接让 AI 在上文窗口中转换,token 消耗巨大且容易超限。解决方案是 "让 AI 写脚本,在本地执行" 。
如何攻克:
- 提供上下文 :我们给 AI 提供了 JSON 的片段样本(
#L1-2)和目标表的字段(wordRank,headWord,content,bookId)。 - AI 生成脚本 :AI 根据指令生成了一个名为
scripts/json2csv.js的 Node.js 脚本,使用fs模块读取 JSON,使用json2csv库(或手动拼接)将其转换为 CSV 格式。 - 本地执行 :我们在命令行运行
node scripts/json2csv.js,脚本在同级目录生成了 CSV 文件。 - 导入 Supabase :最后,我们利用 Supabase 控制台的"导入 CSV"功能,轻松将数据导入到
words表中。整个过程安全、高效、成本最低。
四、核心技术疑点深度答疑(Q&A)
在开发过程中,我的团队成员(以及正在阅读本文的你)肯定会产生下面这些触及灵魂的疑问。我把它们一次性拎出来,用最直白的话讲透,让你不仅"知其然",更"知其所以然"。
Q1:Schema 定义------到底是先有代码还是先有表?
你的疑问 :我既可以在 schema.ts 里写代码生成表,也可以在 Supabase 界面直接建表然后复制 SQL 给 Agent,这两种方式都行吗?
我的回答 :都行! 这其实就是工程化中经典的 "Code First"(代码优先) 与 "Database First"(数据库优先) 之争。
- Code First(本项目采用) :你在
schema.ts中用 Drizzle 的pgTable定义表结构。运行npm run db:push,Drizzle 会把你的 TypeScript 代码"翻译"成CREATE TABLE或ALTER TABLE的 SQL 语句,自动同步到 Supabase。适合新项目,由开发者完全掌控版本。 - Database First(你提到的反向操作) :你在 Supabase 的 SQL Editor 里写
CREATE TABLE执行完了,然后把这段 SQL 粘贴给 Agent。Agent 可以直接帮你把这坨 SQL "逆向" 解析成 Drizzle 的schema.ts代码。适合接手老项目,或者喜欢在数据库客户端调试表结构的同学。
结论 :在 AI 时代,这两种方式 Agent 都能无缝支持。但我强烈推荐 Code First ,因为 schema.ts 就是你的"唯一事实来源",方便 Git 追踪变更,代码即文档。
Q2:shadcn/ui 源码在哪?怎么改?为什么它能做到"懒加载"?
你的疑问:shadcn/ui 把源码弄到项目里,文件在哪?我改了会不会影响升级?其他 UI 库为什么做不到像它那样按需加载?
我的回答:
- 源码位置 :在你项目的
components/ui/目录下。当你执行npx shadcn-ui@latest add button时,它就把button.tsx的源码复制到了这个文件夹里。 - 如何修改 :直接打开
components/ui/button.tsx,想怎么改就怎么改。比如在Button组件的className里默认加个font-bold,全局所有按钮都会变粗体。 - 关于升级 :如果你改了源码,再执行
add命令,它会提示你文件已存在,可以选择跳过 或覆盖。这给了你极高的定制自由度,代价是升级需要手动合并冲突,但毕竟 UI 样式很少频繁大改,这个代价完全值得。 - 为什么其他库做不到(懒加载真相) :Ant Design 或 Element UI 是
npm install一个大包,所有组件都在node_modules里,哪怕你只用了一个 Button,构建时也要把整个库的样式和逻辑全量引入(虽然有 Tree Shaking,但样式文件很难摇干净)。shadcn 的"懒加载" 本质是 "按需安装源码" ------你要用哪个,就add哪个,没add的组件,代码库里压根就不存在,做到了极致轻量和零冗余。
Q3:数据清洗除了省 Token,还有啥好处?
你的疑问:为了不让 AI 读大文件才做脚本转换。除了省 Token,清洗数据还为了什么?
我的回答 :为了数据质量 和后续查询性能。省 Token 只是开胃菜,真正的硬菜在这里:
- 字段标准化(Schema Validation) :原始 JSON 可能有
headWord,也可能有headword,清洗后统一映射成表字段,确保入库不出错。 - 类型转换 :JSON 里字符串的
"123"转换成数据库的INT类型;嵌套对象转换成JSONB字段。 - 去重与过滤 :去掉不需要的字段(比如 GitHub 资料库里的贡献者信息),只保留我们业务需要的
wordRank、headWord等,减小数据库存储压力。 - 建立索引的前提 :只有数据格式规整了,我们才能在
bookId这类字段上创建 B-Tree 索引,让后续的"根据单词书 ID 查单词"毫秒级响应。
Q4:Next.js 的 App Router 和 API Routes 到底分工是啥?
你的疑问:这两个东西都叫 Route,分别有什么作用?Next.js 的好处到底在哪?
我的回答 :最简单的区分------App Router 管"看",API Routes 管"干" 。
| 概念 | 文件路径示例 | 作用 | 运行环境 |
|---|---|---|---|
| App Router | app/books/page.tsx |
定义页面 UI(前端渲染)。通过 page.tsx 访问 /books 时,展示单词书列表界面。 |
Node.js / Edge(支持 SSR) |
| API Routes | app/api/books/route.ts |
定义后端接口(RESTful API)。前端 fetch /api/books 时,执行数据库查询,返回 JSON 数据。 |
Node.js |
Next.js 框架的好处(全栈一体化) :
- 单一仓库(Monorepo) :前端界面和后端接口在同一个项目里,不用像传统 Java + React 那样分开两个 Git 仓库,改个字段要同步两个项目。
- 类型安全 :你可以在服务端
route.ts里用 Drizzle 查出数据,直接通过fetch传给客户端,配合zod校验,前后端类型浑然一体。 - 简化部署 :只需
next build打包,无论是 Vercel 还是 Docker,一个命令搞定全栈部署,运维成本极低。
Q5:后台管理系统该怎么设计表?要让 Agent 分析模块关系吗?
你的疑问:怎么设计表结构?能让 Agent 帮我分析模块关系吗?
我的回答 :绝对可以,而且是 Agent 的强项! 后台管理系统本质上都是 RBAC(基于角色的访问控制) + 核心业务实体。
结合我们的项目,我梳理了 4 张核心表,并让 Agent 帮我画出了 ER 图,清晰展示模块关系:
给 Agent 的 Prompt 策略 :你可以这样对它说:"现在我有 admin-users 负责管理员,books 负责单词书,words 负责单词。请帮我分析它们之间的外键关系,并给出 Drizzle 的 schema 定义,同时告诉我如何利用这种关系进行级联操作。" Agent 会立刻给你生成包含 references(() => ...) 和 onDelete: 'cascade' 的完美代码。
Q6:为什么 JSON 要转 CSV?数据库只认这个格式吗?
你的疑问:JSON 转 CSV,是因为数据库只支持这两种格式吗?
我的回答 :大错特错! Supabase(PostgreSQL)原生完美支持 JSON 和 JSONB 数据类型,你完全可以直接导入 JSON。
那为什么我们偏要转 CSV? 原因有 3 个:
- 体积更小 :CSV 是纯扁平数据,没有 JSON 里大量的
{}、[]和"key":重复开销。178KB 的 JSON 转成 CSV 可能就剩 80KB,上传速度快一倍。 - 可视化预览:Excel 或 WPS 能直接打开 CSV 查看行列数据,管理员审核数据时,看一眼就知道对不对。JSON 打开就是一团乱麻。
- Supabase 导入 UX :Supabase 后台的"导入 CSV"功能极其丝滑,自动识别表头映射字段,点点鼠标就完成。导入 JSON 则需要写 SQL 的
json_populate_recordset函数,不是每个运营同学都会 SQL。
Q7:Cookie 和 Token(Session)到底什么关系?
你的疑问:项目中用到了 Session,也提到了 Cookie,这俩和 JWT Token 有啥区别?
我的回答 :这是面试最高频的混淆点,我用一句话给你理清:
- Cookie :是载体(存放数据的容器),由浏览器自动管理和携带。
- Session ID :是钥匙(一串随机字符串),指向服务端存储的用户状态。
- Token(特指 JWT) :是自包含的身份证明(里面直接存了用户信息,还带签名)。
| 对比维度 | 基于 Cookie + Session(本项目方案) | 基于 Token(JWT) |
|---|---|---|
| 存储位置 | Session ID 存 Cookie;用户数据存数据库 admin-sessions 表 |
Token 存客户端(LocalStorage / Cookie) |
| 安全性 | 高(HttpOnly Cookie 防 XSS;服务端可随时吊销 Session) | 中(若存 LocalStorage 易被 XSS 窃取;且 JWT 签发后无法主动吊销,只能等过期) |
| 服务端状态 | 有状态(占用数据库/Redis 存储) | 无状态(服务端不存,全靠签名解密) |
| 适用场景 | 后台管理系统(严格控制权限) | 开放 API / 微服务(跨域认证) |
我们选择 Cookie + Session 是因为后台管理系统需要极强的权限管控,系统管理员可以随时"踢人下线",在数据库中删除该 Session 记录即可,JWT 则做不到这一点。
Q8:schema.ts 修改后为何要 push/migrate?云端不是实时的吗?
你的疑问:都连了 Supabase 云端数据库了,为什么改代码表结构还要手动运行命令,不是应该实时同步吗?
我的回答 :如果把 schema.ts 的修改自动实时同步到数据库,那将是整个团队的灾难! 想象一下:
- 你在本地测试,删除了
words表的一个字段,如果实时同步,线上生产环境的几百万条单词数据瞬间丢失一列,且无法恢复。 - 你和同事同时在分支里修改了表结构,自动同步会导致数据库结构被反复覆盖,引发
schema mismatch错误。
正确的做法(版本控制思维) :
db:push:相当于 "强制覆盖" ,只适合开发环境或个人项目,快速迭代。db:generate+db:migrate:相当于 "Git 提交" 。generate生成带时间戳的 SQL 迁移文件(例如0001_heavy_silver_surfer.sql),migrate按顺序执行这些文件。这样,生产环境的数据库是一步一步"进化"来的,即便出错,也可以migrate down回滚。
记住:代码连接云端,不代表代码能随意篡改云端数据。代码是"图纸",数据库是"大楼",改图纸要审批(生成迁移),盖大楼要施工(执行迁移)。
Q9:如何在 Supabase 中开启 RLS?让 Agent 创建表时怎么处理?
你的疑问:RLS 是创建表时的选项吗?我让 Agent 建表,怎么开启?
我的回答 :RLS(行级安全)不是 建表语句 CREATE TABLE 里的一个简单开关。它是表建好后 赋予的策略(Policy) 。
如果让 Agent 帮你开启 RLS,你需要明确告诉它两个步骤:
第一步:启用 RLS(在表上开锁)
sql
sql
ALTER TABLE public.words ENABLE ROW LEVEL SECURITY;
第二步:创建具体的策略(谁可以对哪行数据做什么操作)
sql
vbnet
CREATE POLICY "允许用户查看自己的单词记录" ON public.user_words
FOR SELECT
USING ( auth.uid() = user_id );
给 Agent 的 Prompt 参考:
"请帮我为
admin-users表生成 RLS 策略的 SQL 语句。要求只有拥有SYSTEM_ADMIN角色的用户才能 SELECT 和 UPDATE 这张表,普通管理员无法访问。"
Agent 会直接帮你生成完整的 CREATE POLICY 语句,你只需在 Supabase SQL Editor 里执行即可。当然,也可以直接在 Supabase 后台的 Authentication -> Policies 面板里点击 "Enable RLS" 按钮,然后填入规则,效果相同。
Q10:Conventional Commits(约定式提交)为什么这么香?
你的疑问 :feat、fix、chore 这些提交类型有什么用?不就是加个前缀吗?
我的回答 :这绝不仅仅是加个前缀,这是团队协作的"通用语言" 和自动化发版的基础。
- 快速定位变更 :浏览 Git 日志
git log --oneline,一眼就能看出哪些提交引入了新功能(feat),哪些是修 Bug(fix),哪些是改格式化这种无关紧要的变动(style)。review 代码时,能节约 50% 的心智负担。 - 自动化生成 Changelog :CI/CD 工具(如 standard-version 或 semantic-release)可以扫描 Git 提交记录,自动生成
CHANGELOG.md文件,并且根据feat自动升级 Minor 版本号,根据fix自动升级 Patch 版本号 。完全不用手动改package.json里的 version 字段。 - AI Coding Agent 内置这个规范 :意味着当你对 Agent 说"帮我提交代码"时,它会自动分析你改了啥,替你填上正确的
type,这是 AI 赋能工程化落地的一个极佳典范。
补充类型含义:
| 类型 | 说明 | 例子 |
|---|---|---|
| feat | 新功能(Feature) | feat: 新增单词书搜索功能 |
| fix | 修复 Bug | fix: 修复管理员无法删除单词书的级联问题 |
| docs | 仅文档更改 | docs: 更新 README 环境变量说明 |
| style | 代码格式(不影响运行) | style: 使用 prettier 格式化所有组件 |
| refactor | 代码重构(既不是新功能也不是修 Bug) | refactor: 将鉴权逻辑抽取到 middleware |
| test | 增加或修改测试用例 | test: 增加管理员注册接口的单测 |
| chore | 构建流程、依赖更新、工程化 | chore: 升级 Drizzle ORM 到 0.29.0 |
五、避坑指南与最佳实践
1. 环境变量 .env 管理
- 坑点 :忘记将
DATABASE_URL添加到.env.local,或者在生产环境忘记配置。 - 最佳实践 :在项目根目录创建
.env文件,并立刻将其添加到.gitignore中。在团队协作文档中,提供一个.env.example模板文件。
2. Drizzle ORM 迁移文件(migrations)
- 坑点 :直接修改
schema.ts后忘记运行db:push或db:generate,导致数据库表结构与代码不一致,引发查询错误。 - 最佳实践 :将数据库迁移视为一等公民。每次修改
schema.ts,立即运行npm run db:generate生成迁移文件,并在部署时通过 CI/CD 自动运行npm run db:migrate。开发阶段频繁变更可暂时使用db:push,但上线后务必使用migrate以保证版本可控。
3. shadcn/ui 的定制化
- 坑点 :直接修改
components/ui/button.tsx的源码,导致下次升级shadcn-ui时自定义逻辑丢失。 - 最佳实践 :通过
className传递自定义样式,或者在globals.css中覆盖 CSS 变量。如需大面积修改默认主题,请修改tailwind.config.js中的primary等颜色变量。
4. 管理员注册的逻辑陷阱
- 需求回顾 :系统中如果没有任何管理员,自动跳转
/signup注册"系统管理员";如果已有管理员,则不允许再注册系统管理员。 - 坑点 :如果
/signup页面没有做"是否已有管理员"的校验,可能会导致系统被恶意注册出多个超级管理员。 - 正确实现 :在
/signup页面的Page组件或 API Route 中,在渲染表单或处理提交前,先查询admin-users表。如果数据表非空,直接重定向到/signin,从入口杜绝二次注册。
5. 系统管理员权限保护
- 需求回顾:系统管理员不能修改自己的状态和角色,只能修改他人的信息。
- 坑点:前端界面虽然隐藏了"编辑自己"的按钮,但如果后端接口没有做校验,攻击者依然可以通过直接调用 API 来修改超级管理员的角色。
- 正确实现 :在后端更新接口中,先通过 Session 获取当前操作者的 ID,再与请求参数中的目标用户 ID 比对。如果相同,直接返回
403 Forbidden,从数据源头杜绝越权操作。
六、面试高频考点
1. ORM 的原理是什么?为什么要用 Drizzle 而不是直接写 SQL?
-
回答要点:
- 原理 :ORM(对象关系映射)通过元数据描述对象与数据库表之间的映射关系。当我们调用
db.insert(users).values({ name: 'John' })时,ORM 在底层将其翻译成INSERT INTO users (name) VALUES ('John')的 SQL 语句并执行。 - Drizzle 优势 :相比 Prisma,Drizzle 更加轻量和透明,它不强制
prisma generate,而是在编码时提供完整的 TypeScript 类型提示。它支持"即写即查"的 SQL 风格,学习曲线更平滑,性能损耗更小。在 Next.js 这种全栈场景下,它非常适合作为数据库与应用程序之间的"翻译官"。
- 原理 :ORM(对象关系映射)通过元数据描述对象与数据库表之间的映射关系。当我们调用
2. Supabase 的 RLS(Row Level Security)有什么作用?项目中用到了吗?
-
回答要点:
- 作用 :RLS 是 PostgreSQL 提供的一种安全特性。它允许我们为数据库表定义行级别的访问策略(Policy)。例如,我们可以设置策略,只允许用户查询
created_by字段等于自己 ID 的数据行。 - 项目应用 :在我们的项目中,
words表是公共数据,不需要 RLS。但是,如果未来增加"用户背单词记录"表(如user_words),我们就必须 开启 RLS。配置策略如USING (auth.uid() = user_id),确保即便 API 被恶意调用,数据库层面也能确保用户只能访问自己的数据,这是纵深防御的关键一环。
- 作用 :RLS 是 PostgreSQL 提供的一种安全特性。它允许我们为数据库表定义行级别的访问策略(Policy)。例如,我们可以设置策略,只允许用户查询
3. 解释一下 Next.js 中的 Middleware 和 API Route 在权限控制中的分工。
-
回答要点:
- Middleware(中间件) :运行在 Edge Runtime ,在请求到达具体页面或 API 之前执行。它擅长做轻量级 的拦截,如检查 Cookie 是否存在,进行页面重定向。我们用它来实现路由守卫,比如未登录用户访问
/books时,直接重定向到/signin。 - API Route(API 路由) :运行在 Node.js Runtime ,拥有完整的数据库访问能力。它负责重量级 的权限校验,比如根据请求头中的 Session ID 查询数据库,获取用户角色,并校验是否有权限操作特定资源。核心原则是:API Route 是最终的防线,所有敏感操作必须在此进行二次校验。
- Middleware(中间件) :运行在 Edge Runtime ,在请求到达具体页面或 API 之前执行。它擅长做轻量级 的拦截,如检查 Cookie 是否存在,进行页面重定向。我们用它来实现路由守卫,比如未登录用户访问
4. Cookie + Session 与 JWT 的区别?为什么后台管理系统选前者?
- 回答要点 :(直接复用 Q7 中的对比表格,这是面试官最想听到的结构化回答)重点强调服务端可主动吊销 Session 这一管理后台的核心诉求,以及 HttpOnly Cookie 对 XSS 攻击的防御优势。
5. 数据库迁移(Migration)的工作原理是什么?
- 回答要点 :迁移文件本质上是版本化的 SQL 增量脚本 。Drizzle 会在数据库中维护一张
__drizzle_migrations表,记录已经执行过的迁移文件名。每次运行migrate时,它只执行尚未记录的迁移文件,确保数据库结构按顺序、无重复地演进。这正是"代码即真理,迁移即历史"的工程化实践。
七、结语与互动
通过这个"单词大师"项目,我们完整地实践了一套现代化全栈开发流程。我们从实际痛点出发,选择了最适合的技术组合,并深入剖析了其中关键技术的实现原理和设计思考。从 shadcn/ui 的按需源码加载 ,到 Drizzle 的迁移版本控制 ,再到 Cookie + Session 的权限管理,每一个技术选型背后都对应着明确的业务场景和工程考量。
希望这篇文章能帮助你从"会用"走向"懂原理",在未来的项目开发中更加游刃有余。
抛砖引玉:
- 在你的项目中,你更倾向于使用 Drizzle 还是 Prisma?在处理复杂的关联查询时,你有没有遇到什么坑?
- 对于后台管理系统的权限设计,你是更青睐 Cookie + Session 的有状态方案,还是 JWT 的无状态方案?为什么?
- 你平时在团队中推行过 Git 提交规范吗?有没有遇到过"队友就是不按规范来"的困境,你是如何解决的?
欢迎在评论区分享你的经验和看法!我们下期再见!

给 Agent 的 Prompt 策略 :你可以这样对它说:"现在我有 admin-users 负责管理员,books 负责单词书,words 负责单词。请帮我分析它们之间的外键关系,并给出 Drizzle 的 schema 定义,同时告诉我如何利用这种关系进行级联操作。" Agent 会立刻给你生成包含 references(() => ...) 和 onDelete: 'cascade' 的完美代码。
Q6:为什么 JSON 要转 CSV?数据库只认这个格式吗?
你的疑问:JSON 转 CSV,是因为数据库只支持这两种格式吗?
我的回答 :大错特错! Supabase(PostgreSQL)原生完美支持 JSON 和 JSONB 数据类型,你完全可以直接导入 JSON。
那为什么我们偏要转 CSV? 原因有 3 个:
- 体积更小 :CSV 是纯扁平数据,没有 JSON 里大量的
{}、[]和"key":重复开销。178KB 的 JSON 转成 CSV 可能就剩 80KB,上传速度快一倍。 - 可视化预览:Excel 或 WPS 能直接打开 CSV 查看行列数据,管理员审核数据时,看一眼就知道对不对。JSON 打开就是一团乱麻。
- Supabase 导入 UX :Supabase 后台的"导入 CSV"功能极其丝滑,自动识别表头映射字段,点点鼠标就完成。导入 JSON 则需要写 SQL 的
json_populate_recordset函数,不是每个运营同学都会 SQL。
Q7:Cookie 和 Token(Session)到底什么关系?
你的疑问:项目中用到了 Session,也提到了 Cookie,这俩和 JWT Token 有啥区别?
我的回答 :这是面试最高频的混淆点,我用一句话给你理清:
- Cookie :是载体(存放数据的容器),由浏览器自动管理和携带。
- Session ID :是钥匙(一串随机字符串),指向服务端存储的用户状态。
- Token(特指 JWT) :是自包含的身份证明(里面直接存了用户信息,还带签名)。
| 对比维度 | 基于 Cookie + Session(本项目方案) | 基于 Token(JWT) |
|---|---|---|
| 存储位置 | Session ID 存 Cookie;用户数据存数据库 admin-sessions 表 |
Token 存客户端(LocalStorage / Cookie) |
| 安全性 | 高(HttpOnly Cookie 防 XSS;服务端可随时吊销 Session) | 中(若存 LocalStorage 易被 XSS 窃取;且 JWT 签发后无法主动吊销,只能等过期) |
| 服务端状态 | 有状态(占用数据库/Redis 存储) | 无状态(服务端不存,全靠签名解密) |
| 适用场景 | 后台管理系统(严格控制权限) | 开放 API / 微服务(跨域认证) |
我们选择 Cookie + Session 是因为后台管理系统需要极强的权限管控,系统管理员可以随时"踢人下线",在数据库中删除该 Session 记录即可,JWT 则做不到这一点。
Q8:schema.ts 修改后为何要 push/migrate?云端不是实时的吗?
你的疑问:都连了 Supabase 云端数据库了,为什么改代码表结构还要手动运行命令,不是应该实时同步吗?
我的回答 :如果把 schema.ts 的修改自动实时同步到数据库,那将是整个团队的灾难! 想象一下:
- 你在本地测试,删除了
words表的一个字段,如果实时同步,线上生产环境的几百万条单词数据瞬间丢失一列,且无法恢复。 - 你和同事同时在分支里修改了表结构,自动同步会导致数据库结构被反复覆盖,引发
schema mismatch错误。
正确的做法(版本控制思维) :
db:push:相当于 "强制覆盖" ,只适合开发环境或个人项目,快速迭代。db:generate+db:migrate:相当于 "Git 提交" 。generate生成带时间戳的 SQL 迁移文件(例如0001_heavy_silver_surfer.sql),migrate按顺序执行这些文件。这样,生产环境的数据库是一步一步"进化"来的,即便出错,也可以migrate down回滚。
记住:代码连接云端,不代表代码能随意篡改云端数据。代码是"图纸",数据库是"大楼",改图纸要审批(生成迁移),盖大楼要施工(执行迁移)。
Q9:如何在 Supabase 中开启 RLS?让 Agent 创建表时怎么处理?
你的疑问:RLS 是创建表时的选项吗?我让 Agent 建表,怎么开启?
我的回答 :RLS(行级安全)不是 建表语句 CREATE TABLE 里的一个简单开关。它是表建好后 赋予的策略(Policy) 。
如果让 Agent 帮你开启 RLS,你需要明确告诉它两个步骤:
第一步:启用 RLS(在表上开锁)
sql
sql
ALTER TABLE public.words ENABLE ROW LEVEL SECURITY;
第二步:创建具体的策略(谁可以对哪行数据做什么操作)
sql
vbnet
CREATE POLICY "允许用户查看自己的单词记录" ON public.user_words
FOR SELECT
USING ( auth.uid() = user_id );
给 Agent 的 Prompt 参考:
"请帮我为
admin-users表生成 RLS 策略的 SQL 语句。要求只有拥有SYSTEM_ADMIN角色的用户才能 SELECT 和 UPDATE 这张表,普通管理员无法访问。"
Agent 会直接帮你生成完整的 CREATE POLICY 语句,你只需在 Supabase SQL Editor 里执行即可。当然,也可以直接在 Supabase 后台的 Authentication -> Policies 面板里点击 "Enable RLS" 按钮,然后填入规则,效果相同。
Q10:Conventional Commits(约定式提交)为什么这么香?
你的疑问 :feat、fix、chore 这些提交类型有什么用?不就是加个前缀吗?
我的回答 :这绝不仅仅是加个前缀,这是团队协作的"通用语言" 和自动化发版的基础。
- 快速定位变更 :浏览 Git 日志
git log --oneline,一眼就能看出哪些提交引入了新功能(feat),哪些是修 Bug(fix),哪些是改格式化这种无关紧要的变动(style)。review 代码时,能节约 50% 的心智负担。 - 自动化生成 Changelog :CI/CD 工具(如 standard-version 或 semantic-release)可以扫描 Git 提交记录,自动生成
CHANGELOG.md文件,并且根据feat自动升级 Minor 版本号,根据fix自动升级 Patch 版本号 。完全不用手动改package.json里的 version 字段。 - AI Coding Agent 内置这个规范 :意味着当你对 Agent 说"帮我提交代码"时,它会自动分析你改了啥,替你填上正确的
type,这是 AI 赋能工程化落地的一个极佳典范。
补充类型含义:
| 类型 | 说明 | 例子 |
|---|---|---|
| feat | 新功能(Feature) | feat: 新增单词书搜索功能 |
| fix | 修复 Bug | fix: 修复管理员无法删除单词书的级联问题 |
| docs | 仅文档更改 | docs: 更新 README 环境变量说明 |
| style | 代码格式(不影响运行) | style: 使用 prettier 格式化所有组件 |
| refactor | 代码重构(既不是新功能也不是修 Bug) | refactor: 将鉴权逻辑抽取到 middleware |
| test | 增加或修改测试用例 | test: 增加管理员注册接口的单测 |
| chore | 构建流程、依赖更新、工程化 | chore: 升级 Drizzle ORM 到 0.29.0 |
五、避坑指南与最佳实践
1. 环境变量 .env 管理
- 坑点 :忘记将
DATABASE_URL添加到.env.local,或者在生产环境忘记配置。 - 最佳实践 :在项目根目录创建
.env文件,并立刻将其添加到.gitignore中。在团队协作文档中,提供一个.env.example模板文件。
2. Drizzle ORM 迁移文件(migrations)
- 坑点 :直接修改
schema.ts后忘记运行db:push或db:generate,导致数据库表结构与代码不一致,引发查询错误。 - 最佳实践 :将数据库迁移视为一等公民。每次修改
schema.ts,立即运行npm run db:generate生成迁移文件,并在部署时通过 CI/CD 自动运行npm run db:migrate。开发阶段频繁变更可暂时使用db:push,但上线后务必使用migrate以保证版本可控。
3. shadcn/ui 的定制化
- 坑点 :直接修改
components/ui/button.tsx的源码,导致下次升级shadcn-ui时自定义逻辑丢失。 - 最佳实践 :通过
className传递自定义样式,或者在globals.css中覆盖 CSS 变量。如需大面积修改默认主题,请修改tailwind.config.js中的primary等颜色变量。
4. 管理员注册的逻辑陷阱
- 需求回顾 :系统中如果没有任何管理员,自动跳转
/signup注册"系统管理员";如果已有管理员,则不允许再注册系统管理员。 - 坑点 :如果
/signup页面没有做"是否已有管理员"的校验,可能会导致系统被恶意注册出多个超级管理员。 - 正确实现 :在
/signup页面的Page组件或 API Route 中,在渲染表单或处理提交前,先查询admin-users表。如果数据表非空,直接重定向到/signin,从入口杜绝二次注册。
5. 系统管理员权限保护
- 需求回顾:系统管理员不能修改自己的状态和角色,只能修改他人的信息。
- 坑点:前端界面虽然隐藏了"编辑自己"的按钮,但如果后端接口没有做校验,攻击者依然可以通过直接调用 API 来修改超级管理员的角色。
- 正确实现 :在后端更新接口中,先通过 Session 获取当前操作者的 ID,再与请求参数中的目标用户 ID 比对。如果相同,直接返回
403 Forbidden,从数据源头杜绝越权操作。
六、面试高频考点
1. ORM 的原理是什么?为什么要用 Drizzle 而不是直接写 SQL?
-
回答要点:
- 原理 :ORM(对象关系映射)通过元数据描述对象与数据库表之间的映射关系。当我们调用
db.insert(users).values({ name: 'John' })时,ORM 在底层将其翻译成INSERT INTO users (name) VALUES ('John')的 SQL 语句并执行。 - Drizzle 优势 :相比 Prisma,Drizzle 更加轻量和透明,它不强制
prisma generate,而是在编码时提供完整的 TypeScript 类型提示。它支持"即写即查"的 SQL 风格,学习曲线更平滑,性能损耗更小。在 Next.js 这种全栈场景下,它非常适合作为数据库与应用程序之间的"翻译官"。
- 原理 :ORM(对象关系映射)通过元数据描述对象与数据库表之间的映射关系。当我们调用
2. Supabase 的 RLS(Row Level Security)有什么作用?项目中用到了吗?
-
回答要点:
- 作用 :RLS 是 PostgreSQL 提供的一种安全特性。它允许我们为数据库表定义行级别的访问策略(Policy)。例如,我们可以设置策略,只允许用户查询
created_by字段等于自己 ID 的数据行。 - 项目应用 :在我们的项目中,
words表是公共数据,不需要 RLS。但是,如果未来增加"用户背单词记录"表(如user_words),我们就必须 开启 RLS。配置策略如USING (auth.uid() = user_id),确保即便 API 被恶意调用,数据库层面也能确保用户只能访问自己的数据,这是纵深防御的关键一环。
- 作用 :RLS 是 PostgreSQL 提供的一种安全特性。它允许我们为数据库表定义行级别的访问策略(Policy)。例如,我们可以设置策略,只允许用户查询
3. 解释一下 Next.js 中的 Middleware 和 API Route 在权限控制中的分工。
-
回答要点:
- Middleware(中间件) :运行在 Edge Runtime ,在请求到达具体页面或 API 之前执行。它擅长做轻量级 的拦截,如检查 Cookie 是否存在,进行页面重定向。我们用它来实现路由守卫,比如未登录用户访问
/books时,直接重定向到/signin。 - API Route(API 路由) :运行在 Node.js Runtime ,拥有完整的数据库访问能力。它负责重量级 的权限校验,比如根据请求头中的 Session ID 查询数据库,获取用户角色,并校验是否有权限操作特定资源。核心原则是:API Route 是最终的防线,所有敏感操作必须在此进行二次校验。
- Middleware(中间件) :运行在 Edge Runtime ,在请求到达具体页面或 API 之前执行。它擅长做轻量级 的拦截,如检查 Cookie 是否存在,进行页面重定向。我们用它来实现路由守卫,比如未登录用户访问
4. Cookie + Session 与 JWT 的区别?为什么后台管理系统选前者?
- 回答要点 :(直接复用 Q7 中的对比表格,这是面试官最想听到的结构化回答)重点强调服务端可主动吊销 Session 这一管理后台的核心诉求,以及 HttpOnly Cookie 对 XSS 攻击的防御优势。
5. 数据库迁移(Migration)的工作原理是什么?
- 回答要点 :迁移文件本质上是版本化的 SQL 增量脚本 。Drizzle 会在数据库中维护一张
__drizzle_migrations表,记录已经执行过的迁移文件名。每次运行migrate时,它只执行尚未记录的迁移文件,确保数据库结构按顺序、无重复地演进。这正是"代码即真理,迁移即历史"的工程化实践。
七、结语与互动
通过这个"单词大师"项目,我们完整地实践了一套现代化全栈开发流程。我们从实际痛点出发,选择了最适合的技术组合,并深入剖析了其中关键技术的实现原理和设计思考。从 shadcn/ui 的按需源码加载 ,到 Drizzle 的迁移版本控制 ,再到 Cookie + Session 的权限管理,每一个技术选型背后都对应着明确的业务场景和工程考量。
希望这篇文章能帮助你从"会用"走向"懂原理",在未来的项目开发中更加游刃有余。