1. 模块概述
ruoyi-system 是整个平台的 核心业务模块,负责用户、角色、菜单、部门、岗位、字典、配置、通知公告、操作日志、登录日志等全部系统管理功能。
模块分层架构
graph TB
subgraph Controller层
UC[SysUserController]
RC[SysRoleController]
MC[SysMenuController]
DC[SysDeptController]
end
subgraph Service层
USI[ISysUserService]
US[SysUserServiceImpl]
PS[SysPermissionServiceImpl]
MS[SysMenuServiceImpl]
RS[SysRoleServiceImpl]
end
subgraph Mapper层
UM[SysUserMapper]
RM[SysRoleMapper]
MM[SysMenuMapper]
DM[SysDeptMapper]
end
subgraph AOP切面
DS[DataScopeAspect
数据权限]
IA[InnerAuthAspect
内部认证]
end
subgraph Domain
SYS_USER[SysUser]
SYS_ROLE[SysRole]
SYS_MENU[SysMenu]
end
UC --> USI
RC --> RS
MC --> MS
DC --> DC
USI --> US
US --> UM
US --> DS
RS --> RM
MS --> MM
US --> PS
PS --> RS
PS --> MS
核心业务表关系
1 | sys_user ──< sys_user_role >── sys_role ──< sys_role_menu >── sys_menu |
2. 命名体系与易混淆函数对比
2.1 命名规律拆解
| 前缀 | 模块/对象 | 后缀 | 含义 | 示例 |
|---|---|---|---|---|
select |
User |
ById |
按主键查询单个对象 | selectUserById(Long) |
select |
User |
ByUserName |
按用户名查询 | selectUserByUserName(String) |
select |
User |
List |
分页列表查询(含数据权限过滤) | selectUserList(SysUser) |
select |
User |
AllocatedList |
已分配某角色的用户列表 | selectAllocatedList(SysUser) |
select |
User |
UnallocatedList |
未分配某角色的用户列表 | selectUnallocatedList(SysUser) |
insert |
User |
— | 新增用户(含关联表操作) | insertUser(SysUser) |
update |
User |
— | 修改用户(先删关联再重建) | updateUser(SysUser) |
delete |
User |
ById |
按 ID 删除用户(含关联表) | deleteUserById(Long) |
delete |
User |
ByIds |
批量删除用户 | deleteUserByIds(Long[]) |
check |
User |
NameUnique |
校验用户名唯一性 | checkUserNameUnique(SysUser) |
select |
Menu |
PermsByUserId |
按用户 ID 查询权限集合 | selectMenuPermsByUserId(Long) |
select |
Menu |
PermsByRoleId |
按角色 ID 查询权限集合 | selectMenuPermsByRoleId(Long) |
select |
Menu |
TreeByUserId |
按用户 ID 查询菜单树 | selectMenuTreeByUserId(Long) |
build |
Menus |
— | 将菜单列表转为前端路由格式 | buildMenus(List) |
build |
Menu |
Tree |
将菜单列表转为树结构 | buildMenuTree(List) |
命名规律总结:
selectXxxByUserIdvsselectXxxByRoleId:分别从用户维度和角色维度查询,前者用于用户登录后加载菜单,后者用于角色授权时展示可选权限buildMenus是前端路由适配层,buildMenuTree是通用树结构转换,两者返回类型不同(RouterVo vs SysMenu)
2.2 易混淆函数对比表
selectMenuPermsByUserId vs selectMenuPermsByRoleId vs selectMenuTreeByUserId
| 对比维度 | selectMenuPermsByUserId | selectMenuPermsByRoleId | selectMenuTreeByUserId |
|---|---|---|---|
| 输入 | 用户 ID | 角色 ID | 用户 ID |
| 输出 | Set<String> 权限标识 |
Set<String> 权限标识 |
List<SysMenu> 菜单树 |
| 用途 | 用户登录时加载权限列表 | 角色授权时展示可选权限 | 前端渲染侧边栏菜单 |
| 调用者 | SysPermissionServiceImpl | SysPermissionServiceImpl | SysLoginService(通过 Feign) |
| 一句话区分 | 用户维度查权限 vs 角色维度查权限 vs 用户维度查菜单树 |
getRolePermission vs getMenuPermission
| 对比维度 | getRolePermission | getMenuPermission |
|---|---|---|
| 返回 | 角色标识集合(如 admin) |
权限标识集合(如 system:user:list) |
| 用途 | 角色校验(@RequiresRoles) | 权限校验(@RequiresPermissions) |
| 管理员返回值 | {"admin"} |
{"*:*:*"} |
| 一句话区分 | 告诉系统”你是谁” vs 告诉系统”你能做什么” |
3. API Signatures
调用关系图
graph TD
subgraph "用户登录权限加载"
SLS["SysLoginService.getUserInfo"] --> SPS["PermissionService.getRolePermission"]
SLS --> SPS2["PermissionService.getMenuPermission"]
SPS --> RS["RoleService.selectRolePermissionByUserId"]
SPS2 --> MS["MenuService.selectMenuPermsByUserId"]
end
subgraph "用户管理"
UC1["SysUserController.list"] --> US1["SysUserServiceImpl.selectUserList"]
UC2["SysUserController.add"] --> US2["SysUserServiceImpl.insertUser"]
UC3["SysUserController.edit"] --> US3["SysUserServiceImpl.updateUser"]
US1 -.->|DataScope| DS["DataScopeAspect"]
US2 --> US2A["insertUserPost"]
US2 --> US2B["insertUserRole"]
US3 --> US3A["delete+insert UserRole"]
US3 --> US3B["delete+insert UserPost"]
end
subgraph "菜单路由构建"
MC1["SysMenuController.treeselect"] --> MS1["SysMenuServiceImpl.selectMenuTreeByUserId"]
MS1 --> MS1A["getChildPerms
递归构建树"]
MS1A --> MS1B["recursionFn
递归设置children"]
end
subgraph "数据权限"
DS --> DSF["dataScopeFilter
动态拼SQL"]
DSF --> DSL{"遍历用户角色
按数据权限级别"}
DSL -->|ALL| DSLA["不过滤"]
DSL -->|CUSTOM| DSLC["子查询sys_role_dept"]
DSL -->|DEPT| DSLD["dept_id=当前部门"]
DSL -->|DEPT_AND_CHILD| DSLE["递归查子部门"]
DSL -->|SELF| DSLF["user_id=当前用户"]
end
完整签名(核心函数)
1 | // === SysUserServiceImpl === |
4. 数据结构深度解析
4.a SysUser — 为什么需要这个结构体
用户是系统的核心实体,几乎所有业务操作都需要关联到用户。SysUser 不仅承载用户的基本身份信息(用户名、密码、部门),还作为 MyBatis 的 ORM 映射对象,与
sys_user表一一对应。其isAdmin()方法(判断 userId 是否为 1)是整个权限体系的关键分支点——超级管理员在数据权限和菜单权限上都有特殊处理。
4.b 结构定义
1 | public class SysUser extends BaseEntity { |
4.c 字段三层分析表
| 字段 | 设计动机(为什么需要) | 反事实(如果去掉会怎样) | 替代方案(还能怎么做) |
|---|---|---|---|
roleIds (Long[]) |
前端修改用户时一次性传入角色列表,后端接收后批量处理关联表 | 需要额外的接口来管理用户-角色关系,增加前端交互复杂度 | 可以用独立的用户授权接口,但会多一次网络请求 |
roles (List<SysRole>) |
缓存用户拥有的完整角色对象,供数据权限过滤使用 | 数据权限过滤时需要每次查询角色表,增加 DB 压力 | 可以将角色信息存入 Redis 而不是实体中,但增加了序列化复杂度 |
delFlag |
逻辑删除标记,保留数据用于审计和恢复 | 物理删除后无法追溯历史操作记录,误删无法恢复 | 可以用专门的归档表,但增加了数据迁移成本 |
status |
独立于 delFlag 的停用状态,支持临时冻结账号 | 停用和删除混在一起,无法区分”暂时冻结”和”永久删除” | 可以用一个枚举状态字段,但语义不如两个独立字段清晰 |
isAdmin() |
一行代码判断是否为超级管理员(userId == 1),权限系统的核心分支 | 每个需要判断管理员的代码都要重复 userId == 1L 的判断 |
可以用角色判断(hasRole("admin")),但 userId 判断更快且不依赖角色数据 |
4.d 用户生命周期状态图
stateDiagram-v2
[*] --> 新建: insertUser()
新建 --> 正常: status=0, delFlag=0
正常 --> 停用: updateUserStatus(status=1)
停用 --> 正常: updateUserStatus(status=0)
正常 --> 已删除: deleteUserById()
停用 --> 已删除: deleteUserById()
已删除 --> [*]: 数据保留不物理删除
4.e SysMenu 结构定义
1 | public class SysMenu extends BaseEntity { |
4.f SysMenu 字段三层分析表
| 字段 | 设计动机(为什么需要) | 反事实(如果去掉会怎样) | 替代方案(还能怎么做) |
|---|---|---|---|
menuType |
区分目录/菜单/按钮三种类型,决定了前端如何渲染 | 无法区分导航节点和操作按钮,权限粒度太粗 | 可以用单独的权限表,但增加了表关联复杂度 |
perms |
权限标识字符串,是 @RequiresPermissions 校验的匹配目标 | 只能用 URL 做权限控制,无法精细到按钮级别 | 可以用 RBAC 标准模型,但 system:user:list 格式更直观 |
component |
前端 Vue 组件的路径,用于动态路由加载 | 前端无法知道该菜单对应哪个 .vue 文件,需要硬编码路由映射 | 可以用约定大于配置(路径自动推导组件),但灵活性差 |
children |
非 DB 字段,运行时填充子菜单,用于构建树结构 | 每次需要树结构时都要重新查询和组装,性能差 | 可以存 parentId 由前端自行组装,但增加前端计算开销 |
5. 函数逐行精讲
5.a 菜单树构建
函数:SysMenuServiceImpl.buildMenus()
- 调用时机:用户登录后获取路由信息
- 典型调用者:SysLoginService.getUserInfo()(通过 Feign → Controller → Service)
- 前置条件:已从数据库加载用户的菜单列表(含父子关系)
- 目的:将数据库中的 SysMenu 对象转为前端 Vue Router 需要的 RouterVo 格式
1 |
|
5.b 数据权限 SQL 拼接
函数:DataScopeAspect.dataScopeFilter()
- 调用时机:被 @DataScope 标记的方法执行前(AOP Before)
- 典型调用者:DataScopeAspect.handleDataScope()
- 前置条件:当前用户已登录,拥有角色和数据权限级别信息
- 目的:根据用户角色的数据权限级别动态拼接 SQL WHERE 子句
1 | public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String userAlias, String deptAlias, String userField, String deptField, String permission) |
5.c 权限加载
函数:SysPermissionServiceImpl.getMenuPermission()
- 调用时机:用户登录时加载权限信息
- 典型调用者:SysLoginService.getUserInfo()
- 前置条件:用户基本信息已从数据库加载,roles 列表已填充
- 目的:加载用户拥有的全部权限标识,区分管理员和普通用户
1 |
|
6. 关键算法剖析
6.1 菜单树递归构建
SysMenuServiceImpl.getChildPerms() 将扁平菜单列表转为树结构:
1 | 算法步骤: |
6.2 前端路由构建的分支逻辑
buildMenus() 处理三种菜单类型:
graph TD
MENU[菜单节点] --> Q1{menuType = DIR
且有children?}
Q1 -->|是| CASE1[目录路由
alwaysShow=true
递归构建children]
Q1 -->|否| Q2{isMenuFrame?
一级菜单非外链}
Q2 -->|是| CASE2[菜单路由
自身包装为子路由
meta设为null]
Q2 -->|否| Q3{isInnerLink?
顶级内链}
Q3 -->|是| CASE3[内链路由
path替换特殊字符
component=InnerLink]
Q3 -->|否| DEFAULT[默认路由]
7. 设计决策分析
为什么 updateUser() 采用”先删后插”策略处理关联表?
updateUser() 中处理用户-角色和用户-岗位关联时,先 delete 再 insert:
1 | userRoleMapper.deleteUserRoleByUserId(userId); // 先全部删除 |
原因:
- 简化差异计算:如果采用”diff 后增删”,需要先查出原有关联、计算新增和删除的差异、分别执行。先删后插只需要两次数据库操作
- 事务保证:整个过程在
@Transactional中,失败自动回滚,不会出现数据不一致 - 关联数据量小:一个用户的角色和岗位数量通常很少(< 20),批量插入开销可忽略
为什么数据权限使用 params.dataScope 占位符而非 MyBatis 拦截器?
RuoYi 的数据权限通过 BaseEntity.params 的 dataScope 键传递 SQL 片段,在 MyBatis XML 中通过 ${params.dataScope} 引用。
为什么不用 MyBatis 拦截器?
- 灵活性:不同查询可能需要不同的表别名(如
d.dept_idvsdept.dept_id),通过@DataScope注解的deptAlias参数可灵活指定 - 可控性:不是所有查询都需要数据权限过滤,通过注解显式标记更清晰
- 调试友好:生成的 SQL 片段可在日志中直接看到,便于排查
为什么 clearDataScope() 在 handleDataScope() 之前调用?
1 | clearDataScope(point); // 先清空 |
防止 SQL 注入:如果不清空,攻击者可能通过在请求参数中注入 params.dataScope 来拼接恶意 SQL。先清空确保只有 AOP 生成的 SQL 片段才会生效。
8. 学习检查点
📝 本章小结
- ruoyi-system 是核心业务模块,管理用户/角色/菜单/部门/字典等,采用标准的三层架构(Controller → Service → Mapper)
- 数据权限通过 @DataScope 注解 + DataScopeAspect 实现五级隔离,SQL 片段通过
params.dataScope注入 - SysMenuServiceImpl.buildMenus() 将数据库菜单转为 Vue Router 格式,处理目录/菜单/内链三种分支
- 关联表(user_role、user_post、role_menu)采用”先删后插”策略,在事务保护下简化差异计算
- 超级管理员(userId=1)在权限和数据范围上都有特殊分支——拥有全部权限和全部数据
🤔 思考题
DataScopeAspect.dataScopeFilter() 中,如果用户同时拥有”全部数据权限”和”本部门数据权限”两个角色,最终的 SQL 条件是什么?
参考答案
最终 SQL 为空(不过滤)。代码第 90-95 行:当遇到
DATA_SCOPE_ALL时,执行sqlString = new StringBuilder()清空所有之前拼接的条件,然后break跳出循环。这是因为”全部数据权限”是最高级别,其优先级覆盖所有其他级别。所以即使用户也有”本部门”角色,只要有一个角色是”全部数据权限”,就不过滤数据。SysMenuServiceImpl.checkRouteConfigUnique() 中检查了三种冲突:同级路径冲突、根目录路径冲突、路由名称冲突。如果去掉路由名称的全局唯一约束会有什么问题?
参考答案
路由名称(routeName)在 Vue Router 中作为路由的
name属性,用于编程式导航(router.push({ name: 'User' }))和<keep-alive>缓存匹配。如果两个路由的 name 相同,Vue Router 会报警告且编程式导航可能跳转到错误页面。此外,<keep-alive :include="cachedViews">依赖 name 来识别缓存组件,同名会导致缓存混乱。因此路由名称的全局唯一性是必需的。SysPermissionServiceImpl.getMenuPermission() 中,对每个角色的权限查询结果调用
role.setPermissions(rolePerms)写回角色对象。这个操作是为什么?如果不写回会怎样?参考答案
写回
role.setPermissions()是为了后续 DataScopeAspect.dataScopeFilter() 中做权限匹配。数据权限过滤时需要判断角色是否包含当前方法的权限字符(StringUtils.containsAny(role.getPermissions(), ...)),如果不写回,这个判断将始终失败,导致该角色的数据权限级别被跳过。这是 LoginUser 中角色对象的权限字段被”懒加载”的一个例子——在登录时一次性填充,避免每次鉴权都查数据库。