refactor: 会话失效、数据权限和实时推送重构
This commit is contained in:
@@ -254,7 +254,7 @@ CREATE TABLE `sys_role` (
|
||||
`code` varchar(32) NOT NULL COMMENT '角色编码',
|
||||
`sort` int NULL COMMENT '显示顺序',
|
||||
`status` tinyint(1) DEFAULT 1 COMMENT '角色状态(1-正常 0-停用)',
|
||||
`data_scope` tinyint NULL COMMENT '数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据)',
|
||||
`data_scope` tinyint NULL COMMENT '数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)',
|
||||
`create_by` bigint NULL COMMENT '创建人 ID',
|
||||
`create_time` datetime NULL COMMENT '创建时间',
|
||||
`update_by` bigint NULL COMMENT '更新人ID',
|
||||
@@ -291,6 +291,16 @@ CREATE TABLE `sys_role_menu` (
|
||||
UNIQUE INDEX `uk_roleid_menuid`(`role_id` ASC, `menu_id` ASC) USING BTREE COMMENT '角色菜单唯一索引'
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色菜单关联表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for sys_role_dept
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `sys_role_dept`;
|
||||
CREATE TABLE `sys_role_dept` (
|
||||
`role_id` bigint NOT NULL COMMENT '角色ID',
|
||||
`dept_id` bigint NOT NULL COMMENT '部门ID',
|
||||
UNIQUE INDEX `uk_roleid_deptid`(`role_id` ASC, `dept_id` ASC) USING BTREE COMMENT '角色部门唯一索引'
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '角色部门关联表(用于自定义数据权限)';
|
||||
|
||||
-- ============================================
|
||||
-- 系统管理员角色菜单权限(role_id=2)
|
||||
-- 顶级目录
|
||||
|
||||
@@ -26,18 +26,15 @@ public interface JwtClaimConstants {
|
||||
String DEPT_ID = "deptId";
|
||||
|
||||
/**
|
||||
* 数据权限
|
||||
* 数据权限列表
|
||||
* <p>
|
||||
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
|
||||
*/
|
||||
String DATA_SCOPE = "dataScope";
|
||||
String DATA_SCOPES = "dataScopes";
|
||||
|
||||
/**
|
||||
* 权限(角色Code)集合
|
||||
*/
|
||||
String AUTHORITIES = "authorities";
|
||||
|
||||
/**
|
||||
* 安全版本号,用于按用户失效历史令牌
|
||||
*/
|
||||
String SECURITY_VERSION = "securityVersion";
|
||||
|
||||
}
|
||||
|
||||
@@ -26,18 +26,19 @@ public interface RedisConstants {
|
||||
* 认证模块
|
||||
*/
|
||||
interface Auth {
|
||||
// 存储访问令牌对应的用户信息(accessToken -> OnlineUser)
|
||||
// 存储访问令牌对应的用户会话信息(accessToken -> UserSession)
|
||||
String ACCESS_TOKEN_USER = "auth:token:access:{}";
|
||||
// 存储刷新令牌对应的用户信息(refreshToken -> OnlineUser)
|
||||
// 存储刷新令牌对应的用户会话信息(refreshToken -> UserSession)
|
||||
String REFRESH_TOKEN_USER = "auth:token:refresh:{}";
|
||||
// 用户与访问令牌的映射(userId -> accessToken)
|
||||
String USER_ACCESS_TOKEN = "auth:user:access:{}";
|
||||
// 用户与刷新令牌的映射(userId -> refreshToken
|
||||
String USER_REFRESH_TOKEN = "auth:user:refresh:{}";
|
||||
// 黑名单 Token(用于退出登录或注销)
|
||||
// 已撤销 Token 的 JTI(单端退出/会话注销):如果 jti 在撤销列表中,则 Token 立即无效
|
||||
String BLACKLIST_TOKEN = "auth:token:blacklist:{}";
|
||||
// 用户安全版本号(用于按用户失效历史 JWT)
|
||||
String USER_SECURITY_VERSION = "auth:user:security_version:{}";
|
||||
String REVOKED_JTI = BLACKLIST_TOKEN;
|
||||
// 用户 Token 生效起点(用于按用户失效历史 JWT):token.iat < tokenValidAfter => token 无效
|
||||
String USER_TOKEN_VALID_AFTER = "auth:user:token_valid_after:{}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,10 @@ import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 数据权限枚举
|
||||
* <p>
|
||||
* value 越小,数据权限范围越大。
|
||||
* 多角色数据权限合并策略:取并集(OR),即用户能看到所有角色权限范围内的数据。
|
||||
* 如果任一角色是 ALL,则直接跳过数据权限过滤。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.3.0
|
||||
@@ -13,12 +17,31 @@ import lombok.Getter;
|
||||
public enum DataScopeEnum implements IBaseEnum<Integer> {
|
||||
|
||||
/**
|
||||
* value 越小,数据权限范围越大
|
||||
* 所有数据权限 - 最高权限,可查看所有数据
|
||||
*/
|
||||
ALL(1, "所有数据"),
|
||||
|
||||
/**
|
||||
* 部门及子部门数据 - 可查看本部门及其下属所有部门的数据
|
||||
*/
|
||||
DEPT_AND_SUB(2, "部门及子部门数据"),
|
||||
|
||||
/**
|
||||
* 本部门数据 - 仅可查看本部门的数据
|
||||
*/
|
||||
DEPT(3, "本部门数据"),
|
||||
SELF(4, "本人数据");
|
||||
|
||||
/**
|
||||
* 本人数据 - 仅可查看自己的数据
|
||||
*/
|
||||
SELF(4, "本人数据"),
|
||||
|
||||
/**
|
||||
* 自定义部门数据 - 可查看指定部门的数据
|
||||
* <p>
|
||||
* 需要配合 sys_role_dept 表使用,存储角色可访问的部门ID列表
|
||||
*/
|
||||
CUSTOM(5, "自定义部门数据");
|
||||
|
||||
private final Integer value;
|
||||
|
||||
@@ -28,4 +51,32 @@ public enum DataScopeEnum implements IBaseEnum<Integer> {
|
||||
this.value = value;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为全部数据权限
|
||||
*
|
||||
* @param value 数据权限值
|
||||
* @return 是否为全部数据权限
|
||||
*/
|
||||
public static boolean isAll(Integer value) {
|
||||
return ALL.getValue().equals(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据值获取枚举
|
||||
*
|
||||
* @param value 数据权限值
|
||||
* @return 枚举对象,未找到则返回 null
|
||||
*/
|
||||
public static DataScopeEnum getByValue(Integer value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
for (DataScopeEnum dataScope : values()) {
|
||||
if (dataScope.getValue().equals(value)) {
|
||||
return dataScope;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.youlai.boot.config.property;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -53,7 +54,8 @@ public class SecurityProperties {
|
||||
* <li>redis-token - 基于Redis的有状态认证</li>
|
||||
* </ul>
|
||||
*/
|
||||
@NotNull
|
||||
@NotNull(message = "会话类型不能为空")
|
||||
@Pattern(regexp = "jwt|redis-token", message = "会话类型只能是 jwt 或 redis-token")
|
||||
private String type;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
package com.youlai.boot.platform.websocket.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 字典变更事件
|
||||
* <p>
|
||||
* 当字典数据发生变更时,通过 WebSocket 广播此事件通知前端清除缓存。
|
||||
* 前端收到通知后清除对应字典的本地缓存,下次使用时重新从服务端加载。
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 3.0.0
|
||||
*/
|
||||
@Data
|
||||
public class DictChangeEvent {
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DictChangeEvent implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 字典编码 */
|
||||
private String dictCode;
|
||||
|
||||
/** 事件时间戳 */
|
||||
private long timestamp;
|
||||
|
||||
/**
|
||||
* 创建字典变更事件(自动设置当前时间戳)
|
||||
*
|
||||
* @param dictCode 字典编码
|
||||
*/
|
||||
public DictChangeEvent(String dictCode) {
|
||||
this.dictCode = dictCode;
|
||||
this.timestamp = System.currentTimeMillis();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.youlai.boot.platform.websocket.service.impl;
|
||||
|
||||
import com.youlai.boot.platform.websocket.dto.DictChangeEvent;
|
||||
import com.youlai.boot.platform.websocket.dto.OnlineUserDTO;
|
||||
import com.youlai.boot.platform.websocket.dto.TextMessage;
|
||||
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
|
||||
import com.youlai.boot.platform.websocket.session.UserSessionRegistry;
|
||||
@@ -101,7 +102,7 @@ public class WebSocketServiceImpl implements WebSocketService {
|
||||
*
|
||||
* @return 在线用户信息列表
|
||||
*/
|
||||
public List<UserSessionRegistry.OnlineUserDTO> getOnlineUsers() {
|
||||
public List<OnlineUserDTO> getOnlineUsers() {
|
||||
return userSessionRegistry.getOnlineUsers();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.youlai.boot.platform.websocket.session;
|
||||
|
||||
import com.youlai.boot.platform.websocket.dto.OnlineUserDTO;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
@@ -11,25 +12,74 @@ import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* WebSocket 用户会话注册表
|
||||
* <p>
|
||||
* 维护WebSocket连接的用户会话信息,支持多设备同时登录。
|
||||
* 采用双Map结构实现高效查询:
|
||||
* <ul>
|
||||
* <li>userSessionsMap: 用户名 -> 会话ID集合(支持多设备)</li>
|
||||
* <li>sessionDetailsMap: 会话ID -> 会话详情(快速定位用户)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 3.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class UserSessionRegistry {
|
||||
|
||||
/**
|
||||
* 用户会话映射表
|
||||
* <p>
|
||||
* Key: 用户名
|
||||
* Value: 该用户所有WebSocket会话ID集合(支持多设备登录)
|
||||
*/
|
||||
private final Map<String, Set<String>> userSessionsMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 会话详情映射表
|
||||
* <p>
|
||||
* Key: WebSocket会话ID
|
||||
* Value: 会话详情(包含用户名、连接时间等)
|
||||
*/
|
||||
private final Map<String, SessionInfo> sessionDetailsMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 用户上线(建立WebSocket连接)
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param sessionId WebSocket会话ID
|
||||
*/
|
||||
public void userConnected(String username, String sessionId) {
|
||||
userSessionsMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()).add(sessionId);
|
||||
sessionDetailsMap.put(sessionId, new SessionInfo(username, sessionId, System.currentTimeMillis()));
|
||||
log.debug("用户[{}]会话[{}]已注册", username, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户下线(断开所有WebSocket连接)
|
||||
* <p>
|
||||
* 移除该用户的所有会话信息
|
||||
*
|
||||
* @param username 用户名
|
||||
*/
|
||||
public void userDisconnected(String username) {
|
||||
Set<String> sessions = userSessionsMap.remove(username);
|
||||
if (sessions == null) {
|
||||
return;
|
||||
}
|
||||
sessions.forEach(sessionDetailsMap::remove);
|
||||
log.debug("用户[{}]已下线,移除{}个会话", username, sessions.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除指定会话(单设备下线)
|
||||
* <p>
|
||||
* 当用户某一设备断开连接时调用,保留其他设备的会话
|
||||
*
|
||||
* @param sessionId WebSocket会话ID
|
||||
*/
|
||||
public void removeSession(String sessionId) {
|
||||
SessionInfo sessionInfo = sessionDetailsMap.remove(sessionId);
|
||||
if (sessionInfo == null) {
|
||||
@@ -44,33 +94,63 @@ public class UserSessionRegistry {
|
||||
|
||||
sessions.remove(sessionId);
|
||||
if (sessions.isEmpty()) {
|
||||
// 该用户没有任何会话了,移除用户记录
|
||||
userSessionsMap.remove(username);
|
||||
log.debug("用户[{}]最后一个会话已移除", username);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线用户数量
|
||||
*
|
||||
* @return 当前在线用户数(非会话数)
|
||||
*/
|
||||
public int getOnlineUserCount() {
|
||||
return userSessionsMap.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户的会话数量
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 该用户的WebSocket会话数量(多设备登录时大于1)
|
||||
*/
|
||||
public int getUserSessionCount(String username) {
|
||||
Set<String> sessions = userSessionsMap.get(username);
|
||||
return sessions != null ? sessions.size() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取在线会话总数
|
||||
*
|
||||
* @return 所有WebSocket会话的总数(包含多设备)
|
||||
*/
|
||||
public int getTotalSessionCount() {
|
||||
return sessionDetailsMap.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否在线
|
||||
*
|
||||
* @param username 用户名
|
||||
* @return 是否在线(至少有一个活跃会话)
|
||||
*/
|
||||
public boolean isUserOnline(String username) {
|
||||
Set<String> sessions = userSessionsMap.get(username);
|
||||
return sessions != null && !sessions.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有在线用户列表
|
||||
*
|
||||
* @return 在线用户信息列表
|
||||
*/
|
||||
public List<OnlineUserDTO> getOnlineUsers() {
|
||||
return userSessionsMap.entrySet().stream()
|
||||
.map(entry -> {
|
||||
String username = entry.getKey();
|
||||
Set<String> sessions = entry.getValue();
|
||||
// 取最早的连接时间作为登录时间
|
||||
long earliestLoginTime = sessions.stream()
|
||||
.map(sessionDetailsMap::get)
|
||||
.filter(info -> info != null)
|
||||
@@ -83,21 +163,17 @@ public class UserSessionRegistry {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 会话详情(内部使用)
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
private static class SessionInfo {
|
||||
/** 用户名 */
|
||||
private String username;
|
||||
/** WebSocket会话ID */
|
||||
private String sessionId;
|
||||
/** 连接时间戳 */
|
||||
private long connectTime;
|
||||
}
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public static class OnlineUserDTO {
|
||||
private String username;
|
||||
private int sessionCount;
|
||||
private long loginTime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
package com.youlai.boot.plugin.mybatis;
|
||||
|
||||
import cn.hutool.core.collection.CollectionUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.toolkit.StringPool;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler;
|
||||
import com.youlai.boot.common.annotation.DataPermission;
|
||||
import com.youlai.boot.common.base.IBaseEnum;
|
||||
import com.youlai.boot.common.enums.DataScopeEnum;
|
||||
import com.youlai.boot.security.model.RoleDataScope;
|
||||
import com.youlai.boot.security.util.SecurityUtils;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.*;
|
||||
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
|
||||
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
|
||||
import net.sf.jsqlparser.expression.operators.relational.ExpressionList;
|
||||
import net.sf.jsqlparser.expression.operators.relational.InExpression;
|
||||
import net.sf.jsqlparser.schema.Column;
|
||||
import net.sf.jsqlparser.schema.Table;
|
||||
import net.sf.jsqlparser.statement.select.PlainSelect;
|
||||
import net.sf.jsqlparser.statement.select.Select;
|
||||
import net.sf.jsqlparser.statement.select.SubSelect;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 数据权限控制器
|
||||
* <p>
|
||||
* 支持多角色数据权限合并(并集策略):
|
||||
* - 如果任一角色是 ALL,则跳过数据权限过滤
|
||||
* - 否则用 OR 连接各角色的数据权限条件
|
||||
* <p>
|
||||
* 使用 JSQLParser 构建 SQL 条件,避免字符串拼接,提高代码安全性和可读性。
|
||||
*
|
||||
* @author zc
|
||||
* @since 2021-12-10 13:28
|
||||
@@ -24,9 +40,14 @@ import java.lang.reflect.Method;
|
||||
@Slf4j
|
||||
public class MyDataPermissionHandler implements DataPermissionHandler {
|
||||
|
||||
private static final String DEPT_TABLE = "sys_dept";
|
||||
private static final String DEPT_ID_COLUMN = "id";
|
||||
private static final String DEPT_TREE_PATH_COLUMN = "tree_path";
|
||||
|
||||
/**
|
||||
* 获取数据权限的sql片段
|
||||
* @param where 查询条件
|
||||
*
|
||||
* @param where 查询条件
|
||||
* @param mappedStatementId mapper接口方法的全路径
|
||||
* @return sql片段
|
||||
*/
|
||||
@@ -34,16 +55,25 @@ public class MyDataPermissionHandler implements DataPermissionHandler {
|
||||
@SneakyThrows
|
||||
public Expression getSqlSegment(Expression where, String mappedStatementId) {
|
||||
// 如果是未登录,或者是定时任务执行的SQL,或者是超级管理员,直接返回
|
||||
if(SecurityUtils.getUserId() == null || SecurityUtils.isRoot()){
|
||||
if (SecurityUtils.getUserId() == null || SecurityUtils.isRoot()) {
|
||||
return where;
|
||||
}
|
||||
// 获取当前用户的数据权限
|
||||
Integer dataScope = SecurityUtils.getDataScope();
|
||||
DataScopeEnum dataScopeEnum = IBaseEnum.getEnumByValue(dataScope, DataScopeEnum.class);
|
||||
// 如果是全部数据权限,直接返回
|
||||
if (DataScopeEnum.ALL.equals(dataScopeEnum)) {
|
||||
|
||||
// 获取当前用户的数据权限列表
|
||||
List<RoleDataScope> dataScopes = SecurityUtils.getUser()
|
||||
.map(user -> user.getDataScopes())
|
||||
.orElse(List.of());
|
||||
|
||||
// 如果任一角色是 ALL,则跳过数据权限过滤(并集策略)
|
||||
if (hasAllDataScope(dataScopes)) {
|
||||
return where;
|
||||
}
|
||||
|
||||
// 如果没有数据权限,跳过过滤
|
||||
if (CollectionUtil.isEmpty(dataScopes)) {
|
||||
return where;
|
||||
}
|
||||
|
||||
// 获取当前执行的接口类
|
||||
Class<?> clazz = Class.forName(mappedStatementId.substring(0, mappedStatementId.lastIndexOf(StringPool.DOT)));
|
||||
// 获取当前执行的方法名称
|
||||
@@ -51,65 +81,207 @@ public class MyDataPermissionHandler implements DataPermissionHandler {
|
||||
// 获取当前执行的接口类里所有的方法
|
||||
Method[] methods = clazz.getDeclaredMethods();
|
||||
for (Method method : methods) {
|
||||
//找到当前执行的方法
|
||||
// 找到当前执行的方法
|
||||
if (method.getName().equals(methodName)) {
|
||||
DataPermission annotation = method.getAnnotation(DataPermission.class);
|
||||
// 判断当前执行的方法是否有权限注解,如果没有注解直接返回
|
||||
if (annotation == null ) {
|
||||
if (annotation == null) {
|
||||
return where;
|
||||
}
|
||||
return dataScopeFilter(annotation.deptAlias(), annotation.deptIdColumnName(), annotation.userAlias(), annotation.userIdColumnName(), dataScopeEnum,where);
|
||||
// 使用并集策略过滤
|
||||
return dataScopeFilterWithUnion(annotation, dataScopes, where);
|
||||
}
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建过滤条件
|
||||
* 判断是否包含"全部数据"权限
|
||||
*
|
||||
* @param where 当前查询条件
|
||||
* @return 构建后查询条件
|
||||
* @param dataScopes 数据权限列表
|
||||
* @return 是否有全部数据权限
|
||||
*/
|
||||
@SneakyThrows
|
||||
public static Expression dataScopeFilter(String deptAlias, String deptIdColumnName, String userAlias, String userIdColumnName,DataScopeEnum dataScopeEnum, Expression where) {
|
||||
private boolean hasAllDataScope(List<RoleDataScope> dataScopes) {
|
||||
if (CollectionUtil.isEmpty(dataScopes)) {
|
||||
return false;
|
||||
}
|
||||
return dataScopes.stream()
|
||||
.anyMatch(scope -> DataScopeEnum.ALL.getValue().equals(scope.getDataScope()));
|
||||
}
|
||||
|
||||
// 获取部门和用户的别名
|
||||
String deptColumnName = StrUtil.isNotBlank(deptAlias) ? (deptAlias + StringPool.DOT + deptIdColumnName) : deptIdColumnName;
|
||||
String userColumnName = StrUtil.isNotBlank(userAlias) ? (userAlias + StringPool.DOT + userIdColumnName) : userIdColumnName;
|
||||
/**
|
||||
* 使用并集策略进行数据权限过滤
|
||||
* <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;
|
||||
String appendSqlStr;
|
||||
switch (dataScopeEnum) {
|
||||
case ALL:
|
||||
return where;
|
||||
case DEPT:
|
||||
deptId = SecurityUtils.getDeptId();
|
||||
appendSqlStr = deptColumnName + StringPool.EQUALS + deptId;
|
||||
break;
|
||||
case SELF:
|
||||
userId = SecurityUtils.getUserId();
|
||||
appendSqlStr = userColumnName + StringPool.EQUALS + userId;
|
||||
break;
|
||||
// 默认部门及子部门数据权限
|
||||
default:
|
||||
deptId = SecurityUtils.getDeptId();
|
||||
appendSqlStr = deptColumnName + " IN ( SELECT id FROM sys_dept WHERE id = " + deptId + " OR FIND_IN_SET( " + deptId + " , tree_path ) )";
|
||||
break;
|
||||
// 构建各角色的数据权限条件,使用 OR 连接实现并集
|
||||
Expression unionExpression = null;
|
||||
for (RoleDataScope dataScope : dataScopes) {
|
||||
Expression roleExpression = buildRoleDataScopeExpression(
|
||||
deptAlias, deptIdColumnName, userAlias, userIdColumnName, dataScope);
|
||||
if (roleExpression != null) {
|
||||
if (unionExpression == null) {
|
||||
unionExpression = roleExpression;
|
||||
} else {
|
||||
// 使用 OR 连接各角色的条件(并集)
|
||||
unionExpression = new OrExpression(unionExpression, roleExpression);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (StrUtil.isBlank(appendSqlStr)) {
|
||||
if (unionExpression == null) {
|
||||
return where;
|
||||
}
|
||||
|
||||
Expression appendExpression = CCJSqlParserUtil.parseCondExpression(appendSqlStr);
|
||||
// 用括号包裹并集条件
|
||||
Expression finalExpression = new Parenthesis(unionExpression);
|
||||
|
||||
if (where == null) {
|
||||
return appendExpression;
|
||||
return finalExpression;
|
||||
}
|
||||
|
||||
return new AndExpression(where, appendExpression);
|
||||
return new AndExpression(where, finalExpression);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单个角色的数据权限SQL条件
|
||||
* <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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,59 +8,71 @@ import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 短信验证码认证 Token
|
||||
* <p>
|
||||
* 用于短信验证码登录场景,遵循 Spring Security 认证模型:
|
||||
* <ul>
|
||||
* <li>未认证状态:principal 为手机号,credentials 为验证码</li>
|
||||
* <li>已认证状态:principal 为用户详情,credentials 为 null</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.20.0
|
||||
*/
|
||||
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 621L;
|
||||
|
||||
/**
|
||||
* 认证信息 (手机号)
|
||||
* 认证信息
|
||||
* <ul>
|
||||
* <li>未认证时:手机号</li>
|
||||
* <li>已认证时:SysUserDetails 用户详情</li>
|
||||
* </ul>
|
||||
*/
|
||||
private final Object principal;
|
||||
|
||||
/**
|
||||
* 凭证信息 (短信验证码)
|
||||
* 凭证信息
|
||||
* <ul>
|
||||
* <li>未认证时:短信验证码</li>
|
||||
* <li>已认证时:null</li>
|
||||
* </ul>
|
||||
*/
|
||||
private final Object credentials;
|
||||
|
||||
/**
|
||||
* 短信验证码认证 Token (未认证)
|
||||
* 创建未认证的 Token
|
||||
*
|
||||
* @param principal 微信用户信息
|
||||
* @param mobile 手机号
|
||||
* @param verifyCode 短信验证码
|
||||
*/
|
||||
public SmsAuthenticationToken(Object principal, Object credentials) {
|
||||
// 没有授权信息时,设置为 null
|
||||
super((Collection<? extends GrantedAuthority>) null);
|
||||
this.principal = principal;
|
||||
this.credentials = credentials;
|
||||
// 默认未认证
|
||||
this.setAuthenticated(false);
|
||||
public SmsAuthenticationToken(String mobile, String verifyCode) {
|
||||
super(null);
|
||||
this.principal = mobile;
|
||||
this.credentials = verifyCode;
|
||||
setAuthenticated(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码认证 Token (已认证)
|
||||
* 创建已认证的 Token
|
||||
*
|
||||
* @param principal 用户信息
|
||||
* @param principal 用户详情(SysUserDetails)
|
||||
* @param authorities 授权信息
|
||||
*/
|
||||
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
|
||||
super(authorities);
|
||||
this.principal = principal;
|
||||
this.credentials = null;
|
||||
// 认证通过
|
||||
super.setAuthenticated(true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 认证通过
|
||||
* 创建已认证的 Token(静态工厂方法)
|
||||
*
|
||||
* @param principal 用户信息
|
||||
* @param principal 用户详情(SysUserDetails)
|
||||
* @param authorities 授权信息
|
||||
* @return SmsAuthenticationToken
|
||||
* @return 已认证的 SmsAuthenticationToken
|
||||
*/
|
||||
public static SmsAuthenticationToken authenticated(Object principal, Collection<? extends GrantedAuthority> authorities) {
|
||||
return new SmsAuthenticationToken(principal, authorities);
|
||||
|
||||
@@ -3,15 +3,13 @@ package com.youlai.boot.security.model;
|
||||
import cn.hutool.core.collection.CollectionUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.youlai.boot.common.constant.SecurityConstants;
|
||||
import com.youlai.boot.security.model.UserAuthInfo;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -53,9 +51,11 @@ public class SysUserDetails implements UserDetails {
|
||||
private Long deptId;
|
||||
|
||||
/**
|
||||
* 数据权限范围
|
||||
* 数据权限列表
|
||||
* <p>
|
||||
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
|
||||
*/
|
||||
private Integer dataScope;
|
||||
private List<RoleDataScope> dataScopes;
|
||||
|
||||
/**
|
||||
* 用户角色权限集合
|
||||
@@ -73,7 +73,7 @@ public class SysUserDetails implements UserDetails {
|
||||
this.password = user.getPassword();
|
||||
this.enabled = ObjectUtil.equal(user.getStatus(), 1);
|
||||
this.deptId = user.getDeptId();
|
||||
this.dataScope = user.getDataScope();
|
||||
this.dataScopes = user.getDataScopes();
|
||||
|
||||
// 初始化角色权限集合
|
||||
this.authorities = CollectionUtil.isNotEmpty(user.getRoles())
|
||||
@@ -104,4 +104,26 @@ public class SysUserDetails implements UserDetails {
|
||||
public boolean isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否包含"全部数据"权限
|
||||
*
|
||||
* @return 是否有全部数据权限
|
||||
*/
|
||||
public boolean hasAllDataScope() {
|
||||
if (CollectionUtil.isEmpty(dataScopes)) {
|
||||
return false;
|
||||
}
|
||||
return dataScopes.stream()
|
||||
.anyMatch(scope -> scope.getDataScope() == 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据权限列表
|
||||
*
|
||||
* @return 数据权限列表,永不为null
|
||||
*/
|
||||
public List<RoleDataScope> getDataScopes() {
|
||||
return dataScopes != null ? dataScopes : Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.youlai.boot.security.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -52,7 +53,9 @@ public class UserAuthInfo {
|
||||
private Set<String> roles;
|
||||
|
||||
/**
|
||||
* 数据权限范围
|
||||
* 数据权限列表
|
||||
* <p>
|
||||
* 存储用户所有角色的数据权限范围,用于实现多角色权限合并(并集策略)
|
||||
*/
|
||||
private Integer dataScope;
|
||||
private List<RoleDataScope> dataScopes;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 在线用户信息对象
|
||||
* 用户会话信息
|
||||
* <p>
|
||||
* 存储在Token中的用户会话快照,包含用户身份、数据权限和角色权限信息。
|
||||
* 用于Redis-Token模式下的会话管理,支持在线用户查询和会话控制。
|
||||
*
|
||||
* @author wangtao
|
||||
* @since 2025/2/27 10:31
|
||||
@@ -15,7 +19,7 @@ import java.util.Set;
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class OnlineUser {
|
||||
public class UserSession {
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
@@ -33,10 +37,9 @@ public class OnlineUser {
|
||||
private Long deptId;
|
||||
|
||||
/**
|
||||
* 数据权限范围
|
||||
* <p>定义用户可访问的数据范围,如全部、本部门或自定义范围</p>
|
||||
* 数据权限列表
|
||||
*/
|
||||
private Integer dataScope;
|
||||
private List<RoleDataScope> dataScopes;
|
||||
|
||||
/**
|
||||
* 角色权限集合
|
||||
@@ -18,9 +18,22 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
|
||||
/**
|
||||
* 短信验证码认证 Provider
|
||||
* <p>
|
||||
* 实现 Spring Security 的 {@link AuthenticationProvider} 接口,处理短信验证码登录认证。
|
||||
* <p>
|
||||
* 认证流程:
|
||||
* <ol>
|
||||
* <li>根据手机号查询用户信息</li>
|
||||
* <li>校验用户状态(是否禁用)</li>
|
||||
* <li>校验短信验证码(与 Redis 缓存比对)</li>
|
||||
* <li>验证成功后删除验证码,防止重复使用</li>
|
||||
* <li>返回已认证的 Authentication</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2.17.0
|
||||
* @see SmsAuthenticationToken
|
||||
* @see AuthenticationProvider
|
||||
*/
|
||||
@Slf4j
|
||||
public class SmsAuthenticationProvider implements AuthenticationProvider {
|
||||
@@ -29,58 +42,79 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
|
||||
public SmsAuthenticationProvider(UserService userService, RedisTemplate<String, Object> redisTemplate) {
|
||||
this.userService = userService;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 短信验证码认证逻辑,参考 Spring Security 认证密码校验流程
|
||||
* 执行短信验证码认证
|
||||
*
|
||||
* @param authentication 认证对象
|
||||
* @return 认证后的 Authentication 对象
|
||||
* @throws AuthenticationException 认证异常
|
||||
* @see org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate(Authentication)
|
||||
* @param authentication 未认证的 {@link SmsAuthenticationToken}
|
||||
* @return 已认证的 {@link SmsAuthenticationToken}
|
||||
* @throws AuthenticationException 认证失败异常
|
||||
*/
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
String mobile = (String) authentication.getPrincipal();
|
||||
String inputVerifyCode = (String) authentication.getCredentials();
|
||||
|
||||
// 参数校验
|
||||
if (StrUtil.isBlank(mobile)) {
|
||||
log.warn("短信验证码登录失败:手机号为空");
|
||||
throw new CaptchaValidationException("手机号不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(inputVerifyCode)) {
|
||||
log.warn("短信验证码登录失败:验证码为空,手机号={}", mobile);
|
||||
throw new CaptchaValidationException("验证码不能为空");
|
||||
}
|
||||
|
||||
// 根据手机号获取用户信息
|
||||
UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile);
|
||||
|
||||
if (userAuthInfo == null) {
|
||||
log.warn("短信验证码登录失败:用户不存在,手机号={}", mobile);
|
||||
throw new UsernameNotFoundException("用户不存在");
|
||||
}
|
||||
|
||||
// 检查用户状态是否有效
|
||||
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
|
||||
log.warn("短信验证码登录失败:用户已禁用,用户名={}", userAuthInfo.getUsername());
|
||||
throw new DisabledException("用户已被禁用");
|
||||
}
|
||||
|
||||
// 校验发送短信验证码的手机号是否与当前登录用户一致
|
||||
// 校验短信验证码
|
||||
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
|
||||
String cachedVerifyCode = (String) redisTemplate.opsForValue().get(cacheKey);
|
||||
|
||||
if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) {
|
||||
throw new CaptchaValidationException("验证码错误");
|
||||
} else {
|
||||
// 验证成功后删除验证码
|
||||
redisTemplate.delete(cacheKey);
|
||||
if (cachedVerifyCode == null) {
|
||||
log.warn("短信验证码登录失败:验证码已过期,手机号={}", mobile);
|
||||
throw new CaptchaValidationException("验证码已过期,请重新获取");
|
||||
}
|
||||
|
||||
if (!StrUtil.equals(inputVerifyCode, cachedVerifyCode)) {
|
||||
log.warn("短信验证码登录失败:验证码错误,手机号={}", mobile);
|
||||
throw new CaptchaValidationException("验证码错误");
|
||||
}
|
||||
|
||||
// 验证成功后删除验证码,防止重复使用
|
||||
redisTemplate.delete(cacheKey);
|
||||
|
||||
// 构建认证后的用户详情信息
|
||||
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
|
||||
|
||||
log.info("短信验证码登录成功:用户名={},手机号={}", userAuthInfo.getUsername(), mobile);
|
||||
|
||||
// 创建已认证的 SmsAuthenticationToken
|
||||
return SmsAuthenticationToken.authenticated(
|
||||
userDetails,
|
||||
userDetails.getAuthorities()
|
||||
);
|
||||
return SmsAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities());
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持的认证类型
|
||||
*
|
||||
* @param authentication 认证类型
|
||||
* @return 是否支持该认证类型
|
||||
*/
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
|
||||
|
||||
@@ -2,18 +2,21 @@ package com.youlai.boot.security.service;
|
||||
|
||||
import cn.hutool.core.collection.CollectionUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
import com.youlai.boot.security.util.SecurityUtils;
|
||||
import com.youlai.boot.system.service.RoleMenuService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.PatternMatchUtils;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* SpringSecurity 权限校验
|
||||
* Spring Security 权限校验组件
|
||||
* <p>
|
||||
* 用于 SpEL 表达式权限校验,如:@PreAuthorize("@ss.hasPerm('sys:user:add')")
|
||||
* <p>
|
||||
* 权限数据来源:{@link RoleMenuService#getRolePermsByRoleCodes}(带 Redis 缓存)
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 0.0.1
|
||||
@@ -23,19 +26,21 @@ import java.util.*;
|
||||
@Slf4j
|
||||
public class PermissionService {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final RoleMenuService roleMenuService;
|
||||
|
||||
/**
|
||||
* 判断当前登录用户是否拥有操作权限
|
||||
* <p>
|
||||
* 支持通配符匹配,如:权限码 "sys:user:*" 可匹配 "sys:user:add"、"sys:user:delete" 等
|
||||
*
|
||||
* @param requiredPerm 所需权限
|
||||
* @return 是否有权限
|
||||
*/
|
||||
public boolean hasPerm(String requiredPerm) {
|
||||
|
||||
if (StrUtil.isBlank(requiredPerm)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 超级管理员放行
|
||||
if (SecurityUtils.isRoot()) {
|
||||
return true;
|
||||
@@ -47,52 +52,21 @@ public class PermissionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取当前登录用户的所有角色的权限列表
|
||||
Set<String> rolePerms = this.getRolePermsFormCache(roleCodes);
|
||||
// 获取当前登录用户的所有角色的权限列表(从缓存读取)
|
||||
Set<String> rolePerms = roleMenuService.getRolePermsByRoleCodes(roleCodes);
|
||||
if (CollectionUtil.isEmpty(rolePerms)) {
|
||||
return false;
|
||||
}
|
||||
// 判断当前登录用户的所有角色的权限列表中是否包含所需权限
|
||||
|
||||
// 判断权限列表中是否包含所需权限(支持通配符)
|
||||
boolean hasPermission = rolePerms.stream()
|
||||
.anyMatch(rolePerm ->
|
||||
// 匹配权限,支持通配符(* 等)
|
||||
PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)
|
||||
);
|
||||
.anyMatch(rolePerm -> PatternMatchUtils.simpleMatch(rolePerm, requiredPerm));
|
||||
|
||||
if (!hasPermission) {
|
||||
log.error("用户无操作权限:{}",requiredPerm);
|
||||
log.warn("用户无操作权限:userId={}, username={}, requiredPerm={}",
|
||||
SecurityUtils.getUserId(), SecurityUtils.getUsername(), requiredPerm);
|
||||
}
|
||||
return hasPermission;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 从缓存中获取角色权限列表
|
||||
*
|
||||
* @param roleCodes 角色编码集合
|
||||
* @return 角色权限列表
|
||||
*/
|
||||
public Set<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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.hutool.jwt.JWT;
|
||||
import cn.hutool.jwt.JWTPayload;
|
||||
import cn.hutool.jwt.JWTUtil;
|
||||
@@ -15,6 +17,7 @@ import com.youlai.boot.core.exception.BusinessException;
|
||||
import com.youlai.boot.core.web.ResultCode;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import com.youlai.boot.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.security.model.RoleDataScope;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import com.youlai.boot.security.model.SysUserDetails;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -25,17 +28,20 @@ import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* JWT Token 管理器
|
||||
* <p>
|
||||
* 用于生成、解析、校验、刷新 JWT Token
|
||||
* 实现基于JWT的无状态认证,支持:
|
||||
* <ul>
|
||||
* <li>Access Token + Refresh Token 双令牌机制</li>
|
||||
* <li>Token 撤销(jti黑名单)</li>
|
||||
* <li>用户级会话失效(tokenValidAfter)</li>
|
||||
* <li>多角色数据权限存储</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2024/11/15
|
||||
@@ -44,6 +50,9 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class JwtTokenManager implements TokenManager {
|
||||
|
||||
/** tokenValidAfter 默认过期时间(7天),避免Redis内存泄漏 */
|
||||
private static final long TOKEN_VALID_AFTER_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
private final SecurityProperties securityProperties;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final byte[] secretKey;
|
||||
@@ -90,7 +99,25 @@ public class JwtTokenManager implements TokenManager {
|
||||
SysUserDetails userDetails = new SysUserDetails();
|
||||
userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); // 用户ID
|
||||
userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); // 部门ID
|
||||
userDetails.setDataScope(payloads.getInt(JwtClaimConstants.DATA_SCOPE)); // 数据权限范围
|
||||
|
||||
// 解析数据权限列表
|
||||
JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES);
|
||||
if (dataScopesArray != null && !dataScopesArray.isEmpty()) {
|
||||
List<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)); // 用户名
|
||||
// 角色集合
|
||||
@@ -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 validateRefreshToken 是否校验刷新令牌
|
||||
* @param validateRefreshToken 是否校验刷新令牌类型
|
||||
* @return 是否有效
|
||||
*/
|
||||
private boolean validateToken(String token, boolean validateRefreshToken) {
|
||||
@@ -147,27 +182,28 @@ public class JwtTokenManager implements TokenManager {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 2. 校验安全版本号(用于按用户维度失效历史 Token)
|
||||
// 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,将用户安全版本号 +1,旧版本 Token 全部失效
|
||||
// 2. 校验 tokenValidAfter(用于按用户维度失效历史 Token)
|
||||
// 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,更新 tokenValidAfter,早于该时间签发的 Token 全部失效
|
||||
Long userId = payloads.getLong(JwtClaimConstants.USER_ID);
|
||||
if (userId != null) {
|
||||
// 老版本 Token 可能没有 SECURITY_VERSION 声明,视为 0 版本
|
||||
Integer tokenVersionRaw = payloads.getInt(JwtClaimConstants.SECURITY_VERSION);
|
||||
int tokenVersion = tokenVersionRaw != null ? tokenVersionRaw : 0;
|
||||
Object issuedAtObj = payloads.get(JWTPayload.ISSUED_AT);
|
||||
long issuedAtSeconds = 0;
|
||||
if (issuedAtObj instanceof Date issuedAtDate) {
|
||||
issuedAtSeconds = issuedAtDate.getTime() / 1000;
|
||||
}
|
||||
|
||||
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId);
|
||||
Integer currentVersionRaw = (Integer) redisTemplate.opsForValue().get(versionKey);
|
||||
int currentVersion = currentVersionRaw != null ? currentVersionRaw : 0;
|
||||
String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId);
|
||||
Object validAfterObj = redisTemplate.opsForValue().get(validAfterKey);
|
||||
long validAfterSeconds = validAfterObj != null ? Convert.toLong(validAfterObj) : 0;
|
||||
|
||||
// 如果当前版本号比 Token 携带的版本号新,则认为该 Token 已失效
|
||||
if (tokenVersion < currentVersion) {
|
||||
if (issuedAtSeconds < validAfterSeconds) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 判断是否在黑名单中,如果在,则返回 false 标识Token无效
|
||||
// 3. 判断 Token 是否已被撤销(单端退出/会话注销)
|
||||
// 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等
|
||||
if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) {
|
||||
if (isTokenRevoked(jti)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -190,29 +226,63 @@ public class JwtTokenManager implements TokenManager {
|
||||
}
|
||||
JWT jwt = JWTUtil.parseToken(token);
|
||||
JSONObject payloads = jwt.getPayloads();
|
||||
String jti = payloads.getStr(JWTPayload.JWT_ID);
|
||||
Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT);
|
||||
// 黑名单Token Key
|
||||
String blacklistTokenKey = StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, payloads.getStr(JWTPayload.JWT_ID));
|
||||
revokeTokenByJti(jti, expirationAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Token是否已被撤销
|
||||
*
|
||||
* @param jti Token唯一标识
|
||||
* @return true-已撤销,false-未撤销
|
||||
*/
|
||||
private boolean isTokenRevoked(String jti) {
|
||||
if (StringUtils.isBlank(jti)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Token加入撤销黑名单
|
||||
* <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) {
|
||||
int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000);
|
||||
if (expirationAt < currentTimeSeconds) {
|
||||
// Token已过期,直接返回
|
||||
return;
|
||||
}
|
||||
// 计算Token剩余时间,将其加入黑名单(值使用简单布尔标记即可)
|
||||
int expirationIn = expirationAt - currentTimeSeconds;
|
||||
redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
|
||||
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS);
|
||||
} else {
|
||||
// 永不过期的Token永久加入黑名单
|
||||
redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE);
|
||||
redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失效指定用户的所有会话
|
||||
* <p>
|
||||
* 通过提升用户的安全版本号,使携带旧版本号的 Token 在后续校验时全部失效
|
||||
* 通过更新用户 tokenValidAfter 时间戳,使早于该时间签发的 Token 全部失效。
|
||||
* <p>
|
||||
* 适用场景:
|
||||
* <ul>
|
||||
* <li>用户修改密码</li>
|
||||
* <li>管理员强制下线用户</li>
|
||||
* <li>用户主动踢出所有设备</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@Override
|
||||
public void invalidateUserSessions(Long userId) {
|
||||
@@ -220,10 +290,10 @@ public class JwtTokenManager implements TokenManager {
|
||||
return;
|
||||
}
|
||||
|
||||
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId);
|
||||
// 递增版本号
|
||||
redisTemplate.opsForValue().increment(versionKey);
|
||||
|
||||
String validAfterKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VALID_AFTER, userId);
|
||||
long nowSeconds = System.currentTimeMillis() / 1000;
|
||||
// 设置过期时间,避免Redis内存泄漏
|
||||
redisTemplate.opsForValue().set(validAfterKey, nowSeconds, TOKEN_VALID_AFTER_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,8 +323,8 @@ public class JwtTokenManager implements TokenManager {
|
||||
* 生成 JWT Token
|
||||
*
|
||||
* @param authentication 认证信息
|
||||
* @param ttl 过期时间
|
||||
* @return JWT Token
|
||||
* @param ttl 过期时间(秒),-1表示永不过期
|
||||
* @return JWT Token字符串
|
||||
*/
|
||||
private String generateToken(Authentication authentication, int ttl) {
|
||||
return generateToken(authentication, ttl, false);
|
||||
@@ -263,18 +333,43 @@ public class JwtTokenManager implements TokenManager {
|
||||
|
||||
/**
|
||||
* 生成 JWT Token
|
||||
* <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 ttl 过期时间
|
||||
* @param isRefreshToken 类型是否为刷新token
|
||||
* @return JWT Token
|
||||
* @param authentication 认证信息
|
||||
* @param ttl 过期时间(秒)
|
||||
* @param isRefreshToken 是否为刷新令牌
|
||||
* @return JWT Token字符串
|
||||
*/
|
||||
private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) {
|
||||
SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
|
||||
payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); // 部门ID
|
||||
payload.put(JwtClaimConstants.DATA_SCOPE, userDetails.getDataScope()); // 数据权限范围
|
||||
|
||||
// 存储数据权限列表
|
||||
List<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 中添加角色信息
|
||||
Set<String> roles = authentication.getAuthorities().stream()
|
||||
@@ -289,12 +384,6 @@ public class JwtTokenManager implements TokenManager {
|
||||
payload.put(JwtClaimConstants.TOKEN_TYPE, true);
|
||||
}
|
||||
|
||||
// 设置安全版本号:不存在时默认为 0
|
||||
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userDetails.getUserId());
|
||||
Integer currentVersion = (Integer) redisTemplate.opsForValue().get(versionKey);
|
||||
int securityVersion = currentVersion != null ? currentVersion : 0;
|
||||
payload.put(JwtClaimConstants.SECURITY_VERSION, securityVersion);
|
||||
|
||||
// 设置过期时间 -1 表示永不过期
|
||||
if (ttl != -1) {
|
||||
Date expiresAt = DateUtil.offsetSecond(now, ttl);
|
||||
|
||||
@@ -8,7 +8,8 @@ import com.youlai.boot.core.exception.BusinessException;
|
||||
import com.youlai.boot.core.web.ResultCode;
|
||||
import com.youlai.boot.config.property.SecurityProperties;
|
||||
import com.youlai.boot.security.model.AuthenticationToken;
|
||||
import com.youlai.boot.security.model.OnlineUser;
|
||||
import com.youlai.boot.security.model.UserSession;
|
||||
import com.youlai.boot.security.model.RoleDataScope;
|
||||
import com.youlai.boot.security.model.SysUserDetails;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
@@ -26,7 +27,15 @@ import java.util.stream.Collectors;
|
||||
/**
|
||||
* Redis Token 管理器
|
||||
* <p>
|
||||
* 用于生成、解析、校验、刷新 Redis Token
|
||||
* 实现基于Redis的有状态认证,支持:
|
||||
* <ul>
|
||||
* <li>Access Token + Refresh Token 双令牌机制</li>
|
||||
* <li>单设备/多设备登录控制</li>
|
||||
* <li>用户级会话失效</li>
|
||||
* <li>在线用户管理</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 与JWT模式相比,Redis模式支持主动踢人、在线用户查询等功能
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 2024/11/15
|
||||
@@ -55,19 +64,19 @@ public class RedisTokenManager implements TokenManager {
|
||||
String accessToken = IdUtil.fastSimpleUUID();
|
||||
String refreshToken = IdUtil.fastSimpleUUID();
|
||||
|
||||
// 构建用户在线信息
|
||||
OnlineUser onlineUser = new OnlineUser(
|
||||
// 构建用户会话信息
|
||||
UserSession userSession = new UserSession(
|
||||
user.getUserId(),
|
||||
user.getUsername(),
|
||||
user.getDeptId(),
|
||||
user.getDataScope(),
|
||||
user.getDataScopes(),
|
||||
user.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.collect(Collectors.toSet())
|
||||
);
|
||||
|
||||
// 存储访问令牌、刷新令牌和刷新令牌映射
|
||||
storeTokensInRedis(accessToken, refreshToken, onlineUser);
|
||||
storeTokensInRedis(accessToken, refreshToken, userSession);
|
||||
|
||||
// 单设备登录控制
|
||||
handleSingleDeviceLogin(user.getUserId(), accessToken);
|
||||
@@ -87,13 +96,13 @@ public class RedisTokenManager implements TokenManager {
|
||||
*/
|
||||
@Override
|
||||
public Authentication parseToken(String token) {
|
||||
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token));
|
||||
if (onlineUser == null) return null;
|
||||
UserSession userSession = (UserSession) redisTemplate.opsForValue().get(formatTokenKey(token));
|
||||
if (userSession == null) return null;
|
||||
|
||||
// 构建用户权限集合
|
||||
Set<SimpleGrantedAuthority> authorities = null;
|
||||
|
||||
Set<String> roles = onlineUser.getRoles();
|
||||
Set<String> roles = userSession.getRoles();
|
||||
if (CollectionUtil.isNotEmpty(roles)) {
|
||||
authorities = roles.stream()
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
@@ -101,7 +110,7 @@ public class RedisTokenManager implements TokenManager {
|
||||
}
|
||||
|
||||
// 构建用户详情对象
|
||||
SysUserDetails userDetails = buildUserDetails(onlineUser, authorities);
|
||||
SysUserDetails userDetails = buildUserDetails(userSession, authorities);
|
||||
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
|
||||
}
|
||||
|
||||
@@ -135,12 +144,12 @@ public class RedisTokenManager implements TokenManager {
|
||||
*/
|
||||
@Override
|
||||
public AuthenticationToken refreshToken(String refreshToken) {
|
||||
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue()
|
||||
UserSession userSession = (UserSession) redisTemplate.opsForValue()
|
||||
.get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
|
||||
if (onlineUser == null) {
|
||||
if (userSession == null) {
|
||||
throw new BusinessException(ResultCode.REFRESH_TOKEN_INVALID);
|
||||
}
|
||||
Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId()));
|
||||
Object oldAccessTokenValue = redisTemplate.opsForValue().get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId()));
|
||||
// 删除旧的访问令牌记录
|
||||
Optional.of(oldAccessTokenValue)
|
||||
.map(String.class::cast)
|
||||
@@ -148,7 +157,7 @@ public class RedisTokenManager implements TokenManager {
|
||||
|
||||
// 生成新访问令牌并存储
|
||||
String newAccessToken = IdUtil.fastSimpleUUID();
|
||||
storeAccessToken(newAccessToken, onlineUser);
|
||||
storeAccessToken(newAccessToken, userSession);
|
||||
|
||||
int accessTtl = securityProperties.getSession().getAccessTokenTimeToLive();
|
||||
return AuthenticationToken.builder()
|
||||
@@ -166,14 +175,16 @@ public class RedisTokenManager implements TokenManager {
|
||||
@Override
|
||||
public void invalidateToken(String token) {
|
||||
Object value = redisTemplate.opsForValue().get(formatTokenKey(token));
|
||||
if (value instanceof OnlineUser onlineUser) {
|
||||
Long userId = onlineUser.getUserId();
|
||||
if (value instanceof UserSession userSession) {
|
||||
Long userId = userSession.getUserId();
|
||||
invalidateUserSessions(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使指定用户的所有会话失效
|
||||
* <p>
|
||||
* 适用场景:用户修改密码、管理员强制下线、账号封禁等
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
@@ -207,24 +218,26 @@ public class RedisTokenManager implements TokenManager {
|
||||
*
|
||||
* @param accessToken 访问令牌
|
||||
* @param refreshToken 刷新令牌
|
||||
* @param onlineUser 在线用户信息
|
||||
* @param userSession 用户会话信息
|
||||
*/
|
||||
private void storeTokensInRedis(String accessToken, String refreshToken, OnlineUser onlineUser) {
|
||||
private void storeTokensInRedis(String accessToken, String refreshToken, UserSession userSession) {
|
||||
// 访问令牌 -> 用户信息
|
||||
setRedisValue(formatTokenKey(accessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive());
|
||||
setRedisValue(formatTokenKey(accessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
|
||||
|
||||
// 刷新令牌 -> 用户信息
|
||||
String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken);
|
||||
setRedisValue(refreshTokenKey, onlineUser, securityProperties.getSession().getRefreshTokenTimeToLive());
|
||||
setRedisValue(refreshTokenKey, userSession, securityProperties.getSession().getRefreshTokenTimeToLive());
|
||||
|
||||
// 用户ID -> 刷新令牌
|
||||
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, onlineUser.getUserId()),
|
||||
setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userSession.getUserId()),
|
||||
refreshToken,
|
||||
securityProperties.getSession().getRefreshTokenTimeToLive());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单设备登录控制
|
||||
* <p>
|
||||
* 当配置不允许多设备登录时,新登录会使旧Token失效
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param accessToken 新生成的访问令牌
|
||||
@@ -247,27 +260,27 @@ public class RedisTokenManager implements TokenManager {
|
||||
* 存储新的访问令牌
|
||||
*
|
||||
* @param newAccessToken 新访问令牌
|
||||
* @param onlineUser 在线用户信息
|
||||
* @param userSession 用户会话信息
|
||||
*/
|
||||
private void storeAccessToken(String newAccessToken, OnlineUser onlineUser) {
|
||||
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), onlineUser, securityProperties.getSession().getAccessTokenTimeToLive());
|
||||
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, onlineUser.getUserId());
|
||||
private void storeAccessToken(String newAccessToken, UserSession userSession) {
|
||||
setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), userSession, securityProperties.getSession().getAccessTokenTimeToLive());
|
||||
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userSession.getUserId());
|
||||
setRedisValue(userAccessKey, newAccessToken, securityProperties.getSession().getAccessTokenTimeToLive());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户详情对象
|
||||
*
|
||||
* @param onlineUser 在线用户信息
|
||||
* @param userSession 用户会话信息
|
||||
* @param authorities 权限集合
|
||||
* @return SysUserDetails 用户详情
|
||||
*/
|
||||
private SysUserDetails buildUserDetails(OnlineUser onlineUser, Set<SimpleGrantedAuthority> authorities) {
|
||||
private SysUserDetails buildUserDetails(UserSession userSession, Set<SimpleGrantedAuthority> authorities) {
|
||||
SysUserDetails userDetails = new SysUserDetails();
|
||||
userDetails.setUserId(onlineUser.getUserId());
|
||||
userDetails.setUsername(onlineUser.getUsername());
|
||||
userDetails.setDeptId(onlineUser.getDeptId());
|
||||
userDetails.setDataScope(onlineUser.getDataScope());
|
||||
userDetails.setUserId(userSession.getUserId());
|
||||
userDetails.setUsername(userSession.getUsername());
|
||||
userDetails.setDeptId(userSession.getDeptId());
|
||||
userDetails.setDataScopes(userSession.getDataScopes());
|
||||
userDetails.setAuthorities(authorities);
|
||||
return userDetails;
|
||||
}
|
||||
|
||||
@@ -70,16 +70,6 @@ public class SecurityUtils {
|
||||
return getUser().map(SysUserDetails::getDeptId).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据权限范围
|
||||
*
|
||||
* @return Integer
|
||||
*/
|
||||
public static Integer getDataScope() {
|
||||
return getUser().map(SysUserDetails::getDataScope).orElse(null);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取角色集合
|
||||
*
|
||||
|
||||
@@ -120,4 +120,14 @@ public class RoleController {
|
||||
roleService.assignMenusToRole(roleId, menuIds);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@Operation(summary = "获取角色的部门ID集合(自定义数据权限)")
|
||||
@GetMapping("/{roleId}/dept-ids")
|
||||
@PreAuthorize("@ss.hasPerm('sys:role:update')")
|
||||
public Result<List<Long>> getRoleDeptIds(
|
||||
@Parameter(description = "角色ID") @PathVariable Long roleId
|
||||
) {
|
||||
List<Long> deptIds = roleService.getRoleDeptIds(roleId);
|
||||
return Result.success(deptIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -3,7 +3,10 @@ package com.youlai.boot.system.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.youlai.boot.system.model.entity.Role;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -22,4 +25,15 @@ public interface RoleMapper extends BaseMapper<Role> {
|
||||
* @return {@link Integer} – 数据权限范围
|
||||
*/
|
||||
Integer getMaximumDataScope(Set<String> roles);
|
||||
|
||||
/**
|
||||
* 获取角色的数据权限信息列表
|
||||
* <p>
|
||||
* 返回角色编码和数据权限范围的映射列表
|
||||
*
|
||||
* @param roleCodes 角色编码集合
|
||||
* @return 角色数据权限信息列表 [{code: 'ADMIN', data_scope: 1}, ...]
|
||||
*/
|
||||
List<Map<String, Object>> getRoleDataScopeList(@Param("roleCodes") Set<String> roleCodes);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import lombok.Data;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.hibernate.validator.constraints.Range;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "角色表单对象")
|
||||
@Data
|
||||
public class RoleForm {
|
||||
@@ -29,7 +31,10 @@ public class RoleForm {
|
||||
@Range(max = 1, min = 0, message = "角色状态不正确")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description="数据权限")
|
||||
@Schema(description="数据权限(1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据)")
|
||||
private Integer dataScope;
|
||||
|
||||
@Schema(description="自定义数据权限部门ID列表(当dataScope=5时有效)")
|
||||
private List<Long> deptIds;
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import java.util.Set;
|
||||
/**
|
||||
* 角色菜单业务接口
|
||||
*
|
||||
* @author haoxr
|
||||
* @author Ray.Hao
|
||||
* @since 2.5.0
|
||||
*/
|
||||
public interface RoleMenuService extends IService<RoleMenu> {
|
||||
@@ -45,10 +45,12 @@ public interface RoleMenuService extends IService<RoleMenu> {
|
||||
void refreshRolePermsCache(String oldRoleCode, String newRoleCode);
|
||||
|
||||
/**
|
||||
* 获取角色权限集合
|
||||
* 获取角色权限集合(带缓存)
|
||||
* <p>
|
||||
* 采用 Read-Through 缓存策略,缓存未命中时自动回源数据库
|
||||
*
|
||||
* @param roles 角色编码集合
|
||||
* @param roleCodes 角色编码集合
|
||||
* @return 权限集合
|
||||
*/
|
||||
Set<String> getRolePermsByRoleCodes(Set<String> roles);
|
||||
Set<String> getRolePermsByRoleCodes(Set<String> roleCodes);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.youlai.boot.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.youlai.boot.security.model.RoleDataScope;
|
||||
import com.youlai.boot.system.model.entity.Role;
|
||||
import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.system.model.form.RoleForm;
|
||||
@@ -91,5 +92,22 @@ public interface RoleService extends IService<Role> {
|
||||
*/
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.List;
|
||||
/**
|
||||
* 用户公告状态服务类
|
||||
*
|
||||
* @author youlaitech
|
||||
* @author Theo
|
||||
* @since 2024-08-28 16:56
|
||||
*/
|
||||
public interface UserNoticeService extends IService<UserNotice> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,19 @@ import com.youlai.boot.system.model.entity.UserRole;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户角色业务接口
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 0.0.1
|
||||
*/
|
||||
public interface UserRoleService extends IService<UserRole> {
|
||||
|
||||
/**
|
||||
* 保存用户角色
|
||||
*
|
||||
* @param userId
|
||||
* @param roleIds
|
||||
* @param userId 用户ID
|
||||
* @param roleIds 角色ID列表
|
||||
* @return
|
||||
*/
|
||||
void saveUserRoles(Long userId, List<Long> roleIds);
|
||||
|
||||
@@ -25,7 +25,7 @@ public interface UserService extends IService<User> {
|
||||
/**
|
||||
* 用户分页列表
|
||||
*
|
||||
* @return {@link IPage<UserPageVo>} 用户分页列表
|
||||
* @return {@link IPage<UserPageVO>} 用户分页列表
|
||||
*/
|
||||
IPage<UserPageVO> getUserPage(UserQuery queryParams);
|
||||
|
||||
@@ -82,7 +82,7 @@ public interface UserService extends IService<User> {
|
||||
* 获取导出用户列表
|
||||
*
|
||||
* @param queryParams 查询参数
|
||||
* @return {@link List<UserExportDto>} 导出用户列表
|
||||
* @return {@link List<UserExportDTO>} 导出用户列表
|
||||
*/
|
||||
List<UserExportDTO> listExportUsers(UserQuery queryParams);
|
||||
|
||||
@@ -90,14 +90,14 @@ public interface UserService extends IService<User> {
|
||||
/**
|
||||
* 获取登录用户信息
|
||||
*
|
||||
* @return {@link CurrentUserDto} 登录用户信息
|
||||
* @return {@link CurrentUserDTO} 登录用户信息
|
||||
*/
|
||||
CurrentUserDTO getCurrentUserInfo();
|
||||
|
||||
/**
|
||||
* 获取个人中心用户信息
|
||||
*
|
||||
* @return {@link UserProfileVo} 个人中心用户信息
|
||||
* @return {@link UserProfileVO} 个人中心用户信息
|
||||
*/
|
||||
UserProfileVO getUserProfile(Long userId);
|
||||
|
||||
|
||||
@@ -25,10 +25,8 @@ import com.youlai.boot.system.model.vo.UserNoticePageVO;
|
||||
import com.youlai.boot.system.model.vo.NoticeDetailVO;
|
||||
import com.youlai.boot.system.service.NoticeService;
|
||||
import com.youlai.boot.system.service.UserNoticeService;
|
||||
import com.youlai.boot.system.service.UserOnlineService;
|
||||
import com.youlai.boot.system.service.UserService;
|
||||
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
|
||||
import com.youlai.boot.platform.websocket.topic.WebSocketTopics;
|
||||
import com.youlai.boot.platform.websocket.service.WebSocketService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -53,14 +51,13 @@ public class NoticeServiceImpl extends ServiceImpl<NoticeMapper, Notice> impleme
|
||||
private final NoticeConverter noticeConverter;
|
||||
private final UserNoticeService userNoticeService;
|
||||
private final UserService userService;
|
||||
private final WebSocketPublisher webSocketPublisher;
|
||||
private final UserOnlineService userOnlineService;
|
||||
private final WebSocketService webSocketService;
|
||||
|
||||
/**
|
||||
* 获取通知公告分页列表
|
||||
*
|
||||
* @param queryParams 查询参数
|
||||
* @return {@link IPage< NoticePageVo >} 通知公告分页列表
|
||||
* @return {@link IPage< NoticePageVO >} 通知公告分页列表
|
||||
*/
|
||||
@Override
|
||||
public IPage<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> allOnlineUsers = userOnlineService.getOnlineUsers().stream()
|
||||
.map(UserOnlineService.UserOnlineDTO::getUsername)
|
||||
.collect(Collectors.toSet());
|
||||
// 获取在线用户名集合
|
||||
Set<String> allOnlineUsers = webSocketService.getOnlineUsers().stream()
|
||||
.map(dto -> dto.getUsername())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 找出在线用户的通知接收者
|
||||
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.setPublishTime(notice.getPublishTime());
|
||||
|
||||
onlineReceivers.forEach(receiver -> webSocketPublisher.publishToUser(receiver, WebSocketTopics.USER_QUEUE_MESSAGE, noticeDto));
|
||||
// 向在线接收者推送通知
|
||||
onlineReceivers.forEach(receiver -> webSocketService.sendNotification(receiver, noticeDto));
|
||||
}
|
||||
return publishResult;
|
||||
}
|
||||
@@ -268,7 +267,7 @@ public class NoticeServiceImpl extends ServiceImpl<NoticeMapper, Notice> impleme
|
||||
/**
|
||||
*
|
||||
* @param id 通知公告ID
|
||||
* @return NoticeDetailVo 通知公告详情
|
||||
* @return NoticeDetailVO 通知公告详情
|
||||
*/
|
||||
@Override
|
||||
public NoticeDetailVO getNoticeDetail(Long id) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,14 +7,13 @@ import com.youlai.boot.system.mapper.RoleMenuMapper;
|
||||
import com.youlai.boot.system.model.bo.RolePermsBO;
|
||||
import com.youlai.boot.system.model.entity.RoleMenu;
|
||||
import com.youlai.boot.system.service.RoleMenuService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 角色菜单服务实现类
|
||||
@@ -29,44 +28,17 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新权限缓存
|
||||
*/
|
||||
@Override
|
||||
public void refreshRolePermsCache() {
|
||||
String cacheKey = RedisConstants.System.ROLE_PERMS;
|
||||
|
||||
|
||||
// 清理权限缓存
|
||||
redisTemplate.delete(cacheKey);
|
||||
|
||||
// 重新加载权限
|
||||
|
||||
// 预热权限缓存,避免后续请求触发频繁回源
|
||||
List<RolePermsBO> list = this.baseMapper.getRolePermsList(null);
|
||||
if (CollectionUtil.isNotEmpty(list)) {
|
||||
list.forEach(item -> {
|
||||
@@ -77,7 +49,7 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
log.info("权限缓存刷新完成");
|
||||
}
|
||||
|
||||
@@ -87,11 +59,11 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
|
||||
@Override
|
||||
public void refreshRolePermsCache(String roleCode) {
|
||||
String cacheKey = RedisConstants.System.ROLE_PERMS;
|
||||
|
||||
|
||||
// 清理指定角色缓存
|
||||
redisTemplate.opsForHash().delete(cacheKey, roleCode);
|
||||
|
||||
// 重新加载指定角色权限
|
||||
|
||||
// 回源 DB 并更新缓存
|
||||
List<RolePermsBO> list = this.baseMapper.getRolePermsList(roleCode);
|
||||
if (CollectionUtil.isNotEmpty(list)) {
|
||||
RolePermsBO rolePerms = list.get(0);
|
||||
@@ -102,7 +74,7 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
log.info("角色[{}]权限缓存刷新完成", roleCode);
|
||||
}
|
||||
|
||||
@@ -112,11 +84,12 @@ public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> i
|
||||
@Override
|
||||
public void refreshRolePermsCache(String oldRoleCode, String newRoleCode) {
|
||||
String cacheKey = RedisConstants.System.ROLE_PERMS;
|
||||
|
||||
|
||||
// 清理旧角色权限缓存
|
||||
redisTemplate.opsForHash().delete(cacheKey, oldRoleCode);
|
||||
|
||||
// 添加新角色权限缓存
|
||||
redisTemplate.opsForHash().delete(cacheKey, newRoleCode);
|
||||
|
||||
// 回源 DB 并更新新角色编码缓存
|
||||
List<RolePermsBO> list = this.baseMapper.getRolePermsList(newRoleCode);
|
||||
if (CollectionUtil.isNotEmpty(list)) {
|
||||
RolePermsBO rolePerms = list.get(0);
|
||||
@@ -127,20 +100,72 @@ 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 权限集合
|
||||
*/
|
||||
@Override
|
||||
public Set<String> getRolePermsByRoleCodes(Set<String> roles) {
|
||||
// 直接查询数据库(保持原有逻辑)
|
||||
return this.baseMapper.listRolePerms(roles);
|
||||
public Set<String> getRolePermsByRoleCodes(Set<String> roleCodes) {
|
||||
if (CollectionUtil.isEmpty(roleCodes)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.youlai.boot.common.enums.DataScopeEnum;
|
||||
import com.youlai.boot.core.exception.BusinessException;
|
||||
import com.youlai.boot.system.converter.RoleConverter;
|
||||
import com.youlai.boot.system.mapper.RoleMapper;
|
||||
@@ -18,6 +19,7 @@ import com.youlai.boot.system.model.vo.RolePageVO;
|
||||
import com.youlai.boot.common.constant.SystemConstants;
|
||||
import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.security.util.SecurityUtils;
|
||||
import com.youlai.boot.system.service.RoleDeptService;
|
||||
import com.youlai.boot.system.service.RoleMenuService;
|
||||
import com.youlai.boot.system.service.RoleService;
|
||||
import com.youlai.boot.system.service.UserRoleService;
|
||||
@@ -41,6 +43,7 @@ import java.util.Set;
|
||||
public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService {
|
||||
|
||||
private final RoleMenuService roleMenuService;
|
||||
private final RoleDeptService roleDeptService;
|
||||
private final UserRoleService userRoleService;
|
||||
private final RoleConverter roleConverter;
|
||||
|
||||
@@ -48,7 +51,7 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
* 角色分页列表
|
||||
*
|
||||
* @param queryParams 角色查询参数
|
||||
* @return {@link Page< RolePageVo >} – 角色分页列表
|
||||
* @return {@link Page< RolePageVO >} – 角色分页列表
|
||||
*/
|
||||
@Override
|
||||
public Page<RolePageVO> getRolePage(RoleQuery queryParams) {
|
||||
@@ -99,6 +102,7 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
* @return {@link Boolean}
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean saveRole(RoleForm roleForm) {
|
||||
|
||||
Long roleId = roleForm.getId();
|
||||
@@ -123,6 +127,16 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
|
||||
boolean result = this.saveOrUpdate(role);
|
||||
if (result) {
|
||||
// 保存自定义数据权限部门
|
||||
Long savedRoleId = role.getId();
|
||||
if (DataScopeEnum.CUSTOM.getValue().equals(roleForm.getDataScope())) {
|
||||
// 自定义数据权限时,保存角色部门关联
|
||||
roleDeptService.saveRoleDepts(savedRoleId, roleForm.getDeptIds());
|
||||
} else {
|
||||
// 非自定义数据权限时,删除原有部门关联
|
||||
roleDeptService.deleteByRoleId(savedRoleId);
|
||||
}
|
||||
|
||||
// 判断角色编码或状态是否修改,修改了则刷新权限缓存
|
||||
if (oldRole != null
|
||||
&& (
|
||||
@@ -144,7 +158,13 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
||||
@Override
|
||||
public RoleForm getRoleForm(Long roleId) {
|
||||
Role entity = this.getById(roleId);
|
||||
return roleConverter.toForm(entity);
|
||||
RoleForm roleForm = roleConverter.toForm(entity);
|
||||
// 如果是自定义数据权限,查询关联的部门ID列表
|
||||
if (roleForm != null && DataScopeEnum.CUSTOM.getValue().equals(roleForm.getDataScope())) {
|
||||
List<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色的部门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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import com.youlai.boot.common.model.Option;
|
||||
import com.youlai.boot.platform.mail.service.MailService;
|
||||
import com.youlai.boot.platform.sms.enums.SmsTypeEnum;
|
||||
import com.youlai.boot.platform.sms.service.SmsService;
|
||||
import com.youlai.boot.security.model.RoleDataScope;
|
||||
import com.youlai.boot.security.model.UserAuthInfo;
|
||||
import com.youlai.boot.security.service.PermissionService;
|
||||
import com.youlai.boot.security.token.TokenManager;
|
||||
import com.youlai.boot.security.util.SecurityUtils;
|
||||
import com.youlai.boot.system.converter.UserConverter;
|
||||
@@ -62,7 +62,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
|
||||
private final RoleService roleService;
|
||||
|
||||
private final PermissionService permissionService;
|
||||
private final RoleMenuService roleMenuService;
|
||||
|
||||
private final SmsService smsService;
|
||||
|
||||
@@ -81,7 +81,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
* 获取用户分页列表
|
||||
*
|
||||
* @param queryParams 查询参数
|
||||
* @return {@link IPage<UserPageVo>} 用户分页列表
|
||||
* @return {@link IPage<UserPageVO>} 用户分页列表
|
||||
*/
|
||||
@Override
|
||||
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);
|
||||
if (userAuthInfo != null) {
|
||||
Set<String> roles = userAuthInfo.getRoles();
|
||||
// 获取最大范围的数据权限
|
||||
Integer dataScope = roleService.getMaximumDataScope(roles);
|
||||
userAuthInfo.setDataScope(dataScope);
|
||||
// 获取数据权限列表(用于并集策略)
|
||||
List<RoleDataScope> dataScopes = roleService.getRoleDataScopes(roles);
|
||||
userAuthInfo.setDataScopes(dataScopes);
|
||||
}
|
||||
return userAuthInfo;
|
||||
}
|
||||
@@ -236,9 +236,9 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
UserAuthInfo userAuthInfo = this.baseMapper.getAuthInfoByMobile(mobile);
|
||||
if (userAuthInfo != null) {
|
||||
Set<String> roles = userAuthInfo.getRoles();
|
||||
// 获取最大范围的数据权限
|
||||
Integer dataScope = roleService.getMaximumDataScope(roles);
|
||||
userAuthInfo.setDataScope(dataScope);
|
||||
// 获取数据权限列表(用于并集策略)
|
||||
List<RoleDataScope> dataScopes = roleService.getRoleDataScopes(roles);
|
||||
userAuthInfo.setDataScopes(dataScopes);
|
||||
}
|
||||
return userAuthInfo;
|
||||
}
|
||||
@@ -247,7 +247,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
* 获取导出用户列表
|
||||
*
|
||||
* @param queryParams 查询参数
|
||||
* @return {@link List<UserExportDto>} 导出用户列表
|
||||
* @return {@link List<UserExportDTO>} 导出用户列表
|
||||
*/
|
||||
@Override
|
||||
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
|
||||
public CurrentUserDTO getCurrentUserInfo() {
|
||||
@@ -311,7 +311,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
|
||||
// 用户权限集合
|
||||
if (CollectionUtil.isNotEmpty(roles)) {
|
||||
Set<String> perms = permissionService.getRolePermsFormCache(roles);
|
||||
Set<String> perms = roleMenuService.getRolePermsByRoleCodes(roles);
|
||||
userInfoVo.setPerms(perms);
|
||||
}
|
||||
return userInfoVo;
|
||||
@@ -321,7 +321,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
* 获取个人中心用户信息
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return {@link UserProfileVo} 个人中心用户信息
|
||||
* @return {@link UserProfileVO} 个人中心用户信息
|
||||
*/
|
||||
@Override
|
||||
public UserProfileVO getUserProfile(Long userId) {
|
||||
|
||||
26
src/main/resources/mapper/system/RoleDeptMapper.xml
Normal file
26
src/main/resources/mapper/system/RoleDeptMapper.xml
Normal 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>
|
||||
@@ -24,4 +24,27 @@
|
||||
</choose>
|
||||
</where>
|
||||
</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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user