zlwu před 1 rokem
rodič
revize
592888d42a

+ 31 - 2
backend/src/config/settings.py

@@ -14,6 +14,13 @@ class Settings:
     WS_HOST: str = os.getenv("WS_HOST", "localhost")
     WS_PORT: int = int(os.getenv("WS_PORT", "8765"))
     
+    # SSL/WSS配置
+    SSL_ENABLED: bool = os.getenv("SSL_ENABLED", "false").lower() in ("true", "1", "yes")
+    WSS_PORT: int = int(os.getenv("WSS_PORT", "8766"))
+    SSL_CERT_FILE: str = os.getenv("SSL_CERT_FILE", "ssl/tolan.crt")
+    SSL_KEY_FILE: str = os.getenv("SSL_KEY_FILE", "ssl/tolan.key")
+    SSL_VERIFY_MODE: str = os.getenv("SSL_VERIFY_MODE", "none")  # none, optional, required
+    
     # 日志配置
     LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
     LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
@@ -42,6 +49,17 @@ class Settings:
     INPUT_AUDIO_SAMPLE_RATE: int = int(os.getenv("INPUT_AUDIO_SAMPLE_RATE", "16000"))
     OUTPUT_AUDIO_SAMPLE_RATE: int = int(os.getenv("OUTPUT_AUDIO_SAMPLE_RATE", "24000"))
     
+    @classmethod
+    def get_ssl_config(cls) -> Dict[str, Any]:
+        """获取SSL配置"""
+        return {
+            "enabled": cls.SSL_ENABLED,
+            "cert_file": cls.SSL_CERT_FILE,
+            "key_file": cls.SSL_KEY_FILE,
+            "verify_mode": cls.SSL_VERIFY_MODE,
+            "wss_port": cls.WSS_PORT
+        }
+    
     @classmethod
     def get_doubao_ws_config(cls) -> Dict[str, Any]:
         """获取豆包WebSocket连接配置"""
@@ -95,7 +113,7 @@ class Settings:
         settings = {}
         for attr_name in dir(cls):
             if not attr_name.startswith('_') and not callable(getattr(cls, attr_name)):
-                if attr_name != 'get_all_settings':
+                if attr_name not in ['get_all_settings', 'get_ssl_config', 'get_doubao_ws_config', 'get_doubao_session_config']:
                     settings[attr_name] = getattr(cls, attr_name)
         return settings
     
@@ -107,6 +125,9 @@ class Settings:
             if not (1 <= cls.WS_PORT <= 65535):
                 raise ValueError(f"Invalid WS_PORT: {cls.WS_PORT}")
             
+            if not (1 <= cls.WSS_PORT <= 65535):
+                raise ValueError(f"Invalid WSS_PORT: {cls.WSS_PORT}")
+            
             # 验证连接数配置
             if cls.MAX_CONNECTIONS <= 0:
                 raise ValueError(f"MAX_CONNECTIONS must be positive: {cls.MAX_CONNECTIONS}")
@@ -114,7 +135,15 @@ class Settings:
             # 验证超时配置
             if cls.CONNECTION_TIMEOUT <= 0 or cls.SESSION_TIMEOUT <= 0:
                 raise ValueError("Timeout values must be positive")
-                
+            
+            # 验证SSL配置
+            if cls.SSL_ENABLED:
+                import os
+                if not os.path.exists(cls.SSL_CERT_FILE):
+                    raise ValueError(f"SSL certificate file not found: {cls.SSL_CERT_FILE}")
+                if not os.path.exists(cls.SSL_KEY_FILE):
+                    raise ValueError(f"SSL private key file not found: {cls.SSL_KEY_FILE}")
+                    
             return True
         except Exception as e:
             print(f"Settings validation failed: {e}")

+ 1 - 1
frontend/.env

@@ -1,2 +1,2 @@
 # Tolan 前端环境配置
-REACT_APP_WS_URL=ws://localhost:8765
+REACT_APP_WS_URL=ws://192.168.2.105:8765

+ 110 - 64
frontend/src/hooks/useWebSocket.ts

@@ -13,6 +13,7 @@ import {
 } from '../store/slices/websocketSlice';
 import { setError } from '../store/slices/appSlice';
 import { WebSocketMessage } from '../types';
+import { browserCompat } from '../utils/browserCompat';
 
 interface UseWebSocketOptions {
   url?: string;
@@ -22,6 +23,60 @@ interface UseWebSocketOptions {
   onError?: (error: Error) => void;
 }
 
+/**
+ * 获取WebSocket连接URL,支持自动协议检测
+ * @param customUrl 自定义URL
+ * @returns WebSocket连接URL
+ */
+const getWebSocketUrl = (customUrl?: string): string => {
+  let baseUrl: string;
+  
+  if (customUrl) {
+    baseUrl = customUrl;
+  } else {
+    // 从环境变量获取配置
+    const envUrl = process.env.REACT_APP_WS_URL;
+    if (envUrl) {
+      // 如果环境变量中使用了IP地址,但当前页面是localhost,则使用localhost替换
+      if (window.location.hostname === 'localhost' && envUrl.includes('://192.168.')) {
+        const urlParts = envUrl.split('://');
+        const protocolPart = urlParts[0];
+        const addressPart = urlParts[1];
+        const portMatch = addressPart.match(/:(\d+)$/);
+        const port = portMatch ? portMatch[1] : (protocolPart === 'wss' ? '8766' : '8765');
+        baseUrl = `${protocolPart}://localhost:${port}`;
+        console.log('[WebSocket] 检测到localhost环境,将IP地址替换为localhost:', baseUrl);
+      } else {
+        baseUrl = envUrl;
+      }
+    } else {
+      // 自动协议检测
+      const isHttps = window.location.protocol === 'https:';
+      const protocol = isHttps ? 'wss:' : 'ws:';
+      const hostname = window.location.hostname;
+      
+      // 根据协议选择端口
+      const defaultPort = isHttps ? '8766' : '8765';
+      const port = process.env.REACT_APP_WS_PORT || defaultPort;
+      
+      baseUrl = `${protocol}//${hostname}:${port}`;
+    }
+  }
+  
+  // 使用浏览器兼容性管理器处理URL
+  const compatibleUrl = browserCompat.getCompatibleWebSocketUrl(baseUrl);
+  
+  console.log('WebSocket自动协议检测:', {
+    pageProtocol: window.location.protocol,
+    hostname: window.location.hostname,
+    baseUrl,
+    compatibleUrl,
+    browserInfo: browserCompat.getBrowserInfo()
+  });
+  
+  return compatibleUrl;
+};
+
 export const useWebSocket = (options: UseWebSocketOptions = {}) => {
   const dispatch = useDispatch();
   const { isConnected, connectionStatus, reconnectAttempts, session } = useSelector(
@@ -30,7 +85,6 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
   
   const wsRef = useRef<WebSocketService | null>(null);
   const chunkCounterRef = useRef(0);
-  const defaultUrl = process.env.REACT_APP_WS_URL || 'ws://localhost:8765';
   const connectingRef = useRef(false); // 防止重复连接
   const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null); // 错误提示延迟
 
@@ -42,7 +96,7 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
       return;
     }
 
-    const wsUrl = url || options.url || defaultUrl;
+    const wsUrl = getWebSocketUrl(url || options.url);
     
     try {
       connectingRef.current = true;
@@ -54,6 +108,11 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
       }
 
       // 设置事件监听器
+      // 设置音频响应回调
+      if (options.onAudioResponse) {
+        wsRef.current.onAudioResponse(options.onAudioResponse);
+        console.log('[useWebSocket] 音频响应回调已设置');
+      }
       wsRef.current.onOpen(() => {
         console.log('useWebSocket: WebSocket连接已建立,设置状态为已连接');
         connectingRef.current = false; // 重置连接状态
@@ -93,82 +152,69 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
 
       wsRef.current.onMessage((data) => {
         if (data instanceof ArrayBuffer) {
-          // 处理音频响应
-          options.onAudioResponse?.(data);
+          // 处理音频数据
+          chunkCounterRef.current++;
+          console.log(`[useWebSocket] 收到音频chunk ${chunkCounterRef.current}:`, data.byteLength, 'bytes');
+          options.onMessage?.(data);
         } else {
-          // 处理其他消息
+          // 处理文本消息
           try {
-            const message: WebSocketMessage = JSON.parse(data as string);
-            handleMessage(message);
+            const message: WebSocketMessage = JSON.parse(data);
+            console.log('[useWebSocket] 收到消息:', message);
+            
+            switch (message.type) {
+              case 'session_init_response':
+                if (message.data?.session_id) {
+                  dispatch(setSession(message.data.session_id));
+                  console.log('[useWebSocket] 会话已初始化:', message.data.session_id);
+                }
+                break;
+              
+              case 'latency_response':
+                if (typeof message.data?.latency === 'number') {
+                  dispatch(setLatency(message.data.latency));
+                }
+                break;
+              
+              case 'session_init':
+                console.log('[useWebSocket] 收到会话初始化响应:', message);
+                if (message.session_id) {
+                  dispatch(setSession({
+                    id: message.session_id,
+                    userId: message.user_id,
+                    language: message.language || 'zh-CN',
+                    startTime: Date.now(),
+                    lastActivity: Date.now(),
+                    isActive: true,
+                  }));
+                  console.log('[useWebSocket] 会话状态已更新到Redux');
+                }
+                break;
+              
+              default:
+                options.onMessage?.(data);
+                break;
+            }
           } catch (error) {
-            // 直接传递原始数据
+            console.error('[useWebSocket] 解析消息失败:', error, data);
             options.onMessage?.(data);
           }
         }
       });
 
-      // 设置音频响应回调
-      if (options.onAudioResponse) {
-        wsRef.current.onAudioResponse(options.onAudioResponse);
-      }
-
+      // 尝试连接
       await wsRef.current.connect(wsUrl);
+      console.log(`[useWebSocket] 成功连接到 ${wsUrl}`);
 
     } catch (error) {
-      console.error('WebSocket连接失败:', error);
       connectingRef.current = false; // 重置连接状态
-      
-      // 延迟显示错误,给重连机会
-      if (errorTimeoutRef.current) {
-        clearTimeout(errorTimeoutRef.current);
-      }
-      
-      errorTimeoutRef.current = setTimeout(() => {
-        if (!isConnected) {
-          dispatch(setError(error instanceof Error ? error.message : 'WebSocket连接失败'));
-          dispatch(setConnected(false));
-        }
-      }, 3000); // 3秒延迟
+      console.error(`[useWebSocket] 连接失败:`, error);
+      dispatch(setError(`连接失败: ${error instanceof Error ? error.message : '未知错误'}`));
+      throw error;
     }
-  }, [options, defaultUrl, dispatch, isConnected]);
-
-  // 处理WebSocket消息
-  const handleMessage = useCallback((message: WebSocketMessage) => {
-    console.log('[useWebSocket] 处理消息:', message.type, message);
-    
-    switch (message.type) {
-      case 'session_init':
-        console.log('[useWebSocket] 收到会话初始化响应:', message);
-        if (message.session_id) {
-          dispatch(setSession({
-            id: message.session_id,
-            userId: message.user_id,
-            language: message.language || 'zh-CN',
-            startTime: Date.now(),
-            lastActivity: Date.now(),
-            isActive: true,
-          }));
-          console.log('[useWebSocket] 会话状态已更新到Redux');
-        }
-        break;
+  }, [dispatch, options, isConnected]);
 
-      case 'pong':
-        if (message.timestamp) {
-          const latency = Date.now() - message.timestamp;
-          dispatch(setLatency(latency));
-        }
-        break;
-
-      case 'error':
-        console.error('[useWebSocket] 收到错误消息:', message);
-        dispatch(setError(message.message || '服务器错误'));
-        break;
-
-      default:
-        console.log('[useWebSocket] 未处理的消息类型:', message.type);
-        options.onMessage?.(JSON.stringify(message));
-    }
-  }, [dispatch, options]);
+  // 处理WebSocket消息的逻辑已集成到WebSocketManager中
 
   // 断开连接
   const disconnect = useCallback(() => {

+ 205 - 0
frontend/src/utils/browserCompat.ts

@@ -0,0 +1,205 @@
+/**
+ * 浏览器兼容性管理器
+ * 处理不同浏览器的WebSocket连接兼容性问题
+ */
+
+interface BrowserInfo {
+  name: string;
+  version: string;
+  isChrome: boolean;
+  isFirefox: boolean;
+  isSafari: boolean;
+  isEdge: boolean;
+  platform: string;
+  supportsWebSocket: boolean;
+}
+
+class BrowserCompatManager {
+  private browserInfo: BrowserInfo;
+
+  constructor() {
+    this.browserInfo = this.detectBrowser();
+  }
+
+  /**
+   * 检测浏览器信息
+   */
+  private detectBrowser(): BrowserInfo {
+    const userAgent = navigator.userAgent;
+    const platform = navigator.platform;
+    
+    // 检测浏览器类型和版本
+    let name = 'Unknown';
+    let version = 'Unknown';
+    let isChrome = false;
+    let isFirefox = false;
+    let isSafari = false;
+    let isEdge = false;
+
+    if (userAgent.includes('Chrome') && !userAgent.includes('Edg')) {
+      name = 'Chrome';
+      isChrome = true;
+      const match = userAgent.match(/Chrome\/(\d+)/);
+      version = match ? match[1] : 'Unknown';
+    } else if (userAgent.includes('Firefox')) {
+      name = 'Firefox';
+      isFirefox = true;
+      const match = userAgent.match(/Firefox\/(\d+)/);
+      version = match ? match[1] : 'Unknown';
+    } else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
+      name = 'Safari';
+      isSafari = true;
+      const match = userAgent.match(/Version\/(\d+)/);
+      version = match ? match[1] : 'Unknown';
+    } else if (userAgent.includes('Edg')) {
+      name = 'Edge';
+      isEdge = true;
+      const match = userAgent.match(/Edg\/(\d+)/);
+      version = match ? match[1] : 'Unknown';
+    }
+
+    return {
+      name,
+      version,
+      isChrome,
+      isFirefox,
+      isSafari,
+      isEdge,
+      platform,
+      supportsWebSocket: 'WebSocket' in window
+    };
+  }
+
+  /**
+   * 获取浏览器信息
+   */
+  getBrowserInfo(): BrowserInfo {
+    return { ...this.browserInfo };
+  }
+
+  /**
+   * 获取兼容的WebSocket URL
+   * 根据不同浏览器和环境调整WebSocket连接URL
+   */
+  getCompatibleWebSocketUrl(baseUrl: string): string {
+    let compatibleUrl = baseUrl;
+
+    // 确保URL格式正确
+    if (!compatibleUrl.startsWith('ws://') && !compatibleUrl.startsWith('wss://')) {
+      // 如果没有协议,根据当前页面协议自动添加
+      const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
+      compatibleUrl = protocol + compatibleUrl.replace(/^\/+/, '');
+    }
+
+    // Safari特殊处理
+    if (this.browserInfo.isSafari) {
+      // Safari可能对某些WebSocket URL格式比较敏感
+      compatibleUrl = this.normalizeSafariUrl(compatibleUrl);
+    }
+
+    // Chrome特殊处理
+    if (this.browserInfo.isChrome) {
+      // Chrome对混合内容比较严格
+      compatibleUrl = this.normalizeChromeMixedContent(compatibleUrl);
+    }
+
+    // Firefox特殊处理
+    if (this.browserInfo.isFirefox) {
+      // Firefox对WebSocket连接有自己的特点
+      compatibleUrl = this.normalizeFirefoxUrl(compatibleUrl);
+    }
+
+    return compatibleUrl;
+  }
+
+  /**
+   * Safari URL标准化
+   */
+  private normalizeSafariUrl(url: string): string {
+    // Safari可能需要确保端口号格式正确
+    return url.replace(/([^:])\/+/g, '$1/');
+  }
+
+  /**
+   * Chrome混合内容处理
+   */
+  private normalizeChromeMixedContent(url: string): string {
+    // 如果当前页面是HTTPS,确保WebSocket使用WSS
+    if (window.location.protocol === 'https:' && url.startsWith('ws://')) {
+      // 检查是否是本地开发环境
+      const isLocalhost = window.location.hostname === 'localhost' || 
+                         window.location.hostname === '127.0.0.1' ||
+                         window.location.hostname.startsWith('192.168.');
+      
+      if (!isLocalhost) {
+        // 非本地环境强制使用WSS
+        url = url.replace('ws://', 'wss://');
+        console.warn('[BrowserCompat] Chrome HTTPS环境下强制使用WSS协议');
+      }
+    }
+    return url;
+  }
+
+  /**
+   * Firefox URL标准化
+   */
+  private normalizeFirefoxUrl(url: string): string {
+    // Firefox通常对WebSocket URL比较宽松,但确保格式标准
+    return url;
+  }
+
+  /**
+   * 检查WebSocket支持
+   */
+  checkWebSocketSupport(): boolean {
+    return this.browserInfo.supportsWebSocket;
+  }
+
+  /**
+   * 获取推荐的WebSocket配置
+   */
+  getRecommendedConfig(): { 
+    maxReconnectAttempts: number;
+    reconnectInterval: number;
+    pingInterval: number;
+  } {
+    // 根据不同浏览器返回推荐配置
+    const baseConfig = {
+      maxReconnectAttempts: 5,
+      reconnectInterval: 1000,
+      pingInterval: 30000
+    };
+
+    if (this.browserInfo.isSafari) {
+      // Safari可能需要更频繁的心跳
+      return {
+        ...baseConfig,
+        pingInterval: 20000
+      };
+    }
+
+    if (this.browserInfo.isFirefox) {
+      // Firefox可能需要更多重连尝试
+      return {
+        ...baseConfig,
+        maxReconnectAttempts: 7
+      };
+    }
+
+    return baseConfig;
+  }
+
+  /**
+   * 记录浏览器兼容性信息
+   */
+  logCompatibilityInfo(): void {
+    console.log('[BrowserCompat] 浏览器兼容性信息:', {
+      browser: this.browserInfo,
+      webSocketSupport: this.checkWebSocketSupport(),
+      recommendedConfig: this.getRecommendedConfig()
+    });
+  }
+}
+
+// 创建单例实例
+export const browserCompat = new BrowserCompatManager();