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;