菜单结构须用code标识节点并支持前缀匹配,角色只绑定permission_code而非菜单ID;后端查菜单时需一次性加载目标code及其所有祖先节点,再用map索引+递归组装树,校验时严格匹配code而非模糊匹配。

菜单数据结构怎么设计才支持角色分级?
菜单不是扁平列表,而是树状结构,但 Role 和 Menu 之间必须解耦——不能让角色直接存菜单 ID 列表,否则新增菜单或调整层级时要批量更新所有角色。正确做法是用「权限码(permission code)」做中间层。
每个菜单节点定义唯一 code(如 "sys:user:list"、"sys:user:edit"),同时带 parent_code 和 level 字段。角色只绑定一组 permission_code 字符串,后端查菜单时根据当前角色拥有的 codes,递归向上补全父级(因为用户能看到子项,就必须能看见父菜单)。
-
Menu表需包含:id,code,name,parent_code,path,level,sort - 角色不存菜单 ID,只存
permission_code(可去重后存为字符串数组或单独 permission 关联表) - 前端请求菜单时,后端按角色查出所有 codes,再查出这些 code 对应的菜单 + 所有祖先菜单(避免漏掉空父级)
如何用 Go 递归补全角色可见的完整菜单树?
常见错误是先查子菜单、再逐个查 parent,N+1 查询严重。应该一次查出所有相关节点(含祖先),再用 map 构建父子关系,最后递归组装树。关键在 SQL 的 IN 子句和 Go 的 map[string]*Menu 索引。
示例逻辑(使用 gorm):
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
// 1. 查出角色所有 permission_code(含显式授权 + 隐式继承的父级)
codes := getRolePermissionCodes(roleID)
// 2. 一次性查出所有相关菜单(含 codes 对应项及其所有祖先)
var menus []Menu
db.Where("code IN ? OR parent_code IN ?", codes, codes).Find(&menus)
// 3. 构建 code → menu 映射,并收集所有 parent_code
menuMap := make(map[string]*Menu)
parentSet := make(map[string]bool)
for i := range menus {
m := &menus[i]
menuMap[m.Code] = m
if m.ParentCode != "" {
parentSet[m.ParentCode] = true
}
}
// 4. 补全缺失的祖先(比如子菜单的 parent_code 不在 codes 中,但菜单节点仍需显示)
if len(parentSet) > 0 {
var ancestors []Menu
db.Where("code IN ?", maps.Keys(parentSet)).Find(&ancestors)
for i := range ancestors {
a := &ancestors[i]
menuMap[a.Code] = a
}
}
// 5. 组装树:找 level=0 或 parent_code=="" 的为根,递归挂子节点
rootMenus := buildMenuTree(menuMap)
前端传来的 menu_code 怎么校验是否属于该角色?
接口权限校验不能只查 role_permissions 表里有没有这个 code —— 因为有些操作(如删除)需要更高权限("sys:user:delete"),而用户可能只被授予了 "sys:user:list"。所以校验必须严格匹配,不自动降级。
- HTTP 中间件中,从路由或参数提取目标
menu_code(如从ctx.Value("menu_code")或 path 解析) - 查数据库或缓存,确认该
menu_code是否在当前用户角色的permission_codes列表中 - 注意大小写和冒号分隔符一致性;建议在入库前统一
strings.ToLower(code) - 不要用模糊匹配(如
LIKE "sys:user:%"),避免越权(比如给了list却误放行reset_password)
为什么不能把菜单权限硬编码在 Go struct 里?
硬编码 const AdminMenuCodes = []string{"sys:*", "log:*"} 看似方便,但会立刻导致三个问题:无法动态赋权、RBAC 变成 RBA(Role-Based Assignment)、审计日志无法追溯谁在何时开了什么权限。
真实系统中,菜单权限必须可配置、可审计、可回收:
- 权限分配走管理后台界面,写入数据库,而非改代码发版
- 每次菜单变更(增/删/改 code)都应触发缓存失效(如 Redis 中的
role:123:perms) - API 层校验必须走实时 DB 查询或带 TTL 的缓存,不能依赖启动时加载的全局变量
- 如果用
go:embed加载 JSON 菜单配置,那只是初始化数据源,不是权限策略本身
最易被忽略的一点:菜单树的 visible 字段(是否对用户可见)和 enabled 字段(是否可操作)要分离。前者影响前端渲染,后者影响后端 API 校验——它们可以不同,比如管理员能看到「系统设置」菜单,但当前角色没开「修改配置」权限,点击按钮时才报错。

















