Merge branch 'master' into 11-5
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user