做后台管理系统时有一个功能几乎绕不过去权限管理。比如公司内部有这样几个人张三普通员工李四部门管理员王五系统管理员他们登录的是同一个后台。但张三可能只能查看用户李四可以新增和修改用户而王五不仅能管理用户还能管理角色、菜单、系统配置。问题来了后端到底怎么知道“这个用户能不能调用这个接口”很多刚开始做后台系统的开发者第一反应可能是ifuser.Usernameadmin{// 放行}或者ifuser.Roleadmin{// 可以删除用户}小项目这么写似乎没问题。但随着业务越来越复杂用户 ↓ 多个角色 ↓ 多个菜单 ↓ 多个按钮 ↓ 多个 API 权限代码很快就会失控。这也是为什么绝大多数后台管理系统最后都会引入RBAC Role-Based Access Control 基于角色的访问控制今天我们就结合开源项目ShiyuAdmin看看一套比较完整的 RBAC 权限系统到底是怎么设计出来的。项目地址https://github.com/Rodert/ShiyuAdmin一、RBAC 到底解决什么问题先不要看代码。假设现在有一个用户王仕宇他拥有两个角色开发人员 系统管理员而“系统管理员”角色拥有这些权限查看用户 新增用户 修改用户 删除用户 分配角色最终实际上形成的是用户 ↓ 角色 ↓ 权限比如王仕宇 ↓ system_admin ↓ system:user:list system:user:create system:user:update system:user:delete于是当王仕宇调用DELETE /api/v1/system/users/U10001后台只需要判断当前用户有没有 system:user:delete有继续执行没有403 Forbidden这就是 RBAC 最核心的思想。二、为什么不要直接给用户绑定权限一种非常直接的设计是user_permission例如user_id permission 1 user:list 1 user:create 1 user:update 2 user:list 3 user:list 3 user:delete看起来也能工作。但假设公司有1000 个员工其中800 个普通员工普通员工都需要完全相同的 20 个权限。那么就需要800 × 20 16000条权限关系。而 RBAC 增加了一层Role变成800 个用户 ↓ 普通员工角色 ↓ 20 个权限只需要维护用户 → 角色 角色 → 权限所以角色本质上就是一组权限的集合。三、ShiyuAdmin 的 RBAC 数据模型ShiyuAdmin 后端使用 Go Gin GORM。仓库中的核心实体定义在internal/model/entity/user.go代码设计大致可以抽象成下面几个实体。User用户表typeUserstruct{IDint64UserCodestringUsernamestringNicknamestringDeptCodestringStatusintIsSuperAdminbool}其中比较重要的是UserCodeShiyuAdmin 并没有到处拿数据库自增 ID 做业务关联而是使用业务编码。例如U100001 U100002这样做的一个好处是数据库主键负责数据库内部定位业务编码负责业务层关联二者职责分离。四、Role角色角色结构大概是typeRolestruct{IDint64RoleCodestringRoleNamestringRoleKeystringDataScopestringStatusint}例如RoleCode: R001 RoleName: 系统管理员 RoleKey: admin或者RoleCode: R002 RoleName: 普通员工 RoleKey: user这里还有一个很值得注意的字段DataScopestringShiyuAdmin 预留了几种数据权限all dept deptAndChild self也就是说RBAC 解决的是“能不能做”而 DataScope 解决的是“能操作哪些数据”这是两个完全不同的问题。例如权限 system:user:list代表允许查看用户列表但是到底能查看所有人还是自己部门还是仅自己就属于数据权限。五、菜单表为什么同时也是权限表ShiyuAdmin 有一个很有意思的设计typeMenustruct{MenuCodestringParentCodestringMenuTypestringMenuNamestringPermsstringPathstringComponentstringStatusintSortOrderint}很多第一次接触后台权限系统的人可能会问菜单不是前端的吗为什么它会出现在权限系统里原因是后台管理系统通常把目录 菜单 按钮统一抽象成一个权限树。ShiyuAdmin 使用M Directory/Menu Group C 页面菜单 F 功能按钮例如系统管理 │ ├── 用户管理 │ ├── 查询用户 │ ├── 新增用户 │ ├── 修改用户 │ └── 删除用户 │ ├── 角色管理 │ └── 菜单管理数据库可以变成system └── system:user ├── system:user:list ├── system:user:create ├── system:user:update └── system:user:delete于是菜单系统和权限系统就统一了。六、真正重要的是 PermsMenu 中有一个核心字段Permsstring例如system:user:listsystem:user:createsystem:user:updatesystem:user:delete这种权限命名方式非常常见。一般可以理解为模块:资源:动作例如system:user:list拆开就是system ↓ 系统模块 user ↓ 用户资源 list ↓ 查询操作其他模块也可以继续扩展order:list order:create order:refund product:update finance:report:export相比permission_1 permission_2 permission_3这种设计可读性高很多。七、用户和角色之间怎么关联一个用户可能有多个角色。例如王仕宇 角色 开发人员 内容管理员一个角色同样可能对应很多用户。所以User ↔ Role实际上是多对多ShiyuAdmin 使用中间表typeUserRolestruct{IDint64UserCodestringRoleCodestring}对应数据库sys_user_roles比如user_code role_code ---------------------- U001 R001 U001 R003 U002 R002那么 U001 就同时拥有R001 R003两个角色。八、角色和权限同样是多对多角色也不是只能绑定一个菜单。例如运营管理员可能拥有用户查询 订单查询 商品管理 内容管理于是Role ↔ Menu同样需要一张关联表。ShiyuAdmin 中是typeRoleMenustruct{IDint64RoleCodestringMenuCodestring}对应sys_role_menus整个权限模型终于串起来了sys_users │ │ sys_user_roles │ ▼ sys_roles │ │ sys_role_menus │ ▼ sys_menus最终就是User ↓ Role ↓ Menu ↓ Perms九、真正查询权限时发生了什么这才是 RBAC 最关键的一部分。假设用户U001请求接口。系统首先查询SELECTr.*FROMsys_roles rINNERJOINsys_user_roles urONr.role_codeur.role_codeWHEREur.user_codeU001;拿到R001 R002然后分别查这些角色拥有的菜单。大概对应SELECTm.*FROMsys_menus mINNERJOINsys_role_menus rmONm.menu_coderm.menu_codeWHERErm.role_codeR001;ShiyuAdmin 的 repository 层基本也是这个思路。例如获取用户角色时使用db.Table(sys_roles).Joins( INNER JOIN sys_user_roles ON sys_roles.role_code sys_user_roles.role_code ).Where(sys_user_roles.user_code ?,userCode).Find(roles)角色查询菜单则是db.Table(sys_menus).Joins( INNER JOIN sys_role_menus ON sys_menus.menu_code sys_role_menus.menu_code ).Where(sys_role_menus.role_code ?,roleCode).Find(menus)最后就得到了这个用户拥有的所有权限。十、为什么还需要去重假设用户同时拥有开发管理员和系统管理员两个角色都有system:user:list那么查询出来可能是system:user:list system:user:update system:user:list system:menu:list权限显然没有必要重复。所以 ShiyuAdmin 使用 map 做去重permissions:make(map[string]bool)for_,role:rangeroles{menus,err:roleMenuRepo.GetRoleMenus(ctx,role.RoleCode,)iferr!nil{returnnil,err}for_,menu:rangemenus{ifmenunil{continue}ifmenu.Status!1{continue}ifmenu.Perms{continue}permissions[menu.Perms]true}}最终result:make([]string,0,len(permissions))forpermission:rangepermissions{resultappend(result,permission)}这样最后得到system:user:list system:user:update system:menu:list十一、真正控制 API 的是 Gin Middleware权限算出来以后还有一个问题每个接口怎么使用如果每个 Handler 都这么写funcDeleteUser(c*gin.Context){if!hasPermission(...){c.JSON(403,...)return}}代码会非常重复。所以最合理的位置就是MiddlewareShiyuAdmin 提供了一层类似RequirePermission(permissionService,system:user:delete,)于是路由可以写成router.DELETE(/users/:code,RequirePermission(permissionService,system:user:delete,),deleteUser,)请求执行链就变成HTTP Request ↓ JWT Authentication ↓ 解析当前用户 ↓ RequirePermission ↓ 查询用户权限 ↓ system:user:delete ? ↓ YES NO ↓ ↓ Handler 403 Forbidden这就是一套真正工程化的权限系统。十二、超级管理员为什么单独处理ShiyuAdmin 的 User 还有一个字段IsSuperAdminbool权限中间件会优先判断ifclaims.IsSuperAdmin{c.Next()return}也就是说超级管理员不会继续查询角色权限直接放行。为什么因为超级管理员通常意味着拥有全部权限如果仍然要求超级管理员必须绑定所有菜单100 个按钮就必须插入100 条 role_menu未来新增一个按钮还得记得给 super_admin 增加权限很容易出问题。所以很多管理系统都会设置Super Admin bypass十三、Any Permission 和 All PermissionsShiyuAdmin 的权限中间件还有两个比较实用的设计。一个是RequireAnyPermission比如RequireAnyPermission(permissionSvc,[]string{system:user:update,system:user:admin,},)表示满足任意一个权限即可逻辑类似for_,permission:rangerequiredPermissions{ifuserPermissionMap[permission]{c.Next()return}}另外一个是RequireAllPermissions例如RequireAllPermissions(permissionSvc,[]string{finance:report:list,finance:report:export,},)表示必须同时具备查看报表 导出报表才能执行操作。十四、为什么前端隐藏按钮还不够很多新手做权限时容易犯一个错误button v-ifhasPermission(system:user:delete) 删除用户 /button然后觉得权限系统完成了其实完全不是。因为前端权限只能控制UI 是否显示并不能真正保护 API。攻击者完全可以自己调用curl-XDELETE\https://example.com/api/v1/system/users/U001所以真正的安全边界必须在后端正确架构应该是前端权限 ↓ 负责体验 后端权限 ↓ 负责安全ShiyuAdmin 前端也提供了权限判断工具核心思想类似exportconsthasPermission(currentUser,permission,){if(!currentUser){returnfalse;}if(currentUser.isSuperAdmin){returntrue;}returncurrentUser.permissions?.includes(permission)??false;}前端可以控制菜单显示 按钮显示 页面入口但后端仍然继续验证。这是非常重要的一点永远不要相信前端权限。十五、动态菜单又是怎么来的ShiyuAdmin 还有一个非常典型的后台系统功能动态菜单用户登录之后请求/menu/tree服务器根据当前用户 ↓ 角色 ↓ 角色菜单找到允许访问的菜单。例如数据库中有系统管理 ├── 用户管理 ├── 角色管理 ├── 菜单管理 └── 系统配置普通员工只有用户管理权限。后端最终返回[{name:系统管理,children:[{name:用户管理}]}]这里有一个非常容易忽略的问题。假设用户只有用户管理但没有直接绑定它的父节点系统管理那么如果后端单纯过滤只返回拥有权限的节点最后用户会得到用户管理却没有父目录。树形结构就断了。所以 ShiyuAdmin 在生成菜单树时会向上补全Parent也就是用户管理 ↑ 系统管理最终同时保留系统管理 └── 用户管理这个小细节其实非常值得自己写后台管理系统时参考。十六、权限查询为什么需要 Redis现在来看一个性能问题。假设一个接口GET /users每次请求都执行查询用户角色 查询角色菜单 合并权限 检查权限一个请求可能没什么。但是如果1000 QPS权限查询本身就会制造大量数据库访问。所以 ShiyuAdmin 又增加了一层Permission Cache使用 Redis 缓存用户权限。缓存 Key 大致类似user:perms:v1:U001Value[system:user:list,system:user:create,system:user:update]下一次请求时Redis HIT直接拿权限无需访问 MySQL整个流程就从Request ↓ MySQL ↓ UserRole ↓ RoleMenu ↓ Permission变成Request ↓ Redis ↓ Permission对于高频 API 来说非常重要。十七、这里其实还有一个很值得继续优化的点ShiyuAdmin 当前缓存设计里有一个值得讨论的问题角色权限变化后的缓存失效假设U001拥有R001缓存user:perms:v1:U001管理员突然给 R001 增加system:user:delete数据库已经更新。但 Redis 里面可能还是旧权限。于是出现数据库 有权限 Redis 没权限这就是典型的缓存一致性问题目前代码里已经留出了类似InvalidateUserCache(...)以及InvalidateRoleCache(...)这样的设计入口。真正到了生产环境我会更推荐维护role:users:R001例如R001 ↓ U001 U003 U008角色权限发生变化时找到所有角色用户然后删除user:perms:v1:U001 user:perms:v1:U003 user:perms:v1:U008下一次请求自动重建权限缓存。这比等 TTL 自动过期更加可靠。十八、进一步优化一次 SQL 查出所有权限现在权限查询逻辑是用户 ↓ 角色 ↓ 循环角色 ↓ 分别查询 Menu如果用户拥有很多角色理论上可能产生N1 查询我们其实可以直接写一个 SQLSELECTDISTINCTm.permsFROMsys_user_roles urINNERJOINsys_roles rONur.role_coder.role_codeINNERJOINsys_role_menus rmONr.role_coderm.role_codeINNERJOINsys_menus mONrm.menu_codem.menu_codeWHEREur.user_code?ANDr.status1ANDm.status1ANDm.perms;GORM 可以写funcGetUserPermissions(db*gorm.DB,userCodestring,)([]string,error){varpermissions[]stringerr:db.Table(sys_user_roles ur).Select(DISTINCT m.perms).Joins( INNER JOIN sys_roles r ON ur.role_code r.role_code ).Joins( INNER JOIN sys_role_menus rm ON r.role_code rm.role_code ).Joins( INNER JOIN sys_menus m ON rm.menu_code m.menu_code ).Where(ur.user_code ?,userCode).Where(r.status ?,1).Where(m.status ?,1).Where(m.perms ).Pluck(m.perms,permissions).Errorreturnpermissions,err}这样1 次 SQL就可以得到全部权限。十九、再进一步建立唯一索引中间表还有一个非常容易忽略的问题。例如sys_user_roles理论上U001 R001不应该重复。否则可能出现U001 R001 U001 R001 U001 R001虽然代码中可以先 Countifcount0{returnnil}但并发场景下依然可能发生重复写入。更可靠的方法应该交给数据库保证。例如CREATEUNIQUEINDEXuk_user_roleONsys_user_roles(user_code,role_code);RoleMenu 同理CREATEUNIQUEINDEXuk_role_menuONsys_role_menus(role_code,menu_code);数据库约束应该作为最后一道防线二十、一个完整 RBAC 请求到底经历了什么现在把整个过程串起来。假设用户王仕宇登录系统。首先POST /login验证账号密码。生成JWT里面保存userCode isSuperAdmin然后浏览器调用GET /api/v1/system/menus/tree后端JWT ↓ UserCode ↓ UserRole ↓ RoleMenu ↓ Menu ↓ 返回菜单树用户点击删除用户前端发送DELETE /api/v1/system/users/U10010进入Auth Middleware解析UserCode然后进入Permission Middleware检查system:user:delete如果 Redis 有缓存Redis ↓ Permission List如果没有MySQL ↓ Role ↓ Menu ↓ Permission ↓ Redis Cache最后存在权限 ↓ 执行 Handler或者不存在权限 ↓ 403 Forbidden完整流程可以总结成┌─────────────┐ │ User │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ UserRole │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ Role │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ RoleMenu │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ Menu │ │ Perms │ └──────┬──────┘ │ ▼ system:user:delete │ ▼ Permission Middleware │ │ YES NO │ │ ▼ ▼ Handler 403二十一、RBAC 真正难的其实不是“建几张表”看到这里会发现RBAC 本身其实并不复杂。最核心也就是用户 角色 菜单/权限 用户角色关系 角色权限关系真正决定一个权限系统能不能用于生产的是这些细节权限编码怎么设计 超级管理员怎么处理 禁用角色是否立即失效 菜单父节点怎么补齐 权限修改后 Redis 怎么失效 数据权限和功能权限怎么分离 前端按钮和后端 API 怎么保持一致 中间表如何防重复 大量请求时如何减少数据库查询这些才是后台权限系统真正值得研究的地方。最后ShiyuAdmin 这个项目里我觉得比较值得学习的一点并不是简单地实现了用户管理 角色管理 菜单管理而是把这些模块真正连成了一套User ↓ Role ↓ Menu ↓ Permission ↓ Middleware ↓ API再加上动态菜单 超级管理员 数据范围 Redis 权限缓存就已经具备了一套现代后台管理系统权限架构的基本轮廓。如果你正在用 Go 写后台系统建议不要只看CRUD 怎么写而是认真研究一下这种用户 → 角色 → 权限 → API的完整调用链。因为 CRUD 谁都会写。真正拉开后台系统设计差距的往往就是权限、缓存、数据范围、日志这些看起来不起眼的基础设施。项目https://github.com/Rodert/ShiyuAdmin相关源码位置internal/model/entity/user.gointernal/middleware/permission.gointernal/service/permission/service.gointernal/service/permission/cached_service.gointernal/repository/db/user_role_repo.gointernal/repository/db/role_menu_repo.gointernal/api/v1/system/menus.gofrontend/shiyu-admin-web/src/utils/permission.ts