文档目录
glen
· 预计阅读 3 分钟

第 6 章 权限计算与生效:菜单、策略与缓存

本文导航
第 6 章 权限计算与生效:菜单、策略与缓存

第 6 章 权限计算与生效:菜单、策略与缓存

前两章讲「怎么授」。这一章讲「授完之后系统怎么算」,以及为什么有时后台改了权限、页面却没变化。

我的菜单:GET /users/me/menus

Handler 逻辑:

app := strings.Join(ctx.QueryArray("app"), ",")
user, _ := ctx.Get("user")
var menus []*router.Menu

if user.(*model.User).IsSuperAdmin {
	menus = i.menuRepo.GetMenuWithoutBtn()
} else {
	menus = i.userSvc.GetUserMenus(ctx.Request.Context(), user.(*model.User).ID)
}

router.Success(ctx, router.FilterMenusBySubApp(menus, app))

分三步:

  1. 取菜单:
    • 超管:全部菜单,去掉按钮;
    • 普通用户:先查用户角色,再查角色菜单 ID,最后 GetMenuByMenuIDs。
  2. 过滤按钮:GetMenuWithoutBtn / GetMenuByMenuIDs 都会去掉 MenuTypeBtn。
  3. 按子系统过滤:app 参数匹配根菜单的 SubApp。

GetMenuByMenuIDs 还会把命中的菜单 Meta.ShowLink 置为 true,父节点因为子节点命中也会显示。

我的权限:GET /users/me/permissions

func (i *UserService) GetMyPermissions(ctx context.Context, userID uint, isSuperAdmin bool) ([]string, error) {
	if isSuperAdmin {
		return []string{"*:*:*"}, nil
	}

	user, err := i.userRepo.WithContext(ctx).FindById(userID)
	if err != nil {
		return nil, err
	}

	roleIDs := user.GetRoleIDs()
	menuIDs := i.roleSvc.GetRolesMenuIDs(ctx, roleIDs...)
	return i.menuManger.GetPermissionsByMenuIDs(menuIDs...), nil
}

普通用户的权限不是查 Casbin,而是从菜单重新推导:

用户 → 角色 ID → 角色菜单 ID → 菜单上的 DependentsPermission → 去重

所以 me/permissions 返回的是「按当前菜单配置,你应该有的权限」,而接口鉴权查的是 Casbin 策略。两者正常情况下一致;如果不一致,说明策略没有同步,见下一节。

策略什么时候写入

只有两个事件会写 casbin_rule:

事件 触发接口 写入
AssignMenuToRole PUT /roles/:id/menus 删除角色旧 p 行,写入新 p 行
AssignRoleToUser POST /users/:id/roles 删除用户旧 g 行,写入新 g 行

监听器在 owl-admin/app/listener/listener.go。

注意:

  • 直接改数据库里的 admin_role_menu 不会自动更新 Casbin;
  • 直接改 admin_user_role 也不会;
  • 必须走接口,或手工同步 casbin_rule。

运行时怎么校验

中间件每次请求都会:

enforcer.LoadPolicy()
can, err := enforcer.Enforce(cast.ToString(user.ID), permissionKey)

LoadPolicy() 从数据库重新加载策略,所以后台授权后,下一次请求就生效,不需要重启服务。

代价是每次请求都读一次策略表。当前实现如此,量级不大时没问题;高并发场景可以改成定时刷新或监听变更。

前端缓存在哪里

前端登录后会调用 /users/me/permissions,把结果写进 Pinia 和 localStorage:

  • useUserStore().permissions;
  • user-permissions-cache,TTL 24 小时;
  • user-info 里也有一份。

所以可能出现:

后端已经授予新权限 → 接口可以访问
前端按钮仍然不显示(缓存还是旧的)

处理方式:

  1. 重新登录;
  2. 或调用 useUserStore().fetchAndCachePermissions() 刷新;
  3. 清掉浏览器 localStorage 里的 user-permissions-cache。

一次授权的完整生效时间线

T0  管理员给角色勾选「创建用户」
T0  后端写 admin_role_menu,发布事件,写 casbin p 行
T1  用户下一次请求 POST /users
T1  中间件 LoadPolicy + Enforce,通过
T2  前端刷新权限缓存,按钮出现

接口权限在 T1 生效;页面按钮在 T2 生效。

常用排查 SQL

查某角色的策略:

SELECT * FROM casbin_rule WHERE ptype = 'p' AND v0 = '<roleID>';

查某用户的分组:

SELECT * FROM casbin_rule WHERE ptype = 'g' AND v0 = '<userID>';

查角色菜单:

SELECT * FROM admin_role_menu WHERE role_id = <roleID>;

查用户角色:

SELECT * FROM admin_user_role WHERE user_id = <userID>;

对不上的常见原因

me/permissions 有,接口仍 403

  • Casbin 没有对应 p 行:角色分配菜单的接口没走,或事件监听失败;
  • g 行缺失:用户没有分配角色,或分配后事件没执行;
  • 用户 ID 类型不一致:g 行里存的是十进制字符串,确认没有写成别的格式。

接口能访问,me/permissions 没有

  • 权限来自另一个角色,但前端缓存是旧的;
  • 或者该接口是 AccessAuthenticated / AccessSuperAdmin,根本不需要权限标识。

改了菜单,策略没变

  • 只改了 admin_role_menu 表,没有走 PUT /roles/:id/menus;
  • 事件总线没初始化(listener.Init 未执行),检查 Bootstrap()。

自检

  • [ ] 能说清「我的菜单」和「我的权限」的计算路径;
  • [ ] 知道 p 行和 g 行分别由哪个接口写入;
  • [ ] 知道后端鉴权每次请求都 LoadPolicy;
  • [ ] 知道前端权限有 24 小时缓存;
  • [ ] 能用 SQL 定位策略缺失。

评论

加载评论中…

上一篇 第 5 章 菜单与角色授权:从零完成一次授权 下一篇 第 7 章 前端权限控制:按钮该不该显示

BitLabs © 2026 · 论坛讨论 · 博客长文 · 文档手册