RBAC 动态权限与 JWT 认证——织码在线教育系统用户权限模块架构设计详解

📅 发布时间:2026/7/13 15:10:21
RBAC 动态权限与 JWT 认证——织码在线教育系统用户权限模块架构设计详解 引言在企业级教育平台里权限系统不是附属模块而是整套平台的基础设施。管理员要管理课程和组织运营要配置活动和页面讲师要维护自己的内容学员只能访问授权课程部门主管还需要看见本部门及下级部门的数据。如果权限体系只停留在“角色判断 if/else”随着业务增长最终一定会演变成高耦合、难维护、易越权的隐患源。更难的是企业权限系统往往不是单点问题而是四类问题叠加认证、授权、会话、数据范围。登录时要证明“你是谁”访问时要判断“你能做什么”会话层要保证“你现在是否仍然有效”数据层还要决定“你能看到哪部分数据”。这四类能力如果没有统一设计前后端权限极易出现不一致。本文在原稿基础上做两轮技术深化重点拆解织码在线教育系统用户权限模块背后的架构设计如何用 RBAC 收敛菜单、按钮和 API 权限如何把 JWT 降级为签名载体把 Redis 提升为会话权威如何在网关层完成 API 级鉴权以及如何基于部门树实现数据权限隔离。一、权限系统为什么不能只做角色判断传统后台系统常见的实现方式是if(admin.equals(roleCode)){// 全部放行}elseif(teacher.equals(roleCode)){// 允许讲师操作}这种写法在角色不超过三五个时还能工作但一旦进入企业级场景就会出现明显问题角色与权限强耦合每增加一个角色就要改一轮后端逻辑。前后端规则不一致前端按钮隐藏了不代表接口一定不能调。权限颗粒度不足无法区分菜单、按钮和接口三种层级。数据权限缺失即使同为“运营”不同部门能看到的数据也可能完全不同。一个更合理的权限系统应该把“角色”从代码里抽出来作为可配置的数据对象存在。也就是说系统不直接认识“市场总监”或“培训专员”这些业务称谓而是认识用户属于哪些角色角色关联哪些菜单和操作标识菜单关联哪些后端接口角色的数据范围是什么只有这样权限系统才能从“硬编码规则”升级为“可演化模型”。二、RBAC 模型如何同时承载菜单、按钮与接口授权织码在线教育系统的核心做法是把菜单树作为统一权限载体。sys_menu不只服务前端侧边栏它同时承载三类资源目录和菜单用于前端路由和页面导航。按钮权限通过perms标识符控制按钮级展示。接口权限通过apis字段维护接口资源集合在网关层做 API 校验。CREATETABLEsys_menu(idBIGINTPRIMARYKEY,parent_idBIGINTDEFAULT0,nameVARCHAR(50)NOTNULL,pathVARCHAR(200),componentVARCHAR(255),permsVARCHAR(100),typeTINYINTNOTNULLCOMMENT0目录 1菜单 2按钮,apisTEXTCOMMENTJSON数组如[GET:/admin/course/list],statusTINYINTDEFAULT0);CREATETABLEsys_role(idBIGINTPRIMARYKEY,role_nameVARCHAR(50)NOTNULL,data_scopeTINYINTDEFAULT1COMMENT1全部 2本部门 3本部门及以下,statusTINYINTDEFAULT0);CREATETABLEsys_user_role(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,role_idBIGINTNOTNULL,UNIQUEKEYuk_user_role(user_id,role_id));这里有两个关键设计点第一菜单树一棵到底。这样前后端都只需要维护一份权限主数据避免“前端一套权限表后端另一套资源表”的双维护问题。第二接口权限不直接写在角色表。如果把所有接口直接配到角色上权限复用能力很差把接口挂在菜单节点上就能随着菜单能力组合被复用。在查询层登录后可以按用户 - 角色 - 菜单的路径一次性汇总出当前用户的按钮权限集合和接口权限集合publicPermissionSnapshotbuildSnapshot(LonguserId){ListLongroleIdsroleUserMapper.selectRoleIdsByUserId(userId);ListSysMenumenusmenuMapper.selectMenusByRoleIds(roleIds);SetStringpermsnewHashSet();SetStringapisnewHashSet();for(SysMenumenu:menus){if(menu.getPerms()!null){perms.add(menu.getPerms());}if(menu.getApis()!null){apis.addAll(JSON.parseArray(menu.getApis(),String.class));}}returnnewPermissionSnapshot(perms,apis);}这种实现最大的收益是把“角色”从业务含义变成了纯授权载体。角色名可以变权限绑定关系不需要跟着写死在代码里。—三、JWT 为什么只能做签名载体不能做会话权威很多系统在做认证时会直接把 JWT 当作会话本身认为“JWT 验签通过就说明用户有效”。这种思路的问题在于JWT 天生不擅长主动失效。如果账号被禁用、角色被回收、PC 单点登录要踢掉旧会话仅凭一个还没过期的 JWT 是无法立刻收回权限的。因此更稳妥的设计是JWT 负责证明令牌没有被篡改Redis 负责证明这个会话现在是否仍然有效登录流程可以设计成下面这样publicLoginResultlogin(Stringusername,Stringpassword){SysUseruseruserMapper.selectByUsername(username);if(usernull||!rsaPasswordService.verify(password,user.getPassword())){thrownewBizException(用户名或密码错误);}if(user.getStatus()1){thrownewBizException(账号已禁用);}StringtokenJwtUtil.createToken(user.getId(),user.getClientType(),user.getPcSingleLoginEnable(),nacosConfig);redisTemplate.opsForValue().set(base:admin:token:token,String.valueOf(user.getId()),24,TimeUnit.HOURS);permissionCacheBiz.buildPermissionCache(user.getId());returnnewLoginResult(token,user.getId(),user.getUsername());}这个方案里还有三个容易被忽略的安全细节密钥必须 fail-fast不能在配置缺失时回退到弱默认值。JWT 过期时间必须显式校验有些库默认只验签不校验exp。Redis TTL 才是会话生命周期用户活跃时续期不活跃时自然失效。这样一来系统就能同时满足以下需求手动禁用账号后旧 JWT 立刻失效PC 单点登录能强制踢掉旧会话修改密码后旧 token 可以统一吊销登录保持无状态签名能力但会话仍然可控—四、网关层为什么必须做 API 级鉴权前端动态菜单只能解决“看得见什么”但不能解决“调不调得了接口”。一个用户即使看不到“删除课程”按钮也完全可能通过抓包手动发起DELETE /admin/course/delete。因此真正的访问控制必须落在服务端入口也就是网关层。织码在线教育系统在网关里做了统一的全局过滤白名单放行从 Header 提取 token校验 JWT 签名与过期时间校验 Redis 会话是否存在对/admin/**做METHOD PATH级别的权限匹配会话活动续期并把userId透传给下游OverridepublicMonoVoidfilter(ServerWebExchangeexchange,GatewayFilterChainchain){Stringpathexchange.getRequest().getURI().getPath();if(isWhitelisted(path)){returnchain.filter(exchange);}Stringtokenexchange.getRequest().getHeaders().getFirst(token);if(StrUtil.isBlank(token)||!JwtUtil.verify(token,nacosConfig)){returnunauthorized(exchange,令牌无效);}StringuserIdStrredisTemplate.opsForValue().get(base:admin:token:token);if(StrUtil.isBlank(userIdStr)){returnunauthorized(exchange,会话已过期);}StringapiPermexchange.getRequest().getMethod().name():path;SetStringapisloadApis(userIdStr);if(!hasPermission(apis,apiPerm)){returnforbidden(exchange,无访问权限);}redisTemplate.expire(base:admin:token:token,60,TimeUnit.MINUTES);returnchain.filter(exchange);}这里特别值得强调的是缓存策略。网关层如果每次请求都查 Redis会有额外开销如果全查数据库成本更高。因此通常会采用Caffeine 本地缓存30 秒 TTL减小 Redis 读压力Redis 权限缓存登录和权限变更后重建权限主动刷新角色-菜单、用户-角色变更后刷新受影响用户缓存这意味着权限变更不需要等待凌晨全量重建而是可以在几十秒级别内全网生效。同时这套权限集还能驱动前端动态菜单和按钮级隐藏el-button v-permissioncourse:add typeprimary 新增课程 /el-buttonapp.directive(permission,{mounted(el,binding){constpermsuserStore.permsif(binding.value!perms.includes(binding.value)){el.parentNode?.removeChild(el)}}})这样前端展示和后端接口就共享同一份权限源避免“页面能看、接口不能调”或者“页面隐藏了、接口还能调”的权限错位。—五、数据权限的难点不是组织树而是可计算边界菜单和接口权限解决的是“能不能操作”数据权限解决的是“能看多少数据”。在企业培训平台里这个问题尤为关键。集团培训管理员可能要看全公司数据分公司主管只能看本区域数据部门经理只能看本部门和下级小组数据。如果组织架构只有两层这个问题还不明显但一旦进入集团 - 区域 - 分公司 - 部门 - 小组的多级树结构就必须让数据范围可以被快速计算。一个常见且有效的做法是在部门表中增加ancestors字段CREATETABLEsys_dept(idBIGINTPRIMARYKEY,parent_idBIGINTDEFAULT0,ancestorsVARCHAR(500)DEFAULTCOMMENT如 0,1,5,10,dept_nameVARCHAR(50)NOTNULL,statusTINYINTDEFAULT0);这样做的核心收益是查询“本部门及以下”时不需要递归遍历整棵树。只要把所有祖级路径提前冗余好就能快速根据ancestors找出子部门集合。publicStringbuildDataScope(LonguserId,StringdeptAlias){ListSysRolerolesroleMapper.selectRolesByUserId(userId);booleanhasAllScopefalse;ListLongdeptIdsnewArrayList();for(SysRolerole:roles){switch(role.getDataScope()){case1-hasAllScopetrue;case2-deptIds.add(role.getDeptId());case3-deptIds.addAll(deptMapper.selectChildDeptIds(role.getDeptId()));}}if(hasAllScope)return;if(deptIds.isEmpty())return AND 10;return AND deptAlias.dept_id IN (StringUtils.join(deptIds,,));}当然ancestors方案也意味着部门迁移时要同步更新整棵子树路径所以它更适合读多写少的企业组织结构场景。对于培训平台而言这种折中是合理的因为组织调整频率远低于查询频率。—六、总结用户权限系统如果只看功能清单很容易被理解成“登录、菜单、角色管理”几个页面。但从架构层看它实际承担的是整个平台的安全边界。归纳这套实现最值得借鉴的有四点权限主数据统一用一棵菜单树同时管理前端菜单、按钮权限和后端接口资源减少前后端双维护成本。会话权威下沉到 RedisJWT 负责签名Redis 负责有效性既保留令牌机制的轻量性也解决主动吊销问题。网关统一鉴权所有后台请求在入口完成会话和接口权限校验真正做到 UI 与 API 双重控制。数据权限模型化通过data_scope ancestors把“能看到哪部分数据”从业务代码中抽离出来变成可配置、可计算的规则。企业级权限系统真正难的从来不是表多几张而是如何让认证、授权、会话和数据边界四件事在一个统一架构里协同工作。只有把这四层都打通权限系统才算真正可用、可扩展、可审计。如需私有化部署报价、远程产品演示可访问官网https://www.weavecodes.com/私信作者领取企业落地案例。