Files
gongxue-base/openspec/changes/rbac-refactor/design.md

8.7 KiB
Raw Blame History

Context

当前系统采用 NestJS + TypeORM + JWTUser 实体通过 role 字段admin/operatorallowedMenus 字段JSON 数组)控制前端菜单可见性。后端接口仅使用 JwtAuthGuard 验证 Token 有效性,不做权限校验。需要重构为标准 RBAC 模型,为后续多角色(超管、机构负责人、老师、宿管老师)和数据级权限扩展提供架构基础。

Goals / Non-Goals

Goals:

  • 建立 Role、Permission、RolePermission、UserRole 四表 RBAC 数据模型
  • 实现 @RequirePermission 装饰器 + PermissionGuard,对所有业务接口实施操作级权限校验
  • 权限点按 module:action 命名规范(如 student:createbill: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 问题和性能开销。权限列表在登录时打入 JWTJWT 有有效期(默认 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

回滚策略:

  1. 备份 users 表的 role 和 allowedMenus 值到临时表
  2. 迁移失败时从备份恢复并 DROP 新表

## Risks / Trade-offs

- **[迁移风险] users 表结构变更破坏现有业务** → 迁移脚本在事务中执行,失败自动回滚;部署前在 staging 环境验证
- **[性能风险] JWT payload 增大** → ~40 个权限点编码为短字符串数组JWT 体积增加约 500 bytes在可接受范围内
- **[安全风险] 权限变更在下一次登录才生效** → 对于关键角色变更(如降级用户),提供「强制下线」能力(使当前 JWT 失效);后续可用 Redis 黑名单优化
- **[复杂度风险] 前端现有代码改造面大** → 分步迁移先建组件PermissionButton/usePermission再逐页替换确保每一步可编译可运行