Files
VibeCoding/WebRTCSignalServer/src/main/java/com/ttstd/signaling/handler/SignalWebSocketHandler.java
TongTongStudio e689b999e6 feat(controlled, controller): 新增配对码生成与兑换功能
被控端:新增生成一次性配对码并弹窗展示,支持倒计时与复制;控制端:新增输入配对码兑换绑定,刷新设备列表,并在设备列表中显示在线状态和点击连接。同时添加统一 401 拦截器,令牌失效时触发重新激活。
2026-08-03 01:57:42 +08:00

465 lines
22 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.ttstd.signaling.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ttstd.signaling.manager.ConnectionRequestManager;
import com.ttstd.signaling.manager.SessionManager;
import com.ttstd.signaling.manager.SignalMetrics;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.DeviceBinding;
import com.ttstd.signaling.model.DeviceType;
import com.ttstd.signaling.model.SignalMessage;
import com.ttstd.signaling.security.AuthHandshakeInterceptor;
import com.ttstd.signaling.service.AccountService;
import com.ttstd.signaling.service.AuditService;
import com.ttstd.signaling.service.BindingService;
import com.ttstd.signaling.service.DeviceIdentityService;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
public class SignalWebSocketHandler extends TextWebSocketHandler {
private static final Logger logger = LoggerFactory.getLogger(SignalWebSocketHandler.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
/** 因鉴权/封禁被关闭连接时使用的关闭码 */
private static final CloseStatus CLOSE_UNAUTHORIZED = new CloseStatus(4001, "UNAUTHORIZED");
private static final CloseStatus CLOSE_FORCED_LOGOUT = new CloseStatus(4003, "FORCED_LOGOUT");
private final SessionManager sessionManager;
private final ConnectionRequestManager connectionRequestManager;
private final SignalMetrics metrics;
private final AccountService accountService;
private final DeviceIdentityService deviceIdentityService;
private final BindingService bindingService;
private final AuditService auditService;
public SignalWebSocketHandler(SessionManager sessionManager,
ConnectionRequestManager connectionRequestManager,
SignalMetrics metrics,
AccountService accountService,
DeviceIdentityService deviceIdentityService,
BindingService bindingService,
AuditService auditService) {
this.sessionManager = sessionManager;
this.connectionRequestManager = connectionRequestManager;
this.metrics = metrics;
this.accountService = accountService;
this.deviceIdentityService = deviceIdentityService;
this.bindingService = bindingService;
this.auditService = auditService;
this.connectionRequestManager.setSender(this::sendToDevice);
}
/**
* 注册会话失效回调:账号被封禁 / 会话被踢出 / 设备被禁用时立即断开对应连接。
*/
@PostConstruct
void registerRevocationListener() {
AccountService.SessionRevocationListener listener = this::forceDisconnect;
accountService.setRevocationListener(listener);
deviceIdentityService.setRevocationListener(listener);
}
/**
* 强制断开指定主体的连接。
*
* @param principalId 主体 ID
* @param sessionId 指定登录会话;为 null 表示断开该主体全部连接
*/
private void forceDisconnect(String principalId, String sessionId, String reason) {
Set<String> deviceIds = sessionManager.getDeviceIdsByPrincipal(principalId);
for (String deviceId : deviceIds) {
AuthPrincipal principal = sessionManager.getPrincipal(deviceId);
if (principal == null) {
continue;
}
// 指定了会话时只断开该会话,避免误伤同账号其他端
if (sessionId != null && !sessionId.equals(principal.sessionId())) {
continue;
}
WebSocketSession session = sessionManager.getSession(deviceId);
if (session == null || !session.isOpen()) {
continue;
}
Map<String, Object> notice = new HashMap<>();
notice.put("type", "FORCE_LOGOUT");
notice.put("payload", reason == null ? "会话已失效" : reason);
sendToSession(session, notice);
try {
session.close(CLOSE_FORCED_LOGOUT);
} catch (IOException e) {
logger.warn("强制断开连接 {} 失败: {}", deviceId, e.getMessage());
}
logger.info("已强制断开连接: deviceId={} principal={} reason={}",
deviceId, principalId, reason);
}
}
/**
* 向指定设备回送服务端事件(错误/超时通知等)。
*/
private void sendToDevice(String deviceId, Object message) {
WebSocketSession session = sessionManager.getSession(deviceId);
if (session == null || !session.isOpen()) {
logger.warn("无法向 {} 发送事件:连接不在线", deviceId);
return;
}
sendToSession(session, message);
}
/**
* 连接建立后立即以握手阶段裁定的身份完成注册,无需客户端再发 REGISTER。
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
AuthPrincipal principal = principalOf(session);
if (principal == null) {
// 正常情况下握手拦截器已拦截,此处为纵深防御
logger.warn("连接 {} 缺少鉴权主体,立即关闭", session.getId());
session.close(CLOSE_UNAUTHORIZED);
return;
}
sessionManager.registerDevice(principal, session);
metrics.incRegistered();
metrics.recordSessionCount(sessionManager.getOnlineCount());
Map<String, Object> response = new HashMap<>();
response.put("type", "REGISTER_SUCCESS");
response.put("deviceId", principal.deviceId());
// 与既有信令消息协议一致下发的设备ID也写入 fromDeviceId便于客户端统一解析。
response.put("fromDeviceId", principal.deviceId());
response.put("deviceType", principal.deviceType().name());
response.put("displayName", principal.displayName());
sendToSession(session, response);
logger.info("连接已建立并注册: {} ({})", principal.deviceId(), principal.deviceType());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
AuthPrincipal principal = principalOf(session);
if (principal == null) {
session.close(CLOSE_UNAUTHORIZED);
return;
}
String payload = message.getPayload();
logger.debug("收到消息: {}", payload);
metrics.incMessage();
try {
SignalMessage signalMessage = objectMapper.readValue(payload, SignalMessage.class);
String type = signalMessage.getType();
if (type == null) {
logger.warn("消息缺少 type 字段");
return;
}
// 关键:发送方身份一律以服务端鉴权结果覆盖,忽略客户端自报值,
// 防止伪造 fromDeviceId 冒充他人。
String claimedFrom = signalMessage.getFromDeviceId();
if (claimedFrom != null && !claimedFrom.equals(principal.deviceId())) {
logger.warn("客户端自报 fromDeviceId={} 与鉴权身份 {} 不一致,已强制覆盖",
claimedFrom, principal.deviceId());
}
signalMessage.setFromDeviceId(principal.deviceId());
signalMessage.setDeviceType(principal.deviceType().name());
switch (type.toUpperCase()) {
case "REGISTER":
// 身份已在握手阶段确定REGISTER 仅作兼容响应
handleLegacyRegister(session, principal);
break;
case "DEVICE_LIST":
handleDeviceList(session, principal);
break;
case "PING":
// 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接
break;
case "OFFER":
metrics.incOffer();
// 绑定/黑名单前置校验:仅允许已绑定且未被拉黑的主控端发起
if (principal.deviceType() == DeviceType.CONTROLLER) {
String targetUid = signalMessage.getToDeviceId();
if (targetUid == null || targetUid.isEmpty()) {
sendError(session, "缺少目标设备ID");
metrics.incBlockedOffer();
break;
}
if (!bindingService.isBound(targetUid, principal.principalId())) {
sendError(session, "未与该设备建立绑定关系,无法发起连接");
metrics.incBlockedOffer();
auditOfferBlocked(targetUid, principal.principalId(), "NOT_BOUND");
break;
}
if (bindingService.isBlacklisted(targetUid, principal.principalId())) {
sendError(session, "该设备已拒绝来自你的连接");
metrics.incBlockedOffer();
auditOfferBlocked(targetUid, principal.principalId(), "BLACKLISTED");
break;
}
}
// 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
handleConnectionRequest(signalMessage);
break;
case "ANSWER":
metrics.incAnswer();
// 被控端已接受:清理待确认状态并转发 Answer 给主控端
connectionRequestManager.completePendingRequest(
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
forwardMessage(signalMessage, true);
break;
case "CONNECTION_REJECTED":
metrics.incRejected();
// 被控端已拒绝:清理待确认状态并转发拒绝通知给主控端
connectionRequestManager.completePendingRequest(
signalMessage.getToDeviceId(), signalMessage.getFromDeviceId());
forwardMessage(signalMessage, false);
break;
case "ICE_CANDIDATE":
case "CONTROL_COMMAND":
// 普通信令:目标不在线时静默丢弃,不返回提示
forwardMessage(signalMessage, false);
break;
default:
// 其他消息类型直接转发,目标不在线时静默丢弃
forwardMessage(signalMessage, false);
break;
}
} catch (Exception e) {
logger.error("处理消息出错: {}", e.getMessage(), e);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
logger.info("连接已关闭: {} ({})", session.getId(), status);
sessionManager.unregisterSession(session);
metrics.recordSessionCount(sessionManager.getOnlineCount());
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
// EOFException / 连接被对端重置 属于正常网络事件(客户端网络中断、代理空闲超时等),
// 并非服务器错误,降级为 DEBUG 记录,避免误导。
boolean benign = exception instanceof java.io.EOFException
|| (exception.getMessage() != null
&& (exception.getMessage().contains("Connection reset")
|| exception.getMessage().contains("Broken pipe")
|| exception.getMessage().contains("An established connection")));
if (benign) {
logger.debug("传输层关闭(客户端断开),会话 {}: {}",
session.getId(), exception.getMessage());
} else {
logger.warn("传输层错误,会话 {}: {}", session.getId(), exception.getMessage());
}
sessionManager.unregisterSession(session);
metrics.recordSessionCount(sessionManager.getOnlineCount());
// 连接已不可用时无需再以 SERVER_ERROR(1011) 关闭;仅在仍打开时温和关闭。
if (session.isOpen()) {
session.close(CloseStatus.GOING_AWAY);
}
}
private AuthPrincipal principalOf(WebSocketSession session) {
Object attr = session.getAttributes().get(AuthHandshakeInterceptor.ATTR_PRINCIPAL);
return attr instanceof AuthPrincipal principal ? principal : null;
}
/**
* 兼容旧客户端的 REGISTER不再接受客户端自报身份仅回显服务端裁定结果。
*/
private void handleLegacyRegister(WebSocketSession session, AuthPrincipal principal) {
Map<String, Object> response = new HashMap<>();
response.put("type", "REGISTER_SUCCESS");
response.put("deviceId", principal.deviceId());
response.put("fromDeviceId", principal.deviceId());
response.put("deviceType", principal.deviceType().name());
response.put("displayName", principal.displayName());
sendToSession(session, response);
}
/**
* 设备列表(绑定视图)。
*
* <p>为防止枚举被控端,服务端不再向任意主控端返回全局在线设备清单。
* 主控端仅能看到「自己已绑定」的设备及其在线状态;被控端此项为空列表。
*/
private void handleDeviceList(WebSocketSession session, AuthPrincipal principal) {
Map<String, Object> response = new HashMap<>();
response.put("type", "DEVICE_LIST");
if (principal.deviceType() == DeviceType.CONTROLLER) {
List<Map<String, Object>> devices = bindingService.listByUser(principal.principalId())
.stream()
.filter(b -> b.getStatus() == DeviceBinding.BindingStatus.ACTIVE)
.map(b -> {
Map<String, Object> item = new HashMap<>();
item.put("deviceUid", b.getDeviceUid());
item.put("alias", b.getAlias());
item.put("role", b.getRole().name());
item.put("online", sessionManager.isDeviceOnline(b.getDeviceUid()));
return item;
})
.toList();
response.put("boundDevices", devices);
} else {
// 被控端无需浏览主控端列表
response.put("boundDevices", List.of());
}
sendToSession(session, response);
}
/** 记录被拦截的 OFFER未绑定/被拉黑),便于审计与风控。 */
private void auditOfferBlocked(String deviceUid, String userId, String reason) {
try {
auditService.recordUser(userId, AuditService.ACTION_OFFER_BLOCKED,
AuditService.RESULT_SUCCESS, null, "device=" + deviceUid + " reason=" + reason);
} catch (Exception e) {
logger.warn("审计 OFFER 拦截记录失败: {}", e.getMessage());
}
}
/**
* 处理主控端发来的连接请求OFFER先做设备类型校验与去重
* 再登记“待被控端确认”状态,最后转发给被控端。
*/
private void handleConnectionRequest(SignalMessage message) {
String fromDeviceId = message.getFromDeviceId();
String toDeviceId = message.getToDeviceId();
// 1. 校验:仅允许 CONTROLLER -> CONTROLLED
String error = connectionRequestManager.validateOffer(fromDeviceId, toDeviceId);
if (error != null) {
logger.warn("非法连接请求 {} -> {}: {}", fromDeviceId, toDeviceId, error);
Map<String, Object> response = new HashMap<>();
response.put("type", "REQUEST_ERROR");
response.put("toDeviceId", toDeviceId);
response.put("payload", error);
sendToDevice(fromDeviceId, response);
return;
}
// 鉴权类型分类:动态验证码(CODE) / 固定密码(PASSWORD) / 免密(NONE)。
// 服务器只做分类与转发,真实校验在被控端完成(服务器不保存任何密钥)。
String authType = message.getAuthType();
if (authType != null && !authType.trim().isEmpty()) {
if ("CODE".equalsIgnoreCase(authType)) {
logger.info("连接请求 {} -> {} 使用【动态验证码】鉴权", fromDeviceId, toDeviceId);
} else if ("PASSWORD".equalsIgnoreCase(authType)) {
logger.info("连接请求 {} -> {} 使用【固定密码】鉴权", fromDeviceId, toDeviceId);
} else if ("NONE".equalsIgnoreCase(authType)) {
// 显式标记为免密,便于被控端识别
message.setAuthType("NONE");
logger.info("连接请求 {} -> {} 使用【免密连接】鉴权(被控端手动确认)", fromDeviceId, toDeviceId);
} else {
logger.warn("连接请求 {} -> {} 携带未知鉴权类型 authType={}", fromDeviceId, toDeviceId, authType);
}
} else {
// 未携带 authType 也视为免密连接(向后兼容),规范化为 NONE
message.setAuthType("NONE");
logger.info("连接请求 {} -> {} 未携带鉴权,按【免密连接】处理(被控端手动确认)", fromDeviceId, toDeviceId);
}
// 2. 去重:短时间内重复 OFFER 直接忽略,避免被控端反复弹窗
if (connectionRequestManager.isDuplicateOffer(fromDeviceId, toDeviceId)) {
logger.info("重复连接请求 {} -> {} 已忽略", fromDeviceId, toDeviceId);
return;
}
// 3. 目标不在线:立即回送 TARGET_OFFLINE且不登记待确认避免等待超时
if (!sessionManager.isDeviceOnline(toDeviceId)) {
logger.warn("目标被控端 {} 不在线,无法投递连接请求", toDeviceId);
notifySenderTargetOffline(message, toDeviceId);
return;
}
// 4. 登记待确认并转发 OFFER 给被控端
connectionRequestManager.registerPendingRequest(fromDeviceId, toDeviceId);
forwardMessage(message, false);
}
private void forwardMessage(SignalMessage message, boolean notifyOffline) {
String toDeviceId = message.getToDeviceId();
if (toDeviceId == null) {
logger.warn("无法转发消息toDeviceId 为空");
return;
}
WebSocketSession targetSession = sessionManager.getSession(toDeviceId);
if (targetSession == null || !targetSession.isOpen()) {
logger.warn("目标设备 {} 不在线", toDeviceId);
// 仅 OFFER / ANSWER 在目标不在线时回送 TARGET_OFFLINE其余类型静默丢弃
if (notifyOffline) {
notifySenderTargetOffline(message, toDeviceId);
}
return;
}
try {
String jsonMessage = objectMapper.writeValueAsString(message);
targetSession.sendMessage(new TextMessage(jsonMessage));
logger.debug("已转发 {}{} -> {}", message.getType(), message.getFromDeviceId(), toDeviceId);
} catch (IOException e) {
logger.error("转发消息到 {} 失败: {}", toDeviceId, e.getMessage());
}
}
/**
* 目标被控端不在线时,向发送方(主控端)回送 TARGET_OFFLINE 提醒。
*/
private void notifySenderTargetOffline(SignalMessage message, String offlineDeviceId) {
String fromDeviceId = message.getFromDeviceId();
if (fromDeviceId == null) {
logger.warn("无法通知离线状态fromDeviceId 为空");
return;
}
WebSocketSession senderSession = sessionManager.getSession(fromDeviceId);
if (senderSession == null || !senderSession.isOpen()) {
logger.warn("发送方 {} 会话不存在,无法通知目标离线", fromDeviceId);
return;
}
Map<String, Object> response = new HashMap<>();
response.put("type", "TARGET_OFFLINE");
response.put("toDeviceId", offlineDeviceId);
response.put("payload", "目标被控端不在线,请确认设备已开启并连接到信令服务器");
sendToSession(senderSession, response);
logger.info("已通知发送方 {} 目标 {} 不在线", fromDeviceId, offlineDeviceId);
}
private void sendToSession(WebSocketSession session, Object data) {
try {
String json = objectMapper.writeValueAsString(data);
session.sendMessage(new TextMessage(json));
} catch (IOException e) {
logger.error("向会话 {} 发送消息失败: {}", session.getId(), e.getMessage());
}
}
/** 向发送方回送一条 REQUEST_ERROR 业务提示(不关闭连接)。 */
private void sendError(WebSocketSession session, String message) {
Map<String, Object> response = new HashMap<>();
response.put("type", "REQUEST_ERROR");
response.put("payload", message);
sendToSession(session, response);
}
}