当前位置: 首页 > news >正文

从零理解 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 的实现路径清晰:

  1. 数据库层:菜单表统一管理目录、页面、按钮三类节点,按钮节点携带perms权限标识
  2. 后端层:三段中间件链admin_auth → admin_permission → admin_log,职责单一、顺序固定
  3. 前端层:登录后动态拉取菜单生成路由,v-has-perm指令控制按钮显隐
  4. 特殊处理:超级管理员通过'*'通配符绕过所有权限检查
  5. 数据维度:角色的data_scope字段配合 Repository 层过滤,控制数据可见范围

对于大多数中小型后台系统,这套方案已经足够覆盖日常权限管理需求,不需要引入更复杂的 ABAC(基于属性的访问控制)。

如果你正在搭建一套后台管理系统,不想从零实现这一套权限体系,元点Admin 已经帮你把框架、数据库、前后端联动全部打通,开箱即用。


想直接上手体验?

  • 在线 Demo:元点Admin(可实际体验不同角色的权限隔离效果)
  • Gitee 开源地址:Gitee - yuandianxitong/ydadmin · Gitee(欢迎 Star、Fork、提 Issue)

如果本文对你有帮助,欢迎点赞收藏,有问题也欢迎在评论区交流。


http://www.cnnetsun.cn/news/3150128.html

相关文章:

  • 2026实测解析:软件测试培训为什么首推橙好测试开发?零基础/转行必看
  • Skills Manager:开源AI技能管家,实现提示词工程化与团队协作
  • GPT-5.5 Instant:从拼智商到拼情商,AI助手如何变得更懂你
  • 基于大数据爬虫+Hadoop用户偏好迁移的电影推荐系统
  • Dify 实战指南:从零构建 AI 应用,掌握 Agent 工作流与 RAG 核心
  • 当我们在浏览器里点开一把小锁:SSL/TLS是怎么保护我们的
  • python字符串的四种定义方式
  • 基于SpringBoot的合同管理系统与实现
  • 红日靶场(ATTCK实战)1通关方法
  • 少儿C++分级课程体系搭建:从L1到L4的教学设计经验分享
  • MAF预定义ChatClient中间件-07]PerServiceCallChatHistoryPersistingChatClient——基于ReAct循环的一步一存档
  • OpenClaw 的 sessions_spawn 隔离机制
  • 若依系统登录密码RSA加密实战:jsencrypt前端加密与Spring Boot后端解密
  • Go 数据结构 string 深度剖析
  • Docker--Docker Swarm集群
  • Deepin Boot Maker实战指南:跨平台启动盘制作高效方案深度解析
  • 苏州本地AI流量破局!一网推GEO苏州本地服务中心年度收录破8万
  • QA Use:推荐一款AI 原生 E2E 测试平台,自然语言一键跑通用例!
  • 冰河木马 v8.4 手动清除实战:3步删除注册表项与恢复文件关联
  • NS-Emu-Tools 技术架构深度解析:现代模拟器管理的工程化实践
  • 深入浅出CAP理论:从原理到实战,用Go实现一个最终一致性的分布式键值存储
  • 《HarmonyOS技术精讲-Media Library Kit》之实战:构建简易相册应用
  • 网络安全与网络协议知识点汇总 + 选填题库
  • 微信登录 + 微信支付 业务逻辑分步详解
  • 自动扩缩容:3 种策略的适用场景
  • qt的元对象系统(具备反射能力)有哪些部件
  • 把 HLS 字幕玩出花:zwPlayer 如何让 M3U8 视频支持全文搜索、翻译与码率自适应
  • 记录arm64内核调试环境搭建qemu_arm64_linux_01
  • Rust AI 工具配置层级:命令参数、环境变量和配置文件别打架
  • 扒源码 | Cube Sandbox 的微虚机、容器镜像、可写层,是怎么串到一起的