435 lines
13 KiB
JavaScript
435 lines
13 KiB
JavaScript
/**
|
||
* WebSocket 连接管理服务
|
||
*
|
||
* 单例模式,管理与后端的 WebSocket 长连接,提供:
|
||
* - JWT Token 认证连接
|
||
* - 心跳保活(30s 间隔)
|
||
* - 断线自动重连(指数退避 1s→2s→4s→8s→30s max)
|
||
* - 事件分发:on(event, callback) / off(event, callback)
|
||
*
|
||
* 对应后端:GET /ws?token=xxx
|
||
*/
|
||
|
||
import { BASE_URL } from '@/utils/request'
|
||
import { getToken } from '@/utils/storage'
|
||
|
||
// WebSocket 基础地址推导:
|
||
// - 若 BASE_URL 非空(生产 CDN 直连场景),将其协议从 http(s) 替换成 ws(s)
|
||
// - 若 BASE_URL 为空(开发 vite proxy / 生产同源 Nginx),用当前页面 origin 推导同源 ws(s)
|
||
// 这样 WebSocket 天然随页面协议走:HTTPS 页面用 wss,HTTP 页面用 ws,避免混合内容被浏览器阻断
|
||
const resolveWsBase = () => {
|
||
if (BASE_URL) return BASE_URL.replace(/^http/, 'ws')
|
||
// #ifdef H5
|
||
if (typeof window !== 'undefined' && window.location) {
|
||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||
return `${wsProto}//${window.location.host}`
|
||
}
|
||
// #endif
|
||
return ''
|
||
}
|
||
const WS_BASE = resolveWsBase()
|
||
const HEARTBEAT_INTERVAL = 30000
|
||
const RECONNECT_BASE_DELAY = 1000
|
||
const RECONNECT_MAX_DELAY = 30000
|
||
const MAX_RECONNECT_ATTEMPTS = Infinity
|
||
const ACK_DEFAULT_TIMEOUT_MS = 10000
|
||
/** 异常重连检测:10s 窗口内 >=3 次断连即认为异常抖动(常见于同账号多 tab 互踢) */
|
||
const FLAPPING_WINDOW_MS = 10000
|
||
const FLAPPING_THRESHOLD = 3
|
||
/** 两次 flapping 警告之间最小间隔,避免持续抖动时每秒触发 */
|
||
const FLAPPING_NOTIFY_COOLDOWN_MS = 15000
|
||
|
||
class WebSocketService {
|
||
constructor() {
|
||
this.ws = null
|
||
this.listeners = new Map()
|
||
this.heartbeatTimer = null
|
||
this.reconnectTimer = null
|
||
this.reconnectAttempts = 0
|
||
this.isManualClose = false
|
||
this.seq = 0
|
||
/**
|
||
* 等待 ACK 的请求索引:Map<seq, { resolve, reject, timer, event }>
|
||
* Task 9 引入,为 meeting.* 事件提供 Promise 化 ACK 等待
|
||
*/
|
||
this.pendingAcks = new Map()
|
||
/** 近期断连时间戳数组,用于 flapping 检测 */
|
||
this.recentCloseAt = []
|
||
/** 上次 flapping 警告时间,用于冷却 */
|
||
this.lastFlappingNotifyAt = 0
|
||
}
|
||
|
||
/**
|
||
* 建立 WebSocket 连接
|
||
* @param {string} [token] - JWT Token,不传则从本地存储获取
|
||
*/
|
||
connect(token) {
|
||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||
return
|
||
}
|
||
|
||
const accessToken = token || getToken()
|
||
if (!accessToken) {
|
||
console.warn('[WS] 无 Token,无法连接')
|
||
return
|
||
}
|
||
|
||
this.isManualClose = false
|
||
const url = `${WS_BASE}/ws?token=${accessToken}`
|
||
|
||
try {
|
||
// #ifdef H5
|
||
this.ws = new WebSocket(url)
|
||
// #endif
|
||
|
||
// #ifndef H5
|
||
this.ws = uni.connectSocket({
|
||
url,
|
||
complete: () => {}
|
||
})
|
||
// #endif
|
||
|
||
this._bindEvents()
|
||
} catch (err) {
|
||
console.error('[WS] 连接创建失败', err)
|
||
this._scheduleReconnect()
|
||
}
|
||
}
|
||
|
||
/** 主动断开连接 */
|
||
disconnect() {
|
||
this.isManualClose = true
|
||
this._clearTimers()
|
||
if (this.ws) {
|
||
// #ifdef H5
|
||
this.ws.close()
|
||
// #endif
|
||
// #ifndef H5
|
||
uni.closeSocket()
|
||
// #endif
|
||
this.ws = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送消息
|
||
* @param {string} event - 事件类型
|
||
* @param {Object} [data] - 业务数据
|
||
* @returns {number} 消息序号
|
||
*/
|
||
send(event, data = {}) {
|
||
// #ifdef H5
|
||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||
console.warn('[WS] 连接未就绪,无法发送')
|
||
return -1
|
||
}
|
||
// #endif
|
||
// #ifndef H5
|
||
if (!this.ws) {
|
||
console.warn('[WS] 连接未就绪,无法发送')
|
||
return -1
|
||
}
|
||
// #endif
|
||
|
||
this.seq++
|
||
const msg = JSON.stringify({
|
||
event,
|
||
seq: this.seq,
|
||
data,
|
||
time: new Date().toISOString()
|
||
})
|
||
|
||
// #ifdef H5
|
||
this.ws.send(msg)
|
||
// #endif
|
||
// #ifndef H5
|
||
uni.sendSocketMessage({ data: msg })
|
||
// #endif
|
||
|
||
return this.seq
|
||
}
|
||
|
||
/**
|
||
* 发送消息并等待服务端 ACK(Task 9 引入)
|
||
*
|
||
* 服务端对 C→S 事件以相同 event 名 + 对应 seq 返回 ACK 报文:
|
||
* { event, seq, data: { code, message, data? } }
|
||
*
|
||
* 约定:
|
||
* - code === 0 表示成功,Promise resolve 为 data.data(业务 payload)
|
||
* - code !== 0 表示业务失败,Promise reject 为 { code, message }
|
||
* - 超时 reject 为 { code: -1, message: 'ACK 超时' }
|
||
* - 连接未就绪 reject 为 { code: -1, message: '连接未就绪' }
|
||
*
|
||
* 典型用法:
|
||
* const info = await wsService.sendWithAck('meeting.transport.create', { room_code, direction: 'send' })
|
||
*
|
||
* @param {string} event - 事件名
|
||
* @param {Object} [data] - 业务 payload
|
||
* @param {number} [timeoutMS=10000] - 超时时间
|
||
* @returns {Promise<any>} ACK 的 data 字段
|
||
*/
|
||
sendWithAck(event, data = {}, timeoutMS = ACK_DEFAULT_TIMEOUT_MS) {
|
||
return new Promise((resolve, reject) => {
|
||
const seq = this.send(event, data)
|
||
if (seq === -1) {
|
||
reject({ code: -1, message: '连接未就绪' })
|
||
return
|
||
}
|
||
const timer = setTimeout(() => {
|
||
this.pendingAcks.delete(seq)
|
||
reject({ code: -1, message: `ACK 超时 (${event})` })
|
||
}, timeoutMS)
|
||
this.pendingAcks.set(seq, { resolve, reject, timer, event })
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 当前连接是否处于可发送状态(H5 下要求 readyState === OPEN)
|
||
* 与 send() 的就绪判断保持一致
|
||
* @returns {boolean}
|
||
*/
|
||
isConnected() {
|
||
// #ifdef H5
|
||
return !!(this.ws && this.ws.readyState === WebSocket.OPEN)
|
||
// #endif
|
||
// #ifndef H5
|
||
return !!this.ws
|
||
// #endif
|
||
}
|
||
|
||
/**
|
||
* 等待 WebSocket 进入可用(OPEN)状态
|
||
*
|
||
* 场景:SSO / web-view 登录后 connect() 为异步建链(CONNECTING→OPEN)。
|
||
* 设备预览结束立即入会时,首个 sendWithAck 可能早于 socket OPEN,
|
||
* 导致 send() 返回 -1 → 业务侧抛 "连接未就绪"。
|
||
* 本方法在未连接时主动发起一次连接(connect 对 OPEN/CONNECTING 幂等),
|
||
* 并等待 _connected 事件;已连接则立即 resolve;超时 reject。
|
||
*
|
||
* @param {number} [timeoutMS=10000] - 最长等待时间
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
waitForConnected(timeoutMS = 10000) {
|
||
if (this.isConnected()) return Promise.resolve(true)
|
||
// 未建链 / 已断开:主动发起一次连接(幂等,不会重复建链)
|
||
this.connect()
|
||
return new Promise((resolve, reject) => {
|
||
let settled = false
|
||
let timer = null
|
||
let poll = null
|
||
const cleanup = () => {
|
||
this.off('_connected', onConnected)
|
||
if (timer) clearTimeout(timer)
|
||
if (poll) clearInterval(poll)
|
||
}
|
||
const onConnected = () => {
|
||
if (settled) return
|
||
settled = true
|
||
cleanup()
|
||
resolve(true)
|
||
}
|
||
timer = setTimeout(() => {
|
||
if (settled) return
|
||
settled = true
|
||
cleanup()
|
||
reject({ code: -1, message: '连接会议服务器超时' })
|
||
}, timeoutMS)
|
||
// 轮询兜底:防止 _onOpen 在 listener 注册前已触发而错过事件
|
||
poll = setInterval(() => {
|
||
if (this.isConnected()) onConnected()
|
||
}, 100)
|
||
this.on('_connected', onConnected)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 注册事件监听
|
||
* @param {string} event - 事件类型
|
||
* @param {Function} callback - 回调函数
|
||
*/
|
||
on(event, callback) {
|
||
if (!this.listeners.has(event)) {
|
||
this.listeners.set(event, new Set())
|
||
}
|
||
this.listeners.get(event).add(callback)
|
||
}
|
||
|
||
/**
|
||
* 移除事件监听
|
||
* @param {string} event - 事件类型
|
||
* @param {Function} callback - 回调函数
|
||
*/
|
||
off(event, callback) {
|
||
const cbs = this.listeners.get(event)
|
||
if (cbs) {
|
||
cbs.delete(callback)
|
||
}
|
||
}
|
||
|
||
/** 绑定 WebSocket 事件处理 */
|
||
_bindEvents() {
|
||
// #ifdef H5
|
||
this.ws.onopen = () => this._onOpen()
|
||
this.ws.onclose = (e) => this._onClose(e)
|
||
this.ws.onerror = (e) => this._onError(e)
|
||
this.ws.onmessage = (e) => this._onMessage(e.data)
|
||
// #endif
|
||
|
||
// #ifndef H5
|
||
uni.onSocketOpen(() => this._onOpen())
|
||
uni.onSocketClose((e) => this._onClose(e))
|
||
uni.onSocketError((e) => this._onError(e))
|
||
uni.onSocketMessage((e) => this._onMessage(e.data))
|
||
// #endif
|
||
}
|
||
|
||
_onOpen() {
|
||
console.log('[WS] 连接成功')
|
||
this.reconnectAttempts = 0
|
||
this._startHeartbeat()
|
||
this._emit('_connected')
|
||
}
|
||
|
||
_onClose(e) {
|
||
console.log('[WS] 连接关闭', e)
|
||
this._clearTimers()
|
||
this._rejectAllPendingAcks('连接已断开')
|
||
this._emit('_disconnected', e)
|
||
|
||
if (!this.isManualClose) {
|
||
this._recordCloseForFlapping()
|
||
this._scheduleReconnect()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 记录一次非主动断连,并在短窗口内多次断连时触发 _flapping 事件
|
||
* 典型场景:同账号多端登录互踢导致 WS 每秒被后端关闭
|
||
*/
|
||
_recordCloseForFlapping() {
|
||
const now = Date.now()
|
||
this.recentCloseAt.push(now)
|
||
// 丢掉窗口外的历史记录
|
||
this.recentCloseAt = this.recentCloseAt.filter((t) => now - t <= FLAPPING_WINDOW_MS)
|
||
if (this.recentCloseAt.length < FLAPPING_THRESHOLD) return
|
||
if (now - this.lastFlappingNotifyAt < FLAPPING_NOTIFY_COOLDOWN_MS) return
|
||
this.lastFlappingNotifyAt = now
|
||
const count = this.recentCloseAt.length
|
||
console.warn(`[WS] 检测到异常重连抖动:${FLAPPING_WINDOW_MS / 1000}s 内断开 ${count} 次,可能同账号在其他 tab/设备登录`)
|
||
this._emit('_flapping', { count, windowMs: FLAPPING_WINDOW_MS })
|
||
}
|
||
|
||
/**
|
||
* 连接断开时统一拒绝所有未完成的 ACK 等待(Task 9)
|
||
* 避免业务层 await sendWithAck() 永久挂起
|
||
*/
|
||
_rejectAllPendingAcks(reason) {
|
||
if (this.pendingAcks.size === 0) return
|
||
this.pendingAcks.forEach((pending, seq) => {
|
||
clearTimeout(pending.timer)
|
||
pending.reject({ code: -1, message: reason, event: pending.event })
|
||
})
|
||
this.pendingAcks.clear()
|
||
}
|
||
|
||
_onError(e) {
|
||
console.error('[WS] 连接错误', e)
|
||
}
|
||
|
||
_onMessage(data) {
|
||
try {
|
||
const msg = JSON.parse(data)
|
||
// Task 9:ACK 报文(event 以 ".ack" 结尾)会先走 pendingAcks 处理
|
||
// 后端 ws.NewResponse 固定在原 event 后追加 ".ack"
|
||
//
|
||
// 修复(2026-04-23):历史实现在命中 _handleAck 后直接 return,
|
||
// 导致通过 on('xxx.ack') 订阅 ACK 事件的 listener(chat store 的
|
||
// _onSendACK / _onReadACK)永远收不到回调,表现为
|
||
// "发送方消息一直卡在 _sending=true,气泡下方不显示 已读/未读"。
|
||
// 两条通路互不冲突:sendWithAck() 按 seq 命中 pending;
|
||
// on('xxx.ack') 按 event 命中 listener。此处改为并行分发。
|
||
if (typeof msg.event === 'string' && msg.event.endsWith('.ack')) {
|
||
this._handleAck(msg)
|
||
}
|
||
this._emit(msg.event, msg)
|
||
} catch (e) {
|
||
console.warn('[WS] 消息解析失败', data)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理 ACK 报文,触发 pendingAcks 中对应 seq 的 resolve/reject
|
||
* @param {Object} msg - { event: 'xxx.ack', seq, code, message, data? }
|
||
*/
|
||
_handleAck(msg) {
|
||
const pending = this.pendingAcks.get(msg.seq)
|
||
if (!pending) {
|
||
return
|
||
}
|
||
this.pendingAcks.delete(msg.seq)
|
||
clearTimeout(pending.timer)
|
||
if (msg.code === 0) {
|
||
pending.resolve(msg.data)
|
||
} else {
|
||
pending.reject({ code: msg.code, message: msg.message || 'WS 请求失败', event: pending.event })
|
||
}
|
||
}
|
||
|
||
/** 分发事件给注册的监听器 */
|
||
_emit(event, data) {
|
||
const cbs = this.listeners.get(event)
|
||
if (cbs) {
|
||
cbs.forEach(cb => {
|
||
try {
|
||
cb(data)
|
||
} catch (e) {
|
||
console.error(`[WS] 事件处理器异常: ${event}`, e)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/** 启动心跳 */
|
||
_startHeartbeat() {
|
||
this._clearTimers()
|
||
this.heartbeatTimer = setInterval(() => {
|
||
this.send('heartbeat')
|
||
}, HEARTBEAT_INTERVAL)
|
||
}
|
||
|
||
/** 调度重连(指数退避) */
|
||
_scheduleReconnect() {
|
||
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||
console.error('[WS] 超过最大重连次数')
|
||
return
|
||
}
|
||
|
||
const delay = Math.min(
|
||
RECONNECT_BASE_DELAY * Math.pow(2, this.reconnectAttempts),
|
||
RECONNECT_MAX_DELAY
|
||
)
|
||
|
||
console.log(`[WS] ${delay}ms 后重连 (第 ${this.reconnectAttempts + 1} 次)`)
|
||
this.reconnectTimer = setTimeout(() => {
|
||
this.reconnectAttempts++
|
||
this.connect()
|
||
}, delay)
|
||
}
|
||
|
||
_clearTimers() {
|
||
if (this.heartbeatTimer) {
|
||
clearInterval(this.heartbeatTimer)
|
||
this.heartbeatTimer = null
|
||
}
|
||
if (this.reconnectTimer) {
|
||
clearTimeout(this.reconnectTimer)
|
||
this.reconnectTimer = null
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 全局单例 */
|
||
const wsService = new WebSocketService()
|
||
export default wsService
|