From 76e239212a2439428cb2fbb3f2d39ee13f1f4fac Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Wed, 26 Aug 2026 06:16:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E6=B7=BB=E5=8A=A0=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E7=BB=91=E5=AE=9A/=E8=A7=A3=E7=BB=91=E4=B8=8E?= =?UTF-8?q?=E5=B1=8F=E5=B9=95=E9=95=9C=E5=83=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增家属端设备绑定/解绑接口,支持设备上报手机号同步,并引入 WebSocket 依赖以支持 WebRTC 屏幕镜像信令端点。 --- pom.xml | 6 + .../client/controller/ClientController.java | 88 +++++++++ .../youlai/boot/config/SecurityConfig.java | 2 + .../controller/DeviceOpsController.java | 8 + .../device/controller/MobileController.java | 13 ++ .../webrtc/config/WebRtcWebSocketConfig.java | 31 +++ .../support/webrtc/dto/SignalMessage.java | 32 +++ .../webrtc/session/SessionManager.java | 90 +++++++++ .../session/SignalHandshakeInterceptor.java | 164 ++++++++++++++++ .../session/SignalWebSocketHandler.java | 183 ++++++++++++++++++ 10 files changed, 617 insertions(+) create mode 100644 src/main/java/com/youlai/boot/support/webrtc/config/WebRtcWebSocketConfig.java create mode 100644 src/main/java/com/youlai/boot/support/webrtc/dto/SignalMessage.java create mode 100644 src/main/java/com/youlai/boot/support/webrtc/session/SessionManager.java create mode 100644 src/main/java/com/youlai/boot/support/webrtc/session/SignalHandshakeInterceptor.java create mode 100644 src/main/java/com/youlai/boot/support/webrtc/session/SignalWebSocketHandler.java diff --git a/pom.xml b/pom.xml index c56f38d9..239ba78e 100644 --- a/pom.xml +++ b/pom.xml @@ -103,6 +103,12 @@ spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/youlai/boot/client/controller/ClientController.java b/src/main/java/com/youlai/boot/client/controller/ClientController.java index 4c236975..f8df1020 100644 --- a/src/main/java/com/youlai/boot/client/controller/ClientController.java +++ b/src/main/java/com/youlai/boot/client/controller/ClientController.java @@ -25,6 +25,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -198,4 +199,91 @@ public class ClientController { return Result.failed("获取设备列表失败: " + e.getMessage()); } } + + @Operation(summary = "绑定设备(家属端)", description = "将当前登录的 client 用户手机号写入设备 snMobile,建立绑定关系") + @PreAuthorize("isAuthenticated()") + @PostMapping("/bind") + public Result bindDevice( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + if (!StringUtils.hasText(sn)) { + return Result.failed("设备序列号不能为空"); + } + + Long userId = SecurityUtils.getUserId(); + ClientUser clientUser = clientUserService.getById(userId); + if (clientUser == null || !StringUtils.hasText(clientUser.getMobile())) { + return Result.failed("当前用户未绑定手机号,无法绑定设备"); + } + + SnDeviceInfo deviceInfo = deviceService.getOne( + new LambdaQueryWrapper().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(clientUser.getMobile()); + boolean result = deviceService.updateById(update); + return Result.judge(result); + } catch (BusinessException be) { + return Result.failed(be.getMessage()); + } catch (Exception e) { + log.error("bindDevice error, sn: {}", sn, e); + return Result.failed("绑定设备失败: " + e.getMessage()); + } + } + + @Operation(summary = "解绑设备(家属端)", description = "解除当前登录的 client 用户与设备的绑定关系,仅允许本人解绑") + @PreAuthorize("isAuthenticated()") + @PostMapping("/unbind") + public Result unbindDevice( + @Parameter(description = "设备序列号") @RequestParam("sn") String sn + ) { + try { + if (!StringUtils.hasText(sn)) { + return Result.failed("设备序列号不能为空"); + } + + Long userId = SecurityUtils.getUserId(); + ClientUser clientUser = clientUserService.getById(userId); + if (clientUser == null || !StringUtils.hasText(clientUser.getMobile())) { + return Result.failed("当前用户未绑定手机号,无法解绑设备"); + } + + SnDeviceInfo deviceInfo = deviceService.getOne( + new LambdaQueryWrapper().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()); + } + } +}