WebSocket实时聊天通信技术详解

WebSocket实时聊天通信技术详解

一、WebSocket技术概述

1.1 什么是WebSocket

WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

1.2 WebSocket与HTTP的区别

特性HTTPWebSocket
连接方式短连接,每次请求都需要建立连接长连接,一次握手后保持连接状态
数据传输方向单向的,只能客户端请求服务端双向的,客户端和服务端都可以主动发送数据
数据传输效率需要HTTP头部信息,有较大开销建立连接后数据传输开销小,更加高效
实时性需要轮询,实时性较差可立即推送,实时性强
应用场景适合一次性请求/响应模型适合实时交互应用(聊天、游戏等)

1.3 WebSocket通信原理

  1. 建立连接:客户端通过HTTP请求发起WebSocket连接请求,请求中包含了一个特殊的字段Upgrade: websocket
  2. 握手:服务器接收到这个请求后,如果支持WebSocket,会返回HTTP 101响应,表示协议切换成功。
  3. 全双工通信:建立连接后,客户端和服务器都可以随时发送数据,不需要等待对方的请求。
  4. 数据帧:WebSocket使用帧的概念传输数据,每个数据帧包含了控制信息和负载数据。
  5. 心跳机制:为了保持连接的活跃状态,客户端和服务器之间会定期交换心跳信息。
  6. 关闭连接:任何一方可以发送关闭帧来结束连接。

二、AI社交匹配系统的WebSocket实现方案

2.1 系统架构

AI社交匹配系统采用前后端分离架构,WebSocket聊天功能的实现包括以下几个部分:

  1. 前端实现:基于原生WebSocket API封装的聊天组件和工具类
  2. 后端实现:基于Java的WebSocket服务端点和消息处理器
  3. 消息持久化:聊天消息的存储和查询
  4. 消息安全:消息的加密和解密
  5. 状态管理:用户在线状态管理和心跳检测

2.2 技术选型

  • 前端:原生WebSocket API + Vue 3 组合式API
  • 后端:Spring Boot + Java WebSocket API (javax.websocket)
  • 消息格式:JSON
  • 加密算法:AES
  • 持久化存储:MySQL + MyBatis-Plus

2.3 整体流程

  1. 用户登录后,前端建立WebSocket连接,连接时携带JWT令牌以验证身份
  2. 后端验证令牌并建立WebSocket连接,记录用户在线状态
  3. 用户发送消息时,消息先加密,然后通过WebSocket连接发送到服务器
  4. 服务器接收消息,解密内容,处理业务逻辑并持久化存储
  5. 服务器将消息推送给接收方(如果在线)或创建通知(如果不在线)
  6. 接收方接收消息,解密内容并显示在界面上
  7. 用户断开连接时,服务器清理用户状态并关闭WebSocket连接

三、后端WebSocket实现详解

3.1 WebSocket服务端配置

在Spring Boot中,需要先配置WebSocket支持。以下是WebSocket配置类:

/**
 * WebSocket配置类
 */
@Configuration
public class WebSocketConfig {

    /**
     * ServerEndpointExporter自动注册使用@ServerEndpoint注解声明的websocket endpoint
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

此外,为了支持聊天加密功能,还需要配置加密参数:

/**
 * 聊天加密配置类
 * 用于配置聊天消息加密参数
 */
@Configuration
public class ChatEncryptionConfig {

    @Value("${chat.encryption.secret:mNu7UVEE4gMQQVEXWWvzkg==}")
    private String encryptionSecret;

    @Value("${chat.encryption.algorithm:AES/ECB/PKCS5Padding}")
    private String encryptionAlgorithm;

    @Value("${chat.encryption.enabled:true}")
    private boolean encryptionEnabled;

    public String getEncryptionSecret() {
        return encryptionSecret;
    }

    public String getEncryptionAlgorithm() {
        return encryptionAlgorithm;
    }

    public boolean isEncryptionEnabled() {
        return encryptionEnabled;
    }
}

3.2 WebSocket服务端点实现

在Spring Boot中,通过@ServerEndpoint注解定义WebSocket端点,下面是AI社交匹配系统中的ChatWebSocketServer类的完整代码及注释:

/**
 * WebSocket聊天服务端点
 */
@Slf4j
@Component
@ServerEndpoint("/websocket/chat/{token}")
public class ChatWebSocketServer {

    // 用静态变量保存在线用户的WebSocket连接,key为用户ID,value为WebSocket会话
    private static final Map<Long, Session> ONLINE_USERS = new ConcurrentHashMap<>();

    // 由于WebSocket是多例的,所以使用静态注入
    private static ChatService chatService;
    private static ChatEncryptionUtil chatEncryptionUtil;

    @Autowired
    public void setChatService(ChatService chatService) {
        ChatWebSocketServer.chatService = chatService;
    }

    @Autowired
    public void setChatEncryptionUtil(ChatEncryptionUtil chatEncryptionUtil) {
        ChatWebSocketServer.chatEncryptionUtil = chatEncryptionUtil;
    }

    // 当前连接的用户ID
    private Long userId;

    /**
     * 连接建立成功调用的方法
     * @param session WebSocket会话
     * @param token JWT令牌,用于身份验证
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("token") String token) {
        if (!StringUtils.hasText(token)) {
            this.sendMessage(session, Result.error("未登录"));
            try {
                session.close();
            } catch (IOException e) {
                log.error("关闭WebSocket连接失败", e);
            }
            return;
        }

        try {
            // 验证token并获取用户ID
            userId = JwtUtil.getUserId(token);
            if (userId == null) {
                this.sendMessage(session, Result.error("无效的token"));
                session.close();
                return;
            }

            // 记录在线用户
            ONLINE_USERS.put(userId, session);
            log.info("用户连接成功,用户ID:{},当前在线人数:{}", userId, ONLINE_USERS.size());
            this.sendMessage(session, Result.success("连接成功"));
        } catch (Exception e) {
            log.error("WebSocket连接异常", e);
            try {
                session.close();
            } catch (IOException ex) {
                log.error("关闭WebSocket连接失败", ex);
            }
        }
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose() {
        if (userId != null) {
            ONLINE_USERS.remove(userId);
            log.info("用户退出,用户ID:{},当前在线人数:{}", userId, ONLINE_USERS.size());
        }
    }

    /**
     * 收到客户端消息后调用的方法
     * @param message 客户端发送的消息
     * @param session WebSocket会话
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("服务端收到加密消息");
        if (!StringUtils.hasText(message)) {
            this.sendMessage(session, Result.error("消息不能为空"));
            return;
        }
        log.info("服务端收到消息:{}", message);
        try {
            // 解析消息
            JSONObject jsonObject = JSON.parseObject(message);
            String type = jsonObject.getString("type");
            JSONObject data = jsonObject.getJSONObject("data");
            
            // 解密消息内容(如果消息类型是聊天消息)
            if ("chat".equals(type) && data.containsKey("content")) {
                String encryptedContent = data.getString("content");
                if (encryptedContent != null) {
                    // 解密消息内容
                    String decryptedContent = chatEncryptionUtil.decrypt(encryptedContent);
                    data.put("content", decryptedContent);
                }
            }

            // 根据消息类型处理
            switch (type) {
                case "chat":
                    handleChatMessage(data);
                    break;
                case "read":
                    handleReadMessage(data);
                    break;
                case "recall":
                    handleRecallMessage(data);
                    break;
                default:
                    this.sendMessage(session, Result.error("未知的消息类型"));
            }
        } catch (Exception e) {
            log.error("处理WebSocket消息异常", e);
            this.sendMessage(session, Result.error("消息处理失败"));
        }
    }

    /**
     * 处理聊天消息
     * @param data 消息数据
     */
    private void handleChatMessage(JSONObject data) {
        try {
            // 解析消息数据
            ChatMessageDTO messageDTO = new ChatMessageDTO();
            messageDTO.setReceiverId(data.getLong("receiverId"));
            messageDTO.setContent(data.getString("content"));
            messageDTO.setMessageType(data.getInteger("messageType"));
            messageDTO.setMediaUrl(data.getString("mediaUrl"));

            // 发送消息
            ChatMessage message = chatService.sendMessage(userId, messageDTO);

            // 将消息转换为VO对象
            ChatMessageVO messageVO = new ChatMessageVO();
            BeanUtils.copyProperties(message, messageVO);
            messageVO.setIsSelf(true);
            
            // 发送前加密消息内容
            if (messageVO.getContent() != null) {
                String encryptedContent = chatEncryptionUtil.encrypt(messageVO.getContent());
                messageVO.setContent(encryptedContent);
            }
            
            // 发送回发送者
            this.sendMessage(ONLINE_USERS.get(userId), Result.success("发送成功", messageVO));

            // 如果接收者在线,发送给接收者
            Long receiverId = messageDTO.getReceiverId();
            if (ONLINE_USERS.containsKey(receiverId)) {
                // 对接收者来说不是自己发的
                messageVO.setIsSelf(false);
                this.sendMessage(ONLINE_USERS.get(receiverId), 
                        Result.success("收到新消息", messageVO));
            }
        } catch (Exception e) {
            log.error("处理聊天消息异常", e);
            this.sendMessage(ONLINE_USERS.get(userId), Result.error("发送消息失败"));
        }
    }

    /**
     * 处理已读消息
     * @param data 消息数据
     */
    private void handleReadMessage(JSONObject data) {
        try {
            Long sessionId = data.getLong("sessionId");
            // 标记消息为已读
            int count = chatService.markMessagesAsRead(sessionId, userId);
            this.sendMessage(ONLINE_USERS.get(userId), 
                    Result.success("标记已读成功", count));
        } catch (Exception e) {
            log.error("处理已读消息异常", e);
            this.sendMessage(ONLINE_USERS.get(userId), Result.error("标记已读失败"));
        }
    }

    /**
     * 处理撤回消息
     * @param data 消息数据
     */
    private void handleRecallMessage(JSONObject data) {
        try {
            Long messageId = data.getLong("messageId");
            // 撤回消息
            boolean success = chatService.recallMessage(messageId, userId);
            if (success) {
                // 获取消息信息,找到接收者
                ChatMessage message = chatService.getMessageById(messageId);
                if (message != null) {
                    // 通知发送者
                    this.sendMessage(ONLINE_USERS.get(userId), 
                            Result.success("撤回消息成功", messageId));
                    
                    // 如果接收者在线,也通知接收者
                    Long receiverId = message.getReceiverId();
                    if (ONLINE_USERS.containsKey(receiverId)) {
                        this.sendMessage(ONLINE_USERS.get(receiverId), 
                                Result.success("消息已被撤回", messageId));
                    }
                }
            } else {
                this.sendMessage(ONLINE_USERS.get(userId), Result.error("撤回消息失败"));
            }
        } catch (Exception e) {
            log.error("处理撤回消息异常", e);
            this.sendMessage(ONLINE_USERS.get(userId), Result.error("撤回消息失败"));
        }
    }

    /**
     * 发生错误时调用
     * @param session WebSocket会话
     * @param error 错误信息
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("WebSocket发生错误:{},用户ID:{}", error.getMessage(), userId);
        if (userId != null) {
            ONLINE_USERS.remove(userId);
        }
    }

    /**
     * 发送消息
     * @param session WebSocket会话
     * @param result 消息内容
     */
    private void sendMessage(Session session, Result<?> result) {
        if (session != null && session.isOpen()) {
            try {
                session.getBasicRemote().sendText(JSON.toJSONString(result));
            } catch (IOException e) {
                log.error("发送WebSocket消息异常", e);
            }
        }
    }

    /**
     * 向指定用户发送消息
     * @param userId 用户ID
     * @param result 消息内容
     */
    public static void sendMessageToUser(Long userId, Result<?> result) {
        Session session = ONLINE_USERS.get(userId);
        if (session != null && session.isOpen()) {
            try {
                session.getBasicRemote().sendText(JSON.toJSONString(result));
            } catch (IOException e) {
                log.error("发送WebSocket消息异常", e);
            }
        }
    }

    /**
     * 判断用户是否在线
     * @param userId 用户ID
     * @return 是否在线
     */
    public static boolean isUserOnline(Long userId) {
        Session session = ONLINE_USERS.get(userId);
        return session != null && session.isOpen();
    }
}

3.3 消息加密工具类

为了保证消息传输的安全性,系统实现了消息加密和解密的功能:

/**
 * 聊天加密工具类
 * 用于聊天消息的加密和解密
 */
@Slf4j
@Component
public class ChatEncryptionUtil {

    private final ChatEncryptionConfig encryptionConfig;
    private final SecretKeySpec secretKey;

    public ChatEncryptionUtil(ChatEncryptionConfig encryptionConfig) {
        this.encryptionConfig = encryptionConfig;
        
        // 解码Base64编码的密钥
        byte[] decodedKey = Base64.getDecoder().decode(encryptionConfig.getEncryptionSecret());
        // 使用解码后的字节数组生成AES密钥
        this.secretKey = new SecretKeySpec(decodedKey, "AES");
    }

    /**
     * 生成随机AES密钥
     * @return Base64编码的密钥字符串
     */
    public static String generateKey() {
        try {
            KeyGenerator keyGen = KeyGenerator.getInstance("AES");
            keyGen.init(128); // 使用128位密钥
            SecretKey secretKey = keyGen.generateKey();
            return Base64.getEncoder().encodeToString(secretKey.getEncoded());
        } catch (NoSuchAlgorithmException e) {
            log.error("生成AES密钥失败", e);
            throw new RuntimeException("生成密钥失败", e);
        }
    }

    /**
     * 加密消息
     * @param plainText 明文消息
     * @return 加密后的Base64编码字符串
     */
    public String encrypt(String plainText) {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }
        
        // 如果加密被禁用,直接返回原文
        if (!encryptionConfig.isEncryptionEnabled()) {
            return plainText;
        }
        
        try {
            Cipher cipher = Cipher.getInstance(encryptionConfig.getEncryptionAlgorithm());
            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
            byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(encrypted);
        } catch (Exception e) {
            log.error("消息加密失败", e);
            // 加密失败时,返回原文
            return plainText;
        }
    }

    /**
     * 解密消息
     * @param encryptedText 加密后的Base64编码字符串
     * @return 解密后的明文
     */
    public String decrypt(String encryptedText) {
        if (encryptedText == null || encryptedText.isEmpty()) {
            return encryptedText;
        }
        
        // 如果加密被禁用,直接返回原文
        if (!encryptionConfig.isEncryptionEnabled()) {
            return encryptedText;
        }
        
        try {
            // 尝试Base64解码,如果失败可能是未加密的文本
            byte[] encryptedBytes;
            try {
                encryptedBytes = Base64.getDecoder().decode(encryptedText);
            } catch (IllegalArgumentException e) {
                // 解码失败,可能不是Base64编码,返回原文
                return encryptedText;
            }
            
            Cipher cipher = Cipher.getInstance(encryptionConfig.getEncryptionAlgorithm());
            cipher.init(Cipher.DECRYPT_MODE, secretKey);
            byte[] decrypted = cipher.doFinal(encryptedBytes);
            return new String(decrypted, StandardCharsets.UTF_8);
        } catch (Exception e) {
            log.error("消息解密失败", e);
            // 解密失败时,返回原文
            return encryptedText;
        }
    }

    /**
     * 判断是否为加密文本
     * @param text 待判断的文本
     * @return 是否为加密文本
     */
    public boolean isEncrypted(String text) {
        if (text == null || text.isEmpty()) {
            return false;
        }
        
        try {
            // 尝试Base64解码,如果成功且解密也成功,则认为是加密文本
            byte[] encryptedBytes = Base64.getDecoder().decode(text);
            Cipher cipher = Cipher.getInstance(encryptionConfig.getEncryptionAlgorithm());
            cipher.init(Cipher.DECRYPT_MODE, secretKey);
            cipher.doFinal(encryptedBytes);
            return true;
        } catch (Exception e) {
            // 解码或解密失败,说明不是加密文本
            return false;
        }
    }
}

3.4 消息持久化服务

消息的发送与存储由ChatService接口的实现类ChatServiceImpl处理。以下是关键方法的详细实现:

3.4.1 发送消息
/**
 * 发送聊天消息
 * @param senderId 发送者ID
 * @param messageDTO 消息数据传输对象
 * @return 创建的消息实体
 */
@Override
@Transactional(rollbackFor = Exception.class)
public ChatMessage sendMessage(Long senderId, ChatMessageDTO messageDTO) {
    Long receiverId = messageDTO.getReceiverId();

    // 获取或创建会话
    ChatSession session = getOrCreateSession(senderId, receiverId);

    // 创建消息对象
    ChatMessage message = new ChatMessage();
    message.setSessionId(session.getId())
            .setSenderId(senderId)
            .setReceiverId(receiverId)
            .setContent(messageDTO.getContent())
            .setMessageType(messageDTO.getMessageType())
            .setMediaUrl(messageDTO.getMediaUrl())
            .setIsRead(0)  // 0表示未读
            .setIsDeleted(0)  // 0表示未删除
            .setIsRecalled(0)  // 0表示未撤回
            .setCreateTime(LocalDateTime.now());

    chatMessageMapper.insert(message);

    // 更新会话的最后消息信息
    session.setLastMessage(messageDTO.getContent())
            .setLastMessageTime(LocalDateTime.now());

    // 更新未读消息计数
    if (session.getUserId1().equals(receiverId)) {
        session.setUnreadCount1(session.getUnreadCount1() + 1);
    } else {
        session.setUnreadCount2(session.getUnreadCount2() + 1);
    }

    chatSessionMapper.updateById(session);

    // 创建消息通知(如果接收者不在线)
    if (!ChatWebSocketServer.isUserOnline(messageDTO.getReceiverId())) {
        User sender = userService.getById(senderId);
        if (sender != null) {
            String content = sender.getNickname() + "给您发送了新消息";
            notificationService.createMessageNotification(messageDTO.getReceiverId(), senderId, message.getSessionId(), content);
        }
    }

    return message;
}
3.4.2 标记消息为已读
/**
 * 标记会话中的消息为已读
 * @param sessionId 会话ID
 * @param userId 用户ID
 * @return 已读消息的数量
 */
@Override
@Transactional(rollbackFor = Exception.class)
public int markMessagesAsRead(Long sessionId, Long userId) {
    // 验证用户是否在会话中
    ChatSession session = chatSessionMapper.selectById(sessionId);
    if (session == null || ((!session.getUserId1().equals(userId) && !session.getUserId2().equals(userId)))) {
        return 0;
    }

    // 标记消息为已读
    int count = chatMessageMapper.markMessagesAsRead(sessionId, userId);

    // 更新会话的未读消息计数
    if (count > 0) {
        if (session.getUserId1().equals(userId)) {
            session.setUnreadCount1(0);
        } else {
            session.setUnreadCount2(0);
        }
        chatSessionMapper.updateById(session);
    }

    return count;
}
3.4.3 撤回消息
/**
 * 撤回消息
 * @param messageId 消息ID
 * @param userId 用户ID
 * @return 操作是否成功
 */
@Override
@Transactional(rollbackFor = Exception.class)
public boolean recallMessage(Long messageId, Long userId) {
    ChatMessage message = chatMessageMapper.selectById(messageId);
    if (message == null || !message.getSenderId().equals(userId) || message.getIsDeleted() == 1) {
        return false;
    }

    // 判断是否超过可撤回的时间(5分钟)
    if (message.getCreateTime().plusMinutes(5).isBefore(LocalDateTime.now())) {
        return false;
    }

    // 设置为已撤回
    message.setIsRecalled(1);
    return chatMessageMapper.updateById(message) > 0;
}
3.4.4 删除消息
/**
 * 删除消息
 * @param messageId 消息ID
 * @param userId 用户ID
 * @return 操作是否成功
 */
@Override
public boolean deleteMessage(Long messageId, Long userId) {
    ChatMessage message = chatMessageMapper.selectById(messageId);
    if (message == null || (!message.getSenderId().equals(userId) && !message.getReceiverId().equals(userId))) {
        return false;
    }

    // 逻辑删除
    message.setIsDeleted(1);
    return chatMessageMapper.updateById(message) > 0;
}
3.4.5 获取消息详情
/**
 * 根据ID获取消息
 * @param messageId 消息ID
 * @return 消息实体
 */
@Override
public ChatMessage getMessageById(Long messageId) {
    return chatMessageMapper.selectById(messageId);
}
3.4.6 转换消息为视图对象
/**
 * 将消息列表转换为视图对象列表
 * @param messages 消息列表
 * @param userId 当前用户ID
 * @return 消息视图对象列表
 */
private List<ChatMessageVO> convertToMessageVOList(List<ChatMessage> messages, Long userId) {
    if (messages.isEmpty()) {
        return Collections.emptyList();
    }

    List<ChatMessageVO> voList = new ArrayList<>(messages.size());
    for (ChatMessage message : messages) {
        ChatMessageVO vo = new ChatMessageVO();
        BeanUtils.copyProperties(message, vo);

        // 设置是否是自己发送的消息
        vo.setIsSelf(message.getSenderId().equals(userId));

        // 获取发送者信息
        User sender = userService.getById(message.getSenderId());
        if (sender != null) {
            vo.setSenderName(sender.getNickname() != null ? sender.getNickname() : sender.getUsername());
            vo.setSenderAvatar(sender.getAvatar());
        }

        voList.add(vo);
    }

    return voList;
}

四、前端WebSocket实现详解

4.1 WebSocket连接管理

前端通过封装的websocket.js工具类管理WebSocket连接,包含了初始化、关闭、重连、心跳检测等功能:

/**
 * websocket.js - WebSocket连接管理工具
 */
import { useUserStore } from '@/stores/user'
import { encryptMessage, decryptMessage } from './crypto'

// WebSocket实例
let socket = null
// 重连计时器
let reconnectTimer = null
// 心跳计时器
let heartbeatTimer = null
// 消息回调函数
const messageCallbacks = new Map()
// 是否正在重连
let isReconnecting = false
// 重连次数
let reconnectCount = 0
// 最大重连次数
const MAX_RECONNECT_COUNT = 5
// 重连间隔(ms)
const RECONNECT_INTERVAL = 3000
// 心跳间隔(ms)
const HEARTBEAT_INTERVAL = 30000
// 待发送的消息队列
let pendingMessages = []

// WebSocket状态
let wsStatus = {
  connected: false,
  connecting: false,
  retrying: false
}

/**
 * 初始化WebSocket连接
 * 创建新的WebSocket连接,设置事件处理程序
 */
export function initWebSocket() {
  if (socket && socket.readyState === WebSocket.OPEN) return

  closeWebSocket()
  
  const userStore = useUserStore()
  if (!userStore.token) {
    console.error('用户未登录,无法建立WebSocket连接')
    return
  }

  // 获取后端接口的基础URL
  const baseUrl = import.meta.env.VITE_API_BASE_URL || ''
  
  // 构建WebSocket URL
  // 由于Vite开发服务器默认运行在5173端口,而后端可能运行在其他端口(如8080),
  // 我们需要确保WebSocket连接到正确的后端服务
  
  // 假设后端服务运行在8080端口
  const backendPort = "8080"
  const host = window.location.hostname
  const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  
  // 直接使用后端地址和端口
  const wsUrl = `${wsProtocol}//${host}:${backendPort}`
  
  console.log('正在连接WebSocket:', `${wsUrl}/api/websocket/chat/${userStore.token}`)
  
  // 创建WebSocket连接
  try {
    socket = new WebSocket(`${wsUrl}/api/websocket/chat/${userStore.token}`)
  } catch (error) {
    console.error('创建WebSocket连接失败:', error)
    return
  }

  // 连接建立
  socket.onopen = () => {
    console.log('WebSocket连接已建立')
    wsStatus.connected = true
    wsStatus.connecting = false
    wsStatus.retrying = false
    reconnectCount = 0
    isReconnecting = false
    if (reconnectTimer) {
      clearTimeout(reconnectTimer)
      reconnectTimer = null
    }
    startHeartbeat()
    
    // 如果有待发送的消息,连接成功后发送
    if (pendingMessages && pendingMessages.length > 0) {
      console.log('开始发送之前未发送的消息:', pendingMessages.length)
      const messagesToSend = [...pendingMessages]
      pendingMessages = []
      
      messagesToSend.forEach(msg => {
        try {
          socket.send(JSON.stringify(msg))
        } catch (error) {
          console.error('发送缓存消息失败:', error)
          // 如果发送失败,重新加入队列
          pendingMessages.push(msg)
        }
      })
    }
  }

  // 接收消息
  socket.onmessage = (event) => {
    try {
      const data = JSON.parse(event.data)
      // console.log('WebSocket收到消息:', data)
      
      if (data.code === 200) {
        // 解密消息内容
        if (data.data && typeof data.data === 'object' && data.data.content) {
          // 检查是否有消息内容需要解密
          data.data.content = decryptMessage(data.data.content);
        }
        
        // 遍历消息回调函数并执行
        messageCallbacks.forEach((callback) => {
          callback(data)
        })
      } else {
        // console.error('WebSocket收到错误消息:', data.message)
      }
    } catch (error) {
      console.error('WebSocket消息解析失败:', error)
    }
  }

  // 连接关闭
  socket.onclose = (event) => {
    console.log('WebSocket连接已关闭, 代码:', event.code, '原因:', event.reason)
    wsStatus.connected = false
    wsStatus.connecting = false
    stopHeartbeat()
    
    if (!isReconnecting && reconnectCount < MAX_RECONNECT_COUNT) {
      reconnect()
    } else if (reconnectCount >= MAX_RECONNECT_COUNT) {
      console.error('WebSocket重连次数已达上限,请刷新页面重试')
    }
  }

  // 连接错误
  socket.onerror = (error) => {
    console.error('WebSocket连接出错:', error)
    wsStatus.connected = false
    closeWebSocket()
    
    if (!isReconnecting && reconnectCount < MAX_RECONNECT_COUNT) {
      reconnect()
    }
  }
}

/**
 * 关闭WebSocket连接
 */
export function closeWebSocket() {
  if (socket) {
    try {
      socket.close()
    } catch (error) {
      console.error('关闭WebSocket连接出错:', error)
    } finally {
      socket = null
    }
  }
  
  stopHeartbeat()
  
  wsStatus.connected = false
  wsStatus.connecting = false
  wsStatus.retrying = false
}

/**
 * 重新连接WebSocket
 */
function reconnect() {
  if (isReconnecting || reconnectCount >= MAX_RECONNECT_COUNT) return
  
  isReconnecting = true
  wsStatus.retrying = true
  reconnectCount++
  
  console.log(`WebSocket正在尝试第${reconnectCount}次重连...`)
  
  if (reconnectTimer) {
    clearTimeout(reconnectTimer)
  }
  
  reconnectTimer = setTimeout(() => {
    isReconnecting = false
    initWebSocket()
  }, RECONNECT_INTERVAL)
}

/**
 * 启动心跳检测
 */
function startHeartbeat() {
  stopHeartbeat()
  
  heartbeatTimer = setInterval(() => {
    if (socket && socket.readyState === WebSocket.OPEN) {
      // 发送心跳消息
      socket.send(JSON.stringify({
        type: 'heartbeat',
        data: { timestamp: new Date().getTime() }
      }))
    }
  }, HEARTBEAT_INTERVAL)
}

/**
 * 停止心跳检测
 */
function stopHeartbeat() {
  if (heartbeatTimer) {
    clearInterval(heartbeatTimer)
    heartbeatTimer = null
  }
}

/**
 * 发送消息
 * @param {object} message 消息对象
 * @returns {boolean} 是否成功发送
 */
export function sendWebSocketMessage(message) {
  if (!socket || socket.readyState !== WebSocket.OPEN) {
    const stateText = !socket ? 'null' : 
                     socket.readyState === 0 ? 'CONNECTING' : 
                     socket.readyState === 1 ? 'OPEN' : 
                     socket.readyState === 2 ? 'CLOSING' : 'CLOSED';
    
    console.error(`WebSocket未连接, 状态: ${stateText}`)
    
    // 保存消息到待发送队列
    console.log('消息将在连接成功后发送:', message)
    pendingMessages.push(message)
    
    // 如果没有连接或正在重连,则尝试重新连接
    if (!wsStatus.connecting && !wsStatus.retrying) {
      console.log('尝试重新连接WebSocket...')
      initWebSocket()
    }
    
    return false
  }
  
  try {
    socket.send(JSON.stringify(message))
    return true
  } catch (error) {
    console.error('WebSocket发送消息失败:', error)
    pendingMessages.push(message)
    return false
  }
}

/**
 * 添加消息监听回调函数
 * @param {string} key 回调函数的键
 * @param {function} callback 回调函数
 */
export function addMessageListener(key, callback) {
  if (typeof callback === 'function') {
    messageCallbacks.set(key, callback)
  }
}

/**
 * 移除消息监听回调函数
 * @param {string} key 回调函数的键
 */
export function removeMessageListener(key) {
  messageCallbacks.delete(key)
}

/**
 * 发送聊天消息
 * @param {number} receiverId 接收者ID
 * @param {string} content 消息内容
 * @param {number} messageType 消息类型:0-文本,1-图片,2-语音,3-视频,4-文件
 * @param {string} mediaUrl 媒体文件URL
 * @returns {boolean} 是否成功发送
 */
export function sendChatMessage(receiverId, content, messageType = 0, mediaUrl = '') {
  // 加密消息内容
  const encryptedContent = encryptMessage(content);
  
  return sendWebSocketMessage({
    type: 'chat',
    data: {
      receiverId,
      content: encryptedContent,
      messageType,
      mediaUrl
    }
  })
}

/**
 * 发送已读消息
 * @param {number} sessionId 会话ID
 * @returns {boolean} 是否成功发送
 */
export function sendReadMessage(sessionId) {
  return sendWebSocketMessage({
    type: 'read',
    data: {
      sessionId
    }
  })
}

/**
 * 发送撤回消息
 * @param {number} messageId 消息ID
 * @returns {boolean} 是否成功发送
 */
export function sendRecallMessage(messageId) {
  return sendWebSocketMessage({
    type: 'recall',
    data: {
      messageId
    }
  })
}

/**
 * 获取WebSocket连接状态
 * @returns {Object} WebSocket状态对象
 */
export function getWebSocketStatus() {
  return { ...wsStatus }
}

4.2 消息加密与解密

为保护用户隐私,前端使用AES加密算法对聊天内容进行加密处理,在crypto.js中实现:

/**
 * 聊天加密工具类
 * 使用 AES 加密算法对聊天消息进行加密和解密
 */

import CryptoJS from 'crypto-js'

// 默认密钥,应该与后端保持一致
const DEFAULT_SECRET_KEY = 'mNu7UVEE4gMQQVEXWWvzkg=='

/**
 * 加密消息
 * @param {string} plainText 明文消息
 * @param {string} [secretKey=DEFAULT_SECRET_KEY] 可选密钥
 * @returns {string} 加密后的Base64编码字符串
 */
export function encryptMessage(plainText, secretKey = DEFAULT_SECRET_KEY) {
  if (!plainText) return plainText
  
  try {
    // Base64解码密钥
    const key = CryptoJS.enc.Base64.parse(secretKey)
    
    // 使用AES加密
    const encrypted = CryptoJS.AES.encrypt(plainText, key, {
      mode: CryptoJS.mode.ECB,
      padding: CryptoJS.pad.Pkcs7
    })
    
    // 返回Base64编码的加密结果
    return encrypted.toString()
  } catch (error) {
    console.error('消息加密失败:', error)
    return plainText // 加密失败时返回原文
  }
}

/**
 * 解密消息
 * @param {string} encryptedText 加密后的Base64编码字符串
 * @param {string} [secretKey=DEFAULT_SECRET_KEY] 可选密钥
 * @returns {string} 解密后的明文
 */
export function decryptMessage(encryptedText, secretKey = DEFAULT_SECRET_KEY) {
  if (!encryptedText) return encryptedText
  
  try {
    // Base64解码密钥
    const key = CryptoJS.enc.Base64.parse(secretKey)
    
    // 使用AES解密
    const decrypted = CryptoJS.AES.decrypt(encryptedText, key, {
      mode: CryptoJS.mode.ECB,
      padding: CryptoJS.pad.Pkcs7
    })
    
    // 将解密结果转换为UTF8字符串
    return decrypted.toString(CryptoJS.enc.Utf8)
  } catch (error) {
    console.error('消息解密失败:', error)
    return encryptedText // 解密失败时返回原文
  }
}

/**
 * 判断是否为加密文本
 * @param {string} text 待判断的文本
 * @param {string} [secretKey=DEFAULT_SECRET_KEY] 可选密钥
 * @returns {boolean} 是否为加密文本
 */
export function isEncryptedMessage(text, secretKey = DEFAULT_SECRET_KEY) {
  if (!text) return false
  
  try {
    // 尝试解密
    const decrypted = decryptMessage(text, secretKey)
    // 如果解密结果不为空且不等于原文,则认为是加密文本
    return decrypted && decrypted !== text
  } catch (error) {
    return false // 解密出错,不是加密文本
  }
}

4.3 聊天API接口封装

前端使用封装的API接口与后端进行通信,包括获取会话列表、消息历史等功能:

/**
 * 聊天相关API接口
 */
import request from '@/utils/request'
import { decryptMessage, encryptMessage } from '@/utils/crypto'

/**
 * 处理会话列表响应数据,对消息内容进行解密
 * @param {Object} response 响应数据
 * @returns {Object} 处理后的响应数据
 */
function processChatSessionsResponse(response) {
  if (response.code === 200 && response.data) {
    // 处理会话列表中的加密消息
    response.data.forEach(session => {
      if (session.lastMessage) {
        session.lastMessage = decryptMessage(session.lastMessage)
      }
    })
  }
  return response
}

/**
 * 处理消息列表响应数据,对消息内容进行解密
 * @param {Object} response 响应数据
 * @returns {Object} 处理后的响应数据
 */
function processChatMessagesResponse(response) {
  if (response.code === 200 && response.data) {
    // 处理消息列表中的加密消息
    response.data.forEach(message => {
      if (message.content) {
        message.content = decryptMessage(message.content)
      }
    })
  }
  return response
}

/**
 * 获取用户的会话列表
 * @param {number} userId 用户ID
 * @returns {Promise<Object>} 会话列表响应
 */
export function listSessions(userId) {
  return request({
    url: `/chat/sessions/${userId}`,
    method: 'get'
  }).then(processChatSessionsResponse)
}

/**
 * 获取或创建与指定用户的会话
 * @param {number} userId1 用户1ID
 * @param {number} userId2 用户2ID
 * @returns {Promise<Object>} 会话详情响应
 */
export function getOrCreateSession(userId1, userId2) {
  return request({
    url: '/chat/session',
    method: 'post',
    data: {
      userId1,
      userId2
    }
  }).then(processChatSessionsResponse)
}

/**
 * 获取会话详情
 * @param {number} sessionId 会话ID
 * @param {number} userId 用户ID
 * @returns {Promise<Object>} 会话详情响应
 */
export function getSessionDetail(sessionId, userId) {
  return request({
    url: `/chat/session/${sessionId}`,
    method: 'get',
    params: {
      userId
    }
  }).then(processChatSessionsResponse)
}

/**
 * 获取会话历史消息
 * @param {number} sessionId 会话ID
 * @param {number} userId 用户ID
 * @returns {Promise<Object>} 历史消息响应
 */
export function getHistoryMessages(sessionId, userId) {
  return request({
    url: `/chat/messages/${sessionId}`,
    method: 'get',
    params: {
      userId
    }
  }).then(processChatMessagesResponse)
}

/**
 * 获取会话最近消息
 * @param {number} sessionId 会话ID
 * @param {number} userId 用户ID
 * @param {number} limit 消息数量限制
 * @returns {Promise<Object>} 最近消息响应
 */
export function getRecentMessages(sessionId, userId, limit = 20) {
  return request({
    url: `/chat/messages/recent/${sessionId}`,
    method: 'get',
    params: {
      userId,
      limit
    }
  }).then(processChatMessagesResponse)
}

/**
 * 删除消息
 * @param {number} messageId 消息ID
 * @param {number} userId 用户ID
 * @returns {Promise<Object>} 删除结果响应
 */
export function deleteMessage(messageId, userId) {
  return request({
    url: `/chat/message/${messageId}`,
    method: 'delete',
    params: {
      userId
    }
  })
}

4.4 聊天组件实现

聊天页面是用户与WebSocket交互的界面,下面是Vue 3实现的聊天组件:

<template>
  <div class="chat-container">
    <!-- WebSocket连接状态提示 -->
    <a-alert
      v-if="!wsConnected"
      message="连接服务器中,消息将在连接恢复后自动发送"
      type="warning"
      banner
      closable
    />
    
    <a-row class="chat-layout" :gutter="0">
      <!-- 会话/用户列表区域 -->
      <a-col :xs="24" :sm="8" :md="6" :lg="6" :xl="5" class="chat-sidebar">
        <div class="chat-header">
          <h2>聊天</h2>
        </div>
        <a-tabs v-model:activeKey="activeTabKey">
          <!-- 会话列表选项卡 -->
          <a-tab-pane key="sessions" tab="消息">
            <div class="search-box">
              <a-input-search
                v-model:value="searchText"
                placeholder="搜索聊天记录"
                @search="onSearch"
              />
            </div>
            <div class="session-list">
              <a-spin :spinning="loading">
                <session-item
                  v-for="session in filteredSessions"
                  :key="session.id"
                  :session="session"
                  :active="currentSession && currentSession.id === session.id"
                  @click="selectSession(session)"
                />
                <a-empty v-if="!loading && (!sessions || sessions.length === 0)" description="暂无聊天记录" />
              </a-spin>
            </div>
          </a-tab-pane>
          
          <!-- 用户列表选项卡 -->
          <a-tab-pane key="users" tab="联系人">
            <followed-user-list @select-user="selectUser" />
          </a-tab-pane>
        </a-tabs>
      </a-col>

      <!-- 消息区域 -->
      <a-col :xs="24" :sm="16" :md="18" :lg="18" :xl="19" class="chat-content">
        <template v-if="currentSession">
          <div class="chat-header">
            <h3>{{ currentSession.targetName || '会话' }}</h3>
          </div>
          <div class="message-area" ref="messageArea">
            <!-- 加载更多按钮 -->
            <message-loading
              :loading="messageLoading"
              :has-more="hasMoreMessages"
              @load-more="loadMoreMessages"
            />
            
            <!-- 消息列表 -->
            <div class="message-list-container">
              <div class="message-list">
                <message-item
                  v-for="(msg, index) in messages"
                  :key="msg.id"
                  :Message="msg"
                  :showTime="shouldShowTime(msg, messages[index - 1])"
                  @recall="handleRecallMessage"
                  @delete="handleDeleteMessage"
                />
              </div>
              <a-empty 
                v-if="!messageLoading && (!messages || messages.length === 0)" 
                description="暂无消息" 
                class="empty-message"
              />
            </div>
          </div>
          <div class="message-input">
            <message-editor
              @send="sendMessage"
              @upload-image="handleUploadImage"
              @upload-file="handleUploadFile"
              @upload-audio="handleUploadAudio"
            />
          </div>
        </template>
        <div v-else class="empty-chat">
          <a-empty description="选择一个会话或用户开始聊天" />
        </div>
      </a-col>
    </a-row>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { message } from 'ant-design-vue'
import SessionItem from './components/SessionItem.vue'
import MessageItem from './components/MessageItem.vue'
import MessageEditor from './components/MessageEditor.vue'
import MessageLoading from './components/MessageLoading.vue'
import FollowedUserList from './components/FollowedUserList.vue'
import { listSessions, getOrCreateSession, getHistoryMessages, getRecentMessages } from '@/api/chat'
import { initWebSocket, sendChatMessage, sendReadMessage, sendRecallMessage, addMessageListener, removeMessageListener, closeWebSocket, getWebSocketStatus } from '@/utils/websocket'
import { uploadFile } from '@/utils/upload'

// 用户信息
const userStore = useUserStore()
const userId = computed(() => userStore.userId)

// WebSocket连接状态
const wsConnected = ref(false)

// 导航选项卡
const activeTabKey = ref('sessions')

// 会话列表相关
const sessions = ref([])
const loading = ref(false)
const searchText = ref('')
const currentSession = ref(null)

// 消息相关
const messages = ref([])
const messageLoading = ref(false)
const messageArea = ref(null)
const hasMoreMessages = ref(false)

// 筛选会话列表
const filteredSessions = computed(() => {
  if (!searchText.value) return sessions.value
  
  return sessions.value.filter(session => {
    return session.targetName?.toLowerCase().includes(searchText.value.toLowerCase()) ||
           session.lastMessage?.toLowerCase().includes(searchText.value.toLowerCase())
  })
})

// 生命周期钩子
onMounted(() => {
  if (userStore.isLoggedIn) {
    fetchSessions()
    initWebSocket()
    setupWebSocketListener()
    
    // 组件挂载后添加一个自动滚动到底部的延时任务
    nextTick(() => {
      setTimeout(() => {
        if (messages.value.length > 0) {
          scrollToBottom(true);
        }
      }, 500); // 延迟500ms确保DOM完全渲染
    });
  }
})

onUnmounted(() => {
  removeMessageListener('chatView')
  closeWebSocket()
})

// 监听当前会话变化,自动标记为已读
watch(currentSession, (newSession) => {
  if (newSession && newSession.unreadCount > 0) {
    sendReadMessage(newSession.id)
    // 更新本地未读数
    newSession.unreadCount = 0
  }
})

// 监听消息列表变化,自动滚动到底部
watch(messages, () => {
  nextTick(() => {
    scrollToBottom(true);
  });
}, { deep: true })

// 设置WebSocket消息监听器
function setupWebSocketListener() {
  addMessageListener('chatView', handleWebSocketMessage)
  
  // 轮询检查WebSocket连接状态
  const checkConnectionStatus = () => {
    const status = getWebSocketStatus()
    wsConnected.value = status.connected
  }
  
  // 每2秒检查一次连接状态
  const statusInterval = setInterval(checkConnectionStatus, 2000)
  
  // 组件卸载时清除定时器
  onUnmounted(() => {
    clearInterval(statusInterval)
  })
  
  // 初始检查
  checkConnectionStatus()
}

// 处理WebSocket消息
function handleWebSocketMessage(data) {
  if (!data || !data.data) return
  
  if (data.message === "收到新消息") {
    // 接收到新消息
    const newMessage = data.data
    
    // 如果当前正在查看的会话与消息相关,则添加到消息列表
    if (currentSession.value && 
        (newMessage.senderId === currentSession.value.targetUserId || 
         newMessage.receiverId === currentSession.value.targetUserId)) {
      messages.value.push(newMessage)
      
      // 标记为已读
      if (newMessage.sessionId && !newMessage.isSelf) {
        sendReadMessage(newMessage.sessionId)
      }
    }
    
    // 更新会话列表中的最后一条消息
    updateSessionLastMessage(newMessage)
  } else if (data.message === "消息已被撤回") {
    // 消息被撤回
    const messageId = data.data
    const index = messages.value.findIndex(msg => msg.id === messageId)
    if (index !== -1) {
      messages.value[index].isRecalled = 1
    }
  }
}

// 更新会话列表中的最后一条消息
function updateSessionLastMessage(message) {
  const sessionIndex = sessions.value.findIndex(s => s.id === message.sessionId)
  if (sessionIndex !== -1) {
    const session = sessions.value[sessionIndex]
    session.lastMessage = message.content
    session.lastMessageTime = message.createTime
    
    // 如果不是自己发的消息且不是当前会话,增加未读数
    if (!message.isSelf && (!currentSession.value || currentSession.value.id !== session.id)) {
      session.unreadCount++
    }
    
    // 将更新后的会话移到列表顶部
    if (sessionIndex > 0) {
      const updatedSession = { ...session }
      sessions.value.splice(sessionIndex, 1)
      sessions.value.unshift(updatedSession)
    }
  }
}

// 搜索会话
function onSearch() {
  // 可以在这里实现更高级的搜索逻辑,比如API搜索
  console.log('搜索关键词:', searchText.value)
}

// 选择会话
function selectSession(session) {
  if (currentSession.value && currentSession.value.id === session.id) {
    return
  }
  
  currentSession.value = session
  messages.value = []
  messageLoading.value = true
  hasMoreMessages.value = false
  
  // 加载会话消息
  getRecentMessages(session.id, userId.value, 20)
    .then(res => {
      if (res.code === 200) {
        messages.value = res.data || []
        hasMoreMessages.value = messages.value.length >= 20
      }
    })
    .catch(err => {
      console.error('获取消息失败:', err)
      message.error('获取消息失败')
    })
    .finally(() => {
      messageLoading.value = false
      // 滚动到最新消息
      nextTick(() => {
        scrollToBottom(true)
      })
    })
}

// 选择联系人
function selectUser(user) {
  if (!user || !user.id) return
  
  loading.value = true
  // 获取或创建会话
  getOrCreateSession(userId.value, user.id)
    .then(res => {
      if (res.code === 200 && res.data) {
        // 查找会话是否已在列表中
        const existingSession = sessions.value.find(s => s.id === res.data.id)
        if (existingSession) {
          selectSession(existingSession)
        } else {
          // 添加到会话列表并选择
          sessions.value.unshift(res.data)
          selectSession(res.data)
        }
      }
    })
    .catch(err => {
      console.error('创建会话失败:', err)
      message.error('创建会话失败')
    })
    .finally(() => {
      loading.value = false
      // 切换到会话选项卡
      activeTabKey.value = 'sessions'
    })
}

// 加载更多消息
function loadMoreMessages() {
  if (messageLoading.value || !hasMoreMessages.value || !currentSession.value) return
  
  messageLoading.value = true
  
  // 获取当前显示的最早一条消息的ID
  const earliestMessage = messages.value[0]
  if (!earliestMessage) {
    messageLoading.value = false
    return
  }
  
  // 获取更早的消息
  // 这里可以根据API设计调用相应的接口
  setTimeout(() => {
    messageLoading.value = false
    hasMoreMessages.value = false // 假设没有更多消息了
  }, 1000)
}

// 发送消息
function sendMessage(content, type = 0, mediaUrl = '') {
  if (!content && !mediaUrl) return
  
  if (!currentSession.value || !currentSession.value.targetUserId) {
    message.error('发送失败,未选择会话')
    return
  }
  
  // 发送消息
  const success = sendChatMessage(
    currentSession.value.targetUserId,
    content,
    type,
    mediaUrl
  )
  
  if (!success) {
    // 如果发送失败(可能是WebSocket断开),显示提示
    message.warning('消息将在连接恢复后发送')
  }
}

// 撤回消息
function handleRecallMessage(messageId) {
  if (!messageId) return
  
  sendRecallMessage(messageId)
}

// 删除消息
function handleDeleteMessage(messageId) {
  // 删除消息的逻辑(这里只做本地删除,实际应调用API)
  const index = messages.value.findIndex(msg => msg.id === messageId)
  if (index !== -1) {
    messages.value.splice(index, 1)
  }
}

// 处理上传图片
function handleUploadImage(file) {
  uploadFile(file, 'image')
    .then(url => {
      // 发送图片消息
      sendMessage('[图片]', 1, url)
    })
    .catch(err => {
      console.error('图片上传失败:', err)
      message.error('图片上传失败')
    })
}

// 处理上传文件
function handleUploadFile(file) {
  uploadFile(file, 'file')
    .then(url => {
      // 发送文件消息
      sendMessage(`[文件] ${file.name}`, 4, url)
    })
    .catch(err => {
      console.error('文件上传失败:', err)
      message.error('文件上传失败')
    })
}

// 处理上传语音
function handleUploadAudio(file) {
  uploadFile(file, 'audio')
    .then(url => {
      // 发送语音消息
      sendMessage('[语音]', 2, url)
    })
    .catch(err => {
      console.error('语音上传失败:', err)
      message.error('语音上传失败')
    })
}

// 获取会话列表
function fetchSessions() {
  loading.value = true
  
  listSessions(userId.value)
    .then(res => {
      if (res.code === 200) {
        sessions.value = res.data || []
      }
    })
    .catch(err => {
      console.error('获取会话列表失败:', err)
      message.error('获取会话列表失败')
    })
    .finally(() => {
      loading.value = false
    })
}

// 判断是否显示时间
function shouldShowTime(currentMsg, prevMsg) {
  if (!prevMsg) return true
  
  const currentTime = new Date(currentMsg.createTime)
  const prevTime = new Date(prevMsg.createTime)
  
  // 如果两条消息相隔超过5分钟,显示时间
  return currentTime - prevTime > 5 * 60 * 1000
}

// 滚动到底部
function scrollToBottom(forceScroll = false) {
  nextTick(() => {
    if (messageArea.value) {
      // 获取可视区域高度和内容总高度
      const containerHeight = messageArea.value.clientHeight;
      const scrollHeight = messageArea.value.scrollHeight;
      const scrollTop = messageArea.value.scrollTop;
      
      // 当前滚动位置距离底部的距离
      const distanceFromBottom = scrollHeight - scrollTop - containerHeight;
      
      // 如果距离底部很近或强制滚动,则滚动到底部
      if (forceScroll || distanceFromBottom < 200) {
        messageArea.value.scrollTop = messageArea.value.scrollHeight;
      }
    }
  });
}
</script>

4.5 消息显示组件

消息显示组件MessageItem.vue用于渲染不同类型的聊天消息:

<template>
  <div class="message-wrapper">
    <!-- 时间显示 -->
    <div v-if="showTime" class="message-time">
      {{ formatTime(Message.createTime) }}
    </div>
    
    <!-- 消息内容 -->
    <div class="message-item" :class="{ 'message-self': Message.isSelf }">
      <!-- 头像 -->
      <a-avatar 
        :src="Message.senderAvatar || getDefaultAvatar()" 
        :size="40"
      />
      
      <!-- 消息气泡 -->
      <div class="message-content">
        <!-- 消息已撤回 -->
        <div v-if="Message.isRecalled === 1" class="message-recalled">
          消息已撤回
        </div>
        
        <!-- 文本消息 -->
        <div v-else-if="Message.messageType === 0" class="message-text">
          {{ Message.content }}
        </div>
        
        <!-- 图片消息 -->
        <div v-else-if="Message.messageType === 1" class="message-image">
          <a-image 
            :src="Message.mediaUrl" 
            :width="200" 
            alt="图片消息"
          />
        </div>
        
        <!-- 语音消息 -->
        <div v-else-if="Message.messageType === 2" class="message-audio" @click="toggleAudioPlay(Message.id)">
          <audio 
            :ref="el => setAudioRef(el, Message.id)" 
            :src="Message.mediaUrl" 
            preload="metadata"
            style="display: none"
            @loadedmetadata="onAudioLoaded($event, Message.id)"
            @ended="onAudioEnded(Message.id)"
            @timeupdate="onAudioTimeUpdate($event, Message.id)"
          ></audio>
          
          <sound-outlined v-if="!isPlayingAudio(Message.id)" />
          <pause-outlined v-else />
          
          <a-progress 
            :percent="isPlayingAudio(Message.id) ? audioProgress : 0" 
            :show-info="false" 
            size="small" 
            :stroke-width="2"
          />
          
          <span class="audio-duration">{{ formatDuration(audioDuration) }}</span>
        </div>
        
        <!-- 视频消息 -->
        <div v-else-if="Message.messageType === 3" class="message-video">
          <video 
            controls 
            :src="Message.mediaUrl" 
            preload="metadata" 
            style="max-width: 250px; max-height: 250px"
          ></video>
        </div>
        
        <!-- 文件消息 -->
        <div v-else-if="Message.messageType === 4" class="message-file">
          <a :href="Message.mediaUrl" target="_blank" download>
            <file-outlined />
            <span>{{ Message.content.replace('[文件] ', '') }}</span>
          </a>
        </div>
        
        <!-- 操作按钮 -->
        <div class="message-actions" v-if="!Message.isRecalled">
          <a-dropdown :trigger="['hover']">
            <more-outlined class="action-icon" />
            <template #overlay>
              <a-menu>
                <a-menu-item v-if="Message.isSelf && canRecall" @click="$emit('recall', Message.id)">
                  撤回
                </a-menu-item>
                <a-menu-item @click="$emit('delete', Message.id)">
                  删除
                </a-menu-item>
              </a-menu>
            </template>
          </a-dropdown>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import dayjs from 'dayjs'
import { message } from 'ant-design-vue'
import { 
  SoundOutlined, 
  PauseOutlined, 
  FileOutlined, 
  MoreOutlined 
} from '@ant-design/icons-vue'

// 接收props
const props = defineProps({
  Message: {
    type: Object,
    required: true
  },
  showTime: {
    type: Boolean,
    default: false
  }
})

// 定义事件
defineEmits(['recall', 'delete'])

// 是否可以撤回消息(5分钟内)
const canRecall = computed(() => {
  if (!props.Message.createTime) return false

  const createTime = dayjs(props.Message.createTime)
  const now = dayjs()

  // 5分钟内的消息可以撤回
  return now.diff(createTime, 'minute') <= 5
})

// 音频播放相关
const playingAudioId = ref(null)
const audioDuration = ref(0)
const audioProgress = ref(0)
const audioRefs = ref({})

/**
 * 设置音频元素引用
 */
function setAudioRef(el, id) {
  if (el) {
    audioRefs.value[id] = el
  }
}

/**
 * 检查是否正在播放指定ID的音频
 */
function isPlayingAudio(id) {
  return playingAudioId.value === id
}

/**
 * 切换音频播放/暂停
 */
function toggleAudioPlay(id) {
  const audioElement = audioRefs.value[id]
  if (!audioElement) return
  
  if (playingAudioId.value === id) {
    // 当前正在播放,暂停它
    audioElement.pause()
    playingAudioId.value = null
  } else {
    // 如果有其他音频正在播放,先停止它
    if (playingAudioId.value && audioRefs.value[playingAudioId.value]) {
      audioRefs.value[playingAudioId.value].pause()
    }
    
    // 播放新的音频
    audioElement.play().catch(error => {
      console.error('音频播放失败:', error)
      message.error('音频播放失败')
    })
    playingAudioId.value = id
  }
}

/**
 * 音频加载完成事件
 */
function onAudioLoaded(event, id) {
  if (event.target.duration) {
    audioDuration.value = event.target.duration
  }
}

/**
 * 音频播放结束事件
 */
function onAudioEnded(id) {
  if (playingAudioId.value === id) {
    playingAudioId.value = null
    audioProgress.value = 0
  }
}

/**
 * 音频播放时间更新事件
 */
function onAudioTimeUpdate(event, id) {
  if (playingAudioId.value === id) {
    const progress = (event.target.currentTime / event.target.duration) * 100
    audioProgress.value = progress
  }
}

/**
 * 格式化音频时长
 */
function formatDuration(seconds) {
  const minutes = Math.floor(seconds / 60)
  const remainingSeconds = Math.floor(seconds % 60)
  return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`
}

/**
 * 获取默认头像
 */
function getDefaultAvatar() {
  // 使用相对路径
  return props.Message.isSelf 
    ? '/avatar-user.png' // 自己的默认头像
    : '/avatar-default.png'; // 对方的默认头像
}

/**
 * 格式化时间
 */
function formatTime(time) {
  if (!time) return ''

  return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
</script>

五、WebSocket的应用场景

5.1 即时聊天功能

WebSocket最典型的应用场景是即时聊天功能,用户可以实时收发消息,支持文本、图片等多种内容形式,同时还支持消息已读、撤回等高级功能。本项目中的聊天系统支持以下消息类型:

  • 文本消息:普通的文字聊天内容
  • 图片消息:用户可上传图片并发送
  • 语音消息:用户可录制语音并发送
  • 视频消息:用户可上传视频并发送
  • 文件消息:用户可上传各类文件并发送

5.2 消息推送功能

系统可以使用WebSocket向用户推送各类通知,包括新消息提醒、好友请求、系统通知等。当WebSocket服务接收到需要通知用户的事件时,可以直接将消息推送给在线用户,避免了轮询带来的延迟和服务器负载。

本项目中使用了单独的通知WebSocket服务端点来处理各类系统通知:

/**
 * WebSocket通知服务端点
 */
@Slf4j
@Component
@ServerEndpoint("/websocket/notification/{token}")
public class NotificationWebSocketServer {
    // 用静态变量保存在线用户的WebSocket连接
    private static final Map<Long, Session> ONLINE_USERS = new ConcurrentHashMap<>();

    // 由于WebSocket是多例的,所以使用静态注入
    private static NotificationService notificationService;

    @Autowired
    public void setNotificationService(NotificationService notificationService) {
        NotificationWebSocketServer.notificationService = notificationService;
    }

    // 当前连接的用户ID
    private Long userId;

    /**
     * 连接建立成功调用的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("token") String token) {
        // 验证身份,记录在线状态...
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose() {
        // 清理用户在线状态...
    }

    /**
     * 收到客户端消息后调用的方法
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        // 处理来自客户端的消息...
    }

    /**
     * 发送系统通知给所有在线用户
     */
    public static void sendSystemNotificationToAll(String message) {
        // 遍历所有在线用户发送系统通知...
    }

    /**
     * 发送通知给指定用户
     */
    public static void sendNotificationToUser(Long userId, NotificationVO notification) {
        // 向特定用户发送通知...
    }
}

5.3 在线状态管理

通过WebSocket可以实时监测用户的在线状态,显示"在线"、"离线"等状态信息,增强社交互动体验。本项目通过维护一个全局的用户在线映射表,实现了用户在线状态的管理:

// 在ChatWebSocketServer类中
private static final Map<Long, Session> ONLINE_USERS = new ConcurrentHashMap<>();

// 判断用户是否在线
public static boolean isUserOnline(Long userId) {
    Session session = ONLINE_USERS.get(userId);
    return session != null && session.isOpen();
}

5.4 实时数据更新

系统可以通过WebSocket向用户推送匹配结果变化、好友动态更新等实时数据,保持信息的及时性。例如当用户收到好友请求时,可以立即在界面上显示提示,无需等待下一次轮询。

六、WebSocket性能优化和安全措施

6.1 性能优化建议

  1. 连接复用:避免频繁建立和断开连接,保持长连接状态。前端实现了连接管理和自动重连机制,尽可能地保持连接稳定。

  2. 心跳机制:通过定期发送心跳包保持连接活跃,避免连接被中断。本项目的实现:

// 启动心跳检测
function startHeartbeat() {
  stopHeartbeat()
  
  heartbeatTimer = setInterval(() => {
    if (socket && socket.readyState === WebSocket.OPEN) {
      // 发送心跳消息
      socket.send(JSON.stringify({
        type: 'heartbeat',
        data: { timestamp: new Date().getTime() }
      }))
    }
  }, HEARTBEAT_INTERVAL)
}
  1. 消息队列:在连接中断时,将消息缓存在队列中,连接恢复后重新发送。本项目的实现:
// 如果有待发送的消息,连接成功后发送
if (pendingMessages && pendingMessages.length > 0) {
  console.log('开始发送之前未发送的消息:', pendingMessages.length)
  const messagesToSend = [...pendingMessages]
  pendingMessages = []
  
  messagesToSend.forEach(msg => {
    try {
      socket.send(JSON.stringify(msg))
    } catch (error) {
      console.error('发送缓存消息失败:', error)
      // 如果发送失败,重新加入队列
      pendingMessages.push(msg)
    }
  })
}
  1. 自动重连:当连接意外断开时,系统会自动尝试重新连接。本项目的实现:
// 重新连接WebSocket
function reconnect() {
  if (isReconnecting || reconnectCount >= MAX_RECONNECT_COUNT) return
  
  isReconnecting = true
  wsStatus.retrying = true
  reconnectCount++
  
  console.log(`WebSocket正在尝试第${reconnectCount}次重连...`)
  
  if (reconnectTimer) {
    clearTimeout(reconnectTimer)
  }
  
  reconnectTimer = setTimeout(() => {
    isReconnecting = false
    initWebSocket()
  }, RECONNECT_INTERVAL)
}
  1. 批量处理消息:服务端可以将多条消息一次性发送,减少通信次数。

  2. 优化消息格式:使用紧凑的JSON格式,只传输必要的字段,减少数据量。

  3. 使用WebSocket子协议:如STOMP(Simple Text Oriented Messaging Protocol)可以提供更高级的消息管理功能。

6.2 安全措施

  1. 身份验证:通过JWT令牌验证用户身份,确保只有合法用户才能建立WebSocket连接。本项目的实现:
// 验证token并获取用户ID
userId = JwtUtil.getUserId(token);
if (userId == null) {
    this.sendMessage(session, Result.error("无效的token"));
    session.close();
    return;
}
  1. 消息加密:使用AES加密算法对消息内容进行加密,保护用户隐私。本项目的实现:
/**
 * 加密消息
 */
public String encrypt(String plainText) {
    if (plainText == null || plainText.isEmpty()) {
        return plainText;
    }
    
    // 如果加密被禁用,直接返回原文
    if (!encryptionConfig.isEncryptionEnabled()) {
        return plainText;
    }
    
    try {
        Cipher cipher = Cipher.getInstance(encryptionConfig.getEncryptionAlgorithm());
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);
        byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(encrypted);
    } catch (Exception e) {
        log.error("消息加密失败", e);
        // 加密失败时,返回原文
        return plainText;
    }
}
  1. 消息校验:对接收到的消息进行合法性检查,防止非法消息。在处理WebSocket消息前,先验证消息格式和内容是否合法。

  2. 连接限制:限制单个用户的连接数量,防止资源耗尽攻击。可以在全局的WebSocket连接管理中,检测并限制每个用户的连接数。

  3. 敏感词过滤:对消息内容进行敏感词过滤,防止不良信息传播。可以在消息持久化前,使用敏感词过滤器检查消息内容。

  4. 防止XSS攻击:在展示消息内容时,对HTML特殊字符进行转义,防止跨站脚本攻击。

  5. HTTPS传输:WebSocket连接应该基于HTTPS进行,确保传输层安全。可以通过配置SSL证书,启用WSS(WebSocket Secure)协议。

七、总结

WebSocket技术在AI社交匹配系统中的应用,实现了高效、实时的用户通信功能。通过合理的架构设计和技术选型,解决了即时通讯的各种挑战,包括连接管理、消息加密、状态同步等问题。系统既保证了功能的完整性,又确保了通信的安全性和性能,为用户提供了流畅的社交互动体验。

7.1 WebSocket实现要点回顾

  1. 服务端实现

    • 使用@ServerEndpoint注解定义WebSocket端点
    • 使用静态Map管理在线用户连接
    • 处理消息的加密与解密
    • 实现消息的持久化存储
    • 管理用户的在线状态
  2. 前端实现

    • 封装WebSocket连接管理工具
    • 实现消息的加密与解密
    • 处理断线重连和心跳机制
    • 使用Vue组件展示实时消息
    • 支持多种消息类型的发送与显示

7.2 WebSocket优势总结

  1. 实时性:消息可以立即推送给用户,无需等待轮询,大大提高了通信效率。
  2. 双向通信:服务器和客户端都可以主动发送消息,实现了真正的双向通信。
  3. 低延迟:一次握手后保持连接状态,避免了HTTP请求的重复建立与关闭开销。
  4. 低资源占用:相比轮询,WebSocket减少了不必要的请求和响应,降低了服务器负载。
  5. 扩展性:可以通过子协议扩展功能,适应不同的应用场景。

7.3 应用建议

  1. 合理使用WebSocket:WebSocket适合需要实时交互的场景,如聊天、通知、实时监控等,对于普通的数据请求,仍然推荐使用HTTP。

  2. 做好容错处理:网络环境复杂多变,应用应该能够优雅地处理连接断开、重连等异常情况。

  3. 考虑服务器扩展:在高并发场景下,可以考虑使用集群、负载均衡等技术,确保WebSocket服务的可靠性。

  4. 注重安全性:实时通信系统处理的数据往往较为敏感,要重视通信安全,采用加密、身份验证等手段保护用户数据。

通过学习本文档,开发者可以了解WebSocket在实际项目中的具体应用方式,掌握WebSocket通信的核心原理和实现技巧,从而在自己的项目中应用类似的技术解决方案。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值