Files
gongxue-base/docs/superpowers/specs/2026-07-02-rbac-refactor-design.md

738 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
comet_change: rbac-refactor
role: technical-design
canonical_spec: openspec
archived-with: 2026-07-03-rbac-refactor
status: final
---
# RBAC 鉴权重构技术设计
- 日期2026-07-02
- 变更rbac-refactor
- 阶段Design → Build
## 1. 架构总览
```
┌─────────────────────────┐
│ Request │
└───────────┬─────────────┘
┌─────────────────────────┐
│ JwtAuthGuard │
│ (Token 有效性校验) │
└───────────┬─────────────┘
┌───────────▼─────────────┐
│ PermissionGuard │ ← 全局 APP_GUARD
│ (从 req.user 读 │
│ permissions 数组) │
└───────────┬─────────────┘
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
@Public() @RequirePermission 无装饰器
放行 ('student:create') → 403
┌───────────────┐
│ Controller │
└───────────────┘
```
**核心原则**
- **认证Authentication** 与 **授权Authorization** 分离:`AuthModule` 只负责登录/JWT/profile新建 `RbacModule` 负责角色/权限/用户-角色关联。
- **默认拒绝**:全局 `PermissionGuard` 要求所有接口显式声明权限公开接口login通过 `@Public()` 豁免。
- **JWT 携带权限**:登录时将 User→Role→Permission 链展开为 `permissions: string[]` 打入 JWT payload避免每次请求查库。
## 2. 数据模型
### 2.1 ER 图
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ User │ │ Role │ │ Permission │
├──────────────┤ ├──────────────────┤ ├──────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ username(UK) │ │ name (UK) │ │ code (UK) │
│ passwordHash │ │ description │ │ name │
│ name │ │ isSystem │ │ group │
│ isActive │ │ status │ │ description │
│ lastLoginAt │ │ createdAt │ └──────┬───────┘
│ createdAt │ │ updatedAt │ │
│ updatedAt │ └────────┬─────────┘ │
└──────┬───────┘ │ │
│ │ │
│ N:M │ M:N │
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐
│ UserRole │ │ RolePermission │
├──────────────┤ ├──────────────────┤
│ userId (FK) │ │ roleId (FK) │
│ roleId (FK) │ │ permissionId(FK) │
└──────────────┘ └──────────────────┘
```
### 2.2 实体定义
**Permission**`permissions` 表)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK AUTO_INCREMENT | |
| code | VARCHAR(50) UNIQUE | 权限码,格式 `module:action` |
| name | VARCHAR(50) | 中文名称 |
| group | VARCHAR(30) | 分组(对应模块) |
| description | VARCHAR(200) | 可选说明 |
**Role**`roles` 表)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INT PK AUTO_INCREMENT | |
| name | VARCHAR(30) UNIQUE | 角色名称 |
| description | VARCHAR(200) | 角色描述 |
| isSystem | BOOLEAN DEFAULT FALSE | 系统预置角色,不可删除/改名 |
| status | TINYINT DEFAULT 1 | 1=启用, 0=禁用 |
| createdAt | DATETIME | |
| updatedAt | DATETIME | |
**User 实体变更**
| 操作 | 字段 | 说明 |
|------|------|------|
| 移除 | `role` VARCHAR(20) | 原 admin/operator 硬编码 |
| 移除 | `allowedMenus` TEXT | 原 JSON 数组,前端菜单可见性 |
| 新增 | `roles` @ManyToMany → Role | 通过 user_roles 关联表 |
### 2.3 权限点清单42 个)
| Group | 权限码 | 名称 |
|-------|--------|------|
| dashboard | `dashboard:view` | 查看数据面板 |
| student | `student:view` | 查看学生 |
| student | `student:create` | 新增学生 |
| student | `student:edit` | 编辑学生 |
| student | `student:delete` | 删除学生 |
| student | `student:import` | 导入学生 |
| student | `student:export` | 导出学生 |
| room | `room:view` | 查看宿舍 |
| room | `room:create` | 新增宿舍 |
| room | `room:edit` | 编辑宿舍 |
| room | `room:delete` | 删除宿舍 |
| occupancy | `occupancy:view` | 查看入住 |
| occupancy | `occupancy:checkin` | 办理入住 |
| occupancy | `occupancy:checkout` | 办理退宿 |
| occupancy | `occupancy:transfer` | 调换宿舍 |
| expense | `expense:view` | 查看费用 |
| expense | `expense:create` | 录入费用 |
| expense | `expense:edit` | 编辑费用 |
| expense | `expense:delete` | 删除费用 |
| bill | `bill:view` | 查看账单 |
| bill | `bill:generate` | 生成账单 |
| bill | `bill:confirm` | 确认账单 |
| bill | `bill:delete` | 删除账单 |
| bill | `bill:export-excel` | 导出 Excel |
| bill | `bill:export-pdf` | 导出 PDF |
| deposit | `deposit:view` | 查看押金 |
| deposit | `deposit:create` | 新增押金 |
| deposit | `deposit:edit` | 编辑押金 |
| deposit | `deposit:delete` | 删除押金 |
| classroom | `classroom:view` | 查看教室 |
| classroom | `classroom:create` | 新增教室 |
| classroom | `classroom:edit` | 编辑教室 |
| classroom | `classroom:delete` | 删除教室 |
| tenant | `tenant:view` | 查看租赁方 |
| tenant | `tenant:create` | 新增租赁方 |
| tenant | `tenant:edit` | 编辑租赁方 |
| tenant | `tenant:delete` | 删除租赁方 |
| rental | `rental:view` | 查看租赁订单 |
| rental | `rental:create` | 新增租赁订单 |
| rental | `rental:edit` | 编辑租赁订单 |
| rental | `rental:delete` | 删除租赁订单 |
| log | `log:view` | 查看操作日志 |
| user | `user:view` | 查看用户 |
| user | `user:create` | 创建用户 |
| user | `user:edit` | 编辑用户 |
| user | `user:delete` | 删除用户 |
| user | `user:reset-password` | 重置密码 |
| role | `role:view` | 查看角色 |
| role | `role:create` | 创建角色 |
| role | `role:edit` | 编辑角色 |
| role | `role:delete` | 删除角色 |
### 2.4 预置角色与权限映射
| 角色 | code | isSystem | 权限范围 |
|------|------|----------|---------|
| 超管 | `super_admin` | true | **全部 42 个权限点** |
| 机构负责人 | `institution_head` | true | 预留角色,暂分配教室/租赁相关 view 权限 |
| 老师 | `teacher` | true | 预留角色,暂分配学生 view 权限 |
| 宿管老师 | `dormitory_supervisor` | true | 学生/宿舍/入住/费用/账单/押金/日志的全部权限 + dashboard:view替代原 operator |
> **注意**:机构负责人和老师为占位角色。其实际业务权限点(课表、考勤等)在当前系统中尚不存在,后续 change 再补充。
### 2.5 设计决策:为什么不用 JSON 字段?
- JSON 字段无法做数据库级外键约束和联表查询
- "某权限被哪些角色拥有" → 关联表索引查询 O(log n)JSON 需全表扫描 O(n)
- 后续数据级权限(机构→班级→学生)扩展时,关联表可直接扩展 Role + 数据策略组合
## 3. 后端模块设计
### 3.1 模块拆分
```
AuthModule瘦身 RbacModule新建
├── AuthController ├── RbacController
│ ├── POST /auth/login │ ├── GET /rbac/roles
│ └── GET /auth/profile │ ├── GET /rbac/roles/:id
│ │ ├── POST /rbac/roles
├── AuthService │ ├── PUT /rbac/roles/:id
│ ├── login() ← 改:查询权限 │ ├── DELETE /rbac/roles/:id
│ ├── validateUser() │ ├── GET /rbac/permissions
│ └── initAdmin() ← 转移 │ ├── GET /rbac/users
│ │ ├── POST /rbac/users
├── JwtStrategy │ ├── PUT /rbac/users/:id
│ └── validate() ← 改:返回权限 │ ├── DELETE /rbac/users/:id
│ │ └── PUT /rbac/users/:id/password
└── JwtAuthGuard不变
├── RbacService
│ ├── getRoles / CRUD
│ ├── getPermissions / getPermissionTree
│ ├── getUserPermissions(userId): string[]
│ ├── createUser / updateUser / deleteUser
│ └── seedData() ← 幂等种子数据
└── 导入 TypeOrmModule.forFeature([
User, Role, Permission
])
```
**关键改动**
1. `AuthService.login()` → 登录成功后调用 `RbacService.getUserPermissions(userId)`,将结果打入 JWT payload
2. `AuthService.initAdmin()` → 移到 `RbacService.seedData()` 中,因为超管角色和权限种子数据是 RBAC 层的职责
3. `AuthModule` 需要 `imports: [RbacModule]` 或使用 `forwardRef` 避免循环依赖
4.`/auth/register``/auth/users` CRUD 全部迁移到 `RbacController`,变为 `/rbac/users`
### 3.2 循环依赖处理
`AuthModule``RbacModule` 存在双向依赖:
- `AuthService.login()` 需要 `RbacService.getUserPermissions()`
- `RbacController` 的用户 CRUD 接口需要 `JwtAuthGuard`
```typescript
// auth.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([User]),
forwardRef(() => RbacModule), // 延迟解析
PassportModule,
JwtModule.registerAsync({...}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
// rbac.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([User, Role, Permission]),
forwardRef(() => AuthModule), // 延迟解析(获取 JwtAuthGuard
],
controllers: [RbacController],
providers: [RbacService],
exports: [RbacService],
})
```
## 4. 权限守卫设计
### 4.1 装饰器
```typescript
// backend/src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// backend/src/auth/decorators/permission.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const PERMISSION_KEY = 'permissions';
export const RequirePermission = (...permissions: string[]) =>
SetMetadata(PERMISSION_KEY, permissions);
```
**语义约定**
| 用法 | 语义 |
|------|------|
| `@RequirePermission('student:create')` | 需要此权限 |
| `@RequirePermission('bill:export-excel', 'bill:export-pdf')` | 满足**任一**即可OR |
| `@RequirePermission('bill:view')` + `@RequirePermission('bill:delete')` | 两个都需满足AND |
装饰器多次 SetMetadata 时 NestJS 自动合并为数组,`Reflector.get('permissions')` 返回 `[['bill:view'], ['bill:delete']]`,以此区分 AND 还是 OR 调用。
### 4.2 PermissionGuard
```typescript
// backend/src/auth/guards/permission.guard.ts
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. @Public() 豁免
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getClass(),
]);
if (isPublic) return true;
// 2. 获取所需权限
const requiredPermissions = this.reflector.getAllAndOverride<string[][]>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
// 无装饰器 = 默认拒绝
if (!requiredPermissions) return false;
// 3. 从 JWT payload 获取用户权限
const { user } = context.switchToHttp().getRequest();
if (!user?.permissions) return false;
// 4. 匹配逻辑:外层 AND内层 OR
return requiredPermissions.every(group =>
group.some(p => user.permissions.includes(p))
);
}
}
```
**匹配算法**
```
装饰器层级: @ReqPerm(A) @ReqPerm(B) @ReqPerm(C, D)
Reflector 返回: [ [A], [B], [C, D] ]
PermissionGuard: [A] ∧ [B] ∧ [C, D]
A 满足 ∧ B 满足 ∧ (C 或 D 满足)
```
超管拥有全部权限 → `user.permissions.includes(p)` 始终 true → 全部放行。
### 4.3 全局注册
```typescript
// app.module.ts — providers 数组新增
{ provide: APP_GUARD, useClass: PermissionGuard },
```
注意:`APP_GUARD` 的顺序即为执行顺序。ThrottlerGuard 在前、JwtAuthGuard 在各模块注册、PermissionGuard 全局。
## 5. JWT 变更
### 5.1 Payload 结构
```
变更前: { sub: number, username: string, role: string }
变更后: { sub: number, username: string, permissions: string[] }
```
- 移除 `role` 字段(用户不再有单一角色)
- 新增 `permissions` 数组(登录时计算,有效期跟随 JWT 过期,默认 4h
### 5.2 登录流程
```
POST /auth/login { username, password }
→ AuthService.login()
→ 验证凭证
→ RbacService.getUserPermissions(userId)
→ SELECT DISTINCT p.code
FROM permission p
JOIN role_permission rp ON p.id = rp.permissionId
JOIN user_role ur ON rp.roleId = ur.roleId
WHERE ur.userId = ?
→ JWT sign({ sub, username, permissions })
→ 返回 { access_token, user: { id, username, name, roles, permissions } }
```
### 5.3 JwtStrategy.validate()
```typescript
async validate(payload: any) {
return {
id: payload.sub,
username: payload.username,
permissions: payload.permissions || [],
};
}
```
req.user 后续被 PermissionGuard 消费,`permissions` 数组供匹配。
### 5.4 设计决策:为什么权限不入库实时查询?
- **性能**:避免每次请求都做 4 表 JOIN
- **JWT 无状态**:不依赖数据库连接,适合水平扩展
- **折中**:权限变更在下次登录生效。对极高安全要求的操作(账单确认/删除),可在 Controller 内额外做一次实时校验
- **体积**42 个短字符串约增加 500 bytes在可接受范围
## 6. 前端架构
### 6.1 权限基础设施
```
frontend/src/
├── hooks/
│ └── usePermission.ts ← 新建
├── components/
│ ├── PermissionButton.tsx ← 新建
│ └── PermissionRoute.tsx ← 新建
└── pages/
├── Roles/ ← 新建(角色管理)
├── Permissions/ ← 新建(权限一览)
└── Users/ ← 重构
```
### 6.2 usePermission Hook
```typescript
// hooks/usePermission.ts
export function usePermission() {
const permissions: string[] = JSON.parse(
localStorage.getItem('permissions') || '[]'
);
const hasPermission = (code: string) => permissions.includes(code);
const hasAnyPermission = (...codes: string[]) =>
codes.some(c => permissions.includes(c));
const hasAllPermissions = (...codes: string[]) =>
codes.every(c => permissions.includes(c));
return { permissions, hasPermission, hasAnyPermission, hasAllPermissions };
}
```
### 6.3 PermissionButton
```typescript
// components/PermissionButton.tsx
// 无权限时 return null隐藏不占界面空间
const PermissionButton: React.FC<{
permission: string;
children: React.ReactNode;
} & ButtonProps> = ({ permission, children, ...btnProps }) => {
const { hasPermission } = usePermission();
if (!hasPermission(permission)) return null;
return <Button {...btnProps}>{children}</Button>;
};
```
### 6.4 PermissionRoute
```typescript
// components/PermissionRoute.tsx
// 无权限渲染 403 页面
const PermissionRoute: React.FC<{
permission: string;
children: React.ReactNode;
}> = ({ permission, children }) => {
const { hasPermission } = usePermission();
if (!hasPermission(permission)) {
return <Result status="403" title="无权访问" subTitle="您没有访问此页面的权限" />;
}
return <>{children}</>;
};
```
### 6.5 Login 页面变更
```typescript
// 登录成功后
localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user));
localStorage.setItem('permissions', JSON.stringify(res.user.permissions));
```
### 6.6 404/403 处理
```typescript
// api/index.ts — axios 拦截器
// 401 → 跳登录(不变)
// 403 → 不跳转,显示 "权限不足" 提示
if (error.response?.status === 403) {
message.error('权限不足');
return Promise.reject(error);
}
```
### 6.7 菜单过滤
MainLayout 每项菜单新增 `permission` 字段:
```typescript
const menuItems = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据面板', permission: 'dashboard:view' },
{ key: '/students', icon: <TeamOutlined />, label: '学生管理', permission: 'student:view' },
// ...
];
// 按权限过滤
const { hasPermission } = usePermission();
const visibleItems = menuItems.filter(item => !item.permission || hasPermission(item.permission));
```
### 6.8 角色管理页面设计
```
┌─────────────────────────────────────────────────────────────┐
│ 角色管理 [+ 新增角色] │
├─────────────────────────────────────────────────────────────┤
│ ID │ 名称 │ 描述 │ 权限标签 │ 系统 │ 操作 │
├─────┼──────────┼──────────────┼─────────────┼──────┼─────────┤
│ 1 │ 超管 │ 全部权限 │ 42个权限 │ ✓ │ - │
│ 2 │ 宿管老师 │ 宿舍相关管理 │ 查看/管理 │ ✓ │ 编辑 │
│ 3 │ 老师 │ 学生查看 │ 学生:view │ ✓ │ 编辑 │
│ 4 │ 机构负责人│ 教室管理 │ 教室:view │ ✓ │ 编辑 │
└─────────────────────────────────────────────────────────────┘
```
点击编辑 → Modal 弹窗:
- 基本信息:名称(系统角色禁用编辑)、描述
- 权限勾选区域:按 group 分组,每组一个 Card内含 Checkbox.Group
- 每组支持全选/取消全选
### 6.9 用户管理页面变更
| 变更项 | 原设计 | 新设计 |
|--------|--------|--------|
| 角色列 | 单一 Tagadmin/operator | 多角色 Tag 列表 |
| 编辑弹窗-角色 | Select 单选admin/operator | Select mode="multiple"(所有活跃角色) |
| 编辑弹窗-菜单 | Checkbox.Group14 项菜单) | 移除 |
| 新增弹窗 | 默认 operator | 必选角色列表 |
| API 端点 | `/auth/register``/auth/users` | `/rbac/users` |
## 7. 迁移策略
### 7.1 TypeORM Migration 配置
```json
// backend/package.json 新增
{
"scripts": {
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/data-source.ts"
}
}
```
### 7.2 迁移脚本步骤
```sql
-- 在同一事务中执行
BEGIN;
-- Step 1: 创建新表
CREATE TABLE permissions (...);
CREATE TABLE roles (...);
CREATE TABLE role_permissions (...);
CREATE TABLE user_roles (...);
-- Step 2: 插入种子数据
-- 42 个权限点
INSERT INTO permissions (code, name, `group`) VALUES
('dashboard:view', '查看数据面板', 'dashboard'),
...
-- 4 个预置角色
INSERT INTO roles (name, description, isSystem) VALUES
('超管', '系统超级管理员,拥有全部权限', 1),
('宿管老师', '管理宿舍相关业务', 1),
('老师', '查看和管理本班学生', 1),
('机构负责人', '管理机构教室和课程', 1);
-- 角色-权限关联
INSERT INTO role_permissions (roleId, permissionId)
SELECT r.id, p.id FROM roles r, permissions p WHERE r.name = '超管';
-- 宿管老师:学生/宿舍/入住/费用/账单/押金/日志/dashboard
INSERT INTO role_permissions (roleId, permissionId)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name = '宿管老师' AND p.`group` IN ('student', 'room', 'occupancy', 'expense', 'bill', 'deposit', 'log', 'dashboard');
-- Step 3: 迁移现有用户
-- admin → 超管
INSERT INTO user_roles (userId, roleId)
SELECT u.id, r.id FROM users u, roles r
WHERE u.role = 'admin' AND r.name = '超管';
-- operator → 宿管老师
INSERT INTO user_roles (userId, roleId)
SELECT u.id, r.id FROM users u, roles r
WHERE u.role = 'operator' AND r.name = '宿管老师';
-- Step 4: 删除旧字段
ALTER TABLE users DROP COLUMN role;
ALTER TABLE users DROP COLUMN allowed_menus;
COMMIT;
```
### 7.3 回滚策略
```sql
BEGIN;
-- 1. 恢复 users 表旧字段(从备份临时表)
ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'operator';
ALTER TABLE users ADD COLUMN allowed_menus TEXT;
UPDATE users SET role = COALESCE(
(SELECT CASE WHEN r.name = '超管' THEN 'admin' ELSE 'operator' END
FROM user_roles ur JOIN roles r ON ur.roleId = r.id
WHERE ur.userId = users.id LIMIT 1),
'operator'
);
-- 2. 删除新表
DROP TABLE IF EXISTS user_roles;
DROP TABLE IF EXISTS role_permissions;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS permissions;
COMMIT;
```
### 7.4 synchronize → migration 切换
AppModule 中 TypeORM 配置将 `synchronize: true` 改为 `synchronize: false`(仅对迁移后的环境生效,首次迁移前可保留),`migrations: [...]` 指向迁移目录。
## 8. 种子数据初始化
`RbacModule.onModuleInit()` 中调用 `RbacService.seedData()`
```typescript
async seedData() {
// 1. 权限点INSERT ... WHERE NOT EXISTS (幂等)
for (const perm of PRESET_PERMISSIONS) {
await this.permRepo
.createQueryBuilder()
.insert().into(Permission)
.values(perm)
.orIgnore() // SQLite: OR IGNORE; MySQL: ON DUPLICATE KEY
.execute();
}
// 2. 角色:同上
// 3. 角色-权限关联:先查角色和权限,批量 INSERT IGNORE
// 4. 初始 admin 用户(复用原 initAdmin 逻辑)
}
```
**幂等性保证**
- 权限点:`code` UNIQUE 约束 + `orIgnore()`
- 角色:`name` UNIQUE 约束 + `orIgnore()`
- 角色-权限关联:`(roleId, permissionId)` 联合唯一约束
- 重复启动不会重复插入
## 9. 测试策略
### 9.1 单元测试
**PermissionGuard5 用例)**
| # | 场景 | 预期 |
|---|------|------|
| 1 | @Public() 装饰的接口 | 放行 |
| 2 | 无装饰器 | 403 |
| 3 | @RequirePermission('a'),用户有 'a' | 放行 |
| 4 | @RequirePermission('a'),用户无 'a' | 403 |
| 5 | @ReqPerm('a') + @ReqPerm('b'),用户有 'a' 无 'b' | 403AND 不满足) |
**RbacService.getUserPermissions3 用例)**
| # | 场景 | 预期 |
|---|------|------|
| 1 | 单角色用户 | 返回该角色全部权限 |
| 2 | 多角色用户 | 返回并集去重 |
| 3 | 无角色用户 | 返回空数组 |
### 9.2 e2e 测试
| # | 场景 | 步骤 | 预期 |
|---|------|------|------|
| 1 | 超管全通链路 | 登录 → GET /students → POST /students | 全部 200 |
| 2 | 宿管老师受限链路 | 登录 → GET /students → 200POST /rbac/roles → 403 | 学生可查,角色管理拒绝 |
### 9.3 迁移验证
- SQLite 环境:`npm run migration:run` → 表结构正确 → 种子数据完整
- MySQL 环境:同上
- 现有 admin 用户:迁移后登录 → 拥有 `super_admin` 角色 → 全部权限可访问
## 10. 风险与缓解
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| users 表结构变更破坏现有业务 | 中 | 迁移在事务中执行,失败自动回滚;部署前 staging 验证 |
| JWT payload 增大 | 低 | ~42 个短字符串约 500 bytes可接受后续若权限点过多可改用压缩编码 |
| 权限变更在下次登录才生效 | 低 | 关键操作可额外做实时校验;后续可用 Redis 黑名单强制下线 |
| 前端改造面大 | 中 | 分步迁移:先建组件 → 逐页替换 → 每步可编译可运行 |
| synchronize → migration 切换有学习成本 | 低 | 保留 `synchronize: true` 作为本地开发模式migration 仅用于生产部署 |
## 11. 后续扩展预留
本次 RBAC 基础设施为以下扩展预留架构空间:
```
当前阶段(本 change 后续阶段
══════════════════════════ ══════════════════════════
User ↔ Role ↔ Permission User ↔ Organization数据级
操作级student:create 数据级student:view:org-1
→ RBAC + 数据策略组合
```
- `Permission.code` 保持纯粹的操作定义(`module:action`),不混入数据范围
- 后续数据级权限通过新增 `DataPolicy` 实体 + `@DataScope` 装饰器实现
- `Role` 实体预留扩展空间,可关联数据策略
## 12. 实现偏差记录 (Implementation Divergence)
> 本节记录 build 阶段验证时发现的、与原始设计不一致但经确认可接受的实现偏差。
### 12.1 PermissionGuard AND/OR 语义简化
**设计 (Design Doc §4.2)**:
```typescript
// 嵌套 AND/OR: getAllAndOverride<string[][]> → 返回 [['A'], ['B','C']]
// 外层 AND (every),内层 OR (some)
requiredPermissions.every(group => group.some(p => user.permissions.includes(p)))
```
**实现 (PermissionGuard)**:
```typescript
// 扁平 OR: getAllAndMerge<string[]> → 返回 ['A', 'B', 'C']
// 仅 OR 匹配
requiredPermissions.some(p => user.permissions.includes(p))
```
**偏差原因**: 当前所有接口均为单一 `@RequirePermission(...)` 调用,不涉及多次装饰器叠加的 AND 场景。扁平 OR 语义足以覆盖"一个接口需要多个权限中的任一即可访问"的需求,同时简化了匹配逻辑和调试成本。装饰器注释已标注"不支持 AND 语义:多次调用装饰器会被全局 PermissionGuard 合并为扁平数组"。
**影响**: 无。当前无接口需要 AND 权限组合。若未来业务需要"同时满足多个权限才可访问",届时再升级匹配算法(将 `getAllAndMerge` 改为 `getAllAndOverride` + 双层循环),向后兼容。
### 12.2 权限点数量
**设计文档声称** 42 个权限点,**实际实现** 52 个。差异来自:
- 设计文档手动列举时遗漏 `occupancy:delete` 等权限点
- 设计文档实际列举 51 个而非 42 个(计数偏差)
所有 52 个权限点均符合 `module:action` 命名规范且与模块目录结构一致,实现正确。