29 KiB
comet_change, role, canonical_spec, archived-with, status
| comet_change | role | canonical_spec | archived-with | status |
|---|---|---|---|---|
| rbac-refactor | technical-design | openspec | 2026-07-03-rbac-refactor | 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
])
关键改动:
AuthService.login()→ 登录成功后调用RbacService.getUserPermissions(userId),将结果打入 JWT payloadAuthService.initAdmin()→ 移到RbacService.seedData()中,因为超管角色和权限种子数据是 RBAC 层的职责AuthModule需要imports: [RbacModule]或使用forwardRef避免循环依赖- 原
/auth/register、/auth/usersCRUD 全部迁移到RbacController,变为/rbac/users
3.2 循环依赖处理
AuthModule 和 RbacModule 存在双向依赖:
AuthService.login()需要RbacService.getUserPermissions()RbacController的用户 CRUD 接口需要JwtAuthGuard
// 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 装饰器
// 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
// 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 全局注册
// 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()
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
// 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
// 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
// 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 页面变更
// 登录成功后
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 处理
// api/index.ts — axios 拦截器
// 401 → 跳登录(不变)
// 403 → 不跳转,显示 "权限不足" 提示
if (error.response?.status === 403) {
message.error('权限不足');
return Promise.reject(error);
}
6.7 菜单过滤
MainLayout 每项菜单新增 permission 字段:
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 用户管理页面变更
| 变更项 | 原设计 | 新设计 |
|---|---|---|
| 角色列 | 单一 Tag(admin/operator) | 多角色 Tag 列表 |
| 编辑弹窗-角色 | Select 单选(admin/operator) | Select mode="multiple"(所有活跃角色) |
| 编辑弹窗-菜单 | Checkbox.Group(14 项菜单) | 移除 |
| 新增弹窗 | 默认 operator | 必选角色列表 |
| API 端点 | /auth/register、/auth/users |
/rbac/users |
7. 迁移策略
7.1 TypeORM Migration 配置
// 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 迁移脚本步骤
-- 在同一事务中执行
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 回滚策略
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():
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 逻辑)
}
幂等性保证:
- 权限点:
codeUNIQUE 约束 +orIgnore() - 角色:
nameUNIQUE 约束 +orIgnore() - 角色-权限关联:
(roleId, permissionId)联合唯一约束 - 重复启动不会重复插入
9. 测试策略
9.1 单元测试
PermissionGuard(5 用例):
| # | 场景 | 预期 |
|---|---|---|
| 1 | @Public() 装饰的接口 | 放行 |
| 2 | 无装饰器 | 403 |
| 3 | @RequirePermission('a'),用户有 'a' | 放行 |
| 4 | @RequirePermission('a'),用户无 'a' | 403 |
| 5 | @ReqPerm('a') + @ReqPerm('b'),用户有 'a' 无 'b' | 403(AND 不满足) |
RbacService.getUserPermissions(3 用例):
| # | 场景 | 预期 |
|---|---|---|
| 1 | 单角色用户 | 返回该角色全部权限 |
| 2 | 多角色用户 | 返回并集去重 |
| 3 | 无角色用户 | 返回空数组 |
9.2 e2e 测试
| # | 场景 | 步骤 | 预期 |
|---|---|---|---|
| 1 | 超管全通链路 | 登录 → GET /students → POST /students | 全部 200 |
| 2 | 宿管老师受限链路 | 登录 → GET /students → 200;POST /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):
// 嵌套 AND/OR: getAllAndOverride<string[][]> → 返回 [['A'], ['B','C']]
// 外层 AND (every),内层 OR (some)
requiredPermissions.every(group => group.some(p => user.permissions.includes(p)))
实现 (PermissionGuard):
// 扁平 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 命名规范且与模块目录结构一致,实现正确。