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

160
frontend/src/api/meeting.js Normal file
View File

@@ -0,0 +1,160 @@
/**
* 会议模块 REST API
*
* 对应后端路由:/api/v1/meeting/*(见 backend/go-service/app/meeting/router.go
*
* 设计原则:
* - 本文件仅做 REST 封装业务层store/meeting.js统一调本文件方法
* - WebRTC 媒体层信令通过 WSservices/websocket.js + sendWithAck发送不在此处
* - 响应中的 DTO 字段命名与后端 DTO 完全一致(下划线),前端按需再转换
*/
import { get, post } from '@/utils/request'
/**
* 统一拆包utils/request.js 返回完整 envelope { code, message, data, trace_id, time }
* 业务层只关心 data 负载;这里一次性拆包,避免每个 store/页面都写 .data.xxx
* 对无 body 的响应(如 leave/end返回 null 而非 undefined方便调用方判空
*/
const unwrap = (promise) => promise.then((resp) => (resp && 'data' in resp ? (resp.data ?? null) : (resp ?? null)))
// ==================== 会议房间6 接口) ====================
/**
* 创建即时会议
* @param {Object} payload - { title, password?, max_members? }
* @returns {Promise<{ room: MeetingRoomDTO }>}
*/
const createRoom = (payload) => {
return unwrap(post('/api/v1/meeting/rooms', payload))
}
/**
* 查询会议详情(含参与者列表)
* @param {string} roomCode - 会议号XXX-XXX-XXX 或 9 位纯数字)
* @returns {Promise<{ room, participants, online_count }>}
*/
const getRoom = (roomCode) => {
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}`))
}
/**
* 加入会议
* @param {string} roomCode - 会议号
* @param {Object} [payload] - { password? }
* @returns {Promise<{ room, participant, router_id }>}
*/
const joinRoom = (roomCode, payload = {}) => {
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/join`, payload))
}
/**
* 离开会议
* @param {string} roomCode
* @returns {Promise<{ duration }>}
*/
const leaveRoom = (roomCode) => {
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/leave`))
}
/**
* 结束会议(仅主持人可调)
* @param {string} roomCode
* @returns {Promise<void>}
*/
const endRoom = (roomCode) => {
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/end`))
}
/**
* 我的会议列表(历史 + 进行中)
* @param {Object} [params] - { status?, before_id?, limit? }
* @returns {Promise<{ list, has_more }>}
*/
const listMyMeetings = (params = {}) => {
return unwrap(get('/api/v1/meeting/rooms/mine', params))
}
// ==================== 会议成员管理3 接口) ====================
/**
* 转让主持人
* @param {string} roomCode
* @param {number} targetUserID
* @returns {Promise<void>}
*/
const transferHost = (roomCode, targetUserID) => {
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/transfer-host`, {
target_user_id: targetUserID
}))
}
/**
* 移除成员(踢人,仅主持人)
* @param {string} roomCode
* @param {number} userID
* @returns {Promise<void>}
*/
const kickMember = (roomCode, userID) => {
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/kick`, { user_id: userID }))
}
/**
* 邀请用户(批量)
* @param {string} roomCode
* @param {number[]} inviteeIDs - 被邀请用户 ID 数组1~50
* @returns {Promise<{ pushed, skipped }>}
*/
const inviteUsers = (roomCode, inviteeIDs) => {
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/invite`, {
invitee_ids: inviteeIDs
}))
}
// ==================== 邀请兑换1 接口) ====================
/**
* 兑换邀请 Token从通知点进链接时使用
* @param {string} token - 邀请链接中的 token 字符串
* @returns {Promise<{ room_code, inviter_id, has_password }>}
*/
const redeemInvite = (token) => {
return unwrap(post(`/api/v1/meeting/invite-tokens/${token}/redeem`))
}
// ==================== 会议内聊天2 接口) ====================
/**
* 发送会议内聊天消息
* @param {string} roomCode
* @param {string} content - 1~500 字
* @returns {Promise<{ message }>}
*/
const sendChat = (roomCode, content) => {
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/chats`, { content }))
}
/**
* 查询会议聊天历史(游标分页)
* @param {string} roomCode
* @param {Object} [params] - { before_id?, limit? }
* @returns {Promise<{ list, has_more }>}
*/
const listChats = (roomCode, params = {}) => {
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}/chats`, params))
}
export default {
createRoom,
getRoom,
joinRoom,
leaveRoom,
endRoom,
listMyMeetings,
transferHost,
kickMember,
inviteUsers,
redeemInvite,
sendChat,
listChats
}

View File

@@ -0,0 +1,163 @@
/**
* 会议模块前端常量
*
* 与后端 backend/go-service/app/constants/meeting.go 严格一致
* 新增/修改任一常量时必须同步维护两端,否则运行时会因事件名不匹配而静默失败
*
* 版本说明:
* - Task 62026-04-20落地 13 个 WS 事件
* - Task 92026-04-22新增 meeting.consume.resume总计 14 个事件
*/
// ==================== 会议类型 ====================
export const MEETING_TYPE_INSTANT = 1 // 即时会议MVP 仅此)
export const MEETING_TYPE_SCHEDULED = 2 // 预约会议Phase 2e-3
export const MEETING_TYPE_LABEL = {
[MEETING_TYPE_INSTANT]: '即时会议',
[MEETING_TYPE_SCHEDULED]: '预约会议'
}
// ==================== 会议状态 ====================
export const MEETING_STATUS_PENDING = 0 // 未开始(仅预约会议)
export const MEETING_STATUS_ACTIVE = 1 // 进行中
export const MEETING_STATUS_ENDED = 2 // 已结束
export const MEETING_STATUS_LABEL = {
[MEETING_STATUS_PENDING]: '未开始',
[MEETING_STATUS_ACTIVE]: '进行中',
[MEETING_STATUS_ENDED]: '已结束'
}
// ==================== 参与者角色 ====================
export const MEETING_ROLE_PARTICIPANT = 0 // 普通参会人
export const MEETING_ROLE_HOST = 1 // 主持人
export const MEETING_ROLE_COHOST = 2 // 联合主持人保留MVP 不用)
export const MEETING_ROLE_LABEL = {
[MEETING_ROLE_PARTICIPANT]: '参会人',
[MEETING_ROLE_HOST]: '主持人',
[MEETING_ROLE_COHOST]: '联合主持人'
}
// ==================== 会议结束原因 ====================
export const MEETING_ENDED_REASON_HOST_ENDED = 'host_ended'
export const MEETING_ENDED_REASON_EMPTY_TTL = 'empty_ttl'
export const MEETING_ENDED_REASON_ADMIN_FORCE = 'admin_force'
export const MEETING_ENDED_REASON_SYSTEM_ERROR = 'system_error'
export const MEETING_ENDED_REASON_LABEL = {
[MEETING_ENDED_REASON_HOST_ENDED]: '主持人结束',
[MEETING_ENDED_REASON_EMPTY_TTL]: '空房超时',
[MEETING_ENDED_REASON_ADMIN_FORCE]: '管理员强制结束',
[MEETING_ENDED_REASON_SYSTEM_ERROR]: '系统异常'
}
// ==================== 离会原因 ====================
export const MEETING_LEFT_REASON_SELF = 'self'
export const MEETING_LEFT_REASON_KICKED = 'kicked'
export const MEETING_LEFT_REASON_HOST_END = 'host_end'
export const MEETING_LEFT_REASON_EMPTY_TTL = 'empty_ttl'
export const MEETING_LEFT_REASON_DISCONNECT = 'disconnect'
export const MEETING_LEFT_REASON_LABEL = {
[MEETING_LEFT_REASON_SELF]: '主动离开',
[MEETING_LEFT_REASON_KICKED]: '被移除',
[MEETING_LEFT_REASON_HOST_END]: '会议结束',
[MEETING_LEFT_REASON_EMPTY_TTL]: '空房超时',
[MEETING_LEFT_REASON_DISCONNECT]: '网络断开'
}
// ==================== 主持人变更自动转让原因Task 8 引入) ====================
export const MEETING_HOST_AUTO_REASON_GRACE_EXPIRED = 'host_grace_expired' // 主持人宽限期到期自动转让
export const MEETING_HOST_AUTO_REASON_HOST_KICKED = 'host_kicked' // (保留)主持人被管理员踢出自动转让
export const MEETING_HOST_AUTO_REASON_LABEL = {
[MEETING_HOST_AUTO_REASON_GRACE_EXPIRED]: '主持人掉线超时自动转让',
[MEETING_HOST_AUTO_REASON_HOST_KICKED]: '主持人被移除自动转让'
}
// ==================== 默认配置常量 ====================
export const MEETING_MVP_MAX_MEMBERS = 8 // MVP 单会议硬上限
export const MEETING_HOST_GRACE_SECONDS = 120 // 主持人掉线宽限期Task 8
export const MEETING_EMPTY_ROOM_TTL_SECONDS = 300 // 空房销毁 TTLTask 8
export const MEETING_WS_ACK_TIMEOUT_MS = 10000 // WS 事件 ACK 等待超时Task 9
// ==================== WS 事件名14 个,与后端 constants/meeting.go 一一对应) ====================
// 房间组3 个)
export const MEETING_WS_ROOM_JOIN = 'meeting.room.join' // C→S
export const MEETING_WS_ROOM_LEAVE = 'meeting.room.leave' // C→S
export const MEETING_WS_ROOM_ENDED = 'meeting.room.ended' // S→C
// 成员组6 个)
export const MEETING_WS_MEMBER_JOINED = 'meeting.member.joined' // S→C
export const MEETING_WS_MEMBER_LEFT = 'meeting.member.left' // S→C
export const MEETING_WS_MEMBER_STATE_CHANGED = 'meeting.member.state.changed' // 双向
export const MEETING_WS_MEMBER_KICKED = 'meeting.member.kicked' // S→C定向
export const MEETING_WS_MEMBER_PRODUCER_NEW = 'meeting.member.producer.new' // S→C
export const MEETING_WS_HOST_CHANGED = 'meeting.host.changed' // S→C
// 媒体组6 个,含 Task 9 新增 consume.resume
export const MEETING_WS_TRANSPORT_CREATE = 'meeting.transport.create' // C→S
export const MEETING_WS_TRANSPORT_CONNECT = 'meeting.transport.connect' // C→S
export const MEETING_WS_PRODUCE_START = 'meeting.produce.start' // C→S
export const MEETING_WS_CONSUME_START = 'meeting.consume.start' // C→S
export const MEETING_WS_CONSUME_RESUME = 'meeting.consume.resume' // C→STask 9 新增)
export const MEETING_WS_PRODUCER_CLOSE = 'meeting.producer.close' // C→S
// 聊天1 个)
export const MEETING_WS_CHAT_MESSAGE = 'meeting.chat.message' // S→C
/** C→S 客户端可主动发起的事件白名单 */
export const MEETING_WS_CLIENT_EVENTS = [
MEETING_WS_ROOM_JOIN,
MEETING_WS_ROOM_LEAVE,
MEETING_WS_MEMBER_STATE_CHANGED,
MEETING_WS_TRANSPORT_CREATE,
MEETING_WS_TRANSPORT_CONNECT,
MEETING_WS_PRODUCE_START,
MEETING_WS_CONSUME_START,
MEETING_WS_CONSUME_RESUME,
MEETING_WS_PRODUCER_CLOSE
]
/** S→C 服务端广播事件白名单(用于 Store 注册监听时统一处理) */
export const MEETING_WS_SERVER_EVENTS = [
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_rooms.status 无关) ====================
// Task 9 用于 store/meeting.js 驱动 UI 的本地状态
export const MEETING_LOCAL_STATE_IDLE = 'idle' // 未进入会议
export const MEETING_LOCAL_STATE_JOINING = 'joining' // REST JoinRoom 中
export const MEETING_LOCAL_STATE_CONNECTING = 'connecting' // Device load + Transport 创建中
export const MEETING_LOCAL_STATE_CONNECTED = 'connected' // 媒体层就绪,可推流/收流
export const MEETING_LOCAL_STATE_RECONNECTING = 'reconnecting' // WS 断线重连期间Task 8 host 宽限期共用)
export const MEETING_LOCAL_STATE_LEAVING = 'leaving' // REST LeaveRoom 中
export const MEETING_LOCAL_STATE_ENDED = 'ended' // 会议已结束room.ended 收到后进入)
export const MEETING_LOCAL_STATE_LABEL = {
[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]: '已结束'
}

View File

@@ -142,6 +142,12 @@
"navigationBarTitleText": "会议"
}
},
{
"path": "pages/meeting/debug",
"style": {
"navigationBarTitleText": "会议调试Task 9"
}
},
{
"path": "pages/notify/index",
"style": {

View File

@@ -0,0 +1,340 @@
<!--
会议调试页Task 9 临时产物
目的
- 在没有正式 UI 的情况下手工验证 frontend/src/store/meeting.js action 通链路
- H5 端可用mediasoup-client 平台限制 Task 9 决策 Q1
注意
- uni-app H5 <video>/<audio> 标签会被编译成 uni 自定义组件不支持 srcObject
- 因此本页用 <view> 作为容器 H5 环境用原生 document.createElement('video'/'audio') WebRTC track
Task 10 开始后此页可删除或重构为正式 UI路由信息仅在 pages.json debug 块中保留
-->
<template>
<view class="page">
<view class="header">
<text class="title">会议调试Task 9</text>
<text class="state">状态{{ localStateLabel }}</text>
<text class="state">房号{{ currentRoom?.room_code || '-' }} ({{ currentRoom?.title || '-' }})</text>
<text class="state">是否主持人{{ isHost ? '是' : '否' }}</text>
<text class="state" v-if="lastEndedReason">结束原因{{ lastEndedReason }}</text>
<text class="state" v-if="lastAutoHostReason">自动转让{{ lastAutoHostReason }}</text>
</view>
<view class="section">
<text class="subtitle">1. 会议生命周期</text>
<view class="row">
<input v-model="form.title" class="input" placeholder="会议标题" />
<button class="btn primary" :disabled="isInMeeting" @click="onCreate">创建并入会</button>
</view>
<view class="row">
<input v-model="form.roomCode" class="input" placeholder="会议号XXX-XXX-XXX 或 9 位)" />
<input v-model="form.password" class="input password" placeholder="密码(可选)" />
<button class="btn primary" :disabled="isInMeeting" @click="onJoin">加入会议</button>
</view>
<view class="row">
<button class="btn" :disabled="!isInMeeting" @click="onLeave">离开会议</button>
<button class="btn danger" :disabled="!isHost || !isInMeeting" @click="onEnd">结束会议host</button>
<button class="btn" @click="onCleanupStale">清理遗留会议</button>
</view>
<text class="hint" v-if="lastHint">{{ lastHint }}</text>
</view>
<view class="section">
<text class="subtitle">2. 本地推流</text>
<view class="row">
<button class="btn" :disabled="!isConnected" @click="onToggleAudio">
{{ localAudioEnabled ? '关闭麦克风' : '开启麦克风' }}
</button>
<button class="btn" :disabled="!isConnected" @click="onToggleVideo">
{{ localVideoEnabled ? '关闭摄像头' : '开启摄像头' }}
</button>
</view>
<view class="video-wrap">
<text class="caption">本地预览</text>
<view ref="localVideoBox" class="video-slot"></view>
</view>
</view>
<view class="section">
<text class="subtitle">3. 远端参与者{{ activeParticipants.length }} 人活跃</text>
<view v-for="p in activeParticipants" :key="p.user_id" class="participant">
<text class="pname">userId={{ p.user_id }} role={{ p.role }} joined={{ p.joined_at }}</text>
<view class="video-wrap">
<view :ref="el => setRemoteBox(p.user_id, el)" class="video-slot small"></view>
</view>
</view>
</view>
<view class="section">
<text class="subtitle">4. 会议内聊天</text>
<view class="chat-box">
<view v-for="(m, i) in chatMessages" :key="i" class="chat-msg">
<text>{{ m.user_name || m.user_id }}{{ m.content }}</text>
</view>
</view>
<view class="row">
<input v-model="chatInput" class="input" placeholder="输入聊天内容" />
<button class="btn" :disabled="!isInMeeting" @click="onSendChat">发送</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { useMeetingStore } from '@/store/meeting'
import { MEETING_LOCAL_STATE_LABEL } from '@/constants/meeting'
import { storeToRefs } from 'pinia'
const meetingStore = useMeetingStore()
const {
localState,
currentRoom,
chatMessages,
lastEndedReason,
lastAutoHostReason,
localAudioEnabled,
localVideoEnabled,
isInMeeting,
isConnected,
isHost,
activeParticipants,
remoteConsumers
} = storeToRefs(meetingStore)
const form = reactive({ title: '调试会议', roomCode: '', password: '' })
const chatInput = ref('')
const lastHint = ref('')
// 容器 ref保存 Vue 组件实例或真实 DOM
const localVideoBox = ref(null)
const remoteBoxes = reactive({})
const setRemoteBox = (userId, el) => {
if (el) remoteBoxes[userId] = el
else delete remoteBoxes[userId]
}
const localStateLabel = computed(() => {
const label = MEETING_LOCAL_STATE_LABEL[localState.value] || localState.value
return `${localState.value} (${label})`
})
// ==================== 原生 DOM 辅助(仅 H5====================
/** 把 uni <view> 的 ref 统一转成真实 DOM 元素 */
const resolveDom = (refValue) => {
if (!refValue) return null
// #ifdef H5
if (refValue instanceof HTMLElement) return refValue
if (refValue.$el) return refValue.$el
if (refValue.exposed && refValue.exposed.$el) return refValue.exposed.$el
// #endif
return null
}
/** 在容器内确保存在一个 <video> / <audio> 原生元素,并返回它 */
const ensureMediaEl = (container, tagName, className = '') => {
// #ifdef H5
const parent = resolveDom(container)
if (!parent) return null
let el = parent.querySelector(`:scope > ${tagName}`)
if (!el) {
el = document.createElement(tagName)
el.autoplay = true
el.playsInline = true
if (tagName === 'video') {
el.style.width = '100%'
el.style.maxWidth = '240px'
el.style.background = '#000'
el.style.borderRadius = '4px'
}
if (className) el.className = className
parent.appendChild(el)
}
return el
// #endif
// #ifndef H5
return null
// #endif
}
const removeMediaEl = (container, tagName) => {
// #ifdef H5
const parent = resolveDom(container)
if (!parent) return
const el = parent.querySelector(`:scope > ${tagName}`)
if (el) {
try { el.srcObject = null } catch {}
parent.removeChild(el)
}
// #endif
}
// ==================== 本地/远端 track 挂载 ====================
const attachLocalTrack = () => {
// #ifdef H5
const track = meetingStore.getLocalTrack('video')
if (track) {
const el = ensureMediaEl(localVideoBox.value, 'video')
if (el) {
el.muted = true
el.srcObject = new MediaStream([track])
}
} else {
removeMediaEl(localVideoBox.value, 'video')
}
// #endif
}
const attachRemoteTracks = () => {
// #ifdef H5
Object.keys(remoteConsumers.value).forEach((uid) => {
const box = remoteBoxes[uid]
if (!box) return
const videoTrack = meetingStore.getRemoteTrack(Number(uid), 'video')
const audioTrack = meetingStore.getRemoteTrack(Number(uid), 'audio')
if (videoTrack) {
const videoEl = ensureMediaEl(box, 'video', 'remote-video')
if (videoEl) videoEl.srcObject = new MediaStream([videoTrack])
} else {
removeMediaEl(box, 'video')
}
if (audioTrack) {
const audioEl = ensureMediaEl(box, 'audio', 'remote-audio')
if (audioEl) audioEl.srcObject = new MediaStream([audioTrack])
} else {
removeMediaEl(box, 'audio')
}
})
// #endif
}
watch(localVideoEnabled, () => nextTick(attachLocalTrack))
watch(remoteConsumers, () => nextTick(attachRemoteTracks), { deep: true })
watch(activeParticipants, () => nextTick(attachRemoteTracks))
// ==================== Actions ====================
const handleStaleHint = (err) => {
if (err && err.staleMeetingHint) {
lastHint.value = '检测到僵尸参会残留:请点击"清理遗留会议"按钮后重试'
}
}
const onCreate = async () => {
lastHint.value = ''
try {
await meetingStore.createAndEnter({ title: form.title })
uni.showToast({ title: '已创建并入会', icon: 'success' })
} catch (err) {
handleStaleHint(err)
uni.showToast({ title: `创建失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
}
}
const onJoin = async () => {
lastHint.value = ''
try {
await meetingStore.joinAndEnter(form.roomCode, form.password)
uni.showToast({ title: '已加入会议', icon: 'success' })
} catch (err) {
handleStaleHint(err)
uni.showToast({ title: `加入失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
}
}
const onCleanupStale = async () => {
lastHint.value = ''
try {
const { cleanedCodes } = await meetingStore.cleanupStaleMeetings()
if (cleanedCodes.length === 0) {
lastHint.value = '无遗留活跃会议'
uni.showToast({ title: '无遗留活跃会议', icon: 'none' })
} else {
lastHint.value = `已清理 ${cleanedCodes.length} 条:${cleanedCodes.join('、')}`
uni.showToast({ title: `已清理 ${cleanedCodes.length}`, icon: 'success' })
}
} catch (err) {
uni.showToast({ title: `清理失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
}
}
const onLeave = async () => {
await meetingStore.leave()
uni.showToast({ title: '已离会', icon: 'success' })
}
const onEnd = async () => {
try {
await meetingStore.endMeeting()
} catch (err) {
uni.showToast({ title: `结束失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
}
}
const onToggleAudio = async () => {
try {
if (localAudioEnabled.value) await meetingStore.stopLocalAudio()
else await meetingStore.startLocalAudio()
} catch (err) {
uni.showToast({ title: `音频切换失败:${err.message}`, icon: 'none' })
}
}
const onToggleVideo = async () => {
try {
if (localVideoEnabled.value) await meetingStore.stopLocalVideo()
else await meetingStore.startLocalVideo()
} catch (err) {
uni.showToast({ title: `视频切换失败:${err.message}`, icon: 'none' })
}
}
const onSendChat = async () => {
const content = chatInput.value.trim()
if (!content) return
try {
await meetingStore.sendChat(content)
chatInput.value = ''
} catch (err) {
uni.showToast({ title: `发送失败:${err.message || JSON.stringify(err)}`, icon: 'none' })
}
}
onBeforeUnmount(() => {
if (isInMeeting.value) {
meetingStore.leave().catch(() => {})
}
})
</script>
<style scoped>
.page { padding: 24rpx; background: #F8FAFC; min-height: 100vh; }
.header { background: #fff; padding: 24rpx; border-radius: 12rpx; margin-bottom: 24rpx; }
.title { font-size: 36rpx; font-weight: 600; color: #1E293B; display: block; margin-bottom: 12rpx; }
.state { font-size: 26rpx; color: #475569; display: block; margin-top: 4rpx; }
.section { background: #fff; padding: 24rpx; border-radius: 12rpx; margin-bottom: 24rpx; }
.subtitle { font-size: 30rpx; font-weight: 600; color: #1E293B; display: block; margin-bottom: 16rpx; }
.row { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; flex-wrap: wrap; }
.input { flex: 1; min-width: 200rpx; border: 1px solid #CBD5E1; padding: 12rpx 16rpx; border-radius: 8rpx; font-size: 28rpx; }
.input.password { max-width: 200rpx; }
.btn { padding: 12rpx 24rpx; border-radius: 8rpx; background: #E2E8F0; color: #1E293B; font-size: 26rpx; }
.btn.primary { background: #3B82F6; color: #fff; }
.btn.danger { background: #EF4444; color: #fff; }
.btn[disabled] { opacity: 0.5; }
.video-wrap { display: flex; flex-direction: column; gap: 8rpx; margin-top: 16rpx; }
.caption { font-size: 22rpx; color: #64748B; }
.video-slot { width: 100%; min-height: 180rpx; background: #0B1220; border-radius: 8rpx; display: flex; align-items: center; justify-content: center; }
.video-slot.small { min-height: 140rpx; }
.participant { padding: 16rpx 0; border-bottom: 1px dashed #E2E8F0; }
.pname { font-size: 24rpx; color: #475569; }
.chat-box { max-height: 300rpx; overflow-y: auto; background: #F1F5F9; padding: 12rpx; border-radius: 8rpx; margin-bottom: 12rpx; }
.chat-msg { font-size: 26rpx; color: #1E293B; padding: 4rpx 0; }
.hint { font-size: 24rpx; color: #F59E0B; margin-top: 12rpx; display: block; }
</style>

View File

@@ -18,6 +18,7 @@ const HEARTBEAT_INTERVAL = 30000
const RECONNECT_BASE_DELAY = 1000
const RECONNECT_MAX_DELAY = 30000
const MAX_RECONNECT_ATTEMPTS = Infinity
const ACK_DEFAULT_TIMEOUT_MS = 10000
class WebSocketService {
constructor() {
@@ -28,6 +29,11 @@ class WebSocketService {
this.reconnectAttempts = 0
this.isManualClose = false
this.seq = 0
/**
* 等待 ACK 的请求索引Map<seq, { resolve, reject, timer, event }>
* Task 9 引入,为 meeting.* 事件提供 Promise 化 ACK 等待
*/
this.pendingAcks = new Map()
}
/**
@@ -120,6 +126,41 @@ class WebSocketService {
return this.seq
}
/**
* 发送消息并等待服务端 ACKTask 9 引入)
*
* 服务端对 C→S 事件以相同 event 名 + 对应 seq 返回 ACK 报文:
* { event, seq, data: { code, message, data? } }
*
* 约定:
* - code === 0 表示成功Promise resolve 为 data.data业务 payload
* - code !== 0 表示业务失败Promise reject 为 { code, message }
* - 超时 reject 为 { code: -1, message: 'ACK 超时' }
* - 连接未就绪 reject 为 { code: -1, message: '连接未就绪' }
*
* 典型用法:
* const info = await wsService.sendWithAck('meeting.transport.create', { room_code, direction: 'send' })
*
* @param {string} event - 事件名
* @param {Object} [data] - 业务 payload
* @param {number} [timeoutMS=10000] - 超时时间
* @returns {Promise<any>} ACK 的 data 字段
*/
sendWithAck(event, data = {}, timeoutMS = ACK_DEFAULT_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const seq = this.send(event, data)
if (seq === -1) {
reject({ code: -1, message: '连接未就绪' })
return
}
const timer = setTimeout(() => {
this.pendingAcks.delete(seq)
reject({ code: -1, message: `ACK 超时 (${event})` })
}, timeoutMS)
this.pendingAcks.set(seq, { resolve, reject, timer, event })
})
}
/**
* 注册事件监听
* @param {string} event - 事件类型
@@ -171,6 +212,7 @@ class WebSocketService {
_onClose(e) {
console.log('[WS] 连接关闭', e)
this._clearTimers()
this._rejectAllPendingAcks('连接已断开')
this._emit('_disconnected')
if (!this.isManualClose) {
@@ -178,6 +220,19 @@ class WebSocketService {
}
}
/**
* 连接断开时统一拒绝所有未完成的 ACK 等待Task 9
* 避免业务层 await sendWithAck() 永久挂起
*/
_rejectAllPendingAcks(reason) {
if (this.pendingAcks.size === 0) return
this.pendingAcks.forEach((pending, seq) => {
clearTimeout(pending.timer)
pending.reject({ code: -1, message: reason, event: pending.event })
})
this.pendingAcks.clear()
}
_onError(e) {
console.error('[WS] 连接错误', e)
}
@@ -185,12 +240,36 @@ class WebSocketService {
_onMessage(data) {
try {
const msg = JSON.parse(data)
// Task 9ACK 报文event 以 ".ack" 结尾)走 pendingAcks 处理
// 后端 ws.NewResponse 固定在原 event 后追加 ".ack"
if (typeof msg.event === 'string' && msg.event.endsWith('.ack')) {
this._handleAck(msg)
return
}
this._emit(msg.event, msg)
} catch (e) {
console.warn('[WS] 消息解析失败', data)
}
}
/**
* 处理 ACK 报文,触发 pendingAcks 中对应 seq 的 resolve/reject
* @param {Object} msg - { event: 'xxx.ack', seq, code, message, data? }
*/
_handleAck(msg) {
const pending = this.pendingAcks.get(msg.seq)
if (!pending) {
return
}
this.pendingAcks.delete(msg.seq)
clearTimeout(pending.timer)
if (msg.code === 0) {
pending.resolve(msg.data)
} else {
pending.reject({ code: msg.code, message: msg.message || 'WS 请求失败', event: pending.event })
}
}
/** 分发事件给注册的监听器 */
_emit(event, data) {
const cbs = this.listeners.get(event)

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
}
})

View File

@@ -0,0 +1,408 @@
/**
* mediasoup-client 封装层
*
* 职责:
* - 封装 Device/sendTransport/recvTransport/Producer/Consumer 的生命周期
* - 把 Transport 的 connect/produce 回调桥接到 WS 信令sendWithAck
* - 对上层store/meeting.js暴露同步友好的异步 API
*
* 平台约束Task 9 决策 Q1=a1_h5_only
* - mediasoup-client 仅在 H5 端可用;非 H5 平台调用时会在构造时抛 ERR_PLATFORM
* - import 语句通过 uni-app 条件编译注释限定在 H5 构建
*
* 使用方式(仅限 store/meeting.js
* import { createMediaEngine } from '@/utils/mediasoup-client'
* const engine = markRaw(createMediaEngine({ roomCode, userId, sendWithAck }))
* await engine.loadDevice(rtpCapabilities)
* const sendTransport = await engine.ensureSendTransport()
* const producer = await engine.produce({ kind: 'audio', track })
*/
// #ifdef H5
import { Device } from 'mediasoup-client'
// #endif
import {
MEETING_WS_TRANSPORT_CREATE,
MEETING_WS_TRANSPORT_CONNECT,
MEETING_WS_PRODUCE_START,
MEETING_WS_CONSUME_START,
MEETING_WS_CONSUME_RESUME,
MEETING_WS_PRODUCER_CLOSE
} from '@/constants/meeting'
/**
* 创建 MediaEngine 实例
*
* @param {Object} options
* @param {string} options.roomCode - 会议号
* @param {number} options.userId - 当前用户 ID
* @param {Function} options.sendWithAck - WS 发送带 ACK 等待的方法(由 services/websocket.js 提供)
* @param {Function} [options.logger] - 日志函数,默认 console.log
* @returns {Object} MediaEngine 实例
*/
export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defaultLogger }) {
if (!roomCode) throw new Error('[MediaEngine] roomCode 不能为空')
if (!userId) throw new Error('[MediaEngine] userId 不能为空')
if (typeof sendWithAck !== 'function') {
throw new Error('[MediaEngine] sendWithAck 必须是函数')
}
// #ifndef H5
throw new Error('[MediaEngine] 当前平台不支持 mediasoup-client仅支持 H5')
// #endif
// #ifdef H5
/** mediasoup Device 实例Device.load 后持有 Router rtpCapabilities */
let device = null
/** 发送 Transport本地 Producer 用),延迟创建,复用 */
let sendTransport = null
/** 接收 Transport本地 Consumer 用),延迟创建,复用 */
let recvTransport = null
/** 本地 Producer 索引 Map<producerId, Producer> */
const producers = new Map()
/** 本地 Consumer 索引 Map<consumerId, Consumer> */
const consumers = new Map()
/** 是否已关闭,防止重复调用 close */
let closed = false
const ensureNotClosed = () => {
if (closed) {
throw new Error('[MediaEngine] 已关闭')
}
}
const ensureDeviceLoaded = () => {
ensureNotClosed()
if (!device || !device.loaded) {
throw new Error('[MediaEngine] Device 尚未 load')
}
}
/**
* 加载 Device
* @param {Object} routerRtpCapabilities - 后端 JoinRoom 响应中的 rtp_capabilities
*/
const loadDevice = async (routerRtpCapabilities) => {
ensureNotClosed()
if (device && device.loaded) {
logger('debug', '[MediaEngine] Device 已 load跳过')
return
}
if (!routerRtpCapabilities) {
throw new Error('[MediaEngine] routerRtpCapabilities 不能为空')
}
device = new Device()
await device.load({ routerRtpCapabilities })
logger('info', '[MediaEngine] Device load 成功', { canProduceAudio: device.canProduce('audio'), canProduceVideo: device.canProduce('video') })
}
/** 返回 Device.rtpCapabilities用于后端 CreateConsumer */
const getRtpCapabilities = () => {
ensureDeviceLoaded()
return device.rtpCapabilities
}
/**
* 通过 WS 请求后端创建 Transport返回 mediasoup-client 可用的 Transport 元信息
* @param {'send'|'recv'} direction
*/
const requestTransportInfo = async (direction) => {
const info = await sendWithAck(MEETING_WS_TRANSPORT_CREATE, {
room_code: roomCode,
direction
})
if (!info || !info.id) {
throw new Error(`[MediaEngine] 创建 ${direction} Transport 失败:后端返回为空`)
}
return info
}
/**
* 绑定 sendTransport 的 connect + produce 回调(把本地事件桥到 WS 信令)
* mediasoup-client 约定:
* - transport.on('connect', ({ dtlsParameters }, callback, errback))
* 需要在 DTLS 握手完成前告知远端 DTLS 参数
* - transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback))
* 需要在 Producer 创建前把 RTP 参数发给远端,拿到远端分配的 producerId 回调 callback({ id })
*/
const bindSendTransportEvents = (transport) => {
transport.on('connect', ({ dtlsParameters }, callback, errback) => {
sendWithAck(MEETING_WS_TRANSPORT_CONNECT, {
room_code: roomCode,
transport_id: transport.id,
dtls_parameters: dtlsParameters
}).then(() => callback()).catch((err) => {
logger('error', '[MediaEngine] sendTransport connect 失败', err)
errback(err)
})
})
transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback) => {
sendWithAck(MEETING_WS_PRODUCE_START, {
room_code: roomCode,
transport_id: transport.id,
kind,
rtp_parameters: rtpParameters,
app_data: appData || {}
}).then((resp) => {
if (!resp || !resp.producer_id) {
errback(new Error('后端返回 producer_id 为空'))
return
}
callback({ id: resp.producer_id })
}).catch((err) => {
logger('error', '[MediaEngine] sendTransport produce 失败', err)
errback(err)
})
})
transport.on('connectionstatechange', (state) => {
logger('debug', `[MediaEngine] sendTransport state=${state}`)
})
}
/** recvTransport 只需要桥接 connect 回调consume 由 store 主动调起) */
const bindRecvTransportEvents = (transport) => {
transport.on('connect', ({ dtlsParameters }, callback, errback) => {
sendWithAck(MEETING_WS_TRANSPORT_CONNECT, {
room_code: roomCode,
transport_id: transport.id,
dtls_parameters: dtlsParameters
}).then(() => callback()).catch((err) => {
logger('error', '[MediaEngine] recvTransport connect 失败', err)
errback(err)
})
})
transport.on('connectionstatechange', (state) => {
logger('debug', `[MediaEngine] recvTransport state=${state}`)
})
}
/** 按需创建 sendTransport已存在则复用 */
const ensureSendTransport = async () => {
ensureDeviceLoaded()
if (sendTransport && !sendTransport.closed) {
return sendTransport
}
const info = await requestTransportInfo('send')
sendTransport = device.createSendTransport({
id: info.id,
iceParameters: info.iceParameters,
iceCandidates: info.iceCandidates,
dtlsParameters: info.dtlsParameters,
sctpParameters: info.sctpParameters
})
bindSendTransportEvents(sendTransport)
logger('info', '[MediaEngine] sendTransport 创建成功', { id: sendTransport.id })
return sendTransport
}
/** 按需创建 recvTransport已存在则复用 */
const ensureRecvTransport = async () => {
ensureDeviceLoaded()
if (recvTransport && !recvTransport.closed) {
return recvTransport
}
const info = await requestTransportInfo('recv')
recvTransport = device.createRecvTransport({
id: info.id,
iceParameters: info.iceParameters,
iceCandidates: info.iceCandidates,
dtlsParameters: info.dtlsParameters,
sctpParameters: info.sctpParameters
})
bindRecvTransportEvents(recvTransport)
logger('info', '[MediaEngine] recvTransport 创建成功', { id: recvTransport.id })
return recvTransport
}
/**
* 在 sendTransport 上创建 Producer推本地音/视频)
* @param {Object} opts
* @param {'audio'|'video'} opts.kind
* @param {MediaStreamTrack} opts.track
* @param {Object} [opts.encodings]
* @param {Object} [opts.codecOptions]
* @param {Object} [opts.appData]
* @returns {Promise<Producer>}
*/
const produce = async ({ kind, track, encodings, codecOptions, appData }) => {
ensureDeviceLoaded()
if (!device.canProduce(kind)) {
throw new Error(`[MediaEngine] Device 不支持 produce kind=${kind}`)
}
const transport = await ensureSendTransport()
const produceOpts = { track }
if (encodings) produceOpts.encodings = encodings
if (codecOptions) produceOpts.codecOptions = codecOptions
produceOpts.appData = { user_id: userId, ...(appData || {}) }
const producer = await transport.produce(produceOpts)
producers.set(producer.id, producer)
// 监听 Producer 关闭事件,清理本地索引;外部可见的关闭由 closeProducer 触发 WS
producer.on('transportclose', () => {
producers.delete(producer.id)
logger('debug', `[MediaEngine] producer ${producer.id} transportclose`)
})
producer.on('trackended', () => {
logger('warn', `[MediaEngine] producer ${producer.id} trackended将触发关闭`)
closeProducer(producer.id).catch((err) => logger('error', '关闭 Producer 失败', err))
})
logger('info', `[MediaEngine] producer 创建成功 kind=${kind} id=${producer.id}`)
return producer
}
/**
* 订阅远端 Producer请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume
*
* 与 Task 9 决策 Q6=B 对齐的规范流程:
* 1. WS consume.start → 拿到 { id, producerId, kind, rtpParameters } Node 侧 paused
* 2. recvTransport.consume(...) 得到本地 Consumertrack 可用)
* 3. 调用方把 track 挂到 <video>/<audio> 元素DOM 就绪)
* 4. 调用 consumer.resume()(此方法返回的 consumer 暴露 resume()
*
* 本函数执行完 1+2 后返回 consumer调用方挂 track 后必须调一次 engine.resumeConsumer(consumerId)
* 以告知后端 → Node 把 Consumer 从 paused 切到 active
*
* @param {Object} opts
* @param {string} opts.producerId - 要订阅的远端 Producer ID
* @returns {Promise<Consumer>} 本地 Consumer 实例(此时仍 paused
*/
const consume = async ({ producerId }) => {
ensureDeviceLoaded()
if (!producerId) throw new Error('[MediaEngine] producerId 不能为空')
const transport = await ensureRecvTransport()
const info = await sendWithAck(MEETING_WS_CONSUME_START, {
room_code: roomCode,
transport_id: transport.id,
producer_id: producerId,
rtp_capabilities: device.rtpCapabilities
})
if (!info || !info.id) {
throw new Error('[MediaEngine] 后端未返回 Consumer 元信息')
}
const consumer = await transport.consume({
id: info.id,
producerId: info.producerId || info.producer_id || producerId,
kind: info.kind,
rtpParameters: info.rtpParameters
})
consumers.set(consumer.id, consumer)
consumer.on('transportclose', () => {
consumers.delete(consumer.id)
logger('debug', `[MediaEngine] consumer ${consumer.id} transportclose`)
})
logger('info', `[MediaEngine] consumer 创建成功 id=${consumer.id} kind=${consumer.kind}`)
return consumer
}
/**
* 通知后端 resume Consumertrack 已挂载到 DOM 后调用)
* 对应 Task 9 决策 Q6=B 的 meeting.consume.resume WS 事件
* @param {string} consumerId
*/
const resumeConsumer = async (consumerId) => {
ensureNotClosed()
await sendWithAck(MEETING_WS_CONSUME_RESUME, {
room_code: roomCode,
consumer_id: consumerId
})
const local = consumers.get(consumerId)
if (local && typeof local.resume === 'function') {
await local.resume()
}
logger('info', `[MediaEngine] consumer ${consumerId} resumed`)
}
/**
* 关闭指定 Producer本地 close + WS 通知后端)
* 幂等producerId 不存在时静默返回
*/
const closeProducer = async (producerId) => {
if (closed) return
const producer = producers.get(producerId)
if (!producer) return
try {
if (!producer.closed) producer.close()
} catch (e) {
logger('warn', '[MediaEngine] producer.close 抛错', e)
}
producers.delete(producerId)
try {
await sendWithAck(MEETING_WS_PRODUCER_CLOSE, {
room_code: roomCode,
producer_id: producerId
})
} catch (e) {
// 后端已清理也视为成功(幂等);只记日志
logger('warn', `[MediaEngine] producer.close WS 通知失败 ${producerId}`, e)
}
}
/** 本地 Consumer 关闭(通常由 producer.new closed=true 广播触发,无需额外 WS */
const closeConsumer = (consumerId) => {
const consumer = consumers.get(consumerId)
if (!consumer) return
try {
if (!consumer.closed) consumer.close()
} catch (e) {
logger('warn', '[MediaEngine] consumer.close 抛错', e)
}
consumers.delete(consumerId)
}
/** 释放所有资源(本地 close不触发 WS由 store 在离会时调用) */
const close = () => {
if (closed) return
closed = true
try {
producers.forEach((p) => { try { if (!p.closed) p.close() } catch {} })
producers.clear()
consumers.forEach((c) => { try { if (!c.closed) c.close() } catch {} })
consumers.clear()
if (sendTransport && !sendTransport.closed) sendTransport.close()
if (recvTransport && !recvTransport.closed) recvTransport.close()
} finally {
sendTransport = null
recvTransport = null
device = null
}
logger('info', '[MediaEngine] 已关闭')
}
return {
loadDevice,
getRtpCapabilities,
ensureSendTransport,
ensureRecvTransport,
produce,
consume,
resumeConsumer,
closeProducer,
closeConsumer,
close,
getDevice: () => device,
getSendTransport: () => sendTransport,
getRecvTransport: () => recvTransport,
getProducer: (id) => producers.get(id),
getConsumer: (id) => consumers.get(id)
}
// #endif
}
function defaultLogger(level, ...args) {
const fn = console[level] || console.log
fn.call(console, ...args)
}