diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysModule.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysModule.java index e2e812cd80c1dc40f60fdfdebe5ea5f9d526addd..866cf13dcd6a9a6b3d9a387faa2f00d208c766b2 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysModule.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysModule.java @@ -7,6 +7,7 @@ import com.ruoyi.common.core.domain.BaseEntity; * 功能模块-角色-数据权限关联表 sys_module */ public class SysModule extends BaseEntity { + private static final long serialVersionUID = 1L; /** 主键id */ @@ -32,23 +33,56 @@ public class SysModule extends BaseEntity { private Integer level; /** 删除标志 */ private String delFlag; - // getter/setter - public Long getModuleId() { return moduleId; } - public void setModuleId(Long moduleId) { this.moduleId = moduleId; } - public String getModuleName() { return moduleName; } - public void setModuleName(String moduleName) { this.moduleName = moduleName; } - public String getTableName() { return tableName; } - public void setTableName(String tableName) { this.tableName = tableName; } - public Long getRoleId() { return roleId; } - public void setRoleId(Long roleId) { this.roleId = roleId; } - public Integer getDataScope() { return dataScope; } - public void setDataScope(Integer dataScope) { this.dataScope = dataScope; } - public String getScopeExpr() { return scopeExpr; } - public void setScopeExpr(String scopeExpr) { this.scopeExpr = scopeExpr; } - public Integer getLevel() { return level; } - public void setLevel(Integer level) { this.level = level; } - public String getDelFlag() { return delFlag; } - public void setDelFlag(String delFlag) { this.delFlag = delFlag; } + + public Long getModuleId() { + return moduleId; + } + public void setModuleId(Long moduleId) { + this.moduleId = moduleId; + } + public String getModuleName() { + return moduleName; + } + public void setModuleName(String moduleName) { + this.moduleName = moduleName; + } + public String getTableName() { + return tableName; + } + public void setTableName(String tableName) { + this.tableName = tableName; + } + public Long getRoleId() { + return roleId; + } + public void setRoleId(Long roleId) { + this.roleId = roleId; + } + public Integer getDataScope() { + return dataScope; + } + public void setDataScope(Integer dataScope) { + this.dataScope = dataScope; + } + public String getScopeExpr() { + return scopeExpr; + } + public void setScopeExpr(String scopeExpr) { + this.scopeExpr = scopeExpr; + } + public Integer getLevel() { + return level; + } + public void setLevel(Integer level) { + this.level = level; + } + public String getDelFlag() { + return delFlag; + } + public void setDelFlag(String delFlag) { + this.delFlag = delFlag; + } + @Override public String toString() { return "SysModule{" + diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/enums/DataScopeType.java b/ruoyi-common/src/main/java/com/ruoyi/common/enums/DataScopeType.java new file mode 100644 index 0000000000000000000000000000000000000000..8d69a825a6a1c4a2b8c813d27116536ebf24d739 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/enums/DataScopeType.java @@ -0,0 +1,59 @@ +package com.ruoyi.common.enums; + +import com.ruoyi.common.core.domain.entity.SysUser; +import lombok.Getter; + +/** + * 数据范围类型 + * code 与库表 data_scope 对应;label 用于审计展示; + * 提供基于用户与表别名构建过滤SQL的能力。 + */ +@Getter +public enum DataScopeType { + ALL(1, "全部"), + DEPT_AND_CHILD(2, "本部门及下级"), + DEPT_ONLY(3, "本部门"), + SELF_ONLY(4, "仅本人"), + CUSTOM(5, "自定义"); + + private final int code; + private final String label; + + DataScopeType(int code, String label) { + this.code = code; + this.label = label; + } + + public static DataScopeType fromCode(Integer code) { + if (code == null) return null; + for (DataScopeType t : values()) { + if (t.code == code) return t; + } + return null; + } + + /** + * 基于当前类型构建过滤SQL(返回可直接拼到 WHERE 的条件段) + */ + public String buildFilterSql(String tableAlias, SysUser user, String scopeExpr) { + if (this == ALL) { + return null; + } + if (tableAlias == null || tableAlias.isEmpty() || user == null) { + return null; + } + switch (this) { + case DEPT_AND_CHILD: + return tableAlias + ".dept_id IN (SELECT dept_id FROM sys_dept WHERE dept_id = " + + user.getDeptId() + " OR FIND_IN_SET(" + user.getDeptId() + ", ancestors))"; + case DEPT_ONLY: + return tableAlias + ".dept_id = " + user.getDeptId(); + case SELF_ONLY: + return tableAlias + ".user_id = " + user.getUserId(); + case CUSTOM: + return scopeExpr; // 上层保证 data_scope=5 时提供有效的表达式 + default: + return null; + } + } +} \ No newline at end of file diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java index d572fe2047831bb8f3b993aa47690f23ee82cc2a..41bccd921e39d48d8b2304bdfbcf1e9891158ad5 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/MyBatisConfig.java @@ -1,15 +1,16 @@ package com.ruoyi.framework.config; import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.extension.MybatisMapWrapperFactory; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.*; +import com.ruoyi.framework.interceptor.DataPermissionInterceptor; import org.apache.ibatis.session.SqlSessionFactory; import org.apache.ibatis.type.JdbcType; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; @@ -40,6 +41,9 @@ public class MyBatisConfig @Autowired private Environment env; + @Autowired + private ApplicationContext applicationContext; + static final String DEFAULT_RESOURCE_PATTERN = "**/*.class"; public static String setTypeAliasesPackage(String typeAliasesPackage) @@ -127,7 +131,7 @@ public class MyBatisConfig // } @Bean("mybatisSqlSession") - public SqlSessionFactory sqlSessionFactory(DataSource dataSource/*, GlobalConfig globalConfig*/) throws Exception { + public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean sqlSessionFactory = new MybatisSqlSessionFactoryBean(); /* 数据源 */ sqlSessionFactory.setDataSource(dataSource); @@ -145,10 +149,11 @@ public class MyBatisConfig configuration.setMapUnderscoreToCamelCase(true); MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); -// mybatisPlusInterceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); + // 插件方式注册数据权限拦截器 + DataPermissionInterceptor dataPermissionInterceptor = new DataPermissionInterceptor(); + dataPermissionInterceptor.setApplicationContext(applicationContext); + configuration.addInterceptor(dataPermissionInterceptor); sqlSessionFactory.setPlugins(mybatisPlusInterceptor); - /* map 下划线转驼峰 */ -// configuration.setObjectWrapperFactory(new MybatisMapWrapperFactory()); sqlSessionFactory.setConfiguration(configuration); return sqlSessionFactory.getObject(); } diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/interceptor/DataPermissionInterceptor.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/interceptor/DataPermissionInterceptor.java new file mode 100644 index 0000000000000000000000000000000000000000..7bc348c8c79b5a4ada8e49f321d1a37101a714d3 --- /dev/null +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/interceptor/DataPermissionInterceptor.java @@ -0,0 +1,249 @@ +package com.ruoyi.framework.interceptor; + +import com.ruoyi.common.core.domain.entity.SysModule; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.system.service.ISysModuleService; +import com.ruoyi.common.utils.SecurityUtils; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +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.springframework.context.ApplicationContext; +import java.util.*; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.ruoyi.common.core.redis.RedisCache; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import com.ruoyi.common.enums.DataScopeType; + +/** + * MyBatis全局数据权限拦截器 + */ +@Intercepts({ + @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) +}) +public class DataPermissionInterceptor implements Interceptor { + + private static final Logger logger = LoggerFactory.getLogger(DataPermissionInterceptor.class); + + private ApplicationContext applicationContext; + + // Redis Key前缀:data_perm:{deptId}:{yyyyMMdd} + private static final String REDIS_DEPT_DAILY_ACCESS_PREFIX = "data_perm:"; + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.BASIC_ISO_DATE; // yyyyMMdd + + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public Object intercept(Invocation invocation) throws Throwable { + Object[] args = invocation.getArgs(); + MappedStatement ms = (MappedStatement) args[0]; + Object parameter = args[1]; + BoundSql boundSql = ms.getBoundSql(parameter); + String originalSql = boundSql.getSql(); + + // 1. 解析SQL,提取表名 + String tableName = parseTableNameFromSql(originalSql); + if (tableName == null) { + // 打印警告日志 + logger.warn("数据权限拦截器未能解析表名,SQL已放行:{}", originalSql); + return invocation.proceed(); + } + + // 2. 获取当前用户及角色 + SysUser currentUser = SecurityUtils.getLoginUser().getUser(); + if (currentUser == null || currentUser.isAdmin()) { + // 超级管理员不过滤 + return invocation.proceed(); + } + List roleIds = currentUser.getRoles().stream().map(r -> r.getRoleId()).collect(Collectors.toList()); + + // 3. 查找功能模块 + ISysModuleService moduleService = applicationContext.getBean(ISysModuleService.class); + List roleModules = moduleService.getModuleByTableNameAndRoleIds(tableName, roleIds); + SysModule defaultModule = moduleService.getModuleByTableName(tableName); + + // 4. 合并权限,优先级高的优先 + SysModule effectiveModule = null; + if (roleModules != null && !roleModules.isEmpty()) { + // 按level降序DESC + effectiveModule = roleModules.get(0); + } else { + effectiveModule = defaultModule; + } + if (effectiveModule == null) { + // 未配置模块不处理 + return invocation.proceed(); + } + + // 5. 生成过滤条件 + String filterSql = buildFilterSql(effectiveModule, currentUser); + if (filterSql == null || filterSql.trim().isEmpty()) { + logger.warn("数据权限为全部,SQL已放行:{}", originalSql); + return invocation.proceed(); + } + + // 6. 注入过滤条件到SQL + String newSql = injectFilterToSql(originalSql, filterSql); + BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), newSql, boundSql.getParameterMappings(), boundSql.getParameterObject()); + MappedStatement newMs = copyFromMappedStatement(ms, new BoundSqlSqlSource(newBoundSql)); + args[0] = newMs; + + // 执行SQL并在成功后记录部门-日期访问计数 + Object result = invocation.proceed(); + try { + incrementDeptDailyAccess(currentUser.getDeptId()); + } catch (Exception e) { + // 计数失败不影响主流程 + logger.warn("记录部门日访问计数失败,deptId={}", currentUser.getDeptId(), e); + } + + // 记录审计日志(查询操作) + try { + Integer ds = effectiveModule.getDataScope(); + DataScopeType dsType = DataScopeType.fromCode(ds); + String scopeLabel = dsType != null ? dsType.getLabel() : "未知"; + String scopeExpr = (dsType == DataScopeType.CUSTOM) ? String.valueOf(effectiveModule.getScopeExpr()) : ""; + String day = LocalDate.now().format(DAY_FMT); + // 按日志要求:谁、什么时候、访问了什么表、什么部门的数据范围、操作类型 + logger.info( + "数据权限审计 | 日期={} 用户ID={} 用户名={} 角色={} | 操作={} | 表={} | 部门范围={} | 过滤条件={} 自定义表达式={}", + day, + currentUser.getUserId(), + currentUser.getUserName(), + effectiveModule.getRoleId().toString(), + "SELECT", + tableName, + scopeLabel, + filterSql, + scopeExpr + ); + } catch (Exception logEx) { + logger.warn("审计日志记录失败", logEx); + } + + return result; + } + + @Override + public Object plugin(Object target) { + return Plugin.wrap(target, this); + } + + @Override + public void setProperties(Properties properties) {} + + // 解析SQL表名(单表) + private String parseTableNameFromSql(String sql) { + try { + Statement statement = CCJSqlParserUtil.parse(sql); + if (statement instanceof Select) { + Select select = (Select) statement; + PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); + Table table = (Table) plainSelect.getFromItem(); + return table.getName(); + } + } catch (JSQLParserException | ClassCastException e) { + logger.error("解析SQL表名失败", e); + } + return null; + } + + // 生成过滤条件 + private String buildFilterSql(SysModule module, SysUser user) { + // dataScope: 1-全部 2-本部门及下级 3-本部门 4-仅本人 5-自定义 + Integer dataScope = module.getDataScope(); + String tableAlias = module.getTableName(); + if (dataScope == null) return null; + DataScopeType scopeType = DataScopeType.fromCode(dataScope); + if (scopeType == null) { + return null; + } + return scopeType.buildFilterSql(tableAlias, user, module.getScopeExpr()); + } + + // 注入过滤条件到SQL + private String injectFilterToSql(String sql, String filterSql) { + try { + Statement statement = CCJSqlParserUtil.parse(sql); + if (statement instanceof Select) { + Select select = (Select) statement; + PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); + Expression where = plainSelect.getWhere(); + Expression filter = CCJSqlParserUtil.parseCondExpression(filterSql); + if (where != null) { + plainSelect.setWhere(new AndExpression(where, filter)); + } else { + plainSelect.setWhere(filter); + } + return select.toString(); + } + } catch (JSQLParserException e) { + logger.error("数据权限注入失败", e); + } + return sql; + } + + // 复制MappedStatement + private MappedStatement copyFromMappedStatement(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(); + } + + // 包装BoundSql为SqlSource + public static class BoundSqlSqlSource implements SqlSource { + private BoundSql boundSql; + public BoundSqlSqlSource(BoundSql boundSql) { + this.boundSql = boundSql; + } + @Override + public BoundSql getBoundSql(Object parameterObject) { + return boundSql; + } + } + + // 使用Redis记录"部门-日期(天)"访问次数 + private void incrementDeptDailyAccess(Long deptId) { + if (deptId == null || applicationContext == null) { + return; + } + RedisCache redisCache = applicationContext.getBean(RedisCache.class); + String day = LocalDate.now().format(DAY_FMT); + String key = REDIS_DEPT_DAILY_ACCESS_PREFIX + deptId + ":" + day; + // 自增计数 + Long newVal = redisCache.redisTemplate.opsForValue().increment(key); + // 如果是首次创建(值为1),设置过期时间(60天),避免key长期累积 + if (newVal != null && newVal == 1L) { + redisCache.expire(key, 60L, java.util.concurrent.TimeUnit.DAYS); + } + } +} \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysModuleMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysModuleMapper.java index 0da38e044a737406859e8ad9b94e72fefd1b824f..e902252b0554373353b8db34194071bf10e92618 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysModuleMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysModuleMapper.java @@ -1,9 +1,20 @@ package com.ruoyi.system.mapper; +import com.ruoyi.common.core.domain.entity.SysModule; +import org.apache.ibatis.annotations.Param; +import java.util.List; /** * 功能模块-角色-数据权限关联表 数据层 */ public interface SysModuleMapper { + /** + * 根据表名查询功能模块(默认权限) + */ + SysModule selectModuleByTableName(@Param("tableName") String tableName); + /** + * 根据表名和角色ID查询功能模块权限配置 + */ + List selectModuleByTableNameAndRoleIds(@Param("tableName") String tableName, @Param("roleIds") List roleIds); } \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysModuleService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysModuleService.java index 6deee57909eb9fef14dd6e956496c269d3449ec7..04ef27c34a499d4039e85739fb26484d4a8c9d72 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysModuleService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysModuleService.java @@ -7,5 +7,13 @@ import java.util.List; * 功能模块-角色-数据权限关联表 服务层 */ public interface ISysModuleService { + /** + * 根据表名查询功能模块(默认权限) + */ + SysModule getModuleByTableName(String tableName); + /** + * 根据表名和角色ID查询功能模块权限配置 + */ + List getModuleByTableNameAndRoleIds(String tableName, List roleIds); } \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysModuleServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysModuleServiceImpl.java index 8fcc5e619612b7350d5eca0e55cbccdf61e34476..e5121f159780390a41c691a5db82c4349956a68e 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysModuleServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysModuleServiceImpl.java @@ -1,13 +1,24 @@ package com.ruoyi.system.service.impl; +import com.ruoyi.common.core.domain.entity.SysModule; import com.ruoyi.system.mapper.SysModuleMapper; import com.ruoyi.system.service.ISysModuleService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import java.util.List; @Service public class SysModuleServiceImpl implements ISysModuleService { @Autowired private SysModuleMapper sysModuleMapper; + @Override + public SysModule getModuleByTableName(String tableName) { + return sysModuleMapper.selectModuleByTableName(tableName); + } + + @Override + public List getModuleByTableNameAndRoleIds(String tableName, List roleIds) { + return sysModuleMapper.selectModuleByTableNameAndRoleIds(tableName, roleIds); + } } \ No newline at end of file diff --git a/ruoyi-system/src/main/resources/mapper/system/SysModuleMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysModuleMapper.xml new file mode 100644 index 0000000000000000000000000000000000000000..74f849c6c74f7555fc1768021ab83a095a8bb042 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/system/SysModuleMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + \ No newline at end of file