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

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

View File

@@ -254,7 +254,7 @@ CREATE TABLE `sys_role` (
`code` varchar(32) NOT NULL COMMENT '角色编码',
`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
-- 顶级目录

View File

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

View File

@@ -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 生效起点(用于按用户失效历史 JWTtoken.iat < tokenValidAfter => token 无效
String USER_TOKEN_VALID_AFTER = "auth:user:token_valid_after:{}";
}
/**

View File

@@ -5,6 +5,10 @@ import lombok.Getter;
/**
* 数据权限枚举
* <p>
* value 越小,数据权限范围越大。
* 多角色数据权限合并策略取并集OR即用户能看到所有角色权限范围内的数据。
* 如果任一角色是 ALL则直接跳过数据权限过滤。
*
* @author Ray.Hao
* @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;
}
}

View File

@@ -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;
/**

View File

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

View File

@@ -0,0 +1,34 @@
package com.youlai.boot.platform.websocket.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* 在线用户信息DTO
* <p>
* 用于返回在线用户的基本信息,包括用户名、会话数量和登录时间。
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OnlineUserDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 用户名 */
private String username;
/** 会话数量多设备登录时大于1 */
private int sessionCount;
/** 最早登录时间 */
private long loginTime;
}

View File

@@ -0,0 +1,47 @@
package com.youlai.boot.platform.websocket.job;
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
import com.youlai.boot.platform.websocket.session.UserSessionRegistry;
import com.youlai.boot.platform.websocket.topic.WebSocketTopics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 在线用户数统计定时任务
* <p>
* 定时统计并广播当前在线用户数量到所有WebSocket客户端。
* 用于解决以下问题:
* <ul>
* <li>客户端页面刷新后可快速同步最新在线人数</li>
* <li>减少服务端主动推送频率,降低资源消耗</li>
* </ul>
*
* @author Ray.Hao
* @since 3.0.0
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class OnlineUserCountJob {
private final UserSessionRegistry userSessionRegistry;
private final WebSocketPublisher webSocketPublisher;
/**
* 定时统计在线用户数并广播
* <p>
* 每3分钟执行一次推送当前在线用户数量
*/
@Scheduled(cron = "0 */3 * * * ?")
public void execute() {
int onlineCount = userSessionRegistry.getOnlineUserCount();
int sessionCount = userSessionRegistry.getTotalSessionCount();
log.debug("定时统计:在线用户数={}, 总会话数={}", onlineCount, sessionCount);
// 广播在线用户数量
webSocketPublisher.publish(WebSocketTopics.TOPIC_ONLINE_COUNT, onlineCount);
}
}

View File

@@ -1,6 +1,7 @@
package com.youlai.boot.platform.websocket.service.impl;
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();
}

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
package com.youlai.boot.security.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
/**
* 角色数据权限信息
* <p>
* 用于存储单个角色的数据权限范围信息,支持多角色数据权限合并(并集策略)
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RoleDataScope implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 角色编码
*/
private String roleCode;
/**
* 数据权限范围值
* 1-所有数据 2-部门及子部门数据 3-本部门数据 4-本人数据 5-自定义部门数据
*/
private Integer dataScope;
/**
* 自定义部门ID列表仅当 dataScope=5 时有效)
*/
private List<Long> customDeptIds;
/**
* 创建"全部数据"权限
*/
public static RoleDataScope all(String roleCode) {
return new RoleDataScope(roleCode, 1, null);
}
/**
* 创建"部门及子部门"权限
*/
public static RoleDataScope deptAndSub(String roleCode) {
return new RoleDataScope(roleCode, 2, null);
}
/**
* 创建"本部门"权限
*/
public static RoleDataScope dept(String roleCode) {
return new RoleDataScope(roleCode, 3, null);
}
/**
* 创建"本人"权限
*/
public static RoleDataScope self(String roleCode) {
return new RoleDataScope(roleCode, 4, null);
}
/**
* 创建"自定义部门"权限
*/
public static RoleDataScope custom(String roleCode, List<Long> deptIds) {
return new RoleDataScope(roleCode, 5, deptIds);
}
}

View File

@@ -8,59 +8,71 @@ import java.util.Collection;
/**
* 短信验证码认证 Token
* <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);

View File

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

View File

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

View File

@@ -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;
/**
* 角色权限集合

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
/**
* 获取角色集合
*

View File

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

View File

@@ -1,35 +0,0 @@
package com.youlai.boot.system.handler;
import com.youlai.boot.system.service.UserOnlineService;
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
import com.youlai.boot.platform.websocket.topic.WebSocketTopics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 在线用户定时任务
*
* @since 2024/10/7
*
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class OnlineUserJobHandler {
private final UserOnlineService userOnlineService;
private final WebSocketPublisher webSocketPublisher;
// 每3分钟统计一次在线用户数减少服务器压力
@Scheduled(cron = "0 */3 * * * ?")
public void execute() {
log.info("定时任务:统计在线用户数");
// 推送在线用户数量到新主题
int count = userOnlineService.getOnlineUserCount();
webSocketPublisher.publish(WebSocketTopics.TOPIC_ONLINE_COUNT, count);
}
}

View File

@@ -0,0 +1,35 @@
package com.youlai.boot.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.system.model.entity.RoleDept;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 角色部门关联持久层
*
* @author Ray.Hao
* @since 3.0.0
*/
@Mapper
public interface RoleDeptMapper extends BaseMapper<RoleDept> {
/**
* 根据角色ID获取部门ID列表
*
* @param roleId 角色ID
* @return 部门ID列表
*/
List<Long> getDeptIdsByRoleId(@Param("roleId") Long roleId);
/**
* 根据角色编码集合获取所有部门ID列表用于自定义数据权限
*
* @param roleCodes 角色编码集合
* @return 部门ID列表
*/
List<Long> getDeptIdsByRoleCodes(@Param("roleCodes") List<String> roleCodes);
}

View File

@@ -3,7 +3,10 @@ package com.youlai.boot.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.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);
}

View File

@@ -0,0 +1,32 @@
package com.youlai.boot.system.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 角色部门关联实体
* <p>
* 用于存储角色自定义数据权限时可访问的部门ID列表
*
* @author Ray.Hao
* @since 3.0.0
*/
@TableName("sys_role_dept")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RoleDept {
/**
* 角色ID
*/
private Long roleId;
/**
* 部门ID
*/
private Long deptId;
}

View File

@@ -7,6 +7,8 @@ import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import 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;
}

View File

@@ -0,0 +1,47 @@
package com.youlai.boot.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.system.model.entity.RoleDept;
import java.util.List;
/**
* 角色部门关联服务接口
*
* @author Ray.Hao
* @since 4.1.0
*/
public interface RoleDeptService extends IService<RoleDept> {
/**
* 根据角色ID获取部门ID列表
*
* @param roleId 角色ID
* @return 部门ID列表
*/
List<Long> getDeptIdsByRoleId(Long roleId);
/**
* 根据角色编码集合获取所有部门ID列表用于自定义数据权限
*
* @param roleCodes 角色编码集合
* @return 部门ID列表
*/
List<Long> getDeptIdsByRoleCodes(List<String> roleCodes);
/**
* 保存角色部门关联
*
* @param roleId 角色ID
* @param deptIds 部门ID列表
*/
void saveRoleDepts(Long roleId, List<Long> deptIds);
/**
* 删除角色部门关联
*
* @param roleId 角色ID
*/
void deleteByRoleId(Long roleId);
}

View File

@@ -10,7 +10,7 @@ import java.util.Set;
/**
* 角色菜单业务接口
*
* @author haoxr
* @author Ray.Hao
* @since 2.5.0
*/
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);
}

View File

@@ -3,6 +3,7 @@ package com.youlai.boot.system.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.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);
}

View File

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

View File

@@ -1,145 +0,0 @@
package com.youlai.boot.system.service;
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
import com.youlai.boot.platform.websocket.topic.WebSocketTopics;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 用户在线状态服务
* 负责维护用户的在线状态和相关统计
*
* @author Ray.Hao
* @since 3.0.0
*/
@Service
@Slf4j
public class UserOnlineService {
// 在线用户映射表key为用户名value为用户在线信息
private final Map<String, UserOnlineInfo> onlineUsers = new ConcurrentHashMap<>();
private final WebSocketPublisher webSocketPublisher;
public UserOnlineService(WebSocketPublisher webSocketPublisher) {
this.webSocketPublisher = webSocketPublisher;
}
/**
* 用户上线
*
* @param username 用户名
* @param sessionId WebSocket会话ID可选
*/
public void userConnected(String username, String sessionId) {
// 生成会话ID如果未提供
String actualSessionId = sessionId != null ? sessionId : "session-" + System.nanoTime();
UserOnlineInfo info = new UserOnlineInfo(username, actualSessionId, System.currentTimeMillis());
onlineUsers.put(username, info);
log.info("用户[{}]上线,当前在线用户数:{}", username, onlineUsers.size());
// 通知在线用户状态变更
notifyOnlineUsersChange();
}
/**
* 用户下线
*
* @param username 用户名
*/
public void userDisconnected(String username) {
onlineUsers.remove(username);
log.info("用户[{}]下线,当前在线用户数:{}", username, onlineUsers.size());
// 通知在线用户状态变更
notifyOnlineUsersChange();
}
/**
* 获取在线用户列表
*
* @return 在线用户名列表
*/
public List<UserOnlineDTO> getOnlineUsers() {
return onlineUsers.values().stream()
.map(info -> new UserOnlineDTO(info.getUsername(), info.getLoginTime()))
.collect(Collectors.toList());
}
/**
* 获取在线用户数量
*
* @return 在线用户数
*/
public int getOnlineUserCount() {
return onlineUsers.size();
}
/**
* 检查用户是否在线
*
* @param username 用户名
* @return 是否在线
*/
public boolean isUserOnline(String username) {
return onlineUsers.containsKey(username);
}
/**
* 通知所有客户端在线用户变更
*/
private void notifyOnlineUsersChange() {
// 发送简化版数据(仅数量)
sendOnlineUserCount();
}
/**
* 发送在线用户数量(简化版,不包含用户详情)
*/
private void sendOnlineUserCount() {
try {
// 直接发送数量,更轻量
int count = onlineUsers.size();
webSocketPublisher.publish(WebSocketTopics.TOPIC_ONLINE_COUNT, count);
log.debug("已发送在线用户数量: {}", count);
} catch (Exception e) {
log.error("发送在线用户数量失败", e);
}
}
/**
* 用户在线信息
*/
@Data
private static class UserOnlineInfo {
private final String username;
private final String sessionId;
private final long loginTime;
}
/**
* 用户在线Dto用于返回给前端
*/
@Data
public static class UserOnlineDTO {
private final String username;
private final long loginTime;
}
/**
* 在线用户变更事件
*/
@Data
private static class OnlineUsersChangeEvent {
private String type;
private int count;
private List<UserOnlineDTO> users;
private long timestamp;
}
}

View File

@@ -6,13 +6,19 @@ import com.youlai.boot.system.model.entity.UserRole;
import java.util.List;
/**
* 用户角色业务接口
*
* @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);

View File

@@ -25,7 +25,7 @@ public interface UserService extends IService<User> {
/**
* 用户分页列表
*
* @return {@link IPage<UserPageVo>} 用户分页列表
* @return {@link IPage<UserPageVO>} 用户分页列表
*/
IPage<UserPageVO> getUserPage(UserQuery queryParams);
@@ -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);

View File

@@ -25,10 +25,8 @@ import com.youlai.boot.system.model.vo.UserNoticePageVO;
import com.youlai.boot.system.model.vo.NoticeDetailVO;
import com.youlai.boot.system.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) {

View File

@@ -0,0 +1,65 @@
package com.youlai.boot.system.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.system.mapper.RoleDeptMapper;
import com.youlai.boot.system.model.entity.RoleDept;
import com.youlai.boot.system.service.RoleDeptService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
/**
* 角色部门关联服务实现
*
* @author Ray.Hao
* @since 3.0.0
*/
@Service
@RequiredArgsConstructor
public class RoleDeptServiceImpl extends ServiceImpl<RoleDeptMapper, RoleDept> implements RoleDeptService {
@Override
public List<Long> getDeptIdsByRoleId(Long roleId) {
if (roleId == null) {
return Collections.emptyList();
}
return this.baseMapper.getDeptIdsByRoleId(roleId);
}
@Override
public List<Long> getDeptIdsByRoleCodes(List<String> roleCodes) {
if (CollectionUtil.isEmpty(roleCodes)) {
return Collections.emptyList();
}
return this.baseMapper.getDeptIdsByRoleCodes(roleCodes);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void saveRoleDepts(Long roleId, List<Long> deptIds) {
if (roleId == null || CollectionUtil.isEmpty(deptIds)) {
return;
}
// 先删除原有关联
this.remove(new LambdaQueryWrapper<RoleDept>().eq(RoleDept::getRoleId, roleId));
// 批量插入新关联
List<RoleDept> roleDepts = deptIds.stream()
.map(deptId -> new RoleDept(roleId, deptId))
.toList();
this.saveBatch(roleDepts);
}
@Override
public void deleteByRoleId(Long roleId) {
if (roleId == null) {
return;
}
this.remove(new LambdaQueryWrapper<RoleDept>().eq(RoleDept::getRoleId, roleId));
}
}

View File

@@ -7,14 +7,13 @@ import com.youlai.boot.system.mapper.RoleMenuMapper;
import com.youlai.boot.system.model.bo.RolePermsBO;
import com.youlai.boot.system.model.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;
}
/**

View File

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

View File

@@ -15,8 +15,8 @@ import com.youlai.boot.common.model.Option;
import com.youlai.boot.platform.mail.service.MailService;
import com.youlai.boot.platform.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) {

View File

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

View File

@@ -24,4 +24,27 @@
</choose>
</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>

View File

@@ -0,0 +1,387 @@
package com.youlai.boot.plugin.mybatis;
import com.youlai.boot.common.annotation.DataPermission;
import com.youlai.boot.common.enums.DataScopeEnum;
import com.youlai.boot.security.model.RoleDataScope;
import com.youlai.boot.security.model.SysUserDetails;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
import net.sf.jsqlparser.expression.operators.relational.InExpression;
import net.sf.jsqlparser.schema.Column;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 数据权限处理器单元测试
*
* @author Ray.Hao
*/
@DisplayName("数据权限处理器测试")
class MyDataPermissionHandlerTest {
private MyDataPermissionHandler handler;
@BeforeEach
void setUp() {
handler = new MyDataPermissionHandler();
}
@AfterEach
void tearDown() {
// 清理安全上下文
SecurityContextHolder.clearContext();
}
// ==================== 边界条件测试 ====================
@Nested
@DisplayName("边界条件测试")
class BoundaryTests {
@Test
@DisplayName("未登录用户 - 返回原始where条件")
void whenNotLoggedIn_thenReturnOriginalWhere() {
// given: 未设置安全上下文
Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1));
// when
Expression result = handler.getSqlSegment(where, "com.example.mapper.UserMapper.selectList");
// then: 返回原始where
assertThat(result).isSameAs(where);
}
@Test
@DisplayName("超级管理员 - 跳过数据权限过滤")
void whenRootUser_thenReturnOriginalWhere() {
// given: 设置超级管理员
setSecurityContext(1L, "admin", 1L,
Set.of(new SimpleGrantedAuthority("ROLE_ROOT")),
Collections.emptyList());
Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1));
// when
Expression result = handler.getSqlSegment(where, "com.example.mapper.UserMapper.selectList");
// then: 返回原始where不添加数据权限条件
assertThat(result).isSameAs(where);
}
@Test
@DisplayName("无数据权限列表 - 返回原始where条件")
void whenNoDataScopes_thenReturnOriginalWhere() {
// given: 普通用户但无数据权限
setSecurityContext(100L, "user", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_USER")),
Collections.emptyList());
Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1));
// when
Expression result = handler.getSqlSegment(where, "com.example.mapper.UserMapper.selectList");
// then: 返回原始where
assertThat(result).isSameAs(where);
}
}
// ==================== 单一角色数据权限测试 ====================
@Nested
@DisplayName("单一角色数据权限测试")
class SingleRoleTests {
@Test
@DisplayName("全部数据权限(ALL) - 不添加过滤条件")
void whenAllDataScope_thenReturnOriginalWhere() {
// given: 角色拥有全部数据权限
setSecurityContext(100L, "admin", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_ADMIN")),
List.of(RoleDataScope.all("ADMIN")));
Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1));
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 返回原始where
assertThat(result).isSameAs(where);
}
@Test
@DisplayName("本部门数据权限(DEPT) - 添加dept_id = ?条件")
void whenDeptDataScope_thenAddDeptIdCondition() {
// given: 角色拥有本部门数据权限
Long deptId = 10L;
setSecurityContext(100L, "manager", deptId,
Set.of(new SimpleGrantedAuthority("ROLE_MANAGER")),
List.of(RoleDataScope.dept("MANAGER")));
Expression where = null;
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 添加部门过滤条件
assertThat(result).isNotNull();
assertThat(result.toString()).contains("dept_id = " + deptId);
}
@Test
@DisplayName("本人数据权限(SELF) - 添加create_by = ?条件")
void whenSelfDataScope_thenAddCreateByCondition() {
// given: 角色拥有本人数据权限
Long userId = 100L;
setSecurityContext(userId, "user", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_USER")),
List.of(RoleDataScope.self("USER")));
Expression where = null;
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 添加用户过滤条件
assertThat(result).isNotNull();
assertThat(result.toString()).contains("create_by = " + userId);
}
@Test
@DisplayName("自定义部门数据权限(CUSTOM) - 添加dept_id IN (?)条件")
void whenCustomDataScope_thenAddDeptIdInCondition() {
// given: 角色拥有自定义部门数据权限
List<Long> customDeptIds = Arrays.asList(10L, 20L, 30L);
setSecurityContext(100L, "user", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_CUSTOM")),
List.of(RoleDataScope.custom("CUSTOM", customDeptIds)));
Expression where = null;
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 添加自定义部门IN条件
assertThat(result).isNotNull();
String sql = result.toString();
assertThat(sql).contains("dept_id IN");
assertThat(sql).contains("10");
assertThat(sql).contains("20");
assertThat(sql).contains("30");
}
@Test
@DisplayName("自定义部门数据权限(空列表) - 添加1=0条件")
void whenCustomDataScopeWithEmptyList_thenAddFalseCondition() {
// given: 角色拥有自定义部门权限但列表为空
setSecurityContext(100L, "user", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_CUSTOM")),
List.of(RoleDataScope.custom("CUSTOM", Collections.emptyList())));
Expression where = null;
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 添加1=0条件无权限
assertThat(result).isNotNull();
assertThat(result.toString()).contains("1 = 0");
}
@Test
@DisplayName("部门及子部门数据权限(DEPT_AND_SUB) - 添加子查询条件")
void whenDeptAndSubDataScope_thenAddSubQueryCondition() {
// given: 角色拥有部门及子部门数据权限
Long deptId = 10L;
setSecurityContext(100L, "manager", deptId,
Set.of(new SimpleGrantedAuthority("ROLE_MANAGER")),
List.of(RoleDataScope.deptAndSub("MANAGER")));
Expression where = null;
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 添加子查询条件
assertThat(result).isNotNull();
String sql = result.toString();
assertThat(sql).contains("dept_id IN");
assertThat(sql).contains("SELECT");
assertThat(sql).contains("sys_dept");
assertThat(sql).contains("FIND_IN_SET");
}
}
// ==================== 多角色并集策略测试 ====================
@Nested
@DisplayName("多角色并集策略测试")
class MultiRoleTests {
@Test
@DisplayName("多角色 - 任一角色为ALL时跳过过滤")
void whenAnyRoleIsAll_thenSkipFilter() {
// given: 用户有两个角色其中一个是ALL
setSecurityContext(100L, "user", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_ADMIN"), new SimpleGrantedAuthority("ROLE_USER")),
List.of(
RoleDataScope.all("ADMIN"),
RoleDataScope.self("USER")
));
Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1));
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 返回原始where不添加过滤条件
assertThat(result).isSameAs(where);
}
@Test
@DisplayName("多角色 - DEPT和SELF权限合并为OR条件")
void whenDeptAndSelfRoles_thenMergeWithOr() {
// given: 用户有两个角色,分别拥有部门和本人权限
Long deptId = 10L;
Long userId = 100L;
setSecurityContext(userId, "manager", deptId,
Set.of(new SimpleGrantedAuthority("ROLE_MANAGER"), new SimpleGrantedAuthority("ROLE_USER")),
List.of(
RoleDataScope.dept("MANAGER"),
RoleDataScope.self("USER")
));
Expression where = null;
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 生成OR连接的合并条件
assertThat(result).isNotNull();
String sql = result.toString();
assertThat(sql).contains("OR");
assertThat(sql).contains("dept_id = " + deptId);
assertThat(sql).contains("create_by = " + userId);
}
@Test
@DisplayName("多角色 - 多个自定义部门权限合并")
void whenMultipleCustomRoles_thenMergeWithOr() {
// given: 用户有两个自定义部门权限的角色
setSecurityContext(100L, "user", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_A"), new SimpleGrantedAuthority("ROLE_B")),
List.of(
RoleDataScope.custom("A", Arrays.asList(10L, 20L)),
RoleDataScope.custom("B", Arrays.asList(30L, 40L))
));
Expression where = null;
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 生成OR连接的IN条件
assertThat(result).isNotNull();
String sql = result.toString();
assertThat(sql).contains("OR");
assertThat(sql).contains("dept_id IN");
}
@Test
@DisplayName("已有where条件 - 新条件用AND连接")
void whenExistingWhere_thenAndWithNewCondition() {
// given
setSecurityContext(100L, "user", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_USER")),
List.of(RoleDataScope.dept("USER")));
Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1));
// when
Expression result = handler.getSqlSegment(where,
"com.youlai.boot.plugin.mybatis.TestDataPermissionMapper.selectList");
// then: 结果应该包含原始where和数据权限条件
assertThat(result).isInstanceOf(AndExpression.class);
String sql = result.toString();
assertThat(sql).contains("status = 1");
assertThat(sql).contains("dept_id = 10");
}
}
// ==================== 注解配置测试 ====================
@Nested
@DisplayName("注解配置测试")
class AnnotationTests {
@Test
@DisplayName("无@DataPermission注解 - 返回原始where")
void whenNoAnnotation_thenReturnOriginalWhere() {
// given
setSecurityContext(100L, "user", 10L,
Set.of(new SimpleGrantedAuthority("ROLE_USER")),
List.of(RoleDataScope.dept("USER")));
Expression where = new EqualsTo(new Column("status"), new net.sf.jsqlparser.expression.LongValue(1));
// when: 调用无注解的mapper方法
Expression result = handler.getSqlSegment(where,
"java.lang.Object.toString");
// then: 返回原始where
assertThat(result).isSameAs(where);
}
}
// ==================== 辅助方法 ====================
/**
* 设置安全上下文
*
* @param userId 用户ID
* @param username 用户名
* @param deptId 部门ID
* @param authorities 权限集合
* @param dataScopes 数据权限列表
*/
private void setSecurityContext(Long userId, String username, Long deptId,
Set<SimpleGrantedAuthority> authorities,
List<RoleDataScope> dataScopes) {
SysUserDetails userDetails = new SysUserDetails();
userDetails.setUserId(userId);
userDetails.setUsername(username);
userDetails.setDeptId(deptId);
userDetails.setDataScopes(dataScopes);
userDetails.setAuthorities(authorities);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}

View File

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

View File

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