feat(frontend): WebSocket 客户端 + Contact Store + API
- services/websocket.js: 单例 WS 连接管理(心跳/重连/事件分发) - store/websocket.js: Pinia Store 管理连接状态 - store/contact.js: 联系人 Store(好友/申请/分组/黑名单/在线状态) - api/contact.js: 联系人 REST API 封装(17 个接口) - api/user.js: 用户搜索 API Made-with: Cursor
This commit is contained in:
@@ -1,27 +1,241 @@
|
||||
/**
|
||||
* WebSocket 服务模块(占位)
|
||||
* WebSocket 连接管理服务
|
||||
*
|
||||
* 后续阶段实现 IM 即时通讯和会议信令时使用。
|
||||
* 职责:
|
||||
* - 建立和管理 WebSocket 连接
|
||||
* - 心跳保活机制
|
||||
* - 断线自动重连
|
||||
* - 消息收发与事件分发
|
||||
* 单例模式,管理与后端的 WebSocket 长连接,提供:
|
||||
* - JWT Token 认证连接
|
||||
* - 心跳保活(30s 间隔)
|
||||
* - 断线自动重连(指数退避 1s→2s→4s→8s→30s max)
|
||||
* - 事件分发:on(event, callback) / off(event, callback)
|
||||
*
|
||||
* 对应架构设计见:docs/architecture/system-architecture.md 第 4.1 节
|
||||
* 日志规范见:docs/architecture/system-architecture.md 第 8.7 节
|
||||
* 对应后端:GET /ws?token=xxx
|
||||
*/
|
||||
|
||||
// TODO: Phase 2 - IM 模块开发时实现
|
||||
// 预期功能:
|
||||
// - connect(token) — 建立 WebSocket 连接,附带认证 Token
|
||||
// - disconnect() — 主动断开连接
|
||||
// - send(event, data) — 发送消息
|
||||
// - on(event, callback) — 监听事件
|
||||
// - off(event, callback) — 移除监听
|
||||
// - 自动心跳(30s 间隔)
|
||||
// - 断线重连(指数退避,最大 5 次)
|
||||
import { BASE_URL } from '@/utils/request'
|
||||
import { getToken } from '@/utils/storage'
|
||||
|
||||
export default {
|
||||
// 占位导出,后续实现时替换
|
||||
const WS_BASE = BASE_URL.replace(/^http/, 'ws')
|
||||
const HEARTBEAT_INTERVAL = 30000
|
||||
const RECONNECT_BASE_DELAY = 1000
|
||||
const RECONNECT_MAX_DELAY = 30000
|
||||
const MAX_RECONNECT_ATTEMPTS = Infinity
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 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 = {}) {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[WS] 连接未就绪,无法发送')
|
||||
return -1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册事件监听
|
||||
* @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._emit('_disconnected')
|
||||
|
||||
if (!this.isManualClose) {
|
||||
this._scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
_onError(e) {
|
||||
console.error('[WS] 连接错误', e)
|
||||
}
|
||||
|
||||
_onMessage(data) {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
this._emit(msg.event, msg)
|
||||
} catch (e) {
|
||||
console.warn('[WS] 消息解析失败', data)
|
||||
}
|
||||
}
|
||||
|
||||
/** 分发事件给注册的监听器 */
|
||||
_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
|
||||
|
||||
Reference in New Issue
Block a user