update:新增比赛服断线重连机制

This commit is contained in:
2026-07-10 15:11:45 +08:00
parent b5373bee31
commit 98ca638b63
4 changed files with 339 additions and 38 deletions
+232 -12
View File
@@ -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,
};