feat(client): 添加设备绑定/解绑与屏幕镜像功能
新增家属端设备绑定/解绑接口,支持设备上报手机号同步,并引入 WebSocket 依赖以支持 WebRTC 屏幕镜像信令端点。
This commit is contained in:
@@ -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<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/**")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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("*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.youlai.boot.support.webrtc.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* WebRTC 信令消息(控制端 ↔ 设备端,经服务器转发)。
|
||||
* <p>
|
||||
* 字段与移动端 {@code SignalMessage} 对齐(JSON 键名一致),类型语义:
|
||||
* <ul>
|
||||
* <li>type:OFFER / ANSWER / ICE_CANDIDATE / REGISTER_SUCCESS / CONNECTION_REJECTED /
|
||||
* TARGET_OFFLINE / REQUEST_ERROR / REQUEST_TIMEOUT / FORCE_LOGOUT / PING</li>
|
||||
* <li>fromDeviceId / toDeviceId:设备或控制端标识</li>
|
||||
* <li>deviceType:CONTROLLER / 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;
|
||||
}
|
||||
@@ -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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>家属端 client(CONTROLLER)</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");
|
||||
|
||||
// 目标设备 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<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) {
|
||||
// 无需额外处理
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user