一文讲透企业级权限管理:RBAC+数据权限+多租户隔离——SpringBoot+Vue3 权限体系设计全解析
📦 源码1:ruoyi-office-vben |📦 源码2:ruoyi-office |📦 源码3:ruoyi-office
你的系统上线后,老板发现自己能看到所有部门的工资单,实习生发现自己能删除线上配置,A 公司的管理员居然能看到 B 公司的客户数据。这三个"事故"分别对应权限体系的三个层次:功能权限没管住"能做什么",数据权限没管住"能看什么",租户隔离没管住"能看谁的"。 企业级权限管理远不是一个
@PreAuthorize注解能解决的——它是一个从数据库表设计到 MyBatis 拦截器,从后端校验链路到前端按钮显隐,从单租户到 SaaS 多租户的系统性工程。
引言:权限管理到底难在哪?
"加个角色表,关联一下菜单不就行了?"——初次接手权限模块的开发者大多这么想。但真正深入后会发现,企业级权限管理的复杂度远超"RBAC 三个字母":
功能权限的粒度问题:菜单级权限太粗,按钮级权限太碎。同一个页面上,A 角色能"新建"但不能"删除",B 角色能"查看"但不能"导出"。前端的按钮显隐和后端的接口校验必须用同一套权限标识,否则"前端藏了按钮,后端没拦请求"就是一个安全漏洞。
数据权限的隐蔽性:功能权限决定"能不能点这个按钮",数据权限决定"点了之后能看到哪些数据"。部门经理只能看本部门的考勤记录,区域总监能看下辖所有部门的销售数据,超级管理员能看全部——这种按部门层级的数据隔离,不可能靠业务代码逐个 WHERE 实现。
多租户的边界问题:SaaS 场景下,每个租户是一个独立的"小世界"。租户 A 的角色、菜单、用户都不能被租户 B 看到。但有些表(如系统菜单)是所有租户共享的,有些表(如业务数据)必须严格隔离——哪些表加 tenant_id,哪些表不加,决策失误就是数据泄露。
认证与鉴权的分离:微服务架构下,网关负责认证(你是谁),业务服务负责鉴权(你能做什么)。Token 校验、用户信息透传、权限缓存、跨服务 RPC 调用——每一环断裂都会导致权限失效。
本文以 RuoYi Office 的权限体系为例,从设计方法论的角度,完整拆解"功能权限 + 数据权限 + 多租户隔离"三位一体的权限架构。

什么是 RBAC?——企业级权限管理的事实标准
RBAC(Role-Based Access Control,基于角色的访问控制) 是 1992 年由 David Ferraiolo 和 Rick Kuhn 在 NIST(美国国家标准与技术研究院)正式提出的访问控制模型。其核心思想只有一句话:不直接给用户分配权限,而是把权限赋予角色,再把角色赋予用户。
这意味着,当一个组织有 500 名员工和 200 个操作权限时,管理员不需要维护 500 × 200 = 100,000 条用户-权限映射,而只需要定义 10~20 个角色(如"普通员工""部门经理""财务主管""超级管理员"),给每个角色分配一组权限,再把用户划入相应角色——维护量从十万级降到百级。
为什么几乎所有主流企业软件都选择 RBAC?
| 平台/产品 | 权限模型 | 说明 |
|---|---|---|
| 钉钉 | RBAC + 数据权限 | 管理后台通过"角色组"控制可见菜单与操作,数据按部门隔离 |
| 飞书 | RBAC + ABAC 混合 | 基础功能按角色授权,高级场景(如文档权限)引入属性条件 |
| 用友 YonBIP | RBAC + 组织维度 | 集团级多法人场景下,角色 × 组织交叉授权 |
| 金蝶云星空 | RBAC + 权限范围 | 角色 + 业务组织 + 数据规则三层控制 |
| SAP S/4HANA | RBAC(角色 = Profile + Authorization Object) | 企业级 ERP 标杆,通过事务码绑定角色实现权限管控 |
| Odoo | RBAC(Groups) | 开源 ERP 代表,用户组即角色,模块级权限 + 记录规则 |
| 若依(RuoYi) | RBAC + 数据权限 + 多租户 | Spring Security + MyBatis 拦截器,国内开源标杆 |
| RuoYi Office | RBAC + 数据权限 + 多租户隔离 | 本文重点,三层防线完整实现 |
RBAC 成为事实标准的原因可以归纳为三点:
- 符合组织管理直觉:企业天然按"岗位/角色"划分职责,RBAC 的角色概念与组织架构一一对应,业务人员无需理解技术细节即可完成授权。
- 权限变更成本低:员工调岗只需变更角色绑定,而非逐条修改权限。新增一个功能模块,只需给对应角色追加权限。
- 可审计、可追溯:角色作为中间层,使"谁拥有什么权限"清晰可查,满足等保合规、SOX 审计等企业级安全要求。
RBAC 的层次演进
学术上 RBAC 分为四个级别(RBAC₀ ~ RBAC₃),企业实践中最常用的是 RBAC₁(角色继承):
| 级别 | 特性 | 典型场景 |
|---|---|---|
| RBAC₀ | 用户 ↔ 角色 ↔ 权限,最基础的三者关系 | 小型系统、内部工具 |
| RBAC₁ | 在 RBAC₀ 基础上增加角色继承(角色层级) | 部门经理继承普通员工权限 |
| RBAC₂ | 在 RBAC₀ 基础上增加约束(互斥角色、数量限制) | 财务出纳与审核不能同一人 |
| RBAC₃ | RBAC₁ + RBAC₂,同时拥有继承和约束 | 大型集团企业 |
RuoYi Office 实现的权限模型在 RBAC₁ 基础上,额外叠加了数据权限(按部门/自定义范围控制数据可见性)和多租户隔离(SaaS 场景下租户间数据完全隔离),形成了"功能权限 + 数据权限 + 租户隔离"的三层防线体系。下面我们逐层拆解。
一、权限体系全景:三层防线的协同
1.1 三层权限模型
企业级权限管理不是一个单点问题,而是三层防线的协同:
| 层次 | 解决的问题 | 核心机制 | 作用域 |
|---|---|---|---|
| 功能权限(RBAC) | 用户能执行哪些操作 | 用户→角色→菜单/按钮权限字符串 | 接口级 + 按钮级 |
| 数据权限 | 用户能看到哪些数据 | 角色→数据范围→MyBatis 拦截器自动注入 SQL 条件 | 行级 |
| 租户隔离 | 不同租户的数据彼此不可见 | 租户→套餐→MyBatis-Plus 自动追加 tenant_id | 全表级 |
三者的关系不是"选一个用",而是层层叠加:
请求进入 → 认证(你是谁)→ 功能权限(你能做什么)→ 数据权限(你能看哪些)→ 租户隔离(你是哪家公司的)
1.2 核心设计决策
| 决策点 | 方案 | 理由 |
|---|---|---|
| 权限模型 | RBAC(用户-角色-权限) | 业界标准,角色作为中间层解耦用户与权限 |
| 权限粒度 | 菜单 + 按钮(permission 字符串) | 菜单控制页面可见性,按钮控制操作可执行性 |
| 数据权限实现 | MyBatis 拦截器自动拼接 SQL | 业务代码零侵入,新增表只需注册列名映射 |
| 租户隔离实现 | MyBatis-Plus TenantLineHandler | 基于 DO 继承关系自动判断哪些表需要隔离 |
| 认证方式 | OAuth2 Token(无 Session) | 微服务友好,网关透传,无状态可水平扩展 |
| 前后端一致性 | 共享 permission 字符串 | 前端 auth: ['system:role:create'] 与后端 @PreAuthorize 使用同一套标识 |
二、RBAC 功能权限:从四张表到校验链路
2.1 经典四表模型
RBAC(Role-Based Access Control)的核心是用角色作为用户与权限之间的桥梁。RuoYi Office 用 4 张表实现这个模型:
| 表名 | 职责 | 关键字段 |
|---|---|---|
system_user_role | 用户-角色关联 | user_id, role_id |
system_role | 角色定义 | id, name, code, data_scope, status |
system_role_menu | 角色-菜单关联 | role_id, menu_id |
system_menu | 菜单/按钮定义 | id, name, permission, type, parent_id |
数据流转路径:
User → UserRoleDO → RoleDO → RoleMenuDO → MenuDO.permission
↑ ↓
用户登录 "system:role:create"
关键设计:MenuDO 中的 permission 字段是一个字符串(如 system:role:create),它是前后端权限校验的唯一标识。后端用 @PreAuthorize("@ss.hasPermission('system:role:create')") 校验,前端用 auth: ['system:role:create'] 控制按钮显隐——两者用的是同一个字符串。
2.2 菜单的三种类型
system_menu 不仅仅是"菜单",它同时承载了目录、菜单和按钮三种类型:
| type | 名称 | 说明 | permission 字段 |
|---|---|---|---|
1 | 目录 | 侧边栏的折叠目录节点 | 通常为空 |
2 | 菜单 | 点击后打开的页面 | 通常为空(靠路由控制) |
3 | 按钮 | 页面上的操作按钮 | 必填,如 system:role:create |
这种设计让"菜单管理"页面同时管理了路由结构和权限标识,避免了"菜单表一套、权限表一套"的冗余。
2.3 权限校验链路:hasAnyPermissions
当一个请求到达后端时,Spring Security 通过 @PreAuthorize 触发权限校验。整个校验链路如下:
@PreAuthorize("@ss.hasPermission('system:role:create')")
↓
SecurityFrameworkServiceImpl.hasAnyPermissions(permissions)
↓ (Guava 缓存 1 分钟,RPC 调用 PermissionCommonApi)
PermissionServiceImpl.hasAnyPermissions(userId, permissions)
↓
1. 获取用户所有启用状态的角色
2. 对每个 permission,查找拥有该权限的菜单 ID
3. 检查菜单的角色集合与用户角色集合是否有交集
4. 超级管理员短路返回 true
核心实现代码:
@Override
public boolean hasAnyPermissions(Long userId, String... permissions) {
if (ArrayUtil.isEmpty(permissions)) {
return true;
}
// 获得当前登录的角色。如果为空,说明没有权限
List<RoleDO> roles = getEnableUserRoleListByUserIdFromCache(userId);
if (CollUtil.isEmpty(roles)) {
return false;
}
// 情况一:遍历判断每个权限,如果有一满足,说明有权限
for (String permission : permissions) {
if (hasAnyPermission(roles, permission)) {
return true;
}
}
// 情况二:如果是超管,也说明有权限
return roleService.hasAnySuperAdmin(convertSet(roles, RoleDO::getId));
}
private boolean hasAnyPermission(List<RoleDO> roles, String permission) {
List<Long> menuIds = menuService.getMenuIdListByPermissionFromCache(permission);
if (CollUtil.isEmpty(menuIds)) {
return false;
}
Set<Long> roleIds = convertSet(roles, RoleDO::getId);
for (Long menuId : menuIds) {
Set<Long> menuRoleIds = getSelf().getMenuRoleIdListByMenuIdFromCache(menuId);
if (CollUtil.containsAny(menuRoleIds, roleIds)) {
return true;
}
}
return false;
}
设计亮点:
- 空权限短路:
permissions为空时直接返回true,适用于不需要权限的接口 - 超管兜底:普通校验不通过后,再检查是否为超级管理员角色(
code = super_admin),确保超管不被误拦 - 缓存优化:角色列表、菜单-角色映射都从缓存读取(
@Cacheable),避免每次请求查库 - 严格模式:如果一个
permission字符串找不到对应的菜单记录,视为无权限。防止开发者在@PreAuthorize中写了一个不存在的权限字符串导致绕过校验
三、数据权限:SQL 拦截器的自动注入
3.1 问题本质
功能权限解决了"能不能调这个接口",但没解决"调了之后能看到哪些数据"。比如:
- 部门经理查看考勤列表,应该只看到本部门的记录
- 区域总监查看销售报表,应该看到本部门及下级部门的记录
- HR 总监查看全公司的薪资数据,应该看到全部
如果在每个 Service 方法里手动拼接 WHERE dept_id IN (...),那每个业务模块都要写一遍,而且容易遗漏。
3.2 五种数据范围
RuoYi Office 定义了 5 种数据范围,挂在角色上:
| 枚举值 | 名称 | SQL 效果 | 典型场景 |
|---|---|---|---|
ALL(1) | 全部数据 | 不追加条件 | 超级管理员、总经理 |
DEPT_CUSTOM(2) | 自定义部门 | dept_id IN (指定部门列表) | 跨部门协作角色 |
DEPT_ONLY(3) | 仅本部门 | dept_id = 当前用户部门 | 部门经理 |
DEPT_AND_CHILD(4) | 本部门及子部门 | dept_id IN (本部门 + 所有子部门) | 区域总监 |
SELF(5) | 仅自己 | user_id = 当前用户ID | 普通员工 |
一个用户可能拥有多个角色,每个角色的数据范围可能不同。系统采用并集策略——取所有角色数据范围的并集作为最终的可见范围。
3.3 核心实现:getDeptDataPermission
这个方法负责聚合用户所有角色的数据权限,返回一个 DeptDataPermissionRespDTO:
@Override
@DataPermission(enable = false) // 关闭数据权限,避免递归
public DeptDataPermissionRespDTO getDeptDataPermission(Long userId) {
List<RoleDO> roles = getEnableUserRoleListByUserIdFromCache(userId);
DeptDataPermissionRespDTO result = new DeptDataPermissionRespDTO();
if (CollUtil.isEmpty(roles)) {
result.setSelf(true);
return result;
}
// 惰性求值,仅第一次查 DB
Supplier<Long> userDeptId = Suppliers.memoize(
() -> userService.getUser(userId).getDeptId());
for (RoleDO role : roles) {
if (role.getDataScope() == null) {
continue;
}
// ALL → 标记全部可见
if (Objects.equals(role.getDataScope(), DataScopeEnum.ALL.getScope())) {
result.setAll(true);
continue;
}
// DEPT_CUSTOM → 合并自定义部门 + 本部门
if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_CUSTOM.getScope())) {
CollUtil.addAll(result.getDeptIds(), role.getDataScopeDeptIds());
CollUtil.addAll(result.getDeptIds(), userDeptId.get());
continue;
}
// DEPT_ONLY → 仅本部门
if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_ONLY.getScope())) {
CollectionUtils.addIfNotNull(result.getDeptIds(), userDeptId.get());
continue;
}
// DEPT_AND_CHILD → 本部门 + 递归子部门
if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_AND_CHILD.getScope())) {
CollUtil.addAll(result.getDeptIds(),
deptService.getChildDeptIdListFromCache(userDeptId.get()));
CollUtil.addAll(result.getDeptIds(), userDeptId.get());
continue;
}
// SELF → 标记仅看自己
if (Objects.equals(role.getDataScope(), DataScopeEnum.SELF.getScope())) {
result.setSelf(true);
}
}
return result;
}
关键设计:
@DataPermission(enable = false):必须关闭数据权限,否则getUser(userId)会触发数据权限拦截器,而拦截器又会调getDeptDataPermission,形成无限递归- Guava
Suppliers.memoize:用户的部门 ID 只在需要时查一次数据库,多个角色复用同一个值 - DEPT_CUSTOM 自动包含本部门:防止用户自定义了其他部门后反而看不到自己部门的数据
3.4 MyBatis 拦截器:DeptDataPermissionRule
数据权限的"魔法"发生在 MyBatis 拦截器中。DeptDataPermissionRule 实现了 DataPermissionRule 接口,在 SQL 执行前自动追加过滤条件:
| 步骤 | 动作 | 说明 |
|---|---|---|
| 1 | 检查当前表是否注册了部门列或用户列映射 | 未注册的表不受数据权限控制 |
| 2 | 从 LoginUser.context 获取数据权限 DTO | 首次获取走 RPC,结果缓存在请求上下文中 |
| 3 | all == true | 不追加任何条件 |
| 4 | 无部门 ID 且 self == false | 追加 WHERE null = null,返回空数据 |
| 5 | 拼接 dept_id IN (...) 和/或 user_id = ? | 用 OR 连接:WHERE (dept_id IN ? OR user_id = ?) |
业务模块只需在配置类中注册"哪张表的哪个字段是部门列/用户列"即可享受自动数据隔离:
@Bean
public DeptDataPermissionRuleCustomizer deptDataPermissionRuleCustomizer() {
return rule -> {
rule.addDeptColumn(AdminUserDO.class, "dept_id");
rule.addUserColumn(AdminUserDO.class, "id");
rule.addDeptColumn(DeptDO.class, "id");
};
}
新增一个业务表需要数据权限?只需加一行 rule.addDeptColumn(XxxDO.class, "dept_id"),零代码侵入。
四、多租户隔离:套餐驱动的数据边界
4.1 租户模型
多租户是 SaaS 系统的基础能力。RuoYi Office 采用共享数据库、共享 Schema、tenant_id 字段隔离的方案:
| 表 | 职责 | 关键字段 |
|---|---|---|
system_tenant | 租户信息 | id, name, package_id, contact_user_id, expire_time |
system_tenant_package | 租户套餐 | id, name, menu_ids(JSON,控制该套餐包含哪些菜单) |
套餐机制是多租户权限的关键——不同套餐对应不同的菜单集合。基础版套餐只有 OA 模块,高级版套餐包含 OA + CRM + ERP,旗舰版包含全部模块。创建租户时选择套餐,系统自动根据套餐的 menu_ids 分配菜单权限。
4.2 创建租户:一键初始化完整权限体系
创建租户不是简单地插一条记录。系统在一个事务中完成三件事:创建租户记录、创建管理员角色(关联套餐菜单)、创建管理员用户(绑定角色)。
@Override
@DSTransactional
@DataPermission(enable = false)
public Long createTenant(TenantSaveReqVO createReqVO) {
validTenantNameDuplicate(createReqVO.getName(), null);
validTenantWebsiteDuplicate(createReqVO.getWebsites(), null);
TenantPackageDO tenantPackage = tenantPackageService.validTenantPackage(
createReqVO.getPackageId());
// 1. 创建租户记录
TenantDO tenant = BeanUtils.toBean(createReqVO, TenantDO.class);
tenantMapper.insert(tenant);
// 2. 在租户上下文中创建角色和用户
TenantUtils.execute(tenant.getId(), () -> {
Long roleId = createRole(tenantPackage);
Long userId = createUser(roleId, createReqVO);
tenantMapper.updateById(
new TenantDO().setId(tenant.getId()).setContactUserId(userId));
});
return tenant.getId();
}
private Long createRole(TenantPackageDO tenantPackage) {
RoleSaveReqVO reqVO = new RoleSaveReqVO();
reqVO.setName(RoleCodeEnum.TENANT_ADMIN.getName())
.setCode(RoleCodeEnum.TENANT_ADMIN.getCode())
.setSort(0).setRemark("系统自动生成");
Long roleId = roleService.createRole(reqVO, RoleTypeEnum.SYSTEM.getType());
permissionService.assignRoleMenu(roleId, tenantPackage.getMenuIds());
return roleId;
}
TenantUtils.execute(tenantId, runnable) 的作用是切换当前线程的租户上下文。在这个闭包内创建的角色和用户,其 tenant_id 自动设置为新租户的 ID,而不是操作者(平台管理员)的租户 ID。
4.3 TenantDatabaseInterceptor:自动追加 tenant_id
多租户隔离的核心是 TenantDatabaseInterceptor,它实现了 MyBatis-Plus 的 TenantLineHandler 接口:
| 方法 | 作用 |
|---|---|
getTenantId() | 从 TenantContextHolder 获取当前租户 ID,注入到 SQL |
ignoreTable(tableName) | 判断该表是否需要租户隔离 |
ignoreTable 的判断逻辑非常精巧:
| 优先级 | 条件 | 结果 |
|---|---|---|
| 1 | TenantContextHolder.isIgnore() 为 true | 忽略(全局开关) |
| 2 | 表在配置的忽略列表中 | 忽略 |
| 3 | 表对应的 DO 类找不到(非项目内的表) | 忽略 |
| 4 | DO 类继承了 TenantBaseDO | 不忽略(强制租户隔离) |
| 5 | DO 类标注了 @TenantIgnore | 忽略 |
TenantBaseDO vs BaseDO——这是租户隔离的开关。业务表的 DO 类继承 TenantBaseDO(包含 tenant_id 字段),系统自动追加租户条件;系统共享表(如 system_menu)继承 BaseDO 并标注 @TenantIgnore,所有租户共享。
TenantBaseDO (abstract)
├── tenant_id: Long ← 自动注入
└── extends BaseDO
├── creator
├── create_time
├── updater
└── update_time
五、OAuth2 认证链路:从 Token 到 LoginUser
5.1 认证架构
RuoYi Office 采用 OAuth2 Token + 无 Session 的认证方式,支持单体和微服务两种部署模式:
| 部署模式 | 认证流程 |
|---|---|
| 单体 | 请求 → TokenAuthenticationFilter → Token 校验 → 构建 LoginUser → Spring Security 上下文 |
| 微服务 | 请求 → 网关 TokenAuthenticationFilter → Token 校验 → Header 透传 login-user → 业务服务 TokenAuthenticationFilter → 从 Header 解析 LoginUser |
5.2 TokenAuthenticationFilter 核心逻辑
这个过滤器是认证链路的入口,它有两种构建 LoginUser 的方式:
| 优先级 | 来源 | 场景 |
|---|---|---|
| 1 | HTTP Header login-user | 微服务模式,网关已校验 Token 并透传用户信息 |
| 2 | Token 校验 | 单体模式,或直接绕过网关访问服务 |
通过 Token 构建 LoginUser 的流程:
1. 从请求中提取 Token(Header 或 Query Parameter)
2. 调用 OAuth2TokenCommonApi.checkAccessToken(token) 校验 Token
3. 校验用户类型(admin / member)是否匹配请求路径
4. 构建 LoginUser 对象:userId, userType, tenantId, scopes
5. 写入 Spring Security 上下文
5.3 LoginUser:请求上下文的载体
LoginUser 是整个权限体系的"信息中枢",它在一次请求的生命周期中承载用户身份和权限上下文:
| 字段 | 类型 | 说明 |
|---|---|---|
id | Long | 用户 ID |
userType | Integer | 用户类型(1 管理员 / 2 会员) |
tenantId | Long | 租户 ID |
scopes | List\ | OAuth2 授权范围 |
context | Map\ | 可扩展的上下文,数据权限 DTO 缓存在此 |
context 字段是一个巧妙的设计——DeptDataPermissionRule 在首次计算数据权限后,将结果缓存到 LoginUser.context 中。同一个请求内多次触发数据权限拦截(比如一个接口查了两张表),不会重复调用 RPC。
5.4 鉴权的 RPC 调用链
在微服务模式下,权限校验涉及跨服务调用:
业务服务 (@PreAuthorize)
→ SecurityFrameworkServiceImpl.hasAnyPermissions
→ Guava Cache (key: userId + permissions, TTL: 1分钟)
→ PermissionCommonApi.hasAnyPermissions (RPC/Feign)
→ system-server: PermissionServiceImpl.hasAnyPermissions
1 分钟的缓存 是在性能和实时性之间的权衡:修改角色权限后最多 1 分钟生效。对于大多数企业应用场景,这个延迟是可接受的。
六、前端权限实现:动态路由与按钮级控制
6.1 动态路由:后端下发菜单,前端生成路由
前端不硬编码路由表,而是从后端获取当前用户有权限的菜单列表,动态生成路由:
| 步骤 | 说明 |
|---|---|
| 1 | 登录成功后,调用后端接口获取用户菜单列表 |
| 2 | accessStore.accessMenus 存储后端返回的菜单数据 |
| 3 | convertServerMenuToRouteRecordStringComponent 将菜单数据转换为路由记录 |
| 4 | generateAccessible 生成最终的路由配置并注册到 Vue Router |
| 5 | 无权限的路由指向 403 Forbidden 页面 |
这种方式的好处是:后端新增一个菜单,前端无需修改代码。 菜单的路由路径、组件路径、图标、排序全部由后端数据驱动。
6.2 按钮级权限:auth 属性
前端的 TableAction 组件提供了 auth 属性,用于控制按钮的显示和隐藏:
// 角色管理页面的操作按钮
<TableAction
:actions="[
{
label: '新增角色',
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['system:role:create'], // 需要 system:role:create 权限
onClick: handleCreate,
},
{
label: '导出',
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['system:role:export'], // 需要 system:role:export 权限
onClick: handleExport,
},
]"
/>
auth 数组中的字符串与 system_menu 表的 permission 字段完全一致。组件内部通过 useAccess() 判断当前用户是否拥有对应权限,无权限时按钮自动隐藏。
前后端一致性保障:
| 层面 | 权限标识 | 作用 |
|---|---|---|
| 后端 Controller | @PreAuthorize("@ss.hasPermission('system:role:create')") | 拦截无权限的 HTTP 请求 |
| 前端按钮 | auth: ['system:role:create'] | 隐藏无权限的操作按钮 |
| 数据库 | system_menu.permission = 'system:role:create' | 权限标识的唯一来源 |
三者使用同一个字符串,由菜单管理页面统一维护。
6.3 角色管理页面:权限分配的可视化

▲ 角色管理页面:列表展示角色名称、编码、排序、状态等信息;操作列提供编辑、删除、分配菜单权限、分配数据权限四个操作
角色管理页面的核心交互:
| 操作 | 弹窗内容 | 后端接口 |
|---|---|---|
| 菜单权限 | 树形勾选控件,展示所有菜单/按钮,勾选该角色拥有的权限 | assignRoleMenu(roleId, menuIds) |
| 数据权限 | 选择数据范围(5 种),自定义部门时展示部门树 | assignRoleDataScope(roleId, dataScope, deptIds) |
6.4 菜单管理页面:权限标识的统一维护

▲ 菜单管理页面:树形表格展示目录→菜单→按钮的层级结构,每行展示名称、图标、排序、权限标识、组件路径、状态
菜单管理页面是整个权限体系的配置中心。在这里新增一个按钮类型的菜单项并填写 permission 字段,就完成了一个新权限的定义。随后在角色管理中勾选这个权限,前端组件通过 auth 属性引用这个权限字符串——整套流程无需改代码。
七、数据结构
7.1 表结构:system_menu(菜单权限)
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键 |
name | varchar(50) | 菜单名称 |
permission | varchar(100) | 权限标识(如 system:role:create) |
type | tinyint | 类型(1 目录 / 2 菜单 / 3 按钮) |
parent_id | bigint | 父菜单 ID |
path | varchar(200) | 路由路径 |
component | varchar(255) | 前端组件路径 |
component_name | varchar(100) | 组件名称 |
icon | varchar(100) | 图标 |
sort | int | 排序 |
status | tinyint | 状态(0 正常 / 1 停用) |
visible | bit | 是否可见 |
keep_alive | bit | 是否缓存 |
always_show | bit | 是否总是显示 |
7.2 表结构:system_role(角色)
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键 |
name | varchar(30) | 角色名称 |
code | varchar(100) | 角色编码(如 super_admin、tenant_admin) |
sort | int | 排序 |
data_scope | tinyint | 数据范围(1-5,对应 DataScopeEnum) |
data_scope_dept_ids | varchar(500) | 自定义数据范围的部门 ID 列表(JSON) |
type | tinyint | 角色类型(1 内置 / 2 自定义) |
status | tinyint | 状态(0 正常 / 1 停用) |
tenant_id | bigint | 租户编号 |
7.3 表结构:system_user_role(用户角色关联)
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键 |
user_id | bigint | 用户 ID |
role_id | bigint | 角色 ID |
7.4 表结构:system_role_menu(角色菜单关联)
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键 |
role_id | bigint | 角色 ID |
menu_id | bigint | 菜单 ID |
tenant_id | bigint | 租户编号 |
7.5 表结构:system_tenant(租户)
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键(即 tenant_id) |
name | varchar(30) | 租户名称 |
contact_user_id | bigint | 联系人用户 ID(租户管理员) |
contact_name | varchar(30) | 联系人姓名 |
contact_mobile | varchar(30) | 联系人手机号 |
package_id | bigint | 租户套餐 ID |
status | tinyint | 状态(0 正常 / 1 停用) |
websites | varchar(256) | 绑定域名 |
expire_time | datetime | 过期时间 |
7.6 表结构:system_tenant_package(租户套餐)
| 字段 | 类型 | 说明 |
|---|---|---|
id | bigint | 主键 |
name | varchar(30) | 套餐名称(如 基础版、高级版、旗舰版) |
menu_ids | varchar(2048) | 菜单 ID 列表(JSON 数组,控制套餐包含哪些菜单) |
status | tinyint | 状态(0 正常 / 1 停用) |
remark | varchar(256) | 备注 |
7.7 设计要点
system_menu不加tenant_id:菜单是全局共享的,所有租户看到的菜单结构相同,区别在于每个租户的角色关联了哪些菜单system_role和system_role_menu加tenant_id:角色和角色-菜单关联是租户级别的,不同租户有各自独立的角色体系system_user_role不加tenant_id:因为user_id和role_id本身就在各自的租户内唯一data_scope_dept_ids使用 JSON:自定义部门列表长度不确定,用 JSON 存储避免额外关联表
八、设计方法论:如何从零搭建权限体系
8.1 第一步:定义权限粒度
在动手写代码之前,先回答一个问题:你的系统需要多细的权限控制?
| 粒度 | 控制方式 | 适用场景 |
|---|---|---|
| 菜单级 | 控制侧边栏是否显示 | 简单的内部系统 |
| 页面级 | 控制是否能访问某个页面 | 中等复杂度的 B 端系统 |
| 按钮级 | 控制页面上每个按钮的显隐 | 企业级管理系统(推荐) |
| 字段级 | 控制表单中每个字段的可见性 | 金融、医疗等高安全要求系统 |
| 行级(数据权限) | 控制能看到哪些数据行 | 多部门协作的企业系统(推荐) |
RuoYi Office 选择了按钮级 + 行级的组合,覆盖了绝大多数企业应用场景。
8.2 第二步:选择隔离策略
如果你的系统需要服务多个客户(SaaS),必须在第一时间决定租户隔离策略:
| 策略 | 隔离程度 | 成本 | 适用规模 |
|---|---|---|---|
| 独立数据库 | 最高 | 最高 | 大型企业客户,每客户独立部署 |
| 共享数据库,独立 Schema | 高 | 中 | 中型客户,需要一定隔离 |
共享数据库,tenant_id 字段 | 中 | 低 | 大量小型客户,SaaS 模式(推荐) |
RuoYi Office 采用第三种方案,通过 TenantBaseDO 继承和 TenantDatabaseInterceptor 实现自动隔离,开发者无需在每个 SQL 中手写 AND tenant_id = ?。
8.3 第三步:设计缓存策略
权限校验是高频操作——每个 API 请求都要校验。如果每次都查数据库,性能不可接受。RuoYi Office 的缓存设计:
| 缓存层 | 数据 | TTL | 命中率 |
|---|---|---|---|
| Spring Cache | 角色列表、菜单-角色映射 | 跟随数据变更失效 | 极高(数据变更不频繁) |
| Guava Cache | hasAnyPermissions 结果 | 1 分钟 | 高(同一用户短时间内多次请求) |
| LoginUser.context | 数据权限 DTO | 单次请求 | 100%(同一请求内不重复计算) |
三层缓存各有分工:Spring Cache 缓存基础数据,Guava Cache 缓存跨服务 RPC 结果,LoginUser.context 缓存请求级别的计算结果。
九、技术亮点总结
| 设计要点 | 实现方式 | 价值 |
|---|---|---|
| RBAC 四表模型 | user_role + role + role_menu + menu | 经典模型,角色解耦用户与权限 |
| permission 字符串前后端统一 | 同一个 system:role:create 贯穿数据库、后端注解、前端组件 | 消除前后端权限不一致的安全漏洞 |
| 超管短路校验 | 普通校验失败后再检查超管角色 | 确保超管不被误拦 |
| 数据权限 SQL 自动注入 | MyBatis 拦截器 + 列名注册 | 业务代码零侵入 |
| 五种数据范围 + 并集策略 | DataScopeEnum + 多角色合并 | 覆盖企业常见的部门层级场景 |
| TenantBaseDO 继承判断 | 拦截器检查 DO 类继承关系 | 新表只需继承正确的基类即可隔离 |
| 套餐控制菜单 | tenant_package.menu_ids 控制租户可用菜单 | 灵活的 SaaS 定价策略 |
| 创建租户一键初始化 | 事务内创建角色 + 用户 + 分配权限 | 开箱即用,无需手动配置 |
| OAuth2 无 Session | Token 校验 + 网关透传 | 微服务友好,可水平扩展 |
| 三层缓存 | Spring Cache + Guava + 请求上下文 | 高频校验的性能保障 |
| @DataPermission(enable=false) | 注解关闭数据权限 | 防止递归调用 |
| 动态路由 + auth 属性 | 后端下发菜单数据 → 前端生成路由 | 新增菜单/权限无需改前端代码 |
十、快速体验
操作路径:系统管理 → 用户管理 / 角色管理 / 菜单管理
推荐体验流程:
- 查看菜单树:进入「菜单管理」,观察目录→菜单→按钮的三级结构,展开任意菜单查看按钮级别的
permission字段 - 创建角色:进入「角色管理」,新建一个测试角色
- 分配菜单权限:点击角色行的「菜单权限」,在树形控件中勾选部分菜单和按钮
- 分配数据权限:点击角色行的「数据权限」,选择"仅本部门数据"
- 创建用户:进入「用户管理」,新建一个测试用户并分配刚才的角色
- 验证功能权限:用测试用户登录,观察侧边栏只显示有权限的菜单,页面上只显示有权限的按钮
- 验证数据权限:访问有数据权限控制的列表页(如用户管理),观察只能看到本部门的数据
- 体验多租户:如果有多租户环境,切换租户后观察角色、用户、业务数据的完全隔离
源码仓库:
| 平台 | 地址 |
|---|---|
| GitCode(后端) | gitcode.com/zhouzhongya… |
| GitCode(前端) | gitcode.com/zhouzhongya… |
| GitHub(后端) | github.com/yuqing2026/… |
结语
权限管理是企业级应用的"地基"——地基不牢,上层建筑的每一个功能都可能成为安全隐患。RBAC 四表模型解决了"能做什么"的问题,数据权限的 MyBatis 拦截器解决了"能看什么"的问题,多租户的 TenantBaseDO 继承机制解决了"是谁的"的问题。三层防线各司其职,又通过 LoginUser 这个请求级上下文串联在一起。
这套设计模式的核心思想是:让框架承担隔离职责,让业务代码专注业务逻辑。 开发者写一个新的业务模块,不需要关心"这个接口谁能调""这条数据谁能看""这个租户的数据会不会泄露"——框架通过注解、拦截器、继承关系自动处理了这些横切关注点。
如果你正在设计企业级权限体系,或者对"如何用最少的代码侵入实现最完整的权限控制"感兴趣,欢迎参考源码实现。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:ruoyioffice.com/web/(账号 admin / admin123)
💬 技术咨询:添加💬 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!