增加构建通知
This commit is contained in:
parent
58ad4b2643
commit
21f0b965bc
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user