topfans/frontend/utils/socket/ActivitySocket.js

214 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 订阅 APIsubscribe / 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