哈喽~ 大家好,今天给大家分享一下平时在工作当中遇到的数据权限问题。
本人是通过掘金的文章才理解的数据权限,推荐大家也去看一下
在我们的业务系统中,除了菜单/功能权限外,还有一个非常重要的功能,就是数据权限。数据及权限管理,大多数采用的方案还是硬编码的方式,也就是将这种逻辑以if/else
等方式与业务代码耦合在一起,按需做数据权限划分。本文这里采用的是另一种比硬编码相对优雅的方式:mybatis拦截器+spring aop。
技术基础
-
mybatis拦截器
使用mybatis的自定义拦截器,我们可以对待执行的sql进行一层过滤、包装,如做sql日志记录、追加数据权限相关的sql等。
-
spring aop
在不改变原代码的基础上增加功能或者横向相同功能抽取,具体实现形式就是代理,经典原型就是事物控制和日志,我们这里用来做数据权限控制。
设计思路
数据权限粒度
常规的业务系统,数据粒度主要分为如下四种:
- 全部数据权限:即不做权限区分,记录都可查。
- 仅本人数据权限:只能查看自己管辖的数据
- 部门数据权限:只能查看用户所在部门的数据
- 部门及以下数据权限:可以查看用户所在部门及下属部门的数据
- 自定义数据权限:用于一个用户对应多个部门的场景
业务表设计规范
要想让业务数据有归属,则相关的业务表要有相关的字段:dept_id和user_id,即部门id和用户id,,如文章表:
CREATE TABLE `cms_article` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`category_id` bigint(20) unsigned DEFAULT NULL COMMENT '栏目id',
`title` varchar(120) NOT NULL COMMENT '标题',
`description` varchar(255) DEFAULT NULL COMMENT '描述',
`cover` varchar(255) DEFAULT NULL COMMENT '大图',
`author` varchar(30) DEFAULT NULL COMMENT '作者',
`source` varchar(64) DEFAULT NULL COMMENT '文章来源',
`sort` double(10,2) unsigned DEFAULT '10.00' COMMENT '排序',
`publish_time` datetime(3) DEFAULT NULL COMMENT '发布时间',
`is_publish` tinyint(1) DEFAULT NULL COMMENT '是否发布(1->否|NO,2->是|YES)', `content` mediumtext COMMENT '文本内容',
`dept_id` bigint(20) unsigned DEFAULT NULL COMMENT '所属部门',
`user_id` bigint(20) unsigned DEFAULT NULL COMMENT '所属用户',
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
`is_deleted` tinyint(1) unsigned DEFAULT '1' COMMENT '是否删除(1->未删除|NO,2->已删除|YES)', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
数据权限粒度与查询
全部数据
select * from cms_article
部门数据
-- 需要传入当前用户所属部门id
select * from cms_article a where a.dept_id = ?
部门及以下数据
-- 需要传入当前用户所属部门id及下属部门id
select * from cms_article a where a.dept_id in (?,?,?,?)
仅本人数据
-- 需要传入当前用户id
select * from cms_article a where a.user_id = ?
自定义数据权限
-- 需要传入当前用户id
select * from cms_article a where a.dept_id in(select dept_id from sys_role_dept rd left join sys_user_role ur on rd.role_id=ur.role_id where ur.user_id=? group by dept_id)
或者
-- 需要传入录前用户角色id
select * from cms_article a where a.dept_id in(select dept_id from sys_role_dept role_id in (?,?,?))
代码实现
目录结构
├── mldong-framework 框架
├── mldong-commom-base 基础模块
├── src/main/java
└── com.mldong.common.dauth
├── DataScope.java
├── DataScopeConfig.java
├── DataScopeHelper.java
├── DataScopeInterceptor.java
└── DataSopeAspect.java
├── src/main/resource/META-INF/spring.factories
核心代码说明
DataSope.java
package com.mldong.common.dauth;
import java.lang.annotation.*;
@Target({ ElementType.METHOD, ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME)
@Documented public
@interface DataScope {
/**
* 部门表的别名
*/
public String deptAlias() default "";
/**
* 用户表的别名
*/
public String userAlias() default "";
}
数据权限注解,加上注解的方法就会自动追加数据权限相关sql。
DataScopeHelper.java
ThreadLocal线程数据共享工具,aop构造数据权限sql存入threadLocal,拦截器读取数据权限sql。
package com.mldong.common.dauth;
public class DataScopeHelper {
private static final ThreadLocal<String> LOCAL_DATA_AUTH_SQL = new ThreadLocal();
public static void setLocalDataAuthSql(String page) {
LOCAL_DATA_AUTH_SQL.set(page);
}
public static String getLocalDataAuthSql() {
return LOCAL_DATA_AUTH_SQL.get();
}
public static void clearDataAuthSql() {
LOCAL_DATA_AUTH_SQL.remove();
}
}
DataSopeAspect.java
aop实现,这里维护数据权限sql的生命周期,构造->销毁
package com.mldong.common.dauth;
@Aspect
@Component
public class DataSopeAspect {
/**
* 全部数据权限
*/
public static final Integer DATA_SCOPE_ALL = 10;/**
* 部门数据权限
*/
public static final Integer DATA_SCOPE_DEPT = 20;/**
* 部门及以下数据权限
*/
public static final Integer DATA_SCOPE_DEPT_AND_CHILD = 30;
/**
* 仅本人数据权限
*/
public static final Integer DATA_SCOPE_SELF = 40;
/**
* 自定义数据权限
*/
public static final Integer DATA_SCOPE_CUSTOM = 50;
// 加入@DataScope注解的方法执行前执行-用于构造数据权限sql
@Before("@annotation(dataScope)")
public void dataScopeBefore(JoinPoint point, DataScope dataScope) throws Throwable {
// 超级管理员,不处理
if(RequestHolder.isSuperAdmin()) {
return;
}
// TODO 详见源码
}
// 加入@DataScope注解的方法执行完成后执行-用于销毁数据权限sql
@After("@annotation(dataScope)")
@AfterThrowing("@annotation(dataScope)")
public void dataScopeAfter(JoinPoint point, DataScope dataScope) throws Throwable{
if(StringTool.isNotEmpty(DataScopeHelper.getLocalDataAuthSql())) {
// 执行完成,要清除当前权限Sql
DataScopeHelper.clearDataAuthSql();
}
}}
DataScopeInterceptor.java
数据权限mybatis拦截器
package com.mldong.common.dauth;
import com.mldong.common.tool.StringTool;
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.ParameterMapping;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;import java.util.Properties;
@Intercepts({@Signature(
type = org.apache.ibatis.executor.Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
), @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 Logger logger= LoggerFactory.getLogger(DataScopeInterceptor.class);
@Override
public Object intercept(Invocation invocation) throws Throwable {
String dataAuthSql = DataScopeHelper.getLocalDataAuthSql();
// 不为空才处理
if(StringTool.isNotEmpty(dataAuthSql)) {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement)args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds)args[2];
ResultHandler resultHandler = (ResultHandler)args[3];
Executor executor = (Executor)invocation.getTarget();
CacheKey cacheKey;
BoundSql boundSql;
if (args.length == 4) {
boundSql = ms.getBoundSql(parameter);
cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
} else {
cacheKey = (CacheKey)args[4];
boundSql = (BoundSql)args[5];
}
String newSql = boundSql.getSql() + dataAuthSql ;
BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), newSql,
boundSql.getParameterMappings(), boundSql.getParameterObject());
// 把新的查询放到statement里
MappedStatement newMs = newMappedStatement(ms, new BoundSqlSqlSource(newBoundSql));
for (ParameterMapping mapping : boundSql.getParameterMappings()) {
String prop = mapping.getProperty();
if (boundSql.hasAdditionalParameter(prop)) {
newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
}
}
return executor.query(newMs, parameter, rowBounds, resultHandler, cacheKey, newBoundSql);
} else {
return invocation.proceed();
}
}@Override
public Object plugin(Object target) {
return Plugin.wrap(target,this);
}@Override
public void setProperties(Properties properties) {
logger.debug(properties.toString());
}/**
* 定义一个内部辅助类,作用是包装 SQL
*/
class BoundSqlSqlSource implements SqlSource {
private BoundSql boundSql;
public BoundSqlSqlSource(BoundSql boundSql) {
this.boundSql = boundSql;
}
public BoundSql getBoundSql(Object parameterObject) {
return boundSql;
}}
private MappedStatement newMappedStatement (MappedStatement ms, SqlSource newSqlSource) {
MappedStatement.Builder builder = new
MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
builder.resource(ms.getResource());
builder.fetchSize(ms.getFetchSize());
builder.statementType(ms.getStatementType());
builder.keyGenerator(ms.getKeyGenerator());
if (ms.getKeyProperties() != null && ms.getKeyProperties().length > 0) {
builder.keyProperty(ms.getKeyProperties()[0]);
}
builder.timeout(ms.getTimeout());
builder.parameterMap(ms.getParameterMap());
builder.resultMaps(ms.getResultMaps());
builder.resultSetType(ms.getResultSetType());
builder.cache(ms.getCache());
builder.flushCacheRequired(ms.isFlushCacheRequired());
builder.useCache(ms.isUseCache());
return builder.build();
}
}
DataScopeConfig.java
package com.mldong.common.dauth;
import com.github.pagehelper.autoconfigure.PageHelperAutoConfiguration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;import javax.annotation.PostConstruct;
import java.util.List;// 后添加先执行,所以要在mybatis分页插件之后配置拦截器
// @AutoConfigureAfter注解需与spring.factories配合才生效
@AutoConfigureAfter(PageHelperAutoConfiguration.class)
public class DataScopeConfig {
@Autowired
private List<SqlSessionFactory> sqlSessionFactoryList;@PostConstruct
public void addDataAuthInterceptor() {
DataScopeInterceptor interceptor = new DataScopeInterceptor();
for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
sqlSessionFactory.getConfiguration().addInterceptor(interceptor);
}
}
}
spring.factories
这里主要是调一下mybatis分页插件与自定义的数据权限插件的加载顺序
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.github.pagehelper.autoconfigure.PageHelperAutoConfiguration,\
com.mldong.common.dauth.DataScopeConfig
使用样例
在dao层中使用
@Repository
public interface CmsArticleDao {
/**
* 查询文章列表-数据权限
* @param param
* @return
*/
@DataScope(deptAlias = "a", userAlias = "a")
public List<CmsArticleWithExt> selectOnDataScope(CmsArticlePageParam param);
}
在service层中使用
@Service
public class CmsArticleServiceImpl implements CmsArticleService{
@DataScope(deptAlias = "a", userAlias = "a")
@Override
public CommonPage<CmsArticleWithExt> listOnDataScope2(CmsArticlePageParam param) {
Page<CmsArticleWithExt> page =param.buildPage(true);
cmsArticleDao.selectWithExt(param);
return CommonPage.toPage(page);
}
}
效果演示 :
略
小结:
本文使用mybatis的拦截器机制配合aop实现了全局的数据权限处理,基本上能满足大多数有数据权限需求的业务。当然,如果是更为复杂的场景,如涉及到字段权限等的,可能就需要更为细粒化的设计了,这里暂时先不展开。
推荐去:项目源码地址
- 后端
- 前端