Browse Source

群聊多人音视频v1.0

wangqi49 5 months ago
parent
commit
2834749f1d

+ 5 - 5
sql/webchat.sql

@@ -283,8 +283,8 @@ CREATE TABLE webchat.`web_chat_slide_verification` (
 ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='滑块验证配置信息';
 
 -- 初始化验证码配置,管理后台可上传
-INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES (5, '/image/slide/file_5ed7dbb98fb04fa490d157e8798d1f23.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:16:41', NULL, NULL, 1);
-INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES (6, '/image/slide/file_5b9df5f2de52439dbc74c86a6f9e749d.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:16:46', NULL, NULL, 1);
-INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES (7, '/image/slide/file_cc3fc5dea48a43c2923f9e001b9f1b85.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:16:51', NULL, NULL, 1);
-INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES (8, '/image/slide/file_f36ec169377842dd8ec0c65a43619811.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:16:56', NULL, NULL, 1);
-INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES (9, '/image/slide/file_b44c3489577b4dfcaae9c09220ff677c.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:17:00', NULL, NULL, 1);
+INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES ('/image/slide/file_5ed7dbb98fb04fa490d157e8798d1f23.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:16:41', NULL, NULL, 1);
+INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES ('/image/slide/file_5b9df5f2de52439dbc74c86a6f9e749d.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:16:46', NULL, NULL, 1);
+INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES ('/image/slide/file_cc3fc5dea48a43c2923f9e001b9f1b85.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:16:51', NULL, NULL, 1);
+INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES ('/image/slide/file_f36ec169377842dd8ec0c65a43619811.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:16:56', NULL, NULL, 1);
+INSERT INTO `webchat`.`web_chat_slide_verification`(`image`, `x`, `y`, `count`, `STATUS`, `CREATE_BY`, `CREATE_DATE`, `UPDATE_BY`, `UPDATE_DATE`, `VERSION`) VALUES ('/image/slide/file_b44c3489577b4dfcaae9c09220ff677c.jpeg', 0, 0, 0, 1, NULL, '2024-09-19 01:17:00', NULL, NULL, 1);

+ 8 - 1
src/main/java/com/webchat/common/enums/RedisMessageChannelTopicEnum.java

@@ -5,7 +5,14 @@ import lombok.Getter;
 @Getter
 public enum RedisMessageChannelTopicEnum {
 
-    CHAT, PUSH_ARTICLE, APPLY, VIDEO_OFFER_SEND, VIDEO_OFFER_RECEIVER;
+    CHAT,
+    PUSH_ARTICLE,
+    APPLY,
+    GROUP_VIDEO_ONLINE,
+    VIDEO_OFFER_SEND,
+    VIDEO_OFFER_RECEIVER,
+    GROUP_VIDEO_OFFER_SEND,
+    GROUP_VIDEO_OFFER_RECEIVER;
 
     public String getChannel() {
         return this.name();

+ 12 - 0
src/main/java/com/webchat/config/configuration/RedisConfig.java

@@ -3,6 +3,9 @@ package com.webchat.config.configuration;
 import com.webchat.common.enums.RedisMessageChannelTopicEnum;
 import com.webchat.service.listener.RedisApplyMessageListener;
 import com.webchat.service.listener.RedisChatMessageListener;
+import com.webchat.service.listener.RedisGroupVideoOfferReceiveMessageListener;
+import com.webchat.service.listener.RedisGroupVideoOfferSendMessageListener;
+import com.webchat.service.listener.RedisGroupVideoOnlineMessageListener;
 import com.webchat.service.listener.RedisPushMessageListener;
 import com.webchat.service.listener.RedisVideoOfferReceiveMessageListener;
 import com.webchat.service.listener.RedisVideoOfferSendMessageListener;
@@ -27,6 +30,12 @@ public class RedisConfig {
     private RedisVideoOfferSendMessageListener videoOfferSendMessageListener;
     @Resource
     private RedisVideoOfferReceiveMessageListener videoOfferReceiveMessageListener;
+    @Resource
+    private RedisGroupVideoOfferSendMessageListener groupVideoOfferSendMessageListener;
+    @Resource
+    private RedisGroupVideoOfferReceiveMessageListener groupVideoOfferReceiveMessageListener;
+    @Resource
+    private RedisGroupVideoOnlineMessageListener groupVideoOnlineMessageListener;
 
     @Bean
     public RedisMessageListenerContainer container(RedisConnectionFactory redisConnectionFactory) {
@@ -39,6 +48,9 @@ public class RedisConfig {
         container.addMessageListener(redisApplyMessageListener, new ChannelTopic(RedisMessageChannelTopicEnum.APPLY.getChannel()));
         container.addMessageListener(videoOfferSendMessageListener, new ChannelTopic(RedisMessageChannelTopicEnum.VIDEO_OFFER_SEND.getChannel()));
         container.addMessageListener(videoOfferReceiveMessageListener, new ChannelTopic(RedisMessageChannelTopicEnum.VIDEO_OFFER_RECEIVER.getChannel()));
+        container.addMessageListener(groupVideoOfferSendMessageListener, new ChannelTopic(RedisMessageChannelTopicEnum.GROUP_VIDEO_OFFER_SEND.getChannel()));
+        container.addMessageListener(groupVideoOfferReceiveMessageListener, new ChannelTopic(RedisMessageChannelTopicEnum.GROUP_VIDEO_OFFER_RECEIVER.getChannel()));
+        container.addMessageListener(groupVideoOnlineMessageListener, new ChannelTopic(RedisMessageChannelTopicEnum.GROUP_VIDEO_ONLINE.getChannel()));
         return container;
     }
 

+ 116 - 0
src/main/java/com/webchat/controller/client/GroupVideoChatDcsWebSocket.java

@@ -0,0 +1,116 @@
+package com.webchat.controller.client;
+
+import com.webchat.common.enums.RedisMessageChannelTopicEnum;
+import com.webchat.common.util.JsonUtil;
+import com.webchat.common.util.SpringContextUtil;
+import com.webchat.domain.vo.request.mess.VideoChatMessageRequestVO;
+import com.webchat.domain.vo.response.UserBaseResponseInfoVO;
+import com.webchat.service.listener.RedisMessageSender;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.MapUtils;
+import org.springframework.stereotype.Component;
+
+import javax.websocket.EndpointConfig;
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.Session;
+import javax.websocket.server.PathParam;
+import javax.websocket.server.ServerEndpoint;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Data
+@Slf4j
+@Component
+@ServerEndpoint(value = "/ws/group-video-chat/{groupId}/{userId}")
+public class GroupVideoChatDcsWebSocket {
+    /**
+     * 群聊多人音视频webscoket连接对象存储
+     */
+    public static ConcurrentHashMap<String, LinkedHashMap<String, GroupVideoChatDcsWebSocket>> clients = new ConcurrentHashMap();
+    //每个连接都会有自己的会话
+    private Session session;
+    private String groupId;
+    private String userId;
+
+    @OnOpen
+    public void open(@PathParam("groupId") String groupId, @PathParam("userId") String userId, Session session, EndpointConfig config){
+        log.info("音视频通话用户{}的客户端连接成功!", userId);
+        LinkedHashMap<String, GroupVideoChatDcsWebSocket> userSocketTable = clients.get(groupId);
+        if (userSocketTable == null) {
+            userSocketTable = new LinkedHashMap<>();
+            clients.put(groupId, userSocketTable);
+        }
+        userSocketTable.put(userId, this);
+        this.session = session;
+        this.groupId = groupId;
+        this.userId = userId;
+        // 同步当前在线群聊
+        RedisMessageSender redisMessageSender = SpringContextUtil.getBean(RedisMessageSender.class);
+        VideoCustomMessage videoCustomMessage = new VideoCustomMessage();
+        videoCustomMessage.setType("online");
+        videoCustomMessage.setGroupId(groupId);
+        videoCustomMessage.setUserIds(getOnlineUserIdList(groupId));
+        redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.GROUP_VIDEO_ONLINE.getChannel(), JsonUtil.toJsonString(videoCustomMessage));
+    }
+
+    @OnClose
+    public void close(){
+        Map<String, GroupVideoChatDcsWebSocket> userClients = clients.get(groupId);
+        if (MapUtils.isNotEmpty(userClients)) {
+            userClients.remove(userId);
+        }
+    }
+
+    @OnError
+    public void error(Throwable error){
+        error.printStackTrace();
+    }
+
+    @OnMessage
+    public void onMessage(String message) {
+        log.info("收到来自用户{}的消息:{}", userId, message);
+        VideoChatMessageRequestVO chatMessage = JsonUtil.fromJson(message, VideoChatMessageRequestVO.class);
+        if (chatMessage == null) {
+            return;
+        }
+        RedisMessageSender redisMessageSender = SpringContextUtil.getBean(RedisMessageSender.class);
+        if (chatMessage.getType().equals("call")) {
+            // 发送通过邀请提醒消息
+            // 支持分布式场景,走广播模式
+            redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.GROUP_VIDEO_OFFER_SEND.getChannel(), message);
+            return;
+        }
+        if (chatMessage.getType().equals("offer")) {
+            // 发送对话数据
+            redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.GROUP_VIDEO_OFFER_RECEIVER.getChannel(), message);
+            return;
+        }
+        // 发送对话数据
+        redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.GROUP_VIDEO_OFFER_RECEIVER.getChannel(), message);
+    }
+
+    @Data
+    public static class VideoCustomMessage {
+        private String type;
+        private String groupId;
+        private Set<String> userIds;
+        private List<UserBaseResponseInfoVO> onlineUsers;
+    }
+
+    private Set<String> getOnlineUserIdList(String groupId) {
+        LinkedHashMap<String, GroupVideoChatDcsWebSocket> userClients = this.clients.get(groupId);
+        if (MapUtils.isEmpty(userClients)) {
+            return Collections.emptySet();
+        }
+        return userClients.keySet();
+    }
+}
+

+ 164 - 0
src/main/java/com/webchat/controller/client/GroupVideoChatWebSocket.java

@@ -0,0 +1,164 @@
+package com.webchat.controller.client;
+
+import com.webchat.common.enums.RedisMessageChannelTopicEnum;
+import com.webchat.common.enums.RoleCodeEnum;
+import com.webchat.common.util.JsonUtil;
+import com.webchat.common.util.SpringContextUtil;
+import com.webchat.domain.vo.request.mess.VideoChatMessageRequestVO;
+import com.webchat.domain.vo.response.UserBaseResponseInfoVO;
+import com.webchat.service.UserService;
+import com.webchat.service.listener.RedisMessageSender;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
+import org.apache.commons.collections.MapUtils;
+import org.springframework.stereotype.Component;
+
+import javax.websocket.EndpointConfig;
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.Session;
+import javax.websocket.server.PathParam;
+import javax.websocket.server.ServerEndpoint;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Data
+@Slf4j
+@Component
+@ServerEndpoint(value = "/ws/group-video-chat/dcs/{groupId}/{userId}")
+public class GroupVideoChatWebSocket {
+    /**
+     * 群聊多人音视频webscoket连接对象存储
+     */
+    public static ConcurrentHashMap<String, LinkedHashMap<String, GroupVideoChatWebSocket>> clients = new ConcurrentHashMap();
+    //每个连接都会有自己的会话
+    private Session session;
+    private String groupId;
+    private String userId;
+
+    @OnOpen
+    public void open(@PathParam("groupId") String groupId, @PathParam("userId") String userId, Session session, EndpointConfig config){
+        log.info("音视频通话用户{}的客户端连接成功!", userId);
+        LinkedHashMap<String, GroupVideoChatWebSocket> userSocketTable = clients.get(groupId);
+        if (userSocketTable == null) {
+            userSocketTable = new LinkedHashMap<>();
+            clients.put(groupId, userSocketTable);
+        }
+        userSocketTable.put(userId, this);
+        this.session = session;
+        this.groupId = groupId;
+        this.userId = userId;
+        // 同步当前在线群聊
+        RedisMessageSender redisMessageSender = SpringContextUtil.getBean(RedisMessageSender.class);
+        VideoCustomMessage videoCustomMessage = new VideoCustomMessage();
+        videoCustomMessage.setType("online");
+        videoCustomMessage.setGroupId(groupId);
+        videoCustomMessage.setUserIds(getOnlineUserIdList(groupId));
+        redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.GROUP_VIDEO_ONLINE.getChannel(), JsonUtil.toJsonString(videoCustomMessage));
+    }
+
+    @OnClose
+    public void close(){
+        Map<String, GroupVideoChatWebSocket> userClients = clients.get(groupId);
+        if (MapUtils.isNotEmpty(userClients)) {
+            userClients.remove(userId);
+        }
+    }
+
+    @OnError
+    public void error(Throwable error){
+        error.printStackTrace();
+    }
+
+    @OnMessage
+    public void onMessage(String message) {
+        log.info("收到来自用户{}的消息:{}", userId, message);
+        VideoChatMessageRequestVO chatMessage = JsonUtil.fromJson(message, VideoChatMessageRequestVO.class);
+        if (chatMessage == null) {
+            return;
+        }
+        RedisMessageSender redisMessageSender = SpringContextUtil.getBean(RedisMessageSender.class);
+        if (chatMessage.getType().equals("call")) {
+            // 发送通过邀请提醒消息
+            // 支持分布式场景,走广播模式
+            redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.GROUP_VIDEO_OFFER_SEND.getChannel(), message);
+            return;
+        }
+        if (chatMessage.getType().equals("offer")) {
+            handleVideoChattingMessage(message);
+            return;
+        }
+        handleVideoChattingMessage(message);
+    }
+
+    @Data
+    public static class VideoCustomMessage {
+        private String type;
+        private String groupId;
+        private Set<String> userIds;
+        private List<UserBaseResponseInfoVO> onlineUsers;
+    }
+
+    private Set<String> getOnlineUserIdList(String groupId) {
+        LinkedHashMap<String, GroupVideoChatWebSocket> userClients = this.clients.get(groupId);
+        if (MapUtils.isEmpty(userClients)) {
+            return Collections.emptySet();
+        }
+        return userClients.keySet();
+    }
+
+    private void handleVideoChattingMessage(String message) {
+        VideoChatMessageRequestVO chatMessage = JsonUtil.fromJson(message, VideoChatMessageRequestVO.class);
+        if (chatMessage == null) {
+            return;
+        }
+        UserService userService = SpringContextUtil.getBean(UserService.class);
+        String groupId = chatMessage.getGroupId();
+        String targetUserId = chatMessage.getTargetUserId();
+        Map<String, GroupVideoChatWebSocket> wsMap = this.clients.get(groupId);
+        UserBaseResponseInfoVO receiver = userService.getUserBaseInfoByUserId(targetUserId);
+        List<String> receiverUserIds = new ArrayList<>();
+        if (RoleCodeEnum.GROUP.getCode().equals(receiver.getRoleCode())) {
+            // 群组
+            receiverUserIds = userService.getGroupUserIdsFromCache(targetUserId);
+        } else {
+            // 个人
+            receiverUserIds.add(targetUserId);
+        }
+        receiverUserIds.remove(chatMessage.getUserId());
+        if (CollectionUtils.isEmpty(receiverUserIds)) {
+            return;
+        }
+        for (String receiverUserId: receiverUserIds) {
+            GroupVideoChatWebSocket ws = wsMap.get(receiverUserId);
+            if (ws == null) {
+                continue;
+            }
+            synchronized (clients) {
+                Session session = ws.getSession();
+                if (session == null || !session.isOpen()) {
+                    continue;
+                }
+                try {
+                    session.getBasicRemote().sendText(message);
+                } catch (Exception e) {
+                    log.error("消息推送异常,userId:{}", receiverUserId, e);
+                }
+            }
+
+        }
+    }
+}
+

+ 75 - 0
src/main/java/com/webchat/controller/client/VideoChatWebDcsSocket.java

@@ -0,0 +1,75 @@
+package com.webchat.controller.client;
+
+import com.webchat.common.enums.RedisMessageChannelTopicEnum;
+import com.webchat.common.util.JsonUtil;
+import com.webchat.common.util.SpringContextUtil;
+import com.webchat.domain.vo.request.mess.VideoChatMessageRequestVO;
+import com.webchat.service.listener.RedisMessageSender;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import javax.websocket.OnClose;
+import javax.websocket.OnError;
+import javax.websocket.OnMessage;
+import javax.websocket.OnOpen;
+import javax.websocket.Session;
+import javax.websocket.server.PathParam;
+import javax.websocket.server.ServerEndpoint;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.websocket.*;
+
+@Data
+@Slf4j
+@Component
+@ServerEndpoint(value = "/ws/video-chat/dcs/{userId}")
+public class VideoChatWebDcsSocket {
+
+    //存储客户端的连接对象,每个客户端连接都会产生一个连接对象
+    public static ConcurrentHashMap<String, VideoChatWebDcsSocket> clients = new ConcurrentHashMap();
+
+    //每个连接都会有自己的会话
+    private Session session;
+
+    private String userId;
+
+    @OnOpen
+    public void open(@PathParam("userId") String userId, Session session, EndpointConfig config){
+
+        log.info("音视频通话用户{}的客户端连接成功!", userId);
+        clients.put(userId, this);
+        this.session = session;
+        this.userId = userId;
+    }
+
+    @OnClose
+    public void close(){
+        clients.remove(userId);
+    }
+
+    @OnError
+    public void error(Throwable error){
+        error.printStackTrace();
+    }
+
+    @OnMessage
+    public void onMessage(String message) {
+        log.info("收到来自用户{}的消息:{}", userId, message);
+        VideoChatMessageRequestVO chatMessage = JsonUtil.fromJson(message, VideoChatMessageRequestVO.class);
+        if (chatMessage == null) {
+            return;
+        }
+        RedisMessageSender redisMessageSender = SpringContextUtil.getBean(RedisMessageSender.class);
+        if (chatMessage.getType().equals("offer")) {
+            // 发送通过邀请提醒消息
+            // 支持分布式场景,走广播模式
+            redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.VIDEO_OFFER_SEND.getChannel(), message);
+            // 发送对话数据
+            redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.VIDEO_OFFER_RECEIVER.getChannel(), message);
+            return;
+        }
+        // 发送对话数据
+        redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.VIDEO_OFFER_RECEIVER.getChannel(), message);
+    }
+}
+

+ 66 - 17
src/main/java/com/webchat/controller/client/VideoChatWebSocket.java

@@ -1,13 +1,16 @@
 package com.webchat.controller.client;
 
-import com.webchat.common.enums.RedisMessageChannelTopicEnum;
 import com.webchat.common.util.JsonUtil;
 import com.webchat.common.util.SpringContextUtil;
 import com.webchat.domain.vo.request.mess.VideoChatMessageRequestVO;
-import com.webchat.service.listener.RedisMessageSender;
-import lombok.Data;
+import com.webchat.domain.vo.response.UserBaseResponseInfoVO;
+import com.webchat.domain.vo.response.mess.ChatMessageResponseVO;
+import com.webchat.service.UserService;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
 import org.springframework.stereotype.Component;
+
+import javax.websocket.EndpointConfig;
 import javax.websocket.OnClose;
 import javax.websocket.OnError;
 import javax.websocket.OnMessage;
@@ -15,18 +18,19 @@ import javax.websocket.OnOpen;
 import javax.websocket.Session;
 import javax.websocket.server.PathParam;
 import javax.websocket.server.ServerEndpoint;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
-import javax.websocket.*;
-
-@Data
 @Slf4j
 @Component
 @ServerEndpoint(value = "/ws/video-chat/{userId}")
 public class VideoChatWebSocket {
 
     //存储客户端的连接对象,每个客户端连接都会产生一个连接对象
-    public static ConcurrentHashMap<String, VideoChatWebSocket> clients = new ConcurrentHashMap();
+    private static ConcurrentHashMap<String, VideoChatWebSocket> map = new ConcurrentHashMap();
 
     //每个连接都会有自己的会话
     private Session session;
@@ -37,14 +41,14 @@ public class VideoChatWebSocket {
     public void open(@PathParam("userId") String userId, Session session, EndpointConfig config){
 
         log.info("音视频通话用户{}的客户端连接成功!", userId);
-        clients.put(userId, this);
+        map.put(userId, this);
         this.session = session;
         this.userId = userId;
     }
 
     @OnClose
     public void close(){
-        clients.remove(userId);
+        map.remove(userId);
     }
 
     @OnError
@@ -53,23 +57,68 @@ public class VideoChatWebSocket {
     }
 
     @OnMessage
-    public void onMessage(String message) {
+    public void onMessage(String message) throws IOException {
+
         log.info("收到来自用户{}的消息:{}", userId, message);
+
         VideoChatMessageRequestVO chatMessage = JsonUtil.fromJson(message, VideoChatMessageRequestVO.class);
         if (chatMessage == null) {
             return;
         }
-        RedisMessageSender redisMessageSender = SpringContextUtil.getBean(RedisMessageSender.class);
+        UserService userService = SpringContextUtil.getBean(UserService.class);
+        String sendUserId = chatMessage.getUserId();
+        String receiverId = chatMessage.getTargetUserId();
+        UserBaseResponseInfoVO sender = userService.getUserInfoByUserId(sendUserId);
+        UserBaseResponseInfoVO receiver = userService.getUserInfoByUserId(receiverId);
+        if (sender.getRoleCode() > 2 || receiver.getRoleCode() > 2) {
+            // 音视频当前仅支持普通用户之间对话
+            return;
+        }
+
         if (chatMessage.getType().equals("offer")) {
+            // 视频对话发送offer,这里走的是普通聊天,推送音视频请求给接收人
+            ChatMessageResponseVO chatMessageResponseVO = new ChatMessageResponseVO();
+            chatMessageResponseVO.setVideoOffer(true);
+            chatMessageResponseVO.setSenderId(sendUserId);
+            chatMessageResponseVO.setReceiverId(receiverId);
+            chatMessageResponseVO.setSenderName(sender.getUserName());
+            String offerMessage = JsonUtil.toJsonString(chatMessageResponseVO);
             // 发送通过邀请提醒消息
-            // 支持分布式场景,走广播模式
-            redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.VIDEO_OFFER_SEND.getChannel(), message);
-            // 发送对话数据
-            redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.VIDEO_OFFER_RECEIVER.getChannel(), message);
+            Session receiverSession = ChatWebSocket.clients.get(receiverId).getSession();
+            receiverSession.getAsyncRemote().sendText(offerMessage);
+            // offer
+            Set<String> receiverUserIds = new HashSet<>();
+            receiverUserIds.add(receiverId);
+            sendMessageTo(message, receiverUserIds);
+            return;
+        }
+
+        Set<String> receiverUserIds = new HashSet<>();
+        receiverUserIds.add(receiverId);
+        sendMessageTo(message, receiverUserIds);
+    }
+
+    public void sendMessageTo(String message, Set<String> receiver) throws IOException {
+        if (CollectionUtils.isEmpty(receiver)) {
             return;
         }
-        // 发送对话数据
-        redisMessageSender.sendMessage(RedisMessageChannelTopicEnum.VIDEO_OFFER_RECEIVER.getChannel(), message);
+        Set<Map.Entry<String, VideoChatWebSocket>> entries = map.entrySet();
+        for (Map.Entry<String, VideoChatWebSocket> entry : entries) {
+            if(receiver.contains(entry.getKey())){
+                //将消息转发到其他非自身客户端
+                entry.getValue().send(message);
+            }
+        }
+    }
+
+    public void send(String message) throws IOException {
+        if(session.isOpen()){
+            session.getBasicRemote().sendText(message);
+        }
+    }
+
+    public int  getConnetNum(){
+        return map.size();
     }
 }
 

+ 2 - 0
src/main/java/com/webchat/domain/vo/request/mess/VideoChatMessageRequestVO.java

@@ -13,6 +13,8 @@ public class VideoChatMessageRequestVO {
 
     private String targetUserId;
 
+    private String groupId;
+
     private String type;
 
     private Object candidate;

+ 87 - 0
src/main/java/com/webchat/service/listener/RedisGroupVideoOfferReceiveMessageListener.java

@@ -0,0 +1,87 @@
+package com.webchat.service.listener;
+
+import com.webchat.common.enums.RoleCodeEnum;
+import com.webchat.common.util.JsonUtil;
+import com.webchat.controller.client.GroupVideoChatDcsWebSocket;
+import com.webchat.controller.client.GroupVideoChatWebSocket;
+import com.webchat.domain.vo.request.mess.VideoChatMessageRequestVO;
+import com.webchat.domain.vo.response.UserBaseResponseInfoVO;
+import com.webchat.service.UserService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import javax.websocket.Session;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Component
+public class RedisGroupVideoOfferReceiveMessageListener implements MessageListener {
+
+    @Autowired
+    private UserService userService;
+
+    private final RedisTemplate redisTemplate;
+
+    public RedisGroupVideoOfferReceiveMessageListener(@Qualifier("redisTemplate") RedisTemplate redisTemplate) {
+        this.redisTemplate = redisTemplate;
+    }
+
+    @Override
+    public void onMessage(Message message, byte[] bytes) {
+        String channel = (String) redisTemplate.getStringSerializer().deserialize(message.getChannel());
+        String messageStr = (String) redisTemplate.getValueSerializer().deserialize(message.getBody());
+        log.info("redis message listener ======> channel:{}, message:{}", channel, messageStr);
+        this.handleVideoChattingMessage(messageStr);
+    }
+    /**
+     * 发送音视频通话邀请
+     *
+     * @param message
+     */
+    private void handleVideoChattingMessage(String message) {
+        VideoChatMessageRequestVO chatMessage = JsonUtil.fromJson(message, VideoChatMessageRequestVO.class);
+        if (chatMessage == null) {
+            return;
+        }
+        String groupId = chatMessage.getGroupId();
+        String targetUserId = chatMessage.getTargetUserId();
+        Map<String, GroupVideoChatDcsWebSocket> wsMap = GroupVideoChatDcsWebSocket.clients.get(groupId);
+        UserBaseResponseInfoVO receiver = userService.getUserBaseInfoByUserId(targetUserId);
+        List<String> receiverUserIds = new ArrayList<>();
+        if (RoleCodeEnum.GROUP.getCode().equals(receiver.getRoleCode())) {
+            // 群组
+            receiverUserIds = userService.getGroupUserIdsFromCache(targetUserId);
+        } else {
+            // 个人
+            receiverUserIds.add(targetUserId);
+        }
+        receiverUserIds.remove(chatMessage.getUserId());
+        if (CollectionUtils.isEmpty(receiverUserIds)) {
+            return;
+        }
+        for (String receiverUserId: receiverUserIds) {
+            GroupVideoChatDcsWebSocket ws = wsMap.get(receiverUserId);
+            if (ws == null) {
+                continue;
+            }
+            Session session = ws.getSession();
+            if (session == null || !session.isOpen()) {
+                continue;
+            }
+            try {
+                session.getBasicRemote().sendText(message);
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        }
+    }
+}

+ 81 - 0
src/main/java/com/webchat/service/listener/RedisGroupVideoOfferSendMessageListener.java

@@ -0,0 +1,81 @@
+package com.webchat.service.listener;
+
+import com.webchat.common.util.JsonUtil;
+import com.webchat.controller.client.ChatWebSocket;
+import com.webchat.domain.vo.request.mess.VideoChatMessageRequestVO;
+import com.webchat.domain.vo.response.UserBaseResponseInfoVO;
+import com.webchat.domain.vo.response.mess.ChatMessageResponseVO;
+import com.webchat.service.UserService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import javax.websocket.Session;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Component
+public class RedisGroupVideoOfferSendMessageListener implements MessageListener {
+
+
+    @Autowired
+    private UserService userService;
+
+    private final RedisTemplate redisTemplate;
+
+    public RedisGroupVideoOfferSendMessageListener(@Qualifier("redisTemplate") RedisTemplate redisTemplate) {
+        this.redisTemplate = redisTemplate;
+    }
+
+    @Override
+    public void onMessage(Message message, byte[] bytes) {
+        String channel = (String) redisTemplate.getStringSerializer().deserialize(message.getChannel());
+        String messageStr = (String) redisTemplate.getValueSerializer().deserialize(message.getBody());
+        log.info("redis message listener ======> channel:{}, message:{}", channel, messageStr);
+        this.handleGroupVideoChattingMessage(messageStr);
+    }
+
+    private void handleGroupVideoChattingMessage(String message) {
+
+        VideoChatMessageRequestVO chatMessage = JsonUtil.fromJson(message, VideoChatMessageRequestVO.class);
+        if (chatMessage == null) {
+            return;
+        }
+        // 消息接受人,这里取得是群组id(代理人)
+        String groupId = chatMessage.getTargetUserId();
+        UserBaseResponseInfoVO group = userService.getUserBaseInfoByUserId(groupId);
+        List<String> groupUserIds = userService.getGroupUserIdsFromCache(groupId);
+        if (CollectionUtils.isEmpty(groupUserIds)) {
+            return;
+        }
+        Map<String, ChatWebSocket> clients = ChatWebSocket.clients;
+        for (String userId : groupUserIds) {
+            if (userId.equals(chatMessage.getUserId())) {
+                continue;
+            }
+            ChatWebSocket webSocket = clients.get(userId);
+            if (webSocket == null) {
+                continue;
+            }
+            Session session = webSocket.getSession();
+            if (session != null && session.isOpen()) {
+                // 给群聊下每个群成员推送视频邀请信息
+                // 视频对话发送offer,这里走的是普通聊天,推送音视频请求给接收人
+                ChatMessageResponseVO chatMessageResponseVO = new ChatMessageResponseVO();
+                chatMessageResponseVO.setVideoOffer(true);
+                chatMessageResponseVO.setSenderId(groupId);
+                chatMessageResponseVO.setReceiverId(userId);
+                chatMessageResponseVO.setSenderName(group.getUserName());
+                String offerMessage = JsonUtil.toJsonString(chatMessageResponseVO);
+                // 发送给接收人音视频通话申请实时通知
+                session.getAsyncRemote().sendText(offerMessage);
+            }
+        }
+    }
+}

+ 77 - 0
src/main/java/com/webchat/service/listener/RedisGroupVideoOnlineMessageListener.java

@@ -0,0 +1,77 @@
+package com.webchat.service.listener;
+
+import com.webchat.common.util.JsonUtil;
+import com.webchat.controller.client.ChatWebSocket;
+import com.webchat.controller.client.GroupVideoChatWebSocket;
+import com.webchat.domain.vo.request.mess.VideoChatMessageRequestVO;
+import com.webchat.domain.vo.response.UserBaseResponseInfoVO;
+import com.webchat.domain.vo.response.mess.ChatMessageResponseVO;
+import com.webchat.service.UserService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections.CollectionUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import javax.websocket.Session;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Slf4j
+@Component
+public class RedisGroupVideoOnlineMessageListener implements MessageListener {
+
+
+    @Autowired
+    private UserService userService;
+
+    private final RedisTemplate redisTemplate;
+
+    public RedisGroupVideoOnlineMessageListener(@Qualifier("redisTemplate") RedisTemplate redisTemplate) {
+        this.redisTemplate = redisTemplate;
+    }
+
+    @Override
+    public void onMessage(Message message, byte[] bytes) {
+        String channel = (String) redisTemplate.getStringSerializer().deserialize(message.getChannel());
+        String messageStr = (String) redisTemplate.getValueSerializer().deserialize(message.getBody());
+        log.info("redis message listener ======> channel:{}, message:{}", channel, messageStr);
+        this.handleGroupVideoChattingMessage(messageStr);
+    }
+
+    private void handleGroupVideoChattingMessage(String message) {
+
+        GroupVideoChatWebSocket.VideoCustomMessage videoCustomMessage =
+                JsonUtil.fromJson(message, GroupVideoChatWebSocket.VideoCustomMessage.class);
+        if (videoCustomMessage == null) {
+            return;
+        }
+        String groupId = videoCustomMessage.getGroupId();
+        List<String> groupUserIds = userService.getGroupUserIdsFromCache(groupId);
+        if (CollectionUtils.isEmpty(groupUserIds)) {
+            return;
+        }
+        Set<String> onlineUserIds = videoCustomMessage.getUserIds();
+        List<UserBaseResponseInfoVO> onlineUsers = userService.batchGetUserListInfoFromCache(new ArrayList<>(onlineUserIds));
+        videoCustomMessage.setOnlineUsers(onlineUsers);
+        Map<String, GroupVideoChatWebSocket> clients = GroupVideoChatWebSocket.clients.get(groupId);
+        for (String userId : groupUserIds) {
+            GroupVideoChatWebSocket webSocket = clients.get(userId);
+            if (webSocket == null) {
+                continue;
+            }
+            Session session = webSocket.getSession();
+            if (session != null && session.isOpen()) {
+                // 给群聊下每个群成员推送视频邀请信息
+                // 视频对话发送offer,这里走的是普通聊天,推送音视频请求给接收人
+                // 发送给接收人音视频通话申请实时通知
+                session.getAsyncRemote().sendText(JsonUtil.toJsonString(videoCustomMessage));
+            }
+        }
+    }
+}

+ 2 - 7
src/main/java/com/webchat/service/listener/RedisVideoOfferReceiveMessageListener.java

@@ -1,13 +1,8 @@
 package com.webchat.service.listener;
 
 import com.webchat.common.util.JsonUtil;
-import com.webchat.common.util.SpringContextUtil;
-import com.webchat.controller.client.ChatWebSocket;
-import com.webchat.controller.client.VideoChatWebSocket;
+import com.webchat.controller.client.VideoChatWebDcsSocket;
 import com.webchat.domain.vo.request.mess.VideoChatMessageRequestVO;
-import com.webchat.domain.vo.response.UserBaseResponseInfoVO;
-import com.webchat.domain.vo.response.mess.ChatMessageResponseVO;
-import com.webchat.service.UserService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.data.redis.connection.Message;
@@ -47,7 +42,7 @@ public class RedisVideoOfferReceiveMessageListener implements MessageListener {
         }
         String receiverId = chatMessage.getTargetUserId();
         // 校验用户ws 连接session是否在当前机器Hash表
-        VideoChatWebSocket ws = VideoChatWebSocket.clients.get(receiverId);
+        VideoChatWebDcsSocket ws = VideoChatWebDcsSocket.clients.get(receiverId);
         if (ws == null) {
             return;
         }

+ 4 - 4
src/main/resources/application-dev.yml

@@ -37,7 +37,7 @@ spring:
   elasticsearch:
     message-index: chat_mess_dev
     rest:
-      uris: # ES配置
+      uris: #ES配置
 
 # 大模型配置,应用于对话机器人
 llm:
@@ -45,11 +45,11 @@ llm:
     # 当前开启使用哪个模型 kimi or deepseek
     model: kimi
     kimi:
-      api-key: #替换为自己的kimi开发者apiKey
+      api-key: #替换为自己的Kimi开发者apiKey
       model: moonshot-v1-8k
     deepseek:
       api-key: #替换为自己的deepseek开发者apiKey
       model: deepseek-chat
     liblib:
-      accessKey: #替换为自己的liblib开发者accessKey
-      SecretKey: #替换为自己的liblib开发者SecretKey
+      accessKey: #替换为自己的ak
+      SecretKey: #替换为自己的sk

+ 19 - 8
src/main/resources/templates/client/chat.html

@@ -660,22 +660,24 @@
 <!-- 视频通话 -->
 <script>
 
-    function acceptVideoChat(userId, targetUserId) {
+    function acceptVideoChat(path, userId, targetUserId) {
         if (videoChatting) {
             // 音视频中
             return;
         }
         videoChatting = true;
         // 视频通话呼叫
+        var winWidth = path == 'video-chat' ? '450px' : '900px';
+        var winName = path == 'video-chat' ? '音视频通话' : '群聊多人音视频通话';
         layer.open({
             type: 2,
-            title: '视频通话',
+            title: winName,
             shadeClose: true,
             resize: false,
             shade: 0.6,
             maxmin: false, //开启最大化最小化按钮
-            area: ['450px', '700px'],
-            content: '/client/video-chat?userId='+userId+'&targetUserId='+targetUserId
+            area: [winWidth, '700px'],
+            content: '/client/'+path+'?userId='+userId+'&targetUserId='+targetUserId
         });
     }
 
@@ -695,7 +697,12 @@
 
         $("#acceptOffer").on("click", function () {
             $("#video-chat-offer-card").hide();
-            acceptVideoChat(receiverId, senderId);
+            if (senderId.startsWith("G_")) {
+                // 处理群组音视频消息
+                acceptVideoChat("group-video-chat", receiverId, senderId);
+            } else {
+                acceptVideoChat("video-chat", receiverId, senderId);
+            }
         })
 
         $("#rejectOffer").on("click", function () {
@@ -712,12 +719,16 @@
     }
 
     $("#mess-video-menu").on("click", function () {
-        if (selectCardUser.roleCode != 1 && selectCardUser.roleCode != 2) {
+        videoChatting = false;
+        if (selectCardUser.roleCode == 1 || selectCardUser.roleCode == 2) {
+            acceptVideoChat("video-chat", me.userId, selectCardUserId);
+        } else if (selectCardUser.roleCode == 4) {
+            acceptVideoChat("group-video-chat", me.userId, selectCardUserId);
+
+        } else {
             layer.msg("不支持音视频");
             return;
         }
-        videoChatting = false;
-        acceptVideoChat(me.userId, selectCardUserId);
     })
 
 

+ 272 - 0
src/main/resources/templates/client/group-video-chat.html

@@ -0,0 +1,272 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1">
+
+    <title>webchat即时在线聊天室,视频通话</title>
+
+    <link rel="stylesheet" href="/css/common/common.css">
+    <link rel="stylesheet" href="/css/client/chat.css">
+    <link href="/ref/layui-v2.6.8/layui/css/layui.css" rel="stylesheet" type="text/css" />
+
+    <script src="/ref/jquery/jquery-3.4.1.js" type="text/javascript"></script>
+    <script src="/ref/layui-v2.6.8/layui/layui.js" type="text/javascript"></script>
+
+    <style>
+        #hangUpBtn {
+            position: absolute;
+            bottom: 80px;
+            width: 50px;
+            height: 50px;
+            border: none;
+            border-radius: 100px;
+            background-color: #fb5c5c;
+            box-shadow: 0px 0px 10px #fb8d8d;
+        }
+        #videos {
+            position: absolute;
+            left: 0px;
+            top: 0px;
+            width: 100%;
+            height: 100%;
+            background-color: whitesmoke;
+        }
+        #videos video {
+            position: relative;
+            width: 267px;
+            height: 200px;
+            background-color: black;
+            border-radius: 1px;
+            float: left;
+            border-right: 2px solid white;
+            border-bottom: 2px solid white;
+        }
+    </style>
+</head>
+<body>
+<center>
+    <div id = "videos"></div>
+    <div class = "row text-center">
+        <div class = "col-md-12">
+            <button id = "hangUpBtn" class="btn-danger btn">
+                <svg t="1730296252524" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4292" width="30" height="30"><path d="M115.2 115.2c19.2-32 44.8-64 76.8-83.2 32-19.2 70.4-32 115.2-32H320c70.4 0 134.4 76.8 153.6 198.4 12.8 57.6 6.4 121.6-19.2 160-12.8 32-38.4 51.2-70.4 51.2l-12.8 12.8v12.8c19.2 38.4 38.4 76.8 64 108.8 19.2 32 44.8 64 70.4 96 6.4 6.4 12.8 6.4 19.2 6.4h6.4c12.8-19.2 32-38.4 57.6-44.8 44.8-6.4 115.2 12.8 179.2 57.6 96 70.4 140.8 166.4 108.8 230.4l-6.4 6.4c-19.2 38.4-44.8 70.4-83.2 89.6-160 96-409.6-38.4-576-307.2-128-198.4-166.4-428.8-96-563.2z m288 217.6c12.8-32 19.2-76.8 12.8-128-12.8-83.2-64-147.2-96-147.2h-12.8c-32 0-64 6.4-89.6 25.6-19.2 12.8-38.4 32-51.2 57.6-64 115.2-25.6 326.4 89.6 512 147.2 230.4 371.2 364.8 499.2 288 25.6-12.8 44.8-38.4 57.6-70.4 0 0 0-6.4 6.4-6.4v-6.4c19.2-32-19.2-102.4-89.6-153.6-44.8-32-102.4-51.2-134.4-44.8-12.8 6.4-19.2 12.8-19.2 19.2 0 0-12.8 25.6-44.8 32-19.2 6.4-44.8-6.4-64-25.6l-6.4-6.4c-25.6-32-51.2-64-76.8-102.4-25.6-38.4-44.8-83.2-64-121.6V448c-6.4-19.2-6.4-38.4 0-57.6 12.8-25.6 44.8-38.4 44.8-38.4h6.4c19.2 0 25.6-12.8 32-19.2z m217.6 12.8c6.4-12.8 19.2-19.2 32-12.8 32 6.4 51.2 25.6 64 51.2 12.8 25.6 12.8 51.2 6.4 76.8-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 6.4-12.8 0-25.6 0-32-6.4-12.8-19.2-19.2-25.6-25.6-19.2 0-25.6-19.2-19.2-38.4zM672 256c6.4-12.8 19.2-19.2 32-12.8 96 32 147.2 134.4 115.2 230.4-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 19.2-64-12.8-134.4-76.8-153.6-12.8-6.4-19.2-25.6-12.8-44.8 0 6.4 0 0 0 0z m25.6-115.2c6.4-12.8 19.2-19.2 32-12.8 76.8 25.6 140.8 83.2 179.2 153.6 38.4 76.8 44.8 160 19.2 236.8-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 19.2-64 19.2-134.4-12.8-192-32-57.6-83.2-102.4-147.2-128-12.8 0-19.2-19.2-12.8-38.4 0 6.4 0 0 0 0z m0 0" p-id="4293" fill="#ffffff"></path></svg>
+            </button>
+        </div>
+    </div>
+</center>
+</body>
+<script src="/js/client/video.chat.js" type="text/javascript"></script>
+<script type="text/javascript">
+    var wsHost = document.domain;
+    var wsPort = 8101;
+
+    function getUserParamByName(key) {
+        var url = window.location.search;
+        var reg = new RegExp("(^|&)" + key + "=([^&]*)(&|$)");
+        var result = url.substr(1).match(reg);
+        return result ? decodeURIComponent(result[2]) : "";
+    }
+    var userId = getUserParamByName("userId");
+    var targetUserId = getUserParamByName("targetUserId");
+    var connectedUserIds = new Array();
+    var videos = document.querySelector("#videos");
+    // RTCPeerConnection管理
+    const peerConnections = {};
+    var configuration = {
+        "iceServers": [
+            {
+                "urls": "stun:stun.l.google.com:19302"
+            }
+        ]
+    };
+    var PeerConnection = (window.webkitRTCPeerConnection || window.mozRTCPeerConnection || window.RTCPeerConnection || undefined);
+    var RTCSessionDescription = (window.webkitRTCSessionDescription || window.mozRTCSessionDescription || window.RTCSessionDescription || undefined);
+    navigator.getUserMedia = (navigator.getUserMedia ||
+        navigator.webkitGetUserMedia ||
+        navigator.mozGetUserMedia ||
+        navigator.msGetUserMedia);
+
+    // 初始化WebSocket连接对象,用于信令服务器通信
+    let localStream;
+    var conn;
+    function initSignalServerConnection() {
+        conn = new WebSocket("ws://" + wsHost + ":" + wsPort + "/ws/group-video-chat/" + targetUserId + "/" + userId);
+        conn.onopen = function () {
+            console.log("Connected to the signaling server");
+            send({
+                type: "call",
+                userId: userId,
+                targetUserId: targetUserId,
+                groupId: targetUserId
+            })
+        };
+        conn.onmessage = function (msg) {
+            var data = JSON.parse(msg.data);
+            console.log("Got message =====> ", data);
+            switch (data.type) {
+                case "online":
+                    handleOnline(data);
+                    break;
+                case "offer":
+                    handleOffer(data.offer, data.userId);
+                    break;
+                case "answer":
+                    handleAnswer(data.answer, data.userId);
+                    break;
+                case "candidate":
+                    handleCandidate(data.candidate, data.userId);
+                    break;
+                case "leave":
+                    handleLeave(data.userId);
+                    break;
+                default:
+                    break;
+            }
+        };
+        conn.onerror = function (err) {
+            console.log("Got error", err);
+            initSignalServerConnection();
+        };
+        conn.onclose = function (err) {
+            initSignalServerConnection();
+        }
+    }
+
+    startMedia();
+    function startMedia() {
+        // 初始化本地视频流,并绑定到页面当前客户端视频标签上
+        navigator.getUserMedia({ video: true, audio: true }, function (stream) {
+            localStream = stream;
+            // 创建视频元素并绑定到页面上
+            var video = document.createElement('video');
+            video.autoplay = true;
+            video.muted = true;
+            video.id = "video-" + userId;
+            // 将本地流绑定到本地视频元素
+            video.srcObject = localStream;
+            // 将视频元素添加到页面上
+            videos.appendChild(video);
+            initSignalServerConnection();
+        }, function (error) {
+            console.log(error);
+        });
+    }
+
+    function send(message) {
+        conn.send(JSON.stringify(message));
+    }
+
+    function getPeerId(uid) {
+        let peerKeyArr = [userId, uid];
+        return peerKeyArr.sort().join('-');
+    }
+
+    function getPeerConnection(uid) {
+        let peerKey = getPeerId(uid);
+        if (peerConnections[peerKey]) {
+            return peerConnections[peerKey];
+        }
+        var peerConnection = new PeerConnection(configuration);
+        peerConnections[peerKey] = peerConnection;
+        return peerConnection;
+    }
+
+    function handleOnline(message) {
+
+        for (let i = 0; i < message.userIds.length; i++) {
+            var uid = message.userIds[i];
+            if (connectedUserIds.includes(uid)) {
+                continue;
+            }
+            connectedUserIds.push(uid);
+            if (uid == userId) {
+                continue;
+            }
+            var peerConnection = getPeerConnection(uid);
+            let video = document.createElement('video');
+            video.controls = true;
+            video.autoplay = true;
+            video.id = "video-" + uid;
+            // 将视频元素添加到页面上
+            videos.appendChild(video);
+            peerConnection.addStream(localStream);
+            peerConnection.onaddstream = function(event){
+                video.srcObject = event.stream;
+            };
+            peerConnection.onicecandidate = function (event) {
+                if (event.candidate) {
+                    send({
+                        type: "candidate",
+                        candidate: event.candidate,
+                        userId: userId,
+                        targetUserId: uid,
+                        groupId: targetUserId
+                    });
+                }
+            };
+            // 创建一个offer描述
+            peerConnection.createOffer(function (offer) {
+                send({
+                    type: "offer",
+                    offer: offer,
+                    userId: userId,
+                    targetUserId: uid,
+                    groupId: targetUserId
+                });
+                peerConnection.setLocalDescription(offer);
+            }, function (error) {
+                alert("Error when creating an offer");
+            });
+        }
+    }
+
+    function handleOffer(offer, from) {
+        var peerConnection = getPeerConnection(from);
+        peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
+        peerConnection.createAnswer(function (answer) {
+            peerConnection.setLocalDescription(answer);
+            send({
+                type: "answer",
+                answer: answer,
+                userId: userId,
+                targetUserId: from,
+                groupId: targetUserId
+            });
+        }, function (error) {
+            alert("Error when creating an answer");
+        });
+    }
+
+    function handleAnswer(answer, from) {
+        var peerConnection = getPeerConnection(from);
+        peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
+    }
+
+    // 处理收到的ICE候选
+    function handleCandidate(candidate, from) {
+        var peerConnection = getPeerConnection(from);
+        peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
+    }
+
+    function handleLeave(from) {
+        getPeerConnection(from).close();
+        peerConnections[getPeerId(from)] = null;
+        document.getElementById("video-" + from).remove();
+        connectedUserIds = connectedUserIds.filter(function (uid) { return uid !== from; });
+    }
+
+    hangUpBtn.addEventListener("click", function () {
+        connectedUserIds.forEach(function (user) {
+            send({
+                type: "leave",
+                userId: userId,
+                targetUserId: targetUserId,
+                groupId: targetUserId
+            });
+            handleLeave(user);
+        });
+    });
+</script>
+</html>

+ 61 - 53
src/main/resources/templates/client/video-chat.html

@@ -48,13 +48,13 @@
 <center>
     <video id = "localVideo" autoplay></video>
     <video id = "remoteVideo" autoplay></video>
-        <div class = "row text-center">
-            <div class = "col-md-12">
-                <button id = "hangUpBtn" class="btn-danger btn">
-                    <svg t="1730296252524" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4292" width="30" height="30"><path d="M115.2 115.2c19.2-32 44.8-64 76.8-83.2 32-19.2 70.4-32 115.2-32H320c70.4 0 134.4 76.8 153.6 198.4 12.8 57.6 6.4 121.6-19.2 160-12.8 32-38.4 51.2-70.4 51.2l-12.8 12.8v12.8c19.2 38.4 38.4 76.8 64 108.8 19.2 32 44.8 64 70.4 96 6.4 6.4 12.8 6.4 19.2 6.4h6.4c12.8-19.2 32-38.4 57.6-44.8 44.8-6.4 115.2 12.8 179.2 57.6 96 70.4 140.8 166.4 108.8 230.4l-6.4 6.4c-19.2 38.4-44.8 70.4-83.2 89.6-160 96-409.6-38.4-576-307.2-128-198.4-166.4-428.8-96-563.2z m288 217.6c12.8-32 19.2-76.8 12.8-128-12.8-83.2-64-147.2-96-147.2h-12.8c-32 0-64 6.4-89.6 25.6-19.2 12.8-38.4 32-51.2 57.6-64 115.2-25.6 326.4 89.6 512 147.2 230.4 371.2 364.8 499.2 288 25.6-12.8 44.8-38.4 57.6-70.4 0 0 0-6.4 6.4-6.4v-6.4c19.2-32-19.2-102.4-89.6-153.6-44.8-32-102.4-51.2-134.4-44.8-12.8 6.4-19.2 12.8-19.2 19.2 0 0-12.8 25.6-44.8 32-19.2 6.4-44.8-6.4-64-25.6l-6.4-6.4c-25.6-32-51.2-64-76.8-102.4-25.6-38.4-44.8-83.2-64-121.6V448c-6.4-19.2-6.4-38.4 0-57.6 12.8-25.6 44.8-38.4 44.8-38.4h6.4c19.2 0 25.6-12.8 32-19.2z m217.6 12.8c6.4-12.8 19.2-19.2 32-12.8 32 6.4 51.2 25.6 64 51.2 12.8 25.6 12.8 51.2 6.4 76.8-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 6.4-12.8 0-25.6 0-32-6.4-12.8-19.2-19.2-25.6-25.6-19.2 0-25.6-19.2-19.2-38.4zM672 256c6.4-12.8 19.2-19.2 32-12.8 96 32 147.2 134.4 115.2 230.4-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 19.2-64-12.8-134.4-76.8-153.6-12.8-6.4-19.2-25.6-12.8-44.8 0 6.4 0 0 0 0z m25.6-115.2c6.4-12.8 19.2-19.2 32-12.8 76.8 25.6 140.8 83.2 179.2 153.6 38.4 76.8 44.8 160 19.2 236.8-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 19.2-64 19.2-134.4-12.8-192-32-57.6-83.2-102.4-147.2-128-12.8 0-19.2-19.2-12.8-38.4 0 6.4 0 0 0 0z m0 0" p-id="4293" fill="#ffffff"></path></svg>
-                </button>
-            </div>
+    <div class = "row text-center">
+        <div class = "col-md-12">
+            <button id = "hangUpBtn" class="btn-danger btn">
+                <svg t="1730296252524" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4292" width="30" height="30"><path d="M115.2 115.2c19.2-32 44.8-64 76.8-83.2 32-19.2 70.4-32 115.2-32H320c70.4 0 134.4 76.8 153.6 198.4 12.8 57.6 6.4 121.6-19.2 160-12.8 32-38.4 51.2-70.4 51.2l-12.8 12.8v12.8c19.2 38.4 38.4 76.8 64 108.8 19.2 32 44.8 64 70.4 96 6.4 6.4 12.8 6.4 19.2 6.4h6.4c12.8-19.2 32-38.4 57.6-44.8 44.8-6.4 115.2 12.8 179.2 57.6 96 70.4 140.8 166.4 108.8 230.4l-6.4 6.4c-19.2 38.4-44.8 70.4-83.2 89.6-160 96-409.6-38.4-576-307.2-128-198.4-166.4-428.8-96-563.2z m288 217.6c12.8-32 19.2-76.8 12.8-128-12.8-83.2-64-147.2-96-147.2h-12.8c-32 0-64 6.4-89.6 25.6-19.2 12.8-38.4 32-51.2 57.6-64 115.2-25.6 326.4 89.6 512 147.2 230.4 371.2 364.8 499.2 288 25.6-12.8 44.8-38.4 57.6-70.4 0 0 0-6.4 6.4-6.4v-6.4c19.2-32-19.2-102.4-89.6-153.6-44.8-32-102.4-51.2-134.4-44.8-12.8 6.4-19.2 12.8-19.2 19.2 0 0-12.8 25.6-44.8 32-19.2 6.4-44.8-6.4-64-25.6l-6.4-6.4c-25.6-32-51.2-64-76.8-102.4-25.6-38.4-44.8-83.2-64-121.6V448c-6.4-19.2-6.4-38.4 0-57.6 12.8-25.6 44.8-38.4 44.8-38.4h6.4c19.2 0 25.6-12.8 32-19.2z m217.6 12.8c6.4-12.8 19.2-19.2 32-12.8 32 6.4 51.2 25.6 64 51.2 12.8 25.6 12.8 51.2 6.4 76.8-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 6.4-12.8 0-25.6 0-32-6.4-12.8-19.2-19.2-25.6-25.6-19.2 0-25.6-19.2-19.2-38.4zM672 256c6.4-12.8 19.2-19.2 32-12.8 96 32 147.2 134.4 115.2 230.4-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 19.2-64-12.8-134.4-76.8-153.6-12.8-6.4-19.2-25.6-12.8-44.8 0 6.4 0 0 0 0z m25.6-115.2c6.4-12.8 19.2-19.2 32-12.8 76.8 25.6 140.8 83.2 179.2 153.6 38.4 76.8 44.8 160 19.2 236.8-6.4 12.8-19.2 25.6-38.4 19.2-12.8-6.4-25.6-19.2-19.2-38.4 19.2-64 19.2-134.4-12.8-192-32-57.6-83.2-102.4-147.2-128-12.8 0-19.2-19.2-12.8-38.4 0 6.4 0 0 0 0z m0 0" p-id="4293" fill="#ffffff"></path></svg>
+            </button>
         </div>
+    </div>
 </center>
 </body>
 <script src="/js/client/video.chat.js" type="text/javascript"></script>
@@ -70,72 +70,61 @@
         return result ? decodeURIComponent(result[2]) : "";
     }
 
-    // 从URL参数中获取当前用户ID和目标用户ID
     var userId = getUserParamByName("userId");
     var targetUserId = getUserParamByName("targetUserId");
-    var connectedUser = targetUserId;
 
-    // 初始化WebSocket连接对象,用于信令服务器通信
+    //initiating a call
+    //our username
+    var connectedUser = targetUserId;
+    //connecting to our signaling server
     var conn = new WebSocket("ws://"+wsHost+":"+wsPort+"/ws/video-chat/"+userId);
     conn.onopen = function () {
         console.log("Connected to the signaling server");
     };
 
-    // 处理从信令服务器接收到的消息
-    // 当WebSocket接收到信令服务器发送的消息时,触发此事件处理函数
+    //when we got a message from a signaling server
     conn.onmessage = function (msg) {
-        console.log("Got message", msg.data); // 打印接收到的消息内容到控制台,用于调试
 
-        // 解析从服务器接收到的JSON格式的消息数据
+        console.log("Got message", msg.data);
+
         var data = JSON.parse(msg.data);
-        // 根据消息类型进行不同的处理
         switch(data.type) {
-            // 当消息类型为"offer"时,表示远端用户希望建立连接,并发送了他们的SDP(会话描述协议)信息
             case "offer":
-                // 调用handleOffer函数处理offer,设置远端描述,并创建answer响应
                 handleOffer(data.offer);
                 break;
-            // 当消息类型为"answer"时,表示远端用户对我们之前发送的offer做出了响应,并发送了他们的SDP信息
             case "answer":
-                // 调用handleAnswer函数处理answer,设置远端描述
                 handleAnswer(data.answer);
                 break;
-            // 当消息类型为"candidate"时,表示远端用户发送了ICE候选信息,用于NAT穿透和连接建立
+            //when a remote peer sends an ice candidate to us
             case "candidate":
-                // 调用handleCandidate函数处理ICE候选信息,添加到RTCPeerConnection中
                 handleCandidate(data.candidate);
                 break;
-            // 当消息类型为"leave"时,表示远端用户希望结束通话
             case "leave":
-                // 调用handleLeave函数处理离开事件,关闭连接并清理资源
                 handleLeave();
                 break;
-            // 默认情况,不进行任何操作
             default:
                 break;
         }
     };
 
-    // 处理WebSocket连接错误
     conn.onerror = function (err) {
         console.log("Got error", err);
     };
 
-    // 发送消息到信令服务器
+    //alias for sending JSON encoded messages
     function send(message) {
+        //attach the other peer username to our messages
         conn.send(JSON.stringify(message));
     }
 
-    // 获取页面上的按钮和视频元素
     var hangUpBtn = document.querySelector("#hangUpBtn");
     var localVideo = document.querySelector("#localVideo");
     var remoteVideo = document.querySelector("#remoteVideo");
 
-    // 声明RTCPeerConnection对象和流对象
-    var peerConnection;
+    var yourConn;
     var stream;
 
-    // 检测浏览器是否支持WebRTC API
+    // callPage.style.display = "none";
     var PeerConnection = (window.webkitRTCPeerConnection || window.mozRTCPeerConnection || window.RTCPeerConnection || undefined);
     var RTCSessionDescription = (window.webkitRTCSessionDescription || window.mozRTCSessionDescription || window.RTCSessionDescription || undefined);
     navigator.getUserMedia = (navigator.getUserMedia ||
@@ -143,10 +132,13 @@
         navigator.mozGetUserMedia ||
         navigator.msGetUserMedia);
 
-    // 请求用户的音视频流
+    //getting local video stream
     navigator.getUserMedia({ video: true, audio: true }, function (myStream) {
         stream = myStream;
-        localVideo.srcObject = stream; // 将本地流绑定到本地视频元素
+        // displaying local video stream on the page
+        localVideo.srcObject = stream;
+
+        // using Google public stun server
         var configuration = {
             "iceServers": [
                 {
@@ -154,12 +146,19 @@
                 }
             ]
         };
-        peerConnection = new PeerConnection(configuration); // 创建RTCPeerConnection对象
-        peerConnection.addStream(stream); // 将本地流添加到连接中
-        peerConnection.onaddstream = function (e) {
-            remoteVideo.srcObject = e.stream; // 将远程流绑定到远程视频元素
+
+        yourConn = new PeerConnection(configuration);
+
+        // setup stream listening
+        yourConn.addStream(stream);
+
+        //when a remote user adds stream to the peer connection, we display it
+        yourConn.onaddstream = function (e) {
+            remoteVideo.srcObject = e.stream;
         };
-        peerConnection.onicecandidate = function (event) {
+
+        // Setup ice handling
+        yourConn.onicecandidate = function (event) {
             if (event.candidate) {
                 send({
                     type: "candidate",
@@ -169,27 +168,31 @@
                 });
             }
         };
-        // 创建一个offer描述
-        peerConnection.createOffer(function (offer) {
+        // create an offer
+        yourConn.createOffer(function (offer) {
             send({
                 type: "offer",
                 offer: offer,
                 userId: userId,
                 targetUserId: targetUserId
             });
-            peerConnection.setLocalDescription(offer);
+            yourConn.setLocalDescription(offer);
         }, function (error) {
             alert("Error when creating an offer");
         });
+
+
     }, function (error) {
         console.log(error);
     });
 
-    // 处理收到的offer
+    //when somebody sends us an offer
     function handleOffer(offer) {
-        peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
-        peerConnection.createAnswer(function (answer) {
-            peerConnection.setLocalDescription(answer);
+        yourConn.setRemoteDescription(new RTCSessionDescription(offer));
+
+        //create an answer to an offer
+        yourConn.createAnswer(function (answer) {
+            yourConn.setLocalDescription(answer);
             send({
                 type: "answer",
                 answer: answer,
@@ -201,34 +204,39 @@
         });
     }
 
-    // 处理收到的answer
+    //when we got an answer from a remote user
     function handleAnswer(answer) {
-        peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
+        yourConn.setRemoteDescription(new RTCSessionDescription(answer));
     }
 
-    // 处理收到的ICE候选
+    //when we got an ice candidate from a remote user
     function handleCandidate(candidate) {
-        peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
+        yourConn.addIceCandidate(new RTCIceCandidate(candidate));
     }
 
-    // 挂断按钮事件监听器
+    //hang up
     hangUpBtn.addEventListener("click", function () {
+
         send({
             type: "leave",
             userId: userId,
             targetUserId: targetUserId
         });
+
         handleLeave();
     });
 
-    // 处理离开事件
     function handleLeave() {
         connectedUser = null;
         remoteVideo.src = null;
-        peerConnection.close(); // 关闭RTCPeerConnection
-        peerConnection.onicecandidate = null;
-        peerConnection.onaddstream = null;
-        layer.closeAll(); // 关闭所有层(可能是第三方库函数)
+
+        yourConn.close();
+        yourConn.onicecandidate = null;
+        yourConn.onaddstream = null;
+        layer.closeAll();
     }
+
+
+
 </script>
 </html>