214 lines
7.0 KiB
JavaScript
214 lines
7.0 KiB
JavaScript
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 |