从零理解 RBAC:元点Admin 如何实现按钮级权限控制
一、权限系统:每个后台系统都绕不过的核心难题
做过后台系统的人都懂这个痛点。
项目上线第一天,老板问:「财务数据能不能只让财务部门看?」第二天,运营问:「我们能不能只看自己负责的活动,不要看到别人的数据?」第三天,安全审计报告出来了:「这个删除接口没有权限控制,任何登录用户都能调用。」
权限系统,是每一个后台管理系统都绕不过的核心命题。
不做权限控制,系统就是裸奔——任何登录用户都能访问所有页面、调用所有接口,甚至能删掉不该删的数据。
权限粒度太粗,灵活性极差——你只能控制某个用户「能不能进这个页面」,但没法控制「进了页面之后能不能点这个按钮」。结果就是,明明只想让运营人员查看订单,却不得不把「退款」和「删除」按钮也一起暴露出来。
权限粒度太细,维护是噩梦——每个 API 都要单独配权限,光是配置工作就要花上几天,后期调整更是牵一发动全身。
RBAC(Role-Based Access Control,基于角色的访问控制)是这个问题的工程级平衡点。它的核心思路是:不直接给用户分配权限,而是先定义角色,再把权限赋予角色,最后把角色赋给用户。这样,当你需要调整某类用户的权限时,只需要修改对应角色的权限配置,而不需要逐个修改每个用户。
元点Admin(ydadmin)作为一套开源的 PHP + Vue3 后台管理系统框架,在设计上深度实现了 RBAC 权限体系,并将权限粒度细化到了按钮级别。本文将完整拆解其实现原理,从数据库设计、后端中间件,到前端动态路由和指令控制,给你一套可直接参考的权限系统设计思路。
二、RBAC 模型:管理员 → 角色 → 权限 → 菜单
元点Admin 的 RBAC 模型遵循经典的四层结构:
管理员(Admin) ↓ 多对多 角色(Role) ↓ 多对多 权限/菜单(Permission / Menu)核心关系
一个管理员可以拥有多个角色。比如同一个人可以同时是「内容编辑」和「数据分析师」,拥有两个角色的权限合集。
一个角色可以关联多个菜单/权限节点。角色「内容编辑」可以关联「文章管理菜单」、「文章新增按钮」、「文章编辑按钮」,但不关联「文章删除按钮」。
权限检查时取并集。如果一个用户拥有多个角色,其最终权限是所有角色权限的并集。
ER 关系示意
┌──────────┐ ┌──────────────────┐ ┌──────────┐ │ admin │ │ admin_role │ │ role │ │──────────│ │──────────────────│ │──────────│ │ id │───<│ admin_id │ │ id │ │ username │ │ role_id │>───│ name │ │ password │ └──────────────────┘ │ status │ └──────────┘ └────┬─────┘ │ ┌────────┴──────────┐ │ role_menu │ │───────────────────│ │ role_id │ │ menu_id │>──┐ └───────────────────┘ │ ┌────────┴─────┐ │ menu │ │──────────────│ │ id │ │ name │ │ type │ │ perms │ └──────────────┘这个结构的优势在于极低的维护成本。新来一个运营人员,只需把「运营角色」赋给他;运营部门权限需要调整,只需修改「运营角色」对应的菜单集合,所有运营人员权限同步更新,无需逐个处理。
三、后端中间件链:JWT 验证 → 权限检查 → 操作日志
元点Admin 的后端使用 PHP(Webman/Laravel 风格),权限逻辑通过三段中间件链实现,每段职责清晰、顺序不可颠倒。
HTTP 请求 ↓ admin_auth → JWT Token 验证,注入 userId ↓ admin_permission → 权限节点检查 ↓ admin_log → 自动记录操作日志(POST/PUT/DELETE) ↓ Controller 处理第一段:admin_auth — 身份认证
这是整个中间件链的入口,职责是验证请求者的身份合法性。
class AdminAuthMiddleware implements MiddlewareInterface { public function process(Request $request, callable $next): Response { $token = $request->header('Authorization', ''); // 移除 Bearer 前缀 if (str_starts_with($token, 'Bearer ')) { $token = substr($token, 7); } if (empty($token)) { return json(['code' => 401, 'msg' => '请先登录']); } try { // 解析 JWT Token,提取 payload $payload = JwtHelper::parseToken($token); // 将 userId 注入到 Request 对象,供后续中间件和 Controller 使用 $request->userId = $payload['user_id']; $request->userInfo = $payload; } catch (\Exception $e) { return json(['code' => 401, 'msg' => 'Token 已过期或无效,请重新登录']); } return $next($request); } }关键点:中间件将userId注入到$request对象后,后续中间件和所有 Controller 都可以通过$request->userId直接获取当前登录用户 ID,无需重复解析 Token。
第二段:admin_permission — 权限校验
身份确认之后,中间件链进入权限校验环节。这里的核心逻辑是:根据当前请求的路由,查询当前用户是否拥有对应的权限节点。
class AdminPermissionMiddleware implements MiddlewareInterface { public function process(Request $request, callable $next): Response { $userId = $request->userId; // 超级管理员直接放行(userId = 1 或通过角色标识判断) if (AdminService::isSuperAdmin($userId)) { return $next($request); } // 获取当前请求路径,例如 /api/admin/article/create $currentPath = $request->path(); // 从缓存或数据库中获取该用户所有权限节点 $permissions = PermissionService::getUserPermissions($userId); // 检查当前路由是否在权限列表中 if (!in_array($currentPath, $permissions)) { return json(['code' => 403, 'msg' => '暂无权限,请联系管理员']); } return $next($request); } }权限列表通常在用户登录时缓存到 Redis,避免每次请求都查询数据库。缓存的 key 格式为admin:perms:{userId},当角色权限发生变更时主动清除对应缓存。
第三段:admin_log — 操作日志
通过权限验证后,日志中间件会自动记录所有写操作(POST、PUT、DELETE),无需在每个 Controller 中手动埋点。
class AdminLogMiddleware implements MiddlewareInterface { public function process(Request $request, callable $next): Response { $response = $next($request); // 只记录写操作 $method = strtoupper($request->method()); if (!in_array($method, ['POST', 'PUT', 'DELETE'])) { return $response; } // 异步写入日志,不阻塞主流程 OperationLog::asyncCreate([ 'admin_id' => $request->userId, 'method' => $method, 'path' => $request->path(), 'params' => json_encode($request->post()), 'ip' => $request->getRealIp(), 'user_agent' => $request->header('user-agent'), 'created_at' => now(), ]); return $response; } }这三段中间件的执行顺序至关重要:日志中间件必须在权限中间件之后,否则会记录下未经授权的非法请求(这在某些审计场景下可能是需要的,但通常只需记录合法操作);权限中间件必须在认证中间件之后,因为它依赖$request->userId。
四、数据库设计:菜单表与按钮权限
元点Admin 的权限体系最精妙的地方在于:目录、菜单页面、功能按钮,三者统一存储在同一张菜单表中,用type字段加以区分。
菜单表核心字段
CREATE TABLE `yd_menu` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '菜单ID', `parent_id` int(11) NOT NULL DEFAULT 0 COMMENT '父菜单ID,0表示顶级', `name` varchar(64) NOT NULL COMMENT '菜单名称', `type` tinyint(1) NOT NULL COMMENT '类型:1=目录 2=菜单 3=按钮', `path` varchar(128) DEFAULT '' COMMENT '路由路径(type=1,2时使用)', `component` varchar(128) DEFAULT '' COMMENT '前端组件路径(type=2时使用)', `perms` varchar(128) DEFAULT '' COMMENT '权限标识(type=3时使用)', `api_path` varchar(256) DEFAULT '' COMMENT '对应后端API路径', `icon` varchar(64) DEFAULT '' COMMENT '菜单图标', `sort` int(4) NOT NULL DEFAULT 0 COMMENT '排序', `visible` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否显示:1=是 0=否', `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态:1=正常 0=禁用', `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单权限表';三种 type 详解
type = 1:目录
纯粹的导航节点,在侧边栏中表现为可展开的分组菜单,不对应任何实际页面。
INSERT INTO yd_menu (parent_id, name, type, path, icon, sort) VALUES (0, '内容管理', 1, '/content', 'document', 1);type = 2:菜单页面
对应一个实际的前端页面,包含路由路径和 Vue 组件地址。
INSERT INTO yd_menu (parent_id, name, type, path, component, icon, sort) VALUES (1, '文章管理', 2, '/content/article', 'views/content/article/index', 'edit', 1);type = 3:按钮权限
这是实现按钮级权限控制的关键。按钮节点不对应页面路由,只携带一个perms权限标识字符串,以及对应的后端 API 路径。
-- 文章新增按钮 INSERT INTO yd_menu (parent_id, name, type, perms, api_path, sort) VALUES (2, '新增文章', 3, 'article.create', '/api/admin/article/store', 1); -- 文章删除按钮 INSERT INTO yd_menu (parent_id, name, type, perms, api_path, sort) VALUES (2, '删除文章', 3, 'article.delete', '/api/admin/article/destroy', 2); -- 文章编辑按钮 INSERT INTO yd_menu (parent_id, name, type, perms, api_path, sort) VALUES (2, '编辑文章', 3, 'article.update', '/api/admin/article/update', 3); -- 文章列表查询(通常所有有文章管理菜单权限的角色都可以查) INSERT INTO yd_menu (parent_id, name, type, perms, api_path, sort) VALUES (2, '文章列表', 3, 'article.list', '/api/admin/article/index', 4);perms字段的命名规范通常是{模块}.{操作},语义清晰,且与前端v-has-perm指令直接对应。
权限分配的查询逻辑
当用户登录后,系统通过以下关联查询获取该用户的完整权限集合:
SELECT DISTINCT m.perms, m.api_path FROM yd_menu m INNER JOIN yd_role_menu rm ON m.id = rm.menu_id INNER JOIN yd_admin_role ar ON rm.role_id = ar.role_id WHERE ar.admin_id = :userId AND m.type = 3 AND m.status = 1 AND m.perms != ''查询结果就是该用户拥有的全部按钮权限标识列表,如['article.create', 'article.list', 'article.update'],会同时返回给前端(用于控制按钮显示)和缓存在后端(用于 API 权限验证)。
五、前端联动:动态路由 + v-has-perm 指令
有了后端的权限数据,前端需要完成两件事:根据权限动态生成路由,以及在页面上控制按钮的显示与否。
动态路由生成
用户登录成功后,前端请求一个专用接口获取当前用户有权访问的菜单列表,后端只返回 type=1 和 type=2 的节点(目录和菜单页面),按钮权限节点单独处理。
// stores/permission.ts import { defineStore } from 'pinia' import { getMenuList } from '@/api/auth' import { buildRoutes } from '@/utils/route' export const usePermissionStore = defineStore('permission', { state: () => ({ routes: [] as RouteRecordRaw[], permissions: [] as string[], // 按钮权限标识列表 }), actions: { async generateRoutes() { const { data } = await getMenuList() // 后端返回的菜单树(type=1,2的节点)转换为 Vue Router 路由配置 const accessRoutes = buildRoutes(data.menus) // 按钮权限标识列表单独存储 this.permissions = data.permissions // ['article.create', 'article.delete', ...] // 动态添加路由 accessRoutes.forEach(route => { router.addRoute(route) }) this.routes = accessRoutes return accessRoutes } } })buildRoutes工具函数负责将后端返回的菜单数据转换为 Vue Router 可识别的路由配置:
// utils/route.ts function buildRoutes(menus: MenuItem[]): RouteRecordRaw[] { return menus.map(menu => { const route: RouteRecordRaw = { path: menu.path, name: menu.name, meta: { title: menu.name, icon: menu.icon, menuId: menu.id, }, children: [], } if (menu.type === 2 && menu.component) { // 动态导入组件,component 字段值如 'views/content/article/index' route.component = () => import(`@/${menu.component}.vue`) } else if (menu.type === 1) { route.component = Layout // 目录使用布局组件 } if (menu.children && menu.children.length > 0) { route.children = buildRoutes(menu.children) } return route }) }这套动态路由机制的好处是:前端代码无需硬编码任何菜单配置。所有菜单的增删改都在后台管理界面操作,前端自动响应,真正实现了菜单的动态管理。
v-has-perm 自定义指令
按钮权限控制通过 Vue3 自定义指令v-has-perm实现。
指令注册:
// directives/permission.ts import { usePermissionStore } from '@/stores/permission' const hasPermDirective = { mounted(el: HTMLElement, binding: DirectiveBinding) { const { value } = binding if (!value || !Array.isArray(value) || value.length === 0) { console.warn('[v-has-perm] 指令需要传入权限标识数组,例如 v-has-perm="[\'article.create\']"') return } const permissionStore = usePermissionStore() const userPermissions = permissionStore.permissions // 超级管理员拥有通配符 '*',直接通过 if (userPermissions.includes('*')) { return } // 检查用户是否拥有 value 数组中任意一个权限 const hasPermission = value.some(perm => userPermissions.includes(perm)) if (!hasPermission) { // 没有权限则移除该元素(而非仅隐藏,防止通过 CSS 显示) el.parentNode?.removeChild(el) } } } export default hasPermDirective全局注册:
// main.ts import hasPermDirective from '@/directives/permission' const app = createApp(App) app.directive('has-perm', hasPermDirective)在页面组件中使用:
<template> <div class="article-toolbar"> <!-- 只有拥有 article.create 权限的用户才能看到「新增文章」按钮 --> <el-button type="primary" v-has-perm="['article.create']" @click="handleCreate" > 新增文章 </el-button> <!-- 编辑按钮 --> <el-button v-has-perm="['article.update']" @click="handleEdit(row)" > 编辑 </el-button> <!-- 删除按钮 — 高危操作,权限单独控制 --> <el-button type="danger" v-has-perm="['article.delete']" @click="handleDelete(row.id)" > 删除 </el-button> <!-- 支持多权限标识:拥有其中任意一个即可显示 --> <el-button v-has-perm="['article.export', 'report.export']"> 导出 </el-button> </div> </template>v-has-perm指令接收一个权限标识数组,数组内是「或」的关系——只要用户拥有数组中任意一个权限,按钮就会显示。这在处理「编辑/审核」这类多角色都需要的功能时非常实用。
需要特别强调的是:前端权限控制只是 UI 层面的用户体验优化,不能替代后端权限验证。前端隐藏了按钮,并不意味着对应的 API 无法被直接调用。真正的安全保障来自于后端admin_permission中间件对每个 API 请求的权限校验。
六、超级管理员:通配符 '*' 与 v1.3.0 的重要修复
超级管理员是权限系统中的特殊角色——他需要访问所有功能,但总不能把系统里所有的权限节点都手动勾选一遍吧?
元点Admin 的解决方案是通配符权限标识'*'。
后端处理
// 在登录或获取用户信息接口中 public function getUserPermissions(int $userId): array { if (AdminService::isSuperAdmin($userId)) { // 超级管理员返回通配符,不查询具体权限节点 return ['*']; } // 普通管理员查询 RBAC 权限列表 return PermissionService::getPermsByUserId($userId); }前端处理
收到'*'标识后,前端需要在两个地方正确处理:
1. v-has-perm 指令中的判断(已在第五节展示):
// 超级管理员拥有通配符 '*',直接通过所有权限检查 if (userPermissions.includes('*')) { return // 不移除元素,即所有按钮都显示 }2. 动态路由生成时的处理:
// 超级管理员可以直接获取全量菜单,无需过滤 async generateRoutes() { const { data } = await getMenuList() // 后端对超级管理员直接返回全量菜单数据 // permissions 字段包含 ['*'] this.permissions = data.permissions // ...路由生成逻辑 }v1.3.0 / v1.2.1 修复说明
这个看起来简单的'*'通配符,在 v1.2.1 之前存在一个关键 bug:后端登录接口在返回超级管理员的用户信息时,遗漏了permissions字段中的'*'标识,只返回了空数组或者实际的权限节点列表。
导致的结果是:超级管理员登录后,前端v-has-perm指令检查时发现权限列表里没有相应权限,把很多按钮都给隐藏掉了——超级管理员反而看不到某些操作按钮,权限比普通管理员还少,场面十分尴尬。
v1.3.0 同步修复了另一个问题:菜单权限标识命名不一致,部分菜单节点的perms字段命名风格混乱(有的用article:create,有的用article.create,有的用articleCreate),导致前端v-has-perm匹配失效。新版本统一规范为{模块}.{操作}的点分隔命名风格,并补充了多处缺失的按钮权限节点。
如果你正在使用 v1.2.x 版本,强烈建议升级到 v1.3.0+。
七、数据范围控制:不同角色看不同数据
按钮级权限控制了「能做什么操作」,但在很多业务场景中还需要控制「能看哪些数据」。
典型场景:电商后台有多个运营团队,每个团队只应该看到自己负责的商品,而不是所有人的商品。这就是数据范围控制(Data Scope),是 RBAC 在「横向」维度上的延伸。
元点Admin 的数据范围设计在角色表中增加了一个data_scope字段:
ALTER TABLE yd_role ADD COLUMN `data_scope` tinyint(1) DEFAULT 1 COMMENT '数据范围:1=全部 2=本部门 3=本部门及子部门 4=仅本人';在 Repository 层(数据访问层)进行统一过滤,Controller 层无感知:
// repositories/ArticleRepository.php class ArticleRepository { public function getList(Request $request, array $filters = []): LengthAwarePaginator { $query = Article::query()->with(['author', 'category']); // 应用业务过滤条件 if (!empty($filters['status'])) { $query->where('status', $filters['status']); } // 数据范围过滤 — 在业务逻辑过滤之后、查询执行之前统一注入 $this->applyDataScope($query, $request->userId); return $query->orderByDesc('created_at')->paginate(20); } private function applyDataScope(Builder $query, int $userId): void { $role = AdminService::getPrimaryRole($userId); match ($role->data_scope) { 1 => null, // 全部数据,不过滤 2 => $query->where('dept_id', AdminService::getDeptId($userId)), // 仅本部门 3 => $query->whereIn('dept_id', AdminService::getDeptAndChildIds($userId)), // 本部门及子部门 4 => $query->where('created_by', $userId), // 仅本人数据 default => $query->whereRaw('1 = 0'), // 兜底:无数据权限 }; } }这种设计将数据范围过滤逻辑下沉到数据访问层,好处是:
- Controller 代码干净,不掺杂权限逻辑
- 数据过滤规则统一管理,不会遗漏某个接口
- 后续扩展新的数据范围类型只需修改 Repository 层
结合前面的菜单权限(操作维度)和数据范围(数据维度),就构成了一个相对完整的企业级权限控制体系。
八、总结:5 分钟上手元点Admin 的权限配置
回顾整个权限体系,元点Admin 的实现路径清晰:
- 数据库层:菜单表统一管理目录、页面、按钮三类节点,按钮节点携带
perms权限标识 - 后端层:三段中间件链
admin_auth → admin_permission → admin_log,职责单一、顺序固定 - 前端层:登录后动态拉取菜单生成路由,
v-has-perm指令控制按钮显隐 - 特殊处理:超级管理员通过
'*'通配符绕过所有权限检查 - 数据维度:角色的
data_scope字段配合 Repository 层过滤,控制数据可见范围
对于大多数中小型后台系统,这套方案已经足够覆盖日常权限管理需求,不需要引入更复杂的 ABAC(基于属性的访问控制)。
如果你正在搭建一套后台管理系统,不想从零实现这一套权限体系,元点Admin 已经帮你把框架、数据库、前后端联动全部打通,开箱即用。
想直接上手体验?
- 在线 Demo:元点Admin(可实际体验不同角色的权限隔离效果)
- Gitee 开源地址:Gitee - yuandianxitong/ydadmin · Gitee(欢迎 Star、Fork、提 Issue)
如果本文对你有帮助,欢迎点赞收藏,有问题也欢迎在评论区交流。
