- axiosConfig: 忽略 CanceledError 错误日志(组件卸载时的正常行为) - socketService: 首次连接失败使用 warn 级别,后续重试使用 debug 级别 - useEventData: 添加防御性检查,防止 pagination 为 undefined 时崩溃 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
458 lines
14 KiB
JavaScript
458 lines
14 KiB
JavaScript
// src/services/socketService.js
|
||
/**
|
||
* 真实 Socket.IO 服务 - 用于生产环境连接真实后端
|
||
*/
|
||
|
||
import { io } from 'socket.io-client';
|
||
import { logger } from '../utils/logger';
|
||
import { getApiBase } from '../utils/apiConfig';
|
||
|
||
// 优先使用 REACT_APP_SOCKET_URL(专门为 Socket.IO 配置)
|
||
// 如果未配置,则使用 getApiBase()(与 HTTP API 共用地址)
|
||
// Mock 模式下可以通过 .env.mock 配置 REACT_APP_SOCKET_URL=https://valuefrontier.cn 连接生产环境
|
||
const API_BASE_URL = process.env.REACT_APP_SOCKET_URL || getApiBase();
|
||
|
||
class SocketService {
|
||
constructor() {
|
||
this.socket = null;
|
||
this.connected = false;
|
||
this.reconnectAttempts = 0;
|
||
this.maxReconnectAttempts = Infinity; // 无限重试
|
||
this.customReconnectTimer = null; // 自定义重连定时器
|
||
this.pendingListeners = []; // 暂存等待注册的事件监听器
|
||
}
|
||
|
||
/**
|
||
* 计算指数退避延迟
|
||
* 第1次: 60秒, 第2次: 120秒, 第3次: 240秒, 第4次及以后: 240秒
|
||
*/
|
||
getReconnectionDelay(attempt) {
|
||
const delays = [60000, 120000, 240000]; // 1min, 2min, 4min
|
||
const index = Math.min(attempt - 1, delays.length - 1);
|
||
return delays[index];
|
||
}
|
||
|
||
/**
|
||
* 连接到 Socket.IO 服务器
|
||
* @param {object} options - 连接选项
|
||
*/
|
||
connect(options = {}) {
|
||
if (this.socket && this.connected) {
|
||
logger.warn('socketService', 'Already connected');
|
||
return;
|
||
}
|
||
|
||
logger.info('socketService', 'Connecting to Socket.IO server...', { url: API_BASE_URL });
|
||
|
||
// 创建 socket 连接 - 禁用 Socket.IO 自带的重连机制,使用自定义指数退避
|
||
this.socket = io(API_BASE_URL, {
|
||
transports: ['websocket', 'polling'],
|
||
reconnection: false, // 禁用自动重连,改用自定义策略
|
||
timeout: 20000,
|
||
autoConnect: true,
|
||
withCredentials: true, // 允许携带认证信息
|
||
...options,
|
||
});
|
||
|
||
// 注册所有暂存的事件监听器(保留 pendingListeners,不清空)
|
||
if (this.pendingListeners.length > 0) {
|
||
this.pendingListeners.forEach(({ event, callback }) => {
|
||
this.socket.on(event, callback);
|
||
});
|
||
// ⚠️ 重要:不清空 pendingListeners,保留用于重连
|
||
}
|
||
|
||
// 监听连接成功
|
||
this.socket.on('connect', () => {
|
||
this.connected = true;
|
||
this.reconnectAttempts = 0;
|
||
|
||
// 清除自定义重连定时器
|
||
if (this.customReconnectTimer) {
|
||
clearTimeout(this.customReconnectTimer);
|
||
this.customReconnectTimer = null;
|
||
}
|
||
|
||
logger.info('socketService', 'Socket.IO connected', { socketId: this.socket.id });
|
||
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
|
||
});
|
||
|
||
// 监听断开连接
|
||
this.socket.on('disconnect', (reason) => {
|
||
const wasConnected = this.connected;
|
||
this.connected = false;
|
||
logger.warn('socketService', 'Socket.IO disconnected', { reason });
|
||
|
||
// 如果是意外断开(非主动断开),触发自定义重连
|
||
if (wasConnected && reason !== 'io client disconnect') {
|
||
this.scheduleReconnection();
|
||
}
|
||
});
|
||
|
||
// 监听连接错误
|
||
this.socket.on('connect_error', (error) => {
|
||
this.reconnectAttempts++;
|
||
|
||
// 首次连接失败使用 warn 级别,后续使用 debug 级别减少日志噪音
|
||
if (this.reconnectAttempts === 1) {
|
||
logger.warn('socketService', `Socket 连接失败,将在后台重试`, {
|
||
url: API_BASE_URL,
|
||
error: error.message,
|
||
});
|
||
} else {
|
||
logger.debug('socketService', `Socket 重连尝试 #${this.reconnectAttempts}`, {
|
||
error: error.message,
|
||
});
|
||
}
|
||
|
||
// 使用指数退避策略安排下次重连
|
||
this.scheduleReconnection();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 使用指数退避策略安排重连
|
||
*/
|
||
scheduleReconnection() {
|
||
// 清除之前的定时器
|
||
if (this.customReconnectTimer) {
|
||
clearTimeout(this.customReconnectTimer);
|
||
}
|
||
|
||
const delay = this.getReconnectionDelay(this.reconnectAttempts);
|
||
logger.info('socketService', `Scheduling reconnection in ${delay / 1000}s (attempt ${this.reconnectAttempts})`);
|
||
|
||
this.customReconnectTimer = setTimeout(() => {
|
||
if (!this.connected && this.socket) {
|
||
logger.info('socketService', 'Attempting reconnection...', {
|
||
attempt: this.reconnectAttempts,
|
||
});
|
||
this.socket.connect();
|
||
}
|
||
}, delay);
|
||
}
|
||
|
||
/**
|
||
* 断开连接
|
||
*/
|
||
disconnect() {
|
||
if (!this.socket) {
|
||
return;
|
||
}
|
||
|
||
logger.info('socketService', 'Disconnecting from Socket.IO server...');
|
||
|
||
// 清除自定义重连定时器
|
||
if (this.customReconnectTimer) {
|
||
clearTimeout(this.customReconnectTimer);
|
||
this.customReconnectTimer = null;
|
||
}
|
||
|
||
this.socket.disconnect();
|
||
this.socket = null;
|
||
this.connected = false;
|
||
this.reconnectAttempts = 0;
|
||
}
|
||
|
||
/**
|
||
* 监听事件
|
||
* @param {string} event - 事件名称
|
||
* @param {Function} callback - 回调函数
|
||
*/
|
||
on(event, callback) {
|
||
if (!this.socket) {
|
||
// Socket 未初始化,暂存监听器(检查是否已存在,避免重复)
|
||
const exists = this.pendingListeners.some(
|
||
(listener) => listener.event === event && listener.callback === callback
|
||
);
|
||
|
||
if (!exists) {
|
||
this.pendingListeners.push({ event, callback });
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.socket.on(event, callback);
|
||
}
|
||
|
||
/**
|
||
* 移除事件监听
|
||
* @param {string} event - 事件名称
|
||
* @param {Function} callback - 回调函数(可选)
|
||
*/
|
||
off(event, callback) {
|
||
if (!this.socket) {
|
||
return;
|
||
}
|
||
|
||
if (callback) {
|
||
this.socket.off(event, callback);
|
||
} else {
|
||
this.socket.off(event);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送消息到服务器
|
||
* @param {string} event - 事件名称
|
||
* @param {*} data - 发送的数据
|
||
* @param {Function} callback - 确认回调(可选)
|
||
*/
|
||
emit(event, data, callback) {
|
||
if (!this.socket || !this.connected) {
|
||
logger.warn('socketService', 'Cannot emit: socket not connected', { event, data });
|
||
return;
|
||
}
|
||
|
||
if (callback) {
|
||
this.socket.emit(event, data, callback);
|
||
} else {
|
||
this.socket.emit(event, data);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加入房间
|
||
* @param {string} room - 房间名称
|
||
*/
|
||
joinRoom(room) {
|
||
this.emit('join_room', { room });
|
||
}
|
||
|
||
/**
|
||
* 离开房间
|
||
* @param {string} room - 房间名称
|
||
*/
|
||
leaveRoom(room) {
|
||
this.emit('leave_room', { room });
|
||
}
|
||
|
||
/**
|
||
* 获取连接状态
|
||
*/
|
||
isConnected() {
|
||
return this.connected;
|
||
}
|
||
|
||
/**
|
||
* 获取 Socket ID
|
||
*/
|
||
getSocketId() {
|
||
return this.socket?.id || null;
|
||
}
|
||
|
||
/**
|
||
* 手动重连
|
||
* @returns {boolean} 是否触发重连
|
||
*/
|
||
reconnect() {
|
||
if (!this.socket) {
|
||
logger.warn('socketService', 'Cannot reconnect: socket not initialized');
|
||
return false;
|
||
}
|
||
|
||
if (this.connected) {
|
||
logger.info('socketService', 'Already connected, no need to reconnect');
|
||
return false;
|
||
}
|
||
|
||
logger.info('socketService', 'Manually triggering reconnection...');
|
||
|
||
// 清除自动重连定时器
|
||
if (this.customReconnectTimer) {
|
||
clearTimeout(this.customReconnectTimer);
|
||
this.customReconnectTimer = null;
|
||
}
|
||
|
||
// 重置重连计数
|
||
this.reconnectAttempts = 0;
|
||
|
||
// 立即触发重连
|
||
this.socket.connect();
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 获取当前重连尝试次数
|
||
*/
|
||
getReconnectAttempts() {
|
||
return this.reconnectAttempts;
|
||
}
|
||
|
||
/**
|
||
* 获取最大重连次数
|
||
*/
|
||
getMaxReconnectAttempts() {
|
||
return this.maxReconnectAttempts;
|
||
}
|
||
|
||
// ==================== 事件推送专用方法 ====================
|
||
|
||
/**
|
||
* 订阅事件推送
|
||
* @param {object} options - 订阅选项
|
||
* @param {string} options.eventType - 事件类型 ('all' | 'policy' | 'market' | 'tech' | ...)
|
||
* @param {string} options.importance - 重要性 ('all' | 'S' | 'A' | 'B' | 'C')
|
||
* @param {Function} options.onNewEvent - 收到新事件时的回调函数
|
||
* @param {Function} options.onSubscribed - 订阅成功的回调函数(可选)
|
||
*/
|
||
subscribeToEvents(options = {}) {
|
||
const {
|
||
eventType = 'all',
|
||
importance = 'all',
|
||
onNewEvent,
|
||
onSubscribed,
|
||
} = options;
|
||
|
||
// ⚡ 改进状态检查:同时检查 this.connected 和 socket.connected
|
||
// 解决 connect 回调中 this.connected 尚未更新的竞争条件
|
||
const isReady = this.socket && (this.socket.connected || this.connected);
|
||
|
||
if (!isReady) {
|
||
logger.debug('socketService', 'Socket 尚未就绪,等待连接后订阅');
|
||
|
||
if (!this.socket) {
|
||
// 自动连接
|
||
this.connect();
|
||
}
|
||
|
||
// 等待连接成功后再订阅
|
||
this.socket.once('connect', () => {
|
||
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
|
||
});
|
||
return;
|
||
}
|
||
|
||
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
|
||
}
|
||
|
||
/**
|
||
* 执行订阅操作(内部方法)
|
||
*/
|
||
_doSubscribe(eventType, importance, onNewEvent, onSubscribed) {
|
||
// 发送订阅请求
|
||
const subscribeData = {
|
||
event_type: eventType,
|
||
importance: importance,
|
||
};
|
||
this.emit('subscribe_events', subscribeData);
|
||
|
||
// 监听订阅确认
|
||
this.socket.once('subscription_confirmed', (data) => {
|
||
if (onSubscribed) {
|
||
onSubscribed(data);
|
||
}
|
||
});
|
||
|
||
// 监听订阅错误
|
||
this.socket.once('subscription_error', (error) => {
|
||
logger.error('socketService', 'Subscription error', error);
|
||
});
|
||
|
||
// 监听新事件推送
|
||
if (onNewEvent) {
|
||
this.socket.on('new_event', (eventData) => {
|
||
onNewEvent(eventData);
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消订阅事件推送
|
||
* @param {object} options - 取消订阅选项
|
||
* @param {string} options.eventType - 事件类型
|
||
* @param {Function} options.onUnsubscribed - 取消订阅成功的回调函数(可选)
|
||
*/
|
||
unsubscribeFromEvents(options = {}) {
|
||
const {
|
||
eventType = 'all',
|
||
onUnsubscribed,
|
||
} = options;
|
||
|
||
if (!this.socket || !this.connected) {
|
||
logger.warn('socketService', 'Cannot unsubscribe: socket not connected');
|
||
return;
|
||
}
|
||
|
||
// 发送取消订阅请求
|
||
this.emit('unsubscribe_events', {
|
||
event_type: eventType,
|
||
});
|
||
|
||
// 监听取消订阅确认
|
||
this.socket.once('unsubscription_confirmed', (data) => {
|
||
this.socket.off('new_event');
|
||
if (onUnsubscribed) {
|
||
onUnsubscribed(data);
|
||
}
|
||
});
|
||
|
||
// 监听取消订阅错误
|
||
this.socket.once('unsubscription_error', (error) => {
|
||
logger.error('socketService', 'Unsubscription error', error);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 快捷方法:订阅所有类型的事件
|
||
* @param {Function} onNewEvent - 收到新事件时的回调函数(可选)
|
||
* @returns {Function} 取消订阅的函数
|
||
*/
|
||
subscribeToAllEvents(onNewEvent) {
|
||
this.subscribeToEvents({
|
||
eventType: 'all',
|
||
importance: 'all',
|
||
onNewEvent: onNewEvent || (() => {}),
|
||
});
|
||
|
||
// 返回取消订阅的清理函数
|
||
return () => {
|
||
this.unsubscribeFromEvents({ eventType: 'all' });
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 快捷方法:订阅特定重要性的事件
|
||
* @param {string} importance - 重要性级别 ('S' | 'A' | 'B' | 'C')
|
||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||
* @returns {Function} 取消订阅的函数
|
||
*/
|
||
subscribeToImportantEvents(importance, onNewEvent) {
|
||
this.subscribeToEvents({
|
||
eventType: 'all',
|
||
importance,
|
||
onNewEvent,
|
||
});
|
||
|
||
// 返回取消订阅的清理函数
|
||
return () => {
|
||
this.unsubscribeFromEvents({ eventType: 'all' });
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 快捷方法:订阅特定类型的事件
|
||
* @param {string} eventType - 事件类型
|
||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||
* @returns {Function} 取消订阅的函数
|
||
*/
|
||
subscribeToEventType(eventType, onNewEvent) {
|
||
this.subscribeToEvents({
|
||
eventType,
|
||
importance: 'all',
|
||
onNewEvent,
|
||
});
|
||
|
||
// 返回取消订阅的清理函数
|
||
return () => {
|
||
this.unsubscribeFromEvents({ eventType });
|
||
};
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const socketService = new SocketService();
|
||
|
||
export default socketService;
|