15 KiB
15 KiB
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 才显示 -->
<n-button v-perm="'sys:user:add'" type="primary">新增</n-button>
<!-- 多权限:拥有任一权限即显示 -->
<n-button v-perms="['sys:user:add', 'sys:user:update']">操作</n-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 仅用于系统初始化,日常使用普通管理员账号 |
| 数据权限配合功能权限 | 功能权限控制"能做什么",数据权限控制"能看到什么" |