手把手教你用 GoFrame 实现 RBAC 权限管理,从零到一搞定后台权限系统

最近在优化电商后台项目的时候,权限管理这块踩了不少坑。今天就把我的实战经验分享出来,希望能帮到正在做类似需求的朋友们。

前言

做后台管理系统,权限管理几乎是绑死的需求。但说实话,很多教程要么讲得太理论,要么代码不完整跑不起来。

我们这次正好优化了一下 GoFrame 电商项目,做了一套完整的 RBAC 权限系统,从数据库设计到中间件实现,全程实战代码。文章有点长,建议先收藏,慢慢看。

一、先搞清楚 RBAC 是个啥

RBAC 全称是 Role-Based Access Control,翻译过来就是"基于角色的访问控制"。

说人话就是:

  • 用户 不直接拥有权限
  • 用户 拥有 角色
  • 角色 拥有 权限

举个例子:张三是"商品管理员"角色,这个角色有"查看商品"、"编辑商品"的权限,那张三就能操作商品模块。

这样设计的好处是什么?解耦

你想想,如果直接给用户分配权限,100 个用户就要配 100 次。但如果用角色,只需要配置好角色的权限,然后把角色分给用户就行了。后面权限调整,改角色就行,用户那边自动生效。

二、数据库怎么设计

2.1 四张核心表

RBAC 最少需要这四张表:

表名 作用
admin_info 管理员表,存用户信息
role_info 角色表,存角色信息
permission_info 权限表,存权限信息
role_permission_info 角色-权限关联表

2.2 管理员表

sql 复制代码
CREATE TABLE `admin_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(30) NOT NULL DEFAULT '' COMMENT '用户名',
  `password` varchar(50) NOT NULL DEFAULT '' COMMENT '密码',
  `role_ids` varchar(50) NOT NULL DEFAULT '' COMMENT '角色ids',
  `user_salt` varchar(10) NOT NULL DEFAULT '' COMMENT '加密盐',
  `is_admin` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否超级管理员',
  `created_at` datetime NULL DEFAULT NULL,
  `updated_at` datetime NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `name_unique`(`name`)
);

这里有个设计点要说一下:role_ids 我用的是逗号分隔的字符串,比如 "1,2,3" 表示这个用户有 1、2、3 三个角色。

有人可能会说,这不符合数据库范式啊,应该再建一张 admin_role 关联表。

确实,从规范性来说应该这么做。但实际项目中,一个管理员的角色数量通常不会太多(一般就 1-3 个),用逗号分隔反而更简单,查询也方便。这就是工程上的取舍

2.3 角色表

sql 复制代码
CREATE TABLE `role_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) NOT NULL DEFAULT '' COMMENT '角色名称',
  `desc` varchar(255) NOT NULL COMMENT '描述',
  `created_at` datetime NULL DEFAULT NULL,
  `updated_at` datetime NULL DEFAULT NULL,
  `deleted_at` datetime NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `unique_index`(`name`)
);

角色表很简单,就是名称和描述。注意有个 deleted_at 字段,这是软删除,删除的时候不是真删,而是标记一下。

2.4 权限表

sql 复制代码
CREATE TABLE `permission_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(30) NOT NULL DEFAULT '' COMMENT '权限名称',
  `path` varchar(100) NOT NULL DEFAULT '' COMMENT '路径',
  `created_at` datetime NULL DEFAULT NULL,
  `updated_at` datetime NULL DEFAULT NULL,
  `deleted_at` datetime NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `unique_name`(`name`)
);

重点是 path 字段,这个存的是 API 路径前缀。比如存 /backend/goods,那么 /backend/goods/list/backend/goods/add 这些接口都会被这个权限覆盖。

2.5 角色-权限关联表

sql 复制代码
CREATE TABLE `role_permission_info` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `role_id` int(11) NOT NULL DEFAULT 0 COMMENT '角色id',
  `permission_id` int(11) NOT NULL COMMENT '权限id',
  `created_at` datetime NULL DEFAULT NULL,
  `updated_at` datetime NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `unique_index`(`role_id`, `permission_id`)
);

这是个多对多的关联表,一个角色可以有多个权限,一个权限也可以分给多个角色。

2.6 表关系图

画个简单的关系图:

bash 复制代码
┌─────────────┐       ┌─────────────────────┐       ┌─────────────────┐
│ admin_info  │       │ role_permission_info│       │ permission_info │
├─────────────┤       ├─────────────────────┤       ├─────────────────┤
│ id          │       │ id                  │       │ id              │
│ name        │       │ role_id        ─────┼───┐   │ name            │
│ password    │       │ permission_id  ─────┼───┼───│ path            │
│ role_ids ───┼───┐   └─────────────────────┘   │   └─────────────────┘
│ is_admin    │   │                             │
└─────────────┘   │   ┌─────────────┐           │
                  │   │ role_info   │           │
                  └──►│ id     ◄────┼───────────┘
                      │ name        │
                      │ desc        │
                      └─────────────┘

三、核心代码实现

3.1 登录时把角色信息塞进 JWT

登录的时候,不能只存用户 ID,还要把 is_adminrole_ids 一起存进 JWT Token 里。

为什么?因为后面每次请求都要校验权限,如果每次都去数据库查用户的角色信息,性能会很差。存在 JWT 里,解析 Token 就能拿到,省一次数据库查询。

go 复制代码
// 登录验证并返回包含角色信息的数据(用于JWT存储)
func (s *sAdmin) GetAdminByNamePasswordWithRoles(ctx context.Context, in model.UserLoginInput) map[string]interface{} {
    adminInfo := entity.AdminInfo{}
    err := dao.AdminInfo.Ctx(ctx).Where("name", in.Name).Scan(&adminInfo)
    if err != nil {
        return nil
    }
    // 验证密码
    if utility.EncryptPassword(in.Password, adminInfo.UserSalt) != adminInfo.Password {
        return nil
    }
    // 返回的数据会被存入 JWT
    return g.Map{
        "id":       adminInfo.Id,
        "username": adminInfo.Name,
        "is_admin": adminInfo.IsAdmin,  // 是否超级管理员
        "role_ids": adminInfo.RoleIds,  // 角色ID列表
    }
}

3.2 权限校验中间件(核心中的核心)

这是整个权限系统最核心的部分,每个需要鉴权的请求都会经过这个中间件:

go 复制代码
// 超管专属路径(权限管理模块本身)
var adminOnlyPaths = []string{
    "/backend/role",
    "/backend/permission",
    "/backend/admin",
    "/backend/user",
}

// 不需要权限校验的路径
var noPermissionCheckPaths = []string{
    "/backend/login",
    "/backend/logout",
    "/backend/admin/create",
    "/backend/refresh-token",
}

// PermissionCheck 权限校验中间件
func (s *sMiddleware) PermissionCheck(r *ghttp.Request) {
    ctx := r.Context()
    requestPath := r.URL.Path

    // 1. 白名单路径直接放行
    for _, path := range noPermissionCheckPaths {
        if strings.HasPrefix(requestPath, path) {
            r.Middleware.Next()
            return
        }
    }

    // 2. 从 JWT 中获取用户信息
    claims := jwt.ExtractClaims(ctx)
    if claims == nil {
        response.JsonExit(r, 401, "未登录或登录已过期")
        return
    }

    isAdmin := gconv.Int(claims["is_admin"])
    roleIdsStr := gconv.String(claims["role_ids"])

    // 3. 超级管理员直接放行,不用校验
    if isAdmin == 1 {
        r.Middleware.Next()
        return
    }

    // 4. 普通管理员不能访问权限管理模块
    for _, adminPath := range adminOnlyPaths {
        if strings.HasPrefix(requestPath, adminPath) {
            response.JsonExit(r, 403, "权限不足,该功能仅超级管理员可访问")
            return
        }
    }

    // 5. 解析 role_ids
    var roleIds []int
    if roleIdsStr != "" {
        roleIdStrs := strings.Split(roleIdsStr, ",")
        for _, idStr := range roleIdStrs {
            idStr = strings.TrimSpace(idStr)
            if idStr != "" {
                roleIds = append(roleIds, gconv.Int(idStr))
            }
        }
    }

    // 6. 没有角色 = 没有权限
    if len(roleIds) == 0 {
        response.JsonExit(r, 403, "权限不足,未分配角色")
        return
    }

    // 7. 根据角色查询权限路径
    allowedPaths, err := service.Permission().GetPathsByRoleIds(ctx, roleIds)
    if err != nil {
        response.JsonExit(r, 500, "权限校验失败")
        return
    }

    // 8. 前缀匹配
    for _, allowedPath := range allowedPaths {
        if strings.HasPrefix(requestPath, allowedPath) {
            r.Middleware.Next()
            return
        }
    }

    // 9. 没有匹配到任何权限
    response.JsonExit(r, 403, "权限不足,无法访问该功能")
}

代码有点长,但逻辑其实很清晰,我画个流程图:

ini 复制代码
请求进来
    │
    ▼
是白名单路径? ──是──► 放行
    │
   否
    ▼
从 JWT 提取 is_admin 和 role_ids
    │
    ▼
is_admin == 1? ──是──► 放行(超管无敌)
    │
   否
    ▼
是超管专属路径? ──是──► 403 拒绝
    │
   否
    ▼
解析 role_ids,查询权限路径
    │
    ▼
请求路径匹配权限路径? ──是──► 放行
    │
   否
    ▼
403 拒绝

3.3 根据角色查询权限路径

go 复制代码
// GetPathsByRoleIds 根据角色ID列表获取所有权限路径
func (s *sPermission) GetPathsByRoleIds(ctx context.Context, roleIds []int) ([]string, error) {
    if len(roleIds) == 0 {
        return []string{}, nil
    }

    // 1. 查询角色-权限关联表
    var rolePermissions []entity.RolePermissionInfo
    err := dao.RolePermissionInfo.Ctx(ctx).
        WhereIn(dao.RolePermissionInfo.Columns().RoleId, roleIds).
        Scan(&rolePermissions)
    if err != nil {
        return nil, err
    }

    if len(rolePermissions) == 0 {
        return []string{}, nil
    }

    // 2. 提取 permission_ids 并去重
    permissionIdMap := make(map[int]bool)
    for _, rp := range rolePermissions {
        permissionIdMap[rp.PermissionId] = true
    }
    permissionIds := make([]int, 0, len(permissionIdMap))
    for id := range permissionIdMap {
        permissionIds = append(permissionIds, id)
    }

    // 3. 查询权限表获取 path
    var permissions []entity.PermissionInfo
    err = dao.PermissionInfo.Ctx(ctx).
        WhereIn(dao.PermissionInfo.Columns().Id, permissionIds).
        Scan(&permissions)
    if err != nil {
        return nil, err
    }

    // 4. 提取所有 path
    paths := make([]string, 0, len(permissions))
    for _, p := range permissions {
        if p.Path != "" {
            paths = append(paths, p.Path)
        }
    }

    return paths, nil
}

四、路由怎么配置

GoFrame 的路由配置还是挺优雅的,用 Group 分组,然后绑定中间件:

go 复制代码
// 管理后台路由组
s.Group("/backend", func(group *ghttp.RouterGroup) {
    group.Middleware(
        service.Middleware().CORS,
        service.Middleware().Ctx,
        service.Middleware().ResponseHandler,
    )
    
    // 不需要登录的接口
    group.Bind(
        controller.Admin.Create,       // 管理员创建
        controller.Login.Login,        // 登录
        controller.Login.RefreshToken, // 刷新Token
    )
    
    // 需要登录 + 权限校验的接口
    group.Group("/", func(group *ghttp.RouterGroup) {
        group.Middleware(
            service.Middleware().Auth,            // JWT 认证
            service.Middleware().PermissionCheck, // 权限校验
        )
        group.Bind(
            controller.Role,       // 角色管理
            controller.Permission, // 权限管理
            controller.Admin.List,
            controller.Admin.Update,
            controller.Admin.Delete,
            // ... 其他接口
        )
    })
})

五、实际使用

5.1 创建权限

bash 复制代码
POST /backend/permission/add
{
    "name": "商品管理",
    "path": "/backend/goods"
}

5.2 创建角色并分配权限

bash 复制代码
# 创建角色
POST /backend/role/add
{
    "name": "商品管理员",
    "desc": "负责商品相关管理"
}

# 批量添加权限
POST /backend/role/add/permissions
{
    "role_id": 2,
    "permission_ids": [1, 2, 3]
}

5.3 创建管理员并分配角色

bash 复制代码
POST /backend/admin/add
{
    "name": "zhangsan",
    "password": "123456",
    "role_ids": "2,3",
    "is_admin": 0
}

六、几个注意事项

  1. 超级管理员is_admin = 1 的用户拥有所有权限,不受任何限制。建议只给老板或者核心开发人员。

  2. 路径匹配是前缀匹配 :权限路径 /backend/goods 会匹配 /backend/goods/list/backend/goods/add 等所有以它开头的路径。

  3. JWT 存储角色信息 :登录时把 is_adminrole_ids 存入 JWT,避免每次请求都查数据库。

  4. 权限管理模块本身只有超管能访问:这是为了安全,普通管理员不能给自己加权限。

七、写在最后

这套权限系统是我在做GoFrame电商后台项目时实现的,除了权限管理,还包括商品管理、订单管理、用户管理、数据统计等完整功能模块。

如果你正在学习 GoFrame,或者想找一个完整的后台项目参考,可以看看我的这个项目。代码结构清晰,注释也比较完整,应该能帮你少走一些弯路。

项目地址:mp.weixin.qq.com/s/jNspWJrXq...%25EF%25BC%259Ahttps%3A%2F%2Fmp.weixin.qq.com%2Fs%2FjNspWJrXq3pu7u9AZkS8iw "https://mp.weixin.qq.com/s/jNspWJrXq3pu7u9AZkS8iw)%EF%BC%9Ahttps://mp.weixin.qq.com/s/jNspWJrXq3pu7u9AZkS8iw")

觉得有帮助的话,点个赞、收藏一下呗~ 有问题欢迎评论区交流!

相关推荐
苏三说技术1 小时前
try...catch真的影响性能吗?
后端
青梅主码1 小时前
麦肯锡发布最新报告《职场超级代理:赋能人们释放 AI 的全部潜力》:如何用 AI 赋能员工,释放无限潜力?
后端
悟空码字2 小时前
SpringBoot实现日志系统,Bug现形记
java·spring boot·后端
狂奔小菜鸡2 小时前
Day24 | Java泛型通配符与边界解析
java·后端·java ee
用户68545375977692 小时前
为什么你的Python代码那么乱?因为你不会用装饰器
后端
xjz18422 小时前
ThreadPoolExecutor线程回收流程详解
后端
天天摸鱼的java工程师2 小时前
🐇RabbitMQ 从入门到业务实战:一个 Java 程序员的实战手记
java·后端
Frank_zhou2 小时前
CopyOnWriteArrayList
后端
楚兴2 小时前
使用 Eino 和 Ollama 构建智能 Go 应用:从简单问答到复杂 Agent
人工智能·后端