一、权限系统每个后台系统都绕不过的核心难题做过后台系统的人都懂这个痛点。项目上线第一天老板问「财务数据能不能只让财务部门看」第二天运营问「我们能不能只看自己负责的活动不要看到别人的数据」第三天安全审计报告出来了「这个删除接口没有权限控制任何登录用户都能调用。」权限系统是每一个后台管理系统都绕不过的核心命题。不做权限控制系统就是裸奔——任何登录用户都能访问所有页面、调用所有接口甚至能删掉不该删的数据。权限粒度太粗灵活性极差——你只能控制某个用户「能不能进这个页面」但没法控制「进了页面之后能不能点这个按钮」。结果就是明明只想让运营人员查看订单却不得不把「退款」和「删除」按钮也一起暴露出来。权限粒度太细维护是噩梦——每个 API 都要单独配权限光是配置工作就要花上几天后期调整更是牵一发动全身。RBACRole-Based Access Control基于角色的访问控制是这个问题的工程级平衡点。它的核心思路是不直接给用户分配权限而是先定义角色再把权限赋予角色最后把角色赋给用户。这样当你需要调整某类用户的权限时只需要修改对应角色的权限配置而不需要逐个修改每个用户。元点Adminydadmin作为一套开源的 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 的后端使用 PHPWebman/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 父菜单ID0表示顶级, name varchar(64) NOT NULL COMMENT 菜单名称, type tinyint(1) NOT NULL COMMENT 类型1目录 2菜单 3按钮, path varchar(128) DEFAULT COMMENT 路由路径type1,2时使用, component varchar(128) DEFAULT COMMENT 前端组件路径type2时使用, perms varchar(128) DEFAULT COMMENT 权限标识type3时使用, 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 指令有了后端的权限数据前端需要完成两件事根据权限动态生成路由以及在页面上控制按钮的显示与否。动态路由生成用户登录成功后前端请求一个专用接口获取当前用户有权访问的菜单列表后端只返回 type1 和 type2 的节点目录和菜单页面按钮权限节点单独处理。// 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() // 后端返回的菜单树type1,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 classarticle-toolbar !-- 只有拥有 article.create 权限的用户才能看到「新增文章」按钮 -- el-button typeprimary v-has-perm[article.create] clickhandleCreate 新增文章 /el-button !-- 编辑按钮 -- el-button v-has-perm[article.update] clickhandleEdit(row) 编辑 /el-button !-- 删除按钮 — 高危操作权限单独控制 -- el-button typedanger v-has-perm[article.delete] clickhandleDelete(row.id) 删除 /el-button !-- 支持多权限标识拥有其中任意一个即可显示 -- el-button v-has-perm[article.export, report.export] 导出 /el-button /div /templatev-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如果本文对你有帮助欢迎点赞收藏有问题也欢迎在评论区交流。
网站建设
高端定制
企业官网