RuoYi 权限体系源码解析:菜单、按钮、接口权限与数据权限如何协同

RuoYi 的权限体系表面上围绕“菜单管理”和“角色授权”展开,底层实际由两条相对独立的链路组成:一条负责目录、页面、按钮和接口操作的功能权限,另一条负责限制用户能够查询哪些部门或个人数据。本文以 RuoYi v4.8.3 当前源码为基线,从 sys_menu、Shiro UserRealm@RequiresPermissions、Thymeleaf 按钮控制、@DataScope 与 MyBatis 动态 SQL 入手,完整梳理权限字符如何从数据库进入运行时,以及多个角色、缓存和数据范围之间如何协作。

先说结论:RuoYi 不是把所有权限都交给菜单

分析权限体系前,需要先确认版本。本文对应的是官方仓库 yangzongzhuan/RuoYi,也就是服务端渲染的经典单体版。仓库当前标记为 v4.8.3,master 已切换到 Spring Boot 4.x,但安全框架仍然是 Apache Shiro,前端主要使用 Thymeleaf、Bootstrap 和 jQuery。

它与前后端分离的 RuoYi-Vue 并不是同一个实现:RuoYi-Vue 使用 Spring Security,常见写法是 @PreAuthorize("@ss.hasPermi(...)");本文仓库使用 Shiro,接口上对应的是 @RequiresPermissions。两者的表结构和权限字符习惯相似,运行时授权机制却不能混着解释。

RuoYi 经典版可以归纳为四层控制:

层次 回答的问题 主要实现
登录认证 当前请求是谁发出的 Shiro UserRealm、Session、过滤链
菜单权限 用户能看到哪些目录和页面入口 sys_menusys_role_menu、菜单树查询
按钮与接口权限 用户能否执行新增、修改、删除、导出等动作 权限字符、Shiro 标签、@RequiresPermissions
数据权限 用户执行查询后能看到哪些数据行 角色 data_scope@DataScope、AOP 拼接 SQL

前三层属于功能授权,整体接近 RBAC;数据权限是在 RBAC 之上附加的一层行级过滤。菜单隐藏不等于接口安全,能调用接口也不等于能读取所有数据。把这三件事揉成一个“有权限/没权限”的开关,是理解 RuoYi 时最容易踩的坑。

权限模型与核心表

RuoYi 的主授权链路仍然是经典的:

1
用户 → 用户角色 → 角色 → 角色菜单 → 菜单/按钮 → 权限字符

数据范围从角色分叉出去:

1
2
角色 → data_scope
角色 → 角色部门 → 自定义可见部门

用实体关系表示如下:

erDiagram
    SYS_USER ||--o{ SYS_USER_ROLE : "分配"
    SYS_ROLE ||--o{ SYS_USER_ROLE : "包含用户"
    SYS_ROLE ||--o{ SYS_ROLE_MENU : "授予功能"
    SYS_MENU ||--o{ SYS_ROLE_MENU : "目录、菜单或按钮"
    SYS_ROLE ||--o{ SYS_ROLE_DEPT : "限定数据范围"
    SYS_DEPT ||--o{ SYS_ROLE_DEPT : "自定义部门"

    SYS_ROLE {
        bigint role_id
        varchar role_key
        char data_scope
        char status
    }
    SYS_MENU {
        bigint menu_id
        bigint parent_id
        char menu_type
        varchar url
        varchar perms
        char visible
    }

核心表的职责如下:

作用
sys_user 用户、所属部门、状态等身份信息
sys_role 角色、角色权限字符 role_key、数据范围 data_scope
sys_user_role 用户和角色的多对多关系
sys_menu 目录、页面菜单、按钮以及对应权限字符
sys_role_menu 角色可访问的菜单和按钮节点
sys_dept 部门树,通过 ancestors 保存祖先路径
sys_role_dept 当角色采用自定义数据范围时,保存可访问部门

这里没有独立的 sys_permission 表。RuoYi 将功能资源与权限标识都放在 sys_menu 中,再通过 sys_role_menu 一并授权。因此它是一个很实用的后台管理系统模型,但不是严格分离 Role → Permission → Resource 的通用 IAM 模型。

sys_menu 为什么既是菜单表,也是权限表

SysMenumenu_type 的定义非常直接:

1
2
3
4
5
6
7
8
/** 类型(M目录 C菜单 F按钮) */
private String menuType;

/** 菜单URL */
private String url;

/** 权限字符串 */
private String perms;

三种节点并不是同一性质的资源:

类型 含义 是否进入左侧导航 典型字段
M 目录 名称、父节点、图标、排序
C 页面菜单 URL、打开方式、权限字符
F 功能按钮 权限字符,如 system:user:add

例如用户管理可以形成下面的树:

1
2
3
4
5
6
7
8
系统管理 M
└── 用户管理 C /system/user
├── 用户查询 F system:user:list
├── 用户新增 F system:user:add
├── 用户修改 F system:user:edit
├── 用户删除 F system:user:remove
├── 用户导入 F system:user:import
└── 用户导出 F system:user:export

角色授权页面展示的是这棵完整树。勾选一个按钮,本质上不是把按钮 HTML 交给角色,而是在 sys_role_menu 中写入一条 role_id + menu_id 关系;登录后再由该菜单记录的 perms 转换成 Shiro 字符串权限。

这种设计的好处是配置简单:一棵树完成页面入口和操作权限授权,代码生成器也容易生成配套 SQL。代价是菜单资源和授权语义强耦合。一个权限若需要同时保护多个页面、接口或非 UI 任务,只能复用同一字符串,数据库本身没有显式的“权限绑定多个资源”模型。

登录后,角色和权限字符如何进入 Shiro

功能权限真正生效的核心在 UserRealm。登录认证成功后,Shiro 在需要授权信息时调用 doGetAuthorizationInfo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection arg0)
{
SysUser user = ShiroUtils.getSysUser();
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();

if (user.isAdmin())
{
info.addRole("admin");
info.addStringPermission("*:*:*");
}
else
{
Set<String> roles = roleService.selectRoleKeys(user.getUserId());
Set<String> menus = menuService.selectPermsByUserId(user.getUserId());
info.setRoles(roles);
info.setStringPermissions(menus);
}
return info;
}

普通用户会装载两类字符串:

  • sys_role.role_key 进入 Shiro Roles,例如 commonfinance
  • sys_menu.perms 进入 Shiro String Permissions,例如 system:user:list

系统管理员是特殊分支。user.isAdmin() 不是判断用户名,也不是判断 role_key,而是判断内置管理员用户 ID。管理员直接获得角色 admin 和通配权限 *:*:*,不再查询角色菜单授权。

完整调用链如下:

sequenceDiagram
    participant U as 用户
    participant R as UserRealm
    participant S as Role/Menu Service
    participant DB as RBAC 表
    participant A as Shiro

    U->>R: 登录认证
    R->>DB: 查询用户、部门和角色
    R-->>U: 建立 Session
    A->>R: 请求授权信息
    R->>S: 查询 role_key 与 perms
    S->>DB: 关联角色、菜单
    DB-->>S: 字符串集合
    S-->>R: roles + permissions
    R-->>A: AuthorizationInfo

SysMenuMapper.xml 中的权限查询能够看出数据库链路:

1
2
3
4
5
6
7
8
select distinct m.perms
from sys_menu m
left join sys_role_menu rm on m.menu_id = rm.menu_id
left join sys_user_role ur on rm.role_id = ur.role_id
left join sys_role r on r.role_id = ur.role_id
where m.visible = '0'
and r.status = '0'
and ur.user_id = #{userId}

SysMenuServiceImpl.selectPermsByUserId 还会把一个字段中的多个逗号分隔权限拆开:

1
2
3
4
5
6
7
for (String perm : perms)
{
if (StringUtils.isNotEmpty(perm))
{
permsSet.addAll(Arrays.asList(perm.trim().split(",")));
}
}

因此多个角色的功能权限最终按集合合并,语义是并集:只要任一启用角色通过 sys_role_menu 获得某权限字符,用户就具备该权限。

菜单权限:控制导航入口

首页装载菜单时会调用 SysMenuServiceImpl.selectMenusByUser。管理员查询所有正常菜单,普通用户按用户 ID 关联角色与菜单:

1
2
3
4
5
6
7
8
9
if (user.isAdmin())
{
menus = menuMapper.selectMenuNormalAll();
}
else
{
menus = menuMapper.selectMenusByUserId(user.getUserId());
}
return getChildPerms(menus, 0);

普通用户对应的 SQL 只选择 MC

1
2
3
4
5
where ur.user_id = #{userId}
and m.menu_type in ('M', 'C')
and m.visible = 0
and ro.status = 0
order by m.parent_id, m.order_num

查询出的扁平列表再由 getChildPermsrecursionFnparent_id 递归组装为菜单树。按钮 F 不进入导航树,这是“菜单可见”和“按钮可用”能够共用一张表却不互相干扰的关键。

需要留意 visible 的双重效果。在当前源码中,菜单查询和权限字符查询都要求 visible = 0。所以把节点设为隐藏不仅可能让它不出现在导航中,也可能让其 perms 不进入普通用户的授权集合。若只是希望隐藏入口但仍允许从其他页面访问,不应想当然地把 visible 当作纯前端显示字段,应先验证该节点对应的权限装载行为。

按钮权限:前端有两种隐藏方式

经典版页面使用 Thymeleaf Shiro 方言控制静态按钮。例如用户管理页面中的新增按钮:

1
2
3
4
5
<a class="btn btn-success"
onclick="$.operate.addTab()"
shiro:hasPermission="system:user:add">
<i class="fa fa-plus"></i> 新增
</a>

服务端渲染模板时,Shiro 判断当前 Subject 是否拥有 system:user:add。没有权限时,该元素不会按正常方式呈现。

表格行内按钮由 JavaScript 动态拼接,无法简单依赖模板标签,于是 RuoYi 又提供了 PermissionService

1
2
3
4
<script th:inline="javascript">
var editFlag = [[${@permission.hasPermi('system:user:edit')}]];
var removeFlag = [[${@permission.hasPermi('system:user:remove')}]];
</script>
1
2
3
4
public String hasPermi(String permission)
{
return isPermitted(permission) ? StringUtils.EMPTY : "hidden";
}

JS 再把返回的空字符串或 hidden 拼到按钮 class 中:

1
2
3
actions.push(
'<a class="btn btn-success btn-xs ' + editFlag + '">编辑</a>'
);

两种按钮控制最终都调用 Subject.isPermitted(permission),只是适配了不同的渲染场景。

按钮隐藏只改善界面体验,不构成安全边界。攻击者可以直接构造 HTTP 请求,因此对应后端接口必须再次校验同一个权限字符。

接口权限:真正的安全边界在 @RequiresPermissions

用户管理 Controller 很清楚地展示了页面、列表和动作接口的权限划分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RequiresPermissions("system:user:view")
@GetMapping()
public String user() { ... }

@RequiresPermissions("system:user:list")
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysUser user) { ... }

@RequiresPermissions("system:user:add")
@PostMapping("/add")
@ResponseBody
public AjaxResult addSave(SysUser user) { ... }

@RequiresPermissions("system:user:export")
@PostMapping("/export")
@ResponseBody
public AjaxResult export(SysUser user) { ... }

ShiroConfig 注册了 AuthorizationAttributeSourceAdvisor,使 Shiro 能够拦截这些注解。用户不具备权限时,请求在进入业务方法前就被拒绝。

过滤链与方法注解分工不同:

1
2
3
4
filterChainDefinitionMap.put(
"/**",
"user,kickout,onlineSession,syncOnlineSession,csrfValidateFilter"
);

这条兜底规则主要要求用户已登录,并处理会话、踢出、在线状态和 CSRF。源码中曾预留按数据库 URL 生成 Shiro perms[...] 过滤规则的能力:

1
2
3
4
// 系统权限列表
// filterChainDefinitionMap.putAll(
// SpringUtils.getBean(IMenuService.class).selectPermsAll()
// );

但当前代码是注释状态。换句话说,当前版本不是每次根据 sys_menu.url 自动完成接口授权,具体业务接口主要依靠 @RequiresPermissions。数据库权限字符与 Java 注解之间也没有外键或编译期约束,它们靠字符串保持一致。

这解释了一个常见故障:角色明明勾选了按钮,接口仍提示无权限,或者页面按钮可见但调用接口失败。排查时应沿着同一字符串检查:

1
2
3
sys_menu.perms
= 页面 shiro:hasPermission / @permission.hasPermi
= Controller @RequiresPermissions

任意一处拼写、大小写或动作名不同,前后端行为就会分裂。

权限字符到底表示什么

RuoYi 常用三段式命名:

1
模块:资源:动作

例如:

1
2
3
4
5
6
system:user:view
system:user:list
system:user:add
system:user:edit
system:user:remove
system:user:export

三段式不是数据库强制格式,而是 Shiro WildcardPermission 的约定和项目命名规范。它使通配权限能够表达一组能力:

1
2
3
system:user:*    用户资源的全部动作
system:*:* system 模块的全部资源与动作
*:*:* 全部权限

角色还有另一种“权限字符”——sys_role.role_key。它对应 Shiro Role,通常通过 @RequiresRoles("admin")Subject.hasRole(...) 判断。两者不要混淆:

字段 例子 语义 推荐用途
sys_role.role_key finance 用户承担什么角色 少量角色级入口或特殊管理能力
sys_menu.perms finance:settlement:audit 用户能执行什么动作 业务接口和按钮的主要授权依据

业务接口更适合依赖细粒度权限,而不是写死角色。若退款接口只允许 FINANCE_ADMIN,以后财务经理也需要退款时就必须改代码;若接口要求 order:refund,只需在后台调整角色授权。

数据权限不是接口权限的另一种写法

功能权限回答“能不能查询用户列表”,数据权限回答“查询时能看到哪些用户”。RuoYi 把数据范围配置在角色上,Constants.Dept 定义了五种取值:

数据范围 SQL 语义
1 全部数据权限 不追加限制
2 自定义数据权限 部门 ID 来自 sys_role_dept
3 本部门数据权限 dept_id = 当前用户部门
4 本部门及以下数据权限 当前部门或 ancestors 包含当前部门
5 仅本人数据权限 user_id = 当前用户

当角色选择“自定义数据权限”时,角色管理页面提交选中的 deptIdsSysRoleServiceImpl.authDataScope 更新 sys_role.data_scope,先删除旧的 sys_role_dept,再批量写入新的角色部门关系,整个过程在事务中执行。

数据过滤由 @DataScope 标在 Service 方法上。例如:

1
2
3
4
5
6
@Override
@DataScope(deptAlias = "d", userAlias = "u")
public List<SysUser> selectUserList(SysUser user)
{
return userMapper.selectUserList(user);
}

deptAliasuserAlias 必须与 Mapper SQL 中的表别名一致。默认字段名分别是 dept_iduser_id,也可以通过 deptFielduserField 覆盖。

执行流程如下:

flowchart TD
    A["调用带 @DataScope 的 Service"] --> B["DataScopeAspect 读取当前用户"]
    B --> C{"是否超级管理员"}
    C -- 是 --> D["不追加过滤条件"]
    C -- 否 --> E["遍历启用角色与 data_scope"]
    E --> F["生成部门或本人 SQL 条件"]
    F --> G["写入 params.dataScope"]
    G --> H["Mapper 通过 ${params.dataScope} 拼入查询"]

以用户列表为例,Mapper 末尾预留了插槽:

1
2
3
4
5
6
7
8
<select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
select ...
from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
...
${params.dataScope}
</select>

DataScopeAspect 会根据角色生成类似条件:

1
2
3
4
5
6
7
8
9
10
AND (
d.dept_id = 103
OR d.dept_id IN (
SELECT dept_id
FROM sys_dept
WHERE dept_id = 103
OR FIND_IN_SET(103, ancestors)
)
OR u.user_id = 42
)

这些条件使用 OR 合并,因此用户具有多个角色时,最终可见数据通常是各角色范围的并集。只要其中一个符合当前业务权限的角色具有“全部数据权限”,过滤条件就会被清空,用户获得全部数据。

为什么数据权限还要关心权限字符

如果一个用户同时有两个角色:

  • 角色 A 拥有 system:user:list,数据范围为本部门;
  • 角色 B 不拥有 system:user:list,但数据范围为全部数据。

若数据过滤无条件合并所有角色,角色 B 会把角色 A 的用户查询能力意外放大为全量数据。RuoYi 当前源码专门处理了这个问题。

PermissionsAspect 会读取 Controller 的 @RequiresPermissions,把权限字符放入请求级 PermissionContextHolder

1
2
3
4
5
6
7
8
9
@Before("@annotation(controllerRequiresPermissions)")
public void doBefore(
JoinPoint point,
RequiresPermissions controllerRequiresPermissions)
{
PermissionContextHolder.setContext(
StringUtils.join(controllerRequiresPermissions.value(), ",")
);
}

登录时,SysLoginService.setRolePermission 又为每个角色单独查询并保存其权限字符集合。DataScopeAspect 遍历角色时,只采用拥有当前接口权限的角色:

1
2
3
4
5
6
7
if (StringUtils.isNotEmpty(permission)
&& !StringUtils.containsAny(
role.getPermissions(),
Convert.toStrArray(permission)))
{
continue;
}

因此上例中只有角色 A 参与数据范围计算,最终仍限制为本部门。这是 RuoYi 数据权限实现中非常关键、也很容易被忽略的一层:数据范围不是用户所有角色范围的无条件并集,而是与当前权限字符匹配后的角色范围并集。

@DataScope.permission 允许显式指定权限;未指定时,默认读取当前请求中 @RequiresPermissions 提供的字符。若业务调用不经过带权限注解的 Controller,或者异步任务、内部调用没有请求上下文,就需要重新审视权限字符的传递方式,不能假设 AOP 会自动知道业务意图。

数据权限 SQL 拼接的安全边界

Mapper 使用的是 ${params.dataScope},这是文本替换,不是 MyBatis 的 #{} 参数绑定。直接把客户端传入的 params.dataScope 拼进去会形成明显的 SQL 注入风险。

RuoYi 的防护方式是:DataScopeAspect 在生成条件前先把调用参数中的 dataScope 清空,然后只写入服务端根据当前登录用户、角色 ID、用户 ID和固定别名生成的 SQL:

1
2
3
4
5
6
7
8
9
private void clearDataScope(final JoinPoint joinPoint)
{
Object params = joinPoint.getArgs()[0];
if (params instanceof BaseEntity)
{
BaseEntity baseEntity = (BaseEntity) params;
baseEntity.getParams().put(DATA_SCOPE, "");
}
}

这套机制成立依赖几个前提:

  • @DataScope 的查询对象必须是方法第一个参数;
  • 第一个参数必须继承 BaseEntity
  • Mapper 必须从该对象的 params.dataScope 读取条件;
  • 别名和字段名来自代码中的可信注解配置,不能来自请求参数;
  • 业务代码不能绕过 Service,直接调用未受保护的 Mapper 查询。

如果新增业务时只复制 ${params.dataScope},却忘了在对应 Service 方法上加 @DataScope,最好的结果是没有过滤,最坏的结果是给不可信文本留下拼接入口。这个地方看着像一行 XML,实际背后背着整条安全链路。

权限配置修改后为什么要清缓存

UserRealm 的授权信息启用了名为 sys-authCache 的缓存。角色菜单变化后,如果缓存不失效,在线用户可能继续使用旧权限。

RuoYi 在角色和菜单等管理操作中调用:

1
AuthorizationUtils.clearAllCachedAuthorizationInfo();

最终由 UserRealm.clearAllCachedAuthorizationInfo() 清空授权缓存。当前实现选择“清全部”而不是精确失效受影响用户,逻辑简单可靠,但在用户和角色规模很大时会造成额外的重载开销。

数据权限还有另一层状态问题:登录时的 SysUser Principal 已经带有角色及其 dataScope、每角色权限集合。修改当前角色的数据范围后,Controller 会重新查询当前用户并写回 Session;对于其他在线用户,应结合实际版本测试其 Principal 与授权缓存是否都能及时刷新。企业改造时更稳妥的方案是引入权限版本号或用户级缓存失效事件,而不是只依赖全量缓存清理。

一次“用户查询”请求的完整权限链

POST /system/user/list 为例,从登录到返回数据会经过下面几步:

  1. 用户登录,SysLoginService 查询用户、部门和角色,并把每个角色拥有的权限字符写入 role.permissions
  2. Shiro 需要授权信息时,UserRealm 查询用户的 role_key 与全部 sys_menu.perms,形成 AuthorizationInfo 并缓存。
  3. 首页查询 M/C 节点生成菜单树,决定是否展示“用户管理”入口。
  4. 用户页面用 shiro:hasPermissionPermissionService 决定新增、编辑、删除等按钮是否显示。
  5. 请求 /system/user/list 时,@RequiresPermissions("system:user:list") 再做一次后端强制校验。
  6. PermissionsAspectsystem:user:list 保存到当前请求上下文。
  7. Controller 调用带 @DataScope(deptAlias = "d", userAlias = "u")selectUserList
  8. DataScopeAspect 只选择拥有 system:user:list 的角色,根据其 data_scope 生成 SQL。
  9. MyBatis 将服务端生成的 ${params.dataScope} 拼入查询,数据库返回允许范围内的用户记录。

可以把最终判定理解成:

1
2
3
4
5
6
7
请求可执行
= 已登录
AND 拥有接口权限字符

返回数据集合
= 原始查询结果
INTERSECT 当前权限对应角色的数据范围并集

菜单和按钮只负责让正常用户“看得见、点得到”,接口注解和数据过滤才真正决定“做不做得了、能看到多少”。

这套设计的优点与边界

RuoYi 的方案非常适合典型企业管理后台,原因在于它把管理员最常操作的配置压缩成两棵树:角色菜单树和角色部门树。新增普通 CRUD 模块时,只要保持权限字符一致并套用已有模板,就能快速获得菜单、按钮、接口和部门数据过滤能力。

它的主要优点包括:

  • 用户、角色、功能权限关系清晰,符合 RBAC0 的基本结构;
  • 目录、页面和按钮统一配置,管理成本低;
  • 前端隐藏与后端强制校验复用同一权限字符;
  • 数据权限支持全部、自定义部门、本部门、部门及以下和本人;
  • 数据范围会按当前接口权限筛选角色,减少多角色造成的越权;
  • 超级管理员、禁用角色和授权缓存都有明确处理。

边界同样明显:

菜单、权限和资源耦合

sys_menu 同时承担导航树、按钮资源和权限目录。一旦系统需要 API、字段、文件、工作流节点、消息主题等非菜单资源,这张表会越来越像“万能权限表”。更通用的 IAM 通常会拆为 PermissionResource,让一个业务权限绑定多个资源。

接口与数据库配置靠字符串约定

@RequiresPermissions("system:user:list")sys_menu.perms 没有结构化绑定。重命名、复制代码或手工配置时容易漂移。可以通过启动时扫描注解并与数据库比对、自动生成权限清单、集成测试等方式降低风险。

数据权限以 SQL 文本注入查询

它对 MyBatis 和查询别名有较强侵入,方法参数位置、BaseEntity、别名和 Mapper 插槽都必须遵循约定。复杂报表、多表聚合、子查询、跨库查询或 JPA 场景通常需要专门设计,而不是继续机械复制 ${params.dataScope}

默认模型不是多租户隔离

部门数据范围不能替代 Tenant 隔离。SaaS 系统必须在所有业务数据和查询链路上独立约束 tenant_id,且租户条件应优先作为不可绕过的安全边界。数据权限是在租户内部继续缩小可见范围,两者不能合并成一个部门条件。

不支持复杂约束和显式拒绝

多个角色主要按权限并集工作,没有内建的互斥角色、职责分离、审批上下文、时间与环境条件,也没有 Deny 优先策略。财务付款申请人与审批人互斥、按金额授权、按数据属性决策等场景,需要 RBAC2、ABAC 或策略引擎补充。

基于 RuoYi 扩展权限体系时的建议

若继续沿用当前设计,至少应保持下面几条工程纪律:

  1. 权限字符使用稳定的 domain:resource:action 命名,不把 URL、中文名称或角色名直接当权限。
  2. 每个危险接口都必须有后端权限校验,不能因为按钮已隐藏就省略注解。
  3. 新增接口时同步检查菜单配置、模板权限和 Controller 注解,最好用自动化测试验证三者一致。
  4. @DataScope 放在可被 Spring AOP 拦截的 Service 公共方法上,避免同类自调用绕过代理。
  5. 严格核对 deptAliasuserAlias 与 SQL 别名;查询模型不是 BaseEntity 时不要直接照搬。
  6. 把租户隔离、功能权限和数据权限作为三层独立边界,禁止用部门范围代替租户条件。
  7. 权限变更后明确设计缓存和在线会话失效策略;高规模场景优先精确失效而非全量清缓存。
  8. 对导出、批量删除、角色分配、数据权限配置等高风险操作增加审计日志和越权测试。

如果系统已经出现应用、菜单、路由、按钮、API、字段等多种资源类型,更合适的演进方向是:

flowchart LR
    U["User"] --> R["Role"]
    R --> P["Permission"]
    P --> X["Resource"]
    X --> T["MENU / API / ACTION / FIELD"]

这样角色只绑定业务能力,权限再绑定一个或多个具体资源。RuoYi 原有的 perms 可以逐步迁移为 Permission.code,菜单和按钮则变成 Resource,而数据范围作为角色权限关系或授权策略的附加约束。比起继续给 sys_menu 塞更多类型,这条路后期更省心——权限表也不必兼职整个宇宙。

总结

RuoYi 经典版的权限体系不是单纯的“角色勾菜单”,而是四套机制围绕同一个权限字符协作:

  • sys_user_rolesys_role_menu 建立用户、角色、菜单/按钮之间的 RBAC 关系;
  • UserRealmrole_keysys_menu.perms 装入 Shiro,并为管理员授予 *:*:*
  • M/C 节点生成导航,F 节点提供按钮权限,Thymeleaf 只负责界面可见性;
  • Controller 的 @RequiresPermissions 才是业务接口的强制授权边界;
  • 角色 data_scopesys_role_dept@DataScope${params.dataScope} 共同完成行级数据过滤;
  • PermissionsAspect 将接口权限传给数据权限切面,使数据范围只由真正授予当前能力的角色决定。

理解这条链路后,许多若依权限问题就不再神秘:菜单不显示要查 M/C 查询与角色菜单关系,按钮不显示要查 F 节点和权限字符,接口 403 要查 Shiro 授权集合与注解,数据过多或过少则要查 data_scope、角色权限匹配、AOP 参数和 Mapper SQL 插槽。它们共用一套配置入口,但每一层解决的是不同问题。


RuoYi 权限体系源码解析:菜单、按钮、接口权限与数据权限如何协同
https://allendericdalexander.github.io/2026/08/16/java/ruoyi-permission-source-analysis/
作者
AtLuoFu
发布于
2026年8月16日
许可协议