数据权限,一个注解搞定!

[TOC]

上篇文章松哥和大家介绍了 Spring Security 中常见的八个权限注解,每一个权限注解都有它自己的使用场景,在这些权限注解中,有两个是后置的权限注解:@PostAuthorize@PostFilter,这两个都是在目标方法执行完毕之后进行权限处理,具体的用法小伙伴们可以参考上篇文章,这里我就不再赘述了。

这些后置的权限处理注解有一个共同劣势,就是得先去数据库中查询到数据,然后再根据当前用户权限进行过滤,如果数据量比较小,这样做倒也没有啥大问题,但是如果数据量比较大,这样做很明显效率会特别低,如果能在查询的时候就把数据过滤好了,那么就会特别省事了。

巧了,松哥最近在做的 TienChin 项目使用了 RuoYi-Vue 脚手架,这个脚手架中自定义了一个注解 @DataScope 就是专门用来处理数据权限的,上次有小伙伴在微信上问这个注解的实现原理是什么样子的?好吧,今天就安排上。

这篇文章我先和大家分析一下 RuoYi-Vue 脚手架中 @DataScope 注解的实现原理,在 TienChin 项目视频中到时候还会有深入讲解。

1. 思路分析

首先我们先来捋一捋这里的权限实现的思路。

@DataScope 注解处理的内容叫做数据权限,就是说你这个用户登录后能够访问哪些数据。传统的做法就是根据当前认证用户的 id 或者角色或者权限等信息去查询,但是这种做法比较麻烦比较费事,每次查询都要写大量 SQL,而这些 SQL 中又有大量雷同的地方,所以我们希望能够将之进行统一处理,进而就引出了 @DataScope 注解。

在 RuoYi-Vue 脚手架中,将用户的数据权限分为了五类,分别如下:

  • 1:这个表示全部数据权限,也就是这个用户登录上来之后可以访问所有的数据,一般来说只有超级管理员具备此权限。
  • 2:这个表示自定义数据权限,自定义数据权限就表示根据用户的角色查找到这个用户可以操作哪个部门的数据,以此为依据进行数据查询。
  • 3:这个表示部门数据权限,这个简单,就是这个用户只能查询到本部门的数据。
  • 4:这个表示本部门及以下数据权限,这个意思就是用户可以查询到本部门以及本部门下面子部门的数据。
  • 5:这个就表示这个用户仅仅只能查看自己的数据。

在 TienChin 这个项目中,数据权限也基本上是按照这个脚手架的设计来的,我们只需要搞懂这里的实现思路,将来就可以随心所欲的去自定义数据权限注解了。

2. 表结构分析

捋清楚了思路,再来看看表结构。

这里涉及到如下几张表:

  • sys_user:用户表
  • sys_role:角色表
  • sys_dept:部门表
  • sys_user_role:用户角色关联表
  • sys_role_dept:角色部门关联表

这几个表中有一些细节我来和大家梳理下。一个一个来看。

用户表中有一个 dept_id 表示这个用户所属的部门 id,一个用户属于一个部门。

角色表中有一个字段叫做 data_scope,表示这个角色所对应的数据权限,取值就是 1-5,含义就是我们上面所列出来的含义,这个很重要哦。

部门表在设计的时候,有一个 ancestors 字段,通过这个字段可以非常方便的查询一个部门的子部门。

最后两张关联表就没啥好说了。

好了,这些都分析完了,我们就来看看具体的实现。

3. 具体实现

3.1 @DataScope

先来看数据权限注解的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataScope {
/**
* 部门表的别名
*/
public String deptAlias() default "";

/**
* 用户表的别名
*/
public String userAlias() default "";
}

这个注解中有两个属性,一个是 deptAlias 和 userAlias。由于数据权限实现的核心思路就是在要执行的 SQL 上动态追加查询条件,那么动态追加的 SQL 必须要考虑到原本 SQL 定义时的部门表别名和用户表别名。这两个属性就是用来干这事的。

所以小伙伴们可能也看出来,这个 @DataScope 跟我们以前的注解还不太一样,以前自定义的其他注解跟业务耦合度比较小,这个 @DataScope 跟业务的耦合度则比较高,必须要看一下你业务 SQL 中对于部门表和用户表取的别名是啥,然后配置到这个注解上。

因此,@DataScope 注解不算是一个特别灵活的注解,咱们就抱着学习的态度了解下他的实现方式就行了。

3.2 切面分析

注解定义好了,接下来就是切面分析了。

我把 RuoYi-Vue 脚手架中,解析这个注解的切面代码列出来,咱们逐行进行分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
@Aspect
@Component
public class DataScopeAspect {
/**
* 全部数据权限
*/
public static final String DATA_SCOPE_ALL = "1";

/**
* 自定数据权限
*/
public static final String DATA_SCOPE_CUSTOM = "2";

/**
* 部门数据权限
*/
public static final String DATA_SCOPE_DEPT = "3";

/**
* 部门及以下数据权限
*/
public static final String DATA_SCOPE_DEPT_AND_CHILD = "4";

/**
* 仅本人数据权限
*/
public static final String DATA_SCOPE_SELF = "5";

/**
* 数据权限过滤关键字
*/
public static final String DATA_SCOPE = "dataScope";

@Before("@annotation(controllerDataScope)")
public void doBefore(JoinPoint point, DataScope controllerDataScope) throws Throwable {
clearDataScope(point);
handleDataScope(point, controllerDataScope);
}

protected void handleDataScope(final JoinPoint joinPoint, DataScope controllerDataScope) {
// 获取当前的用户
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNotNull(loginUser)) {
SysUser currentUser = loginUser.getUser();
// 如果是超级管理员,则不过滤数据
if (StringUtils.isNotNull(currentUser) && !currentUser.isAdmin()) {
dataScopeFilter(joinPoint, currentUser, controllerDataScope.deptAlias(),
controllerDataScope.userAlias());
}
}
}

/**
* 数据范围过滤
*
* @param joinPoint 切点
* @param user 用户
* @param userAlias 别名
*/
public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String deptAlias, String userAlias) {
StringBuilder sqlString = new StringBuilder();

for (SysRole role : user.getRoles()) {
String dataScope = role.getDataScope();
if (DATA_SCOPE_ALL.equals(dataScope)) {
sqlString = new StringBuilder();
break;
} else if (DATA_SCOPE_CUSTOM.equals(dataScope)) {
sqlString.append(StringUtils.format(
" OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ", deptAlias,
role.getRoleId()));
} else if (DATA_SCOPE_DEPT.equals(dataScope)) {
sqlString.append(StringUtils.format(" OR {}.dept_id = {} ", deptAlias, user.getDeptId()));
} else if (DATA_SCOPE_DEPT_AND_CHILD.equals(dataScope)) {
sqlString.append(StringUtils.format(
" OR {}.dept_id IN ( SELECT dept_id FROM sys_dept WHERE dept_id = {} or find_in_set( {} , ancestors ) )",
deptAlias, user.getDeptId(), user.getDeptId()));
} else if (DATA_SCOPE_SELF.equals(dataScope)) {
if (StringUtils.isNotBlank(userAlias)) {
sqlString.append(StringUtils.format(" OR {}.user_id = {} ", userAlias, user.getUserId()));
} else {
// 数据权限为仅本人且没有userAlias别名不查询任何数据
sqlString.append(" OR 1=0 ");
}
}
}

if (StringUtils.isNotBlank(sqlString.toString())) {
Object params = joinPoint.getArgs()[0];
if (StringUtils.isNotNull(params) && params instanceof BaseEntity) {
BaseEntity baseEntity = (BaseEntity) params;
baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");
}
}
}

/**
* 拼接权限sql前先清空params.dataScope参数防止注入
*/
private void clearDataScope(final JoinPoint joinPoint) {
Object params = joinPoint.getArgs()[0];
if (StringUtils.isNotNull(params) && params instanceof BaseEntity) {
BaseEntity baseEntity = (BaseEntity) params;
baseEntity.getParams().put(DATA_SCOPE, "");
}
}
}
  1. 首先一上来就定义了五种不同的数据权限类型,这五种类型咱们前面已经介绍过了,这里就不再赘述了。
  2. 接下来的 doBefore 方法是一个前置通知。由于 @DataScope 注解是加在 service 层的方法上,所以这里使用前置通知,为方法的执行补充 SQL 参数,具体思路是这样:加了数据权限注解的 service 层方法的参数必须是对象,并且这个对象必须继承自 BaseEntity,BaseEntity 中则有一个 Map 类型的 params 属性,我们如果需要为 service 层方法的执行补充一句 SQL,那么就把补充的内容放到这个 params 变量中,补充内容的 key 就是前面声明的 dataScope,value 则是一句 SQL。在 doBefore 方法中先执行 clearDataScope 去清除 params 变量中已有的内容,防止 SQL 注入(因为这个 params 的内容也可以从前端传来);然后执行 handleDataScope 方法进行数据权限的过滤。
  3. 在 handleDataScope 方法中,主要是查询到当前的用户,然后调用 dataScopeFilter 方法进行数据过滤,这个就是过滤的核心方法了。
  4. 由于一个用户可能有多个角色,所以在 dataScopeFilter 方法中要先遍历角色,不同的角色有不同的数据权限,这些不同的数据权限之间通过 OR 相连,最终生成的补充 SQL 的格式类似这样 AND(xxx OR xxx OR xxx) 这样,每一个 xxx 代表一个角色生成的过滤条件。
  5. 接下来就是根据各种不同的数据权限生成补充 SQL 了:如果数据权限为 1,则生成的 SQL 为空,即查询 SQL 不添加限制条件;如果数据权限为 2,表示自定义数据权限,此时根据用户的角色查询出用户的部门,生成查询限制的 SQL;如果数据权限为 3,表示用户的数据权限仅限于自己所在的部门,那么将用户所属的部门拎出来作为查询限制;如果数据权限为 4,表示用户的权限是自己的部门和他的子部门,那么就将用户所属的部门以及其子部门拎出来作为限制查询条件;如果数据权限为 5,表示用户的数据权限仅限于自己,即只能查看自己的数据,那么就用用户自身的 id 作为查询的限制条件。最后,再把生成的 SQL 稍微处理下,变成 AND(xxx OR xxx OR xxx) 格式,这个就比较简单了,就是字符串截取+字符串拼接。

好啦,大功告成,接下来我们通过几个具体的查询来看看这个切面的应用。

4. 案例分析

我们来看一下 @DataScope 注解三个具体的应用大家就明白了。

在 RuoYi-Vue 脚手架中,这个注解主要有三个使用场景:

  1. 查询部门。
  2. 查询角色。
  3. 查询用户。

假设我现在以 ry 这个用户登录,这个用户的角色是普通角色,普通角色的数据权限是 2,即自定义数据权限,我们就来看看这个用户是如何查询数据的。

我们分别来看。

4.1 查询部门

首先查询部门的方法位于 org.javaboy.tienchin.system.service.impl.SysDeptServiceImpl#selectDeptList 位置,具体方法如下:

1
2
3
4
5
@Override
@DataScope(deptAlias = "d")
public List<SysDept> selectDeptList(SysDept dept) {
return deptMapper.selectDeptList(dept);
}

这个参数 SysDept 继承自 BaseEntity,而 BaseEntity 中有一个 params 属性,这个咱们前面也已经介绍过了,不再赘述。

我们来看下这个 selectDeptList 方法对应的 SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<sql id="selectDeptVo">
select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.status, d.del_flag, d.create_by, d.create_time
from sys_dept d
</sql>
<select id="selectDeptList" parameterType="SysDept" resultMap="SysDeptResult">
<include refid="selectDeptVo"/>
where d.del_flag = '0'
<if test="deptId != null and deptId != 0">
AND dept_id = #{deptId}
</if>
<if test="parentId != null and parentId != 0">
AND parent_id = #{parentId}
</if>
<if test="deptName != null and deptName != ''">
AND dept_name like concat('%', #{deptName}, '%')
</if>
<if test="status != null and status != ''">
AND status = #{status}
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
order by d.parent_id, d.order_num
</select>

大家可以看到,在 SQL 的最后面有一句 ${params.dataScope},就是把在 DataScopeAspect 切面中拼接的 SQL 追加进来。

所以这个 SQL 最终的形式类似下面这样:

1
select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.status, d.del_flag, d.create_by, d.create_time from sys_dept d where d.del_flag = '0' AND (d.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = 2 ) ) order by d.parent_id, d.order_num

可以看到,追加了最后面的 SQL 之后,就实现了数据过滤(这里是根据自定义数据权限进行过滤)。那么这里还涉及到一个细节,前面 SQL 在定义时,用的表别名是什么,我们在 @DataScope 中指定的别名就要是什么。

4.2 查询角色

首先查询角色的方法位于 org.javaboy.tienchin.system.service.impl.SysRoleServiceImpl#selectRoleList 位置,具体方法如下:

1
2
3
4
5
@Override
@DataScope(deptAlias = "d")
public List<SysRole> selectRoleList(SysRole role) {
return roleMapper.selectRoleList(role);
}

这个参数 SysRole 继承自 BaseEntity,而 BaseEntity 中有一个 params 属性,这个咱们前面也已经介绍过了,不再赘述。

我们来看下这个 selectRoleList 方法对应的 SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<sql id="selectRoleVo">
select distinct r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.menu_check_strictly, r.dept_check_strictly,
r.status, r.del_flag, r.create_time, r.remark
from sys_role r
left join sys_user_role ur on ur.role_id = r.role_id
left join sys_user u on u.user_id = ur.user_id
left join sys_dept d on u.dept_id = d.dept_id
</sql>
<select id="selectRoleList" parameterType="SysRole" resultMap="SysRoleResult">
<include refid="selectRoleVo"/>
where r.del_flag = '0'
<if test="roleId != null and roleId != 0">
AND r.role_id = #{roleId}
</if>
<if test="roleName != null and roleName != ''">
AND r.role_name like concat('%', #{roleName}, '%')
</if>
<if test="status != null and status != ''">
AND r.status = #{status}
</if>
<if test="roleKey != null and roleKey != ''">
AND r.role_key like concat('%', #{roleKey}, '%')
</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
and date_format(r.create_time,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''"><!-- 结束时间检索 -->
and date_format(r.create_time,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
order by r.role_sort
</select>

大家可以看到,在 SQL 的最后面有一句 ${params.dataScope},就是把在 DataScopeAspect 切面中拼接的 SQL 追加进来。

所以这个 SQL 最终的形式类似下面这样:

1
select distinct r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.menu_check_strictly, r.dept_check_strictly, r.status, r.del_flag, r.create_time, r.remark from sys_role r left join sys_user_role ur on ur.role_id = r.role_id left join sys_user u on u.user_id = ur.user_id left join sys_dept d on u.dept_id = d.dept_id where r.del_flag = '0' AND (d.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = 2 ) ) order by r.role_sort LIMIT ?

可以看到,追加了最后面的 SQL 之后,就实现了数据过滤(这里是根据自定义数据权限进行过滤)。

过滤的逻辑就是根据用户所属的部门 id 找到用户 id,然后根据用户 id 找到对应的角色 id,最后再把查询到的角色返回。

其实我觉得查询部门和查询用户进行数据过滤,这个都好理解,当前登录用户能够操作哪些部门,能够操作哪些用户,这些都容易理解,能操作哪些角色该如何理解呢?特别是上面这个查询 SQL 绕了一大圈,有的小伙伴可能会说,系统本来不就有一个 sys_role_dept 表吗?这个表就是关联角色信息和部门信息的,直接拿着用户的部门 id 来这张表中查询用户能操作的角色 id 不就行行了?此言差矣!这里我觉得大家应该这样来理解:用户所属的部门这是用户所属的部门,用户能操作的部门是能操作的部门,这两个之间没有必然联系。sys_user 表中的 dept_id 字段是表示这个用户所属的部门 id,而 sys_role_dept 表中是描述某一个角色能够操作哪些部门,这是不一样的,把这个捋清楚了,上面的 SQL 就好懂了。

4.3 查询用户

最后再来看查询用户。

查询用户的方法在 org.javaboy.tienchin.system.service.impl.SysUserServiceImpl#selectUserList 位置,对应的 SQL 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader from sys_user u
left join sys_dept d on u.dept_id = d.dept_id
where u.del_flag = '0'
<if test="userId != null and userId != 0">
AND u.user_id = #{userId}
</if>
<if test="userName != null and userName != ''">
AND u.user_name like concat('%', #{userName}, '%')
</if>
<if test="status != null and status != ''">
AND u.status = #{status}
</if>
<if test="phonenumber != null and phonenumber != ''">
AND u.phonenumber like concat('%', #{phonenumber}, '%')
</if>
<if test="params.beginTime != null and params.beginTime != ''"><!-- 开始时间检索 -->
AND date_format(u.create_time,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
</if>
<if test="params.endTime != null and params.endTime != ''"><!-- 结束时间检索 -->
AND date_format(u.create_time,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
</if>
<if test="deptId != null and deptId != 0">
AND (u.dept_id = #{deptId} OR u.dept_id IN ( SELECT t.dept_id FROM sys_dept t WHERE find_in_set(#{deptId}, ancestors) ))
</if>
<!-- 数据范围过滤 -->
${params.dataScope}
</select>

这个就比较容易了,根据部门查询用户或者就是查询当前用户。最终生成的 SQL 类似下面这样(这是自定义数据权限,即根据用户部门查找用户):

1
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.create_by, u.create_time, u.remark, d.dept_name, d.leader from sys_user u left join sys_dept d on u.dept_id = d.dept_id where u.del_flag = '0' AND (d.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = 2 ) ) LIMIT ?

5. 小结

好啦,通过上面的分析,大家对于 @DataScope 应该也认识的差不多了吧?接下来松哥还会再录几个视频放在 TienChin 项目中,松哥也录了几个视频和小伙伴们一起探讨这个注解的玩法:

对视频感兴趣的小伙伴戳戳戳这里:

喜欢这篇文章吗?扫码关注公众号【江南一点雨】【江南一点雨】专注于 SPRING BOOT+微服务以及前后端分离技术,每天推送原创技术干货,关注后回复 JAVA,领取松哥为你精心准备的 JAVA 干货!

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×