feat(phase2e-2): 前端 mediasoup-client 集成 + Pinia meeting Store(Task 9)

- frontend/src/api/meeting.js:12 个 REST 接口封装,统一 unwrap envelope.data
- frontend/src/services/websocket.js:新增 sendWithAck(Promise 化 + 超时 + 序列号)
- frontend/src/utils/mediasoup-client.js:MediaEngine 包装 Device/Transport/Producer/Consumer
- frontend/src/store/meeting.js:Pinia 会议状态机,桥接 14 个 WS 事件 + cleanupStaleMeetings
- frontend/src/constants/meeting.js:状态枚举 + 事件名集中管理
- frontend/src/pages/meeting/debug.vue:临时调试页(H5 原生 video/audio DOM 绕过 uni 组件限制)
- backend:meeting.consume.resume WS 事件 + create/join 响应透传 router_id + rtp_capabilities
- 文档:frontend/meeting.md、websocket.md、CURRENT_STATUS、plan 全部同步 Task 9 落地

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-22 11:22:49 +08:00
parent e7f6a32dfe
commit f97fec24a8
22 changed files with 2314 additions and 60 deletions

View File

@@ -0,0 +1,658 @@
/**
* 会议模块 Pinia Store
*
* 职责:
* - 管理当前入会状态本地状态机idle/joining/connecting/connected/reconnecting/leaving/ended
* - 维护房间信息、参与者列表、本地/远端媒体 Producer/Consumer
* - 串联 REST API + WebSocket 信令 + mediasoup-client
* - 监听 8 个服务端广播事件,实时更新 state
*
* 设计要点Task 9
* - WS 事件监听仅在进入会议室时注册、离会时注销(决策 Q3=a3_on_enter_room
* - mediasoup 的 Device/Transport/Producer/Consumer 用 markRaw 避免 Vue 代理
* - mediasoup-client 仅 H5 可用,其他平台调用 enterRoom 会抛错
*
* 上层调用模式(极简):
* const meetingStore = useMeetingStore()
* await meetingStore.createAndEnter({ title }) // 创建并入会
* await meetingStore.joinAndEnter(roomCode, password) // 加入已有会议
* await meetingStore.startLocalAudio()
* await meetingStore.startLocalVideo()
* await meetingStore.leave() // 离会
*/
import { defineStore } from 'pinia'
import { ref, computed, markRaw, reactive } from 'vue'
import meetingApi from '@/api/meeting'
import wsService from '@/services/websocket'
import { createMediaEngine } from '@/utils/mediasoup-client'
import { useUserStore } from '@/store/user'
import {
MEETING_LOCAL_STATE_IDLE,
MEETING_LOCAL_STATE_JOINING,
MEETING_LOCAL_STATE_CONNECTING,
MEETING_LOCAL_STATE_CONNECTED,
MEETING_LOCAL_STATE_RECONNECTING,
MEETING_LOCAL_STATE_LEAVING,
MEETING_LOCAL_STATE_ENDED,
MEETING_WS_ROOM_JOIN,
MEETING_WS_ROOM_ENDED,
MEETING_WS_MEMBER_JOINED,
MEETING_WS_MEMBER_LEFT,
MEETING_WS_MEMBER_STATE_CHANGED,
MEETING_WS_MEMBER_KICKED,
MEETING_WS_MEMBER_PRODUCER_NEW,
MEETING_WS_HOST_CHANGED,
MEETING_WS_CHAT_MESSAGE,
MEETING_ROLE_HOST,
MEETING_ENDED_REASON_LABEL,
MEETING_HOST_AUTO_REASON_LABEL
} from '@/constants/meeting'
/** 判断当前平台是否支持 mediasoup-client仅 H5 */
const isMediaSupported = () => {
// #ifdef H5
return true
// #endif
// #ifndef H5
return false
// #endif
}
export const useMeetingStore = defineStore('meeting', () => {
// ==================== State ====================
/** 本地状态机MEETING_LOCAL_STATE_* */
const localState = ref(MEETING_LOCAL_STATE_IDLE)
/** 当前会议房间 DTOREST 响应中的 room 字段) */
const currentRoom = ref(null)
/** 当前用户的 participant DTO */
const currentParticipant = ref(null)
/** Router IDNode mediasoup Router—— 仅日志/调试用 */
const routerID = ref('')
/** 活跃参与者列表(含当前用户,按加入时间正序) */
const participants = ref([])
/** 会议聊天消息列表(最新在尾部) */
const chatMessages = ref([])
/** 最近一次会议结束原因room.ended_reason 或 room.ended.reason */
const lastEndedReason = ref('')
/** 最近一次自动主持人转让原因auto_reason */
const lastAutoHostReason = ref('')
/** 本地媒体状态 */
const localAudioEnabled = ref(false)
const localVideoEnabled = ref(false)
/**
* 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId? }
* 用于启停麦克风/摄像头
*/
const localProducers = reactive({ audio: null, video: null })
/**
* 远端 Consumer 索引:{ [userId]: { audio?: Consumer, video?: Consumer, producerIds: Set } }
* 使用 reactive 但 Consumer 实例本身是 markRaw不会被深度代理
*/
const remoteConsumers = reactive({})
/**
* 标记:当前在 Store 内的事件监听器集合(进房注册,离房注销)
* Key 是事件名Value 是监听函数引用
*/
const _listeners = new Map()
/** MediaEngine 实例H5 才有值),用 markRaw 包裹避免 Vue 深度代理 */
let _engine = null
// ==================== Getters ====================
const isInMeeting = computed(() => {
return localState.value !== MEETING_LOCAL_STATE_IDLE &&
localState.value !== MEETING_LOCAL_STATE_ENDED
})
const isConnected = computed(() => localState.value === MEETING_LOCAL_STATE_CONNECTED)
const isHost = computed(() => {
const userStore = useUserStore()
const uid = userStore.userInfo?.id
return currentRoom.value && uid && currentRoom.value.host_id === uid
})
const activeParticipants = computed(() => {
return participants.value.filter(p => p.is_active)
})
// ==================== 私有工具 ====================
const _log = (level, ...args) => {
const fn = console[level] || console.log
fn.call(console, ...args)
}
/**
* 识别"已在其他会议中"这类僵尸残留错误,加上提示前端可调用 cleanupStaleMeetings 的 hint
* 保留原始 err 对象引用,仅附加 staleMeetingHint 字段供上层 UI 判定
*/
const _enrichStaleMeetingError = (err) => {
if (!err) return err
const msg = err.message || err.data?.message || ''
if (msg.includes('已在其他会议中') || msg.includes('已在此会议中')) {
err.staleMeetingHint = true
}
return err
}
const _reset = () => {
localState.value = MEETING_LOCAL_STATE_IDLE
currentRoom.value = null
currentParticipant.value = null
routerID.value = ''
participants.value = []
chatMessages.value = []
localAudioEnabled.value = false
localVideoEnabled.value = false
localProducers.audio = null
localProducers.video = null
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
_engine = null
}
/** 统一注册 WS 监听(进房调用一次) */
const _registerListeners = () => {
const handlers = {
[MEETING_WS_ROOM_ENDED]: _onRoomEnded,
[MEETING_WS_MEMBER_JOINED]: _onMemberJoined,
[MEETING_WS_MEMBER_LEFT]: _onMemberLeft,
[MEETING_WS_MEMBER_STATE_CHANGED]: _onMemberStateChanged,
[MEETING_WS_MEMBER_KICKED]: _onMemberKicked,
[MEETING_WS_MEMBER_PRODUCER_NEW]: _onProducerNew,
[MEETING_WS_HOST_CHANGED]: _onHostChanged,
[MEETING_WS_CHAT_MESSAGE]: _onChatMessage
}
Object.entries(handlers).forEach(([event, handler]) => {
wsService.on(event, handler)
_listeners.set(event, handler)
})
}
const _unregisterListeners = () => {
_listeners.forEach((handler, event) => {
wsService.off(event, handler)
})
_listeners.clear()
}
// ==================== WS 事件回调 ====================
const _onRoomEnded = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
lastEndedReason.value = data.reason || ''
_log('info', '[Meeting] 会议已结束', data.reason, MEETING_ENDED_REASON_LABEL[data.reason])
_cleanupMedia()
localState.value = MEETING_LOCAL_STATE_ENDED
_unregisterListeners()
}
const _onMemberJoined = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
// 后端广播目前只带基础字段,完整 DTO 通过 GET /rooms/:code 二次拉取
if (!participants.value.find(p => p.user_id === data.user_id)) {
participants.value.push({
user_id: data.user_id,
joined_at: data.joined_at,
is_active: true,
role: 0
})
}
}
const _onMemberLeft = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
const idx = participants.value.findIndex(p => p.user_id === data.user_id)
if (idx >= 0) {
participants.value[idx].is_active = false
participants.value[idx].left_reason = data.reason
}
_cleanupRemoteUser(data.user_id)
}
const _onMemberStateChanged = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
const p = participants.value.find(p => p.user_id === data.user_id)
if (p) {
if (typeof data.audio_enabled === 'boolean') p.audio_enabled = data.audio_enabled
if (typeof data.video_enabled === 'boolean') p.video_enabled = data.video_enabled
}
}
const _onMemberKicked = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
const userStore = useUserStore()
if (data.user_id === userStore.userInfo?.id) {
_log('warn', '[Meeting] 当前用户被踢出会议')
lastEndedReason.value = 'kicked'
_cleanupMedia()
localState.value = MEETING_LOCAL_STATE_ENDED
_unregisterListeners()
} else {
const idx = participants.value.findIndex(p => p.user_id === data.user_id)
if (idx >= 0) {
participants.value[idx].is_active = false
participants.value[idx].left_reason = 'kicked'
}
_cleanupRemoteUser(data.user_id)
}
}
/**
* 收到 meeting.member.producer.new 广播 → 自动订阅
* 后端在 Producer 创建 / 关闭时都广播本事件closed=true 表示关闭
*/
const _onProducerNew = async (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
const userStore = useUserStore()
if (data.user_id === userStore.userInfo?.id) return
if (data.closed) {
_cleanupRemoteProducer(data.user_id, data.producer_id)
return
}
if (!_engine) {
_log('warn', '[Meeting] producer.new 收到但 MediaEngine 未就绪,跳过')
return
}
try {
const consumer = await _engine.consume({ producerId: data.producer_id })
if (!remoteConsumers[data.user_id]) {
remoteConsumers[data.user_id] = markRaw({
audio: null,
video: null,
producerIds: new Set()
})
}
const slot = remoteConsumers[data.user_id]
slot[consumer.kind] = markRaw(consumer)
slot.producerIds.add(data.producer_id)
// track 已在 consumer.track 中,调用方(页面)可从 store 拿 consumer.track 挂到 DOM
// 这里先做最简化的 resume页面渲染后若还需等 DOM 就绪,可改为在页面层调 resumeConsumer
await _engine.resumeConsumer(consumer.id)
} catch (err) {
_log('error', '[Meeting] 订阅远端 Producer 失败', err)
}
}
const _onHostChanged = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
currentRoom.value.host_id = data.new_host_id
lastAutoHostReason.value = data.auto_reason || ''
_log('info', '[Meeting] 主持人变更',
data.new_host_id,
data.auto_reason ? `(auto: ${MEETING_HOST_AUTO_REASON_LABEL[data.auto_reason]})` : '(manual)')
const p = participants.value.find(p => p.user_id === data.new_host_id)
if (p) p.role = MEETING_ROLE_HOST
const old = participants.value.find(p => p.user_id === data.old_host_id)
if (old) old.role = 0
}
const _onChatMessage = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
chatMessages.value.push(data)
}
// ==================== 远端媒体清理 ====================
const _cleanupRemoteProducer = (userId, producerId) => {
const slot = remoteConsumers[userId]
if (!slot) return
slot.producerIds.delete(producerId)
// 无法从 producerId 直接反查 consumer简单策略关 slot 内所有 consumer 然后重建MVP
// 但更稳的:把 Consumer 索引按 producerId 存一遍
;['audio', 'video'].forEach(kind => {
const consumer = slot[kind]
if (consumer && !consumer.closed) {
try { consumer.close() } catch {}
slot[kind] = null
}
})
if (slot.producerIds.size === 0) {
delete remoteConsumers[userId]
}
}
const _cleanupRemoteUser = (userId) => {
const slot = remoteConsumers[userId]
if (!slot) return
;['audio', 'video'].forEach(kind => {
if (slot[kind] && !slot[kind].closed) {
try { slot[kind].close() } catch {}
}
})
delete remoteConsumers[userId]
}
const _cleanupMedia = () => {
if (_engine) {
try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) }
_engine = null
}
}
// ==================== 进入会议(公共私有) ====================
/**
* 入会后的共同后置流程:加载 Device、创建两条 Transport、发 room.join 信令、订阅在线 Producer
*/
const _afterJoined = async ({ rtpCapabilities }) => {
if (!isMediaSupported()) {
throw new Error('当前平台不支持会议(仅支持 H5 浏览器)')
}
localState.value = MEETING_LOCAL_STATE_CONNECTING
const userStore = useUserStore()
const uid = userStore.userInfo?.id
if (!uid) throw new Error('当前用户未登录')
_engine = markRaw(createMediaEngine({
roomCode: currentRoom.value.room_code,
userId: uid,
sendWithAck: wsService.sendWithAck.bind(wsService),
logger: _log
}))
await _engine.loadDevice(rtpCapabilities)
_registerListeners()
// 告知后端 WS 我已绑定本房间(用于 WS 连接 ↔ roomCode 映射)
await wsService.sendWithAck(MEETING_WS_ROOM_JOIN, {
room_code: currentRoom.value.room_code
})
// 按需先创建 sendTransport + recvTransport避免页面点"开麦"时首帧有额外抖动
await _engine.ensureSendTransport()
await _engine.ensureRecvTransport()
// 拉取最新参与者列表(广播里只有 ID这里补齐完整 DTO
try {
const detail = await meetingApi.getRoom(currentRoom.value.room_code)
if (detail && Array.isArray(detail.participants)) {
participants.value = detail.participants
}
} catch (e) {
_log('warn', '[Meeting] 拉取参与者详情失败,保留广播缓存', e)
}
localState.value = MEETING_LOCAL_STATE_CONNECTED
}
// ==================== Actions会议生命周期 ====================
/**
* 创建即时会议并立即入会
* @param {Object} payload - { title, password?, max_members? }
*/
const createAndEnter = async (payload) => {
if (isInMeeting.value) {
throw new Error('你当前已在其他会议中')
}
localState.value = MEETING_LOCAL_STATE_JOINING
try {
const resp = await meetingApi.createRoom(payload)
currentRoom.value = resp.room
routerID.value = resp.router_id || ''
// 创建者 host participant 尚未通过 REST 返回,用本地 userStore 占位
const userStore = useUserStore()
currentParticipant.value = {
user_id: userStore.userInfo?.id,
role: MEETING_ROLE_HOST,
is_active: true,
joined_at: new Date().toISOString()
}
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
return currentRoom.value
} catch (err) {
_log('error', '[Meeting] 创建并入会失败', err)
_reset()
throw _enrichStaleMeetingError(err)
}
}
/**
* 加入已有会议
* @param {string} roomCode
* @param {string} [password]
*/
const joinAndEnter = async (roomCode, password = '') => {
if (isInMeeting.value) {
throw new Error('你当前已在其他会议中')
}
localState.value = MEETING_LOCAL_STATE_JOINING
try {
const resp = await meetingApi.joinRoom(roomCode, { password })
currentRoom.value = resp.room
currentParticipant.value = resp.participant
routerID.value = resp.router_id || ''
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
return currentRoom.value
} catch (err) {
_log('error', '[Meeting] 加入会议失败', err)
_reset()
throw _enrichStaleMeetingError(err)
}
}
/** 主动离会(关闭媒体 + REST leave */
const leave = async () => {
if (!isInMeeting.value || !currentRoom.value) return
localState.value = MEETING_LOCAL_STATE_LEAVING
const code = currentRoom.value.room_code
try {
_cleanupMedia()
_unregisterListeners()
await meetingApi.leaveRoom(code)
} catch (err) {
_log('warn', '[Meeting] leave API 失败(忽略)', err)
} finally {
_reset()
}
}
/** 主持人结束会议(会广播 room.ended 给所有人) */
const endMeeting = async () => {
if (!currentRoom.value) return
const code = currentRoom.value.room_code
try {
await meetingApi.endRoom(code)
} catch (err) {
_log('warn', '[Meeting] end API 失败', err)
throw err
}
// room.ended WS 广播会触发 _onRoomEnded 清理
}
// ==================== Actions本地推流 ====================
/** 开启本地音频 */
const startLocalAudio = async () => {
if (!_engine) throw new Error('未连接')
if (localProducers.audio) return
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const track = stream.getAudioTracks()[0]
const producer = await _engine.produce({ kind: 'audio', track })
localProducers.audio = producer.id
localAudioEnabled.value = true
}
/** 开启本地视频 */
const startLocalVideo = async () => {
if (!_engine) throw new Error('未连接')
if (localProducers.video) return
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
const track = stream.getVideoTracks()[0]
const producer = await _engine.produce({ kind: 'video', track })
localProducers.video = producer.id
localVideoEnabled.value = true
}
/** 关闭本地音频 */
const stopLocalAudio = async () => {
if (!_engine || !localProducers.audio) return
await _engine.closeProducer(localProducers.audio)
localProducers.audio = null
localAudioEnabled.value = false
}
/** 关闭本地视频 */
const stopLocalVideo = async () => {
if (!_engine || !localProducers.video) return
await _engine.closeProducer(localProducers.video)
localProducers.video = null
localVideoEnabled.value = false
}
/**
* 取得某个远端用户的 track供页面挂到 <video>/<audio>
* @param {number} userId
* @param {'audio'|'video'} kind
* @returns {MediaStreamTrack|null}
*/
const getRemoteTrack = (userId, kind) => {
const slot = remoteConsumers[userId]
if (!slot || !slot[kind]) return null
return slot[kind].track
}
/** 取得本地 Producer 对应的 track预览用 */
const getLocalTrack = (kind) => {
if (!_engine) return null
const producerId = localProducers[kind]
if (!producerId) return null
const producer = _engine.getProducer(producerId)
return producer ? producer.track : null
}
// ==================== Actions聊天/管理 ====================
const sendChat = async (content) => {
if (!currentRoom.value) throw new Error('未入会')
await meetingApi.sendChat(currentRoom.value.room_code, content)
}
const loadChatHistory = async (beforeId = 0, limit = 30) => {
if (!currentRoom.value) return { list: [], has_more: false }
return meetingApi.listChats(currentRoom.value.room_code, { before_id: beforeId, limit })
}
const transferHost = async (targetUserID) => {
if (!currentRoom.value) throw new Error('未入会')
await meetingApi.transferHost(currentRoom.value.room_code, targetUserID)
}
const kickMember = async (userId) => {
if (!currentRoom.value) throw new Error('未入会')
await meetingApi.kickMember(currentRoom.value.room_code, userId)
}
const inviteUsers = async (inviteeIds) => {
if (!currentRoom.value) throw new Error('未入会')
return meetingApi.inviteUsers(currentRoom.value.room_code, inviteeIds)
}
/**
* 清理遗留的僵尸活跃会议
*
* 使用场景:前端崩溃 / 强制刷新 / 关闭标签页未调 `/leave` 时,后端
* `meeting_participants` 表仍标记用户为活跃参会者,再次创建/加入会议会
* 被后端以"你当前已在其他会议中"拒绝。本方法拉取用户全部 `status=Active`
* 会议并依次调用 `/leave`,顺带处理可能多条的僵尸记录(比如 DAO 异常)。
*
* @returns {Promise<{ cleanedCodes: string[] }>}
*/
const cleanupStaleMeetings = async () => {
const resp = await meetingApi.listMyMeetings({ status: 1, limit: 50 })
const list = (resp && resp.list) || []
const cleanedCodes = []
for (const room of list) {
try {
await meetingApi.leaveRoom(room.room_code)
cleanedCodes.push(room.room_code)
} catch (err) {
_log('warn', '[Meeting] 清理遗留会议失败(忽略继续)', room.room_code, err)
}
}
if (isInMeeting.value) {
// 本地状态可能因 leave 网络错误等处于脏状态,一并 reset
_reset()
}
return { cleanedCodes }
}
// ==================== 导出 ====================
return {
// state
localState,
currentRoom,
currentParticipant,
routerID,
participants,
chatMessages,
lastEndedReason,
lastAutoHostReason,
localAudioEnabled,
localVideoEnabled,
localProducers,
remoteConsumers,
// getters
isInMeeting,
isConnected,
isHost,
activeParticipants,
// lifecycle
createAndEnter,
joinAndEnter,
leave,
endMeeting,
// media
startLocalAudio,
startLocalVideo,
stopLocalAudio,
stopLocalVideo,
getRemoteTrack,
getLocalTrack,
// chat & admin
sendChat,
loadChatHistory,
transferHost,
kickMember,
inviteUsers,
cleanupStaleMeetings,
// util
isMediaSupported
}
})