增加构建通知

This commit is contained in:
dengqichen 2025-11-20 16:33:06 +08:00
parent 58ad4b2643
commit 21f0b965bc
10 changed files with 687 additions and 4 deletions

View File

@ -1,8 +1,11 @@
package com.qqchen.deploy.backend.framework.security.util; package com.qqchen.deploy.backend.framework.security.util;
import com.qqchen.deploy.backend.framework.security.CustomUserDetails;
import com.qqchen.deploy.backend.framework.utils.RedisUtil;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
@ -10,6 +13,7 @@ import org.springframework.stereotype.Component;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -25,6 +29,12 @@ public class JwtTokenUtil {
@Value("${jwt.expiration}") @Value("${jwt.expiration}")
private Long expiration; private Long expiration;
@Resource
private RedisUtil redisUtil;
// Redis Key前缀
private static final String TOKEN_PREFIX = "auth:token:";
private SecretKey key; private SecretKey key;
private SecretKey getKey() { private SecretKey getKey() {
@ -63,9 +73,23 @@ public class JwtTokenUtil {
return expiration.before(new Date()); return expiration.before(new Date());
} }
/**
* 生成Token并存储到Redis
*
* @param userDetails 用户信息
* @return Token字符串
*/
public String generateToken(UserDetails userDetails) { public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
return doGenerateToken(claims, userDetails.getUsername()); String token = doGenerateToken(claims, userDetails.getUsername());
// 存储到Redis如果userDetails是CustomUserDetails可以获取userId
if (userDetails instanceof CustomUserDetails) {
Long userId = ((CustomUserDetails) userDetails).getUserId();
storeToken(userId, token);
}
return token;
} }
private String doGenerateToken(Map<String, Object> claims, String subject) { private String doGenerateToken(Map<String, Object> claims, String subject) {
@ -78,13 +102,118 @@ public class JwtTokenUtil {
.compact(); .compact();
} }
/**
* 验证Token集成Redis验证
*
* @param token Token字符串
* @param userDetails 用户信息
* @return true=有效, false=无效
*/
public Boolean validateToken(String token, UserDetails userDetails) { public Boolean validateToken(String token, UserDetails userDetails) {
try { try {
final String username = getUsernameFromToken(token); final String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
// 1. 基础验证用户名匹配 + Token未过期
if (!username.equals(userDetails.getUsername()) || isTokenExpired(token)) {
return false;
}
// 2. Redis验证检查Token是否匹配用于强制下线场景
if (userDetails instanceof CustomUserDetails) {
Long userId = ((CustomUserDetails) userDetails).getUserId();
// 检查Redis中的Token是否匹配如果Redis中没有Token说明用户已被强制下线
String storedToken = getStoredToken(userId);
if (storedToken == null) {
log.warn("Token不存在可能已被强制下线: userId={}", userId);
return false;
}
if (!storedToken.equals(token)) {
log.warn("Token不匹配: userId={}", userId);
return false;
}
}
return true;
} catch (Exception e) { } catch (Exception e) {
log.error("Error validating token", e); log.error("Error validating token", e);
return false; return false;
} }
} }
// ============================== Redis Token 管理 ==============================
/**
* 存储Token到Redis包含登录时间
*/
private void storeToken(Long userId, String token) {
String key = TOKEN_PREFIX + userId;
// 存储Token + 登录时间
Map<String, Object> tokenInfo = new HashMap<>();
tokenInfo.put("token", token);
tokenInfo.put("loginTime", LocalDateTime.now().toString());
redisUtil.set(key, tokenInfo, expiration);
log.debug("Token已存储到Redis: userId={}, ttl={}秒", userId, expiration);
}
/**
* 从Redis获取Token字符串
*/
private String getStoredToken(Long userId) {
String key = TOKEN_PREFIX + userId;
Object value = redisUtil.get(key);
if (value == null) return null;
// 兼容旧格式字符串和新格式Map
if (value instanceof String) {
return (String) value;
} else if (value instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> tokenInfo = (Map<String, Object>) value;
return (String) tokenInfo.get("token");
}
return null;
}
/**
* 获取完整的Token信息包含在线状态
*/
@SuppressWarnings("unchecked")
public Map<String, Object> getTokenInfo(Long userId) {
String key = TOKEN_PREFIX + userId;
Object value = redisUtil.get(key);
if (value instanceof Map) {
return (Map<String, Object>) value;
}
return null;
}
/**
* 删除Token用于登出或强制下线
*
* @param userId 用户ID
*/
public void removeToken(Long userId) {
String key = TOKEN_PREFIX + userId;
redisUtil.del(key);
log.info("Token已删除: userId={}", userId);
}
/**
* 刷新Token过期时间
*
* @param userId 用户ID
*/
public void refreshToken(Long userId) {
String key = TOKEN_PREFIX + userId;
redisUtil.expire(key, expiration);
log.debug("Token过期时间已刷新: userId={}, ttl={}秒", userId, expiration);
}
} }

View File

@ -0,0 +1,63 @@
package com.qqchen.deploy.backend.system.api;
import com.qqchen.deploy.backend.framework.api.Response;
import com.qqchen.deploy.backend.system.model.query.OnlineUserQuery;
import com.qqchen.deploy.backend.system.model.vo.OnlineStatisticsVO;
import com.qqchen.deploy.backend.system.model.vo.OnlineUserVO;
import com.qqchen.deploy.backend.system.service.IOnlineUserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.data.domain.Page;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* 在线用户管理接口
*
* @author qqchen
* @date 2025-11-20
*/
@Tag(name = "在线用户管理")
@RestController
@RequestMapping("/api/v1/online")
public class OnlineUserController {
@Resource
private IOnlineUserService onlineUserService;
/**
* 获取在线用户列表分页
*/
@Operation(summary = "获取在线用户列表")
@GetMapping("/users")
@PreAuthorize("hasAuthority('system:online:view')")
public Response<Page<OnlineUserVO>> getOnlineUsers(OnlineUserQuery query) {
Page<OnlineUserVO> result = onlineUserService.getOnlineUsers(query);
return Response.success(result);
}
/**
* 获取在线用户统计
*/
@Operation(summary = "获取在线统计信息")
@GetMapping("/statistics")
public Response<OnlineStatisticsVO> getStatistics() {
OnlineStatisticsVO statistics = onlineUserService.getStatistics();
return Response.success(statistics);
}
/**
* 强制用户下线
*/
@Operation(summary = "强制用户下线")
@DeleteMapping("/kick/{userId}")
@PreAuthorize("hasAuthority('system:online:kick')")
public Response<Void> kickUser(
@Parameter(description = "用户ID", required = true) @PathVariable Long userId
) {
onlineUserService.kickUser(userId);
return Response.success();
}
}

View File

@ -0,0 +1,21 @@
package com.qqchen.deploy.backend.system.model.query;
import com.qqchen.deploy.backend.framework.query.BaseQuery;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 在线用户查询对象
*
* @author qqchen
* @date 2025-11-20
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class OnlineUserQuery extends BaseQuery {
/**
* 搜索关键词用户名/昵称
*/
private String keyword;
}

View File

@ -0,0 +1,11 @@
package com.qqchen.deploy.backend.system.model.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class ResetPasswordRequest {
@NotBlank(message = "新密码不能为空")
private String newPassword;
}

View File

@ -0,0 +1,34 @@
package com.qqchen.deploy.backend.system.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 在线用户统计视图对象
*
* @author qqchen
* @date 2025-11-20
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OnlineStatisticsVO {
/**
* 当前在线人数
*/
private Integer totalOnline;
/**
* 今日峰值
*/
private Integer todayPeak;
/**
* 平均在线时长格式化字符串 "2小时30分钟"
*/
private String averageOnlineTime;
}

View File

@ -0,0 +1,71 @@
package com.qqchen.deploy.backend.system.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 在线用户视图对象
*
* @author qqchen
* @date 2025-11-20
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OnlineUserVO {
/**
* 用户ID
*/
private Long userId;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 部门名称
*/
private String departmentName;
/**
* 登录时间
*/
private LocalDateTime loginTime;
/**
* 最后活跃时间
*/
private LocalDateTime lastActiveTime;
/**
* 在线时长
*/
private Long onlineDuration;
/**
* IP地址
*/
private String ipAddress;
/**
* 浏览器信息
*/
private String browser;
/**
* 操作系统
*/
private String os;
}

View File

@ -0,0 +1,46 @@
package com.qqchen.deploy.backend.system.service;
import com.qqchen.deploy.backend.system.model.query.OnlineUserQuery;
import com.qqchen.deploy.backend.system.model.vo.OnlineStatisticsVO;
import com.qqchen.deploy.backend.system.model.vo.OnlineUserVO;
import org.springframework.data.domain.Page;
import java.util.List;
/**
* 在线用户服务接口
*
* @author qqchen
* @date 2025-11-20
*/
public interface IOnlineUserService {
/**
* 获取在线用户列表分页
*
* @param query 查询参数
* @return 在线用户分页列表
*/
Page<OnlineUserVO> getOnlineUsers(OnlineUserQuery query);
/**
* 获取在线用户列表不分页
*
* @return 所有在线用户
*/
List<OnlineUserVO> getAllOnlineUsers();
/**
* 获取在线用户统计
*
* @return 统计信息
*/
OnlineStatisticsVO getStatistics();
/**
* 强制用户下线
*
* @param userId 用户ID
*/
void kickUser(Long userId);
}

View File

@ -0,0 +1,222 @@
package com.qqchen.deploy.backend.system.service.impl;
import com.qqchen.deploy.backend.framework.utils.RedisUtil;
import com.qqchen.deploy.backend.framework.security.util.JwtTokenUtil;
import com.qqchen.deploy.backend.system.entity.User;
import com.qqchen.deploy.backend.system.model.query.OnlineUserQuery;
import com.qqchen.deploy.backend.system.model.vo.OnlineStatisticsVO;
import com.qqchen.deploy.backend.system.model.vo.OnlineUserVO;
import com.qqchen.deploy.backend.system.repository.IUserRepository;
import com.qqchen.deploy.backend.system.service.IOnlineUserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 在线用户服务实现
*
* @author qqchen
* @date 2025-11-20
*/
@Slf4j
@Service
public class OnlineUserServiceImpl implements IOnlineUserService {
@Resource
private RedisUtil redisUtil;
@Resource
private JwtTokenUtil jwtTokenUtil;
@Resource
private IUserRepository userRepository;
// Redis Key前缀使用JwtTokenUtil的统一Key
private static final String TOKEN_PREFIX = "auth:token:";
private static final String PEAK_KEY = "online:peak:today";
@Override
public Page<OnlineUserVO> getOnlineUsers(OnlineUserQuery query) {
List<OnlineUserVO> allOnlineUsers = getAllOnlineUsers();
// 关键词过滤
String keyword = query.getKeyword();
if (keyword != null && !keyword.trim().isEmpty()) {
String lowerKeyword = keyword.toLowerCase();
allOnlineUsers = allOnlineUsers.stream()
.filter(user ->
user.getUsername().toLowerCase().contains(lowerKeyword) ||
(user.getNickname() != null && user.getNickname().toLowerCase().contains(lowerKeyword))
)
.collect(Collectors.toList());
}
// 排序按登录时间倒序
allOnlineUsers.sort(Comparator.comparing(OnlineUserVO::getLoginTime).reversed());
// 分页使用BaseQuery的pageNum从1开始
int pageNum = query.getPageNum() != null ? query.getPageNum() : 1;
int pageSize = query.getPageSize() != null ? query.getPageSize() : 10;
int start = (pageNum - 1) * pageSize;
int end = Math.min(start + pageSize, allOnlineUsers.size());
List<OnlineUserVO> pageContent = start < allOnlineUsers.size() ? allOnlineUsers.subList(start, end) : Collections.emptyList();
// 创建PageRequest注意Spring Data的page从0开始
PageRequest pageRequest = PageRequest.of(pageNum - 1, pageSize);
return new PageImpl<>(pageContent, pageRequest, allOnlineUsers.size());
}
@Override
public List<OnlineUserVO> getAllOnlineUsers() {
// 获取所有Token Key
Set<String> keys = redisUtil.keys(TOKEN_PREFIX + "*");
if (keys == null || keys.isEmpty()) {
return Collections.emptyList();
}
List<OnlineUserVO> onlineUsers = new ArrayList<>();
for (String key : keys) {
try {
// 从Tok Key中提取userId
String userIdStr = key.replace(TOKEN_PREFIX, "");
Long userId = Long.parseLong(userIdStr);
// 获取Token信息包含在线状态
Map<String, Object> tokenInfo = jwtTokenUtil.getTokenInfo(userId);
if (tokenInfo == null) continue;
String loginTimeStr = (String) tokenInfo.get("loginTime");
// 查询用户详细信息
User user = userRepository.findById(userId).orElse(null);
if (user == null) continue;
// 解析时间
LocalDateTime loginTime = loginTimeStr != null ? LocalDateTime.parse(loginTimeStr) : LocalDateTime.now();
// 计算在线时长
long onlineDuration = Duration.between(loginTime, LocalDateTime.now()).getSeconds();
OnlineUserVO vo = OnlineUserVO.builder()
.userId(userId)
.username(user.getUsername())
.nickname(user.getNickname())
.departmentName(user.getDepartment() != null ? user.getDepartment().getName() : null)
.loginTime(loginTime)
.lastActiveTime(loginTime) // 由于没有心跳最后活跃时间=登录时间
.onlineDuration(onlineDuration)
.build();
onlineUsers.add(vo);
} catch (Exception e) {
log.error("解析在线用户信息失败: key={}", key, e);
}
}
return onlineUsers;
}
@Override
public OnlineStatisticsVO getStatistics() {
int totalOnline = getAllOnlineUsers().size();
// 获取今日峰值
Integer todayPeak = totalOnline;
Object peakObj = redisUtil.get(PEAK_KEY);
if (peakObj != null) {
todayPeak = Math.max(totalOnline, getIntValue(peakObj));
}
// 计算平均在线时长
String averageOnlineTime = calculateAverageOnlineTime();
return OnlineStatisticsVO.builder()
.totalOnline(totalOnline)
.todayPeak(todayPeak)
.averageOnlineTime(averageOnlineTime)
.build();
}
@Override
public void kickUser(Long userId) {
// 删除Token同时删除了在线信息
jwtTokenUtil.removeToken(userId);
log.warn("用户已被强制下线: userId={}", userId);
}
/**
* 更新今日峰值
*/
private void updateTodayPeak() {
int currentOnline = getAllOnlineUsers().size();
Object peakObj = redisUtil.get(PEAK_KEY);
int currentPeak = peakObj != null ? getIntValue(peakObj) : 0;
if (currentOnline > currentPeak) {
// 更新峰值保留到今天结束
long secondsUntilMidnight = Duration.between(
LocalDateTime.now(),
LocalDateTime.now().plusDays(1).withHour(0).withMinute(0).withSecond(0)
).getSeconds();
redisUtil.set(PEAK_KEY, currentOnline, secondsUntilMidnight);
}
}
/**
* 计算平均在线时长
*/
private String calculateAverageOnlineTime() {
List<OnlineUserVO> users = getAllOnlineUsers();
if (users.isEmpty()) {
return "0分钟";
}
long totalSeconds = users.stream()
.mapToLong(OnlineUserVO::getOnlineDuration)
.sum();
long avgSeconds = totalSeconds / users.size();
long hours = avgSeconds / 3600;
long minutes = (avgSeconds % 3600) / 60;
if (hours > 0) {
return String.format("%d小时%d分钟", hours, minutes);
} else {
return String.format("%d分钟", minutes);
}
}
/**
* 安全地获取Long值
*/
private Long getLongValue(Object obj) {
if (obj == null) return null;
if (obj instanceof Long) return (Long) obj;
if (obj instanceof Integer) return ((Integer) obj).longValue();
if (obj instanceof String) return Long.parseLong((String) obj);
return null;
}
/**
* 安全地获取Integer值
*/
private Integer getIntValue(Object obj) {
if (obj == null) return 0;
if (obj instanceof Integer) return (Integer) obj;
if (obj instanceof Long) return ((Long) obj).intValue();
if (obj instanceof String) return Integer.parseInt((String) obj);
return 0;
}
}

View File

@ -102,7 +102,9 @@ VALUES
-- 部门管理 -- 部门管理
(5, '部门管理', '/system/departments', 'System/Department/List', 'ApartmentOutlined', 'system:department', 2, 1, 40, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE), (5, '部门管理', '/system/departments', 'System/Department/List', 'ApartmentOutlined', 'system:department', 2, 1, 40, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE),
-- 权限管理(隐藏菜单) -- 权限管理(隐藏菜单)
(6, '权限管理', '/system/permissions', 'System/Permission/List', 'SafetyOutlined', 'system:permission', 2, 1, 50, TRUE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE); (6, '权限管理', '/system/permissions', 'System/Permission/List', 'SafetyOutlined', 'system:permission', 2, 1, 50, TRUE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE),
-- 在线用户管理
(7, '在线用户', '/system/online', 'System/Online/List', 'UsersRound', 'system:online:view', 2, 1, 60, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE);
-- ==================== 初始化角色数据 ==================== -- ==================== 初始化角色数据 ====================
DELETE FROM sys_role WHERE id < 100; DELETE FROM sys_role WHERE id < 100;
@ -206,6 +208,10 @@ INSERT INTO sys_permission (id, create_time, menu_id, code, name, type, sort) VA
(45, NOW(), 5, 'system:department:delete', '部门删除', 'FUNCTION', 5), (45, NOW(), 5, 'system:department:delete', '部门删除', 'FUNCTION', 5),
(46, NOW(), 5, 'system:department:tree', '获取部门树', 'FUNCTION', 6), (46, NOW(), 5, 'system:department:tree', '获取部门树', 'FUNCTION', 6),
-- 在线用户管理 (menu_id=7)
(51, NOW(), 7, 'system:online:view', '查看在线用户', 'FUNCTION', 1),
(52, NOW(), 7, 'system:online:kick', '强制下线', 'FUNCTION', 2),
-- 运维管理权限 -- 运维管理权限
-- 团队管理 (menu_id=201) -- 团队管理 (menu_id=201)
(101, NOW(), 201, 'deploy:team:list', '团队查询', 'FUNCTION', 1), (101, NOW(), 201, 'deploy:team:list', '团队查询', 'FUNCTION', 1),

View File

@ -0,0 +1,80 @@
package com.qqchen.deploy.backend.notification;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qqchen.deploy.backend.notification.dto.NotificationChannelDTO;
import com.qqchen.deploy.backend.notification.entity.config.EmailNotificationConfig;
import com.qqchen.deploy.backend.notification.entity.config.WeworkNotificationConfig;
import com.qqchen.deploy.backend.notification.enums.NotificationChannelTypeEnum;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
/**
* 通知配置 JSON 序列化/反序列化测试
* 验证重构后的 BaseNotificationConfig 多态机制是否正常
*/
@SpringBootTest
public class NotificationConfigJsonTest {
@Autowired
private ObjectMapper objectMapper;
@Test
public void testWeworkConfigSerialization() throws Exception {
NotificationChannelDTO dto = new NotificationChannelDTO();
dto.setName("测试企微渠道");
dto.setChannelType(NotificationChannelTypeEnum.WEWORK);
dto.setEnabled(true);
WeworkNotificationConfig config = new WeworkNotificationConfig();
config.setKey("test-webhook-key-12345");
dto.setConfig(config);
String json = objectMapper.writeValueAsString(dto);
System.out.println("Wework Serialization: " + json);
assertTrue(json.contains("\"channelType\":\"WEWORK\""));
assertTrue(json.contains("\"key\":\"test-webhook-key-12345\""));
NotificationChannelDTO deserialized = objectMapper.readValue(json, NotificationChannelDTO.class);
assertNotNull(deserialized.getConfig());
assertTrue(deserialized.getConfig() instanceof WeworkNotificationConfig);
assertEquals("test-webhook-key-12345", ((WeworkNotificationConfig) deserialized.getConfig()).getKey());
}
@Test
public void testEmailConfigSerialization() throws Exception {
NotificationChannelDTO dto = new NotificationChannelDTO();
dto.setName("测试邮件渠道");
dto.setChannelType(NotificationChannelTypeEnum.EMAIL);
dto.setEnabled(true);
EmailNotificationConfig config = new EmailNotificationConfig();
config.setSmtpHost("smtp.example.com");
config.setSmtpPort(465);
config.setUsername("noreply@example.com");
config.setPassword("password123");
config.setFrom("noreply@example.com");
config.setFromName("Deploy Platform");
config.setUseSsl(true);
dto.setConfig(config);
String json = objectMapper.writeValueAsString(dto);
System.out.println("Email Serialization: " + json);
assertTrue(json.contains("\"channelType\":\"EMAIL\""));
assertTrue(json.contains("\"smtpHost\":\"smtp.example.com\""));
NotificationChannelDTO deserialized = objectMapper.readValue(json, NotificationChannelDTO.class);
assertNotNull(deserialized.getConfig());
assertTrue(deserialized.getConfig() instanceof EmailNotificationConfig);
EmailNotificationConfig deserializedConfig = (EmailNotificationConfig) deserialized.getConfig();
assertEquals("smtp.example.com", deserializedConfig.getSmtpHost());
assertEquals(465, deserializedConfig.getSmtpPort());
assertEquals("noreply@example.com", deserializedConfig.getUsername());
}
}