WebRTC实现无插件多端视频通话
环境
- 前端:HTML5 + jQuery
- 后端:JDK-1.8 + SpringBoot-1.4.2
- 浏览器:谷歌/火狐/360
简介
WebRTC负责浏览器间直接的音视频数据传输,HTML负责前端音视频的采集和展示,信令服务器则是 “牵线搭桥” 的角色,解决WebRTC无法直接交换连接信息的问题。本文以实现网页端之间的视频通话为主,安卓端需要自行开发测试,原理是相通的。
| 概念 | 作用 |
|---|---|
| WebRTC | 浏览器原生的实时通信 API,让两个浏览器(端)直接建立P2P连接,实现无插件传输音视频/数据 |
| RTCPeerConnection | WebRTC 核心对象,负责管理P2P连接、处理音视频数据传输、收集ICE候选 |
| SDP | 描述音视频编码格式、网络信息等会话规则 |
| ICE | 解决NAT/防火墙穿透问题,生成可访问的网络地址(ICE候选),让不同内网的设备能找到彼此 |
| HTML | 通过video标签展示音视频流,配合JavaScript调用WebRTC API完成采集、连接等逻辑 |
| WebSocket | 实现浏览器与信令服务器之间的双向实时通信 |
| 信令服务器 | 负责交换连接参数(如SDP、ICE候选)和通话的处理结果 |
一、信令服务器
1、添加pom依赖(核心是websocket)
<dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> </dependencies> 2、配置实体类(呼叫排队、信令消息、消息类型枚举)
/** * 呼叫排队 */ @Data @NoArgsConstructor @AllArgsConstructor public class CallWaitItem { private String fromUserId; // 呼叫方ID private String toUserId; // 被叫方ID private String offerData; // 缓存的Offer数据 private long queueTime; // 排队时间戳(毫秒) private int queueIndex; // 队列位置 } /** * WebRTC 信令消息 */ @Data @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) // 忽略未知字段 public class WebrtcMessage { private String type; // 消息类型(对应WebrtcMessageType) private String fromUserId; // 发送方ID private String toUserId; // 接收方ID private String data; // 核心数据(SDP/ICE/提示信息等) } /** * WebRTC 消息类型枚举 */ @Getter public enum WebrtcMessageType { OFFER("offer"), // 发起呼叫 ANSWER("answer"), // 接听呼叫 ICE_CANDIDATE("iceCandidate"), // 网络候选信息 LEAVE("leave"), // 主动挂断/取消呼叫 REJECT("reject"), // 拒接呼叫 ERROR("error"), // 错误消息 PING("ping"), // 心跳检测 QUEUE_UPDATE("queueUpdate"), // 排队状态更新 QUEUE_TIMEOUT("queueTimeout"), // 排队超时 OFFLINE_NOTIFY("offlineNotify"); // 对方离线通知 private final String type; WebrtcMessageType(String type) { this.type = type; } /** * 通过字符串获取枚举 * @param type * @return */ public static WebrtcMessageType getByType(String type) { for (WebrtcMessageType messageType : values()) { if (messageType.getType().equals(type)) { return messageType; } } return null; } } 3、添加WebRTC信令消息处理器(转发消息+通话逻辑处理)
/** * WebRTC 信令消息处理器 */ @Slf4j @Component public class WebrtcSignalingHandler extends TextWebSocketHandler { // ========== 全局核心映射 ========== private static final Map<String, WebSocketSession> ONLINE_SESSIONS = new ConcurrentHashMap<>(); // 在线用户会话映射 private static final Map<String, String> CALL_BIND_MAP = new ConcurrentHashMap<>(); // 通话绑定映射 private static final Map<String, List<CallWaitItem>> CALL_WAIT_QUEUE_MAP = new ConcurrentHashMap<>(); // 通话排队队列映射 // ========== 可配置常量 ========== private static final long QUEUE_TIMEOUT_MS = 5 * 60 * 1000; // 排队超时时间(毫秒) private static final boolean ENABLE_QUEUE = true; // 排队功能开关 private static final long PING_TIMEOUT_MS = 3000; // WebSocket的超时时间 // ========== 全局工具对象 ========== private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); // JSON映射对象 // ========== WebSocket连接生命周期处理 ========== /** * 连接建立后触发 * @param session * @throws Exception */ @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 获取并验证用户ID String userId = getUserIdFromSession(session); if (userId == null || userId.trim().isEmpty()) { session.close(CloseStatus.BAD_DATA.withReason("缺少有效的用户ID(URL拼接?userId=[ID])")); log.warn("【WebSocket 连接失败】缺少有效用户ID,会话URL:{},会话ID:{}", session.getUri(), session.getId()); return; } // 重复登录:强制下线旧会话 if (ONLINE_SESSIONS.containsKey(userId)) { WebSocketSession oldSession = ONLINE_SESSIONS.get(userId); if (oldSession != null && oldSession.isOpen()) { oldSession.close(CloseStatus.POLICY_VIOLATION.withReason("该账号在其他设备登录,已被强制下线")); log.info("【WebSocket 重复登录】用户【{}】在新设备登录,已强制下线旧设备(旧会话ID:{})", userId, oldSession.getId()); } ONLINE_SESSIONS.remove(userId); } ONLINE_SESSIONS.put(userId, session); session.getAttributes().put("userId", userId); log.info("【WebSocket 连接成功】用户【{}】,会话ID:{},当前在线人数:{}", userId, session.getId(), ONLINE_SESSIONS.size()); } /** * 连接关闭后触发 * @param session * @param status * @throws Exception */ @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 从会话属性中获取用户ID String userId = (String) session.getAttributes().get("userId"); if (userId == null) { log.warn("【WebSocket 连接关闭】会话无有效用户ID,会话ID:{},关闭状态:{}", session.getId(), status); return; } // 清理用户相关所有数据,并通知通话对象 cleanUserRelatedData(userId, true); log.info("【WebSocket 连接关闭】用户【{}】,会话ID:{},当前在线人数:{},关闭状态:{}", userId, session.getId(), ONLINE_SESSIONS.size(), status); } /** * 连接出现异常后触发 * @param session * @param exception * @throws Exception */ @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { String userId = (String) session.getAttributes().get("userId"); if (userId != null) { // 清理用户相关所有数据,不通知通话对象 cleanUserRelatedData(userId, false); log.error("【WebSocket 会话异常】用户【{}】,会话ID:{},当前在线人数:{}", userId, session.getId(), ONLINE_SESSIONS.size(), exception); } else { log.warn("【WebSocket 会话异常】无有效用户ID,会话ID:{}", session.getId(), exception); } if (session.isOpen()) { session.close(CloseStatus.SERVER_ERROR.withReason("会话内部异常,已自动关闭")); } } // ========== 核心消息分发处理 ========== /** * 收到消息后触发 * @param session * @param message * @throws Exception */ @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String fromUserId = (String) session.getAttributes().get("userId"); if (fromUserId == null) { log.warn("【消息处理失败】发送方会话无有效用户ID,会话ID:{}", session.getId()); return; } String rawMessage = message.getPayload(); log.info("【收到信令消息】用户【{}】,会话ID:{},原始消息内容:{}", fromUserId, session.getId(), rawMessage); try { // 解析消息并补全数据 WebrtcMessage webrtcMessage = OBJECT_MAPPER.readValue(rawMessage, WebrtcMessage.class); webrtcMessage.setFromUserId(fromUserId); // 获取并验证接收方ID String toUserId = webrtcMessage.getToUserId(); if (toUserId == null || toUserId.trim().isEmpty()) { sendErrorMsg(session, "缺少接收方用户ID(toUserId)"); return; } if (fromUserId.equals(toUserId)) { sendErrorMsg(session, "无法呼叫自身,请输入其他用户ID"); return; } // 统一使用枚举分发消息,避免硬编码错误 WebrtcMessageType msgType = WebrtcMessageType.getByType(webrtcMessage.getType()); if (msgType == null) { sendErrorMsg(session, "不支持的消息类型:" + webrtcMessage.getType()); return; } // 分发消息 switch (msgType) { case OFFER: handleOfferMessage(session, webrtcMessage, fromUserId, toUserId); break; case ANSWER: handleAnswerMessage(webrtcMessage, fromUserId, toUserId); break; case REJECT: handleRejectMessage(webrtcMessage, fromUserId, toUserId); break; case LEAVE: handleLeaveMessage(webrtcMessage, fromUserId, toUserId); break; case ICE_CANDIDATE: handleIceCandidateMessage(webrtcMessage, fromUserId, toUserId); break; case PING: // 心跳消息:仅更新会话状态,不做额外处理 break; default: sendErrorMsg(session, "暂未实现的消息类型:" + msgType.getType()); } } catch (Exception e) { sendErrorMsg(session, "消息处理失败:格式错误或服务器内部异常"); log.error("【消息处理异常】用户【{}】,会话ID:{},原始消息:{}", fromUserId, session.getId(), rawMessage, e); } } // ========== 业务消息处理 ========== /** * 处理呼叫请求(Offer):统一处理空闲/占线/排队 * @param session * @param message * @param fromUserId * @param toUserId * @throws Exception */ private void handleOfferMessage(WebSocketSession session, WebrtcMessage message, String fromUserId, String toUserId) throws Exception { log.info("【处理发起呼叫】呼叫方【{}】→ 被叫方【{}】,开始处理 OFFER 消息", fromUserId, toUserId); // 判断被叫方是否在线 if (!isUserOnline(toUserId)) { log.warn("【发起呼叫失败】被叫方【{}】未在线或连接已关闭,呼叫方【{}】", toUserId, fromUserId); sendErrorMsg(session, "对方【" + toUserId + "】未在线或连接已关闭"); return; } // 判断被叫方是否忙线(通话中/正在处理来电) if (isUserBusy(toUserId)) { if (!ENABLE_QUEUE) { log.warn("【发起呼叫失败】被叫方【{}】正忙,未开启排队功能,呼叫方【{}】", toUserId, fromUserId); sendErrorMsg(session, "对方【" + toUserId + "】正忙,无法接听(未开启排队功能)"); return; } log.info("【被叫方忙线】呼叫方【{}】→ 被叫方【{}】,准备加入排队队列", fromUserId, toUserId); // 被叫方忙线且开启排队,处理排队逻辑(去重+新增排队项) List<CallWaitItem> waitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(toUserId, new CopyOnWriteArrayList<>()); // 去重处理,避免重复加入排队队列 boolean isDuplicate = waitQueue.stream().anyMatch(item -> item.getFromUserId().equals(fromUserId)); if (isDuplicate) { CallWaitItem existItem = waitQueue.stream() .filter(item -> item.getFromUserId().equals(fromUserId)) .findFirst() .orElse(null); if (existItem != null) { // 已在队列中:更新排队状态提示 sendQueueUpdateMsg(getUserSession(fromUserId), toUserId, existItem.getQueueIndex(), waitQueue.size()); log.debug("【排队去重】呼叫方【{}】已在被叫方【{}】的排队队列中(第{}位),无需重复加入", fromUserId, toUserId, existItem.getQueueIndex()); } return; } // 新增排队项:缓存Offer数据和排队信息 CallWaitItem waitItem = new CallWaitItem(); waitItem.setFromUserId(fromUserId); waitItem.setToUserId(toUserId); waitItem.setOfferData(message.getData()); waitItem.setQueueTime(System.currentTimeMillis()); waitItem.setQueueIndex(waitQueue.size() + 1); // 排队项加入队列,更新队列映射 waitQueue.add(waitItem); CALL_WAIT_QUEUE_MAP.put(toUserId, waitQueue); // 通知呼叫方当前排队状态 sendQueueUpdateMsg(session, toUserId, waitItem.getQueueIndex(), waitQueue.size()); log.info("用户【{}】呼叫【{}】:被叫方忙线,已加入排队(第{}位,总{}人)", fromUserId, toUserId, waitItem.getQueueIndex(), waitQueue.size()); return; } // 被叫方空闲,转发Offer,绑定临时通话状态 CALL_BIND_MAP.put(toUserId, fromUserId); // 标记被叫方为忙线(处理来电中) forwardMessage(message, toUserId); log.info("【转发 Offer 成功】呼叫方【{}】→ 被叫方【{}】,对方空闲,已完成临时通话绑定", fromUserId, toUserId); } /** * 处理接听呼叫(Answer):建立双向通话绑定,完成SDP协商 * @param message * @param answererId * @param callerId * @throws Exception */ private void handleAnswerMessage(WebrtcMessage message, String answererId, String callerId) throws Exception { log.info("【处理接听呼叫】接听方【{}】→ 呼叫方【{}】,开始处理 ANSWER 消息", answererId, callerId); // 验证双方是否在线 if (!isUserOnline(callerId) || !isUserOnline(answererId)) { log.warn("【接听呼叫失败】双方或一方已离线,接听方【{}】,呼叫方【{}】", answererId, callerId); sendErrorMsg(getUserSession(answererId), "对方已离线,无法完成接听"); return; } // 建立双向通话绑定 CALL_BIND_MAP.put(callerId, answererId); CALL_BIND_MAP.put(answererId, callerId); // 转发Answer给呼叫方,完成通话建立 forwardMessage(message, callerId); log.info("【建立双向通话成功】接听方【{}】↔ 呼叫方【{}】,已完成双向通话绑定,开始传输媒体流", answererId, callerId); } /** * 处理拒接呼叫(Reject):通知呼叫方,触发下一个排队呼叫 * @param message * @param rejecterId * @param callerId * @throws Exception */ private void handleRejectMessage(WebrtcMessage message, String rejecterId, String callerId) throws Exception { log.info("【处理拒接呼叫】拒接方【{}】→ 呼叫方【{}】,开始处理 REJECT 消息", rejecterId, callerId); // 通知呼叫方被拒接 WebSocketSession callerSession = getUserSession(callerId); if (callerSession != null && callerSession.isOpen()) { message.setData("对方【" + rejecterId + "】已拒接你的呼叫"); forwardMessage(message, callerId); log.debug("【拒接通知发送成功】拒接方【{}】→ 呼叫方【{}】", rejecterId, callerId); } // 清理当前通话绑定,触发下一个排队呼叫 cleanCallBind(rejecterId, callerId); processNextWaitItem(rejecterId); log.info("【拒接呼叫处理完成】拒接方【{}】,呼叫方【{}】,已清理通话绑定并触发下一个排队项", rejecterId, callerId); } /** * 处理主动挂断(Leave):通知对方,清理通话状态,触发下一个排队呼叫 * @param message * @param operatorId * @param targetId * @throws Exception */ private void handleLeaveMessage(WebrtcMessage message, String operatorId, String targetId) throws Exception { log.info("【处理主动挂断】操作方【{}】→ 目标方【{}】,开始处理 LEAVE 消息", operatorId, targetId); if ("cancelCall".equals(message.getData())) { // 通知目标方对方已取消 if (isUserOnline(targetId)) { message.setData("对方【" + operatorId + "】已取消呼叫"); forwardMessage(message, targetId); log.debug("【取消呼叫发送成功】操作方【{}】→ 目标方【{}】", operatorId, targetId); } // 从排队列表移除挂断方的数据 for (List<CallWaitItem> waitQueue : CALL_WAIT_QUEUE_MAP.values()) { waitQueue.removeIf(item -> item.getFromUserId().equals(operatorId)); } // 触发下一个排队呼叫转发 List<CallWaitItem> updatedWaitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(targetId, new CopyOnWriteArrayList<>()); // 如果挂断方是当前绑定的通话就移除绑定,然后触发下一个排队项 if (operatorId.equals(CALL_BIND_MAP.get(targetId))) { cleanCallBind(operatorId, targetId); } if (CALL_BIND_MAP.get(targetId)==null){ // 取出队列头部,转发Offer给被叫方 if (updatedWaitQueue.size()>0) { CallWaitItem nextItem = updatedWaitQueue.remove(0); try { // 构建Offer消息,复用缓存的Offer数据 WebrtcMessage offerMsg = new WebrtcMessage(); offerMsg.setType(WebrtcMessageType.OFFER.getType()); offerMsg.setFromUserId(nextItem.getFromUserId()); offerMsg.setToUserId(targetId); offerMsg.setData(nextItem.getOfferData()); // 标记被叫方为忙线避免重复来电,再转发Offer CALL_BIND_MAP.put(targetId, nextItem.getFromUserId()); forwardMessage(offerMsg, targetId); // 更新剩余排队项的索引,通知所有排队用户状态 updateWaitQueueIndex(updatedWaitQueue, targetId); log.info("【队列调度成功】呼叫方【{}】的 Offer 消息已转发给被叫方【{}】,剩余排队人数:{}", nextItem.getFromUserId(), targetId, updatedWaitQueue.size()); } catch (Exception e) { log.error("【队列调度失败】转发 Offer 消息给被叫方【{}】异常,排队项:{}", targetId, nextItem, e); // 异常时:递归处理下一个排队项,避免队列阻塞 processNextWaitItem(targetId); } } }else{ // 取消的不是当前绑定通话,只需要更新排队索引 updateWaitQueueIndex(updatedWaitQueue, targetId); } log.info("【主动取消处理完成】操作方【{}】,目标方【{}】,已清理通话绑定并触发排队项调度", operatorId, targetId); } else { // 通知目标方对方已挂断 if (isUserOnline(targetId)) { message.setData("对方【" + operatorId + "】已挂断通话"); forwardMessage(message, targetId); log.debug("【挂断通知发送成功】操作方【{}】→ 目标方【{}】", operatorId, targetId); } // 清理通话绑定,触发下一个排队呼叫 cleanCallBind(operatorId, targetId); processNextWaitItem(operatorId); processNextWaitItem(targetId); log.info("【主动挂断处理完成】操作方【{}】,目标方【{}】,已清理通话绑定并触发排队项调度", operatorId, targetId); } } /** * 处理ICE候选消息:仅转发 * @param message * @param fromUserId * @param toUserId * @throws Exception */ private void handleIceCandidateMessage(WebrtcMessage message, String fromUserId, String toUserId) throws Exception { log.debug("【处理 ICE 候选消息】发送方【{}】→ 接收方【{}】,准备转发", fromUserId, toUserId); // 验证接收方是否在线 if (isUserOnline(toUserId)) { // 转发 forwardMessage(message, toUserId); log.info("【ICE 候选消息转发成功】发送方【{}】→ 接收方【{}】", fromUserId, toUserId); } else { log.warn("【ICE 候选消息转发失败】接收方【{}】已离线,发送方【{}】", toUserId, fromUserId); sendErrorMsg(getUserSession(fromUserId), "对方已离线,无法转发ICE候选消息"); } } // ========== 排队队列逻辑 ========== /** * 处理下一个排队项:被叫方空闲后,自动转发下一个呼叫方的Offer消息 * @param toUserId */ private void processNextWaitItem(String toUserId) { if (!ENABLE_QUEUE) { CALL_WAIT_QUEUE_MAP.remove(toUserId); log.debug("【排队功能关闭】已清理被叫方【{}】的排队队列", toUserId); return; } // 获取被叫方的排队队列 List<CallWaitItem> waitQueue = CALL_WAIT_QUEUE_MAP.getOrDefault(toUserId, new CopyOnWriteArrayList<>()); if (waitQueue.isEmpty()) { CALL_WAIT_QUEUE_MAP.remove(toUserId); log.debug("【排队队列为空】被叫方【{}】无待处理的排队项,已清理队列映射", toUserId); return; } // 取出队列头部,转发Offer给被叫方 CallWaitItem nextItem = waitQueue.remove(0); try { // 构建Offer消息,复用缓存的Offer数据 WebrtcMessage offerMsg = new WebrtcMessage(); offerMsg.setType(WebrtcMessageType.OFFER.getType()); offerMsg.setFromUserId(nextItem.getFromUserId()); offerMsg.setToUserId(toUserId); offerMsg.setData(nextItem.getOfferData()); // 标记被叫方为忙线避免重复来电,再转发Offer CALL_BIND_MAP.put(toUserId, nextItem.getFromUserId()); forwardMessage(offerMsg, toUserId); // 更新剩余排队项的索引,通知所有排队用户状态 updateWaitQueueIndex(waitQueue, toUserId); log.info("【队列调度成功】呼叫方【{}】的 Offer 消息已转发给被叫方【{}】,剩余排队人数:{}", nextItem.getFromUserId(), toUserId, waitQueue.size()); } catch (Exception e) { log.error("【队列调度失败】转发 Offer 消息给被叫方【{}】异常,排队项:{}", toUserId, nextItem, e); // 异常时:递归处理下一个排队项,避免队列阻塞 processNextWaitItem(toUserId); } // 清理空队列 if (waitQueue.isEmpty()) { CALL_WAIT_QUEUE_MAP.remove(toUserId); } else { CALL_WAIT_QUEUE_MAP.put(toUserId, waitQueue); } } /** * 更新排队项索引,通知所有排队用户当前状态 * @param waitQueue * @param toUserId */ private void updateWaitQueueIndex(List<CallWaitItem> waitQueue, String toUserId) { log.debug("【更新排队索引】被叫方【{}】,待更新排队项数量:{}", toUserId, waitQueue.size()); for (int i = 0; i < waitQueue.size(); i++) { CallWaitItem item = waitQueue.get(i); item.setQueueIndex(i + 1); try { sendQueueUpdateMsg(getUserSession(item.getFromUserId()), toUserId, item.getQueueIndex(), waitQueue.size()); log.debug("【排队状态更新】已通知用户【{}】,当前排队位置:第{}位,总人数:{}", item.getFromUserId(), item.getQueueIndex(), waitQueue.size()); } catch (Exception e) { log.error("【排队状态更新失败】无法推送消息给排队用户【{}】", item.getFromUserId(), e); } } } // ========== 辅助工具方法 ========== /** * 判断用户是否在线 * @param userId * @return */ private boolean isUserOnline(String userId) { WebSocketSession session = ONLINE_SESSIONS.get(userId); boolean isOnline = session != null && session.isOpen(); log.debug("【用户在线判断】用户【{}】,在线状态:{}", userId, isOnline); return isOnline; } /** * 判断用户是否忙线(通话中/正在处理来电) * @param userId * @return */ private boolean isUserBusy(String userId) { boolean isBusy = CALL_BIND_MAP.containsKey(userId); log.debug("【用户忙线判断】用户【{}】,忙线状态:{}", userId, isBusy); return isBusy; } /** * 转发消息给目标用户 * @param message * @param targetId * @throws Exception */ private void forwardMessage(WebrtcMessage message, String targetId) throws Exception { WebSocketSession targetSession = ONLINE_SESSIONS.get(targetId); if (targetSession != null && targetSession.isOpen()) { String msgJson = OBJECT_MAPPER.writeValueAsString(message); targetSession.sendMessage(new TextMessage(msgJson)); log.debug("【消息转发成功】消息类型:{},转发至用户【{}】,消息内容:{}", message.getType(), targetId, msgJson); return; } log.warn("【消息转发失败】目标用户【{}】未在线或会话已关闭", targetId); } /** * 从会话URL中获取用户ID * @param session * @return */ private String getUserIdFromSession(WebSocketSession session) { try { // 获取URL中的查询参数 String query = session.getUri().getQuery(); if (query == null || query.isEmpty()) { log.warn("【获取用户ID失败】会话 URL 无查询参数,会话ID:{}", session.getId()); return null; } for (String param : query.split("&")) { String[] keyValue = param.split("=", 2); if (keyValue.length == 2 && "userId".equals(keyValue[0])) { String userId = keyValue[1]; log.debug("【提取用户ID成功】会话ID:{},提取到用户ID:{}", session.getId(), userId); return userId; } } log.warn("【提取用户ID失败】查询参数中无 userId 字段,会话ID:{},查询参数:{}", session.getId(), query); return null; } catch (Exception e) { log.error("【提取用户ID异常】会话ID:{}", session.getId(), e); return null; } } /** * 获取用户对应的WebSocket会话对象 * @param userId * @return */ private WebSocketSession getUserSession(String userId) { WebSocketSession session = ONLINE_SESSIONS.getOrDefault(userId, null); log.debug("【获取用户会话】用户【{}】,会话是否存在:{}", userId, session != null); return session; } /** * 发送错误提示消息 */ private void sendErrorMsg(WebSocketSession session, String errorMsg) throws Exception { if (session == null || !session.isOpen()) { log.warn("【发送错误消息失败】会话已关闭或不存在,错误消息:{}", errorMsg); return; } // 构建消息并发送 WebrtcMessage errorMessage = new WebrtcMessage(); errorMessage.setType(WebrtcMessageType.ERROR.getType()); errorMessage.setData(errorMsg); String msgJson = OBJECT_MAPPER.writeValueAsString(errorMessage); session.sendMessage(new TextMessage(msgJson)); log.debug("【错误消息发送成功】会话ID:{},错误消息:{}", session.getId(), errorMsg); } /** * 发送排队状态更新消息 * @param session * @param toUserId * @param queueIndex * @param totalCount * @throws Exception */ private void sendQueueUpdateMsg(WebSocketSession session, String toUserId, int queueIndex, int totalCount) throws Exception { if (session == null || !session.isOpen()) { log.warn("【发送排队状态消息失败】会话已关闭或不存在,被叫方【{}】,排队位置:{}", toUserId, queueIndex); return; } // 构建消息并发送 WebrtcMessage queueMsg = new WebrtcMessage(); queueMsg.setType(WebrtcMessageType.QUEUE_UPDATE.getType()); queueMsg.setToUserId(toUserId); String queueData = String.format("{\"queueIndex\":%d,\"totalCount\":%d}", queueIndex, totalCount); queueMsg.setData(queueData); String msgJson = OBJECT_MAPPER.writeValueAsString(queueMsg); session.sendMessage(new TextMessage(msgJson)); log.debug("【排队状态消息发送成功】会话ID:{},被叫方【{}】,排队位置:第{}位,总人数:{}", session.getId(), toUserId, queueIndex, totalCount); } /** * 发送排队超时消息 * @param session * @throws Exception */ private void sendQueueTimeoutMsg(WebSocketSession session) throws Exception { if (session == null || !session.isOpen()) { log.warn("【发送排队超时消息失败】会话已关闭或不存在"); return; } // 构建消息并发送 WebrtcMessage timeoutMsg = new WebrtcMessage(); timeoutMsg.setType(WebrtcMessageType.QUEUE_TIMEOUT.getType()); timeoutMsg.setData("排队超时,已退出队列"); String msgJson = OBJECT_MAPPER.writeValueAsString(timeoutMsg); session.sendMessage(new TextMessage(msgJson)); log.debug("【排队超时消息发送成功】会话ID:{}", session.getId()); } /** * 发送对方离线通知 * @param targetId * @param offlineUserId * @throws Exception */ private void sendOfflineNotifyMsg(String targetId, String offlineUserId) throws Exception { // 构建消息并转发给目标用户 WebrtcMessage offlineMsg = new WebrtcMessage(); offlineMsg.setType(WebrtcMessageType.OFFLINE_NOTIFY.getType()); offlineMsg.setData("对方【" + offlineUserId + "】已掉线,通话已结束"); forwardMessage(offlineMsg, targetId); log.debug("【对方离线通知发送成功】接收方【{}】,离线用户【{}】", targetId, offlineUserId); } /** * 清理通话绑定关系 * @param userId1 * @param userId2 */ private void cleanCallBind(String userId1, String userId2) { CALL_BIND_MAP.remove(userId1); CALL_BIND_MAP.remove(userId2); log.debug("【清理通话绑定】已清理用户【{}】和【{}】的通话绑定关系", userId1, userId2); } /** * 清理用户相关所有数据(会话/通话/排队) * @param userId * @param notifyPartner * @throws Exception */ private void cleanUserRelatedData(String userId, boolean notifyPartner) throws Exception { log.info("【清理用户相关数据】开始清理用户【{}】的所有相关数据,是否通知通话对象:{}", userId, notifyPartner); // 移除在线会话映射,标记用户为离线 ONLINE_SESSIONS.remove(userId); // 通知通话对象对方离线 String callPartnerId = CALL_BIND_MAP.get(userId); if (callPartnerId != null && notifyPartner) { sendOfflineNotifyMsg(callPartnerId, userId); cleanCallBind(userId, callPartnerId); // 触发通话对象的下一个排队项调度 processNextWaitItem(callPartnerId); } // 清理该用户作为被叫方的排队队列 CALL_WAIT_QUEUE_MAP.remove(userId); // 清理该用户作为呼叫方的所有排队项(跨所有被叫方队列) for (List<CallWaitItem> waitQueue : CALL_WAIT_QUEUE_MAP.values()) { waitQueue.removeIf(item -> item.getFromUserId().equals(userId)); } log.info("【清理用户相关数据完成】用户【{}】的所有数据已清理完毕", userId); } // ========== 定时任务 ========== /** * 清理过期排队项 */ @Scheduled(fixedRate = 10 * 1000) private void cleanExpiredWaitItems() { if (!ENABLE_QUEUE) { return; } long currentTime = System.currentTimeMillis(); for (Map.Entry<String, List<CallWaitItem>> entry : CALL_WAIT_QUEUE_MAP.entrySet()) { String toUserId = entry.getKey(); List<CallWaitItem> waitQueue = entry.getValue(); // 筛选过期排队项 List<CallWaitItem> expiredItems = waitQueue.stream() .filter(item -> (currentTime - item.getQueueTime()) > QUEUE_TIMEOUT_MS) .collect(Collectors.toList()); // 清理过期项,通知用户 for (CallWaitItem expiredItem : expiredItems) { waitQueue.remove(expiredItem); try { sendQueueTimeoutMsg(getUserSession(expiredItem.getFromUserId())); log.debug("【定时任务-清理过期排队项】清理,当前时间戳:{},待处理队列数量:{}", currentTime, CALL_WAIT_QUEUE_MAP.size()); } catch (Exception e) { log.error("【定时任务-清理过期排队项】推送超时提示给用户【{}】失败", expiredItem.getFromUserId(), e); } log.info("【定时任务-清理过期排队项】已清理用户【{}】呼叫【{}】的过期排队项(排队超时)", expiredItem.getFromUserId(), toUserId); } // 更新剩余排队项索引 updateWaitQueueIndex(waitQueue, toUserId); // 移除空队列 if (waitQueue.isEmpty()) { CALL_WAIT_QUEUE_MAP.remove(toUserId); } } } /** * 清理无效WebSocket会话(每10秒执行一次) */ @Scheduled(fixedRate = 10 * 1000) private void cleanInvalidSessions() { for (String userId : ONLINE_SESSIONS.keySet()) { WebSocketSession session = ONLINE_SESSIONS.get(userId); if (session == null || !session.isOpen()) { try { cleanUserRelatedData(userId, false); } catch (Exception e) { log.error("【定时任务-清理无效会话】清理用户【{}】数据失败", userId, e); } log.info("【定时任务-清理无效会话】已清理用户【{}】的无效 WebSocket 会话", userId); }else{ boolean isSessionValid = sendPingWithTimeout(session); if (!isSessionValid) { log.info(String.format("【定时任务-清理无效会话】用户【%s】的 WebSocket 会话 ping 超时,判定为无效并清理", userId)); ONLINE_SESSIONS.remove(userId); if (session.isOpen()) { try { session.close(CloseStatus.GOING_AWAY.withReason("会话超时,已自动关闭")); } catch (Exception ex) { log.error("【定时任务-清理无效会话】关闭WebSocket无效会话失败:用户ID【{}】", userId, ex); } } } } } } private boolean sendPingWithTimeout(WebSocketSession session) { FutureTask<Boolean> pingTask = new FutureTask<>(() -> { try { session.sendMessage(new TextMessage("{\"type\":\"ping\"}")); return true; // 发送成功,会话有效 } catch (Exception e) { return false; // 发送异常,会话无效 } }); Thread pingThread = new Thread(pingTask, "WebSocket-Ping-Thread"); pingThread.start(); try { return pingTask.get(PING_TIMEOUT_MS, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { pingTask.cancel(true); return false; } catch (InterruptedException | ExecutionException e) { // 其他异常(线程中断、任务执行异常):判定会话无效 log.warn(String.format("【定时任务-清理无效会话】ping 操作出现异常:%s", e.getMessage())); return false; } } } 4、添加WebSocket配置类
/** * WebSocket 配置类 */ @Configuration @EnableWebSocket @EnableScheduling public class WebSocketConfig implements WebSocketConfigurer { @Resource private WebrtcSignalingHandler webrtcSignalingHandler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) { webSocketHandlerRegistry .addHandler(webrtcSignalingHandler, "/webrtc") // 绑定处理器和访问端点 .setAllowedOrigins("*"); // 允许跨域 } /** * 定时任务线程池 * @return */ @Bean(name = "customWebSocketTaskScheduler") public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); // 5个线程处理定时任务 scheduler.setThreadNamePrefix("websocket-scheduler-"); // 线程名前缀 scheduler.setAwaitTerminationSeconds(10); // 任务关闭时等待秒数 scheduler.setWaitForTasksToCompleteOnShutdown(true); // 关闭时等待任务完成 return scheduler; } } 5、启动类增加注解@EnableScheduling
@SpringBootApplication @EnableScheduling public class WebrtcServerApplication { private static Logger log = LoggerFactory.getLogger(WebrtcServerApplication.class); public static void main(String[] args) { SpringApplication.run(WebrtcServerApplication.class, args); log.info("========================================================================"); log.info("=========================WebRTC信令服务器启动成功=========================="); log.info("--------WebSocket访问端点:ws://[IP]:[Port]/webrtc?userId=[用户ID]"); log.info("========================================================================"); } } 6、测试连接
使用Apipost或其他工具创建WebSocket连接,连接地址(根据实际配置填写):ws://[IP]:[Port]/webrtc?userId=[用户ID]

二、HTML页面
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>WebRTC视频对讲</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; font-family: "Microsoft YaHei", sans-serif; } .container { width: 90%; max-width: 1200px; margin: 20px auto; } .input-area, .queue-area, .call-area { margin-bottom: 20px; padding: 15px; border: 1px solid #eee; border-radius: 8px; } .queue-area, .call-area { display: none; } .video-area { display: flex; gap: 20px; flex-wrap: wrap; } .video-box { flex: 1; min-width: 320px; } video { width: 100%; height: 360px; border: 1px solid #ccc; border-radius: 8px; background-color: #000; } button { padding: 8px 16px; margin: 5px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .btn-call { background-color: #4CAF50; color: white; } .btn-hangup, .btn-reject { background-color: #f44336; color: white; } .btn-answer { background-color: #2196F3; color: white; } input { padding: 8px; margin: 5px; border: 1px solid #ccc; border-radius: 4px; width: 200px; } .tip, .queue-tip { color: #666; font-size: 12px; margin-top: 10px; } .queue-highlight { color: #ff9800; font-weight: bold; } </style> </head> <body> <div> <!-- 基础输入区域 --> <div> <h3>基础配置</h3> <label>自身用户ID:</label> <input type="text" placeholder="输入自己的ID" required> <button onclick="initWebSocket()">初始化连接</button> <br> <label>要呼叫的用户ID:</label> <input type="text" placeholder="输入对方的ID"> <button onclick="callUser()">呼叫</button> <button onclick="hangupCall()">挂断/取消呼叫</button> <p>提示:先输入自身ID并初始化连接,再输入对方ID进行呼叫</p> </div> <!-- 排队状态区域 --> <div> <h3>排队状态</h3> <p>你正在呼叫 <span></span></p> <p>当前排队位置:第 <span>0</span> 位</p> <p>队列总人数:<span>0</span> 人</p> <p>提示:排队超时5分钟将自动退出,被叫方空闲后将自动为你转发请求</p> </div> <!-- 来电提示区域 --> <div> <h3>来电提醒</h3> <p>来自 <span></span> 的呼叫</p> <button onclick="answerCall()">接听</button> <button onclick="rejectCall()">拒接</button> </div> <!-- 视频播放区域 --> <div> <div> <h4>本地视频(静音)</h4> <video autoplay muted playsinline></video> </div> <div> <h4>远程视频</h4> <video autoplay playsinline></video> </div> </div> </div> <script src="js/jquery-3.5.1.min.js"></script> <script> /************************** 全局变量定义 **************************/ var webSocket = null; // WebSocket实例 var peerConnection = null; // RTCPeerConnection实例 var localStream = null; // 本地媒体流 var remoteStream = null; // 远程媒体流 var currentCallerId = null; // 当前来电方ID var isCallValid = false; // 通话是否有效 var isCalling = false; // 是否正在呼叫中 var pendingIceCandidates = []; // 待处理ICE候选队列 var webrtcServerUrl = "ws://192.168.1.190:9999/webrtc"; // 信令服务器WebSocket地址 var RTC_CONFIG = {iceServers: []}; // RTC配置,内网环境不需要配置,需要内网穿透可以配置{iceServers: [{urls: "stun:stun.l.google.com:19302"},{urls: "stun:stun1.l.google.com:19302"}]} /************************** 浏览器兼容性处理 **************************/ // 兼容RTCPeerConnection window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection; // 兼容RTCSessionDescription window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription; // 兼容RTCIceCandidate window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate; // 兼容媒体设备接口 navigator.mediaDevices = navigator.mediaDevices || {}; // 若浏览器不支持标准的getUserMedia,使用降级兼容方案 if (!navigator.mediaDevices.getUserMedia) { navigator.mediaDevices.getUserMedia = function(constraints) { // 获取浏览器私有实现的getUserMedia var getUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia; if (!getUserMedia) { // 无任何可用实现,返回错误Promise return Promise.reject(new Error("当前浏览器不支持获取媒体设备(无getUserMedia实现)")); } // 封装为标准Promise格式 return new Promise(function(resolve, reject) { getUserMedia.call(navigator, constraints, resolve, reject); }); }; } /************************** 初始化WebSocket连接 **************************/ function initWebSocket() { // 获取自身用户ID var fromUserId = $("#fromUserId").val().trim(); if (!fromUserId) { alert("请先输入有效的自身用户ID!"); console.error("[WebSocket初始化失败] 原因:未输入有效的自身用户ID"); return; } // 关闭旧连接 if (webSocket && webSocket.readyState === WebSocket.OPEN) { console.log("[WebSocket] 检测到已有活跃连接,先关闭旧连接"); webSocket.close(); } // 拼接完整的WebSocket地址 var wsUrl = webrtcServerUrl + "?userId=" + fromUserId; console.log("[WebSocket] 开始创建连接,目标地址:", wsUrl); try { // 创建WebSocket实例并连接信令服务器 webSocket = new WebSocket(wsUrl); // 连接成功后触发 webSocket.onopen = function() { console.log("[WebSocket] 连接成功!状态:OPEN"); alert("WebSocket连接成功!可以开始呼叫对方了"); // 显示"呼叫"按钮 $("#callBtn").show(); }; // 连接关闭后触发 webSocket.onclose = function(event) { console.log("[WebSocket] 连接已关闭,关闭详情:", event); // 重置界面状态,隐藏相关功能按钮 $("#callBtn").hide(); $("#hangupBtn").hide(); $("#queueArea").hide(); console.log("[WebSocket] 关闭状态码:", event.code, ",关闭原因:", event.reason); alert("WebSocket连接已关闭,请重新初始化连接"); }; // 连接出现异常后触发 webSocket.onerror = function(error) { console.error("[WebSocket] 连接错误,错误详情:", error); alert("WebSocket连接失败!请检查信令服务器是否启动"); }; // 收到消息后触发 webSocket.onmessage = function(event) { console.log("[WebSocket] 收到服务器消息,原始数据:", event.data); try { var msg = JSON.parse(event.data); console.log("[WebSocket] 消息解析成功,消息内容:", msg); // 处理消息 handleMessage(msg); } catch (e) { console.error("[WebSocket] 解析信令消息失败,原始数据:", event.data, ",错误详情:", e); alert("收到无效的信令消息,无法处理(格式非合法JSON)"); } }; } catch (e) { console.error("[WebSocket] 创建连接实例失败,错误详情:", e); alert("无法创建WebSocket连接,请检查浏览器是否支持WebSocket"); } } /************************** 处理消息 **************************/ function handleMessage(msg) { var msgType = msg.type; var fromUserId = msg.fromUserId; var data = msg.data; // 心跳消息不处理 if ("{\"type\":\"ping\"}" == data) { return; } console.log("[信令消息分发] 收到消息类型:", msgType, ",发送方:", fromUserId, ",消息数据:", data); // 根据消息类型处理 switch (msgType) { case "offer": // 对方发起呼叫的Offer消息 handleOfferMessage(fromUserId, data); break; case "answer": // 对方接听呼叫的Answer消息 handleAnswerMessage(data); break; case "iceCandidate": // 对方发送的ICE候选消息 handleIceCandidateMessage(data); break; case "reject": // 对方拒接呼叫的消息 handleRejectMessage(fromUserId); break; case "leave": // 对方挂断通话的消息 handleLeaveMessage(fromUserId, data); break; case "error": // 服务器返回的错误消息 handleErrorMessage(data); break; case "queueUpdate": // 排队状态更新消息 handleQueueUpdateMessage(data); break; case "queueTimeout": // 排队超时消息 handleQueueTimeoutMessage(data); break; case "offlineNotify": // 对方离线通知消息 handleOfflineNotifyMessage(data); break; default: // 未知消息类型 console.warn("[信令消息分发] 收到未知类型的消息,无法处理,消息类型:", msgType); } } /************************** 各类信令消息具体处理函数 **************************/ // 处理对方发起呼叫的Offer消息 function handleOfferMessage(fromUserId, data) { console.log("[Offer消息处理] 收到来自", fromUserId, "的呼叫,Offer数据:", data); // 校验当前是否已有正在进行的通话 if (peerConnection) { alert("已有正在进行的通话,无法接收新的呼叫"); console.warn("[Offer消息处理] 处理失败,原因:已有活跃的PeerConnection(存在正在进行的通话)"); return; } // 记录当前来电方ID currentCallerId = fromUserId; // 显示来电提醒区域 $("#callerUserId").text(fromUserId); $("#callArea").show(); console.log("[Offer消息处理] 已显示来电提醒界面,等待用户接听/拒接"); // 获取本地媒体流 getLocalMediaStream(function() { // 创建PeerConnection实例 createPeerConnection(); // 封装Offer为RTCSessionDescription对象 var offer = new RTCSessionDescription({ type: "offer", sdp: data }); // 设置远程会话描述 peerConnection.setRemoteDescription(offer).then(function() { console.log("[Offer消息处理] 设置远程Offer描述成功"); // 处理待排队的ICE候选 if (pendingIceCandidates.length > 0) { console.log("[Offer消息处理] 开始处理待排队的ICE候选,数量:", pendingIceCandidates.length); pendingIceCandidates.forEach(function(cacheData) { handleIceCandidateMessage(cacheData); }); // 清空待处理队列 pendingIceCandidates = []; } }).catch(function(error) { console.error("[Offer消息处理] 设置远程Offer描述失败,错误详情:", error); alert("处理来电失败,无法建立连接"); // 重置通话状态,清理资源 resetCallState(); }); }); } // 处理对方接听呼叫的Answer消息 function handleAnswerMessage(data) { console.log("[Answer消息处理] 收到对方接听消息,Answer数据:", data); // 校验PeerConnection是否就绪 if (!peerConnection) { console.warn("[Answer消息处理] 处理失败,原因:PeerConnection未初始化(无活跃通话)"); return; } // 封装Answer为RTCSessionDescription对象 var answer = new RTCSessionDescription({ type: "answer", sdp: data }); // 设置远程会话描述 peerConnection.setRemoteDescription(answer).then(function() { console.log("[Answer消息处理] 设置远程Answer描述成功,SDP协商完成,开始建立P2P媒体连接"); // 更新状态,隐藏排队区域 isCalling = false; $("#queueArea").hide(); }).catch(function(error) { console.error("[Answer消息处理] 设置远程Answer描述失败,错误详情:", error); alert("对方接听失败,无法建立通话"); isCalling = false; // 重置通话状态,清理资源 resetCallState(); }); } // 处理对方发送的ICE候选消息 function handleIceCandidateMessage(data) { console.log("[ICE候选处理] 收到对方ICE候选消息,候选数据:", data); // 校验PeerConnection是否就绪,未就绪则缓存候选 if (!peerConnection) { console.log("[ICE候选处理] PeerConnection未就绪,将候选加入待处理队列,当前队列长度:", pendingIceCandidates.length + 1); pendingIceCandidates.push(data); return; } try { // 解析ICE候选数据,修改数据(兼容安卓) var iceCandidateData = JSON.parse(data); var iceCandidateCopy = JSON.parse(JSON.stringify(iceCandidateData)); if (iceCandidateCopy.sdp) { iceCandidateCopy.candidate = iceCandidateCopy.sdp; delete iceCandidateCopy.sdp; } delete iceCandidateCopy.adapterType; delete iceCandidateCopy.serverUrl; // 校验候选数据是否有效 if (!iceCandidateCopy.candidate) { console.warn("[ICE候选处理] 候选数据无效,无candidate字段,跳过处理"); return; } // 封装为RTCIceCandidate对象,并添加到PeerConnection var iceCandidate = new RTCIceCandidate(iceCandidateCopy); peerConnection.addIceCandidate(iceCandidate).catch(function(error) { console.error("[ICE候选处理] 添加远程ICE候选失败,错误详情:", error); }); console.log("[ICE候选处理] 远程ICE候选添加成功"); } catch (e) { console.error("[ICE候选处理] 解析或添加ICE候选失败,错误详情:", e); } } // 处理对方拒接呼叫的消息 function handleRejectMessage(fromUserId) { var rejectMsg = "对方【" + fromUserId + "】已拒接通话"; console.log("[拒接消息处理] " + rejectMsg); alert(rejectMsg); // 重置通话状态,清理资源 resetCallState(); } // 处理对方挂断通话的消息 function handleLeaveMessage(fromUserId, data) { if (data.indexOf("已挂断")!=-1 || fromUserId==currentCallerId) { var leaveMsg = "对方【" + fromUserId + "】已挂断通话"; console.log("[挂断消息处理] " + leaveMsg); alert(leaveMsg); // 重置通话状态,清理资源 resetCallState(); } else { var leaveMsg = "对方【" + fromUserId + "】已取消通话"; console.log("[取消消息处理] " + leaveMsg); alert(leaveMsg); } } // 处理服务器返回的错误消息 function handleErrorMessage(data) { console.error("[错误消息处理] 收到服务器错误提示:", data); // 若当前通话有效,重置状态并提示用户 if (isCallValid) { isCallValid = false; resetCallState(); alert(data); } } // 处理排队状态更新消息 function handleQueueUpdateMessage(data) { console.log("[排队状态处理] 收到排队状态更新消息,排队数据:", data); try { // 解析排队信息(队列位置、总人数) var queueInfo = JSON.parse(data); // 更新界面显示 $("#queueIndex").text(queueInfo.queueIndex); $("#queueTotal").text(queueInfo.totalCount); $("#queueToUserId").text($("#toUserId").val().trim()); $("#queueArea").show(); console.log("[排队状态处理] 排队界面更新完成,当前位置:第" + queueInfo.queueIndex + "位,总人数:" + queueInfo.totalCount + "人"); } catch (e) { console.error("[排队状态处理] 解析排队状态失败,错误详情:", e); } } // 处理排队超时消息 function handleQueueTimeoutMessage(data) { console.log("[排队超时处理] " + data); alert(data); // 隐藏排队区域,重置通话状态 $("#queueArea").hide(); resetCallState(); } // 处理对方离线通知消息 function handleOfflineNotifyMessage(data) { console.log("[离线通知处理] " + data); alert(data); // 重置通话状态,清理资源 resetCallState(); } // 获取本地音视频媒体流 function getLocalMediaStream(callback) { // 定义媒体流约束条件 var mediaConstraints = { video: { width: { ideal: 640, max: 640 }, height: { ideal: 480, max: 480 }, frameRate: { ideal: 30, max: 30 }, aspectRatio: 1.3333333333 // 4:3宽高比 }, audio: { echoCancellation: { ideal: true, exact: true }, // 回声消除 noiseSuppression: { ideal: true, exact: true }, // 噪声抑制 autoGainControl: { ideal: true, exact: true }, // 自动增益控制 highpassFilter: { ideal: true, exact: true } // 高通滤波器(过滤低频噪声) } }; console.log("[媒体流获取] 开始获取本地高清媒体流,约束条件:", mediaConstraints); // 获取媒体流 navigator.mediaDevices.getUserMedia(mediaConstraints).then(function(stream) { // 保存本地媒体流,绑定到本地视频元素 localStream = stream; $("#localVideo")[0].srcObject = stream; console.log("[媒体流获取] 本地高清媒体流获取成功,流包含轨道数:", stream.getTracks().length); // 执行回调函数 if (typeof callback === "function") { callback(); } }).catch(function(error) { console.error("[媒体流获取] 本地高清媒体流获取失败,错误详情:", error); // 按错误类型降级处理或提示用户 if (error.name === "OverconstrainedError") { console.log("[媒体流获取] 高清约束条件不满足,切换为降级模式获取媒体流"); getLocalMediaStreamDefault(callback); } else if (error.name === "NotAllowedError") { alert("请授予浏览器麦克风/摄像头的访问权限!"); } else if (error.name === "NotFoundError") { alert("未检测到麦克风/摄像头设备,请检查设备是否连接!"); } else { alert("获取媒体流失败:" + error.message); } }); } // 降级获取本地媒体流(高清配置失败时使用,兼容更多设备) function getLocalMediaStreamDefault(callback) { // 定义简化的媒体流约束条件 var mediaConstraints = { video: true, // 使用浏览器默认配置 audio: { echoCancellation: true, noiseSuppression: true } // 保留基础音频优化 }; console.log("[媒体流获取] 开始降级模式获取本地媒体流,约束条件:", mediaConstraints); // 获取媒体流 navigator.mediaDevices.getUserMedia(mediaConstraints).then(function(stream) { // 保存本地媒体流,绑定到本地视频元素 localStream = stream; $("#localVideo")[0].srcObject = stream; console.log("[媒体流获取] 降级模式本地媒体流获取成功,流包含轨道数:", stream.getTracks().length); // 执行回调函数 if (typeof callback === "function") { callback(); } }).catch(function(error) { console.error("[媒体流获取] 降级模式本地媒体流获取失败,错误详情:", error); alert("获取媒体流失败:" + error.message); }); } // 创建RTCPeerConnection实例 function createPeerConnection() { // 关闭旧实例 if (peerConnection) { console.log("[PeerConnection] 检测到已有旧实例,先关闭旧连接"); peerConnection.close(); } // 创建新的PeerConnection实例 peerConnection = new RTCPeerConnection(RTC_CONFIG); console.log("[PeerConnection] 新实例创建成功"); // 将轨道添加到PeerConnection if (localStream) { localStream.getTracks().forEach(function(track) { peerConnection.addTrack(track, localStream); console.log("[PeerConnection] 已添加本地媒体轨道,轨道类型:", track.kind, ",轨道ID:", track.id); }); } // 监听ICE候选收集事件 peerConnection.onicecandidate = function(event) { if (event.candidate) { console.log("[PeerConnection] 本地收集到ICE候选,准备发送给对方:", event.candidate); // 深拷贝避免修改原始候选数据,兼容安卓 var iceCandidateCopy = JSON.parse(JSON.stringify(event.candidate)); iceCandidateCopy.sdp = iceCandidateCopy.candidate; delete iceCandidateCopy.candidate; // 发送ICE候选消息给对方 sendMessage({ type: "iceCandidate", data: JSON.stringify(iceCandidateCopy) }); } else { console.log("[PeerConnection] ICE候选收集完成,无更多候选数据"); } }; // 监听远程媒体流到达事件 peerConnection.ontrack = function(event) { console.log("[PeerConnection] 收到远程媒体轨道,轨道类型:", event.track.kind, ",轨道ID:", event.track.id); // 初始化远程媒体流 if (!remoteStream) { remoteStream = new MediaStream(); console.log("[PeerConnection] 远程媒体流初始化成功"); } var track = event.track; // 避免重复添加相同轨道 if (!remoteStream.getTracks().some(t => t.id === track.id)) { remoteStream.addTrack(track); console.log("[PeerConnection] 已添加远程媒体轨道到远程流"); } // 绑定远程媒体流到视频元素,播放远程视频 $("#remoteVideo")[0].srcObject = remoteStream; $("#remoteVideo")[0].play().catch(function(error) { console.warn("[PeerConnection] 远程视频播放触发失败(可能已自动播放),错误详情:", error); }); }; // 监听连接状态变化事件 peerConnection.onconnectionstatechange = function() { if (!peerConnection) { return; } var connState = peerConnection.connectionState; console.log("[PeerConnection] 连接状态变化,当前状态:", connState); // 连接关闭或失败时,重置通话状态 if (connState === "closed" || connState === "failed") { console.log("[PeerConnection] 连接已关闭或失败,自动重置通话状态"); resetCallState(); } }; } // 发送信令消息到服务器 function sendMessage(msg) { // 校验WebSocket连接是否活跃 if (!webSocket || webSocket.readyState !== WebSocket.OPEN) { alert("WebSocket连接已断开,无法发送消息"); console.error("[消息发送失败] 原因:WebSocket连接未处于OPEN状态,当前状态:", webSocket ? webSocket.readyState : "未初始化"); return; } // 获取目标用户ID(优先使用当前来电方ID,其次使用输入框中的目标ID) var toUserId = $("#toUserId").val().trim(); if (msg.type !== "leave" && !toUserId && !currentCallerId) { alert("请先输入目标用户ID"); console.error("[消息发送失败] 原因:未指定目标用户ID"); return; } // 构建完整的信令消息 var fullMsg = { type: msg.type, fromUserId: $("#fromUserId").val().trim(), toUserId: toUserId || currentCallerId, data: msg.data }; try { var jsonMsg = JSON.stringify(fullMsg); webSocket.send(jsonMsg); console.log("[消息发送成功] 消息类型:", msg.type, ",目标用户:", fullMsg.toUserId, ",发送内容:", jsonMsg); } catch (e) { console.error("[消息发送失败] 错误详情:", e); alert("无法发送消息,请检查网络连接"); } } // 发起呼叫 function callUser() { // 获取并校验目标用户ID var toUserId = $("#toUserId").val().trim(); if (!toUserId) { alert("请先输入目标用户ID"); console.error("[发起呼叫失败] 原因:未输入目标用户ID"); return; } console.log("[发起呼叫] 开始呼叫目标用户:", toUserId); // 获取本地媒体流,准备建立通话 getLocalMediaStream(function() { // 创建PeerConnection实例 createPeerConnection(); // 更新通话状态 isCallValid = true; isCalling = true; // 定义Offer选项(表示愿意接收音频和视频) var offerOptions = { offerToReceiveAudio: true, offerToReceiveVideo: true }; console.log("[发起呼叫] 开始创建Offer,选项:", offerOptions); // 创建Offer并设置本地会话描述 peerConnection.createOffer(offerOptions).then(function(offer) { return peerConnection.setLocalDescription(offer); }).then(function() { // 发送Offer消息给目标用户,发起呼叫 sendMessage({ type: "offer", data: peerConnection.localDescription.sdp }); // 更新界面状态,隐藏呼叫按钮,显示挂断按钮 $("#callBtn").hide(); $("#hangupBtn").show(); console.log("[发起呼叫] Offer消息发送成功,等待对方接听"); }).catch(function(error) { console.error("[发起呼叫] 生成或发送Offer失败,错误详情:", error); alert("呼叫失败,无法建立连接"); isCalling = false; // 重置通话状态,清理资源 resetCallState(); }); }); } // 接听呼叫 function answerCall() { // 校验PeerConnection和来电方ID是否有效 if (!peerConnection || !currentCallerId) { alert("无有效来电,无法接听"); console.error("[接听呼叫失败] 原因:PeerConnection未初始化或无有效来电方ID"); return; } console.log("[接听呼叫] 开始接听来自", currentCallerId, "的呼叫"); // 更新通话状态 isCallValid = true; // 创建Answer并设置本地会话描述 peerConnection.createAnswer().then(function(answer) { return peerConnection.setLocalDescription(answer); }).then(function() { // 发送Answer消息给来电方,确认接听 sendMessage({ type: "answer", data: peerConnection.localDescription.sdp }); // 更新界面状态,隐藏来电区域,显示挂断按钮 $("#callArea").hide(); $("#callBtn").hide(); $("#toUserId").val(currentCallerId); $("#hangupBtn").show(); console.log("[接听呼叫] Answer消息发送成功,等待建立媒体连接"); }).catch(function(error) { console.error("[接听呼叫] 生成或发送Answer失败,错误详情:", error); alert("接听失败,无法建立通话"); }); } // 拒绝接听 function rejectCall() { // 若有有效来电方ID,发送拒接消息给对方 if (currentCallerId && webSocket && webSocket.readyState === WebSocket.OPEN) { console.log("[拒接呼叫] 开始拒接来自", currentCallerId, "的呼叫"); sendMessage({ type: "reject", data: "reject" }); } // 重置通话状态,清理资源 resetCallState(); alert("已拒接来电"); console.log("[拒接呼叫] 已完成拒接操作,通话状态已重置"); } // 挂断/取消呼叫 function hangupCall() { if (isCalling) { // 正在呼叫中(未被接听),执行取消呼叫逻辑 console.log("[挂断/取消呼叫] 执行取消呼叫逻辑,当前呼叫目标:", $("#toUserId").val().trim()); if (webSocket && webSocket.readyState === WebSocket.OPEN) { sendMessage({ type: "leave", data: "cancelCall" }); } resetCallState(); alert("已取消呼叫"); } else { // 通话中(已接听),执行挂断通话逻辑 console.log("[挂断/取消呼叫] 执行挂断通话逻辑,当前通话对象:", $("#toUserId").val().trim()); if (webSocket && webSocket.readyState === WebSocket.OPEN) { sendMessage({ type: "leave", data: "hangup" }); } resetCallState(); alert("已挂断通话"); } console.log("[挂断/取消呼叫] 已完成操作,通话状态已重置"); } // 重置通话状态 function resetCallState() { console.log("[重置通话状态] 开始清理所有通话相关资源和界面状态"); // 重置全局状态变量 isCallValid = false; isCalling = false; // 关闭本地媒体流,释放摄像头/麦克风设备 if (localStream) { localStream.getTracks().forEach(function(track) { track.stop(); console.log("[重置通话状态] 已停止本地媒体轨道,轨道类型:", track.kind); }); localStream = null; $("#localVideo")[0].srcObject = null; console.log("[重置通话状态] 本地媒体流已清理,本地视频已重置"); } // 关闭PeerConnection if (peerConnection) { // 移除事件监听 peerConnection.onicecandidate = null; peerConnection.ontrack = null; peerConnection.onconnectionstatechange = null; // 关闭连接 peerConnection.close(); peerConnection = null; console.log("[重置通话状态] PeerConnection已关闭并清理"); } // 关闭远程媒体流,清理远程视频 if (remoteStream) { remoteStream.getTracks().forEach(function(track) { track.stop(); console.log("[重置通话状态] 已停止远程媒体轨道,轨道类型:", track.kind); }); remoteStream = null; $("#remoteVideo")[0].srcObject = null; console.log("[重置通话状态] 远程媒体流已清理,远程视频已重置"); } // 重置界面状态,恢复初始样式 $("#hangupBtn").hide(); $("#callArea").hide(); $("#queueArea").hide(); $("#callBtn").show(); $("#toUserId").val(""); $("#callerUserId").text(""); console.log("[重置通话状态] 界面状态已恢复初始"); // 清理全局变量,清空待处理ICE候选队列 currentCallerId = null; pendingIceCandidates = []; console.log("[重置通话状态] 所有通话相关资源清理完成"); } </script> </body> </html> 三、配置浏览器
浏览器获取本地摄像头/麦克风需要有限制,地址需要满足任意一项:localhost、127.0.0.1、https,内网环境下想通过IP来访问需要配置unsafely-treat-insecure-origin-as-secure
谷歌、Edge、360浏览器
- 进入浏览器配置页面
谷歌 / 360:chrome://flags/#unsafely-treat-insecure-origin-as-secure Edge:edge://flags/#unsafely-treat-insecure-origin-as-secure 
2. 启用【Insecure origins treated as secure】项,在输入框中添加需要对讲的系统地址【http://服务器IP:项目端口】,多个地址之间使用英文【,】分隔

火狐浏览器
- 搜索框中输入【insecure】
找到【media.devices.insecure.enabled】和【media.getusermedia.insecure.enabled】,点击后面的切换按钮,把值改为true后刷新页面
](https://qiniu.meowparty.cn/coder.2023/2026-04-10/839b41d9dfff435790a950eec1f98f42.png)
地址栏输入【about:config】

四、测试视频通话
创建了4个浏览器分别登录不同的id,用来模拟一对一呼叫和多对一呼叫的情况

一对一呼叫
2 >>> 1


多对一呼叫(排队功能)
2 >>> 1,3 >>> 1,4 >>> 1
排队列表

用户3取消呼叫,用户4的排队顺位提升

用户2取消呼叫,用户4的呼叫请求传递到用户1并接听成功

