package com.ttstd.signaling.handler; import com.fasterxml.jackson.databind.ObjectMapper; import com.ttstd.signaling.manager.ConnectionRequestManager; import com.ttstd.signaling.manager.SessionManager; import com.ttstd.signaling.manager.SignalMetrics; import com.ttstd.signaling.model.AuthPrincipal; import com.ttstd.signaling.model.DeviceBinding; import com.ttstd.signaling.model.DeviceType; import com.ttstd.signaling.model.SignalMessage; import com.ttstd.signaling.security.AuthHandshakeInterceptor; import com.ttstd.signaling.service.AccountService; import com.ttstd.signaling.service.AuditService; import com.ttstd.signaling.service.BindingService; import com.ttstd.signaling.service.DeviceIdentityService; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @Component public class SignalWebSocketHandler extends TextWebSocketHandler { private static final Logger logger = LoggerFactory.getLogger(SignalWebSocketHandler.class); private static final ObjectMapper objectMapper = new ObjectMapper(); /** 因鉴权/封禁被关闭连接时使用的关闭码 */ private static final CloseStatus CLOSE_UNAUTHORIZED = new CloseStatus(4001, "UNAUTHORIZED"); private static final CloseStatus CLOSE_FORCED_LOGOUT = new CloseStatus(4003, "FORCED_LOGOUT"); private final SessionManager sessionManager; private final ConnectionRequestManager connectionRequestManager; private final SignalMetrics metrics; private final AccountService accountService; private final DeviceIdentityService deviceIdentityService; private final BindingService bindingService; private final AuditService auditService; public SignalWebSocketHandler(SessionManager sessionManager, ConnectionRequestManager connectionRequestManager, SignalMetrics metrics, AccountService accountService, DeviceIdentityService deviceIdentityService, BindingService bindingService, AuditService auditService) { this.sessionManager = sessionManager; this.connectionRequestManager = connectionRequestManager; this.metrics = metrics; this.accountService = accountService; this.deviceIdentityService = deviceIdentityService; this.bindingService = bindingService; this.auditService = auditService; this.connectionRequestManager.setSender(this::sendToDevice); } /** * 注册会话失效回调:账号被封禁 / 会话被踢出 / 设备被禁用时立即断开对应连接。 */ @PostConstruct void registerRevocationListener() { AccountService.SessionRevocationListener listener = this::forceDisconnect; accountService.setRevocationListener(listener); deviceIdentityService.setRevocationListener(listener); } /** * 强制断开指定主体的连接。 * * @param principalId 主体 ID * @param sessionId 指定登录会话;为 null 表示断开该主体全部连接 */ private void forceDisconnect(String principalId, String sessionId, String reason) { Set deviceIds = sessionManager.getDeviceIdsByPrincipal(principalId); for (String deviceId : deviceIds) { AuthPrincipal principal = sessionManager.getPrincipal(deviceId); if (principal == null) { continue; } // 指定了会话时只断开该会话,避免误伤同账号其他端 if (sessionId != null && !sessionId.equals(principal.sessionId())) { continue; } WebSocketSession session = sessionManager.getSession(deviceId); if (session == null || !session.isOpen()) { continue; } Map notice = new HashMap<>(); notice.put("type", "FORCE_LOGOUT"); notice.put("payload", reason == null ? "会话已失效" : reason); sendToSession(session, notice); try { session.close(CLOSE_FORCED_LOGOUT); } catch (IOException e) { logger.warn("强制断开连接 {} 失败: {}", deviceId, e.getMessage()); } logger.info("已强制断开连接: deviceId={} principal={} reason={}", deviceId, principalId, reason); } } /** * 向指定设备回送服务端事件(错误/超时通知等)。 */ private void sendToDevice(String deviceId, Object message) { WebSocketSession session = sessionManager.getSession(deviceId); if (session == null || !session.isOpen()) { logger.warn("无法向 {} 发送事件:连接不在线", deviceId); return; } sendToSession(session, message); } /** * 连接建立后立即以握手阶段裁定的身份完成注册,无需客户端再发 REGISTER。 */ @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { AuthPrincipal principal = principalOf(session); if (principal == null) { // 正常情况下握手拦截器已拦截,此处为纵深防御 logger.warn("连接 {} 缺少鉴权主体,立即关闭", session.getId()); session.close(CLOSE_UNAUTHORIZED); return; } sessionManager.registerDevice(principal, session); metrics.incRegistered(); metrics.recordSessionCount(sessionManager.getOnlineCount()); Map response = new HashMap<>(); response.put("type", "REGISTER_SUCCESS"); response.put("deviceId", principal.deviceId()); // 与既有信令消息协议一致:下发的设备ID也写入 fromDeviceId,便于客户端统一解析。 response.put("fromDeviceId", principal.deviceId()); response.put("deviceType", principal.deviceType().name()); response.put("displayName", principal.displayName()); sendToSession(session, response); logger.info("连接已建立并注册: {} ({})", principal.deviceId(), principal.deviceType()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { AuthPrincipal principal = principalOf(session); if (principal == null) { session.close(CLOSE_UNAUTHORIZED); return; } String payload = message.getPayload(); logger.debug("收到消息: {}", payload); metrics.incMessage(); try { SignalMessage signalMessage = objectMapper.readValue(payload, SignalMessage.class); String type = signalMessage.getType(); if (type == null) { logger.warn("消息缺少 type 字段"); return; } // 关键:发送方身份一律以服务端鉴权结果覆盖,忽略客户端自报值, // 防止伪造 fromDeviceId 冒充他人。 String claimedFrom = signalMessage.getFromDeviceId(); if (claimedFrom != null && !claimedFrom.equals(principal.deviceId())) { logger.warn("客户端自报 fromDeviceId={} 与鉴权身份 {} 不一致,已强制覆盖", claimedFrom, principal.deviceId()); } signalMessage.setFromDeviceId(principal.deviceId()); signalMessage.setDeviceType(principal.deviceType().name()); switch (type.toUpperCase()) { case "REGISTER": // 身份已在握手阶段确定,REGISTER 仅作兼容响应 handleLegacyRegister(session, principal); break; case "DEVICE_LIST": handleDeviceList(session, principal); break; case "PING": // 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接 break; case "OFFER": metrics.incOffer(); // 绑定/黑名单前置校验:仅允许已绑定且未被拉黑的主控端发起 if (principal.deviceType() == DeviceType.CONTROLLER) { String targetUid = signalMessage.getToDeviceId(); if (targetUid == null || targetUid.isEmpty()) { sendError(session, "缺少目标设备ID"); metrics.incBlockedOffer(); break; } if (!bindingService.isBound(targetUid, principal.principalId())) { sendError(session, "未与该设备建立绑定关系,无法发起连接"); metrics.incBlockedOffer(); auditOfferBlocked(targetUid, principal.principalId(), "NOT_BOUND"); break; } if (bindingService.isBlacklisted(targetUid, principal.principalId())) { sendError(session, "该设备已拒绝来自你的连接"); metrics.incBlockedOffer(); auditOfferBlocked(targetUid, principal.principalId(), "BLACKLISTED"); break; } } // 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发 handleConnectionRequest(signalMessage); break; case "ANSWER": metrics.incAnswer(); // 被控端已接受:清理待确认状态并转发 Answer 给主控端 connectionRequestManager.completePendingRequest( signalMessage.getToDeviceId(), signalMessage.getFromDeviceId()); forwardMessage(signalMessage, true); break; case "CONNECTION_REJECTED": metrics.incRejected(); // 被控端已拒绝:清理待确认状态并转发拒绝通知给主控端 connectionRequestManager.completePendingRequest( signalMessage.getToDeviceId(), signalMessage.getFromDeviceId()); forwardMessage(signalMessage, false); break; case "ICE_CANDIDATE": case "CONTROL_COMMAND": // 普通信令:目标不在线时静默丢弃,不返回提示 forwardMessage(signalMessage, false); break; default: // 其他消息类型直接转发,目标不在线时静默丢弃 forwardMessage(signalMessage, false); break; } } catch (Exception e) { logger.error("处理消息出错: {}", e.getMessage(), e); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { logger.info("连接已关闭: {} ({})", session.getId(), status); sessionManager.unregisterSession(session); metrics.recordSessionCount(sessionManager.getOnlineCount()); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { // EOFException / 连接被对端重置 属于正常网络事件(客户端网络中断、代理空闲超时等), // 并非服务器错误,降级为 DEBUG 记录,避免误导。 boolean benign = exception instanceof java.io.EOFException || (exception.getMessage() != null && (exception.getMessage().contains("Connection reset") || exception.getMessage().contains("Broken pipe") || exception.getMessage().contains("An established connection"))); if (benign) { logger.debug("传输层关闭(客户端断开),会话 {}: {}", session.getId(), exception.getMessage()); } else { logger.warn("传输层错误,会话 {}: {}", session.getId(), exception.getMessage()); } sessionManager.unregisterSession(session); metrics.recordSessionCount(sessionManager.getOnlineCount()); // 连接已不可用时无需再以 SERVER_ERROR(1011) 关闭;仅在仍打开时温和关闭。 if (session.isOpen()) { session.close(CloseStatus.GOING_AWAY); } } private AuthPrincipal principalOf(WebSocketSession session) { Object attr = session.getAttributes().get(AuthHandshakeInterceptor.ATTR_PRINCIPAL); return attr instanceof AuthPrincipal principal ? principal : null; } /** * 兼容旧客户端的 REGISTER:不再接受客户端自报身份,仅回显服务端裁定结果。 */ private void handleLegacyRegister(WebSocketSession session, AuthPrincipal principal) { Map response = new HashMap<>(); response.put("type", "REGISTER_SUCCESS"); response.put("deviceId", principal.deviceId()); response.put("fromDeviceId", principal.deviceId()); response.put("deviceType", principal.deviceType().name()); response.put("displayName", principal.displayName()); sendToSession(session, response); } /** * 设备列表(绑定视图)。 * *

为防止枚举被控端,服务端不再向任意主控端返回全局在线设备清单。 * 主控端仅能看到「自己已绑定」的设备及其在线状态;被控端此项为空列表。 */ private void handleDeviceList(WebSocketSession session, AuthPrincipal principal) { Map response = new HashMap<>(); response.put("type", "DEVICE_LIST"); if (principal.deviceType() == DeviceType.CONTROLLER) { List> devices = bindingService.listByUser(principal.principalId()) .stream() .filter(b -> b.getStatus() == DeviceBinding.BindingStatus.ACTIVE) .map(b -> { Map item = new HashMap<>(); item.put("deviceUid", b.getDeviceUid()); item.put("alias", b.getAlias()); item.put("role", b.getRole().name()); item.put("online", sessionManager.isDeviceOnline(b.getDeviceUid())); return item; }) .toList(); response.put("boundDevices", devices); } else { // 被控端无需浏览主控端列表 response.put("boundDevices", List.of()); } sendToSession(session, response); } /** 记录被拦截的 OFFER(未绑定/被拉黑),便于审计与风控。 */ private void auditOfferBlocked(String deviceUid, String userId, String reason) { try { auditService.recordUser(userId, AuditService.ACTION_OFFER_BLOCKED, AuditService.RESULT_SUCCESS, null, "device=" + deviceUid + " reason=" + reason); } catch (Exception e) { logger.warn("审计 OFFER 拦截记录失败: {}", e.getMessage()); } } /** * 处理主控端发来的连接请求(OFFER):先做设备类型校验与去重, * 再登记“待被控端确认”状态,最后转发给被控端。 */ private void handleConnectionRequest(SignalMessage message) { String fromDeviceId = message.getFromDeviceId(); String toDeviceId = message.getToDeviceId(); // 1. 校验:仅允许 CONTROLLER -> CONTROLLED String error = connectionRequestManager.validateOffer(fromDeviceId, toDeviceId); if (error != null) { logger.warn("非法连接请求 {} -> {}: {}", fromDeviceId, toDeviceId, error); Map response = new HashMap<>(); response.put("type", "REQUEST_ERROR"); response.put("toDeviceId", toDeviceId); response.put("payload", error); sendToDevice(fromDeviceId, response); return; } // 鉴权类型分类:动态验证码(CODE) / 固定密码(PASSWORD) / 免密(NONE)。 // 服务器只做分类与转发,真实校验在被控端完成(服务器不保存任何密钥)。 String authType = message.getAuthType(); if (authType != null && !authType.trim().isEmpty()) { if ("CODE".equalsIgnoreCase(authType)) { logger.info("连接请求 {} -> {} 使用【动态验证码】鉴权", fromDeviceId, toDeviceId); } else if ("PASSWORD".equalsIgnoreCase(authType)) { logger.info("连接请求 {} -> {} 使用【固定密码】鉴权", fromDeviceId, toDeviceId); } else if ("NONE".equalsIgnoreCase(authType)) { // 显式标记为免密,便于被控端识别 message.setAuthType("NONE"); logger.info("连接请求 {} -> {} 使用【免密连接】鉴权(被控端手动确认)", fromDeviceId, toDeviceId); } else { logger.warn("连接请求 {} -> {} 携带未知鉴权类型 authType={}", fromDeviceId, toDeviceId, authType); } } else { // 未携带 authType 也视为免密连接(向后兼容),规范化为 NONE message.setAuthType("NONE"); logger.info("连接请求 {} -> {} 未携带鉴权,按【免密连接】处理(被控端手动确认)", fromDeviceId, toDeviceId); } // 2. 去重:短时间内重复 OFFER 直接忽略,避免被控端反复弹窗 if (connectionRequestManager.isDuplicateOffer(fromDeviceId, toDeviceId)) { logger.info("重复连接请求 {} -> {} 已忽略", fromDeviceId, toDeviceId); return; } // 3. 目标不在线:立即回送 TARGET_OFFLINE,且不登记待确认(避免等待超时) if (!sessionManager.isDeviceOnline(toDeviceId)) { logger.warn("目标被控端 {} 不在线,无法投递连接请求", toDeviceId); notifySenderTargetOffline(message, toDeviceId); return; } // 4. 登记待确认并转发 OFFER 给被控端 connectionRequestManager.registerPendingRequest(fromDeviceId, toDeviceId); forwardMessage(message, false); } private void forwardMessage(SignalMessage message, boolean notifyOffline) { String toDeviceId = message.getToDeviceId(); if (toDeviceId == null) { logger.warn("无法转发消息:toDeviceId 为空"); return; } WebSocketSession targetSession = sessionManager.getSession(toDeviceId); if (targetSession == null || !targetSession.isOpen()) { logger.warn("目标设备 {} 不在线", toDeviceId); // 仅 OFFER / ANSWER 在目标不在线时回送 TARGET_OFFLINE,其余类型静默丢弃 if (notifyOffline) { notifySenderTargetOffline(message, toDeviceId); } return; } try { String jsonMessage = objectMapper.writeValueAsString(message); targetSession.sendMessage(new TextMessage(jsonMessage)); logger.debug("已转发 {}:{} -> {}", message.getType(), message.getFromDeviceId(), toDeviceId); } catch (IOException e) { logger.error("转发消息到 {} 失败: {}", toDeviceId, e.getMessage()); } } /** * 目标被控端不在线时,向发送方(主控端)回送 TARGET_OFFLINE 提醒。 */ private void notifySenderTargetOffline(SignalMessage message, String offlineDeviceId) { String fromDeviceId = message.getFromDeviceId(); if (fromDeviceId == null) { logger.warn("无法通知离线状态:fromDeviceId 为空"); return; } WebSocketSession senderSession = sessionManager.getSession(fromDeviceId); if (senderSession == null || !senderSession.isOpen()) { logger.warn("发送方 {} 会话不存在,无法通知目标离线", fromDeviceId); return; } Map response = new HashMap<>(); response.put("type", "TARGET_OFFLINE"); response.put("toDeviceId", offlineDeviceId); response.put("payload", "目标被控端不在线,请确认设备已开启并连接到信令服务器"); sendToSession(senderSession, response); logger.info("已通知发送方 {} 目标 {} 不在线", fromDeviceId, offlineDeviceId); } private void sendToSession(WebSocketSession session, Object data) { try { String json = objectMapper.writeValueAsString(data); session.sendMessage(new TextMessage(json)); } catch (IOException e) { logger.error("向会话 {} 发送消息失败: {}", session.getId(), e.getMessage()); } } /** 向发送方回送一条 REQUEST_ERROR 业务提示(不关闭连接)。 */ private void sendError(WebSocketSession session, String message) { Map response = new HashMap<>(); response.put("type", "REQUEST_ERROR"); response.put("payload", message); sendToSession(session, response); } }