--- 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(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); if (isPublic) return true; // 2. 获取所需权限 const requiredPermissions = this.reflector.getAllAndOverride( 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 ; }; ``` ### 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 ; } 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: , label: '数据面板', permission: 'dashboard:view' }, { key: '/students', icon: , 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 配置 ```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 单元测试 **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)**: ```typescript // 嵌套 AND/OR: getAllAndOverride → 返回 [['A'], ['B','C']] // 外层 AND (every),内层 OR (some) requiredPermissions.every(group => group.some(p => user.permissions.includes(p))) ``` **实现 (PermissionGuard)**: ```typescript // 扁平 OR: getAllAndMerge → 返回 ['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` 命名规范且与模块目录结构一致,实现正确。