Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_EleVue/zh/core/rbac.md
T
2026-10-08 17:50:29 +08:00

15 KiB
Raw Blame History

5.4 权限管理(RBAC)

:::tip 概述 系统采用 RBAC(基于角色的访问控制)权限模型,通过"用户 → 角色 → 菜单/权限"三层关系实现细粒度的权限控制。权限控制分为两个维度:功能权限(能做什么操作)和数据权限(能看到哪些数据,见 5.9 数据权限)。 :::

权限模型

ER 关系图

┌─────────┐    ┌────────────┐    ┌─────────┐    ┌────────────┐    ┌─────────┐
│  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 状态

权限码格式

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 重置密码

权限码示例

用户管理:
  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 非空)

菜单树示例

系统管理 (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 节点上。 :::

权限校验流程

完整流程图

请求进入 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. 放行

校验代码(来自项目实际源码)

// 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 中使用

声明权限注解

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 { /* ... */ }
}

注解组合规则

#[OperationLog] + #[Permission] 组合使用:
  - OperationLog:LogOperation 中间件记录操作日志
  - Permission:Authenticate 中间件校验权限(不通过返回 403)

执行顺序:
  Authenticate 先校验权限 → 通过后 LogOperation 才采集日志上下文

权限校验失败时:
  - 返回 403,不进入 Controller
  - 该次请求不会产生操作日志(尚未进入采集阶段后的业务执行)

数据权限

三种数据权限范围由 HasDataScope::getRoleDataScope() 按角色解析(详见 5.9 数据权限)。当前 laravel_role 表不含 data_scope 字段,读取缺省为 1——角色一律按全部数据处理:

┌─────────────────────────────────────────────────────────┐
│  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 菜单树形列表(用于权限分配)

角色权限分配流程

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 清除该角色权限缓存

前端权限控制

按钮级权限指令

<template>
  <!-- 单权限:拥有 sys:user:add 才显示 -->
  <el-button v-perm="'sys:user:add'" type="primary">新增</el-button>

  <!-- 多权限:拥有任一权限即显示 -->
  <el-button v-perms="['sys:user:add', 'sys:user:update']">操作</el-button>

  <!-- 无权限的按钮直接从 DOM 移除,而非仅隐藏 -->
</template>

超级管理员

用户权限列表包含 *:*:* 时,表示超级管理员,v-perm/v-perms 指令始终放行(后端对应 uid=1 直接放行)。

菜单动态渲染

后端 GET /index/getMenus
    │ 返回当前用户的菜单树(已过滤无权限的菜单)
    ▼
前端 asyncRouteStore
    │ 将菜单树转换为 Vue Router 路由
    │ router.addRoute() 动态注入
    ▼
侧边栏组件
    │ 遍历菜单树渲染菜单项
    ▼
用户只能看到有权限的菜单

:::warning 前端权限不是安全防线 v-perm 只是体验层(隐藏按钮);后端 #[Permission] 注解 + auth.jwt 中间件才是最终防线。 :::

最佳实践

实践 说明
权限码命名统一 使用 sys:{module}:{action} 格式
最小权限原则 只分配必要的权限,不给多余权限
角色职责单一 每个角色对应明确的职责范围
定期审计权限 定期检查角色权限分配是否合理
超级管理员仅一个 uid=1 仅用于系统初始化,日常使用普通管理员账号
数据权限配合功能权限 功能权限控制"能做什么",数据权限控制"能看到什么"