375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
// =============================================================================
|
||
// 企微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,
|
||
}
|
||
}
|