博客 / 詳情

返回

手把手教你用 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 管理員表

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 角色表

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 權限表

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 角色-權限關聯表

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 表關係圖

畫個簡單的關係圖:

┌─────────────┐       ┌─────────────────────┐       ┌─────────────────┐
│ 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 就能拿到,省一次數據庫查詢。

// 登錄驗證並返回包含角色信息的數據(用於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 權限校驗中間件(核心中的核心)

這是整個權限系統最核心的部分,每個需要鑑權的請求都會經過這個中間件:

// 超管專屬路徑(權限管理模塊本身)
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, "權限不足,無法訪問該功能")
}

代碼有點長,但邏輯其實很清晰,我畫個流程圖:

請求進來
    │
    ▼
是白名單路徑? ──是──► 放行
    │
   否
    ▼
從 JWT 提取 is_admin 和 role_ids
    │
    ▼
is_admin == 1? ──是──► 放行(超管無敵)
    │
   否
    ▼
是超管專屬路徑? ──是──► 403 拒絕
    │
   否
    ▼
解析 role_ids,查詢權限路徑
    │
    ▼
請求路徑匹配權限路徑? ──是──► 放行
    │
   否
    ▼
403 拒絕

3.3 根據角色查詢權限路徑

// 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 分組,然後綁定中間件:

// 管理後台路由組
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 創建權限

POST /backend/permission/add
{
    "name": "商品管理",
    "path": "/backend/goods"
}

5.2 創建角色並分配權限

# 創建角色
POST /backend/role/add
{
    "name": "商品管理員",
    "desc": "負責商品相關管理"
}

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

5.3 創建管理員並分配角色

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,或者想找一個完整的後台項目參考,可以看看我的這個項目。代碼結構清晰,註釋也比較完整,應該能幫你少走一些彎路。

項目地址:https://mp.weixin.qq.com/s/jNspWJrXq3pu7u9AZkS8iw):https://mp.weixin.qq.com/s/jNspWJrXq3pu7u9AZkS8iw

覺得有幫助的話,點個贊、收藏一下唄~ 有問題歡迎評論區交流!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.