第一阶段:数据库架构升级 (Database Schema)
我们将引入标准的 RBAC 数据库模型。
- 创建 roles 表 (角色定义)
- 存储角色的元数据。
- 字段:id, name (唯一标识, 如 'admin'), displayName (显示名), description, isSystem (防止删除核心角色), color (用于前端徽章显示)。
- 创建 permissions 表 (权限定义)
- 存储系统中所有可执行的操作。
- 字段:id, slug (唯一标识, 如 'topic:create'), description, category (分组, 如 'forum', 'moderation').
- 创建 role_permissions 表 (角色-权限关联)
- 定义每个角色拥有哪些权限。
- 字段:roleId, permissionId。
- 创建 users_roles 表 (用户-角色关联) ★ 核心变更
- 实现多角色支持。
- 字段:userId, roleId。
- 注:现有的 users.role 字段将保留用于兼容旧逻辑(作为“主角色”或“显示角色”),但权限检查将依赖这张新表。
第二阶段:数据迁移与初始化 (Migration & Seeding)
这是最关键的一步,确保现有数据平滑过渡。
- 编写 Seed 脚本 (apps/api/src/scripts/seed-rbac.js)
- Step 1: 初始化权限池
- 扫描代码逻辑,注册所有权限(例如:file:upload, topic:pin, user:ban, invitation:create 等)。
- Step 2: 初始化基础角色
- 创建 admin (拥有所有权限)。
- 创建 moderator (拥有管理内容权限)。
- 创建 vip (拥有高级功能权限)。
- 创建 user (基础权限)。
- Step 3: 迁移现有用户数据
- 读取 users 表中所有用户。
- 根据用户的 role 字段(字符串),在 users_roles 表中创建对应的记录。
- 结果:原本 role='admin' 的用户,现在会在 users_roles 中有一条关联到 Admin 角色的记录。
- Step 1: 初始化权限池
第三阶段:后端核心改造 (Backend Core)
- 升级认证插件 (apps/api/src/plugins/auth.js)
- 在用户登录/鉴权时,通过 users_roles -> roles -> role_permissions -> permissions 链条查询。
- 将所有权限 Slug 扁平化为一个数组 user.permissions = ['p1', 'p2', ...]。
- 在 request.user 上挂载辅助函数:user.hasRole('admin') 和 user.can('topic:create')。
- 开发角色管理 API (apps/api/src/routes/admin/roles)
- GET /admin/roles: 获取角色列表(含权限)。
- POST /admin/roles: 创建新角色。
- PUT /admin/roles/:id: 修改角色(名称、颜色)。
- PUT /admin/roles/:id/permissions: 分配权限。
- GET /admin/permissions: 获取所有可用权限列表(用于前端勾选)。
第四阶段:业务逻辑替换 (Refactoring)
- 替换硬编码检查
- 查找代码中的 if (user.role === 'admin')。
- 替换为 if (user.can('system:admin')) 或 if (user.hasRole('admin'))。
- 重点修改:posts/index.js (发帖权限), upload/index.js (上传权限), admin/ (后台权限)。
- 重构邀请规则
- 修改 invitation_rules 表,将 role 字段(字符串)改为关联 role_id(外键)。
- 这意味着管理员可以为自己创建的 "超级版主" 角色单独设定邀请码生成规则。
第五阶段:前端对接 (Frontend)
- 管理后台 (apps/web/src/app/dashboard/roles)
- 新增角色管理模块:增删改查角色。
- 权限矩阵界面:一个表格,行是权限,列是角色,通过复选框快速分配权限。
- 用户管理
- 在编辑用户界面,允许通过多选框(Multi-select)修改用户的角色(不再是单选下拉框)。
=====================================
// ============ RBAC (Role-Based Access Control) ============
// ============ Roles (角色) ============
// ============ RBAC: Roles (角色) ============
export const roles = pgTable(
'roles',
{
...$defaults,
name: varchar('name', { length: 50 }).notNull().unique(),
displayName: varchar('display_name', { length: 100 }).notNull(),
description: text('description'),
isSystem: boolean('is_system').notNull().default(false),
isDefault: boolean('is_default').notNull().default(false),
color: varchar('color', { length: 7 }).default('#000000'),
},
(table) => [
index('roles_name_idx').on(table.name),
index('roles_is_system_idx').on(table.isSystem),
index('roles_is_default_idx').on(table.isDefault),
]
);
export const rolesRelations = relations(roles, ({ many }) => ({
users: many(usersRoles),
permissions: many(rolePermissions),
invitationRules: many(invitationRules, { relationName: 'role' }),
}));
// ============ RBAC: Permissions (权限) ============
export const permissions = pgTable(
'permissions',
{
...$defaults,
slug: varchar('slug', { length: 100 }).notNull().unique(),
description: text('description'),
category: varchar('category', { length: 50 }),
},
(table) => [
index('permissions_slug_idx').on(table.slug),
index('permissions_category_idx').on(table.category),
]
);
export const permissionsRelations = relations(permissions, ({ many }) => ({
roles: many(rolePermissions),
}));
// ============ RBAC: Role Permissions (角色权限关联) ============
export const rolePermissions = pgTable(
'role_permissions',
{
roleId: integer('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
permissionId: integer('permission_id')
.notNull()
.references(() => permissions.id, { onDelete: 'cascade' }),
createdAt: $createdAt,
},
(table) => [
unique('role_permissions_role_permission_unique').on(table.roleId, table.permissionId),
index('role_permissions_role_idx').on(table.roleId),
index('role_permissions_permission_idx').on(table.permissionId),
]
);
export const rolePermissionsRelations = relations(rolePermissions, ({ one }) => ({
role: one(roles, {
fields: [rolePermissions.roleId],
references: [roles.id],
}),
permission: one(permissions, {
fields: [rolePermissions.permissionId],
references: [permissions.id],
}),
}));
// ============ RBAC: User Roles (用户角色关联) ============
export const usersRoles = pgTable(
'users_roles',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
roleId: integer('role_id')
.notNull()
.references(() => roles.id, { onDelete: 'cascade' }),
createdAt: $createdAt,
},
(table) => [
unique('users_roles_user_role_unique').on(table.userId, table.roleId),
index('users_roles_user_idx').on(table.userId),
index('users_roles_role_idx').on(table.roleId),
]
);
export const usersRolesRelations = relations(usersRoles, ({ one }) => ({
user: one(users, {
fields: [usersRoles.userId],
references: [users.id],
}),
role: one(roles, {
fields: [usersRoles.roleId],
references: [roles.id],
}),
}));
// ============ Invitation Rules (邀请规则) ============
export const invitationRules = pgTable(
'invitation_rules',
{
...$defaults,
role: varchar('role', { length: 20 }).default('user'), // Legacy field for backward compatibility
roleId: integer('role_id').references(() => roles.id, {
onDelete: 'set null',
}),
dailyLimit: integer('daily_limit').notNull().default(1),
maxUsesPerCode: integer('max_uses_per_code').notNull().default(1),
expireDays: integer('expire_days').notNull().default(30),
pointsCost: integer('points_cost').notNull().default(0),
isActive: boolean('is_active').notNull().default(true),
},
(table) => [
index('invitation_rules_role_idx').on(table.role),
index('invitation_rules_role_id_idx').on(table.roleId),
index('invitation_rules_is_active_idx').on(table.isActive),
]
);
export const invitationRulesRelations = relations(invitationRules, ({ one }) => ({
role: one(roles, {
fields: [invitationRules.roleId],
references: [roles.id],
relationName: 'role',
}),
}));
=====================
RBAC 架构重构最终方案 (RBAC Refactoring Master Plan)
- 数据库架构升级 (apps/api/src/db/schema.js) 采用多角色(Many-to-Many)模型,并保留兼容性。
- 新增表:
- roles: 角色定义(id, name, displayName, description, isSystem, isDefault)。
- permissions: 权限池(id, slug, description, category)。
- rolePermissions: 角色-权限关联表。
- usersRoles: 用户-角色关联表(userId, roleId)。
- 修改表:
- invitationRules: 将 role 字段(字符串)改为 roleId(外键),或保留 role 字符串但改为存储 roles.name 以保持逻辑一致性(建议使用外键 roleId 更稳健)。
- users: 保留 role 字段用于向后兼容(逻辑上作为“主显示角色”),但在鉴权时优先使用 usersRoles 表的数据。
- 初始化脚本与数据迁移 (apps/api/src/scripts/init/) 参考 apps/api/src/scripts/init/index.js 的模块化结构。
- 新建 apps/api/src/scripts/init/rbac.js:
- 定义 DEFAULT_PERMISSIONS: 按资源分类(如 topic:read, topic:create, topic:edit, topic:delete, topic:pin, ui:admin_dashboard 等)。
- 定义 DEFAULT_ROLES: Admin, Moderator, User, VIP。
- 实现 initRBAC(db):
- 同步权限池(确保代码中定义的新权限写入数据库)。
- 创建/更新默认角色。
- 为默认角色分配默认权限。
- (关键) 迁移旧用户数据:扫描 users 表,根据 role 字段在 usersRoles 表中创建对应记录。
- 更新 apps/api/src/scripts/init/index.js:
- 引入并调用 initRBAC,确保执行 npm run seed 时自动应用。
- 后端核心鉴权 (apps/api/src/plugins/auth.js)
- User Object 增强:
- 登录/获取用户信息时,执行 SQL Join 查询该用户的所有角色及对应权限。
- 将权限扁平化并注入到 request.user 对象中,例如: request.user.permissions = ['topic:read', 'topic:create', 'file:upload']; request.user.roles = ['admin', 'vip']; // 角色名列表 * 输出到前端: 确保 /api/auth/me 或用户信息接口返回 permissions 数组,以便前端使用。
- 后台管理 API (apps/api/src/routes/admin/) 遵循 RESTful 规范,并参考 captcha/index.js 使用 preHandler 进行权限守卫。
- 路径: apps/api/src/routes/admin/roles/index.js
- 守卫:
// 示例 preHandler
const requireAdminPermission = async (req, reply) => {
if (!req.user.permissions.includes('sys:manage_roles')) {
throw reply.forbidden('需要角色管理权限');
}
};
- 接口:
- GET /: 获取角色列表(含权限统计)。
- POST /: 创建新角色。
- GET /:id: 获取单个角色详情(含已分配权限)。
- PUT /:id: 更新角色信息。
- DELETE /:id: 删除角色(系统角色除外)。
- POST /:id/permissions: 更新该角色的权限集合。
- 路径: apps/api/src/routes/admin/permissions/index.js
- GET /: 获取所有系统可用权限(用于前端构建“权限矩阵”复选框)。
- 前端权限控制 (apps/web)
- 获取权限:
- 用户登录后,API 返回的用户信息应包含 permissions: []。
- 前端 Store (如 Zustand 或 Context) 存储此权限列表。
- UI 组件控制:
- 封装 <PermissionGuard slug="topic:pin"> 组件或使用 hook usePermission('topic:pin')。
- 改造 TopicSidebar.jsx:
- 旧逻辑: if (user.role === 'admin' || user.role === 'moderator')
- 新逻辑: if (user.permissions.includes('topic:pin'))
- 这样,如果你新建了一个“实习版主”角色并赋予了置顶权限,该按钮会自动对该角色可见。