diff --git a/backend/pom.xml b/backend/pom.xml index ce187008..a0ea5880 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -131,6 +131,13 @@ provided + + + nl.basjes.parse.useragent + yauaa + 7.26.0 + + org.springframework.boot diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/ServerApiController.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/ServerApiController.java index 3f748949..478898c4 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/ServerApiController.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/api/ServerApiController.java @@ -15,6 +15,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -34,23 +35,23 @@ public class ServerApiController private IServerService serverService; @Override - public Response create(ServerDTO dto) { + public Response create(@Validated @RequestBody ServerDTO dto) { return super.create(dto); } @Override - public Response update(Long aLong, ServerDTO dto) { - return super.update(aLong, dto); + public Response update(@PathVariable Long id, @Validated @RequestBody ServerDTO dto) { + return super.update(id, dto); } @Override - public Response delete(Long aLong) { - return super.delete(aLong); + public Response delete(@PathVariable Long id) { + return super.delete(id); } @Override - public Response findById(Long aLong) { - return super.findById(aLong); + public Response findById(Long id) { + return super.findById(id); } @Override diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/security/util/JwtTokenUtil.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/security/util/JwtTokenUtil.java index c8606f9a..113a781e 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/framework/security/util/JwtTokenUtil.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/security/util/JwtTokenUtil.java @@ -2,10 +2,12 @@ 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 com.qqchen.deploy.backend.framework.utils.UserAgentUtil; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; @@ -32,6 +34,9 @@ public class JwtTokenUtil { @Resource private RedisUtil redisUtil; + @Resource + private UserAgentUtil userAgentUtil; + // Redis Key前缀 private static final String TOKEN_PREFIX = "auth:token:"; @@ -74,7 +79,7 @@ public class JwtTokenUtil { } /** - * 生成Token并存储到Redis + * 生成Token(集成Redis存储) * * @param userDetails 用户信息 * @return Token字符串 @@ -91,6 +96,26 @@ public class JwtTokenUtil { return token; } + + /** + * 生成Token(集成Redis存储,包含请求信息) + * + * @param userDetails 用户信息 + * @param request HTTP请求 + * @return Token字符串 + */ + public String generateToken(UserDetails userDetails, HttpServletRequest request) { + Map claims = new HashMap<>(); + String token = doGenerateToken(claims, userDetails.getUsername()); + + // 存储到Redis(如果userDetails是CustomUserDetails,可以获取userId) + if (userDetails instanceof CustomUserDetails) { + Long userId = ((CustomUserDetails) userDetails).getUserId(); + storeToken(userId, token, request); + } + + return token; + } private String doGenerateToken(Map claims, String subject) { return Jwts.builder() @@ -159,6 +184,32 @@ public class JwtTokenUtil { log.debug("Token已存储到Redis: userId={}, ttl={}秒", userId, expiration); } + /** + * 存储Token到Redis(包含登录时间和请求信息) + */ + private void storeToken(Long userId, String token, HttpServletRequest request) { + String key = TOKEN_PREFIX + userId; + + // 获取IP地址 + String ipAddress = userAgentUtil.getRealIpAddress(request); + + // 解析User-Agent + String userAgentString = request.getHeader("User-Agent"); + UserAgentUtil.UserAgentInfo userAgentInfo = userAgentUtil.parseUserAgent(userAgentString); + + // 存储Token + 登录时间 + 请求信息 + Map tokenInfo = new HashMap<>(); + tokenInfo.put("token", token); + tokenInfo.put("loginTime", LocalDateTime.now().toString()); + tokenInfo.put("ipAddress", ipAddress); + tokenInfo.put("browser", userAgentInfo.getBrowser()); + tokenInfo.put("os", userAgentInfo.getOs()); + + redisUtil.set(key, tokenInfo, expiration); + log.debug("Token已存储到Redis: userId={}, ip={}, browser={}, os={}, ttl={}秒", + userId, ipAddress, userAgentInfo.getBrowser(), userAgentInfo.getOs(), expiration); + } + /** * 从Redis获取Token字符串 */ diff --git a/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/UserAgentUtil.java b/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/UserAgentUtil.java new file mode 100644 index 00000000..4189e646 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/framework/utils/UserAgentUtil.java @@ -0,0 +1,244 @@ +package com.qqchen.deploy.backend.framework.utils; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import nl.basjes.parse.useragent.UserAgent; +import nl.basjes.parse.useragent.UserAgentAnalyzer; +import org.springframework.stereotype.Component; + +/** + * User-Agent解析工具类 + * + * @author qqchen + * @date 2025-11-20 + */ +@Slf4j +@Component +public class UserAgentUtil { + + private static final UserAgentAnalyzer USER_AGENT_ANALYZER = UserAgentAnalyzer + .newBuilder() + .hideMatcherLoadStats() + .withCache(10000) + .build(); + + /** + * 解析User-Agent信息 + * + * @param userAgentString User-Agent字符串 + * @return 解析结果 + */ + public UserAgentInfo parseUserAgent(String userAgentString) { + if (userAgentString == null || userAgentString.trim().isEmpty()) { + return UserAgentInfo.unknown(); + } + + try { + UserAgent userAgent = USER_AGENT_ANALYZER.parse(userAgentString); + + return UserAgentInfo.builder() + .browser(getBrowserName(userAgent)) + .browserVersion(userAgent.getValue(UserAgent.AGENT_VERSION)) + .os(getOperatingSystem(userAgent)) + .osVersion(userAgent.getValue(UserAgent.OPERATING_SYSTEM_VERSION)) + .deviceType(userAgent.getValue(UserAgent.DEVICE_CLASS)) + .build(); + } catch (Exception e) { + log.warn("解析User-Agent失败: {}", userAgentString, e); + return UserAgentInfo.unknown(); + } + } + + /** + * 从HttpServletRequest获取真实IP地址 + * + * @param request HTTP请求 + * @return IP地址 + */ + public String getRealIpAddress(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + // 多级代理的情况,第一个IP为客户端真实IP + int index = ip.indexOf(','); + if (index != -1) { + return ip.substring(0, index).trim(); + } else { + return ip.trim(); + } + } + + ip = request.getHeader("X-Real-IP"); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.trim(); + } + + ip = request.getHeader("Proxy-Client-IP"); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.trim(); + } + + ip = request.getHeader("WL-Proxy-Client-IP"); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.trim(); + } + + ip = request.getHeader("HTTP_CLIENT_IP"); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.trim(); + } + + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.trim(); + } + + // 如果都没有,返回request.getRemoteAddr() + return request.getRemoteAddr(); + } + + /** + * 获取浏览器名称 + */ + private String getBrowserName(UserAgent userAgent) { + String agentName = userAgent.getValue(UserAgent.AGENT_NAME); + String agentVersion = userAgent.getValue(UserAgent.AGENT_VERSION); + + if ("Unknown".equals(agentName)) { + return "Unknown"; + } + + // 返回浏览器名称 + 主版本号 + if (agentVersion != null && !agentVersion.isEmpty() && !"Unknown".equals(agentVersion)) { + String majorVersion = agentVersion.split("\\.")[0]; + return agentName + " " + majorVersion; + } + + return agentName; + } + + /** + * 获取操作系统名称 + */ + private String getOperatingSystem(UserAgent userAgent) { + String osName = userAgent.getValue(UserAgent.OPERATING_SYSTEM_NAME); + String osVersion = userAgent.getValue(UserAgent.OPERATING_SYSTEM_VERSION); + + if ("Unknown".equals(osName)) { + return "Unknown"; + } + + // Windows NT 版本号转换为友好名称 + if ("Windows NT".equals(osName) && osVersion != null && !osVersion.isEmpty()) { + return convertWindowsNTVersion(osVersion); + } + + // 返回操作系统名称 + 版本 + if (osVersion != null && !osVersion.isEmpty() && !"Unknown".equals(osVersion)) { + return osName + " " + osVersion; + } + + return osName; + } + + /** + * 将Windows NT版本号转换为用户友好的显示名称 + */ + private String convertWindowsNTVersion(String ntVersion) { + switch (ntVersion) { + case "10.0": + return "Windows 10/11"; // Windows 10 和 11 都是 NT 10.0 + case "6.3": + return "Windows 8.1"; + case "6.2": + return "Windows 8"; + case "6.1": + return "Windows 7"; + case "6.0": + return "Windows Vista"; + case "5.2": + return "Windows Server 2003"; + case "5.1": + return "Windows XP"; + case "5.0": + return "Windows 2000"; + default: + return "Windows NT " + ntVersion; // 未知版本保持原样 + } + } + + /** + * User-Agent解析结果 + */ + public static class UserAgentInfo { + private String browser; + private String browserVersion; + private String os; + private String osVersion; + private String deviceType; + + public static UserAgentInfo unknown() { + return UserAgentInfo.builder() + .browser("Unknown") + .browserVersion("Unknown") + .os("Unknown") + .osVersion("Unknown") + .deviceType("Unknown") + .build(); + } + + public static UserAgentInfoBuilder builder() { + return new UserAgentInfoBuilder(); + } + + // Getters + public String getBrowser() { return browser; } + public String getBrowserVersion() { return browserVersion; } + public String getOs() { return os; } + public String getOsVersion() { return osVersion; } + public String getDeviceType() { return deviceType; } + + // Builder + public static class UserAgentInfoBuilder { + private String browser; + private String browserVersion; + private String os; + private String osVersion; + private String deviceType; + + public UserAgentInfoBuilder browser(String browser) { + this.browser = browser; + return this; + } + + public UserAgentInfoBuilder browserVersion(String browserVersion) { + this.browserVersion = browserVersion; + return this; + } + + public UserAgentInfoBuilder os(String os) { + this.os = os; + return this; + } + + public UserAgentInfoBuilder osVersion(String osVersion) { + this.osVersion = osVersion; + return this; + } + + public UserAgentInfoBuilder deviceType(String deviceType) { + this.deviceType = deviceType; + return this; + } + + public UserAgentInfo build() { + UserAgentInfo info = new UserAgentInfo(); + info.browser = this.browser; + info.browserVersion = this.browserVersion; + info.os = this.os; + info.osVersion = this.osVersion; + info.deviceType = this.deviceType; + return info; + } + } + } +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/api/UserApiController.java b/backend/src/main/java/com/qqchen/deploy/backend/system/api/UserApiController.java index c9ae4246..3e544343 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/system/api/UserApiController.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/api/UserApiController.java @@ -12,6 +12,7 @@ import com.qqchen.deploy.backend.system.model.response.LoginResponse; import com.qqchen.deploy.backend.system.service.IUserService; import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.data.domain.Page; import org.springframework.validation.annotation.Validated; @@ -71,8 +72,8 @@ public class UserApiController extends BaseController login(@Validated @RequestBody LoginRequest request) { - return Response.success(userService.login(request)); + public Response login(@Validated @RequestBody LoginRequest request, HttpServletRequest httpRequest) { + return Response.success(userService.login(request, httpRequest)); } @Operation(summary = "获取当前用户信息") diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/service/IUserService.java b/backend/src/main/java/com/qqchen/deploy/backend/system/service/IUserService.java index 887a4162..38539b45 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/system/service/IUserService.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/service/IUserService.java @@ -9,12 +9,13 @@ import com.qqchen.deploy.backend.system.model.response.LoginResponse; import com.qqchen.deploy.backend.system.entity.User; import com.qqchen.deploy.backend.system.model.request.UserRequest; import com.qqchen.deploy.backend.system.model.RoleDTO; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.transaction.annotation.Transactional; import java.util.List; public interface IUserService extends IBaseService { - LoginResponse login(LoginRequest request); + LoginResponse login(LoginRequest request, HttpServletRequest httpRequest); UserDTO register(UserRequest request); boolean checkUsernameExists(String username); boolean checkEmailExists(String email); diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/OnlineUserServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/OnlineUserServiceImpl.java index bf4a8c2e..66d92393 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/OnlineUserServiceImpl.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/OnlineUserServiceImpl.java @@ -96,6 +96,9 @@ public class OnlineUserServiceImpl implements IOnlineUserService { if (tokenInfo == null) continue; String loginTimeStr = (String) tokenInfo.get("loginTime"); + String ipAddress = (String) tokenInfo.get("ipAddress"); + String browser = (String) tokenInfo.get("browser"); + String os = (String) tokenInfo.get("os"); // 查询用户详细信息 User user = userRepository.findById(userId).orElse(null); @@ -115,6 +118,9 @@ public class OnlineUserServiceImpl implements IOnlineUserService { .loginTime(loginTime) .lastActiveTime(loginTime) // 由于没有心跳,最后活跃时间=登录时间 .onlineDuration(onlineDuration) + .ipAddress(ipAddress) + .browser(browser) + .os(os) .build(); onlineUsers.add(vo); diff --git a/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/UserServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/UserServiceImpl.java index 2676771e..5dedd982 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/UserServiceImpl.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/system/service/impl/UserServiceImpl.java @@ -25,6 +25,7 @@ import com.qqchen.deploy.backend.system.repository.IRoleRepository; import com.qqchen.deploy.backend.system.entity.Department; import com.qqchen.deploy.backend.system.entity.Role; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.hibernate.Hibernate; import org.springframework.security.authentication.AuthenticationManager; @@ -118,12 +119,12 @@ public class UserServiceImpl extends BaseServiceImpl new BusinessException(ResponseCode.USER_NOT_FOUND)); diff --git a/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql b/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql index a70490fd..14f473a3 100644 --- a/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql +++ b/backend/src/main/resources/db/changelog/changes/v1.0.0-data.sql @@ -104,7 +104,7 @@ VALUES -- 权限管理(隐藏菜单) (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); +(7, '在线用户', '/system/online', 'System/Online/List', 'UserSwitchOutlined', 'system:online:view', 2, 1, 60, FALSE, TRUE, 'system', '2024-01-01 00:00:00', 0, FALSE); -- ==================== 初始化角色数据 ==================== DELETE FROM sys_role WHERE id < 100;