1. 添加依赖
pom.xml
<!-- WebSocket支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2. WebSocket 配置类
package com.yu.cloudpicturebackend.config;
import com.yu.cloudpicturebackend.websocket.PictureReviewHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
import javax.annotation.Resource;
import java.util.Map;
@Configuration
@EnableWebSocket
@Slf4j
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private PictureReviewHandler pictureReviewHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
log.info("🔄 注册WebSocket处理器,路径: /ws/{userId}");
registry.addHandler(pictureReviewHandler, "/ws/{userId}")
.addInterceptors(new HttpSessionHandshakeInterceptor() {
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
log.info("🤝 WebSocket握手请求: {} {}", request.getMethod(), request.getURI());
String path = request.getURI().getPath();
String userId = path.substring(path.lastIndexOf("/") + 1);
attributes.put("userId", userId);
log.info("提取用户ID: {}", userId);
return super.beforeHandshake(request, response, wsHandler, attributes);
}
})
.setAllowedOrigins("*");
log.info("✅ WebSocket处理器注册完成");
}
}
3. Websocket 服务端实现
3.1. 消息实体类
import lombok.Data;
@Data
public class WebSocketMessage {
private String type; // 消息类型:heartbeat/unicast/broadcast
private String from; // 发送者ID
private String to; // 接收者ID(单播时使用)
private String content; // 消息内容
private Long timestamp; // 时间戳
}
3.2. WebSocket 处理器实现 - 核心
package com.yu.cloudpicturebackend.websocket;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.Arrays;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
@Component
public class PictureReviewHandler extends TextWebSocketHandler {
// 在类开头添加
public PictureReviewHandler() {
log.info("✅ PictureReviewHandler 已初始化");
}
// 在线连接数
private static final AtomicInteger onlineCount = new AtomicInteger(0);
// 存放每个客户端对应的会话
private static final ConcurrentHashMap<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<WebSocketSession, String> userIdMap = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<WebSocketSession, Long> heartbeatMap = new ConcurrentHashMap<>();
/**
* 连接建立后调用的方法
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("🔄 开始建立WebSocket连接,URI: {}", session.getUri());
try {
String userId = extractUserIdFromUri(session.getUri());
log.info("尝试建立连接,用户ID: {}, 会话ID: {}", userId, session.getId());
if (userId == null || userId.trim().isEmpty()) {
log.error("用户ID为空,关闭连接");
session.close(CloseStatus.BAD_DATA.withReason("用户ID不能为空"));
return;
}
// 如果用户已存在,关闭旧连接
if (sessionMap.containsKey(userId)) {
WebSocketSession oldSession = sessionMap.get(userId);
if (oldSession != null && oldSession.isOpen()) {
log.info("关闭用户{}的旧连接", userId);
oldSession.close(CloseStatus.NORMAL.withReason("新连接建立"));
}
sessionMap.remove(userId);
userIdMap.remove(oldSession);
heartbeatMap.remove(oldSession);
}
sessionMap.put(userId, session);
userIdMap.put(session, userId);
heartbeatMap.put(session, System.currentTimeMillis());
addOnlineCount();
log.info("✅ 用户连接成功: {},当前在线人数: {}", userId, getOnlineCount());
// 发送连接成功消息
String welcomeMsg = "{"type":"system","message":"连接成功"}";
session.sendMessage(new TextMessage(welcomeMsg));
// 启动心跳检测
startHeartbeatCheckIfNeeded();
} catch (Exception e) {
log.error("建立WebSocket连接时发生异常", e);
if (session.isOpen()) {
session.close(CloseStatus.SERVER_ERROR.withReason("服务器异常"));
}
}
}
/**
* 处理文本消息
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String userId = userIdMap.get(session);
if (userId == null) return;
log.info("收到用户{}的消息:{}", userId, message.getPayload());
WebSocketMessage msg = JSONUtil.parse(message.getPayload()).toBean(WebSocketMessage.class);
// 心跳检测
if ("heartbeat".equals(msg.getType())) {
heartbeatMap.put(session, System.currentTimeMillis());
return;
}
// 处理业务消息
switch (msg.getType()) {
case "unicast":
sendToUser(msg.getTo(), message.getPayload());
break;
case "broadcast":
broadcast(message.getPayload());
break;
default:
log.warn("未知的消息类型:{}", msg.getType());
}
}
/**
* 处理传输错误
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
String userId = userIdMap.get(session);
log.error("用户{}的连接发生错误:{}", userId, exception.getMessage());
exception.printStackTrace();
// 移除会话
removeSession(session);
}
/**
* 连接关闭后调用的方法
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String userId = userIdMap.get(session);
removeSession(session);
log.info("用户退出:{},当前在线人数:{}", userId, getOnlineCount());
}
/**
* 是否支持分片消息
*/
@Override
public boolean supportsPartialMessages() {
return false;
}
/**
* 从URI中提取用户ID
*/
private String extractUserIdFromUri(java.net.URI uri) {
if (uri == null) {
log.error("URI为空");
return null;
}
String path = uri.getPath();
log.info("解析WebSocket路径: {}", path);
String[] pathSegments = path.split("/");
log.info("路径分段: {}", Arrays.toString(pathSegments));
// 路径格式: /ws/{userId}
if (pathSegments.length >= 3) {
String userId = pathSegments[2];
log.info("提取到的用户ID: {}", userId);
return userId;
} else {
log.error("路径格式错误,期望格式: /ws/{{userId}},实际路径: {}", path);
return null;
}
}
/**
* 移除会话
*/
private static void removeSession(WebSocketSession session) {
String userId = userIdMap.get(session);
if (userId != null) {
sessionMap.remove(userId);
userIdMap.remove(session);
heartbeatMap.remove(session);
subOnlineCount();
}
}
/**
* 单播消息
*/
public static void sendToUser(String toUserId, String message) {
if (sessionMap.containsKey(toUserId)) {
WebSocketSession session = sessionMap.get(toUserId);
if (session.isOpen()) {
try {
session.sendMessage(new TextMessage(message));
} catch (IOException e) {
log.error("发送消息给用户{}失败:{}", toUserId, e.getMessage());
}
}
} else {
log.warn("用户{}不在线,消息发送失败", toUserId);
}
}
/**
* 广播消息
*/
public static void broadcast(String message) {
sessionMap.forEach((userId, session) -> {
if (session.isOpen()) {
try {
session.sendMessage(new TextMessage(message));
} catch (IOException e) {
log.error("广播消息给用户{}失败:{}", userId, e.getMessage());
}
}
});
}
public static synchronized int getOnlineCount() {
return onlineCount.get();
}
public static synchronized void addOnlineCount() {
onlineCount.incrementAndGet();
}
public static synchronized void subOnlineCount() {
onlineCount.decrementAndGet();
}
/**
* 启动心跳检测(确保只启动一个线程)
*/
private static volatile boolean heartbeatThreadStarted = false;
private synchronized void startHeartbeatCheckIfNeeded() {
if (!heartbeatThreadStarted) {
heartbeatThreadStarted = true;
new HeartbeatThread().start();
}
}
/**
* 心跳检测线程
*/
private static class HeartbeatThread extends Thread {
@Override
public void run() {
while (true) {
try {
// 每30秒检测一次
Thread.sleep(30000);
long currentTime = System.currentTimeMillis();
heartbeatMap.entrySet().removeIf(entry -> {
WebSocketSession session = entry.getKey();
long lastHeartbeat = entry.getValue();
// 超过60秒未收到心跳,关闭连接
if (currentTime - lastHeartbeat > 60000) {
try {
if (session.isOpen()) {
session.close();
}
} catch (IOException e) {
log.error("关闭超时会话失败:{}", e.getMessage());
}
removeSession(session);
return true;
}
return false;
});
// 如果没有会话了,停止线程
if (sessionMap.isEmpty()) {
heartbeatThreadStarted = false;
break;
}
} catch (Exception e) {
log.error("心跳检测异常:{}", e.getMessage());
heartbeatThreadStarted = false;
break;
}
}
}
}
}
4. 前端实现
4.1. websocket 工具类
// utils/websocket.js
export const getWebSocketUrl = (userId) => {
const { protocol } = window.location
let backendUrl
if (process.env.NODE_ENV === 'development') {
// 开发环境:使用固定的后端地址
backendUrl = 'localhost:8123'
} else if (process.env.NODE_ENV === 'test') {
// 测试环境
backendUrl = 'test-backend.com:8123'
} else {
// 生产环境:可能使用相同的域名(通过 Nginx 代理)
// 或者指定的后端域名
backendUrl = process.env.VUE_APP_BACKEND_URL || window.location.host
}
const contextPath = '/api'
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProtocol}//${backendUrl}${contextPath}/ws/${userId}`
// console.log('🔧 当前环境:', process.env.NODE_ENV)
// console.log('🎯 后端地址:', backendUrl)
// console.log('🌐 WebSocket URL:', wsUrl)
return wsUrl
}
4.2. 发起连接
<template>
<div id="global-header">
<a-row :wrap="false">
<a-col flex="135px">
<router-link to="/">
<div class="title-bar">
<img src="../assets/logo.png" alt="logo" class="logo" />
<div class="title">鱼籽云图库</div>
</div>
</router-link>
</a-col>
<a-col flex="1">
<a-menu
style="width: auto; min-width: 450px"
v-model:selectedKeys="current"
mode="horizontal"
:items="items"
@click="doMenuClick"
/>
</a-col>
<a-col>
<icon-font type="icon-tongzhi" style="font-size: 20px; margin-right: 10px" />
</a-col>
<a-col flex="220px">
<a-dropdown>
<a class="ant-dropdown-link">
<div class="user-login-status">
<a-space v-if="loginUserStore.loginUser.id" style="color: #000">
<a-avatar
:src="
loginUserStore.loginUser.userAvatar
? loginUserStore.loginUser.userAvatar
: '/src/assets/avatar.png'
"
></a-avatar>
{{ loginUserStore.loginUser.userName }}
<DownOutlined />
</a-space>
<a-button type="primary" href="/user/login" v-else>登录</a-button>
</div>
</a>
<template #overlay>
<a-menu v-if="loginUserStore.loginUser.id" style="width: 120px; text-align: center">
<a-menu-item>
<a href="javascript:;" @click="gotoUserCenter">个人中心</a>
</a-menu-item>
<a-menu-item>
<a href="javascript:;" @click="logout">退出登录</a>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-col>
</a-row>
<!-- <span class="iconfont icon-shangchuan fabu-btn" @click="openUploadModal"></span>-->
<UploadSelectModal ref="uploadSelectModalRef" />
<div ref="logRef"></div>
</div>
</template>
<script lang="ts" setup>
import { getWebSocketUrl } from '@/utils/websocket.ts'
const loginUserStore = useLoginUserStore()
// ================== websocket =====================
var websocket = null
var heartbeatInterval = null
// 监听用户ID变化
const userId = ref(loginUserStore.loginUser?.id)
// 连接WebSocket
function connect() {
console.log('🔗 开始连接WebSocket,用户ID:', userId.value)
if (!userId.value) {
console.warn('❌ 连接失败:用户ID为空')
return false
}
// 检查WebSocket支持
if (!('WebSocket' in window)) {
console.error('❌ 浏览器不支持WebSocket')
return false
}
try {
// 构造WebSocket URL - 添加调试
const wsUrl = getWebSocketUrl(userId.value)
// 如果已有连接,先关闭
if (websocket) {
if (websocket.readyState === WebSocket.OPEN) {
console.log('🔄 关闭现有连接')
websocket.close()
}
websocket = null
}
// 创建新连接
websocket = new WebSocket(wsUrl)
console.log('🆕 创建WebSocket实例')
websocket.onopen = function (event) {
console.log('✅ WebSocket连接成功建立')
console.log('📊 连接详情:', {
url: wsUrl,
readyState: websocket?.readyState,
protocol: websocket?.protocol,
})
startHeartbeat()
}
websocket.onmessage = function (event) {
console.log('📨 收到消息:', event.data)
// 这里可以处理实际的消息逻辑
}
websocket.onclose = function (event) {
console.log('🔒 WebSocket连接关闭:', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
})
stopHeartbeat()
}
websocket.onerror = function (error) {
console.error('❌ WebSocket错误:', error)
console.log('🔧 错误详情:', {
readyState: websocket?.readyState,
url: wsUrl,
})
}
return true
} catch (error) {
console.error('💥 WebSocket连接异常:', error)
return false
}
}
// 断开连接
function disconnect() {
console.log('🔌 断开WebSocket连接')
stopHeartbeat()
if (websocket) {
// 移除事件监听器避免内存泄漏
websocket.onopen = null
websocket.onmessage = null
websocket.onclose = null
websocket.onerror = null
if (websocket.readyState === WebSocket.OPEN) {
websocket.close(1000, '正常关闭')
}
websocket = null
}
}
// 发送单播消息
function sendUnicast() {
if (websocket == null || websocket.readyState != WebSocket.OPEN) {
alert('请先建立连接')
return
}
var message = {
type: 'unicast',
from: 1111,
to: userId.value,
content: '单播消息',
timestamp: new Date().getTime(),
}
websocket.send(JSON.stringify(message))
console.log('已发送单播消息: ' + JSON.stringify(message))
}
// 发送广播消息
function sendBroadcast() {
if (websocket == null || websocket.readyState != WebSocket.OPEN) {
alert('请先建立连接')
return
}
var message = {
type: 'broadcast',
from: 1111,
content: '广播消息',
timestamp: new Date().getTime(),
}
websocket.send(JSON.stringify(message))
console.log('已发送广播消息: ' + JSON.stringify(message))
}
// 启动心跳检测
function startHeartbeat() {
// 每20秒发送一次心跳
heartbeatInterval = setInterval(function () {
if (websocket.readyState == WebSocket.OPEN) {
var heartbeat = {
type: 'heartbeat',
from: 1111,
timestamp: new Date().getTime(),
}
websocket.send(JSON.stringify(heartbeat))
console.log('已发送心跳')
}
}, 20000)
}
// 停止心跳检测
function stopHeartbeat() {
if (heartbeatInterval != null) {
clearInterval(heartbeatInterval)
heartbeatInterval = null
}
}
onMounted(() => {
// 额外检查:如果watch没有触发,手动检查一次
if (loginUserStore.loginUser?.id && !websocket) {
console.log('🔄 手动触发连接(watch可能未触发)')
setTimeout(() => {
connect()
}, 200)
}
})
// 监听用户加载状态和ID变化
watch(
() => loginUserStore.loginUser?.id,
(newUserId, prevUserId) => {
console.log('👀 状态变化:', {
用户ID: newUserId,
之前ID: prevUserId,
})
// 只有当有用户ID时才连接
if (newUserId) {
console.log('✅ 用户信息加载完成,建立WebSocket连接')
userId.value = newUserId
disconnect()
setTimeout(() => connect(), 100)
} else if (!newUserId) {
console.log('⏸️ 用户未登录,不连接WebSocket')
disconnect()
}
},
{ immediate: true },
)
onUnmounted(() => {
console.log('🧹 组件卸载,清理WebSocket')
disconnect()
})
</script>
<style scoped></style>
注意:如果后端设置了如下的路径上下文
server:
port: 8123
servlet:
context-path: /api
那么服务端的 ws 地址就会有变化,
参考:ws://localhost:8123 /api/ws/1972918799441076226
5. 结合业务功能 - 图片审核结果通知
5.1. 后端
5.1.1. 审核结果消息VO封装类
package com.yu.cloudpicturebackend.model.vo;
import cn.hutool.json.JSONUtil;
import lombok.Data;
import java.util.Date;
@Data
public class ReviewResultMessageVO {
private String type = "review_result"; // 消息类型
private Long pictureId; // 图片ID
private Integer reviewStatus; // 审核状态
private String reviewNote; // 审核备注
private String noticeToUser; // 通知文案
private String name; // 图片名称
private Date reviewTime; // 审核时间
// 转换为JSON字符串
public String toJson() {
return JSONUtil.toJsonStr(this);
}
}
5.1.2. 图片审核服务层
/**
* 管理员审核图片
*
* @param pictureStatusAlterRequest
*/
@Override
public void alterStatus(PictureStatusAlterRequest pictureStatusAlterRequest) {
Long pictureId = pictureStatusAlterRequest.getPictureId();
Integer reviewStatus = pictureStatusAlterRequest.getReviewStatus();
String reviewNote = pictureStatusAlterRequest.getReviewNote(); // 审核备注
Boolean notifyUser = pictureStatusAlterRequest.getNotifyUser(); // 是否通知用户
String noticeToUser = pictureStatusAlterRequest.getNoticeToUser(); // 通知文案
ThrowUtils.throwIf(pictureId == null || reviewStatus == null || PictureReviewStatusEnum.getEnumByValue(reviewStatus) == null, ErrorCode.PARAMS_ERROR);
Picture picture = this.getById(pictureId);
ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR);
// 更新图片状态
UpdateWrapper<Picture> pictureUpdateWrapper = new UpdateWrapper<>();
pictureUpdateWrapper.eq("id", pictureId);
pictureUpdateWrapper.set("review_status", reviewStatus);
pictureUpdateWrapper.set(reviewNote != null, "review_note", reviewNote);
boolean isUpdated = this.update(pictureUpdateWrapper);
ThrowUtils.throwIf(!isUpdated, ErrorCode.OPERATION_ERROR);
// 如果选择通知用户,则发送WebSocket通知
if (Boolean.TRUE.equals(notifyUser)) {
notifyUserReviewResult(picture, reviewStatus, noticeToUser);
}
}
/**
* 通知用户审核结果
*/
private void notifyUserReviewResult(Picture picture, Integer reviewStatus, String noticeToUser) {
try {
Long userId = picture.getUserId(); // 假设Picture实体中有userId字段
if (userId == null) {
log.warn("图片 {} 的用户ID为空,无法发送通知", picture.getId());
return;
}
// 构建审核结果消息
ReviewResultMessageVO message = new ReviewResultMessageVO();
message.setPictureId(picture.getId());
message.setReviewStatus(reviewStatus);
message.setNoticeToUser(noticeToUser);
message.setName(picture.getName());
message.setReviewTime(new Date());
// 通过WebSocket发送单播消息
PictureReviewHandler.sendToUser(userId, message.toJson());
log.info("✅ 已向用户{}发送图片审核通知,图片ID:{},状态:{}",
userId, picture.getId(), reviewStatus);
} catch (Exception e) {
log.error("发送审核结果通知失败,图片ID:{}", picture.getId(), e);
}
}
也可以在 websocket 处理器新增方法,方便调用
/**
* 发送图片审核结果通知给指定用户
*/
public static void notifyReviewResult(Long userId, ReviewResultMessageVO message) {
if (userId == null || message == null) {
log.warn("用户ID或消息为空,无法发送审核通知");
return;
}
String jsonMessage = message.toJson();
sendToUser(userId, jsonMessage);
log.debug("发送图片审核通知给用户{}: {}", userId, jsonMessage);
}
/**
* 批量发送审核结果通知
*/
public static void batchNotifyReviewResult(Map<Long, ReviewResultMessageVO> messages) {
if (messages == null || messages.isEmpty()) {
return;
}
messages.forEach((userId, message) -> {
if (userId != null && message != null) {
notifyReviewResult(userId, message);
}
});
}
5.2. 前端
在 onmessage 中解析收到的消息,并处理通知
// WebSocket 消息处理
websocket.onmessage = function (event) {
console.log('📨 收到消息:', event.data)
const message = JSON.parse(event.data)
if (message.type === 'review_result') {
// 处理审核结果通知
handleReviewResult(message)
}
}
function handleReviewResult(message) {
const statusText = STATUS_MAP[message.reviewStatus] || '未知状态'
// 显示通知
notification.open({
message: '审核结果通知',
description: h(
'div',
[
h('div', `图片名称:"${message.name}"`),
h('div', `审核结果:${statusText}`),
message.noticeToUser ? h('div', `备注:${message.noticeToUser}`) : null,
].filter(Boolean),
),
})
}
6. 高级功能拓展
6.1.1. 消息持久化
// 在WebSocketServer类中添加
private void saveMessage(WebSocketMessage message) {
// 实现消息存储逻辑,可存入数据库或Redis
log.info("存储消息: {}", JSON.toJSONString(message));
}
6.1.2. 断线重连机制(前端)
// 修改前端connect函数
var reconnectAttempts = 0;
var maxReconnectAttempts = 5;
var reconnectInterval = 5000; // 5秒
function connect() {
// ...原有代码...
websocket.onclose = function() {
logMessage('连接已关闭');
$('#disconnectBtn').prop('disabled', true);
stopHeartbeat();
// 断线重连逻辑
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
logMessage('尝试重新连接(' + reconnectAttempts + '/' + maxReconnectAttempts + ')');
setTimeout(connect, reconnectInterval);
}
};
// 连接成功后重置重试计数
websocket.onopen = function() {
reconnectAttempts = 0;
// ...原有代码...
};
}