若依 RBAC 权限控制学习笔记
整理自关于若依权限体系的完整讨论,涵盖核心模型、认证授权流程、接口权限机制、角色授权方式,以及从硬编码 AOP 迁移到若依动态权限的完整代码示例。
一、总体认识
若依的 RBAC 权限控制不是单一技术,而是一套贯穿前后端、从接口到数据行的多层纵深防御体系。核心是在经典 "用户 → 角色 → 权限" 模型基础上,结合 Spring Security 与 JWT 进行工程化扩展。
权限划分为四个层级:
- 菜单权限:用户能看到哪些页面/路由。
- 按钮权限:页面内哪些按钮/元素显示。
- 接口权限:后端接口是否允许调用。
- 数据权限:能查看哪些数据行。
二、核心数据表设计
| 表名 | 作用 |
|---|---|
sys_user |
用户表 |
sys_role |
角色表,role_key 用于角色校验 |
sys_menu |
菜单表,同时存储页面路由、按钮权限、接口权限标识 |
sys_user_role |
用户与角色关联表 |
sys_role_menu |
角色与菜单/按钮/接口权限关联表 |
关键字段:
sys_menu.menu_type:M:目录C:页面菜单F:按钮/接口权限
sys_menu.perms:权限标识字符串,例如system:user:add。- 管理员角色通常拥有通配权限
*:*:*。
三、认证流程:确认"你是谁"
若依采用 Spring Security + JWT + Redis 组合方案。
1. 登录认证
用户登录时,SysLoginService 调用 AuthenticationManager 触发 Spring Security 认证流程。UserDetailsServiceImpl 从数据库加载用户信息、密码并校验。
2. 权限加载
认证成功后,SysPermissionService 根据用户 → 角色 → 菜单权限,加载出该用户的权限集合 Set<String> permissions,放入 LoginUser。
3. 令牌生成与缓存
系统生成 UUID 作为 Key,将 LoginUser 存入 Redis,并生成 JWT 返回前端。后续请求通过 JWT 识别用户,同时利用 Redis 实现令牌续期与主动失效。
4. 请求拦截
每个请求到达后端时,JwtAuthenticationTokenFilter 拦截请求,解析 JWT,从 Redis 取出对应的 LoginUser 存入 SecurityContext,为后续鉴权准备数据。
四、授权机制:判断"你能做什么"
1. 接口层:方法级权限校验
若依使用 Spring Security 的 @PreAuthorize 注解和自定义 PermissionService(Bean 名称为 ss)实现接口权限控制。
示例:
java
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
return userService.selectUserList(user);
}
校验逻辑:
@PreAuthorize是 Spring Security 的方法级授权注解。@ss是若依自定义的PermissionServiceBean。hasPermi('system:user:list')从当前登录用户LoginUser的permissions集合中判断是否包含该权限字符串。- 管理员拥有
*:*:*,直接放行。 - 角色校验可用
@ss.hasRole('admin')。
2. 前端层:路由与按钮控制
页面/路由级:动态路由
用户登录后,前端调用 getRouters 接口获取有权访问的菜单树,通过 GenerateRoutes 动态生成 Vue Router 路由配置,避免用户直接输入 URL 访问未授权页面。
按钮/元素级:自定义指令
若依提供 v-hasPermi 和 v-hasRole 指令:
html
<el-button v-hasPermi="['system:user:add']" @click="handleAdd">新增用户</el-button>
无权限时直接从 DOM 中移除元素。
3. 数据层:行级数据过滤
数据权限控制用户能看到哪些数据行,核心机制是 基于 AOP 的注解 + MyBatis 拦截器/动态 SQL。
若依定义 5 种数据权限范围,与角色绑定:
- 全部数据权限
- 自定数据权限
- 本部门数据权限
- 本部门及以下数据权限
- 仅本人数据权限
在查询方法或 Mapper 接口上添加 @DataScope 注解后,框架会根据当前用户角色配置的数据权限范围,在 SQL 后追加类似 AND dept_id IN (...) 或 AND create_by = ... 的过滤条件。
五、重点澄清:接口权限不是"AOP 动态查表放行"
常见误解:
若依接口权限是使用 AOP 切面外加动态表获取用户角色信息,给相应角色开放后端接口。
更准确的理解:
-
不是若依自己写 AOP 切面动态决定放行哪个接口
接口权限校验使用的是 Spring Security 的方法级授权
@PreAuthorize。底层会经过 Spring AOP 代理/方法拦截器,但这是 Spring Security 的机制,不是若依额外写切面动态开放接口。 -
不是每次请求都动态查表获取角色权限
用户角色和权限信息主要在登录认证阶段加载,放入
LoginUser并缓存到 Redis。接口调用时只从当前登录用户上下文取权限集合做判断,不是每个接口都查sys_user_role、sys_role_menu、sys_menu。
准确概括:
若依接口权限 = Spring Security 的
@PreAuthorize方法级授权 + 自定义PermissionService校验当前登录用户权限集合。角色和权限数据在登录时加载并缓存,接口调用时只做内存/上下文判断。
角色或权限变更后,通常需要用户重新登录或刷新缓存,权限才会生效。
六、如何给角色分配后端接口权限
若依不是单独维护"接口列表"再勾选,而是通过 "菜单权限"树间接完成。核心逻辑:
后端接口的权限标识
perms被绑定在menu_type='F'的菜单项上,给角色勾选这些菜单项,就等于授予该角色调用对应接口的权限。
1. 管理后台操作
入口:系统管理 → 角色管理。
- 编辑目标角色,点击"菜单权限"。
- 在树形菜单中勾选需要的权限节点,包括页面菜单及其下面的"按钮"类型节点。
- 保存后,系统更新
sys_role_menu关联表。
注意:只能勾选已存在于菜单树中的权限项。新增接口权限前,通常需要先在 系统管理 → 菜单管理 中创建对应权限节点。
2. 数据链路
第一步:接口权限标识存储在 sys_menu
为接口创建一条特殊记录:
menu_type = 'F'perms = 'system:user:add'等权限字符串
第二步:角色与权限绑定存储在 sys_role_menu
给角色勾选权限后,sys_role_menu 插入记录,表示"某角色拥有某菜单/按钮/接口权限"。
第三步:登录时聚合权限
SysPermissionService 根据 sys_user_role 找到用户角色,再通过 sys_role_menu 找到角色关联的 menu_id,从 sys_menu 筛选 menu_type='F' 且 perms 不为空的记录,提取 perms,汇总成 Set<String> permissions,存入 LoginUser 并缓存 Redis。
第四步:接口调用时校验
@ss.hasPermi('system:user:add') 判断缓存好的权限集合中是否包含该字符串。
3. 完整链路总结
text
sys_menu 中创建 menu_type='F' 的接口权限记录并写 perms
↓
角色管理界面勾选该记录,数据写入 sys_role_menu
↓
用户登录时聚合其角色关联的所有 perms,存入 LoginUser.permissions
↓
接口调用时 @PreAuthorize("@ss.hasPermi('...')") 校验集合是否包含权限字符串
七、从硬编码 AOP 迁移到若依动态权限:工期与步骤
1. 工期估算
从硬编码 @AOP 角色判断迁移到若依动态权限,核心工作量不在编码复杂度,而在存量接口盘点 + 权限数据配置的准确性。
- 中等规模单体项目,熟练开发者通常 1~2 周 完成主体迁移和测试。
- 系统简单:3~5 天。
- 接口数量庞大或涉及数据权限:2~3 周甚至更久。
2. 改造步骤
阶段一:数据库与权限数据初始化(约 1~2 天)
- 盘点所有需要权限控制的接口。
- 为每个接口定义唯一权限标识,命名规范通常为
{模块}:{业务}:{操作},如system:user:add。 - 将权限标识作为
menu_type='F'的记录插入sys_menu。 - 在角色管理界面为角色勾选权限节点,数据写入
sys_role_menu。
阶段二:后端代码改造(约 2~3 天)
- 将硬编码 AOP 注解替换为
@PreAuthorize("@ss.hasPermi('模块:业务:操作')")。 - 保证
@PreAuthorize中的权限字符串与sys_menu.perms完全一致。
阶段三:前端权限点适配(约 1~2 天,如需要)
- 将
v-if="hasRole('Admin')"等判断替换为v-hasPermi="['模块:业务:操作']"。 - 权限字符串必须与后端、数据库一致。
阶段四:迁移验证与测试(约 2~3 天)
- 使用不同角色账号登录,验证接口放行与拦截。
- 注意 Redis 缓存:修改角色权限后,通常需要清缓存或让用户重新登录。
3. 影响工期的关键变量
- 存量接口数量。
- 是否涉及数据权限迁移。
- 团队对若依
PermissionService、菜单配置流程的熟悉程度。
八、代码讲解:改造前 vs 改造后
1. 改造前:硬编码 AOP 方式
自定义注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
String[] value();
}
AOP 切面
java
@Aspect
@Component
public class RoleAspect {
@Around("@annotation(requireRole)")
public Object check(ProceedingJoinPoint pjp, RequireRole requireRole) throws Throwable {
String currentRole = UserContext.getCurrentRole();
boolean allowed = Arrays.asList(requireRole.value()).contains(currentRole);
if (!allowed) {
throw new AccessDeniedException("无权限");
}
return pjp.proceed();
}
}
使用方式
java
@RequireRole({"User", "Admin"})
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
return userService.selectUserList(user);
}
问题:角色写死在注解中,新增角色或调整权限需要改代码重新发布;只能控制到角色粒度,无法控制到具体接口。
2. 改造后:若依动态权限方式
后端代码只改注解
java
// 原来
// @RequireRole({"User", "Admin"})
// 改为
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
return userService.selectUserList(user);
}
@ss 对应的 PermissionService
java
@Service("ss")
public class PermissionService {
private static final String ALL_PERMISSION = "*:*:*";
public boolean hasPermi(String permission) {
if (StringUtils.isEmpty(permission)) {
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser)
|| CollectionUtils.isEmpty(loginUser.getPermissions())) {
return false;
}
return hasPermissions(loginUser.getPermissions(), permission);
}
private boolean hasPermissions(Set<String> permissions, String permission) {
return permissions.contains(ALL_PERMISSION)
|| permissions.contains(StringUtils.trim(permission));
}
public boolean hasRole(String role) {
LoginUser loginUser = SecurityUtils.getLoginUser();
return loginUser != null && loginUser.getRoles().contains(role);
}
}
关键点:校验时不查数据库,只判断 LoginUser.permissions 集合是否包含权限字符串。
登录时权限加载:SysPermissionService
java
@Component
public class SysPermissionService {
@Autowired
private ISysMenuService menuService;
public Set<String> getRolePermission(SysUser user) {
Set<String> roles = new HashSet<>();
if (user.isAdmin()) {
roles.add("admin");
} else {
roles.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
}
return roles;
}
public Set<String> getMenuPermission(SysUser user) {
Set<String> perms = new HashSet<>();
if (user.isAdmin()) {
perms.add("*:*:*");
} else {
List<SysRole> roles = user.getRoles();
if (!CollectionUtils.isEmpty(roles)) {
for (SysRole role : roles) {
Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId());
role.setPermissions(rolePerms);
perms.addAll(rolePerms);
}
} else {
perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
}
}
return perms;
}
}
UserDetailsServiceImpl 组装 LoginUser
java
@Override
public UserDetails loadUserByUsername(String username) {
SysUser user = userService.selectUserByUserName(username);
Set<String> userPermissions = permissionService.getMenuPermission(user);
return new LoginUser(user.getUserId(), user.getDeptId(), user, userPermissions);
}
权限查询 SQL 示例
xml
<select id="selectMenuPermsByUserId" resultType="String">
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
where m.status = '0'
and m.perms is not null and m.perms != ''
and ur.user_id = #{userId}
</select>
数据配置:新增接口权限并授权
sql
-- 1. 在菜单表新增接口/按钮权限节点
INSERT INTO sys_menu (
menu_name, parent_id, order_num, path, component,
is_frame, is_cache, menu_type, visible, status,
perms, icon, create_by, create_time, remark
) VALUES (
'用户查询', 100, 1, '', NULL,
1, 0, 'F', '0', '0',
'system:user:list', '#', 'admin', sysdate(), ''
);
-- 假设生成 menu_id = 1001
-- 2. 角色与权限节点绑定
INSERT INTO sys_role_menu (role_id, menu_id) VALUES (2, 1001);
INSERT INTO sys_role_menu (role_id, menu_id) VALUES (3, 1001);
前端同步改造
html
<!-- 改造前 -->
<el-button v-if="currentRole === 'Admin'">新增用户</el-button>
<!-- 改造后 -->
<el-button v-hasPermi="['system:user:add']" @click="handleAdd">新增用户</el-button>
v-hasPermi 指令核心逻辑:
js
export default {
mounted(el, binding) {
const { value } = binding;
const all_permission = "*:*:*";
const permissions = store.getters && store.getters.permissions;
if (value && value instanceof Array && value.length > 0) {
const permissionFlag = value;
const hasPermissions = permissions.some(permission => {
return all_permission === permission || permissionFlag.includes(permission);
});
if (!hasPermissions) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
throw new Error(`请设置操作权限标签值`);
}
}
};
九、完整迁移示例:删除用户接口
① 后端代码
java
@PreAuthorize("@ss.hasPermi('system:user:remove')")
@DeleteMapping("/{userIds}")
public AjaxResult remove(@PathVariable Long[] userIds) {
return toAjax(userService.deleteUserByIds(userIds));
}
② 数据库插入权限节点
sql
INSERT INTO sys_menu (
menu_name, parent_id, order_num, menu_type, perms, status
) VALUES (
'用户删除', 100, 2, 'F', 'system:user:remove', '0'
);
③ 角色授权
sql
INSERT INTO sys_role_menu (role_id, menu_id)
VALUES (2, <上一步生成的 menu_id>);
④ 前端按钮
html
<el-button
v-hasPermi="['system:user:remove']"
type="danger"
@click="handleDelete">
删除
</el-button>
⑤ 验证
- 管理员登录:看到按钮并能删除。
- 普通角色未勾选:按钮不显示,直接调接口返回 403。
十、迁移对照总表
| 维度 | 改造前:硬编码 AOP | 改造后:若依动态权限 |
|---|---|---|
| 接口注解 | @RequireRole({"User","Admin"}) |
@PreAuthorize("@ss.hasPermi('system:user:list')") |
| 权限数据存放 | 代码中写死 | sys_menu.perms |
| 角色授权 | 改代码重新发布 | 后台勾选,写 sys_role_menu |
| 校验时机 | 每次请求走 AOP | 登录时聚合成 Set,请求时 contains |
| 前端控制 | v-if="role==='Admin'" |
v-hasPermi="['system:user:add']" |
| 权限粒度 | 角色级 | 接口/按钮级 |
| 动态调整 | 需改代码 | 改数据并刷新缓存/重新登录 |
十一、易错点与总结
易错点
-
三端权限字符串必须完全一致
sys_menu.perms=@ss.hasPermi('...')=v-hasPermi="['...']"。大小写、分隔符、拼写错一个,就会导致前端不显示或接口 403。 -
改完权限要清缓存或重新登录
权限在登录时加载进 Redis。后台修改角色权限后,旧 token 中的
LoginUser.permissions仍是旧数据,必须让用户重新登录或刷新缓存。 -
不要误以为每次接口请求都查权限表
接口调用时只判断
LoginUser.permissions集合,真正的查表发生在登录阶段。
总结
若依 RBAC 的完整体系:
- 认证 :Spring Security + JWT + Redis,登录时加载用户和权限,缓存
LoginUser。 - 接口授权 :
@PreAuthorize("@ss.hasPermi('...')")校验当前用户权限集合。 - 前端授权 :动态路由 +
v-hasPermi/v-hasRole指令。 - 数据授权 :
@DataScope注解 + AOP + 动态 SQL 实现行级过滤。 - 角色授权接口权限 :通过
sys_menu的menu_type='F'记录承载perms,角色勾选后写入sys_role_menu,登录时聚合为权限集合。
从硬编码 AOP 迁移到若依动态权限,代码层面每个接口通常只改一行注解,主要工作量和风险在存量接口盘点、权限字符串配置一致性、角色授权数据以及缓存刷新验证上。