refactor: 会话失效、数据权限和实时推送重构

This commit is contained in:
Ray.Hao
2026-02-12 17:19:42 +08:00
parent 3a35b24476
commit faf6754bf4
44 changed files with 2145 additions and 515 deletions

View File

@@ -254,7 +254,7 @@ CREATE TABLE `sys_role` (
`code` varchar(32) NOT NULL COMMENT '角色编码', `code` varchar(32) NOT NULL COMMENT '角色编码',
`sort` int NULL COMMENT '显示顺序', `sort` int NULL COMMENT '显示顺序',
`status` tinyint(1) DEFAULT 1 COMMENT '角色状态(1-正常 0-停用)', `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_by` bigint NULL COMMENT '创建人 ID',
`create_time` datetime NULL COMMENT '创建时间', `create_time` datetime NULL COMMENT '创建时间',
`update_by` bigint NULL COMMENT '更新人ID', `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 '角色菜单唯一索引' UNIQUE INDEX `uk_roleid_menuid`(`role_id` ASC, `menu_id` ASC) USING BTREE COMMENT '角色菜单唯一索引'
) ENGINE = InnoDB CHARACTER SET = utf8mb4 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 -- 系统管理员角色菜单权限role_id=2
-- 顶级目录 -- 顶级目录

View File

@@ -26,18 +26,15 @@ public interface JwtClaimConstants {
String DEPT_ID = "deptId"; String DEPT_ID = "deptId";
/** /**
* 数据权限 * 数据权限列表
* <p>
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
*/ */
String DATA_SCOPE = "dataScope"; String DATA_SCOPES = "dataScopes";
/** /**
* 权限(角色Code)集合 * 权限(角色Code)集合
*/ */
String AUTHORITIES = "authorities"; String AUTHORITIES = "authorities";
/**
* 安全版本号,用于按用户失效历史令牌
*/
String SECURITY_VERSION = "securityVersion";
} }

View File

@@ -26,18 +26,19 @@ public interface RedisConstants {
* 认证模块 * 认证模块
*/ */
interface Auth { interface Auth {
// 存储访问令牌对应的用户信息accessToken -> OnlineUser // 存储访问令牌对应的用户会话信息accessToken -> UserSession
String ACCESS_TOKEN_USER = "auth:token:access:{}"; String ACCESS_TOKEN_USER = "auth:token:access:{}";
// 存储刷新令牌对应的用户信息refreshToken -> OnlineUser // 存储刷新令牌对应的用户会话信息refreshToken -> UserSession
String REFRESH_TOKEN_USER = "auth:token:refresh:{}"; String REFRESH_TOKEN_USER = "auth:token:refresh:{}";
// 用户与访问令牌的映射userId -> accessToken // 用户与访问令牌的映射userId -> accessToken
String USER_ACCESS_TOKEN = "auth:user:access:{}"; String USER_ACCESS_TOKEN = "auth:user:access:{}";
// 用户与刷新令牌的映射userId -> refreshToken // 用户与刷新令牌的映射userId -> refreshToken
String USER_REFRESH_TOKEN = "auth:user:refresh:{}"; String USER_REFRESH_TOKEN = "auth:user:refresh:{}";
// 黑名单 Token(用于退出登录或注销) // 已撤销 Token 的 JTI单端退出/会话注销):如果 jti 在撤销列表中,则 Token 立即无效
String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; String BLACKLIST_TOKEN = "auth:token:blacklist:{}";
// 用户安全版本号(用于按用户失效历史 JWT String REVOKED_JTI = BLACKLIST_TOKEN;
String USER_SECURITY_VERSION = "auth:user:security_version:{}"; // 用户 Token 生效起点(用于按用户失效历史 JWTtoken.iat < tokenValidAfter => token 无效
String USER_TOKEN_VALID_AFTER = "auth:user:token_valid_after:{}";
} }
/** /**

View File

@@ -5,6 +5,10 @@ import lombok.Getter;
/** /**
* 数据权限枚举 * 数据权限枚举
* <p>
* value 越小,数据权限范围越大。
* 多角色数据权限合并策略取并集OR即用户能看到所有角色权限范围内的数据。
* 如果任一角色是 ALL则直接跳过数据权限过滤。
* *
* @author Ray.Hao * @author Ray.Hao
* @since 2.3.0 * @since 2.3.0
@@ -13,12 +17,31 @@ import lombok.Getter;
public enum DataScopeEnum implements IBaseEnum<Integer> { public enum DataScopeEnum implements IBaseEnum<Integer> {
/** /**
* value 越小,数据权限范围越大 * 所有数据权限 - 最高权限,可查看所有数据
*/ */
ALL(1, "所有数据"), ALL(1, "所有数据"),
/**
* 部门及子部门数据 - 可查看本部门及其下属所有部门的数据
*/
DEPT_AND_SUB(2, "部门及子部门数据"), DEPT_AND_SUB(2, "部门及子部门数据"),
/**
* 本部门数据 - 仅可查看本部门的数据
*/
DEPT(3, "本部门数据"), DEPT(3, "本部门数据"),
SELF(4, "本人数据");
/**
* 本人数据 - 仅可查看自己的数据
*/
SELF(4, "本人数据"),
/**
* 自定义部门数据 - 可查看指定部门的数据
* <p>
* 需要配合 sys_role_dept 表使用存储角色可访问的部门ID列表
*/
CUSTOM(5, "自定义部门数据");
private final Integer value; private final Integer value;
@@ -28,4 +51,32 @@ public enum DataScopeEnum implements IBaseEnum<Integer> {
this.value = value; this.value = value;
this.label = label; 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;
}
} }

View File

@@ -3,6 +3,7 @@ package com.youlai.boot.config.property;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -53,7 +54,8 @@ public class SecurityProperties {
* <li>redis-token - 基于Redis的有状态认证</li> * <li>redis-token - 基于Redis的有状态认证</li>
* </ul> * </ul>
*/ */
@NotNull @NotNull(message = "会话类型不能为空")
@Pattern(regexp = "jwt|redis-token", message = "会话类型只能是 jwt 或 redis-token")
private String type; private String type;
/** /**

View File

@@ -1,13 +1,40 @@
package com.youlai.boot.platform.websocket.dto; package com.youlai.boot.platform.websocket.dto;
import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* 字典变更事件
* <p>
* 当字典数据发生变更时,通过 WebSocket 广播此事件通知前端清除缓存。
* 前端收到通知后清除对应字典的本地缓存,下次使用时重新从服务端加载。
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data @Data
public class DictChangeEvent { @NoArgsConstructor
@AllArgsConstructor
public class DictChangeEvent implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 字典编码 */
private String dictCode; private String dictCode;
/** 事件时间戳 */
private long timestamp; private long timestamp;
/**
* 创建字典变更事件(自动设置当前时间戳)
*
* @param dictCode 字典编码
*/
public DictChangeEvent(String dictCode) { public DictChangeEvent(String dictCode) {
this.dictCode = dictCode; this.dictCode = dictCode;
this.timestamp = System.currentTimeMillis(); this.timestamp = System.currentTimeMillis();

View File

@@ -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
* <p>
* 用于返回在线用户的基本信息,包括用户名、会话数量和登录时间。
*
* @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;
}

View File

@@ -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;
/**
* 在线用户数统计定时任务
* <p>
* 定时统计并广播当前在线用户数量到所有WebSocket客户端。
* 用于解决以下问题:
* <ul>
* <li>客户端页面刷新后可快速同步最新在线人数</li>
* <li>减少服务端主动推送频率,降低资源消耗</li>
* </ul>
*
* @author Ray.Hao
* @since 3.0.0
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class OnlineUserCountJob {
private final UserSessionRegistry userSessionRegistry;
private final WebSocketPublisher webSocketPublisher;
/**
* 定时统计在线用户数并广播
* <p>
* 每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);
}
}

View File

@@ -1,6 +1,7 @@
package com.youlai.boot.platform.websocket.service.impl; package com.youlai.boot.platform.websocket.service.impl;
import com.youlai.boot.platform.websocket.dto.DictChangeEvent; 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.dto.TextMessage;
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher; import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
import com.youlai.boot.platform.websocket.session.UserSessionRegistry; import com.youlai.boot.platform.websocket.session.UserSessionRegistry;
@@ -101,7 +102,7 @@ public class WebSocketServiceImpl implements WebSocketService {
* *
* @return 在线用户信息列表 * @return 在线用户信息列表
*/ */
public List<UserSessionRegistry.OnlineUserDTO> getOnlineUsers() { public List<OnlineUserDTO> getOnlineUsers() {
return userSessionRegistry.getOnlineUsers(); return userSessionRegistry.getOnlineUsers();
} }

View File

@@ -1,8 +1,9 @@
package com.youlai.boot.platform.websocket.session; package com.youlai.boot.platform.websocket.session;
import com.youlai.boot.platform.websocket.dto.OnlineUserDTO;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
@@ -11,25 +12,74 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* WebSocket 用户会话注册表
* <p>
* 维护WebSocket连接的用户会话信息支持多设备同时登录。
* 采用双Map结构实现高效查询
* <ul>
* <li>userSessionsMap: 用户名 -> 会话ID集合支持多设备</li>
* <li>sessionDetailsMap: 会话ID -> 会话详情(快速定位用户)</li>
* </ul>
*
* @author Ray.Hao
* @since 3.0.0
*/
@Slf4j
@Component @Component
public class UserSessionRegistry { public class UserSessionRegistry {
/**
* 用户会话映射表
* <p>
* Key: 用户名
* Value: 该用户所有WebSocket会话ID集合支持多设备登录
*/
private final Map<String, Set<String>> userSessionsMap = new ConcurrentHashMap<>(); private final Map<String, Set<String>> userSessionsMap = new ConcurrentHashMap<>();
/**
* 会话详情映射表
* <p>
* Key: WebSocket会话ID
* Value: 会话详情(包含用户名、连接时间等)
*/
private final Map<String, SessionInfo> sessionDetailsMap = new ConcurrentHashMap<>(); private final Map<String, SessionInfo> sessionDetailsMap = new ConcurrentHashMap<>();
/**
* 用户上线建立WebSocket连接
*
* @param username 用户名
* @param sessionId WebSocket会话ID
*/
public void userConnected(String username, String sessionId) { public void userConnected(String username, String sessionId) {
userSessionsMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()).add(sessionId); userSessionsMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()).add(sessionId);
sessionDetailsMap.put(sessionId, new SessionInfo(username, sessionId, System.currentTimeMillis())); sessionDetailsMap.put(sessionId, new SessionInfo(username, sessionId, System.currentTimeMillis()));
log.debug("用户[{}]会话[{}]已注册", username, sessionId);
} }
/**
* 用户下线断开所有WebSocket连接
* <p>
* 移除该用户的所有会话信息
*
* @param username 用户名
*/
public void userDisconnected(String username) { public void userDisconnected(String username) {
Set<String> sessions = userSessionsMap.remove(username); Set<String> sessions = userSessionsMap.remove(username);
if (sessions == null) { if (sessions == null) {
return; return;
} }
sessions.forEach(sessionDetailsMap::remove); sessions.forEach(sessionDetailsMap::remove);
log.debug("用户[{}]已下线,移除{}个会话", username, sessions.size());
} }
/**
* 移除指定会话(单设备下线)
* <p>
* 当用户某一设备断开连接时调用,保留其他设备的会话
*
* @param sessionId WebSocket会话ID
*/
public void removeSession(String sessionId) { public void removeSession(String sessionId) {
SessionInfo sessionInfo = sessionDetailsMap.remove(sessionId); SessionInfo sessionInfo = sessionDetailsMap.remove(sessionId);
if (sessionInfo == null) { if (sessionInfo == null) {
@@ -44,33 +94,63 @@ public class UserSessionRegistry {
sessions.remove(sessionId); sessions.remove(sessionId);
if (sessions.isEmpty()) { if (sessions.isEmpty()) {
// 该用户没有任何会话了,移除用户记录
userSessionsMap.remove(username); userSessionsMap.remove(username);
log.debug("用户[{}]最后一个会话已移除", username);
} }
} }
/**
* 获取在线用户数量
*
* @return 当前在线用户数(非会话数)
*/
public int getOnlineUserCount() { public int getOnlineUserCount() {
return userSessionsMap.size(); return userSessionsMap.size();
} }
/**
* 获取指定用户的会话数量
*
* @param username 用户名
* @return 该用户的WebSocket会话数量多设备登录时大于1
*/
public int getUserSessionCount(String username) { public int getUserSessionCount(String username) {
Set<String> sessions = userSessionsMap.get(username); Set<String> sessions = userSessionsMap.get(username);
return sessions != null ? sessions.size() : 0; return sessions != null ? sessions.size() : 0;
} }
/**
* 获取在线会话总数
*
* @return 所有WebSocket会话的总数包含多设备
*/
public int getTotalSessionCount() { public int getTotalSessionCount() {
return sessionDetailsMap.size(); return sessionDetailsMap.size();
} }
/**
* 检查用户是否在线
*
* @param username 用户名
* @return 是否在线(至少有一个活跃会话)
*/
public boolean isUserOnline(String username) { public boolean isUserOnline(String username) {
Set<String> sessions = userSessionsMap.get(username); Set<String> sessions = userSessionsMap.get(username);
return sessions != null && !sessions.isEmpty(); return sessions != null && !sessions.isEmpty();
} }
/**
* 获取所有在线用户列表
*
* @return 在线用户信息列表
*/
public List<OnlineUserDTO> getOnlineUsers() { public List<OnlineUserDTO> getOnlineUsers() {
return userSessionsMap.entrySet().stream() return userSessionsMap.entrySet().stream()
.map(entry -> { .map(entry -> {
String username = entry.getKey(); String username = entry.getKey();
Set<String> sessions = entry.getValue(); Set<String> sessions = entry.getValue();
// 取最早的连接时间作为登录时间
long earliestLoginTime = sessions.stream() long earliestLoginTime = sessions.stream()
.map(sessionDetailsMap::get) .map(sessionDetailsMap::get)
.filter(info -> info != null) .filter(info -> info != null)
@@ -83,21 +163,17 @@ public class UserSessionRegistry {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* WebSocket 会话详情(内部使用)
*/
@Data @Data
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor
private static class SessionInfo { private static class SessionInfo {
/** 用户名 */
private String username; private String username;
/** WebSocket会话ID */
private String sessionId; private String sessionId;
/** 连接时间戳 */
private long connectTime; private long connectTime;
} }
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class OnlineUserDTO {
private String username;
private int sessionCount;
private long loginTime;
}
} }

View File

@@ -1,22 +1,38 @@
package com.youlai.boot.plugin.mybatis; package com.youlai.boot.plugin.mybatis;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.StringPool; import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler; import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler;
import com.youlai.boot.common.annotation.DataPermission; 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.common.enums.DataScopeEnum;
import com.youlai.boot.security.model.RoleDataScope;
import com.youlai.boot.security.util.SecurityUtils; import com.youlai.boot.security.util.SecurityUtils;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j; 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.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.lang.reflect.Method;
import java.util.List;
/** /**
* 数据权限控制器 * 数据权限控制器
* <p>
* 支持多角色数据权限合并(并集策略):
* - 如果任一角色是 ALL则跳过数据权限过滤
* - 否则用 OR 连接各角色的数据权限条件
* <p>
* 使用 JSQLParser 构建 SQL 条件,避免字符串拼接,提高代码安全性和可读性。
* *
* @author zc * @author zc
* @since 2021-12-10 13:28 * @since 2021-12-10 13:28
@@ -24,9 +40,14 @@ import java.lang.reflect.Method;
@Slf4j @Slf4j
public class MyDataPermissionHandler implements DataPermissionHandler { 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片段 * 获取数据权限的sql片段
* @param where 查询条件 *
* @param where 查询条件
* @param mappedStatementId mapper接口方法的全路径 * @param mappedStatementId mapper接口方法的全路径
* @return sql片段 * @return sql片段
*/ */
@@ -34,16 +55,25 @@ public class MyDataPermissionHandler implements DataPermissionHandler {
@SneakyThrows @SneakyThrows
public Expression getSqlSegment(Expression where, String mappedStatementId) { public Expression getSqlSegment(Expression where, String mappedStatementId) {
// 如果是未登录或者是定时任务执行的SQL或者是超级管理员直接返回 // 如果是未登录或者是定时任务执行的SQL或者是超级管理员直接返回
if(SecurityUtils.getUserId() == null || SecurityUtils.isRoot()){ if (SecurityUtils.getUserId() == null || SecurityUtils.isRoot()) {
return where; return where;
} }
// 获取当前用户的数据权限
Integer dataScope = SecurityUtils.getDataScope(); // 获取当前用户的数据权限列表
DataScopeEnum dataScopeEnum = IBaseEnum.getEnumByValue(dataScope, DataScopeEnum.class); List<RoleDataScope> dataScopes = SecurityUtils.getUser()
// 如果是全部数据权限,直接返回 .map(user -> user.getDataScopes())
if (DataScopeEnum.ALL.equals(dataScopeEnum)) { .orElse(List.of());
// 如果任一角色是 ALL则跳过数据权限过滤并集策略
if (hasAllDataScope(dataScopes)) {
return where; return where;
} }
// 如果没有数据权限,跳过过滤
if (CollectionUtil.isEmpty(dataScopes)) {
return where;
}
// 获取当前执行的接口类 // 获取当前执行的接口类
Class<?> clazz = Class.forName(mappedStatementId.substring(0, mappedStatementId.lastIndexOf(StringPool.DOT))); Class<?> clazz = Class.forName(mappedStatementId.substring(0, mappedStatementId.lastIndexOf(StringPool.DOT)));
// 获取当前执行的方法名称 // 获取当前执行的方法名称
@@ -51,65 +81,207 @@ public class MyDataPermissionHandler implements DataPermissionHandler {
// 获取当前执行的接口类里所有的方法 // 获取当前执行的接口类里所有的方法
Method[] methods = clazz.getDeclaredMethods(); Method[] methods = clazz.getDeclaredMethods();
for (Method method : methods) { for (Method method : methods) {
//找到当前执行的方法 // 找到当前执行的方法
if (method.getName().equals(methodName)) { if (method.getName().equals(methodName)) {
DataPermission annotation = method.getAnnotation(DataPermission.class); DataPermission annotation = method.getAnnotation(DataPermission.class);
// 判断当前执行的方法是否有权限注解,如果没有注解直接返回 // 判断当前执行的方法是否有权限注解,如果没有注解直接返回
if (annotation == null ) { if (annotation == null) {
return where; return where;
} }
return dataScopeFilter(annotation.deptAlias(), annotation.deptIdColumnName(), annotation.userAlias(), annotation.userIdColumnName(), dataScopeEnum,where); // 使用并集策略过滤
return dataScopeFilterWithUnion(annotation, dataScopes, where);
} }
} }
return where; return where;
} }
/** /**
* 构建过滤条件 * 判断是否包含"全部数据"权限
* *
* @param where 当前查询条件 * @param dataScopes 数据权限列表
* @return 构建后查询条件 * @return 是否有全部数据权限
*/ */
@SneakyThrows private boolean hasAllDataScope(List<RoleDataScope> dataScopes) {
public static Expression dataScopeFilter(String deptAlias, String deptIdColumnName, String userAlias, String userIdColumnName,DataScopeEnum dataScopeEnum, Expression where) { 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; * <p>
* 多个角色的数据权限通过 OR 连接,实现并集效果
*
* @param annotation 数据权限注解
* @param dataScopes 数据权限列表
* @param where 原始查询条件
* @return 追加权限过滤后的查询条件
*/
private Expression dataScopeFilterWithUnion(DataPermission annotation, List<RoleDataScope> dataScopes, Expression where) {
String deptAlias = annotation.deptAlias();
String deptIdColumnName = annotation.deptIdColumnName();
String userAlias = annotation.userAlias();
String userIdColumnName = annotation.userIdColumnName();
Long deptId, userId; // 构建各角色的数据权限条件,使用 OR 连接实现并集
String appendSqlStr; Expression unionExpression = null;
switch (dataScopeEnum) { for (RoleDataScope dataScope : dataScopes) {
case ALL: Expression roleExpression = buildRoleDataScopeExpression(
return where; deptAlias, deptIdColumnName, userAlias, userIdColumnName, dataScope);
case DEPT: if (roleExpression != null) {
deptId = SecurityUtils.getDeptId(); if (unionExpression == null) {
appendSqlStr = deptColumnName + StringPool.EQUALS + deptId; unionExpression = roleExpression;
break; } else {
case SELF: // 使用 OR 连接各角色的条件(并集)
userId = SecurityUtils.getUserId(); unionExpression = new OrExpression(unionExpression, roleExpression);
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;
} }
if (StrUtil.isBlank(appendSqlStr)) { if (unionExpression == null) {
return where; return where;
} }
Expression appendExpression = CCJSqlParserUtil.parseCondExpression(appendSqlStr); // 用括号包裹并集条件
Expression finalExpression = new Parenthesis(unionExpression);
if (where == null) { if (where == null) {
return appendExpression; return finalExpression;
} }
return new AndExpression(where, appendExpression); return new AndExpression(where, finalExpression);
} }
/**
* 构建单个角色的数据权限SQL条件
* <p>
* 使用 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;
}
/**
* 构建部门及子部门数据权限条件
* <p>
* 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);
}
/**
* 构建自定义部门数据权限条件
* <p>
* SQL: dept_id IN (?, ?, ...)
*
* @param deptColumn 部门列
* @param customDeptIds 自定义部门ID列表
* @return IN 表达式,如果没有部门则返回 1=0
*/
private Expression buildCustomDeptExpression(Column deptColumn, List<Long> 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<Expression> deptIdList = new ExpressionList<>();
for (Long deptId : customDeptIds) {
deptIdList.addExpression(new LongValue(deptId));
}
return new InExpression(deptColumn, deptIdList);
}
} }

View File

@@ -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;
/**
* 角色数据权限信息
* <p>
* 用于存储单个角色的数据权限范围信息,支持多角色数据权限合并(并集策略)
*
* @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<Long> 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<Long> deptIds) {
return new RoleDataScope(roleCode, 5, deptIds);
}
}

View File

@@ -8,59 +8,71 @@ import java.util.Collection;
/** /**
* 短信验证码认证 Token * 短信验证码认证 Token
* <p>
* 用于短信验证码登录场景,遵循 Spring Security 认证模型:
* <ul>
* <li>未认证状态principal 为手机号credentials 为验证码</li>
* <li>已认证状态principal 为用户详情credentials 为 null</li>
* </ul>
* *
* @author Ray.Hao * @author Ray.Hao
* @since 2.20.0 * @since 2.20.0
*/ */
public class SmsAuthenticationToken extends AbstractAuthenticationToken { public class SmsAuthenticationToken extends AbstractAuthenticationToken {
@Serial @Serial
private static final long serialVersionUID = 621L; private static final long serialVersionUID = 621L;
/** /**
* 认证信息 (手机号) * 认证信息
* <ul>
* <li>未认证时:手机号</li>
* <li>已认证时SysUserDetails 用户详情</li>
* </ul>
*/ */
private final Object principal; private final Object principal;
/** /**
* 凭证信息 (短信验证码) * 凭证信息
* <ul>
* <li>未认证时:短信验证码</li>
* <li>已认证时null</li>
* </ul>
*/ */
private final Object credentials; private final Object credentials;
/** /**
* 短信验证码认证 Token (未认证) * 创建未认证 Token
* *
* @param principal 微信用户信息 * @param mobile 手机号
* @param verifyCode 短信验证码
*/ */
public SmsAuthenticationToken(Object principal, Object credentials) { public SmsAuthenticationToken(String mobile, String verifyCode) {
// 没有授权信息时,设置为 null super(null);
super((Collection<? extends GrantedAuthority>) null); this.principal = mobile;
this.principal = principal; this.credentials = verifyCode;
this.credentials = credentials; setAuthenticated(false);
// 默认未认证
this.setAuthenticated(false);
} }
/** /**
* 短信验证码认证 Token (已认证) * 创建已认证 Token
* *
* @param principal 用户信息 * @param principal 用户详情SysUserDetails
* @param authorities 授权信息 * @param authorities 授权信息
*/ */
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) { public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities); super(authorities);
this.principal = principal; this.principal = principal;
this.credentials = null; this.credentials = null;
// 认证通过
super.setAuthenticated(true); super.setAuthenticated(true);
} }
/** /**
* 认证通过 * 创建已认证的 Token静态工厂方法
* *
* @param principal 用户信息 * @param principal 用户详情SysUserDetails
* @param authorities 授权信息 * @param authorities 授权信息
* @return SmsAuthenticationToken * @return 已认证的 SmsAuthenticationToken
*/ */
public static SmsAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) { public static SmsAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
return new SmsAuthenticationToken(principal, authorities); return new SmsAuthenticationToken(principal, authorities);

View File

@@ -3,15 +3,13 @@ package com.youlai.boot.security.model;
import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import com.youlai.boot.common.constant.SecurityConstants; import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.security.model.UserAuthInfo;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection; import java.util.*;
import java.util.Collections;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -53,9 +51,11 @@ public class SysUserDetails implements UserDetails {
private Long deptId; private Long deptId;
/** /**
* 数据权限范围 * 数据权限列表
* <p>
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
*/ */
private Integer dataScope; private List<RoleDataScope> dataScopes;
/** /**
* 用户角色权限集合 * 用户角色权限集合
@@ -73,7 +73,7 @@ public class SysUserDetails implements UserDetails {
this.password = user.getPassword(); this.password = user.getPassword();
this.enabled = ObjectUtil.equal(user.getStatus(), 1); this.enabled = ObjectUtil.equal(user.getStatus(), 1);
this.deptId = user.getDeptId(); this.deptId = user.getDeptId();
this.dataScope = user.getDataScope(); this.dataScopes = user.getDataScopes();
// 初始化角色权限集合 // 初始化角色权限集合
this.authorities = CollectionUtil.isNotEmpty(user.getRoles()) this.authorities = CollectionUtil.isNotEmpty(user.getRoles())
@@ -104,4 +104,26 @@ public class SysUserDetails implements UserDetails {
public boolean isEnabled() { public boolean isEnabled() {
return this.enabled; 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<RoleDataScope> getDataScopes() {
return dataScopes != null ? dataScopes : Collections.emptyList();
}
} }

View File

@@ -2,6 +2,7 @@ package com.youlai.boot.security.model;
import lombok.Data; import lombok.Data;
import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
@@ -52,7 +53,9 @@ public class UserAuthInfo {
private Set<String> roles; private Set<String> roles;
/** /**
* 数据权限范围 * 数据权限列表
* <p>
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
*/ */
private Integer dataScope; private List<RoleDataScope> dataScopes;
} }

View File

@@ -4,10 +4,14 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
* 在线用户信息对象 * 用户会话信息
* <p>
* 存储在Token中的用户会话快照包含用户身份数据权限和角色权限信息
* 用于Redis-Token模式下的会话管理支持在线用户查询和会话控制
* *
* @author wangtao * @author wangtao
* @since 2025/2/27 10:31 * @since 2025/2/27 10:31
@@ -15,7 +19,7 @@ import java.util.Set;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class OnlineUser { public class UserSession {
/** /**
* 用户ID * 用户ID
@@ -33,10 +37,9 @@ public class OnlineUser {
private Long deptId; private Long deptId;
/** /**
* 数据权限范围 * 数据权限列表
* <p>定义用户可访问的数据范围如全部本部门或自定义范围</p>
*/ */
private Integer dataScope; private List<RoleDataScope> dataScopes;
/** /**
* 角色权限集合 * 角色权限集合

View File

@@ -18,9 +18,22 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
/** /**
* 短信验证码认证 Provider * 短信验证码认证 Provider
* <p>
* 实现 Spring Security 的 {@link AuthenticationProvider} 接口,处理短信验证码登录认证。
* <p>
* 认证流程:
* <ol>
* <li>根据手机号查询用户信息</li>
* <li>校验用户状态(是否禁用)</li>
* <li>校验短信验证码(与 Redis 缓存比对)</li>
* <li>验证成功后删除验证码,防止重复使用</li>
* <li>返回已认证的 Authentication</li>
* </ol>
* *
* @author Ray.Hao * @author Ray.Hao
* @since 2.17.0 * @since 2.17.0
* @see SmsAuthenticationToken
* @see AuthenticationProvider
*/ */
@Slf4j @Slf4j
public class SmsAuthenticationProvider implements AuthenticationProvider { public class SmsAuthenticationProvider implements AuthenticationProvider {
@@ -29,58 +42,79 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
public SmsAuthenticationProvider(UserService userService, RedisTemplate<String, Object> redisTemplate) { public SmsAuthenticationProvider(UserService userService, RedisTemplate<String, Object> redisTemplate) {
this.userService = userService; this.userService = userService;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
} }
/** /**
* 短信验证码认证逻辑,参考 Spring Security 认证密码校验流程 * 执行短信验证码认证
* *
* @param authentication 认证对象 * @param authentication 认证的 {@link SmsAuthenticationToken}
* @return 认证的 Authentication 对象 * @return 认证的 {@link SmsAuthenticationToken}
* @throws AuthenticationException 认证异常 * @throws AuthenticationException 认证失败异常
* @see org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate(Authentication)
*/ */
@Override @Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException { public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String mobile = (String) authentication.getPrincipal(); String mobile = (String) authentication.getPrincipal();
String inputVerifyCode = (String) authentication.getCredentials(); 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); UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile);
if (userAuthInfo == null) { if (userAuthInfo == null) {
log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile);
throw new UsernameNotFoundException("用户不存在"); throw new UsernameNotFoundException("用户不存在");
} }
// 检查用户状态是否有效 // 检查用户状态是否有效
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) { if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername());
throw new DisabledException("用户已被禁用"); throw new DisabledException("用户已被禁用");
} }
// 校验发送短信验证码的手机号是否与当前登录用户一致 // 校验短信验证码
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile); String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey); String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) { if (cachedVerifyCode == null) {
throw new CaptchaValidationException("验证码错误"); log.warn("短信验证码登录失败:验证码已过期,手机号={}", mobile);
} else { throw new CaptchaValidationException("验证码已过期,请重新获取");
// 验证成功后删除验证码
redisTemplate.delete(cacheKey);
} }
if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) {
log.warn("短信验证码登录失败:验证码错误,手机号={}", mobile);
throw new CaptchaValidationException("验证码错误");
}
// 验证成功后删除验证码,防止重复使用
redisTemplate.delete(cacheKey);
// 构建认证后的用户详情信息 // 构建认证后的用户详情信息
SysUserDetails userDetails = new SysUserDetails(userAuthInfo); SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile);
// 创建已认证的 SmsAuthenticationToken // 创建已认证的 SmsAuthenticationToken
return SmsAuthenticationToken.authenticated( return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
userDetails,
userDetails.getAuthorities()
);
} }
/**
* 支持的认证类型
*
* @param authentication 认证类型
* @return 是否支持该认证类型
*/
@Override @Override
public boolean supports(Class<?> authentication) { public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication); return SmsAuthenticationToken.class.isAssignableFrom(authentication);

View File

@@ -2,18 +2,21 @@ package com.youlai.boot.security.service;
import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.security.util.SecurityUtils; import com.youlai.boot.security.util.SecurityUtils;
import com.youlai.boot.system.service.RoleMenuService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.PatternMatchUtils; import org.springframework.util.PatternMatchUtils;
import java.util.*; import java.util.Set;
/** /**
* SpringSecurity 权限校验 * Spring Security 权限校验组件
* <p>
* 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:add')")
* <p>
* 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存)
* *
* @author Ray.Hao * @author Ray.Hao
* @since 0.0.1 * @since 0.0.1
@@ -23,19 +26,21 @@ import java.util.*;
@Slf4j @Slf4j
public class PermissionService { public class PermissionService {
private final RedisTemplate<String, Object> redisTemplate; private final RoleMenuService roleMenuService;
/** /**
* 判断当前登录用户是否拥有操作权限 * 判断当前登录用户是否拥有操作权限
* <p>
* 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:add"、"sys:user:delete" 等
* *
* @param requiredPerm 所需权限 * @param requiredPerm 所需权限
* @return 是否有权限 * @return 是否有权限
*/ */
public boolean hasPerm(String requiredPerm) { public boolean hasPerm(String requiredPerm) {
if (StrUtil.isBlank(requiredPerm)) { if (StrUtil.isBlank(requiredPerm)) {
return false; return false;
} }
// 超级管理员放行 // 超级管理员放行
if (SecurityUtils.isRoot()) { if (SecurityUtils.isRoot()) {
return true; return true;
@@ -47,52 +52,21 @@ public class PermissionService {
return false; return false;
} }
// 获取当前登录用户的所有角色的权限列表 // 获取当前登录用户的所有角色的权限列表(从缓存读取)
Set<String> rolePerms = this.getRolePermsFormCache(roleCodes); Set<String> rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes);
if (CollectionUtil.isEmpty(rolePerms)) { if (CollectionUtil.isEmpty(rolePerms)) {
return false; return false;
} }
// 判断当前登录用户的所有角色的权限列表中是否包含所需权限
// 判断权限列表中是否包含所需权限(支持通配符)
boolean hasPermission = rolePerms.stream() boolean hasPermission = rolePerms.stream()
.anyMatch(rolePerm -> .anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm));
// 匹配权限,支持通配符(* 等)
PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)
);
if (!hasPermission) { if (!hasPermission) {
log.error("用户无操作权限:{}",requiredPerm); log.warn("用户无操作权限:userId={}, username={}, requiredPerm={}",
SecurityUtils.getUserId(), SecurityUtils.getUsername(), requiredPerm);
} }
return hasPermission; return hasPermission;
} }
/**
* 从缓存中获取角色权限列表
*
* @param roleCodes 角色编码集合
* @return 角色权限列表
*/
public Set<String> getRolePermsFormCache(Set<String> roleCodes) {
if (CollectionUtil.isEmpty(roleCodes)) {
return Collections.emptySet();
}
// 构建缓存Key
String cacheKey = RedisConstants.System.ROLE_PERMS;
Set<String> perms = new HashSet<>();
Collection<Object> roleCodesAsObjects = new ArrayList<>(roleCodes);
List<Object> rolePermsList = redisTemplate.opsForHash().multiGet(cacheKey, roleCodesAsObjects);
for (Object rolePermsObj : rolePermsList) {
if (rolePermsObj instanceof Set) {
@SuppressWarnings("unchecked")
Set<String> rolePerms = (Set<String>) rolePermsObj;
perms.addAll(rolePerms);
}
}
return perms;
}
} }

View File

@@ -4,7 +4,9 @@ import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject; import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.hutool.jwt.JWT; import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTPayload; import cn.hutool.jwt.JWTPayload;
import cn.hutool.jwt.JWTUtil; 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.core.web.ResultCode;
import com.youlai.boot.config.property.SecurityProperties; import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.security.model.AuthenticationToken; import com.youlai.boot.security.model.AuthenticationToken;
import com.youlai.boot.security.model.RoleDataScope;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import com.youlai.boot.security.model.SysUserDetails; import com.youlai.boot.security.model.SysUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 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.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Date; import java.util.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* JWT Token 管理器 * JWT Token 管理器
* <p> * <p>
* 用于生成、解析、校验、刷新 JWT Token * 实现基于JWT的无状态认证支持
* <ul>
* <li>Access Token + Refresh Token 双令牌机制</li>
* <li>Token 撤销jti黑名单</li>
* <li>用户级会话失效tokenValidAfter</li>
* <li>多角色数据权限存储</li>
* </ul>
* *
* @author Ray.Hao * @author Ray.Hao
* @since 2024/11/15 * @since 2024/11/15
@@ -44,6 +50,9 @@ import java.util.stream.Collectors;
@Service @Service
public class JwtTokenManager implements TokenManager { 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 SecurityProperties securityProperties;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final byte[] secretKey; private final byte[] secretKey;
@@ -90,7 +99,25 @@ public class JwtTokenManager implements TokenManager {
SysUserDetails userDetails = new SysUserDetails(); SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_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<RoleDataScope> 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<Long> 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)); // 用户名 userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); // 用户名
// 角色集合 // 角色集合
@@ -126,9 +153,17 @@ public class JwtTokenManager implements TokenManager {
/** /**
* 校验令牌 * 校验令牌
* <p>
* 校验流程(按顺序执行):
* <ol>
* <li>签名验证 + 过期时间检查</li>
* <li>刷新令牌类型校验(仅刷新场景)</li>
* <li>tokenValidAfter 校验(用户级会话失效)</li>
* <li>jti 黑名单校验单Token撤销</li>
* </ol>
* *
* @param token JWT Token * @param token JWT Token
* @param validateRefreshToken 是否校验刷新令牌 * @param validateRefreshToken 是否校验刷新令牌类型
* @return 是否有效 * @return 是否有效
*/ */
private boolean validateToken(String token, boolean validateRefreshToken) { private boolean validateToken(String token, boolean validateRefreshToken) {
@@ -147,27 +182,28 @@ public class JwtTokenManager implements TokenManager {
return false; return false;
} }
} }
// 2. 校验安全版本号(用于按用户维度失效历史 Token // 2. 校验 tokenValidAfter(用于按用户维度失效历史 Token
// 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,将用户安全版本号 +1旧版本 Token 全部失效 // 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,更新 tokenValidAfter早于该时间签发的 Token 全部失效
Long userId = payloads.getLong(JwtClaimConstants.USER_ID); Long userId = payloads.getLong(JwtClaimConstants.USER_ID);
if (userId != null) { if (userId != null) {
// 老版本 Token 可能没有 SECURITY_VERSION 声明,视为 0 版本 Object issuedAtObj = payloads.get(JWTPayload.ISSUED_AT);
Integer tokenVersionRaw = payloads.getInt(JwtClaimConstants.SECURITY_VERSION); long issuedAtSeconds = 0;
int tokenVersion = tokenVersionRaw != null ? tokenVersionRaw : 0; if (issuedAtObj instanceof Date issuedAtDate) {
issuedAtSeconds = issuedAtDate.getTime() / 1000;
}
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId); String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId);
Integer currentVersionRaw = (Integer) redisTemplate.opsForValue().get(versionKey); Object validAfterObj = redisTemplate.opsForValue().get(validAfterKey);
int currentVersion = currentVersionRaw != null ? currentVersionRaw : 0; long validAfterSeconds = validAfterObj != null ? Convert.toLong(validAfterObj) : 0;
// 如果当前版本号比 Token 携带的版本号新,则认为该 Token 已失效 if (issuedAtSeconds < validAfterSeconds) {
if (tokenVersion < currentVersion) {
return false; return false;
} }
} }
// 3. 判断是否在黑名单中,如果在,则返回 false 标识Token无效 // 3. 判断 Token 是否已被撤销(单端退出/会话注销)
// 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等 // 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等
if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) { if (isTokenRevoked(jti)) {
return false; return false;
} }
} }
@@ -190,29 +226,63 @@ public class JwtTokenManager implements TokenManager {
} }
JWT jwt = JWTUtil.parseToken(token); JWT jwt = JWTUtil.parseToken(token);
JSONObject payloads = jwt.getPayloads(); JSONObject payloads = jwt.getPayloads();
String jti = payloads.getStr(JWTPayload.JWT_ID);
Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT); Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT);
// 黑名单Token Key revokeTokenByJti(jti, expirationAt);
String blacklistTokenKey = StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, payloads.getStr(JWTPayload.JWT_ID)); }
/**
* 检查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加入撤销黑名单
* <p>
* 黑名单有效期与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) { if (expirationAt != null) {
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
if (expirationAt < currentTimeSeconds) { if (expirationAt < currentTimeSeconds) {
// Token已过期直接返回
return; return;
} }
// 计算Token剩余时间将其加入黑名单值使用简单布尔标记即可
int expirationIn = expirationAt - currentTimeSeconds; int expirationIn = expirationAt - currentTimeSeconds;
redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
} else { } else {
// 永不过期的Token永久加入黑名单 redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE);
} }
} }
/** /**
* 失效指定用户的所有会话 * 失效指定用户的所有会话
* <p> * <p>
* 通过提升用户的安全版本号,使携带旧版本号的 Token 在后续校验时全部失效 * 通过更新用户 tokenValidAfter 时间戳,使早于该时间签发的 Token 全部失效
* <p>
* 适用场景:
* <ul>
* <li>用户修改密码</li>
* <li>管理员强制下线用户</li>
* <li>用户主动踢出所有设备</li>
* </ul>
*
* @param userId 用户ID
*/ */
@Override @Override
public void invalidateUserSessions(Long userId) { public void invalidateUserSessions(Long userId) {
@@ -220,10 +290,10 @@ public class JwtTokenManager implements TokenManager {
return; return;
} }
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId); String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId);
// 递增版本号 long nowSeconds = System.currentTimeMillis() / 1000;
redisTemplate.opsForValue().increment(versionKey); // 设置过期时间避免Redis内存泄漏
redisTemplate.opsForValue().set(validAfterKey, nowSeconds, TOKEN_VALID_AFTER_TTL_SECONDS, TimeUnit.SECONDS);
} }
/** /**
@@ -253,8 +323,8 @@ public class JwtTokenManager implements TokenManager {
* 生成 JWT Token * 生成 JWT Token
* *
* @param authentication 认证信息 * @param authentication 认证信息
* @param ttl 过期时间 * @param ttl 过期时间(秒),-1表示永不过期
* @return JWT Token * @return JWT Token字符串
*/ */
private String generateToken(Authentication authentication, int ttl) { private String generateToken(Authentication authentication, int ttl) {
return generateToken(authentication, ttl, false); return generateToken(authentication, ttl, false);
@@ -263,18 +333,43 @@ public class JwtTokenManager implements TokenManager {
/** /**
* 生成 JWT Token * 生成 JWT Token
* <p>
* Payload包含
* <ul>
* <li>userId - 用户ID</li>
* <li>deptId - 部门ID</li>
* <li>dataScopes - 数据权限列表</li>
* <li>authorities - 角色权限集合</li>
* <li>tokenType - 是否为刷新令牌</li>
* <li>iat/exp - 签发/过期时间</li>
* <li>jti - Token唯一标识用于撤销</li>
* </ul>
* *
* @param authentication 认证信息 * @param authentication 认证信息
* @param ttl 过期时间 * @param ttl 过期时间(秒)
* @param isRefreshToken 类型是否为刷新token * @param isRefreshToken 是否为刷新令牌
* @return JWT Token * @return JWT Token字符串
*/ */
private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) { private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) {
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal(); SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
Map<String, Object> payload = new HashMap<>(); Map<String, Object> payload = new HashMap<>();
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围
// 存储数据权限列表
List<RoleDataScope> dataScopes = userDetails.getDataScopes();
if (dataScopes != null && !dataScopes.isEmpty()) {
List<Map<String, Object>> scopesList = dataScopes.stream()
.map(scope -> {
Map<String, Object> 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 中添加角色信息 // claims 中添加角色信息
Set<String> roles = authentication.getAuthorities().stream() Set<String> roles = authentication.getAuthorities().stream()
@@ -289,12 +384,6 @@ public class JwtTokenManager implements TokenManager {
payload.put(JwtClaimConstants.TOKEN_TYPE, true); 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 表示永不过期 // 设置过期时间 -1 表示永不过期
if (ttl != -1) { if (ttl != -1) {
Date expiresAt = DateUtil.offsetSecond(now, ttl); Date expiresAt = DateUtil.offsetSecond(now, ttl);

View File

@@ -8,7 +8,8 @@ import com.youlai.boot.core.exception.BusinessException;
import com.youlai.boot.core.web.ResultCode; import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.config.property.SecurityProperties; import com.youlai.boot.config.property.SecurityProperties;
import com.youlai.boot.security.model.AuthenticationToken; 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 com.youlai.boot.security.model.SysUserDetails;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
@@ -26,7 +27,15 @@ import java.util.stream.Collectors;
/** /**
* Redis Token 管理器 * Redis Token 管理器
* <p> * <p>
* 用于生成、解析、校验、刷新 Redis Token * 实现基于Redis的有状态认证支持
* <ul>
* <li>Access Token + Refresh Token 双令牌机制</li>
* <li>单设备/多设备登录控制</li>
* <li>用户级会话失效</li>
* <li>在线用户管理</li>
* </ul>
* <p>
* 与JWT模式相比Redis模式支持主动踢人、在线用户查询等功能
* *
* @author Ray.Hao * @author Ray.Hao
* @since 2024/11/15 * @since 2024/11/15
@@ -55,19 +64,19 @@ public class RedisTokenManager implements TokenManager {
String accessToken = IdUtil.fastSimpleUUID(); String accessToken = IdUtil.fastSimpleUUID();
String refreshToken = IdUtil.fastSimpleUUID(); String refreshToken = IdUtil.fastSimpleUUID();
// 构建用户在线信息 // 构建用户会话信息
OnlineUser onlineUser = new OnlineUser( UserSession userSession = new UserSession(
user.getUserId(), user.getUserId(),
user.getUsername(), user.getUsername(),
user.getDeptId(), user.getDeptId(),
user.getDataScope(), user.getDataScopes(),
user.getAuthorities().stream() user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority) .map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet()) .collect(Collectors.toSet())
); );
// 存储访问令牌、刷新令牌和刷新令牌映射 // 存储访问令牌、刷新令牌和刷新令牌映射
storeTokensInRedis(accessToken, refreshToken, onlineUser); storeTokensInRedis(accessToken, refreshToken, userSession);
// 单设备登录控制 // 单设备登录控制
handleSingleDeviceLogin(user.getUserId(), accessToken); handleSingleDeviceLogin(user.getUserId(), accessToken);
@@ -87,13 +96,13 @@ public class RedisTokenManager implements TokenManager {
*/ */
@Override @Override
public Authentication parseToken(String token) { public Authentication parseToken(String token) {
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token)); UserSession userSession = (UserSession) redisTemplate.opsForValue().get(formatTokenKey(token));
if (onlineUser == null) return null; if (userSession == null) return null;
// 构建用户权限集合 // 构建用户权限集合
Set<SimpleGrantedAuthority> authorities = null; Set<SimpleGrantedAuthority> authorities = null;
Set<String> roles = onlineUser.getRoles(); Set<String> roles = userSession.getRoles();
if (CollectionUtil.isNotEmpty(roles)) { if (CollectionUtil.isNotEmpty(roles)) {
authorities = roles.stream() authorities = roles.stream()
.map(SimpleGrantedAuthority::new) .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); return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
} }
@@ -135,12 +144,12 @@ public class RedisTokenManager implements TokenManager {
*/ */
@Override @Override
public AuthenticationToken refreshToken(String refreshToken) { public AuthenticationToken refreshToken(String refreshToken) {
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue() UserSession userSession = (UserSession) redisTemplate.opsForValue()
.get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); .get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
if (onlineUser == null) { if (userSession == null) {
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID); 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) Optional.of(oldAccessTokenValue)
.map(String.class::cast) .map(String.class::cast)
@@ -148,7 +157,7 @@ public class RedisTokenManager implements TokenManager {
// 生成新访问令牌并存储 // 生成新访问令牌并存储
String newAccessToken = IdUtil.fastSimpleUUID(); String newAccessToken = IdUtil.fastSimpleUUID();
storeAccessToken(newAccessToken, onlineUser); storeAccessToken(newAccessToken, userSession);
int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive(); int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive();
return AuthenticationToken.builder() return AuthenticationToken.builder()
@@ -166,14 +175,16 @@ public class RedisTokenManager implements TokenManager {
@Override @Override
public void invalidateToken(String token) { public void invalidateToken(String token) {
Object value = redisTemplate.opsForValue().get(formatTokenKey(token)); Object value = redisTemplate.opsForValue().get(formatTokenKey(token));
if (value instanceof OnlineUser onlineUser) { if (value instanceof UserSession userSession) {
Long userId = onlineUser.getUserId(); Long userId = userSession.getUserId();
invalidateUserSessions(userId); invalidateUserSessions(userId);
} }
} }
/** /**
* 使指定用户的所有会话失效 * 使指定用户的所有会话失效
* <p>
* 适用场景:用户修改密码、管理员强制下线、账号封禁等
* *
* @param userId 用户ID * @param userId 用户ID
*/ */
@@ -207,24 +218,26 @@ public class RedisTokenManager implements TokenManager {
* *
* @param accessToken 访问令牌 * @param accessToken 访问令牌
* @param refreshToken 刷新令牌 * @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); String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
setRedisValue(refreshTokenKey, onlineUser, securityProperties.getSession().getRefreshTokenTimeToLive()); setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive());
// 用户ID -> 刷新令牌 // 用户ID -> 刷新令牌
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, onlineUser.getUserId()), setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()),
refreshToken, refreshToken,
securityProperties.getSession().getRefreshTokenTimeToLive()); securityProperties.getSession().getRefreshTokenTimeToLive());
} }
/** /**
* 处理单设备登录控制 * 处理单设备登录控制
* <p>
* 当配置不允许多设备登录时新登录会使旧Token失效
* *
* @param userId 用户ID * @param userId 用户ID
* @param accessToken 新生成的访问令牌 * @param accessToken 新生成的访问令牌
@@ -247,27 +260,27 @@ public class RedisTokenManager implements TokenManager {
* 存储新的访问令牌 * 存储新的访问令牌
* *
* @param newAccessToken 新访问令牌 * @param newAccessToken 新访问令牌
* @param onlineUser 在线用户信息 * @param userSession 用户会话信息
*/ */
private void storeAccessToken(String newAccessToken, OnlineUser onlineUser) { private void storeAccessToken(String newAccessToken, UserSession userSession) {
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive()); setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId()); String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId());
setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive()); setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive());
} }
/** /**
* 构建用户详情对象 * 构建用户详情对象
* *
* @param onlineUser 在线用户信息 * @param userSession 用户会话信息
* @param authorities 权限集合 * @param authorities 权限集合
* @return SysUserDetails 用户详情 * @return SysUserDetails 用户详情
*/ */
private SysUserDetails buildUserDetails(OnlineUser onlineUser, Set<SimpleGrantedAuthority> authorities) { private SysUserDetails buildUserDetails(UserSession userSession, Set<SimpleGrantedAuthority> authorities) {
SysUserDetails userDetails = new SysUserDetails(); SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(onlineUser.getUserId()); userDetails.setUserId(userSession.getUserId());
userDetails.setUsername(onlineUser.getUsername()); userDetails.setUsername(userSession.getUsername());
userDetails.setDeptId(onlineUser.getDeptId()); userDetails.setDeptId(userSession.getDeptId());
userDetails.setDataScope(onlineUser.getDataScope()); userDetails.setDataScopes(userSession.getDataScopes());
userDetails.setAuthorities(authorities); userDetails.setAuthorities(authorities);
return userDetails; return userDetails;
} }

View File

@@ -70,16 +70,6 @@ public class SecurityUtils {
return getUser().map(SysUserDetails::getDeptId).orElse(null); return getUser().map(SysUserDetails::getDeptId).orElse(null);
} }
/**
* 获取数据权限范围
*
* @return Integer
*/
public static Integer getDataScope() {
return getUser().map(SysUserDetails::getDataScope).orElse(null);
}
/** /**
* 获取角色集合 * 获取角色集合
* *

View File

@@ -120,4 +120,14 @@ public class RoleController {
roleService.assignMenusToRole(roleId, menuIds); roleService.assignMenusToRole(roleId, menuIds);
return Result.success(); return Result.success();
} }
@Operation(summary = "获取角色的部门ID集合(自定义数据权限)")
@GetMapping("/{roleId}/dept-ids")
@PreAuthorize("@ss.hasPerm('sys:role:update')")
public Result<List<Long>> getRoleDeptIds(
@Parameter(description = "角色ID") @PathVariable Long roleId
) {
List<Long> deptIds = roleService.getRoleDeptIds(roleId);
return Result.success(deptIds);
}
} }

View File

@@ -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);
}
}

View File

@@ -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<RoleDept> {
/**
* 根据角色ID获取部门ID列表
*
* @param roleId 角色ID
* @return 部门ID列表
*/
List<Long> getDeptIdsByRoleId(@Param("roleId") Long roleId);
/**
* 根据角色编码集合获取所有部门ID列表用于自定义数据权限
*
* @param roleCodes 角色编码集合
* @return 部门ID列表
*/
List<Long> getDeptIdsByRoleCodes(@Param("roleCodes") List<String> roleCodes);
}

View File

@@ -3,7 +3,10 @@ package com.youlai.boot.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.system.model.entity.Role; import com.youlai.boot.system.model.entity.Role;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
/** /**
@@ -22,4 +25,15 @@ public interface RoleMapper extends BaseMapper<Role> {
* @return {@link Integer} 数据权限范围 * @return {@link Integer} 数据权限范围
*/ */
Integer getMaximumDataScope(Set<String> roles); Integer getMaximumDataScope(Set<String> roles);
/**
* 获取角色的数据权限信息列表
* <p>
* 返回角色编码和数据权限范围的映射列表
*
* @param roleCodes 角色编码集合
* @return 角色数据权限信息列表 [{code: 'ADMIN', data_scope: 1}, ...]
*/
List<Map<String, Object>> getRoleDataScopeList(@Param("roleCodes") Set<String> roleCodes);
} }

View File

@@ -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;
/**
* 角色部门关联实体
* <p>
* 用于存储角色自定义数据权限时可访问的部门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;
}

View File

@@ -7,6 +7,8 @@ import lombok.Data;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.hibernate.validator.constraints.Range; import org.hibernate.validator.constraints.Range;
import java.util.List;
@Schema(description = "角色表单对象") @Schema(description = "角色表单对象")
@Data @Data
public class RoleForm { public class RoleForm {
@@ -29,7 +31,10 @@ public class RoleForm {
@Range(max = 1, min = 0, message = "角色状态不正确") @Range(max = 1, min = 0, message = "角色状态不正确")
private Integer status; private Integer status;
@Schema(description="数据权限") @Schema(description="数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)")
private Integer dataScope; private Integer dataScope;
@Schema(description="自定义数据权限部门ID列表(当dataScope=5时有效)")
private List<Long> deptIds;
} }

View File

@@ -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<RoleDept> {
/**
* 根据角色ID获取部门ID列表
*
* @param roleId 角色ID
* @return 部门ID列表
*/
List<Long> getDeptIdsByRoleId(Long roleId);
/**
* 根据角色编码集合获取所有部门ID列表用于自定义数据权限
*
* @param roleCodes 角色编码集合
* @return 部门ID列表
*/
List<Long> getDeptIdsByRoleCodes(List<String> roleCodes);
/**
* 保存角色部门关联
*
* @param roleId 角色ID
* @param deptIds 部门ID列表
*/
void saveRoleDepts(Long roleId, List<Long> deptIds);
/**
* 删除角色部门关联
*
* @param roleId 角色ID
*/
void deleteByRoleId(Long roleId);
}

View File

@@ -10,7 +10,7 @@ import java.util.Set;
/** /**
* 角色菜单业务接口 * 角色菜单业务接口
* *
* @author haoxr * @author Ray.Hao
* @since 2.5.0 * @since 2.5.0
*/ */
public interface RoleMenuService extends IService<RoleMenu> { public interface RoleMenuService extends IService<RoleMenu> {
@@ -45,10 +45,12 @@ public interface RoleMenuService extends IService<RoleMenu> {
void refreshRolePermsCache(String oldRoleCode, String newRoleCode); void refreshRolePermsCache(String oldRoleCode, String newRoleCode);
/** /**
* 获取角色权限集合 * 获取角色权限集合(带缓存)
* <p>
* 采用 Read-Through 缓存策略,缓存未命中时自动回源数据库
* *
* @param roles 角色编码集合 * @param roleCodes 角色编码集合
* @return 权限集合 * @return 权限集合
*/ */
Set<String> getRolePermsByRoleCodes(Set<String> roles); Set<String> getRolePermsByRoleCodes(Set<String> roleCodes);
} }

View File

@@ -3,6 +3,7 @@ package com.youlai.boot.system.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService; 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.system.model.entity.Role;
import com.youlai.boot.common.model.Option; import com.youlai.boot.common.model.Option;
import com.youlai.boot.system.model.form.RoleForm; import com.youlai.boot.system.model.form.RoleForm;
@@ -91,5 +92,22 @@ public interface RoleService extends IService<Role> {
*/ */
Integer getMaximumDataScope(Set<String> roles); Integer getMaximumDataScope(Set<String> roles);
/**
* 获取角色的部门ID列表自定义数据权限
*
* @param roleId 角色ID
* @return 部门ID列表
*/
List<Long> getRoleDeptIds(Long roleId);
/**
* 获取用户所有角色的数据权限列表
* <p>
* 用于实现多角色数据权限合并(并集策略)
*
* @param roleCodes 角色编码集合
* @return 角色数据权限列表
*/
List<RoleDataScope> getRoleDataScopes(Set<String> roleCodes);
} }

View File

@@ -13,7 +13,7 @@ import java.util.List;
/** /**
* 用户公告状态服务类 * 用户公告状态服务类
* *
* @author youlaitech * @author Theo
* @since 2024-08-28 16:56 * @since 2024-08-28 16:56
*/ */
public interface UserNoticeService extends IService<UserNotice> { public interface UserNoticeService extends IService<UserNotice> {

View File

@@ -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<String, UserOnlineInfo> 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<UserOnlineDTO> 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<UserOnlineDTO> users;
private long timestamp;
}
}

View File

@@ -6,13 +6,19 @@ import com.youlai.boot.system.model.entity.UserRole;
import java.util.List; import java.util.List;
/**
* 用户角色业务接口
*
* @author Ray.Hao
* @since 0.0.1
*/
public interface UserRoleService extends IService<UserRole> { public interface UserRoleService extends IService<UserRole> {
/** /**
* 保存用户角色 * 保存用户角色
* *
* @param userId * @param userId 用户ID
* @param roleIds * @param roleIds 角色ID列表
* @return * @return
*/ */
void saveUserRoles(Long userId, List<Long> roleIds); void saveUserRoles(Long userId, List<Long> roleIds);

View File

@@ -25,7 +25,7 @@ public interface UserService extends IService<User> {
/** /**
* 用户分页列表 * 用户分页列表
* *
* @return {@link IPage<UserPageVo>} 用户分页列表 * @return {@link IPage<UserPageVO>} 用户分页列表
*/ */
IPage<UserPageVO> getUserPage(UserQuery queryParams); IPage<UserPageVO> getUserPage(UserQuery queryParams);
@@ -82,7 +82,7 @@ public interface UserService extends IService<User> {
* 获取导出用户列表 * 获取导出用户列表
* *
* @param queryParams 查询参数 * @param queryParams 查询参数
* @return {@link List<UserExportDto>} 导出用户列表 * @return {@link List<UserExportDTO>} 导出用户列表
*/ */
List<UserExportDTO> listExportUsers(UserQuery queryParams); List<UserExportDTO> listExportUsers(UserQuery queryParams);
@@ -90,14 +90,14 @@ public interface UserService extends IService<User> {
/** /**
* 获取登录用户信息 * 获取登录用户信息
* *
* @return {@link CurrentUserDto} 登录用户信息 * @return {@link CurrentUserDTO} 登录用户信息
*/ */
CurrentUserDTO getCurrentUserInfo(); CurrentUserDTO getCurrentUserInfo();
/** /**
* 获取个人中心用户信息 * 获取个人中心用户信息
* *
* @return {@link UserProfileVo} 个人中心用户信息 * @return {@link UserProfileVO} 个人中心用户信息
*/ */
UserProfileVO getUserProfile(Long userId); UserProfileVO getUserProfile(Long userId);

View File

@@ -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.model.vo.NoticeDetailVO;
import com.youlai.boot.system.service.NoticeService; import com.youlai.boot.system.service.NoticeService;
import com.youlai.boot.system.service.UserNoticeService; 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.system.service.UserService;
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher; import com.youlai.boot.platform.websocket.service.WebSocketService;
import com.youlai.boot.platform.websocket.topic.WebSocketTopics;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -53,14 +51,13 @@ public class NoticeServiceImpl extends ServiceImpl<NoticeMapper, Notice> impleme
private final NoticeConverter noticeConverter; private final NoticeConverter noticeConverter;
private final UserNoticeService userNoticeService; private final UserNoticeService userNoticeService;
private final UserService userService; private final UserService userService;
private final WebSocketPublisher webSocketPublisher; private final WebSocketService webSocketService;
private final UserOnlineService userOnlineService;
/** /**
* 获取通知公告分页列表 * 获取通知公告分页列表
* *
* @param queryParams 查询参数 * @param queryParams 查询参数
* @return {@link IPage< NoticePageVo >} 通知公告分页列表 * @return {@link IPage< NoticePageVO >} 通知公告分页列表
*/ */
@Override @Override
public IPage<NoticePageVO> getNoticePage(NoticeQuery queryParams) { public IPage<NoticePageVO> getNoticePage(NoticeQuery queryParams) {
@@ -214,9 +211,10 @@ public class NoticeServiceImpl extends ServiceImpl<NoticeMapper, Notice> impleme
Set<String> receivers = targetUserList.stream().map(User::getUsername).collect(Collectors.toSet()); Set<String> receivers = targetUserList.stream().map(User::getUsername).collect(Collectors.toSet());
Set<String> allOnlineUsers = userOnlineService.getOnlineUsers().stream() // 获取在线用户名集合
.map(UserOnlineService.UserOnlineDTO::getUsername) Set<String> allOnlineUsers = webSocketService.getOnlineUsers().stream()
.collect(Collectors.toSet()); .map(dto -> dto.getUsername())
.collect(Collectors.toSet());
// 找出在线用户的通知接收者 // 找出在线用户的通知接收者
Set<String> onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers)); Set<String> onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers));
@@ -227,7 +225,8 @@ public class NoticeServiceImpl extends ServiceImpl<NoticeMapper, Notice> impleme
noticeDto.setType(notice.getType()); noticeDto.setType(notice.getType());
noticeDto.setPublishTime(notice.getPublishTime()); noticeDto.setPublishTime(notice.getPublishTime());
onlineReceivers.forEach(receiver -> webSocketPublisher.publishToUser(receiver, WebSocketTopics.USER_QUEUE_MESSAGE, noticeDto)); // 向在线接收者推送通知
onlineReceivers.forEach(receiver -> webSocketService.sendNotification(receiver, noticeDto));
} }
return publishResult; return publishResult;
} }
@@ -268,7 +267,7 @@ public class NoticeServiceImpl extends ServiceImpl<NoticeMapper, Notice> impleme
/** /**
* *
* @param id 通知公告ID * @param id 通知公告ID
* @return NoticeDetailVo 通知公告详情 * @return NoticeDetailVO 通知公告详情
*/ */
@Override @Override
public NoticeDetailVO getNoticeDetail(Long id) { public NoticeDetailVO getNoticeDetail(Long id) {

View File

@@ -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<RoleDeptMapper, RoleDept> implements RoleDeptService {
@Override
public List<Long> getDeptIdsByRoleId(Long roleId) {
if (roleId == null) {
return Collections.emptyList();
}
return this.baseMapper.getDeptIdsByRoleId(roleId);
}
@Override
public List<Long> getDeptIdsByRoleCodes(List<String> roleCodes) {
if (CollectionUtil.isEmpty(roleCodes)) {
return Collections.emptyList();
}
return this.baseMapper.getDeptIdsByRoleCodes(roleCodes);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void saveRoleDepts(Long roleId, List<Long> deptIds) {
if (roleId == null || CollectionUtil.isEmpty(deptIds)) {
return;
}
// 先删除原有关联
this.remove(new LambdaQueryWrapper<RoleDept>().eq(RoleDept::getRoleId, roleId));
// 批量插入新关联
List<RoleDept> 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<RoleDept>().eq(RoleDept::getRoleId, roleId));
}
}

View File

@@ -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.bo.RolePermsBO;
import com.youlai.boot.system.model.entity.RoleMenu; import com.youlai.boot.system.model.entity.RoleMenu;
import com.youlai.boot.system.service.RoleMenuService; import com.youlai.boot.system.service.RoleMenuService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.*;
import java.util.Set; import java.util.stream.Collectors;
/** /**
* 角色菜单服务实现类 * 角色菜单服务实现类
@@ -29,33 +28,6 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
/**
* 启动时初始化权限缓存
*/
@PostConstruct
public void initRolePermsCache() {
log.info("开始初始化权限缓存...");
List<RolePermsBO> 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<String> perms = rolePerms.getPerms();
if (CollectionUtil.isNotEmpty(perms)) {
redisTemplate.opsForHash().put(cacheKey, roleCode, perms);
}
});
log.info("权限缓存初始化完成,共{}条数据", allRolePermsList.size());
}
/** /**
* 刷新权限缓存 * 刷新权限缓存
*/ */
@@ -66,7 +38,7 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
// 清理权限缓存 // 清理权限缓存
redisTemplate.delete(cacheKey); redisTemplate.delete(cacheKey);
// 重新加载权限 // 预热权限缓存,避免后续请求触发频繁回源
List<RolePermsBO> list = this.baseMapper.getRolePermsList(null); List<RolePermsBO> list = this.baseMapper.getRolePermsList(null);
if (CollectionUtil.isNotEmpty(list)) { if (CollectionUtil.isNotEmpty(list)) {
list.forEach(item -> { list.forEach(item -> {
@@ -91,7 +63,7 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
// 清理指定角色缓存 // 清理指定角色缓存
redisTemplate.opsForHash().delete(cacheKey, roleCode); redisTemplate.opsForHash().delete(cacheKey, roleCode);
// 重新加载指定角色权限 // 回源 DB 并更新缓存
List<RolePermsBO> list = this.baseMapper.getRolePermsList(roleCode); List<RolePermsBO> list = this.baseMapper.getRolePermsList(roleCode);
if (CollectionUtil.isNotEmpty(list)) { if (CollectionUtil.isNotEmpty(list)) {
RolePermsBO rolePerms = list.get(0); RolePermsBO rolePerms = list.get(0);
@@ -115,8 +87,9 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
// 清理旧角色权限缓存 // 清理旧角色权限缓存
redisTemplate.opsForHash().delete(cacheKey, oldRoleCode); redisTemplate.opsForHash().delete(cacheKey, oldRoleCode);
redisTemplate.opsForHash().delete(cacheKey, newRoleCode);
// 添加新角色权限缓存 // 回源 DB 并更新新角色编码缓存
List<RolePermsBO> list = this.baseMapper.getRolePermsList(newRoleCode); List<RolePermsBO> list = this.baseMapper.getRolePermsList(newRoleCode);
if (CollectionUtil.isNotEmpty(list)) { if (CollectionUtil.isNotEmpty(list)) {
RolePermsBO rolePerms = list.get(0); RolePermsBO rolePerms = list.get(0);
@@ -128,19 +101,71 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
} }
} }
log.info("角色编码变更: {} -> {},权限缓存已更新", oldRoleCode, newRoleCode); log.info("角色编码变更: {} -> {}相关权限缓存刷新完成", oldRoleCode, newRoleCode);
} }
/** /**
* 获取角色权限集合 * 获取角色权限集合(带缓存)
* <p>
* 采用 Read-Through 缓存策略:
* <ol>
* <li>优先从 Redis Hash 缓存读取</li>
* <li>缓存未命中时回源 DB 并写入缓存</li>
* </ol>
* *
* @param roles 角色编码集合 * @param roleCodes 角色编码集合
* @return 权限集合 * @return 权限集合
*/ */
@Override @Override
public Set<String> getRolePermsByRoleCodes(Set<String> roles) { public Set<String> getRolePermsByRoleCodes(Set<String> roleCodes) {
// 直接查询数据库(保持原有逻辑) if (CollectionUtil.isEmpty(roleCodes)) {
return this.baseMapper.listRolePerms(roles); return Collections.emptySet();
}
String cacheKey = RedisConstants.System.ROLE_PERMS;
Set<String> perms = new HashSet<>();
List<String> roleCodeList = new ArrayList<>(roleCodes);
// 1. 尝试从缓存批量获取
List<Object> cachedPermsList = redisTemplate.opsForHash().multiGet(cacheKey, new ArrayList<>(roleCodeList));
List<String> 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<String> dbPerms = this.baseMapper.listRolePerms(Collections.singleton(roleCode));
if (dbPerms == null) {
dbPerms = Collections.emptySet();
}
// 写入缓存(空集也写入,防止缓存穿透)
redisTemplate.opsForHash().put(cacheKey, roleCode, dbPerms);
perms.addAll(dbPerms);
}
}
return perms;
} }
/** /**

View File

@@ -7,6 +7,7 @@ import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; 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.core.exception.BusinessException;
import com.youlai.boot.system.converter.RoleConverter; import com.youlai.boot.system.converter.RoleConverter;
import com.youlai.boot.system.mapper.RoleMapper; 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.constant.SystemConstants;
import com.youlai.boot.common.model.Option; import com.youlai.boot.common.model.Option;
import com.youlai.boot.security.util.SecurityUtils; 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.RoleMenuService;
import com.youlai.boot.system.service.RoleService; import com.youlai.boot.system.service.RoleService;
import com.youlai.boot.system.service.UserRoleService; import com.youlai.boot.system.service.UserRoleService;
@@ -41,6 +43,7 @@ import java.util.Set;
public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService { public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService {
private final RoleMenuService roleMenuService; private final RoleMenuService roleMenuService;
private final RoleDeptService roleDeptService;
private final UserRoleService userRoleService; private final UserRoleService userRoleService;
private final RoleConverter roleConverter; private final RoleConverter roleConverter;
@@ -48,7 +51,7 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
* 角色分页列表 * 角色分页列表
* *
* @param queryParams 角色查询参数 * @param queryParams 角色查询参数
* @return {@link Page< RolePageVo >} 角色分页列表 * @return {@link Page< RolePageVO >} 角色分页列表
*/ */
@Override @Override
public Page<RolePageVO> getRolePage(RoleQuery queryParams) { public Page<RolePageVO> getRolePage(RoleQuery queryParams) {
@@ -99,6 +102,7 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
* @return {@link Boolean} * @return {@link Boolean}
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class)
public boolean saveRole(RoleForm roleForm) { public boolean saveRole(RoleForm roleForm) {
Long roleId = roleForm.getId(); Long roleId = roleForm.getId();
@@ -123,6 +127,16 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
boolean result = this.saveOrUpdate(role); boolean result = this.saveOrUpdate(role);
if (result) { 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 if (oldRole != null
&& ( && (
@@ -144,7 +158,13 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
@Override @Override
public RoleForm getRoleForm(Long roleId) { public RoleForm getRoleForm(Long roleId) {
Role entity = this.getById(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<Long> deptIds = roleDeptService.getDeptIdsByRoleId(roleId);
roleForm.setDeptIds(deptIds);
}
return roleForm;
} }
/** /**
@@ -254,4 +274,70 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
return dataScope; return dataScope;
} }
/**
* 获取角色的部门ID列表自定义数据权限
*
* @param roleId 角色ID
* @return 部门ID列表
*/
@Override
public List<Long> getRoleDeptIds(Long roleId) {
return roleDeptService.getDeptIdsByRoleId(roleId);
}
/**
* 获取用户所有角色的数据权限列表
* <p>
* 用于实现多角色数据权限合并(并集策略)
*
* @param roleCodes 角色编码集合
* @return 角色数据权限列表
*/
@Override
public List<RoleDataScope> getRoleDataScopes(Set<String> roleCodes) {
if (CollectionUtil.isEmpty(roleCodes)) {
return List.of();
}
// 获取角色的数据权限信息
List<Map<String, Object>> roleDataScopeList = this.baseMapper.getRoleDataScopeList(roleCodes);
if (CollectionUtil.isEmpty(roleDataScopeList)) {
return List.of();
}
// 获取所有自定义数据权限的角色编码
List<String> customRoleCodes = roleDataScopeList.stream()
.filter(map -> DataScopeEnum.CUSTOM.getValue().equals(map.get("data_scope")))
.map(map -> (String) map.get("code"))
.toList();
// 批量获取自定义角色的部门ID
Map<String, List<Long>> customDeptIdsMap = new java.util.HashMap<>();
if (CollectionUtil.isNotEmpty(customRoleCodes)) {
// 查询每个角色关联的部门ID
for (String roleCode : customRoleCodes) {
// 根据角色编码获取角色ID
Role role = this.getOne(new LambdaQueryWrapper<Role>()
.eq(Role::getCode, roleCode)
.select(Role::getId));
if (role != null) {
List<Long> 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();
}
} }

View File

@@ -15,8 +15,8 @@ import com.youlai.boot.common.model.Option;
import com.youlai.boot.platform.mail.service.MailService; import com.youlai.boot.platform.mail.service.MailService;
import com.youlai.boot.platform.sms.enums.SmsTypeEnum; import com.youlai.boot.platform.sms.enums.SmsTypeEnum;
import com.youlai.boot.platform.sms.service.SmsService; 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.model.UserAuthInfo;
import com.youlai.boot.security.service.PermissionService;
import com.youlai.boot.security.token.TokenManager; import com.youlai.boot.security.token.TokenManager;
import com.youlai.boot.security.util.SecurityUtils; import com.youlai.boot.security.util.SecurityUtils;
import com.youlai.boot.system.converter.UserConverter; import com.youlai.boot.system.converter.UserConverter;
@@ -62,7 +62,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
private final RoleService roleService; private final RoleService roleService;
private final PermissionService permissionService; private final RoleMenuService roleMenuService;
private final SmsService smsService; private final SmsService smsService;
@@ -81,7 +81,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
* 获取用户分页列表 * 获取用户分页列表
* *
* @param queryParams 查询参数 * @param queryParams 查询参数
* @return {@link IPage<UserPageVo>} 用户分页列表 * @return {@link IPage<UserPageVO>} 用户分页列表
*/ */
@Override @Override
public IPage<UserPageVO> getUserPage(UserQuery queryParams) { public IPage<UserPageVO> getUserPage(UserQuery queryParams) {
@@ -215,9 +215,9 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username); UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByUsername(username);
if (userAuthInfo != null) { if (userAuthInfo != null) {
Set<String> roles = userAuthInfo.getRoles(); Set<String> roles = userAuthInfo.getRoles();
// 获取最大范围的数据权限 // 获取数据权限列表(用于并集策略)
Integer dataScope = roleService.getMaximumDataScope(roles); List<RoleDataScope> dataScopes = roleService.getRoleDataScopes(roles);
userAuthInfo.setDataScope(dataScope); userAuthInfo.setDataScopes(dataScopes);
} }
return userAuthInfo; return userAuthInfo;
} }
@@ -236,9 +236,9 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile); UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile);
if (userAuthInfo != null) { if (userAuthInfo != null) {
Set<String> roles = userAuthInfo.getRoles(); Set<String> roles = userAuthInfo.getRoles();
// 获取最大范围的数据权限 // 获取数据权限列表(用于并集策略)
Integer dataScope = roleService.getMaximumDataScope(roles); List<RoleDataScope> dataScopes = roleService.getRoleDataScopes(roles);
userAuthInfo.setDataScope(dataScope); userAuthInfo.setDataScopes(dataScopes);
} }
return userAuthInfo; return userAuthInfo;
} }
@@ -247,7 +247,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
* 获取导出用户列表 * 获取导出用户列表
* *
* @param queryParams 查询参数 * @param queryParams 查询参数
* @return {@link List<UserExportDto>} 导出用户列表 * @return {@link List<UserExportDTO>} 导出用户列表
*/ */
@Override @Override
public List<UserExportDTO> listExportUsers(UserQuery queryParams) { public List<UserExportDTO> listExportUsers(UserQuery queryParams) {
@@ -285,7 +285,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
/** /**
* 获取登录用户信息 * 获取登录用户信息
* *
* @return {@link CurrentUserDto} 用户信息 * @return {@link CurrentUserDTO} 用户信息
*/ */
@Override @Override
public CurrentUserDTO getCurrentUserInfo() { public CurrentUserDTO getCurrentUserInfo() {
@@ -311,7 +311,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
// 用户权限集合 // 用户权限集合
if (CollectionUtil.isNotEmpty(roles)) { if (CollectionUtil.isNotEmpty(roles)) {
Set<String> perms = permissionService.getRolePermsFormCache(roles); Set<String> perms = roleMenuService.getRolePermsByRoleCodes(roles);
userInfoVo.setPerms(perms); userInfoVo.setPerms(perms);
} }
return userInfoVo; return userInfoVo;
@@ -321,7 +321,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
* 获取个人中心用户信息 * 获取个人中心用户信息
* *
* @param userId 用户ID * @param userId 用户ID
* @return {@link UserProfileVo} 个人中心用户信息 * @return {@link UserProfileVO} 个人中心用户信息
*/ */
@Override @Override
public UserProfileVO getUserProfile(Long userId) { public UserProfileVO getUserProfile(Long userId) {

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.youlai.boot.system.mapper.RoleDeptMapper">
<!-- 根据角色ID获取部门ID列表 -->
<select id="getDeptIdsByRoleId" resultType="java.lang.Long">
SELECT dept_id
FROM sys_role_dept
WHERE role_id = #{roleId}
</select>
<!-- 根据角色编码集合获取所有部门ID列表 -->
<select id="getDeptIdsByRoleCodes" resultType="java.lang.Long">
SELECT DISTINCT rd.dept_id
FROM sys_role_dept rd
INNER JOIN sys_role r ON rd.role_id = r.id
WHERE r.code IN
<foreach collection="roleCodes" item="roleCode" separator="," open="(" close=")">
#{roleCode}
</foreach>
AND r.is_deleted = 0
</select>
</mapper>

View File

@@ -24,4 +24,27 @@
</choose> </choose>
</where> </where>
</select> </select>
<!-- 获取角色的数据权限信息列表 -->
<select id="getRoleDataScopeList" resultType="java.util.Map">
SELECT
code,
data_scope
FROM
sys_role
<where>
<choose>
<when test="roleCodes!=null and roleCodes.size>0">
AND code IN
<foreach collection="roleCodes" item="roleCode" separator="," open="(" close=")">
#{roleCode}
</foreach>
</when>
<otherwise>
id = -1
</otherwise>
</choose>
AND is_deleted = 0
</where>
</select>
</mapper> </mapper>

View File

@@ -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<Long> 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<SimpleGrantedAuthority> authorities,
List<RoleDataScope> 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);
}
}

View File

@@ -0,0 +1,41 @@
package com.youlai.boot.plugin.mybatis;
import com.youlai.boot.common.annotation.DataPermission;
import java.util.List;
/**
* 数据权限测试 Mapper
* <p>
* 用于测试数据权限拦截器的SQL注入功能
*/
public interface TestDataPermissionMapper {
/**
* 查询列表(带数据权限过滤)
*/
@DataPermission
List<Object> selectList();
/**
* 查询列表(不带数据权限过滤)
*/
List<Object> selectListWithoutPermission();
/**
* 多表关联查询(指定别名)
*/
@DataPermission(deptAlias = "u", userAlias = "u")
List<Object> selectWithJoin();
/**
* 自定义列名查询(多表关联场景)
*/
@DataPermission(
deptAlias = "t",
deptIdColumnName = "dept_id",
userAlias = "t",
userIdColumnName = "create_by"
)
List<Object> selectWithAlias();
}

View File

@@ -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<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> 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<RoleDataScope> 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<RoleDataScope> 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()
);
}
}