feat(phase2e-2): WS 稳定性改进——重连后自动重绑定房间 + 多端登录抖动检测
背景 - Task 9 联调发现同账号多 tab 登录会触发后端单设备踢下线机制,WS 每秒被关闭一次, 所有 meeting.* 的 sendWithAck 都因 "连接已断开" 而失败,表现为"开启音视频提示失败" - 同时 WS 断线重连后后端对该连接的 roomCode 绑定丢失,会议广播不再送达本端 改动 - store/meeting.js:_registerListeners 增加 WS 生命周期监听 - _disconnected:从 CONNECTED/CONNECTING 切到 RECONNECTING,UI 可展示"重连中…" - _connected:在 RECONNECTING 状态自动重发 meeting.room.join 重绑定房间,并刷新参与者列表 - services/websocket.js:_recordCloseForFlapping 做 10s 滑窗 flapping 检测 - 窗口内累计 >=3 次非主动断连 → 触发 _flapping 事件(含 count/windowMs) - 15s 冷却避免抖动期间重复告警 - debug.vue:新增 WS 状态指示灯(已连接/重连中/已断)+ flapping 警告横幅 - 订阅 _connected / _disconnected / _flapping 三个 WS 内置事件,onBeforeUnmount 注销 验证(Playwright) - 单次断连:testuser1 创建会议后手动 close WS → 指示灯 reconnecting,1s 后重连 - 日志链 "WS 已断开 → 进入 reconnecting → 连接成功 → 重绑定房间 307-857-909 → 恢复 connected" 全通 - 4 次连续断连:第 3 次触发 flapping,UI 横幅 "⚠ 检测到异常重连(3 次/10s),可能同账号在其他端登录" - 每次断连后房号、主持人状态、localState 均正确恢复为 connected Made-with: Cursor
This commit is contained in:
@@ -15,6 +15,11 @@
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">会议调试(Task 9)</text>
|
||||
<view class="ws-status">
|
||||
<view class="ws-dot" :class="wsStatusClass"></view>
|
||||
<text class="ws-text">{{ wsStatusLabel }}</text>
|
||||
<text class="ws-flap" v-if="wsFlapping">⚠ 检测到异常重连({{ wsFlapCount }} 次/{{ Math.round(wsFlapWindowMs / 1000) }}s),可能同账号在其他端登录</text>
|
||||
</view>
|
||||
<text class="state">状态:{{ localStateLabel }}</text>
|
||||
<text class="state">房号:{{ currentRoom?.room_code || '-' }} ({{ currentRoom?.title || '-' }})</text>
|
||||
<text class="state">是否主持人:{{ isHost ? '是' : '否' }}</text>
|
||||
@@ -83,9 +88,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, reactive, computed, watch, onBeforeUnmount, onMounted, nextTick } from 'vue'
|
||||
import { useMeetingStore } from '@/store/meeting'
|
||||
import { MEETING_LOCAL_STATE_LABEL } from '@/constants/meeting'
|
||||
import wsService from '@/services/websocket'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const meetingStore = useMeetingStore()
|
||||
@@ -122,6 +128,53 @@ const localStateLabel = computed(() => {
|
||||
return `${localState.value} (${label})`
|
||||
})
|
||||
|
||||
// ==================== WS 状态指示灯 ====================
|
||||
|
||||
/** 'connected' | 'reconnecting' | 'disconnected' */
|
||||
const wsStatus = ref('disconnected')
|
||||
const wsFlapping = ref(false)
|
||||
const wsFlapCount = ref(0)
|
||||
const wsFlapWindowMs = ref(10000)
|
||||
let _wsFlapResetTimer = null
|
||||
|
||||
const wsStatusClass = computed(() => `ws-dot-${wsStatus.value}`)
|
||||
const wsStatusLabel = computed(() => {
|
||||
switch (wsStatus.value) {
|
||||
case 'connected': return 'WS 已连接'
|
||||
case 'reconnecting': return 'WS 重连中…'
|
||||
default: return 'WS 已断开'
|
||||
}
|
||||
})
|
||||
|
||||
const _onWsConnected = () => { wsStatus.value = 'connected' }
|
||||
const _onWsDisconnected = () => {
|
||||
wsStatus.value = wsService.isManualClose ? 'disconnected' : 'reconnecting'
|
||||
}
|
||||
const _onWsFlapping = (info) => {
|
||||
wsFlapping.value = true
|
||||
wsFlapCount.value = info?.count || 0
|
||||
wsFlapWindowMs.value = info?.windowMs || 10000
|
||||
if (_wsFlapResetTimer) clearTimeout(_wsFlapResetTimer)
|
||||
// 连续 20s 无新 flapping 信号时自动消隐
|
||||
_wsFlapResetTimer = setTimeout(() => { wsFlapping.value = false }, 20000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 页面加载时根据当前 WS 实例判断初始状态
|
||||
// #ifdef H5
|
||||
if (wsService.ws && wsService.ws.readyState === WebSocket.OPEN) {
|
||||
wsStatus.value = 'connected'
|
||||
} else if (wsService.ws && wsService.ws.readyState === WebSocket.CONNECTING) {
|
||||
wsStatus.value = 'reconnecting'
|
||||
} else {
|
||||
wsStatus.value = 'disconnected'
|
||||
}
|
||||
// #endif
|
||||
wsService.on('_connected', _onWsConnected)
|
||||
wsService.on('_disconnected', _onWsDisconnected)
|
||||
wsService.on('_flapping', _onWsFlapping)
|
||||
})
|
||||
|
||||
// ==================== 原生 DOM 辅助(仅 H5)====================
|
||||
|
||||
/** 把 uni <view> 的 ref 统一转成真实 DOM 元素 */
|
||||
@@ -308,6 +361,10 @@ const onSendChat = async () => {
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
wsService.off('_connected', _onWsConnected)
|
||||
wsService.off('_disconnected', _onWsDisconnected)
|
||||
wsService.off('_flapping', _onWsFlapping)
|
||||
if (_wsFlapResetTimer) clearTimeout(_wsFlapResetTimer)
|
||||
if (isInMeeting.value) {
|
||||
meetingStore.leave().catch(() => {})
|
||||
}
|
||||
@@ -337,4 +394,12 @@ onBeforeUnmount(() => {
|
||||
.chat-box { max-height: 300rpx; overflow-y: auto; background: #F1F5F9; padding: 12rpx; border-radius: 8rpx; margin-bottom: 12rpx; }
|
||||
.chat-msg { font-size: 26rpx; color: #1E293B; padding: 4rpx 0; }
|
||||
.hint { font-size: 24rpx; color: #F59E0B; margin-top: 12rpx; display: block; }
|
||||
.ws-status { display: flex; align-items: center; gap: 10rpx; margin: 8rpx 0 12rpx; flex-wrap: wrap; }
|
||||
.ws-dot { width: 16rpx; height: 16rpx; border-radius: 50%; background: #94A3B8; box-shadow: 0 0 0 2rpx rgba(148,163,184,0.18); }
|
||||
.ws-dot.ws-dot-connected { background: #10B981; box-shadow: 0 0 0 2rpx rgba(16,185,129,0.22); }
|
||||
.ws-dot.ws-dot-reconnecting { background: #F59E0B; box-shadow: 0 0 0 2rpx rgba(245,158,11,0.22); animation: ws-pulse 1.2s ease-in-out infinite; }
|
||||
.ws-dot.ws-dot-disconnected { background: #EF4444; box-shadow: 0 0 0 2rpx rgba(239,68,68,0.22); }
|
||||
.ws-text { font-size: 24rpx; color: #334155; }
|
||||
.ws-flap { font-size: 22rpx; color: #B45309; background: #FEF3C7; padding: 4rpx 10rpx; border-radius: 6rpx; }
|
||||
@keyframes ws-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,11 @@ const RECONNECT_BASE_DELAY = 1000
|
||||
const RECONNECT_MAX_DELAY = 30000
|
||||
const MAX_RECONNECT_ATTEMPTS = Infinity
|
||||
const ACK_DEFAULT_TIMEOUT_MS = 10000
|
||||
/** 异常重连检测:10s 窗口内 >=3 次断连即认为异常抖动(常见于同账号多 tab 互踢) */
|
||||
const FLAPPING_WINDOW_MS = 10000
|
||||
const FLAPPING_THRESHOLD = 3
|
||||
/** 两次 flapping 警告之间最小间隔,避免持续抖动时每秒触发 */
|
||||
const FLAPPING_NOTIFY_COOLDOWN_MS = 15000
|
||||
|
||||
class WebSocketService {
|
||||
constructor() {
|
||||
@@ -34,6 +39,10 @@ class WebSocketService {
|
||||
* Task 9 引入,为 meeting.* 事件提供 Promise 化 ACK 等待
|
||||
*/
|
||||
this.pendingAcks = new Map()
|
||||
/** 近期断连时间戳数组,用于 flapping 检测 */
|
||||
this.recentCloseAt = []
|
||||
/** 上次 flapping 警告时间,用于冷却 */
|
||||
this.lastFlappingNotifyAt = 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,13 +222,31 @@ class WebSocketService {
|
||||
console.log('[WS] 连接关闭', e)
|
||||
this._clearTimers()
|
||||
this._rejectAllPendingAcks('连接已断开')
|
||||
this._emit('_disconnected')
|
||||
this._emit('_disconnected', e)
|
||||
|
||||
if (!this.isManualClose) {
|
||||
this._recordCloseForFlapping()
|
||||
this._scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次非主动断连,并在短窗口内多次断连时触发 _flapping 事件
|
||||
* 典型场景:同账号多端登录互踢导致 WS 每秒被后端关闭
|
||||
*/
|
||||
_recordCloseForFlapping() {
|
||||
const now = Date.now()
|
||||
this.recentCloseAt.push(now)
|
||||
// 丢掉窗口外的历史记录
|
||||
this.recentCloseAt = this.recentCloseAt.filter((t) => now - t <= FLAPPING_WINDOW_MS)
|
||||
if (this.recentCloseAt.length < FLAPPING_THRESHOLD) return
|
||||
if (now - this.lastFlappingNotifyAt < FLAPPING_NOTIFY_COOLDOWN_MS) return
|
||||
this.lastFlappingNotifyAt = now
|
||||
const count = this.recentCloseAt.length
|
||||
console.warn(`[WS] 检测到异常重连抖动:${FLAPPING_WINDOW_MS / 1000}s 内断开 ${count} 次,可能同账号在其他 tab/设备登录`)
|
||||
this._emit('_flapping', { count, windowMs: FLAPPING_WINDOW_MS })
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接断开时统一拒绝所有未完成的 ACK 等待(Task 9)
|
||||
* 避免业务层 await sendWithAck() 永久挂起
|
||||
|
||||
@@ -177,7 +177,10 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
[MEETING_WS_MEMBER_KICKED]: _onMemberKicked,
|
||||
[MEETING_WS_MEMBER_PRODUCER_NEW]: _onProducerNew,
|
||||
[MEETING_WS_HOST_CHANGED]: _onHostChanged,
|
||||
[MEETING_WS_CHAT_MESSAGE]: _onChatMessage
|
||||
[MEETING_WS_CHAT_MESSAGE]: _onChatMessage,
|
||||
// WS 连接生命周期:用于断线/重连期间维持会议会话,避免"连接断开 → 后端失去 roomCode 绑定 → 广播丢失"
|
||||
_connected: _onWsReconnected,
|
||||
_disconnected: _onWsDisconnected
|
||||
}
|
||||
Object.entries(handlers).forEach(([event, handler]) => {
|
||||
wsService.on(event, handler)
|
||||
@@ -321,6 +324,51 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
chatMessages.value.push(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* WS 连接断开:仅当当前已进入会议(CONNECTED/CONNECTING)时切换到 RECONNECTING,
|
||||
* 供 UI 展示"重连中…",避免用户误以为自己已离会。
|
||||
*/
|
||||
const _onWsDisconnected = () => {
|
||||
if (!currentRoom.value) return
|
||||
if (
|
||||
localState.value === MEETING_LOCAL_STATE_CONNECTED ||
|
||||
localState.value === MEETING_LOCAL_STATE_CONNECTING
|
||||
) {
|
||||
_log('warn', '[Meeting] WS 已断开,进入 reconnecting')
|
||||
localState.value = MEETING_LOCAL_STATE_RECONNECTING
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WS 重连成功:若当前仍在某个房间内,自动重发 meeting.room.join 以恢复后端 WS↔roomCode 绑定。
|
||||
* 后端 WS Handler 对同一用户重连后,需要再次收到 meeting.room.join 才会将该连接加入房间广播组,
|
||||
* 否则即使 mediasoup 层仍在线,所有 meeting.* 广播都不会再送达本端。
|
||||
*/
|
||||
const _onWsReconnected = async () => {
|
||||
if (!currentRoom.value) return
|
||||
// 仅在 RECONNECTING 状态触发(避免首次进房 _onOpen 与 _afterJoined 的首次 join 并发冲突)
|
||||
if (localState.value !== MEETING_LOCAL_STATE_RECONNECTING) return
|
||||
const roomCode = currentRoom.value.room_code
|
||||
try {
|
||||
_log('info', '[Meeting] WS 重连后重绑定房间', roomCode)
|
||||
await wsService.sendWithAck(MEETING_WS_ROOM_JOIN, { room_code: roomCode })
|
||||
// 重连期间可能错过若干广播,重新拉一次详情对齐参与者列表
|
||||
try {
|
||||
const detail = await meetingApi.getRoom(roomCode)
|
||||
if (detail && Array.isArray(detail.participants)) {
|
||||
participants.value = detail.participants
|
||||
}
|
||||
} catch (e) {
|
||||
_log('warn', '[Meeting] 重绑定后拉取房间详情失败', e)
|
||||
}
|
||||
localState.value = MEETING_LOCAL_STATE_CONNECTED
|
||||
_log('info', '[Meeting] WS 重绑定房间成功,恢复 connected')
|
||||
} catch (err) {
|
||||
_log('warn', '[Meeting] WS 重绑定房间失败,保持 reconnecting 等待下次重连', err)
|
||||
// 保留 RECONNECTING 状态,下一次 WS 重连会再次触发本回调
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 远端媒体清理 ====================
|
||||
|
||||
const _cleanupRemoteProducer = (userId, producerId) => {
|
||||
|
||||
Reference in New Issue
Block a user