当前位置: 首页 > article >正文

vue+springboot+webtrc+websocket实现双人音视频通话会议

前言

最近一些时间我有研究,如何实现一个视频会议功能,但是找了好多资料都不太理想,最终参考了一个文章
WebRTC实现双端音视频聊天(Vue3 + SpringBoot)

只不过,它的实现效果里面只会播放本地的mp4视频文件,但是按照它的原理是可以正常的实现音视频通话的
它的最终效果是这样的

在这里插入图片描述

然后我的实现逻辑在它的基础上进行了优化
实现了如下效果,如下是我部署项目到服务器之后,和朋友验证之后的截图

在这里插入图片描述

针对它的逻辑,我优化了如下几点

  1. 第一个人可以输入房间号创建房间,需要注意的是,当前第二个人还没加入进来的时候,视频两边都不展示
  2. 第二个人根据第一个人的房间号输入进行加入房间,等待视频流的加载就可以互相看到两边的视频和听到音频
  3. 添加了关闭/开启麦克风和摄像头功能
    ps: 需要注意的是,我接下来分享的代码逻辑,如果某个人突然加入别的房间,原房间它视频分享还是在的,我没有额外进行处理关闭原房间的音视频流,大家可根据这个进行调整

题外话,根据如上的原理,你可以进一步优化,将其开发一个视频会议功能,当前我有开发一个类似的,但是本次只分享双人音视频通话会议项目

在这里插入图片描述
在这里插入图片描述

VUE逻辑

如下为前端部分逻辑,需要注意的是,本次项目还是沿用参考文章的VUE3项目

前端项目结构如下:

在这里插入图片描述

package.json

{
  "name": "webrtc_test",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "axios": "^1.7.7",
    "vue": "^3.5.12"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.1.4",
    "vite": "^5.4.10"
  }
}

换言之,你需要使用npm安装如上依赖

npm i axios@1.7.7

vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import fs from 'fs';
// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
      // 如果需要部署服务器,需要申请SSL证书,然后下载证书到指定文件夹
    https: {
          key: fs.readFileSync('src/certs/www.springsso.top.key'),
          cert: fs.readFileSync('src/certs/www.springsso.top.pem'),
        }
  },
})

main.js

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

App.vue

<template>
  <div class="video-chat">
    <div v-if="isRoomEmpty">
      <p>{{ roomStatusText }}</p>
    </div>
    <!-- 视频双端显示 -->
    <div class="video_box">
      <div class="self_video">
        <div class="text_tip">我:<span class="userId">{{ userId }}</span></div>
        <video ref="localVideo" autoplay playsinline></video>
      </div>
      <div class="remote_video">
        <div class="text_tip">对方:<span class="userId">{{ oppositeUserId }}</span></div>
        <video ref="remoteVideo" autoplay playsinline></video>
      </div>
    </div>
    <!-- 加入房间按钮 -->
    <div class="room-controls">
      <div class="room-input">
        <input v-model="roomId" placeholder="请输入房间号" />
        <button @click="createRoom">创建房间</button>
        <button @click="joinRoomWithId">加入房间</button>
      </div>
      <div class="media-controls">
        <button @click="toggleAudio">
          {{ isAudioEnabled ? '关闭麦克风' : '打开麦克风' }}
        </button>
        <button @click="toggleVideo">
          {{ isVideoEnabled ? '关闭摄像头' : '打开摄像头' }}
        </button>
      </div>
    </div>
    <!-- 日志打印 -->
    <div class="log_box">
      <pre>
          <div v-for="(item, index) of logData" :key="index">{{ item }}</div>
        </pre>
    </div>
  </div>
</template>
<script setup>
import { ref, onMounted, nextTick } from "vue";
import axios from "axios";

// WebRTC 相关变量
const localVideo = ref(null);
const remoteVideo = ref(null);
const isRoomEmpty = ref(true); // 判断房间是否为空

let localStream; // 本地流数据
let peerConnection; // RTC连接对象
let signalingSocket; // 信令服务器socket对象
let userId; // 当前用户ID
let oppositeUserId; // 对方用户ID

let logData = ref(["日志初始化..."]);

// 请求根路径,如果需要部署服务器,把对应ip改成自己服务器ip
let BaseUrl = "https://localhost:8095/meetingV1s"

let wsUrl = "wss://localhost:8095/meetingV1s";


// candidate信息
let candidateInfo = "";

// 发起端标识
let offerFlag = false;

// 房间状态文本
let roomStatusText = ref("点击'加入房间'开始音视频聊天");

// STUN 服务器,
// const iceServers = [
//   {
//     urls: "stun:stun.l.google.com:19302"  // Google 的 STUN 服务器
//   },
//   {
//     urls: "stun:自己的公网IP:3478" // 自己的Stun服务器
//   },
//   {
//     urls: "turn:自己的公网IP:3478",   // 自己的 TURN 服务器
//     username: "maohe",
//     credential: "maohe"
//   }
// ];
// ============< 看这 >================
// 没有搭建STUN和TURN服务器的使用如下ice配置即可
const iceServers = [
  {
    urls: "stun:stun.l.google.com:19302"  // Google 的 STUN 服务器
  }
];

// 在 script setup 中添加新的变量声明
const roomId = ref(''); // 房间号
const isAudioEnabled = ref(true); // 音频状态
const isVideoEnabled = ref(true); // 视频状态

onMounted(() => {
  generateRandomId();
})

// 加入房间,开启本地摄像头获取音视频流数据。
function joinRoomHandle() {
  roomStatusText.value = "等待对方加入房间..."
  getVideoStream();
}


// 获取本地视频 模拟从本地摄像头获取音视频流数据
async function getVideoStream() {
  try {
    localStream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    localVideo.value.srcObject = localStream;
    wlog(`获取本地流成功~`)
    createPeerConnection(); // 创建RTC对象,监听candidate
  } catch (err) {
    console.error('获取本地媒体流失败:', err);
  }
}


// 初始化 WebSocket 连接
function initWebSocket() {
  wlog("开始连接websocket")
  // 连接ws时携带用户ID和房间号
  signalingSocket = new WebSocket(`${wsUrl}/rtc?userId=${userId}&roomId=${roomId.value}`);

  signalingSocket.onopen = () => {
    wlog('WebSocket 已连接');
  };

  // 消息处理
  signalingSocket.onmessage = (event) => {
    handleSignalingMessage(event.data);
  };
};

// 消息处理器 - 解析器
function handleSignalingMessage(message) {
  wlog("收到ws消息,开始解析...")
  wlog(message)
  let parseMsg = JSON.parse(message);
  wlog(`解析结果:${parseMsg}`);

  if (parseMsg.type == "join") {
    joinHandle(parseMsg.data);
  } else if (parseMsg.type == "offer") {
    wlog("收到发起端offer,开始解析...");
    offerHandle(parseMsg.data);
  } else if (parseMsg.type == "answer") {
    wlog("收到接收端的answer,开始解析...");
    answerHandle(parseMsg.data);
  }else if(parseMsg.type == "candidate"){
    wlog("收到远端candidate,开始解析...");
    candidateHandle(parseMsg.data);
  }

}

// 远端Candidate处理器
async function candidateHandle(candidate){
  peerConnection.addIceCandidate(new RTCIceCandidate(JSON.parse(candidate)));
  wlog("+++++++ 本端candidate设置完毕 ++++++++");
}

// 接收端的answer处理
async function answerHandle(answer) {
  wlog("将answer设置为远端信息");
  peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(answer))); // 设置远端SDP
}

// 发起端offer处理器
async function offerHandle(offer) {
  wlog("将发起端的offer设置为远端媒体信息");
  await peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(offer)));
  wlog("创建Answer 并设置到本地");
  let answer = await peerConnection.createAnswer()
  await peerConnection.setLocalDescription(answer);

  wlog("发送answer给发起端");
  // 构造answer消息发送给对端
  let paramObj = {
    userId: oppositeUserId,
    type: "answer",
    data: JSON.stringify(answer)
  }
  // 执行发送
  const res = await axios.post(`${BaseUrl}/rtcs/sendMessage`, paramObj);
}

// 加入处理器
function joinHandle(userIds) {
  // 判断连接的用户个数
  if (userIds.length == 1 && userIds[0] == userId) {
    wlog("标识为发起端,等待对方加入房间...")
    isRoomEmpty.value = true;
    // 存在一个连接并且是自身,标识我们是发起端
    offerFlag = true;
  } else if (userIds.length > 1) {
    // 对方加入了
    wlog("对方已连接...")
    isRoomEmpty.value = false;

    // 取出对方ID
    for (let id of userIds) {
      if (id != userId) {
        oppositeUserId = id;
      }
    }

    wlog(`对端ID: ${oppositeUserId}`)
    // 开始交换SDP和Candidate
    swapVideoInfo()
  }
}

// 交换SDP和candidate
async function swapVideoInfo() {
  wlog("开始交换Sdp和Candidate...");
  // 检查是否为发起端,如果是创建offer设置到本地,并发送给远端
  if (offerFlag) {
    wlog(`发起端创建offer`)
    let offer = await peerConnection.createOffer()
    await peerConnection.setLocalDescription(offer); // 将媒体信息设置到本地
    wlog("发启端设置SDP-offer到本地");

    // 构造消息ws发送给远端
    let paramObj = {
      userId: oppositeUserId,
      type: "offer",
      data: JSON.stringify(offer)
    };

    wlog(`构造offer信息发送给远端:${paramObj}`)

    // 执行发送
    const res = await axios.post(`${BaseUrl}/rtcs/sendMessage`, paramObj);

  }
}

// 将candidate信息发送给远端
async function sendCandidate(candidate) {
  // 构造消息ws发送给远端
  let paramObj = {
    userId: oppositeUserId,
    type: "candidate",
    data: JSON.stringify(candidate)
  };

  wlog(`构造candidate信息发送给远端:${paramObj}`);

  // 执行发送
  const res = await axios.post(`${BaseUrl}/rtcs/sendMessage`, paramObj);

}

// 创建RTC连接对象并监听和获取condidate信息
function createPeerConnection() {
  wlog("开始创建PC对象...")
  peerConnection = new RTCPeerConnection(iceServers);
  wlog("创建PC对象成功")
  // 创建RTC连接对象后连接websocket
  initWebSocket();

  // 监听网络信息(ICE Candidate)
  peerConnection.onicecandidate = (event) => {
    if (event.candidate) {
      candidateInfo = event.candidate;
      wlog("candidate信息变化...");
      // 将candidate信息发送给远端
      setTimeout(()=>{
        sendCandidate(event.candidate);
      }, 150)
    }
  };

  // 监听远端音视频流
  peerConnection.ontrack = (event) => {
    nextTick(() => {
      wlog("====> 收到远端数据流 <=====")
      if (!remoteVideo.value.srcObject) {
        remoteVideo.value.srcObject = event.streams[0];
        remoteVideo.value.play();  // 强制播放
      }
    });
  };

  // 监听ice连接状态
  peerConnection.oniceconnectionstatechange = () => {
    wlog(`RTC连接状态改变:${peerConnection.iceConnectionState}`);
  };


  // 添加本地音视频流到 PeerConnection
  localStream.getTracks().forEach(track => {
    peerConnection.addTrack(track, localStream);
  });
}


// 日志编写
function wlog(text) {
  logData.value.unshift(text);
}

// 给用户生成随机ID.
function generateRandomId() {
  userId = Math.random().toString(36).substring(2, 12); // 生成10位的随机ID
  wlog(`分配到ID:${userId}`)
}

// 创建房间
async function createRoom() {
  if (!roomId.value) {
    alert('请输入房间号');
    return;
  }
  try {
    const res = await axios.post(`${BaseUrl}/rtcs/createRoom`, {
      roomId: roomId.value,
      userId: userId
    });
    if (res.data.success) {
      wlog(`创建房间成功:${roomId.value}`);
      joinRoomHandle();
    }
  } catch (error) {
    wlog(`创建房间失败:${error}`);
  }
}

// 加入指定房间
async function joinRoomWithId() {
  if (!roomId.value) {
    alert('请输入房间号');
    return;
  }
  try {
    const res = await axios.post(`${BaseUrl}/rtcs/joinRoom`, {
      roomId: roomId.value,
      userId: userId
    });
    if (res.data.success) {
      wlog(`加入房间成功:${roomId.value}`);
      joinRoomHandle();
    }
  } catch (error) {
    wlog(`加入房间失败:${error}`);
  }
}

// 切换音频
function toggleAudio() {
  if (localStream) {
    const audioTrack = localStream.getAudioTracks()[0];
    if (audioTrack) {
      audioTrack.enabled = !audioTrack.enabled;
      isAudioEnabled.value = audioTrack.enabled;
      wlog(`麦克风已${audioTrack.enabled ? '打开' : '关闭'}`);
    }
  }
}

// 切换视频
function toggleVideo() {
  if (localStream) {
    const videoTrack = localStream.getVideoTracks()[0];
    if (videoTrack) {
      videoTrack.enabled = !videoTrack.enabled;
      isVideoEnabled.value = videoTrack.enabled;
      wlog(`摄像头已${videoTrack.enabled ? '打开' : '关闭'}`);
    }
  }
}
</script>

<style scoped>
.video-chat {
  display: flex;
  flex-direction: column;
  align-items: center;
}

video {
  width: 300px;
  height: 200px;
  margin: 10px;
}

.remote_video {
  border: solid rgb(30, 40, 226) 1px;
  margin-left: 20px;
}

.self_video {
  border: solid red 1px;
}

.video_box {
  display: flex;
}

.video_box div {
  border-radius: 10px;
}

.join_room_btn button {
  border: none;
  background-color: rgb(119 178 63);
  height: 30px;
  width: 80px;
  border-radius: 10px;
  color: white;
  margin-top: 10px;
  cursor: pointer;
  font-size: 13px;
}

.text_tip {
  font-size: 13px;
  color: #484848;
  padding: 6px;
}

pre {
  width: 600px;
  height: 300px;
  background-color: #d4d4d4;
  border-radius: 10px;
  padding: 10px;
  overflow-y: auto;
}

pre div {
  padding: 4px 0px;
  font-size: 15px;
}

.userId{
  color: #3669ad;
}

.video-chat p{
  font-weight: 600;
  color: #b24242;
}

.room-controls {
  margin: 20px 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.room-input {
  display: flex;
  gap: 10px;
  align-items: center;
}

.room-input input {
  padding: 5px 10px;
  border: 1px solid #ccc;
  border-radius: 5px;
}

.media-controls {
  display: flex;
  gap: 10px;
}

.room-controls button {
  border: none;
  background-color: rgb(119 178 63);
  height: 30px;
  padding: 0 15px;
  border-radius: 5px;
  color: white;
  cursor: pointer;
  font-size: 13px;
}

.media-controls button {
  background-color: #3669ad;
}
</style>

SpringBoot逻辑

如下为后端逻辑,项目结构如下:
在这里插入图片描述

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.7.9</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.mh</groupId>
	<artifactId>webrtc-backend</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>webrtc-backend</name>
	<description>webrtc-backend</description>

	<properties>
		<java.version>1.8</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.18.34</version>
		</dependency>

	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<version>2.6.2</version>
				<configuration>
					<mainClass>com.mh.WebrtcBackendApplication</mainClass>
					<layout>ZIP</layout>
				</configuration>
				<executions>
					<execution>
						<goals>
							<goal>repackage</goal>
						</goals>
					</execution>
				</executions>
			</plugin>
		</plugins>
	</build>

</project>

application.yml

server:
  port: 8095
  servlet:
    context-path: /meetingV1s
  ssl: #ssl配置
    enabled: true  # 默认为true
    #key-alias: alias-key # 别名(可以不进行配置)
    # 保存SSL证书的秘钥库的路径,如果部署到服务器,必须要开启ssl才能获取到摄像头和麦克风
    key-store: classpath:www.springsso.top.jks
    # ssl证书密码
    key-password: gf71v8lf
    key-store-password: gf71v8lf
    key-store-type: JKS
  tomcat:
    uri-encoding: UTF-8

入口文件

// 这个是自己实际项目位置
package com.mh;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class WebrtcBackendApplication {

	public static void main(String[] args) {
		SpringApplication.run(WebrtcBackendApplication.class, args);
	}

}

WebSocket处理器

package com.mh.common;

import com.mh.dto.bo.UserManager;
import com.mh.dto.vo.MessageOut;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.util.ArrayList;
import java.util.Set;

/**
 * Date:2024/11/14
 * author:zmh
 * description: WebSocket处理器
 **/

@Component
@RequiredArgsConstructor
@Slf4j
public class RtcWebSocketHandler  extends TextWebSocketHandler {

    // 管理用户的加入和退出...
    private final UserManager userManager;
    private final ObjectMapper objectMapper = new ObjectMapper();

    // 用户连接成功
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 获取用户ID和房间ID
        String userId = getParameterByName(session.getUri(), "userId");
        String roomId = getParameterByName(session.getUri(), "roomId");

        if (userId != null && roomId != null) {
            // 保存用户会话
            userManager.addUser(userId, session);
            log.info("用户 {} 连接成功,房间:{}", userId, roomId);

            // 获取房间中的所有用户
            Set<String> roomUsers = userManager.getRoomUsers(roomId);
            
            // 通知房间内所有用户(包括新加入的用户)
            for (String uid : roomUsers) {
                WebSocketSession userSession = userManager.getUser(uid);
                if (userSession != null && userSession.isOpen()) {
                    MessageOut messageOut = new MessageOut();
                    messageOut.setType("join");
                    messageOut.setData(new ArrayList<>(roomUsers));
                    
                    String message = objectMapper.writeValueAsString(messageOut);
                    userSession.sendMessage(new TextMessage(message));
                    log.info("向用户 {} 发送房间更新消息", uid);
                }
            }
        }
    }

    // 接收到客户端消息,解析消息内容进行分发
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 转换并分发消息
        log.info("收到消息");
    }

    // 处理断开的连接
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        String userId = getParameterByName(session.getUri(), "userId");
        String roomId = getParameterByName(session.getUri(), "roomId");

        if (userId != null && roomId != null) {
            // 从房间和会话管理中移除用户
            userManager.removeUser(userId);
            userManager.leaveRoom(roomId, userId);
            
            // 获取更新后的房间用户列表
            Set<String> remainingUsers = userManager.getRoomUsers(roomId);
            
            // 通知房间内的其他用户
            for (String uid : remainingUsers) {
                WebSocketSession userSession = userManager.getUser(uid);
                if (userSession != null && userSession.isOpen()) {
                    MessageOut messageOut = new MessageOut();
                    messageOut.setType("join");
                    messageOut.setData(new ArrayList<>(remainingUsers));
                    
                    String message = objectMapper.writeValueAsString(messageOut);
                    userSession.sendMessage(new TextMessage(message));
                    log.info("向用户 {} 发送用户离开更新消息", uid);
                }
            }
            
            log.info("用户 {} 断开连接,房间:{}", userId, roomId);
        }
    }

    // 辅助方法:从URI中获取参数值
    private String getParameterByName(URI uri, String paramName) {
        String query = uri.getQuery();
        if (query != null) {
            String[] pairs = query.split("&");
            for (String pair : pairs) {
                String[] keyValue = pair.split("=");
                if (keyValue.length == 2 && keyValue[0].equals(paramName)) {
                    return keyValue[1];
                }
            }
        }
        return null;
    }
}

WebSocket配置类

package com.mh.config;

import com.mh.common.RtcWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

/**
 * Date:2024/11/14
 * author:zmh
 * description: WebSocket配置类
 **/

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final RtcWebSocketHandler rtcWebSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(rtcWebSocketHandler, "/rtc")
                .setAllowedOrigins("*");
    }
}

webRtc相关接口

package com.mh.controller;

import com.mh.dto.bo.UserManager;
import com.mh.dto.vo.MessageReceive;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/**
 * Date:2024/11/15
 * author:zmh
 * description: rtc 相关接口
 **/

@RestController
@Slf4j
@CrossOrigin
@RequiredArgsConstructor
@RequestMapping("/rtcs")
public class RtcController {

    private final UserManager userManager;

    /**
     * 给指定用户发送执行类型消息
     * @param messageReceive 消息参数接收Vo
     * @return ·
     */
    @PostMapping("/sendMessage")
    public Boolean sendMessage(@RequestBody MessageReceive messageReceive){
        userManager.sendMessage(messageReceive);
        return true;
    }

    @PostMapping("/createRoom")
    public ResponseEntity<?> createRoom(@RequestBody Map<String, String> params) {
        String roomId = params.get("roomId");
        String userId = params.get("userId");
        // 在 UserManager 中实现房间创建逻辑
        boolean success = userManager.createRoom(roomId, userId);
        Map<String, Object> response = new HashMap<>();
        response.put("success", success);
        return ResponseEntity.ok(response);
    }

    @PostMapping("/joinRoom")
    public ResponseEntity<?> joinRoom(@RequestBody Map<String, String> params) {
        String roomId = params.get("roomId");
        String userId = params.get("userId");
        // 在 UserManager 中实现加入房间逻辑
        boolean success = userManager.joinRoom(roomId, userId);
        Map<String, Object> response = new HashMap<>();
        response.put("success", success);
        return ResponseEntity.ok(response);
    }
}

用户管理器单例对象

package com.mh.dto.bo;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mh.dto.vo.MessageOut;
import com.mh.dto.vo.MessageReceive;
import java.util.stream.Collectors;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.HashSet;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Date:2024/11/14
 * author:zmh
 * description: 用户管理器单例对象
 **/

@Data
@Component
@Slf4j
public class UserManager {
    // 管理连接用户信息
    private final HashMap<String, WebSocketSession> userMap = new HashMap<>();

    // 添加房间管理的Map
    private final Map<String, Set<String>> roomUsers = new ConcurrentHashMap<>();

    // 加入用户
    public void addUser(String userId, WebSocketSession session) {
        userMap.put(userId, session);
        log.info("用户 {} 加入", userId);
    }

    // 移除用户
    public void removeUser(String userId) {
        userMap.remove(userId);
        log.info("用户 {} 退出", userId);
    }

    // 获取用户
    public WebSocketSession getUser(String userId) {
        return userMap.get(userId);
    }

    // 获取所有用户ID构造成list返回
    public List<String> getAllUserId() {
        return userMap.keySet().stream().collect(Collectors.toList());
    }

    // 通知用户加入-广播消息
    public void sendMessageAllUser() throws IOException {
        // 获取所有连接用户ID列表
        List<String> allUserId = getAllUserId();
        for (String userId : userMap.keySet()) {
            WebSocketSession session = userMap.get(userId);
            MessageOut messageOut = new MessageOut("join", allUserId);
            String messageText = new ObjectMapper().writeValueAsString(messageOut);
            // 广播消息
             session.sendMessage(new TextMessage(messageText));
        }
    }

    /**
     * 创建房间
     * @param roomId 房间ID
     * @param userId 用户ID
     * @return 创建结果
     */
    public boolean createRoom(String roomId, String userId) {
        if (roomUsers.containsKey(roomId)) {
            log.warn("房间 {} 已存在", roomId);
            return false;
        }
        Set<String> users = new HashSet<>();
        users.add(userId);
        roomUsers.put(roomId, users);
        log.info("用户 {} 创建了房间 {}", userId, roomId);
        return true;
    }

    /**
     * 加入房间
     * @param roomId 房间ID
     * @param userId 用户ID
     * @return 加入结果
     */
    public boolean joinRoom(String roomId, String userId) {
        Set<String> users = roomUsers.computeIfAbsent(roomId, k -> new HashSet<>());
        if (users.size() >= 2) {
            log.warn("房间 {} 已满", roomId);
            return false;
        }
        users.add(userId);
        log.info("用户 {} 加入房间 {}", userId, roomId);
        return true;
    }

    /**
     * 离开房间
     * @param roomId 房间ID
     * @param userId 用户ID
     */
    public void leaveRoom(String roomId, String userId) {
        Set<String> users = roomUsers.get(roomId);
        if (users != null) {
            users.remove(userId);
            if (users.isEmpty()) {
                roomUsers.remove(roomId);
                log.info("房间 {} 已清空并删除", roomId);
            }
            log.info("用户 {} 离开了房间 {}", userId, roomId);
        }
    }

    /**
     * 获取房间用户
     * @param roomId 房间ID
     * @return 用户集合
     */
    public Set<String> getRoomUsers(String roomId) {
        return roomUsers.getOrDefault(roomId, new HashSet<>());
    }

    // 修改现有的 sendMessage 方法,考虑房间信息
    public void sendMessage(MessageReceive messageReceive) {
        String userId = messageReceive.getUserId();
        String type = messageReceive.getType();
        String data = messageReceive.getData();
        
        WebSocketSession session = userMap.get(userId);
        if (session != null && session.isOpen()) {
            try {
                MessageOut messageOut = new MessageOut();
                messageOut.setType(type);
                messageOut.setData(data);
                
                String message = new ObjectMapper().writeValueAsString(messageOut);
                session.sendMessage(new TextMessage(message));
                log.info("消息发送成功: type={}, to={}", type, userId);
            } catch (Exception e) {
                log.error("消息发送失败", e);
            }
        }
    }
}

消息输出前端Vo对象

package com.mh.dto.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * Date:2024/11/15
 * author:zmh
 * description: 消息输出前端Vo对象
 **/

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageOut {
    /**
     * 消息类型【join, offer, answer, candidate, leave】
     */
    private String type;

    /**
     * 消息内容 前端stringFiy序列化后字符串
     */
    private Object data;
}

消息接收Vo对象

package com.mh.dto.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * Date:2024/11/15
 * author:zmh
 * description: 消息接收Vo对象
 **/

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageReceive {

    /**
     * 用户ID,用于获取用户Session
     */
    private String userId;

    /**
     * 消息类型【join, offer, answer, candidate, leave】
     */
    private String type;

    /**
     * 消息内容 前端stringFiy序列化后字符串
     */
    private String data;
}

结语

如上为vue+springboot+webtrc+websocket实现双人音视频通话会议的全部逻辑,如有遗漏后续会进行补充


http://www.kler.cn/a/547897.html

相关文章:

  • 仿 RabbitMQ 消息队列6(实战项目)
  • DeepSeek 从入门到精通学习指南,2025清华大学《DeepSeek从入门到精通》正式发布104页pdf版超全解析
  • CAS单点登录(第7版)16.模仿
  • 如何使用Three.js制作3D月球与星空效果
  • Deesek:新一代数据处理与分析框架实战指南
  • TypeScript type 和 interface 的区别
  • 1-YIUI总览
  • 基于Python豆瓣电影数据可视化分析系统的设计与实现
  • vscode调试redis
  • Uniapp 从入门到精通:数据绑定与响应式原理
  • 使用pocketpal-ai在手机上搭建本地AI聊天环境
  • 基于大数据的北京市天气数据分析系统的设计与实现
  • 今日AI和商界事件(2025-02-15)
  • .NET版Word处理控件Aspose.Words教程:使用 C# 删除 Word 中的空白页
  • GeekPad智慧屏编程控制(三)
  • 微软AutoGen高级功能——Magentic-One
  • Java八股文详细文档.3(基于黑马、ChatGPT、DeepSeek)
  • Golang GC 三色标记法
  • 【算法】【区间和】acwing算法基础 802. 区间和 【有点复杂,但思路简单】
  • Windows环境安装Kafka(集群版)