import SocketManager from './SocketManager' // 活动实时推送 WebSocket 路径 // 优先取 VITE_WS_ACTIVITY_PATH(方便 Nginx 反向代理时统一改前缀), // 未配置时回退到 /activity const ACTIVITY_WS_PATH = (() => { const configured = import.meta.env.VITE_WS_ACTIVITY_PATH if (configured && String(configured).trim()) { const p = String(configured).trim() // 兜底:若用户忘了写前导 /,补上 return p.startsWith('/') ? p : `/${p}` } return '/activity' })() /** * 活动实时推送 WebSocket 客户端 * - 继承 SocketManager,复用连接/心跳/重连 * - 额外暴露 topic 订阅 API:subscribe / unsubscribe */ // 指数退避:前 5 次按 1s, 2s, 4s, 8s, 16s 重试;之后固定 30s 持续重试(不停止) const ACTIVITY_RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 16000] const ACTIVITY_RECONNECT_FIXED_INTERVAL = 30000 class ActivitySocket extends SocketManager { constructor(options = {}) { super({ serviceName: 'Activity', path: options.path || ACTIVITY_WS_PATH, // 退避由子类覆写 _tryReconnect 实现;父类仅需一个可用的回退值 reconnectInterval: options.reconnectInterval || 1000, heartbeatInterval: options.heartbeatInterval || 30000, maxReconnectAttempts: options.maxReconnectAttempts || Infinity }) // 退避调度:前 5 次按数组执行,之后固定 30s this._backoffSchedule = ACTIVITY_RECONNECT_BACKOFF.slice() this._fixedInterval = ACTIVITY_RECONNECT_FIXED_INTERVAL // 记录已订阅 topic,便于重连后自动重订阅 this._topics = new Set() // 特定消息类型回调 this.onMessagesResponseCallback = null this.onContributionsResponseCallback = null this._registerActivityHandlers() } /** * 重连:指数退避 * - 前 N 次(N = backoffSchedule.length):按 1s, 2s, 4s, 8s, 16s 退避 * - 之后:以 30s 固定间隔持续重试(不停止),弱网下也能恢复 */ _tryReconnect() { if (this.isClosing) { console.log(`[${this.serviceName}] Closing intentionally, skip reconnect`) return } if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.warn(`[${this.serviceName}] Max reconnect attempts reached`) this._emit('error', { code: 'RECONNECT_FAILED', message: '重连次数已达上限' }) return } this.reconnectAttempts++ const idx = this.reconnectAttempts - 1 const delay = idx < this._backoffSchedule.length ? this._backoffSchedule[idx] : this._fixedInterval const phase = idx < this._backoffSchedule.length ? 'backoff' : 'fixed' console.log( `[${this.serviceName}] Auto reconnecting... ` + `(attempt ${this.reconnectAttempts}, phase=${phase}, delay=${delay}ms)` ) this.reconnectTimer = setTimeout(() => { this._doConnect() }, delay) } _registerActivityHandlers() { // 留言响应 this.registerHandler('messages_response', (data) => { if (this.onMessagesResponseCallback) { this.onMessagesResponseCallback(data) } }) // 贡献响应 this.registerHandler('contributions_response', (data) => { if (this.onContributionsResponseCallback) { this.onContributionsResponseCallback(data) } }) } /** * 连接到活动推送服务 */ async connect(token) { await super.connect(token, ACTIVITY_WS_PATH) } /** * 订阅活动主题 * @param {string|number} activityId * @param {string[]} topics - ['messages', 'contributions'] */ subscribe(activityId, topics = []) { if (!activityId || !Array.isArray(topics) || topics.length === 0) return const id = Number(activityId) topics.forEach(t => this._topics.add(`${id}:${t}`)) if (this.isConnected) { this.send({ action: 'subscribe', activity_id: id, topics }) } } /** * 取消订阅活动主题 */ unsubscribe(activityId, topics = []) { if (!activityId || !Array.isArray(topics) || topics.length === 0) return const id = Number(activityId) topics.forEach(t => this._topics.delete(`${id}:${t}`)) if (this.isConnected) { this.send({ action: 'unsubscribe', activity_id: id, topics }) } } /** * 注册 messages 响应回调 */ onMessagesResponse(cb) { this.onMessagesResponseCallback = cb this.registerHandler('messages_response', (data) => { if (this.onMessagesResponseCallback) { this.onMessagesResponseCallback(data) } }) } offMessagesResponse() { this.onMessagesResponseCallback = null this.registerHandler('messages_response', () => {}) } /** * 注册 contributions 响应回调 */ onContributionsResponse(cb) { this.onContributionsResponseCallback = cb this.registerHandler('contributions_response', (data) => { if (this.onContributionsResponseCallback) { this.onContributionsResponseCallback(data) } }) } offContributionsResponse() { this.onContributionsResponseCallback = null this.registerHandler('contributions_response', () => {}) } /** * 重连成功后,自动重新订阅所有 topic */ resubscribeAll() { if (this._topics.size === 0) return const byActivity = new Map() this._topics.forEach(key => { const [activityId, topic] = key.split(':') const id = Number(activityId) if (!byActivity.has(id)) byActivity.set(id, []) byActivity.get(id).push(topic) }) byActivity.forEach((topics, activityId) => { this.send({ action: 'subscribe', activity_id: activityId, topics }) }) } /** * SocketManager.connect() 检测到 token 变化时会调用。 * 清空本地 topic 缓存,避免上一个用户的订阅残留导致新用户订阅错乱。 * callbacks(onMessagesResponseCallback / onContributionsResponseCallback)保留, * 下次组件挂载时会重新设置。 */ _resetSubscriptions() { this._topics = new Set() console.log(`[${this.serviceName}] _resetSubscriptions: cleared topic cache`) } } // ==================== 单例 ==================== let activityInstance = null export function getActivitySocket() { if (!activityInstance) { activityInstance = new ActivitySocket() // 重连成功后自动重订阅 activityInstance.on('connect', () => activityInstance.resubscribeAll()) } return activityInstance } export function closeActivitySocket() { if (activityInstance) { activityInstance.close() } } export default ActivitySocket