Forráskód Böngészése

fix: 修复Yjs消息同步异常及学生端无法获取最新消息问题

本次修改解决了Yjs消息同步的关键问题:
1. 将消息存储从messageV2升级到messageV3,避免旧delete标记导致latest永远返回undefined
2. 移除了删除latest的操作,改为通过sendMessage自然覆盖,防止学生端获取到空值
3. 新增了详细的日志系统,包括WebSocket原始消息、Y.Doc更新事件和根类型监听,便于诊断同步问题
4. 老师端和学生端在连接/同步完成后补发必要消息,确保学生能获取最新幻灯片和消息
5. 修复了sendMessage可能出现的空值警告,完善了异常处理
lsc 2 hete
szülő
commit
d0e0088f8b
1 módosított fájl, 160 hozzáadás és 26 törlés
  1. 160 26
      src/views/Student/index.vue

+ 160 - 26
src/views/Student/index.vue

@@ -1517,12 +1517,8 @@ const clearAllSyncStates = () => {
     if (props.type == '1' && isCreator.value && docSocket.value) {
       console.log('🧹 创建老师退出,清空所有同步状态')
       docSocket.value.transact(() => {
-        // 清空普通消息(Map 结构,messageV2 避免与旧 Array('message') 根类型冲突)
-        const messageMap = docSocket.value?.getMap?.('messageV2')
-        if (messageMap) {
-          messageMap.delete('latest')
-        }
-        // 清空计时器消息数组
+        // ★ 不删除 latest!delete 会传播给学生导致 get('latest') 返回 undefined
+        // 旧 latest 会被 sendMessage 的 set('latest', message) 自然覆盖
         const timerMessagesArray = docSocket.value?.getArray?.('timerMessages')
         if (timerMessagesArray) {
           timerMessagesArray.delete(0, timerMessagesArray.length)
@@ -3262,16 +3258,23 @@ const messageInit = () => {
   yIsResultArrayMessages.value = null
 
   // 普通消息(Map,只保留最后一条)
-  // 必须使用 messageV2:服务器上旧的 message 是 Array 根类型,
-  // 同名再 getMap 会导致根类型冲突、后注册的被改名,observe 间歇性失效
-  yMessage.value = doc.getMap('messageV2')
+  // 必须使用 messageV3:服务器上 messageV2 累积了旧的 delete('latest') 标记,
+  // 导致 get('latest') 永远返回 undefined(Yjs 的 typeMapGet 在最后一个 item 被 deleted 时返回 undefined)
+  yMessage.value = doc.getMap('messageV3')
   yMessage.value.observe((e: any) => {
     // 同时兼容 keysChanged 和 changes.keys(不同 Yjs 版本/事件结构)
     const changed = e.keysChanged || e.changes?.keys
+    // ★ 打印每个 key 的 action(add/update/delete)和 oldValue
+    const keysMap = e.keys || e.changes?.keys
+    if (keysMap && keysMap.get) {
+      for (const [key, info] of keysMap.entries()) {
+        console.log(`🔗 observe key="${key}" action=${info.action} oldValue=`, info.oldValue)
+      }
+    }
     console.log('🔗 yMessage observe 触发, changed keys:', changed ? Array.from(changed) : 'null')
     if (!changed || !changed.has('latest')) return
     const message = yMessage.value!.get('latest')
-    console.log('🔗 yMessage latest:', message?.type, 'mId:', message?.mId, '当前 mId:', mId.value)
+    console.log('🔗 yMessage latest:', message, '当前 mId:', mId.value)
     if (message && message.mId !== mId.value) {
       getMessages(message)
     }
@@ -3375,11 +3378,8 @@ const messageInit = () => {
   if (isFirstEnter.value && isCreator.value && docSocket.value) {
     console.log('🧹 首次进入且为创建者,清空所有同步状态')
     docSocket.value.transact(() => {
-      // 清空普通消息(Map 结构,最新一条存在 latest key)
-      const messageMapForClear = docSocket.value?.getMap?.('messageV2')
-      if (messageMapForClear) {
-        messageMapForClear.delete('latest')
-      }
+      // ★ 不删除 latest!delete 会传播给学生导致 get('latest') 返回 undefined
+      // 旧 latest 会被 sendMessage 的 set('latest', message) 自然覆盖
       // 清空计时器消息数组
       const timerMessagesArray = docSocket.value?.getArray?.('timerMessages')
       if (timerMessagesArray) {
@@ -3619,6 +3619,9 @@ const sendMessage = (obj: any) => {
         // 用 Map.set 代替 Array.delete+push,只产生 1 条 update
         yMessage.value.set('latest', message)
       }
+      else {
+        console.warn('⚠️ sendMessage: yMessage.value 为 null,消息未写入! type:', messageType, 'docSocket:', !!docSocket.value)
+      }
     }
   })
 }
@@ -3828,6 +3831,13 @@ const getMessages = (msgObj: any) => {
       })
     })
   }
+
+  // ★ 老师收到学生 class_begin 后,回发当前幻灯片,确保学生有 latest 可跟随
+  if (msgObj.type === 'class_begin' && isCreator.value) {
+    const curIndex = slidesStore.slideIndex ?? 0
+    sendMessage({ slideIndex: curIndex, courseid: props.courseid, type: 'slideIndex' })
+    console.log('🔗 老师响应 class_begin,回发 slideIndex:', curIndex)
+  }
 }
 
 
@@ -4057,17 +4067,18 @@ onMounted(() => {
         console.log('════════ Yjs 诊断 ════════\ndocSocket.value 为 null')
         return null
       }
-      const ymap = doc.getMap('messageV2')
-      const msgType = doc.share.get('messageV2')
+      const msgV3Type = doc.share.get('messageV3')
+      const oldMsgType = doc.share.get('message')
       const report = {
         '1. doc.guid': doc.guid,
-        '2. yMessage.value === doc.getMap("messageV2")': yMessage.value === ymap,
+        '2. yMessage.value === doc.share.get("messageV3")': yMessage.value === msgV3Type,
         '3. yMessage.value?.doc?.guid': yMessage.value?.doc?.guid,
         '4. doc.store.clients.size': doc.store.clients.size,
-        '5. doc.share.get("messageV2") 类型': msgType?.constructor?.name,
-        '6. doc.getMap("messageV2").size': ymap.size,
-        '7. doc.getMap("messageV2").get("latest")': ymap.get('latest'),
-        '8. yMessage.value?.get("latest")': yMessage.value?.get('latest'),
+        '5a. share.get("messageV3") 类型': msgV3Type?.constructor?.name,
+        '5b. share.get("message") 类型': oldMsgType?.constructor?.name,
+        '6. messageV3.size': msgV3Type?.size,
+        '7. messageV3?.get("latest")': msgV3Type?.get?.('latest'),
+        '8. yMessage.value?.get("latest")': yMessage.value?.get?.('latest'),
         '9. doc 里所有顶层 key': Array.from(doc.share.entries()).map(([k, v]: [string, any]) => k + ':' + v.constructor.name),
         '10. provider synced': providerSocket.value?.synced,
         '11. ws.readyState': providerSocket.value?.ws?.readyState,
@@ -4308,7 +4319,6 @@ const createWebSocketConnection = async (type = 1) => {
   
   isConnecting.value = true
   connectionStatus.value = 'connecting'
-  
   try {
     // 清理之前的连接
     // if (providerSocket.value && type == 1) {
@@ -4350,11 +4360,103 @@ const createWebSocketConnection = async (type = 1) => {
     )
     providerSocket.value.awareness.setLocalState(null)
     providerSocket.value.awareness.destroy() // 完全禁用 awareness
+
+    // ★ 三层日志之一:raw websocket 消息(确认数据到达浏览器)
+    const _ws = (providerSocket.value as any).ws
+    if (_ws && !_ws.__logged) {
+      _ws.__logged = true // 防止重复绑定
+      // 包一层 onmessage,抓 provider 处理异常
+      const _origOnMsg = _ws.onmessage
+      if (typeof _origOnMsg === 'function') {
+        _ws.onmessage = function(ev: MessageEvent) {
+          try {
+            _origOnMsg.call(_ws, ev)
+          }
+          catch (e: any) {
+            console.error('❌ provider onmessage 异常:', e?.message || e, e?.stack || '')
+          }
+        }
+      }
+      _ws.addEventListener('message', (ev: MessageEvent) => {
+        try {
+          const data = new Uint8Array(ev.data)
+          const msgType = data[0]
+          const typeNames: any = { 0: 'sync', 1: 'awareness', 2: 'queryAwareness' }
+          let detail = ''
+          if (msgType === 0) {
+            const sub = data[1]
+            detail = ({ 0: 'step1(请求缺失)', 1: 'step2(服务器推送)', 2: 'update' } as any)[sub] || 'unknown'
+          }
+          console.log(`📥 ws raw: ${typeNames[msgType] || msgType} ${detail} (${data.length} bytes)`)
+
+          // ★ 诊断:sync update 到达时手动解码 + applyUpdate,看是不是重复数据
+          if (msgType === 0 && data[1] === 2) {
+            const doc = docSocket.value
+            if (doc) {
+              // 解码 varint 提取 update bytes
+              let pos = 2 // 跳过 msgType + subType
+              let updateLen = 0
+              let shift = 0
+              while (pos < data.length) {
+                const b = data[pos++]
+                updateLen |= (b & 0x7f) << shift
+                if (b < 0x80) break
+                shift += 7
+              }
+              const updateBytes = data.slice(pos, pos + updateLen)
+
+              // ★ 用 Y.decodeUpdate 解码 update(不创建 temp doc,不触发 applyUpdate 的 DELETE)
+              try {
+                const { structs } = Y.decodeUpdate(updateBytes)
+                console.log(`📦 解码: updateBytes=${updateBytes.length}, structs=${structs.length}`)
+                for (const struct of structs) {
+                  const arr = struct?.content?.arr
+                  console.log(`📦 struct: type=${struct?.content?.constructor?.name}, parentSub=${struct?.parentSub}, arr=${arr ? JSON.stringify(arr)?.slice(0, 150) : '无'}`)
+                  if (arr && Array.isArray(arr)) {
+                    for (const val of arr) {
+                      if (val && typeof val === 'object' && val.type && val.mId) {
+                        console.log(`📦 ✅ 从 update 提取消息: type=${val.type}, mId=${val.mId}, 当前 mId=${mId.value}`)
+                        if (val.mId !== mId.value) {
+                          getMessages(val)
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+              catch (e) {
+                console.log('📦 update 解码失败:', e)
+              }
+            }
+          }
+        }
+        catch (e) {
+          console.log('📥 ws raw: parse error', e)
+        }
+      })
+    }
+
+    // ★ 三层日志之二:Y.Doc update 事件 — 列出所有根类型的 size,看更新到底改了哪个
+    docSocket.value.on('update', (update: Uint8Array, origin: any) => {
+      const originName = origin?.constructor?.name || (origin === providerSocket.value ? 'ws-provider' : typeof origin)
+      const share = (docSocket.value as any)?.share
+      const sizes: string[] = []
+      if (share) {
+        for (const [name, type] of share.entries()) {
+          const sz = type.size ?? type.length ?? '?'
+          const latest = type.get?.('latest')
+          sizes.push(`${name}=${sz}${latest ? '(' + latest.type + ')' : ''}`)
+        }
+      }
+      console.log(`📥 Y.Doc update: ${update.length} bytes, origin: ${originName}, types: [${sizes.join(', ')}]`)
+    })
+
     // 清理可能残留的旧心跳定时器
     if (heartbeatTimer.value) {
       clearInterval(heartbeatTimer.value)
       heartbeatTimer.value = null
     }
+
     // 启动定期更新 token
     // 30分钟后再次更新
     if (authTokenUpdateTimer.value) {
@@ -4380,12 +4482,15 @@ const createWebSocketConnection = async (type = 1) => {
         
         mId.value = Math.random().toString(36).substr(2, 9)
         messageInit()
+        console.log('🔗 connected 后: yMessage.value =', yMessage.value ? '已绑定' : 'null', 'doc.guid=', docSocket.value?.guid)
         // 连接成功后,读取当前计时器状态(Map)
         if (docSocket.value) {
           initializeYjsData()
         }
-        if(type == 1){
-          createWebSocketConnection(2)
+        if (type == 1) {
+          setTimeout(() => {
+            createWebSocketConnection(2)
+          }, 1000)
         }
       }
       else if (event.status === 'disconnected') {
@@ -4424,6 +4529,23 @@ const createWebSocketConnection = async (type = 1) => {
       if (!isSynced) return
       console.log('🔗 Yjs sync 完成,开始 catch-up')
 
+      // ★ 诊断:枚举 doc.share 里所有根类型,给每个都加 observe
+      const share = (docSocket.value as any).share
+      if (share) {
+        console.log('🔗 根类型列表:')
+        for (const [name, type] of share.entries()) {
+          console.log(`  ${name}: ${type.constructor.name}, size: ${type.size ?? type.length ?? '?'}`)
+          // 给每个根类型加 observe,看哪个在动
+          if (type.observe && !type.__diagObserved) {
+            type.__diagObserved = true
+            type.observe((e: any) => {
+              const changed = e.keysChanged || e.changes?.keys
+              console.log(`🔗 [${name}] observe 触发! changed:`, changed ? Array.from(changed) : e)
+            })
+          }
+        }
+      }
+
       // catch-up:读取服务器同步过来的 latest 消息
       if (yMessage.value) {
         const latest = yMessage.value.get('latest')
@@ -4448,11 +4570,23 @@ const createWebSocketConnection = async (type = 1) => {
 
       // 学生端补发上线消息,让老师知道学生已就绪
       if (props.type === '2') {
+        console.log('🔗 sync handler: yMessage.value =', yMessage.value ? '已绑定' : 'null', 'doc.guid=', docSocket.value?.guid)
         sendMessage({ type: 'class_begin', courseid: props.courseid })
+        // ★ 确认 class_begin 是否真的写入了 messageV3
+        const msgV3 = (docSocket.value as any)?.share?.get?.('messageV3')
+        console.log('🔗 学生发送 class_begin 后, messageV3.size:', msgV3?.size, 'latest:', msgV3?.get?.('latest')?.type)
+      }
+
+      // ★ 老师端:sync 完成后补发当前幻灯片,确保 messageV3 的 latest 有值
+      // 解决:服务器上 latest 被 clearAllSyncStates 删除后,新连入学生拿到空 Map 无法跟随
+      if (props.type === '1') {
+        const curIndex = slidesStore.slideIndex ?? 0
+        sendMessage({ slideIndex: curIndex, courseid: props.courseid, type: 'slideIndex' })
+        console.log('🔗 老师补发当前幻灯片 slideIndex:', curIndex)
       }
 
       // 自动运行 Yjs 诊断
-      ;(window as any).PPTistStudent?.diagnoseYjs?.()
+      (window as any).PPTistStudent?.diagnoseYjs?.()
     })
     // 监听连接错误
     // providerSocket.value.on('connection-error', (error: any) => {