diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/websocket/CustomerServiceWebSocket.java b/ruoyi-admin/src/main/java/com/ruoyi/web/websocket/CustomerServiceWebSocket.java index 7b88a93d..e8659eef 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/websocket/CustomerServiceWebSocket.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/websocket/CustomerServiceWebSocket.java @@ -3,15 +3,21 @@ package com.ruoyi.web.websocket; import java.io.IOException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import javax.websocket.OnClose; import javax.websocket.OnError; import javax.websocket.OnMessage; import javax.websocket.OnOpen; +import javax.websocket.PongMessage; import javax.websocket.Session; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; +import java.nio.ByteBuffer; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -41,12 +47,24 @@ public class CustomerServiceWebSocket { /** concurrent包的线程安全Set,用来存放每个客户端对应的WebSocket对象 */ private static final ConcurrentHashMap webSocketMap = new ConcurrentHashMap<>(); + /** 心跳定时器 */ + private static final ScheduledExecutorService heartbeatExecutor = Executors.newScheduledThreadPool(10); + + /** 心跳间隔时间(秒) */ + private static final int HEARTBEAT_INTERVAL = 30; + + /** 连接超时时间(毫秒) */ + private static final long SESSION_TIMEOUT = 300000; // 5分钟 + /** 与某个客户端的连接会话,需要通过它来给客户端发送数据 */ private Session session; /** 接收userId */ private String userId = ""; + /** 心跳任务 */ + private java.util.concurrent.ScheduledFuture heartbeatTask; + /** * 注入客服服务 */ @@ -62,7 +80,16 @@ public class CustomerServiceWebSocket { public void onOpen(Session session, @PathParam("userId") String userId) { this.session = session; this.userId = userId; + + // 设置session超时时间 + session.setMaxIdleTimeout(SESSION_TIMEOUT); + if (webSocketMap.containsKey(userId)) { + // 如果已存在连接,先清理旧的心跳任务 + CustomerServiceWebSocket oldWebSocket = webSocketMap.get(userId); + if (oldWebSocket != null && oldWebSocket.heartbeatTask != null) { + oldWebSocket.heartbeatTask.cancel(true); + } webSocketMap.remove(userId); webSocketMap.put(userId, this); } else { @@ -70,7 +97,10 @@ public class CustomerServiceWebSocket { addOnlineCount(); } - log.info("用户连接:" + userId + ",当前在线人数为:" + getOnlineCount()); + // 启动心跳机制 + startHeartbeat(); + + log.info("用户连接:" + userId + ",当前在线人数为:" + getOnlineCount() + ",连接超时设置为:" + SESSION_TIMEOUT + "ms"); try { sendMessage("连接成功"); @@ -84,6 +114,9 @@ public class CustomerServiceWebSocket { */ @OnClose public void onClose() { + // 停止心跳任务 + stopHeartbeat(); + if (webSocketMap.containsKey(userId)) { webSocketMap.remove(userId); subOnlineCount(); @@ -105,6 +138,14 @@ public class CustomerServiceWebSocket { log.info("用户退出:" + userId + ",当前在线人数为:" + getOnlineCount()); } + /** + * 处理Pong消息 + */ + @OnMessage + public void onPong(PongMessage pongMessage, Session session) { + log.debug("收到用户{}的pong消息,连接正常", this.userId); + } + /** * 收到客户端消息后调用的方法 * @@ -280,4 +321,57 @@ public class CustomerServiceWebSocket { log.error("发送消息给用户{}失败", userId, e); } } + + /** + * 启动心跳机制 + */ + private void startHeartbeat() { + if (heartbeatTask != null) { + heartbeatTask.cancel(true); + } + + heartbeatTask = heartbeatExecutor.scheduleWithFixedDelay(() -> { + try { + if (session != null && session.isOpen()) { + // 发送ping消息 + ByteBuffer pingData = ByteBuffer.wrap("ping".getBytes()); + session.getBasicRemote().sendPing(pingData); + log.debug("向用户{}发送ping消息", userId); + } else { + log.warn("用户{}的session已关闭,停止心跳", userId); + stopHeartbeat(); + } + } catch (Exception e) { + log.error("发送心跳消息失败,用户: {}, 错误: {}", userId, e.getMessage()); + stopHeartbeat(); + } + }, HEARTBEAT_INTERVAL, HEARTBEAT_INTERVAL, TimeUnit.SECONDS); + + log.info("用户{}启动心跳机制,间隔{}秒", userId, HEARTBEAT_INTERVAL); + } + + /** + * 停止心跳机制 + */ + private void stopHeartbeat() { + if (heartbeatTask != null && !heartbeatTask.isCancelled()) { + heartbeatTask.cancel(true); + log.info("用户{}停止心跳机制", userId); + } + } + + /** + * 发送ping消息保持连接活跃 + */ + public void sendPing() { + try { + if (session != null && session.isOpen()) { + ByteBuffer pingData = ByteBuffer.wrap("heartbeat".getBytes()); + session.getBasicRemote().sendPing(pingData); + log.debug("向用户{}发送ping消息", userId); + } + } catch (Exception e) { + log.error("发送ping消息失败,用户: {}", userId, e); + } + } } \ No newline at end of file diff --git a/ruoyi-admin/src/main/resources/templates/customer/service/index.html b/ruoyi-admin/src/main/resources/templates/customer/service/index.html index 7b2b6f3a..e95bf716 100644 --- a/ruoyi-admin/src/main/resources/templates/customer/service/index.html +++ b/ruoyi-admin/src/main/resources/templates/customer/service/index.html @@ -199,6 +199,64 @@ padding: 2px 8px; font-size: 11px; } + + /* 会话分隔符样式 */ + .session-separator { + margin: 20px 0; + text-align: center; + position: relative; + } + + .session-separator::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: #e7eaec; + } + + .session-separator-text { + background: #f8f8f9; + padding: 5px 15px; + color: #666; + font-size: 12px; + border: 1px solid #e7eaec; + border-radius: 15px; + display: inline-block; + } + + .session-separator.current-session .session-separator-text { + background: #e6f7ff; + border-color: #1890ff; + color: #1890ff; + font-weight: bold; + } + + /* 历史消息样式 */ + .history-message { + opacity: 0.8; + } + + .history-message .message-content { + border-style: dashed; + } + + /* 加载状态样式 */ + .loading-message { + text-align: center; + color: #666; + font-style: italic; + margin: 20px 0; + } + + .no-history-message { + text-align: center; + color: #999; + font-style: italic; + margin: 40px 0; + } @@ -307,6 +365,11 @@ } } + // 心跳相关变量 + var heartbeatTimer = null; + var heartbeatInterval = 30000; // 30秒心跳间隔 + var manualClose = false; // 手动关闭标志 + // 创建WebSocket连接 function createWebSocketConnection(userId) { // 根据当前页面协议动态选择WebSocket协议 @@ -318,9 +381,12 @@ try { // 如果已有连接,先关闭 if (wsConnection && wsConnection.readyState !== WebSocket.CLOSED) { + manualClose = true; + stopHeartbeat(); wsConnection.close(); } + manualClose = false; wsConnection = new WebSocket(wsUrl); console.log("[DEBUG] WebSocket对象创建成功"); } catch (e) { @@ -368,10 +434,19 @@ wsConnection.onopen = function() { console.log("[SUCCESS] WebSocket连接已建立"); console.log("[DEBUG] WebSocket readyState:", wsConnection.readyState); + // 启动心跳机制 + startHeartbeat(); }; wsConnection.onmessage = function(event) { console.log("[DEBUG] 收到WebSocket原始消息:", event.data); + + // 处理pong消息 + if (event.data === 'pong') { + console.log("[DEBUG] 收到服务端pong响应"); + return; + } + try { var message = JSON.parse(event.data); console.log("[DEBUG] 解析后的消息对象:", message); @@ -387,9 +462,21 @@ reason: event.reason, wasClean: event.wasClean }); - // 尝试重连 - console.log("[DEBUG] 5秒后尝试重连WebSocket..."); - setTimeout(initWebSocket, 5000); + + // 停止心跳 + stopHeartbeat(); + + // 如果不是手动关闭,则尝试重连 + if (!manualClose) { + console.log("[DEBUG] 5秒后尝试重连WebSocket..."); + setTimeout(function() { + getCurrentUserId(function(serviceId) { + if (serviceId) { + initWebSocket(serviceId); + } + }); + }, 5000); + } }; wsConnection.onerror = function(error) { @@ -398,6 +485,34 @@ }; } + // 启动心跳机制 + function startHeartbeat() { + console.log("[DEBUG] 启动心跳机制,间隔:", heartbeatInterval + "ms"); + stopHeartbeat(); // 先停止之前的心跳 + heartbeatTimer = setInterval(function() { + sendPing(); + }, heartbeatInterval); + } + + // 停止心跳机制 + function stopHeartbeat() { + if (heartbeatTimer) { + console.log("[DEBUG] 停止心跳机制"); + clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + } + + // 发送ping消息 + function sendPing() { + if (wsConnection && wsConnection.readyState === WebSocket.OPEN) { + console.log("[DEBUG] 发送ping心跳消息"); + wsConnection.send('ping'); + } else { + console.log("[WARN] WebSocket连接不可用,无法发送ping消息"); + } + } + // 处理WebSocket消息 function handleWebSocketMessage(message) { console.log('[DEBUG] 开始处理WebSocket消息:', message); @@ -491,7 +606,14 @@ html += '
'; html += '
' + avatar + '
'; html += '
'; - html += '
' + (session.customerName || '访客' + session.sessionId) + '
'; + // 构建显示名称:用户名:手机号 + var displayName = ''; + if (session.userId) { + displayName = '访客:' + session.userId; + } else { + displayName = '访客:未知号码'; + } + html += '
' + displayName + '
'; html += '
' + (session.lastMessage || '暂无消息') + '
'; html += '
' + formatTime(session.lastMessageTime) + '
'; html += '
'; @@ -520,21 +642,154 @@ // 加载聊天历史 function loadChatHistory(sessionId) { - $.get('/customer/service/messages/' + sessionId, function(data) { - var html = ''; - if (data.code === 0 && data.data) { - data.data.forEach(function(message) { - html += createMessageHtml(message.content, message.senderType === 'CUSTOMER', message.createTime); + console.log('[DEBUG] 开始加载聊天历史 - sessionId:', sessionId); + + // 显示加载状态 + $('#chatMessages').html('
正在加载聊天历史...
'); + + // 首先获取会话信息以获取用户ID + $.get('/customer/service/sessions') + .done(function(sessionsData) { + if (sessionsData.code === 0 && sessionsData.data) { + // 查找当前会话 + var currentSession = sessionsData.data.find(function(session) { + return session.sessionId === sessionId; + }); + + if (currentSession && currentSession.userId) { + console.log('[DEBUG] 找到会话用户ID:', currentSession.userId); + // 通过用户ID获取所有历史记录 + loadChatHistoryByUserId(currentSession.userId, sessionId); + } else { + console.log('[DEBUG] 未找到用户ID,使用原有方式加载会话历史'); + // 如果没有用户ID,回退到原有方式 + loadChatHistoryBySessionId(sessionId); + } + } else { + console.log('[DEBUG] 获取会话列表失败,使用原有方式加载会话历史'); + loadChatHistoryBySessionId(sessionId); + } + }) + .fail(function(xhr, status, error) { + console.error('[ERROR] 获取会话列表失败:', { + status: xhr.status, + statusText: xhr.statusText, + error: error }); - } - $('#chatMessages').html(html); - scrollToBottom(); - }); + // 回退到原有方式 + loadChatHistoryBySessionId(sessionId); + }); + } + + // 通过用户ID加载聊天历史 + function loadChatHistoryByUserId(userId, currentSessionId) { + console.log('[DEBUG] 通过用户ID加载聊天历史 - userId:', userId); + + $.get('/system/chatHistory/app/user/' + userId) + .done(function(data) { + console.log('[DEBUG] 用户聊天历史API响应:', data); + + var html = ''; + if (data.code === 0 && data.data && data.data.length > 0) { + console.log('[DEBUG] 用户聊天历史消息数量:', data.data.length); + + // 按时间排序 + var sortedMessages = data.data.sort(function(a, b) { + return new Date(a.createTime) - new Date(b.createTime); + }); + + // 按会话分组显示 + var sessionGroups = {}; + sortedMessages.forEach(function(message) { + if (!sessionGroups[message.sessionId]) { + sessionGroups[message.sessionId] = []; + } + sessionGroups[message.sessionId].push(message); + }); + + // 渲染消息,当前会话高亮显示 + Object.keys(sessionGroups).forEach(function(sessionId) { + var isCurrentSession = sessionId === currentSessionId; + var separatorClass = isCurrentSession ? 'session-separator current-session' : 'session-separator'; + var separatorText = isCurrentSession ? '当前会话' : '历史会话 - ' + sessionId; + + html += '
'; + html += ' ' + separatorText + ''; + html += '
'; + + sessionGroups[sessionId].forEach(function(message) { + console.log('[DEBUG] 消息类型:', message.messageType, '消息内容:', message.content); + // messageType为"user"的消息显示在左边(received=true),其他消息显示在右边(received=false) + // received样式:左对齐,白色背景;sent样式:右对齐,蓝色背景 + var isReceived = message.messageType === 'user'; + html += createMessageHtml(message.content, isReceived, message.createTime, isCurrentSession); + }); + }); + } else { + console.log('[DEBUG] 用户聊天历史数据为空'); + html = '
暂无聊天记录
'; + } + + $('#chatMessages').html(html); + scrollToBottom(); + console.log('[DEBUG] 用户聊天历史加载完成'); + }) + .fail(function(xhr, status, error) { + console.error('[ERROR] 用户聊天历史API调用失败:', { + status: xhr.status, + statusText: xhr.statusText, + error: error, + response: xhr.responseText + }); + // 回退到原有方式 + loadChatHistoryBySessionId(currentSessionId); + }); + } + + // 通过会话ID加载聊天历史(原有方式) + function loadChatHistoryBySessionId(sessionId) { + console.log('[DEBUG] 通过会话ID加载聊天历史 - sessionId:', sessionId); + + $.get('/customer/service/messages/' + sessionId) + .done(function(data) { + console.log('[DEBUG] 会话聊天历史API响应:', data); + + var html = ''; + if (data.code === 0 && data.data) { + console.log('[DEBUG] 会话聊天历史消息数量:', data.data.length); + data.data.forEach(function(message) { + console.log('[DEBUG] 消息类型:', message.messageType, '消息内容:', message.content); + // messageType为"user"的消息显示在左边(received=true),其他消息显示在右边(received=false) + // received样式:左对齐,白色背景;sent样式:右对齐,蓝色背景 + var isReceived = message.messageType === 'user'; + html += createMessageHtml(message.content, isReceived, message.createTime, true); + }); + } else { + console.log('[DEBUG] 会话聊天历史数据为空或返回错误:', data); + html = '
暂无聊天记录
'; + } + + $('#chatMessages').html(html); + scrollToBottom(); + console.log('[DEBUG] 会话聊天历史加载完成'); + }) + .fail(function(xhr, status, error) { + console.error('[ERROR] 会话聊天历史API调用失败:', { + status: xhr.status, + statusText: xhr.statusText, + error: error, + response: xhr.responseText + }); + $('#chatMessages').html('
加载聊天历史失败,请稍后重试
'); + }); } // 创建消息HTML - function createMessageHtml(content, isReceived, timestamp) { + function createMessageHtml(content, isReceived, timestamp, isCurrentSession) { var messageClass = isReceived ? 'received' : 'sent'; + if (isCurrentSession === false) { + messageClass += ' history-message'; + } var html = '
'; html += '
'; html += '
' + content + '
'; diff --git a/ruoyi-system/src/main/java/com/ruoyi/customer/service/ICustomerServiceService.java b/ruoyi-system/src/main/java/com/ruoyi/customer/service/ICustomerServiceService.java index c3f638fe..255d0283 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/customer/service/ICustomerServiceService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/customer/service/ICustomerServiceService.java @@ -304,4 +304,12 @@ public interface ICustomerServiceService * @return 已接受的转人工记录,如果没有则返回null */ public ManualServiceSessions getAcceptedManualSessionByUserId(Long userId); + + /** + * 清理客服的已接受转人工记录 + * + * @param serviceId 客服ID + * @return 清理数量 + */ + public int cleanupAcceptedManualRequestsByServiceId(Long serviceId); } \ No newline at end of file diff --git a/ruoyi-system/src/main/java/com/ruoyi/customer/service/impl/CustomerServiceServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/customer/service/impl/CustomerServiceServiceImpl.java index 319adec8..3a06d24f 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/customer/service/impl/CustomerServiceServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/customer/service/impl/CustomerServiceServiceImpl.java @@ -621,4 +621,36 @@ public class CustomerServiceServiceImpl implements ICustomerServiceService e.printStackTrace(); } } + + /** + * 清理客服的已接受转人工记录 + * + * @param serviceId 客服ID + * @return 清理数量 + */ + @Override + public int cleanupAcceptedManualRequestsByServiceId(Long serviceId) + { + ManualServiceSessions condition = new ManualServiceSessions(); + condition.setServiceId(serviceId); + condition.setStatus("1"); // 已接受状态 + + List acceptedRequests = manualServiceSessionsMapper.selectManualServiceSessionsList(condition); + + if (acceptedRequests != null && !acceptedRequests.isEmpty()) { + System.out.println("[DEBUG] 清理客服 " + serviceId + " 的已接受转人工记录,数量: " + acceptedRequests.size()); + + // 将状态更新为已关闭 + for (ManualServiceSessions request : acceptedRequests) { + request.setStatus("3"); // 3-已关闭(WebSocket断开) + request.setEndTime(DateUtils.getNowDate()); + request.setUpdateTime(DateUtils.getNowDate()); + manualServiceSessionsMapper.updateManualServiceSessions(request); + } + + return acceptedRequests.size(); + } + + return 0; + } } \ No newline at end of file