().eq(SnDeviceInfo::getSerialno, sn)
+ );
+ if (deviceInfo == null) {
+ return Result.failed("设备不存在");
+ }
+
+ // 仅允许本人解绑
+ if (!StringUtils.hasText(deviceInfo.getSnMobile())
+ || !deviceInfo.getSnMobile().equals(clientUser.getMobile())) {
+ return Result.failed("当前用户未绑定该设备,无法解绑");
+ }
+
+ SnDeviceInfo update = new SnDeviceInfo();
+ update.setId(deviceInfo.getId());
+ update.setSnMobile(null);
+ boolean result = deviceService.updateById(update);
+ return Result.judge(result);
+ } catch (BusinessException be) {
+ return Result.failed(be.getMessage());
+ } catch (Exception e) {
+ log.error("unbindDevice error, sn: {}", sn, e);
+ return Result.failed("解绑设备失败: " + e.getMessage());
+ }
+ }
}
diff --git a/src/main/java/com/youlai/boot/config/SecurityConfig.java b/src/main/java/com/youlai/boot/config/SecurityConfig.java
index 12b8e401..d7df0332 100644
--- a/src/main/java/com/youlai/boot/config/SecurityConfig.java
+++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java
@@ -74,6 +74,8 @@ public class SecurityConfig {
requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll();
// open 认证接口(登录/注册/刷新/退出)免登录;其业务接口统一走下方 authenticated() 保护
requestMatcherRegistry.requestMatchers("/api/v1/open/**").permitAll();
+ // WebRTC 信令端点:双身份(设备签名 / client JWT)由握手拦截器自行鉴权,不走标准 HTTP 认证链
+ requestMatcherRegistry.requestMatchers("/ws/signal").permitAll();
// client 家属端接口:与后台管理使用同一套 TokenManager 鉴权,
// 但仅允许携带 client 端令牌(clientType=client)访问,实现端与端隔离
requestMatcherRegistry.requestMatchers("/api/v1/client/**")
diff --git a/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java b/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java
index 7f6cbbd3..993094d9 100644
--- a/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java
+++ b/src/main/java/com/youlai/boot/device/controller/DeviceOpsController.java
@@ -154,6 +154,14 @@ public class DeviceOpsController {
return Result.judge(result);
}
+ @Operation(summary = "屏幕镜像", description = "通知设备开启屏幕镜像(由家属端 client 进行 WebRTC 实时远程观看与控制)")
+ @PostMapping("/mirror")
+ @Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.REFRESH)
+ public Result> mirror(@RequestParam String sn) {
+ boolean result = deviceService.mirror(sn);
+ return Result.judge(result);
+ }
+
@Operation(summary = "开发者模式")
@PostMapping("/developer")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.DEVELOPER)
diff --git a/src/main/java/com/youlai/boot/device/controller/MobileController.java b/src/main/java/com/youlai/boot/device/controller/MobileController.java
index 142c7018..3eab0aed 100644
--- a/src/main/java/com/youlai/boot/device/controller/MobileController.java
+++ b/src/main/java/com/youlai/boot/device/controller/MobileController.java
@@ -33,6 +33,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -198,6 +199,18 @@ public class MobileController {
}
}
+ // 设备上报手机号:仅当设备未建立绑定(snMobile 为空)时同步,避免覆盖家属端手动建立的绑定
+ if (snOtherInfoReq.getPhoneNumber() != null && !snOtherInfoReq.getPhoneNumber().trim().isEmpty()) {
+ SnDeviceInfo deviceInfo = deviceService.lambdaQuery()
+ .eq(SnDeviceInfo::getSerialno, sn)
+ .one();
+ if (deviceInfo != null && !StringUtils.hasText(deviceInfo.getSnMobile())) {
+ deviceInfo.setSnMobile(snOtherInfoReq.getPhoneNumber());
+ deviceInfo.setUpdateTime(LocalDateTime.now());
+ deviceService.updateById(deviceInfo);
+ }
+ }
+
logger.info("其他信息上传成功, sn: {}", sn);
return Result.success();
diff --git a/src/main/java/com/youlai/boot/support/webrtc/config/WebRtcWebSocketConfig.java b/src/main/java/com/youlai/boot/support/webrtc/config/WebRtcWebSocketConfig.java
new file mode 100644
index 00000000..048ce4ef
--- /dev/null
+++ b/src/main/java/com/youlai/boot/support/webrtc/config/WebRtcWebSocketConfig.java
@@ -0,0 +1,31 @@
+package com.youlai.boot.support.webrtc.config;
+
+import com.youlai.boot.support.webrtc.session.SignalHandshakeInterceptor;
+import com.youlai.boot.support.webrtc.session.SignalWebSocketHandler;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+
+/**
+ * WebRTC 信令 WebSocket 端点配置。
+ *
+ * 注册 {@code /ws/signal}:设备端与家属端 client 共用同一端点,
+ * 由 {@link SignalHandshakeInterceptor} 在握手阶段识别双身份(设备签名 / client JWT)。
+ */
+@Configuration
+@EnableWebSocket
+@RequiredArgsConstructor
+public class WebRtcWebSocketConfig implements WebSocketConfigurer {
+
+ private final SignalWebSocketHandler signalWebSocketHandler;
+ private final SignalHandshakeInterceptor signalHandshakeInterceptor;
+
+ @Override
+ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
+ registry.addHandler(signalWebSocketHandler, "/ws/signal")
+ .addInterceptors(signalHandshakeInterceptor)
+ .setAllowedOriginPatterns("*");
+ }
+}
diff --git a/src/main/java/com/youlai/boot/support/webrtc/dto/SignalMessage.java b/src/main/java/com/youlai/boot/support/webrtc/dto/SignalMessage.java
new file mode 100644
index 00000000..4248fe44
--- /dev/null
+++ b/src/main/java/com/youlai/boot/support/webrtc/dto/SignalMessage.java
@@ -0,0 +1,32 @@
+package com.youlai.boot.support.webrtc.dto;
+
+import lombok.Data;
+
+/**
+ * WebRTC 信令消息(控制端 ↔ 设备端,经服务器转发)。
+ *
+ * 字段与移动端 {@code SignalMessage} 对齐(JSON 键名一致),类型语义:
+ *
+ * - type:OFFER / ANSWER / ICE_CANDIDATE / REGISTER_SUCCESS / CONNECTION_REJECTED /
+ * TARGET_OFFLINE / REQUEST_ERROR / REQUEST_TIMEOUT / FORCE_LOGOUT / PING
+ * - fromDeviceId / toDeviceId:设备或控制端标识
+ * - deviceType:CONTROLLER / CONTROLLED(由服务器按身份强制覆盖,客户端不可自报)
+ * - payload:按 type 承载 SDP / ICE candidate 的 JSON 字符串
+ *
+ */
+@Data
+public class SignalMessage {
+
+ /** 消息类型:OFFER / ANSWER / ICE_CANDIDATE / REGISTER_SUCCESS / CONNECTION_REJECTED / TARGET_OFFLINE 等 */
+ private String type;
+
+ private String fromDeviceId;
+
+ private String toDeviceId;
+
+ /** CONTROLLER(控制端=client 家属端)/ CONTROLLED(被控端=设备)。由服务器识别身份后覆盖。 */
+ private String deviceType;
+
+ /** 按 type 承载 SDP({"sdp": "..."})或 ICE candidate({"sdpMid":"","sdpMLineIndex":0,"candidate":"..."}) */
+ private String payload;
+}
diff --git a/src/main/java/com/youlai/boot/support/webrtc/session/SessionManager.java b/src/main/java/com/youlai/boot/support/webrtc/session/SessionManager.java
new file mode 100644
index 00000000..a2b1f05c
--- /dev/null
+++ b/src/main/java/com/youlai/boot/support/webrtc/session/SessionManager.java
@@ -0,0 +1,90 @@
+package com.youlai.boot.support.webrtc.session;
+
+import com.youlai.boot.support.webrtc.dto.SignalMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.TextMessage;
+import org.springframework.web.socket.WebSocketSession;
+import tools.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * WebRTC 信令会话管理器。
+ *
+ * 维护「标识 → WebSocket 会话」的映射,支持按 toDeviceId 精确转发信令消息。
+ * 会话注册时即带身份信息(deviceType / 目标设备 sn),用于路由与后续断开清理。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SessionManager {
+
+ private final ObjectMapper objectMapper;
+
+ /** 已连接标识(SN 或 clientId)→ 会话 */
+ private final Map sessions = new ConcurrentHashMap<>();
+
+ /**
+ * 注册会话(设备端或控制端)。
+ *
+ * @param deviceId 标识:设备端为 SN,控制端为 client 用户名/ID
+ * @param session 会话
+ */
+ public void register(String deviceId, WebSocketSession session) {
+ WebSocketSession old = sessions.put(deviceId, session);
+ if (old != null && old.isOpen()) {
+ try {
+ old.close();
+ } catch (IOException e) {
+ log.warn("关闭被顶替的旧会话失败: {}", e.getMessage());
+ }
+ }
+ log.info("[webrtc] 会话注册: {} ({}), 当前在线 {}", deviceId, session.getId(), sessions.size());
+ }
+
+ public void unregister(String deviceId) {
+ sessions.remove(deviceId);
+ log.info("[webrtc] 会话注销: {}, 当前在线 {}", deviceId, sessions.size());
+ }
+
+ public boolean isOnline(String deviceId) {
+ WebSocketSession s = sessions.get(deviceId);
+ return s != null && s.isOpen();
+ }
+
+ /**
+ * 向指定标识转发消息。目标离线时返回 false。
+ */
+ public boolean sendTo(String deviceId, SignalMessage message) {
+ WebSocketSession target = sessions.get(deviceId);
+ if (target == null || !target.isOpen()) {
+ return false;
+ }
+ try {
+ synchronized (target) {
+ target.sendMessage(new TextMessage(toJson(message)));
+ }
+ return true;
+ } catch (IOException e) {
+ log.error("[webrtc] 转发失败 to={}: {}", deviceId, e.getMessage());
+ return false;
+ }
+ }
+
+ public Map getSessions() {
+ return sessions;
+ }
+
+ private String toJson(SignalMessage message) {
+ try {
+ return objectMapper.writeValueAsString(message);
+ } catch (Exception e) {
+ log.error("[webrtc] 信令序列化失败: {}", e.getMessage());
+ return "{}";
+ }
+ }
+}
diff --git a/src/main/java/com/youlai/boot/support/webrtc/session/SignalHandshakeInterceptor.java b/src/main/java/com/youlai/boot/support/webrtc/session/SignalHandshakeInterceptor.java
new file mode 100644
index 00000000..d043c527
--- /dev/null
+++ b/src/main/java/com/youlai/boot/support/webrtc/session/SignalHandshakeInterceptor.java
@@ -0,0 +1,164 @@
+package com.youlai.boot.support.webrtc.session;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import com.youlai.boot.framework.security.model.SecurityUserDetails;
+import com.youlai.boot.framework.security.token.TokenManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Component;
+import org.springframework.web.socket.WebSocketHandler;
+import org.springframework.web.socket.server.HandshakeInterceptor;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * WebRTC 信令握手鉴权拦截器(识别双身份)。
+ *
+ * 遵循平台原有用户逻辑,区分两端:
+ *
+ * - 设备端(CONTROLLED):携带 {@code X-Device-SN/X-Nonce/X-Timestamp/X-Sign},
+ * 与 HTTP 层 {@code MobileApiSignatureFilter} 相同的 SHA-256 验签 + 2 分钟时间戳容差。
+ * - 家属端 client(CONTROLLER):携带 {@code Authorization: Bearer },
+ * 解析后要求 {@code clientType == client}(admin 令牌不可连入)。
+ *
+ * 两端互斥:既带设备签名又带 Bearer 时优先按设备端处理;都不带则拒绝。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SignalHandshakeInterceptor implements HandshakeInterceptor {
+
+ private static final String HEADER_DEVICE_ID = "X-Device-SN";
+ private static final String HEADER_NONCE = "X-Nonce";
+ private static final String HEADER_TIMESTAMP = "X-Timestamp";
+ private static final String HEADER_SIGN = "X-Sign";
+ private static final long SIGN_VALID_DURATION = 2 * 60 * 1000L;
+ private static final String CLIENT_TYPE_CLIENT = "client";
+
+ /** 会话属性键:身份标识(SN 或 client 用户名) */
+ public static final String ATTR_DEVICE_ID = "deviceId";
+ /** 会话属性键:设备端标识(仅设备端会话存在) */
+ public static final String ATTR_SN = "sn";
+ /** 会话属性键:client 用户名(仅 client 会话存在) */
+ public static final String ATTR_CLIENT_NAME = "clientName";
+ /** 会话属性键:deviceType = CONTROLLED / CONTROLLER */
+ public static final String ATTR_DEVICE_TYPE = "deviceType";
+ /** 会话属性键:client 请求控制的目标设备 SN */
+ public static final String ATTR_TARGET_SN = "targetSn";
+
+ private final TokenManager tokenManager;
+
+ @Override
+ public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
+ WebSocketHandler wsHandler, Map attributes) {
+
+ // 1. 设备端签名鉴权(优先)
+ String sn = request.getHeaders().getFirst(HEADER_DEVICE_ID);
+ if (StrUtil.isNotBlank(sn)) {
+ if (verifyDeviceSignature(request, sn)) {
+ attributes.put(ATTR_DEVICE_ID, sn);
+ attributes.put(ATTR_SN, sn);
+ attributes.put(ATTR_DEVICE_TYPE, "CONTROLLED");
+ log.info("[webrtc] 设备端握手通过: sn={}", sn);
+ return true;
+ }
+ log.warn("[webrtc] 设备端签名校验失败: sn={}", sn);
+ return false;
+ }
+
+ // 2. 家属端 client JWT 鉴权
+ String authHeader = request.getHeaders().getFirst("Authorization");
+ if (StrUtil.isNotBlank(authHeader) && authHeader.startsWith("Bearer ")) {
+ String token = authHeader.substring(7).trim();
+ return authenticateClient(token, request, attributes);
+ }
+
+ log.warn("[webrtc] 握手拒绝: 缺少设备签名或 client 令牌");
+ return false;
+ }
+
+ private boolean authenticateClient(String token, ServerHttpRequest request, Map attributes) {
+ try {
+ if (!tokenManager.validateToken(token)) {
+ log.warn("[webrtc] client 令牌无效");
+ return false;
+ }
+ Authentication auth = tokenManager.parseToken(token);
+ if (auth == null || !(auth.getPrincipal() instanceof SecurityUserDetails details)) {
+ log.warn("[webrtc] client 令牌解析失败");
+ return false;
+ }
+ if (!CLIENT_TYPE_CLIENT.equals(details.getClientType())) {
+ log.warn("[webrtc] 拒绝非 client 令牌连接: clientType={}", details.getClientType());
+ return false;
+ }
+
+ String clientName = details.getUsername();
+ attributes.put(ATTR_DEVICE_ID, clientName);
+ attributes.put(ATTR_CLIENT_NAME, clientName);
+ attributes.put(ATTR_DEVICE_TYPE, "CONTROLLER");
+
+ // 目标设备 SN(client 要控制谁),从 query 参数读取
+ String targetSn = request.getURI().getQuery() == null ? null
+ : extractQueryParam(request.getURI().getQuery(), "sn");
+ if (StrUtil.isNotBlank(targetSn)) {
+ attributes.put(ATTR_TARGET_SN, targetSn);
+ }
+ log.info("[webrtc] client 握手通过: user={}, targetSn={}", clientName, targetSn);
+ return true;
+ } catch (Exception e) {
+ log.error("[webrtc] client 鉴权异常: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ private boolean verifyDeviceSignature(ServerHttpRequest request, String sn) {
+ String nonce = request.getHeaders().getFirst(HEADER_NONCE);
+ String timestampStr = request.getHeaders().getFirst(HEADER_TIMESTAMP);
+ String sign = request.getHeaders().getFirst(HEADER_SIGN);
+ if (StrUtil.isBlank(nonce) || StrUtil.isBlank(timestampStr) || StrUtil.isBlank(sign)) {
+ return false;
+ }
+ long timestamp;
+ try {
+ timestamp = Long.parseLong(timestampStr);
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ if (Math.abs(System.currentTimeMillis() - timestamp) > SIGN_VALID_DURATION) {
+ return false;
+ }
+ SortedMap params = new TreeMap<>();
+ params.put(HEADER_DEVICE_ID, sn);
+ params.put(HEADER_NONCE, nonce);
+ params.put(HEADER_TIMESTAMP, timestampStr);
+ StringBuilder sb = new StringBuilder();
+ params.forEach((k, v) -> sb.append(k).append("=").append(v).append("&"));
+ sb.setLength(sb.length() - 1);
+ String expected = DigestUtil.sha256Hex(sb.toString());
+ return sign.equals(expected);
+ }
+
+ private String extractQueryParam(String query, String key) {
+ for (String pair : query.split("&")) {
+ int idx = pair.indexOf('=');
+ if (idx > 0 && pair.substring(0, idx).equals(key)) {
+ return pair.substring(idx + 1);
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
+ WebSocketHandler wsHandler, Exception exception) {
+ // 无需额外处理
+ }
+}
diff --git a/src/main/java/com/youlai/boot/support/webrtc/session/SignalWebSocketHandler.java b/src/main/java/com/youlai/boot/support/webrtc/session/SignalWebSocketHandler.java
new file mode 100644
index 00000000..7acdc446
--- /dev/null
+++ b/src/main/java/com/youlai/boot/support/webrtc/session/SignalWebSocketHandler.java
@@ -0,0 +1,183 @@
+package com.youlai.boot.support.webrtc.session;
+
+import cn.hutool.core.util.StrUtil;
+import com.youlai.boot.client.model.entity.ClientUser;
+import com.youlai.boot.client.service.ClientUserService;
+import com.youlai.boot.device.model.entity.SnDeviceInfo;
+import com.youlai.boot.device.service.DeviceService;
+import com.youlai.boot.support.webrtc.dto.SignalMessage;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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 com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import tools.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * WebRTC 信令 WebSocket 处理器(消息转发)。
+ *
+ * 职责:
+ *
+ * - 连接建立后,按握手身份注册会话并下发 {@code REGISTER_SUCCESS}。
+ * - 转发 OFFER/ANSWER/ICE_CANDIDATE 到目标标识;服务端强制覆盖 deviceType,防伪造。
+ * - client 控制端发起 OFFER 前,校验其已绑定目标设备(复用 snMobile==mobile 绑定关系)。
+ *
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SignalWebSocketHandler extends TextWebSocketHandler {
+
+ private final SessionManager sessionManager;
+ private final DeviceService deviceService;
+ private final ClientUserService clientUserService;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public void afterConnectionEstablished(WebSocketSession session) {
+ Map attrs = session.getAttributes();
+ String deviceId = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_ID);
+ String deviceType = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_TYPE);
+ if (StrUtil.isBlank(deviceId)) {
+ closeSession(session, CloseStatus.POLICY_VIOLATION, "缺失身份");
+ return;
+ }
+ sessionManager.register(deviceId, session);
+ log.info("[webrtc] 连接建立: {} ({})", deviceId, deviceType);
+
+ // 下发 REGISTER_SUCCESS
+ SignalMessage reg = new SignalMessage();
+ reg.setType("REGISTER_SUCCESS");
+ reg.setFromDeviceId(deviceId);
+ reg.setToDeviceId(deviceId);
+ reg.setDeviceType(deviceType);
+ sendToSelf(session, reg);
+ }
+
+ @Override
+ protected void handleTextMessage(WebSocketSession session, TextMessage message) {
+ Map attrs = session.getAttributes();
+ String fromDeviceId = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_ID);
+ String deviceType = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_TYPE);
+
+ SignalMessage msg;
+ try {
+ msg = objectMapper.readValue(message.getPayload(), SignalMessage.class);
+ } catch (Exception e) {
+ log.warn("[webrtc] 信令消息解析失败: {}", e.getMessage());
+ return;
+ }
+ if (msg == null || StrUtil.isBlank(msg.getType())) {
+ return;
+ }
+
+ // 服务端强制覆盖身份,防伪造
+ msg.setFromDeviceId(fromDeviceId);
+ msg.setDeviceType(deviceType);
+
+ String type = msg.getType().toUpperCase();
+ String toDeviceId = msg.getToDeviceId();
+ log.debug("[webrtc] 收到并处理: from={}, to={}, type={}, deviceType={}", fromDeviceId, toDeviceId, type, deviceType);
+
+ // client 控制端发起 OFFER 前,校验绑定目标设备
+ if ("CONTROLLER".equals(deviceType) && "OFFER".equals(type)) {
+ if (StrUtil.isBlank(toDeviceId)) {
+ sendToSelf(session, rejected(toDeviceId, "缺少目标设备"));
+ return;
+ }
+ String clientName = fromDeviceId;
+ if (!isBound(clientName, toDeviceId)) {
+ log.warn("[webrtc] client {} 未绑定设备 {},拒绝 OFFER", clientName, toDeviceId);
+ sendToSelf(session, rejected(toDeviceId, "当前用户未绑定该设备"));
+ return;
+ }
+ }
+
+ // 转发:目标离线时回送 TARGET_OFFLINE
+ boolean delivered = sessionManager.sendTo(toDeviceId, msg);
+ log.info("[webrtc] 转发结果: from={}, to={}, type={}, delivered={}, 当前在线设备={}",
+ fromDeviceId, toDeviceId, type, delivered, sessionManager.getSessions().keySet());
+ if (!delivered) {
+ log.warn("[webrtc] 目标离线或不存在: to={}, type={}", toDeviceId, type);
+ SignalMessage offline = new SignalMessage();
+ offline.setType("TARGET_OFFLINE");
+ offline.setFromDeviceId("server");
+ offline.setToDeviceId(fromDeviceId);
+ offline.setDeviceType(deviceType);
+ sendToSelf(session, offline);
+ }
+ }
+
+ @Override
+ public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
+ Map attrs = session.getAttributes();
+ String deviceId = (String) attrs.get(SignalHandshakeInterceptor.ATTR_DEVICE_ID);
+ if (StrUtil.isNotBlank(deviceId)) {
+ sessionManager.unregister(deviceId);
+ }
+ log.info("[webrtc] 连接关闭: deviceId={}, sessionId={}, code={}, reason={}, attrs={}",
+ deviceId, session.getId(), status.getCode(), status.getReason(), attrs);
+ }
+
+ @Override
+ public void handleTransportError(WebSocketSession session, Throwable exception) {
+ log.error("[webrtc] 传输错误: sessionId={}, error={}", session.getId(), exception.getMessage());
+ try {
+ session.close(CloseStatus.SERVER_ERROR);
+ } catch (IOException ignored) {
+ }
+ }
+
+ /** 校验 client 用户(app_user.mobile)是否已绑定目标设备(sys_sn.snMobile)。 */
+ private boolean isBound(String clientName, String sn) {
+ try {
+ ClientUser user = clientUserService.getOne(
+ new LambdaQueryWrapper().eq(ClientUser::getUsername, clientName).last("limit 1")
+ );
+ if (user == null || StrUtil.isBlank(user.getMobile())) {
+ return false;
+ }
+ SnDeviceInfo device = deviceService.getOne(
+ new LambdaQueryWrapper().eq(SnDeviceInfo::getSerialno, sn)
+ );
+ return device != null && user.getMobile().equals(device.getSnMobile());
+ } catch (Exception e) {
+ log.error("[webrtc] 绑定校验异常: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ private SignalMessage rejected(String toDeviceId, String reason) {
+ SignalMessage msg = new SignalMessage();
+ msg.setType("CONNECTION_REJECTED");
+ msg.setFromDeviceId("server");
+ msg.setToDeviceId(toDeviceId);
+ msg.setDeviceType("CONTROLLER");
+ msg.setPayload("{\"reason\":\"" + reason + "\"}");
+ return msg;
+ }
+
+ private void sendToSelf(WebSocketSession session, SignalMessage msg) {
+ try {
+ synchronized (session) {
+ session.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg)));
+ }
+ } catch (Exception e) {
+ log.error("[webrtc] 回送失败: {}", e.getMessage());
+ }
+ }
+
+ private void closeSession(WebSocketSession session, CloseStatus status, String reason) {
+ try {
+ session.close(status.withReason(reason));
+ } catch (IOException e) {
+ log.warn("[webrtc] 关闭异常: {}", e.getMessage());
+ }
+ }
+}