目录
点击查看上一篇: 【SaToken使用】SpringBoot整合SaToken(一)token自动续期+token定期刷新+注解鉴权
前言
数据范围:1 所有数据、2 所在部门及子部门数据、3 所在部门数据、4 仅本人数据、5 自定义数据
关于数据权限,一直没有一个很好的通用的解决方案。
方式一:
之前有试过参考若依的那个用自定义注解+aop去实现数据权限,然后觉得通用性不高,无法适用于我自己的项目的业务场景,而且我用的mybatisplus,不是联表的话是不会去xml自己写sql语句的,而是直接用QueryWrapper,用这种实现方式是没办法把拼接好的sql放到QueryWrapper中做条件的。
方式二:
然后我又找了种方法,也是用注解和aop,但它是需要解析sql,把数据范围条件拼接到sql中,变成了一条新的sql语句去执行的。尝试了下这种方法,确实可以,不管是在xml自己写的SQL也好,还是用户mybatisplus自带的查询也好,甚至有分页都不用担心。但是面对复杂SQL比如子查询、嵌套查询等,要想把条件给拼接到正确的位置上去,太复杂了,那些group by、where、order by这些关键词位置判断就够呛,要是有子查询、嵌套查询,都有where,那过滤条件放到哪个where里面?这也是比较头疼的事。所以我感觉这种方法也不太行,代码太啰嗦。
方式三:
后面做项目的时候,我上面两种方法都没采用,我的想法是两种方法最后都是在原有的SQL上拼接条件,那为什么不把权限条件像其他条件一样,直接写在SQL里面,而是用注解、拦截器去拼接?就比如查询用户列表,a用户的数据权限是只能查看本部门的数据,所以权限条件是 dept_id=a用户的部门 ,然后列表有个搜索框,搜索姓名的,那么在mybatis中SQL就是
select *
from sys_user
<where>
<if test="name != null"> and name=#{name} </if>
<if test="dataScope != null"> and ${dataScope} </if>
</where>
如果不是自己写SQL,而是用QueryWrapper的话,那也简单
public PageInfo<SysUser> page(SysQuery query){
QueryWrapper<SysUser> queryWrapper = new QueryWrapper<>();
if (StrUtil.isNotBlank(query.getName())){
queryWrapper.eq("name",query.getName());
}
String apply = queryApplyScope("dept",null,1); // 获取权限条件
if (StrUtil.isNotBlank(apply)){
queryWrapper.apply(apply);
}
return new PageInfo<>(baseMapper.selectList(queryWrapper));
}
像这样把权限条件和其他筛选条件一样,直接用不是挺好的嘛?还是说为了做到低侵入、无侵入?不管哪种方法,得看是否适用当前业务场景,不过如果没要求无侵入的话,直接用肯定是比注解+拦截器拼接的方式更灵活的。下面详细说下第二种实现和第三种实现,可以参考下。
方式二实现、自定义注解+拦截器拦截SQL,实现改变原SQL
参考文章:springboot自定义注解+mybatis拦截器实现数据权限
1、自定义注解 DataScope
import java.lang.annotation.*;
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
/** 拼接的条件是取 部门id 还是 部门name(1 id 2 name) */
int type() default 1;
/** 查询的字段名 */
String fieldName() default "";
/** 查询本人数据时使用的的字段名 */
String createBy() default "";
}
2、数据过滤处理 DataScopeAspect
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.util.StrUtil;
import com.entity.sys.SysUser;
import com.service.sys.SysUserService;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
/**
* 数据过滤处理
*/
@Aspect
@Component
public class DataScopeAspect {
/** 用于存储过滤条件的SQL */
public static final ThreadLocal DATA_AUTH_THREAD_LOCAL = new ThreadLocal();
@Resource
private SysUserService sysUserService;
// 配置织入点
@Pointcut("@annotation(DataScope)")
public void dataScopePointCut() { }
@Before("dataScopePointCut()")
public void doBefore(JoinPoint point){
DATA_AUTH_THREAD_LOCAL.remove();
handleDataAuthScope(point);
}
@After("@annotation(DataScope)")
public void doAfter(){
//清空数据权限拼接SQL
DATA_AUTH_THREAD_LOCAL.remove();
}
/**
* 处理数据权限
* @param joinPoint 切面
*/
protected void handleDataAuthScope(final JoinPoint joinPoint) {
// 获取当前的用户
SysUser user = (SysUser) StpUtil.getSession().get("user");
if (null == user) {
return;
}
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
DataScope dataScope = method.getAnnotation(DataScope.class);
// 获取当前用户要拼接的数据权限条件
String str = sysUserService.queryApplyScope(dataScope.fieldName(), dataScope.createBy(), dataScope.type());
if (StrUtil.isNotBlank(str)){
//设置数据权限拼接sql
DATA_AUTH_THREAD_LOCAL.set(str);
}
}
}
3、拦截SQL语句,并将过滤条件拼接到原SQL中
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
/**
* 拦截SQL语句,根据处理好的数据权限条件拼接在原SQL后,组成新的SQL语句
*/
@Component
@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})})
public class DataScopeInterceptor implements Interceptor {
/** 分组 */
private static final String GROUP_BY = "GROUP BY";
/** 排序 */
private static final String ORDER_BY = "ORDER BY";
/** 分页 */
private static final String LIMIT = "LIMIT";
/** where */
private static final String WHERE = "WHERE";
/** where条件 */
private static final String WHERE_CONDITION = " WHERE 1=1 ";
/**
* 拦截sql
* @param invocation
*/
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
if (SqlCommandType.SELECT == sqlCommandType) {
// 获取过滤条件
Object dataAuthSql = DataScopeAspect.DATA_AUTH_THREAD_LOCAL.get();
//如果添加数据权限
if (null != dataAuthSql) {
BoundSql boundSql = (BoundSql) invocation.getArgs()[5];
StringBuilder newSqlBuilder = new StringBuilder();
// 获取到原始sql语句
String mSql = boundSql.getSql();
System.out.println("原始SQL:\t"+mSql);
mSql = addWhere(mSql);
//重写sql语句 前面拼接数据权限语句
if (mSql.indexOf(GROUP_BY) > 0) {
newSqlBuilder.append(mSql.substring(0, mSql.lastIndexOf(GROUP_BY)))
.append(" and ")
.append(dataAuthSql.toString())
.append(" ")
.append(mSql.substring(mSql.lastIndexOf(GROUP_BY), mSql.length()));
} else if (mSql.indexOf(ORDER_BY) > 0) {
newSqlBuilder.append(mSql.substring(0, mSql.lastIndexOf(ORDER_BY)))
.append(" and ")
.append(dataAuthSql.toString())
.append(" ")
.append(mSql.substring(mSql.lastIndexOf(ORDER_BY), mSql.length()));
} else if (mSql.indexOf(LIMIT) > 0) {
newSqlBuilder.append(mSql.substring(0, mSql.lastIndexOf(LIMIT)))
.append(" and ")
.append(dataAuthSql.toString())
.append(" ")
.append(mSql.substring(mSql.lastIndexOf(LIMIT), mSql.length()));
} else if (mSql.indexOf(WHERE) > 0) {
newSqlBuilder.append(mSql)
.append(" and ")
.append(dataAuthSql.toString());
} else {
newSqlBuilder.append(mSql)
.append(WHERE_CONDITION)
.append(" and ")
.append(dataAuthSql.toString());
}
System.out.println("增强后的SQL:\t"+newSqlBuilder.toString());
//通过反射修改sql语句
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, newSqlBuilder.toString());
}
}
return invocation.proceed();
}
/**
* 添加where关键字
* @param sql sql语句
* @return sql
*/
private String addWhere(String sql) {
if (sql.indexOf(WHERE) >= 0) {
return sql;
}
StringBuilder newSqlBuilder = new StringBuilder();
if (sql.indexOf(GROUP_BY) > 0) {
newSqlBuilder.append(sql.substring(0, sql.lastIndexOf(GROUP_BY)))
.append(WHERE_CONDITION)
.append(sql.substring(sql.lastIndexOf(GROUP_BY), sql.length()));
} else if (sql.indexOf(ORDER_BY) > 0) {
newSqlBuilder.append(sql.substring(0, sql.lastIndexOf(ORDER_BY)))
.append(WHERE_CONDITION)
.append(sql.substring(sql.lastIndexOf(ORDER_BY), sql.length()));
} else if (sql.indexOf(LIMIT) > 0) {
newSqlBuilder.append(sql.substring(0, sql.lastIndexOf(LIMIT)))
.append(WHERE_CONDITION)
.append(sql.substring(sql.lastIndexOf(LIMIT), sql.length()));
} else {
newSqlBuilder.append(sql).append(" ").append(WHERE_CONDITION);
}
return newSqlBuilder.toString();
}
}
4、用户列表
/**
* 用户列表
*/
@GetMapping("/list")
@DataScope(fieldName="b.id",createBy="a.id")
@SaCheckPermission("system:user:view")
public ResultVo list(SysQuery query) {
return ResultUtil.success(service.page(query));
}
<select id="page" resultType="com.entity.sys.SysUser">
SELECT a.id,user_name,real_name,dept_id,name deptName
FROM sys_user a
LEFT JOIN sys_dept b on a.dept_id=b.id
<where>
<if test="code != null">
AND a.user_name like concat('%', #{code}, '%')
</if>
<if test="name != null">
AND a.real_name like concat('%', #{name}, '%')
</if>
<if test="parentId != null">
AND b.id=#{parentId}
</if>
</where>
ORDER BY a.create_time desc
</select>
以上方式亲测单表、联表查询没什么问题,也不影响分页,但是遇上子查询、嵌套查询就不好使了。
方式三实现
关于我上面说的第二种、第三种过滤方式,都是要事先设置好拼接条件,用的时候直接拿就可以了,下面详细说下我的过滤条件是怎么设置的。
1、思路
1、在登录的时候,就根据角色获取查询范围,获取到条件值,然后保存到用户信息中(用户信息是保存到redis中的)。
2、查询的时候就取出当前用户里面的条件值,然后根据你要用哪个字段过滤,把条件值拼接到要过滤的字段中。
3、这样就得到了最终得权限条件,就可以直接把这一串字符之间拼接到查询的SQL语句中。
2、举栗
用户陈怡,她的角色是部门负责人,数据范围是 2(所在部门及子部门数据),当她登录的时候,获取她部门及子部门的所有ID(name),设置到 SysUser 对象的 deptAndSubId 属性中;她登录进去后要查看用户列表,那么这个用户列表要过滤数据是根据哪个字段过滤呢?在执行SQL之前就要设置好要过滤的字段为 dept_id ,然后拿出redis存储她本人用户信息里的 deptAndSubId 属性值,将值拼接到要过滤的字段中:“FIND_IN_SET(dept_id,‘2,3,4,5’)” ,得到这串条件字符后,再拼接到 where 中。
3、实现
实体对象
/**
* 角色信息表
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_role")
public class SysRole extends BaseEntity {
/**
* 角色权限字符
*/
@TableField("role_key")
private String roleKey;
/**
* 角色名称
*/
@TableField("role_name")
private String roleName;
/**
* 菜单树是否展开(0折叠 1展开 )
*/
@TableField("is_open")
private Boolean isOpen;
/**
* 数据范围(1 所有数据 2 所在部门及子部门数据 3 所在部门数据 4 仅本人数据 5 自定义数据)
*/
@TableField("data_scope")
private Integer dataScope;
/**
* 备注
*/
@TableField("remark")
private String remark;
/**
* 菜单组
*/
private transient String [] menuIds;
/**
* 部门组
*/
private transient String [] deptIds;
}
/**
* 用户信息
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_user")
public class SysUser extends BaseEntity {
/**
* 用户名称
*/
@TableField("user_name")
private String userName;
/**
* 用户名称
*/
@TableField("real_name")
private String realName;
/**
* 所属部门
*/
@TableField("dept_id")
private String deptId;
/**
* 密码
*/
@TableField("password")
private String password;
/**
* 盐值
*/
@TableField("salt")
private String salt;
/**
* 头像
*/
@TableField("picture")
private String picture;
/**
* 最后登录时间
*/
@TableField("login_date")
private String loginDate;
/**
* 密码更改时间
*/
@TableField("update_pwd_time")
private LocalDateTime updatePwdTime;
private transient String deptName;
private transient String parentId;
/** 角色组 */
private transient String [] roleIds;
private transient List<SysUserRole.UserRoleVo> roleList;
/** 查询的数据范围条件 */
private transient Map<String,String> queryDataScope;
}
/**
* 用户角色关联
*/
@Data
@TableName("sys_user_role")
public class SysUserRole{
/**
* 用户ID
*/
@TableId("user_id")
private String userId;
/**
* 角色ID
*/
@TableField("role_id")
private String roleId;
public SysUserRole(String userId, String roleId) {
this.userId = userId;
this.roleId = roleId;
}
public SysUserRole() {}
@Data
public static class UserRoleVo{
private String roleId;
private String roleName;
private String roleKey;
private Integer dataScope;
}
}
登录
/** 登录 */
@PostMapping("/login")
public ResultVo login(String userName, String password, String code,HttpServletRequest request){
try {
// ......省略其他代码
SysUser user = passwordErrorNum(userName, password); // 校验用户名和密码是否正确
sysUserService.setDataScope(user); // 设置用户的数据范围查询条件
StpUtil.login(user.getId());
String tokenValue = StpUtil.getTokenValue();
sysLog.setInfo(userName+"登录成功");
StpUtil.getSession().set("user",user);
return ResultUtil.success(tokenValue);
} catch (ExceptionVo e) {
return ResultUtil.error(e.getCode(),e.getMessage());
}catch (Exception e) {
e.printStackTrace();
return ResultUtil.error(BaseConstant.UNKNOWN_EXCEPTION);
}
}
SysUserService.setDataScope(),获取条件值
/**
* 2、3、4 的数据范围
*/
private void setDataScope(Map<String,String> queryDataScope,List<String> collect,SysUser user){
if (collect.contains("2")){ // 获取所在部门及子部门的id和name
Map<String, String> child = sysDeptService.getChildIdAndName(user.getDeptId());
if (child != null){
queryDataScope.put("deptAndSubId",child.get("id"));
queryDataScope.put("deptAndSubName",child.get("name"));
}
queryDataScope.put("deptId",user.getDeptId());
queryDataScope.put("deptName",user.getDeptName());
}else if (collect.contains("3")){ // 获取所在部门的id和name
queryDataScope.put("deptId",user.getDeptId());
queryDataScope.put("deptName",user.getDeptName());
}else { // 仅本人数据:获取当前用户id,以及获取所在部门的id和name
queryDataScope.put("createBy",user.getId());
queryDataScope.put("deptId",user.getDeptId());
queryDataScope.put("deptName",user.getDeptName());
}
}
/**
* 设置用户的数据范围查询条件:
* 问题:关于部门id和name,既然设置了id为什么还要设置name呢?
* 解释:像用户与部门、角色与部门之间的关联用的是部门id关联。
* 但是因为我自己这边的业务的原因,有一些表数据的所属部门并不是用部门id来进行关联的,而是直接用部门名称关联,
* 为了方便以后加业务,拓展,所以我这里把部门名称也一起设置了,这个可以根据实际情况选择要不要设置部门名称,
*/
public void setDataScope(SysUser user){
Map<String,String> queryDataScope = new HashMap<>();
List<SysUserRole.UserRoleVo> roleList = user.getRoleList();// 获取用户所属角色列表
// 获取所有角色的数据范围
List<String> collect = roleList.stream().map(t -> String.valueOf(t.getDataScope())).collect(Collectors.toList());
// 1-4这几个数字里,数字从小到大,数据范围是越来越小的,比如1包含了2、3、4的范围,2包含了3、4的范围,以此类推。
// 所以只要在用户拥有的数据范围里面,直接取最小的那个数字的范围就行,其他数字的范围就不用取了。
if (collect.contains("1")) { // 全部数据就不设置过滤条件了
return;
}
if (collect.contains("5")){// 自定义数据:根据角色id获取关联的部门id
// 包含了5则是取其他数字的数据范围和5的数据范围的并集
for (int i = 0; i < collect.size(); i++) {
if ("5".equals(collect.get(i))){
String roleId = roleList.get(i).getRoleId();
SysQuery query = new SysQuery();
query.setId(roleId);
List<String> idList = sysDeptService.selectIdListByRoleId(query); // 根据角色id获取部门id
List<SysDept> deptList = sysDeptService.listByIds(idList); // 根据部门id查询部门列表
if (!deptList.isEmpty()) {
List<String> c = deptList.stream().map(SysDept::getName).collect(Collectors.toList());
queryDataScope.put("customName",StrUtil.join(",", c));
}
queryDataScope.put("customId",StrUtil.join(",", idList));
}
}
queryDataScope.put("dataScope","5");
// 其他数字的数据范围
setDataScope(queryDataScope,collect,user);
}else {
setDataScope(queryDataScope,collect,user);
}
// 将过滤值设置到用户信息中
user.setQueryDataScope(queryDataScope);
}
BaseService.queryApplyScope(),设置查询条件语句
/**
* 根据 当前用户的数据范围 拼接查询条件语句(这里拼接的查询条件也可以用于单表 QueryWrapper 查询)
* @param fieldName 查询的字段名
* @param createBy 查询本人数据时使用的的字段名
* @param type 拼接的条件是取 部门id 还是 部门name(1 id 2 name)
*/
public String queryApplyScope(String fieldName,String createBy,int type){
StringBuilder sqlString = new StringBuilder();
SysUser user = getCurrentUser();
if (user.getQueryDataScope() != null){
Map<String, String> scope = user.getQueryDataScope();
if (type == 1){ // 用部门id字段过滤
if (StrUtil.isNotBlank(scope.get("dataScope"))){ // 这里不为空,说明数据范围是 5(自定义数据)
// 5 和其他数字的范围取并集,用 or 连接,并且它们的外层不要忘了用括号括起来
sqlString.append(StrUtil.format("(FIND_IN_SET({},'{}')",fieldName, scope.get("customId")));
if (StrUtil.isNotBlank(scope.get("deptAndSubId"))) {
sqlString.append(StrUtil.format(" or FIND_IN_SET({},'{}')",fieldName, scope.get("deptAndSubId")));
}else if (StrUtil.isNotBlank(scope.get("deptId"))){
sqlString.append(StrUtil.format(" or {} = '{}'",fieldName, scope.get("deptId")));
}
sqlString.append(")");
}else {
// 拼接条件为 部门id时,所在部门及子部门数据用 FIND_IN_SET 查询;所在部门数据直接用等于查询;
if (StrUtil.isNotBlank(scope.get("deptAndSubId"))) {
sqlString.append(StrUtil.format("FIND_IN_SET({},'{}')",fieldName, scope.get("deptAndSubId")));
}else if (StrUtil.isNotBlank(scope.get("deptId"))){
sqlString.append(StrUtil.format("{} = '{}'",fieldName, scope.get("deptId")));
}
}
}else { // 用部门名称字段过滤
if (StrUtil.isNotBlank(scope.get("dataScope"))){
sqlString.append(StrUtil.format("(FIND_IN_SET({},'{}')",fieldName, scope.get("customName")));
if (StrUtil.isNotBlank(scope.get("deptAndSubName"))) {
sqlString.append(StrUtil.format(" or FIND_IN_SET({},'{}')",fieldName, scope.get("deptAndSubName")));
}else if (StrUtil.isNotBlank(scope.get("deptName"))){
sqlString.append(StrUtil.format(" or {} = '{}'",fieldName, scope.get("deptName")));
}
sqlString.append(")");
}else {
// 拼接条件为 部门name时,所在部门及子部门数据用 FIND_IN_SET 查询;所在部门数据直接用等于查询;
if (StrUtil.isNotBlank(scope.get("deptAndSubName"))){
sqlString.append(StrUtil.format("FIND_IN_SET({},'{}')",fieldName, scope.get("deptAndSubName")));
}else if (StrUtil.isNotBlank(scope.get("deptName"))){
sqlString.append(StrUtil.format("{} = '{}'",fieldName, scope.get("deptName")));
}
}
}
// 这里还要判断 createBy 字段不为空,是因为有的表它不需要根据本人过滤,只用根据部门过滤
if (StrUtil.isNotBlank(createBy) && StrUtil.isNotBlank(scope.get("createBy"))){
// 仅本人数据直接用等于查询;
if (StrUtil.isNotBlank(sqlString)){
sqlString.append(StrUtil.format(" and {} = '{}'",createBy, scope.get("createBy")));
}else {
sqlString.append(StrUtil.format("{} = '{}'",createBy, scope.get("createBy")));
}
}
}
return sqlString.toString();
}
用户列表(不使用注解)
@GetMapping("/list")
@SaCheckPermission("system:user:view")
public ResultVo list(SysQuery query) {
return ResultUtil.success(service.page(query));
}
/**
* 用户列表分页
*/
public PageInfo<SysUser> page(SysQuery queryVo) {
PageHelper.startPage(queryVo.getPageNum(),queryVo.getPageSize());
Map<String, Object> map = sysParams(queryVo);
String sqlStr = queryApplyScope("b.id","a.id", 1);
if (StrUtil.isNotBlank(sqlStr)){
map.put("dataScope",sqlStr);
}
List<SysUser> list = baseMapper.page(map);
return new PageInfo<>(list);
}
<select id="page" resultType="com.entity.sys.SysUser">
SELECT a.id,user_name,real_name,dept_id,name deptName
FROM sys_user a
LEFT JOIN sys_dept b on a.dept_id=b.id
<where>
<if test="code != null">
AND a.user_name like concat('%', #{code}, '%')
</if>
<if test="name != null">
AND a.real_name like concat('%', #{name}, '%')
</if>
<if test="parentId != null">
AND b.id=#{parentId}
</if>
<if test="dataScope != null">
AND ${dataScope}
</if>
</where>
ORDER BY a.create_time desc
</select>
三、源码
完整的数据库和代码都在源码里。如果你觉得我的文章(源码)对你有帮助的话,可以帮我点个赞再走哦~ヾ(•ω•`)o