权限系统的四道防线:从数据库事务锁到操作级保护规则的完整设计
一个只给管理员用的后台系统,最要紧的代码往往不是界面,而是两句话的答案:谁能进得来,谁进来之后能干什么。 本文用 Next.js 16 + Supabase + Drizzle 从零搭了一个"单词管理后台",把这两件事落到了一条完整的链路上:Supabase 这种 BaaS 数据库让"数据库 + 部署"的开销趋近于零,Drizzle ORM 让"建表"变成写一个 TypeScript 对象,而认证和权限则靠三块设计撑起来------首个账号用数据库事务锁安全地初始化成超级管理员、会话令牌只在 Cookie 里放随机串而数据库只存它的哈希、以及更新接口里四道"防误操作"的管理员保护规则。
一、技术底座:为什么选择 BaaS + ORM
后台系统的第一块地基是数据。这个项目没有自建 MySQL,也没有自己写建表 SQL,而是用了两样东西把"数据层"的成本压到最低。
1.1 Supabase:云端数据库即服务
"性能、安全、可扩展性、部署成本几乎为 0。"
Supabase 提供的是云端的 PostgreSQL 数据库。对后台项目来说,这意味着数据库在云端已经建好,本地只需要在 .env 里配一行 DATABASE_URL 连接字符串,不用自己买机器、配权限、做备份。
1.2 Drizzle:TypeScript 优先的 ORM
ORM 解决的核心问题是**"不用写 SQL、不用做数据库的底层处理"**。看 lib/db.ts,整个连接只有几行:
typescript
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL!;
export const client = postgres(connectionString, { max: 1 });
export const db = drizzle(client);
Postgres 是驱动,drizzle(client) 把连接包装成 db 这个数据库操作句柄。之后业务代码里 db.select(...)、db.insert(...) 都是在操作"对象",由 Drizzle 翻译成 SQL。
高级语言里的 User 对象,对应数据库里低级的 users 表的一行记录。
Drizzle 配套的命令构成了建表到上线的完整闭环:
| 命令 | 作用 |
|---|---|
db:generate |
根据 schema 生成迁移文件 |
db:migrate |
执行数据库迁移 |
db:push |
把 schema 推送到云端数据库 |
db:studio |
数据库可视化工具 |
"建表"从手写 CREATE TABLE 变成了在 schema 文件里声明结构,再跑迁移命令。
二、两张表:身份与会话
后台的权限模型需要两张表:一张存管理员身份,一张存会话。它们在 lib/schema.ts 里用 Drizzle 的对象写法定义。
2.1 admin-users:身份表
typescript
export const userRole = pgEnum("user_role", ["super_admin", "content_admin"]);
export const userStatus = pgEnum("user_status", ["active", "disabled"]);
export const users = pgTable("admin-users", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
role: userRole("role").notNull().default("content_admin"),
status: userStatus("status").notNull().default("active"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
设计要点:
pgEnum定义角色和状态 :用数据库枚举而不是自由文本,让"角色、状态这类有限集合"在数据库层面就不能写错------这是约束,不只是约定。email唯一键:一个邮箱只能注册一个管理员,登录时按邮箱查也正好用上这个唯一索引。- 默认值兜底 :新管理员默认
content_admin+active------没有显式声明,就进不了特权。 passwordHash不存密码明文:这一列放的是 bcrypt 哈希。
2.2 admin-session:会话表
typescript
export const sessions = pgTable("admin-session", {
id: uuid("id").defaultRandom().primaryKey(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
tokenHash: text("token_hash").notNull().unique(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index("admin_session_user_id_idx").on(table.userId),
index("admin_session_expires_at_idx").on(table.expiresAt)
]);
三个设计对应三种语义:
userId外键指向users.id,onDelete: "cascade"------管理员被删,它的所有会话级联清空,这是"删人即下线"的数据库级保证。tokenHash是会话令牌的哈希(唯一键)------后面详解为什么库中存哈希不存令牌本身。- 为
userId和expiresAt各建索引------按用户查会话、按过期时间清理会话是两条高频查询路径。
三、防线一:事务锁保证"超级管理员只初始化一次"
一个后台总得有第一个管理员。常见做法是注册页面谁都能来,第一个注册的人自动成为超级管理员。但这里藏着一个并发问题:两个请求同时来注册怎么办?如果没有保护,两个人都可能被当成"第一个",系统就出现了两个超管。
3.1 竞态条件的发生场景
css
请求 A:查询 users 表 → 空(准备创建)
请求 B:查询 users 表 → 空(也准备创建)
请求 A:插入第一个超管 ✅
请求 B:插入第二个超管 ✅ ← 灾难!
3.2 解决方案:PostgreSQL 咨询锁
typescript
const user = await db.transaction(async (tx) => {
// 获取咨询锁(事务级),同一把锁同时只允许一个事务持有
await tx.execute(sql`select pg_advisory_xact_lock(392017)`);
const existing = await tx.select({ id: users.id }).from(users).limit(1);
if (existing.length) return null;
const [created] = await tx.insert(users).values({
name, email, passwordHash, role: "super_admin"
}).returning();
return created;
});
if (!user) {
return NextResponse.json(
{ error: "系统已完成初始化,请直接登录" },
{ status: 409 }
);
}
pg_advisory_xact_lock 的语义:
- 同一把咨询锁同时只允许一个事务持有。
- 其他事务必须等它提交或回滚才能拿到。
- 把"查有没有用户 → 没有就插入"放进同一个事务、并先抢咨询锁,就能保证:
- 两个并发的注册请求,只有一个能拿到锁进入"检查是否已有用户"。
- 拿到锁的那个发现表是空的,插入超级管理员,提交事务。
- 另一个等锁释放后再查,发现已经有用户了,走
return null分支。
"先检查再写入"这个竞态窗口,被数据库级锁关死了。
3.3 其他安全细节
- bcrypt 哈希密码 :
hash(password, 12),cost 12 是安全与性能之间的折中。 - 首个账号角色显式指定为
super_admin:不依赖默认值(默认是content_admin)。 - 邮箱规范 :
normalizeEmail做了 trim + toLowerCase,避免A@B.com和a@b.com被当成两个账号。 - 注册成功后再
createSession(user.id):用户不用二次登录。
四、防线二:会话令牌的哈希存储
初始化之后就是日常登录。这套会话系统在 lib/auth.ts 里。
4.1 令牌签发
typescript
export const SESSION_COOKIE = "danci_session";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 天
function digest(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string) {
const store = await cookies();
const currentToken = store.get(SESSION_COOKIE)?.value;
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE * 1000);
await db.transaction(async (tx) => {
// 单会话策略:新登录挤掉旧登录
if (currentToken) {
await tx.delete(sessions).where(eq(sessions.tokenHash, digest(currentToken)));
}
await tx.insert(sessions).values({
userId,
tokenHash: digest(token),
expiresAt
});
});
store.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SESSION_MAX_AGE,
});
}
4.2 三层设计
| 层次 | 设计 | 解决的问题 |
|---|---|---|
| 令牌本身 | randomBytes(32) 随机串,仅出现在 Cookie 中 |
不包含用户信息,是"指向数据库某行会话的钥匙" |
| 数据库存储 | 只存 SHA-256 哈希 |
数据库泄露不会直接暴露可用令牌 |
| 单会话策略 | 新令牌插入前删除旧会话 | 后登录踢掉先登录,符合后台安全直觉 |
4.3 Cookie 属性
| 属性 | 作用 |
|---|---|
httpOnly: true |
JS 读不到,防 XSS 偷令牌 |
sameSite: "lax" |
跨站请求默认不带 Cookie,防 CSRF |
secure: true(生产) |
只允许 HTTPS 传输 |
maxAge: 7 天 |
有效期,对齐数据库 expires_at |
4.4 会话验证
typescript
export async function getCurrentUser(): Promise<SafeUser | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
const [row] = await db.select({
id: users.id, name: users.name, email: users.email,
role: users.role, status: users.status,
}).from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(and(
eq(sessions.tokenHash, digest(token)), // 令牌匹配
gt(sessions.expiresAt, new Date()), // 未过期
eq(users.status, "active"), // 账号启用
))
.limit(1);
return row ?? null;
}
三个条件一次校验: 令牌哈希匹配、会话未过期、账号仍是启用状态。任何一个不满足,都返回 null(视为未登录)。
一个值得记住的取舍: 令牌是随机的,所以服务端必须查数据库才能验证。代价是"每个请求一次数据库查询",换来的是"随时可以精准吊销某一个人的会话"。后台管理员数量级很小,这个代价完全可以接受。
五、防线三:权限守卫(401 vs 403)
有了登录态,接下来是"谁能调哪个接口"。lib/api-auth.ts 把鉴权收敛成一个函数:
typescript
import "server-only";
import { getCurrentUser } from "@/lib/auth";
export async function requireApiUser(superAdmin = false) {
const user = await getCurrentUser();
if (!user) {
return { error: NextResponse.json({ error: "请先登录" }, { status: 401 }) };
}
if (superAdmin && user.role !== "super_admin") {
return { error: NextResponse.json({ error: "没有操作权限" }, { status: 403 }) };
}
return { user };
}
它把三种状态映射成两种响应:
| 状态 | 响应码 | 含义 |
|---|---|---|
| 未登录 | 401 | "请先登录"------身份问题 |
| 登录了但权限不够 | 403 | "没有操作权限"------授权问题 |
业务路由开头统一使用:
typescript
const auth = await requireApiUser(true);
if (auth.error) return auth.error;
一行就把"这条路只有超管能走"立住了。
六、防线四:更新管理员的四道保护规则
最有意思的部分在 PATCH 接口------更新一个管理员。它在一个事务里先锁、再查、再算,最后落库,把"防误操作"做成了四条保护规则。
typescript
const isSelf = auth.user.id === id;
// 规则一:不能降级自己
if (isSelf && existing.role === "super_admin" && role !== "super_admin") {
return { error: "cannot_demote_self" as const };
}
// 规则二:不能停用自己
if (isSelf && existing.status === "active" && status === "disabled") {
return { error: "cannot_disable_self" as const };
}
// 规则三:不能把别人提升为超管
if (!isSelf && existing.role !== "super_admin" && role === "super_admin") {
return { error: "cannot_promote" as const };
}
// 规则四:必须至少保留一个启用的超管
const removesEnabledSuperAdmin =
existing.role === "super_admin" &&
existing.status === "active" &&
(role !== "super_admin" || status !== "active");
if (removesEnabledSuperAdmin) {
const anotherEnabledSuperAdmin = await tx.select({ id: users.id })
.from(users)
.where(and(
eq(users.role, "super_admin"),
eq(users.status, "active"),
ne(users.id, id),
))
.limit(1);
if (!anotherEnabledSuperAdmin.length) {
return { error: "last_super_admin" as const };
}
}
四条规则,每一条都对应一个真实的事故场景:
| 规则 | 错误码 | 防止的事故 |
|---|---|---|
| 不能降级自己 | cannot_demote_self |
超管一个手滑把自己降级,没人能管理管理员 |
| 不能停用自己 | cannot_disable_self |
管理员亲手把自己锁在门外 |
| 不能把别人提升为超管 | cannot_promote |
内容管理员拿到提权接口后自我提权 |
| 必须保留至少一个超管 | last_super_admin |
最后一个超管被降级/停用,系统无人能登后台 |
第四条最容易被忽略,也最重要。 它单独算了一笔账:如果这次修改会让"一个当前启用的超管"变成"非超管或停用",就再去查表里还有没有另一个启用状态的超管;一个都没有,就拒绝。
这四条规则的本质:管理员是唯一有权限删管理员的人,那么谁来保护"管理员"这个集合本身?答案是把保护写进更新接口的事务里。
6.1 下线联动
typescript
// 改密码或停用 → 删除该用户所有会话
if (passwordHash || status === "disabled") {
await tx.delete(sessions).where(eq(sessions.userId, id));
}
- 改密码:旧密码签发的令牌全部作废,防止泄露的会话继续有效。
- 停用账号 :配合
getCurrentUser里的eq(users.status, "active"),停用一个人 = 会话立刻无效 + 数据库行删除,双保险。
6.2 前端双重锁定
前端同样把这些规则"锁死"在 UI 层:
- 编辑自己的账号时,角色下拉只有"超级管理员"一个选项,状态下拉只有"启用"一个选项。
- 新增管理员固定为内容管理员。
后端规则 + 前端选项双写,即便前端被绕过,PATCH 接口的四条规则仍然拦得住。
七、工程实践:数据清洗与组件库
7.1 大文件数据清洗
从 GitHub 下载的单词资料库是一个 178KB 的 JSON 文件,想把它导入数据库。
错误做法:把 JSON 内容直接丢给 AI。
178KB 进 AI 上下文,token 开销太大。
正确做法:让 AI 写一段格式转换脚本(脚本本身很小),本地运行脚本完成 JSON → CSV(或 SQL)的转换,再把产物导入数据库。
大文件不要整个塞进模型上下文,而是让模型生成处理它的代码。
AI 只负责写"怎么做"的几十行代码,成本差几个数量级。
7.2 组件库选型
后台管理系统 80% 的组件(表格、弹窗、输入框、下拉)在不同业务里长得几乎一样:
80% 前端组件业务趋同,不用重复造轮子,选用第三方组件库。
项目选的是 shadcn/ui,理由:定制性很好、配合 TailwindCSS、语义化、AI 友好、按需加载。
把样式和可访问性交给成熟组件,把注意力留给真正属于业务的部分。
7.3 提交规范
采用 Conventional Commits(约定式提交):
| 类型 | 用途 |
|---|---|
feat |
新增功能 |
fix |
修复 bug |
docs |
文档变更 |
refactor |
代码重构 |
style |
样式变更 |
test |
测试变更 |
chore |
构建工具变更 |
一条提交只干一件事,类型前缀让人一眼看懂这次提交改变了什么。
八、面试高频问题与答题框架
Q1:BaaS 和 ORM 分别解决了什么问题?
回答框架:
BaaS(如 Supabase)把数据库、鉴权、存储作为云服务提供,部署成本几乎为 0,本地只需要一个连接串。ORM(如 Drizzle)让开发者不用写 SQL,用 TypeScript 对象操作数据库------高级语言里的 User 对象映射到数据库 users 表的一行记录,建表变成写 schema 对象,配合 generate/migrate/push 命令完成建表、迁移和推送。
Q2:为什么用 pg_advisory_xact_lock 初始化超级管理员?
回答框架:
"先检查表里有没有用户,没有就插入"存在竞态窗口:两个并发请求可能同时读到"空表",然后各自插入,产生两个超管。pg_advisory_xact_lock 是 PostgreSQL 咨询锁,同一把锁同时只允许一个事务持有。把"检查 + 插入"放进同一个事务并先抢锁,第二个请求必须等第一个提交后才查得到已有用户,从而走 409 拒绝。把一次性初始化决策下沉到数据库事务里,从根上消除竞态。
Q3:为什么 Cookie 存随机令牌,数据库只存哈希?
回答框架:
令牌是 randomBytes(32) 生成的随机串,本身不含用户信息。数据库只存它的 SHA-256 哈希,防止"数据库泄露直接变成账号泄露"------攻击者拿到的是哈希而非可用令牌。这是 OWASP 对服务端会话存储的标准建议。验证时把 Cookie 令牌哈希后查 token_hash,再校验 expires_at > now 和 status = active 三个条件。
Q4:401 和 403 分别代表什么?
回答框架:
401 表示"未登录"------getCurrentUser() 返回 null(无令牌、令牌无效、会话过期或账号停用)。403 表示"登录了但权限不够"------接口要求超管,当前用户只是内容管理员。一个是身份问题,一个是授权问题。
Q5:PATCH 管理员的四条保护规则是什么?
回答框架:
因为管理员是唯一有权限管理管理员的人,必须防止误操作把系统锁死:
- 不能降级本人------否则失去管理能力
- 不能停用本人------等于把自己锁在门外
- 不能把别人提升为超管------防止无权提权
- 必须保留至少一个启用的超管------防止"最后一个超管被移除"导致无人能登后台
第四条需要额外查库确认系统里还有没有可用的超管,是全局兜底。
Q6:改密码/停用后为什么要删除全部会话?
回答框架:
改密码后旧会话令牌仍然有效,删除全部会话让旧密码签发的令牌立即作废,防止密码泄露前的会话继续使用。停用同理。同时 getCurrentUser 里还有 status = active 校验,即使会话没删,停用的账号也查不出用户。数据库行删除 + 查询条件校验双保险。
Q7:178KB 的 JSON 为什么不直接给 AI 处理?
回答框架:
178KB 进 AI 上下文,token 开销太大。正确做法是让 AI 写一段体积很小的格式转换脚本,本地运行完成 JSON → CSV 的转换,再导入数据库。AI 只负责生成"怎么做"的代码,不消费大文件本身,成本差几个数量级。大文件一律"生成处理它的代码",而不是把它塞进上下文。
九、结语:从一张空表到一套能拦住误操作的权限体系
这个后台权限系统,核心是这条链路:
sql
技术底座 Supabase(BaaS)+ Drizzle(ORM,schema 即建表)
数据模型 admin-users 存身份,admin-session 存会话,外键级联 + 索引
首次初始化 pg_advisory_xact_lock 事务锁 → 只产生一个超级管理员
会话签发 随机令牌进 Cookie,SHA-256 哈希进数据库,单会话 + 7 天过期
会话验证 tokenHash 匹配 + 未过期 + 账号启用,三条件 JOIN 一次查
权限守卫 requireApiUser:401 未登录 / 403 无权限,接口按需放行
管理保护 不能降级/停用自己、不能提权他人、必须保留最后一个超管
下线联动 改密码/停用 → 删除全部会话,立即失效
权限系统的本质,是把"谁能进、能干什么"从口头约定变成代码约束:
- 数据库枚举挡住非法取值
- 事务锁挡住并发竞态
- 令牌哈希挡住盗库变现
- 四道保护规则挡住最后一个超管被误删
把这些防线写进对应层,一个后台才算真正"锁好了门"。