From 13f0a035c788429a451faea06bb95a347553d27e Mon Sep 17 00:00:00 2001
From: cuitw <1051735452@qq.com>
Date: Mon, 2 Mar 2026 10:42:14 +0800
Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E8=81=94=E7=BD=91=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ruoyi-admin/pom.xml | 6 +
.../web/controller/RouteWaypointsController.java | 14 +-
.../ruoyi/websocket/config/LoginUserPrincipal.java | 27 ++++
.../config/WebSocketChannelInterceptor.java | 37 +++++
.../ruoyi/websocket/config/WebSocketConfig.java | 47 ++++++
.../config/WebSocketHandshakeHandler.java | 80 ++++++++++
.../controller/RoomWebSocketController.java | 146 ++++++++++++++++++
.../com/ruoyi/websocket/dto/RoomMemberDTO.java | 93 ++++++++++++
.../com/ruoyi/websocket/dto/RoomSessionInfo.java | 33 ++++
.../listener/WebSocketDisconnectListener.java | 46 ++++++
.../websocket/service/RoomWebSocketService.java | 158 +++++++++++++++++++
.../com/ruoyi/framework/config/SecurityConfig.java | 2 +
ruoyi-ui/package.json | 2 +
ruoyi-ui/src/api/system/routes.js | 5 +-
ruoyi-ui/src/api/system/waypoints.js | 5 +-
ruoyi-ui/src/utils/websocket.js | 167 +++++++++++++++++++++
ruoyi-ui/src/views/childRoom/FourTPanel.vue | 10 +-
ruoyi-ui/src/views/childRoom/index.vue | 73 ++++++++-
ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue | 17 ++-
ruoyi-ui/vue.config.js | 4 +-
20 files changed, 946 insertions(+), 26 deletions(-)
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/config/LoginUserPrincipal.java
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketChannelInterceptor.java
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketConfig.java
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketHandshakeHandler.java
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomSessionInfo.java
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java
create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomWebSocketService.java
create mode 100644 ruoyi-ui/src/utils/websocket.js
diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml
index a4ff47b..d066b6b 100644
--- a/ruoyi-admin/pom.xml
+++ b/ruoyi-admin/pom.xml
@@ -50,6 +50,12 @@
3.42.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
+
com.ruoyi
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java
index cf9be8f..875bd0f 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java
@@ -37,7 +37,7 @@ public class RouteWaypointsController extends BaseController
/**
* 查询航线具体航点明细列表
*/
- @PreAuthorize("@ss.hasPermi('system:waypoints:list')")
+ @PreAuthorize("@ss.hasPermi('system:routes:list')")
@GetMapping("/list")
public TableDataInfo list(RouteWaypoints routeWaypoints)
{
@@ -49,7 +49,7 @@ public class RouteWaypointsController extends BaseController
/**
* 导出航线具体航点明细列表
*/
- @PreAuthorize("@ss.hasPermi('system:waypoints:export')")
+ @PreAuthorize("@ss.hasPermi('system:routes:export')")
@Log(title = "航线具体航点明细", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, RouteWaypoints routeWaypoints)
@@ -62,7 +62,7 @@ public class RouteWaypointsController extends BaseController
/**
* 获取航线具体航点明细详细信息
*/
- @PreAuthorize("@ss.hasPermi('system:waypoints:query')")
+ @PreAuthorize("@ss.hasPermi('system:routes:query')")
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
@@ -72,7 +72,7 @@ public class RouteWaypointsController extends BaseController
/**
* 新增航线具体航点明细
*/
- @PreAuthorize("@ss.hasPermi('system:waypoints:add')")
+ @PreAuthorize("@ss.hasPermi('system:routes:add')")
@Log(title = "航线具体航点明细", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody RouteWaypoints routeWaypoints)
@@ -81,9 +81,9 @@ public class RouteWaypointsController extends BaseController
}
/**
- * 修改航线具体航点明细
+ * 修改航线具体航点明细(复用航线编辑权限,航点属于航线的一部分)
*/
- @PreAuthorize("@ss.hasPermi('system:waypoints:edit')")
+ @PreAuthorize("@ss.hasPermi('system:routes:edit')")
@Log(title = "航线具体航点明细", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody RouteWaypoints routeWaypoints)
@@ -94,7 +94,7 @@ public class RouteWaypointsController extends BaseController
/**
* 删除航线具体航点明细
*/
- @PreAuthorize("@ss.hasPermi('system:waypoints:remove')")
+ @PreAuthorize("@ss.hasPermi('system:routes:remove')")
@Log(title = "航线具体航点明细", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/LoginUserPrincipal.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/LoginUserPrincipal.java
new file mode 100644
index 0000000..f575eca
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/LoginUserPrincipal.java
@@ -0,0 +1,27 @@
+package com.ruoyi.websocket.config;
+
+import java.security.Principal;
+import com.ruoyi.common.core.domain.model.LoginUser;
+
+/**
+ * 将 LoginUser 包装为 Principal,供 WebSocket 使用
+ *
+ * @author ruoyi
+ */
+public class LoginUserPrincipal implements Principal {
+
+ private final LoginUser loginUser;
+
+ public LoginUserPrincipal(LoginUser loginUser) {
+ this.loginUser = loginUser;
+ }
+
+ @Override
+ public String getName() {
+ return loginUser != null ? loginUser.getUsername() : null;
+ }
+
+ public LoginUser getLoginUser() {
+ return loginUser;
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketChannelInterceptor.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketChannelInterceptor.java
new file mode 100644
index 0000000..e0b1307
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketChannelInterceptor.java
@@ -0,0 +1,37 @@
+package com.ruoyi.websocket.config;
+
+import java.util.Map;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.MessageChannel;
+import org.springframework.messaging.simp.stomp.StompCommand;
+import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
+import org.springframework.messaging.support.ChannelInterceptor;
+import org.springframework.messaging.support.MessageHeaderAccessor;
+import org.springframework.stereotype.Component;
+import com.ruoyi.common.core.domain.model.LoginUser;
+import com.ruoyi.websocket.config.LoginUserPrincipal;
+
+/**
+ * WebSocket 通道拦截器:将握手时的 loginUser 注入到消息头
+ *
+ * @author ruoyi
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 99)
+public class WebSocketChannelInterceptor implements ChannelInterceptor {
+
+ @Override
+ public Message> preSend(Message> message, MessageChannel channel) {
+ StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
+ if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
+ Map sessionAttrs = accessor.getSessionAttributes();
+ if (sessionAttrs != null && sessionAttrs.containsKey("loginUser")) {
+ LoginUser loginUser = (LoginUser) sessionAttrs.get("loginUser");
+ accessor.setUser(new LoginUserPrincipal(loginUser));
+ }
+ }
+ return message;
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketConfig.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketConfig.java
new file mode 100644
index 0000000..376f9a2
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketConfig.java
@@ -0,0 +1,47 @@
+package com.ruoyi.websocket.config;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.messaging.simp.config.ChannelRegistration;
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
+import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
+import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
+import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
+
+/**
+ * WebSocket STOMP 配置
+ *
+ * @author ruoyi
+ */
+@Configuration
+@EnableWebSocketMessageBroker
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+ @Autowired
+ private WebSocketHandshakeHandler webSocketHandshakeHandler;
+
+ @Autowired
+ private WebSocketChannelInterceptor webSocketChannelInterceptor;
+
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry config) {
+ config.enableSimpleBroker("/topic", "/queue");
+ config.setApplicationDestinationPrefixes("/app");
+ config.setUserDestinationPrefix("/user");
+ }
+
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ registry.addEndpoint("/ws")
+ .setAllowedOriginPatterns("*")
+ .addInterceptors(webSocketHandshakeHandler)
+ .withSockJS();
+ }
+
+ @Override
+ public void configureClientInboundChannel(ChannelRegistration registration) {
+ registration.interceptors(webSocketChannelInterceptor);
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketHandshakeHandler.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketHandshakeHandler.java
new file mode 100644
index 0000000..4be2730
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketHandshakeHandler.java
@@ -0,0 +1,80 @@
+package com.ruoyi.websocket.config;
+
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Map;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.http.server.ServletServerHttpRequest;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.WebSocketHandler;
+import org.springframework.web.socket.server.HandshakeInterceptor;
+import com.ruoyi.common.constant.Constants;
+import com.ruoyi.common.core.domain.model.LoginUser;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.framework.web.service.TokenService;
+
+/**
+ * WebSocket 握手拦截器:校验 JWT Token(支持 query 参数 token 或 Authorization header)
+ *
+ * @author ruoyi
+ */
+@Component
+public class WebSocketHandshakeHandler implements HandshakeInterceptor {
+
+ private final TokenService tokenService;
+
+ public WebSocketHandshakeHandler(TokenService tokenService) {
+ this.tokenService = tokenService;
+ }
+
+ @Override
+ public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
+ WebSocketHandler wsHandler, Map attributes) throws Exception {
+ if (!(request instanceof ServletServerHttpRequest)) {
+ return false;
+ }
+ ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
+ HttpServletRequest req = servletRequest.getServletRequest();
+ String token = req.getParameter("token");
+ if (StringUtils.isEmpty(token)) {
+ String auth = req.getHeader("Authorization");
+ if (StringUtils.isNotEmpty(auth) && auth.startsWith(Constants.TOKEN_PREFIX)) {
+ token = auth.substring(Constants.TOKEN_PREFIX.length()).trim();
+ }
+ }
+ if (StringUtils.isEmpty(token)) {
+ return false;
+ }
+ final String tokenFinal = token;
+ HttpServletRequest wrappedReq = new HttpServletRequestWrapper(req) {
+ @Override
+ public String getHeader(String name) {
+ if ("Authorization".equalsIgnoreCase(name)) {
+ return Constants.TOKEN_PREFIX + tokenFinal;
+ }
+ return super.getHeader(name);
+ }
+ @Override
+ public Enumeration getHeaders(String name) {
+ if ("Authorization".equalsIgnoreCase(name)) {
+ return Collections.enumeration(Collections.singletonList(Constants.TOKEN_PREFIX + tokenFinal));
+ }
+ return super.getHeaders(name);
+ }
+ };
+ LoginUser loginUser = tokenService.getLoginUser(wrappedReq);
+ if (loginUser == null) {
+ return false;
+ }
+ attributes.put("loginUser", loginUser);
+ return true;
+ }
+
+ @Override
+ public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
+ WebSocketHandler wsHandler, Exception exception) {
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
new file mode 100644
index 0000000..e9b66ba
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
@@ -0,0 +1,146 @@
+package com.ruoyi.websocket.controller;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.messaging.handler.annotation.DestinationVariable;
+import org.springframework.messaging.handler.annotation.MessageMapping;
+import org.springframework.messaging.handler.annotation.Payload;
+import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.stereotype.Controller;
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.common.core.domain.model.LoginUser;
+import com.ruoyi.websocket.config.LoginUserPrincipal;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.domain.Rooms;
+import com.ruoyi.system.service.IRoomsService;
+import com.ruoyi.websocket.dto.RoomMemberDTO;
+import com.ruoyi.websocket.service.RoomWebSocketService;
+
+/**
+ * WebSocket 房间消息控制器
+ *
+ * @author ruoyi
+ */
+@Controller
+public class RoomWebSocketController {
+
+ @Autowired
+ private SimpMessagingTemplate messagingTemplate;
+
+ @Autowired
+ private RoomWebSocketService roomWebSocketService;
+
+ @Autowired
+ private IRoomsService roomsService;
+
+ private static final String TYPE_JOIN = "JOIN";
+ private static final String TYPE_LEAVE = "LEAVE";
+ private static final String TYPE_PING = "PING";
+ private static final String TYPE_MEMBER_JOINED = "MEMBER_JOINED";
+ private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT";
+ private static final String TYPE_MEMBER_LIST = "MEMBER_LIST";
+ private static final String TYPE_PONG = "PONG";
+
+ /**
+ * 处理房间消息:JOIN、LEAVE、PING
+ */
+ @MessageMapping("/room/{roomId}")
+ public void handleRoomMessage(@DestinationVariable Long roomId, @Payload String payload,
+ SimpMessageHeaderAccessor accessor) {
+ LoginUser loginUser = null;
+ if (accessor.getUser() instanceof LoginUserPrincipal) {
+ loginUser = ((LoginUserPrincipal) accessor.getUser()).getLoginUser();
+ }
+ if (loginUser == null) return;
+
+ Map body = parsePayload(payload);
+ if (body == null) return;
+
+ String type = (String) body.get("type");
+ String sessionId = accessor.getSessionId();
+ if (sessionId == null) sessionId = UUID.randomUUID().toString();
+
+ if (TYPE_JOIN.equals(type)) {
+ handleJoin(roomId, sessionId, loginUser, body);
+ } else if (TYPE_LEAVE.equals(type)) {
+ handleLeave(roomId, sessionId, loginUser);
+ } else if (TYPE_PING.equals(type)) {
+ handlePing(roomId, sessionId, loginUser);
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map parsePayload(String payload) {
+ try {
+ return JSON.parseObject(payload, Map.class);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private void handleJoin(Long roomId, String sessionId, LoginUser loginUser, Map body) {
+ Rooms room = roomsService.selectRoomsById(roomId);
+ if (room == null) return;
+
+ RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, body);
+ roomWebSocketService.joinRoom(roomId, sessionId, member);
+
+ List members = roomWebSocketService.getRoomMembers(roomId);
+ Map memberListMsg = new HashMap<>();
+ memberListMsg.put("type", TYPE_MEMBER_LIST);
+ memberListMsg.put("members", members);
+
+ Map joinedMsg = new HashMap<>();
+ joinedMsg.put("type", TYPE_MEMBER_JOINED);
+ joinedMsg.put("member", member);
+
+ String topic = "/topic/room/" + roomId;
+ messagingTemplate.convertAndSend(topic, memberListMsg);
+ }
+
+ private void handleLeave(Long roomId, String sessionId, LoginUser loginUser) {
+ RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, null);
+ roomWebSocketService.leaveRoom(roomId, sessionId, loginUser.getUserId());
+
+ Map msg = new HashMap<>();
+ msg.put("type", TYPE_MEMBER_LEFT);
+ msg.put("member", member);
+ msg.put("sessionId", sessionId);
+
+ messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
+ }
+
+ private void handlePing(Long roomId, String sessionId, LoginUser loginUser) {
+ roomWebSocketService.refreshSessionHeartbeat(sessionId);
+
+ Map msg = new HashMap<>();
+ msg.put("type", TYPE_PONG);
+
+ messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", msg);
+ }
+
+ private RoomMemberDTO buildMember(LoginUser loginUser, String sessionId, Long roomId, Map body) {
+ RoomMemberDTO dto = new RoomMemberDTO();
+ dto.setUserId(loginUser.getUserId());
+ dto.setUserName(loginUser.getUsername());
+ dto.setNickName(loginUser.getUser().getNickName());
+ dto.setAvatar(loginUser.getUser().getAvatar());
+ dto.setSessionId(sessionId);
+ dto.setDeviceId(body != null && body.containsKey("deviceId") ? String.valueOf(body.get("deviceId")) : "default");
+ dto.setJoinedAt(System.currentTimeMillis());
+
+ Rooms room = roomsService.selectRoomsById(roomId);
+ if (room != null && loginUser.getUserId().equals(room.getOwnerId())) {
+ dto.setRole("owner");
+ } else if (StringUtils.isNotEmpty(loginUser.getUser().getUserLevel())) {
+ dto.setRole(loginUser.getUser().getUserLevel());
+ } else {
+ dto.setRole("member");
+ }
+ return dto;
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java
new file mode 100644
index 0000000..1f642fb
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java
@@ -0,0 +1,93 @@
+package com.ruoyi.websocket.dto;
+
+import java.io.Serializable;
+
+/**
+ * WebSocket 房间成员信息 DTO(仅用于 Redis 序列化,非数据库实体)
+ *
+ * @author ruoyi
+ */
+public class RoomMemberDTO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /** 用户ID */
+ private Long userId;
+ /** 用户账号 */
+ private String userName;
+ /** 用户昵称 */
+ private String nickName;
+ /** 头像地址 */
+ private String avatar;
+ /** WebSocket 会话ID */
+ private String sessionId;
+ /** 设备标识 */
+ private String deviceId;
+ /** 加入时间戳 */
+ private Long joinedAt;
+ /** 角色:owner=房主, admin=管理员, member=成员 */
+ private String role;
+
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ public String getUserName() {
+ return userName;
+ }
+
+ public void setUserName(String userName) {
+ this.userName = userName;
+ }
+
+ public String getNickName() {
+ return nickName;
+ }
+
+ public void setNickName(String nickName) {
+ this.nickName = nickName;
+ }
+
+ public String getAvatar() {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar) {
+ this.avatar = avatar;
+ }
+
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ public void setSessionId(String sessionId) {
+ this.sessionId = sessionId;
+ }
+
+ public String getDeviceId() {
+ return deviceId;
+ }
+
+ public void setDeviceId(String deviceId) {
+ this.deviceId = deviceId;
+ }
+
+ public Long getJoinedAt() {
+ return joinedAt;
+ }
+
+ public void setJoinedAt(Long joinedAt) {
+ this.joinedAt = joinedAt;
+ }
+
+ public String getRole() {
+ return role;
+ }
+
+ public void setRole(String role) {
+ this.role = role;
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomSessionInfo.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomSessionInfo.java
new file mode 100644
index 0000000..3535bbd
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomSessionInfo.java
@@ -0,0 +1,33 @@
+package com.ruoyi.websocket.dto;
+
+import java.io.Serializable;
+
+/**
+ * WebSocket 会话与房间关联信息(用于断开连接时清理)
+ *
+ * @author ruoyi
+ */
+public class RoomSessionInfo implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ /** 房间ID */
+ private Long roomId;
+ /** 成员信息 */
+ private RoomMemberDTO member;
+
+ public Long getRoomId() {
+ return roomId;
+ }
+
+ public void setRoomId(Long roomId) {
+ this.roomId = roomId;
+ }
+
+ public RoomMemberDTO getMember() {
+ return member;
+ }
+
+ public void setMember(RoomMemberDTO member) {
+ this.member = member;
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java
new file mode 100644
index 0000000..86fdbdb
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java
@@ -0,0 +1,46 @@
+package com.ruoyi.websocket.listener;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationListener;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.messaging.SessionDisconnectEvent;
+import com.ruoyi.websocket.dto.RoomSessionInfo;
+import com.ruoyi.websocket.service.RoomWebSocketService;
+
+/**
+ * WebSocket 断开连接监听器
+ * 当客户端断开(刷新、关闭标签页、网络异常等)时,自动从房间移除该会话,避免同一账号重复出现在在线列表中
+ *
+ * @author ruoyi
+ */
+@Component
+public class WebSocketDisconnectListener implements ApplicationListener {
+
+ private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT";
+
+ @Autowired
+ private RoomWebSocketService roomWebSocketService;
+
+ @Autowired
+ private SimpMessagingTemplate messagingTemplate;
+
+ @Override
+ public void onApplicationEvent(SessionDisconnectEvent event) {
+ String sessionId = event.getSessionId();
+ if (sessionId == null) return;
+
+ RoomSessionInfo info = roomWebSocketService.leaveBySessionId(sessionId);
+ if (info == null) return;
+
+ Map msg = new HashMap<>();
+ msg.put("type", TYPE_MEMBER_LEFT);
+ msg.put("member", info.getMember());
+ msg.put("sessionId", sessionId);
+
+ String topic = "/topic/room/" + info.getRoomId();
+ messagingTemplate.convertAndSend(topic, msg);
+ }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomWebSocketService.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomWebSocketService.java
new file mode 100644
index 0000000..dfbf643
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomWebSocketService.java
@@ -0,0 +1,158 @@
+package com.ruoyi.websocket.service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.websocket.dto.RoomMemberDTO;
+import com.ruoyi.websocket.dto.RoomSessionInfo;
+
+/**
+ * WebSocket 房间 Redis 管理服务
+ *
+ * @author ruoyi
+ */
+@Service
+public class RoomWebSocketService {
+
+ private static final String ROOM_MEMBERS_PREFIX = "room:";
+ private static final String ROOM_MEMBERS_SUFFIX = ":members";
+ private static final String USER_SESSIONS_PREFIX = "user:";
+ private static final String USER_SESSIONS_SUFFIX = ":sessions";
+ private static final String SESSION_PREFIX = "session:";
+ private static final int SESSION_EXPIRE_MINUTES = 30;
+
+ @Autowired
+ private RedisCache redisCache;
+
+ @Autowired
+ @Qualifier("stringObjectRedisTemplate")
+ private RedisTemplate redisTemplate;
+
+ private String roomMembersKey(Long roomId) {
+ return ROOM_MEMBERS_PREFIX + roomId + ROOM_MEMBERS_SUFFIX;
+ }
+
+ private String userSessionsKey(Long userId) {
+ return USER_SESSIONS_PREFIX + userId + USER_SESSIONS_SUFFIX;
+ }
+
+ private String sessionKey(String sessionId) {
+ return SESSION_PREFIX + sessionId;
+ }
+
+ /**
+ * 用户加入房间。
+ * 同一用户只保留最新会话,加入前会先移除该用户在房间内的所有旧会话,避免刷新/重连后重复显示。
+ */
+ public void joinRoom(Long roomId, String sessionId, RoomMemberDTO member) {
+ removeStaleSessionsForUser(roomId, member.getUserId(), sessionId);
+
+ String key = roomMembersKey(roomId);
+ redisCache.setCacheMapValue(key, sessionId, member);
+ redisCache.expire(key, 24, TimeUnit.HOURS);
+
+ redisTemplate.opsForSet().add(userSessionsKey(member.getUserId()), sessionId);
+
+ RoomSessionInfo sessionInfo = new RoomSessionInfo();
+ sessionInfo.setRoomId(roomId);
+ sessionInfo.setMember(member);
+ redisCache.setCacheObject(sessionKey(sessionId), sessionInfo, SESSION_EXPIRE_MINUTES, TimeUnit.MINUTES);
+ }
+
+ /**
+ * 移除同一用户在房间内的旧会话(历史残留,如服务重启前未收到断开事件;前端每次加载生成新 deviceId 导致无法按设备匹配)
+ */
+ private void removeStaleSessionsForUser(Long roomId, Long userId, String currentSessionId) {
+ String key = roomMembersKey(roomId);
+ Map map = redisCache.getCacheMap(key);
+ if (map == null || map.isEmpty()) return;
+
+ List toRemove = new ArrayList<>();
+ for (Map.Entry e : map.entrySet()) {
+ String sid = e.getKey();
+ if (sid.equals(currentSessionId)) continue;
+ Object val = e.getValue();
+ RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val
+ : JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class);
+ if (dto != null && userId.equals(dto.getUserId())) {
+ toRemove.add(sid);
+ }
+ }
+ for (String sid : toRemove) {
+ Object val = redisCache.getCacheMapValue(key, sid);
+ RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val
+ : (val != null ? JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class) : null);
+ leaveRoom(roomId, sid, dto != null ? dto.getUserId() : null);
+ }
+ }
+
+ /**
+ * 用户离开房间
+ */
+ public void leaveRoom(Long roomId, String sessionId, Long userId) {
+ String key = roomMembersKey(roomId);
+ redisCache.deleteCacheMapValue(key, sessionId);
+
+ if (userId != null) {
+ redisTemplate.opsForSet().remove(userSessionsKey(userId), sessionId);
+ }
+
+ redisCache.deleteObject(sessionKey(sessionId));
+ }
+
+ /**
+ * 获取房间成员列表
+ */
+ public List getRoomMembers(Long roomId) {
+ String key = roomMembersKey(roomId);
+ Map map = redisCache.getCacheMap(key);
+ List list = new ArrayList<>();
+ if (map != null && !map.isEmpty()) {
+ for (Object val : map.values()) {
+ if (val != null) {
+ RoomMemberDTO dto = val instanceof RoomMemberDTO ? (RoomMemberDTO) val : JSON.parseObject(JSON.toJSONString(val), RoomMemberDTO.class);
+ if (dto != null) list.add(dto);
+ }
+ }
+ }
+ return list;
+ }
+
+ /**
+ * 刷新会话心跳(延长过期时间)
+ */
+ public void refreshSessionHeartbeat(String sessionId) {
+ String key = sessionKey(sessionId);
+ Object val = redisCache.getCacheObject(key);
+ if (val != null) {
+ redisCache.setCacheObject(key, val, SESSION_EXPIRE_MINUTES, TimeUnit.MINUTES);
+ }
+ }
+
+ /**
+ * 根据会话ID离开房间(用于连接断开时清理)
+ *
+ * @param sessionId 会话ID
+ * @return 若该会话在房间中,返回会话信息(含 roomId、member)用于广播;否则返回 null
+ */
+ public RoomSessionInfo leaveBySessionId(String sessionId) {
+ Object val = redisCache.getCacheObject(sessionKey(sessionId));
+ if (val == null) return null;
+
+ RoomSessionInfo info = val instanceof RoomSessionInfo ? (RoomSessionInfo) val
+ : JSON.parseObject(JSON.toJSONString(val), RoomSessionInfo.class);
+ if (info == null || info.getRoomId() == null || info.getMember() == null) return null;
+
+ Long roomId = info.getRoomId();
+ RoomMemberDTO member = info.getMember();
+ leaveRoom(roomId, sessionId, member.getUserId());
+ return info;
+ }
+}
diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
index d5f704e..182db2f 100644
--- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
+++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
@@ -109,6 +109,8 @@ public class SecurityConfig {
permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll());
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
requests.antMatchers("/login", "/register", "/captchaImage").permitAll()
+ // WebSocket 握手(鉴权在 HandshakeInterceptor 中完成)
+ .antMatchers("/ws/**").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
diff --git a/ruoyi-ui/package.json b/ruoyi-ui/package.json
index 4aa7fcd..5f366ac 100644
--- a/ruoyi-ui/package.json
+++ b/ruoyi-ui/package.json
@@ -26,6 +26,7 @@
},
"dependencies": {
"@riophae/vue-treeselect": "0.4.0",
+ "@stomp/stompjs": "^7.3.0",
"axios": "0.28.1",
"cesium": "^1.95.0",
"clipboard": "2.0.8",
@@ -42,6 +43,7 @@
"nprogress": "0.2.0",
"quill": "2.0.2",
"screenfull": "5.0.2",
+ "sockjs-client": "^1.6.1",
"sortablejs": "1.10.2",
"splitpanes": "2.4.1",
"vue": "2.6.12",
diff --git a/ruoyi-ui/src/api/system/routes.js b/ruoyi-ui/src/api/system/routes.js
index c945cda..6be88f1 100644
--- a/ruoyi-ui/src/api/system/routes.js
+++ b/ruoyi-ui/src/api/system/routes.js
@@ -26,12 +26,13 @@ export function addRoutes(data) {
})
}
-// 修改实体部署与航线
+// 修改实体部署与航线(禁用防重复提交,航线编辑可能快速连续触发)
export function updateRoutes(data) {
return request({
url: '/system/routes',
method: 'put',
- data: data
+ data: data,
+ headers: { repeatSubmit: false }
})
}
diff --git a/ruoyi-ui/src/api/system/waypoints.js b/ruoyi-ui/src/api/system/waypoints.js
index 551e122..742e669 100644
--- a/ruoyi-ui/src/api/system/waypoints.js
+++ b/ruoyi-ui/src/api/system/waypoints.js
@@ -26,12 +26,13 @@ export function addWaypoints(data) {
})
}
-// 修改航线具体航点明细
+// 修改航线具体航点明细(禁用防重复提交,拖拽/批量保存可能快速连续触发)
export function updateWaypoints(data) {
return request({
url: '/system/waypoints',
method: 'put',
- data: data
+ data: data,
+ headers: { repeatSubmit: false }
})
}
diff --git a/ruoyi-ui/src/utils/websocket.js b/ruoyi-ui/src/utils/websocket.js
new file mode 100644
index 0000000..c8d43c8
--- /dev/null
+++ b/ruoyi-ui/src/utils/websocket.js
@@ -0,0 +1,167 @@
+/**
+ * WebSocket 房间连接服务(SockJS + STOMP)
+ */
+import SockJS from 'sockjs-client'
+import { Client } from '@stomp/stompjs'
+import { getToken } from '@/utils/auth'
+
+const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api'
+
+/**
+ * 创建房间 WebSocket 连接
+ * @param {Object} options
+ * @param {string|number} options.roomId - 房间 ID
+ * @param {Function} options.onMembers - 收到成员列表回调 (members) => {}
+ * @param {Function} options.onMemberJoined - 成员加入回调 (member) => {}
+ * @param {Function} options.onMemberLeft - 成员离开回调 (member, sessionId) => {}
+ * @param {Function} options.onConnected - 连接成功回调
+ * @param {Function} options.onDisconnected - 断开回调
+ * @param {Function} options.onError - 错误回调
+ * @param {string} [options.deviceId] - 设备标识
+ */
+export function createRoomWebSocket(options) {
+ const {
+ roomId,
+ onMembers,
+ onMemberJoined,
+ onMemberLeft,
+ onConnected,
+ onDisconnected,
+ onError,
+ deviceId = 'web-' + Math.random().toString(36).slice(2, 10)
+ } = options
+
+ let client = null
+ let subscription = null
+ let heartbeatTimer = null
+ let reconnectAttempts = 0
+ const maxReconnectAttempts = 10
+ const reconnectDelay = 2000
+
+ function getWsUrl() {
+ const token = getToken()
+ const base = window.location.origin + WS_BASE
+ const sep = base.includes('?') ? '&' : '?'
+ return base + '/ws' + (token ? sep + 'token=' + encodeURIComponent(token) : '')
+ }
+
+ function sendJoin() {
+ if (client && client.connected) {
+ client.publish({
+ destination: '/app/room/' + roomId,
+ body: JSON.stringify({ type: 'JOIN', deviceId })
+ })
+ }
+ }
+
+ function sendLeave() {
+ if (client && client.connected) {
+ client.publish({
+ destination: '/app/room/' + roomId,
+ body: JSON.stringify({ type: 'LEAVE' })
+ })
+ }
+ }
+
+ function sendPing() {
+ if (client && client.connected) {
+ client.publish({
+ destination: '/app/room/' + roomId,
+ body: JSON.stringify({ type: 'PING' })
+ })
+ }
+ }
+
+ function startHeartbeat() {
+ stopHeartbeat()
+ heartbeatTimer = setInterval(sendPing, 30000)
+ }
+
+ function stopHeartbeat() {
+ if (heartbeatTimer) {
+ clearInterval(heartbeatTimer)
+ heartbeatTimer = null
+ }
+ }
+
+ function handleMessage(message) {
+ try {
+ const body = JSON.parse(message.body)
+ const type = body.type
+ if (type === 'MEMBER_LIST' && body.members) {
+ onMembers && onMembers(body.members)
+ } else if (type === 'MEMBER_JOINED' && body.member) {
+ onMemberJoined && onMemberJoined(body.member)
+ } else if (type === 'MEMBER_LEFT' && body.member) {
+ onMemberLeft && onMemberLeft(body.member, body.sessionId)
+ }
+ } catch (e) {
+ console.warn('[WebSocket] parse message error:', e)
+ }
+ }
+
+ function connect() {
+ const token = getToken()
+ if (!token) {
+ onError && onError(new Error('未登录'))
+ return
+ }
+
+ const sock = new SockJS(getWsUrl())
+ client = new Client({
+ webSocketFactory: () => sock,
+ reconnectDelay: 0,
+ heartbeatIncoming: 0,
+ heartbeatOutgoing: 0,
+ onConnect: () => {
+ reconnectAttempts = 0
+ subscription = client.subscribe('/topic/room/' + roomId, handleMessage)
+ sendJoin()
+ startHeartbeat()
+ onConnected && onConnected()
+ },
+ onStompError: (frame) => {
+ console.warn('[WebSocket] STOMP error:', frame)
+ onError && onError(new Error(frame.headers?.message || '连接错误'))
+ },
+ onWebSocketClose: () => {
+ stopHeartbeat()
+ subscription = null
+ onDisconnected && onDisconnected()
+ }
+ })
+ client.activate()
+ }
+
+ function disconnect() {
+ stopHeartbeat()
+ sendLeave()
+ if (subscription) {
+ subscription.unsubscribe()
+ subscription = null
+ }
+ if (client) {
+ client.deactivate()
+ client = null
+ }
+ }
+
+ function tryReconnect() {
+ if (reconnectAttempts >= maxReconnectAttempts) return
+ reconnectAttempts++
+ setTimeout(() => {
+ disconnect()
+ connect()
+ }, reconnectDelay)
+ }
+
+ connect()
+
+ return {
+ disconnect,
+ reconnect: connect,
+ get connected() {
+ return client && client.connected
+ }
+ }
+}
diff --git a/ruoyi-ui/src/views/childRoom/FourTPanel.vue b/ruoyi-ui/src/views/childRoom/FourTPanel.vue
index 8f73881..5f64769 100644
--- a/ruoyi-ui/src/views/childRoom/FourTPanel.vue
+++ b/ruoyi-ui/src/views/childRoom/FourTPanel.vue
@@ -335,14 +335,14 @@ export default {