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结构实现高效查询:
+ *
+ * - userSessionsMap: 用户名 -> 会话ID集合(支持多设备)
+ * - sessionDetailsMap: 会话ID -> 会话详情(快速定位用户)
+ *
+ *
+ * @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 认证模型:
+ *
+ * - 未认证状态:principal 为手机号,credentials 为验证码
+ * - 已认证状态:principal 为用户详情,credentials 为 null
+ *
*
* @author Ray.Hao
* @since 2.20.0
*/
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
+
@Serial
private static final long serialVersionUID = 621L;
/**
- * 认证信息 (手机号)
+ * 认证信息
+ *
+ * - 未认证时:手机号
+ * - 已认证时:SysUserDetails 用户详情
+ *
*/
private final Object principal;
/**
- * 凭证信息 (短信验证码)
+ * 凭证信息
+ *
+ * - 未认证时:短信验证码
+ * - 已认证时:null
+ *
*/
private final Object credentials;
/**
- * 短信验证码认证 Token (未认证)
+ * 创建未认证的 Token
*
- * @param principal 微信用户信息
+ * @param mobile 手机号
+ * @param verifyCode 短信验证码
*/
- public SmsAuthenticationToken(Object principal, Object credentials) {
- // 没有授权信息时,设置为 null
- super((Collection extends GrantedAuthority>) 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 extends GrantedAuthority> 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 extends GrantedAuthority> 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} 接口,处理短信验证码登录认证。
+ *
+ * 认证流程:
+ *
+ * - 根据手机号查询用户信息
+ * - 校验用户状态(是否禁用)
+ * - 校验短信验证码(与 Redis 缓存比对)
+ * - 验证成功后删除验证码,防止重复使用
+ * - 返回已认证的 Authentication
+ *
*
* @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