权限系统的四道防线:从数据库事务锁到操作级保护规则的完整设计

权限系统的四道防线:从数据库事务锁到操作级保护规则的完整设计

一个只给管理员用的后台系统,最要紧的代码往往不是界面,而是两句话的答案:谁能进得来,谁进来之后能干什么。 本文用 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.idonDelete: "cascade"------管理员被删,它的所有会话级联清空,这是"删人即下线"的数据库级保证。
  • tokenHash 是会话令牌的哈希(唯一键)------后面详解为什么库中存哈希不存令牌本身。
  • userIdexpiresAt 各建索引------按用户查会话、按过期时间清理会话是两条高频查询路径。

三、防线一:事务锁保证"超级管理员只初始化一次"

一个后台总得有第一个管理员。常见做法是注册页面谁都能来,第一个注册的人自动成为超级管理员。但这里藏着一个并发问题:两个请求同时来注册怎么办?如果没有保护,两个人都可能被当成"第一个",系统就出现了两个超管。

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.coma@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 哈希 数据库泄露不会直接暴露可用令牌
单会话策略 新令牌插入前删除旧会话 后登录踢掉先登录,符合后台安全直觉
属性 作用
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 拒绝。把一次性初始化决策下沉到数据库事务里,从根上消除竞态。

回答框架

令牌是 randomBytes(32) 生成的随机串,本身不含用户信息。数据库只存它的 SHA-256 哈希,防止"数据库泄露直接变成账号泄露"------攻击者拿到的是哈希而非可用令牌。这是 OWASP 对服务端会话存储的标准建议。验证时把 Cookie 令牌哈希后查 token_hash,再校验 expires_at > nowstatus = active 三个条件。

Q4:401 和 403 分别代表什么?

回答框架

401 表示"未登录"------getCurrentUser() 返回 null(无令牌、令牌无效、会话过期或账号停用)。403 表示"登录了但权限不够"------接口要求超管,当前用户只是内容管理员。一个是身份问题,一个是授权问题。

Q5:PATCH 管理员的四条保护规则是什么?

回答框架

因为管理员是唯一有权限管理管理员的人,必须防止误操作把系统锁死:

  1. 不能降级本人------否则失去管理能力
  2. 不能停用本人------等于把自己锁在门外
  3. 不能把别人提升为超管------防止无权提权
  4. 必须保留至少一个启用的超管------防止"最后一个超管被移除"导致无人能登后台

第四条需要额外查库确认系统里还有没有可用的超管,是全局兜底。

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 无权限,接口按需放行
管理保护   不能降级/停用自己、不能提权他人、必须保留最后一个超管
下线联动   改密码/停用 → 删除全部会话,立即失效

权限系统的本质,是把"谁能进、能干什么"从口头约定变成代码约束:

  • 数据库枚举挡住非法取值
  • 事务锁挡住并发竞态
  • 令牌哈希挡住盗库变现
  • 四道保护规则挡住最后一个超管被误删

把这些防线写进对应层,一个后台才算真正"锁好了门"。

相关推荐
PedroQue9944 分钟前
@meng-xi/vite-plugin v1.3.0:generateUni 一键流水线
前端·vite
IMPYLH1 小时前
HTML 的 <legend> 元素
java·前端·html
今日无bug1 小时前
从同步阻塞到 async/await:一文梳理 JS 异步流程控制的进化史
javascript·promise
真夜1 小时前
RN热更新安全问题
前端·react native
Zeroplucky1 小时前
Binder 线程池耗尽案例:持锁同步调用 VHAL 导致 SystemUI 卡顿或 ANR
前端
xiaominlaopodaren1 小时前
three.js最小地图运行时(八): 瓦片调试网格
javascript·gis·three.js
Zeroplucky1 小时前
从 registerContentObserver 剖析 Android Binder 流程
前端
Yinlin1241 小时前
深入悬浮组件实现
前端
yangzheui1 小时前
nvue页面事件穿透到下层元素解决办法
前端