From 5aff74d36f7a634130def65f497d09c411897262 Mon Sep 17 00:00:00 2001
From: "Ray.Hao" <1490493387@qq.com>
Date: Tue, 22 Apr 2025 20:49:49 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AD=97=E5=85=B8=E5=AE=9E=E6=97=B6?=
=?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=92=8C=20`websocket`=20=E9=87=8D=E6=9E=84?=
=?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
sql/mysql/youlai_boot.sql | 5 +-
.../youlai/boot/config/WebSocketConfig.java | 243 ++++++++--------
.../mail/controller/MailController.java | 2 +-
.../listener/OnlineUserListener.java | 44 ---
.../websocket/service/OnlineUserService.java | 70 -----
.../system/controller/DictController.java | 41 ++-
...aConverter.java => DictItemConverter.java} | 6 +-
.../system/event/UserConnectionEvent.java | 38 ---
.../handler/OnlineUserJobHandler.java | 8 +-
.../boot/system/model/dto/UserSessionDTO.java | 37 +++
.../boot/system/model/event/DictEvent.java | 27 ++
.../boot/system/model/form/DictForm.java | 2 +
.../boot/system/service/DictService.java | 9 +-
.../system/service/UserOnlineService.java | 157 ++++++++++
.../boot/system/service/WebSocketService.java | 46 +++
.../service/impl/DictItemServiceImpl.java | 14 +-
.../system/service/impl/DictServiceImpl.java | 67 +++--
.../service/impl/NoticeServiceImpl.java | 8 +-
.../service/impl/WebSocketServiceImpl.java | 269 ++++++++++++++++++
.../mapper/system/RoleMenuMapper.xml | 2 +-
.../resources/templates/codegen/index.vue.vm | 4 +-
21 files changed, 770 insertions(+), 329 deletions(-)
delete mode 100644 src/main/java/com/youlai/boot/shared/websocket/listener/OnlineUserListener.java
delete mode 100644 src/main/java/com/youlai/boot/shared/websocket/service/OnlineUserService.java
rename src/main/java/com/youlai/boot/system/converter/{DictDataConverter.java => DictItemConverter.java} (89%)
delete mode 100644 src/main/java/com/youlai/boot/system/event/UserConnectionEvent.java
rename src/main/java/com/youlai/boot/{shared/websocket => system}/handler/OnlineUserJobHandler.java (77%)
create mode 100644 src/main/java/com/youlai/boot/system/model/dto/UserSessionDTO.java
create mode 100644 src/main/java/com/youlai/boot/system/model/event/DictEvent.java
create mode 100644 src/main/java/com/youlai/boot/system/service/UserOnlineService.java
create mode 100644 src/main/java/com/youlai/boot/system/service/WebSocketService.java
create mode 100644 src/main/java/com/youlai/boot/system/service/impl/WebSocketServiceImpl.java
diff --git a/sql/mysql/youlai_boot.sql b/sql/mysql/youlai_boot.sql
index a6402555..d8a7927b 100644
--- a/sql/mysql/youlai_boot.sql
+++ b/sql/mysql/youlai_boot.sql
@@ -173,7 +173,6 @@ INSERT INTO `sys_menu` VALUES (84, 6, '0,1,6', '字典删除', 4, NULL, '', NULL
INSERT INTO `sys_menu` VALUES (88, 2, '0,1,2', '重置密码', 4, NULL, '', NULL, 'sys:user:reset-password', NULL, NULL, 1, 4, '', NULL, now(), now(), NULL);
INSERT INTO `sys_menu` VALUES (89, 0, '0', '功能演示', 2, NULL, '/function', 'Layout', NULL, NULL, NULL, 1, 12, 'menu', '', now(), now(), NULL);
INSERT INTO `sys_menu` VALUES (90, 89, '0,89', 'Websocket', 1, NULL, '/function/websocket', 'demo/websocket', NULL, NULL, 1, 1, 3, '', '', now(), now(), NULL);
-INSERT INTO `sys_menu` VALUES (91, 89, '0,89', '敬请期待...', 2, NULL, 'other/:id', 'demo/other', NULL, NULL, NULL, 1, 4, '', '', now(), now(), NULL);
INSERT INTO `sys_menu` VALUES (95, 36, '0,36', '字典组件', 1, NULL, 'dict-demo', 'demo/dictionary', NULL, NULL, 1, 1, 4, '', '', now(), now(), NULL);
INSERT INTO `sys_menu` VALUES (97, 89, '0,89', 'Icons', 1, NULL, 'icon-demo', 'demo/icons', NULL, NULL, 1, 1, 2, 'el-icon-Notification', '', now(), now(), NULL);
INSERT INTO `sys_menu` VALUES (102, 26, '0,26', 'document', 3, '', 'internal-doc', 'demo/internal-doc', NULL, NULL, NULL, 1, 1, 'document', '', now(), now(), NULL);
@@ -214,6 +213,7 @@ INSERT INTO `sys_menu` VALUES (144, 26, '0,26', '后端文档', 3, NULL, 'https:
INSERT INTO `sys_menu` VALUES (145, 26, '0,26', '移动端文档', 3, NULL, 'https://youlai.blog.csdn.net/article/details/143222890', '', NULL, NULL, NULL, 1, 4, 'document', '', '2024-10-05 23:36:03', '2024-10-05 23:36:03', NULL);
INSERT INTO `sys_menu` VALUES (146, 36, '0,36', '拖拽组件', 1, NULL, 'drag', 'demo/drag', NULL, NULL, NULL, 1, 5, '', '', '2025-03-31 14:14:45', '2025-03-31 14:14:52', NULL);
INSERT INTO `sys_menu` VALUES (147, 36, '0,36', '滚动文本', 1, NULL, 'text-scroll', 'demo/text-scroll', NULL, NULL, NULL, 1, 6, '', '', '2025-03-31 14:14:49', '2025-03-31 14:14:56', NULL);
+INSERT INTO `sys_menu` VALUES (148, 89, '0,89', '字典实时同步', 1, NULL, 'dict-sync', 'demo/dict-sync', NULL, NULL, NULL, 1, 3, '', '', '2025-03-31 14:14:49', '2025-03-31 14:14:56', NULL);
-- ----------------------------
-- Table structure for sys_role
@@ -351,6 +351,7 @@ INSERT INTO `sys_role_menu` VALUES (2, 144);
INSERT INTO `sys_role_menu` VALUES (2, 145);
INSERT INTO `sys_role_menu` VALUES (2, 146);
INSERT INTO `sys_role_menu` VALUES (2, 147);
+INSERT INTO `sys_role_menu` VALUES (2, 148);
-- ----------------------------
-- Table structure for sys_user
@@ -559,4 +560,4 @@ INSERT INTO `sys_user_notice` VALUES (8, 8, 2, 1, NULL, now(), now(), 0);
INSERT INTO `sys_user_notice` VALUES (9, 9, 2, 1, NULL, now(), now(), 0);
INSERT INTO `sys_user_notice` VALUES (10, 10, 2, 1, NULL, now(), now(), 0);
-SET FOREIGN_KEY_CHECKS = 1;
\ No newline at end of file
+SET FOREIGN_KEY_CHECKS = 1;
diff --git a/src/main/java/com/youlai/boot/config/WebSocketConfig.java b/src/main/java/com/youlai/boot/config/WebSocketConfig.java
index 2d9bbf48..a344010b 100644
--- a/src/main/java/com/youlai/boot/config/WebSocketConfig.java
+++ b/src/main/java/com/youlai/boot/config/WebSocketConfig.java
@@ -3,11 +3,11 @@ package com.youlai.boot.config;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.core.security.model.SysUserDetails;
import com.youlai.boot.core.security.token.TokenManager;
-import com.youlai.boot.system.event.UserConnectionEvent;
+import com.youlai.boot.system.service.WebSocketService;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
-import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpHeaders;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
@@ -27,144 +27,143 @@ import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
- * WebSocket 配置
+ * WebSocket配置
*
* @author Ray.Hao
- * @since 2.4.0
+ * @since 3.0.0
*/
-// 启用WebSocket消息代理功能和配置STOMP协议,实现实时双向通信和消息传递
@EnableWebSocketMessageBroker
@Configuration
@Slf4j
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
- private final ApplicationEventPublisher eventPublisher;
+ private final TokenManager tokenManager;
+ private final WebSocketService webSocketService;
- private final TokenManager tokenManager;
+ public WebSocketConfig(TokenManager tokenManager, @Lazy WebSocketService webSocketService) {
+ this.tokenManager = tokenManager;
+ this.webSocketService = webSocketService;
+ }
- public WebSocketConfig(ApplicationEventPublisher eventPublisher, TokenManager tokenManager) {
- this.eventPublisher = eventPublisher;
- this.tokenManager = tokenManager;
- }
-
- /**
- * 注册一个端点,客户端通过这个端点进行连接
- */
- @Override
- public void registerStompEndpoints(StompEndpointRegistry registry) {
- registry
- // 注册 /ws 的端点
- .addEndpoint("/ws")
- // 允许跨域
- .setAllowedOriginPatterns("*");
- }
+ /**
+ * 注册一个端点,客户端通过这个端点进行连接
+ */
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ registry
+ // 注册 /ws 的端点
+ .addEndpoint("/ws")
+ // 允许跨域
+ .setAllowedOriginPatterns("*");
+ }
- /**
- * 配置消息代理
- */
- @Override
- public void configureMessageBroker(MessageBrokerRegistry registry) {
- // 客户端发送消息的请求前缀
- registry.setApplicationDestinationPrefixes("/app");
+ /**
+ * 配置消息代理
+ */
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry registry) {
+ // 客户端发送消息的请求前缀
+ registry.setApplicationDestinationPrefixes("/app");
- // 客户端订阅消息的请求前缀,topic一般用于广播推送,queue用于点对点推送
- registry.enableSimpleBroker("/topic", "/queue");
+ // 客户端订阅消息的请求前缀,topic一般用于广播推送,queue用于点对点推送
+ registry.enableSimpleBroker("/topic", "/queue");
- // 服务端通知客户端的前缀,可以不设置,默认为user
- registry.setUserDestinationPrefix("/user");
- }
+ // 服务端通知客户端的前缀,可以不设置,默认为user
+ registry.setUserDestinationPrefix("/user");
+ }
- /**
- * 配置客户端入站通道拦截器
- *
- * 核心功能:
- * 1. 连接建立时解析令牌并绑定用户身份
- * 2. 连接关闭时触发下线通知
- * 3. 异常Token的防御性处理
- */
- @Override
- public void configureClientInboundChannel(ChannelRegistration registration) {
- registration.interceptors(new ChannelInterceptor() {
- @Override
- public Message> preSend(@NotNull Message> message, @NotNull MessageChannel channel) {
- StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
- if (accessor == null) {
- return ChannelInterceptor.super.preSend(message, channel);
- }
+ /**
+ * 配置客户端入站通道拦截器
+ *
+ * 核心功能:
+ * 1. 连接建立时解析令牌并绑定用户身份
+ * 2. 连接关闭时触发下线通知
+ * 3. 异常Token的防御性处理
+ */
+ @Override
+ public void configureClientInboundChannel(ChannelRegistration registration) {
+ registration.interceptors(new ChannelInterceptor() {
+ @Override
+ public Message> preSend(@NotNull Message> message, @NotNull MessageChannel channel) {
+ StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
+ if (accessor == null) {
+ return ChannelInterceptor.super.preSend(message, channel);
+ }
- try {
- // 处理客户端连接请求
- if (StompCommand.CONNECT.equals(accessor.getCommand())) {
- /*
- * 安全校验流程:
- * 1. 从HEADER中获取Authorization值
- * 2. 校验Bearer Token格式合法性
- * 3. 解析并验证JWT有效性
- * 4. 绑定用户身份到当前会话
- */
- String authorization = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION);
+ try {
+ // 处理客户端连接请求
+ if (StompCommand.CONNECT.equals(accessor.getCommand())) {
+ /*
+ * 安全校验流程:
+ * 1. 从HEADER中获取Authorization值
+ * 2. 校验Bearer Token格式合法性
+ * 3. 解析并验证JWT有效性
+ * 4. 绑定用户身份到当前会话
+ */
+ String authorization = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION);
- // 防御性校验:确保Authorization头存在且格式正确
- if (StrUtil.isBlank(authorization) || !authorization.startsWith("Bearer ")) {
- log.warn("非法连接请求:缺少有效的Authorization头");
- throw new AuthenticationCredentialsNotFoundException("Missing authorization header");
- }
-
- // 提取并处理JWT令牌(移除Bearer前缀)
- String token = authorization.substring(7);
- Authentication authentication = tokenManager.parseToken(token);
-
- // 令牌解析失败处理
- if (authentication == null) {
- log.error("令牌解析失败:{}", token);
- throw new BadCredentialsException("Invalid token");
- }
-
- // 获取用户详细信息
- SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
- if (userDetails == null || StrUtil.isBlank(userDetails.getUsername())) {
- log.error("无效的用户凭证:{}", token);
- throw new BadCredentialsException("Invalid user credentials");
- }
-
- String username = userDetails.getUsername();
- log.info("WebSocket连接建立:用户[{}]", username);
-
- // 绑定用户身份到当前会话(重要:用于@SendToUser等注解)
- accessor.setUser(authentication);
-
- // 发布用户上线事件(示例:可用于更新在线用户列表)
- eventPublisher.publishEvent(new UserConnectionEvent(this, username, true));
-
- }
- // 处理客户端断开请求
- else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
- /*
- * 注意:只有成功建立过认证的连接才会触发下线事件
- * 防止未认证成功的连接产生脏数据
- */
- Authentication authentication = (Authentication) accessor.getUser();
- if (authentication != null && authentication.isAuthenticated()) {
- String username = ((SysUserDetails) authentication.getPrincipal()).getUsername();
- log.info("WebSocket连接关闭:用户[{}]", username);
- eventPublisher.publishEvent(new UserConnectionEvent(this, username, false));
- }
- }
- } catch (AuthenticationException ex) {
- // 认证失败时强制关闭连接
- log.error("连接认证失败:{}", ex.getMessage());
- throw ex;
- } catch (Exception ex) {
- // 捕获其他未知异常
- log.error("WebSocket连接处理异常:", ex);
- throw new MessagingException("Connection processing failed");
- }
-
- return ChannelInterceptor.super.preSend(message, channel);
+ // 防御性校验:确保Authorization头存在且格式正确
+ if (StrUtil.isBlank(authorization) || !authorization.startsWith("Bearer ")) {
+ log.warn("非法连接请求:缺少有效的Authorization头");
+ throw new AuthenticationCredentialsNotFoundException("Missing authorization header");
}
- });
- }
+ // 提取并处理JWT令牌(移除Bearer前缀)
+ String token = authorization.substring(7);
+ Authentication authentication = tokenManager.parseToken(token);
+
+ // 令牌解析失败处理
+ if (authentication == null) {
+ log.error("令牌解析失败:{}", token);
+ throw new BadCredentialsException("Invalid token");
+ }
+
+ // 获取用户详细信息
+ SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
+ if (userDetails == null || StrUtil.isBlank(userDetails.getUsername())) {
+ log.error("无效的用户凭证:{}", token);
+ throw new BadCredentialsException("Invalid user credentials");
+ }
+
+ String username = userDetails.getUsername();
+ log.info("WebSocket连接建立:用户[{}]", username);
+
+ // 绑定用户身份到当前会话(重要:用于@SendToUser等注解)
+ accessor.setUser(authentication);
+
+ // 记录用户上线状态
+ webSocketService.userConnected(username, accessor.getSessionId());
+
+ }
+ // 处理客户端断开请求
+ else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
+ /*
+ * 注意:只有成功建立过认证的连接才会触发下线事件
+ * 防止未认证成功的连接产生脏数据
+ */
+ Authentication authentication = (Authentication) accessor.getUser();
+ if (authentication != null && authentication.isAuthenticated()) {
+ String username = ((SysUserDetails) authentication.getPrincipal()).getUsername();
+ log.info("WebSocket连接关闭:用户[{}]", username);
+
+ // 记录用户下线状态
+ webSocketService.userDisconnected(username);
+ }
+ }
+ } catch (AuthenticationException ex) {
+ // 认证失败时强制关闭连接
+ log.error("连接认证失败:{}", ex.getMessage());
+ throw ex;
+ } catch (Exception ex) {
+ // 捕获其他未知异常
+ log.error("WebSocket连接处理异常:", ex);
+ throw new MessagingException("Connection processing failed");
+ }
+
+ return ChannelInterceptor.super.preSend(message, channel);
+ }
+ });
+ }
}
diff --git a/src/main/java/com/youlai/boot/shared/mail/controller/MailController.java b/src/main/java/com/youlai/boot/shared/mail/controller/MailController.java
index 7eb1acbc..d9e4c20d 100644
--- a/src/main/java/com/youlai/boot/shared/mail/controller/MailController.java
+++ b/src/main/java/com/youlai/boot/shared/mail/controller/MailController.java
@@ -5,7 +5,7 @@ import org.springframework.web.bind.annotation.*;
/**
* 邮件控制层
*
- * @author Ray
+ * @author Ray.Hao
* @since 2.10.0
*/
@RestController
diff --git a/src/main/java/com/youlai/boot/shared/websocket/listener/OnlineUserListener.java b/src/main/java/com/youlai/boot/shared/websocket/listener/OnlineUserListener.java
deleted file mode 100644
index 54fd3481..00000000
--- a/src/main/java/com/youlai/boot/shared/websocket/listener/OnlineUserListener.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.youlai.boot.shared.websocket.listener;
-
-import com.youlai.boot.shared.websocket.service.OnlineUserService;
-import com.youlai.boot.system.event.UserConnectionEvent;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.context.event.EventListener;
-import org.springframework.messaging.simp.SimpMessagingTemplate;
-import org.springframework.stereotype.Component;
-
-/**
- * 在线用户监听器
- *
- * @author haoxr
- * @since 2024/9/25
- */
-@Component
-@RequiredArgsConstructor
-@Slf4j
-public class OnlineUserListener {
-
- private final SimpMessagingTemplate messagingTemplate;
- private final OnlineUserService onlineUserService;
-
- /**
- * 用户连接事件处理
- *
- * @param event 用户连接事件
- */
- @EventListener
- public void handleUserConnectionEvent(UserConnectionEvent event) {
- String username = event.getUsername();
- if (event.isConnected()) {
- onlineUserService.addOnlineUser(username);
- log.info("User connected: {}", username);
- } else {
- onlineUserService.removeOnlineUser(username);
- log.info("User disconnected: {}", username);
- }
- // 推送在线用户人数
- messagingTemplate.convertAndSend("/topic/onlineUserCount", onlineUserService.getOnlineUserCount());
- }
-
-}
diff --git a/src/main/java/com/youlai/boot/shared/websocket/service/OnlineUserService.java b/src/main/java/com/youlai/boot/shared/websocket/service/OnlineUserService.java
deleted file mode 100644
index 6ef06a57..00000000
--- a/src/main/java/com/youlai/boot/shared/websocket/service/OnlineUserService.java
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.youlai.boot.shared.websocket.service;
-
-import org.springframework.stereotype.Service;
-
-import java.util.Collections;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.stream.Collectors;
-
-/**
- * 在线用户服务
- *
- * @author haoxr
- * @since 2024/9/26
- */
-
-@Service
-public class OnlineUserService {
-
- private final Set onlineUsers = ConcurrentHashMap.newKeySet();
-
- /**
- * 添加用户到在线用户集合
- *
- * @param username 用户名
- */
- public void addOnlineUser(String username) {
- onlineUsers.add(username);
- }
-
- /**
- * 从在线用户集合移除用户
- *
- * @param username 用户名
- */
- public void removeOnlineUser(String username) {
- onlineUsers.remove(username);
- }
-
- /**
- * 获取所有在线用户
- *
- * @return 在线用户集合
- */
- public Set getAllOnlineUsers() {
- return Collections.unmodifiableSet(onlineUsers);
- }
-
- /**
- * 获取在线的接收者
- * 从所有接收者中过滤出在线的接收者
- *
- * @param receivers 接收者
- * @return 在线的接收者集合
- */
- public Set getOnlineReceivers(Set receivers) {
- return receivers.stream().filter(onlineUsers::contains).collect(Collectors.toSet());
- }
-
- /**
- * 获取在线用户数量
- *
- * @return 在线用户数量
- */
- public int getOnlineUserCount() {
- return onlineUsers.size();
- }
-
-
-}
diff --git a/src/main/java/com/youlai/boot/system/controller/DictController.java b/src/main/java/com/youlai/boot/system/controller/DictController.java
index 3ec31b3b..252af202 100644
--- a/src/main/java/com/youlai/boot/system/controller/DictController.java
+++ b/src/main/java/com/youlai/boot/system/controller/DictController.java
@@ -16,6 +16,7 @@ import com.youlai.boot.system.model.form.DictForm;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.system.service.DictItemService;
import com.youlai.boot.system.service.DictService;
+import com.youlai.boot.system.service.WebSocketService;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
@@ -42,7 +43,7 @@ public class DictController {
private final DictService dictService;
private final DictItemService dictItemService;
-
+ private final WebSocketService webSocketService;
//---------------------------------------------------
// 字典相关接口
@@ -80,6 +81,10 @@ public class DictController {
@RepeatSubmit
public Result> saveDict(@Valid @RequestBody DictForm formData) {
boolean result = dictService.saveDict(formData);
+ // 发送字典更新通知
+ if (result) {
+ webSocketService.broadcastDictChange(formData.getDictCode());
+ }
return Result.judge(result);
}
@@ -88,9 +93,13 @@ public class DictController {
@PreAuthorize("@ss.hasPerm('sys:dict:edit')")
public Result> updateDict(
@PathVariable Long id,
- @RequestBody DictForm DictForm
+ @RequestBody DictForm dictForm
) {
- boolean status = dictService.updateDict(id, DictForm);
+ boolean status = dictService.updateDict(id, dictForm);
+ // 发送字典更新通知
+ if (status && dictForm.getDictCode() != null) {
+ webSocketService.broadcastDictChange(dictForm.getDictCode());
+ }
return Result.judge(status);
}
@@ -100,7 +109,16 @@ public class DictController {
public Result> deleteDictionaries(
@Parameter(description = "字典ID,多个以英文逗号(,)拼接") @PathVariable String ids
) {
+ // 获取字典编码列表,用于发送删除通知
+ List dictCodes = dictService.getDictCodesByIds(Arrays.stream(ids.split(",")).toList());
+
dictService.deleteDictByIds(Arrays.stream(ids.split(",")).toList());
+
+ // 发送字典删除通知
+ for (String dictCode : dictCodes) {
+ webSocketService.broadcastDictChange(dictCode);
+ }
+
return Result.success();
}
@@ -138,6 +156,12 @@ public class DictController {
) {
formData.setDictCode(dictCode);
boolean result = dictItemService.saveDictItem(formData);
+
+ // 发送字典更新通知
+ if (result) {
+ webSocketService.broadcastDictChange(dictCode);
+ }
+
return Result.judge(result);
}
@@ -163,6 +187,12 @@ public class DictController {
formData.setId(itemId);
formData.setDictCode(dictCode);
boolean status = dictItemService.updateDictItem(formData);
+
+ // 发送字典更新通知
+ if (status) {
+ webSocketService.broadcastDictChange(dictCode);
+ }
+
return Result.judge(status);
}
@@ -170,9 +200,14 @@ public class DictController {
@DeleteMapping("/{dictCode}/items/{itemIds}")
@PreAuthorize("@ss.hasPerm('sys:dict-item:delete')")
public Result deleteDictItems(
+ @PathVariable String dictCode,
@Parameter(description = "字典ID,多个以英文逗号(,)拼接") @PathVariable String itemIds
) {
dictItemService.deleteDictItemByIds(itemIds);
+
+ // 发送字典更新通知
+ webSocketService.broadcastDictChange(dictCode);
+
return Result.success();
}
diff --git a/src/main/java/com/youlai/boot/system/converter/DictDataConverter.java b/src/main/java/com/youlai/boot/system/converter/DictItemConverter.java
similarity index 89%
rename from src/main/java/com/youlai/boot/system/converter/DictDataConverter.java
rename to src/main/java/com/youlai/boot/system/converter/DictItemConverter.java
index c2480f78..99a354b0 100644
--- a/src/main/java/com/youlai/boot/system/converter/DictDataConverter.java
+++ b/src/main/java/com/youlai/boot/system/converter/DictItemConverter.java
@@ -10,13 +10,13 @@ import org.mapstruct.Mapper;
import java.util.List;
/**
- * 字典项 对象转换器
+ * 字典项对象转换器
*
- * @author Ray
+ * @author Ray.Hao
* @since 2022/6/8
*/
@Mapper(componentModel = "spring")
-public interface DictDataConverter {
+public interface DictItemConverter {
Page toPageVo(Page page);
diff --git a/src/main/java/com/youlai/boot/system/event/UserConnectionEvent.java b/src/main/java/com/youlai/boot/system/event/UserConnectionEvent.java
deleted file mode 100644
index dadea814..00000000
--- a/src/main/java/com/youlai/boot/system/event/UserConnectionEvent.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.youlai.boot.system.event;
-
-import lombok.Getter;
-import org.springframework.context.ApplicationEvent;
-
-
-/**
- * 用户连接事件
- *
- * @author Ray
- * @since 2.3.0
- */
-@Getter
-public class UserConnectionEvent extends ApplicationEvent {
-
- /**
- * 用户名
- */
- private final String username;
-
- /**
- * 是否连接
- */
- private final boolean connected;
-
- /**
- * 用户连接事件
- *
- * @param source 事件源
- * @param username 用户名
- * @param connected 是否连接
- */
- public UserConnectionEvent(Object source, String username, boolean connected) {
- super(source);
- this.username = username;
- this.connected = connected;
- }
-}
diff --git a/src/main/java/com/youlai/boot/shared/websocket/handler/OnlineUserJobHandler.java b/src/main/java/com/youlai/boot/system/handler/OnlineUserJobHandler.java
similarity index 77%
rename from src/main/java/com/youlai/boot/shared/websocket/handler/OnlineUserJobHandler.java
rename to src/main/java/com/youlai/boot/system/handler/OnlineUserJobHandler.java
index 75b148e2..1bcc405d 100644
--- a/src/main/java/com/youlai/boot/shared/websocket/handler/OnlineUserJobHandler.java
+++ b/src/main/java/com/youlai/boot/system/handler/OnlineUserJobHandler.java
@@ -1,7 +1,7 @@
-package com.youlai.boot.shared.websocket.handler;
+package com.youlai.boot.system.handler;
-import com.youlai.boot.shared.websocket.service.OnlineUserService;
+import com.youlai.boot.system.service.UserOnlineService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
@@ -19,7 +19,7 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class OnlineUserJobHandler {
- private final OnlineUserService onlineUserService;
+ private final UserOnlineService userOnlineService;
private final SimpMessagingTemplate messagingTemplate;
// 每分钟统计一次在线用户数
@@ -27,7 +27,7 @@ public class OnlineUserJobHandler {
public void execute() {
log.info("定时任务:统计在线用户数");
// 推送在线用户人数
- messagingTemplate.convertAndSend("/topic/onlineUserCount", onlineUserService.getOnlineUserCount());
+ messagingTemplate.convertAndSend("/topic/onlineUserCount", userOnlineService.getOnlineUserCount());
}
}
diff --git a/src/main/java/com/youlai/boot/system/model/dto/UserSessionDTO.java b/src/main/java/com/youlai/boot/system/model/dto/UserSessionDTO.java
new file mode 100644
index 00000000..80cb44c1
--- /dev/null
+++ b/src/main/java/com/youlai/boot/system/model/dto/UserSessionDTO.java
@@ -0,0 +1,37 @@
+package com.youlai.boot.system.model.dto;
+
+import lombok.Data;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * 用户会话DTO
+ *
+ * @author Ray.Hao
+ * @since 3.0.0
+ */
+@Data
+public class UserSessionDTO {
+
+ /**
+ * 用户名
+ */
+ private String username;
+
+ /**
+ * 用户会话ID集合
+ */
+ private Set sessionIds;
+
+ /**
+ * 最后活动时间
+ */
+ private long lastActiveTime;
+
+ public UserSessionDTO(String username) {
+ this.username = username;
+ this.sessionIds = new HashSet<>();
+ this.lastActiveTime = System.currentTimeMillis();
+ }
+}
diff --git a/src/main/java/com/youlai/boot/system/model/event/DictEvent.java b/src/main/java/com/youlai/boot/system/model/event/DictEvent.java
new file mode 100644
index 00000000..7f30ee10
--- /dev/null
+++ b/src/main/java/com/youlai/boot/system/model/event/DictEvent.java
@@ -0,0 +1,27 @@
+package com.youlai.boot.system.model.event;
+
+import lombok.Data;
+
+/**
+ * 字典更新事件
+ *
+ * @author Ray.Hao
+ * @since 3.0.0
+ */
+@Data
+public class DictEvent {
+ /**
+ * 字典编码
+ */
+ private String dictCode;
+
+ /**
+ * 时间戳
+ */
+ private long timestamp;
+
+ public DictEvent(String dictCode) {
+ this.dictCode = dictCode;
+ this.timestamp = System.currentTimeMillis();
+ }
+}
diff --git a/src/main/java/com/youlai/boot/system/model/form/DictForm.java b/src/main/java/com/youlai/boot/system/model/form/DictForm.java
index 31e5a996..b17d320a 100644
--- a/src/main/java/com/youlai/boot/system/model/form/DictForm.java
+++ b/src/main/java/com/youlai/boot/system/model/form/DictForm.java
@@ -2,6 +2,7 @@ package com.youlai.boot.system.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
@@ -26,6 +27,7 @@ public class DictForm {
private String name;
@Schema(description = "字典编码", example ="gender")
+ @NotBlank(message = "字典编码不能为空")
private String dictCode;
@Schema(description = "备注")
diff --git a/src/main/java/com/youlai/boot/system/service/DictService.java b/src/main/java/com/youlai/boot/system/service/DictService.java
index 6c9b9b68..5ab06ab7 100644
--- a/src/main/java/com/youlai/boot/system/service/DictService.java
+++ b/src/main/java/com/youlai/boot/system/service/DictService.java
@@ -66,6 +66,11 @@ public interface DictService extends IService {
*/
void deleteDictByIds(List ids);
-
-
+ /**
+ * 根据字典ID列表获取字典编码列表
+ *
+ * @param ids 字典ID列表
+ * @return 字典编码列表
+ */
+ List getDictCodesByIds(List ids);
}
diff --git a/src/main/java/com/youlai/boot/system/service/UserOnlineService.java b/src/main/java/com/youlai/boot/system/service/UserOnlineService.java
new file mode 100644
index 00000000..0632a76c
--- /dev/null
+++ b/src/main/java/com/youlai/boot/system/service/UserOnlineService.java
@@ -0,0 +1,157 @@
+package com.youlai.boot.system.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.youlai.boot.core.security.model.SysUserDetails;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+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 onlineUsers = new ConcurrentHashMap<>();
+
+ private SimpMessagingTemplate messagingTemplate;
+ private final ObjectMapper objectMapper;
+
+ @Autowired
+ public UserOnlineService(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ @Autowired(required = false)
+ public void setMessagingTemplate(SimpMessagingTemplate messagingTemplate) {
+ this.messagingTemplate = messagingTemplate;
+ }
+
+ /**
+ * 用户上线
+ *
+ * @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 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() {
+ if (messagingTemplate == null) {
+ log.warn("消息模板尚未初始化,无法发送在线用户变更通知");
+ return;
+ }
+
+ try {
+ OnlineUsersChangeEvent event = new OnlineUsersChangeEvent();
+ event.setType("ONLINE_USERS_CHANGE");
+ event.setCount(onlineUsers.size());
+ event.setUsers(getOnlineUsers());
+ event.setTimestamp(System.currentTimeMillis());
+
+ String message = objectMapper.writeValueAsString(event);
+ messagingTemplate.convertAndSend("/topic/online-users", message);
+ } catch (JsonProcessingException e) {
+ log.error("Failed to send online users change event", 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 users;
+ private long timestamp;
+ }
+}
diff --git a/src/main/java/com/youlai/boot/system/service/WebSocketService.java b/src/main/java/com/youlai/boot/system/service/WebSocketService.java
new file mode 100644
index 00000000..487412a8
--- /dev/null
+++ b/src/main/java/com/youlai/boot/system/service/WebSocketService.java
@@ -0,0 +1,46 @@
+package com.youlai.boot.system.service;
+
+/**
+ * WebSocket服务接口
+ *
+ * 提供与WebSocket连接管理相关的功能,包括:
+ * - 用户连接/断开事件处理
+ * - 字典数据变更通知
+ * - 系统消息推送
+ *
+ *
+ * @author Ray.Hao
+ * @since 3.0.0
+ */
+public interface WebSocketService {
+
+ /**
+ * 处理用户连接事件
+ *
+ * @param username 用户名
+ * @param sessionId WebSocket会话ID
+ */
+ void userConnected(String username, String sessionId);
+
+ /**
+ * 处理用户断开连接事件
+ *
+ * @param username 用户名
+ */
+ void userDisconnected(String username);
+
+ /**
+ * 广播字典数据变更通知
+ *
+ * @param dictCode 字典编码
+ */
+ void broadcastDictChange(String dictCode);
+
+ /**
+ * 发送系统通知给特定用户
+ *
+ * @param username 目标用户名
+ * @param message 通知消息内容
+ */
+ void sendNotification(String username, Object message);
+}
diff --git a/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java
index 620a53cf..7c1d8058 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java
@@ -3,7 +3,7 @@ package com.youlai.boot.system.service.impl;
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.system.converter.DictDataConverter;
+import com.youlai.boot.system.converter.DictItemConverter;
import com.youlai.boot.system.mapper.DictItemMapper;
import com.youlai.boot.system.model.entity.DictItem;
import com.youlai.boot.system.model.form.DictItemForm;
@@ -27,7 +27,7 @@ import java.util.List;
@RequiredArgsConstructor
public class DictItemServiceImpl extends ServiceImpl implements DictItemService {
- private final DictDataConverter dictDataConverter;
+ private final DictItemConverter dictItemConverter;
/**
* 获取字典项分页列表
@@ -78,7 +78,7 @@ public class DictItemServiceImpl extends ServiceImpl i
@Override
public DictItemForm getDictItemForm( Long itemId) {
DictItem entity = this.getById(itemId);
- return dictDataConverter.toForm(entity);
+ return dictItemConverter.toForm(entity);
}
/**
@@ -89,7 +89,7 @@ public class DictItemServiceImpl extends ServiceImpl i
*/
@Override
public boolean saveDictItem(DictItemForm formData) {
- DictItem entity = dictDataConverter.toEntity(formData);
+ DictItem entity = dictItemConverter.toEntity(formData);
return this.save(entity);
}
@@ -101,7 +101,7 @@ public class DictItemServiceImpl extends ServiceImpl i
*/
@Override
public boolean updateDictItem(DictItemForm formData) {
- DictItem entity = dictDataConverter.toEntity(formData);
+ DictItem entity = dictItemConverter.toEntity(formData);
return this.updateById(entity);
}
@@ -112,7 +112,9 @@ public class DictItemServiceImpl extends ServiceImpl i
*/
@Override
public void deleteDictItemByIds(String ids) {
- List idList = Arrays.stream(ids.split(",")).map(Long::parseLong).toList();
+ List idList = Arrays.stream(ids.split(","))
+ .map(Long::parseLong)
+ .toList();
this.removeByIds(idList);
}
diff --git a/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java
index 8bd1d11f..256f76bb 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java
@@ -12,7 +12,6 @@ import com.youlai.boot.system.model.entity.Dict;
import com.youlai.boot.system.model.entity.DictItem;
import com.youlai.boot.system.model.form.DictForm;
import com.youlai.boot.system.model.query.DictPageQuery;
-import com.youlai.boot.system.model.vo.DictItemOptionVO;
import com.youlai.boot.system.model.vo.DictPageVO;
import com.youlai.boot.system.service.DictItemService;
import com.youlai.boot.system.service.DictService;
@@ -23,7 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
- * 数据字典业务实现类
+ * 字典业务实现类
*
* @author haoxr
* @since 2022/10/12
@@ -110,20 +109,23 @@ public class DictServiceImpl extends ServiceImpl implements Di
*/
@Override
public boolean updateDict(Long id, DictForm dictForm) {
- // 更新字典
- Dict entity = dictConverter.toEntity(dictForm);
-
- // 校验 code 是否唯一
- String dictCode = entity.getDictCode();
- long count = this.count(new LambdaQueryWrapper()
- .eq(Dict::getDictCode, dictCode)
- .ne(Dict::getId, id)
- );
- if (count > 0) {
- throw new BusinessException("字典编码已存在");
+ // 获取字典
+ Dict entity = this.getById(id);
+ if (entity == null) {
+ throw new BusinessException("字典不存在");
}
-
- return this.updateById(entity);
+ // 校验 code 是否唯一
+ String dictCode = dictForm.getDictCode();
+ if (!entity.getDictCode().equals(dictCode)) {
+ long count = this.count(new LambdaQueryWrapper()
+ .eq(Dict::getDictCode, dictCode)
+ );
+ Assert.isTrue(count == 0, "字典编码已存在");
+ }
+ // 更新字典
+ Dict dict = dictConverter.toEntity(dictForm);
+ dict.setId(id);
+ return this.updateById(dict);
}
/**
@@ -131,25 +133,34 @@ public class DictServiceImpl extends ServiceImpl implements Di
*
* @param ids 字典ID,多个以英文逗号(,)分割
*/
- @Override
@Transactional
+ @Override
public void deleteDictByIds(List ids) {
- for (String id : ids) {
- Dict dict = this.getById(id);
- if (dict != null) {
- boolean removeResult = this.removeById(id);
- // 删除字典下的字典项
- if (removeResult) {
- dictItemService.remove(
- new LambdaQueryWrapper()
- .eq(DictItem::getDictCode, dict.getDictCode())
- );
- }
+ // 删除字典
+ this.removeByIds(ids);
- }
+ // 删除字典项
+ List list = this.listByIds(ids);
+ if (!list.isEmpty()) {
+ List dictCodes = list.stream().map(Dict::getDictCode).toList();
+ dictItemService.remove(new LambdaQueryWrapper()
+ .in(DictItem::getDictCode, dictCodes)
+ );
}
}
+ /**
+ * 根据字典ID列表获取字典编码列表
+ *
+ * @param ids 字典ID列表
+ * @return 字典编码列表
+ */
+ @Override
+ public List getDictCodesByIds(List ids) {
+ List dictList = this.listByIds(ids);
+ return dictList.stream().map(Dict::getDictCode).toList();
+ }
+
}
diff --git a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java
index a05f3051..e78c876e 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java
@@ -9,7 +9,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.core.security.util.SecurityUtils;
-import com.youlai.boot.shared.websocket.service.OnlineUserService;
import com.youlai.boot.system.converter.NoticeConverter;
import com.youlai.boot.system.enums.NoticePublishStatusEnum;
import com.youlai.boot.system.enums.NoticeTargetEnum;
@@ -26,6 +25,7 @@ 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 lombok.RequiredArgsConstructor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
@@ -53,7 +53,7 @@ public class NoticeServiceImpl extends ServiceImpl impleme
private final UserNoticeService userNoticeService;
private final UserService userService;
private final SimpMessagingTemplate messagingTemplate;
- private final OnlineUserService onlineUserService;
+ private final UserOnlineService userOnlineService;
/**
* 获取通知公告分页列表
@@ -213,7 +213,9 @@ public class NoticeServiceImpl extends ServiceImpl impleme
Set receivers = targetUserList.stream().map(User::getUsername).collect(Collectors.toSet());
- Set allOnlineUsers = onlineUserService.getAllOnlineUsers();
+ Set allOnlineUsers = userOnlineService.getOnlineUsers().stream()
+ .map(UserOnlineService.UserOnlineDTO::getUsername)
+ .collect(Collectors.toSet());
// 找出在线用户的通知接收者
Set onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers));
diff --git a/src/main/java/com/youlai/boot/system/service/impl/WebSocketServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/WebSocketServiceImpl.java
new file mode 100644
index 00000000..db714dab
--- /dev/null
+++ b/src/main/java/com/youlai/boot/system/service/impl/WebSocketServiceImpl.java
@@ -0,0 +1,269 @@
+package com.youlai.boot.system.service.impl;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.youlai.boot.system.model.event.DictEvent;
+import com.youlai.boot.system.service.WebSocketService;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+/**
+ * WebSocket服务实现类
+ * 统一处理WebSocket消息发送和用户在线状态管理
+ *
+ * @author Ray.Hao
+ * @since 3.0.0
+ */
+@Service
+@Slf4j
+public class WebSocketServiceImpl implements WebSocketService {
+
+ // 在线用户映射表,key为用户名,value为用户在线信息
+ private final Map onlineUsers = new ConcurrentHashMap<>();
+
+ private SimpMessagingTemplate messagingTemplate;
+ private final ObjectMapper objectMapper;
+
+ @Autowired
+ public WebSocketServiceImpl(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ @Autowired(required = false)
+ public void setMessagingTemplate(SimpMessagingTemplate messagingTemplate) {
+ this.messagingTemplate = messagingTemplate;
+ log.info("WebSocket消息模板已初始化");
+ }
+
+ //==================================
+ // 用户在线状态管理功能
+ //==================================
+
+ /**
+ * 用户上线
+ *
+ * @param username 用户名
+ * @param sessionId WebSocket会话ID(可选)
+ */
+ @Override
+ 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());
+
+ // 通知在线用户状态变更
+ notifyOnlineUsersChangeInternal();
+ }
+
+ /**
+ * 用户下线
+ *
+ * @param username 用户名
+ */
+ @Override
+ public void userDisconnected(String username) {
+ onlineUsers.remove(username);
+ log.info("用户[{}]下线,当前在线用户数:{}", username, onlineUsers.size());
+
+ // 通知在线用户状态变更
+ notifyOnlineUsersChangeInternal();
+ }
+
+ /**
+ * 获取在线用户列表
+ *
+ * @return 在线用户名列表
+ */
+ public List 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);
+ }
+
+ /**
+ * 手动触发在线用户变更通知
+ * 供外部手动触发通知使用
+ */
+ public void notifyOnlineUsersChange() {
+ log.info("手动触发在线用户变更通知,当前在线用户数:{}", onlineUsers.size());
+ notifyOnlineUsersChangeInternal();
+ }
+
+ /**
+ * 内部通用通知方法
+ * 通知所有客户端在线用户变更
+ */
+ private void notifyOnlineUsersChangeInternal() {
+ if (messagingTemplate == null) {
+ log.warn("消息模板尚未初始化,无法发送在线用户变更通知");
+ return;
+ }
+
+ try {
+ OnlineUsersChangeEvent event = new OnlineUsersChangeEvent();
+ event.setType("ONLINE_USERS_CHANGE");
+ event.setCount(onlineUsers.size());
+ event.setUsers(getOnlineUsers());
+ event.setTimestamp(System.currentTimeMillis());
+
+ String message = objectMapper.writeValueAsString(event);
+ messagingTemplate.convertAndSend("/topic/online-users", message);
+ log.debug("已发送在线用户变更通知");
+ } catch (JsonProcessingException 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 users;
+ private long timestamp;
+ }
+
+ //==================================
+ // WebSocket消息发送功能
+ //==================================
+
+ /**
+ * 向所有客户端发送字典更新事件
+ *
+ * @param dictCode 字典编码
+ */
+ @Override
+ public void broadcastDictChange(String dictCode) {
+ DictEvent event = new DictEvent(dictCode);
+ sendDictEvent(event);
+ }
+
+ /**
+ * 发送字典事件消息
+ *
+ * @param event 字典事件
+ */
+ private void sendDictEvent(DictEvent event) {
+ if (messagingTemplate == null) {
+ log.warn("消息模板尚未初始化,无法发送字典更新通知");
+ return;
+ }
+
+ try {
+ String message = objectMapper.writeValueAsString(event);
+ messagingTemplate.convertAndSend("/topic/dict", message);
+ log.info("已发送字典事件通知, dictCode: {}", event.getDictCode());
+ } catch (JsonProcessingException e) {
+ log.error("发送字典事件失败", e);
+ }
+ }
+
+ /**
+ * 向特定用户发送系统消息
+ *
+ * @param username 用户名
+ * @param message 消息内容
+ */
+ @Override
+ public void sendNotification(String username, Object message) {
+ if (messagingTemplate == null) {
+ log.warn("消息模板尚未初始化,无法发送用户消息");
+ return;
+ }
+
+ try {
+ String messageJson = objectMapper.writeValueAsString(message);
+ messagingTemplate.convertAndSendToUser(username, "/queue/messages", messageJson);
+ log.info("已向用户[{}]发送消息", username);
+ } catch (JsonProcessingException e) {
+ log.error("向用户[{}]发送消息失败", username, e);
+ }
+ }
+
+ /**
+ * 发送广播消息给所有用户
+ *
+ * @param message 消息内容
+ */
+ public void broadcastMessage(String message) {
+ if (messagingTemplate == null) {
+ log.warn("消息模板尚未初始化,无法发送广播消息");
+ return;
+ }
+
+ try {
+ SystemMessage systemMessage = new SystemMessage("系统", message, System.currentTimeMillis());
+ String messageJson = objectMapper.writeValueAsString(systemMessage);
+ messagingTemplate.convertAndSend("/topic/public", messageJson);
+ log.info("已发送广播消息: {}", message);
+ } catch (JsonProcessingException e) {
+ log.error("发送广播消息失败", e);
+ }
+ }
+
+ /**
+ * 系统消息对象
+ */
+ @Data
+ public static class SystemMessage {
+ private String sender;
+ private String content;
+ private long timestamp;
+
+ public SystemMessage(String sender, String content, long timestamp) {
+ this.sender = sender;
+ this.content = content;
+ this.timestamp = timestamp;
+ }
+ }
+}
diff --git a/src/main/resources/mapper/system/RoleMenuMapper.xml b/src/main/resources/mapper/system/RoleMenuMapper.xml
index c6654581..11fc527d 100644
--- a/src/main/resources/mapper/system/RoleMenuMapper.xml
+++ b/src/main/resources/mapper/system/RoleMenuMapper.xml
@@ -33,7 +33,7 @@
INNER JOIN sys_role t2 ON t1.role_id = t2.id AND t2.is_deleted = 0 AND t2.`status` = 1
INNER JOIN sys_menu t3 ON t1.menu_id = t3.id
WHERE
- type = '${@com.youlai.boot.system.enums.MenuTypeEnum@BUTTON.getValue()}'
+ t3.type = '${@com.youlai.boot.system.enums.MenuTypeEnum@BUTTON.getValue()}'
AND t2.`code` = #{roleCode}
diff --git a/src/main/resources/templates/codegen/index.vue.vm b/src/main/resources/templates/codegen/index.vue.vm
index b7c4021a..67ce5bd2 100644
--- a/src/main/resources/templates/codegen/index.vue.vm
+++ b/src/main/resources/templates/codegen/index.vue.vm
@@ -26,8 +26,8 @@
#else
- 选项一
- 选项二
+ 选项一
+ 选项二
#end
#elseif($fieldConfig.formType == "CHECK_BOX")