diff --git a/sql/mysql/youlai_admin.sql b/sql/mysql/youlai_admin.sql index 8c4de406..b143fecc 100644 --- a/sql/mysql/youlai_admin.sql +++ b/sql/mysql/youlai_admin.sql @@ -254,7 +254,7 @@ CREATE TABLE `sys_role` ( `code` varchar(32) NOT NULL COMMENT '角色编码', `sort` int NULL COMMENT '显示顺序', `status` tinyint(1) DEFAULT 1 COMMENT '角色状态(1-正常 0-停用)', - `data_scope` tinyint NULL COMMENT '数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据)', + `data_scope` tinyint NULL COMMENT '数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)', `create_by` bigint NULL COMMENT '创建人 ID', `create_time` datetime NULL COMMENT '创建时间', `update_by` bigint NULL COMMENT '更新人ID', @@ -291,6 +291,16 @@ CREATE TABLE `sys_role_menu` ( UNIQUE INDEX `uk_roleid_menuid`(`role_id` ASC, `menu_id` ASC) USING BTREE COMMENT '角色菜单唯一索引' ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色菜单关联表'; +-- ---------------------------- +-- Table structure for sys_role_dept +-- ---------------------------- +DROP TABLE IF EXISTS `sys_role_dept`; +CREATE TABLE `sys_role_dept` ( + `role_id` bigint NOT NULL COMMENT '角色ID', + `dept_id` bigint NOT NULL COMMENT '部门ID', + UNIQUE INDEX `uk_roleid_deptid`(`role_id` ASC, `dept_id` ASC) USING BTREE COMMENT '角色部门唯一索引' +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色部门关联表(用于自定义数据权限)'; + -- ============================================ -- 系统管理员角色菜单权限(role_id=2) -- 顶级目录 diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java index c0a84a94..8fbaa3be 100644 --- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java @@ -26,18 +26,15 @@ public interface JwtClaimConstants { String DEPT_ID = "deptId"; /** - * 数据权限 + * 数据权限列表 + *

+ * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) */ - String DATA_SCOPE = "dataScope"; + String DATA_SCOPES = "dataScopes"; /** * 权限(角色Code)集合 */ String AUTHORITIES = "authorities"; - /** - * 安全版本号,用于按用户失效历史令牌 - */ - String SECURITY_VERSION = "securityVersion"; - } diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java index 195f14af..da943764 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -26,18 +26,19 @@ public interface RedisConstants { * 认证模块 */ interface Auth { - // 存储访问令牌对应的用户信息(accessToken -> OnlineUser) + // 存储访问令牌对应的用户会话信息(accessToken -> UserSession) String ACCESS_TOKEN_USER = "auth:token:access:{}"; - // 存储刷新令牌对应的用户信息(refreshToken -> OnlineUser) + // 存储刷新令牌对应的用户会话信息(refreshToken -> UserSession) String REFRESH_TOKEN_USER = "auth:token:refresh:{}"; // 用户与访问令牌的映射(userId -> accessToken) String USER_ACCESS_TOKEN = "auth:user:access:{}"; // 用户与刷新令牌的映射(userId -> refreshToken String USER_REFRESH_TOKEN = "auth:user:refresh:{}"; - // 黑名单 Token(用于退出登录或注销) + // 已撤销 Token 的 JTI(单端退出/会话注销):如果 jti 在撤销列表中,则 Token 立即无效 String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; - // 用户安全版本号(用于按用户失效历史 JWT) - String USER_SECURITY_VERSION = "auth:user:security_version:{}"; + String REVOKED_JTI = BLACKLIST_TOKEN; + // 用户 Token 生效起点(用于按用户失效历史 JWT):token.iat < tokenValidAfter => token 无效 + String USER_TOKEN_VALID_AFTER = "auth:user:token_valid_after:{}"; } /** diff --git a/src/main/java/com/youlai/boot/common/enums/DataScopeEnum.java b/src/main/java/com/youlai/boot/common/enums/DataScopeEnum.java index 0dbb1316..b8471d20 100644 --- a/src/main/java/com/youlai/boot/common/enums/DataScopeEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/DataScopeEnum.java @@ -5,6 +5,10 @@ import lombok.Getter; /** * 数据权限枚举 + *

+ * value 越小,数据权限范围越大。 + * 多角色数据权限合并策略:取并集(OR),即用户能看到所有角色权限范围内的数据。 + * 如果任一角色是 ALL,则直接跳过数据权限过滤。 * * @author Ray.Hao * @since 2.3.0 @@ -13,12 +17,31 @@ import lombok.Getter; public enum DataScopeEnum implements IBaseEnum { /** - * value 越小,数据权限范围越大 + * 所有数据权限 - 最高权限,可查看所有数据 */ ALL(1, "所有数据"), + + /** + * 部门及子部门数据 - 可查看本部门及其下属所有部门的数据 + */ DEPT_AND_SUB(2, "部门及子部门数据"), + + /** + * 本部门数据 - 仅可查看本部门的数据 + */ DEPT(3, "本部门数据"), - SELF(4, "本人数据"); + + /** + * 本人数据 - 仅可查看自己的数据 + */ + SELF(4, "本人数据"), + + /** + * 自定义部门数据 - 可查看指定部门的数据 + *

+ * 需要配合 sys_role_dept 表使用,存储角色可访问的部门ID列表 + */ + CUSTOM(5, "自定义部门数据"); private final Integer value; @@ -28,4 +51,32 @@ public enum DataScopeEnum implements IBaseEnum { this.value = value; this.label = label; } + + /** + * 判断是否为全部数据权限 + * + * @param value 数据权限值 + * @return 是否为全部数据权限 + */ + public static boolean isAll(Integer value) { + return ALL.getValue().equals(value); + } + + /** + * 根据值获取枚举 + * + * @param value 数据权限值 + * @return 枚举对象,未找到则返回 null + */ + public static DataScopeEnum getByValue(Integer value) { + if (value == null) { + return null; + } + for (DataScopeEnum dataScope : values()) { + if (dataScope.getValue().equals(value)) { + return dataScope; + } + } + return null; + } } diff --git a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java index 7f42cf14..5e4b3725 100644 --- a/src/main/java/com/youlai/boot/config/property/SecurityProperties.java +++ b/src/main/java/com/youlai/boot/config/property/SecurityProperties.java @@ -3,6 +3,7 @@ package com.youlai.boot.config.property; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -53,7 +54,8 @@ public class SecurityProperties { *

  • redis-token - 基于Redis的有状态认证
  • * */ - @NotNull + @NotNull(message = "会话类型不能为空") + @Pattern(regexp = "jwt|redis-token", message = "会话类型只能是 jwt 或 redis-token") private String type; /** diff --git a/src/main/java/com/youlai/boot/platform/websocket/dto/DictChangeEvent.java b/src/main/java/com/youlai/boot/platform/websocket/dto/DictChangeEvent.java index 4df538de..1835694f 100644 --- a/src/main/java/com/youlai/boot/platform/websocket/dto/DictChangeEvent.java +++ b/src/main/java/com/youlai/boot/platform/websocket/dto/DictChangeEvent.java @@ -1,13 +1,40 @@ package com.youlai.boot.platform.websocket.dto; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; +import java.io.Serial; +import java.io.Serializable; + +/** + * 字典变更事件 + *

    + * 当字典数据发生变更时,通过 WebSocket 广播此事件通知前端清除缓存。 + * 前端收到通知后清除对应字典的本地缓存,下次使用时重新从服务端加载。 + * + * @author Ray.Hao + * @since 3.0.0 + */ @Data -public class DictChangeEvent { +@NoArgsConstructor +@AllArgsConstructor +public class DictChangeEvent implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + /** 字典编码 */ private String dictCode; + + /** 事件时间戳 */ private long timestamp; + /** + * 创建字典变更事件(自动设置当前时间戳) + * + * @param dictCode 字典编码 + */ public DictChangeEvent(String dictCode) { this.dictCode = dictCode; this.timestamp = System.currentTimeMillis(); diff --git a/src/main/java/com/youlai/boot/platform/websocket/dto/OnlineUserDTO.java b/src/main/java/com/youlai/boot/platform/websocket/dto/OnlineUserDTO.java new file mode 100644 index 00000000..eaac9ea6 --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/websocket/dto/OnlineUserDTO.java @@ -0,0 +1,34 @@ +package com.youlai.boot.platform.websocket.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 在线用户信息DTO + *

    + * 用于返回在线用户的基本信息,包括用户名、会话数量和登录时间。 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OnlineUserDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 用户名 */ + private String username; + + /** 会话数量(多设备登录时大于1) */ + private int sessionCount; + + /** 最早登录时间 */ + private long loginTime; +} diff --git a/src/main/java/com/youlai/boot/platform/websocket/job/OnlineUserCountJob.java b/src/main/java/com/youlai/boot/platform/websocket/job/OnlineUserCountJob.java new file mode 100644 index 00000000..308ea81b --- /dev/null +++ b/src/main/java/com/youlai/boot/platform/websocket/job/OnlineUserCountJob.java @@ -0,0 +1,47 @@ +package com.youlai.boot.platform.websocket.job; + +import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher; +import com.youlai.boot.platform.websocket.session.UserSessionRegistry; +import com.youlai.boot.platform.websocket.topic.WebSocketTopics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 在线用户数统计定时任务 + *

    + * 定时统计并广播当前在线用户数量到所有WebSocket客户端。 + * 用于解决以下问题: + *

    + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Component +@Slf4j +@RequiredArgsConstructor +public class OnlineUserCountJob { + + private final UserSessionRegistry userSessionRegistry; + private final WebSocketPublisher webSocketPublisher; + + /** + * 定时统计在线用户数并广播 + *

    + * 每3分钟执行一次,推送当前在线用户数量 + */ + @Scheduled(cron = "0 */3 * * * ?") + public void execute() { + int onlineCount = userSessionRegistry.getOnlineUserCount(); + int sessionCount = userSessionRegistry.getTotalSessionCount(); + + log.debug("定时统计:在线用户数={}, 总会话数={}", onlineCount, sessionCount); + + // 广播在线用户数量 + webSocketPublisher.publish(WebSocketTopics.TOPIC_ONLINE_COUNT, onlineCount); + } +} diff --git a/src/main/java/com/youlai/boot/platform/websocket/service/impl/WebSocketServiceImpl.java b/src/main/java/com/youlai/boot/platform/websocket/service/impl/WebSocketServiceImpl.java index 46744240..76bf5ce8 100644 --- a/src/main/java/com/youlai/boot/platform/websocket/service/impl/WebSocketServiceImpl.java +++ b/src/main/java/com/youlai/boot/platform/websocket/service/impl/WebSocketServiceImpl.java @@ -1,6 +1,7 @@ package com.youlai.boot.platform.websocket.service.impl; import com.youlai.boot.platform.websocket.dto.DictChangeEvent; +import com.youlai.boot.platform.websocket.dto.OnlineUserDTO; import com.youlai.boot.platform.websocket.dto.TextMessage; import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher; import com.youlai.boot.platform.websocket.session.UserSessionRegistry; @@ -101,7 +102,7 @@ public class WebSocketServiceImpl implements WebSocketService { * * @return 在线用户信息列表 */ - public List getOnlineUsers() { + public List getOnlineUsers() { return userSessionRegistry.getOnlineUsers(); } diff --git a/src/main/java/com/youlai/boot/platform/websocket/session/UserSessionRegistry.java b/src/main/java/com/youlai/boot/platform/websocket/session/UserSessionRegistry.java index 658bbb5b..1377e9b5 100644 --- a/src/main/java/com/youlai/boot/platform/websocket/session/UserSessionRegistry.java +++ b/src/main/java/com/youlai/boot/platform/websocket/session/UserSessionRegistry.java @@ -1,8 +1,9 @@ package com.youlai.boot.platform.websocket.session; +import com.youlai.boot.platform.websocket.dto.OnlineUserDTO; import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.List; @@ -11,25 +12,74 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; +/** + * WebSocket 用户会话注册表 + *

    + * 维护WebSocket连接的用户会话信息,支持多设备同时登录。 + * 采用双Map结构实现高效查询: + *

    + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Slf4j @Component public class UserSessionRegistry { + /** + * 用户会话映射表 + *

    + * Key: 用户名 + * Value: 该用户所有WebSocket会话ID集合(支持多设备登录) + */ private final Map> userSessionsMap = new ConcurrentHashMap<>(); + + /** + * 会话详情映射表 + *

    + * Key: WebSocket会话ID + * Value: 会话详情(包含用户名、连接时间等) + */ private final Map sessionDetailsMap = new ConcurrentHashMap<>(); + /** + * 用户上线(建立WebSocket连接) + * + * @param username 用户名 + * @param sessionId WebSocket会话ID + */ public void userConnected(String username, String sessionId) { userSessionsMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()).add(sessionId); sessionDetailsMap.put(sessionId, new SessionInfo(username, sessionId, System.currentTimeMillis())); + log.debug("用户[{}]会话[{}]已注册", username, sessionId); } + /** + * 用户下线(断开所有WebSocket连接) + *

    + * 移除该用户的所有会话信息 + * + * @param username 用户名 + */ public void userDisconnected(String username) { Set sessions = userSessionsMap.remove(username); if (sessions == null) { return; } sessions.forEach(sessionDetailsMap::remove); + log.debug("用户[{}]已下线,移除{}个会话", username, sessions.size()); } + /** + * 移除指定会话(单设备下线) + *

    + * 当用户某一设备断开连接时调用,保留其他设备的会话 + * + * @param sessionId WebSocket会话ID + */ public void removeSession(String sessionId) { SessionInfo sessionInfo = sessionDetailsMap.remove(sessionId); if (sessionInfo == null) { @@ -44,33 +94,63 @@ public class UserSessionRegistry { sessions.remove(sessionId); if (sessions.isEmpty()) { + // 该用户没有任何会话了,移除用户记录 userSessionsMap.remove(username); + log.debug("用户[{}]最后一个会话已移除", username); } } + /** + * 获取在线用户数量 + * + * @return 当前在线用户数(非会话数) + */ public int getOnlineUserCount() { return userSessionsMap.size(); } + /** + * 获取指定用户的会话数量 + * + * @param username 用户名 + * @return 该用户的WebSocket会话数量(多设备登录时大于1) + */ public int getUserSessionCount(String username) { Set sessions = userSessionsMap.get(username); return sessions != null ? sessions.size() : 0; } + /** + * 获取在线会话总数 + * + * @return 所有WebSocket会话的总数(包含多设备) + */ public int getTotalSessionCount() { return sessionDetailsMap.size(); } + /** + * 检查用户是否在线 + * + * @param username 用户名 + * @return 是否在线(至少有一个活跃会话) + */ public boolean isUserOnline(String username) { Set sessions = userSessionsMap.get(username); return sessions != null && !sessions.isEmpty(); } + /** + * 获取所有在线用户列表 + * + * @return 在线用户信息列表 + */ public List getOnlineUsers() { return userSessionsMap.entrySet().stream() .map(entry -> { String username = entry.getKey(); Set sessions = entry.getValue(); + // 取最早的连接时间作为登录时间 long earliestLoginTime = sessions.stream() .map(sessionDetailsMap::get) .filter(info -> info != null) @@ -83,21 +163,17 @@ public class UserSessionRegistry { .collect(Collectors.toList()); } + /** + * WebSocket 会话详情(内部使用) + */ @Data @AllArgsConstructor - @NoArgsConstructor private static class SessionInfo { + /** 用户名 */ private String username; + /** WebSocket会话ID */ private String sessionId; + /** 连接时间戳 */ private long connectTime; } - - @Data - @AllArgsConstructor - @NoArgsConstructor - public static class OnlineUserDTO { - private String username; - private int sessionCount; - private long loginTime; - } } diff --git a/src/main/java/com/youlai/boot/plugin/mybatis/MyDataPermissionHandler.java b/src/main/java/com/youlai/boot/plugin/mybatis/MyDataPermissionHandler.java index f1d14579..53550a50 100644 --- a/src/main/java/com/youlai/boot/plugin/mybatis/MyDataPermissionHandler.java +++ b/src/main/java/com/youlai/boot/plugin/mybatis/MyDataPermissionHandler.java @@ -1,22 +1,38 @@ package com.youlai.boot.plugin.mybatis; +import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.toolkit.StringPool; import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler; import com.youlai.boot.common.annotation.DataPermission; -import com.youlai.boot.common.base.IBaseEnum; import com.youlai.boot.common.enums.DataScopeEnum; +import com.youlai.boot.security.model.RoleDataScope; import com.youlai.boot.security.util.SecurityUtils; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; -import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.*; import net.sf.jsqlparser.expression.operators.conditional.AndExpression; -import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.expression.operators.conditional.OrExpression; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.expression.operators.relational.InExpression; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.statement.select.SubSelect; import java.lang.reflect.Method; +import java.util.List; /** * 数据权限控制器 + *

    + * 支持多角色数据权限合并(并集策略): + * - 如果任一角色是 ALL,则跳过数据权限过滤 + * - 否则用 OR 连接各角色的数据权限条件 + *

    + * 使用 JSQLParser 构建 SQL 条件,避免字符串拼接,提高代码安全性和可读性。 * * @author zc * @since 2021-12-10 13:28 @@ -24,9 +40,14 @@ import java.lang.reflect.Method; @Slf4j public class MyDataPermissionHandler implements DataPermissionHandler { + private static final String DEPT_TABLE = "sys_dept"; + private static final String DEPT_ID_COLUMN = "id"; + private static final String DEPT_TREE_PATH_COLUMN = "tree_path"; + /** * 获取数据权限的sql片段 - * @param where 查询条件 + * + * @param where 查询条件 * @param mappedStatementId mapper接口方法的全路径 * @return sql片段 */ @@ -34,16 +55,25 @@ public class MyDataPermissionHandler implements DataPermissionHandler { @SneakyThrows public Expression getSqlSegment(Expression where, String mappedStatementId) { // 如果是未登录,或者是定时任务执行的SQL,或者是超级管理员,直接返回 - if(SecurityUtils.getUserId() == null || SecurityUtils.isRoot()){ + if (SecurityUtils.getUserId() == null || SecurityUtils.isRoot()) { return where; } - // 获取当前用户的数据权限 - Integer dataScope = SecurityUtils.getDataScope(); - DataScopeEnum dataScopeEnum = IBaseEnum.getEnumByValue(dataScope, DataScopeEnum.class); - // 如果是全部数据权限,直接返回 - if (DataScopeEnum.ALL.equals(dataScopeEnum)) { + + // 获取当前用户的数据权限列表 + List dataScopes = SecurityUtils.getUser() + .map(user -> user.getDataScopes()) + .orElse(List.of()); + + // 如果任一角色是 ALL,则跳过数据权限过滤(并集策略) + if (hasAllDataScope(dataScopes)) { return where; } + + // 如果没有数据权限,跳过过滤 + if (CollectionUtil.isEmpty(dataScopes)) { + return where; + } + // 获取当前执行的接口类 Class clazz = Class.forName(mappedStatementId.substring(0, mappedStatementId.lastIndexOf(StringPool.DOT))); // 获取当前执行的方法名称 @@ -51,65 +81,207 @@ public class MyDataPermissionHandler implements DataPermissionHandler { // 获取当前执行的接口类里所有的方法 Method[] methods = clazz.getDeclaredMethods(); for (Method method : methods) { - //找到当前执行的方法 + // 找到当前执行的方法 if (method.getName().equals(methodName)) { DataPermission annotation = method.getAnnotation(DataPermission.class); // 判断当前执行的方法是否有权限注解,如果没有注解直接返回 - if (annotation == null ) { + if (annotation == null) { return where; } - return dataScopeFilter(annotation.deptAlias(), annotation.deptIdColumnName(), annotation.userAlias(), annotation.userIdColumnName(), dataScopeEnum,where); + // 使用并集策略过滤 + return dataScopeFilterWithUnion(annotation, dataScopes, where); } } return where; } /** - * 构建过滤条件 + * 判断是否包含"全部数据"权限 * - * @param where 当前查询条件 - * @return 构建后查询条件 + * @param dataScopes 数据权限列表 + * @return 是否有全部数据权限 */ - @SneakyThrows - public static Expression dataScopeFilter(String deptAlias, String deptIdColumnName, String userAlias, String userIdColumnName,DataScopeEnum dataScopeEnum, Expression where) { + private boolean hasAllDataScope(List dataScopes) { + if (CollectionUtil.isEmpty(dataScopes)) { + return false; + } + return dataScopes.stream() + .anyMatch(scope -> DataScopeEnum.ALL.getValue().equals(scope.getDataScope())); + } - // 获取部门和用户的别名 - String deptColumnName = StrUtil.isNotBlank(deptAlias) ? (deptAlias + StringPool.DOT + deptIdColumnName) : deptIdColumnName; - String userColumnName = StrUtil.isNotBlank(userAlias) ? (userAlias + StringPool.DOT + userIdColumnName) : userIdColumnName; + /** + * 使用并集策略进行数据权限过滤 + *

    + * 多个角色的数据权限通过 OR 连接,实现并集效果 + * + * @param annotation 数据权限注解 + * @param dataScopes 数据权限列表 + * @param where 原始查询条件 + * @return 追加权限过滤后的查询条件 + */ + private Expression dataScopeFilterWithUnion(DataPermission annotation, List dataScopes, Expression where) { + String deptAlias = annotation.deptAlias(); + String deptIdColumnName = annotation.deptIdColumnName(); + String userAlias = annotation.userAlias(); + String userIdColumnName = annotation.userIdColumnName(); - Long deptId, userId; - String appendSqlStr; - switch (dataScopeEnum) { - case ALL: - return where; - case DEPT: - deptId = SecurityUtils.getDeptId(); - appendSqlStr = deptColumnName + StringPool.EQUALS + deptId; - break; - case SELF: - userId = SecurityUtils.getUserId(); - appendSqlStr = userColumnName + StringPool.EQUALS + userId; - break; - // 默认部门及子部门数据权限 - default: - deptId = SecurityUtils.getDeptId(); - appendSqlStr = deptColumnName + " IN ( SELECT id FROM sys_dept WHERE id = " + deptId + " OR FIND_IN_SET( " + deptId + " , tree_path ) )"; - break; + // 构建各角色的数据权限条件,使用 OR 连接实现并集 + Expression unionExpression = null; + for (RoleDataScope dataScope : dataScopes) { + Expression roleExpression = buildRoleDataScopeExpression( + deptAlias, deptIdColumnName, userAlias, userIdColumnName, dataScope); + if (roleExpression != null) { + if (unionExpression == null) { + unionExpression = roleExpression; + } else { + // 使用 OR 连接各角色的条件(并集) + unionExpression = new OrExpression(unionExpression, roleExpression); + } + } } - if (StrUtil.isBlank(appendSqlStr)) { + if (unionExpression == null) { return where; } - Expression appendExpression = CCJSqlParserUtil.parseCondExpression(appendSqlStr); + // 用括号包裹并集条件 + Expression finalExpression = new Parenthesis(unionExpression); if (where == null) { - return appendExpression; + return finalExpression; } - return new AndExpression(where, appendExpression); + return new AndExpression(where, finalExpression); } + /** + * 构建单个角色的数据权限SQL条件 + *

    + * 使用 JSQLParser 构建 Expression,避免字符串拼接 + * + * @param deptAlias 部门表别名 + * @param deptIdColumnName 部门ID字段名 + * @param userAlias 用户表别名 + * @param userIdColumnName 用户ID字段名 + * @param roleDataScope 角色数据权限 + * @return 数据权限条件表达式 + */ + private Expression buildRoleDataScopeExpression(String deptAlias, String deptIdColumnName, + String userAlias, String userIdColumnName, + RoleDataScope roleDataScope) { + Column deptColumn = buildColumn(deptAlias, deptIdColumnName); + Column userColumn = buildColumn(userAlias, userIdColumnName); + + Long deptId = SecurityUtils.getDeptId(); + Long userId = SecurityUtils.getUserId(); + + DataScopeEnum dataScopeEnum = DataScopeEnum.getByValue(roleDataScope.getDataScope()); + if (dataScopeEnum == null) { + return null; + } + + return switch (dataScopeEnum) { + case ALL -> null; // 全部数据权限,不添加过滤条件 + case DEPT_AND_SUB -> buildDeptAndSubExpression(deptColumn, deptId); + case DEPT -> buildEqualsExpression(deptColumn, deptId); + case SELF -> buildEqualsExpression(userColumn, userId); + case CUSTOM -> buildCustomDeptExpression(deptColumn, roleDataScope.getCustomDeptIds()); + }; + } + + /** + * 构建列引用 + * + * @param alias 表别名 + * @param columnName 列名 + * @return 列引用 + */ + private Column buildColumn(String alias, String columnName) { + if (StrUtil.isNotBlank(alias)) { + return new Column(alias + StringPool.DOT + columnName); + } + return new Column(columnName); + } + + /** + * 构建等于条件 + * + * @param column 列 + * @param value 值 + * @return 等于表达式 + */ + private Expression buildEqualsExpression(Column column, Long value) { + EqualsTo equalsTo = new EqualsTo(); + equalsTo.setLeftExpression(column); + equalsTo.setRightExpression(new LongValue(value)); + return equalsTo; + } + + /** + * 构建部门及子部门数据权限条件 + *

    + * SQL: dept_id IN (SELECT id FROM sys_dept WHERE id = ? OR FIND_IN_SET(?, tree_path)) + * + * @param deptColumn 部门列 + * @param deptId 部门ID + * @return IN 子查询表达式 + */ + private Expression buildDeptAndSubExpression(Column deptColumn, Long deptId) { + // 构建子查询: SELECT id FROM sys_dept WHERE id = ? OR FIND_IN_SET(?, tree_path) + PlainSelect subSelectBody = new PlainSelect(); + subSelectBody.setFromItem(new Table(DEPT_TABLE)); + subSelectBody.addSelectItems(new Column(DEPT_ID_COLUMN)); + + // WHERE id = ? + EqualsTo idEquals = new EqualsTo(); + idEquals.setLeftExpression(new Column(DEPT_ID_COLUMN)); + idEquals.setRightExpression(new LongValue(deptId)); + + // FIND_IN_SET(?, tree_path) + Function findInSet = new Function(); + findInSet.setName("FIND_IN_SET"); + findInSet.setParameters(new ExpressionList<>( + new LongValue(deptId), + new Column(DEPT_TREE_PATH_COLUMN) + )); + + // WHERE id = ? OR FIND_IN_SET(?, tree_path) + OrExpression whereClause = new OrExpression(idEquals, findInSet); + subSelectBody.setWhere(whereClause); + + // 构建子查询 + SubSelect subSelect = new SubSelect(); + subSelect.setSelectBody(subSelectBody); + + // 构建 IN 表达式 + return new InExpression(deptColumn, subSelect); + } + + /** + * 构建自定义部门数据权限条件 + *

    + * SQL: dept_id IN (?, ?, ...) + * + * @param deptColumn 部门列 + * @param customDeptIds 自定义部门ID列表 + * @return IN 表达式,如果没有部门则返回 1=0 + */ + private Expression buildCustomDeptExpression(Column deptColumn, List customDeptIds) { + if (CollectionUtil.isEmpty(customDeptIds)) { + // 没有自定义部门,返回 1=0(无权限) + EqualsTo falseCondition = new EqualsTo(); + falseCondition.setLeftExpression(new LongValue(1)); + falseCondition.setRightExpression(new LongValue(0)); + return falseCondition; + } + + // 构建 IN 表达式列表 + ExpressionList deptIdList = new ExpressionList<>(); + for (Long deptId : customDeptIds) { + deptIdList.addExpression(new LongValue(deptId)); + } + + return new InExpression(deptColumn, deptIdList); + } } - diff --git a/src/main/java/com/youlai/boot/security/model/RoleDataScope.java b/src/main/java/com/youlai/boot/security/model/RoleDataScope.java new file mode 100644 index 00000000..d00babaf --- /dev/null +++ b/src/main/java/com/youlai/boot/security/model/RoleDataScope.java @@ -0,0 +1,76 @@ +package com.youlai.boot.security.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +/** + * 角色数据权限信息 + *

    + * 用于存储单个角色的数据权限范围信息,支持多角色数据权限合并(并集策略) + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RoleDataScope implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 角色编码 + */ + private String roleCode; + + /** + * 数据权限范围值 + * 1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据 + */ + private Integer dataScope; + + /** + * 自定义部门ID列表(仅当 dataScope=5 时有效) + */ + private List customDeptIds; + + /** + * 创建"全部数据"权限 + */ + public static RoleDataScope all(String roleCode) { + return new RoleDataScope(roleCode, 1, null); + } + + /** + * 创建"部门及子部门"权限 + */ + public static RoleDataScope deptAndSub(String roleCode) { + return new RoleDataScope(roleCode, 2, null); + } + + /** + * 创建"本部门"权限 + */ + public static RoleDataScope dept(String roleCode) { + return new RoleDataScope(roleCode, 3, null); + } + + /** + * 创建"本人"权限 + */ + public static RoleDataScope self(String roleCode) { + return new RoleDataScope(roleCode, 4, null); + } + + /** + * 创建"自定义部门"权限 + */ + public static RoleDataScope custom(String roleCode, List deptIds) { + return new RoleDataScope(roleCode, 5, deptIds); + } + +} diff --git a/src/main/java/com/youlai/boot/security/model/SmsAuthenticationToken.java b/src/main/java/com/youlai/boot/security/model/SmsAuthenticationToken.java index 2e575cf5..f9a3c66f 100644 --- a/src/main/java/com/youlai/boot/security/model/SmsAuthenticationToken.java +++ b/src/main/java/com/youlai/boot/security/model/SmsAuthenticationToken.java @@ -8,59 +8,71 @@ import java.util.Collection; /** * 短信验证码认证 Token + *

    + * 用于短信验证码登录场景,遵循 Spring Security 认证模型: + *

    * * @author Ray.Hao * @since 2.20.0 */ public class SmsAuthenticationToken extends AbstractAuthenticationToken { + @Serial private static final long serialVersionUID = 621L; /** - * 认证信息 (手机号) + * 认证信息 + * */ private final Object principal; /** - * 凭证信息 (短信验证码) + * 凭证信息 + * */ private final Object credentials; /** - * 短信验证码认证 Token (未认证) + * 创建未认证的 Token * - * @param principal 微信用户信息 + * @param mobile 手机号 + * @param verifyCode 短信验证码 */ - public SmsAuthenticationToken(Object principal, Object credentials) { - // 没有授权信息时,设置为 null - super((Collection) null); - this.principal = principal; - this.credentials = credentials; - // 默认未认证 - this.setAuthenticated(false); + public SmsAuthenticationToken(String mobile, String verifyCode) { + super(null); + this.principal = mobile; + this.credentials = verifyCode; + setAuthenticated(false); } /** - * 短信验证码认证 Token (已认证) + * 创建已认证的 Token * - * @param principal 用户信息 + * @param principal 用户详情(SysUserDetails) * @param authorities 授权信息 */ public SmsAuthenticationToken(Object principal, Collection authorities) { super(authorities); this.principal = principal; this.credentials = null; - // 认证通过 super.setAuthenticated(true); } - /** - * 认证通过 + * 创建已认证的 Token(静态工厂方法) * - * @param principal 用户信息 + * @param principal 用户详情(SysUserDetails) * @param authorities 授权信息 - * @return SmsAuthenticationToken + * @return 已认证的 SmsAuthenticationToken */ public static SmsAuthenticationToken authenticated(Object principal, Collection authorities) { return new SmsAuthenticationToken(principal, authorities); diff --git a/src/main/java/com/youlai/boot/security/model/SysUserDetails.java b/src/main/java/com/youlai/boot/security/model/SysUserDetails.java index ba59ea8c..a532ad04 100644 --- a/src/main/java/com/youlai/boot/security/model/SysUserDetails.java +++ b/src/main/java/com/youlai/boot/security/model/SysUserDetails.java @@ -3,15 +3,13 @@ package com.youlai.boot.security.model; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.ObjectUtil; import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.security.model.UserAuthInfo; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.util.Collection; -import java.util.Collections; +import java.util.*; import java.util.stream.Collectors; /** @@ -53,9 +51,11 @@ public class SysUserDetails implements UserDetails { private Long deptId; /** - * 数据权限范围 + * 数据权限列表 + *

    + * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) */ - private Integer dataScope; + private List dataScopes; /** * 用户角色权限集合 @@ -73,7 +73,7 @@ public class SysUserDetails implements UserDetails { this.password = user.getPassword(); this.enabled = ObjectUtil.equal(user.getStatus(), 1); this.deptId = user.getDeptId(); - this.dataScope = user.getDataScope(); + this.dataScopes = user.getDataScopes(); // 初始化角色权限集合 this.authorities = CollectionUtil.isNotEmpty(user.getRoles()) @@ -104,4 +104,26 @@ public class SysUserDetails implements UserDetails { public boolean isEnabled() { return this.enabled; } + + /** + * 判断是否包含"全部数据"权限 + * + * @return 是否有全部数据权限 + */ + public boolean hasAllDataScope() { + if (CollectionUtil.isEmpty(dataScopes)) { + return false; + } + return dataScopes.stream() + .anyMatch(scope -> scope.getDataScope() == 1); + } + + /** + * 获取数据权限列表 + * + * @return 数据权限列表,永不为null + */ + public List getDataScopes() { + return dataScopes != null ? dataScopes : Collections.emptyList(); + } } diff --git a/src/main/java/com/youlai/boot/security/model/UserAuthInfo.java b/src/main/java/com/youlai/boot/security/model/UserAuthInfo.java index 02cc7002..1532836f 100644 --- a/src/main/java/com/youlai/boot/security/model/UserAuthInfo.java +++ b/src/main/java/com/youlai/boot/security/model/UserAuthInfo.java @@ -2,6 +2,7 @@ package com.youlai.boot.security.model; import lombok.Data; +import java.util.List; import java.util.Set; /** @@ -52,7 +53,9 @@ public class UserAuthInfo { private Set roles; /** - * 数据权限范围 + * 数据权限列表 + *

    + * 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略) */ - private Integer dataScope; + private List dataScopes; } diff --git a/src/main/java/com/youlai/boot/security/model/OnlineUser.java b/src/main/java/com/youlai/boot/security/model/UserSession.java similarity index 60% rename from src/main/java/com/youlai/boot/security/model/OnlineUser.java rename to src/main/java/com/youlai/boot/security/model/UserSession.java index 6dd72608..114b4eae 100644 --- a/src/main/java/com/youlai/boot/security/model/OnlineUser.java +++ b/src/main/java/com/youlai/boot/security/model/UserSession.java @@ -4,10 +4,14 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; import java.util.Set; /** - * 在线用户信息对象 + * 用户会话信息 + *

    + * 存储在Token中的用户会话快照,包含用户身份、数据权限和角色权限信息。 + * 用于Redis-Token模式下的会话管理,支持在线用户查询和会话控制。 * * @author wangtao * @since 2025/2/27 10:31 @@ -15,7 +19,7 @@ import java.util.Set; @Data @NoArgsConstructor @AllArgsConstructor -public class OnlineUser { +public class UserSession { /** * 用户ID @@ -33,10 +37,9 @@ public class OnlineUser { private Long deptId; /** - * 数据权限范围 - *

    定义用户可访问的数据范围,如全部、本部门或自定义范围

    + * 数据权限列表 */ - private Integer dataScope; + private List dataScopes; /** * 角色权限集合 diff --git a/src/main/java/com/youlai/boot/security/provider/SmsAuthenticationProvider.java b/src/main/java/com/youlai/boot/security/provider/SmsAuthenticationProvider.java index 57b5f79b..919a44ac 100644 --- a/src/main/java/com/youlai/boot/security/provider/SmsAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/security/provider/SmsAuthenticationProvider.java @@ -18,9 +18,22 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; /** * 短信验证码认证 Provider + *

    + * 实现 Spring Security 的 {@link AuthenticationProvider} 接口,处理短信验证码登录认证。 + *

    + * 认证流程: + *

      + *
    1. 根据手机号查询用户信息
    2. + *
    3. 校验用户状态(是否禁用)
    4. + *
    5. 校验短信验证码(与 Redis 缓存比对)
    6. + *
    7. 验证成功后删除验证码,防止重复使用
    8. + *
    9. 返回已认证的 Authentication
    10. + *
    * * @author Ray.Hao * @since 2.17.0 + * @see SmsAuthenticationToken + * @see AuthenticationProvider */ @Slf4j public class SmsAuthenticationProvider implements AuthenticationProvider { @@ -29,58 +42,79 @@ public class SmsAuthenticationProvider implements AuthenticationProvider { private final RedisTemplate redisTemplate; - public SmsAuthenticationProvider(UserService userService, RedisTemplate redisTemplate) { this.userService = userService; this.redisTemplate = redisTemplate; } /** - * 短信验证码认证逻辑,参考 Spring Security 认证密码校验流程 + * 执行短信验证码认证 * - * @param authentication 认证对象 - * @return 认证后的 Authentication 对象 - * @throws AuthenticationException 认证异常 - * @see org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate(Authentication) + * @param authentication 未认证的 {@link SmsAuthenticationToken} + * @return 已认证的 {@link SmsAuthenticationToken} + * @throws AuthenticationException 认证失败异常 */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String mobile = (String) authentication.getPrincipal(); String inputVerifyCode = (String) authentication.getCredentials(); + // 参数校验 + if (StrUtil.isBlank(mobile)) { + log.warn("短信验证码登录失败:手机号为空"); + throw new CaptchaValidationException("手机号不能为空"); + } + if (StrUtil.isBlank(inputVerifyCode)) { + log.warn("短信验证码登录失败:验证码为空,手机号={}", mobile); + throw new CaptchaValidationException("验证码不能为空"); + } + // 根据手机号获取用户信息 UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile); if (userAuthInfo == null) { + log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile); throw new UsernameNotFoundException("用户不存在"); } // 检查用户状态是否有效 if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) { + log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername()); throw new DisabledException("用户已被禁用"); } - // 校验发送短信验证码的手机号是否与当前登录用户一致 + // 校验短信验证码 String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile); String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey); - if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) { - throw new CaptchaValidationException("验证码错误"); - } else { - // 验证成功后删除验证码 - redisTemplate.delete(cacheKey); + if (cachedVerifyCode == null) { + log.warn("短信验证码登录失败:验证码已过期,手机号={}", mobile); + throw new CaptchaValidationException("验证码已过期,请重新获取"); } + if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) { + log.warn("短信验证码登录失败:验证码错误,手机号={}", mobile); + throw new CaptchaValidationException("验证码错误"); + } + + // 验证成功后删除验证码,防止重复使用 + redisTemplate.delete(cacheKey); + // 构建认证后的用户详情信息 SysUserDetails userDetails = new SysUserDetails(userAuthInfo); + log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile); + // 创建已认证的 SmsAuthenticationToken - return SmsAuthenticationToken.authenticated( - userDetails, - userDetails.getAuthorities() - ); + return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities()); } + /** + * 支持的认证类型 + * + * @param authentication 认证类型 + * @return 是否支持该认证类型 + */ @Override public boolean supports(Class authentication) { return SmsAuthenticationToken.class.isAssignableFrom(authentication); diff --git a/src/main/java/com/youlai/boot/security/service/PermissionService.java b/src/main/java/com/youlai/boot/security/service/PermissionService.java index 705a38d1..26b90cd4 100644 --- a/src/main/java/com/youlai/boot/security/service/PermissionService.java +++ b/src/main/java/com/youlai/boot/security/service/PermissionService.java @@ -2,18 +2,21 @@ package com.youlai.boot.security.service; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; -import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.security.util.SecurityUtils; +import com.youlai.boot.system.service.RoleMenuService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.PatternMatchUtils; -import java.util.*; +import java.util.Set; /** - * SpringSecurity 权限校验 + * Spring Security 权限校验组件 + *

    + * 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:add')") + *

    + * 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存) * * @author Ray.Hao * @since 0.0.1 @@ -23,19 +26,21 @@ import java.util.*; @Slf4j public class PermissionService { - private final RedisTemplate redisTemplate; + private final RoleMenuService roleMenuService; /** * 判断当前登录用户是否拥有操作权限 + *

    + * 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:add"、"sys:user:delete" 等 * * @param requiredPerm 所需权限 * @return 是否有权限 */ public boolean hasPerm(String requiredPerm) { - if (StrUtil.isBlank(requiredPerm)) { return false; } + // 超级管理员放行 if (SecurityUtils.isRoot()) { return true; @@ -47,52 +52,21 @@ public class PermissionService { return false; } - // 获取当前登录用户的所有角色的权限列表 - Set rolePerms = this.getRolePermsFormCache(roleCodes); + // 获取当前登录用户的所有角色的权限列表(从缓存读取) + Set rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes); if (CollectionUtil.isEmpty(rolePerms)) { return false; } - // 判断当前登录用户的所有角色的权限列表中是否包含所需权限 + + // 判断权限列表中是否包含所需权限(支持通配符) boolean hasPermission = rolePerms.stream() - .anyMatch(rolePerm -> - // 匹配权限,支持通配符(* 等) - PatternMatchUtils.simpleMatch(rolePerm, requiredPerm) - ); + .anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)); if (!hasPermission) { - log.error("用户无操作权限:{}",requiredPerm); + log.warn("用户无操作权限:userId={}, username={}, requiredPerm={}", + SecurityUtils.getUserId(), SecurityUtils.getUsername(), requiredPerm); } return hasPermission; } - - /** - * 从缓存中获取角色权限列表 - * - * @param roleCodes 角色编码集合 - * @return 角色权限列表 - */ - public Set getRolePermsFormCache(Set roleCodes) { - if (CollectionUtil.isEmpty(roleCodes)) { - return Collections.emptySet(); - } - - // 构建缓存Key - String cacheKey = RedisConstants.System.ROLE_PERMS; - - Set perms = new HashSet<>(); - Collection roleCodesAsObjects = new ArrayList<>(roleCodes); - List rolePermsList = redisTemplate.opsForHash().multiGet(cacheKey, roleCodesAsObjects); - - for (Object rolePermsObj : rolePermsList) { - if (rolePermsObj instanceof Set) { - @SuppressWarnings("unchecked") - Set rolePerms = (Set) rolePermsObj; - perms.addAll(rolePerms); - } - } - - return perms; - } - } diff --git a/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java index 5f3f5397..9de12107 100644 --- a/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java +++ b/src/main/java/com/youlai/boot/security/token/JwtTokenManager.java @@ -4,7 +4,9 @@ import cn.hutool.core.convert.Convert; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; import cn.hutool.jwt.JWT; import cn.hutool.jwt.JWTPayload; import cn.hutool.jwt.JWTUtil; @@ -15,6 +17,7 @@ import com.youlai.boot.core.exception.BusinessException; import com.youlai.boot.core.web.ResultCode; import com.youlai.boot.config.property.SecurityProperties; import com.youlai.boot.security.model.AuthenticationToken; +import com.youlai.boot.security.model.RoleDataScope; import org.apache.commons.lang3.StringUtils; import com.youlai.boot.security.model.SysUserDetails; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -25,17 +28,20 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** * JWT Token 管理器 *

    - * 用于生成、解析、校验、刷新 JWT Token + * 实现基于JWT的无状态认证,支持: + *

      + *
    • Access Token + Refresh Token 双令牌机制
    • + *
    • Token 撤销(jti黑名单)
    • + *
    • 用户级会话失效(tokenValidAfter)
    • + *
    • 多角色数据权限存储
    • + *
    * * @author Ray.Hao * @since 2024/11/15 @@ -44,6 +50,9 @@ import java.util.stream.Collectors; @Service public class JwtTokenManager implements TokenManager { + /** tokenValidAfter 默认过期时间(7天),避免Redis内存泄漏 */ + private static final long TOKEN_VALID_AFTER_TTL_SECONDS = 7 * 24 * 60 * 60; + private final SecurityProperties securityProperties; private final RedisTemplate redisTemplate; private final byte[] secretKey; @@ -90,7 +99,25 @@ public class JwtTokenManager implements TokenManager { SysUserDetails userDetails = new SysUserDetails(); userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID - userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围 + + // 解析数据权限列表 + JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES); + if (dataScopesArray != null && !dataScopesArray.isEmpty()) { + List dataScopes = dataScopesArray.stream() + .map(obj -> { + JSONObject item = (JSONObject) obj; + String roleCode = item.getStr("roleCode"); + Integer dataScope = item.getInt("dataScope"); + JSONArray deptIdsArray = item.getJSONArray("customDeptIds"); + List customDeptIds = null; + if (deptIdsArray != null) { + customDeptIds = deptIdsArray.toList(Long.class); + } + return new RoleDataScope(roleCode, dataScope, customDeptIds); + }) + .collect(Collectors.toList()); + userDetails.setDataScopes(dataScopes); + } userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名 // 角色集合 @@ -126,9 +153,17 @@ public class JwtTokenManager implements TokenManager { /** * 校验令牌 + *

    + * 校验流程(按顺序执行): + *

      + *
    1. 签名验证 + 过期时间检查
    2. + *
    3. 刷新令牌类型校验(仅刷新场景)
    4. + *
    5. tokenValidAfter 校验(用户级会话失效)
    6. + *
    7. jti 黑名单校验(单Token撤销)
    8. + *
    * * @param token JWT Token - * @param validateRefreshToken 是否校验刷新令牌 + * @param validateRefreshToken 是否校验刷新令牌类型 * @return 是否有效 */ private boolean validateToken(String token, boolean validateRefreshToken) { @@ -147,27 +182,28 @@ public class JwtTokenManager implements TokenManager { return false; } } - // 2. 校验安全版本号(用于按用户维度失效历史 Token) - // 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,将用户安全版本号 +1,旧版本 Token 全部失效 + // 2. 校验 tokenValidAfter(用于按用户维度失效历史 Token) + // 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,更新 tokenValidAfter,早于该时间签发的 Token 全部失效 Long userId = payloads.getLong(JwtClaimConstants.USER_ID); if (userId != null) { - // 老版本 Token 可能没有 SECURITY_VERSION 声明,视为 0 版本 - Integer tokenVersionRaw = payloads.getInt(JwtClaimConstants.SECURITY_VERSION); - int tokenVersion = tokenVersionRaw != null ? tokenVersionRaw : 0; + Object issuedAtObj = payloads.get(JWTPayload.ISSUED_AT); + long issuedAtSeconds = 0; + if (issuedAtObj instanceof Date issuedAtDate) { + issuedAtSeconds = issuedAtDate.getTime() / 1000; + } - String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId); - Integer currentVersionRaw = (Integer) redisTemplate.opsForValue().get(versionKey); - int currentVersion = currentVersionRaw != null ? currentVersionRaw : 0; + String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId); + Object validAfterObj = redisTemplate.opsForValue().get(validAfterKey); + long validAfterSeconds = validAfterObj != null ? Convert.toLong(validAfterObj) : 0; - // 如果当前版本号比 Token 携带的版本号新,则认为该 Token 已失效 - if (tokenVersion < currentVersion) { + if (issuedAtSeconds < validAfterSeconds) { return false; } } - // 3. 判断是否在黑名单中,如果在,则返回 false 标识Token无效 + // 3. 判断 Token 是否已被撤销(单端退出/会话注销) // 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等 - if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) { + if (isTokenRevoked(jti)) { return false; } } @@ -190,29 +226,63 @@ public class JwtTokenManager implements TokenManager { } JWT jwt = JWTUtil.parseToken(token); JSONObject payloads = jwt.getPayloads(); + String jti = payloads.getStr(JWTPayload.JWT_ID); Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT); - // 黑名单Token Key - String blacklistTokenKey = StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, payloads.getStr(JWTPayload.JWT_ID)); + revokeTokenByJti(jti, expirationAt); + } + /** + * 检查Token是否已被撤销 + * + * @param jti Token唯一标识 + * @return true-已撤销,false-未撤销 + */ + private boolean isTokenRevoked(String jti) { + if (StringUtils.isBlank(jti)) { + return false; + } + return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti))); + } + + /** + * 将Token加入撤销黑名单 + *

    + * 黑名单有效期与Token剩余有效期一致,避免永久存储 + * + * @param jti Token唯一标识 + * @param expirationAt Token过期时间戳 + */ + private void revokeTokenByJti(String jti, Integer expirationAt) { + if (StringUtils.isBlank(jti)) { + return; + } + + String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti); if (expirationAt != null) { int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); if (expirationAt < currentTimeSeconds) { - // Token已过期,直接返回 return; } - // 计算Token剩余时间,将其加入黑名单(值使用简单布尔标记即可) int expirationIn = expirationAt - currentTimeSeconds; - redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); + redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); } else { - // 永不过期的Token永久加入黑名单 - redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE); + redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE); } } /** * 失效指定用户的所有会话 *

    - * 通过提升用户的安全版本号,使携带旧版本号的 Token 在后续校验时全部失效 + * 通过更新用户 tokenValidAfter 时间戳,使早于该时间签发的 Token 全部失效。 + *

    + * 适用场景: + *

      + *
    • 用户修改密码
    • + *
    • 管理员强制下线用户
    • + *
    • 用户主动踢出所有设备
    • + *
    + * + * @param userId 用户ID */ @Override public void invalidateUserSessions(Long userId) { @@ -220,10 +290,10 @@ public class JwtTokenManager implements TokenManager { return; } - String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId); - // 递增版本号 - redisTemplate.opsForValue().increment(versionKey); - + String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId); + long nowSeconds = System.currentTimeMillis() / 1000; + // 设置过期时间,避免Redis内存泄漏 + redisTemplate.opsForValue().set(validAfterKey, nowSeconds, TOKEN_VALID_AFTER_TTL_SECONDS, TimeUnit.SECONDS); } /** @@ -253,8 +323,8 @@ public class JwtTokenManager implements TokenManager { * 生成 JWT Token * * @param authentication 认证信息 - * @param ttl 过期时间 - * @return JWT Token + * @param ttl 过期时间(秒),-1表示永不过期 + * @return JWT Token字符串 */ private String generateToken(Authentication authentication, int ttl) { return generateToken(authentication, ttl, false); @@ -263,18 +333,43 @@ public class JwtTokenManager implements TokenManager { /** * 生成 JWT Token + *

    + * Payload包含: + *

      + *
    • userId - 用户ID
    • + *
    • deptId - 部门ID
    • + *
    • dataScopes - 数据权限列表
    • + *
    • authorities - 角色权限集合
    • + *
    • tokenType - 是否为刷新令牌
    • + *
    • iat/exp - 签发/过期时间
    • + *
    • jti - Token唯一标识(用于撤销)
    • + *
    * - * @param authentication 认证信息 - * @param ttl 过期时间 - * @param isRefreshToken 类型是否为刷新token - * @return JWT Token + * @param authentication 认证信息 + * @param ttl 过期时间(秒) + * @param isRefreshToken 是否为刷新令牌 + * @return JWT Token字符串 */ private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) { SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal(); Map payload = new HashMap<>(); payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID - payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围 + + // 存储数据权限列表 + List dataScopes = userDetails.getDataScopes(); + if (dataScopes != null && !dataScopes.isEmpty()) { + List> scopesList = dataScopes.stream() + .map(scope -> { + Map scopeMap = new HashMap<>(); + scopeMap.put("roleCode", scope.getRoleCode()); + scopeMap.put("dataScope", scope.getDataScope()); + scopeMap.put("customDeptIds", scope.getCustomDeptIds()); + return scopeMap; + }) + .collect(Collectors.toList()); + payload.put(JwtClaimConstants.DATA_SCOPES, scopesList); + } // claims 中添加角色信息 Set roles = authentication.getAuthorities().stream() @@ -289,12 +384,6 @@ public class JwtTokenManager implements TokenManager { payload.put(JwtClaimConstants.TOKEN_TYPE, true); } - // 设置安全版本号:不存在时默认为 0 - String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userDetails.getUserId()); - Integer currentVersion = (Integer) redisTemplate.opsForValue().get(versionKey); - int securityVersion = currentVersion != null ? currentVersion : 0; - payload.put(JwtClaimConstants.SECURITY_VERSION, securityVersion); - // 设置过期时间 -1 表示永不过期 if (ttl != -1) { Date expiresAt = DateUtil.offsetSecond(now, ttl); diff --git a/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java index 9c899156..1d275e60 100644 --- a/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java +++ b/src/main/java/com/youlai/boot/security/token/RedisTokenManager.java @@ -8,7 +8,8 @@ import com.youlai.boot.core.exception.BusinessException; import com.youlai.boot.core.web.ResultCode; import com.youlai.boot.config.property.SecurityProperties; import com.youlai.boot.security.model.AuthenticationToken; -import com.youlai.boot.security.model.OnlineUser; +import com.youlai.boot.security.model.UserSession; +import com.youlai.boot.security.model.RoleDataScope; import com.youlai.boot.security.model.SysUserDetails; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.redis.core.RedisTemplate; @@ -26,7 +27,15 @@ import java.util.stream.Collectors; /** * Redis Token 管理器 *

    - * 用于生成、解析、校验、刷新 Redis Token + * 实现基于Redis的有状态认证,支持: + *

      + *
    • Access Token + Refresh Token 双令牌机制
    • + *
    • 单设备/多设备登录控制
    • + *
    • 用户级会话失效
    • + *
    • 在线用户管理
    • + *
    + *

    + * 与JWT模式相比,Redis模式支持主动踢人、在线用户查询等功能 * * @author Ray.Hao * @since 2024/11/15 @@ -55,19 +64,19 @@ public class RedisTokenManager implements TokenManager { String accessToken = IdUtil.fastSimpleUUID(); String refreshToken = IdUtil.fastSimpleUUID(); - // 构建用户在线信息 - OnlineUser onlineUser = new OnlineUser( + // 构建用户会话信息 + UserSession userSession = new UserSession( user.getUserId(), user.getUsername(), user.getDeptId(), - user.getDataScope(), + user.getDataScopes(), user.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toSet()) ); // 存储访问令牌、刷新令牌和刷新令牌映射 - storeTokensInRedis(accessToken, refreshToken, onlineUser); + storeTokensInRedis(accessToken, refreshToken, userSession); // 单设备登录控制 handleSingleDeviceLogin(user.getUserId(), accessToken); @@ -87,13 +96,13 @@ public class RedisTokenManager implements TokenManager { */ @Override public Authentication parseToken(String token) { - OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token)); - if (onlineUser == null) return null; + UserSession userSession = (UserSession) redisTemplate.opsForValue().get(formatTokenKey(token)); + if (userSession == null) return null; // 构建用户权限集合 Set authorities = null; - Set roles = onlineUser.getRoles(); + Set roles = userSession.getRoles(); if (CollectionUtil.isNotEmpty(roles)) { authorities = roles.stream() .map(SimpleGrantedAuthority::new) @@ -101,7 +110,7 @@ public class RedisTokenManager implements TokenManager { } // 构建用户详情对象 - SysUserDetails userDetails = buildUserDetails(onlineUser, authorities); + SysUserDetails userDetails = buildUserDetails(userSession, authorities); return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); } @@ -135,12 +144,12 @@ public class RedisTokenManager implements TokenManager { */ @Override public AuthenticationToken refreshToken(String refreshToken) { - OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue() + UserSession userSession = (UserSession) redisTemplate.opsForValue() .get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); - if (onlineUser == null) { + if (userSession == null) { throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); } - Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId())); + Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId())); // 删除旧的访问令牌记录 Optional.of(oldAccessTokenValue) .map(String.class::cast) @@ -148,7 +157,7 @@ public class RedisTokenManager implements TokenManager { // 生成新访问令牌并存储 String newAccessToken = IdUtil.fastSimpleUUID(); - storeAccessToken(newAccessToken, onlineUser); + storeAccessToken(newAccessToken, userSession); int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); return AuthenticationToken.builder() @@ -166,14 +175,16 @@ public class RedisTokenManager implements TokenManager { @Override public void invalidateToken(String token) { Object value = redisTemplate.opsForValue().get(formatTokenKey(token)); - if (value instanceof OnlineUser onlineUser) { - Long userId = onlineUser.getUserId(); + if (value instanceof UserSession userSession) { + Long userId = userSession.getUserId(); invalidateUserSessions(userId); } } /** * 使指定用户的所有会话失效 + *

    + * 适用场景:用户修改密码、管理员强制下线、账号封禁等 * * @param userId 用户ID */ @@ -207,24 +218,26 @@ public class RedisTokenManager implements TokenManager { * * @param accessToken 访问令牌 * @param refreshToken 刷新令牌 - * @param onlineUser 在线用户信息 + * @param userSession 用户会话信息 */ - private void storeTokensInRedis(String accessToken, String refreshToken, OnlineUser onlineUser) { + private void storeTokensInRedis(String accessToken, String refreshToken, UserSession userSession) { // 访问令牌 -> 用户信息 - setRedisValue(formatTokenKey(accessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive()); + setRedisValue(formatTokenKey(accessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive()); // 刷新令牌 -> 用户信息 String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); - setRedisValue(refreshTokenKey, onlineUser, securityProperties.getSession().getRefreshTokenTimeToLive()); + setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive()); // 用户ID -> 刷新令牌 - setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, onlineUser.getUserId()), + setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()), refreshToken, securityProperties.getSession().getRefreshTokenTimeToLive()); } /** * 处理单设备登录控制 + *

    + * 当配置不允许多设备登录时,新登录会使旧Token失效 * * @param userId 用户ID * @param accessToken 新生成的访问令牌 @@ -247,27 +260,27 @@ public class RedisTokenManager implements TokenManager { * 存储新的访问令牌 * * @param newAccessToken 新访问令牌 - * @param onlineUser 在线用户信息 + * @param userSession 用户会话信息 */ - private void storeAccessToken(String newAccessToken, OnlineUser onlineUser) { - setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive()); - String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId()); + private void storeAccessToken(String newAccessToken, UserSession userSession) { + setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive()); + String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()); setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive()); } /** * 构建用户详情对象 * - * @param onlineUser 在线用户信息 + * @param userSession 用户会话信息 * @param authorities 权限集合 * @return SysUserDetails 用户详情 */ - private SysUserDetails buildUserDetails(OnlineUser onlineUser, Set authorities) { + private SysUserDetails buildUserDetails(UserSession userSession, Set authorities) { SysUserDetails userDetails = new SysUserDetails(); - userDetails.setUserId(onlineUser.getUserId()); - userDetails.setUsername(onlineUser.getUsername()); - userDetails.setDeptId(onlineUser.getDeptId()); - userDetails.setDataScope(onlineUser.getDataScope()); + userDetails.setUserId(userSession.getUserId()); + userDetails.setUsername(userSession.getUsername()); + userDetails.setDeptId(userSession.getDeptId()); + userDetails.setDataScopes(userSession.getDataScopes()); userDetails.setAuthorities(authorities); return userDetails; } diff --git a/src/main/java/com/youlai/boot/security/util/SecurityUtils.java b/src/main/java/com/youlai/boot/security/util/SecurityUtils.java index 94ce0434..cbe21b20 100644 --- a/src/main/java/com/youlai/boot/security/util/SecurityUtils.java +++ b/src/main/java/com/youlai/boot/security/util/SecurityUtils.java @@ -70,16 +70,6 @@ public class SecurityUtils { return getUser().map(SysUserDetails::getDeptId).orElse(null); } - /** - * 获取数据权限范围 - * - * @return Integer - */ - public static Integer getDataScope() { - return getUser().map(SysUserDetails::getDataScope).orElse(null); - } - - /** * 获取角色集合 * diff --git a/src/main/java/com/youlai/boot/system/controller/RoleController.java b/src/main/java/com/youlai/boot/system/controller/RoleController.java index a6dfd9fc..28beb430 100644 --- a/src/main/java/com/youlai/boot/system/controller/RoleController.java +++ b/src/main/java/com/youlai/boot/system/controller/RoleController.java @@ -120,4 +120,14 @@ public class RoleController { roleService.assignMenusToRole(roleId, menuIds); return Result.success(); } + + @Operation(summary = "获取角色的部门ID集合(自定义数据权限)") + @GetMapping("/{roleId}/dept-ids") + @PreAuthorize("@ss.hasPerm('sys:role:update')") + public Result> getRoleDeptIds( + @Parameter(description = "角色ID") @PathVariable Long roleId + ) { + List deptIds = roleService.getRoleDeptIds(roleId); + return Result.success(deptIds); + } } diff --git a/src/main/java/com/youlai/boot/system/handler/OnlineUserJobHandler.java b/src/main/java/com/youlai/boot/system/handler/OnlineUserJobHandler.java deleted file mode 100644 index 3fcb7a80..00000000 --- a/src/main/java/com/youlai/boot/system/handler/OnlineUserJobHandler.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.youlai.boot.system.handler; - - -import com.youlai.boot.system.service.UserOnlineService; -import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher; -import com.youlai.boot.platform.websocket.topic.WebSocketTopics; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * 在线用户定时任务 - * - * @since 2024/10/7 - * - */ -@Component -@Slf4j -@RequiredArgsConstructor -public class OnlineUserJobHandler { - - private final UserOnlineService userOnlineService; - private final WebSocketPublisher webSocketPublisher; - - // 每3分钟统计一次在线用户数,减少服务器压力 - @Scheduled(cron = "0 */3 * * * ?") - public void execute() { - log.info("定时任务:统计在线用户数"); - // 推送在线用户数量到新主题 - int count = userOnlineService.getOnlineUserCount(); - webSocketPublisher.publish(WebSocketTopics.TOPIC_ONLINE_COUNT, count); - } - -} diff --git a/src/main/java/com/youlai/boot/system/mapper/RoleDeptMapper.java b/src/main/java/com/youlai/boot/system/mapper/RoleDeptMapper.java new file mode 100644 index 00000000..86df3d63 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/RoleDeptMapper.java @@ -0,0 +1,35 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.system.model.entity.RoleDept; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 角色部门关联持久层 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Mapper +public interface RoleDeptMapper extends BaseMapper { + + /** + * 根据角色ID获取部门ID列表 + * + * @param roleId 角色ID + * @return 部门ID列表 + */ + List getDeptIdsByRoleId(@Param("roleId") Long roleId); + + /** + * 根据角色编码集合获取所有部门ID列表(用于自定义数据权限) + * + * @param roleCodes 角色编码集合 + * @return 部门ID列表 + */ + List getDeptIdsByRoleCodes(@Param("roleCodes") List roleCodes); + +} diff --git a/src/main/java/com/youlai/boot/system/mapper/RoleMapper.java b/src/main/java/com/youlai/boot/system/mapper/RoleMapper.java index 7db98cfd..fead0397 100644 --- a/src/main/java/com/youlai/boot/system/mapper/RoleMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/RoleMapper.java @@ -3,7 +3,10 @@ package com.youlai.boot.system.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.youlai.boot.system.model.entity.Role; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -22,4 +25,15 @@ public interface RoleMapper extends BaseMapper { * @return {@link Integer} – 数据权限范围 */ Integer getMaximumDataScope(Set roles); + + /** + * 获取角色的数据权限信息列表 + *

    + * 返回角色编码和数据权限范围的映射列表 + * + * @param roleCodes 角色编码集合 + * @return 角色数据权限信息列表 [{code: 'ADMIN', data_scope: 1}, ...] + */ + List> getRoleDataScopeList(@Param("roleCodes") Set roleCodes); + } diff --git a/src/main/java/com/youlai/boot/system/model/entity/RoleDept.java b/src/main/java/com/youlai/boot/system/model/entity/RoleDept.java new file mode 100644 index 00000000..93ef111b --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/RoleDept.java @@ -0,0 +1,32 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 角色部门关联实体 + *

    + * 用于存储角色自定义数据权限时,可访问的部门ID列表 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@TableName("sys_role_dept") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RoleDept { + + /** + * 角色ID + */ + private Long roleId; + + /** + * 部门ID + */ + private Long deptId; + +} diff --git a/src/main/java/com/youlai/boot/system/model/form/RoleForm.java b/src/main/java/com/youlai/boot/system/model/form/RoleForm.java index d1588557..3f0e09d2 100644 --- a/src/main/java/com/youlai/boot/system/model/form/RoleForm.java +++ b/src/main/java/com/youlai/boot/system/model/form/RoleForm.java @@ -7,6 +7,8 @@ import lombok.Data; import jakarta.validation.constraints.NotBlank; import org.hibernate.validator.constraints.Range; +import java.util.List; + @Schema(description = "角色表单对象") @Data public class RoleForm { @@ -29,7 +31,10 @@ public class RoleForm { @Range(max = 1, min = 0, message = "角色状态不正确") private Integer status; - @Schema(description="数据权限") + @Schema(description="数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)") private Integer dataScope; + @Schema(description="自定义数据权限部门ID列表(当dataScope=5时有效)") + private List deptIds; + } diff --git a/src/main/java/com/youlai/boot/system/service/RoleDeptService.java b/src/main/java/com/youlai/boot/system/service/RoleDeptService.java new file mode 100644 index 00000000..de8ba840 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/RoleDeptService.java @@ -0,0 +1,47 @@ +package com.youlai.boot.system.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.system.model.entity.RoleDept; + +import java.util.List; + +/** + * 角色部门关联服务接口 + * + * @author Ray.Hao + * @since 4.1.0 + */ +public interface RoleDeptService extends IService { + + /** + * 根据角色ID获取部门ID列表 + * + * @param roleId 角色ID + * @return 部门ID列表 + */ + List getDeptIdsByRoleId(Long roleId); + + /** + * 根据角色编码集合获取所有部门ID列表(用于自定义数据权限) + * + * @param roleCodes 角色编码集合 + * @return 部门ID列表 + */ + List getDeptIdsByRoleCodes(List roleCodes); + + /** + * 保存角色部门关联 + * + * @param roleId 角色ID + * @param deptIds 部门ID列表 + */ + void saveRoleDepts(Long roleId, List deptIds); + + /** + * 删除角色部门关联 + * + * @param roleId 角色ID + */ + void deleteByRoleId(Long roleId); + +} diff --git a/src/main/java/com/youlai/boot/system/service/RoleMenuService.java b/src/main/java/com/youlai/boot/system/service/RoleMenuService.java index dc9e47a4..1a2d948b 100644 --- a/src/main/java/com/youlai/boot/system/service/RoleMenuService.java +++ b/src/main/java/com/youlai/boot/system/service/RoleMenuService.java @@ -10,7 +10,7 @@ import java.util.Set; /** * 角色菜单业务接口 * - * @author haoxr + * @author Ray.Hao * @since 2.5.0 */ public interface RoleMenuService extends IService { @@ -45,10 +45,12 @@ public interface RoleMenuService extends IService { void refreshRolePermsCache(String oldRoleCode, String newRoleCode); /** - * 获取角色权限集合 + * 获取角色权限集合(带缓存) + *

    + * 采用 Read-Through 缓存策略,缓存未命中时自动回源数据库 * - * @param roles 角色编码集合 + * @param roleCodes 角色编码集合 * @return 权限集合 */ - Set getRolePermsByRoleCodes(Set roles); + Set getRolePermsByRoleCodes(Set roleCodes); } diff --git a/src/main/java/com/youlai/boot/system/service/RoleService.java b/src/main/java/com/youlai/boot/system/service/RoleService.java index d6f61562..00360d64 100644 --- a/src/main/java/com/youlai/boot/system/service/RoleService.java +++ b/src/main/java/com/youlai/boot/system/service/RoleService.java @@ -3,6 +3,7 @@ package com.youlai.boot.system.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; +import com.youlai.boot.security.model.RoleDataScope; import com.youlai.boot.system.model.entity.Role; import com.youlai.boot.common.model.Option; import com.youlai.boot.system.model.form.RoleForm; @@ -91,5 +92,22 @@ public interface RoleService extends IService { */ Integer getMaximumDataScope(Set roles); + /** + * 获取角色的部门ID列表(自定义数据权限) + * + * @param roleId 角色ID + * @return 部门ID列表 + */ + List getRoleDeptIds(Long roleId); + + /** + * 获取用户所有角色的数据权限列表 + *

    + * 用于实现多角色数据权限合并(并集策略) + * + * @param roleCodes 角色编码集合 + * @return 角色数据权限列表 + */ + List getRoleDataScopes(Set roleCodes); } diff --git a/src/main/java/com/youlai/boot/system/service/UserNoticeService.java b/src/main/java/com/youlai/boot/system/service/UserNoticeService.java index 4de8cdba..4f4c7747 100644 --- a/src/main/java/com/youlai/boot/system/service/UserNoticeService.java +++ b/src/main/java/com/youlai/boot/system/service/UserNoticeService.java @@ -13,7 +13,7 @@ import java.util.List; /** * 用户公告状态服务类 * - * @author youlaitech + * @author Theo * @since 2024-08-28 16:56 */ public interface UserNoticeService extends IService { diff --git a/src/main/java/com/youlai/boot/system/service/UserOnlineService.java b/src/main/java/com/youlai/boot/system/service/UserOnlineService.java deleted file mode 100644 index 9dde5159..00000000 --- a/src/main/java/com/youlai/boot/system/service/UserOnlineService.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.youlai.boot.system.service; - -import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher; -import com.youlai.boot.platform.websocket.topic.WebSocketTopics; -import lombok.Data; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -/** - * 用户在线状态服务 - * 负责维护用户的在线状态和相关统计 - * - * @author Ray.Hao - * @since 3.0.0 - */ -@Service -@Slf4j -public class UserOnlineService { - - // 在线用户映射表,key为用户名,value为用户在线信息 - private final Map onlineUsers = new ConcurrentHashMap<>(); - - private final WebSocketPublisher webSocketPublisher; - - public UserOnlineService(WebSocketPublisher webSocketPublisher) { - this.webSocketPublisher = webSocketPublisher; - } - - /** - * 用户上线 - * - * @param username 用户名 - * @param sessionId WebSocket会话ID(可选) - */ - public void userConnected(String username, String sessionId) { - // 生成会话ID(如果未提供) - String actualSessionId = sessionId != null ? sessionId : "session-" + System.nanoTime(); - UserOnlineInfo info = new UserOnlineInfo(username, actualSessionId, System.currentTimeMillis()); - onlineUsers.put(username, info); - log.info("用户[{}]上线,当前在线用户数:{}", username, onlineUsers.size()); - - // 通知在线用户状态变更 - notifyOnlineUsersChange(); - } - - /** - * 用户下线 - * - * @param username 用户名 - */ - public void userDisconnected(String username) { - onlineUsers.remove(username); - log.info("用户[{}]下线,当前在线用户数:{}", username, onlineUsers.size()); - - // 通知在线用户状态变更 - notifyOnlineUsersChange(); - } - - /** - * 获取在线用户列表 - * - * @return 在线用户名列表 - */ - public List getOnlineUsers() { - return onlineUsers.values().stream() - .map(info -> new UserOnlineDTO(info.getUsername(), info.getLoginTime())) - .collect(Collectors.toList()); - } - - /** - * 获取在线用户数量 - * - * @return 在线用户数 - */ - public int getOnlineUserCount() { - return onlineUsers.size(); - } - - /** - * 检查用户是否在线 - * - * @param username 用户名 - * @return 是否在线 - */ - public boolean isUserOnline(String username) { - return onlineUsers.containsKey(username); - } - - /** - * 通知所有客户端在线用户变更 - */ - private void notifyOnlineUsersChange() { - // 发送简化版数据(仅数量) - sendOnlineUserCount(); - } - - /** - * 发送在线用户数量(简化版,不包含用户详情) - */ - private void sendOnlineUserCount() { - try { - // 直接发送数量,更轻量 - int count = onlineUsers.size(); - webSocketPublisher.publish(WebSocketTopics.TOPIC_ONLINE_COUNT, count); - log.debug("已发送在线用户数量: {}", count); - } catch (Exception e) { - log.error("发送在线用户数量失败", e); - } - } - - /** - * 用户在线信息 - */ - @Data - private static class UserOnlineInfo { - private final String username; - private final String sessionId; - private final long loginTime; - } - - /** - * 用户在线Dto(用于返回给前端) - */ - @Data - public static class UserOnlineDTO { - private final String username; - private final long loginTime; - } - - /** - * 在线用户变更事件 - */ - @Data - private static class OnlineUsersChangeEvent { - private String type; - private int count; - private List users; - private long timestamp; - } -} diff --git a/src/main/java/com/youlai/boot/system/service/UserRoleService.java b/src/main/java/com/youlai/boot/system/service/UserRoleService.java index 0d42915b..7254490e 100644 --- a/src/main/java/com/youlai/boot/system/service/UserRoleService.java +++ b/src/main/java/com/youlai/boot/system/service/UserRoleService.java @@ -6,13 +6,19 @@ import com.youlai.boot.system.model.entity.UserRole; import java.util.List; +/** + * 用户角色业务接口 + * + * @author Ray.Hao + * @since 0.0.1 + */ public interface UserRoleService extends IService { /** * 保存用户角色 * - * @param userId - * @param roleIds + * @param userId 用户ID + * @param roleIds 角色ID列表 * @return */ void saveUserRoles(Long userId, List roleIds); diff --git a/src/main/java/com/youlai/boot/system/service/UserService.java b/src/main/java/com/youlai/boot/system/service/UserService.java index 0b0fac89..f98ef295 100644 --- a/src/main/java/com/youlai/boot/system/service/UserService.java +++ b/src/main/java/com/youlai/boot/system/service/UserService.java @@ -25,7 +25,7 @@ public interface UserService extends IService { /** * 用户分页列表 * - * @return {@link IPage} 用户分页列表 + * @return {@link IPage} 用户分页列表 */ IPage getUserPage(UserQuery queryParams); @@ -82,7 +82,7 @@ public interface UserService extends IService { * 获取导出用户列表 * * @param queryParams 查询参数 - * @return {@link List} 导出用户列表 + * @return {@link List} 导出用户列表 */ List listExportUsers(UserQuery queryParams); @@ -90,14 +90,14 @@ public interface UserService extends IService { /** * 获取登录用户信息 * - * @return {@link CurrentUserDto} 登录用户信息 + * @return {@link CurrentUserDTO} 登录用户信息 */ CurrentUserDTO getCurrentUserInfo(); /** * 获取个人中心用户信息 * - * @return {@link UserProfileVo} 个人中心用户信息 + * @return {@link UserProfileVO} 个人中心用户信息 */ UserProfileVO getUserProfile(Long userId); diff --git a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java index e9e670b3..52d0f825 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java @@ -25,10 +25,8 @@ import com.youlai.boot.system.model.vo.UserNoticePageVO; import com.youlai.boot.system.model.vo.NoticeDetailVO; import com.youlai.boot.system.service.NoticeService; import com.youlai.boot.system.service.UserNoticeService; -import com.youlai.boot.system.service.UserOnlineService; import com.youlai.boot.system.service.UserService; -import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher; -import com.youlai.boot.platform.websocket.topic.WebSocketTopics; +import com.youlai.boot.platform.websocket.service.WebSocketService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -53,14 +51,13 @@ public class NoticeServiceImpl extends ServiceImpl impleme private final NoticeConverter noticeConverter; private final UserNoticeService userNoticeService; private final UserService userService; - private final WebSocketPublisher webSocketPublisher; - private final UserOnlineService userOnlineService; + private final WebSocketService webSocketService; /** * 获取通知公告分页列表 * * @param queryParams 查询参数 - * @return {@link IPage< NoticePageVo >} 通知公告分页列表 + * @return {@link IPage< NoticePageVO >} 通知公告分页列表 */ @Override public IPage getNoticePage(NoticeQuery queryParams) { @@ -214,9 +211,10 @@ public class NoticeServiceImpl extends ServiceImpl impleme Set receivers = targetUserList.stream().map(User::getUsername).collect(Collectors.toSet()); - Set allOnlineUsers = userOnlineService.getOnlineUsers().stream() - .map(UserOnlineService.UserOnlineDTO::getUsername) - .collect(Collectors.toSet()); + // 获取在线用户名集合 + Set allOnlineUsers = webSocketService.getOnlineUsers().stream() + .map(dto -> dto.getUsername()) + .collect(Collectors.toSet()); // 找出在线用户的通知接收者 Set onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers)); @@ -227,7 +225,8 @@ public class NoticeServiceImpl extends ServiceImpl impleme noticeDto.setType(notice.getType()); noticeDto.setPublishTime(notice.getPublishTime()); - onlineReceivers.forEach(receiver -> webSocketPublisher.publishToUser(receiver, WebSocketTopics.USER_QUEUE_MESSAGE, noticeDto)); + // 向在线接收者推送通知 + onlineReceivers.forEach(receiver -> webSocketService.sendNotification(receiver, noticeDto)); } return publishResult; } @@ -268,7 +267,7 @@ public class NoticeServiceImpl extends ServiceImpl impleme /** * * @param id 通知公告ID - * @return NoticeDetailVo 通知公告详情 + * @return NoticeDetailVO 通知公告详情 */ @Override public NoticeDetailVO getNoticeDetail(Long id) { diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleDeptServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleDeptServiceImpl.java new file mode 100644 index 00000000..c6240b4a --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleDeptServiceImpl.java @@ -0,0 +1,65 @@ +package com.youlai.boot.system.service.impl; + +import cn.hutool.core.collection.CollectionUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.system.mapper.RoleDeptMapper; +import com.youlai.boot.system.model.entity.RoleDept; +import com.youlai.boot.system.service.RoleDeptService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +/** + * 角色部门关联服务实现 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Service +@RequiredArgsConstructor +public class RoleDeptServiceImpl extends ServiceImpl implements RoleDeptService { + + @Override + public List getDeptIdsByRoleId(Long roleId) { + if (roleId == null) { + return Collections.emptyList(); + } + return this.baseMapper.getDeptIdsByRoleId(roleId); + } + + @Override + public List getDeptIdsByRoleCodes(List roleCodes) { + if (CollectionUtil.isEmpty(roleCodes)) { + return Collections.emptyList(); + } + return this.baseMapper.getDeptIdsByRoleCodes(roleCodes); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saveRoleDepts(Long roleId, List deptIds) { + if (roleId == null || CollectionUtil.isEmpty(deptIds)) { + return; + } + // 先删除原有关联 + this.remove(new LambdaQueryWrapper().eq(RoleDept::getRoleId, roleId)); + // 批量插入新关联 + List roleDepts = deptIds.stream() + .map(deptId -> new RoleDept(roleId, deptId)) + .toList(); + this.saveBatch(roleDepts); + } + + @Override + public void deleteByRoleId(Long roleId) { + if (roleId == null) { + return; + } + this.remove(new LambdaQueryWrapper().eq(RoleDept::getRoleId, roleId)); + } + +} diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java index 9304ce68..5e68745a 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java @@ -7,14 +7,13 @@ import com.youlai.boot.system.mapper.RoleMenuMapper; import com.youlai.boot.system.model.bo.RolePermsBO; import com.youlai.boot.system.model.entity.RoleMenu; import com.youlai.boot.system.service.RoleMenuService; -import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import java.util.List; -import java.util.Set; +import java.util.*; +import java.util.stream.Collectors; /** * 角色菜单服务实现类 @@ -29,44 +28,17 @@ public class RoleMenuServiceImpl extends ServiceImpl i private final RedisTemplate redisTemplate; - /** - * 启动时初始化权限缓存 - */ - @PostConstruct - public void initRolePermsCache() { - log.info("开始初始化权限缓存..."); - - List allRolePermsList = this.baseMapper.getRolePermsList(null); - - if (CollectionUtil.isEmpty(allRolePermsList)) { - log.warn("权限数据为空,跳过缓存初始化"); - return; - } - - // 所有数据统一缓存 - String cacheKey = RedisConstants.System.ROLE_PERMS; - allRolePermsList.forEach(rolePerms -> { - String roleCode = rolePerms.getRoleCode(); - Set perms = rolePerms.getPerms(); - - if (CollectionUtil.isNotEmpty(perms)) { - redisTemplate.opsForHash().put(cacheKey, roleCode, perms); - } - }); - log.info("权限缓存初始化完成,共{}条数据", allRolePermsList.size()); - } - /** * 刷新权限缓存 */ @Override public void refreshRolePermsCache() { String cacheKey = RedisConstants.System.ROLE_PERMS; - + // 清理权限缓存 redisTemplate.delete(cacheKey); - - // 重新加载权限 + + // 预热权限缓存,避免后续请求触发频繁回源 List list = this.baseMapper.getRolePermsList(null); if (CollectionUtil.isNotEmpty(list)) { list.forEach(item -> { @@ -77,7 +49,7 @@ public class RoleMenuServiceImpl extends ServiceImpl i } }); } - + log.info("权限缓存刷新完成"); } @@ -87,11 +59,11 @@ public class RoleMenuServiceImpl extends ServiceImpl i @Override public void refreshRolePermsCache(String roleCode) { String cacheKey = RedisConstants.System.ROLE_PERMS; - + // 清理指定角色缓存 redisTemplate.opsForHash().delete(cacheKey, roleCode); - - // 重新加载指定角色权限 + + // 回源 DB 并更新缓存 List list = this.baseMapper.getRolePermsList(roleCode); if (CollectionUtil.isNotEmpty(list)) { RolePermsBO rolePerms = list.get(0); @@ -102,7 +74,7 @@ public class RoleMenuServiceImpl extends ServiceImpl i } } } - + log.info("角色[{}]权限缓存刷新完成", roleCode); } @@ -112,11 +84,12 @@ public class RoleMenuServiceImpl extends ServiceImpl i @Override public void refreshRolePermsCache(String oldRoleCode, String newRoleCode) { String cacheKey = RedisConstants.System.ROLE_PERMS; - + // 清理旧角色权限缓存 redisTemplate.opsForHash().delete(cacheKey, oldRoleCode); - - // 添加新角色权限缓存 + redisTemplate.opsForHash().delete(cacheKey, newRoleCode); + + // 回源 DB 并更新新角色编码缓存 List list = this.baseMapper.getRolePermsList(newRoleCode); if (CollectionUtil.isNotEmpty(list)) { RolePermsBO rolePerms = list.get(0); @@ -127,20 +100,72 @@ public class RoleMenuServiceImpl extends ServiceImpl i } } } - - log.info("角色编码变更: {} -> {},权限缓存已更新", oldRoleCode, newRoleCode); + + log.info("角色编码变更: {} -> {},相关权限缓存刷新完成", oldRoleCode, newRoleCode); } /** - * 获取角色权限集合 + * 获取角色权限集合(带缓存) + *

    + * 采用 Read-Through 缓存策略: + *

      + *
    1. 优先从 Redis Hash 缓存读取
    2. + *
    3. 缓存未命中时回源 DB 并写入缓存
    4. + *
    * - * @param roles 角色编码集合 + * @param roleCodes 角色编码集合 * @return 权限集合 */ @Override - public Set getRolePermsByRoleCodes(Set roles) { - // 直接查询数据库(保持原有逻辑) - return this.baseMapper.listRolePerms(roles); + public Set getRolePermsByRoleCodes(Set roleCodes) { + if (CollectionUtil.isEmpty(roleCodes)) { + return Collections.emptySet(); + } + + String cacheKey = RedisConstants.System.ROLE_PERMS; + Set perms = new HashSet<>(); + List roleCodeList = new ArrayList<>(roleCodes); + + // 1. 尝试从缓存批量获取 + List cachedPermsList = redisTemplate.opsForHash().multiGet(cacheKey, new ArrayList<>(roleCodeList)); + + List missingRoles = new ArrayList<>(); + for (int i = 0; i < roleCodeList.size(); i++) { + Object cachedPerms = cachedPermsList.get(i); + String roleCode = roleCodeList.get(i); + + if (cachedPerms == null) { + // 缓存未命中,记录需要回源的角色 + missingRoles.add(roleCode); + continue; + } + + // Redis JSON 序列化后,Set 会以 Collection 形式反序列化 + if (cachedPerms instanceof Collection collection) { + collection.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .forEach(perms::add); + } else { + // 兼容单个权限字符串的极端情况 + perms.add(cachedPerms.toString()); + } + } + + // 2. 回源 DB 并同步到缓存 + if (!missingRoles.isEmpty()) { + for (String roleCode : missingRoles) { + Set dbPerms = this.baseMapper.listRolePerms(Collections.singleton(roleCode)); + if (dbPerms == null) { + dbPerms = Collections.emptySet(); + } + // 写入缓存(空集也写入,防止缓存穿透) + redisTemplate.opsForHash().put(cacheKey, roleCode, dbPerms); + perms.addAll(dbPerms); + } + } + + return perms; } /** diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java index 787a1f0c..cbeed2c2 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java @@ -7,6 +7,7 @@ import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.common.enums.DataScopeEnum; import com.youlai.boot.core.exception.BusinessException; import com.youlai.boot.system.converter.RoleConverter; import com.youlai.boot.system.mapper.RoleMapper; @@ -18,6 +19,7 @@ import com.youlai.boot.system.model.vo.RolePageVO; import com.youlai.boot.common.constant.SystemConstants; import com.youlai.boot.common.model.Option; import com.youlai.boot.security.util.SecurityUtils; +import com.youlai.boot.system.service.RoleDeptService; import com.youlai.boot.system.service.RoleMenuService; import com.youlai.boot.system.service.RoleService; import com.youlai.boot.system.service.UserRoleService; @@ -41,6 +43,7 @@ import java.util.Set; public class RoleServiceImpl extends ServiceImpl implements RoleService { private final RoleMenuService roleMenuService; + private final RoleDeptService roleDeptService; private final UserRoleService userRoleService; private final RoleConverter roleConverter; @@ -48,7 +51,7 @@ public class RoleServiceImpl extends ServiceImpl implements Ro * 角色分页列表 * * @param queryParams 角色查询参数 - * @return {@link Page< RolePageVo >} – 角色分页列表 + * @return {@link Page< RolePageVO >} – 角色分页列表 */ @Override public Page getRolePage(RoleQuery queryParams) { @@ -99,6 +102,7 @@ public class RoleServiceImpl extends ServiceImpl implements Ro * @return {@link Boolean} */ @Override + @Transactional(rollbackFor = Exception.class) public boolean saveRole(RoleForm roleForm) { Long roleId = roleForm.getId(); @@ -123,6 +127,16 @@ public class RoleServiceImpl extends ServiceImpl implements Ro boolean result = this.saveOrUpdate(role); if (result) { + // 保存自定义数据权限部门 + Long savedRoleId = role.getId(); + if (DataScopeEnum.CUSTOM.getValue().equals(roleForm.getDataScope())) { + // 自定义数据权限时,保存角色部门关联 + roleDeptService.saveRoleDepts(savedRoleId, roleForm.getDeptIds()); + } else { + // 非自定义数据权限时,删除原有部门关联 + roleDeptService.deleteByRoleId(savedRoleId); + } + // 判断角色编码或状态是否修改,修改了则刷新权限缓存 if (oldRole != null && ( @@ -144,7 +158,13 @@ public class RoleServiceImpl extends ServiceImpl implements Ro @Override public RoleForm getRoleForm(Long roleId) { Role entity = this.getById(roleId); - return roleConverter.toForm(entity); + RoleForm roleForm = roleConverter.toForm(entity); + // 如果是自定义数据权限,查询关联的部门ID列表 + if (roleForm != null && DataScopeEnum.CUSTOM.getValue().equals(roleForm.getDataScope())) { + List deptIds = roleDeptService.getDeptIdsByRoleId(roleId); + roleForm.setDeptIds(deptIds); + } + return roleForm; } /** @@ -254,4 +274,70 @@ public class RoleServiceImpl extends ServiceImpl implements Ro return dataScope; } + /** + * 获取角色的部门ID列表(自定义数据权限) + * + * @param roleId 角色ID + * @return 部门ID列表 + */ + @Override + public List getRoleDeptIds(Long roleId) { + return roleDeptService.getDeptIdsByRoleId(roleId); + } + + /** + * 获取用户所有角色的数据权限列表 + *

    + * 用于实现多角色数据权限合并(并集策略) + * + * @param roleCodes 角色编码集合 + * @return 角色数据权限列表 + */ + @Override + public List getRoleDataScopes(Set roleCodes) { + if (CollectionUtil.isEmpty(roleCodes)) { + return List.of(); + } + + // 获取角色的数据权限信息 + List> roleDataScopeList = this.baseMapper.getRoleDataScopeList(roleCodes); + if (CollectionUtil.isEmpty(roleDataScopeList)) { + return List.of(); + } + + // 获取所有自定义数据权限的角色编码 + List customRoleCodes = roleDataScopeList.stream() + .filter(map -> DataScopeEnum.CUSTOM.getValue().equals(map.get("data_scope"))) + .map(map -> (String) map.get("code")) + .toList(); + + // 批量获取自定义角色的部门ID + Map> customDeptIdsMap = new java.util.HashMap<>(); + if (CollectionUtil.isNotEmpty(customRoleCodes)) { + // 查询每个角色关联的部门ID + for (String roleCode : customRoleCodes) { + // 根据角色编码获取角色ID + Role role = this.getOne(new LambdaQueryWrapper() + .eq(Role::getCode, roleCode) + .select(Role::getId)); + if (role != null) { + List deptIds = roleDeptService.getDeptIdsByRoleId(role.getId()); + customDeptIdsMap.put(roleCode, deptIds); + } + } + } + + // 构建角色数据权限列表 + return roleDataScopeList.stream() + .map(map -> { + String code = (String) map.get("code"); + Integer dataScope = (Integer) map.get("data_scope"); + if (DataScopeEnum.CUSTOM.getValue().equals(dataScope)) { + return RoleDataScope.custom(code, customDeptIdsMap.getOrDefault(code, List.of())); + } + return new RoleDataScope(code, dataScope, null); + }) + .toList(); + } + } diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java index 275121ca..0f969417 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java @@ -15,8 +15,8 @@ import com.youlai.boot.common.model.Option; import com.youlai.boot.platform.mail.service.MailService; import com.youlai.boot.platform.sms.enums.SmsTypeEnum; import com.youlai.boot.platform.sms.service.SmsService; +import com.youlai.boot.security.model.RoleDataScope; import com.youlai.boot.security.model.UserAuthInfo; -import com.youlai.boot.security.service.PermissionService; import com.youlai.boot.security.token.TokenManager; import com.youlai.boot.security.util.SecurityUtils; import com.youlai.boot.system.converter.UserConverter; @@ -62,7 +62,7 @@ public class UserServiceImpl extends ServiceImpl implements Us private final RoleService roleService; - private final PermissionService permissionService; + private final RoleMenuService roleMenuService; private final SmsService smsService; @@ -81,7 +81,7 @@ public class UserServiceImpl extends ServiceImpl implements Us * 获取用户分页列表 * * @param queryParams 查询参数 - * @return {@link IPage} 用户分页列表 + * @return {@link IPage} 用户分页列表 */ @Override public IPage getUserPage(UserQuery queryParams) { @@ -215,9 +215,9 @@ public class UserServiceImpl extends ServiceImpl implements Us UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username); if (userAuthInfo != null) { Set roles = userAuthInfo.getRoles(); - // 获取最大范围的数据权限 - Integer dataScope = roleService.getMaximumDataScope(roles); - userAuthInfo.setDataScope(dataScope); + // 获取数据权限列表(用于并集策略) + List dataScopes = roleService.getRoleDataScopes(roles); + userAuthInfo.setDataScopes(dataScopes); } return userAuthInfo; } @@ -236,9 +236,9 @@ public class UserServiceImpl extends ServiceImpl implements Us UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile); if (userAuthInfo != null) { Set roles = userAuthInfo.getRoles(); - // 获取最大范围的数据权限 - Integer dataScope = roleService.getMaximumDataScope(roles); - userAuthInfo.setDataScope(dataScope); + // 获取数据权限列表(用于并集策略) + List dataScopes = roleService.getRoleDataScopes(roles); + userAuthInfo.setDataScopes(dataScopes); } return userAuthInfo; } @@ -247,7 +247,7 @@ public class UserServiceImpl extends ServiceImpl implements Us * 获取导出用户列表 * * @param queryParams 查询参数 - * @return {@link List} 导出用户列表 + * @return {@link List} 导出用户列表 */ @Override public List listExportUsers(UserQuery queryParams) { @@ -285,7 +285,7 @@ public class UserServiceImpl extends ServiceImpl implements Us /** * 获取登录用户信息 * - * @return {@link CurrentUserDto} 用户信息 + * @return {@link CurrentUserDTO} 用户信息 */ @Override public CurrentUserDTO getCurrentUserInfo() { @@ -311,7 +311,7 @@ public class UserServiceImpl extends ServiceImpl implements Us // 用户权限集合 if (CollectionUtil.isNotEmpty(roles)) { - Set perms = permissionService.getRolePermsFormCache(roles); + Set perms = roleMenuService.getRolePermsByRoleCodes(roles); userInfoVo.setPerms(perms); } return userInfoVo; @@ -321,7 +321,7 @@ public class UserServiceImpl extends ServiceImpl implements Us * 获取个人中心用户信息 * * @param userId 用户ID - * @return {@link UserProfileVo} 个人中心用户信息 + * @return {@link UserProfileVO} 个人中心用户信息 */ @Override public UserProfileVO getUserProfile(Long userId) { diff --git a/src/main/resources/mapper/system/RoleDeptMapper.xml b/src/main/resources/mapper/system/RoleDeptMapper.xml new file mode 100644 index 00000000..303bc8fd --- /dev/null +++ b/src/main/resources/mapper/system/RoleDeptMapper.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/src/main/resources/mapper/system/RoleMapper.xml b/src/main/resources/mapper/system/RoleMapper.xml index 7da35d92..b3bf9f2f 100644 --- a/src/main/resources/mapper/system/RoleMapper.xml +++ b/src/main/resources/mapper/system/RoleMapper.xml @@ -24,4 +24,27 @@ + + + diff --git a/src/test/java/com/youlai/boot/plugin/mybatis/MyDataPermissionHandlerTest.java b/src/test/java/com/youlai/boot/plugin/mybatis/MyDataPermissionHandlerTest.java new file mode 100644 index 00000000..84a147ac --- /dev/null +++ b/src/test/java/com/youlai/boot/plugin/mybatis/MyDataPermissionHandlerTest.java @@ -0,0 +1,387 @@ +package com.youlai.boot.plugin.mybatis; + +import com.youlai.boot.common.annotation.DataPermission; +import com.youlai.boot.common.enums.DataScopeEnum; +import com.youlai.boot.security.model.RoleDataScope; +import com.youlai.boot.security.model.SysUserDetails; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.expression.operators.conditional.OrExpression; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.expression.operators.relational.InExpression; +import net.sf.jsqlparser.schema.Column; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 数据权限处理器单元测试 + * + * @author Ray.Hao + */ +@DisplayName("数据权限处理器测试") +class MyDataPermissionHandlerTest { + + private MyDataPermissionHandler handler; + + @BeforeEach + void setUp() { + handler = new MyDataPermissionHandler(); + } + + @AfterEach + void tearDown() { + // 清理安全上下文 + SecurityContextHolder.clearContext(); + } + + // ==================== 边界条件测试 ==================== + + @Nested + @DisplayName("边界条件测试") + class BoundaryTests { + + @Test + @DisplayName("未登录用户 - 返回原始where条件") + void whenNotLoggedIn_thenReturnOriginalWhere() { + // given: 未设置安全上下文 + Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1)); + + // when + Expression result = handler.getSqlSegment(where, "com.example.mapper.UserMapper.selectList"); + + // then: 返回原始where + assertThat(result).isSameAs(where); + } + + @Test + @DisplayName("超级管理员 - 跳过数据权限过滤") + void whenRootUser_thenReturnOriginalWhere() { + // given: 设置超级管理员 + setSecurityContext(1L, "admin", 1L, + Set.of(new SimpleGrantedAuthority("ROLE_ROOT")), + Collections.emptyList()); + + Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1)); + + // when + Expression result = handler.getSqlSegment(where, "com.example.mapper.UserMapper.selectList"); + + // then: 返回原始where,不添加数据权限条件 + assertThat(result).isSameAs(where); + } + + @Test + @DisplayName("无数据权限列表 - 返回原始where条件") + void whenNoDataScopes_thenReturnOriginalWhere() { + // given: 普通用户但无数据权限 + setSecurityContext(100L, "user", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_USER")), + Collections.emptyList()); + + Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1)); + + // when + Expression result = handler.getSqlSegment(where, "com.example.mapper.UserMapper.selectList"); + + // then: 返回原始where + assertThat(result).isSameAs(where); + } + } + + // ==================== 单一角色数据权限测试 ==================== + + @Nested + @DisplayName("单一角色数据权限测试") + class SingleRoleTests { + + @Test + @DisplayName("全部数据权限(ALL) - 不添加过滤条件") + void whenAllDataScope_thenReturnOriginalWhere() { + // given: 角色拥有全部数据权限 + setSecurityContext(100L, "admin", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_ADMIN")), + List.of(RoleDataScope.all("ADMIN"))); + + Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1)); + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 返回原始where + assertThat(result).isSameAs(where); + } + + @Test + @DisplayName("本部门数据权限(DEPT) - 添加dept_id = ?条件") + void whenDeptDataScope_thenAddDeptIdCondition() { + // given: 角色拥有本部门数据权限 + Long deptId = 10L; + setSecurityContext(100L, "manager", deptId, + Set.of(new SimpleGrantedAuthority("ROLE_MANAGER")), + List.of(RoleDataScope.dept("MANAGER"))); + + Expression where = null; + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 添加部门过滤条件 + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("dept_id = " + deptId); + } + + @Test + @DisplayName("本人数据权限(SELF) - 添加create_by = ?条件") + void whenSelfDataScope_thenAddCreateByCondition() { + // given: 角色拥有本人数据权限 + Long userId = 100L; + setSecurityContext(userId, "user", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_USER")), + List.of(RoleDataScope.self("USER"))); + + Expression where = null; + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 添加用户过滤条件 + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("create_by = " + userId); + } + + @Test + @DisplayName("自定义部门数据权限(CUSTOM) - 添加dept_id IN (?)条件") + void whenCustomDataScope_thenAddDeptIdInCondition() { + // given: 角色拥有自定义部门数据权限 + List customDeptIds = Arrays.asList(10L, 20L, 30L); + setSecurityContext(100L, "user", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_CUSTOM")), + List.of(RoleDataScope.custom("CUSTOM", customDeptIds))); + + Expression where = null; + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 添加自定义部门IN条件 + assertThat(result).isNotNull(); + String sql = result.toString(); + assertThat(sql).contains("dept_id IN"); + assertThat(sql).contains("10"); + assertThat(sql).contains("20"); + assertThat(sql).contains("30"); + } + + @Test + @DisplayName("自定义部门数据权限(空列表) - 添加1=0条件") + void whenCustomDataScopeWithEmptyList_thenAddFalseCondition() { + // given: 角色拥有自定义部门权限但列表为空 + setSecurityContext(100L, "user", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_CUSTOM")), + List.of(RoleDataScope.custom("CUSTOM", Collections.emptyList()))); + + Expression where = null; + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 添加1=0条件(无权限) + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("1 = 0"); + } + + @Test + @DisplayName("部门及子部门数据权限(DEPT_AND_SUB) - 添加子查询条件") + void whenDeptAndSubDataScope_thenAddSubQueryCondition() { + // given: 角色拥有部门及子部门数据权限 + Long deptId = 10L; + setSecurityContext(100L, "manager", deptId, + Set.of(new SimpleGrantedAuthority("ROLE_MANAGER")), + List.of(RoleDataScope.deptAndSub("MANAGER"))); + + Expression where = null; + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 添加子查询条件 + assertThat(result).isNotNull(); + String sql = result.toString(); + assertThat(sql).contains("dept_id IN"); + assertThat(sql).contains("SELECT"); + assertThat(sql).contains("sys_dept"); + assertThat(sql).contains("FIND_IN_SET"); + } + } + + // ==================== 多角色并集策略测试 ==================== + + @Nested + @DisplayName("多角色并集策略测试") + class MultiRoleTests { + + @Test + @DisplayName("多角色 - 任一角色为ALL时跳过过滤") + void whenAnyRoleIsAll_thenSkipFilter() { + // given: 用户有两个角色,其中一个是ALL + setSecurityContext(100L, "user", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_ADMIN"), new SimpleGrantedAuthority("ROLE_USER")), + List.of( + RoleDataScope.all("ADMIN"), + RoleDataScope.self("USER") + )); + + Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1)); + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 返回原始where,不添加过滤条件 + assertThat(result).isSameAs(where); + } + + @Test + @DisplayName("多角色 - DEPT和SELF权限合并为OR条件") + void whenDeptAndSelfRoles_thenMergeWithOr() { + // given: 用户有两个角色,分别拥有部门和本人权限 + Long deptId = 10L; + Long userId = 100L; + setSecurityContext(userId, "manager", deptId, + Set.of(new SimpleGrantedAuthority("ROLE_MANAGER"), new SimpleGrantedAuthority("ROLE_USER")), + List.of( + RoleDataScope.dept("MANAGER"), + RoleDataScope.self("USER") + )); + + Expression where = null; + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 生成OR连接的合并条件 + assertThat(result).isNotNull(); + String sql = result.toString(); + assertThat(sql).contains("OR"); + assertThat(sql).contains("dept_id = " + deptId); + assertThat(sql).contains("create_by = " + userId); + } + + @Test + @DisplayName("多角色 - 多个自定义部门权限合并") + void whenMultipleCustomRoles_thenMergeWithOr() { + // given: 用户有两个自定义部门权限的角色 + setSecurityContext(100L, "user", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_A"), new SimpleGrantedAuthority("ROLE_B")), + List.of( + RoleDataScope.custom("A", Arrays.asList(10L, 20L)), + RoleDataScope.custom("B", Arrays.asList(30L, 40L)) + )); + + Expression where = null; + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 生成OR连接的IN条件 + assertThat(result).isNotNull(); + String sql = result.toString(); + assertThat(sql).contains("OR"); + assertThat(sql).contains("dept_id IN"); + } + + @Test + @DisplayName("已有where条件 - 新条件用AND连接") + void whenExistingWhere_thenAndWithNewCondition() { + // given + setSecurityContext(100L, "user", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_USER")), + List.of(RoleDataScope.dept("USER"))); + + Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1)); + + // when + Expression result = handler.getSqlSegment(where, + "com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList"); + + // then: 结果应该包含原始where和数据权限条件 + assertThat(result).isInstanceOf(AndExpression.class); + String sql = result.toString(); + assertThat(sql).contains("status = 1"); + assertThat(sql).contains("dept_id = 10"); + } + } + + // ==================== 注解配置测试 ==================== + + @Nested + @DisplayName("注解配置测试") + class AnnotationTests { + + @Test + @DisplayName("无@DataPermission注解 - 返回原始where") + void whenNoAnnotation_thenReturnOriginalWhere() { + // given + setSecurityContext(100L, "user", 10L, + Set.of(new SimpleGrantedAuthority("ROLE_USER")), + List.of(RoleDataScope.dept("USER"))); + + Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1)); + + // when: 调用无注解的mapper方法 + Expression result = handler.getSqlSegment(where, + "java.lang.Object.toString"); + + // then: 返回原始where + assertThat(result).isSameAs(where); + } + } + + // ==================== 辅助方法 ==================== + + /** + * 设置安全上下文 + * + * @param userId 用户ID + * @param username 用户名 + * @param deptId 部门ID + * @param authorities 权限集合 + * @param dataScopes 数据权限列表 + */ + private void setSecurityContext(Long userId, String username, Long deptId, + Set authorities, + List dataScopes) { + SysUserDetails userDetails = new SysUserDetails(); + userDetails.setUserId(userId); + userDetails.setUsername(username); + userDetails.setDeptId(deptId); + userDetails.setDataScopes(dataScopes); + userDetails.setAuthorities(authorities); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/test/java/com/youlai/boot/plugin/mybatis/TestDataPermissionMapper.java b/src/test/java/com/youlai/boot/plugin/mybatis/TestDataPermissionMapper.java new file mode 100644 index 00000000..850bc2a3 --- /dev/null +++ b/src/test/java/com/youlai/boot/plugin/mybatis/TestDataPermissionMapper.java @@ -0,0 +1,41 @@ +package com.youlai.boot.plugin.mybatis; + +import com.youlai.boot.common.annotation.DataPermission; + +import java.util.List; + +/** + * 数据权限测试 Mapper + *

    + * 用于测试数据权限拦截器的SQL注入功能 + */ +public interface TestDataPermissionMapper { + + /** + * 查询列表(带数据权限过滤) + */ + @DataPermission + List selectList(); + + /** + * 查询列表(不带数据权限过滤) + */ + List selectListWithoutPermission(); + + /** + * 多表关联查询(指定别名) + */ + @DataPermission(deptAlias = "u", userAlias = "u") + List selectWithJoin(); + + /** + * 自定义列名查询(多表关联场景) + */ + @DataPermission( + deptAlias = "t", + deptIdColumnName = "dept_id", + userAlias = "t", + userIdColumnName = "create_by" + ) + List selectWithAlias(); +} diff --git a/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java b/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java new file mode 100644 index 00000000..5307f6c4 --- /dev/null +++ b/src/test/java/com/youlai/boot/security/token/JwtTokenManagerTest.java @@ -0,0 +1,355 @@ +package com.youlai.boot.security.token; + +import com.youlai.boot.config.property.SecurityProperties; +import com.youlai.boot.security.model.AuthenticationToken; +import com.youlai.boot.security.model.RoleDataScope; +import com.youlai.boot.security.model.SysUserDetails; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * JwtTokenManager 单元测试 + * + * @author Ray.Hao + */ +@ExtendWith(MockitoExtension.class) +class JwtTokenManagerTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private JwtTokenManager tokenManager; + private SecurityProperties securityProperties; + + private static final String TEST_SECRET_KEY = "TestSecretKey01234567890123456789"; + private static final int ACCESS_TOKEN_TTL = 3600; + private static final int REFRESH_TOKEN_TTL = 604800; + + @BeforeEach + void setUp() { + securityProperties = createSecurityProperties(); + + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + lenient().when(redisTemplate.hasKey(anyString())).thenReturn(false); + + tokenManager = new JwtTokenManager(securityProperties, redisTemplate); + } + + @Nested + @DisplayName("Token 生成测试") + class GenerateTokenTests { + + @Test + @DisplayName("应成功生成有效的访问令牌和刷新令牌") + void should_generate_valid_token() { + Authentication authentication = createTestAuthentication(); + + AuthenticationToken token = tokenManager.generateToken(authentication); + + assertThat(token).isNotNull(); + assertThat(token.getAccessToken()).isNotBlank(); + assertThat(token.getRefreshToken()).isNotBlank(); + assertThat(token.getTokenType()).isEqualTo("Bearer"); + assertThat(token.getExpiresIn()).isEqualTo(ACCESS_TOKEN_TTL); + assertThat(token.getAccessToken()).isNotEqualTo(token.getRefreshToken()); + } + + @Test + @DisplayName("生成的 Token 应包含用户信息") + void should_contain_user_info() { + Authentication authentication = createTestAuthentication(); + + AuthenticationToken token = tokenManager.generateToken(authentication); + Authentication parsed = tokenManager.parseToken(token.getAccessToken()); + + assertThat(parsed).isNotNull(); + assertThat(parsed.getName()).isEqualTo("testuser"); + + SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + assertThat(userDetails.getUserId()).isEqualTo(1L); + assertThat(userDetails.getDeptId()).isEqualTo(100L); + } + + @Test + @DisplayName("生成的 Token 应包含角色权限") + void should_contain_authorities() { + Authentication authentication = createTestAuthentication(); + + AuthenticationToken token = tokenManager.generateToken(authentication); + Authentication parsed = tokenManager.parseToken(token.getAccessToken()); + + Collection authorities = parsed.getAuthorities(); + assertThat(authorities).hasSize(2); + assertThat(authorities) + .extracting("authority") + .containsExactlyInAnyOrder("ROLE_ADMIN", "ROLE_USER"); + } + + @Test + @DisplayName("生成的 Token 应包含数据权限") + void should_contain_data_scopes() { + Authentication authentication = createTestAuthentication(); + + AuthenticationToken token = tokenManager.generateToken(authentication); + Authentication parsed = tokenManager.parseToken(token.getAccessToken()); + + SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + List dataScopes = userDetails.getDataScopes(); + + assertThat(dataScopes).hasSize(2); + assertThat(dataScopes.get(0).getRoleCode()).isEqualTo("ADMIN"); + assertThat(dataScopes.get(0).getDataScope()).isEqualTo(1); + } + } + + @Nested + @DisplayName("Token 校验测试") + class ValidateTokenTests { + + @Test + @DisplayName("有效 Token 校验应返回 true") + void should_validate_valid_token() { + Authentication authentication = createTestAuthentication(); + AuthenticationToken token = tokenManager.generateToken(authentication); + + boolean isValid = tokenManager.validateToken(token.getAccessToken()); + + assertThat(isValid).isTrue(); + } + + @Test + @DisplayName("无效签名 Token 校验应返回 false") + void should_reject_invalid_signature() { + String invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + + boolean isValid = tokenManager.validateToken(invalidToken); + + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("刷新令牌校验应区分访问令牌") + void should_distinguish_refresh_token() { + Authentication authentication = createTestAuthentication(); + AuthenticationToken token = tokenManager.generateToken(authentication); + + // 访问令牌不能作为刷新令牌使用 + boolean isValidRefreshToken = tokenManager.validateRefreshToken(token.getAccessToken()); + assertThat(isValidRefreshToken).isFalse(); + + // 刷新令牌是有效的 + boolean isValidRefreshToken2 = tokenManager.validateRefreshToken(token.getRefreshToken()); + assertThat(isValidRefreshToken2).isTrue(); + } + } + + @Nested + @DisplayName("Token 撤销测试") + class InvalidateTokenTests { + + @Test + @DisplayName("撤销 Token 后校验应返回 false") + void should_invalidate_token() { + Authentication authentication = createTestAuthentication(); + AuthenticationToken token = tokenManager.generateToken(authentication); + + // 先验证 Token 有效 + assertThat(tokenManager.validateToken(token.getAccessToken())).isTrue(); + + // 撤销 Token + tokenManager.invalidateToken(token.getAccessToken()); + + // 验证 Redis 存储了撤销标记 + verify(valueOperations).set(anyString(), any(Boolean.class), anyLong(), any()); + } + + @Test + @DisplayName("撤销空 Token 应安全处理") + void should_handle_null_token() { + tokenManager.invalidateToken(null); + tokenManager.invalidateToken(""); + + verify(valueOperations, never()).set(anyString(), any(), anyLong(), any()); + } + } + + @Nested + @DisplayName("用户会话失效测试") + class InvalidateUserSessionsTests { + + @Test + @DisplayName("失效用户所有会话后,旧 Token 应无效") + void should_invalidate_user_sessions() { + Authentication authentication = createTestAuthentication(); + AuthenticationToken token = tokenManager.generateToken(authentication); + + // 模拟用户会话失效后的 Redis 状态 + when(valueOperations.get(anyString())).thenReturn(System.currentTimeMillis() / 1000 + 1000); + + // 验证 Token 已失效 + boolean isValid = tokenManager.validateToken(token.getAccessToken()); + assertThat(isValid).isFalse(); + } + + @Test + @DisplayName("失效空用户 ID 应安全处理") + void should_handle_null_user_id() { + tokenManager.invalidateUserSessions(null); + + verify(valueOperations, never()).set(anyString(), any(), anyLong(), any()); + } + } + + @Nested + @DisplayName("Token 刷新测试") + class RefreshTokenTests { + + @Test + @DisplayName("应成功刷新访问令牌") + void should_refresh_access_token() { + Authentication authentication = createTestAuthentication(); + AuthenticationToken originalToken = tokenManager.generateToken(authentication); + + AuthenticationToken refreshedToken = tokenManager.refreshToken(originalToken.getRefreshToken()); + + assertThat(refreshedToken).isNotNull(); + assertThat(refreshedToken.getAccessToken()).isNotBlank(); + assertThat(refreshedToken.getAccessToken()).isNotEqualTo(originalToken.getAccessToken()); + assertThat(refreshedToken.getRefreshToken()).isEqualTo(originalToken.getRefreshToken()); + } + + @Test + @DisplayName("使用访问令牌刷新应失败") + void should_fail_with_access_token() { + Authentication authentication = createTestAuthentication(); + AuthenticationToken token = tokenManager.generateToken(authentication); + + // 使用访问令牌尝试刷新 + boolean isValidRefresh = tokenManager.validateRefreshToken(token.getAccessToken()); + assertThat(isValidRefresh).isFalse(); + } + } + + @Nested + @DisplayName("Token 解析测试") + class ParseTokenTests { + + @Test + @DisplayName("应正确解析 Token 中的用户信息") + void should_parse_user_details() { + Authentication authentication = createTestAuthentication(); + AuthenticationToken token = tokenManager.generateToken(authentication); + + Authentication parsed = tokenManager.parseToken(token.getAccessToken()); + + assertThat(parsed).isInstanceOf(UsernamePasswordAuthenticationToken.class); + assertThat(parsed.getPrincipal()).isInstanceOf(SysUserDetails.class); + + SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + assertThat(userDetails.getUserId()).isEqualTo(1L); + assertThat(userDetails.getDeptId()).isEqualTo(100L); + assertThat(userDetails.getUsername()).isEqualTo("testuser"); + } + + @Test + @DisplayName("应正确解析自定义部门数据权限") + void should_parse_custom_data_scope() { + Authentication authentication = createTestAuthenticationWithCustomScope(); + AuthenticationToken token = tokenManager.generateToken(authentication); + + Authentication parsed = tokenManager.parseToken(token.getAccessToken()); + SysUserDetails userDetails = (SysUserDetails) parsed.getPrincipal(); + + List dataScopes = userDetails.getDataScopes(); + assertThat(dataScopes).hasSize(1); + assertThat(dataScopes.get(0).getCustomDeptIds()).containsExactly(10L, 20L, 30L); + } + } + + // ========== 测试数据构建方法 ========== + + private SecurityProperties createSecurityProperties() { + SecurityProperties properties = new SecurityProperties(); + SecurityProperties.SessionConfig sessionConfig = new SecurityProperties.SessionConfig(); + sessionConfig.setType("jwt"); + sessionConfig.setAccessTokenTimeToLive(ACCESS_TOKEN_TTL); + sessionConfig.setRefreshTokenTimeToLive(REFRESH_TOKEN_TTL); + + SecurityProperties.JwtConfig jwtConfig = new SecurityProperties.JwtConfig(); + jwtConfig.setSecretKey(TEST_SECRET_KEY); + sessionConfig.setJwt(jwtConfig); + + properties.setSession(sessionConfig); + properties.setIgnoreUrls(new String[]{"/api/v1/auth/login/**"}); + properties.setUnsecuredUrls(new String[]{"/doc.html"}); + return properties; + } + + private Authentication createTestAuthentication() { + SysUserDetails userDetails = new SysUserDetails(); + userDetails.setUserId(1L); + userDetails.setUsername("testuser"); + userDetails.setDeptId(100L); + userDetails.setEnabled(true); + userDetails.setDataScopes(List.of( + new RoleDataScope("ADMIN", 1, null), // 全部数据权限 + new RoleDataScope("USER", 4, null) // 本人数据权限 + )); + userDetails.setAuthorities(Set.of( + new SimpleGrantedAuthority("ROLE_ADMIN"), + new SimpleGrantedAuthority("ROLE_USER") + )); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } + + private Authentication createTestAuthenticationWithCustomScope() { + SysUserDetails userDetails = new SysUserDetails(); + userDetails.setUserId(2L); + userDetails.setUsername("customuser"); + userDetails.setDeptId(200L); + userDetails.setEnabled(true); + userDetails.setDataScopes(List.of( + new RoleDataScope("CUSTOM_ROLE", 5, List.of(10L, 20L, 30L)) // 自定义部门权限 + )); + userDetails.setAuthorities(Set.of( + new SimpleGrantedAuthority("ROLE_CUSTOM") + )); + + return new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + } +}