| | |
| | | |
| | | type MessageCallback = (data: any) => void; |
| | | |
| | | enum SocketState { |
| | | IDLE = 'idle', |
| | | CONNECTING = 'connecting', |
| | | CONNECTED = 'connected', |
| | | } |
| | | |
| | | class WebSocketManager { |
| | | private static instance: WebSocketManager; |
| | | private socketTask: Taro.SocketTask | null = null; |
| | | private url: string = ''; |
| | | private isConnected: boolean = false; |
| | | private isConnecting: boolean = false; |
| | | private isManualClose: boolean = false; |
| | | private reconnectAttempts: number = 0; |
| | | private maxReconnectAttempts: number = 5; |
| | | private reconnectTimer: any = null; |
| | | private heartbeatTimer: any = null; |
| | | private heartbeatTimeoutTimer: any = null; // 用于检测心跳超时的定时器 |
| | | private heartbeatInterval: number = 10000; // 10秒心跳 |
| | | private socketTask: Taro.SocketTask | null = null; // 当前生效的 socketTask |
| | | private url: string = ''; // 当前连接地址 |
| | | private socketState: SocketState = SocketState.IDLE; // 当前连接状态 |
| | | private isConnected: boolean = false; // 对外兼容的连接态 |
| | | private isConnecting: boolean = false; // 对外兼容的连接中标记 |
| | | private isManualClose: boolean = false; // 是否手动关闭 |
| | | private isAppActive: boolean = true; // 应用是否处于前台 |
| | | private shouldReconnectWhenActive: boolean = false; // 回前台后是否需要补重连 |
| | | private reconnectAttempts: number = 0; // 当前连续重连次数 |
| | | private reconnectTimer: any = null; // 重连定时器 |
| | | private heartbeatTimer: any = null; // 心跳发送定时器 |
| | | private heartbeatTimeoutTimer: any = null; // 单次等待 pong 的超时定时器 |
| | | private heartbeatInterval: number = 15000; // 心跳间隔 |
| | | private heartbeatTimeout: number = 10000; // 单次心跳等待 pong 的超时时间 |
| | | private maxHeartbeatMisses: number = 2; // 连续丢失 pong 的最大次数 |
| | | private locationReportTimer: any = null; // 位置上报定时器 |
| | | private locationReportInterval: number = 35000; // 35秒上报一次位置 |
| | | private heartbeatTimeout: number = 3000; // 3秒收不到响应即视为掉线 |
| | | private listeners: Map<string, MessageCallback[]> = new Map(); |
| | | private globalEventsBound: boolean = false; |
| | | |
| | | private isHeartbeatMessage(data: any) { |
| | | const type = String(data?.type || '').toLowerCase(); |
| | | return type === 'ping' || type === 'pong'; |
| | | } |
| | | private locationReportInterval: number = 35000; // 35 秒上报一次位置 |
| | | private listeners: Map<string, MessageCallback[]> = new Map(); // 事件监听器 |
| | | private globalEventsBound: boolean = false; // 全局事件是否已绑定 |
| | | private connectionSeed: number = 0; // 连接代次种子 |
| | | private activeConnectionId: number = 0; // 当前生效连接代次 |
| | | private heartbeatSeq: number = 0; // 心跳序号 |
| | | private pendingHeartbeatSeq: number | null = null; // 当前等待 pong 的心跳序号 |
| | | private missedHeartbeatCount: number = 0; // 连续未收到 pong 的次数 |
| | | |
| | | private constructor() { } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 开始定时位置上报。 |
| | | * 应用回到前台时恢复心跳或补发重连。 |
| | | */ |
| | | private startLocationReport() { |
| | | this.stopLocationReport(); |
| | | this.sendLocationMessage(); |
| | | this.locationReportTimer = setInterval(() => { |
| | | this.sendLocationMessage(); |
| | | }, this.locationReportInterval); |
| | | } |
| | | |
| | | /** |
| | | * 停止定时位置上报。 |
| | | */ |
| | | private stopLocationReport() { |
| | | if (this.locationReportTimer) { |
| | | clearInterval(this.locationReportTimer); |
| | | this.locationReportTimer = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 发送位置消息。 |
| | | */ |
| | | private async sendLocationMessage() { |
| | | if (!this.isConnected) return; |
| | | const uid = await getStorage(_UID) || '-1'; |
| | | const userId = uid.toString(); |
| | | if (!userId || userId === '-1') return; |
| | | try { |
| | | const locationRes = await Taro.getLocation({ |
| | | type: 'gcj02', |
| | | isHighAccuracy: true, |
| | | highAccuracyExpireTime: 3000 |
| | | } as any); |
| | | const longitude = Number(locationRes?.longitude); |
| | | const latitude = Number(locationRes?.latitude); |
| | | if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return; |
| | | this.send({ |
| | | type: 'location', |
| | | longitude, |
| | | latitude, |
| | | userId, |
| | | timestamp: Date.now() * 1000 |
| | | }); |
| | | } catch (error) { |
| | | // 定时定位失败静默处理,避免干扰业务流程 |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 初始化连接 |
| | | * @param url WebSocket地址 |
| | | */ |
| | | public async connect(url: string) { |
| | | if (this.socketTask || this.isConnected || this.isConnecting) { |
| | | console.log('WebSocket 已连接或正在连接中'); |
| | | public onAppShow() { |
| | | this.isAppActive = true; |
| | | this.shouldReconnectWhenActive = false; |
| | | if (this.isConnected && this.activeConnectionId > 0) { |
| | | this.startHeartbeat(this.activeConnectionId); |
| | | this.startLocationReport(this.activeConnectionId); |
| | | return; |
| | | } |
| | | |
| | | this.url = url; |
| | | this.isConnecting = true; |
| | | this.isManualClose = false; |
| | | console.log(`正在连接 WebSocket: ${url}`); |
| | | |
| | | const uid = await getStorage(_UID) || '-1'; |
| | | const task = Taro.connectSocket({ |
| | | header: { |
| | | 'XP-Uid': uid.toString(), |
| | | }, |
| | | url: this.url, |
| | | success: () => { }, |
| | | fail: (err) => { |
| | | console.error('WebSocket 连接失败:', err); |
| | | this.isConnecting = false; |
| | | this.handleReconnect(); |
| | | } |
| | | }); |
| | | const maybeTask: any = task as any; |
| | | if (maybeTask && typeof maybeTask.onOpen === 'function') { |
| | | this.socketTask = maybeTask; |
| | | this.isConnecting = false; |
| | | this.initEventListeners(); |
| | | return; |
| | | } |
| | | if (maybeTask && typeof maybeTask.then === 'function') { |
| | | maybeTask |
| | | .then((resolvedTask) => { |
| | | this.socketTask = resolvedTask; |
| | | this.isConnecting = false; |
| | | this.initEventListeners(); |
| | | }) |
| | | .catch((err) => { |
| | | console.error('WebSocket 连接失败:', err); |
| | | this.isConnecting = false; |
| | | this.handleReconnect(); |
| | | }); |
| | | return; |
| | | } |
| | | this.socketTask = null; |
| | | this.isConnecting = false; |
| | | this.initEventListeners(); |
| | | } |
| | | |
| | | public isConnectedStatus() { |
| | | return this.isConnected; |
| | | } |
| | | |
| | | /** |
| | | * 初始化事件监听 |
| | | */ |
| | | private initEventListeners() { |
| | | if (this.socketTask && typeof this.socketTask.onOpen === 'function') { |
| | | this.socketTask.onOpen(async () => { |
| | | console.log('WebSocket 连接已打开'); |
| | | this.isConnected = true; |
| | | this.isConnecting = false; |
| | | this.reconnectAttempts = 0; |
| | | this.startHeartbeat(); |
| | | this.startLocationReport(); |
| | | const uid = await getStorage(_UID) || '-1'; |
| | | this.send({ type: 'auth', appUserId: uid.toString(), timestamp: Date.now() }); |
| | | this.notifyListeners('open', { status: 'connected' }); |
| | | }); |
| | | |
| | | this.socketTask.onMessage((res) => { |
| | | this.resetHeartbeatTimeout(); |
| | | let data: any = {}; |
| | | try { |
| | | data = safeParse(res.data as string, {}); |
| | | console.log('收到 WebSocket 消息:', res.data); |
| | | } catch (e) { |
| | | console.warn('解析 WebSocket data 失败:', res.data); |
| | | this.notifyListeners('message', res.data); |
| | | return; |
| | | } |
| | | |
| | | const content = safeParse(data.content || '{}', {}); |
| | | |
| | | try { |
| | | this.handleIncomingMessage(data, content); |
| | | if (data.type) { |
| | | this.notifyListeners(data.type, data); |
| | | } |
| | | this.notifyListeners('message', data); |
| | | } catch (e) { |
| | | console.error('处理 WebSocket 消息出错:', e); |
| | | } |
| | | }); |
| | | |
| | | this.socketTask.onClose((res) => { |
| | | console.log('WebSocket 连接已关闭', res); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.stopHeartbeat(); |
| | | this.stopLocationReport(); |
| | | this.socketTask = null; |
| | | |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.notifyListeners('close', res); |
| | | }); |
| | | |
| | | this.socketTask.onError((err) => { |
| | | console.error('WebSocket 错误:', err); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.socketTask = null; |
| | | this.stopHeartbeat(); |
| | | this.stopLocationReport(); |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.notifyListeners('error', err); |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | if (this.globalEventsBound) return; |
| | | this.globalEventsBound = true; |
| | | |
| | | Taro.onSocketOpen(async () => { |
| | | console.log('WebSocket 连接已打开'); |
| | | this.isConnected = true; |
| | | this.isConnecting = false; |
| | | this.reconnectAttempts = 0; |
| | | this.startHeartbeat(); |
| | | this.startLocationReport(); |
| | | const uid = await getStorage(_UID) || '-1'; |
| | | this.send({ type: 'auth', appUserId: uid.toString(), timestamp: Date.now() }); |
| | | this.notifyListeners('open', { status: 'connected' }); |
| | | }); |
| | | |
| | | Taro.onSocketMessage((res) => { |
| | | this.resetHeartbeatTimeout(); |
| | | let data: any = {}; |
| | | try { |
| | | data = safeParse(res.data as string, {}); |
| | | console.log('收到 WebSocket 消息:', res.data); |
| | | } catch (e) { |
| | | console.warn('解析 WebSocket data 失败:', res.data); |
| | | this.notifyListeners('message', res.data); |
| | | return; |
| | | } |
| | | |
| | | const content = safeParse(data.content || '{}', {}); |
| | | |
| | | try { |
| | | this.handleIncomingMessage(data, content); |
| | | if (data.type) { |
| | | this.notifyListeners(data.type, data); |
| | | } |
| | | this.notifyListeners('message', data); |
| | | } catch (e) { |
| | | console.error('处理 WebSocket 消息出错:', e); |
| | | } |
| | | }); |
| | | |
| | | Taro.onSocketClose((res) => { |
| | | console.log('WebSocket 连接已关闭', res); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.stopHeartbeat(); |
| | | this.stopLocationReport(); |
| | | this.socketTask = null; |
| | | |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.notifyListeners('close', res); |
| | | }); |
| | | |
| | | Taro.onSocketError((err) => { |
| | | console.error('WebSocket 错误:', err); |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.socketTask = null; |
| | | this.stopHeartbeat(); |
| | | this.stopLocationReport(); |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.notifyListeners('error', err); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 发送消息 |
| | | * @param data 消息内容 |
| | | */ |
| | | public send(data: any) { |
| | | if (!this.isConnected) { |
| | | console.warn('WebSocket 未连接,消息未发送:', data); |
| | | return; |
| | | } |
| | | |
| | | const message = typeof data === 'string' ? data : JSON.stringify(data); |
| | | if (this.socketTask) { |
| | | this.socketTask.send({ |
| | | data: message, |
| | | fail: (err) => { |
| | | console.error('发送消息失败:', err); |
| | | } |
| | | }); |
| | | } else { |
| | | Taro.sendSocketMessage({ |
| | | data: message, |
| | | fail: (err) => { |
| | | console.error('发送消息失败:', err); |
| | | } |
| | | }); |
| | | if (!this.isManualClose && this.url) { |
| | | this.connect(this.url); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 关闭连接 |
| | | * 应用进入后台时暂停心跳,避免后台定时器抖动误判掉线。 |
| | | */ |
| | | public close() { |
| | | this.isManualClose = true; |
| | | public onAppHide() { |
| | | this.isAppActive = false; |
| | | this.stopHeartbeat(); |
| | | this.stopLocationReport(); |
| | | if (this.reconnectTimer) { |
| | | clearTimeout(this.reconnectTimer); |
| | | this.reconnectTimer = null; |
| | | this.shouldReconnectWhenActive = true; |
| | | } |
| | | |
| | | if (this.socketTask) { |
| | | this.socketTask.close({ |
| | | code: 1000, |
| | | reason: '用户主动关闭连接' |
| | | }); |
| | | this.socketTask = null; |
| | | } |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | } |
| | | |
| | | /** |
| | | * 处理重连 |
| | | * 初始化连接。 |
| | | * @param url WebSocket 地址 |
| | | */ |
| | | private handleReconnect() { |
| | | if (this.reconnectTimer) return; |
| | | public async connect(url: string) { |
| | | if (!url) { |
| | | return; |
| | | } |
| | | if (this.isConnected && this.url === url) { |
| | | return; |
| | | } |
| | | if (this.isConnecting && this.url === url) { |
| | | return; |
| | | } |
| | | |
| | | // 固定约 3 秒重连一次,并加上 0~1 秒的随机抖动 |
| | | const delay = 3000 + Math.floor(Math.random() * 1000); |
| | | console.log(`正在尝试重连,延迟 ${delay}ms... (第 ${this.reconnectAttempts + 1} 次尝试)`); |
| | | this.url = url; |
| | | this.isManualClose = false; |
| | | this.shouldReconnectWhenActive = false; |
| | | this.isConnecting = true; |
| | | this.socketState = SocketState.CONNECTING; |
| | | this.clearReconnectTimer(); |
| | | |
| | | this.reconnectTimer = setTimeout(() => { |
| | | // 防止数字溢出(无限重试时) |
| | | if (this.reconnectAttempts < 9999) { |
| | | this.reconnectAttempts++; |
| | | const connectionId = this.createConnectionId(); |
| | | console.log(`正在连接 WebSocket: ${url},连接代次:${connectionId}`); |
| | | |
| | | try { |
| | | const uid = await this.getValidUid(); |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | this.reconnectTimer = null; |
| | | this.connect(this.url); |
| | | }, delay); |
| | | } |
| | | if (!uid) { |
| | | console.warn('WebSocket 连接已取消,UID 无效'); |
| | | this.markConnectionClosed(connectionId); |
| | | return; |
| | | } |
| | | |
| | | /** |
| | | * 开启心跳 |
| | | */ |
| | | private startHeartbeat() { |
| | | this.stopHeartbeat(); |
| | | this.heartbeatTimer = setInterval(() => { |
| | | if (this.isConnected) { |
| | | this.send({ type: 'ping', timestamp: Date.now() }); |
| | | // 发送 ping 后,启动一个超时检测 |
| | | // 如果在配置的时间内没有收到任何消息(pong 或业务消息),则认为连接已假死 |
| | | if (this.heartbeatTimeoutTimer) { |
| | | clearTimeout(this.heartbeatTimeoutTimer); |
| | | const task = Taro.connectSocket({ |
| | | header: { |
| | | 'XP-Uid': uid, |
| | | }, |
| | | url: this.url, |
| | | success: () => { }, |
| | | fail: (err) => { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | console.error('WebSocket 连接失败:', err); |
| | | this.markConnectionClosed(connectionId); |
| | | this.scheduleReconnect(connectionId, 'connect_fail'); |
| | | } |
| | | this.heartbeatTimeoutTimer = setTimeout(() => { |
| | | console.warn('心跳超时,未收到服务器响应,主动断开 WebSocket 连接'); |
| | | this.closeAndReconnect(); |
| | | }, this.heartbeatTimeout); |
| | | }); |
| | | |
| | | const maybeTask: any = task as any; |
| | | if (maybeTask && typeof maybeTask.onOpen === 'function') { |
| | | this.attachTask(connectionId, maybeTask); |
| | | return; |
| | | } |
| | | }, this.heartbeatInterval); |
| | | } |
| | | |
| | | /** |
| | | * 停止心跳 |
| | | */ |
| | | private stopHeartbeat() { |
| | | if (this.heartbeatTimer) { |
| | | clearInterval(this.heartbeatTimer); |
| | | this.heartbeatTimer = null; |
| | | } |
| | | if (this.heartbeatTimeoutTimer) { |
| | | clearTimeout(this.heartbeatTimeoutTimer); |
| | | this.heartbeatTimeoutTimer = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 收到消息时重置心跳超时检测 |
| | | */ |
| | | private resetHeartbeatTimeout() { |
| | | if (this.heartbeatTimeoutTimer) { |
| | | clearTimeout(this.heartbeatTimeoutTimer); |
| | | this.heartbeatTimeoutTimer = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 主动关闭并重连 (处理假死状态) |
| | | */ |
| | | private closeAndReconnect() { |
| | | // 强制关闭当前连接 |
| | | if (this.socketTask) { |
| | | try { |
| | | this.socketTask.close({ |
| | | code: 1000, |
| | | reason: '心跳超时主动关闭' |
| | | }); |
| | | } catch (e) { |
| | | console.error('强制关闭 Socket 失败:', e); |
| | | if (maybeTask && typeof maybeTask.then === 'function') { |
| | | maybeTask |
| | | .then((resolvedTask: Taro.SocketTask) => { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | this.closeTaskSilently(resolvedTask, '过期连接任务关闭'); |
| | | return; |
| | | } |
| | | this.attachTask(connectionId, resolvedTask); |
| | | }) |
| | | .catch((err: any) => { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | console.error('WebSocket 连接失败:', err); |
| | | this.markConnectionClosed(connectionId); |
| | | this.scheduleReconnect(connectionId, 'connect_promise_fail'); |
| | | }); |
| | | return; |
| | | } |
| | | this.socketTask = null; |
| | | |
| | | this.bindGlobalEventListeners(); |
| | | } catch (error) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | console.error('初始化 WebSocket 连接失败:', error); |
| | | this.markConnectionClosed(connectionId); |
| | | this.scheduleReconnect(connectionId, 'connect_exception'); |
| | | } |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | } |
| | | |
| | | /** |
| | | * 对外暴露当前连接状态。 |
| | | */ |
| | | public isConnectedStatus() { |
| | | return this.isConnected; |
| | | } |
| | | |
| | | /** |
| | | * 发送消息。 |
| | | * @param data 消息内容 |
| | | */ |
| | | public send(data: any) { |
| | | this.sendInternal(data, { silentWhenDisconnected: false }); |
| | | } |
| | | |
| | | /** |
| | | * 主动关闭连接。 |
| | | */ |
| | | public close() { |
| | | const closedConnectionId = this.activeConnectionId; |
| | | this.isManualClose = true; |
| | | this.shouldReconnectWhenActive = false; |
| | | this.clearReconnectTimer(); |
| | | this.stopHeartbeat(); |
| | | |
| | | // 立即触发重连 |
| | | if (!this.isManualClose) { |
| | | this.handleReconnect(); |
| | | } |
| | | this.stopLocationReport(); |
| | | this.invalidateCurrentConnection(); |
| | | this.closeTaskSilently(this.socketTask, '用户主动关闭连接'); |
| | | this.socketTask = null; |
| | | this.markConnectionClosed(closedConnectionId); |
| | | } |
| | | |
| | | /** |
| | | * 注册事件监听 |
| | | * 注册事件监听。 |
| | | * @param event 事件名称 |
| | | * @param callback 回调函数 |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 移除事件监听 |
| | | * 移除事件监听。 |
| | | * @param event 事件名称 |
| | | * @param callback 回调函数 |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 通知监听器 |
| | | * 创建新的连接代次,并让旧连接回调全部失效。 |
| | | */ |
| | | private createConnectionId() { |
| | | this.connectionSeed += 1; |
| | | this.activeConnectionId = this.connectionSeed; |
| | | return this.activeConnectionId; |
| | | } |
| | | |
| | | /** |
| | | * 当前连接失效后让旧回调不再影响全局状态。 |
| | | */ |
| | | private invalidateCurrentConnection() { |
| | | this.connectionSeed += 1; |
| | | this.activeConnectionId = this.connectionSeed; |
| | | this.pendingHeartbeatSeq = null; |
| | | } |
| | | |
| | | /** |
| | | * 判断回调是否仍然属于当前连接代次。 |
| | | */ |
| | | private isCurrentConnection(connectionId: number) { |
| | | return connectionId > 0 && connectionId === this.activeConnectionId; |
| | | } |
| | | |
| | | /** |
| | | * 绑定当前 socketTask,并注册基于连接代次的回调。 |
| | | */ |
| | | private attachTask(connectionId: number, task: Taro.SocketTask) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | this.closeTaskSilently(task, '过期连接任务关闭'); |
| | | return; |
| | | } |
| | | this.socketTask = task; |
| | | this.bindTaskEventListeners(connectionId, task); |
| | | } |
| | | |
| | | /** |
| | | * 绑定 task 级别事件,确保旧连接回调不会污染新连接。 |
| | | */ |
| | | private bindTaskEventListeners(connectionId: number, task: Taro.SocketTask) { |
| | | task.onOpen(() => { |
| | | this.handleSocketOpen(connectionId); |
| | | }); |
| | | |
| | | task.onMessage((res) => { |
| | | this.handleSocketMessage(connectionId, res.data); |
| | | }); |
| | | |
| | | task.onClose((res) => { |
| | | this.handleSocketClose(connectionId, res); |
| | | }); |
| | | |
| | | task.onError((err) => { |
| | | this.handleSocketError(connectionId, err); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 兼容无 task 级事件的运行环境。 |
| | | */ |
| | | private bindGlobalEventListeners() { |
| | | if (this.globalEventsBound) return; |
| | | this.globalEventsBound = true; |
| | | |
| | | Taro.onSocketOpen(() => { |
| | | this.handleSocketOpen(this.activeConnectionId); |
| | | }); |
| | | |
| | | Taro.onSocketMessage((res) => { |
| | | this.handleSocketMessage(this.activeConnectionId, res.data); |
| | | }); |
| | | |
| | | Taro.onSocketClose((res) => { |
| | | this.handleSocketClose(this.activeConnectionId, res); |
| | | }); |
| | | |
| | | Taro.onSocketError((err) => { |
| | | this.handleSocketError(this.activeConnectionId, err); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 处理连接建立成功。 |
| | | */ |
| | | private async handleSocketOpen(connectionId: number) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | console.log(`WebSocket 连接已打开,连接代次:${connectionId}`); |
| | | this.isConnected = true; |
| | | this.isConnecting = false; |
| | | this.socketState = SocketState.CONNECTED; |
| | | this.reconnectAttempts = 0; |
| | | this.resetHeartbeatState(); |
| | | if (this.isAppActive) { |
| | | this.startHeartbeat(connectionId); |
| | | this.startLocationReport(connectionId); |
| | | } |
| | | |
| | | const uid = await this.getValidUid(); |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | if (!uid) { |
| | | console.warn('WebSocket 鉴权已取消,UID 无效'); |
| | | const expiredTask = this.socketTask; |
| | | this.stopHeartbeat(); |
| | | this.invalidateCurrentConnection(); |
| | | this.socketTask = null; |
| | | this.closeTaskSilently(expiredTask, 'UID 无效,关闭未鉴权连接'); |
| | | this.markConnectionClosed(connectionId); |
| | | return; |
| | | } |
| | | this.sendInternal( |
| | | { type: 'auth', appUserId: uid, timestamp: Date.now() }, |
| | | { silentWhenDisconnected: true } |
| | | ); |
| | | this.notifyListeners('open', { status: 'connected' }); |
| | | } |
| | | |
| | | /** |
| | | * 处理服务端消息。 |
| | | */ |
| | | private handleSocketMessage(connectionId: number, rawData: any) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | |
| | | console.log('收到 WebSocket 消息:', rawData); |
| | | const data = this.normalizeIncomingMessage(rawData); |
| | | if (this.isPongMessage(data)) { |
| | | this.handlePong(connectionId); |
| | | } |
| | | |
| | | const content = safeParse(data?.content || '{}', {}); |
| | | |
| | | try { |
| | | this.handleIncomingMessage(data, content); |
| | | if (data?.type) { |
| | | this.notifyListeners(data.type, data); |
| | | } |
| | | this.notifyListeners('message', data); |
| | | } catch (error) { |
| | | console.error('处理 WebSocket 消息出错:', error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 处理连接关闭。 |
| | | */ |
| | | private handleSocketClose(connectionId: number, res: any) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | console.log('忽略过期连接关闭回调', res); |
| | | return; |
| | | } |
| | | console.log('WebSocket 连接已关闭', res); |
| | | this.markConnectionClosed(connectionId); |
| | | this.notifyListeners('close', res); |
| | | if (!this.isManualClose) { |
| | | this.scheduleReconnect(connectionId, 'socket_close'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 处理连接异常。 |
| | | */ |
| | | private handleSocketError(connectionId: number, err: any) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | console.log('忽略过期连接错误回调', err); |
| | | return; |
| | | } |
| | | console.error('WebSocket 错误:', err); |
| | | this.markConnectionClosed(connectionId); |
| | | this.notifyListeners('error', err); |
| | | if (!this.isManualClose) { |
| | | this.scheduleReconnect(connectionId, 'socket_error'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 标准化消息结构,兼容纯字符串 pong。 |
| | | */ |
| | | private normalizeIncomingMessage(rawData: any) { |
| | | const parsedData = safeParse(rawData, rawData); |
| | | if (typeof parsedData === 'string') { |
| | | return { |
| | | type: parsedData, |
| | | raw: rawData, |
| | | }; |
| | | } |
| | | return parsedData || {}; |
| | | } |
| | | |
| | | /** |
| | | * 获取有效 UID,约束连接头和鉴权消息都不能使用 -1 等无效值。 |
| | | */ |
| | | private async getValidUid() { |
| | | const uid = await getStorage(_UID); |
| | | return this.normalizeValidUid(uid); |
| | | } |
| | | |
| | | /** |
| | | * 标准化并校验 UID。 |
| | | */ |
| | | private normalizeValidUid(uid: any) { |
| | | const normalizedUid = String(uid ?? '').trim(); |
| | | if (!normalizedUid || normalizedUid === '-1' || normalizedUid === 'undefined' || normalizedUid === 'null') { |
| | | return null; |
| | | } |
| | | return normalizedUid; |
| | | } |
| | | |
| | | /** |
| | | * 判断消息是否为服务端 pong。 |
| | | */ |
| | | private isPongMessage(data: any) { |
| | | const type = String(data?.type || '').toLowerCase(); |
| | | return type === 'pong'; |
| | | } |
| | | |
| | | /** |
| | | * 根据 pong 清理等待状态,并恢复心跳健康计数。 |
| | | */ |
| | | private handlePong(connectionId: number) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | this.pendingHeartbeatSeq = null; |
| | | this.missedHeartbeatCount = 0; |
| | | if (this.heartbeatTimeoutTimer) { |
| | | clearTimeout(this.heartbeatTimeoutTimer); |
| | | this.heartbeatTimeoutTimer = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 启动心跳轮询。 |
| | | */ |
| | | private startHeartbeat(connectionId: number) { |
| | | this.stopHeartbeat(); |
| | | if (!this.isCurrentConnection(connectionId) || !this.isAppActive) { |
| | | return; |
| | | } |
| | | this.heartbeatTimer = setInterval(() => { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | if (!this.isConnected || this.socketState !== SocketState.CONNECTED || !this.isAppActive) { |
| | | return; |
| | | } |
| | | if (this.pendingHeartbeatSeq !== null) { |
| | | return; |
| | | } |
| | | this.sendHeartbeat(connectionId); |
| | | }, this.heartbeatInterval); |
| | | } |
| | | |
| | | /** |
| | | * 发送一轮 ping,并等待服务端回 pong。 |
| | | */ |
| | | private sendHeartbeat(connectionId: number) { |
| | | if (!this.isCurrentConnection(connectionId) || !this.isAppActive) { |
| | | return; |
| | | } |
| | | const currentHeartbeatSeq = ++this.heartbeatSeq; |
| | | this.pendingHeartbeatSeq = currentHeartbeatSeq; |
| | | const hasSent = this.sendInternal( |
| | | { type: 'ping', timestamp: Date.now(), seq: currentHeartbeatSeq }, |
| | | { silentWhenDisconnected: true } |
| | | ); |
| | | |
| | | if (!hasSent) { |
| | | this.handleHeartbeatMiss(connectionId, currentHeartbeatSeq, 'ping_send_fail'); |
| | | return; |
| | | } |
| | | |
| | | this.heartbeatTimeoutTimer = setTimeout(() => { |
| | | this.handleHeartbeatMiss(connectionId, currentHeartbeatSeq, 'pong_timeout'); |
| | | }, this.heartbeatTimeout); |
| | | } |
| | | |
| | | /** |
| | | * 处理单轮心跳未收到 pong 的场景。 |
| | | */ |
| | | private handleHeartbeatMiss(connectionId: number, heartbeatSeq: number, reason: string) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | if (!this.isAppActive) { |
| | | this.pendingHeartbeatSeq = null; |
| | | return; |
| | | } |
| | | if (this.pendingHeartbeatSeq !== heartbeatSeq) { |
| | | return; |
| | | } |
| | | |
| | | this.pendingHeartbeatSeq = null; |
| | | if (this.heartbeatTimeoutTimer) { |
| | | clearTimeout(this.heartbeatTimeoutTimer); |
| | | this.heartbeatTimeoutTimer = null; |
| | | } |
| | | this.missedHeartbeatCount += 1; |
| | | console.warn(`WebSocket 心跳未收到 pong,原因:${reason},连续失败 ${this.missedHeartbeatCount} 次`); |
| | | |
| | | if (this.missedHeartbeatCount < this.maxHeartbeatMisses) { |
| | | return; |
| | | } |
| | | |
| | | this.safeReconnect(connectionId, reason); |
| | | } |
| | | |
| | | /** |
| | | * 安全关闭当前连接并发起重连,不让旧连接回调影响新连接。 |
| | | */ |
| | | private safeReconnect(connectionId: number, reason: string) { |
| | | if (!this.isCurrentConnection(connectionId)) { |
| | | return; |
| | | } |
| | | |
| | | console.warn(`WebSocket 准备重连,原因:${reason},连接代次:${connectionId}`); |
| | | const expiredTask = this.socketTask; |
| | | this.stopHeartbeat(); |
| | | this.markConnectionClosed(connectionId); |
| | | this.invalidateCurrentConnection(); |
| | | this.socketTask = null; |
| | | this.closeTaskSilently(expiredTask, `重连前关闭旧连接:${reason}`); |
| | | this.scheduleReconnect(this.activeConnectionId, reason); |
| | | } |
| | | |
| | | /** |
| | | * 停止心跳及其超时检测。 |
| | | */ |
| | | private stopHeartbeat() { |
| | | if (this.heartbeatTimer) { |
| | | clearInterval(this.heartbeatTimer); |
| | | this.heartbeatTimer = null; |
| | | } |
| | | if (this.heartbeatTimeoutTimer) { |
| | | clearTimeout(this.heartbeatTimeoutTimer); |
| | | this.heartbeatTimeoutTimer = null; |
| | | } |
| | | this.pendingHeartbeatSeq = null; |
| | | } |
| | | |
| | | /** |
| | | * 启动位置上报轮询。 |
| | | */ |
| | | private startLocationReport(connectionId: number) { |
| | | this.stopLocationReport(); |
| | | if (!this.isCurrentConnection(connectionId) || !this.isAppActive || !this.isConnected) { |
| | | return; |
| | | } |
| | | this.sendLocationMessage(connectionId); |
| | | this.locationReportTimer = setInterval(() => { |
| | | if (!this.isCurrentConnection(connectionId) || !this.isAppActive || !this.isConnected) { |
| | | return; |
| | | } |
| | | this.sendLocationMessage(connectionId); |
| | | }, this.locationReportInterval); |
| | | } |
| | | |
| | | /** |
| | | * 停止位置上报轮询。 |
| | | */ |
| | | private stopLocationReport() { |
| | | if (this.locationReportTimer) { |
| | | clearInterval(this.locationReportTimer); |
| | | this.locationReportTimer = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 上报当前位置,失败时静默处理。 |
| | | */ |
| | | private async sendLocationMessage(connectionId: number) { |
| | | if (!this.isCurrentConnection(connectionId) || !this.isConnected || !this.isAppActive) { |
| | | return; |
| | | } |
| | | const userId = await this.getValidUid(); |
| | | if (!userId) { |
| | | return; |
| | | } |
| | | try { |
| | | const locationRes = await Taro.getLocation({ |
| | | type: 'gcj02', |
| | | isHighAccuracy: true, |
| | | highAccuracyExpireTime: 3000, |
| | | } as any); |
| | | if (!this.isCurrentConnection(connectionId) || !this.isConnected || !this.isAppActive) { |
| | | return; |
| | | } |
| | | const longitude = Number(locationRes?.longitude); |
| | | const latitude = Number(locationRes?.latitude); |
| | | if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) { |
| | | return; |
| | | } |
| | | this.sendInternal( |
| | | { |
| | | type: 'location', |
| | | longitude, |
| | | latitude, |
| | | userId, |
| | | timestamp: Date.now() * 1000, |
| | | }, |
| | | { silentWhenDisconnected: true } |
| | | ); |
| | | } catch (error) { |
| | | // 定时定位失败静默处理,避免干扰业务流程 |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 重置心跳健康状态。 |
| | | */ |
| | | private resetHeartbeatState() { |
| | | this.pendingHeartbeatSeq = null; |
| | | this.missedHeartbeatCount = 0; |
| | | if (this.heartbeatTimeoutTimer) { |
| | | clearTimeout(this.heartbeatTimeoutTimer); |
| | | this.heartbeatTimeoutTimer = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 调度下一次重连,后台期间延后到前台再处理。 |
| | | */ |
| | | private scheduleReconnect(connectionId: number, reason: string) { |
| | | if (!this.isCurrentConnection(connectionId) || this.isManualClose || !this.url) { |
| | | return; |
| | | } |
| | | if (!this.isAppActive) { |
| | | this.shouldReconnectWhenActive = true; |
| | | return; |
| | | } |
| | | if (this.reconnectTimer) { |
| | | return; |
| | | } |
| | | |
| | | const delay = this.getReconnectDelay(); |
| | | console.log(`正在尝试重连,原因:${reason},延迟 ${delay}ms... (第 ${this.reconnectAttempts + 1} 次尝试)`); |
| | | |
| | | this.reconnectTimer = setTimeout(() => { |
| | | this.reconnectTimer = null; |
| | | if (this.isManualClose || !this.isAppActive || !this.url) { |
| | | return; |
| | | } |
| | | this.reconnectAttempts += 1; |
| | | this.connect(this.url); |
| | | }, delay); |
| | | } |
| | | |
| | | /** |
| | | * 获取退避后的重连延迟。 |
| | | */ |
| | | private getReconnectDelay() { |
| | | const retryLevel = Math.min(this.reconnectAttempts, 4); |
| | | const baseDelay = Math.min(1000 * Math.pow(2, retryLevel), 15000); |
| | | const jitter = Math.floor(Math.random() * 500); |
| | | return baseDelay + jitter; |
| | | } |
| | | |
| | | /** |
| | | * 清理已有重连任务。 |
| | | */ |
| | | private clearReconnectTimer() { |
| | | if (this.reconnectTimer) { |
| | | clearTimeout(this.reconnectTimer); |
| | | this.reconnectTimer = null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 发送消息的底层实现。 |
| | | */ |
| | | private sendInternal(data: any, options: { silentWhenDisconnected: boolean }) { |
| | | if (!this.isConnected || this.socketState !== SocketState.CONNECTED) { |
| | | if (!options.silentWhenDisconnected) { |
| | | console.warn('WebSocket 未连接,消息未发送:', data); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | const message = typeof data === 'string' ? data : JSON.stringify(data); |
| | | if (this.socketTask) { |
| | | this.socketTask.send({ |
| | | data: message, |
| | | fail: (err) => { |
| | | console.error('发送消息失败:', err); |
| | | } |
| | | }); |
| | | return true; |
| | | } |
| | | |
| | | Taro.sendSocketMessage({ |
| | | data: message, |
| | | fail: (err) => { |
| | | console.error('发送消息失败:', err); |
| | | } |
| | | }); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 关闭指定 task,不影响当前代次判断。 |
| | | */ |
| | | private closeTaskSilently(task: Taro.SocketTask | null | undefined, reason: string) { |
| | | if (!task) { |
| | | return; |
| | | } |
| | | try { |
| | | task.close({ |
| | | code: 1000, |
| | | reason, |
| | | }); |
| | | } catch (error) { |
| | | console.error('关闭 SocketTask 失败:', error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 统一更新关闭态。 |
| | | */ |
| | | private markConnectionClosed(connectionId: number) { |
| | | if (this.isCurrentConnection(connectionId)) { |
| | | this.socketTask = null; |
| | | } |
| | | this.isConnected = false; |
| | | this.isConnecting = false; |
| | | this.socketState = SocketState.IDLE; |
| | | this.stopHeartbeat(); |
| | | this.stopLocationReport(); |
| | | } |
| | | |
| | | /** |
| | | * 通知监听器。 |
| | | * @param event 事件名称 |
| | | * @param data 数据 |
| | | */ |
| | |
| | | } |
| | | } |
| | | |
| | | // 处理传入消息 |
| | | /** |
| | | * 处理传入消息。 |
| | | */ |
| | | private handleIncomingMessage(data: any, content: any) { |
| | | if (data?.type === 'ORDER_STATUS') { |
| | | const status = content?.status; |
| | |
| | | Taro.setStorageSync('tripStatus', mapped.tripStatus); |
| | | Taro.setStorageSync('orderId', orderId); |
| | | } |
| | | this.notifyListeners('order_status', { |
| | | status, |
| | | ...mapped, |
| | | data, |
| | | content |
| | | }); |
| | | this.notifyListeners('order_status', { status, ...mapped, data, content }); |
| | | } |
| | | } |
| | | if (data?.type === 'ORDER_ROUTE' || data?.type === 'ORDER_UPDATE_LINE') { |
| | |
| | | } |
| | | } |
| | | |
| | | // 归一化路线点 |
| | | /** |
| | | * 归一化路线点。 |
| | | */ |
| | | private normalizeRoutePoints(content: any) { |
| | | return this.normalizeRoutePointsByKey(content, 'polyline'); |
| | | } |
| | | |
| | | /** |
| | | * 归一化已行驶路线点。 |
| | | */ |
| | | private normalizeServedPoints(content: any) { |
| | | return this.normalizeRoutePointsByKey(content, 'served'); |
| | | } |
| | | |
| | | /** |
| | | * 根据指定 key 归一化路线点。 |
| | | */ |
| | | private normalizeRoutePointsByKey(content: any, key: 'polyline' | 'served') { |
| | | const route = safeParse(content?.route ?? '{}', {}); |
| | | const rawPoints = route?.[key] ?? []; |
| | |
| | | .filter(Boolean); |
| | | } |
| | | |
| | | // 映射订单状态到行程状态 |
| | | /** |
| | | * 映射订单状态到行程状态。 |
| | | */ |
| | | private mapOrderStatusToTripStatus(status: number) { |
| | | switch (status) { |
| | | case 0: |