Better Admin LogoBetter Admin
架构

数据库设计

17 张表如何分域、关键字段怎么设计、权限位存储在哪。

Better Admin 数据模型:17 张表按领域分为五组身份与权限usersrefresh_tokensrolesmenususer_rolesrole_menus组织中心deptspostsuser_posts公告中心noticesnotice_scopesnotice_read_recordsnotice_remind_logs数据字典dict_typesdict_items审计与通知logsnotifications共 17 张表 · 全部由 Drizzle ORM 定义,四端共用

设计原则

原则做法
统一 ORM全部表由 Drizzle ORM 定义,真源是 apps/nest/src/db/schema/ 的 barrel index.ts
服务端生成主键text 主键 + nanoid(),不使用自增整数,避免 ID 可枚举
软删除统一用 deletedAt(timestamptz,可空);唯一约束写成部分唯一索引,只对未删除记录生效
时间戳带时区一律 timestamp(..., { withTimezone: true })updatedAt$onUpdate 自动维护
权限不进关联表权限点不是"行 + 关联表",而是编译期常量 + 整型位掩码,存在桥接表的 permissions 字段上

授权链的核心

RBAC 授权链:用户 → 用户角色 → 角色 → 角色菜单授权位 → 菜单users用户user_roles用户 ↔ 角色roles角色role_menus角色 × 菜单menus菜单树 / 按钮位1 : NN : 11 : NN : 1① 用户 → 角色:一个用户可挂多个角色,权限按「位或」叠加。② 角色 → 菜单:每条「角色 × 菜单」记录持有一个 bigint 权限位掩码。③ 菜单 → 按钮位:菜单声明自身所需权限位,用于门控页面、按钮与 API。

整个 RBAC 只靠三张桥接表/主体表串起来,权限位挂在"角色 × 菜单"这一层,而不是挂在权限行上。

// apps/nest/src/db/schema/role_menus.schema.ts
export const roleMenus = pgTable(
  'role_menus',
  {
    roleId: text('role_id')
      .notNull()
      .references(() => roles.id, { onDelete: 'cascade' }),
    menuId: text('menu_id')
      .notNull()
      .references(() => menus.id, { onDelete: 'cascade' }),
    permissions: bigint('permissions', { mode: 'bigint' })
      .notNull()
      .default(sql`0`),
    createdAt: timestamp('created_at', { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (table) => [
    primaryKey({ columns: [table.roleId, table.menuId] }),
    index('role_menus_role_idx').on(table.roleId),
    index('role_menus_menu_idx').on(table.menuId),
  ],
);

一个 bigint 装下一个菜单上的所有操作

role_menus.permissions 是一个 bigint 位掩码:这一个角色在这一个菜单上被授予了哪些操作,全部按位存在同一个数字里。不需要为每个权限点建一行记录

用户表关键设计

// apps/nest/src/db/schema/users.schema.ts(节选)
export const users = pgTable(
  'users',
  {
    id: text('id').primaryKey().$defaultFn(() => nanoid()),
    username: text('username').notNull(),
    email: text('email').notNull(),
    passwordHash: text('password_hash').notNull(),
    displayName: text('display_name').notNull(),
    // … 组织与档案字段(deptId / employeeNo / employmentStatus / entryDate / gender)
    status: text('status').notNull().default('active'),
    /**
     * 令牌版本号:签发 JWT 时写入 payload(ver claim)。
     * 改密码 / 封禁时 +1,使该用户全部存量 access/refresh token 失效。
     */
    tokenVersion: integer('token_version').notNull().default(0),
    createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { withTimezone: true })
      .notNull()
      .defaultNow()
      .$onUpdate(() => new Date()),
    deletedAt: timestamp('deleted_at', { withTimezone: true }),
  },
  (table) => [
    uniqueIndex('users_username_unique_active')
      .on(table.username)
      .where(sql`${table.deletedAt} is null`),
    uniqueIndex('users_email_unique_active')
      .on(table.email)
      .where(sql`${table.deletedAt} is null`),
  ],
);

两个细节值得留意:

① 部分唯一索引让软删除变得可用。 用户名和邮箱的唯一约束都带着 where deleted_at is null —— 账号被软删之后,原来的用户名和邮箱会被释放,可以被新账号复用。如果写成普通唯一索引,这里就死锁了。

② 一个字段实现全端强制下线。 tokenVersion 会作为 ver 声明写进 JWT。改密码或封禁账号时把它 +1,所有存量 Token 立刻失效。

③ 账号状态与在职状态是正交的两个维度。 statusactive / disabled)管能不能登录,employmentStatusemployed / resigned)是人事属性。

其余分组

分组要点
组织中心depts · posts · user_posts部门是自引用树(parent_id,删除用 restrict);depts.leader_idusers.dept_id 形成循环引用,靠 Drizzle 的 AnyPgColumn 惰性回调声明外键
公告中心notices · notice_scopes · notice_read_records · notice_remind_logs公告本身、发布范围、已读记录、催办记录四张表;已读记录有 (notice_id, user_id) 唯一约束,重复阅读不会插新行
数据字典dict_types · dict_itemsdict_items.type_code 外键指向 dict_types.code;同一类型下 valuelabel 各自唯一
审计与通知logs · notifications日志用 type + action 分类、detail 存 jsonb;用户删除后日志的 user_id 置空(set null)而不是级联删除,审计线索得以保留
会话托管refresh_tokens只存 token_hash(唯一),不存明文;expires_at 控制时效,用户删除时级联清理

跨端一致性约束

数据库只有一份,四个前端共用。因此:

  • Schema、数据模型、业务规则、数据类型、API Contract 五项必须一致,不能因为某个前端框架"顺手"就改结构。
  • 迁移只能从 NestJS 端发起db:generatedb:migrate)。Next.js 端的 db:pull 只做反向内省、生成类型,不产迁移也不执行迁移;Nuxt 端没有数据库脚本。
  • 浏览器端永远不允许直连 PostgreSQL,连接串只存在于服务端环境变量。

本页目录