From 766572576630efe98d504ccec99782a871664655 Mon Sep 17 00:00:00 2001 From: gr Date: Thu, 12 Nov 2026 14:26:54 +0800 Subject: [PATCH] =?UTF-8?q?i=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/video_companion/src/main.ts | 80 ++++++++++++++++--- .../tests/room-binding.test.cjs | 55 +++++++++++-- 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/app/video_companion/src/main.ts b/app/video_companion/src/main.ts index 2ee21e1be..36d05e74c 100755 --- a/app/video_companion/src/main.ts +++ b/app/video_companion/src/main.ts @@ -243,6 +243,7 @@ let pendingRoomId = '' let boundRoomId = '' let roomBindingSentAt = 0 let roomBindingAttempts = 0 +let engineRoomEventsBound: unknown = null let resolveHostCallReady: ((value: boolean) => void) | null = null const pendingTranscriptionStarts = new Map() const pendingTranscriptionStops = new Map() @@ -1838,21 +1839,68 @@ function extractRoomFromEngineResult(result: unknown): string { return normalizeRoomPair(data.roomID, data.strRoomID) || normalizeRoomPair(data.roomId, data.strRoomId) } -/** 部分 CallKit 版本会在宿主落库前清掉 Store 里的 ROOM_ID,引擎实例上的字段作兜底。 */ -function readRoomIdFromEngine(): string { +interface CallEngineLike { + on?: (event: string, handler: (payload: unknown) => void) => void + getTRTCCloudInstance?: () => unknown +} + +function currentCallEngine(): CallEngineLike | null { try { - const engine = TUICallKitAPI.getTUICallEngineInstance?.() as Record | null | undefined - if (!engine) return '' - for (const key of ['roomId', 'roomID', '_roomId', '_roomID', 'strRoomID', 'strRoomId']) { - const found = normalizeRoomScalar(engine[key]) + const engine = TUICallKitAPI.getTUICallEngineInstance?.() + return engine && typeof engine === 'object' ? engine as CallEngineLike : null + } catch { + return null + } +} + +/** + * Store 里的 ROOM_ID 只在 CallKit 封装的 calls() 返回后才写入,而 calls() 可能到对方接听才返回, + * 甚至整通都不返回(实测二次拨打时如此),Store 便一直是 0。本端一发起呼叫就进了 TRTC 房间, + * TRTC 实例上的进房参数是最早、最可靠的房间号来源;离房后旧房间号仍留在实例上,只在 + * 仍在房间里时才采信,避免把上一通的房间绑到这一通。 + */ +function readRoomIdFromEngine(): string { + const engine = currentCallEngine() + if (!engine || typeof engine.getTRTCCloudInstance !== 'function') return '' + try { + const trtc = engine.getTRTCCloudInstance() as Record | null | undefined + if (!trtc || typeof trtc !== 'object') return '' + const room = trtc._room + const joined = !!room && typeof room === 'object' && (room as Record).isJoined === true + if (!joined) return '' + const params = trtc.enterRoomParams + if (params && typeof params === 'object') { + const record = params as Record + const found = normalizeRoomPair(record.roomId, record.strRoomId) + || normalizeRoomPair(record.roomID, record.strRoomID) if (found) return found } + return normalizeRoomScalar((room as Record).roomId) } catch { - // 引擎未初始化或字段不可访问时只用 Store。 + // TRTC 实例字段不可访问时只用 Store。 } return '' } +/** 引擎的 onCallBegin / onCallReceived / onCallEnd 事件自带房间号,不经过 Store,也不依赖 calls() 返回。 */ +function handleEngineRoomEvent(payload: unknown): void { + const roomId = extractRoomFromEngineResult(payload) + if (roomId) observeRoomId(roomId) +} + +function bindEngineRoomEvents(): void { + const engine = currentCallEngine() + if (!engine || engine === engineRoomEventsBound || typeof engine.on !== 'function') return + try { + for (const event of ['onCallBegin', 'onCallReceived', 'onCallEnd']) { + engine.on(event, handleEngineRoomEvent) + } + engineRoomEventsBound = engine + } catch { + // 引擎事件不可订阅时仍有 Store 轮询和 TRTC 实例两路兜底。 + } +} + function readRoomId(): string { return normalizeRoomScalar(TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)) || readRoomIdFromEngine() } @@ -1883,6 +1931,8 @@ async function pollRoomId(): Promise { const cycle = callCycleGeneration while (activeConfig && !endNotified && cycle === callCycleGeneration) { if (boundRoomId) return + // 引擎实例在 init 后才出现,轮询时顺带补订阅(已订阅则为空操作)。 + bindEngineRoomEvents() emitRoomId() await new Promise((resolve) => window.setTimeout(resolve, 250)) } @@ -1904,6 +1954,8 @@ function handleStatusChanged(payload: unknown): void { const status = typeof value === 'string' ? value : 'unknown' if (status === 'connected' || status.startsWith('calling-')) { if (endNotified || !activeConfig) return + // 呼叫已经发出去了:即使 calls() 的 Promise 迟迟不返回,也不能卡住之后的再次拨打。 + starting = false const cycle = callCycleGeneration phase.value = 'connected' statusText.value = '视频问诊进行中' @@ -1925,6 +1977,7 @@ function handleStatusChanged(payload: unknown): void { } } else if (status === 'calling' || status.startsWith('dialing')) { if (endNotified || !activeConfig) return + starting = false phase.value = 'dialing' statusText.value = '正在等待患者接听' appendVideoCallStatus('dialing', '正在等待患者接听') @@ -1997,6 +2050,7 @@ async function startVideo(): Promise { if (starting || !endNotified) throw new Error('已有视频通话正在进行') const config = activeConfig const generation = contextGeneration + let cycle = -1 starting = true try { if (hangupNotification) await hangupNotification @@ -2004,6 +2058,7 @@ async function startVideo(): Promise { if (!activeConfig || !endNotified) throw new Error('已有视频通话正在进行') hangupNotification = null callCycleGeneration += 1 + cycle = callCycleGeneration endNotified = false phase.value = 'starting' statusText.value = '正在创建安全视频通话' @@ -2030,6 +2085,7 @@ async function startVideo(): Promise { userSig: activeConfig.userSig, ...(chat ? { tim: chat, isFromChat: true } : {}), }) + bindEngineRoomEvents() await nextTick() if (activeConfig !== config || generation !== contextGeneration) throw new Error('当前问诊已切换') phase.value = 'dialing' @@ -2044,14 +2100,18 @@ async function startVideo(): Promise { userIDList: [activeConfig.targetUserId], type: TUICallType.VIDEO_CALL, }) - // 房间号优先取引擎返回值:Store 的 ROOM_ID 可能晚一拍或已被清空; + // CallKit 封装的 calls() 可能到对方接听、甚至这通结束后才返回;返回时若已是下一通, + // 这里的任何状态都不能再碰,否则会把上一通的房间号绑到新通话上。 + if (cycle !== callCycleGeneration) return // 房间不绑定,服务端就不会发起云端录制,这次面诊的回放会直接丢失。 observeRoomId(extractRoomFromEngineResult(callResult) || readRoomId()) void pollRoomId() emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' }) } catch (error) { const message = safeErrorMessage(error, '无法发起视频通话') - if (activeConfig !== config || generation !== contextGeneration) throw new Error(message) + if (activeConfig !== config || generation !== contextGeneration || cycle !== callCycleGeneration) { + throw new Error(message) + } phase.value = 'error' statusText.value = message appendVideoCallStatus('failed', `视频通话发起失败:${message}`) @@ -2059,7 +2119,7 @@ async function startVideo(): Promise { emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig?.diagnosisId, message }) throw new Error(message) } finally { - starting = false + if (cycle === -1 || cycle === callCycleGeneration) starting = false } } diff --git a/app/video_companion/tests/room-binding.test.cjs b/app/video_companion/tests/room-binding.test.cjs index 2c10000d4..f310181fa 100644 --- a/app/video_companion/tests/room-binding.test.cjs +++ b/app/video_companion/tests/room-binding.test.cjs @@ -11,8 +11,8 @@ const source = fs.readFileSync(path.join(__dirname, '../src/main.ts'), 'utf8') const ast = ts.createSourceFile('main.ts', source, ts.ScriptTarget.Latest, true) const names = new Set([ 'normalizeRoomId', 'normalizeRoomScalar', 'normalizeRoomPair', 'extractRoomFromEngineResult', - 'readRoomIdFromEngine', 'readRoomId', 'observeRoomId', 'emitRoomId', 'roomBindingResult', - 'recordingResult', + 'currentCallEngine', 'readRoomIdFromEngine', 'handleEngineRoomEvent', 'bindEngineRoomEvents', + 'readRoomId', 'observeRoomId', 'emitRoomId', 'roomBindingResult', 'recordingResult', ]) const declarations = ast.statements.filter(node => ts.isFunctionDeclaration(node) && names.has(node.name?.text)) assert.equal(declarations.length, names.size) @@ -25,6 +25,7 @@ function harness(options = {}) { let activeConfig = {userID:'doctor_1',targetUserId:'patient_1',diagnosisId:501}; let endNotified=false, notice={value:''}; let emittedRoomId='', pendingRoomId='', boundRoomId='', roomBindingSentAt=0, roomBindingAttempts=0; + let engineRoomEventsBound=null; let now=100000; const Date=class extends globalThis.Date { constructor(...args){super(...(args.length?args:[now]))} static now(){return now} }; const events=[]; @@ -34,6 +35,7 @@ function harness(options = {}) { const TUICallKitAPI={getTUICallEngineInstance(){ if(options.engineThrows) throw new Error('no engine'); return options.engine }}; return { events, extractRoomFromEngineResult, readRoomId, observeRoomId, emitRoomId, roomBindingResult, + bindEngineRoomEvents, advance(ms){now+=ms}, bound:()=>boundRoomId, pending:()=>pendingRoomId, notice:()=>notice.value, end(){endNotified=true}, }`)(options) @@ -50,15 +52,54 @@ test('engine call result yields the room id, including nested and string forms', assert.equal(h.extractRoomFromEngineResult('1234'), '') }) -test('store room id wins, engine instance fields are the fallback', () => { - assert.equal(harness({ store: '77', engine: { _roomId: 123 } }).readRoomId(), '77') - assert.equal(harness({ store: 0, engine: { _roomId: 123 } }).readRoomId(), '123') - assert.equal(harness({ store: undefined, engine: { strRoomID: 'str-room' } }).readRoomId(), 'str-room') - assert.equal(harness({ store: '', engine: { roomId: { nested: true } } }).readRoomId(), '') +function trtcEngine(room, params) { + return { getTRTCCloudInstance: () => ({ enterRoomParams: params, _room: room }) } +} + +test('store room id wins, the TRTC instance is the fallback only while joined', () => { + const joined = { isJoined: true, roomId: '123' } + assert.equal(harness({ store: '77', engine: trtcEngine(joined, { roomId: 123 }) }).readRoomId(), '77') + assert.equal(harness({ store: 0, engine: trtcEngine(joined, { roomId: 123 }) }).readRoomId(), '123') + assert.equal(harness({ store: undefined, engine: trtcEngine({ isJoined: true }, { strRoomId: 'str-room' }) }).readRoomId(), 'str-room') + assert.equal(harness({ store: 0, engine: trtcEngine({ isJoined: true, roomId: 456 }, undefined) }).readRoomId(), '456') + // The previous call's room lingers on the TRTC instance after leaving: never bind it to the next call. + assert.equal(harness({ store: 0, engine: trtcEngine({ isJoined: false, roomId: '123' }, { roomId: 123 }) }).readRoomId(), '') + assert.equal(harness({ store: 0, engine: trtcEngine(undefined, { roomId: 123 }) }).readRoomId(), '') + assert.equal(harness({ store: '', engine: { getTRTCCloudInstance: () => null } }).readRoomId(), '') + assert.equal(harness({ store: '', engine: { getTRTCCloudInstance() { throw new Error('gone') } } }).readRoomId(), '') + assert.equal(harness({ store: '', engine: { _roomId: 123 } }).readRoomId(), '') assert.equal(harness({ store: '', engineThrows: true }).readRoomId(), '') assert.equal(harness({ store: '', engine: null }).readRoomId(), '') }) +test('engine call events bind the room even when the Store never learns it', () => { + const registered = [] + const handlers = new Map() + const engine = { on: (event, handler) => { registered.push(event); handlers.set(event, handler) } } + const h = harness({ store: 0, engine }) + h.bindEngineRoomEvents() + h.bindEngineRoomEvents() + assert.deepEqual(registered, ['onCallBegin', 'onCallReceived', 'onCallEnd']) + + handlers.get('onCallBegin')({ roomID: 1643002506, strRoomID: '', callMediaType: 2, callRole: 1 }) + assert.equal(h.events.length, 1) + assert.equal(h.events[0].event, 'room') + assert.equal(h.events[0].roomId, '1643002506') + h.roomBindingResult('1643002506', true, '') + // The matching onCallEnd is a no-op once bound; payloads without a room are ignored. + handlers.get('onCallEnd')({ roomID: 1643002506, reason: 'hangup' }) + handlers.get('onCallBegin')({ callMediaType: 2 }) + assert.equal(h.events.length, 1) + h.end() + handlers.get('onCallBegin')({ roomID: 7 }) + assert.equal(h.events.length, 1) + + // Engines without an event API, or none at all, are tolerated. + harness({ store: 0, engine: {} }).bindEngineRoomEvents() + harness({ store: 0, engine: null }).bindEngineRoomEvents() + harness({ store: 0, engineThrows: true }).bindEngineRoomEvents() +}) + test('a room learned from calls() is bound once and retried with backoff until acknowledged', () => { const h = harness({ store: 0, engine: null }) assert.equal(h.observeRoomId(h.extractRoomFromEngineResult({ roomID: 900 }) || h.readRoomId()), false)