8.7 KiB
Context
当前系统采用 NestJS + TypeORM + JWT,User 实体通过 role 字段(admin/operator)和 allowedMenus 字段(JSON 数组)控制前端菜单可见性。后端接口仅使用 JwtAuthGuard 验证 Token 有效性,不做权限校验。需要重构为标准 RBAC 模型,为后续多角色(超管、机构负责人、老师、宿管老师)和数据级权限扩展提供架构基础。
Goals / Non-Goals
Goals:
- 建立 Role、Permission、RolePermission、UserRole 四表 RBAC 数据模型
- 实现
@RequirePermission装饰器 +PermissionGuard,对所有业务接口实施操作级权限校验 - 权限点按
module:action命名规范(如student:create、bill:export),覆盖全部现有功能 - 前端实现路由级和按钮级权限控制
- 提供角色管理、权限一览、用户-角色分配三个管理页面
- 用户可拥有多个角色,权限为所有角色权限的并集
- 现有 admin 用户自动迁移为超管角色,无缝升级
Non-Goals:
- 不实现数据级权限过滤(如「老师只能看本班学生」),需等待机构/班级实体
- 不实现用户级别的直接权限覆盖(所有权限通过角色获得,无需单独增删)
- 不改变现有业务接口的功能逻辑
- 不实现权限变更后的实时推送(刷新页面或重新登录后生效)
Decisions
1. 数据模型设计
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ User │ │ Role │ │ Permission │
├──────────────┤ ├──────────────────┤ ├──────────────┤
│ id │ │ id │ │ id │
│ username │ │ name │ │ code (UK) │
│ passwordHash │ │ description │ │ name │
│ name │ │ isSystem │ │ group │
│ isActive │ │ status │ │ description │
│ ... │ │ created_at │ └──────┬───────┘
└──────┬───────┘ └────────┬─────────┘ │
│ │ │
│ N:M │ M:N │
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐
│ UserRole │ │ RolePermission │
├──────────────┤ ├──────────────────┤
│ userId (FK) │ │ roleId (FK) │
│ roleId (FK) │ │ permissionId(FK) │
└──────────────┘ └──────────────────┘
User 实体变更:
- 移除
role列(原 varchar admin/operator) - 移除
allowedMenus列(原 JSON text) - 新增
@ManyToMany→ Role 关联
为什么不用 JSON 字段存储权限? JSON 字段无法做数据库级的外键约束和联表查询;当需要「某权限被哪些角色拥有」时,JSON 需全表扫描,而关联表只需索引查询。
为什么不用用户-权限直连? 当前阶段不需要。Comet 基本原则是"不实现用户级别的直接权限覆盖",简化模型,所有权限通过角色中转。
2. 权限编码规范
权限点采用 module:action 格式,模块名与现有目录结构一致:
| Group | 权限点示例 |
|---|---|
| dashboard | dashboard:view |
| student | student:view, student:create, student:edit, student:delete, student:import, student:export |
| room | room:view, room:create, room:edit, room:delete |
| occupancy | occupancy:view, occupancy:checkin, occupancy:checkout, occupancy:transfer |
| expense | expense:view, expense:create, expense:edit, expense:delete |
| bill | bill:view, bill:generate, bill:confirm, bill:export-excel, bill:export-pdf, bill:delete |
| deposit | deposit:view, deposit:create, deposit:edit, deposit:delete |
| classroom | classroom:view, classroom:create, classroom:edit, classroom:delete |
| tenant | tenant:view, tenant:create, tenant:edit, tenant:delete |
| classroom-rental | rental:view, rental:create, rental:edit, rental:delete |
| log | log:view |
| user | user:view, user:create, user:edit, user:delete, user:reset-password |
| role | role:view, role:create, role:edit, role:delete |
为什么不在 code 中包含数据范围(如 student:view:class-1)? 数据范围属于数据级权限,当前阶段只做操作级。code 保持纯粹的操作定义,后续通过 RBAC + 数据策略组合实现数据过滤。
3. 预置角色与权限映射
| 角色 | isSystem | 权限范围 |
|---|---|---|
| 超管 (super_admin) | true | 全部权限点(不可删除/禁用的系统角色) |
| 机构负责人 (institution_head) | true | 预留角色,暂分配教室/租赁相关权限 |
| 老师 (teacher) | true | 预留角色,暂分配学生查看权限 |
| 宿管老师 (dormitory_supervisor) | true | 学生、宿舍、入住、费用、账单、押金、日志查看权限(替代原 operator) |
机构负责人和老师角色的大部分实际业务权限点(课表、考勤等)在当前系统中尚不存在,创建为占位角色,后续 change 再补充权限。
4. @RequirePermission 装饰器设计
// 单一权限
@RequirePermission('student:create')
// 任一权限满足(OR)
@RequirePermission('bill:export-excel', 'bill:export-pdf')
// 全部权限满足(AND)
@RequirePermission('bill:view')
@RequirePermission('bill:delete')
替代方案考虑:
- NestJS CASL:功能强大但引入额外依赖和概念负担,对当前需求过度。当前只需简单的「有权限/无权限」二值判断。
- Guards 函数:每个模块写独立 Guard 过于分散,不如装饰器统一声明式管理。
5. JWT Payload 扩展
变更前: { sub, username, role }
变更后: { sub, username, permissions: ["student:view", "bill:export", ...] }
为什么不每次从数据库查询权限? 每次请求都查数据库会造成 N+1 问题和性能开销。权限列表在登录时打入 JWT,JWT 有有效期(默认 24h),角色变更在下次登录时生效。折中方案:对于安全性要求极高的操作(删除/确认账单),可额外在 Controller 层做一次实时校验。
6. 前端权限控制方案
路由级(App.tsx):
<Route path="users" element={
<PermissionRoute permission="user:view"><UsersPage /></PermissionRoute>
} />
菜单级(MainLayout):
菜单项配置 permission 字段,按用户 permissions 过滤
按钮级(各页面):
<PermissionButton permission="student:delete">删除</PermissionButton>
用 display:none 或 disabled 控制
权限判断逻辑封装为 usePermission() hook:
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
hasPermission('student:create') // boolean
7. 迁移策略
数据库迁移脚本(TypeORM migration):
1. 创建 roles, permissions, role_permissions, user_roles 表
2. INSERT 种子数据(4 角色 + ~40 权限点 + 角色-权限关联)
3. 查找 role='admin' 的用户 → INSERT user_roles (userId, super_admin_role_id)
4. 查找 role='operator' 的用户 → INSERT user_roles (userId, dormitory_supervisor_role_id)
(因为宿管老师角色权限与原 operator 允许的菜单最接近)
5. ALTER TABLE users DROP COLUMN role, DROP COLUMN allowed_menus
回滚策略:
- 备份 users 表的 role 和 allowedMenus 值到临时表
- 迁移失败时从备份恢复并 DROP 新表
## Risks / Trade-offs
- **[迁移风险] users 表结构变更破坏现有业务** → 迁移脚本在事务中执行,失败自动回滚;部署前在 staging 环境验证
- **[性能风险] JWT payload 增大** → ~40 个权限点编码为短字符串数组,JWT 体积增加约 500 bytes,在可接受范围内
- **[安全风险] 权限变更在下一次登录才生效** → 对于关键角色变更(如降级用户),提供「强制下线」能力(使当前 JWT 失效);后续可用 Redis 黑名单优化
- **[复杂度风险] 前端现有代码改造面大** → 分步迁移:先建组件(PermissionButton/usePermission),再逐页替换,确保每一步可编译可运行