权限改了,接口立刻生效,菜单要等重新登录:SagooIoT 权限体系的四道闸门

给一个角色勾完权限,点保存,接口返回 200。让那个用户刷新页面------菜单还是旧的。

这不是 bug。在 SagooIoT 的权限体系里,"这个接口你能不能调"和"这个菜单按钮显不显示"走的是两条路:一条每次请求都去数据库问一遍,另一条的结果被缓存到用户下次登录为止。两边的生效时间自然对不上。

要把这件事讲透,得先认全它背后的六张表,再跟着一次请求把四道闸门走一遍。

一、先认六张表:权限不挂在用户身上

SagooIoT 的权限模型里,用户不直接拥有权限。链条是:用户 → 角色 → 条目。条目分四类,用一个字段区分:

items_type 指向 存在哪张表
menu 菜单/目录 sys_menu
button 页面上的操作按钮 sys_menu_button
column 列表里的一列 sys_menu_column
api 一个后端接口地址 sys_api

sys_authorize 这张表就是"角色 × 条目"的叉乘:

sql 复制代码
CREATE TABLE `sys_authorize` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `role_id` int(11) NOT NULL COMMENT '角色ID',
  `items_type` varchar(50) NOT NULL COMMENT '项目类型 menu菜单 button按钮 column列表字段 api接口',
  `items_id` int(11) NOT NULL COMMENT '项目ID',
  `is_check_all` int(11) NOT NULL COMMENT '是否全选 1是 0否',
  `is_deleted` int(11) NOT NULL,
  ...
) COMMENT='角色与菜单、按钮、列表权限关系';

四类条目还各自有一张"归属表"------按钮属于哪个菜单、列表字段属于哪个菜单、接口属于哪个菜单:

表 作用 init.sql 里的记录数
sys_menu 菜单/目录 114(有效 49 / 已删 65)
sys_menu_button 菜单下的按钮 247(有效 114 / 已删 133)
sys_menu_column 菜单下的列表字段 281(有效 174 / 已删 107)
sys_api 接口清单(含分类节点) 594(61 条分类 + 533 条接口)
sys_menu_api 接口挂在哪个菜单下 2497(有效 227 / 已删 2270)
sys_authorize 角色拿到了哪些条目 20023(全部 is_deleted=1)

最后一行值得停一下:两万条种子授权记录,is_deleted 全是 1。这批数据对应的是演示角色,角色被删掉之后,挂在它下面的授权记录也一起软删了。所以一个全新初始化的库,除了超级管理员,没有任何角色持有权限------这不是脏数据,是初始状态。

组织结构那几张表的种子数据更能说明问题:

表 记录数 说明
sys_organization 14(有效 5) 组织树,配合应用管理做多租户
sys_dept 1(已删) 部门,ancestors 存祖级路径
sys_post 14(有效 1) 岗位
sys_role 12(有效 1,id=1 超管) 角色
sys_user_role 7 用户与角色绑定
sys_user_post 23 用户与岗位绑定
sys_role_dept 0 角色与部门的自定义数据权限绑定

sys_role_dept 是空表,这一条后面还会回来讲。

二、一次请求要穿过四道闸门

把 /api/v1/system/user/list 这种受保护接口的请求拆开,它依次经过:

  1. 认证:token 有没有、过没过期(GfToken 中间件)
  2. 总开关 + API 开关:安全控制开没开、接口级校验开没开
  3. 接口权限 :这个用户所属的角色,有没有一条 api 类型的授权指向当前 URL
  4. 展示层:按钮和列表列显不显示(前端指令,不属于后端闸门)

其中只有第 3 道真正决定"能不能调得到数据"。第 4 道是体验。前两道是开关。这个分层不难,难的是每一道的具体判定条件都藏着一个坑。

三、第一道闸门:白名单在配置文件里,不在数据库里

受保护的分组在路由层显式挂载中间件,command/router/ 下一共 8 处:

go 复制代码
group.Group("/system", func(group *ghttp.RouterGroup) {
    group.Middleware(service.Middleware().Auth)
    group.Bind(
        systemController.SysRole,   // 角色
        systemController.SysUser,   // 用户
        systemController.SysMenu,   // 菜单
        ...
    )
})

而 token 校验是全局挂的,免登录名单来自配置文件的 gfToken.excludePaths:

yaml 复制代码
gfToken:
  timeOut: 10800         #token超时时间(秒)
  maxRefresh: 5400       #token自动刷新时间(秒)
  multiLogin: true       #是否允许一个账号多人同时登录
  encryptKey: "49c54195e750b04e74a8429b17896586"    #加密key (32位)
  excludePaths: #排除不做登录验证的路由地址
    - "/api/v1/login"
    - "/api/v1/sysinfo"
    - "/api/v1/captcha"

免登录名单只有三条,且只能在配置文件里改,管理端没有对应界面------改完要重启。这是个明确的取舍:白名单属于部署期决定的事,不适合让运维在页面上随时改。

但 timeOut: 10800 这行有个陷阱。看初始化 token 服务的代码:

go 复制代码
//判断控制是否生效
configDataByIsSecurityControlEnabled, err := service.ConfigData().GetConfigByKey(ctx, consts.SysIsSecurityControlEnabled)
...
if configDataByIsSecurityControlEnabled != nil && strings.EqualFold(configDataByIsSecurityControlEnabled.ConfigValue, "1") {
    //获取token过期时间
    configDataByTokenExpiryDate, err = service.ConfigData().GetConfigByKey(ctx, consts.SysTokenExpiryDate)
    ...
}
err = g.Cfg().MustGet(ctx, "gfToken").Struct(&gftService.options)
//设置token过期时间
if configDataByTokenExpiryDate != nil {
    var minutes int
    minutes, err = strconv.Atoi(configDataByTokenExpiryDate.ConfigValue)
    ...
    gftService.options.Timeout = int64(minutes * 60)
}

顺序是:先读配置文件,再用数据库里的值覆盖它 。数据库里 sys.token.expiry.date 的默认值是 120,单位是分钟:

复制代码
sys.token.expiry.date          默认=120   TOKEN过期时间

所以配置文件里写的 10800 秒其实不生效,实际是 120 × 60 = 7200 秒。而 sys.is.single.login 默认 0,代码里的判定是"0 → MultiLogin = true",也就是默认允许一个账号多设备同时登录 ------和配置文件里写的 multiLogin: true 一致,但决定权在数据库。

同一段逻辑还有个副作用:如果 sys.is.security.control.enabled 不等于 1,上面两个数据库值一个都不读,timeOut: 10800 和 multiLogin: true 就"复活"了。关掉总开关,token 过期时间会从 7200 秒变回 10800 秒------这是很多人没预期到的连带变化。

四、第二道闸门:两级开关,文档里只写了一个

代码里的 Auth 中间件要先过两道开关:

go 复制代码
//判断是否启用安全控制
configDataByIsSecurityControlEnabled, _ = service.ConfigData().GetConfigByKey(r.Context(), consts.SysIsSecurityControlEnabled)
sysApiSwitch := 0
if configDataByIsSecurityControlEnabled != nil && strings.EqualFold(configDataByIsSecurityControlEnabled.ConfigValue, "1") {
    //查询API开关是否打开
    sysApiSwitchConfig, _ := service.ConfigData().GetConfigByKey(r.Context(), consts.SysApiSwitch)
    if sysApiSwitchConfig != nil {
        sysApiSwitch = gconv.Int(sysApiSwitchConfig.ConfigValue)
    }
}

if sysApiSwitch == 0 {
    r.Middleware.Next()
    return
}

两级:sys.is.security.control.enabled(总开关)→ sys.api.switch(接口级校验开关)。只有两个都打开,才会真的去查权限。默认值两个都是 1。

官方文档里给的示例代码只有一个 IsOpenAccessControl 开关,和现在的实现已经不是同一版了------这是读文档时容易踩空的地方。

同一个模块下的系统参数一共 17 项,都属于 security 分类:

参数 默认值 作用
sys.is.security.control.enabled 1 安全控制总开关
sys.api.switch 1 接口权限校验开关
sys.button.switch 0 按钮权限开关
sys.column.switch 0 列表字段权限开关
sys.token.expiry.date 120 token 过期(分钟)
sys.is.single.login 0 是否禁止多设备登录
sys.password.error.num 3 密码连续错误次数
sys.again.login.date 10 错误后允许再次登录的间隔(分钟)
sys.password.minimum.length 8 密码最小长度
sys.is.rsa.enabled 1 登录是否启用 RSA 加密

注意 sys.button.switch 和 sys.column.switch 默认都是 0:开箱状态下按钮和列的权限控制是关的,只有接口权限在把关。想按角色把按钮灰掉,得先把这两个开关打开。

五、第三道闸门:五步查询链

过了两个开关,Auth 才开始真正查权限。这条链有五步:

go 复制代码
url := r.Request.URL.Path
if strings.EqualFold(url, "/api/v1/system/user/currentUser") || strings.EqualFold(url, "/api/v1/common/dict/data/list") {
    r.Middleware.Next()
    return
}

// 1. 拿用户角色
userRoleInfo, err := service.SysUserRole().GetInfoByUserId(r.Context(), userId)
...
var roleIds []int
var isSuperAdmin = false
for _, userRole := range userRoleInfo {
    if userRole.RoleId == 1 {   // 超级管理员硬编码
        isSuperAdmin = true
    }
    roleIds = append(roleIds, userRole.RoleId)
}
if isSuperAdmin {
    r.Middleware.Next()
    return
}

// 2. 角色拿到了哪些 api 类型的授权条目
authorizeInfo, _ := service.SysAuthorize().GetInfoByRoleIdsAndItemsType(r.Context(), roleIds, consts.Api)
// 3. 这些授权条目指向哪些菜单-接口绑定记录
menuApiInfo, _ := service.SysMenuApi().GetInfoByIds(r.Context(), menuApiIds)
// 4. 这些绑定记录指向哪些接口
apiInfo, _ := service.SysApi().GetInfoByIds(r.Context(), apiIds)
// 5. 接口地址和当前 URL 逐个比
for _, api := range apiInfo {
    if strings.EqualFold(url, api.Address) {
        isExist = true
        break
    }
}

白名单两条:/api/v1/system/user/currentUser(前端要拿当前用户和菜单)和 /api/v1/common/dict/data/list(字典表,几乎每个页面都要)。超级管理员靠 roleId == 1 硬编码放行,sys_role 里 id=1 那条也确实是"超级管理员"。

链上任何一步空掉,返回的错误信息都不一样,排查时可以据此定位:

失败点 返回的提示
用户没有任何角色 用户未配置角色信息,请联系管理员
角色没有任何 api 授权 未授权接口,无访问权限!
授权条目没有对应的菜单-接口绑定 接口未绑定菜单,请联系管理员!
查不到接口记录 相关接口未配置
URL 不在允许列表里 无权限访问

六、这条链上的四条硬约束

约束一:只比 URL,不比请求方法。

sys_api 表里是有 method 字段的,种子数据里也确实填了:

复制代码
接口 method 分布: get 225 / post 117 / put 77 / delete 65
                 GET 19 / POST 22 / PUT 4 / DELETE 4

但第 5 步的比对只做了一件事------strings.EqualFold(url, api.Address)。method 从头到尾没被读。这意味着授权是按"路径"给的,不是按"路径 + 方法"给的:一个角色如果被授权了 /api/v1/product/list,那么不管这个路径上挂的是 GET 还是 POST,判定结果一样。

顺带解释了另一个现象:同一个字段里 get 和 GET 混着存了 49 条,从来没人发现,因为没人读它 。字段设计了、数据填了、判定里没用------这是权限系统里"看起来有、实际没有"的最典型形态。真要做方法级最小权限,得先让比对带上 api.Method。

约束二:接口必须先绑菜单。

第 3 步查的是 sys_menu_api------接口和菜单的绑定关系,不是 sys_api 本身。也就是说,一个接口只有在"挂在某个菜单下"之后,才可能被授权出去。新加一个接口但忘了绑定菜单,结果是所有非超管用户都调不动,报错是"接口未绑定菜单,请联系管理员"。

这条设计有它的道理(授权界面是按菜单树勾选的,先有归属才能勾),但它把"接口是否可用"和"接口挂在哪个菜单下"绑在了一起。sys_menu_api 2497 条记录里只有 227 条有效,绝大多数是历史菜单被删后留下的残骸。

约束三:接口条目自己有启停和删除状态。

sys_api 594 条里,status 启用 379 / 停用 215,is_deleted 保留 370 / 已删 224。而读取时是这么过滤的:

go 复制代码
dao.SysApi.Ctx(ctx).Where(g.Map{
    dao.SysApi.Columns().IsDeleted: 0,
    dao.SysApi.Columns().Status:    1,
})

所以一个接口要在"存在 + 启用 + 已绑菜单 + 已授权给角色"四个条件同时成立时才放行。停用一个接口不必删它,改 status 即可。

约束四:未登录时中间件写的是空响应,不是 401。

go 复制代码
func (s *sMiddleware) Auth(r *ghttp.Request) {
    userId := service.Context().GetUserId(r.Context())
    if userId == 0 {
        return   // 既没有 Next(),也没有写任何响应
    }
    ...
}

文档里给的版本是"未登录 → 返回一个 JSON 错误并提示去登录",现在代码里是直接 return。它不调 Next(),业务处理函数就不会执行;同时也没写任何响应体。链路上包在最外层的 ResponseHandler 会兜底补一个标准响应:

go 复制代码
if r.Response.BufferLength() > 0 { return }
err := r.GetError()
res := r.GetHandlerResponse()
code := gcode.CodeOK
...
response.Json(r, code.Code(), "", res)

结果客户端收到的是 HTTP 200 + {"code":0,"message":"","data":null}------一个"成功"形状的空响应。

正常情况下走不到这条路:token 校验中间件在 Auth 之前,token 不对就已经被打回去了。能走到的场景是"token 在缓存里还有效,但 JWT 解不开"------比如服务端换了 encryptKey 而缓存没清。这时候前端拿到 code:0,拦截器按成功处理,页面上表现成一个空列表,而不是跳登录页。从防御性编程的角度,这里补一个显式的未登录响应会更稳。

七、第四道闸门:前端的按钮和列,是软闸门

前端有两组指令,一组管按钮,一组管列表列:

ts 复制代码
/**
 * 用户权限指令
 * @directive 单个权限验证(v-auth="xxx")
 * @directive 多个权限验证,满足一个则显示(v-auths="[xxx,xxx]")
 * @directive 多个权限验证,全部满足则显示(v-auth-all="[xxx,xxx]")
 */
export function authDirective(app: App) {
	const allPermissions = "*/*/*"
	app.directive('auth', {
		mounted(el, binding) {
			if (localStorage.btnNoAuth) return
			const buttons = <string[]>router.currentRoute.value.meta.buttons
			if (buttons.includes(allPermissions)) return
			if (!buttons.includes(binding.value)) el.parentNode.removeChild(el)
		},
	});

几个要点:

  • 权限清单来自路由的 meta.buttons,也就是后端菜单接口下发给当前用户的那棵树,不是前端写死的。
  • 判定失败时执行的是 parentNode.removeChild(el)------按钮直接从 DOM 上摘掉,不是置灰。想看看到底有没有这个按钮,翻 DOM 是找不到的。
  • */*/* 是通配符,超管的按钮列表里带着它,所有指令直接放行。
  • 有个浏览器本地的旁路开关:localStorage.btnNoAuth(列权限是 colNoAuth)只要存在,整组指令直接跳过。这是给开发调试留的口子,也再次说明前端这层只是体验------真正的闸门在后端第三道。

接口权限是靠 sys.api.switch 管的,按钮和列的开关是 sys.button.switch / sys.column.switch,默认关着。

八、数据权限:表和接口都齐了,消费端没人调

前面看到 sys_role_dept 是空表,但它不是被废弃的设计------整套配置链路是完整的:

角色表的 data_scope 字段定义了四种数据范围:

sql 复制代码
`data_scope` tinyint unsigned DEFAULT '3' COMMENT '数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 4:本部门及以下数据权限)'

管理端有对应的接口和逻辑,POST /api/v1/system/role/dataScope 提交角色 ID、数据范围和部门 ID 列表:

go 复制代码
// DataScope 角色数据授权
func (s *sSysRole) DataScope(ctx context.Context, id int, dataScope uint, deptIds []int64) (err error) {
	if dataScope == 2 {
		if deptIds == nil {
			return gerror.New("请选择部门信息")
		}
	}
	...
	err = g.Try(ctx, func(ctx context.Context) {
		role.DataScope = dataScope
		_, editErr := dao.SysRole.Ctx(ctx).Data(role).Where(dao.SysRole.Columns().Id, id).Update()
		...
		if dataScope == 2 {
			// 删除原有绑定关系
			_, delErr := dao.SysRoleDept.Ctx(ctx).Where(dao.SysRoleDept.Columns().RoleId, id).Delete()
			// 添加绑定关系
			_, addErr := dao.SysRoleDept.Ctx(ctx).Data(roleDepts).Insert()
		}
	})

写入侧一切正常,事务里先删旧绑定再插新绑定,GetInfoByRoleId 读的时候还会把已停用、已删除的部门过滤掉。问题出在读取侧。

官方文档【数据权限开发】给的做法是:在列表查询的 logic 里加一行调用。

go 复制代码
    //根据数据权限过滤数据
    m, _ = service.SysAuthorize().FilterDataByPermissions(ctx, m)

但这个方法在开源仓库里不存在 。ISysAuthorize 接口声明的全部方法是:

复制代码
AuthorizeQuery / GetInfoByRoleId / GetInfoByRoleIds / GetInfoByRoleIdsAndItemsType
DelByRoleId / Add / AddAuthorize / IsAllowAuthorize / InitAuthorize

没有 FilterDataByPermissions。全仓库搜这个名字,零命中。搜文档里提到的另一个名字 GetDataWhere,只在 sys_login_log.go(访问日志列表)里出现过一次------而且是被注释掉的:

go 复制代码
	/*where,err := GetDataWhere(ctx, service.Context().GetUserId(ctx), new(entity.SysLoginLog))
	if err != nil {
		return
	}
	if len(where) > 0 {
		m = m.Where(where)
	}*/

这是整个开源仓库里唯一一处试图用数据权限过滤查询的地方,它没有生效。

所以现在的状态是:数据权限可以配、可以存、可以查回来,但没有任何一个列表查询会去读它 。想让数据权限真正起作用,需要按文档的思路自己接:给业务表加 dept_id 字段,然后在每个列表查询里根据当前用户的 data_scope 拼 where 条件。data_scope 默认值是 3(本部门数据权限),也就是说------如果哪天你把过滤逻辑接上,所有存量角色的默认行为会从"看全部"变成"只看本部门"。接的时候要么先把角色批量改成 1,要么在代码里把"未显式配置"和"配置为本部门"区分开。

九、两处缓存,两个生效时间

文章开头那个"改完权限、菜单还是旧的",根因在用户菜单的缓存上。

/api/v1/system/user/currentUser 这个接口负责把当前用户的菜单树连同按钮、列表字段、接口一起下发,结果会写进缓存:

go 复制代码
if menuTreeOut != nil {
    err := cache.Instance().Set(ctx, consts.CacheUserAuthorize+"_"+gconv.String(loginUserId), menuTreeOut, 0)
    ...
}

第三个参数是过期时间,传的是 0 ,在 GoFrame 的 gcache 里表示永不过期。而这个 key 在整个仓库里只有两处主动清理,都在 login.go:一处是登录、一处是登出。

go 复制代码
func (s *sLogin) LoginOut(ctx context.Context) (err error) {
	...
	loginUserId := service.Context().GetUserId(ctx)
	_, err = cache.Instance().Remove(ctx, userOnline.Key)
	_, err = cache.Instance().Remove(ctx, consts.CacheUserAuthorize+"_"+gconv.String(loginUserId))
	_, err = cache.Instance().Remove(ctx, consts.CacheUserInfo+"_"+gconv.String(loginUserId))
	...
}

于是形成了文章开头那组时间差:

变更内容 接口侧生效时间 菜单/按钮侧生效时间
调整角色权限 立即(鉴权直查库) 用户下次登录
新增菜单/按钮 立即 用户下次登录
停用某接口 立即 ------

"立即"这一侧,是因为 Auth 走的那个方法根本没有读缓存:

go 复制代码
// GetInfoByRoleIdsAndItemsType 根据角色ID和项目类型获取权限信息
func (s *sSysAuthorize) GetInfoByRoleIdsAndItemsType(ctx context.Context, roleIds []int, itemsType string) (data []*entity.SysAuthorize, err error) {
	err = dao.SysAuthorize.Ctx(ctx).Where(g.Map{
		dao.SysAuthorize.Columns().IsDeleted: 0,
		dao.SysAuthorize.Columns().ItemsType: itemsType,
	}).WhereIn(dao.SysAuthorize.Columns().RoleId, roleIds).Scan(&data)
	return
}

直接查库,没有 cache.Instance().Get。而同一份数据在 GetInfoByRoleIds 里是走缓存的:

go 复制代码
	tmpData, err = cache.Instance().Get(ctx, consts.CacheSysAuthorize+"_"+gconv.String(v))

SystemCache:sysAuthorize:<roleId> 这个 key 在授权保存和启动预热时都会写,读它的却只有 GetInfoByRoleIds------也就是"装配用户菜单"和"判断能不能给某个角色授权"这两条路径。鉴权主路径绕开了它。

缓存建了但主路径没用,还有一处是"命中了也照样查库":

go 复制代码
// sys_menu_api.go  GetInfoByIds
tmpData, err = cache.Instance().Get(ctx, consts.CacheSysMenuApi)
...
if data == nil || len(data) > 0 {
	err = dao.SysMenuApi.Ctx(ctx).Where(g.Map{...}).WhereIn(dao.SysMenuApi.Columns().Id, ids).Scan(&data)
}

data == nil || len(data) > 0 这个条件,只有在"拿到一个非 nil 的空切片"时才为假。缓存命中且匹配到记录时 len(data) > 0 成立,于是又去查了一次库 ,并把结果覆盖回 data。缓存没省下查询,只是多了一次读缓存的开销。

同样的写法在 sys_api.go 里就不一样:

go 复制代码
	if apiInfo != nil && len(apiInfo) >= 0 {
		data = apiInfo
		return
	}

len(apiInfo) >= 0 恒为真,所以这里的实际判断只剩 apiInfo != nil------缓存命中就直接返回。这一处是真的用上了缓存。

最后提一句 InitAuthorize。它的名字容易让人以为是"初始化权限数据",其实做的是缓存预热:启动时把菜单、按钮、列表字段、接口、菜单-接口绑定、角色授权全部读出来塞进缓存。它在启动初始化清单的 WebAdmin 批次里排第一:

go 复制代码
var InitFuncNoDeferListWebAdmin = []NoDeferFunc{
	{service.SysAuthorize().InitAuthorize, "系统权限"},
	{initSystemStatistics, "系统统计"},
	{service.SysInfo().ServerInfoEscalation, "集群数据"},
	{initPlugins, "插件"},
}

它不会创建任何授权关系------库是空的,预热出来也是空的。

十、checkAccessAuth:读的开关和 Auth 不一样

除了 Auth 中间件,还有一个独立接口让前端主动问"我能不能访问这个地址":

go 复制代码
type CheckAccessAuthReq struct {
	g.Meta  `path:"/checkAccessAuth" method:"get" summary:"验证接口是否具有访问权限" tags:"公共方法"`
	Address string `p:"address"   description:"接口地址" v:"required#接口地址不能为空"`
}

它的查询逻辑和 Auth 的第三道闸门几乎一样------同样查角色、同样硬编码 roleId == 1、同样比 URL 字符串。区别在开关那一行:

go 复制代码
// CheckAccessAuth 验证访问权限
func (s *sCheckAuth) CheckAccessAuth(ctx context.Context, address string) (isAllow bool, err error) {
	//查询API开关是否打开
	sysApiSwitchConfig, _ := service.ConfigData().GetConfigByKey(ctx, "sys.api.switch")
	...
}

它只读 sys.api.switch,不读总开关 sys.is.security.control.enabled 。两个默认值都是 1 时行为一致;一旦有人把总开关关掉、sys.api.switch 留着不管,Auth 会直接放行所有请求,而 checkAccessAuth 仍然会按角色返回 false------前端把按钮灰掉,后端其实全放。这种不一致不会报错,只会在"明明有权限却点不动"和"明明没权限却能调通"之间来回摆动。

同一个控制器里还有个 /isToken,返回当前 token 状态和 read / save 两种授权级别,用于规则引擎里的只读降级,这里就不展开了。

十一、一次鉴权的数据库账

把上面的路径串起来,一个 3 个角色的用户调一次受保护接口,光鉴权部分要打这些查询:

步骤 查询
1. 取用户角色 select * from sys_user_role where user_id = ?
2. 逐个校验角色状态 每个角色 1 次 count(*) from sys_role where id = ? and status = 1 and is_deleted = 0
3. 取 api 类型授权 select * from sys_authorize where items_type = 'api' and role_id in (...) and is_deleted = 0
4. 取菜单-接口绑定 select * from sys_menu_api where id in (...) and is_deleted = 0(命中缓存后仍会查)
5. 取接口记录 走 SystemCache:sysApi 缓存,命中则 0 次

也就是 3 + N 次 (N 是角色数),3 个角色就是 6 次。第 2 步是典型的 N+1:GetInfoByUserId 拿到角色列表后,在循环里对每个角色单独发一次 count 去过滤已停用、已删除的角色。把这一步换成一次 where id in (...) and status = 1 and is_deleted = 0 的联表查询,能直接省掉 N 次往返。

这不是"能不能扛住"的问题------单次查询都很轻,走到量级上之前都不会有感知。但如果哪天要做压测或者排查接口延迟,先知道这几个查询存在,比对着火焰图猜要快得多。

十二、把一个新的接口接进权限体系

按上面的链路,顺序是固定的:

  1. 记接口 :系统管理 → 接口管理,新增一条,填 method 和 address(address 要带 /api/v1 前缀,鉴权时拿 r.Request.URL.Path 直接比对),status 置为启用。
  2. 绑菜单 :把这条接口关联到它归属的菜单(sys_menu_api)。跳了这步,非超管一律报"接口未绑定菜单"。
  3. 配按钮和列 (可选):需要在页面上按角色灰按钮、隐列时,先在菜单下加按钮/列表字段,编码要和前端的 v-auth="'xxx'" / v-col="'xxx'" 对得上,再把 sys.button.switch / sys.column.switch 打开。
  4. 授权给角色:角色管理 → 角色权限,按"菜单 → 按钮 → 列表 → 接口"四步勾选保存。
  5. 让用户重新登录:接口权限保存即生效;但被改动的用户要重新登录,才能拿到新的菜单树和按钮列表。

第 5 步是最容易被漏掉的一步。如果用户反馈"权限改了没反应",先让他退出重登,再去看后台配置。

十三、小结

SagooIoT 的权限体系,把"能进门""能调哪个接口""能看哪些行""按钮灰不灰"拆成了四层,每层有自己的开关和存储位置:

  • 认证:token,免登录白名单在配置文件,过期时间在数据库且覆盖配置文件。
  • 开关:总开关 + API 开关两级串联,另外还有按钮、列两个独立开关,默认关。
  • 接口权限 :角色 → 授权条目 → 菜单-接口绑定 → 接口记录 → URL 字符串比对。超管硬编码 roleId == 1,两条白名单接口。
  • 展示 :前端指令,按路由 meta.buttons 判定,removeChild 直接从 DOM 摘掉。
  • 数据权限 :模型、表、接口、写入逻辑都完备,data_scope 四种范围和 sys_role_dept 自定范围都在,缺的是查询侧那一行调用------目前没有任何列表查询会读它。

几个从代码里读出来的、值得记一笔的点:

  • 授权只比 URL,不比 method,方法级最小权限做不到。
  • 未登录时中间件返回的是 200 + code:0 的空成功响应,不是 401。
  • 用户菜单缓存永不过期,只在登录/登出时清理,因此权限变更的"接口侧"和"界面侧"生效时间必然不一致。
  • sys_menu_api 的缓存命中后仍会查一次库,条件 data == nil || len(data) > 0 恒真。
  • checkAccessAuth 不读总开关,和 Auth 的判定依据不一致。

最后,两条腿都要看:后端接口权限是硬闸门,前端按钮列表是软闸门。只配前端不配接口,等于没配。

项目地址:

相关推荐
文慧的科技江湖1 小时前
2026年10月4日充电桩行业晚报:当“只充80%“成为通行规则,行业开始管单车占桩时长 | 慧知开源充电桩平台
开源·apache·新能源·虚拟电厂·充电桩·ocpp·v2g
零基础1231 小时前
VoiceStudio 开源项目深度解析:特性、对比与实战测试
人工智能·经验分享·python·开源
悟天特斯2 小时前
AI驱动的楼宇节能:从“经验省电“到“算法能效“的进化之路
人工智能·物联网
wuyk5553 小时前
《WiFi 嵌入式物联网开发全套实战》| 第 29 章 WiFi 长连接保活机制:无线层 + 应用层双重心跳
物联网
乐讯通物联网服务商3 小时前
外勤执法记录仪接入物联网:间歇联网终端通信方案选型分析
物联网·执法记录仪·物联网卡
miofly3 小时前
GitHub 日榜趋势速报 | 2026-10-05
开源·github
m4Rk_3 小时前
【论文阅读】Agent 记忆机制(90):HyperMem——用超图建模长期记忆中的高阶关联
论文阅读·人工智能·学习·开源·github
miofly12 小时前
Aleph Alpha 开源 78B 参数 MoE 模型 Kolibri
开源·github
微三云马玮均—GEO源码系统 私有化部署12 小时前
消费返物业费:消费+服务趋势的必然产物!
大数据·人工智能·物联网·区块链·生活