Files
wecom_it_smart_desk/frontend-h5/src/composables/useH5WebSocket.ts
T

375 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================
// 企微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<typeof setInterval> | null = null
/** 重连定时器 ID */
let reconnectTimer: ReturnType<typeof setTimeout> | 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,
}
}