update:新增比赛服断线重连机制
This commit is contained in:
+232
-12
@@ -24,11 +24,20 @@ let isConnecting = false;
|
||||
let manualClose = false;
|
||||
let audioAckListenerReady = false;
|
||||
let lastReadyRouteKey = "";
|
||||
let reconnectTimer = null;
|
||||
let connectTimeoutTimer = null;
|
||||
let livenessTimer = null;
|
||||
let reconnectAttempt = 0;
|
||||
let networkConnected = true;
|
||||
let lastNetworkType = "";
|
||||
|
||||
// 后端要求非心跳消息必须等前端语音播报完成后再 ACK。
|
||||
const pendingAcks = [];
|
||||
const ACK_AUDIO_TIMEOUT_MS = 9000;
|
||||
const READY_ROUTE_FALLBACK_DELAY_MS = 300;
|
||||
const CONNECT_TIMEOUT_MS = 8000;
|
||||
const LIVENESS_TIMEOUT_MS = 30000;
|
||||
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000];
|
||||
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
|
||||
const MATCH_STATUS_HALF_REST = 3;
|
||||
|
||||
@@ -47,6 +56,7 @@ const BUSINESS_TYPE_BY_SERVER_TYPE = {
|
||||
|
||||
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
|
||||
export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack";
|
||||
export const MATCH_WS_STATE_EVENT = "match-ws-state";
|
||||
|
||||
function normalizeShootData(shootData) {
|
||||
if (!shootData || typeof shootData !== "object") return shootData;
|
||||
@@ -382,6 +392,166 @@ function normalizeServerUrl(serverAddr, token) {
|
||||
return appendQuery(url, "token", token);
|
||||
}
|
||||
|
||||
function emitMatchSocketState(state, extra = {}) {
|
||||
uni.$emit(MATCH_WS_STATE_EVENT, {
|
||||
state,
|
||||
matchId: currentContext?.matchId || "",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (!reconnectTimer) return;
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
function clearConnectTimeout() {
|
||||
if (!connectTimeoutTimer) return;
|
||||
clearTimeout(connectTimeoutTimer);
|
||||
connectTimeoutTimer = null;
|
||||
}
|
||||
|
||||
function clearLivenessTimer() {
|
||||
if (!livenessTimer) return;
|
||||
clearTimeout(livenessTimer);
|
||||
livenessTimer = null;
|
||||
}
|
||||
|
||||
function clearSocketHealthTimers() {
|
||||
clearConnectTimeout();
|
||||
clearLivenessTimer();
|
||||
}
|
||||
|
||||
function closeSocketTask(socketTask, reason) {
|
||||
if (!socketTask) return;
|
||||
try {
|
||||
socketTask.close({ reason });
|
||||
} catch (err) {
|
||||
console.log("[match-ws] close socket task failed", reason, err);
|
||||
}
|
||||
}
|
||||
|
||||
function detachCurrentSocket(reason) {
|
||||
const currentSocket = socket;
|
||||
socket = null;
|
||||
isConnecting = false;
|
||||
clearSocketHealthTimers();
|
||||
closeSocketTask(currentSocket, reason);
|
||||
}
|
||||
|
||||
function scheduleReconnect(reason, { immediate = false } = {}) {
|
||||
if (
|
||||
manualClose ||
|
||||
!currentContext ||
|
||||
!networkConnected ||
|
||||
reconnectTimer ||
|
||||
socket ||
|
||||
isConnecting
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = immediate
|
||||
? 0
|
||||
: RECONNECT_DELAYS_MS[
|
||||
Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)
|
||||
];
|
||||
const attempt = reconnectAttempt + 1;
|
||||
reconnectAttempt = attempt;
|
||||
emitMatchSocketState("reconnecting", { reason, delay, attempt });
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (manualClose || !currentContext || !networkConnected) return;
|
||||
connectMatchWebSocket({
|
||||
...currentContext,
|
||||
force: true,
|
||||
reconnecting: true,
|
||||
reconnectReason: reason,
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function handleUnexpectedDisconnect(
|
||||
socketTask,
|
||||
reason,
|
||||
detail,
|
||||
{ closeTask = true } = {}
|
||||
) {
|
||||
if (socket !== socketTask) return;
|
||||
|
||||
socket = null;
|
||||
isConnecting = false;
|
||||
clearSocketHealthTimers();
|
||||
if (closeTask) closeSocketTask(socketTask, reason);
|
||||
|
||||
console.log("[match-ws] unexpected disconnect", {
|
||||
reason,
|
||||
detail,
|
||||
context: currentContext,
|
||||
});
|
||||
emitMatchSocketState(networkConnected ? "disconnected" : "offline", {
|
||||
reason,
|
||||
});
|
||||
scheduleReconnect(reason);
|
||||
}
|
||||
|
||||
function startConnectTimeout(socketTask) {
|
||||
clearConnectTimeout();
|
||||
connectTimeoutTimer = setTimeout(() => {
|
||||
handleUnexpectedDisconnect(socketTask, "connect-timeout");
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function resetLivenessTimer(socketTask) {
|
||||
clearLivenessTimer();
|
||||
livenessTimer = setTimeout(() => {
|
||||
handleUnexpectedDisconnect(socketTask, "heartbeat-timeout");
|
||||
}, LIVENESS_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export function forceReconnectMatchWebSocket(reason = "force-reconnect") {
|
||||
if (manualClose || !currentContext) return false;
|
||||
|
||||
clearReconnectTimer();
|
||||
detachCurrentSocket(reason);
|
||||
if (!networkConnected) {
|
||||
emitMatchSocketState("offline", { reason });
|
||||
return false;
|
||||
}
|
||||
|
||||
reconnectAttempt = 0;
|
||||
scheduleReconnect(reason, { immediate: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function handleMatchNetworkStatusChange(status = {}) {
|
||||
const previousConnected = networkConnected;
|
||||
const networkType = String(status.networkType || "");
|
||||
const nextConnected =
|
||||
status.isConnected !== false && networkType.toLowerCase() !== "none";
|
||||
const networkTypeChanged =
|
||||
!!lastNetworkType && !!networkType && lastNetworkType !== networkType;
|
||||
|
||||
networkConnected = nextConnected;
|
||||
if (networkType) lastNetworkType = networkType;
|
||||
if (manualClose || !currentContext) return;
|
||||
|
||||
if (!nextConnected) {
|
||||
clearReconnectTimer();
|
||||
detachCurrentSocket("network-offline");
|
||||
emitMatchSocketState("offline", { reason: "network-offline" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!previousConnected || networkTypeChanged) {
|
||||
forceReconnectMatchWebSocket(
|
||||
networkTypeChanged ? "network-type-changed" : "network-recovered"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 统一发送二进制 protobuf 消息,并在日志里保留发送类型。
|
||||
function sendBuffer(buffer, label, clientMessage) {
|
||||
if (!socket || !buffer) return;
|
||||
@@ -395,6 +565,7 @@ function sendBuffer(buffer, label, clientMessage) {
|
||||
fail: (err) => {
|
||||
if (socket !== currentSocket) return;
|
||||
console.log("比赛服 WebSocket 消息发送失败", label, clientMessage, err);
|
||||
handleUnexpectedDisconnect(currentSocket, "send-failed", err);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -582,7 +753,16 @@ function sendLeave() {
|
||||
|
||||
export function connectMatchWebSocket(options = {}) {
|
||||
// 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。
|
||||
const { serverAddr, matchId, userId, token, mode } = options;
|
||||
const {
|
||||
serverAddr,
|
||||
matchId,
|
||||
userId,
|
||||
token,
|
||||
mode,
|
||||
force = false,
|
||||
reconnecting = false,
|
||||
reconnectReason = "",
|
||||
} = options;
|
||||
const url = normalizeServerUrl(serverAddr, token);
|
||||
const normalizedMatchId = normalizeId(matchId);
|
||||
const normalizedUserId = normalizeId(userId);
|
||||
@@ -593,11 +773,10 @@ export function connectMatchWebSocket(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
socket &&
|
||||
const isSameContext =
|
||||
currentContext?.url === url &&
|
||||
currentContext?.matchId === normalizedMatchId
|
||||
) {
|
||||
currentContext?.matchId === normalizedMatchId;
|
||||
if (!force && socket && isSameContext) {
|
||||
// 同一场比赛同一地址重复通知时不重复建连。
|
||||
console.log("[match-ws] already connected or connecting", {
|
||||
matchId: normalizedMatchId,
|
||||
@@ -606,22 +785,38 @@ export function connectMatchWebSocket(options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换比赛服地址时先关闭旧连接,不发送 LEAVE,避免误通知旧比赛离场。
|
||||
closeMatchWebSocket({ sendLeave: false, reason: "switch" });
|
||||
if (force && isSameContext) {
|
||||
// 网络切换或假死重连时只销毁旧链路,保留比赛上下文且不发送 LEAVE。
|
||||
detachCurrentSocket(reconnectReason || "force-reconnect");
|
||||
} else if (!isSameContext) {
|
||||
// 切换比赛服地址时先关闭旧连接,不发送 LEAVE,避免误通知旧比赛离场。
|
||||
closeMatchWebSocket({ sendLeave: false, reason: "switch" });
|
||||
}
|
||||
|
||||
clearReconnectTimer();
|
||||
manualClose = false;
|
||||
isConnecting = true;
|
||||
if (!isSameContext) reconnectAttempt = 0;
|
||||
const connectedOnce = isSameContext && currentContext?.connectedOnce === true;
|
||||
currentContext = {
|
||||
serverAddr,
|
||||
matchId: normalizedMatchId,
|
||||
userId: normalizedUserId,
|
||||
token,
|
||||
url,
|
||||
mode: Number.isFinite(normalizedMode) ? normalizedMode : undefined,
|
||||
isMelee:
|
||||
Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined,
|
||||
meleeHalfRest: false,
|
||||
meleeHalfRest: isSameContext
|
||||
? currentContext?.meleeHalfRest === true
|
||||
: false,
|
||||
connectedOnce,
|
||||
};
|
||||
ensureAudioAckListener();
|
||||
emitMatchSocketState(reconnecting ? "reconnecting" : "connecting", {
|
||||
reason: reconnectReason,
|
||||
attempt: reconnectAttempt,
|
||||
});
|
||||
|
||||
const socketTask = uni.connectSocket({
|
||||
url,
|
||||
@@ -636,40 +831,60 @@ export function connectMatchWebSocket(options = {}) {
|
||||
socket = null;
|
||||
isConnecting = false;
|
||||
console.log("[match-ws] connect failed", err);
|
||||
clearSocketHealthTimers();
|
||||
emitMatchSocketState(networkConnected ? "disconnected" : "offline", {
|
||||
reason: "connect-failed",
|
||||
});
|
||||
scheduleReconnect("connect-failed");
|
||||
},
|
||||
});
|
||||
|
||||
socket = socketTask;
|
||||
startConnectTimeout(socketTask);
|
||||
|
||||
socketTask.onOpen(() => {
|
||||
if (socket !== socketTask) return;
|
||||
clearConnectTimeout();
|
||||
isConnecting = false;
|
||||
const wasReconnected =
|
||||
currentContext?.connectedOnce === true || reconnecting || reconnectAttempt > 0;
|
||||
if (currentContext) currentContext.connectedOnce = true;
|
||||
reconnectAttempt = 0;
|
||||
resetLivenessTimer(socketTask);
|
||||
console.log("[match-ws] connected", {
|
||||
matchId: normalizedMatchId,
|
||||
url,
|
||||
reconnected: wasReconnected,
|
||||
});
|
||||
emitMatchSocketState("open", {
|
||||
reason: reconnectReason,
|
||||
reconnected: wasReconnected,
|
||||
});
|
||||
});
|
||||
|
||||
socketTask.onMessage((res) => {
|
||||
if (socket !== socketTask) return;
|
||||
resetLivenessTimer(socketTask);
|
||||
handleMessage(res.data);
|
||||
});
|
||||
|
||||
socketTask.onError((err) => {
|
||||
if (socket !== socketTask) return;
|
||||
console.log("[match-ws] socket error", err);
|
||||
handleUnexpectedDisconnect(socketTask, "socket-error", err);
|
||||
});
|
||||
|
||||
socketTask.onClose((result) => {
|
||||
if (socket !== socketTask) return;
|
||||
socket = null;
|
||||
isConnecting = false;
|
||||
console.log("[match-ws] closed", {
|
||||
manualClose,
|
||||
result,
|
||||
context: currentContext,
|
||||
});
|
||||
if (!manualClose) removeAudioAckListener();
|
||||
if (manualClose) return;
|
||||
handleUnexpectedDisconnect(socketTask, "socket-close", result, {
|
||||
closeTask: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -690,6 +905,9 @@ export function closeMatchWebSocket(options = {}) {
|
||||
const { sendLeave: shouldSendLeave = true, reason = "manual" } = options;
|
||||
|
||||
manualClose = true;
|
||||
clearReconnectTimer();
|
||||
clearSocketHealthTimers();
|
||||
reconnectAttempt = 0;
|
||||
pendingAcks.forEach((task) => {
|
||||
if (task?.timer) clearTimeout(task.timer);
|
||||
});
|
||||
@@ -700,7 +918,7 @@ export function closeMatchWebSocket(options = {}) {
|
||||
if (shouldSendLeave) sendLeave();
|
||||
socket = null;
|
||||
try {
|
||||
currentSocket.close();
|
||||
currentSocket.close({ reason });
|
||||
} catch (err) {
|
||||
console.log("[match-ws] close failed", err);
|
||||
}
|
||||
@@ -718,4 +936,6 @@ export default {
|
||||
connectMatchWebSocket,
|
||||
connectMatchWebSocketFromNotice,
|
||||
closeMatchWebSocket,
|
||||
forceReconnectMatchWebSocket,
|
||||
handleMatchNetworkStatusChange,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user