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 class="container">
			<!-- 基础输入区域 -->
			<div class="input-area">
				<h3>基础配置</h3>
				<label>自身用户ID:</label>
				<input type="text" id="fromUserId" placeholder="输入自己的ID" required value="">
				<button onclick="initWebSocket()" class="btn-call">初始化连接</button>
				<br>
				<label>要呼叫的用户ID:</label>
				<input type="text" id="toUserId" placeholder="输入对方的ID" value="">
				<button onclick="callUser()" class="btn-call" id="callBtn" style="display: none;">呼叫</button>
				<button onclick="hangupCall()" class="btn-hangup" id="hangupBtn" style="display: none;">挂断/取消呼叫</button>
				<p class="tip">提示:先输入自身ID并初始化连接,再输入对方ID进行呼叫</p>
			</div>

			<!-- 排队状态区域 -->
			<div class="queue-area" id="queueArea">
				<h3>排队状态</h3>
				<p>你正在呼叫 <span id="queueToUserId" class="queue-highlight"></span></p>
				<p>当前排队位置:第 <span id="queueIndex" class="queue-highlight">0</span> 位</p>
				<p>队列总人数:<span id="queueTotal" class="queue-highlight">0</span> 人</p>
				<p class="queue-tip">提示:排队超时5分钟将自动退出,被叫方空闲后将自动为你转发请求</p>
			</div>

			<!-- 来电提示区域 -->
			<div class="call-area" id="callArea">
				<h3>来电提醒</h3>
				<p>来自 <span id="callerUserId" class="queue-highlight"></span> 的呼叫</p>
				<button onclick="answerCall()" class="btn-answer" id="answerBtn">接听</button>
				<button onclick="rejectCall()" class="btn-reject" id="rejectBtn">拒接</button>
			</div>

			<!-- 视频播放区域 -->
			<div class="video-area">
				<div class="video-box">
					<h4>本地视频(静音)</h4>
					<video id="localVideo" autoplay muted playsinline></video>
				</div>
				<div class="video-box">
					<h4>远程视频</h4>
					<video id="remoteVideo" 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浏览器

  1. 进入浏览器配置页面

    谷歌 / 360:chrome://flags/#unsafely-treat-insecure-origin-as-secure
    Edge:edge://flags/#unsafely-treat-insecure-origin-as-secure

  1. 启用【Insecure origins treated as secure】项,在输入框中添加需要对讲的系统地址【http://服务器IP:项目端口】,多个地址之间使用英文【,】分隔

火狐浏览器

  1. 地址栏输入【about:config】

  2. 搜索框中输入【insecure】

  3. 找到【media.devices.insecure.enabled】和【media.getusermedia.insecure.enabled】,点击后面的切换按钮,把值改为true后刷新页面

四、测试视频通话

创建了4个浏览器分别登录不同的id,用来模拟一对一呼叫和多对一呼叫的情况

一对一呼叫

2 >>> 1

多对一呼叫(排队功能)

2 >>> 1,3 >>> 1,4 >>> 1

排队列表

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

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


相关推荐
青云计划6 小时前
知光项目知文发布模块
java·后端·spring·mybatis
赶路人儿6 小时前
Jsoniter(java版本)使用介绍
java·开发语言
探路者继续奋斗7 小时前
IDD意图驱动开发之意图规格说明书
java·规格说明书·开发规范·意图驱动开发·idd
消失的旧时光-19437 小时前
第十九课:为什么要引入消息队列?——异步系统设计思想
java·开发语言
A懿轩A7 小时前
【Java 基础编程】Java 面向对象入门:类与对象、构造器、this 关键字,小白也能写 OOP
java·开发语言
乐观勇敢坚强的老彭8 小时前
c++寒假营day03
java·开发语言·c++
biubiubiu07068 小时前
谷歌浏览器无法访问localhost:8080
java
+VX:Fegn08958 小时前
计算机毕业设计|基于springboot + vue鲜花商城系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
大黄说说8 小时前
新手选语言不再纠结:Java、Python、Go、JavaScript 四大热门语言全景对比与学习路线建议
java·python·golang
烟沙九洲8 小时前
Java 中的 封装、继承、多态
java