目录
- 一、背景
- 二、动机
- 三、实现思路
- 3.1 权限类型、操作类型
- 3.2 统一用户及数据权限集合模型
- 3.3 定义数据权限拦截注解
- 3.4 提取配置属性
- 3.5 数据权限拦截器实现
- 四、集成方式
- 五、关于D3S
一、背景
最近一直在做RBAC相关的架构设计与实现,传统的RBAC的权限控制只是控制到REST接口(url)、具体方法(权限码)等,而通常实际业务场景还需要对数据权限进行控制,例如:
- 用户仅允许查询自己的用户信息,不允许查询其他人的用户信息
- 用户仅被允许查询 同部门 或 同部门及子部门 的用户信息
- 用户仅允许查询指定部门的数据等
- 用户仅允许查看自己的信息,不允许修改和删除
- . . . . . .
而对应到具体操作界面上,可以参见【若依】实现的角色数据权限设置
在若依的实现中,将数据权限类型划分为:
数据权限类型 | 说明 |
---|---|
全部数据权限 | 允许查询所有数据 |
仅本人数据权限 | 仅允许查询自己的数据 |
本部门数据权限 | 仅允许查询同部门的数据 |
本部门及以下数据权限 | 允许查询同部门及子部门下的数据 |
自定义数据权限 | 允许查询指定部门下的数据 |
且在若依中每个角色仅对应一种数据权限,而在实际设计RBAC模型时,有可能一个角色可以对应多种数据权限,如下图:
二、动机
若依框架已经包含了其自身的基于Mybatis
的数据权限实现:
- 基于AOP(DataScopeAspect)实现,在需要数据权限控制的Service实现类方法中标注@DataScope(deptAlias = “d”, userAlias = “u”)注解
- 拦截登录用户信息
- 根据用户角色及数据权限信息动态生成数据权限过滤SQL条件
- 设置数据权限过滤SQL条件到
BaseEntity.params["dataScope"]
- 在Mapper.xml中需要控制数据权限的SQL中添加数据权限条件占位符${params.dataScope}
但是在实际使用过程中,还是会有以下问题:
- DataScopeAspect中的数据权限条件SQL写死在代码中,无法支持动态配置
- 支持数据条件的Mapper接口方法参数需要与
BaseEntity
绑定 - Mapper.xml中需通过数据权限条件占位符
${params.dataScope}
才能使用数据权限,若使用Mybatis-Plus内置BaseMapper中的方法,没有对应的xml,则无法设置数据权限条件占位符${params.dataScope}
由于若依的数据权限实现强依赖了若依框架自身(BaseEntity、Mybatis Mapper.xml、固定的数据库设计等),在脱离了若依框架则难以适用,但其实现思想还是值得参考与借鉴的,综上,促使本作者实现了更广泛适用的基于Mybatis数据权限插件实现。
三、实现思路
所谓更广泛适用,即结合本作者日常的架构设计,需:
- 支持数据权限条件SQL的动态配置(尽可能支持不同数据库设计)
- 支持Mybatis-Plus内置BaseMapper中的方法
- 自动附加数据权限SQL条件,同时保留支持在Mapper.xml使用数据权限占位符
- 数据权限注解需放置在Mapper接口方法上(支持具体到单独Sql的数据权限控制)
- 数据权限无需依赖Mapper方法中的特定类型参数(如无需依赖BaseEntity)
- 提供自身统一的用户及数据权限对象抽象,使得不同框架的用户、权限模型都可以进行适配
- 支持数据权限类型限制(
ADMIN
、USER
、USER_CUSTOM
、DEPT
、DEPT_AND_CHILD
、DEPT_CUSTOM
) - 支持数据操作(
ALL
、SELECT
、INSERT
、UPDATE
、DELETE
)限制(可限制仅支持特定类型的SQL,如仅支持查询SELECT等)
由于仅需要支持Mapper接口方法上的数据权限控制,且考虑到有修改SQL的需要,故采用了Mybatis拦截器的实现方式。同时Mybatis-Plus框架也提供了数据权限拦截器实现,具体使用可参见:gitee/baomidou/mybatis-plus/issues/I37I90,但考虑到还需要支持原生Mybatis(未使用Mybatis-Plus)实现,故采用了原生的Mybatis拦截器实现。
注:
Mybatis-Plus DataPermissionHandler实现需依赖jsqlparser,
个人感觉没有直接拼接SQL来的方便😅,还有一定学习成本,
如需使用Mybatis-Plus DataPermissionHandler,关于jsqlparser的使用可参见:JSqlParser入门系列
3.1 权限类型、操作类型
首先,定义支持的 数据权限类型枚举 和 操作类型枚举:
数据权限类型 用于限制用户的可见数据范围,
操作类型 即限制了用户对可见数据的操作。
数据权限类型 / DpTypeEnum | 说明 |
---|---|
ADMIN | 管理员数据权限,拥有全部数据的权限 |
USER | 用户自身数据权限,仅允许操作自己的数据 |
USER_CUSTOM | 自定义用户数据权限,仅允许操作指定用户的数据 |
DEPT | 部门数据权限,仅允许操作同部门的数据 |
DEPT_AND_CHILD | 部门及子部门数据权限,仅允许操作同部门及子部门的数据 |
DEPT_CUSTOM | 自定义部门数据权限,仅允许操作指定部门的数据 |
/**
* 数据权限类型枚举
*
* @author luohq
* @date 2023-07-02
*/
public enum DpTypeEnum {
//管理员数据权限(全部数据)
ADMIN("1"),
//用户自身数据权限
USER("2"),
//自定义用户数据权限
USER_CUSTOM("3"),
//部门数据权限
DEPT("4"),
//部门及子部门数据权限
DEPT_AND_CHILD("5"),
//自定义部门数据权限
DEPT_CUSTOM("6");
......
}
数据操作类型 / DpOpEnum | 说明 |
---|---|
ALL | 全部操作,允许执行全部类型的SQL语句 |
SELECT | 查询操作,仅允许执行SELECT类型的SQL语句 |
UPDATE | 修改操作,仅允许执行UPDATE类型的SQL语句 |
INSERT | 插入操作,仅允许执行INSERT类型的SQL语句 |
DELETE | 删除操作,仅允许执行DELETE类型的SQL语句 |
/**
* 数据权限 - 操作 - 枚举
*
* @author luohq
* @date 2023-07-02
*/
public enum DpOpEnum {
//全部操作
ALL("1", "ALL"),
//查询操作
SELECT("2", "SELECT"),
//修改操作
UPDATE("3", "UPDATE"),
//插入操作
INSERT("4", "INSERT"),
//删除操作
DELETE("5", "DELETE");
......
}
3.2 统一用户及数据权限集合模型
接下来定义统一的 用户 及 用户所拥有的数据权限及操作集合 抽象:
用户信息属性 / BaseUserDto | 说明 |
---|---|
BaseUserDto.getUserId | 表示当前用户标识,在拥有数据权限USER 时使用 |
BaseUserDto.getDeptId | 表示当前用户所在部门标识,在拥有数据权限DEPT 时使用 |
BaseUserDto.getDataPermissions | 表示当前用户拥有的数据权限集合 |
BaseUserDto.getDeptAndChildDeptIds | 表示当前用户所在部门及子部门标识集合,在拥有数据权限DEPT_AND_CHILD 时使用,若不需要可为空 |
BaseUserDto.getAdditionalParams | 表示附加参数集合,可根据需要自行定义,可用于后续数据权限SQL条件拼接 |
/**
* 用户及数据权限集合DTO
*
* @author luohq
* @date 2023-07-02
*/
public interface BaseUserDto {
/**
* 获取当前用户ID
*
* @return 当前用户ID
*/
String getUserId();
/**
* 获取当前用户所属部门ID
*
* @return
*/
String getDeptId();
/**
* 获取当前用户所属的部门及子部门ID集合
*
* @return 前用户所属的部门及子部门ID集合
*/
Collection<String> getDeptAndChildDeptIds();
/**
* 获取当前用户数据权限集合
*
* @return 当前用户数据权限集合
*/
Collection<BaseDpDto> getDataPermissions();
/**
* 获取其他附加的参数集合
*
* @return 附加参数集合
*/
Map<String, String> getAdditionalParams();
}
数据权限属性 / BaseDpDto | 说明 |
---|---|
BaseDpDto.getType | 表示数据权限类型(ADMIN 、USER 、USER_CUSTOM 、DEPT 、DEPT_AND_CHILD 、DEPT_CUSTOM ) |
BaseDpDto.getOperations | 表示当前数据权限类型支持的操作集合(ALL 、SELECT 、INSERT 、UPDATE 、DELETE ),若BaseDpDto.getOperations返回空或空集合,则表示默认支持所有操作 |
BaseDpDto.getManageDeptIds | 表示当前用户管控的部门标识集合,仅在getType为DEPT_CUSTOM 时被使用 |
BaseDpDto.getManageUserIds | 表示当前用户管控的用户标识集合,仅在getType为USER_CUSTOM 时被使用 |
/**
* 数据权限DTO
*
* @author luohq
* @date 2023-07-02
*/
public interface BaseDpDto {
/**
* 获取数据权限类型
*
* @return 数据权限类型
*/
DpTypeEnum getType();
/**
* 获取数据权限支持的操作集合,若操作集合为空则表示默认支持所有操作
*
* @return 数据权限支持的操作集合
*/
Collection<DpOpEnum> getOperations();
/**
* 获取当前用户管控部门ID集合
*
* @return 用户管控部门ID集合
*/
Collection<String> getManageDeptIds();
/**
* 获取当前用户管控人员ID集合
*
* @return 用户管控人员ID集合
*/
Collection<String> getManageUserIds();
}
数据权限用户上下文实现:
/**
* 数据权限用户信息上线文容器
*
* @author luohq
* @date 2023-07-02
*/
public class DpUserContextHolder {
/**
* 用户ThreadLocal
*/
private static final ThreadLocal<BaseUserDto> contextHolder = new ThreadLocal<>();
/**
* 内部构造函数
*/
private DpUserContextHolder() {
}
/**
* 清空用户上下文
*/
public static void clearContext() {
contextHolder.remove();
}
/**
* 获取当前用户上下文
*
* @return 数据权限用户信息
*/
public static BaseUserDto getContext() {
return contextHolder.get();
}
/**
* 设置当前用户上下文
*
* @param dpUserDto 数据权限用户信息
*/
public static void setContext(BaseUserDto dpUserDto) {
Assert.notNull(dpUserDto, "Only non-null DpUser instances are permitted");
contextHolder.set(dpUserDto);
}
}
之后凡是需集成该数据权限插件,都需要将各自的用户、权限模型统一转换为当前定义的统一的用户及数据权限集合模型,例如在用户完成鉴权后,可以获取用户及数据权限信息并转换为BaseUserDto
和BaseDpDto
,然后设置到DpUserContextHolder
中。
而如上的统一用户及数据权限集合模型,都会被转换为参数集,后续会被用于数据权限条件SQL的拼接中:
注:
可在后续不同数据权限SQL条件配置中使用{参数名}
的形式使用对应的参数值,
如{deptAlias}
、{deptIdColumn}
等。
/**
* SQl解析参数DTO
*
* @author luohq
* @date 2023-07-02
*/
public class SqlParseParamDto {
......
public SqlParseParamDto(DataPermission dpAnno, BaseUserDto user, BaseDpDto dp) {
......
//初始参数Map
this.initParamMap(user);
}
private void initParamMap(BaseUserDto user) {
this.paramMap = new HashMap<>(8);
//基础参数
paramMap.put("deptAlias", this.deptAlias);
paramMap.put("deptIdColumn", this.deptIdColumn);
paramMap.put("userAlias", this.userAlias);
paramMap.put("userIdColumn", this.userIdColumn);
paramMap.put("userId", this.userId);
paramMap.put("deptId", this.deptId);
paramMap.put("deptAndChildDeptIds", this.idsToString(this.deptAndChildDeptIds, EMPTY));
paramMap.put("userIdIdWithSingleQuote", this.idsToString(Collections.singleton(this.userId), SINGLE_QUOTE));
paramMap.put("deptIdWithSingleQuote", this.idsToString(Collections.singleton(this.deptId), SINGLE_QUOTE));
paramMap.put("deptAndChildDeptIdsWithSingleQuote", this.idsToString(this.deptAndChildDeptIds, SINGLE_QUOTE));
paramMap.put("userIdIdWithDoubleQuote", this.idsToString(Collections.singleton(this.userId), DOUBLE_QUOTE));
paramMap.put("deptIdWithDoubleQuote", this.idsToString(Collections.singleton(this.deptId), DOUBLE_QUOTE));
paramMap.put("deptAndChildDeptIdsWithDoubleQuote", this.idsToString(this.deptAndChildDeptIds, DOUBLE_QUOTE));
paramMap.put("manageDeptIds", this.idsToString(this.manageDeptIds, EMPTY));
paramMap.put("manageUserIds", this.idsToString(this.manageDeptIds, EMPTY));
paramMap.put("manageDeptIdsWithSingleQuote", this.idsToString(this.manageDeptIds, SINGLE_QUOTE));
paramMap.put("manageUserIdsWithSingleQuote", this.idsToString(this.manageUserIds, SINGLE_QUOTE));
paramMap.put("manageDeptIdsWithDoubleQuote", this.idsToString(this.manageDeptIds, DOUBLE_QUOTE));
paramMap.put("manageUserIdsWithDoubleQuote", this.idsToString(this.manageUserIds, DOUBLE_QUOTE));
//附加参数
if (null != user.getAdditionalParams() && !user.getAdditionalParams().isEmpty()) {
this.paramMap.putAll(user.getAdditionalParams());
}
}
......
}
3.3 定义数据权限拦截注解
定义数据权限拦截注解@DataPermission
,即在需要支持数据权限拦截的Mapper接口方法上添加该注解,同时该注解亦可用在接口定义上,用于支持Mybatis-Plus的BaseMapper中的内建方法。
@DataPermission属性 | 说明 |
---|---|
@DataPermission.userAlias @DataPermission.userIdColumn | 数据权限拦截器拼接SQL时可根据userAlias、userIdColumn设置用户数据过滤条件的表别名、用户标识列名 |
@DataPermission.deptAlias @DataPermission.deptIdColumn | 数据权限拦截器拼接SQL时可根据deptAlias、deptIdColumn设置部门数据过滤条件的表别名、部门标识列名 |
@DataPermission.supportTypes | 若supportTypes为空,则表示支持所有数据权限类型, 若supportTypes非空,则表示仅支持指定的数据权限类型 |
@DataPermission.methodName | 仅当@DataPermission注解在 接口 定义上时需设置此方法名methodName, 此时methodName用于表示父接口如BaseMapper中的方法名selectById, 而当@DataPermission注解在方法上时无需设置此方法名 |
@DataPermission.defaultAllowAll | 当用户数据权限集合为空时,默认是否允许查看全部数据(默认false,即不允许) |
/**
* 数据权限注解
*
* @author luohq
* @date 2023-07-02
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(DataPermission.DataPermissions.class)
public @interface DataPermission {
/**
* 部门表的别名
*/
String deptAlias() default "";
/**
* 部门表的ID列名
*/
String deptIdColumn() default "";
/**
* 用户表的别名
*/
String userAlias() default "";
/**
* 用户表的ID列名
*/
String userIdColumn() default "";
/**
* 支持的数据权限类型(默认支持所有类型)
*/
DpTypeEnum[] supportTypes() default {};
/**
* 当用户数据权限集合为空时,默认是否允许查看全部数据(默认false,即不允许)
*/
boolean defaultAllowAll() default false;
/**
* 方法名(仅注解在类或接口上时需设置此方法名,而注解在方法上时无需设置此方法名)
*/
String methodName() default "";
/**
* 数据权限组合注解
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@interface DataPermissions {
/**
* 数据权限集合
*/
DataPermission[] value();
}
}
3.4 提取配置属性
将不同数据权限类型的SQL条件语句皆提取为配置属性,以便支持不同的数据库设计。
配置属性 / DataPermissionProps 前缀:d3s.data-permission | 配置说明 |
---|---|
condition-for-user | 用户权限USER 限制条件,默认: {userAlias}.{userIdColumn} = {userId} |
condition-for-user-custom | 自定义用户权限USER_CUSTOM 限制条件,默认: {userAlias}.{userIdColumn} in ({manageUserIds} ) |
condition-for-dept | 部门权限DEPT 限制条件,默认: {deptAlias}.{deptIdColumn} = {deptId} |
condition-for-dept-and-child | 部门及子部门权限DEPT_AND_CHILD 限制条件,默认: {deptAlias}.{deptIdColumn} in ({deptAndChildDeptIds}) ,亦可支持ancestors等类似祖先ID路径的查询条件配置,例如: {deptAlias}.{deptIdColumn} in (select dept_id from sys_dept where dept_id = {deptId} or find_in_set({deptId}, ancestors)) 或者 {deptAlias}.{deptIdColumn} in (select dept_id from sys_dept where dept_id = {deptId} or ancestors like '%,{deptId},%') |
condition-for-dept-custom | 自定义部门权限DEPT_CUSTOM 限制条件,默认: {deptAlias}.{deptIdColumn} in ({manageDeptIds}) |
default-invalid-id | 默认无效ID,默认为0 |
throw-exception-when-no-data-permission | 没有数据权限时是否抛出DataPermissionException, true则表示没有数据权限时抛出DataPermissionException, false则表示没有数据权限时拼接false条件,仅INSERT命令类型会抛出DataPermissionException |
/**
* 数据权限配置属性
*
* @author luohq
* @date 2023-06-21 10:13
*/
@ConfigurationProperties(prefix = DataPermissionProps.PREFIX)
public class DataPermissionProps {
public static final String PREFIX = "d3s.data-permission";
/**
* 用户权限限制条件
*/
private String conditionForUser = "{userAlias}.{userIdColumn} = {userId}";
/**
* 自定义用户权限限制条件
*/
private String conditionForUserCustom = "{userAlias}.{userIdColumn} in ({manageUserIds})";
/**
* 部门权限限制条件
*/
private String conditionForDept = "{deptAlias}.{deptIdColumn} = {deptId}";
/**
* 部门及子部门权限限制条件,例如:
* <ul>
* <li>{deptAlias}.{deptIdColumn} in (select dept_id from sys_dept where dept_id = {deptId} or find_in_set({deptId}, ancestors))</li>
* <li>{deptAlias}.{deptIdColumn} in (select dept_id from sys_dept where dept_id = {deptId} or ancestors like '%,{deptId},%')</li>
* <li>{deptAlias}.{deptIdColumn} in ({deptAndChildDeptIds})</li>
* </ul>
*/
private String conditionForDeptAndChild = "{deptAlias}.{deptIdColumn} in ({deptAndChildDeptIds})";
/**
* 自定义部门权限限制条件
*/
private String conditionForDeptCustom = "{deptAlias}.{deptIdColumn} in ({manageDeptIds})";
/**
* 默认无效ID
*/
private String defaultInvalidId = "0";
/**
* 没有数据权限时是否抛出DataPermissionException
* <ol>
* <li>true则表示没有数据权限时抛出DataPermissionException</li>
* <li>false则表示没有数据权限时拼接false条件,仅INSERT命令类型会抛出DataPermissionException</li>
* </ol>
*/
private Boolean throwExceptionWhenNoDataPermission = true;
......
}
3.5 数据权限拦截器实现
实现Mybatis拦截器,对Executor接口的query、update方法进行拦截,且需保证数据权限拦截器最后被添加(则最先被执行)。
该数据权限拦截器的主要功能如下:
- 判断当前被拦截的Mapper接口方法是否标注了@DataPermission注解(又或者Mapper接口上标注了@DataPermission.methodName为对应方法),若标注了@DataPermission注解则启用数据权限拦截
- 获取DpUserContextHolder上下文中的用户及数据权限信息,根据不同数据权限类型动态生成数据权限SQL限制条件
- 若数据权限为空 或 不满足操作权限时,则根据
d3s.data-permission.throw-exception-when-no-data-permission
配置,若配置为true则抛出DataPermissionException,若配置为false,则仅在INSERT操作抛出DataPermissionException,其他操作则拼接false条件
- 若数据权限为空 或 不满足操作权限时,则根据
- 替换原始SQL为拼接了数据权限限制的SQL语句
- 若原始SQL中存在
{DATA_PERMISSION_CONDITION}
占位符,则替换此占位符为对应的数据权限SQL限制条件 - 若原始SQL中不存在
{DATA_PERMISSION_CONDITION}
占位符,则将对应的数据权限SQL限制条件拼接到原始SQL语句后
- 若原始SQL中存在
数据权限拦截器具体实现代码如下:
/**
* 数据权限 - Mybatis Interceptor
*
* @author luohq
* @date 2023-06-18
*/
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = 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 DataPermissionInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(DataPermissionInterceptor.class);
/**
* Executor.query()带有cacheKey和boundSql参数的方法的参数个数
*/
private static final Integer EXECUTOR_QUERY_CACHE_ARGS_COUNT = 6;
/**
* 数据权限条件占位符
*/
private static final String DATA_PERMISSION_CONDITION_PLACEHOLDER = "{DATA_PERMISSION_CONDITION}";
/**
* 空白字符正则表达式
*/
private static final String BLANK_CHAR_REGEX = "[\\t\\n\\r]";
/**
* 空格字符串
*/
private static final String SPACE_STRING = " ";
/**
* 缓存Map(mapperMethodId, 对应的@DataPermission注解)
*/
private Map<String, DataPermission> mapperMethodIdToDpAnnoMap = new ConcurrentHashMap<>();
/**
* 数据权限配置属性
*/
private DataPermissionProps dpProps;
/**
* 数据权限类型对应的条件SQL属性
*/
private Map<DpTypeEnum, Supplier<String>> dpTypeToConditionSqlProplMap = new HashMap<>(6);
/**
* 数据权限烂机器 - 构造函数
*
* @param dpProps 数据权限配置属性
*/
public DataPermissionInterceptor(DataPermissionProps dpProps) {
this.dpProps = dpProps;
//初始化
this.init();
}
/**
* 初始数据权限类型对应的条件SQL属性
*/
private void init() {
dpTypeToConditionSqlProplMap.put(DpTypeEnum.USER, this.dpProps::getConditionForUser);
dpTypeToConditionSqlProplMap.put(DpTypeEnum.USER_CUSTOM, this.dpProps::getConditionForUserCustom);
dpTypeToConditionSqlProplMap.put(DpTypeEnum.DEPT, this.dpProps::getConditionForDept);
dpTypeToConditionSqlProplMap.put(DpTypeEnum.DEPT_AND_CHILD, this.dpProps::getConditionForDeptAndChild);
dpTypeToConditionSqlProplMap.put(DpTypeEnum.DEPT_CUSTOM, this.dpProps::getConditionForDeptCustom);
}
@Override
public Object intercept(Invocation invocation) throws Throwable {
//获取执行参数
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
//当前SQL命令类型 - UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH
SqlCommandType sqlCommandType = ms.getSqlCommandType();
//mapper方法参数
Object paramObjOfMapperMethod = args[1];
//获取BoundSql(区分处理2个query方法)
BoundSql boundSql = EXECUTOR_QUERY_CACHE_ARGS_COUNT.equals(args.length)
? (BoundSql) args[EXECUTOR_QUERY_CACHE_ARGS_COUNT - 1]
: ms.getSqlSource().getBoundSql(paramObjOfMapperMethod);
//Mapper方法ID,格式: mapper接口名全路径.mapper方法名,例如com.luo.dao.BizMapper.insert
String mapperMethodId = ms.getId();
//解析当前执行的Mapper方法
DataPermission dpAnno = this.parseMapperMethodDpAnno(mapperMethodId);
//如果没有数据权限注解,则继续执行逻辑
if (Objects.isNull(dpAnno)) {
//继续执行逻辑
return invocation.proceed();
}
//判断是否已设置当前用户数据权限上下文
BaseUserDto dpUser = DpUserContextHolder.getContext();
if (Objects.isNull(dpUser)) {
throw new RuntimeException("无法获取当前用户数据权限信息 - 请先执行DpUserContextHolder.setContext(dpUser)方法");
}
//提取原SQL
String oldSql = boundSql.getSql().replaceAll(BLANK_CHAR_REGEX, SPACE_STRING);
//拼接生成新SQL - 附加数据权限条件
String newSqlWithDataPermission = this.fillSqlWithDataPermissionCondition(mapperMethodId, oldSql, sqlCommandType, dpAnno, dpUser);
log.debug("DataPermissionInterceptor[{}] SQL Before Refactoring: {}", mapperMethodId, oldSql);
log.debug("DataPermissionInterceptor[{}] SQL After Refactoring: {}", mapperMethodId, newSqlWithDataPermission);
//替换原SQL
this.replaceSql(newSqlWithDataPermission, ms, boundSql, invocation);
//继续执行逻辑
return invocation.proceed();
}
@Override
public Object plugin(Object o) {
//获取代理权
if (o instanceof Executor) {
//如果是Executor(执行增删改查操作),则拦截下来
return Plugin.wrap(o, this);
} else {
return o;
}
}
@Override
public void setProperties(Properties properties) {
//读取mybatis配置文件中属性
}
/**
* 解析Mapper方法上的@DataPermission注解(或者Mapper接口上的@DataPermission注解)
*
* @param mapperMethodId mapper方法ID,格式: mapper接口名全路径.mapper方法名,例如com.luo.dao.BizMapper.insert
* @return Mapper方法对应的@DataPermission注解
* @throws ClassNotFoundException
*/
private DataPermission parseMapperMethodDpAnno(String mapperMethodId) throws ClassNotFoundException {
//优先从缓存中获取mapperMethodId对应的@DataPermission注解
if (this.mapperMethodIdToDpAnnoMap.containsKey(mapperMethodId)) {
return this.mapperMethodIdToDpAnnoMap.get(mapperMethodId);
}
int lastDotIndex = mapperMethodId.lastIndexOf(".");
//Mapper接口类
String mapperClassFromId = mapperMethodId.substring(0, lastDotIndex);
//Mapper接口方法名
String mapperMethodNameFromId = mapperMethodId.substring((lastDotIndex + 1));
//反射Mapper接口类
Class<?> mapperClass = Class.forName(mapperClassFromId);
Method[] mapperMethods = mapperClass.getMethods();
//获取当前执行的mapper方法
Method mapperMethod = null;
for (Method method : mapperMethods) {
String methodName = method.getName();
//匹配当前执行的mapper方法
if (this.matchMapperMethod(methodName, mapperMethodNameFromId)) {
mapperMethod = method;
break;
}
}
//方法不匹配,则无需拦截
if (Objects.isNull(mapperMethod)) {
return null;
}
//解析当前方法的DataPermission注解
DataPermission dpAnnoOfMethod = AnnotatedElementUtils.getMergedAnnotation(mapperMethod, DataPermission.class);
if (Objects.nonNull(dpAnnoOfMethod)) {
//缓存mapperMethodId对应的@DataPermission注解
this.mapperMethodIdToDpAnnoMap.put(mapperMethodId, dpAnnoOfMethod);
return dpAnnoOfMethod;
}
//解析类上的DataPermission注解(Repeatable支持解析多注解)
Set<DataPermission> dpAnnoSetOfClass = AnnotatedElementUtils.getMergedRepeatableAnnotations(mapperClass, DataPermission.class);
for (DataPermission dpAnnoOfClass : dpAnnoSetOfClass) {
//匹配当前执行的mapper方法
if (Objects.nonNull(dpAnnoOfClass.methodName()) && this.matchMapperMethod(dpAnnoOfClass.methodName(), mapperMethodNameFromId)) {
//缓存mapperMethodId对应的@DataPermission注解
this.mapperMethodIdToDpAnnoMap.put(mapperMethodId, dpAnnoOfClass);
return dpAnnoOfClass;
}
}
//方法上没有@DataPermission则返回null
return null;
}
/**
* 添加数据权限SQL条件
*
* @param mapperMethodId mapper方法ID,格式: mapper接口名全路径.mapper方法名,例如com.luo.dao.BizMapper.insert
* @param oldSql 原始SQL
* @param sqlCommandType SQL命令类型
* @param dpAnno Mapper方法上的数据权限注解
* @param dpUser 当前用户数据权限上下文
* @return
*/
private String fillSqlWithDataPermissionCondition(String mapperMethodId, String oldSql, SqlCommandType sqlCommandType, DataPermission dpAnno, BaseUserDto dpUser) {
//若无匹配的数据权限,是否允许查询全部数据
String defaultAllowAll = String.valueOf(dpAnno.defaultAllowAll());
//若当前用户没有数据权限
if (Objects.isNull(dpUser.getDataPermissions()) || dpUser.getDataPermissions().isEmpty()) {
//根据defaultAllowAll属性,判断允许或不允许查询全部数据
return this.fillSqlWithFinalDpCondition(mapperMethodId, oldSql, sqlCommandType, defaultAllowAll, dpUser);
}
//获取当前用户的数据权限(非空)
Collection<BaseDpDto> dpCollection = dpUser.getDataPermissions();
String dpCondition = "";
//遍历用户拥有的数据权限集合,根据不同权限类型依次拼接数据权限Sql条件
for (BaseDpDto curDp : dpCollection) {
DpTypeEnum curDpType = curDp.getType();
//转换sql填充参数
SqlParseParamDto sqlParseParamDto = SqlParseParamDto.of(dpAnno, dpUser, curDp);
//最高权限 - 查询全部
if (DpTypeEnum.ADMIN.equals(curDpType)) {
//直接在原SQL上拼接true条件
return this.fillSqlWithFinalDpCondition(mapperMethodId, oldSql, sqlCommandType, SqlConditionUtils.ALLOW_ALL_CONDITION, dpUser);
}
//当前Mapper方法是否支持该数据权限类型和操作类型
if (this.supportDpTypeAndOperation(dpAnno, curDp, sqlCommandType)) {
//其他权限 - 拼接条件
String conditionForCurDpType = this.dpTypeToConditionSqlProplMap.get(curDpType).get();
conditionForCurDpType = sqlParseParamDto.fillSqlParams(conditionForCurDpType);
dpCondition = SqlConditionUtils.appendOrCondition(dpCondition, conditionForCurDpType);
}
}
//是否拥有数据权限
Boolean hasDataPermission = !dpCondition.isEmpty();
//doCondition = ((dp_condition1) or (dp_condition2))
dpCondition = hasDataPermission
//删除前置 OR
? dpCondition.substring(SqlConditionUtils.OR_SEPARATOR.length())
//若无匹配的数据权限类型,默认则不允许查询全部(false)
: defaultAllowAll;
//TODO 特殊处理Insert,若dpCondition为空,则抛出数据权限异常
//TODO 根据commandType限制SQL执行类型 - UNKNOWN, INSERT, UPDATE, DELETE, SELECT, FLUSH
//替换原Sql中的数据权限占位符{DATA_PERMISSION_CONDITION}为dpCondition
//若不存在数据权限占位符{DATA_PERMISSION_CONDITION},则拼接dpCondition到最后
return this.fillSqlWithFinalDpCondition(mapperMethodId, oldSql, sqlCommandType, dpCondition, dpUser);
}
/**
* 填充最终的数据权限SQL条件
* <ol>
* <li>替换原Sql中的数据权限占位符{DATA_PERMISSION_CONDITION}为dpCondition</li>
* <li>若为默认全部数据权限true,则不拼接数据权限条件</li>
* <li>若不存在数据权限占位符{DATA_PERMISSION_CONDITION} 且 不是默认全部数据权限,则拼接dpCondition到最后</li>
* </ol>
*
* @param oldSql 原始SQL
* @param dpCondition 拼接后的数据权限SQL条件
* @return 填充后的SQL
*/
private String fillSqlWithFinalDpCondition(String mapperMethodId, String oldSql, SqlCommandType sqlCommandType, String dpCondition, BaseUserDto dpUser) {
//若无数据权限,则特殊处理INSERT 或 依照配置抛出DataPermissionException
if (SqlConditionUtils.isDenyAllCondition(dpCondition)
&& (SqlCommandType.INSERT.equals(sqlCommandType) || this.dpProps.getThrowExceptionWhenNoDataPermission())) {
throw new DataPermissionException(mapperMethodId, sqlCommandType.name(), dpUser);
}
//替换原Sql中的数据权限占位符{DATA_PERMISSION_CONDITION}为:(dp_condition)
if (oldSql.contains(DATA_PERMISSION_CONDITION_PLACEHOLDER)) {
return oldSql.replace(DATA_PERMISSION_CONDITION_PLACEHOLDER, SqlConditionUtils.formatBracketConditionWithParams(dpCondition));
}
//若为默认全部数据权限,则不拼接数据权限条件
if (SqlConditionUtils.isAllowAllCondition(dpCondition)) {
return oldSql;
}
//若不存在数据权限占位符{DATA_PERMISSION_CONDITION} 且 不是默认全部数据权限,则拼接数据权限条件
return SqlConditionUtils.appendWhereAndCondition(oldSql, dpCondition);
}
/**
* 当前Mapper方法是否支持该数据权限类型和操作类型<br/>
* <ol>
* <li>若@DataPermission.supportTypes为空,则表示支持所有权限类型</li>
* <li>若BaseDpDto.operations为空,则表示支持所有SqlCommandType</li>
* <li>若@DataPermission.supportTypes非空,则需要与BaseDpTo.type进行匹配</li>
* <li>若BaseDpDto.operations非空,则需要与当前SqlCommandType进行匹配</li>
* <li>若BaseDpDto.operations包含ALL,则表示支持所有SqlCommandType</li>
* </ol>
*
* @param dpAnno Mapper方法上的数据权限注解
* @param curDpDto 当前待处理数据权限
* @param sqlCommandType Sql命令类型
* @return 是否支持
*/
private Boolean supportDpTypeAndOperation(DataPermission dpAnno, BaseDpDto curDpDto, SqlCommandType sqlCommandType) {
//是否支持数据权限类型
Boolean matchDpType = DpUtils.isEmptyArray(dpAnno.supportTypes())
? true
: Stream.of(dpAnno.supportTypes()).anyMatch(supportDpType -> supportDpType.equals(curDpDto.getType()));
//是否支持数据权限操作
Boolean matchDpOperation = DpUtils.isEmptyCollection(curDpDto.getOperations())
? true
: curDpDto.getOperations().stream().anyMatch(operation -> DpOpEnum.ALL.equals(operation) || operation.getSqlCommandType().equals(sqlCommandType.name()));
return matchDpType && matchDpOperation;
}
/**
* Mapper接口中的方法是否匹配当前Mybatis拦截器拦截到的方法
*
* @param curMethodName mapper中的方法名
* @param mapperMethodNameFromId 当前Mybatis拦截器拦截到的方法名
* @return
*/
private Boolean matchMapperMethod(String curMethodName, String mapperMethodNameFromId) {
//"_COUNT"兼容PageHelper自定义分页sql
return curMethodName.equals(mapperMethodNameFromId) || curMethodName.concat("_COUNT").equals(mapperMethodNameFromId);
}
/**
* 替换原SQL
*
* @param newSql 新SQL语句
* @param ms 原MappedStatement
* @param boundSql 原BoundSql
* @param invocation Invocation
*/
private void replaceSql(String newSql, MappedStatement ms, BoundSql boundSql, Invocation invocation) {
//创建新的BoundSql
BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), newSql, boundSql.getParameterMappings(), boundSql.getParameterObject());
for (ParameterMapping mapping : boundSql.getParameterMappings()) {
String prop = mapping.getProperty();
if (boundSql.hasAdditionalParameter(prop)) {
newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
}
}
//创建新的MappedStatement
MappedStatement newMs = newMappedStatement(ms, parameterObject -> newBoundSql);
Object[] queryArgs = invocation.getArgs();
//替换参数MappedStatement
queryArgs[0] = newMs;
//替换参数BoundSql
if (EXECUTOR_QUERY_CACHE_ARGS_COUNT.equals(queryArgs.length)) {
queryArgs[EXECUTOR_QUERY_CACHE_ARGS_COUNT - 1] = newBoundSql;
}
}
/**
* 创建新的MappedStatement
*
* @param ms 原MappedStatement
* @param newSqlSource 新的SqlSource
* @return 新的MappedStatement
*/
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();
}
}
注:
完整的数据权限插件实现可参见:
https://gitee.com/luoex/d3s/tree/master/d3s-extend/d3s-data-permission-mybatis
四、集成方式
maven依赖:
<!-- D3S 数据权限Mybatis扩展依赖 -->
<dependency>
<groupId>com.luo.d3s</groupId>
<artifactId>d3s-data-permission-mybatis</artifactId>
</dependency>
在需要支持数据权限的Mapper接口或方法上标注@DataPermission注解,示例SysUserMapper.java代码如下:
/**
* 用户Mapper
*
* @author luohq
* @date 2023-06-25
*/
@DataPermission(methodName = "selectById", userAlias = "", userIdColumn = "id", supportTypes = {DpTypeEnum.USER})
@DataPermission(methodName = "selectPage", userAlias = "", userIdColumn = "id", deptAlias = "", deptIdColumn = "dept_id", supportTypes = {DpTypeEnum.USER, DpTypeEnum.DEPT_AND_CHILD})
@DataPermission(methodName = "updateById", userAlias = "", userIdColumn = "id", supportTypes = {DpTypeEnum.USER})
@DataPermission(methodName = "deleteById", userAlias = "", userIdColumn = "id", supportTypes = {DpTypeEnum.USER})
@DataPermission(methodName = "deleteBatchIds", userAlias = "", userIdColumn = "id", supportTypes = {DpTypeEnum.USER})
@DataPermission(methodName = "selectList", userAlias = "", userIdColumn = "id", deptAlias = "", deptIdColumn = "dept_id", supportTypes = {DpTypeEnum.USER, DpTypeEnum.DEPT_AND_CHILD})
@DataPermission(methodName = "insert")
public interface SysUserMapper extends BaseMapper<SysUser> {
/**
* 根据ID查询用户
*
* @param id 用户ID
* @return 用户
*/
@DataPermission(userAlias = "", userIdColumn = "id", supportTypes = {DpTypeEnum.USER})
SysUser findById(Long id);
/**
* Mapper接口中的默认方法(default)不走Mybatis拦截器,仅默认方法中调用的具体方法才会走拦截器,
* 如该方法中的this.selectList()方法,拦截器拦截到的也是selectList()方法,
* 可通过在Mapper接口类上声明@DataPermission(methodName = "selectList", ...)来指定拦截的具体方法
* @param pageNum 分页页码
* @param pageSize 分页大小
* @return 分页结果
*/
default PageInfo<SysUser> findPageWithPageHelper(Integer pageNum, Integer pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<SysUser> sysUsers = this.selectList(Wrappers.emptyWrapper());
return new PageInfo(sysUsers);
}
/**
* PageHelper - 动态SQL查询
*
* @param userName 用户名
* @return 用户列表
*/
@DataPermission(userAlias = "", userIdColumn = "id", deptAlias = "", deptIdColumn = "dept_id", supportTypes = {DpTypeEnum.USER, DpTypeEnum.DEPT_AND_CHILD})
@Select("select * from sys_user where user_name like concat('%', #{userName}, '%')")
List<SysUser> findPageWithPageHelper_dynamicSql(String userName);
/**
* 查询用户列表(Mapper.xml中使用了数据权限SQL条件占位符{DATA_PERMISSION_CONDITION})
*
* @param status 帐号状态(0正常 1停用)
* @param sex 用户性别(0男 1女 2未知)
* @return 用户列表
*/
@DataPermission(userAlias = "", userIdColumn = "id", deptAlias = "", deptIdColumn = "dept_id", supportTypes = {DpTypeEnum.USER, DpTypeEnum.DEPT_AND_CHILD})
List<SysUser> findList_dpConditionPlaceholder(@Param("status") String status, @Param("sex") String sex);
}
示例SysUserMapper对应的SysUserMapper.xml定义如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.luo.d3s.ext.dp.sample.dao.SysUserMapper">
<!-- 通用查询映射结果 -->
<resultMap id="BaseResultMap" type="com.luo.d3s.ext.dp.sample.entity.SysUser">
<id column="id" property="id" />
<result column="dept_id" property="deptId" />
<result column="user_name" property="userName" />
<result column="nick_name" property="nickName" />
<result column="user_type" property="userType" />
<result column="email" property="email" />
<result column="phonenumber" property="phonenumber" />
<result column="sex" property="sex" />
<result column="avatar" property="avatar" />
<result column="password" property="password" />
<result column="status" property="status" />
<result column="del_flag" property="delFlag" />
<result column="login_ip" property="loginIp" />
<result column="login_date" property="loginDate" />
<result column="create_by" property="createBy" />
<result column="create_time" property="createTime" />
<result column="update_by" property="updateBy" />
<result column="update_time" property="updateTime" />
<result column="remark" property="remark" />
</resultMap>
<!-- 通用查询结果列 -->
<sql id="Base_Column_List">
id,
dept_id,
user_name,
nick_name,
user_type,
email,
phonenumber,
sex,
avatar,
password,
status,
del_flag,
login_ip,
login_date,
create_by,
create_time,
update_by,
update_time,
remark
</sql>
<select id="findById" parameterType="Long" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"></include>
from sys_user
<where>
id = #{id}
</where>
</select>
<select id="findList_dpConditionPlaceholder" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"></include>
from sys_user
<where>
{DATA_PERMISSION_CONDITION}
<if test="status != null and status != ''">
and status = #{status}
</if>
<if test="sex != null and sex != ''">
and sex = #{sex}
</if>
</where>
</select>
</mapper>
集成配置如下:
spring:
# Sql初始化配置
sql:
init:
# 导入h2 table定义
schema-locations: classpath:h2/schema.sql
# 导入h2 数据定义
data-locations: classpath:h2/data.sql
# 数据库配置
datasource:
type: com.zaxxer.hikari.HikariDataSource
# ============================================================
# ============= 使用H2内存数据库 ================================
# ============================================================
driver-class-name: org.h2.Driver
# 使用h2内存数据(以mysql兼容模式运行)
url: jdbc:h2:mem:demo-db;MODE=MySQL;DATABASE_TO_LOWER=TRUE
username: root
password: 123456
# 日志级别配置
logging:
level:
root: info
com.luo.d3s: debug
# mybatis-plus配置
mybatis-plus:
mapper-locations: classpath*:mapper/*.xml
type-aliases-package: com.luo.d3s.ext.dp.sample.entity
global-config:
banner: false
db-config:
# 主键类型 - 自增
id-type: AUTO
configuration:
# Mybatis日志实现类 - 使用SLF4J
# 具体实现可参见包:mybatis.jar/org.apache.ibatis.logging
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
call-setters-on-nulls: true
d3s:
data-permission:
# 没有数据权限时不抛异常,拼接false条件
throw-exception-when-no-data-permission: false
具体单元测试代码如下:
/**
* 数据权限测试
*
* @author luohq
* @date 2023-06-23 19:48
*/
@Slf4j
@SpringBootTest
@ContextConfiguration(classes = {TestConfig.class})
@TestMethodOrder(MethodOrderer.MethodName.class)
public class SyUserMapperTest {
@Resource
SysUserMapper sysUserMapper;
@Resource
private DataPermissionProps dpProps;
public static final Long USER_ID_WITH_DP_USER = 2L;
public static final Long USER_ID_WITHOUT_DP_USER = 1L;
@BeforeEach
void initDpUserContext() {
this.mockUser();
}
private void mockUser() {
Collection<BaseDpDto> dpCollection = Arrays.asList(
DpPropDto.ofType(DpTypeEnum.USER),
DpPropDto.ofType(DpTypeEnum.DEPT_AND_CHILD),
DpPropDto.ofTypeAndManageIds(DpTypeEnum.DEPT_CUSTOM, Arrays.asList(100L, 101L, 105L), null)
);
//子部门未设置,则默认为{0}
UserPropDto user = UserPropDto.ofUserDeptAndDataPermissions(USER_ID_WITH_DP_USER, 105L, dpCollection);
//设置数据权限用户上下文
DpUserContextHolder.setContext(user);
}
private void mockUser_withDP_ADMIN() {
Collection<BaseDpDto> dpCollection = Arrays.asList(
DpPropDto.ofType(DpTypeEnum.ADMIN)
);
//子部门未设置,则默认为{0}
UserPropDto user = UserPropDto.ofUserDeptAndDataPermissions(USER_ID_WITH_DP_USER, 105L, dpCollection);
//设置数据权限用户上下文
DpUserContextHolder.setContext(user);
}
private void mockUser_withoutAnyDP() {
Collection<BaseDpDto> dpCollection = Collections.emptySet();
//子部门未设置,则默认为{0}
UserPropDto user = UserPropDto.ofUserDeptAndDataPermissions(USER_ID_WITH_DP_USER, 105L, dpCollection);
//设置数据权限用户上下文
DpUserContextHolder.setContext(user);
}
private void mockUser_withDP_DEPT_AND_CHILD() {
Collection<BaseDpDto> dpCollection = Arrays.asList(
DpPropDto.ofType(DpTypeEnum.USER),
DpPropDto.ofType(DpTypeEnum.DEPT_AND_CHILD),
DpPropDto.ofTypeAndManageIds(DpTypeEnum.DEPT_CUSTOM, Arrays.asList(100L, 101L, 105L), null)
);
//设置用户子部门
Collection<Long> deptAndChildDeptIds = Arrays.asList(101L, 102L, 103L, 104L, 105L, 106L, 107L);
UserPropDto user = UserPropDto.ofAll(USER_ID_WITH_DP_USER, 105L, deptAndChildDeptIds, dpCollection);
//设置数据权限用户上下文
DpUserContextHolder.setContext(user);
}
void mockUser_withSupplier() {
Collection<BaseDpDto> dpCollection = Arrays.asList(
DpPropDto.ofType(DpTypeEnum.USER),
DpPropDto.ofType(DpTypeEnum.DEPT_AND_CHILD),
DpPropDto.ofTypeAndManageIds(DpTypeEnum.DEPT_CUSTOM, Arrays.asList(100L, 101L, 105L), null)
);
UserPropDto user = UserPropDto.ofUserDeptAndDataPermissions(USER_ID_WITH_DP_USER, 105L, dpCollection);
UserSupplierDto userSupplier = UserSupplierDto.fromProp(user);
userSupplier.setDeptAndChildDeptIdsSupplier(() -> {
String userId = userSupplier.getUserId();
return Arrays.asList(101L, 102L, 103L, 104L, 105L, 106L, 107L);
});
//设置数据权限用户上下文
DpUserContextHolder.setContext(user);
}
private void mockUser_withOperations_SELECT() {
Collection<BaseDpDto> dpCollection = Arrays.asList(
DpPropDto.ofTypeAndOperations(DpTypeEnum.USER, Arrays.asList(DpOpEnum.SELECT)),
DpPropDto.ofTypeAndOperations(DpTypeEnum.DEPT_AND_CHILD, Arrays.asList(DpOpEnum.SELECT)),
DpPropDto.ofAll(DpTypeEnum.DEPT_CUSTOM, Arrays.asList(DpOpEnum.SELECT), Arrays.asList(100L, 101L, 105L), null)
);
//子部门未设置,则默认为{0}
UserPropDto user = UserPropDto.ofUserDeptAndDataPermissions(USER_ID_WITH_DP_USER, 105L, dpCollection);
//设置数据权限用户上下文
DpUserContextHolder.setContext(user);
}
/**
* 无数据权限时,是否抛出异常
*
* @return 是否抛出异常
*/
boolean throwExceptionWhenNoDataPermission() {
return this.dpProps.getThrowExceptionWhenNoDataPermission();
}
@RepeatedTest(2)
void testSysUserMapper0100_findById_withDP_USER() {
//仅允许查询自己的用户信息
SysUser sysUser = this.sysUserMapper.findById(USER_ID_WITH_DP_USER);
log.info("[WITH DP USER] sysUserMapper.findById: {}", sysUser);
Assertions.assertNotNull(sysUser);
}
@RepeatedTest(2)
void testSysUserMapper0110_findById_withoutDP_USER() {
//仅允许查询自己的用户信息
SysUser sysUser = this.sysUserMapper.findById(USER_ID_WITHOUT_DP_USER);
log.info("[WITHOUT DP USER] sysUserMapper.findById: {}", sysUser);
Assertions.assertNull(sysUser);
}
@RepeatedTest(2)
void testSysUserMapper0200_selectById_withDP_USER() {
//仅允许查询自己的用户信息
SysUser sysUser = this.sysUserMapper.selectById(USER_ID_WITH_DP_USER);
log.info("[WITH DP USER] sysUserMapper.selectById: {}", sysUser);
Assertions.assertNotNull(sysUser);
}
@RepeatedTest(2)
void testSysUserMapper0210_selectById_withoutDP_USER() {
//不允许查询别人的用户信息
SysUser sysUser = this.sysUserMapper.selectById(USER_ID_WITHOUT_DP_USER);
log.info("[WITHOUT DP USER] sysUserMapper.selectById: {}", sysUser);
Assertions.assertNull(sysUser);
}
@Test
@DisabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0220_selectById_withoutAnyDP() {
this.mockUser_withoutAnyDP();
//不允许查询任何的用户信息
SysUser sysUser = this.sysUserMapper.selectById(USER_ID_WITH_DP_USER);
log.info("[WITHOUT ANY DP] sysUserMapper.selectById: {}", sysUser);
Assertions.assertNull(sysUser);
}
@Test
@EnabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0220_selectById_withoutAnyDP_throwEx() {
this.mockUser_withoutAnyDP();
//不允许查询任何的用户信息
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
//不允许查询任何的用户信息
SysUser sysUser = this.sysUserMapper.selectById(USER_ID_WITH_DP_USER);
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITHOUT ANY DP] sysUserMapper.selectById: {}", dataPermissionException.getMessage(), dataPermissionException);
}
@Test
void testSysUserMapper0300_updateById_withDP_USER() {
//仅允许更新自己的用户信息
SysUser sysUser = new SysUser();
sysUser.setId(USER_ID_WITH_DP_USER);
sysUser.setUserName("user_haha");
Integer retCount = this.sysUserMapper.updateById(sysUser);
log.info("[WITH DP USER] sysUserMapper.updateById result: {}", retCount);
Assertions.assertEquals(1, retCount);
}
@Test
@DisabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0305_updateById_withOP_SELECT() {
this.mockUser_withOperations_SELECT();
//不允许更新操作
SysUser sysUser = new SysUser();
sysUser.setId(USER_ID_WITH_DP_USER);
sysUser.setUserName("user_haha");
Integer retCount = this.sysUserMapper.updateById(sysUser);
log.info("[WITH OP SELECT] sysUserMapper.updateById result: {}", retCount);
Assertions.assertEquals(0, retCount);
}
@Test
@EnabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0305_updateById_withOP_SELECT_throwEx() {
this.mockUser_withOperations_SELECT();
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
//不允许更新操作
SysUser sysUser = new SysUser();
sysUser.setId(USER_ID_WITH_DP_USER);
sysUser.setUserName("user_haha");
Integer retCount = this.sysUserMapper.updateById(sysUser);
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITH OP SELECT] sysUserMapper.updateById: {}", dataPermissionException.getMessage(), dataPermissionException);
}
@Test
void testSysUserMapper0310_updateById_withoutDP_USER() {
//不允许更新别人的用户信息
SysUser sysUser = new SysUser();
sysUser.setId(USER_ID_WITHOUT_DP_USER);
sysUser.setUserName("user_haha");
Integer retCount = this.sysUserMapper.updateById(sysUser);
log.info("[WITHOUT DP USER] sysUserMapper.updateById result: {}", retCount);
Assertions.assertEquals(0, retCount);
}
@Disabled
@Test
void testSysUserMapper0400_deleteById_withDP_USER() {
//仅允许删除自己的用户信息
Integer retCount = this.sysUserMapper.deleteById(USER_ID_WITH_DP_USER);
log.info("[WITH DP USER] sysUserMapper.deleteById result: {}", retCount);
Assertions.assertEquals(1, retCount);
}
@Test
void testSysUserMapper0410_deleteById_withoutDP_USER() {
//不允许删除其他用户的用户信息
Integer retCount = this.sysUserMapper.deleteById(USER_ID_WITHOUT_DP_USER);
log.info("[WITHOUT DP USER] sysUserMapper.deleteById result: {}", retCount);
Assertions.assertEquals(0, retCount);
}
@Test
@DisabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0415_deleteById_withoutOP_SELECT() {
this.mockUser_withOperations_SELECT();
//不允许删除操作
Integer retCount = this.sysUserMapper.deleteById(USER_ID_WITHOUT_DP_USER);
log.info("[WITH OP SELECT] sysUserMapper.deleteById result: {}", retCount);
Assertions.assertEquals(0, retCount);
}
@Test
@EnabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0415_deleteById_withoutOP_SELECT_throwEx() {
this.mockUser_withOperations_SELECT();
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
//不允许删除操作
Integer retCount = this.sysUserMapper.deleteById(USER_ID_WITHOUT_DP_USER);
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITH OP SELECT] sysUserMapper.deleteById: {}", dataPermissionException.getMessage(), dataPermissionException);
}
@Test
void testSysUserMapper0500_deleteBatchIds_withoutDP_USER() {
//不允许批量删除其他用户的用户信息
Integer retCount = this.sysUserMapper.deleteBatchIds(Arrays.asList(USER_ID_WITHOUT_DP_USER));
log.info("[WITHOUT DP USER] sysUserMapper.deleteBatchIds result: {}", retCount);
Assertions.assertEquals(0, retCount);
}
@Test
@DisabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0525_deleteBatchIds_withoutAnyDP() {
this.mockUser_withoutAnyDP();
//不允许批量删除任何用户的用户信息
Integer retCount = this.sysUserMapper.deleteBatchIds(Arrays.asList(USER_ID_WITHOUT_DP_USER));
log.info("[WITHOUT ANY DP] sysUserMapper.deleteBatchIds result: {}", retCount);
Assertions.assertEquals(0, retCount);
}
@Test
@EnabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0525_deleteBatchIds_withoutAnyDP_throwEx() {
this.mockUser_withoutAnyDP();
//没有数据权限
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
//不允许批量删除任何用户的用户信息
Integer retCount = this.sysUserMapper.deleteBatchIds(Arrays.asList(USER_ID_WITHOUT_DP_USER));
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITHOUT ANY DP] sysUserMapper.deleteBatchIds: {}", dataPermissionException.getMessage(), dataPermissionException);
}
@Test
void testSysUserMapper0600_selectPage_withDP_USER() {
//仅允许查询自己的用户信息
Page pageParam = Page.of(1, 10);
OrderItem orderItem = OrderItem.desc("create_time");
pageParam.addOrder(orderItem);
Page pageResult = this.sysUserMapper.selectPage(pageParam, Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getStatus, 0));
log.info("[WITH DP USER] sysUserMapper.selectPage: total={}, pages={}, records={}", pageResult.getTotal(), pageResult.getPages(), pageResult.getRecords());
Assertions.assertEquals(1, pageResult.getTotal());
Assertions.assertEquals(1, pageResult.getRecords().size());
}
@Test
@DisabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0610_selectPage_withoutAnyDP() {
this.mockUser_withoutAnyDP();
//不允许查询任何的用户信息
Page pageParam = Page.of(1, 10);
OrderItem orderItem = OrderItem.desc("create_time");
pageParam.addOrder(orderItem);
Page pageResult = this.sysUserMapper.selectPage(pageParam, Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getStatus, 0));
log.info("[WITHOUT ANY DP] sysUserMapper.selectPage: total={}, pages={}, records={}", pageResult.getTotal(), pageResult.getPages(), pageResult.getRecords());
Assertions.assertEquals(0, pageResult.getTotal());
Assertions.assertEquals(0, pageResult.getRecords().size());
}
@Test
@EnabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0610_selectPage_withoutAnyDP_throwEx() {
this.mockUser_withoutAnyDP();
//没有数据权限
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
//不允许查询任何的用户信息
Page pageParam = Page.of(1, 10);
OrderItem orderItem = OrderItem.desc("create_time");
pageParam.addOrder(orderItem);
Page pageResult = this.sysUserMapper.selectPage(pageParam, Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getStatus, 0));
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITHOUT ANY DP] sysUserMapper.selectPage: {}", dataPermissionException.getMessage(), dataPermissionException);
}
@Test
void testSysUserMapper0620_selectPage_withDP_ADMIN() {
//重置用户为超级管理员
this.mockUser_withDP_ADMIN();
//允许查询全部的用户信息
Page pageParam = Page.of(1, 10);
OrderItem orderItem = OrderItem.desc("create_time");
pageParam.addOrder(orderItem);
Page pageResult = this.sysUserMapper.selectPage(pageParam, Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getStatus, 0));
log.info("[WITH DP ADMIN] sysUserMapper.selectPage: total={}, pages={}, records={}", pageResult.getTotal(), pageResult.getPages(), pageResult.getRecords());
Assertions.assertEquals(13, pageResult.getTotal());
Assertions.assertEquals(10, pageResult.getRecords().size());
}
@Test
void testSysUserMapper0630_selectPage_withDP_USER__DEPT_AND_CHILD() {
//重置用户,该用户设置了部门及子部门
this.mockUser_withDP_DEPT_AND_CHILD();
//仅允许查询自己及子部门的用户信息
Page pageParam = Page.of(1, 10);
OrderItem orderItem = OrderItem.desc("create_time");
pageParam.addOrder(orderItem);
Page pageResult = this.sysUserMapper.selectPage(pageParam, Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getStatus, 0));
log.info("[WITH DP USER, DEPT_AND_CHILD] sysUserMapper.selectPage: total={}, pages={}, records={}", pageResult.getTotal(), pageResult.getPages(), pageResult.getRecords());
Assertions.assertEquals(2, pageResult.getTotal());
Assertions.assertEquals(2, pageResult.getRecords().size());
}
@Test
void testSysUserMapper0700_findPageWithPageHelper_withDP_USER() {
//仅允许查询自己的用户信息
PageInfo<SysUser> pageInfo = this.sysUserMapper.findPageWithPageHelper(1, 10);
log.info("[WITH DP USER] sysUserMapper.findPageWithPageHelper: total={}, pages={}, list={}", pageInfo.getTotal(), pageInfo.getPages(), pageInfo.getList());
Assertions.assertEquals(1, pageInfo.getTotal());
Assertions.assertEquals(1, pageInfo.getList().size());
}
@Test
@DisabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0710_findPageWithPageHelper_withoutAndyDP() {
this.mockUser_withoutAnyDP();
//不允许查询任何的用户信息
PageInfo<SysUser> pageInfo = this.sysUserMapper.findPageWithPageHelper(1, 10);
log.info("[WITHOUT ANY DP] sysUserMapper.findPageWithPageHelper: total={}, pages={}, list={}", pageInfo.getTotal(), pageInfo.getPages(), pageInfo.getList());
Assertions.assertEquals(0, pageInfo.getTotal());
Assertions.assertEquals(0, pageInfo.getList().size());
}
@Test
@EnabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0710_findPageWithPageHelper_withoutAndyDP_throwEx() {
this.mockUser_withoutAnyDP();
//没有数据权限
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
//不允许查询任何的用户信息
PageInfo<SysUser> pageInfo = this.sysUserMapper.findPageWithPageHelper(1, 10);
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITHOUT ANY DP] sysUserMapper.findPageWithPageHelper: {}", dataPermissionException.getMessage(), dataPermissionException);
}
@Test
void testSysUserMapper0720_findPageWithPageHelper_withDP_ADMIN() {
//重置用户为超级管理员
this.mockUser_withDP_ADMIN();
//允许查询全部的用户信息
PageInfo<SysUser> pageInfo = this.sysUserMapper.findPageWithPageHelper(1, 10);
log.info("[WITH DP ADMIN] sysUserMapper.findPageWithPageHelper: total={}, pages={}, list={}", pageInfo.getTotal(), pageInfo.getPages(), pageInfo.getList());
Assertions.assertEquals(13, pageInfo.getTotal());
Assertions.assertEquals(10, pageInfo.getList().size());
}
@Test
void testSysUserMapper0730_findPageWithPageHelper_withDP_USER__DEPT_AND_CHILD() {
//重置用户,该用户设置了部门及子部门
this.mockUser_withDP_DEPT_AND_CHILD();
//仅允许查询自己及子部门的用户信息
PageInfo<SysUser> pageInfo = this.sysUserMapper.findPageWithPageHelper(1, 10);
log.info("[WITH DP USER, DEPT_AND_CHILD] sysUserMapper.findPageWithPageHelper: total={}, pages={}, list={}", pageInfo.getTotal(), pageInfo.getPages(), pageInfo.getList());
Assertions.assertEquals(2, pageInfo.getTotal());
Assertions.assertEquals(2, pageInfo.getList().size());
}
@Test
void testSysUserMapper0800_findPageWithPageHelper_dynamicSql_withDP_USER() {
//仅允许查询自己的用户信息
String userName = "user";
//开启分页
PageHelper.startPage(1, 10);
List<SysUser> userList = this.sysUserMapper.findPageWithPageHelper_dynamicSql(userName);
PageInfo<SysUser> pageInfo = new PageInfo<>(userList);
log.info("[WITH DP USER] sysUserMapper.findPageWithPageHelper_dynamicSql: total={}, pages={}, list={}", pageInfo.getTotal(), pageInfo.getPages(), pageInfo.getList());
Assertions.assertEquals(1, pageInfo.getTotal());
Assertions.assertEquals(1, pageInfo.getList().size());
}
@Test
@DisabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0810_findPageWithPageHelper_dynamicSql_withoutAnyDP() {
this.mockUser_withoutAnyDP();
//仅允许查询自己的用户信息
String userName = "user";
//开启分页
PageHelper.startPage(1, 10);
List<SysUser> userList = this.sysUserMapper.findPageWithPageHelper_dynamicSql(userName);
PageInfo<SysUser> pageInfo = new PageInfo<>(userList);
log.info("[WITHOUT ANY DP] sysUserMapper.findPageWithPageHelper_dynamicSql: total={}, pages={}, list={}", pageInfo.getTotal(), pageInfo.getPages(), pageInfo.getList());
Assertions.assertEquals(0, pageInfo.getTotal());
Assertions.assertEquals(0, pageInfo.getList().size());
}
@Test
@EnabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0810_findPageWithPageHelper_dynamicSql_withoutAnyDP_throwEx() {
this.mockUser_withoutAnyDP();
//没有数据权限
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
//仅允许查询自己的用户信息
String userName = "user";
//开启分页
PageHelper.startPage(1, 10);
List<SysUser> userList = this.sysUserMapper.findPageWithPageHelper_dynamicSql(userName);
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITHOUT ANY DP] sysUserMapper.findPageWithPageHelper_dynamicSql: {}", dataPermissionException.getMessage(), dataPermissionException);
}
@Test
void testSysUserMapper0820_findPageWithPageHelper_dynamicSql_withDP_ADMIN() {
//重置用户为超级管理员
this.mockUser_withDP_ADMIN();
//仅允许查询自己的用户信息
String userName = "user";
//开启分页
PageHelper.startPage(1, 10);
List<SysUser> userList = this.sysUserMapper.findPageWithPageHelper_dynamicSql(userName);
PageInfo<SysUser> pageInfo = new PageInfo<>(userList);
log.info("[WITH DP ADMIN] sysUserMapper.findPageWithPageHelper_dynamicSql: total={}, pages={}, list={}", pageInfo.getTotal(), pageInfo.getPages(), pageInfo.getList());
Assertions.assertEquals(12, pageInfo.getTotal());
Assertions.assertEquals(10, pageInfo.getList().size());
}
@Test
void testSysUserMapper0830_findPageWithPageHelper_dynamicSql_withDP_USER__DEPT_AND_CHILD() {
//重置用户,该用户设置了部门及子部门
this.mockUser_withDP_DEPT_AND_CHILD();
//仅允许查询自己的用户信息
String userName = "user";
//开启分页
PageHelper.startPage(1, 10);
List<SysUser> userList = this.sysUserMapper.findPageWithPageHelper_dynamicSql(userName);
PageInfo<SysUser> pageInfo = new PageInfo<>(userList);
log.info("[WITH DP USER, DEPT_AND_CHILD] sysUserMapper.findPageWithPageHelper_dynamicSql: total={}, pages={}, list={}", pageInfo.getTotal(), pageInfo.getPages(), pageInfo.getList());
Assertions.assertEquals(1, pageInfo.getTotal());
Assertions.assertEquals(1, pageInfo.getList().size());
}
@Test
void testSysUserMapper0900_findList_dpConditionPlaceholder_withDP_USER() {
//仅允许查询自己的用户信息
List<SysUser> sysUsers = this.sysUserMapper.findList_dpConditionPlaceholder("0", "1");
log.info("[WITH DP USER] sysUserMapper.findList_dpConditionPlaceholder: total={},sysUsers={}", sysUsers.size(), sysUsers);
Assertions.assertEquals(1, sysUsers.size());
Assertions.assertEquals(USER_ID_WITH_DP_USER, sysUsers.get(0).getId());
}
@Test
void testSysUserMapper0905_findList_dpConditionPlaceholder_withoutDP_USER() {
//当前用户和性别参数不匹配,故查询结果为空
List<SysUser> sysUsers = this.sysUserMapper.findList_dpConditionPlaceholder("0", "0");
log.info("[WITH DP USER] sysUserMapper.findList_dpConditionPlaceholder: total={},sysUsers={}", sysUsers.size(), sysUsers);
Assertions.assertEquals(0, sysUsers.size());
}
@Test
@DisabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0910_findList_dpConditionPlaceholder_withoutAnyDP() {
this.mockUser_withoutAnyDP();
//不允许查询任何的用户信息
List<SysUser> sysUsers = this.sysUserMapper.findList_dpConditionPlaceholder("0", "1");
log.info("[WITHOUT ANY DP] sysUserMapper.findList_dpConditionPlaceholder: total={},sysUsers={}", sysUsers.size(), sysUsers);
Assertions.assertEquals(0, sysUsers.size());
}
@Test
@EnabledIf("throwExceptionWhenNoDataPermission")
void testSysUserMapper0910_findList_dpConditionPlaceholder_withoutAnyDP_throwEx() {
this.mockUser_withoutAnyDP();
//没有数据权限
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
//不允许查询任何的用户信息
List<SysUser> sysUsers = this.sysUserMapper.findList_dpConditionPlaceholder("0", "1");
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITHOUT ANY DP] sysUserMapper.findList_dpConditionPlaceholder: {}", dataPermissionException.getMessage(), dataPermissionException);
}
@ParameterizedTest
@CsvSource(value = {
"1,7",
"0,6"
})
void testSysUserMapper0920_findList_dpConditionPlaceholder_withDP_ADMIN(String sex, Integer userCount) {
this.mockUser_withDP_ADMIN();
//许查询任何的用户信息
List<SysUser> sysUsers = this.sysUserMapper.findList_dpConditionPlaceholder("0", sex);
log.info("[WITH DP ADMIN] sysUserMapper.findList_dpConditionPlaceholder: total={},sysUsers={}", sysUsers.size(), sysUsers);
Assertions.assertEquals(userCount, sysUsers.size());
}
@Test
void testSysUserMapper1000_insert_throwEx() {
this.mockUser_withOperations_SELECT();
MyBatisSystemException myBatisSystemException = Assertions.assertThrows(
MyBatisSystemException.class,
() -> {
SysUser sysUser = new SysUser();
sysUser.setUserName("haha");
sysUser.setNickName("haha");
sysUser.setSex("1");
sysUser.setPhonenumber("123456789");
sysUser.setEmail("luo@email.com");
int retCount = this.sysUserMapper.insert(sysUser);
});
DataPermissionException dataPermissionException = this.extractDpException(myBatisSystemException);
log.error("[WITHOUT OP INSERT] sysUserMapper.insert: {}", dataPermissionException.getMessage(), dataPermissionException);
}
/**
* 从MyBatisSystemException提取DataPermissionException
*
* @param myBatisSystemException Mybatis系统异常
* @return 数据权限异常
*/
private DataPermissionException extractDpException(MyBatisSystemException myBatisSystemException) {
return Optional.ofNullable(myBatisSystemException)
//org.apache.ibatis.exceptions.PersistenceException
.map(MyBatisSystemException::getCause)
//DataPermissionException
.map(Throwable::getCause)
.filter(ex -> ex instanceof DataPermissionException)
.map(DataPermissionException.class::cast)
.orElse(null);
}
}
注:
完整的数据权限插件单元测试可参见:
https://gitee.com/luoex/d3s/tree/master/d3s-extend/d3s-data-permission-mybatis/src/test
五、关于D3S
D3S(DDD with SpringBoot)为本作者使用DDD过程中开发的框架,目前已可公开查看源码,目前笔者正在持续完善该框架,争取打造一个可落地的DDD框架。而本文所讲的d3s-data-permission-mybatis
为D3S中的一个扩展组件,此组件亦可独立使用。
D3S源码地址:https://gitee.com/luoex/d3s