# 5.4 权限管理(RBAC) :::tip 概述 系统采用 RBAC(基于角色的访问控制)权限模型,通过"用户 → 角色 → 菜单/权限"三层关系实现细粒度的权限控制。权限控制分为两个维度:**功能权限**(能做什么操作)和**数据权限**(能看到哪些数据,见 [5.9 数据权限](/zh/core/data-scope.html))。 ::: ## 权限模型 ### ER 关系图 ```text ┌─────────┐ ┌────────────┐ ┌─────────┐ ┌────────────┐ ┌─────────┐ │ User │───►│ UserRole │───►│ Role │───►│ RoleMenu │───►│ Menu │ │ │ │ │ │ │ │ │ │ │ │ id │ │ user_id │ │ id │ │ role_id │ │ id │ │ username│ │ role_id │ │tenant_id│ │ menu_id │ │ perm │ │ realname│ └────────────┘ │ name │ └────────────┘ │ type │ │ dept_id │ │ code │ │ parent │ │tenant_id│ │ note │ │ sort │ └─────────┘ └─────────┘ └─────────┘ ``` ### 关联关系说明 | 关系 | 说明 | 中间表 | |------|------|--------| | User ↔ Role | 多对多:一个用户可有多个角色,一个角色可分配给多个用户 | `laravel_user_role` | | Role ↔ Menu | 多对多:一个角色可有多个菜单/权限,一个菜单可分配给多个角色 | `laravel_role_menu` | | User → Dept | 多对一:一个用户属于一个部门 | `dept_id` 外键 | | User → Tenant | 多对一:一个用户属于一个租户 | `tenant_id` 外键 | ### 核心表结构 **laravel_role(角色表):** | 字段 | 类型 | 说明 | |------|------|------| | `id` | INT | 主键 | | `tenant_id` | INT | 租户ID:0=平台级(角色按租户隔离,RoleService 继承 BaseTenantService) | | `name` | VARCHAR(50) | 角色名称 | | `code` | VARCHAR(50) | 角色编码 | | `note` | VARCHAR | 备注 | | `sort` | INT | 排序 | **laravel_menu(菜单/权限表):** | 字段 | 类型 | 说明 | |------|------|------| | `id` | INT | 主键 | | `parent_id` | INT | 父级 ID(0=顶级) | | `name` | VARCHAR(50) | 菜单名称 | | `permission` | VARCHAR(100) | 权限标识(如 `sys:user:add`)——**菜单即权限** | | `type` | TINYINT | 类型:0=菜单/目录(可导航) 1=权限节点 | | `path` | VARCHAR(200) | 路由路径 | | `icon` | VARCHAR(100) | 图标 | | `sort` | INT | 排序 | | `status` | TINYINT | 状态 | ## 权限码格式 ```text sys:{模块名}:{操作} ``` ### 权限码命名规范 | 操作 | 权限码 | 说明 | |------|--------|------| | 分页查询 | `sys:{module}:page` | 列表页 | | 全量列表 | `sys:{module}:list` | 下拉框数据源 | | 详情 | `sys:{module}:detail` | 查看详情 | | 新增 | `sys:{module}:add` | 添加记录 | | 修改 | `sys:{module}:update` | 修改记录 | | 删除 | `sys:{module}:delete` | 删除记录 | | 批量删除 | `sys:{module}:batchDelete` | 批量删除 | | 导入 | `sys:{module}:import` | Excel 导入 | | 导出 | `sys:{module}:export` | Excel 导出 | | 重置密码 | `sys:{module}:resetPwd` | 重置密码 | ### 权限码示例 ```text 用户管理: sys:user:page 用户分页 sys:user:detail 用户详情 sys:user:add 添加用户 sys:user:update 修改用户 sys:user:delete 删除用户 sys:user:batchDelete 批量删除用户 sys:user:import 导入用户 sys:user:export 导出用户 sys:user:resetPwd 重置密码 角色管理: sys:role:page 角色分页 sys:role:add 添加角色 sys:role:update 修改角色 sys:role:delete 删除角色 文章管理: sys:article:page 文章分页 sys:article:add 添加文章 sys:article:update 修改文章 sys:article:delete 删除文章 ``` ## 菜单类型 | type | 说明 | 前端行为 | 权限校验 | |------|------|----------|----------| | 0 | 菜单/目录(可导航) | 渲染为侧边栏分组与菜单项(`getMenus` 只取 type=0) | 不参与权限校验 | | 1 | 权限节点(按钮/查看等) | 不渲染,仅用于权限校验 | **参与权限校验**(`Authenticate` 查库仅取 `type=1` 且 `permission` 非空) | ### 菜单树示例 ```text 系统管理 (type=0, 目录) ├── 用户管理 (type=0, 页面, component=system/user/index) │ ├── 查看用户 (type=1, 权限节点, permission=sys:user:page) │ ├── 添加用户 (type=1, 权限节点, permission=sys:user:add) │ ├── 修改用户 (type=1, 权限节点, permission=sys:user:update) │ ├── 删除用户 (type=1, 权限节点, permission=sys:user:delete) │ └── 重置密码 (type=1, 权限节点, permission=sys:user:resetPwd) ├── 角色管理 (type=0, 页面, component=system/role/index) │ ├── 查看角色 (type=1, 权限节点, permission=sys:role:page) │ ├── 添加角色 (type=1, 权限节点, permission=sys:role:add) │ └── 修改角色 (type=1, 权限节点, permission=sys:role:update) └── 菜单管理 (type=0, 页面, component=system/menu/index) └── 查看菜单 (type=1, 权限节点, permission=sys:menu:list) 内容管理 (type=0, 目录) ├── 文章管理 (type=0, 页面, component=content/article/index) │ ├── 查看文章 (type=1, 权限节点, permission=sys:article:page) │ ├── 添加文章 (type=1, 权限节点, permission=sys:article:add) │ └── 删除文章 (type=1, 权限节点, permission=sys:article:delete) └── 分类管理 (type=0, 页面, component=content/category/index) ``` :::tip 两类节点各司其职 - **type=0(菜单/目录)**:驱动前端导航(`GET /index/getMenus` 过滤 `type=0, status=0, hide=0, is_delete=0`); - **type=1(权限节点)**:驱动后端鉴权(`Authenticate` 过滤 `type=1, permission != ''`)。 页面的查看权限(如 `sys:user:page`)也挂在 type=1 节点上。 ::: ## 权限校验流程 ### 完整流程图 ```text 请求进入 Authenticate(auth.jwt) │ ▼ 1. 从 JWT 获取 uid │ ▼ 2. uid === 1(超级管理员)? │ 是 → 直接放行 ▼ 3. 读取控制器方法的 #[Permission] 注解(ReflectionMethod) │ 无注解 → 放行(该接口只需登录) ▼ 4. 三级缓存获取用户权限列表 ├─ Redis perms:{uid}(一级) ├─ Laravel Cache perms_fallback:{uid} 300秒(二级) └─ 查库:UserRole → RoleMenu join Menu 条件:is_delete=0、type=1、permission != '' (结果回填缓存) ▼ 5. 判断权限码是否在列表中 in_array('*:*:*') → 通配放行 in_array($permissionCode, $permissions) │ 不包含 → HTTP 403 {"code":403,"msg":"无访问权限:sys:xxx:xxx"} ▼ 6. 放行 ``` ### 校验代码(来自项目实际源码) ```php // app/Http/Middleware/Authenticate.php(节选) protected function checkPermission(int $uid, string $permissionCode): bool { // 超级管理员跳过 if ($uid === 1) { return true; } // 从 Redis 获取权限(一级缓存) try { $cached = Redis::get("perms:{$uid}"); if ($cached) { $perms = json_decode($cached, true) ?? []; // 拥有通配权限或包含目标编码即通过 return in_array('*:*:*', $perms) || in_array($permissionCode, $perms); } } catch (\Exception $e) { // Redis 不可用时继续降级 } // 二级缓存:Laravel Cache(5 分钟) $cacheKey = "perms_fallback:{$uid}"; $permissions = Cache::remember($cacheKey, 300, function () use ($uid) { return $this->loadPermissionsFromDb($uid); }); return in_array('*:*:*', $permissions) || in_array($permissionCode, $permissions); } // 数据库加载:user_role → role_menu join menu(未删除、type=1、permission 非空) protected function loadPermissionsFromDb(int $uid): array { $roleIds = UserRole::where('user_id', $uid)->pluck('role_id')->toArray(); if (empty($roleIds)) { return []; } return RoleMenu::join('menu', 'role_menu.menu_id', '=', 'menu.id') ->whereIn('role_menu.role_id', $roleIds) ->where('menu.is_delete', 0) ->where('menu.type', 1) ->where('menu.permission', '!=', '') ->pluck('menu.permission') ->toArray(); } ``` ### 错误响应 | 场景 | HTTP 状态 | code | msg | |------|----------|------|-----| | 无权限 | **403** | 403 | 无访问权限:{权限码} | | 无角色(权限列表为空) | **403** | 403 | 无访问权限:{权限码} | | 缓存过期后重新加载失败 | **403** | 403 | 同上(查库兜底保证一致性) | :::tip 权限缓存一致性 菜单(`MenuObserver`)或角色菜单(`RoleMenuObserver`)数据变更时自动清除相关缓存;角色管理保存权限后立即生效,无需等待 TTL。 ::: ## 在 Controller 中使用 ### 声明权限注解 ```php use App\Attributes\OperationLog; use App\Attributes\Permission; class UserController extends Controller { // ── 查询类 ── #[Permission('sys:user:page', '用户分页')] public function page(): JsonResponse { /* ... */ } #[Permission('sys:user:detail', '用户详情')] public function detail(int $id): JsonResponse { /* ... */ } // ── 写操作类 ── #[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')] #[Permission('sys:user:add', '添加用户')] public function add(UserRequest $request): JsonResponse { /* ... */ } #[OperationLog('用户管理-修改', OperationLog::TYPE_UPDATE, '修改用户ID:{id}')] #[Permission('sys:user:update', '修改用户')] public function update(UserRequest $request): JsonResponse { /* ... */ } #[OperationLog('用户管理-删除', OperationLog::TYPE_DELETE, '删除用户ID:{id}')] #[Permission('sys:user:delete', '删除用户')] public function delete(int $id): JsonResponse { /* ... */ } // ── 特殊操作类 ── #[OperationLog('用户管理-重置密码', OperationLog::TYPE_RESET, '重置用户ID:{id}的密码')] #[Permission('sys:user:resetPwd', '重置密码')] public function resetPwd(): JsonResponse { /* ... */ } // ── 不需要权限的接口 ── // 不标注 #[Permission] 即可,所有已登录用户都可访问 public function getOptions(): JsonResponse { /* ... */ } } ``` ### 注解组合规则 ```text #[OperationLog] + #[Permission] 组合使用: - OperationLog:LogOperation 中间件记录操作日志 - Permission:Authenticate 中间件校验权限(不通过返回 403) 执行顺序: Authenticate 先校验权限 → 通过后 LogOperation 才采集日志上下文 权限校验失败时: - 返回 403,不进入 Controller - 该次请求不会产生操作日志(尚未进入采集阶段后的业务执行) ``` ## 数据权限 三种数据权限范围由 `HasDataScope::getRoleDataScope()` 按角色解析(详见 [5.9 数据权限](/zh/core/data-scope.html))。当前 `laravel_role` 表不含 `data_scope` 字段,读取缺省为 1——角色一律按全部数据处理: ```text ┌─────────────────────────────────────────────────────────┐ │ data_scope = 1 → 全部数据(不过滤,当前默认) │ │ data_scope = 2 → 本部门数据(WHERE dept_id = 本部门) │ │ data_scope = 3 → 仅本人数据(WHERE create_user = 自己)│ └─────────────────────────────────────────────────────────┘ ``` ## 权限管理接口 | 接口 | 方法 | 说明 | |------|------|------| | `/role/page` | GET | 角色分页列表 | | `/role/add` | POST | 添加角色(含菜单权限) | | `/role/update` | PUT | 修改角色(含菜单权限) | | `/role/delete/{id}` | DELETE | 删除角色 | | `/role/menu/list/{id}` | GET | 获取角色菜单权限列表(含勾选状态) | | `/role/menu/save` | POST | 保存角色菜单权限 | | `/menu/treeList` | GET | 菜单树形列表(用于权限分配) | ### 角色权限分配流程 ```text 1. 前端调用 GET /menu/treeList 获取完整菜单树 2. 前端调用 GET /role/menu/list/{roleId} 获取该角色已有的菜单 ID 3. 前端渲染树形 checkbox,回显已选中项 4. 用户勾选/取消菜单权限 5. 前端调用 POST /role/menu/save 提交 { roleId, menuIds: [1,2,3,...] } 6. 后端写入 role_menu 关联表,RoleMenuObserver 清除该角色权限缓存 ``` ## 前端权限控制 ### 按钮级权限指令 ```vue ``` ### 超级管理员 用户权限列表包含 `*:*:*` 时,表示超级管理员,`v-perm`/`v-perms` 指令始终放行(后端对应 `uid=1` 直接放行)。 ### 菜单动态渲染 ```text 后端 GET /index/getMenus │ 返回当前用户的菜单树(已过滤无权限的菜单) ▼ 前端 asyncRouteStore │ 将菜单树转换为 Vue Router 路由 │ router.addRoute() 动态注入 ▼ 侧边栏组件 │ 遍历菜单树渲染菜单项 ▼ 用户只能看到有权限的菜单 ``` :::warning 前端权限不是安全防线 `v-perm` 只是体验层(隐藏按钮);后端 `#[Permission]` 注解 + `auth.jwt` 中间件才是最终防线。 ::: ## 最佳实践 | 实践 | 说明 | |------|------| | 权限码命名统一 | 使用 `sys:{module}:{action}` 格式 | | 最小权限原则 | 只分配必要的权限,不给多余权限 | | 角色职责单一 | 每个角色对应明确的职责范围 | | 定期审计权限 | 定期检查角色权限分配是否合理 | | 超级管理员仅一个 | uid=1 仅用于系统初始化,日常使用普通管理员账号 | | 数据权限配合功能权限 | 功能权限控制"能做什么",数据权限控制"能看到什么" |