一文讲透企业级权限管理:RBAC+数据权限+多租户隔离——SpringBoot+Vue3 权限体系设计全解析

0 阅读25分钟

一文讲透企业级权限管理:RBAC+数据权限+多租户隔离——SpringBoot+Vue3 权限体系设计全解析

📦 源码1ruoyi-office-vben |📦 源码2ruoyi-office |📦 源码3ruoyi-office

你的系统上线后,老板发现自己能看到所有部门的工资单,实习生发现自己能删除线上配置,A 公司的管理员居然能看到 B 公司的客户数据。这三个"事故"分别对应权限体系的三个层次:功能权限没管住"能做什么",数据权限没管住"能看什么",租户隔离没管住"能看谁的"。 企业级权限管理远不是一个 @PreAuthorize 注解能解决的——它是一个从数据库表设计到 MyBatis 拦截器,从后端校验链路到前端按钮显隐,从单租户到 SaaS 多租户的系统性工程。

引言:权限管理到底难在哪?

"加个角色表,关联一下菜单不就行了?"——初次接手权限模块的开发者大多这么想。但真正深入后会发现,企业级权限管理的复杂度远超"RBAC 三个字母":

功能权限的粒度问题:菜单级权限太粗,按钮级权限太碎。同一个页面上,A 角色能"新建"但不能"删除",B 角色能"查看"但不能"导出"。前端的按钮显隐和后端的接口校验必须用同一套权限标识,否则"前端藏了按钮,后端没拦请求"就是一个安全漏洞。

数据权限的隐蔽性:功能权限决定"能不能点这个按钮",数据权限决定"点了之后能看到哪些数据"。部门经理只能看本部门的考勤记录,区域总监能看下辖所有部门的销售数据,超级管理员能看全部——这种按部门层级的数据隔离,不可能靠业务代码逐个 WHERE 实现。

多租户的边界问题:SaaS 场景下,每个租户是一个独立的"小世界"。租户 A 的角色、菜单、用户都不能被租户 B 看到。但有些表(如系统菜单)是所有租户共享的,有些表(如业务数据)必须严格隔离——哪些表加 tenant_id,哪些表不加,决策失误就是数据泄露。

认证与鉴权的分离:微服务架构下,网关负责认证(你是谁),业务服务负责鉴权(你能做什么)。Token 校验、用户信息透传、权限缓存、跨服务 RPC 调用——每一环断裂都会导致权限失效。

本文以 RuoYi Office 的权限体系为例,从设计方法论的角度,完整拆解"功能权限 + 数据权限 + 多租户隔离"三位一体的权限架构。 rbac-model-diagram.png


什么是 RBAC?——企业级权限管理的事实标准

RBAC(Role-Based Access Control,基于角色的访问控制) 是 1992 年由 David Ferraiolo 和 Rick Kuhn 在 NIST(美国国家标准与技术研究院)正式提出的访问控制模型。其核心思想只有一句话:不直接给用户分配权限,而是把权限赋予角色,再把角色赋予用户。

这意味着,当一个组织有 500 名员工和 200 个操作权限时,管理员不需要维护 500 × 200 = 100,000 条用户-权限映射,而只需要定义 10~20 个角色(如"普通员工""部门经理""财务主管""超级管理员"),给每个角色分配一组权限,再把用户划入相应角色——维护量从十万级降到百级。

为什么几乎所有主流企业软件都选择 RBAC?

平台/产品权限模型说明
钉钉RBAC + 数据权限管理后台通过"角色组"控制可见菜单与操作,数据按部门隔离
飞书RBAC + ABAC 混合基础功能按角色授权,高级场景(如文档权限)引入属性条件
用友 YonBIPRBAC + 组织维度集团级多法人场景下,角色 × 组织交叉授权
金蝶云星空RBAC + 权限范围角色 + 业务组织 + 数据规则三层控制
SAP S/4HANARBAC(角色 = Profile + Authorization Object)企业级 ERP 标杆,通过事务码绑定角色实现权限管控
OdooRBAC(Groups)开源 ERP 代表,用户组即角色,模块级权限 + 记录规则
若依(RuoYi)RBAC + 数据权限 + 多租户Spring Security + MyBatis 拦截器,国内开源标杆
RuoYi OfficeRBAC + 数据权限 + 多租户隔离本文重点,三层防线完整实现

RBAC 成为事实标准的原因可以归纳为三点:

  1. 符合组织管理直觉:企业天然按"岗位/角色"划分职责,RBAC 的角色概念与组织架构一一对应,业务人员无需理解技术细节即可完成授权。
  2. 权限变更成本低:员工调岗只需变更角色绑定,而非逐条修改权限。新增一个功能模块,只需给对应角色追加权限。
  3. 可审计、可追溯:角色作为中间层,使"谁拥有什么权限"清晰可查,满足等保合规、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检查当前表是否注册了部门列或用户列映射未注册的表不受数据权限控制
2LoginUser.context 获取数据权限 DTO首次获取走 RPC,结果缓存在请求上下文中
3all == 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 的判断逻辑非常精巧:

优先级条件结果
1TenantContextHolder.isIgnore() 为 true忽略(全局开关)
2表在配置的忽略列表中忽略
3表对应的 DO 类找不到(非项目内的表)忽略
4DO 类继承了 TenantBaseDO不忽略(强制租户隔离)
5DO 类标注了 @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 的方式:

优先级来源场景
1HTTP Header login-user微服务模式,网关已校验 Token 并透传用户信息
2Token 校验单体模式,或直接绕过网关访问服务

通过 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 是整个权限体系的"信息中枢",它在一次请求的生命周期中承载用户身份和权限上下文:

字段类型说明
idLong用户 ID
userTypeInteger用户类型(1 管理员 / 2 会员)
tenantIdLong租户 ID
scopesList\OAuth2 授权范围
contextMap\可扩展的上下文,数据权限 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登录成功后,调用后端接口获取用户菜单列表
2accessStore.accessMenus 存储后端返回的菜单数据
3convertServerMenuToRouteRecordStringComponent 将菜单数据转换为路由记录
4generateAccessible 生成最终的路由配置并注册到 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 角色管理页面:权限分配的可视化

system-role-list.png

▲ 角色管理页面:列表展示角色名称、编码、排序、状态等信息;操作列提供编辑、删除、分配菜单权限、分配数据权限四个操作

角色管理页面的核心交互:

操作弹窗内容后端接口
菜单权限树形勾选控件,展示所有菜单/按钮,勾选该角色拥有的权限assignRoleMenu(roleId, menuIds)
数据权限选择数据范围(5 种),自定义部门时展示部门树assignRoleDataScope(roleId, dataScope, deptIds)

6.4 菜单管理页面:权限标识的统一维护

system-menu-list.png

▲ 菜单管理页面:树形表格展示目录→菜单→按钮的层级结构,每行展示名称、图标、排序、权限标识、组件路径、状态

菜单管理页面是整个权限体系的配置中心。在这里新增一个按钮类型的菜单项并填写 permission 字段,就完成了一个新权限的定义。随后在角色管理中勾选这个权限,前端组件通过 auth 属性引用这个权限字符串——整套流程无需改代码。


七、数据结构

7.1 表结构:system_menu(菜单权限)

字段类型说明
idbigint主键
namevarchar(50)菜单名称
permissionvarchar(100)权限标识(如 system:role:create
typetinyint类型(1 目录 / 2 菜单 / 3 按钮)
parent_idbigint父菜单 ID
pathvarchar(200)路由路径
componentvarchar(255)前端组件路径
component_namevarchar(100)组件名称
iconvarchar(100)图标
sortint排序
statustinyint状态(0 正常 / 1 停用)
visiblebit是否可见
keep_alivebit是否缓存
always_showbit是否总是显示

7.2 表结构:system_role(角色)

字段类型说明
idbigint主键
namevarchar(30)角色名称
codevarchar(100)角色编码(如 super_admintenant_admin
sortint排序
data_scopetinyint数据范围(1-5,对应 DataScopeEnum)
data_scope_dept_idsvarchar(500)自定义数据范围的部门 ID 列表(JSON)
typetinyint角色类型(1 内置 / 2 自定义)
statustinyint状态(0 正常 / 1 停用)
tenant_idbigint租户编号

7.3 表结构:system_user_role(用户角色关联)

字段类型说明
idbigint主键
user_idbigint用户 ID
role_idbigint角色 ID

7.4 表结构:system_role_menu(角色菜单关联)

字段类型说明
idbigint主键
role_idbigint角色 ID
menu_idbigint菜单 ID
tenant_idbigint租户编号

7.5 表结构:system_tenant(租户)

字段类型说明
idbigint主键(即 tenant_id)
namevarchar(30)租户名称
contact_user_idbigint联系人用户 ID(租户管理员)
contact_namevarchar(30)联系人姓名
contact_mobilevarchar(30)联系人手机号
package_idbigint租户套餐 ID
statustinyint状态(0 正常 / 1 停用)
websitesvarchar(256)绑定域名
expire_timedatetime过期时间

7.6 表结构:system_tenant_package(租户套餐)

字段类型说明
idbigint主键
namevarchar(30)套餐名称(如 基础版、高级版、旗舰版)
menu_idsvarchar(2048)菜单 ID 列表(JSON 数组,控制套餐包含哪些菜单)
statustinyint状态(0 正常 / 1 停用)
remarkvarchar(256)备注

7.7 设计要点

  • system_menu 不加 tenant_id:菜单是全局共享的,所有租户看到的菜单结构相同,区别在于每个租户的角色关联了哪些菜单
  • system_rolesystem_role_menutenant_id:角色和角色-菜单关联是租户级别的,不同租户有各自独立的角色体系
  • system_user_role 不加 tenant_id:因为 user_idrole_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 CachehasAnyPermissions 结果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 无 SessionToken 校验 + 网关透传微服务友好,可水平扩展
三层缓存Spring Cache + Guava + 请求上下文高频校验的性能保障
@DataPermission(enable=false)注解关闭数据权限防止递归调用
动态路由 + auth 属性后端下发菜单数据 → 前端生成路由新增菜单/权限无需改前端代码

十、快速体验

操作路径:系统管理 → 用户管理 / 角色管理 / 菜单管理

推荐体验流程

  1. 查看菜单树:进入「菜单管理」,观察目录→菜单→按钮的三级结构,展开任意菜单查看按钮级别的 permission 字段
  2. 创建角色:进入「角色管理」,新建一个测试角色
  3. 分配菜单权限:点击角色行的「菜单权限」,在树形控件中勾选部分菜单和按钮
  4. 分配数据权限:点击角色行的「数据权限」,选择"仅本部门数据"
  5. 创建用户:进入「用户管理」,新建一个测试用户并分配刚才的角色
  6. 验证功能权限:用测试用户登录,观察侧边栏只显示有权限的菜单,页面上只显示有权限的按钮
  7. 验证数据权限:访问有数据权限控制的列表页(如用户管理),观察只能看到本部门的数据
  8. 体验多租户:如果有多租户环境,切换租户后观察角色、用户、业务数据的完全隔离

源码仓库

平台地址
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 支持一下!