Files
EchoChat/frontend/src/services/websocket.js
2026-06-04 14:32:52 +08:00

435 lines
13 KiB
JavaScript
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.

/**
* 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 页面用 wssHTTP 页面用 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
}
/**
* 发送消息并等待服务端 ACKTask 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 9ACK 报文event 以 ".ack" 结尾)会先走 pendingAcks 处理
// 后端 ws.NewResponse 固定在原 event 后追加 ".ack"
//
// 修复2026-04-23历史实现在命中 _handleAck 后直接 return
// 导致通过 on('xxx.ack') 订阅 ACK 事件的 listenerchat 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