
最近在开发一个基于 Spring Boot 的在线社区项目时遇到了一个有趣的用户行为分析需求如何优雅地记录并展示用户的“在线状态”与“活跃状态”比如用户明明在线WebSocket 连接正常但长时间没有操作是“发呆”还是“离开”这个看似简单的需求背后涉及到 WebSocket 心跳检测、用户状态管理、前端实时渲染等多个技术点的协同。本文将围绕如何实现一个高可用的用户在线状态与活跃度追踪系统从后端状态机设计到前端动态展示手把手带你完成一套完整的解决方案。无论你是想为社交应用添加“正在输入”、“在线”、“发呆”等状态还是需要监控后台管理员的实时活跃情况这篇文章都能提供可直接复用的代码和架构思路。1. 核心概念用户状态的多维建模在开始编码之前我们必须厘清几个关键概念。一个完整的用户状态系统远不止“在线”和“离线”两种。1.1 状态维度定义通常我们可以从两个正交的维度来定义用户状态连接状态 (Connection State)描述用户客户端与服务器之间的网络链路情况。在线 (Online)WebSocket 或长连接已成功建立。离线 (Offline)连接已断开或从未建立。活跃状态 (Activity State)描述用户在连接建立后的交互行为。活跃 (Active)用户近期有操作如点击、滑动、发送消息。空闲 (Idle)用户在线但一段时间内无操作即“发呆”状态。离开 (Away)用户可能最小化了窗口或切换了标签页通过 Page Visibility API 判断。我们最终要呈现给其他用户的往往是这两个维度的组合例如“在线且活跃”、“在线但空闲发呆”、“离线”。1.2 系统架构概览整个系统可以划分为三个主要部分后端状态服务负责维护所有用户的状态信息处理心跳并对外提供状态查询接口。WebSocket 网关管理连接的建立、维持和断开并与状态服务通信。前端状态同步负责发送心跳、捕获用户活动并实时接收和渲染其他用户的状态变化。本文将使用Spring Boot作为后端框架Spring WebSocket (STOMP)作为通信层Redis作为状态存储Vue 3作为前端框架来演示。你可以根据项目情况替换为 Socket.IO、Netty 或任何其他技术栈核心思想是相通的。2. 环境准备与项目初始化2.1 技术栈与版本说明后端:JDK: 17 或 21 (LTS版本)Spring Boot: 3.1.x依赖:spring-boot-starter-websocket,spring-boot-starter-data-redis,spring-boot-starter-security(用于简单的用户认证)中间件:Redis: 7.x (用于存储用户状态会话)前端:Node.js: 18Vue: 3.3.x依赖:sockjs-client,stompjs,vue-router,pinia(状态管理)注意版本号仅供参考请根据你的实际项目环境进行调整。本文重点在于演示核心逻辑和配置思路。2.2 创建 Spring Boot 项目使用 Spring Initializr 或 IDE 创建项目选择以下依赖Spring WebSpring WebSocketSpring Data RedisSpring SecurityLombok (可选简化代码)生成项目后基础结构如下user-status-demo ├── src/main/java/com/example/userstatus │ ├── config/ # 配置类 │ ├── controller/ # REST WebSocket 控制器 │ ├── dto/ # 数据传输对象 │ ├── service/ # 业务逻辑 │ └── UserStatusDemoApplication.java ├── src/main/resources │ ├── application.yml │ └── static/ # 前端资源 (可选) └── pom.xml2.3 前端项目初始化使用 Vite 快速创建一个 Vue 项目npm create vuelatest user-status-frontend # 选择项目特性时加上 Router 和 Pinia cd user-status-frontend npm install sockjs-client stompjs npm run dev3. 后端核心实现状态管理与 WebSocket 集成3.1 定义用户状态模型与枚举首先我们定义清晰的状态枚举和存储模型。// 文件路径src/main/java/com/example/userstatus/enums/UserActivityState.java package com.example.userstatus.enums; import lombok.Getter; Getter public enum UserActivityState { ACTIVE(活跃, 1), IDLE(空闲, 2), // “发呆”状态 AWAY(离开, 3); private final String description; private final int code; UserActivityState(String description, int code) { this.description description; this.code code; } public static UserActivityState fromCode(int code) { for (UserActivityState state : values()) { if (state.getCode() code) { return state; } } return IDLE; // 默认返回空闲 } }// 文件路径src/main/java/com/example/userstatus/dto/UserStatusDTO.java package com.example.userstatus.dto; import com.example.userstatus.enums.UserActivityState; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Data; import java.time.LocalDateTime; Data JsonInclude(JsonInclude.Include.NON_NULL) public class UserStatusDTO { private String userId; private String username; private String avatar; private Boolean online; // 连接状态true在线false离线 private UserActivityState activity; // 活跃状态 private LocalDateTime lastActiveTime; // 最后一次活跃时间 private LocalDateTime lastHeartbeatTime; // 最后一次心跳时间 // 前端显示的状态文本 public String getDisplayStatus() { if (Boolean.FALSE.equals(online)) { return 离线; } switch (activity) { case ACTIVE: return 在线; case IDLE: return 发呆中; // 这里就是我们标题的“发呆的花火火” case AWAY: return 离开; default: return 在线; } } }3.2 配置 WebSocket 与 Redis在application.yml中配置 Redis 和 WebSocket 端点。# 文件路径src/main/resources/application.yml spring: data: redis: host: localhost port: 6379 # password: yourpassword # 如果Redis有密码 database: 0 # 简单配置实际生产环境需要更详细的安全配置 server: port: 8080 logging: level: com.example.userstatus: DEBUG接下来配置 WebSocket。我们使用 STOMP 作为子协议它比原生 WebSocket 更易于管理。// 文件路径src/main/java/com/example/userstatus/config/WebSocketConfig.java package com.example.userstatus.config; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 客户端将连接到此端点。允许跨域并支持 SockJS 降级。 registry.addEndpoint(/ws) .setAllowedOriginPatterns(*) // 生产环境应指定具体域名 .withSockJS(); } Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 启用一个简单的内存代理用于订阅 /topic 和 /user registry.enableSimpleBroker(/topic, /queue); // 设置应用程序目的地的前缀客户端发送消息到 /app/xxx registry.setApplicationDestinationPrefixes(/app); // 点对点消息前缀默认是 /user registry.setUserDestinationPrefix(/user); } }3.3 实现用户状态服务这是系统的核心负责状态的存储、更新和查询。我们使用 Redis 的 Hash 结构来存储用户状态Key 为user:status:{userId}。// 文件路径src/main/java/com/example/userstatus/service/UserStatusService.java package com.example.userstatus.service; import com.example.userstatus.dto.UserStatusDTO; import com.example.userstatus.enums.UserActivityState; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.HashOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.ConcurrentHashMap; Service Slf4j RequiredArgsConstructor public class UserStatusService { private final RedisTemplateString, Object redisTemplate; private final ObjectMapper objectMapper; private static final String STATUS_KEY_PREFIX user:status:; // 内存中维护一个在线用户列表用于快速广播。也可完全依赖Redis。 private final MapString, UserStatusDTO onlineUserMap new ConcurrentHashMap(); /** * 用户上线 */ public void userOnline(String userId, String username, String sessionId) { String key STATUS_KEY_PREFIX userId; HashOperationsString, String, String hashOps redisTemplate.opsForHash(); UserStatusDTO status new UserStatusDTO(); status.setUserId(userId); status.setUsername(username); status.setOnline(true); status.setActivity(UserActivityState.ACTIVE); // 刚上线默认活跃 status.setLastActiveTime(LocalDateTime.now()); status.setLastHeartbeatTime(LocalDateTime.now()); try { String statusJson objectMapper.writeValueAsString(status); hashOps.put(key, data, statusJson); // 设置一个过期时间防止僵尸数据例如30分钟 redisTemplate.expire(key, 30, java.util.concurrent.TimeUnit.MINUTES); onlineUserMap.put(userId, status); log.info(用户上线: {}, session: {}, username, sessionId); // 广播用户上线事件 broadcastStatusChange(status); } catch (Exception e) { log.error(保存用户状态到Redis失败: {}, userId, e); } } /** * 更新用户活跃状态例如收到心跳或用户操作 */ public void updateUserActivity(String userId, UserActivityState newActivity) { UserStatusDTO status getUserStatus(userId); if (status ! null status.getOnline()) { status.setActivity(newActivity); status.setLastActiveTime(LocalDateTime.now()); if (newActivity UserActivityState.ACTIVE) { status.setLastHeartbeatTime(LocalDateTime.now()); } saveStatusToRedis(status); onlineUserMap.put(userId, status); // 状态变化时广播 if (!newActivity.equals(status.getActivity())) { broadcastStatusChange(status); } } } /** * 处理心跳主要更新最后心跳时间并将状态置为ACTIVE */ public void processHeartbeat(String userId) { updateUserActivity(userId, UserActivityState.ACTIVE); } /** * 用户下线 */ public void userOffline(String userId, String sessionId) { UserStatusDTO status getUserStatus(userId); if (status ! null) { status.setOnline(false); status.setActivity(UserActivityState.IDLE); // 下线后状态无意义可置为默认 saveStatusToRedis(status); onlineUserMap.remove(userId); log.info(用户下线: {}, session: {}, status.getUsername(), sessionId); broadcastStatusChange(status); } // 可以选择删除Redis key或者保留一段时间供查询 // redisTemplate.delete(STATUS_KEY_PREFIX userId); } /** * 获取指定用户状态 */ public UserStatusDTO getUserStatus(String userId) { // 优先从内存缓存获取 UserStatusDTO cached onlineUserMap.get(userId); if (cached ! null) { return cached; } // 从Redis获取 HashOperationsString, String, String hashOps redisTemplate.opsForHash(); String json hashOps.get(STATUS_KEY_PREFIX userId, data); if (json ! null) { try { return objectMapper.readValue(json, UserStatusDTO.class); } catch (Exception e) { log.error(解析用户状态失败: {}, userId, e); } } return null; } /** * 获取所有在线用户状态 */ public ListUserStatusDTO getAllOnlineUsers() { return new ArrayList(onlineUserMap.values()); } /** * 定期检查空闲用户模拟“发呆”检测 * 每60秒运行一次检查超过120秒无心跳的用户将其状态置为IDLE */ Scheduled(fixedRate 60000) // 60秒 public void checkIdleUsers() { LocalDateTime now LocalDateTime.now(); LocalDateTime idleThreshold now.minusSeconds(120); // 2分钟无活跃视为发呆 onlineUserMap.values().forEach(status - { if (status.getOnline() status.getActivity() UserActivityState.ACTIVE) { if (status.getLastHeartbeatTime().isBefore(idleThreshold)) { log.debug(用户 {} 进入发呆状态, status.getUsername()); updateUserActivity(status.getUserId(), UserActivityState.IDLE); } } }); } /** * 广播状态变更给所有订阅了 /topic/user.status 的客户端 */ private void broadcastStatusChange(UserStatusDTO status) { // 这里通过WebSocket广播具体实现在Controller中调用 // 本服务只负责业务逻辑广播由消息控制器处理 } private void saveStatusToRedis(UserStatusDTO status) { String key STATUS_KEY_PREFIX status.getUserId(); HashOperationsString, String, String hashOps redisTemplate.opsForHash(); try { String statusJson objectMapper.writeValueAsString(status); hashOps.put(key, data, statusJson); redisTemplate.expire(key, 30, java.util.concurrent.TimeUnit.MINUTES); } catch (Exception e) { log.error(保存状态到Redis失败: {}, status.getUserId(), e); } } }3.4 实现 WebSocket 消息控制器这个控制器处理客户端发来的 WebSocket 消息如连接建立、断开、心跳等。// 文件路径src/main/java/com/example/userstatus/controller/WebSocketController.java package com.example.userstatus.controller; import com.example.userstatus.dto.UserStatusDTO; import com.example.userstatus.service.UserStatusService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.annotation.SubscribeMapping; import org.springframework.web.bind.annotation.RestController; import java.security.Principal; import java.time.LocalDateTime; RestController Slf4j RequiredArgsConstructor public class WebSocketController { private final UserStatusService userStatusService; private final SimpMessagingTemplate messagingTemplate; /** * 处理客户端心跳消息 * 客户端发送到 /app/heartbeat */ MessageMapping(/heartbeat) public void handleHeartbeat(Principal principal) { if (principal ! null) { String userId principal.getName(); // 假设用户名即userId userStatusService.processHeartbeat(userId); // 可以返回一个确认这里省略 } } /** * 当客户端订阅 /topic/user.status 时立即返回当前所有在线用户状态 */ SubscribeMapping(/topic/user.status) public java.util.ListUserStatusDTO sendInitialStatus() { return userStatusService.getAllOnlineUsers(); } /** * 广播用户状态变更的通用方法由 UserStatusService 调用 */ public void broadcastUserStatus(UserStatusDTO status) { // 广播给所有订阅了 /topic/user.status 的客户端 messagingTemplate.convertAndSend(/topic/user.status, status); // 也可以单独发送给特定用户 /user/{userId}/queue/status messagingTemplate.convertAndSendToUser( status.getUserId(), /queue/status, status ); } }3.5 实现连接监听器我们需要监听 WebSocket 的连接和断开事件以触发用户的上线和下线逻辑。// 文件路径src/main/java/com/example/userstatus/listener/WebSocketEventListener.java package com.example.userstatus.listener; import com.example.userstatus.service.UserStatusService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionConnectedEvent; import org.springframework.web.socket.messaging.SessionDisconnectEvent; import java.security.Principal; Component Slf4j RequiredArgsConstructor public class WebSocketEventListener { private final UserStatusService userStatusService; EventListener public void handleWebSocketConnectListener(SessionConnectedEvent event) { StompHeaderAccessor headerAccessor StompHeaderAccessor.wrap(event.getMessage()); Principal principal headerAccessor.getUser(); if (principal ! null) { String username principal.getName(); String sessionId headerAccessor.getSessionId(); // 这里假设 username 就是 userId。实际项目可能从 token 或数据库获取更多信息。 userStatusService.userOnline(username, username, sessionId); } } EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { StompHeaderAccessor headerAccessor StompHeaderAccessor.wrap(event.getMessage()); Principal principal headerAccessor.getUser(); if (principal ! null) { String username principal.getName(); String sessionId headerAccessor.getSessionId(); userStatusService.userOffline(username, sessionId); } } }4. 前端实现状态同步与UI渲染4.1 建立 WebSocket 连接与状态管理在前端项目中我们创建一个websocket.js服务来管理连接和状态。// 文件路径src/services/websocket.js import SockJS from sockjs-client; import { Client } from stomp/stompjs; class WebSocketService { constructor() { this.stompClient null; this.connected false; this.subscriptions new Map(); // 保存订阅引用便于取消 } connect(token) { // 假设你的后端运行在 8080 端口 const socket new SockJS(http://localhost:8080/ws); this.stompClient new Client({ webSocketFactory: () socket, connectHeaders: { // 传递认证信息例如 JWT Token Authorization: Bearer ${token} }, debug: (str) { console.log(STOMP: , str); }, reconnectDelay: 5000, heartbeatIncoming: 4000, heartbeatOutgoing: 4000, }); this.stompClient.onConnect (frame) { this.connected true; console.log(WebSocket 连接成功:, frame); // 连接成功后订阅用户状态广播主题 this.subscribeToUserStatus(); // 开始发送心跳 this.startHeartbeat(); }; this.stompClient.onStompError (frame) { console.error(STOMP 协议错误:, frame.headers[message]); console.error(详情:, frame.body); }; this.stompClient.activate(); } disconnect() { if (this.stompClient) { this.stompClient.deactivate(); this.connected false; console.log(WebSocket 连接已断开); } } subscribeToUserStatus() { const subscription this.stompClient.subscribe(/topic/user.status, (message) { const userStatus JSON.parse(message.body); console.log(收到用户状态更新:, userStatus); // 触发一个全局事件或更新状态管理库如 Pinia window.dispatchEvent(new CustomEvent(userStatusUpdated, { detail: userStatus })); }); this.subscriptions.set(userStatus, subscription); } startHeartbeat() { // 每30秒发送一次心跳 this.heartbeatInterval setInterval(() { if (this.connected this.stompClient) { this.stompClient.publish({ destination: /app/heartbeat, body: JSON.stringify({}), }); } }, 30000); } stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } } } export default new WebSocketService();4.2 创建 Pinia Store 管理用户状态使用 Pinia 来集中管理所有用户的状态数据。// 文件路径src/stores/userStatus.js import { defineStore } from pinia; import { ref, computed } from vue; export const useUserStatusStore defineStore(userStatus, () { // 存储所有用户的状态key 是 userIdvalue 是状态对象 const userStatusMap ref(new Map()); // 添加或更新用户状态 function updateUserStatus(status) { userStatusMap.value.set(status.userId, status); } // 移除用户状态用户下线 function removeUserStatus(userId) { userStatusMap.value.delete(userId); } // 获取特定用户的状态 function getUserStatus(userId) { return userStatusMap.value.get(userId) || { userId, online: false, activity: IDLE, displayStatus: 离线 }; } // 获取所有在线用户列表 const onlineUsers computed(() { return Array.from(userStatusMap.value.values()).filter(user user.online); }); // 监听全局事件更新 store if (typeof window ! undefined) { window.addEventListener(userStatusUpdated, (event) { updateUserStatus(event.detail); }); } return { userStatusMap, updateUserStatus, removeUserStatus, getUserStatus, onlineUsers, }; });4.3 实现用户活动追踪与状态更新我们需要在前端捕获用户活动鼠标移动、按键等以更新本地活跃状态并通知后端。// 文件路径src/utils/activityTracker.js import { debounce } from lodash-es; // 需要安装 lodash-es class ActivityTracker { constructor(websocketService) { this.ws websocketService; this.lastActivityTime Date.now(); this.IDLE_THRESHOLD 60000; // 1分钟无操作视为空闲发呆 this.checkInterval null; this.init(); } init() { // 监听用户活动事件 const events [mousemove, keydown, click, scroll]; const updateActivity debounce(() { this.lastActivityTime Date.now(); this.reportActivity(ACTIVE); }, 1000); // 防抖1秒内多次活动只报告一次 events.forEach(event { window.addEventListener(event, updateActivity, { passive: true }); }); // 监听页面可见性变化 document.addEventListener(visibilitychange, () { if (document.hidden) { this.reportActivity(AWAY); } else { this.reportActivity(ACTIVE); } }); // 定期检查是否进入空闲状态 this.checkInterval setInterval(() { const now Date.now(); if (now - this.lastActivityTime this.IDLE_THRESHOLD) { this.reportActivity(IDLE); } }, 10000); // 每10秒检查一次 } reportActivity(activity) { // 通过 WebSocket 发送活动状态到后端 // 这里需要根据你的后端接口实现 if (this.ws.stompClient this.ws.connected) { this.ws.stompClient.publish({ destination: /app/activity, body: JSON.stringify({ activity }), }); } } destroy() { if (this.checkInterval) { clearInterval(this.checkInterval); } // 移除事件监听器简化示例实际需要保存引用 } } export default ActivityTracker;4.4 在 Vue 组件中展示用户状态最后创建一个组件来展示用户列表及其状态。!-- 文件路径src/components/OnlineUserList.vue -- template div classonline-user-list h3在线用户 ({{ onlineUsers.length }})/h3 ul li v-foruser in onlineUsers :keyuser.userId classuser-item div classuser-avatar img :srcuser.avatar || /default-avatar.png :altuser.username / !-- 状态指示器 -- span classstatus-indicator :classgetStatusClass(user)/span /div div classuser-info strong{{ user.username }}/strong span classuser-status :classstatus- user.activity?.toLowerCase() {{ user.displayStatus || getDisplayStatus(user) }} /span small v-ifuser.lastActiveTime 最后活动: {{ formatTime(user.lastActiveTime) }} /small /div /li /ul /div /template script setup import { storeToRefs } from pinia; import { useUserStatusStore } from /stores/userStatus; import { computed } from vue; const userStatusStore useUserStatusStore(); const { onlineUsers } storeToRefs(userStatusStore); const getStatusClass (user) { if (!user.online) return status-offline; switch (user.activity) { case ACTIVE: return status-active; case IDLE: return status-idle; // “发呆”状态 case AWAY: return status-away; default: return status-offline; } }; const getDisplayStatus (user) { if (!user.online) return 离线; switch (user.activity) { case ACTIVE: return 在线; case IDLE: return 发呆中; case AWAY: return 离开; default: return 在线; } }; const formatTime (timeStr) { // 简单的时间格式化可以使用 dayjs 或 date-fns const date new Date(timeStr); return ${date.getHours().toString().padStart(2, 0)}:${date.getMinutes().toString().padStart(2, 0)}; }; /script style scoped .online-user-list { padding: 1rem; background: #f8f9fa; border-radius: 8px; } .user-item { display: flex; align-items: center; padding: 0.75rem; margin-bottom: 0.5rem; background: white; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .user-avatar { position: relative; margin-right: 1rem; } .user-avatar img { width: 40px; height: 40px; border-radius: 50%; } .status-indicator { position: absolute; bottom: 0; right: 0; width: 10px; height: 10px; border-radius: 50%; border: 2px solid white; } .status-active { background-color: #4caf50; } /* 绿色-活跃 */ .status-idle { background-color: #ff9800; } /* 橙色-发呆 */ .status-away { background-color: #9e9e9e; } /* 灰色-离开 */ .status-offline { background-color: #f44336; }/* 红色-离线 */ .user-info { display: flex; flex-direction: column; } .user-status { font-size: 0.85em; color: #666; margin-top: 0.25rem; } .status-idle { color: #ff9800; font-style: italic; } /style5. 部署、测试与常见问题排查5.1 运行与测试步骤启动后端确保 Redis 服务已启动。运行UserStatusDemoApplication主类。访问http://localhost:8080应能看到 Whitelabel Error Page正常因为没有定义根路径的Controller。启动前端进入user-status-frontend目录运行npm run dev。访问http://localhost:5173(或 Vite 提供的地址)。模拟多用户登录你需要一个简单的登录机制来区分用户。为了测试可以临时修改代码在连接 WebSocket 时硬编码不同的用户 ID。打开两个不同的浏览器或匿名窗口访问前端页面模拟两个用户登录。观察状态变化用户连接后列表中应显示为“在线”。停止操作页面等待超过设定的空闲时间如2分钟状态应变为“发呆中”。切换浏览器标签页状态可能变为“离开”取决于 Page Visibility API 的实现。关闭页面或断开连接状态应变为“离线”。5.2 常见问题与解决方案问题现象可能原因排查思路与解决方案WebSocket 连接失败报错Error during WebSocket handshake1. 后端 WebSocket 端点路径不正确。2. 跨域问题。3. 代理服务器如 Nginx未正确配置 WebSocket 转发。1. 检查前端连接的 URL (/ws) 是否与后端registry.addEndpoint(“/ws”)一致。2. 检查后端setAllowedOriginPatterns开发环境可设为“*”生产环境需指定域名。3. Nginx 需添加proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection “upgrade”;。连接成功但收不到状态广播消息1. 客户端未正确订阅主题。2. 后端广播逻辑未触发或路径错误。3. STOMP 客户端库版本兼容性问题。1. 检查前端subscribe的路径 (/topic/user.status) 是否与后端convertAndSend的路径一致。2. 在后端broadcastUserStatus方法中添加日志确认是否被调用。3. 在浏览器开发者工具的Network-WS标签页查看 STOMP 帧。用户状态不更新一直显示“在线”1. 心跳未发送或后端未处理。2. 空闲检测定时任务未执行。3. Redis 数据未正确更新。1. 检查前端心跳定时器是否工作查看网络请求。2. 检查后端Scheduled注解是否生效需在主类添加EnableScheduling。3. 使用 Redis CLI 查看user:status:*键的值是否变化。用户下线后状态仍显示“在线”1. WebSocket 断开事件未捕获。2.SessionDisconnectEvent中未获取到用户信息。3. 网络异常导致断开事件未触发。1. 检查WebSocketEventListener中的日志是否打印。2. 确保连接时 Principal 已正确设置需要 Spring Security 集成或自定义认证。3. 实现一个兜底机制在UserStatusService中定期清理长时间无心跳的“僵尸”连接。生产环境内存泄漏1.onlineUserMap无限增长未清理离线用户。2. WebSocket 会话未正确清理。1. 在userOffline方法中务必从onlineUserMap移除用户。2. 考虑将onlineUserMap也移至 Redis使用 Set 或 Sorted Set 管理并设置过期时间。6. 最佳实践与进阶优化建议6.1 状态存储与一致性使用 Redis 集群对于大规模用户单机 Redis 可能成为瓶颈。考虑使用 Redis 集群分片存储用户状态。最终一致性在分布式环境下状态更新可能存在延迟。如果你的应用对强一致性要求不高可以接受秒级延迟。否则需要考虑更复杂的分布式一致性方案。状态快照与持久化重要的状态变更如上线、下线可以异步持久化到数据库用于历史查询和分析。6.2 性能与可扩展性广播优化当在线用户数极大时向所有人广播状态变更会带来巨大压力。可以考虑按需订阅只让用户订阅其关注的人或所在群组的状态。状态聚合与差分更新不是每次变化都广播完整对象只发送变化的字段如{userId: “123”, activity: “IDLE”}。使用专业的消息中间件如 RabbitMQ、Kafka 或 Redis Pub/Sub 来解耦和缓冲消息。前端防抖与节流用户活动追踪如mousemove事件非常频繁务必使用防抖debounce或节流throttle技术避免向后端发送过多请求。6.3 安全与可靠性认证与授权本文示例简化了认证。生产环境必须集成安全的认证机制如 JWT。在 WebSocket 握手阶段验证 Token确保只有合法用户能建立连接和发布状态。防止状态伪造后端服务是状态唯一的权威来源。前端发送的心跳和活动报告只能作为参考后端应有自己的超时判断逻辑防止恶意客户端发送虚假的“活跃”信号。连接保活与重连网络不稳定是常态。前端 WebSocket 客户端必须实现自动重连机制并在重连后恢复状态订阅。监控与告警监控 WebSocket 连接数、Redis 内存使用、心跳异常等指标设置告警及时发现系统异常。6.4 用户体验细化状态防抖避免状态在“活跃”和“发呆”间快速闪烁。可以在后端状态机中引入“状态保持时间”例如进入“活跃”状态后至少保持10秒才允许切换到“发呆”。丰富状态类型根据业务需要可以定义更精细的状态如“正在输入”、“忙碌”、“请勿打扰”等。离线状态处理用户离线后其状态是保留最后状态还是清除通常保留最后状态并显示“离线”更友好。可以设置一个过期时间如7天后自动清理 Redis 中的数据。通过以上步骤我们构建了一个功能相对完整的用户在线状态与活跃度追踪系统。从后端的状态机设计、Redis存储、WebSocket通信到前端的状态管理、活动追踪和UI渲染形成了一个闭环。你可以在此基础上根据自己项目的具体业务逻辑进行扩展和定制例如与聊天系统、游戏状态、协同编辑等场景深度集成。记住核心在于理解“连接状态”与“活跃状态”的分离以及通过心跳和用户行为来驱动状态机的流转。