/** * df-relay WS 客户端(uni-app 跨端适配) * * 协议(对齐 crates/df-relay/src/relay.rs handle_connection): * 1. 建立 WS 连接到 `/ws/miniapp` * 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时) * 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close * * 跨端适配: * - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket) * - H5:uni.connectSocket 封装浏览器 WebSocket * - 统一经 uni API 封装,业务层不直接 new WebSocket * * 连接生命周期(P0/P1 审计后重构,根本性,非补丁): * - 所有"建连"经 reconnectNow() 幂等入口:clear 退避 timer + cleanup + openSocket * connect()/resumeIfDisconnected()/scheduleReconnect 回调全部经此,杜绝双连竞态 + socket 泄漏 * - openSocket() 幂等:首行 destroySocket 残留 socket,保证 this.socket 引用唯一 * - destroySocket(task):显式注销 onOpen/onMessage/onClose/onError(覆盖空函数,微信无 offXxx) * + close,防旧 socket 闭包回调污染新连接(单例 this 句柄被多 socket 捕获) * - cleanup() 彻底化:destroySocket + 停心跳 + 清所有 timer + 清 pendingQueue * 清队列防跨连接/跨会话陈旧命令补发(旧命令上下文已失效) * - 超时三重兜底:连接超时(onOpen 未到)/握手超时(hello_ack 丢失,>relay 10s)/ * onError 兜底(微信 socket onError 不保证 onClose 跟随,DNS/TLS/域名未加白场景) * - 心跳活性检测:lastInboundAt + 2x 间隔判定半开死连接(NAT 超时/网络切换) * - pendingQueue 上限 + TTL:防握手永卡时无限堆积内存泄漏 * * 重连:指数退避(baseDelay * 2^min(n,6),封顶 maxDelay,带随机抖动) * 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态 * JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON * 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365) */ import { getConfig } from '@/config' import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay' /** 调试日志门控(P1 走查:生产 console.log 过微信 bridge,热路径成本)。dev=true / build=false。 */ const DEBUG = import.meta.env.DEV /** WS 连接状态 */ export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting' /** 入站事件回调(Event/Control 消息分派) */ export interface WsHandlers { /** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */ onEvent: (msg: BroadcastMessage) => void /** 收到 Control 消息(心跳响应 / 在线状态等) */ onControl: (msg: ControlMessage) => void /** 状态变更(连接 / 断开 / 重连中) */ onStatus: (status: WsStatus, detail?: string) => void } /** pendingQueue 带入队时间戳,flush 时按 TTL 丢弃陈旧命令 */ interface QueuedCmd { cmd: MiniCommand ts: number } /** 握手超时(>relay.rs:321 服务端 10s,留余量) */ const HANDSHAKE_TIMEOUT_MS = 12000 /** 连接超时(openSocket 后 onOpen 未到则重连;治 onError 不触发 onClose 场景) */ const CONNECT_TIMEOUT_MS = 8000 /** pendingQueue 上限(防握手永卡时无限堆积内存泄漏) */ const PENDING_MAX = 50 /** pendingQueue 单条 TTL(flush 时超时丢弃,防跨连接陈旧命令补发) */ const PENDING_TTL_MS = 60000 /** 心跳活性判定因子:超过 N 倍心跳间隔无入站消息判半开死连接 */ const HEARTBEAT_DEAD_FACTOR = 2 /** 最大重连次数(永久性故障如 relayHost 错/中继下线,停止无限退避,提示用户检查) */ const MAX_RECONNECT_ATTEMPTS = 20 /** * WS 客户端单例(miniapp 全局一个连接) * * 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。 * 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。 */ class WsClient { private socket: UniApp.SocketTask | null = null private handlers: WsHandlers | null = null private status: WsStatus = 'disconnected' /** 握手是否已完成(收到首个广播或握手成功信号) */ private handshaked = false private heartbeatTimer: ReturnType | null = null private reconnectTimer: ReturnType | null = null /** 握手超时定时器(handleOpen 发 Hello 后启,收 hello_ack 后清) */ private handshakeTimer: ReturnType | null = null /** 连接超时定时器(openSocket 启,onOpen 到达后清) */ private connectTimer: ReturnType | null = null private reconnectAttempts = 0 /** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */ private manualClose = false /** 可恢复标志:达上限 disconnected 时置 true,允许 connect/resumeIfDisconnected 重置重连 */ private recoverable = false /** * 握手完成前积压的命令队列(send 在 !handshaked 时不丢弃,入队等 hello_ack 后补发)。 * 治「握手成功到首帧竞态丢命令」:握手刚翻 true 时 relay 设备路由表可能未就绪, * 业务层快速重发会被 send 守卫放行后静默丢失;入队保证握手稳定后顺序补发。 * 带时间戳 + 上限 + TTL,防握手永卡时无限堆积(见 PENDING_MAX/PENDING_TTL_MS)。 */ private pendingQueue: QueuedCmd[] = [] /** 最后入站消息时间戳(心跳活性检测,relay 无 pong 故用任意入站消息替代) */ private lastInboundAt = 0 /** 订阅(单订阅,composable 注册一次) */ subscribe(h: WsHandlers): void { this.handlers = h } /** 读取当前状态 */ getStatus(): WsStatus { return this.status } /** 主动连接 */ connect(): void { if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') { return } this.manualClose = false this.recoverable = false // 用户主动连接复位重连计数(可达上限后的 disconnected 也允许手动重试) this.reconnectAttempts = 0 // 统一经 reconnectNow(幂等 clear timer + cleanup + openSocket),杜绝 reconnecting 态双连 this.reconnectNow() } /** 主动断开(用户手动,不重连) */ disconnect(): void { this.manualClose = true this.reconnectAttempts = 0 // cleanup 内部清 pendingQueue + 注销 socket + 清所有 timer this.cleanup() this.setStatus('disconnected', 'manual close') } /** * 若已断开或正在退避重连则立即重连(onShow 时调用,小程序后台回前台)。 * * 覆盖 `disconnected` 与 `reconnecting` 两种态: * - disconnected:onClose 已触发,直接重连; * - reconnecting:退避计时未到,抢占退避立即重连(治「切后台回前台卡在 reconnecting」头号症状)。 * * 统一经 reconnectNow(幂等清理),保证抢占时不残留旧 socket/timer。 */ resumeIfDisconnected(): void { if (this.manualClose) return // 已达上限 disconnected(recoverable=true)也允许重置计数并重连 if (this.status === 'disconnected' || this.status === 'reconnecting' || this.recoverable) { this.recoverable = false // 用户触发的立即重连(onShow 抢占退避)复位计数,允许达上限后手动恢复 this.reconnectAttempts = 0 this.reconnectNow() } } /** * 暂停后台定时器(小程序 onHide 调,P1-9):停心跳 setInterval。 * * 微信后台定时器节流但仍触发,心跳持续发 ping 浪费 + 后台无入站致半开检测误判。 * 不 disconnect(disconnect 清队列 + manualClose,不适合后台暂离),仅停心跳保留连接。 * 重连 setTimeout 若后台断连触发,单次非循环,影响可接受(MVP 不暂停)。 */ pauseBackground(): void { this.stopHeartbeat() } /** * 恢复前台(小程序 onShow 调):连接在则重启心跳,断则重连。 */ resumeBackground(): void { if (this.status === 'connected' && this.handshaked) { this.startHeartbeat() } else { this.resumeIfDisconnected() } } /** * 发送 Command(miniapp → device) * * 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成 * BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。 */ send(cmd: MiniCommand): boolean { if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked) if (!this.socket || !this.handshaked) { // 握手未完成不丢弃,入队待 hello_ack 后补发(治握手成功到首帧竞态丢命令)。 // 仅在未主动断开(manualClose=false)且连接尚有恢复预期时入队, // 否则(manualClose=true / 长期 disconnected)不堆积陈旧命令。 if (this.manualClose) { console.warn('[WsClient] 已主动断开,丢弃命令', cmd.cmd) return false } this.enqueuePending(cmd) return false } try { const text = JSON.stringify(cmd) this.socket.send({ data: text }) return true } catch (e) { console.error('[WsClient] send 失败', e) return false } } /** 入队(带上限 + 时间戳,TTL 丢弃见 flushPendingQueue) */ private enqueuePending(cmd: MiniCommand): void { if (this.pendingQueue.length >= PENDING_MAX) { // 超上限丢弃最旧,防握手永卡时无限堆积内存泄漏 this.pendingQueue.shift() console.warn('[WsClient] pendingQueue 超上限,丢弃最旧命令') } this.pendingQueue.push({ cmd, ts: Date.now() }) console.log('[WsClient] 握手未完成,命令入队待补发', cmd.cmd, 'queue=', this.pendingQueue.length) } /** * 补发握手期间积压的命令队列(收到 hello_ack 后调)。 * * list_conversations 已由握手逻辑自己发(不进队列),此处仅补发业务 send 积压。 * 补发前置 handshaked=true,send 守卫放行,逐条直发 socket。 * TTL 丢弃:入队超 PENDING_TTL_MS 的视为陈旧(上下文已失效),不补发。 */ private flushPendingQueue(): void { if (this.pendingQueue.length === 0) return const now = Date.now() console.log('[WsClient] 补发握手积压命令', this.pendingQueue.length) while (this.pendingQueue.length > 0) { const item = this.pendingQueue.shift()! if (now - item.ts > PENDING_TTL_MS) { console.warn('[WsClient] 补发时丢弃超时陈旧命令', item.cmd.cmd) continue } try { if (this.socket) { this.socket.send({ data: JSON.stringify(item.cmd) }) } } catch (e) { console.error('[WsClient] 补发命令失败', item.cmd.cmd, e) } } } // ─── 连接生命周期(统一入口 + 幂等清理) ─────────────────── /** * 幂等立即建连入口(clear 退避 timer + cleanup + openSocket)。 * * 所有需要"立即开新连接"的路径(connect/resumeIfDisconnected/scheduleReconnect 回调) * 统一经此,保证: * - 不残留挂起的 reconnectTimer(防退避 timer 与 openSocket 双触发建两个 socket); * - 旧 socket 被 cleanup 注销+关闭(防 this.socket 引用覆盖后句柄泄漏); * - pendingQueue 被清(防跨连接/跨会话陈旧命令补发)。 */ private reconnectNow(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } this.cleanup() this.openSocket() } private openSocket(): void { // 幂等:残留 socket 先注销+关闭(reconnectNow 已 cleanup,此为双保险) if (this.socket) { this.destroySocket(this.socket) this.socket = null } const cfg = getConfig() this.setStatus('connecting') console.log('[WsClient] 连接', cfg.relayHost) let task: UniApp.SocketTask try { task = uni.connectSocket({ url: cfg.relayHost, complete: () => { // complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是) }, }) } catch (e) { console.error('[WsClient] connectSocket 异常', e) this.scheduleReconnect() return } this.socket = task task.onOpen(() => this.handleOpen()) task.onMessage((res) => this.handleMessage(res)) task.onClose((res) => this.handleClose(res)) task.onError((res) => this.handleError(res)) // 连接超时:onOpen 未到则重连(治 onError 不触发 onClose 的场景: // DNS 解析失败/TLS 握手失败/relayHost 不可达/wss 域名未加白名单) this.connectTimer = setTimeout(() => { this.connectTimer = null if (this.status === 'connecting') { console.warn('[WsClient] 连接超时,onOpen 未到,重连') this.cleanup() this.scheduleReconnect() } }, CONNECT_TIMEOUT_MS) } /** WS 已建立连接 → 立即发 Hello 握手帧 + 启握手超时 */ private handleOpen(): void { // onOpen 到达,取消连接超时 if (this.connectTimer) { clearTimeout(this.connectTimer) this.connectTimer = null } this.setStatus('handshaking') const cfg = getConfig() const hello: Hello = { kind: 'miniapp', device_id: cfg.deviceId, token: cfg.token, } try { this.socket?.send({ data: JSON.stringify(hello) }) } catch (e) { console.error('[WsClient] 发送 Hello 失败', e) // 失败先 cleanup(关坏 socket + 清监听器)再退避,与 handleHandshakeResponse 失败路径对齐 this.cleanup() this.scheduleReconnect() return } // 握手超时:hello_ack 丢失(网络抖动/relay send_text 失败吞错 relay.rs:253-257)则重连。 // >relay.rs:321 服务端 10s,留余量。握手成功/失败时 handleHandshakeResponse 会清此 timer。 this.handshakeTimer = setTimeout(() => { this.handshakeTimer = null if (!this.handshaked) { console.warn('[WsClient] 握手超时,hello_ack 未收到,重连') this.cleanup() this.scheduleReconnect() } }, HANDSHAKE_TIMEOUT_MS) } /** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */ private handleMessage(res: { data: string | ArrayBuffer }): void { // 协议纯 JSON 文本帧(relay 发文本),ArrayBuffer 非预期 —— 加 warn 可观测 // (防二进制帧静默丢弃致握手期 hello_ack 被吃却无日志) if (typeof res.data !== 'string') { console.warn('[WsClient] 收到非预期二进制帧,丢弃', (res.data as ArrayBuffer).byteLength) return } const text = res.data if (!text) return // 活性:任意入站帧更新(含 relay 回的 pong 心跳响应,用于半连接检测) this.lastInboundAt = Date.now() // 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238) if (!this.handshaked) { this.handleHandshakeResponse(text) return } // 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发) let msg: BroadcastMessage try { msg = JSON.parse(text) as BroadcastMessage } catch (e) { console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200)) return } // 按 kind 分派:Event → 业务层;Control → 心跳响应等 if (msg.kind === 'event') { this.handlers?.onEvent(msg) } else if (msg.kind === 'control') { this.handlers?.onControl(msg.payload as ControlMessage) } // command 是 device→miniapp 反向(不应在此端收到,忽略) } /** 处理握手响应(relay 发 hello_ack 显式 ack;失败时发 error 帧 + 关连接) */ private handleHandshakeResponse(text: string): void { // 结构化解析(优先):对齐 relay.rs:255 实发 hello_ack 字面量 // 成功:{"kind":"control","payload":{"control_kind":"hello_ack"}} // 失败:{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"} (relay.rs:200-238) // 注:hello_ack 的 control_kind 在 payload 内(与 error 顶层字段结构不同,须分别判)。 let parsedOk = false let parsed: { kind?: string; error?: string; payload?: { control_kind?: string } } | null = null try { parsed = JSON.parse(text) as { kind?: string; error?: string; payload?: { control_kind?: string } } parsedOk = true } catch (e) { // JSON.parse 失败:降级回旧 includes 兜底,保证非 JSON 帧不误判 console.warn('[WsClient] 握手响应 JSON 解析失败,降级 includes 兜底', e, text.slice(0, 200)) } if (parsedOk && parsed) { // 显式 error 字段存在即握手失败(对齐 relay.rs:203/220/233 error 顶层字段) if (parsed.error !== undefined) { console.error('[WsClient] 握手失败', parsed.error, text) this.setStatus('disconnected', `handshake failed: ${parsed.error}`) this.clearHandshakeTimer() // 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect this.cleanup() return } // 显式 ack 信号:kind==='control' 且 payload.control_kind==='hello_ack' // (对齐 relay.rs:255 实发字面量;ControlMessage 枚举未含 hello_ack 故不强转类型) if (parsed.kind === 'control' && parsed.payload?.control_kind === 'hello_ack') { this.clearHandshakeTimer() this.handshaked = true this.reconnectAttempts = 0 this.setStatus('connected') this.startHeartbeat() // F-#95:握手成功后立即发 list_conversations —— device 在线探测 + 首屏会话列表同步。 // device 在线则 route_list_conversations 推回 AiConversationList(useAiChat 置 deviceOnline=true); // device 离线则无响应,文案保持「已连接中继」诚实表述。 // 此时 handshaked=true,send 守卫放行;relay 纯透传入站文本帧作 payload。 this.send({ cmd: 'list_conversations', args: {} }) // 补发握手期间积压的业务命令(治握手成功到首帧竞态丢命令) this.flushPendingQueue() // hello_ack 是纯控制帧无业务 payload,无需转 handleMessage 处理 return } // 解析成功但既非 error 也非 hello_ack:异常帧,丢弃等下一条(保守不误判握手通过) console.warn('[WsClient] 握手阶段收到非 ack/error 控制帧,丢弃', text.slice(0, 200)) return } // 降级兜底(JSON.parse 失败走此分支):保留旧 includes 判定,防协议演进/非 JSON 帧 if (text.includes('"error"')) { console.error('[WsClient] 握手失败(includes 兜底)', text) this.setStatus('disconnected', `handshake failed: ${text}`) this.clearHandshakeTimer() this.cleanup() return } // 兜底:非错误消息视为握手通过(覆盖旧实现行为,防解析失败的 hello_ack 帧) this.clearHandshakeTimer() this.handshaked = true this.reconnectAttempts = 0 this.setStatus('connected') this.startHeartbeat() this.send({ cmd: 'list_conversations', args: {} }) // 补发握手期间积压的业务命令(降级兜底路径同样补发) this.flushPendingQueue() this.handleMessage({ data: text }) } private handleClose(res: { code: number; reason: string }): void { console.log('[WsClient] 连接关闭', res.code, res.reason) this.cleanup() if (!this.manualClose) { this.scheduleReconnect() } else { this.setStatus('disconnected', 'manual close') } } private handleError(res: { errMsg: string }): void { console.error('[WsClient] socket 错误', res.errMsg) // 微信 socket onError 不保证 onClose 跟随(DNS/TLS 失败/域名未加白名单/onOpen 也不触发)。 // 若尚在 connecting/handshaking(未进过 connected),主动兜底重连,防永卡。 // connected 态的半开死连接由心跳活性检测(P1-2)兜底,此处不重连(可能 onClose 随后跟随)。 if (this.status === 'connecting' || this.status === 'handshaking') { this.cleanup() this.scheduleReconnect() } } /** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'} + 半开死连接检测) */ private startHeartbeat(): void { this.stopHeartbeat() const cfg = getConfig() // 连接成功初始化活性时间(首次心跳检查不立即判死) this.lastInboundAt = Date.now() this.heartbeatTimer = setInterval(() => { // 死连接检测:NAT 超时/网络切换致 TCP 半开(onClose 不触发,socket 仍 open)。 // relay 无 pong,用「任意入站消息」更新 lastInboundAt;超过 N 倍间隔无入站判死,主动重连。 const now = Date.now() if (now - this.lastInboundAt > cfg.heartbeatInterval * HEARTBEAT_DEAD_FACTOR) { console.warn('[WsClient] 心跳活性检测:连接疑似半开死连接,主动重连') this.cleanup() this.scheduleReconnect() return } const ping: ControlMessage = { control_kind: 'ping' } // 直接发 ControlMessage JSON(relay 透传,device 可回 pong) try { this.socket?.send({ data: JSON.stringify(ping) }) } catch (e) { console.warn('[WsClient] 心跳发送失败', e) } // 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用 void cfg }, cfg.heartbeatInterval) } private stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer) this.heartbeatTimer = null } } /** * 指数退避重连(baseDelay * 2^min(attempt,6),封顶 maxDelay,带随机抖动)。 * * - attempt 软上限 6:超 6 不再翻倍,防长尾退避爬到 maxDelay 后卡死; * - 抖动因子 0.5~1.0:防多客户端重连风暴(雷同退避同步触发); * - manualClose/disconnect 时复位 reconnectAttempts(见 disconnect)。 * * 回调统一经 reconnectNow(幂等清理 + openSocket),不裸调 openSocket。 */ private scheduleReconnect(): void { if (this.reconnectTimer) return // 永久性故障上限:relayHost 错/中继下线时停止无限退避,避免长跑空耗 + 误导用户「重连中」。 // 用户手动重连(connect/resumeIfDisconnected)复位计数后可重新开始。 if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { console.warn(`[WsClient] 已达最大重连次数 ${MAX_RECONNECT_ATTEMPTS},停止重连`) // 不清 cleanup(保留 manualClose 不变),仅设状态 + 停退避 timer // 让 connect()/resumeIfDisconnected() 可重置计数后恢复 this.setStatus('disconnected', '已达最大重连次数,请检查中继地址/网络后手动重连') this.recoverable = true if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } return } const cfg = getConfig() const attempt = this.reconnectAttempts++ const exp = Math.min(attempt, 6) const base = Math.min(cfg.reconnectBaseDelay * Math.pow(2, exp), cfg.reconnectMaxDelay) const jitter = 0.5 + Math.random() * 0.5 const delay = Math.round(base * jitter) this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`) console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`) this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.reconnectNow() }, delay) } /** 清握手超时定时器(握手成功/失败时调) */ private clearHandshakeTimer(): void { if (this.handshakeTimer) { clearTimeout(this.handshakeTimer) this.handshakeTimer = null } } /** * 注销并关闭指定 socket task(防旧 socket 闭包回调污染新连接)。 * * 微信 SocketTask 无 offXxx API,用「覆盖空函数」解绑:onXxx 多次调用覆盖最后者。 * 必须对传入 task 操作(非 this.socket),避免 this.socket 时序指向新 socket 时误注销。 */ private destroySocket(task: UniApp.SocketTask): void { try { task.onOpen(() => {}) } catch { /* 注销忽略 */ } try { task.onMessage(() => {}) } catch { /* 注销忽略 */ } try { task.onClose(() => {}) } catch { /* 注销忽略 */ } try { task.onError(() => {}) } catch { /* 注销忽略 */ } try { task.close({}) } catch { // 已关闭的 socket close 抛错忽略 } } /** * 彻底清理:停心跳 + 清所有 timer + 注销关闭 socket + 清队列 + 复位 handshaked。 * * 清 pendingQueue:断连/重连/抢占时,旧命令上下文已失效,不补发到新连接(防串话)。 */ private cleanup(): void { this.stopHeartbeat() if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) this.reconnectTimer = null } this.clearHandshakeTimer() if (this.connectTimer) { clearTimeout(this.connectTimer) this.connectTimer = null } if (this.socket) { this.destroySocket(this.socket) this.socket = null } this.handshaked = false this.pendingQueue = [] } private setStatus(status: WsStatus, detail?: string): void { this.status = status this.handlers?.onStatus(status, detail) } } /** 全局单例(miniapp 进程内一个连接) */ export const wsClient = new WsClient()