WebSocket实时聊天通信技术详解
一、WebSocket技术概述
1.1 什么是WebSocket
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
1.2 WebSocket与HTTP的区别
特性 | HTTP | WebSocket |
---|---|---|
连接方式 | 短连接,每次请求都需要建立连接 | 长连接,一次握手后保持连接状态 |
数据传输方向 | 单向的,只能客户端请求服务端 | 双向的,客户端和服务端都可以主动发送数据 |
数据传输效率 | 需要HTTP头部信息,有较大开销 | 建立连接后数据传输开销小,更加高效 |
实时性 | 需要轮询,实时性较差 | 可立即推送,实时性强 |
应用场景 | 适合一次性请求/响应模型 | 适合实时交互应用(聊天、游戏等) |
1.3 WebSocket通信原理
- 建立连接:客户端通过HTTP请求发起WebSocket连接请求,请求中包含了一个特殊的字段
Upgrade: websocket
。 - 握手:服务器接收到这个请求后,如果支持WebSocket,会返回HTTP 101响应,表示协议切换成功。
- 全双工通信:建立连接后,客户端和服务器都可以随时发送数据,不需要等待对方的请求。
- 数据帧:WebSocket使用帧的概念传输数据,每个数据帧包含了控制信息和负载数据。
- 心跳机制:为了保持连接的活跃状态,客户端和服务器之间会定期交换心跳信息。
- 关闭连接:任何一方可以发送关闭帧来结束连接。
二、AI社交匹配系统的WebSocket实现方案
2.1 系统架构
AI社交匹配系统采用前后端分离架构,WebSocket聊天功能的实现包括以下几个部分:
- 前端实现:基于原生WebSocket API封装的聊天组件和工具类
- 后端实现:基于Java的WebSocket服务端点和消息处理器
- 消息持久化:聊天消息的存储和查询
- 消息安全:消息的加密和解密
- 状态管理:用户在线状态管理和心跳检测
2.2 技术选型
- 前端:原生WebSocket API + Vue 3 组合式API
- 后端:Spring Boot + Java WebSocket API (javax.websocket)
- 消息格式:JSON
- 加密算法:AES
- 持久化存储:MySQL + MyBatis-Plus
2.3 整体流程
- 用户登录后,前端建立WebSocket连接,连接时携带JWT令牌以验证身份
- 后端验证令牌并建立WebSocket连接,记录用户在线状态
- 用户发送消息时,消息先加密,然后通过WebSocket连接发送到服务器
- 服务器接收消息,解密内容,处理业务逻辑并持久化存储
- 服务器将消息推送给接收方(如果在线)或创建通知(如果不在线)
- 接收方接收消息,解密内容并显示在界面上
- 用户断开连接时,服务器清理用户状态并关闭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 性能优化建议
-
连接复用:避免频繁建立和断开连接,保持长连接状态。前端实现了连接管理和自动重连机制,尽可能地保持连接稳定。
-
心跳机制:通过定期发送心跳包保持连接活跃,避免连接被中断。本项目的实现:
// 启动心跳检测
function startHeartbeat() {
stopHeartbeat()
heartbeatTimer = setInterval(() => {
if (socket && socket.readyState === WebSocket.OPEN) {
// 发送心跳消息
socket.send(JSON.stringify({
type: 'heartbeat',
data: { timestamp: new Date().getTime() }
}))
}
}, HEARTBEAT_INTERVAL)
}
- 消息队列:在连接中断时,将消息缓存在队列中,连接恢复后重新发送。本项目的实现:
// 如果有待发送的消息,连接成功后发送
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)
}
})
}
- 自动重连:当连接意外断开时,系统会自动尝试重新连接。本项目的实现:
// 重新连接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)
}
-
批量处理消息:服务端可以将多条消息一次性发送,减少通信次数。
-
优化消息格式:使用紧凑的JSON格式,只传输必要的字段,减少数据量。
-
使用WebSocket子协议:如STOMP(Simple Text Oriented Messaging Protocol)可以提供更高级的消息管理功能。
6.2 安全措施
- 身份验证:通过JWT令牌验证用户身份,确保只有合法用户才能建立WebSocket连接。本项目的实现:
// 验证token并获取用户ID
userId = JwtUtil.getUserId(token);
if (userId == null) {
this.sendMessage(session, Result.error("无效的token"));
session.close();
return;
}
- 消息加密:使用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;
}
}
-
消息校验:对接收到的消息进行合法性检查,防止非法消息。在处理WebSocket消息前,先验证消息格式和内容是否合法。
-
连接限制:限制单个用户的连接数量,防止资源耗尽攻击。可以在全局的WebSocket连接管理中,检测并限制每个用户的连接数。
-
敏感词过滤:对消息内容进行敏感词过滤,防止不良信息传播。可以在消息持久化前,使用敏感词过滤器检查消息内容。
-
防止XSS攻击:在展示消息内容时,对HTML特殊字符进行转义,防止跨站脚本攻击。
-
HTTPS传输:WebSocket连接应该基于HTTPS进行,确保传输层安全。可以通过配置SSL证书,启用WSS(WebSocket Secure)协议。
七、总结
WebSocket技术在AI社交匹配系统中的应用,实现了高效、实时的用户通信功能。通过合理的架构设计和技术选型,解决了即时通讯的各种挑战,包括连接管理、消息加密、状态同步等问题。系统既保证了功能的完整性,又确保了通信的安全性和性能,为用户提供了流畅的社交互动体验。
7.1 WebSocket实现要点回顾
-
服务端实现:
- 使用
@ServerEndpoint
注解定义WebSocket端点 - 使用静态Map管理在线用户连接
- 处理消息的加密与解密
- 实现消息的持久化存储
- 管理用户的在线状态
- 使用
-
前端实现:
- 封装WebSocket连接管理工具
- 实现消息的加密与解密
- 处理断线重连和心跳机制
- 使用Vue组件展示实时消息
- 支持多种消息类型的发送与显示
7.2 WebSocket优势总结
- 实时性:消息可以立即推送给用户,无需等待轮询,大大提高了通信效率。
- 双向通信:服务器和客户端都可以主动发送消息,实现了真正的双向通信。
- 低延迟:一次握手后保持连接状态,避免了HTTP请求的重复建立与关闭开销。
- 低资源占用:相比轮询,WebSocket减少了不必要的请求和响应,降低了服务器负载。
- 扩展性:可以通过子协议扩展功能,适应不同的应用场景。
7.3 应用建议
-
合理使用WebSocket:WebSocket适合需要实时交互的场景,如聊天、通知、实时监控等,对于普通的数据请求,仍然推荐使用HTTP。
-
做好容错处理:网络环境复杂多变,应用应该能够优雅地处理连接断开、重连等异常情况。
-
考虑服务器扩展:在高并发场景下,可以考虑使用集群、负载均衡等技术,确保WebSocket服务的可靠性。
-
注重安全性:实时通信系统处理的数据往往较为敏感,要重视通信安全,采用加密、身份验证等手段保护用户数据。
通过学习本文档,开发者可以了解WebSocket在实际项目中的具体应用方式,掌握WebSocket通信的核心原理和实现技巧,从而在自己的项目中应用类似的技术解决方案。