587 lines
20 KiB
JavaScript
587 lines
20 KiB
JavaScript
// 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 = `
|
||
<div class="message-header">
|
||
<span>${message.uid === appState.uid ? '我' : '對方'}</span>
|
||
<span>${date.toLocaleTimeString()}</span>
|
||
</div>
|
||
<div class="message-content">${escapeHtml(message.content)}</div>
|
||
`;
|
||
|
||
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');
|
||
|