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:
bujinyuan
2026-04-22 14:08:07 +08:00
parent 807e111cb9
commit 467135e0d5
3 changed files with 143 additions and 3 deletions

View File

@@ -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>