forked from wangziqi/gongxue-base
162 lines
8.7 KiB
Markdown
162 lines
8.7 KiB
Markdown
## 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 装饰器设计
|
||
|
||
```typescript
|
||
// 单一权限
|
||
@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:
|
||
```typescript
|
||
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),再逐页替换,确保每一步可编译可运行
|