// ============================================================================= // 企微IT智能服务台 — H5用户端 WebSocket 组合式函数 // ============================================================================= // 说明:封装 H5 员工端的 WebSocket 连接管理,提供: // 1. 自动连接 + 断线重连(指数退避,最大 30 秒) // 2. 心跳保活(每 30 秒发送 ping) // 3. 事件分发:收到消息后根据 type 调用对应 store 方法 // 4. 降级策略:WS 断连时自动启动轮询 fallback,WS 重连后自动停止轮询 // // 与坐席端 useWebSocket 的区别: // - 连接端点不同:/ws/h5/{employee_id}(坐席端用 /ws/{agent_id}) // - 认证方式不同:使用 employee token(坐席端用 agent token) // - 事件处理不同:重点关注参与者变更事件(坐席端关注所有事件) // - 无 typing 发送能力(H5员工不需要发送 typing 指示器) // // 使用方式: // const { connect, disconnect } = useH5WebSocket() // onMounted(() => connect()) // onUnmounted(() => disconnect()) // ============================================================================= import { useConversationStore } from '@/stores/conversation' import { useEmployeeStore } from '@/stores/employee' // -------------------------------------------------------------------------- // 常量配置 // -------------------------------------------------------------------------- /** 心跳间隔(毫秒):每 30 秒发送一次 ping,保持连接存活 */ const HEARTBEAT_INTERVAL = 30000 /** 最大重连延迟(毫秒):指数退避上限 30 秒 */ const MAX_RECONNECT_DELAY = 30000 /** 重连延迟基数(毫秒):首次重连等待 1 秒 */ const RECONNECT_BASE_DELAY = 1000 /** * H5员工端 WebSocket 组合式函数 * * 核心职责: * - 管理 WebSocket 连接的生命周期(建立、维持、断开、重连) * - 处理服务端推送的实时事件,分发到对应的 store * - 实现 WS → 轮询的自动降级和恢复 * * 为什么用组合式函数(composable): * - 遵循 Vue3 的组合式 API 模式,与组件生命周期绑定 * - 与坐席端 useWebSocket 保持一致的架构风格 */ export function useH5WebSocket() { // ========================================================================== // 内部状态 // ========================================================================== /** WebSocket 实例 */ let ws: WebSocket | null = null /** 心跳定时器 ID */ let heartbeatTimer: ReturnType | null = null /** 重连定时器 ID */ let reconnectTimer: ReturnType | null = null /** 重连尝试次数(用于指数退避计算) */ let reconnectAttempts = 0 /** 是否主动断开(用户登出时设为 true,避免自动重连) */ let intentionalDisconnect = false // ========================================================================== // 连接管理 // ========================================================================== /** * 建立 H5 员工 WebSocket 连接 * * 做什么:根据当前员工ID和token构建 WS URL,建立连接,注册事件处理函数 * 为什么:H5员工需要实时接收参与者变更事件(新参与者加入、有人退出等) * * 连接 URL 格式: * - 开发环境:ws://localhost:8000/ws/h5/{employeeId}?token=xxx * - 生产环境:wss://domain.com/ws/h5/{employeeId}?token=xxx */ function connect(): void { const employeeStore = useEmployeeStore() const employeeId = employeeStore.employeeId const token = employeeStore.token // 如果没有员工ID或token,说明未登录,不建立连接 if (!employeeId || !token) { console.warn('[H5 WS] 未登录或缺少token,跳过连接') return } // 如果已有连接,先断开 if (ws) { disconnect() } // 重置主动断开标记 intentionalDisconnect = false // 构建 WebSocket URL // 开发环境:直接连后端 8000 端口(与坐席端一致) // 生产环境:通过同源 wss:// 连接(nginx 统一代理) const isDev = import.meta.env.DEV const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const wsHost = isDev ? 'localhost:8000' : window.location.host const wsUrl = `${wsProtocol}//${wsHost}/ws/h5/${employeeId}` console.log(`[H5 WS] 正在连接: ${wsUrl}`) // ISS-B2 修复: 使用 WebSocket subprotocol 传递 token(与坐席端一致) // 浏览器原生 WebSocket API 第2参数是 protocols,服务端从 sec-websocket-protocol 头读取 bearer.{token} ws = new WebSocket(wsUrl, [`bearer.${token}`]) // ---------------------------------------------------------------------- // 连接成功 // ---------------------------------------------------------------------- ws.onopen = () => { console.log('[H5 WS] 连接成功') // 重置重连计数 reconnectAttempts = 0 // 启动心跳 startHeartbeat() // WS 已连接,停止轮询 fallback // 为什么:WS 连接正常时不需要轮询,减少不必要的 HTTP 请求 const store = useConversationStore() store.stopPolling() } // ---------------------------------------------------------------------- // 收到消息 // ---------------------------------------------------------------------- ws.onmessage = (event: MessageEvent) => { try { const msg = JSON.parse(event.data) handleMessage(msg) } catch (error) { console.error('[H5 WS] 消息解析失败:', error) } } // ---------------------------------------------------------------------- // 连接关闭 // ---------------------------------------------------------------------- ws.onclose = () => { console.log('[H5 WS] 连接关闭') // 停止心跳 stopHeartbeat() // 清空 ws 引用 ws = null // 如果不是主动断开,启动降级和重连 if (!intentionalDisconnect) { // WS 断连,启动轮询 fallback // 为什么:WS 不可用时,仍需通过轮询获取最新数据 const store = useConversationStore() store.startPolling() // 尝试重连 scheduleReconnect() } } // ---------------------------------------------------------------------- // 连接错误 // ---------------------------------------------------------------------- ws.onerror = (error: Event) => { console.error('[H5 WS] 连接错误:', error) // onclose 会自动触发,这里不需要额外处理 } } /** * 主动断开 WebSocket 连接 * * 做什么:关闭 WS 连接,清理定时器,标记为主动断开 * 为什么:员工登出时需要主动断开,避免后台重连 */ function disconnect(): void { // 标记为主动断开,阻止自动重连 intentionalDisconnect = true // 清理重连定时器 if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } // 清理心跳定时器 stopHeartbeat() // 关闭 WebSocket 连接 if (ws) { ws.close() ws = null } // 重置重连计数 reconnectAttempts = 0 console.log('[H5 WS] 已主动断开连接') } // ========================================================================== // 心跳保活 // ========================================================================== /** * 启动心跳定时器 * * 做什么:每 HEARTBEAT_INTERVAL 毫秒发送一次 ping 消息 * 为什么:防止中间代理(Nginx、CDN 等)因空闲超时断开 WebSocket 连接 */ function startHeartbeat(): void { // 先清理旧定时器(避免重复) stopHeartbeat() heartbeatTimer = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })) } }, HEARTBEAT_INTERVAL) } /** * 停止心跳定时器 */ function stopHeartbeat(): void { if (heartbeatTimer) { clearInterval(heartbeatTimer) heartbeatTimer = null } } // ========================================================================== // 断线重连(指数退避) // ========================================================================== /** * 安排重连 * * 做什么:根据指数退避算法计算延迟,安排下一次重连 * 为什么:避免 WS 断连后所有客户端同时重连导致服务器压力过大 * * 指数退避公式:delay = min(base * 2^attempts, maxDelay) * 第1次重连:1秒后 * 第2次重连:2秒后 * 第3次重连:4秒后 * 第4次及以后:8秒、16秒、30秒(达到上限) */ function scheduleReconnect(): void { // 如果已主动断开,不重连 if (intentionalDisconnect) return // 计算延迟(指数退避) const delay = Math.min( RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY ) reconnectAttempts++ console.log( `[H5 WS] 将在 ${delay / 1000} 秒后重连(第 ${reconnectAttempts} 次)` ) // 清理旧的重连定时器 if (reconnectTimer) { clearTimeout(reconnectTimer) } // 安排重连 reconnectTimer = setTimeout(() => { console.log('[H5 WS] 正在重连...') connect() }, delay) } // ========================================================================== // 消息处理(事件分发) // ========================================================================== /** * 处理从 WebSocket 收到的消息 * * 做什么:根据消息的 type 字段,调用对应的 store 方法处理 * 为什么:不同类型的事件需要不同的处理逻辑 * * H5员工端关注的事件: * - participant_invited: 新参与者被邀请 — 刷新参与者列表 * - participant_joined: 参与者加入 — 刷新参与者列表 * - participant_removed: 参与者被移除 — 刷新参与者列表 * - participant_left: 参与者退出 — 刷新参与者列表 * - new_message: 新消息 — 追加到消息列表 * - pong: 心跳响应,忽略 * * @param msg - WebSocket 消息对象,包含 type 和 data 字段 */ function handleMessage(msg: { type: string; data?: any }): void { const store = useConversationStore() switch (msg.type) { // ================================================================== // 参与者变更事件(邀请功能核心) // ================================================================== case 'participant_invited': // 参与者被邀请:实时刷新参与者列表 // 做什么:用 WS 推送的 participants 数据直接更新 store // 为什么:比等3秒轮询更实时,被邀请人能看到最新的参与者状态 if (msg.data?.participants) { store.updateParticipants(msg.data.participants) } break case 'participant_joined': // 参与者加入:实时刷新参与者列表 if (msg.data?.participants) { store.updateParticipants(msg.data.participants) } break case 'participant_removed': // 参与者被移除:实时刷新参与者列表 // 特殊:如果被移除的是当前用户,需要退出会话视图 if (msg.data?.participants) { store.updateParticipants(msg.data.participants) } // 检查是否当前用户被移除 if (msg.data?.changed) { const employeeStore = useEmployeeStore() const removedIds = msg.data.changed.map((p: any) => p.id) if (removedIds.includes(employeeStore.employeeId)) { console.log('[H5 WS] 当前用户被移除会话') store.handleRemovedFromConversation() } } break case 'participant_left': // 参与者主动退出:实时刷新参与者列表 if (msg.data?.participants) { store.updateParticipants(msg.data.participants) } break // ================================================================== // 新消息事件 // ================================================================== case 'new_message': // 新消息事件:追加到消息列表 // 做什么:检查消息是否属于当前会话,是则追加到消息列表 // 为什么:比3秒轮询更实时,坐席/其他参与者的回复立即可见 if (msg.data) { store.handleNewMessage(msg.data) } break case 'pong': // 心跳响应,不需要处理 break default: console.warn(`[H5 WS] 未知消息类型: ${msg.type}`) } } // ========================================================================== // 返回 // ========================================================================== return { /** 建立 WebSocket 连接 */ connect, /** 主动断开 WebSocket 连接(登出时调用) */ disconnect, } }