diff --git a/WebApp/client/public/onebyone/renderer.js b/WebApp/client/public/onebyone/renderer.js index 5fc8f49..d81271f 100644 --- a/WebApp/client/public/onebyone/renderer.js +++ b/WebApp/client/public/onebyone/renderer.js @@ -174,6 +174,10 @@ class UIRenderer { // 通话结束 - 渲染通话结束界面 this.renderCallEnded(); break; + case 'PARTICIPANT_LEFT': + // participant离开 - 更新UI但房间仍然存在 + this.renderParticipantLeft(changes.connectionId); + break; } } @@ -892,6 +896,19 @@ class UIRenderer { window.location.href = './endcall/endcall.html'; } + // 渲染participant离开(host端,房间仍然存在) + renderParticipantLeft(connectionId) { + console.log(`Participant left: ${connectionId}, updating UI`); + // 更新远程用户状态显示为离线 + if (this.elements.remoteNetworkIndicator) { + this.elements.remoteNetworkIndicator.className = 'w-2 h-2 bg-gray-500 rounded-full'; + } + // 显示断开连接的遮罩层(如果存在) + if (this.elements.disconnectedOverlay) { + this.elements.disconnectedOverlay.classList.remove('hidden'); + } + } + // 获取状态文本 getStatusText(status) { const statusMap = { diff --git a/WebApp/client/public/onebyone/store.js b/WebApp/client/public/onebyone/store.js index c7f469b..e4db728 100644 --- a/WebApp/client/public/onebyone/store.js +++ b/WebApp/client/public/onebyone/store.js @@ -8,15 +8,9 @@ import { RenderStreaming } from "../../module/renderstreaming.js"; // WebRTC连 import { getServerConfig, getRTCConfiguration } from "../js/config.js";//服务器配置和RTC配置 import { showNotification, generateId } from './utils.js'; // 导入通知函数 import chatMessage from './chatmessage.js'; -// 默认视频流尺寸 -const defaultStreamWidth = 1280; -const defaultStreamHeight = 720; class CallStateManager { constructor() { - const renderstreaming = null; // WebRTC连接管理实例 - const useWebSocket = null; // 是否使用WebSocket信令 - const connectionId = null; // 连接ID // 核心状态 this.state = { id: generateId(), @@ -332,9 +326,23 @@ class CallStateManager { ] }; this.renderstreaming = new RenderStreaming(signaling, config); // 创建WebRTC连接管理实例 - +this.renderstreaming.onNewPeer = (connectionId) => { + console.log(`New peer created for ${connectionId}, adding local tracks`); + if (this.state.localStream) { + const tracks = this.state.localStream.getTracks(); + for (const track of tracks) { + this.renderstreaming.addTransceiver(track, { direction: 'sendonly' }); + } + this.setCodecPreferences(); + } + }; // 连接建立回调 - this.renderstreaming.onConnect = () => { + this.renderstreaming.onConnect = (connectionId, data) => { + // 保存角色信息(host/participant) + if (data && data.role) { + this.role = data.role; + console.log(`Connected as ${this.role}`); + } // 连接建立后,更新状态为ongoing this.state.session.status = 'ongoing'; this.notify({ type: 'CALL_STATUS_CHANGE', status: 'ongoing' }); @@ -347,21 +355,39 @@ class CallStateManager { }); if (this.state.localStream) { - const tracks = this.state.localStream.getTracks(); // 获取本地媒体轨道 - for (const track of tracks) { - this.renderstreaming.addTransceiver(track, { direction: 'sendonly' }); // 添加发送轨道 - } - this.setCodecPreferences(); // 设置编解码器偏好 - this.showStatsMessage(); // 显示统计信息 + // const tracks = this.state.localStream.getTracks(); + // for (const track of tracks) { + // this.renderstreaming.addTransceiver(track, { direction: 'sendonly' }); + // } + // this.setCodecPreferences(); + this.showStatsMessage(); } else { console.error('Local stream is not available'); showNotification('本地视频流不可用', 'error'); } }; - // 连接断开回调 + // 连接断开回调(收到服务器的 disconnect 消息,通常是 host 离开导致房间关闭) this.renderstreaming.onDisconnect = () => { - this.hangUp(); // 挂断连接 + console.log('Received disconnect from server, host left or room closed'); + this.hangUp(); // 房间已关闭,挂断连接 + }; + + // participant离开回调(host收到,房间仍然存在) + this.renderstreaming.onParticipantLeft = (connectionId) => { + console.log(`Participant left: ${connectionId}, room still active`); + // 更新远程用户状态,但不关闭房间 + this.updateRemoteUserStatus('offline'); + this.updateRemoteUserNetworkQuality('no_signal'); + showNotification('对方已离开通话', 'warning'); + // 清理远端流,重置Peer连接为新participant加入做准备 + if (this.state.remoteStream) { + this.state.remoteStream.getTracks().forEach(track => track.stop()); + this.state.remoteStream = null; + } + this.notify({ type: 'REMOTE_STREAM_OBTAINED', stream: null }); + // 通知UI更新 + this.notify({ type: 'PARTICIPANT_LEFT', connectionId: connectionId }); }; // 轨道事件回调 @@ -473,6 +499,8 @@ class CallStateManager { /** * 挂断WebRTC连接 + * Host挂断:房间删除,通知所有participants + * Participant挂断:仅自己离开,房间保留 * @async * @returns {Promise} */ @@ -484,11 +512,17 @@ class CallStateManager { clearInterval(this.durationInterval); this.durationInterval = null; } - console.log(`Disconnect peer on ${this.connectionId}.`); + + const isHost = this.role === 'host'; + console.log(`Disconnect peer on ${this.connectionId}. Role: ${this.role}`); // 删除连接并停止WebRTC if (this.renderstreaming) { try { + // 发送断开连接信令给服务器 + // 服务器会根据角色决定: + // - host断开:通知所有participants,删除房间 + // - participant断开:仅通知host,保留房间 await this.renderstreaming.deleteConnection(); await this.renderstreaming.stop(); } catch (error) { @@ -501,8 +535,9 @@ class CallStateManager { this.updateRemoteUserStatus('offline'); this.updateRemoteUserNetworkQuality('no_signal'); this.connectionId = null; + this.role = null; this.state.session.status = 'ended'; - this.notify({ type: 'CALL_ENDED' }); + this.notify({ type: 'CALL_ENDED', reason: isHost ? 'host_hangup' : 'participant_hangup' }); } /** @@ -570,18 +605,14 @@ class CallStateManager { this.state.session.remoteUser.networkQuality = networkQuality; this.notify({ type: 'REMOTE_MEDIA_CHANGE', localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser }); } - // 结束通话 - endCall() { - if (this.durationInterval) { - clearInterval(this.durationInterval); - this.durationInterval = null; - } - this.state.session.status = 'ended'; - this.notify({ type: 'CALL_ENDED' }); - - // 发送结束通话请求到服务器 - // [API_CALL: POST /api/call/:callId/leave] - // [WEBSOCKET_EMIT: leave-call] + // 结束通话(用户主动点击挂断按钮) + async endCall() { + console.log(`endCall called. Role: ${this.role}`); + // 调用 hangUp() 正确关闭 WebRTC 连接并发送断开信令 + // hangUp 内部会根据角色区分: + // - host: 通知所有participants,删除房间 + // - participant: 仅自己离开,房间保留 + await this.hangUp(); } // 加入通话 diff --git a/WebApp/client/src/peer.js b/WebApp/client/src/peer.js index 92fdd6f..da379a0 100644 --- a/WebApp/client/src/peer.js +++ b/WebApp/client/src/peer.js @@ -42,7 +42,7 @@ export default class Peer extends EventTarget { this.pc.oniceconnectionstatechange = () => { _this.log(`iceConnectionState changed:${_this.pc.iceConnectionState}`); - if (_this.pc.iceConnectionState === 'disconnected') { + if (_this.pc.iceConnectionState === 'failed') { this.dispatchEvent(new Event('disconnect')); } }; diff --git a/WebApp/client/src/renderstreaming.js b/WebApp/client/src/renderstreaming.js index d699ff1..4968730 100644 --- a/WebApp/client/src/renderstreaming.js +++ b/WebApp/client/src/renderstreaming.js @@ -23,6 +23,8 @@ export class RenderStreaming { this.onTrackEvent = function (data) { Logger.log(`OnTrack event peer with data:${data}`); }; this.onAddChannel = function (data) { Logger.log(`onAddChannel event peer with data:${data}`); }; this.onMessage = function (data) { Logger.log(`On message: ${data}`); }; + this.onParticipantLeft = function (connectionId) { Logger.log(`Participant left on ${connectionId}.`); }; + this.onNewPeer = function (connectionId) { Logger.log(`New peer created for ${connectionId}.`); }; this._config = config; this._signaling = signaling; this._signaling.addEventListener('connect', this._onConnect.bind(this)); @@ -31,13 +33,14 @@ export class RenderStreaming { this._signaling.addEventListener('answer', this._onAnswer.bind(this)); this._signaling.addEventListener('candidate', this._onIceCandidate.bind(this)); this._signaling.addEventListener('on-message', this._onMessage.bind(this)); + this._signaling.addEventListener('participant-left', this._onParticipantLeft.bind(this)); } async _onConnect(e) { const data = e.detail; if (this._connectionId == data.connectionId) { this._preparePeerConnection(this._connectionId, data.polite); - this.onConnect(data.connectionId); + this.onConnect(data.connectionId, data); } } @@ -54,6 +57,12 @@ export class RenderStreaming { async _onOffer(e) { const offer = e.detail; + // 如果已有Peer但ICE连接已断开,需要重建Peer + if (this._peer && this._peer.pc && this._peer.pc.iceConnectionState === 'disconnected') { + Logger.log('ICE disconnected, resetting PeerConnection for new offer'); + this._peer.close(); + this._peer = null; + } if (!this._peer) { this._preparePeerConnection(offer.connectionId, offer.polite); } @@ -91,6 +100,12 @@ export class RenderStreaming { const data = e.detail; this.onMessage(data); } + + async _onParticipantLeft(e) { + const data = e.detail; + Logger.log(`Participant left: ${data.connectionId}`); + this.onParticipantLeft(data.connectionId); + } /** * if not set argument, a generated uuid is used. * @param {string | null} connectionId @@ -114,9 +129,9 @@ export class RenderStreaming { // Create peerConnection with proxy server and set up handlers this._peer = new Peer(connectionId, polite, this._config); - this._peer.addEventListener('disconnect', () => { - this.onDisconnect(`Receive disconnect message from peer. connectionId:${connectionId}`); - }); + // this._peer.addEventListener('disconnect', () => { + // this.onDisconnect(`Receive disconnect message from peer. connectionId:${connectionId}`); + // }); this._peer.addEventListener('trackevent', (e) => { const data = e.detail; this.onTrackEvent(data); @@ -145,6 +160,7 @@ export class RenderStreaming { const candidate = e.detail; this._signaling.sendCandidate(candidate.connectionId, candidate.candidate, candidate.sdpMid, candidate.sdpMLineIndex); }); + this.onNewPeer(connectionId); return this._peer; } diff --git a/WebApp/client/src/signaling.js b/WebApp/client/src/signaling.js index 14708b1..9a5ed22 100644 --- a/WebApp/client/src/signaling.js +++ b/WebApp/client/src/signaling.js @@ -193,6 +193,9 @@ export class WebSocketSignaling extends EventTarget { case "on-message": this.dispatchEvent(new CustomEvent('on-message', { detail: msg.data })); break; + case "participant-left": + this.dispatchEvent(new CustomEvent('participant-left', { detail: msg })); + break; case "broadcast": // 处理服务器广播的消息 this.dispatchEvent(new CustomEvent('on-message', { detail: msg.message })); diff --git a/WebApp/package.json b/WebApp/package.json index 2f90044..31864d7 100644 --- a/WebApp/package.json +++ b/WebApp/package.json @@ -7,7 +7,7 @@ "test": "jest --colors test/*.ts", "newman": "newman run test/renderstreaming.postman_collection.json", "start": "node ./build/index.js -s -p 8080 -m private -k ./server.key -c ./server.cert", - "dev": "ts-node ./src/index.ts -s -p 8080", + "dev": "ts-node ./src/index.ts -s -p 8080 -m private", "lint": "eslint src/**/*.ts test/**/*.ts", "pack": "pkg ." }, diff --git a/WebApp/src/class/websockethandler.ts b/WebApp/src/class/websockethandler.ts index e0b23bf..64e18a7 100644 --- a/WebApp/src/class/websockethandler.ts +++ b/WebApp/src/class/websockethandler.ts @@ -19,11 +19,21 @@ let isPrivate: boolean; const clients: Map> = new Map>(); /** - * 连接对映射 - * 键: connectionId - * 值: [WebSocket实例1, WebSocket实例2] + * 连接组结构 + * host: 主机WebSocket实例(第一个连接的客户端) + * participants: 参与者WebSocket集合(后续连接的客户端) */ -const connectionPair: Map = new Map(); +interface ConnectionGroup { + host: WebSocket; + participants: Set; +} + +/** + * 连接组映射 + * 键: connectionId + * 值: ConnectionGroup(1个host + 多个participants) + */ +const connectionGroup: Map = new Map(); /** * 获取或创建WebSocket会话的连接ID集合 @@ -66,6 +76,37 @@ function add(ws: WebSocket): void { console.log(`Add WebSocket: ${id}`); } +/** + * 判断WebSocket是否为指定连接组的host + * @param ws WebSocket连接实例 + * @param connectionId 连接ID + * @returns 是否为host + */ +function isHost(ws: WebSocket, connectionId: string): boolean { + const group = connectionGroup.get(connectionId); + return group != null && group.host === ws; +} + +/** + * 向连接组中除发送者外的所有成员发送消息 + * @param connectionId 连接ID + * @param senderWs 发送者WebSocket实例 + * @param message 要发送的消息对象 + */ +function broadcastToGroup(connectionId: string, senderWs: WebSocket, message: any): void { + const group = connectionGroup.get(connectionId); + if (!group) return; + // 如果发送者是host,转发给所有participants + if (senderWs === group.host) { + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify(message)); + }); + } else { + // 如果发送者是participant,转发给host + group.host.send(JSON.stringify(message)); + } +} + /** * 移除WebSocket连接 * @param ws WebSocket连接实例 @@ -73,20 +114,25 @@ function add(ws: WebSocket): void { function remove(ws: WebSocket): void { // 获取连接的所有连接ID const connectionIds = clients.get(ws); + if (!connectionIds) return; + // 遍历所有连接ID connectionIds.forEach(connectionId => { - // 获取连接对 - const pair = connectionPair.get(connectionId); - if (pair) { - // 找到另一个WebSocket实例 - const otherSessionWs = pair[0] == ws ? pair[1] : pair[0]; - if (otherSessionWs) { - // 向另一个连接发送断开连接消息 - otherSessionWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId })); + const group = connectionGroup.get(connectionId); + if (group) { + if (group.host === ws) { + // host断开连接,通知所有participants房间已关闭 + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId, reason: "host-left" })); + }); + // 删除整个连接组 + connectionGroup.delete(connectionId); + } else { + // participant断开连接,从participants中移除并通知host + group.participants.delete(ws); + group.host.send(JSON.stringify({ type: "participant-left", connectionId: connectionId })); } } - // 从连接对映射中删除 - connectionPair.delete(connectionId); // 记录删除连接ID的日志 console.log(`Remove connectionId: ${connectionId}`); }); @@ -96,7 +142,8 @@ function remove(ws: WebSocket): void { } /** - * 处理连接请求 + * 处理连接请求(1对多模式) + * 第一个连接的客户端成为host,后续连接的客户端成为participants * @param ws WebSocket连接实例 * @param connectionId 连接ID */ @@ -104,21 +151,16 @@ function onConnect(ws: WebSocket, connectionId: string): void { let polite = true; // 处理私有模式 if (isPrivate) { - if (connectionPair.has(connectionId)) { - const pair = connectionPair.get(connectionId); - - if (pair[0] != null && pair[1] != null) { - // 连接ID已被使用 - ws.send(JSON.stringify({ type: "error", message: `${connectionId}: This connection id is already used.` })); - return; - } else if (pair[0] != null) { - // 找到配对连接 - connectionPair.set(connectionId, [pair[0], ws]); - } + if (connectionGroup.has(connectionId)) { + const group = connectionGroup.get(connectionId); + // 已有host,新连接作为participant加入 + group.participants.add(ws); + console.log(`Participant joined connectionId: ${connectionId}, total participants: ${group.participants.size}`); } else { - // 创建新的连接对 - connectionPair.set(connectionId, [ws, null]); + // 第一个连接成为host + connectionGroup.set(connectionId, { host: ws, participants: new Set() }); polite = false; + console.log(`Host created connectionId: ${connectionId}`); } } @@ -126,35 +168,44 @@ function onConnect(ws: WebSocket, connectionId: string): void { const connectionIds = getOrCreateConnectionIds(ws); // 添加连接ID connectionIds.add(connectionId); - // 发送连接成功消息 - ws.send(JSON.stringify({ type: "connect", connectionId: connectionId, polite: polite })); + // 发送连接成功消息(包含角色信息) + const role = polite ? 'participant' : 'host'; + ws.send(JSON.stringify({ type: "connect", connectionId: connectionId, polite: polite, role: role })); //启用心跳包 //AddHeartbeat(ws, connectionId); } /** - * 处理断开连接请求 + * 处理断开连接请求(1对多模式) * @param ws WebSocket连接实例 * @param connectionId 连接ID */ function onDisconnect(ws: WebSocket, connectionId: string): void { // 获取连接的连接ID集合 const connectionIds = clients.get(ws); - // 从集合中删除连接ID - connectionIds.delete(connectionId); + if (connectionIds) { + // 从集合中删除连接ID + connectionIds.delete(connectionId); + } - // 处理连接对 - if (connectionPair.has(connectionId)) { - const pair = connectionPair.get(connectionId); - // 找到另一个WebSocket实例 - const otherSessionWs = pair[0] == ws ? pair[1] : pair[0]; - if (otherSessionWs) { - // 向另一个连接发送断开连接消息 - otherSessionWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId })); + // 处理连接组 + const group = connectionGroup.get(connectionId); + if (group) { + if (group.host === ws) { + // host断开连接,通知所有participants房间已关闭,并删除连接组 + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId, reason: "host-left" })); + }); + connectionGroup.delete(connectionId); + console.log(`Host disconnected, room ${connectionId} deleted, notified ${group.participants.size} participants`); + } else { + // participant断开连接,从组中移除并通知host(使用participant-left类型,host不会关闭房间) + group.participants.delete(ws); + group.host.send(JSON.stringify({ type: "participant-left", connectionId: connectionId })); + console.log(`Participant left connectionId: ${connectionId}, remaining participants: ${group.participants.size}`); } } - // 从连接对映射中删除 - connectionPair.delete(connectionId); + // 向当前连接发送断开连接消息 ws.send(JSON.stringify({ type: "disconnect", connectionId: connectionId })); //RemoveHeartbeat(ws); @@ -163,7 +214,8 @@ function onDisconnect(ws: WebSocket, connectionId: string): void { } /** - * 处理offer信令 + * 处理offer信令(1对多模式) + * host的offer转发给所有participants,participant的offer转发给host * @param ws WebSocket连接实例 * @param message 消息数据 */ @@ -175,22 +227,27 @@ function onOffer(ws: WebSocket, message: any): void { // 处理私有模式 if (isPrivate) { - if (connectionPair.has(connectionId)) { - const pair = connectionPair.get(connectionId); - // 找到另一个WebSocket实例 - const otherSessionWs = pair[0] == ws ? pair[1] : pair[0]; - if (otherSessionWs) { - // 设置为polite模式 + if (connectionGroup.has(connectionId)) { + const group = connectionGroup.get(connectionId); + if (group.host === ws) { + // host发送offer,转发给所有participants newOffer.polite = true; - // 发送offer消息 - otherSessionWs.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer })); + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer })); + }); + } else { + // participant发送offer,转发给host + newOffer.polite = true; + group.host.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer })); } } return; } - // 公共模式:创建新的连接对 - connectionPair.set(connectionId, [ws, null]); + // 公共模式:创建新的连接组(如果不存在) + if (!connectionGroup.has(connectionId)) { + connectionGroup.set(connectionId, { host: ws, participants: new Set() }); + } // 向所有其他客户端广播offer clients.forEach((_v, k) => { if (k == ws) { @@ -201,7 +258,8 @@ function onOffer(ws: WebSocket, message: any): void { } /** - * 处理answer信令 + * 处理answer信令(1对多模式) + * participant的answer转发给host * @param ws WebSocket连接实例 * @param message 消息数据 */ @@ -215,27 +273,27 @@ function onAnswer(ws: WebSocket, message: any): void { // 创建新的answer const newAnswer = new Answer(message.sdp, Date.now()); - // 检查连接对是否存在 - if (!connectionPair.has(connectionId)) { + // 检查连接组是否存在 + if (!connectionGroup.has(connectionId)) { return; } - // 获取连接对 - const pair = connectionPair.get(connectionId); - // 找到另一个WebSocket实例 - const otherSessionWs = pair[0] == ws ? pair[1] : pair[0]; + const group = connectionGroup.get(connectionId); - // 公共模式:更新连接对 - if (!isPrivate) { - connectionPair.set(connectionId, [otherSessionWs, ws]); + if (group.host === ws) { + // host发送answer,转发给所有participants(通常host不发送answer) + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer })); + }); + } else { + // participant发送answer,转发给host + group.host.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer })); } - - // 发送answer消息 - otherSessionWs.send(JSON.stringify({ from: connectionId, to: "", type: "answer", data: newAnswer })); } /** - * 处理candidate信令 + * 处理candidate信令(1对多模式) + * host的candidate转发给所有participants,participant的candidate转发给host * @param ws WebSocket连接实例 * @param message 消息数据 */ @@ -247,13 +305,16 @@ function onCandidate(ws: WebSocket, message: any): void { // 处理私有模式 if (isPrivate) { - if (connectionPair.has(connectionId)) { - const pair = connectionPair.get(connectionId); - // 找到另一个WebSocket实例 - const otherSessionWs = pair[0] == ws ? pair[1] : pair[0]; - if (otherSessionWs) { - // 发送candidate消息 - otherSessionWs.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate })); + if (connectionGroup.has(connectionId)) { + const group = connectionGroup.get(connectionId); + if (group.host === ws) { + // host发送candidate,转发给所有participants + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate })); + }); + } else { + // participant发送candidate,转发给host + group.host.send(JSON.stringify({ from: connectionId, to: "", type: "candidate", data: candidate })); } } return; @@ -263,26 +324,29 @@ function onCandidate(ws: WebSocket, message: any): void { // 获取连接ID const connectionId = message.connectionId; const clientId = message.clientId; - clients.forEach((_v, k) => { - if (k === ws) { - return; + // 在1对多模式下,通知host有新的呼叫请求 + if (connectionGroup.has(connectionId)) { + const group = connectionGroup.get(connectionId); + if (group.host !== ws) { + // participant发起呼叫,通知host + group.host.send(JSON.stringify({ from: connectionId, to: "", type: "call-request", data: connectionId })); } - if (_v == clientId) { - k.send(JSON.stringify({ from: connectionId, to: "", type: "call-request", data: connectionId })); - } - - }); - + } else { + // 兼容旧的广播方式 + clients.forEach((_v, k) => { + if (k === ws) { + return; + } + if (_v == clientId) { + k.send(JSON.stringify({ from: connectionId, to: "", type: "call-request", data: connectionId })); + } + }); + } } /** - * 处理广播消息请求 - * @param ws WebSocket连接实例 - * @param message 消息数据 - */ - /** - * 处理广播消息请求 + * 处理广播消息请求(1对多模式) * @param ws WebSocket连接实例 * @param message 消息数据 */ @@ -291,24 +355,22 @@ function onCandidate(ws: WebSocket, message: any): void { const targetConnectionId = message.targetConnectionId; if (targetConnectionId) { - // 向指定连接广播 - if (connectionPair.has(targetConnectionId)) { - const pair = connectionPair.get(targetConnectionId); - // 向连接对中的两个WebSocket实例发送消息 - if (pair[0]) { - pair[0].send(JSON.stringify({ + // 向指定连接组广播 + if (connectionGroup.has(targetConnectionId)) { + const group = connectionGroup.get(targetConnectionId); + // 向组内所有成员发送消息 + group.host.send(JSON.stringify({ + type: "broadcast", + message: broadcastMessage, + from: "server" + })); + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ type: "broadcast", message: broadcastMessage, from: "server" })); - } - if (pair[1]) { - pair[1].send(JSON.stringify({ - type: "broadcast", - message: broadcastMessage, - from: "server" - })); - } + }); } } else { // 全局广播:向所有客户端发送消息 @@ -353,16 +415,12 @@ function onCandidate(ws: WebSocket, message: any): void { */ function onGetAllConnectionIds(): string[] { // 获取所有connectionId - const connectionIds = Array.from(connectionPair.keys()); - // 发送连接ID列表给客户端 - // ws.send(JSON.stringify({ - // type: "connection-ids", - // connectionIds: connectionIds - // })); + const connectionIds = Array.from(connectionGroup.keys()); return connectionIds; } /** - * 处理chat-message信令 + * 处理chat-message信令(1对多模式) + * host的消息转发给所有participants,participant的消息转发给host * @param ws WebSocket连接实例 * @param message 消息数据 */ @@ -370,17 +428,20 @@ function onCandidate(ws: WebSocket, message: any): void { // 获取连接ID const connectionId = message.connectionId; const chatMessage = message.message; - if (connectionPair.has(connectionId)) { - const pair = connectionPair.get(connectionId); - // 找到另一个WebSocket实例 - const otherSessionWs = pair[0] == ws ? pair[1] : pair[0]; - if (otherSessionWs) { - // 发送chat-message消息 - otherSessionWs.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage })); + if (connectionGroup.has(connectionId)) { + const group = connectionGroup.get(connectionId); + if (group.host === ws) { + // host发送消息,转发给所有participants + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage })); + }); + } else { + // participant发送消息,转发给host + group.host.send(JSON.stringify({ from: connectionId, to: "", type: "on-message", data: chatMessage })); } } } /** * 导出WebSocket处理器函数 */ - export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId, onBroadcast, onGetAllConnectionIds, AddHeartbeat, RemoveHeartbeat ,onMessage}; + export { reset, add, remove, onConnect, onDisconnect, onOffer, onAnswer, onCandidate, onCallConnectionId, onBroadcast, onGetAllConnectionIds, AddHeartbeat, RemoveHeartbeat, onMessage, isHost, broadcastToGroup, connectionGroup }; diff --git a/WebApp/私有模式和公有模式区别.md b/WebApp/私有模式和公有模式区别.md new file mode 100644 index 0000000..e55f2cc --- /dev/null +++ b/WebApp/私有模式和公有模式区别.md @@ -0,0 +1,514 @@ +# WebRTC 信令模式说明:私有模式 vs 公有模式 + +## 📊 核心对比 + +| 特性 | **私有模式 (private)** | **公有模式 (public)** | +|------|----------------------|---------------------| +| **连接关系** | 1对多 (Host-Participants) | 多对多 (广播) | +| **角色分配** | 有明确的 host 和 participant 角色 | 所有客户端平等,无角色区分 | +| **信令路由** | 定向转发 (host ↔ participants) | 全局广播 (除发送者外所有人) | +| **连接组管理** | 使用 `connectionGroup` 管理 | 使用 `clients` 全局列表 | +| **适用场景** | 视频会议、主控-从控 | 直播、公开房间 | + +--- + +## 🔒 私有模式 (Private Mode) + +### 工作原理 + +``` +客户端A (Host) ←→ 服务器 ←→ 客户端B (Participant 1) + ←→ 客户端C (Participant 2) + ←→ 客户端D (Participant 3) +``` + +### 关键特性 + +#### 1. 角色分配机制 + +- **第一个**连接到服务器的客户端成为 **Host** +- **后续**连接的客户端成为 **Participants** +- 通过 `polite` 参数区分角色: + - `host`: `polite = false` + - `participant`: `polite = true` + +**代码实现** (`src/class/websockethandler.ts` 第 150-176 行): + +```typescript +function onConnect(ws: WebSocket, connectionId: string): void { + let polite = true; + + // 处理私有模式 + if (isPrivate) { + if (connectionGroup.has(connectionId)) { + const group = connectionGroup.get(connectionId); + // 已有host,新连接作为participant加入 + group.participants.add(ws); + console.log(`Participant joined connectionId: ${connectionId}, total participants: ${group.participants.size}`); + } else { + // 第一个连接成为host + connectionGroup.set(connectionId, { host: ws, participants: new Set() }); + polite = false; + console.log(`Host created connectionId: ${connectionId}`); + } + } + + // 发送连接成功消息(包含角色信息) + const role = polite ? 'participant' : 'host'; + ws.send(JSON.stringify({ type: "connect", connectionId: connectionId, polite: polite, role: role })); +} +``` + +#### 2. 信令转发规则 + +- **Host 发送** → 转发给**所有 participants** +- **Participant 发送** → 只转发给 **host** +- **Participants 之间不能直接通信** + +**代码实现** (`src/class/websockethandler.ts` 第 96-108 行): + +```typescript +function broadcastToGroup(connectionId: string, senderWs: WebSocket, message: any): void { + const group = connectionGroup.get(connectionId); + if (!group) return; + + // 如果发送者是host,转发给所有participants + if (senderWs === group.host) { + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify(message)); + }); + } else { + // 如果发送者是participant,转发给host + group.host.send(JSON.stringify(message)); + } +} +``` + +#### 3. Offer 信令处理 + +**代码实现** (`src/class/websockethandler.ts` 第 220-243 行): + +```typescript +function onOffer(ws: WebSocket, message: any): void { + const connectionId = message.connectionId as string; + const newOffer = new Offer(message.sdp, Date.now(), false); + + // 处理私有模式 + if (isPrivate) { + if (connectionGroup.has(connectionId)) { + const group = connectionGroup.get(connectionId); + if (group.host === ws) { + // host发送offer,转发给所有participants + newOffer.polite = true; + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer })); + }); + } else { + // participant发送offer,转发给host + newOffer.polite = true; + group.host.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer })); + } + } + return; + } + // ... 公有模式处理 +} +``` + +#### 4. 断开连接处理 + +- **Host 断开** → 通知所有 participants,删除整个连接组 +- **Participant 断开** → 只通知 host,从 participants 中移除 + +**代码实现** (`src/class/websockethandler.ts` 第 114-142 行): + +```typescript +function remove(ws: WebSocket): void { + const connectionIds = clients.get(ws); + if (!connectionIds) return; + + connectionIds.forEach(connectionId => { + const group = connectionGroup.get(connectionId); + if (group) { + if (group.host === ws) { + // host断开连接,通知所有participants + group.participants.forEach(participantWs => { + participantWs.send(JSON.stringify({ type: "disconnect", connectionId: connectionId })); + }); + // 删除整个连接组 + connectionGroup.delete(connectionId); + } else { + // participant断开连接,从participants中移除并通知host + group.participants.delete(ws); + group.host.send(JSON.stringify({ type: "disconnect", connectionId: connectionId })); + } + } + console.log(`Remove connectionId: ${connectionId}`); + }); + + clients.delete(ws); +} +``` + +### 数据结构 + +```typescript +interface ConnectionGroup { + host: WebSocket; // 主机(第一个连接) + participants: Set; // 参与者集合(后续连接) +} + +const connectionGroup: Map = new Map(); +``` + +### 应用场景 + +- ✅ **视频会议** (1个主播 + 多个观众) +- ✅ **远程桌面控制** (1个主控 + 多个观察者) +- ✅ **直播互动** (主播与观众连麦) +- ✅ **教学系统** (教师 + 多个学生) +- ✅ **主从控制** (Unity 应用为主机,多个 Web 客户端为参与者) + +--- + +## 🌐 公有模式 (Public Mode) + +### 工作原理 + +``` +客户端A ←→ 服务器 ←→ 客户端B + ↕ (广播) ↕ +客户端C ←────────────→ 客户端D +``` + +### 关键特性 + +#### 1. 无角色区分 + +- 所有客户端地位平等 +- 没有 host/participant 的概念 +- `polite` 始终为 `true` + +#### 2. 全局广播机制 + +- 任何客户端发送的消息 → **广播给所有其他客户端** +- 支持 **多对多** 通信 +- 每个客户端既是发送者也是接收者 + +**代码实现** (`src/class/websockethandler.ts` 第 245-256 行): + +```typescript +function onOffer(ws: WebSocket, message: any): void { + const connectionId = message.connectionId as string; + const newOffer = new Offer(message.sdp, Date.now(), false); + + // ... 私有模式处理 + + // 公共模式:创建新的连接组(如果不存在) + if (!connectionGroup.has(connectionId)) { + connectionGroup.set(connectionId, { host: ws, participants: new Set() }); + } + + // 向所有其他客户端广播offer + clients.forEach((_v, k) => { + if (k == ws) { + return; // 跳过发送者 + } + k.send(JSON.stringify({ from: connectionId, to: "", type: "offer", data: newOffer })); + }); +} +``` + +#### 3. 对等通信 + +**消息流转示例**: +``` +A 发送消息 → B、C、D 都收到 +B 发送消息 → A、C、D 都收到 +C 发送消息 → A、B、D 都收到 +D 发送消息 → A、B、C 都收到 +``` + +### 应用场景 + +- ✅ **多人聊天室** (所有用户平等) +- ✅ **公开游戏房间** (多玩家对战) +- ✅ **协作编辑** (多人实时编辑) +- ✅ **广播直播** (单向推流,多人观看) +- ✅ **P2P 网状网络** (Full Mesh 拓扑) + +--- + +## 💻 启动方式 + +### 私有模式 + +```bash +# 使用 npm 脚本 +npm start -- -m private + +# 或直接运行 +node ./build/index.js -s -p 8080 -m private -k ./server.key -c ./server.cert + +# 开发模式 +npm run dev -- -m private +``` + +### 公有模式 + +```bash +# 使用 npm 脚本(默认模式) +npm start + +# 或明确指定 +npm start -- -m public + +# 或直接运行 +node ./build/index.js -s -p 8080 -m public -k ./server.key -c ./server.cert +``` + +### 参数说明 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `-m` | 通信模式 | `-m private` 或 `-m public` | +| `-p` | 端口号 | `-p 8080` | +| `-s` | 启用 HTTPS | `-s` | +| `-k` | 密钥文件 | `-k ./server.key` | +| `-c` | 证书文件 | `-c ./server.cert` | +| `-t` | 信令类型 | `-t websocket` 或 `-t http` | + +--- + +## 📝 实际例子 + +### 私有模式示例 + +假设有 3 个客户端连接到同一个 `connectionId: "room1"`: + +**连接顺序**: +1. **客户端A** 先连接 → 成为 **Host** (`polite: false`) +2. **客户端B** 连接 → 成为 **Participant 1** (`polite: true`) +3. **客户端C** 连接 → 成为 **Participant 2** (`polite: true`) + +**消息流转**: +``` +A (Host) 发送消息 + → B 收到 + → C 收到 + +B (Participant) 发送消息 + → A 收到 + → C 收不到 ❌ + +C (Participant) 发送消息 + → A 收到 + → B 收不到 ❌ +``` + +**信令流程图**: +``` +B --[offer]--> 服务器 --[offer]--> A (Host) +A --[answer]--> 服务器 --[answer]--> B +A --[offer]--> 服务器 --[offer]--> C +C --[answer]--> 服务器 --[answer]--> A +``` + +### 公有模式示例 + +假设有 3 个客户端连接: + +**连接顺序**: +1. **客户端A** 连接 (`polite: true`) +2. **客户端B** 连接 (`polite: true`) +3. **客户端C** 连接 (`polite: true`) + +**消息流转**: +``` +A 发送消息 + → B 收到 ✅ + → C 收到 ✅ + +B 发送消息 + → A 收到 ✅ + → C 收到 ✅ + +C 发送消息 + → A 收到 ✅ + → B 收到 ✅ +``` + +**信令流程图**: +``` +A --[offer]--> 服务器 --[offer]--> B +A --[offer]--> 服务器 --[offer]--> C +B --[offer]--> 服务器 --[offer]--> A +B --[offer]--> 服务器 --[offer]--> C +C --[offer]--> 服务器 --[offer]--> A +C --[offer]--> 服务器 --[offer]--> B +``` + +--- + +## 🔍 代码中的模式判断 + +### 初始化模式 + +**`src/class/websockethandler.ts` 第 62-65 行**: + +```typescript +function reset(mode: string): void { + // 设置是否为私有模式 + isPrivate = mode == "private"; +} +``` + +### 模式判断逻辑 + +在各个信令处理函数中,通过 `isPrivate` 变量判断: + +```typescript +// Offer 处理 +if (isPrivate) { + // 私有模式逻辑:定向转发 +} else { + // 公有模式逻辑:全局广播 +} + +// Candidate 处理 +if (isPrivate) { + // 私有模式逻辑 + return; +} + +// Message 处理 +if (connectionGroup.has(connectionId)) { + const group = connectionGroup.get(connectionId); + if (group.host === ws) { + // host 发送 → 转发给 participants + } else { + // participant 发送 → 转发给 host + } +} +``` + +--- + +## 🎯 模式选择建议 + +### 选择私有模式的场景 + +- ✅ 需要明确的主从关系 +- ✅ 中心节点需要控制所有通信 +- ✅ 参与者之间不需要直接通信 +- ✅ 需要管理连接层级结构 +- ✅ 资源优化(减少不必要的 P2P 连接) + +### 选择公有模式的场景 + +- ✅ 所有参与者地位平等 +- ✅ 需要多对多通信 +- ✅ Full Mesh P2P 拓扑 +- ✅ 每个客户端都需要相互连接 +- ✅ 去中心化的应用场景 + +--- + +## 📊 性能对比 + +| 指标 | 私有模式 | 公有模式 | +|------|---------|---------| +| **连接数** | O(n) - 线性增长 | O(n²) - 平方增长 | +| **服务器负载** | 中等(需要路由转发) | 较低(主要是广播) | +| **客户端负载** | 较低(只与 host 建立连接) | 较高(需要与所有客户端建立连接) | +| **网络带宽** | 节省(定向传输) | 较高(广播传输) | +| **扩展性** | 好(适合大量 participants) | 受限(连接数随用户数平方增长) | + +--- + +## 🔧 测试方式 + +### 单元测试 + +项目中已包含完整的单元测试: + +**后端测试** (`test/websockethandler.test.ts`): + +```typescript +// 公有模式测试 +describe('websocket signaling test in public mode', () => { + beforeAll(async () => { + wsHandler.reset("public"); + // ... + }); +}); + +// 私有模式测试 +describe('websocket signaling test in private mode', () => { + beforeAll(async () => { + wsHandler.reset("private"); + // ... + }); +}); +``` + +**前端测试** (`client/test/signaling.test.js`): + +```javascript +// 公有模式测试 +describe.each([ + { mode: "mock" }, + { mode: "http" }, + { mode: "websocket" }, +])('signaling test in public mode', ({ mode }) => { + // ... +}); + +// 私有模式测试 +describe.each([ + { mode: "mock" }, + { mode: "http" }, + { mode: "websocket" }, +])('signaling test in private mode', ({ mode }) => { + // ... +}); +``` + +### 运行测试 + +```bash +# 运行后端测试 +npm test + +# 运行前端测试 +cd client +npm test +``` + +--- + +## 📚 相关文件 + +- **WebSocket 处理器**: `src/class/websockethandler.ts` +- **HTTP 处理器**: `src/class/httphandler.ts` +- **WebSocket 服务**: `src/websocket.ts` +- **前端信令**: `client/src/signaling.js` +- **主入口**: `src/index.ts` +- **后端测试**: `test/websockethandler.test.ts` +- **前端测试**: `client/test/signaling.test.js` + +--- + +## 💡 总结 + +| 模式 | 核心特点 | 最佳用途 | +|------|---------|---------| +| **私有模式** | 1对多、主从架构、定向转发 | 视频会议、远程控制、教学系统 | +| **公有模式** | 多对多、对等网络、全局广播 | 聊天室、多人游戏、协作编辑 | + +选择合适的模式取决于你的应用架构需求和通信拓扑结构。 + +--- + +**文档生成时间**: 2026-04-22 +**项目版本**: 3.1.0