后台系统最难的不是 CRUD:用 ShiyuAdmin 看懂 RBAC 权限设计

很多人第一次写后台管理系统的时候,会觉得最麻烦的是:

  • 用户管理
  • 部门管理
  • 菜单管理
  • 增删改查
  • 分页查询

但真正把系统做大之后,你会发现:

CRUD 反而是最简单的。

后台系统真正难处理的是一个问题:

谁,可以在什么情况下,访问什么资源?

比如:

张三可以查看用户列表,但不能删除用户。

李四可以修改订单,但是看不到系统配置。

财务人员能看到财务菜单,但普通员工连这个菜单都不应该出现。

超级管理员则拥有全部权限。

这些需求如果一开始没有设计好,后面业务越多,权限判断就会越来越乱。

最近我在整理自己的开源后台项目 ShiyuAdmin 时,也重新梳理了一遍后台权限系统。

ShiyuAdmin 本身是一个基于:

Go
Gin
Gorm
JWT
Redis
React
Ant Design Pro

实现的前后端分离后台管理系统,并且内置用户、角色、菜单、部门、动态菜单和接口权限等能力。

项目地址:

https://github.com/Rodert/ShiyuAdmin

今天不讲 CRUD。

我们专门聊聊一个后台管理系统里的核心:

RBAC 权限到底应该怎么设计。


一、最简单的权限系统为什么不够用?

假设我们刚开始写一个后台。

最直接的方法是什么?

可能是在代码里面判断:

if user.Role == "admin" {
    // 可以操作
}

稍微复杂一点:

if user.Role == "admin" || user.Role == "manager" {
    // 可以删除用户
}

项目小的时候完全没问题。

但是业务一多,很快就会变成:

if user.Role == "admin" ||
    user.Role == "manager" ||
    user.Role == "operator" {
    // ...
}

然后另外一个接口又写一遍。

if user.Role != "guest" {
    // ...
}

再过几个月,你自己都不知道:

manager
operator
admin
super_admin

到底分别能干什么。

问题的根源在于:

角色和具体操作绑定得太死了。

所以成熟一点的后台系统通常不会直接问:

你是不是管理员?

而是问:

你有没有 system:user:delete 这个权限?

这就进入了 RBAC。


二、RBAC 到底是什么?

RBAC:

Role-Based Access Control

中文一般叫:

基于角色的访问控制。

核心关系其实非常简单:

用户
 ↓
角色
 ↓
权限

例如:

王仕宇
 ↓
系统管理员
 ↓
system:user:list
system:user:create
system:user:update
system:user:delete

另外一个用户:

小明
 ↓
运营人员
 ↓
system:user:list
system:user:update

这样删除用户这个能力就不需要写:

role == "admin"

而是统一判断:

HasPermission("system:user:delete")

这一步非常重要。

因为从这里开始:

角色只负责组织权限,权限才真正决定用户能做什么。


三、ShiyuAdmin 是怎么拆这个关系的?

ShiyuAdmin 的权限体系可以简单理解为:

User
  ↓
UserRole
  ↓
Role
  ↓
RoleMenu
  ↓
Menu
  ↓
Perms

也就是:

用户
 ↓
用户角色关系
 ↓
角色
 ↓
角色菜单关系
 ↓
菜单/按钮
 ↓
权限标识

项目里已经把 user_role、role_menu、role、menu 等能力拆成独立 Repository / Service,而不是把所有权限逻辑都塞进 Controller。

假设菜单表里存在这样一些权限:

system:user:list

system:user:add

system:user:update

system:user:delete

system:role:list

system:role:update

那么一个角色只需要绑定对应菜单和权限。

例如:

普通管理员

system:user:list
system:user:add
system:user:update

超级管理员:

*

或者直接通过超级管理员标记绕过普通权限判断。


四、第一层:JWT 解决“你是谁”

权限判断之前必须先解决一个问题:

当前请求是谁发出来的?

ShiyuAdmin 使用 JWT 做登录认证。

整个请求大概是:

客户端
  ↓
Authorization: Bearer xxx
  ↓
Auth Middleware
  ↓
解析 JWT
  ↓
得到 UserCode / Role / Session
  ↓
写入 Gin Context

项目中的 Auth 中间件会从:

Authorization: Bearer TOKEN

读取 Token。

Token 验证通过以后,把解析出的 Claims 放进 Gin Context:

c.Set("currentUser", claims)

后面的业务代码就不需要重新解析 Token 了。与此同时,项目还会根据 Token 计算 sessionID,并通过 SessionValidator 判断当前会话是否已经被注销。

于是请求链变成:

Request
   ↓
JWT Middleware
   ↓
currentUser
   ↓
Permission Middleware
   ↓
Controller

这就是一个很典型的认证与授权分离。


五、认证和授权千万不要混为一谈

很多刚开始写权限系统的人容易把两个概念混在一起:

Authentication
Authorization

它们实际上完全不同。

Authentication:

你是谁?

Authorization:

你能干什么?

例如:

JWT 验证成功

只能说明:

这个用户登录了

但并不能说明:

这个用户可以删除其他用户

所以请求应该经过两层:

JWT Authentication
        ↓
Permission Authorization

也就是:

先认证
再鉴权

这是后台权限系统最基本的一条边界。


六、第二层:Permission Middleware 解决“你能干什么”

ShiyuAdmin 把接口权限检查放到了 Gin Middleware。

思路可以简化为:

func RequirePermission(
    permissionService PermissionService,
    permission string,
) gin.HandlerFunc {

    return func(c *gin.Context) {

        user := GetCurrentUser(c)

        if user.IsSuperAdmin {
            c.Next()
            return
        }

        ok, err := permissionService.CheckPermission(
            c.Request.Context(),
            user.UserCode,
            permission,
        )

        if err != nil {
            c.Abort()
            return
        }

        if !ok {
            c.Abort()
            return
        }

        c.Next()
    }
}

实际项目中的 RequirePermission 会先从 Context 中取得 JWT Claims,然后判断超级管理员,再调用 PermissionService 查询用户是否拥有指定权限。

如果不存在权限,则返回 HTTP:

403 Forbidden

项目还分别实现了:

RequirePermission

RequireAnyPermission

RequireAllPermissions

RequireSuperAdmin

因此可以支持“必须拥有一个权限”“拥有任意一个即可”“必须同时拥有多个权限”等不同场景。

这一点其实非常实用。


七、Any Permission 和 All Permissions 有什么区别?

例如某个接口:

只需要满足:

订单管理员
或者
超级客服

中的任意一个权限。

就可以使用:

RequireAnyPermission(
    permissionService,
    []string{
        "order:update",
        "order:admin",
    },
)

逻辑是:

A OR B

而有些敏感接口可能要求:

既拥有审核权限
又拥有财务权限

则可以使用:

RequireAllPermissions(
    permissionService,
    []string{
        "finance:audit",
        "finance:approve",
    },
)

逻辑变成:

A AND B

所以一个成熟的权限系统并不只有:

HasPermission()

最好能够表达:

ANY
ALL
SUPER ADMIN

这些不同的授权语义。


八、用户权限最终是怎么查出来的?

ShiyuAdmin 的 Permission Service 逻辑很典型。

第一步:

根据用户获取角色。

类似:

roles, err := userRoleRepo.GetUserRoles(
    ctx,
    userCode,
)

第二步:

遍历角色。

for _, role := range roles {

}

第三步:

获取角色拥有的菜单。

menus, err := roleMenuRepo.GetRoleMenus(
    ctx,
    role.RoleCode,
)

第四步:

提取菜单中的权限标识:

menu.Perms

最后去重:

permsMap := make(map[string]bool)

得到:

[
    "system:user:list",
    "system:user:add",
    "system:user:update",
    "system:role:list"
]

ShiyuAdmin 当前的 Permission Service 正是按照“用户 → 角色 → 菜单 → Perms”的路径汇总权限,同时过滤被禁用的角色和菜单,再进行去重。

这个结构的好处在于:

权限来源非常清晰。


九、为什么权限最好挂在 Menu 上?

这里还有一个很有意思的设计问题。

很多后台管理系统都会把:

菜单
按钮
权限

放在一套资源模型里面。

例如:

系统管理
 └── 用户管理
      ├── 查看
      ├── 新增
      ├── 修改
      └── 删除

对应:

system:user:list
system:user:add
system:user:update
system:user:delete

这样前端和后端就可以共享一套权限模型。

前端决定:

按钮显示不显示

后端决定:

接口允许不允许调用

例如前端:

{hasPermission('system:user:delete') && (
    <Button danger>
        删除
    </Button>
)}

后端:

router.DELETE(
    "/users/:id",
    RequirePermission(
        permissionService,
        "system:user:delete",
    ),
    userController.Delete,
)

两边使用同一个:

system:user:delete

整个权限系统就统一了。


十、但前端隐藏按钮绝对不等于权限控制

这里一定要特别强调。

很多后台系统有一个非常严重的问题:

前端做了:

if (!hasPermission) {
    return null
}

于是开发者就以为安全了。

实际上用户完全可以绕过前端。

直接调用:

curl -X DELETE \
http://localhost:18000/api/v1/system/users/100

或者用:

Postman
Apifox
Burp Suite
浏览器 DevTools

直接请求接口。

所以:

前端权限控制解决的是用户体验,后端权限控制解决的才是安全问题。

正确架构应该是:

             权限
              │
       ┌──────┴──────┐
       ↓             ↓
     前端           后端
       ↓             ↓
控制按钮显示      控制接口访问

二者缺一不可。


十一、权限系统为什么需要 Redis?

如果每一个请求都查询:

用户
 ↓
用户角色
 ↓
角色
 ↓
角色菜单
 ↓
菜单
 ↓
权限

数据库压力会非常大。

假设首页同时发送:

20 个 API 请求

每个 API 都重新查询权限。

那么一次打开页面可能会产生几十次甚至上百次数据库访问。

所以 ShiyuAdmin 又在 Permission Service 外增加了一层 Redis 缓存。

缓存 Key 的设计类似:

user:perms:v1:{userCode}

例如:

user:perms:v1:10001

Value:

[
  "system:user:list",
  "system:user:add",
  "system:user:update"
]

查询过程变成:

请求
 ↓
Redis
 ↓
命中?
 ├── YES → 直接返回权限
 │
 └── NO
      ↓
    查询数据库
      ↓
    生成权限列表
      ↓
    写 Redis

项目中的 CachedService 正是这样包装基础 Permission Service,并使用 TTL 保存用户权限列表。


十二、缓存权限以后,真正困难的问题来了

权限缓存并不是加一个 Redis 就结束了。

真正困难的问题叫:

缓存失效。

假设:

用户 A
 ↓
管理员角色
 ↓
拥有删除用户权限

Redis 中已经缓存:

system:user:delete

现在管理员把这个权限取消了。

数据库已经修改。

但是 Redis 里面还存在:

system:user:delete

那么在缓存过期之前:

这个用户仍然可能拥有删除权限。

这就是典型的:

缓存与数据库一致性

问题。

ShiyuAdmin 当前已经提供:

InvalidateUserCache()

用于删除单个用户权限缓存;而角色级缓存失效部分目前仍采用比较简化的方案,源码中的注释也明确提到,可以继续通过维护“角色 → 用户”的映射,或者使用 SCAN 等方式完善角色权限变更后的批量失效。

实际上这正好是一个非常值得继续优化的地方。


十三、生产环境可以怎么进一步优化?

如果让我继续完善这套权限缓存,我会考虑建立:

Role → Users

映射。

例如:

role:users:admin

里面保存:

10001
10002
10003

当:

admin

角色权限发生变化时:

找到这些用户:

10001
10002
10003

然后逐个删除:

user:perms:v1:10001
user:perms:v1:10002
user:perms:v1:10003

这样就可以做到:

修改角色权限
      ↓
找到角色用户
      ↓
删除对应权限缓存
      ↓
下一次请求重新计算

这比单纯等待 TTL 过期更加可靠。


十四、再进一步:给权限增加版本号

还有一种思路。

为用户或者角色维护:

PermissionVersion

例如:

user_perm_version = 12

JWT 或缓存记录:

version = 11

发现:

11 != 12

说明权限发生过变化。

直接重新加载。

于是权限缓存可以从:

user:perms:10001

变成:

user:perms:12:10001

版本号变化后:

旧缓存自动失效。

ShiyuAdmin 当前的 CachedService 已经在 Key 中加入了:

v1

这样的缓存版本字段。

这其实已经为后续做整个权限缓存版本升级留下了空间。


十五、权限命名也非常重要

我比较推荐:

模块:资源:动作

例如:

system:user:list

system:user:create

system:user:update

system:user:delete

再比如订单:

order:order:list

order:order:create

order:order:refund

商品:

product:goods:list

product:goods:create

product:goods:update

product:goods:delete

不要写:

permission1

user1

admin_edit

几个月以后基本没人知道什么意思。

一个好的权限字符串应该做到:

不用看文档也能大概知道它控制什么。


十六、一个完整请求到底经历了什么?

现在把整个流程串起来。

用户访问:

DELETE /api/v1/system/users/100

请求头:

Authorization: Bearer eyJ...

第一步:

Auth Middleware

解析:

JWT

获得:

UserCode = 10001

第二步:

Permission Middleware

要求:

system:user:delete

第三步:

查询:

Redis

得到:

[
  "system:user:list",
  "system:user:update",
  "system:user:delete"
]

第四步:

判断:

system:user:delete

存在。

于是:

PASS

继续执行:

UserController.Delete()

如果不存在:

直接返回:

403 Forbidden

整个 Controller 根本不会执行。

所以一个比较清晰的后台权限架构应该是:

HTTP Request
      ↓
JWT Authentication
      ↓
Current User
      ↓
Permission Middleware
      ↓
Redis Permission Cache
      ↓
RBAC Permission Service
      ↓
Controller
      ↓
Service
      ↓
Repository
      ↓
Database

十七、为什么我更推荐“权限中间件”这种方案?

你当然也可以在 Controller 里面写:

func DeleteUser(c *gin.Context) {

    if !HasPermission(...) {
        return
    }

}

但这样写久了以后:

每个 Controller 都会存在大量重复代码。

例如:

checkPermission()
checkRole()
checkAdmin()
checkLogin()

最后业务代码和权限代码混在一起。

而 Middleware 的好处就是:

Controller

只负责:

业务

MiddleWare:

认证 / 权限 / 日志 / Trace

Service:

业务规则

Repository:

数据库

每层职责非常明确。

这也是为什么在 ShiyuAdmin 的后端结构里,可以看到独立的:

middleware
repository
service
api
model

这些目录,而不是把所有代码全部堆在 Handler 里面。


十八、后台系统真正值得学习的地方

很多人学习后台项目,第一反应是:

登录怎么写?

分页怎么写?

CRUD 怎么写?

这些当然重要。

但当你真正开始设计一个可以长期维护的系统以后,更值得研究的其实是:

认证怎么设计

权限怎么设计

角色怎么设计

数据权限怎么设计

缓存怎么设计

操作日志怎么设计

会话注销怎么设计

接口边界怎么设计

这些东西才决定了:

这个后台到底只是一个 Demo,

还是一个真正能够继续扩展的系统。

ShiyuAdmin 目前除了用户、角色、菜单和接口权限,还包含部门、数据管理、Redis 缓存管理、系统监控、操作日志以及数据仪表盘等模块,并支持 PostgreSQL、MySQL、SQLite 等部署方式。

如果你正在学习:

Go
Gin
Gorm
JWT
Redis
RBAC
React
Ant Design Pro

可以直接把代码拉下来跑一遍。

有时候看十篇 RBAC 教程,

不如真正跟着一个项目,把:

用户
 ↓
角色
 ↓
菜单
 ↓
权限
 ↓
JWT
 ↓
Middleware
 ↓
Redis

完整跑通一次。

你会发现:

后台管理系统真正有意思的地方,才刚刚开始。


项目地址:

https://github.com/Rodert/ShiyuAdmin

作者:王仕宇 / JavaPub

posted @ 2026-09-16 16:16  JavaPub  阅读(5)  评论(0)    收藏  举报