一、背景与需求分析
在企业级管理系统中,数据权限控制是保障系统安全、防止越权访问的重要手段之一。不同角色的用户对数据的访问范围应当受到限制,例如:
| 角色 | 数据访问范围 |
|---|---|
| 超级管理员 | 可访问所有数据 |
| 普通员工 | 仅能查看本部门及子部门数据 |
| 部门主管 | 可自定义数据权限 |
| 客户经理 | 仅能看到自己创建的数据 |
传统的做法是在每个接口中手动拼接权限条件,这种方式不仅重复性高,还容易出错。为此,mldong 快速开发框架基于 MyBatis Plus 提供了统一的数据权限控制机制。
二、架构设计与实现原理
1. 整体流程图
2. 核心组件介绍
| 类名 | 作用 |
|---|---|
@DataScope | 注解,用于标记需进行数据权限控制的方法或类 |
DataPermissionHandlerImpl | 实现 MyBatis Plus 的 DataPermissionHandler 接口,负责处理 SQL 拼接逻辑 |
DataAuthSqlBuilder | 接口,定义 SQL 构建器规范 |
DefaultDataAuthSqlBuilder | 默认实现类,根据角色权限动态构建 SQL |
RoleDeptServiceImpl | 获取角色关联的部门 ID 列表 |
LoginUser | 登录用户信息,包含角色、部门、岗位等权限信息 |
三、MyBatis Plus 插件机制与 DataPermissionInterceptor 原理
✅ 1. MyBatis Plus 插件机制简介
MyBatis Plus 提供了一套插件机制(InnerInterceptor),允许开发者拦截 SQL 执行过程并修改其行为。
数据权限插件 DataPermissionInterceptor 就是通过
beforeQuery拦截查询 SQL,并在其中动态拼接权限条件。
📌 功能说明:
- 解析当前方法上的
@DataScope注解。 - 获取当前登录用户信息(
LoginUser)。 - 调用 SQL 构建器(DataAuthSqlBuilder)生成权限条件表达式。
- 将权限表达式合并到原始 WHERE 条件中。
✅ 2. 插件配置示例
@Configuration
@MapperScan("com.mldong.**.mapper")
public class MybatisConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加数据权限插件
interceptor.addInnerInterceptor(new DataPermissionInterceptor(new DataPermissionHandlerImpl()));
// 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
✅ 3. 插件定制aop
当使用DataPermissionInterceptor插件时,默认所有的select查询都会进行权限校验,会使用到<font style="color:#080808;background-color:#ffffff;">jsqlparser</font>工作去对sql进行校验,该校验过于严格,会让有些即使不需要进行数据权限的sql会报错,影响使用,所以这里使用DataPermissionAop切面的方式去判断是否开启数据权限。
主要使用<font style="color:#080808;background-color:#ffffff;">mybatis-plus</font>的<font style="color:#080808;background-color:#ffffff;">IgnoreStrategy</font>去控制。
package com.mldong.aop;
import com.baomidou.mybatisplus.core.plugins.IgnoreStrategy;
import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
import com.mldong.annotation.DataScope;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
/**
* 控制数据权限插件的行为:
* - 默认所有 Mapper 查询都不启用数据权限
* - 加了 @DataScope 注解的方法/类,才启用数据权限逻辑
*/
@Aspect
@Component
public class DataPermissionAop {
/**
* 拦截所有 Mapper 方法
*/
@Around("target(com.baomidou.mybatisplus.core.mapper.BaseMapper)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Method method = getMethodFrom(joinPoint);
Class<?> targetClass = method.getDeclaringClass();
// 获取类和方法上的 @DataScope 注解
DataScope classAnnotation = targetClass.getAnnotation(DataScope.class);
DataScope methodAnnotation = method.getAnnotation(DataScope.class);
boolean isClassAnnotated = classAnnotation != null;
boolean isMethodAnnotated = methodAnnotation != null;
// 判断是否需要启用数据权限逻辑
if (isClassAnnotated || isMethodAnnotated) {
// 如果是方法单独设置 ignore=true,则忽略数据权限
if (isMethodAnnotated && methodAnnotation.ignore()) {
return handleWithIgnore(joinPoint); // 显式忽略
}
// 启用数据权限 → 不做任何 Ignore 处理,让 MyBatis Plus 插件生效
return joinPoint.proceed();
} else {
// 没有任何注解 → 默认忽略数据权限
return handleWithIgnore(joinPoint);
}
}
/**
* 带有 try-finally 的统一忽略策略处理
*/
private Object handleWithIgnore(ProceedingJoinPoint joinPoint) throws Throwable {
IgnoreStrategy strategy = IgnoreStrategy.builder().dataPermission(true).build();
InterceptorIgnoreHelper.handle(strategy);
try {
return joinPoint.proceed();
} finally {
InterceptorIgnoreHelper.clearIgnoreStrategy();
}
}
/**
* 从 JoinPoint 中提取出当前执行的方法对象
*/
private Method getMethodFrom(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
return signature.getMethod();
}
}
四、关键代码详解
1. 注解定义:@DataScope
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface DataScope {
String deptFieldName() default "deptId";
String tableAlias() default "";
boolean ignore() default false;
String userFieldName() default "createUser"; // Java 字段名 → create_user
Class<? extends DataAuthSqlBuilder> clazz() default DefaultDataAuthSqlBuilder.class;
}
示例使用:
@Mapper
public interface UserMapper extends BaseMapper<User> {
@DataScope(tableAlias = "t")
List<UserVO> selectCustom(IPage<UserVO> page, @Param(Constants.WRAPPER) Wrapper<User> wrapper);
}
2. SQL 构建接口:DataAuthSqlBuilder
public interface DataAuthSqlBuilder {
String build(LoginUser loginUser, DataScope dataScope);
}
3. 默认 SQL 构建器:DefaultDataAuthSqlBuilder
package com.mldong.auth.data.builder;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.mldong.annotation.DataScope;
import com.mldong.auth.LoginUser;
import com.mldong.auth.data.DataAuthSqlBuilder;
import com.mldong.auth.data.RoleDataScopeEnum;
import com.mldong.web.LoginUserHolder;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
@Component
public class DefaultDataAuthSqlBuilder implements DataAuthSqlBuilder {
@Override
public String build(LoginUser loginUser, DataScope dataScope) {
String tableAlias = dataScope.tableAlias();
String deptFieldName = StrUtil.toUnderlineCase(dataScope.deptFieldName());
deptFieldName = StrUtil.isEmpty(tableAlias)?deptFieldName:tableAlias+"."+deptFieldName;
String userFieldName = StrUtil.toUnderlineCase(dataScope.userFieldName());
userFieldName = StrUtil.isEmpty(tableAlias)?userFieldName:tableAlias+"."+userFieldName;
String sqlSegment = "";
Map<Long,Integer> roleDataScopeMap = loginUser.getRoleDataScopeMap();
if(ObjectUtil.isNotEmpty(roleDataScopeMap)
&& roleDataScopeMap.size()==1
&& roleDataScopeMap.containsValue(RoleDataScopeEnum.SELF_DATA.getCode())
){
// 只能查看自己的数据
sqlSegment = StrUtil.format("({} = {})", userFieldName, loginUser.getId());
return sqlSegment;
}
StringBuilder sqlSegmentBuilder = new StringBuilder(sqlSegment);
String finalDeptFieldName = deptFieldName;
AtomicBoolean hasDeptDataScope = new AtomicBoolean(false);
AtomicBoolean hasDeptAndSubDataScope = new AtomicBoolean(false);
roleDataScopeMap.forEach((roleId, ds)->{
if(ObjectUtil.equals(ds,RoleDataScopeEnum.CUSTOM_DATA.getCode())){
// 自定义数据权限,会关联sys_role_dept表
sqlSegmentBuilder.append(StrUtil.format(" {} in(select dept_id from sys_role_dept where role_id={}) or", finalDeptFieldName,roleId));
} else if (ObjectUtil.equals(ds,RoleDataScopeEnum.DEPT_DATA.getCode())){
// 本部门数据权限
if(!hasDeptDataScope.get()){
sqlSegmentBuilder.append(StrUtil.format(" {} = {} or", finalDeptFieldName,loginUser.getDeptId()));
hasDeptDataScope.set(true);
}
} else if(ObjectUtil.equals(ds,RoleDataScopeEnum.DEPT_AND_SUB_DATA.getCode())){
// 本部门及以下部门数据权限
if(!hasDeptAndSubDataScope.get()){
List<Long> deptAllSubIdList = LoginUserHolder.getDeptAllSubIdList();
if(loginUser.getDeptId()!=null){
deptAllSubIdList.add(loginUser.getDeptId());
}
sqlSegmentBuilder.append(StrUtil.format(" {} in({}) or", finalDeptFieldName,StrUtil.join(",",deptAllSubIdList)));
hasDeptAndSubDataScope.set(true);
}
}
});
// 默认情况下,都可以查看自己的数据
sqlSegmentBuilder.append(StrUtil.format(" {} = {} or", userFieldName, loginUser.getId()));
if(sqlSegmentBuilder.length()>0){
sqlSegment = sqlSegmentBuilder.substring(0,sqlSegmentBuilder.length()-3);
sqlSegment = StrUtil.format("({})", sqlSegment);
}
return sqlSegment;
}
}
✅ 字段命名转换逻辑说明:
dataScope.userFieldName()默认为"createUser"- 经过
StrUtil.toUnderlineCase(...)后变为"create_user" - 若设置了
tableAlias="t",则拼接成"t.create_user"
✅ 符合团队字段命名规范
五、子部门权限的特殊处理:递归获取所有子部门 ID
✅ 场景说明:
当用户拥有“本部门及以下”权限时,需要获取当前部门及其所有子部门的 ID 列表,以便在 SQL 中使用 IN 查询。
✅ 递归获取子部门逻辑:
// file: DeptServiceImpl.java
@Override
public List<Long> getAllSubDeptIds(Long parentId) {
// 1. 从缓存中获取部门列表
List<Dept> deptList = getDeptListInCache();
// 这是子孙部门id集合
List<Long> subDeptIds = new ArrayList<>();
// 转换成树结构
List<Dept> treeData = TreeTool.listToTree(deptList, parentId, Dept.class);
// 递归获取子部门
TreeTool.recursion(treeData, new IRecursionTree<Dept, List<Long>>() {
@Override
public void rowHandleBefore(Dept dept, List<Long> res) {
res.add(dept.getId());
}
@Override
public void rowHandleAfter(Dept dept, List<Long> res) {
}
}, subDeptIds);
return subDeptIds;
}
🧠 说明:
- 使用递归方式从数据库中查询子部门;
- 返回值
List<Long>包含当前部门和所有子部门 ID; - 在
DefaultDataAuthSqlBuilder中被调用,用于构造 SQL 条件:
sqlSegmentBuilder.append(StrUtil.format(" {} in({}) or", deptFieldName, StrUtil.join(",", deptAllSubIdList)));
六、自定义权限(CUSTOM_DATA)使用示例
✅ 场景说明:
假设用户拥有多个角色:
- 角色 A:数据权限类型为
CUSTOM_DATA,绑定部门 [100, 101] - 角色 B:数据权限类型为
DEPT_DATA,绑定部门 200 - 角色 C:数据权限类型为
SELF_DATA,只看自己的数据
✅ 示例代码:
// 用户拥有的角色及其数据权限
loginUser.setRoleDataScopeMap(Map.of(
1L, RoleDataScopeEnum.CUSTOM_DATA.getCode(),
2L, RoleDataScopeEnum.DEPT_DATA.getCode(),
3L, RoleDataScopeEnum.SELF_DATA.getCode()
));
✅ 最终生成的 SQL 片段:
(
t.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id = 1)
OR t.dept_id = 200
OR t.create_user = 1000
)
七、多角色混合权限输出效果
✅ 场景 1:两个自定义权限
loginUser.setRoleDataScopeMap(Map.of(
1L, RoleDataScopeEnum.CUSTOM_DATA.getCode(), -- 部门 100, 101
2L, RoleDataScopeEnum.CUSTOM_DATA.getCode() -- 部门 102, 103
));
✅ SQL 输出:
(
t.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id = 1)
OR t.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id = 2)
OR t.create_user = 1000
)
✅ 场景 2:自定义权限 + 子部门权限 + 自己的数据权限
loginUser.setRoleDataScopeMap(Map.of(
1L, RoleDataScopeEnum.CUSTOM_DATA.getCode(), -- 部门 100, 101
2L, RoleDataScopeEnum.DEPT_AND_SUB_DATA.getCode() -- 当前部门 200 及子部门 201, 202
));
✅ SQL 输出:
(
t.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id = 1)
OR t.dept_id IN (200, 201, 202)
OR t.create_user = 1000
)
八、测试用例与 SQL 输出效果汇总
| 场景描述 | SQL 条件 |
|---|---|
| 超级管理员 | 无附加条件 |
| 仅自己数据权限 | (t.create_user = 1000) |
| 本部门权限(ID: 100) | (t.dept_id = 100 OR t.create_user = 1000) |
| 本部门及子部门权限(ID: 100, 子部门: 101, 102) | (t.dept_id IN (100, 101, 102) OR t.create_user = 1000) |
| 自定义权限(角色 1 对应部门 100, 101) | (t.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id=1) OR t.create_user = 1000) |
| 多个自定义权限(角色 1 和 2) | (t.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id=1) OR t.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id=2) OR t.create_user = 1000) |
| 自定义 + 子部门 | (t.dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id=1) OR t.dept_id IN (200, 201, 202) OR t.create_user = 1000) |
九、总结与建议
✅ 优势:
- 灵活扩展:支持多种权限类型组合。
- 低侵入性:只需添加注解即可生效。
- 性能优化:避免全表扫描,利用索引查询。
- 字段命名规范:
createUser→create_user,完全符合团队命名规范。 - 组合权限支持:多个角色权限自动合并处理。
📌 建议:
- 增加单元测试覆盖所有权限类型组合。
- 支持字段级别权限控制。
- 提供可视化界面配置角色数据权限
- (详见1:flow.mldong.com/ )(admin/123456)。
- (详见1:flow-pro.mldong.com/ )(admin/123456)。
- 异常日志记录与调试输出增强。
如需获取完整源码,请访问 Gitee 地址:
十、附录:关键类对照表
| 类名 | 路径 | 职责 |
|---|---|---|
| DataScope | com.mldong.annotation.DataScope | 注解定义 |
| DataAuthSqlBuilder | com.mldong.auth.data.DataAuthSqlBuilder | SQL 构建接口 |
| DefaultDataAuthSqlBuilder | com.mldong.auth.data.builder.DefaultDataAuthSqlBuilder | 默认 SQL 构建实现 |
| DataPermissionHandlerImpl | com.mldong.mp.DataPermissionHandlerImpl | 权限处理器 |
| DataPermissionAop | com.mldong.aop | 数据权限是否启用切面 |
| MybatisConfig | com.mldong.config.MybatisConfig | 插件配置 |
| UserMapper | com.mldong.modules.sys.mapper.UserMapper | Mapper 层使用示例 |