- 将HaveADrink依赖从1.0.8升级至1.0.12版本 - 集成邀请码功能,新增common/invite.js处理邀请链路 - 实现发送好友请求返回邀请码的新流程 - 添加删除动态功能,新增DeleteFeed接口 - 集成UniAppWebSocket适配器,重构WebSocket连接管理 - 优化好友请求支持关键词搜索功能 - 在FeedCard组件中添加删除按钮和分享按钮 - 更新SDK类型定义文件以匹配新接口规范
259 lines
7.8 KiB
JavaScript
259 lines
7.8 KiB
JavaScript
/* 碰盏日记 - 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
|