feat: 字典实时同步和 websocket 重构优化

This commit is contained in:
Ray.Hao
2025-04-22 20:49:49 +08:00
parent f06fe3ee01
commit 5aff74d36f
21 changed files with 770 additions and 329 deletions

View File

@@ -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 (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 (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 (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 (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 (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); 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 (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 (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 (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 -- 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, 145);
INSERT INTO `sys_role_menu` VALUES (2, 146); INSERT INTO `sys_role_menu` VALUES (2, 146);
INSERT INTO `sys_role_menu` VALUES (2, 147); INSERT INTO `sys_role_menu` VALUES (2, 147);
INSERT INTO `sys_role_menu` VALUES (2, 148);
-- ---------------------------- -- ----------------------------
-- Table structure for sys_user -- Table structure for sys_user

View File

@@ -3,11 +3,11 @@ package com.youlai.boot.config;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.youlai.boot.core.security.model.SysUserDetails; import com.youlai.boot.core.security.model.SysUserDetails;
import com.youlai.boot.core.security.token.TokenManager; 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 lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.messaging.Message; import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel; import org.springframework.messaging.MessageChannel;
@@ -30,21 +30,19 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
* WebSocket配置 * WebSocket配置
* *
* @author Ray.Hao * @author Ray.Hao
* @since 2.4.0 * @since 3.0.0
*/ */
// 启用WebSocket消息代理功能和配置STOMP协议实现实时双向通信和消息传递
@EnableWebSocketMessageBroker @EnableWebSocketMessageBroker
@Configuration @Configuration
@Slf4j @Slf4j
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final ApplicationEventPublisher eventPublisher;
private final TokenManager tokenManager; private final TokenManager tokenManager;
private final WebSocketService webSocketService;
public WebSocketConfig(ApplicationEventPublisher eventPublisher, TokenManager tokenManager) { public WebSocketConfig(TokenManager tokenManager, @Lazy WebSocketService webSocketService) {
this.eventPublisher = eventPublisher;
this.tokenManager = tokenManager; this.tokenManager = tokenManager;
this.webSocketService = webSocketService;
} }
/** /**
@@ -135,8 +133,8 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
// 绑定用户身份到当前会话(重要:用于@SendToUser等注解 // 绑定用户身份到当前会话(重要:用于@SendToUser等注解
accessor.setUser(authentication); accessor.setUser(authentication);
// 发布用户上线事件(示例:可用于更新在线用户列表) // 记录用户上线状态
eventPublisher.publishEvent(new UserConnectionEvent(this, username, true)); webSocketService.userConnected(username, accessor.getSessionId());
} }
// 处理客户端断开请求 // 处理客户端断开请求
@@ -149,7 +147,9 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
if (authentication != null && authentication.isAuthenticated()) { if (authentication != null && authentication.isAuthenticated()) {
String username = ((SysUserDetails) authentication.getPrincipal()).getUsername(); String username = ((SysUserDetails) authentication.getPrincipal()).getUsername();
log.info("WebSocket连接关闭用户[{}]", username); log.info("WebSocket连接关闭用户[{}]", username);
eventPublisher.publishEvent(new UserConnectionEvent(this, username, false));
// 记录用户下线状态
webSocketService.userDisconnected(username);
} }
} }
} catch (AuthenticationException ex) { } catch (AuthenticationException ex) {
@@ -166,5 +166,4 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
} }
}); });
} }
} }

View File

@@ -5,7 +5,7 @@ import org.springframework.web.bind.annotation.*;
/** /**
* 邮件控制层 * 邮件控制层
* *
* @author Ray * @author Ray.Hao
* @since 2.10.0 * @since 2.10.0
*/ */
@RestController @RestController

View File

@@ -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());
}
}

View File

@@ -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<String> 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<String> getAllOnlineUsers() {
return Collections.unmodifiableSet(onlineUsers);
}
/**
* 获取在线的接收者
* 从所有接收者中过滤出在线的接收者
*
* @param receivers 接收者
* @return 在线的接收者集合
*/
public Set<String> getOnlineReceivers(Set<String> receivers) {
return receivers.stream().filter(onlineUsers::contains).collect(Collectors.toSet());
}
/**
* 获取在线用户数量
*
* @return 在线用户数量
*/
public int getOnlineUserCount() {
return onlineUsers.size();
}
}

View File

@@ -16,6 +16,7 @@ import com.youlai.boot.system.model.form.DictForm;
import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.system.service.DictItemService; import com.youlai.boot.system.service.DictItemService;
import com.youlai.boot.system.service.DictService; 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.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -42,7 +43,7 @@ public class DictController {
private final DictService dictService; private final DictService dictService;
private final DictItemService dictItemService; private final DictItemService dictItemService;
private final WebSocketService webSocketService;
//--------------------------------------------------- //---------------------------------------------------
// 字典相关接口 // 字典相关接口
@@ -80,6 +81,10 @@ public class DictController {
@RepeatSubmit @RepeatSubmit
public Result<?> saveDict(@Valid @RequestBody DictForm formData) { public Result<?> saveDict(@Valid @RequestBody DictForm formData) {
boolean result = dictService.saveDict(formData); boolean result = dictService.saveDict(formData);
// 发送字典更新通知
if (result) {
webSocketService.broadcastDictChange(formData.getDictCode());
}
return Result.judge(result); return Result.judge(result);
} }
@@ -88,9 +93,13 @@ public class DictController {
@PreAuthorize("@ss.hasPerm('sys:dict:edit')") @PreAuthorize("@ss.hasPerm('sys:dict:edit')")
public Result<?> updateDict( public Result<?> updateDict(
@PathVariable Long id, @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); return Result.judge(status);
} }
@@ -100,7 +109,16 @@ public class DictController {
public Result<?> deleteDictionaries( public Result<?> deleteDictionaries(
@Parameter(description = "字典ID多个以英文逗号(,)拼接") @PathVariable String ids @Parameter(description = "字典ID多个以英文逗号(,)拼接") @PathVariable String ids
) { ) {
// 获取字典编码列表,用于发送删除通知
List<String> dictCodes = dictService.getDictCodesByIds(Arrays.stream(ids.split(",")).toList());
dictService.deleteDictByIds(Arrays.stream(ids.split(",")).toList()); dictService.deleteDictByIds(Arrays.stream(ids.split(",")).toList());
// 发送字典删除通知
for (String dictCode : dictCodes) {
webSocketService.broadcastDictChange(dictCode);
}
return Result.success(); return Result.success();
} }
@@ -138,6 +156,12 @@ public class DictController {
) { ) {
formData.setDictCode(dictCode); formData.setDictCode(dictCode);
boolean result = dictItemService.saveDictItem(formData); boolean result = dictItemService.saveDictItem(formData);
// 发送字典更新通知
if (result) {
webSocketService.broadcastDictChange(dictCode);
}
return Result.judge(result); return Result.judge(result);
} }
@@ -163,6 +187,12 @@ public class DictController {
formData.setId(itemId); formData.setId(itemId);
formData.setDictCode(dictCode); formData.setDictCode(dictCode);
boolean status = dictItemService.updateDictItem(formData); boolean status = dictItemService.updateDictItem(formData);
// 发送字典更新通知
if (status) {
webSocketService.broadcastDictChange(dictCode);
}
return Result.judge(status); return Result.judge(status);
} }
@@ -170,9 +200,14 @@ public class DictController {
@DeleteMapping("/{dictCode}/items/{itemIds}") @DeleteMapping("/{dictCode}/items/{itemIds}")
@PreAuthorize("@ss.hasPerm('sys:dict-item:delete')") @PreAuthorize("@ss.hasPerm('sys:dict-item:delete')")
public Result<Void> deleteDictItems( public Result<Void> deleteDictItems(
@PathVariable String dictCode,
@Parameter(description = "字典ID多个以英文逗号(,)拼接") @PathVariable String itemIds @Parameter(description = "字典ID多个以英文逗号(,)拼接") @PathVariable String itemIds
) { ) {
dictItemService.deleteDictItemByIds(itemIds); dictItemService.deleteDictItemByIds(itemIds);
// 发送字典更新通知
webSocketService.broadcastDictChange(dictCode);
return Result.success(); return Result.success();
} }

View File

@@ -12,11 +12,11 @@ import java.util.List;
/** /**
* 字典项对象转换器 * 字典项对象转换器
* *
* @author Ray * @author Ray.Hao
* @since 2022/6/8 * @since 2022/6/8
*/ */
@Mapper(componentModel = "spring") @Mapper(componentModel = "spring")
public interface DictDataConverter { public interface DictItemConverter {
Page<DictPageVO> toPageVo(Page<DictItem> page); Page<DictPageVO> toPageVo(Page<DictItem> page);

View File

@@ -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;
}
}

View File

@@ -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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
@@ -19,7 +19,7 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor @RequiredArgsConstructor
public class OnlineUserJobHandler { public class OnlineUserJobHandler {
private final OnlineUserService onlineUserService; private final UserOnlineService userOnlineService;
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
// 每分钟统计一次在线用户数 // 每分钟统计一次在线用户数
@@ -27,7 +27,7 @@ public class OnlineUserJobHandler {
public void execute() { public void execute() {
log.info("定时任务:统计在线用户数"); log.info("定时任务:统计在线用户数");
// 推送在线用户人数 // 推送在线用户人数
messagingTemplate.convertAndSend("/topic/onlineUserCount", onlineUserService.getOnlineUserCount()); messagingTemplate.convertAndSend("/topic/onlineUserCount", userOnlineService.getOnlineUserCount());
} }
} }

View File

@@ -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<String> sessionIds;
/**
* 最后活动时间
*/
private long lastActiveTime;
public UserSessionDTO(String username) {
this.username = username;
this.sessionIds = new HashSet<>();
this.lastActiveTime = System.currentTimeMillis();
}
}

View File

@@ -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();
}
}

View File

@@ -2,6 +2,7 @@ package com.youlai.boot.system.model.form;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@@ -26,6 +27,7 @@ public class DictForm {
private String name; private String name;
@Schema(description = "字典编码", example ="gender") @Schema(description = "字典编码", example ="gender")
@NotBlank(message = "字典编码不能为空")
private String dictCode; private String dictCode;
@Schema(description = "备注") @Schema(description = "备注")

View File

@@ -66,6 +66,11 @@ public interface DictService extends IService<Dict> {
*/ */
void deleteDictByIds(List<String> ids); void deleteDictByIds(List<String> ids);
/**
* 根据字典ID列表获取字典编码列表
*
* @param ids 字典ID列表
* @return 字典编码列表
*/
List<String> getDictCodesByIds(List<String> ids);
} }

View File

@@ -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<String, UserOnlineInfo> 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<UserOnlineDTO> 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<UserOnlineDTO> users;
private long timestamp;
}
}

View File

@@ -0,0 +1,46 @@
package com.youlai.boot.system.service;
/**
* WebSocket服务接口
* <p>
* 提供与WebSocket连接管理相关的功能包括
* - 用户连接/断开事件处理
* - 字典数据变更通知
* - 系统消息推送
* </p>
*
* @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);
}

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; 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.mapper.DictItemMapper;
import com.youlai.boot.system.model.entity.DictItem; import com.youlai.boot.system.model.entity.DictItem;
import com.youlai.boot.system.model.form.DictItemForm; import com.youlai.boot.system.model.form.DictItemForm;
@@ -27,7 +27,7 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> implements DictItemService { public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> implements DictItemService {
private final DictDataConverter dictDataConverter; private final DictItemConverter dictItemConverter;
/** /**
* 获取字典项分页列表 * 获取字典项分页列表
@@ -78,7 +78,7 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
@Override @Override
public DictItemForm getDictItemForm( Long itemId) { public DictItemForm getDictItemForm( Long itemId) {
DictItem entity = this.getById(itemId); DictItem entity = this.getById(itemId);
return dictDataConverter.toForm(entity); return dictItemConverter.toForm(entity);
} }
/** /**
@@ -89,7 +89,7 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
*/ */
@Override @Override
public boolean saveDictItem(DictItemForm formData) { public boolean saveDictItem(DictItemForm formData) {
DictItem entity = dictDataConverter.toEntity(formData); DictItem entity = dictItemConverter.toEntity(formData);
return this.save(entity); return this.save(entity);
} }
@@ -101,7 +101,7 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
*/ */
@Override @Override
public boolean updateDictItem(DictItemForm formData) { public boolean updateDictItem(DictItemForm formData) {
DictItem entity = dictDataConverter.toEntity(formData); DictItem entity = dictItemConverter.toEntity(formData);
return this.updateById(entity); return this.updateById(entity);
} }
@@ -112,7 +112,9 @@ public class DictItemServiceImpl extends ServiceImpl<DictItemMapper, DictItem> i
*/ */
@Override @Override
public void deleteDictItemByIds(String ids) { public void deleteDictItemByIds(String ids) {
List<Long> idList = Arrays.stream(ids.split(",")).map(Long::parseLong).toList(); List<Long> idList = Arrays.stream(ids.split(","))
.map(Long::parseLong)
.toList();
this.removeByIds(idList); this.removeByIds(idList);
} }

View File

@@ -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.entity.DictItem;
import com.youlai.boot.system.model.form.DictForm; import com.youlai.boot.system.model.form.DictForm;
import com.youlai.boot.system.model.query.DictPageQuery; 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.model.vo.DictPageVO;
import com.youlai.boot.system.service.DictItemService; import com.youlai.boot.system.service.DictItemService;
import com.youlai.boot.system.service.DictService; import com.youlai.boot.system.service.DictService;
@@ -23,7 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
/** /**
* 数据字典业务实现类 * 字典业务实现类
* *
* @author haoxr * @author haoxr
* @since 2022/10/12 * @since 2022/10/12
@@ -110,20 +109,23 @@ public class DictServiceImpl extends ServiceImpl<DictMapper, Dict> implements Di
*/ */
@Override @Override
public boolean updateDict(Long id, DictForm dictForm) { public boolean updateDict(Long id, DictForm dictForm) {
// 更新字典 // 获取字典
Dict entity = dictConverter.toEntity(dictForm); Dict entity = this.getById(id);
if (entity == null) {
throw new BusinessException("字典不存在");
}
// 校验 code 是否唯一 // 校验 code 是否唯一
String dictCode = entity.getDictCode(); String dictCode = dictForm.getDictCode();
if (!entity.getDictCode().equals(dictCode)) {
long count = this.count(new LambdaQueryWrapper<Dict>() long count = this.count(new LambdaQueryWrapper<Dict>()
.eq(Dict::getDictCode, dictCode) .eq(Dict::getDictCode, dictCode)
.ne(Dict::getId, id)
); );
if (count > 0) { Assert.isTrue(count == 0, "字典编码已存在");
throw new BusinessException("字典编码已存在");
} }
// 更新字典
return this.updateById(entity); Dict dict = dictConverter.toEntity(dictForm);
dict.setId(id);
return this.updateById(dict);
} }
/** /**
@@ -131,23 +133,32 @@ public class DictServiceImpl extends ServiceImpl<DictMapper, Dict> implements Di
* *
* @param ids 字典ID多个以英文逗号(,)分割 * @param ids 字典ID多个以英文逗号(,)分割
*/ */
@Override
@Transactional @Transactional
@Override
public void deleteDictByIds(List<String> ids) { public void deleteDictByIds(List<String> ids) {
for (String id : ids) { // 删除字典
Dict dict = this.getById(id); this.removeByIds(ids);
if (dict != null) {
boolean removeResult = this.removeById(id); // 删除字典项
// 删除字典下的字典项 List<Dict> list = this.listByIds(ids);
if (removeResult) { if (!list.isEmpty()) {
dictItemService.remove( List<String> dictCodes = list.stream().map(Dict::getDictCode).toList();
new LambdaQueryWrapper<DictItem>() dictItemService.remove(new LambdaQueryWrapper<DictItem>()
.eq(DictItem::getDictCode, dict.getDictCode()) .in(DictItem::getDictCode, dictCodes)
); );
} }
}
} /**
} * 根据字典ID列表获取字典编码列表
*
* @param ids 字典ID列表
* @return 字典编码列表
*/
@Override
public List<String> getDictCodesByIds(List<String> ids) {
List<Dict> dictList = this.listByIds(ids);
return dictList.stream().map(Dict::getDictCode).toList();
} }
} }

View File

@@ -9,7 +9,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.core.security.util.SecurityUtils; 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.converter.NoticeConverter;
import com.youlai.boot.system.enums.NoticePublishStatusEnum; import com.youlai.boot.system.enums.NoticePublishStatusEnum;
import com.youlai.boot.system.enums.NoticeTargetEnum; 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.model.vo.NoticeDetailVO;
import com.youlai.boot.system.service.NoticeService; import com.youlai.boot.system.service.NoticeService;
import com.youlai.boot.system.service.UserNoticeService; import com.youlai.boot.system.service.UserNoticeService;
import com.youlai.boot.system.service.UserOnlineService;
import com.youlai.boot.system.service.UserService; import com.youlai.boot.system.service.UserService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
@@ -53,7 +53,7 @@ public class NoticeServiceImpl extends ServiceImpl<NoticeMapper, Notice> impleme
private final UserNoticeService userNoticeService; private final UserNoticeService userNoticeService;
private final UserService userService; private final UserService userService;
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
private final OnlineUserService onlineUserService; private final UserOnlineService userOnlineService;
/** /**
* 获取通知公告分页列表 * 获取通知公告分页列表
@@ -213,7 +213,9 @@ public class NoticeServiceImpl extends ServiceImpl<NoticeMapper, Notice> impleme
Set<String> receivers = targetUserList.stream().map(User::getUsername).collect(Collectors.toSet()); Set<String> receivers = targetUserList.stream().map(User::getUsername).collect(Collectors.toSet());
Set<String> allOnlineUsers = onlineUserService.getAllOnlineUsers(); Set<String> allOnlineUsers = userOnlineService.getOnlineUsers().stream()
.map(UserOnlineService.UserOnlineDTO::getUsername)
.collect(Collectors.toSet());
// 找出在线用户的通知接收者 // 找出在线用户的通知接收者
Set<String> onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers)); Set<String> onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers));

View File

@@ -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<String, UserOnlineInfo> 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<UserOnlineDTO> 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<UserOnlineDTO> 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;
}
}
}

View File

@@ -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_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 INNER JOIN sys_menu t3 ON t1.menu_id = t3.id
WHERE WHERE
type = '${@com.youlai.boot.system.enums.MenuTypeEnum@BUTTON.getValue()}' t3.type = '${@com.youlai.boot.system.enums.MenuTypeEnum@BUTTON.getValue()}'
<if test="roleCode!=null and roleCode.trim() neq ''"> <if test="roleCode!=null and roleCode.trim() neq ''">
AND t2.`code` = #{roleCode} AND t2.`code` = #{roleCode}
</if> </if>

View File

@@ -26,8 +26,8 @@
<dict v-model="queryParams.$fieldConfig.fieldName" type="radio" code="$fieldConfig.dictType" /> <dict v-model="queryParams.$fieldConfig.fieldName" type="radio" code="$fieldConfig.dictType" />
#else #else
<el-radio-group v-model="queryParams.$fieldConfig.fieldName"> <el-radio-group v-model="queryParams.$fieldConfig.fieldName">
<el-radio :key="1" :label="1">选项一</el-radio> <el-radio :key="1" :value="1">选项一</el-radio>
<el-radio :key="2" :label="2">选项二</el-radio> <el-radio :key="2" :value="2">选项二</el-radio>
</el-radio-group> </el-radio-group>
#end #end
#elseif($fieldConfig.formType == "CHECK_BOX") #elseif($fieldConfig.formType == "CHECK_BOX")