// API 配置 const API_BASE_URL = 'http://localhost:8888/api/v1'; // Centrifugo WebSocket URL - 默認使用 8000 端口(與 HTTP API 同端口) // 如果配置了不同的 WebSocket 端口,請修改此處 const CENTRIFUGO_WS_URL = 'ws://localhost:8000/connection/websocket'; // 應用狀態 let appState = { uid: null, token: null, centrifugoToken: null, expireAt: null, roomID: null, centrifugoClient: null, matchStatus: null, wsRetryCount: 0 }; // 工具函數 function log(message, type = 'info') { const logContainer = document.getElementById('logContainer'); const entry = document.createElement('div'); entry.className = `log-entry ${type}`; entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; logContainer.appendChild(entry); logContainer.scrollTop = logContainer.scrollHeight; console.log(`[${type.toUpperCase()}]`, message); } function showSection(sectionId) { document.querySelectorAll('.section').forEach(section => { section.classList.add('hidden'); }); document.getElementById(sectionId).classList.remove('hidden'); } // API 調用函數 async function apiCall(endpoint, method = 'GET', body = null, needAuth = false) { const options = { method, headers: { 'Content-Type': 'application/json', } }; if (needAuth && appState.token) { options.headers['Authorization'] = `Bearer ${appState.token}`; } if (body) { options.body = JSON.stringify(body); } try { const response = await fetch(`${API_BASE_URL}${endpoint}`, options); // 先讀取響應文本 const text = await response.text(); // 嘗試解析 JSON let data; try { data = text ? JSON.parse(text) : {}; } catch (parseError) { // 如果不是有效的 JSON,使用原始文本作為錯誤信息 log(`響應不是有效的 JSON: ${text}`, 'error'); if (!response.ok) { throw new Error(text || `HTTP ${response.status}`); } data = {}; } if (!response.ok) { const errorMsg = data.message || data.error || data.error?.message || text || `HTTP ${response.status}`; log(`API 錯誤: ${errorMsg}`, 'error'); throw new Error(errorMsg); } return data; } catch (error) { if (error instanceof TypeError && error.message.includes('fetch')) { log(`網路錯誤: ${error.message}`, 'error'); throw new Error('網路連接失敗,請檢查後端服務是否運行'); } log(`API 錯誤: ${error.message}`, 'error'); throw error; } } // 匿名登入 async function handleLogin() { const userName = document.getElementById('userName').value || 'Anonymous'; const btn = document.getElementById('loginBtn'); btn.disabled = true; btn.textContent = '登入中...'; try { log('開始匿名登入...', 'info'); const response = await apiCall('/auth/anon', 'POST', { name: userName }); // 調試:檢查響應內容 log(`登入響應: uid=${response.uid}, hasToken=${!!response.token}, hasCentrifugoToken=${!!response.centrifugo_token}`, 'info'); appState.uid = response.uid; appState.token = response.token; appState.centrifugoToken = response.centrifugo_token; appState.expireAt = response.expire_at; // 驗證 token 是否正確獲取 if (!appState.centrifugoToken) { log('警告:未獲取到 Centrifugo token,請檢查後端響應', 'error'); log(`完整響應: ${JSON.stringify(response)}`, 'error'); } else { const tokenPreview = appState.centrifugoToken.substring(0, 20) + '...'; log(`已獲取 Centrifugo token (前20字符): ${tokenPreview}`, 'info'); } document.getElementById('userUID').textContent = appState.uid; log(`登入成功!UID: ${appState.uid}`, 'success'); showSection('matchingSection'); } catch (error) { log(`登入失敗: ${error.message}`, 'error'); alert('登入失敗,請重試'); } finally { btn.disabled = false; btn.textContent = '開始聊天'; } } // 加入配對 async function handleJoinMatch() { const btn = document.getElementById('joinMatchBtn'); btn.disabled = true; btn.textContent = '配對中...'; try { log('加入配對佇列...', 'info'); const response = await apiCall('/matchmaking/join', 'POST', null, true); appState.matchStatus = response.status; document.getElementById('matchStatus').textContent = response.status === 'waiting' ? '等待配對中...' : '已配對!'; log(`配對狀態: ${response.status}`, response.status === 'matched' ? 'success' : 'info'); if (response.status === 'matched') { // 如果立即配對成功,需要查詢 roomID await handleCheckStatus(); } else { // 開始輪詢狀態 startStatusPolling(); } } catch (error) { log(`加入配對失敗: ${error.message}`, 'error'); } finally { btn.disabled = false; btn.textContent = '加入配對'; } } // 檢查配對狀態 async function handleCheckStatus() { try { const response = await apiCall('/matchmaking/status', 'GET', null, true); appState.matchStatus = response.status; document.getElementById('matchStatus').textContent = response.status === 'waiting' ? '等待配對中...' : '已配對!'; if (response.status === 'matched' && response.room_id) { appState.roomID = response.room_id; document.getElementById('roomID').textContent = response.room_id; log(`配對成功!房間 ID: ${response.room_id}`, 'success'); // 停止輪詢,進入聊天室 stopStatusPolling(); await enterChatRoom(); } } catch (error) { log(`檢查狀態失敗: ${error.message}`, 'error'); } } // 開始狀態輪詢 let pollingInterval = null; function startStatusPolling() { if (pollingInterval) return; log('開始輪詢配對狀態...', 'info'); pollingInterval = setInterval(async () => { await handleCheckStatus(); }, 2000); // 每 2 秒檢查一次 } function stopStatusPolling() { if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; log('停止輪詢配對狀態', 'info'); } } // 進入聊天室 async function enterChatRoom() { showSection('chatSection'); // 進入聊天室前先刷新 token,以確保獲取到最新的房間權限 // 因為匿名登入時還不知道房間ID,所以當時的 token 沒有房間權限 await handleRefreshToken(); // 連接到 Centrifugo connectToCentrifugo(); // 載入歷史訊息 await loadHistoryMessages(); } // 連接到 Centrifugo function connectToCentrifugo() { if (!appState.centrifugoToken || !appState.roomID) { log(`無法連接 Centrifugo:缺少 token 或 roomID (token: ${appState.centrifugoToken ? '存在' : '不存在'}, roomID: ${appState.roomID || '不存在'})`, 'error'); return; } try { // 關閉舊連接 if (appState.centrifugoClient) { appState.centrifugoClient.close(); } // 檢查 token 是否過期 if (appState.expireAt) { const now = Math.floor(Date.now() / 1000); if (now >= appState.expireAt) { log('Centrifugo token 已過期,請先刷新 token', 'error'); return; } const timeUntilExpiry = appState.expireAt - now; log(`Centrifugo token 將在 ${Math.floor(timeUntilExpiry / 60)} 分鐘後過期`, 'info'); } // 記錄 token 前幾個字符用於調試(不記錄完整 token 以保護安全) const tokenPreview = appState.centrifugoToken.substring(0, 20) + '...'; log(`正在連接 Centrifugo,使用 token: ${tokenPreview}`, 'info'); // Centrifugo WebSocket 連線(使用 JSON 格式) // Centrifugo v5: 必須先發送 connect 命令進行認證 const wsUrl = `${CENTRIFUGO_WS_URL}?format=json`; log(`WebSocket URL: ${wsUrl}`, 'info'); const ws = new WebSocket(wsUrl); let messageId = 1; let isConnected = false; ws.onopen = () => { log('WebSocket 連接已建立,正在發送認證請求...', 'info'); // 重置重試計數 appState.wsRetryCount = 0; // Centrifugo v5: 必須先發送 connect 命令進行認證 const connectMsg = { id: messageId++, connect: { token: appState.centrifugoToken } }; ws.send(JSON.stringify(connectMsg)); log('已發送 connect 認證請求', 'info'); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); // 跳過空回應(心跳 pong)和只有 id 的回應 const isEmptyOrPong = Object.keys(data).length === 0 || (Object.keys(data).length === 1 && data.id); // 只記錄有意義的訊息(非心跳、非空回應) if (!isEmptyOrPong && !data.subscribe) { log(`收到 Centrifugo 訊息: ${JSON.stringify(data)}`, 'info'); } // Centrifugo v5 JSON 協議回應格式 // 處理 connect 回應 if (data.connect) { isConnected = true; appState.wsRetryCount = 0; // 重置重連計數 log(`已成功連接到 Centrifugo,client: ${data.connect.client}`, 'success'); // 設置心跳定時器(Centrifugo 要求客戶端發送 ping) const pingInterval = (data.connect.ping || 25) * 1000; if (appState.pingTimer) { clearInterval(appState.pingTimer); } appState.pingTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { // 發送空物件作為 ping ws.send('{}'); } }, pingInterval); log(`已設置心跳間隔: ${pingInterval / 1000} 秒`, 'info'); // 檢查是否已經通過 token 自動訂閱了房間頻道 const roomChannel = `room:${appState.roomID}`; if (data.connect.subs && data.connect.subs[roomChannel]) { log(`已通過 token 自動訂閱頻道: ${roomChannel}`, 'success'); } else { // 需要手動訂閱 const subscribeMsg = { id: messageId++, subscribe: { channel: roomChannel } }; ws.send(JSON.stringify(subscribeMsg)); log(`已發送訂閱請求: ${roomChannel}`, 'info'); } } // 處理 subscribe 回應 else if (data.subscribe) { log(`成功訂閱頻道`, 'success'); } // 處理錯誤 else if (data.error) { // code 105 = already subscribed,這不是真正的錯誤 if (data.error.code === 105) { log(`頻道已訂閱 (這是正常的)`, 'info'); } else { log(`Centrifugo 錯誤: code=${data.error.code}, message=${data.error.message}`, 'error'); if (data.error.code === 109) { log('Token 驗證失敗,請檢查 token 是否正確', 'error'); } else if (data.error.code === 103) { log('頻道訂閱權限不足', 'error'); } } } // 處理 push 訊息(新訊息推送) else if (data.push) { const push = data.push; if (push.pub) { // 收到發布的訊息 const message = push.pub.data; if (message && typeof message === 'object') { displayMessage(message); log(`收到新訊息: ${message.content || JSON.stringify(message)}`, 'info'); } } else if (push.join) { log(`用戶 ${push.join.user} 加入了房間`, 'info'); } else if (push.leave) { log(`用戶 ${push.leave.user} 離開了房間`, 'info'); } } // 處理 disconnect else if (data.disconnect) { log(`Centrifugo 服務器主動斷開連接: ${data.disconnect.reason}`, 'error'); } } catch (error) { log(`處理 WebSocket 訊息錯誤: ${error.message}`, 'error'); log(`原始訊息: ${event.data}`, 'error'); } }; ws.onerror = (error) => { log(`WebSocket 錯誤: ${error.message || error}`, 'error'); // 記錄更多錯誤信息 if (error.target && error.target.readyState === WebSocket.CLOSED) { log('WebSocket 連接已關閉', 'error'); } }; ws.onclose = (event) => { const reason = event.reason || '無'; log(`WebSocket 連線已關閉 (code: ${event.code}, reason: ${reason})`, 'info'); // 清除心跳定時器 if (appState.pingTimer) { clearInterval(appState.pingTimer); appState.pingTimer = null; } // 1000 表示正常關閉(主動斷開) const normalCloseCodes = [1000]; if (event.code === 1006) { log('WebSocket 異常關閉,可能是網路問題或服務中斷', 'error'); } // 非正常關閉且還在房間中,自動重連 if (!normalCloseCodes.includes(event.code) && appState.roomID && appState.centrifugoToken) { const retryCount = appState.wsRetryCount || 0; const maxRetries = 10; // 增加最大重試次數 if (retryCount < maxRetries) { appState.wsRetryCount = retryCount + 1; // 使用指數退避策略,最長等待 30 秒 const delay = Math.min(1000 * Math.pow(1.5, retryCount), 30000); log(`將在 ${Math.round(delay / 1000)} 秒後重新連接... (${retryCount + 1}/${maxRetries})`, 'info'); setTimeout(() => { if (appState.roomID && appState.centrifugoToken) { connectToCentrifugo(); } }, delay); } else { log('WebSocket 重連次數已達上限,請刷新頁面重試', 'error'); } } }; appState.centrifugoClient = ws; } catch (error) { log(`連接 Centrifugo 失敗: ${error.message}`, 'error'); } } // 載入歷史訊息 async function loadHistoryMessages() { try { log('載入歷史訊息...', 'info'); // 使用查詢參數傳遞 page_size 和 page_index const response = await apiCall( `/rooms/${appState.roomID}/messages?page_size=20&page_index=1`, 'GET', null, true ); const messages = response.data || []; messages.reverse(); // 從舊到新顯示 messages.forEach(msg => { displayMessage(msg); }); log(`已載入 ${messages.length} 條歷史訊息`, 'success'); } catch (error) { log(`載入歷史訊息失敗: ${error.message}`, 'error'); // 即使載入失敗也不影響聊天功能 } } // 顯示訊息 function displayMessage(message) { const container = document.getElementById('messagesContainer'); const messageDiv = document.createElement('div'); messageDiv.className = `message ${message.uid === appState.uid ? 'own' : ''}`; const date = new Date(message.timestamp); messageDiv.innerHTML = `
`; container.appendChild(messageDiv); container.scrollTop = container.scrollHeight; } // 發送訊息 async function handleSendMessage() { const input = document.getElementById('messageInput'); const content = input.value.trim(); if (!content) { alert('請輸入訊息內容'); return; } if (!appState.roomID) { alert('尚未加入房間'); return; } const btn = document.getElementById('sendBtn'); btn.disabled = true; try { const clientMsgID = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; await apiCall( `/rooms/${appState.roomID}/messages`, 'POST', { content: content, client_msg_id: clientMsgID }, true ); log(`訊息已發送: ${content}`, 'success'); input.value = ''; } catch (error) { log(`發送訊息失敗: ${error.message}`, 'error'); alert('發送訊息失敗,請重試'); } finally { btn.disabled = false; input.focus(); } } // 刷新 Token async function handleRefreshToken() { if (!appState.token) { alert('請先登入'); return; } const btn = document.getElementById('refreshTokenBtn'); btn.disabled = true; btn.textContent = '刷新中...'; try { log('刷新 Token...', 'info'); const response = await apiCall('/auth/refresh', 'POST', { token: appState.token }); appState.token = response.token; appState.centrifugoToken = response.centrifugo_token; appState.expireAt = response.expire_at; // 驗證新 token 是否正確獲取 if (!appState.centrifugoToken) { log('警告:刷新後未獲取到 Centrifugo token', 'error'); } else { const tokenPreview = appState.centrifugoToken.substring(0, 20) + '...'; log(`已獲取新的 Centrifugo token: ${tokenPreview}`, 'info'); } log('Token 刷新成功!', 'success'); // 重新連接 Centrifugo if (appState.centrifugoClient) { appState.centrifugoClient.close(); } if (appState.roomID) { connectToCentrifugo(); } } catch (error) { log(`刷新 Token 失敗: ${error.message}`, 'error'); alert('Token 刷新失敗,請重新登入'); } finally { btn.disabled = false; btn.textContent = '刷新 Token'; } } // 鍵盤事件處理 function handleKeyPress(event) { if (event.key === 'Enter') { handleSendMessage(); } } // HTML 轉義 function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // 檢查 Token 過期 function checkTokenExpiry() { if (appState.expireAt) { const now = Math.floor(Date.now() / 1000); const timeUntilExpiry = appState.expireAt - now; if (timeUntilExpiry < 0) { log('Token 已過期', 'error'); alert('Token 已過期,請刷新或重新登入'); } else if (timeUntilExpiry < 300) { // 5 分鐘內過期 log(`Token 將在 ${Math.floor(timeUntilExpiry / 60)} 分鐘後過期,建議刷新`, 'info'); } } } // 定期檢查 Token 過期 setInterval(checkTokenExpiry, 60000); // 每分鐘檢查一次 // 初始化 log('應用程式已載入', 'info');