Merge branch 'master' into 11-5

This commit is contained in:
Your Name
2026-11-12 14:30:59 +08:00
2 changed files with 118 additions and 17 deletions
+70 -10
View File
@@ -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<string, PendingTranscriptionReply>()
const pendingTranscriptionStops = new Map<string, PendingTranscriptionReply>()
@@ -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<string, unknown> | 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<string, unknown> | null | undefined
if (!trtc || typeof trtc !== 'object') return ''
const room = trtc._room
const joined = !!room && typeof room === 'object' && (room as Record<string, unknown>).isJoined === true
if (!joined) return ''
const params = trtc.enterRoomParams
if (params && typeof params === 'object') {
const record = params as Record<string, unknown>
const found = normalizeRoomPair(record.roomId, record.strRoomId)
|| normalizeRoomPair(record.roomID, record.strRoomID)
if (found) return found
}
return normalizeRoomScalar((room as Record<string, unknown>).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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
emit({ source: 'doctor-call', event: 'error', diagnosisId: activeConfig?.diagnosisId, message })
throw new Error(message)
} finally {
starting = false
if (cycle === -1 || cycle === callCycleGeneration) starting = false
}
}
@@ -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)