feat(client): 添加设备绑定/解绑与屏幕镜像功能

新增家属端设备绑定/解绑接口,支持设备上报手机号同步,并引入 WebSocket 依赖以支持 WebRTC 屏幕镜像信令端点。
This commit is contained in:
TongTongStudio
2026-08-26 06:16:55 +08:00
parent 2eb14103a1
commit 76e239212a
10 changed files with 617 additions and 0 deletions

View File

@@ -103,6 +103,12 @@
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- WebRTC 远程控制WebSocket 信令端点(/ws/signal -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>

View File

@@ -25,6 +25,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -198,4 +199,91 @@ public class ClientController {
return Result.failed("获取设备列表失败: " + e.getMessage()); 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<SnDeviceInfo>().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<SnDeviceInfo>().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());
}
}
} }

View File

@@ -74,6 +74,8 @@ public class SecurityConfig {
requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll(); requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll();
// open 认证接口(登录/注册/刷新/退出)免登录;其业务接口统一走下方 authenticated() 保护 // open 认证接口(登录/注册/刷新/退出)免登录;其业务接口统一走下方 authenticated() 保护
requestMatcherRegistry.requestMatchers("/api/v1/open/**").permitAll(); requestMatcherRegistry.requestMatchers("/api/v1/open/**").permitAll();
// WebRTC 信令端点:双身份(设备签名 / client JWT由握手拦截器自行鉴权不走标准 HTTP 认证链
requestMatcherRegistry.requestMatchers("/ws/signal").permitAll();
// client 家属端接口:与后台管理使用同一套 TokenManager 鉴权, // client 家属端接口:与后台管理使用同一套 TokenManager 鉴权,
// 但仅允许携带 client 端令牌clientType=client访问实现端与端隔离 // 但仅允许携带 client 端令牌clientType=client访问实现端与端隔离
requestMatcherRegistry.requestMatchers("/api/v1/client/**") requestMatcherRegistry.requestMatchers("/api/v1/client/**")

View File

@@ -154,6 +154,14 @@ public class DeviceOpsController {
return Result.judge(result); 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 = "开发者模式") @Operation(summary = "开发者模式")
@PostMapping("/developer") @PostMapping("/developer")
@Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.DEVELOPER) @Log(module = LogModuleEnum.DEVICE, value = ActionTypeEnum.DEVELOPER)

View File

@@ -33,6 +33,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; 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); logger.info("其他信息上传成功, sn: {}", sn);
return Result.success(); return Result.success();

View File

@@ -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 端点配置。
* <p>
* 注册 {@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("*");
}
}

View File

@@ -0,0 +1,32 @@
package com.youlai.boot.support.webrtc.dto;
import lombok.Data;
/**
* WebRTC 信令消息(控制端 ↔ 设备端,经服务器转发)。
* <p>
* 字段与移动端 {@code SignalMessage} 对齐JSON 键名一致),类型语义:
* <ul>
* <li>typeOFFER / ANSWER / ICE_CANDIDATE / REGISTER_SUCCESS / CONNECTION_REJECTED /
* TARGET_OFFLINE / REQUEST_ERROR / REQUEST_TIMEOUT / FORCE_LOGOUT / PING</li>
* <li>fromDeviceId / toDeviceId设备或控制端标识</li>
* <li>deviceTypeCONTROLLER / CONTROLLED由服务器按身份强制覆盖客户端不可自报</li>
* <li>payload按 type 承载 SDP / ICE candidate 的 JSON 字符串</li>
* </ul>
*/
@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;
}

View File

@@ -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 信令会话管理器。
* <p>
* 维护「标识 → WebSocket 会话」的映射,支持按 toDeviceId 精确转发信令消息。
* 会话注册时即带身份信息deviceType / 目标设备 sn用于路由与后续断开清理。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionManager {
private final ObjectMapper objectMapper;
/** 已连接标识SN 或 clientId→ 会话 */
private final Map<String, WebSocketSession> 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<String, WebSocketSession> getSessions() {
return sessions;
}
private String toJson(SignalMessage message) {
try {
return objectMapper.writeValueAsString(message);
} catch (Exception e) {
log.error("[webrtc] 信令序列化失败: {}", e.getMessage());
return "{}";
}
}
}

View File

@@ -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 信令握手鉴权拦截器(识别双身份)。
* <p>
* 遵循平台原有用户逻辑,区分两端:
* <ul>
* <li><b>设备端CONTROLLED</b>:携带 {@code X-Device-SN/X-Nonce/X-Timestamp/X-Sign}
* 与 HTTP 层 {@code MobileApiSignatureFilter} 相同的 SHA-256 验签 + 2 分钟时间戳容差。</li>
* <li><b>家属端 clientCONTROLLER</b>:携带 {@code Authorization: Bearer <JWT>}
* 解析后要求 {@code clientType == client}admin 令牌不可连入)。</li>
* </ul>
* 两端互斥:既带设备签名又带 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<String, Object> 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<String, Object> 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");
// 目标设备 SNclient 要控制谁),从 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<String, String> 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) {
// 无需额外处理
}
}

View File

@@ -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 处理器(消息转发)。
* <p>
* 职责:
* <ul>
* <li>连接建立后,按握手身份注册会话并下发 {@code REGISTER_SUCCESS}。</li>
* <li>转发 OFFER/ANSWER/ICE_CANDIDATE 到目标标识;服务端强制覆盖 deviceType防伪造。</li>
* <li>client 控制端发起 OFFER 前,校验其已绑定目标设备(复用 snMobile==mobile 绑定关系)。</li>
* </ul>
*/
@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<String, Object> 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<String, Object> 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<String, Object> 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<ClientUser>().eq(ClientUser::getUsername, clientName).last("limit 1")
);
if (user == null || StrUtil.isBlank(user.getMobile())) {
return false;
}
SnDeviceInfo device = deviceService.getOne(
new LambdaQueryWrapper<SnDeviceInfo>().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());
}
}
}