给一个角色勾完权限,点保存,接口返回 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 这种受保护接口的请求拆开,它依次经过:
- 认证:token 有没有、过没过期(GfToken 中间件)
- 总开关 + API 开关:安全控制开没开、接口级校验开没开
- 接口权限 :这个用户所属的角色,有没有一条
api类型的授权指向当前 URL - 展示层:按钮和列表列显不显示(前端指令,不属于后端闸门)
其中只有第 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 次往返。
这不是"能不能扛住"的问题------单次查询都很轻,走到量级上之前都不会有感知。但如果哪天要做压测或者排查接口延迟,先知道这几个查询存在,比对着火焰图猜要快得多。
十二、把一个新的接口接进权限体系
按上面的链路,顺序是固定的:
- 记接口 :系统管理 → 接口管理,新增一条,填
method和address(address要带/api/v1前缀,鉴权时拿r.Request.URL.Path直接比对),status置为启用。 - 绑菜单 :把这条接口关联到它归属的菜单(
sys_menu_api)。跳了这步,非超管一律报"接口未绑定菜单"。 - 配按钮和列 (可选):需要在页面上按角色灰按钮、隐列时,先在菜单下加按钮/列表字段,编码要和前端的
v-auth="'xxx'"/v-col="'xxx'"对得上,再把sys.button.switch/sys.column.switch打开。 - 授权给角色:角色管理 → 角色权限,按"菜单 → 按钮 → 列表 → 接口"四步勾选保存。
- 让用户重新登录:接口权限保存即生效;但被改动的用户要重新登录,才能拿到新的菜单树和按钮列表。
第 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的判定依据不一致。
最后,两条腿都要看:后端接口权限是硬闸门,前端按钮列表是软闸门。只配前端不配接口,等于没配。
项目地址: