/* 碰盏日记 - WebSocket 管理器 * 单例模式,负责私信实时通信 * 基于 HaveADrink SDK 的 ChatWebSocket(ChatWebSocketConn)实现: * - 底层 socket 通过 UniAppWebSocket 适配器注入(common/uni-websocket.js) * - 连接地址、token query、指数退避重连均由 SDK 管理 * - 消息协议:{ mesgType, data } * 对外保持 on/off/send/connect/disconnect/userId 接口,页面层无感知 */ import client, { API_HOST } from './api' // SDK 具名导出的连接类(绕开 client.ChatWebSocket 方法内部使用的浏览器 API) import { ChatWebSocketConn } from 'HaveADrink' /** * 自行解析 host 并构造 ChatWebSocketConn * SDK 的 client.ChatWebSocket() 内部使用 new URL() / new URLSearchParams(), * 微信小程序沙箱中裸标识符 URL 解析到不可构造的空壳,全局 polyfill 无法生效, * 因此这里手动完成同样的逻辑:https -> wss,query 键值对拼接 * @param {object} input - { token } * @returns {ChatWebSocketConn} */ function createChatConn(input) { const m = String(API_HOST).match(/^(https?|wss?):\/\/([^/?#\s]+)/i) const scheme = m && m[1].toLowerCase() === 'https' ? 'wss' : 'ws' const host = `${scheme}://${m ? m[2] : API_HOST}` const query = Object.keys(input || {}) .filter((k) => input[k] !== '' && input[k] !== null && input[k] !== undefined) .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(input[k])}`) .join('&') return new ChatWebSocketConn(host, query) } // 客户端消息类型(与 SDK ClientMesgType 枚举值一致) const ClientMesgType = { Chat: 'chat', Typing: 'typing', Read: 'read', Ping: 'ping' } class WebSocketManager { constructor() { this.conn = null // SDK ChatWebSocketConn 实例 this.isConnected = false this.isConnecting = false this.listeners = {} // 事件订阅 { event: [callbacks] } this.messageQueue = [] // 断线期间暂存的消息 { mesgType, data } this.heartbeatTimer = null this.heartbeatInterval = 30000 // 30s 发一次 ping this.manualClose = false this.token = '' this.userId = '' // 服务端 connected 事件返回的用户ID } /** * 建立 WebSocket 连接(走 SDK ChatWebSocket) * @param {string} token - 用户认证 token */ connect(token) { if (this.conn || this.isConnecting) return this.token = token || uni.getStorageSync('auth_token') || '' if (!this.token) { console.warn('[WS] 无 token,跳过连接') return } this.manualClose = false this.isConnecting = true console.log('[WS] 正在连接(SDK ChatWebSocket)...') // SDK 内部:new websocket(`${host}/api/have_a_drink/v1/chat/chat?token=xxx`) // websocket 即注入的 UniAppWebSocket 适配器,重连由 SDK 指数退避管理 // 注意:不用 client.ChatWebSocket()(其内部 new URL 在小程序沙箱不可用), // 直接用 SDK 导出的 ChatWebSocketConn 构造,等价实现 const conn = createChatConn({ token: this.token }) conn.onopen = () => { console.log('[WS] 连接成功') this.isConnected = true this.isConnecting = false this._startHeartbeat() this._flushQueue() this._emit('connect', {}) } conn.onclose = () => { console.log('[WS] 连接关闭') this.isConnected = false this.isConnecting = false this._stopHeartbeat() this._emit('disconnect', {}) // 非主动关闭时,SDK 已自动安排指数退避重连,无需手动处理 } conn.onerror = (err) => { console.warn('[WS] 连接错误', err) this.isConnecting = false } // ===== 服务端协议消息(SDK 已解析 { mesgType, data }) ===== conn.onServerChatData = (data) => { this._emit('message', data) } conn.onServerTypingData = (data) => { this._emit('typing', data) } conn.onServerReadData = (data) => { this._emit('read', data) } conn.onServerAckData = (data) => { this._emit('ack', data) } conn.onServerConnectedData = (data) => { // 服务端验证 token 成功后推送,携带 userId this.userId = data && data.userId ? data.userId : '' console.log('[WS] 服务端确认连接, userId:', this.userId) this._emit('connected', data) } conn.onServerKickedData = (data) => { console.warn('[WS] 被踢下线') this._emit('kicked', data) this.disconnect() } this.conn = conn } /** 主动断开连接(SDK close() 会关闭重连标志并断开 socket) */ disconnect() { this.manualClose = true this._stopHeartbeat() if (this.conn) { this.conn.close() this.conn = null } this.isConnected = false this.isConnecting = false } /** * 发送消息(与 SDK writeClient* 方法格式一致) * @param {string} mesgType - 消息类型: chat | typing | read | ping * @param {object} data - 消息数据 */ send(mesgType, data = {}) { if (this.isConnected && this.conn) { this._write(mesgType, data) } else { // 断线暂存队列(ping 不暂存) if (mesgType !== ClientMesgType.Ping) { this.messageQueue.push({ mesgType, data }) } // 未连接时触发(重)连接 if (!this.conn && !this.manualClose) { this.connect(this.token) } } } /** * 订阅事件 * @param {string} event - message | typing | read | ack | connected | kicked | connect | disconnect * @param {function} callback */ on(event, callback) { if (!this.listeners[event]) { this.listeners[event] = [] } this.listeners[event].push(callback) } /** * 取消订阅 * @param {string} event * @param {function} callback */ off(event, callback) { if (!this.listeners[event]) return if (callback) { this.listeners[event] = this.listeners[event].filter(cb => cb !== callback) } else { delete this.listeners[event] } } // ========== 内部方法 ========== /** 通过 SDK writeClient* 方法写入消息 */ _write(mesgType, data) { try { switch (mesgType) { case ClientMesgType.Chat: this.conn.writeClientChatData(data) break case ClientMesgType.Typing: this.conn.writeClientTypingData(data) break case ClientMesgType.Read: this.conn.writeClientReadData(data) break case ClientMesgType.Ping: this.conn.writeClientPingData(data) break default: console.warn('[WS] 未知发送类型', mesgType) } } catch (e) { console.warn('[WS] 发送异常', e) // 发送失败时暂存,待重连后补发 if (mesgType !== ClientMesgType.Ping) { this.messageQueue.push({ mesgType, data }) } } } /** 触发事件 */ _emit(event, data) { const cbs = this.listeners[event] if (cbs) { cbs.forEach(cb => { try { cb(data) } catch (e) { console.warn('[WS] 事件回调异常', e) } }) } } /** 启动心跳(保活,防运营商/小程序后台掐连接) */ _startHeartbeat() { this._stopHeartbeat() this.heartbeatTimer = setInterval(() => { this.send(ClientMesgType.Ping, {}) }, this.heartbeatInterval) } /** 停止心跳 */ _stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer) this.heartbeatTimer = null } } /** 重连成功后重发队列消息 */ _flushQueue() { if (!this.messageQueue.length) return console.log(`[WS] 重发 ${this.messageQueue.length} 条暂存消息`) const queue = [...this.messageQueue] this.messageQueue = [] queue.forEach(({ mesgType, data }) => this._write(mesgType, data)) } } // 导出全局单例 const wsManager = new WebSocketManager() export default wsManager