Browse Source

初步联网功能

mh
cuitw 1 month ago
parent
commit
13f0a035c7
  1. 6
      ruoyi-admin/pom.xml
  2. 14
      ruoyi-admin/src/main/java/com/ruoyi/web/controller/RouteWaypointsController.java
  3. 27
      ruoyi-admin/src/main/java/com/ruoyi/websocket/config/LoginUserPrincipal.java
  4. 37
      ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketChannelInterceptor.java
  5. 47
      ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketConfig.java
  6. 80
      ruoyi-admin/src/main/java/com/ruoyi/websocket/config/WebSocketHandshakeHandler.java
  7. 146
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  8. 93
      ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomMemberDTO.java
  9. 33
      ruoyi-admin/src/main/java/com/ruoyi/websocket/dto/RoomSessionInfo.java
  10. 46
      ruoyi-admin/src/main/java/com/ruoyi/websocket/listener/WebSocketDisconnectListener.java
  11. 158
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomWebSocketService.java
  12. 2
      ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java
  13. 2
      ruoyi-ui/package.json
  14. 5
      ruoyi-ui/src/api/system/routes.js
  15. 5
      ruoyi-ui/src/api/system/waypoints.js
  16. 167
      ruoyi-ui/src/utils/websocket.js
  17. 10
      ruoyi-ui/src/views/childRoom/FourTPanel.vue
  18. 73
      ruoyi-ui/src/views/childRoom/index.vue
  19. 17
      ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue
  20. 4
      ruoyi-ui/vue.config.js

6
ruoyi-admin/pom.xml

@ -50,6 +50,12 @@
<version>3.42.0.0</version>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 核心模块-->
<dependency>
<groupId>com.ruoyi</groupId>

14
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)

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

37
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<String, Object> sessionAttrs = accessor.getSessionAttributes();
if (sessionAttrs != null && sessionAttrs.containsKey("loginUser")) {
LoginUser loginUser = (LoginUser) sessionAttrs.get("loginUser");
accessor.setUser(new LoginUserPrincipal(loginUser));
}
}
return message;
}
}

47
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);
}
}

80
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<String, Object> 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<String> 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) {
}
}

146
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";
/**
* 处理房间消息JOINLEAVEPING
*/
@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<String, Object> 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<String, Object> 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<String, Object> body) {
Rooms room = roomsService.selectRoomsById(roomId);
if (room == null) return;
RoomMemberDTO member = buildMember(loginUser, sessionId, roomId, body);
roomWebSocketService.joinRoom(roomId, sessionId, member);
List<RoomMemberDTO> members = roomWebSocketService.getRoomMembers(roomId);
Map<String, Object> memberListMsg = new HashMap<>();
memberListMsg.put("type", TYPE_MEMBER_LIST);
memberListMsg.put("members", members);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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;
}
}

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

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

46
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<SessionDisconnectEvent> {
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<String, Object> 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);
}
}

158
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<String, Object> 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<String, Object> map = redisCache.getCacheMap(key);
if (map == null || map.isEmpty()) return;
List<String> toRemove = new ArrayList<>();
for (Map.Entry<String, Object> 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<RoomMemberDTO> getRoomMembers(Long roomId) {
String key = roomMembersKey(roomId);
Map<String, Object> map = redisCache.getCacheMap(key);
List<RoomMemberDTO> 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 若该会话在房间中返回会话信息 roomIdmember用于广播否则返回 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;
}
}

2
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()

2
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",

5
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 }
})
}

5
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 }
})
}

167
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
}
}
}

10
ruoyi-ui/src/views/childRoom/FourTPanel.vue

@ -335,14 +335,14 @@ export default {
</script>
<style scoped>
/* 根容器:覆盖全屏,居中显示 4T 面板 */
/* 根容器:覆盖全屏,居中显示 4T 面板;遮罩不阻挡地图点击,便于打开4T时仍可绘制航线 */
.four-t-panel {
position: fixed;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.12); /* 轻微遮罩,突出面板 */
background: transparent; /* 无遮罩,不使屏幕变暗 */
z-index: 200;
opacity: 0;
pointer-events: none;
@ -351,7 +351,11 @@ export default {
.four-t-panel.four-t-panel-ready {
opacity: 1;
pointer-events: auto;
pointer-events: none; /* 遮罩穿透:点击地图区域可继续绘制航线 */
}
.four-t-panel.four-t-panel-ready .panel-container {
pointer-events: auto; /* 仅面板本身可点击、拖动 */
}
/* 面板容器 */

73
ruoyi-ui/src/views/childRoom/index.vue

@ -315,6 +315,7 @@
<!-- 在线成员弹窗 -->
<online-members-dialog
v-model="showOnlineMembers"
:online-members="wsOnlineMembers"
/>
<!-- 平台编辑弹窗 -->
@ -370,7 +371,7 @@
@confirm="handleImportConfirm"
/>
<!-- 4T悬浮窗THREAT/TASK/TARGET/TACTIC- 仅点击方案或4T时渲染 -->
<!-- 4T悬浮窗THREAT/TASK/TARGET/TACTIC- 仅点击4T图标时打开 -->
<four-t-panel
v-if="show4TPanel && !screenshotMode"
:visible.sync="show4TPanel"
@ -435,6 +436,7 @@ import RightPanel from './RightPanel'
import BottomLeftPanel from './BottomLeftPanel'
import TopHeader from './TopHeader'
import FourTPanel from './FourTPanel'
import { createRoomWebSocket } from '@/utils/websocket';
import { listScenario, addScenario, delScenario } from "@/api/system/scenario";
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes, getPlatformStyle, getMissileParams, updateMissilePositions } from "@/api/system/routes";
import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints";
@ -511,7 +513,9 @@ export default {
//
roomCode: 'JTF-7-ALPHA',
onlineCount: 30,
onlineCount: 0,
wsOnlineMembers: [],
wsConnection: null,
combatTime: 'K+00:00:00', //
astroTime: '',
roomDetail: null,
@ -592,7 +596,7 @@ export default {
isRightPanelHidden: true, //
// K
showKTimePopup: false,
// 4T4T
// 4T4T/
show4TPanel: false,
// /
@ -703,6 +707,7 @@ export default {
},
mounted() {
this.getList();
this.connectRoomWebSocket();
//
this.isMenuHidden = true;
//
@ -718,6 +723,7 @@ export default {
setInterval(this.updateCombatTime, 1000);
},
beforeDestroy() {
this.disconnectRoomWebSocket();
//
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
@ -1193,6 +1199,60 @@ export default {
showOnlineMembersDialog() {
this.showOnlineMembers = true;
},
connectRoomWebSocket() {
if (!this.currentRoomId) return;
this.disconnectRoomWebSocket();
const baseUrl = process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || ''));
this.wsConnection = createRoomWebSocket({
roomId: this.currentRoomId,
onMembers: (members) => {
this.wsOnlineMembers = members.map(m => ({
id: m.sessionId || m.userId,
name: m.nickName || m.userName,
role: m.role === 'owner' ? '房主' : (m.role === 'admin' ? '管理员' : '成员'),
status: '在线',
isEditing: false,
avatar: m.avatar ? (m.avatar.startsWith('http') ? m.avatar : (baseUrl + m.avatar)) : ''
}));
this.onlineCount = this.wsOnlineMembers.length;
},
onMemberJoined: (member) => {
const baseUrl = (process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || '')));
const m = {
id: member.sessionId || member.userId,
name: member.nickName || member.userName,
role: member.role === 'owner' ? '房主' : (member.role === 'admin' ? '管理员' : '成员'),
status: '在线',
isEditing: false,
avatar: member.avatar ? (member.avatar.startsWith('http') ? member.avatar : (baseUrl + member.avatar)) : ''
};
if (!this.wsOnlineMembers.find(x => x.id === m.id)) {
this.wsOnlineMembers = [...this.wsOnlineMembers, m];
this.onlineCount = this.wsOnlineMembers.length;
}
},
onMemberLeft: (member, sessionId) => {
this.wsOnlineMembers = this.wsOnlineMembers.filter(m => m.id !== sessionId && m.id !== member.sessionId);
this.onlineCount = this.wsOnlineMembers.length;
},
onConnected: () => {},
onDisconnected: () => {
this.onlineCount = 0;
this.wsOnlineMembers = [];
},
onError: (err) => {
console.warn('[WebSocket]', err);
}
});
},
disconnectRoomWebSocket() {
if (this.wsConnection) {
this.wsConnection.disconnect();
this.wsConnection = null;
}
this.wsOnlineMembers = [];
this.onlineCount = 0;
},
//
openPlatformDialog(platform) {
this.selectedPlatform = JSON.parse(JSON.stringify(platform));
@ -2449,16 +2509,15 @@ export default {
this.handleMenuAction(item.action)
}
// 4T
if (item.id === 'file' || item.id === 'start' || item.id === 'insert' || item.id === '4t') {
// 4T便4T线
if (item.id === 'file' || item.id === 'start' || item.id === 'insert') {
this.drawDom = false;
this.airspaceDrawDom = false;
}
//
if (item.id === 'file') {
// 4T
this.show4TPanel = true;
// 4T
if (this.activeRightTab === 'plan' && !this.isRightPanelHidden) {
this.isRightPanelHidden = true;
} else {

17
ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue

@ -15,7 +15,7 @@
<el-tab-pane :label="$t('onlineMembersDialog.onlineMembers')" name="members">
<div class="members-list">
<div
v-for="member in onlineMembers"
v-for="member in displayOnlineMembers"
:key="member.id"
class="member-item"
:class="{ active: member.isEditing }"
@ -120,7 +120,7 @@
<div class="chat-room">
<div class="chat-header">
<h4>{{ $t('onlineMembersDialog.groupChat') }}</h4>
<span class="online-count">{{ onlineMembers.length }} {{ $t('onlineMembersDialog.onlineCount') }}</span>
<span class="online-count">{{ displayOnlineMembers.length }} {{ $t('onlineMembersDialog.onlineCount') }}</span>
</div>
<!-- 聊天内容区域 -->
@ -167,6 +167,10 @@ export default {
value: {
type: Boolean,
default: false
},
onlineMembers: {
type: Array,
default: () => []
}
},
data() {
@ -174,8 +178,8 @@ export default {
activeTab: 'members',
showRollbackDialog: false,
// 线
onlineMembers: [
// 线 props.onlineMembers 使 mock
_mockOnlineMembers: [
{ id: 1, name: '张三', role: '指挥官', status: '在线', isEditing: true, avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png' },
{ id: 2, name: '李四', role: '参谋', status: '在线', isEditing: false, avatar: 'https://cube.elemecdn.com/1/88/03b0d39583f48206768a7534e55bcpng.png' },
{ id: 3, name: '王五', role: '操作员', status: '在线', isEditing: false, avatar: 'https://cube.elemecdn.com/2/88/03b0d39583f48206768a7534e55bcpng.png' },
@ -263,6 +267,11 @@ export default {
]
};
},
computed: {
displayOnlineMembers() {
return (this.onlineMembers && this.onlineMembers.length > 0) ? this.onlineMembers : this._mockOnlineMembers;
}
},
methods: {
closeDialog() {
this.$emit('input', false);

4
ruoyi-ui/vue.config.js

@ -38,7 +38,8 @@ module.exports = {
'@cesium/engine',
'@cesium/widgets',
'@zip.js',
'@spz-loader'
'@spz-loader',
'@stomp/stompjs'
],
devServer: {
@ -55,6 +56,7 @@ module.exports = {
[process.env.VUE_APP_BASE_API]: {
target: baseUrl,
changeOrigin: true,
ws: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: ''
}

Loading…
Cancel
Save