视频会议保存

This commit is contained in:
duoaohui
2026-05-18 19:42:55 +08:00
parent 315884685f
commit ac8ec4c903
41 changed files with 5069 additions and 39 deletions

56
admin/src/api/meeting.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* 会议管理 API 模块Phase B
*
* 对应后端路由:/api/v1/admin/meetings
* 所有接口需要 JWT + admin 角色权限
*
* 接口列表:
* - GET /api/v1/admin/meetings 会议列表(分页 + 多条件筛选)
* - GET /api/v1/admin/meetings/:id 会议详情(含参与者 + 录制列表)
* - GET /api/v1/admin/meetings/stats 会议统计(卡片)
*/
import request from '@/utils/request'
/**
* 获取会议列表
* @param {Object} params
* @param {number} [params.page]
* @param {number} [params.page_size]
* @param {string} [params.keyword] - 模糊匹配 title / room_code
* @param {number} [params.status] - 0=未开始 1=进行中 2=已结束
* @param {number} [params.host_id] - 主持人 ID
* @param {boolean} [params.has_recording] - true=仅含录制的会议
* @param {string} [params.start_time] - YYYY-MM-DD
* @param {string} [params.end_time] - YYYY-MM-DD
* @returns {Promise<{data:{total:number,list:Array,page:number,page_size:number}}>}
*/
export function getMeetingList(params) {
return request({
url: '/api/v1/admin/meetings',
method: 'get',
params
})
}
/**
* 获取会议详情(含参与者 + 录制列表)
* @param {number} id - 会议主键 ID
* @returns {Promise<{data:Object}>}
*/
export function getMeetingDetail(id) {
return request({
url: `/api/v1/admin/meetings/${id}`,
method: 'get'
})
}
/**
* 获取会议统计(顶栏卡片)
* @returns {Promise<{data:{total_count,active_count,today_count,recording_count}}>}
*/
export function getMeetingStats() {
return request({
url: '/api/v1/admin/meetings/stats',
method: 'get'
})
}

View File

@@ -74,6 +74,12 @@ const routes = [
name: 'MessageStats', name: 'MessageStats',
component: () => import('@/views/message/stats.vue'), component: () => import('@/views/message/stats.vue'),
meta: { title: '消息统计' } meta: { title: '消息统计' }
},
{
path: 'meeting/list',
name: 'MeetingList',
component: () => import('@/views/meeting/list.vue'),
meta: { title: '会议记录' }
} }
] ]
} }

View File

@@ -65,6 +65,14 @@
<el-menu-item index="/message/stats">消息统计</el-menu-item> <el-menu-item index="/message/stats">消息统计</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="meeting-manage">
<template #title>
<el-icon><VideoCamera /></el-icon>
<span>会议管理</span>
</template>
<el-menu-item index="/meeting/list">会议记录</el-menu-item>
</el-sub-menu>
<el-menu-item index="/monitor/online"> <el-menu-item index="/monitor/online">
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<template #title>在线监控</template> <template #title>在线监控</template>

View File

@@ -0,0 +1,654 @@
<!--
管理端 - 会议管理列表Phase B
设计系统 message/list.vue 一致的 Data-Dense Dashboard 风格
功能
- 顶部统计卡片历史会议总数 / 进行中 / 今日新建 / 录制总数
- 多条件筛选关键词 / 状态 / 仅含录制 / 时间范围
- 数据表格会议号 / 标题 / 主持人 / 状态 / 参与人数 / 录制数 / 时长 / 创建时间
- 详情弹窗基础信息 + 参与者列表 + 录制列表含播放链接 / 失败原因
对应后端 API管理端
- GET /api/v1/admin/meetings
- GET /api/v1/admin/meetings/:id
- GET /api/v1/admin/meetings/stats
-->
<template>
<div class="meeting-list-page">
<div class="page-header">
<h2 class="page-title">会议管理</h2>
</div>
<!-- 顶部统计卡片 -->
<div class="stat-cards">
<el-card class="stat-card" shadow="never">
<div class="stat-label">会议总数</div>
<div class="stat-value">{{ stats.total_count }}</div>
</el-card>
<el-card class="stat-card stat-card--accent" shadow="never">
<div class="stat-label">进行中</div>
<div class="stat-value">{{ stats.active_count }}</div>
</el-card>
<el-card class="stat-card" shadow="never">
<div class="stat-label">今日新建</div>
<div class="stat-value">{{ stats.today_count }}</div>
</el-card>
<el-card class="stat-card stat-card--rec" shadow="never">
<div class="stat-label">录制总数</div>
<div class="stat-value">{{ stats.recording_count }}</div>
</el-card>
</div>
<!-- 筛选区 -->
<el-card class="filter-card" shadow="never">
<el-form :inline="true" class="filter-form" @submit.prevent="handleSearch">
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="会议号 / 标题"
clearable
style="width: 220px"
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 120px">
<el-option label="未开始" :value="0" />
<el-option label="进行中" :value="1" />
<el-option label="已结束" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="主持人ID">
<el-input
v-model="filters.host_id"
placeholder="用户ID"
clearable
style="width: 120px"
type="number"
/>
</el-form-item>
<el-form-item label="仅含录制">
<el-switch v-model="filters.has_recording" />
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
@change="onDateRangeChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card class="table-card" shadow="never">
<el-table
v-loading="loading"
:data="meetingList"
stripe
border
style="width: 100%"
row-class-name="table-row"
>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="会议号" width="140">
<template #default="{ row }">
<span class="room-code">{{ row.room_code }}</span>
</template>
</el-table-column>
<el-table-column label="标题 / 类型" min-width="200">
<template #default="{ row }">
<div class="title-cell">
<span class="meeting-title">{{ row.title || '(未命名会议)' }}</span>
<el-tag size="small" type="info" effect="plain">{{ row.type_label }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="主持人" min-width="160">
<template #default="{ row }">
<div class="host-cell">
<div
class="host-avatar"
:style="{ backgroundColor: getAvatarColor(row.host_nickname) }"
>
<img v-if="row.host_avatar" :src="row.host_avatar" class="host-avatar-img" />
<span v-else>{{ (row.host_nickname || '?')[0] }}</span>
</div>
<div class="host-info">
<span class="host-name">{{ row.host_nickname || '-' }}</span>
<span class="host-id">ID: {{ row.host_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagMap[row.status]?.type || 'info'" size="small">
{{ row.status_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="参与人数" width="100" align="center">
<template #default="{ row }">
<span class="num-cell">{{ row.participant_count }}</span>
<span class="num-suffix">/ {{ row.max_members }}</span>
</template>
</el-table-column>
<el-table-column label="录制" width="100" align="center">
<template #default="{ row }">
<el-tag
v-if="row.recording_count > 0"
type="warning"
size="small"
effect="light"
>
{{ row.recording_count }}
</el-tag>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="时长" width="110" align="center">
<template #default="{ row }">
<span>{{ formatDuration(row.duration_sec) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" align="center" />
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchMeetingList"
@current-change="fetchMeetingList"
/>
</div>
</el-card>
<!-- 会议详情弹窗 -->
<el-dialog
v-model="detailVisible"
title="会议详情"
width="780px"
destroy-on-close
class="detail-dialog"
>
<div v-loading="detailLoading" class="detail-content">
<template v-if="currentMeeting">
<!-- 会议头部 -->
<div class="detail-header">
<div class="detail-header-main">
<div class="detail-room-code">{{ currentMeeting.room_code }}</div>
<div class="detail-title">{{ currentMeeting.title }}</div>
<div class="detail-meta">
<el-tag size="small" :type="statusTagMap[currentMeeting.status]?.type || 'info'">
{{ currentMeeting.status_label }}
</el-tag>
<el-tag size="small" type="info" effect="plain">{{ currentMeeting.type_label }}</el-tag>
<span class="meta-divider">·</span>
<span class="meta-item">主持人 {{ currentMeeting.host_nickname || currentMeeting.host_id }}</span>
<span class="meta-divider">·</span>
<span class="meta-item">时长 {{ formatDuration(currentMeeting.duration_sec) }}</span>
</div>
</div>
</div>
<!-- 元数据栅格 -->
<div class="detail-grid">
<div class="detail-grid-item">
<span class="grid-label">参与人数</span>
<span class="grid-value">
{{ currentMeeting.participant_count }} / {{ currentMeeting.max_members }}
</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">录制段数</span>
<span class="grid-value">{{ currentMeeting.recording_count }}</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">开始时间</span>
<span class="grid-value">{{ currentMeeting.started_at || '—' }}</span>
</div>
<div class="detail-grid-item">
<span class="grid-label">结束时间</span>
<span class="grid-value">
{{ currentMeeting.ended_at || '—' }}
<el-tag
v-if="currentMeeting.ended_reason"
size="small"
effect="plain"
type="info"
style="margin-left: 6px"
>
{{ endedReasonLabel(currentMeeting.ended_reason) }}
</el-tag>
</span>
</div>
</div>
<!-- 参与者 -->
<div class="detail-section">
<div class="section-header">
<span>参与者</span>
<span class="section-count">{{ currentMeeting.participants?.length || 0 }}</span>
</div>
<el-table
:data="currentMeeting.participants || []"
size="small"
border
style="width: 100%"
>
<el-table-column label="用户" min-width="180">
<template #default="{ row }">
<div class="participant-cell">
<div
class="participant-avatar"
:style="{ backgroundColor: getAvatarColor(row.nickname) }"
>
<img v-if="row.avatar" :src="row.avatar" class="participant-avatar-img" />
<span v-else>{{ (row.nickname || '?')[0] }}</span>
</div>
<div class="participant-info">
<span class="participant-name">{{ row.nickname || '-' }}</span>
<span class="participant-id">ID: {{ row.user_id }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="角色" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.role === 1 ? 'danger' : row.role === 2 ? 'warning' : 'info'"
size="small"
effect="light"
>
{{ row.role_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="joined_at" label="加入时间" width="170" align="center" />
<el-table-column label="离开时间" width="170" align="center">
<template #default="{ row }">
<span v-if="row.left_at">{{ row.left_at }}</span>
<el-tag v-else type="success" size="small" effect="plain">在线</el-tag>
</template>
</el-table-column>
<el-table-column label="离会原因" width="110" align="center">
<template #default="{ row }">
{{ row.left_reason ? leftReasonLabel(row.left_reason) : '—' }}
</template>
</el-table-column>
<el-table-column label="参会时长" width="100" align="center">
<template #default="{ row }">{{ formatDuration(row.duration) }}</template>
</el-table-column>
</el-table>
</div>
<!-- 录制列表 -->
<div class="detail-section">
<div class="section-header">
<span>录制列表</span>
<span class="section-count">{{ currentMeeting.recordings?.length || 0 }}</span>
</div>
<el-empty
v-if="!currentMeeting.recordings || currentMeeting.recordings.length === 0"
description="无录制记录"
:image-size="60"
/>
<el-table
v-else
:data="currentMeeting.recordings"
size="small"
border
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column label="发起人" width="100" align="center">
<template #default="{ row }">ID: {{ row.started_by }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="recordingStatusTagMap[row.status]?.type || 'info'" size="small">
{{ recordingStatusTagMap[row.status]?.label || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时长" width="100" align="center">
<template #default="{ row }">{{ formatDuration(row.duration_sec) }}</template>
</el-table-column>
<el-table-column label="文件大小" width="110" align="center">
<template #default="{ row }">{{ formatBytes(row.size_bytes) }}</template>
</el-table-column>
<el-table-column prop="started_at" label="开始时间" width="170" align="center" />
<el-table-column label="文件" min-width="100" align="center">
<template #default="{ row }">
<a
v-if="row.status === 'ready' && row.file_url"
:href="row.file_url"
target="_blank"
class="file-link"
>播放 / 下载</a>
<span v-else-if="row.status === 'failed'" class="failure-text">
{{ row.failure_reason || '失败' }}
</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getMeetingList, getMeetingDetail, getMeetingStats } from '@/api/meeting'
const AVATAR_COLORS = ['#2563EB', '#7C3AED', '#059669', '#D97706', '#DC2626', '#0891B2', '#4F46E5', '#BE185D']
const statusTagMap = {
0: { label: '未开始', type: 'info' },
1: { label: '进行中', type: 'success' },
2: { label: '已结束', type: 'warning' }
}
const recordingStatusTagMap = {
recording: { label: '录制中', type: 'success' },
uploading: { label: '上传中', type: 'warning' },
ready: { label: '已就绪', type: 'success' },
failed: { label: '失败', type: 'danger' }
}
const endedReasonLabels = {
host_ended: '主持人结束',
empty_ttl: '空房超时',
admin_force: '管理员强制',
system_error: '系统异常'
}
const leftReasonLabels = {
self: '主动离开',
kicked: '被移除',
host_end: '会议结束',
empty_ttl: '空房超时',
disconnect: '掉线'
}
const endedReasonLabel = (r) => endedReasonLabels[r] || r
const leftReasonLabel = (r) => leftReasonLabels[r] || r
const loading = ref(false)
const meetingList = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const dateRange = ref(null)
const stats = reactive({
total_count: 0,
active_count: 0,
today_count: 0,
recording_count: 0
})
const filters = reactive({
keyword: '',
status: null,
host_id: null,
has_recording: false
})
const detailVisible = ref(false)
const detailLoading = ref(false)
const currentMeeting = ref(null)
const getAvatarColor = (name) => {
if (!name) return '#94A3B8'
return AVATAR_COLORS[name.charCodeAt(0) % AVATAR_COLORS.length]
}
const formatDuration = (sec) => {
if (!sec || sec <= 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const formatBytes = (b) => {
if (!b || b <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let n = b
let i = 0
while (n >= 1024 && i < units.length - 1) {
n /= 1024
i++
}
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`
}
const onDateRangeChange = (val) => {
if (!val) {
filters.start_time = undefined
filters.end_time = undefined
}
}
const fetchMeetingList = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value
}
if (filters.keyword) params.keyword = filters.keyword
if (filters.status != null) params.status = filters.status
if (filters.host_id) params.host_id = Number(filters.host_id)
if (filters.has_recording) params.has_recording = true
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = dateRange.value[0]
params.end_time = dateRange.value[1]
}
const res = await getMeetingList(params)
meetingList.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (e) {
ElMessage.error(e?.message || '获取会议列表失败')
} finally {
loading.value = false
}
}
const fetchStats = async () => {
try {
const res = await getMeetingStats()
Object.assign(stats, res.data || {})
} catch (e) {
// 统计失败不阻塞列表
console.warn('[meeting] stats load failed', e)
}
}
const handleSearch = () => {
currentPage.value = 1
fetchMeetingList()
}
const handleReset = () => {
filters.keyword = ''
filters.status = null
filters.host_id = null
filters.has_recording = false
dateRange.value = null
currentPage.value = 1
fetchMeetingList()
}
const viewDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
currentMeeting.value = null
try {
const res = await getMeetingDetail(row.id)
currentMeeting.value = res.data
} catch (e) {
ElMessage.error(e?.message || '获取会议详情失败')
} finally {
detailLoading.value = false
}
}
onMounted(() => {
fetchMeetingList()
fetchStats()
})
</script>
<style scoped>
.meeting-list-page { padding: 0; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-title { font-size: 20px; font-weight: 600; color: #1E293B; margin: 0; }
/* 统计卡片 */
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.stat-card { border: 1px solid #E2E8F0; }
.stat-card.stat-card--accent { border-color: #BFDBFE; background: linear-gradient(135deg, #EFF6FF 0%, #FFFFFF 100%); }
.stat-card.stat-card--rec { border-color: #FCD34D; background: linear-gradient(135deg, #FFFBEB 0%, #FFFFFF 100%); }
.stat-label { font-size: 12px; color: #64748B; text-transform: uppercase; letter-spacing: 0.04em; }
.stat-value { font-size: 26px; font-weight: 700; color: #0F172A; margin-top: 4px; }
.filter-card { margin-bottom: 16px; }
.filter-form { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.table-card { margin-bottom: 16px; }
.room-code {
font-family: 'SF Mono', 'Menlo', monospace;
font-weight: 600;
color: #2563EB;
}
.title-cell { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.meeting-title { font-weight: 500; color: #1E293B; }
.num-cell { font-weight: 600; color: #0F172A; }
.num-suffix { font-size: 12px; color: #94A3B8; margin-left: 2px; }
.text-muted { color: #94A3B8; }
/* 主持人 */
.host-cell { display: flex; align-items: center; gap: 10px; }
.host-avatar {
width: 32px; height: 32px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600; color: #FFFFFF; flex-shrink: 0; overflow: hidden;
}
.host-avatar-img { width: 100%; height: 100%; object-fit: cover; }
.host-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.host-name { font-weight: 500; color: #1E293B; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host-id { font-size: 11px; color: #94A3B8; }
.pagination-wrapper { display: flex; justify-content: flex-end; padding-top: 16px; }
/* ===== 详情弹窗 ===== */
.detail-content { min-height: 200px; }
.detail-header {
padding: 16px 20px;
background: linear-gradient(135deg, #F8FAFC 0%, #EFF6FF 100%);
border-radius: 12px;
margin-bottom: 20px;
}
.detail-room-code {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 13px;
color: #2563EB;
font-weight: 600;
letter-spacing: 0.05em;
}
.detail-title { font-size: 18px; font-weight: 700; color: #0F172A; margin: 4px 0 8px; }
.detail-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 13px; color: #475569; }
.meta-divider { color: #CBD5E1; }
.meta-item { display: inline-flex; align-items: center; }
.detail-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 20px;
}
.detail-grid-item {
display: flex; flex-direction: column; gap: 4px;
padding: 12px 16px; background: #F8FAFC; border-radius: 8px; border: 1px solid #E2E8F0;
}
.grid-label { font-size: 12px; color: #94A3B8; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; }
.grid-value { font-size: 14px; color: #1E293B; font-weight: 500; display: inline-flex; align-items: center; }
.detail-section { margin-bottom: 20px; }
.section-header {
display: flex; align-items: center; gap: 8px;
font-size: 14px; font-weight: 600; color: #334155;
margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid #E2E8F0;
}
.section-count { font-size: 12px; color: #94A3B8; font-weight: 500; }
/* 参与者头像 */
.participant-cell { display: flex; align-items: center; gap: 8px; }
.participant-avatar {
width: 28px; height: 28px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 600; color: #FFFFFF; flex-shrink: 0; overflow: hidden;
}
.participant-avatar-img { width: 100%; height: 100%; object-fit: cover; }
.participant-info { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.participant-name { font-size: 12px; font-weight: 500; color: #1E293B; }
.participant-id { font-size: 10px; color: #94A3B8; }
.file-link { color: #2563EB; font-size: 13px; text-decoration: none; }
.file-link:hover { text-decoration: underline; }
.failure-text { color: #DC2626; font-size: 12px; }
</style>

View File

@@ -0,0 +1,89 @@
// Package controller 提供 admin 模块的 HTTP 入口(会议管理)
package controller
import (
"errors"
"strconv"
"github.com/echochat/backend/app/admin/service"
"github.com/echochat/backend/app/dto"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// MeetingManageController 管理端会议管理 HTTP 控制器
//
// 路由(在 admin/router.go 中挂载到 /api/v1/admin/meetings 下JWT + admin 角色):
// - GET /api/v1/admin/meetings 分页列表 + 多条件筛选
// - GET /api/v1/admin/meetings/stats 会议管理摘要统计
// - GET /api/v1/admin/meetings/:id 会议详情(含参与者、录制列表)
type MeetingManageController struct {
svc *service.MeetingManageService
}
// NewMeetingManageController Wire Provider
func NewMeetingManageController(svc *service.MeetingManageService) *MeetingManageController {
return &MeetingManageController{svc: svc}
}
// GetMeetingList GET /api/v1/admin/meetings
func (ctl *MeetingManageController) GetMeetingList(c *gin.Context) {
ctx := c.Request.Context()
var req dto.AdminMeetingListRequest
if err := c.ShouldBindQuery(&req); err != nil {
utils.ResponseBadRequest(c, "参数格式错误")
return
}
resp, err := ctl.svc.GetMeetingList(ctx, &req)
if err != nil {
ctl.handleError(c, err, "获取会议列表失败")
return
}
utils.ResponseSuccess(c, resp)
}
// GetMeetingDetail GET /api/v1/admin/meetings/:id
func (ctl *MeetingManageController) GetMeetingDetail(c *gin.Context) {
ctx := c.Request.Context()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
utils.ResponseBadRequest(c, "会议 ID 非法")
return
}
resp, err := ctl.svc.GetMeetingDetail(ctx, id)
if err != nil {
ctl.handleError(c, err, "获取会议详情失败")
return
}
utils.ResponseSuccess(c, resp)
}
// GetMeetingStats GET /api/v1/admin/meetings/stats
func (ctl *MeetingManageController) GetMeetingStats(c *gin.Context) {
ctx := c.Request.Context()
resp, err := ctl.svc.GetStats(ctx)
if err != nil {
ctl.handleError(c, err, "获取会议统计失败")
return
}
utils.ResponseSuccess(c, resp)
}
// handleError 统一业务错误映射
func (ctl *MeetingManageController) handleError(c *gin.Context, err error, fallbackMsg string) {
switch {
case errors.Is(err, service.ErrMeetingNotFound):
utils.ResponseNotFound(c, err.Error())
default:
logs.Warn(c.Request.Context(), "controller.meeting_manage_controller.handleError",
fallbackMsg, zap.Error(err))
utils.ResponseError(c, fallbackMsg)
}
}

View File

@@ -0,0 +1,286 @@
// Package dao 提供 admin 模块的数据库访问操作(会议管理)
package dao
import (
"context"
"errors"
"fmt"
"time"
"github.com/echochat/backend/app/dto"
meetingModel "github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// MeetingManageDAO 管理端会议数据访问对象
//
// 设计要点:
// - 列表查询走聚合 SQL一次取 meeting_rooms + host 昵称 + 参与者数 + 录制数,避免 N+1
// - 仅做"查询/统计"层操作,写操作(结束会议、删除录制)放在 service 层并复用既有路径
// - 与 MessageManageDAO 保持一致的 ListXxx + 统计方法风格
type MeetingManageDAO struct {
db *gorm.DB
}
// NewMeetingManageDAO 创建 MeetingManageDAO 实例
func NewMeetingManageDAO(db *gorm.DB) *MeetingManageDAO {
return &MeetingManageDAO{db: db}
}
// MeetingListRow 列表行的聚合结果DAO 内部结构,不外泄)
//
// 通过一次带 LEFT JOIN + GROUP BY 的查询拿齐:
// - 主表 meeting_rooms 全部字段
// - 主持人昵称 / 头像auth_users
// - 参与者数meeting_participants 去重 user_id
// - 录制数meeting_recordings 总条目)
//
// 注意:使用 GORM Raw + Scan绕开 model 主键自动展开,方便聚合
type MeetingListRow struct {
ID int64 `gorm:"column:id"`
RoomCode string `gorm:"column:room_code"`
Title string `gorm:"column:title"`
Type int `gorm:"column:type"`
Status int `gorm:"column:status"`
HostID int64 `gorm:"column:host_id"`
HostNickname string `gorm:"column:host_nickname"`
HostAvatar string `gorm:"column:host_avatar"`
MaxMembers int `gorm:"column:max_members"`
ParticipantCount int `gorm:"column:participant_count"`
RecordingCount int `gorm:"column:recording_count"`
ScheduledAt *time.Time `gorm:"column:scheduled_at"`
StartedAt *time.Time `gorm:"column:started_at"`
EndedAt *time.Time `gorm:"column:ended_at"`
EndedReason *string `gorm:"column:ended_reason"`
CreatedAt time.Time `gorm:"column:created_at"`
}
// buildListWhere 把 AdminMeetingListRequest 拼成 WHERE 条件,返回 SQL 片段与参数
// 复用给 ListMeetings + CountMeetings 两个查询,保证条件一致
func (d *MeetingManageDAO) buildListWhere(req *dto.AdminMeetingListRequest) (string, []any) {
conditions := []string{"1=1"}
args := []any{}
if req.Keyword != "" {
conditions = append(conditions, "(mr.title ILIKE ? OR mr.room_code ILIKE ?)")
kw := fmt.Sprintf("%%%s%%", req.Keyword)
args = append(args, kw, kw)
}
if req.Status != nil {
conditions = append(conditions, "mr.status = ?")
args = append(args, *req.Status)
}
if req.HostID != nil {
conditions = append(conditions, "mr.host_id = ?")
args = append(args, *req.HostID)
}
if req.StartTime != "" {
if t, err := time.Parse("2006-01-02", req.StartTime); err == nil {
conditions = append(conditions, "mr.created_at >= ?")
args = append(args, t)
}
}
if req.EndTime != "" {
if t, err := time.Parse("2006-01-02", req.EndTime); err == nil {
conditions = append(conditions, "mr.created_at < ?")
args = append(args, t.AddDate(0, 0, 1))
}
}
// HasRecording 通过 HAVING 在外层处理,这里不拼接
where := ""
for i, c := range conditions {
if i == 0 {
where = c
} else {
where = where + " AND " + c
}
}
return where, args
}
// ListMeetings 分页查询会议列表
//
// 聚合 SQL 设计:
// - LEFT JOIN auth_users拿主持人昵称host 可能已删除时为空,不影响行)
// - LEFT JOIN meeting_participants聚合参与者数COUNT DISTINCT user_id
// - LEFT JOIN meeting_recordings聚合录制数COUNT recording.id
// - HasRecording 通过 HAVING recording_count > 0 二次过滤
//
// 排序created_at DESC最新会议在前
func (d *MeetingManageDAO) ListMeetings(ctx context.Context, req *dto.AdminMeetingListRequest) ([]MeetingListRow, int64, error) {
funcName := "dao.meeting_manage_dao.ListMeetings"
logs.Debug(ctx, funcName, "查询会议列表")
where, args := d.buildListWhere(req)
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
// 子查询聚合 participant_count / recording_count避免笛卡尔积放大 COUNT
// 这里用相关子查询而非 JOIN+GROUP BY便于 HAVING 与 COUNT(*) 分页对齐
baseSelect := `
SELECT
mr.id, mr.room_code, mr.title, mr.type, mr.status, mr.host_id,
COALESCE(u.nickname, '') AS host_nickname,
COALESCE(u.avatar, '') AS host_avatar,
mr.max_members,
COALESCE((SELECT COUNT(DISTINCT mp.user_id) FROM meeting_participants mp WHERE mp.room_id = mr.id), 0) AS participant_count,
COALESCE((SELECT COUNT(*) FROM meeting_recordings mrec WHERE mrec.room_id = mr.id), 0) AS recording_count,
mr.scheduled_at, mr.started_at, mr.ended_at, mr.ended_reason, mr.created_at
FROM meeting_rooms mr
LEFT JOIN auth_users u ON u.id = mr.host_id
WHERE ` + where
// HasRecording 过滤:包成外层 SELECT * FROM (...) WHERE recording_count > 0
listSQL := baseSelect
countSQL := `SELECT COUNT(*) FROM (` + baseSelect + `) AS sub`
if req.HasRecording != nil && *req.HasRecording {
listSQL = `SELECT * FROM (` + baseSelect + `) AS sub WHERE recording_count > 0`
countSQL = `SELECT COUNT(*) FROM (` + listSQL + `) AS sub2`
}
listSQL += ` ORDER BY created_at DESC OFFSET ? LIMIT ?`
var total int64
if err := d.db.WithContext(ctx).Raw(countSQL, args...).Scan(&total).Error; err != nil {
logs.Error(ctx, funcName, "统计会议总数失败", zap.Error(err))
return nil, 0, err
}
pageArgs := append([]any{}, args...)
pageArgs = append(pageArgs, (page-1)*pageSize, pageSize)
var rows []MeetingListRow
if err := d.db.WithContext(ctx).Raw(listSQL, pageArgs...).Scan(&rows).Error; err != nil {
logs.Error(ctx, funcName, "查询会议列表失败", zap.Error(err))
return nil, 0, err
}
return rows, total, nil
}
// GetMeetingByID 取单条会议聚合行(含主持人昵称 + 聚合计数)
// 供详情接口复用 MeetingListRow 投影,避免重复定义结构
func (d *MeetingManageDAO) GetMeetingByID(ctx context.Context, id int64) (*MeetingListRow, error) {
funcName := "dao.meeting_manage_dao.GetMeetingByID"
sql := `
SELECT
mr.id, mr.room_code, mr.title, mr.type, mr.status, mr.host_id,
COALESCE(u.nickname, '') AS host_nickname,
COALESCE(u.avatar, '') AS host_avatar,
mr.max_members,
COALESCE((SELECT COUNT(DISTINCT mp.user_id) FROM meeting_participants mp WHERE mp.room_id = mr.id), 0) AS participant_count,
COALESCE((SELECT COUNT(*) FROM meeting_recordings mrec WHERE mrec.room_id = mr.id), 0) AS recording_count,
mr.scheduled_at, mr.started_at, mr.ended_at, mr.ended_reason, mr.created_at
FROM meeting_rooms mr
LEFT JOIN auth_users u ON u.id = mr.host_id
WHERE mr.id = ?
LIMIT 1
`
var row MeetingListRow
err := d.db.WithContext(ctx).Raw(sql, id).Scan(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
logs.Error(ctx, funcName, "查询会议详情失败", zap.Int64("id", id), zap.Error(err))
return nil, err
}
if row.ID == 0 {
return nil, nil
}
return &row, nil
}
// ListParticipantsDetail 拉取会议的全部参与者(带用户昵称/头像)
//
// 按 joined_at ASC 排序,主持人先入会后退出时也按时间顺序展示,便于核对入会轨迹
func (d *MeetingManageDAO) ListParticipantsDetail(ctx context.Context, roomID int64) ([]ParticipantDetailRow, error) {
funcName := "dao.meeting_manage_dao.ListParticipantsDetail"
sql := `
SELECT
mp.user_id, mp.role, mp.joined_at, mp.left_at, mp.left_reason, mp.duration,
COALESCE(u.nickname, '') AS nickname,
COALESCE(u.avatar_url, '') AS avatar
FROM meeting_participants mp
LEFT JOIN auth_users u ON u.id = mp.user_id
WHERE mp.room_id = ?
ORDER BY mp.joined_at ASC, mp.id ASC
`
var rows []ParticipantDetailRow
if err := d.db.WithContext(ctx).Raw(sql, roomID).Scan(&rows).Error; err != nil {
logs.Error(ctx, funcName, "查询会议参与者失败", zap.Int64("room_id", roomID), zap.Error(err))
return nil, err
}
return rows, nil
}
// ParticipantDetailRow 参与者明细投影
type ParticipantDetailRow struct {
UserID int64 `gorm:"column:user_id"`
Role int `gorm:"column:role"`
JoinedAt time.Time `gorm:"column:joined_at"`
LeftAt *time.Time `gorm:"column:left_at"`
LeftReason *string `gorm:"column:left_reason"`
Duration int `gorm:"column:duration"`
Nickname string `gorm:"column:nickname"`
Avatar string `gorm:"column:avatar"`
}
// ListRecordingsByRoom 拉取会议下的全部录制admin 视角,按 started_at DESC
//
// 复用 meeting/model.MeetingRecording不另起 DTOservice 层再投影成 AdminMeetingRecordingDTO
func (d *MeetingManageDAO) ListRecordingsByRoom(ctx context.Context, roomID int64) ([]meetingModel.MeetingRecording, error) {
var list []meetingModel.MeetingRecording
err := d.db.WithContext(ctx).
Where("room_id = ?", roomID).
Order("started_at DESC, id DESC").
Find(&list).Error
return list, err
}
// CountTotal 历史会议总数
func (d *MeetingManageDAO) CountTotal(ctx context.Context) (int64, error) {
var count int64
err := d.db.WithContext(ctx).Model(&meetingModel.MeetingRoom{}).Count(&count).Error
return count, err
}
// CountActive 当前进行中status=1的会议数
func (d *MeetingManageDAO) CountActive(ctx context.Context) (int64, error) {
var count int64
err := d.db.WithContext(ctx).
Model(&meetingModel.MeetingRoom{}).
Where("status = ?", 1).
Count(&count).Error
return count, err
}
// CountToday 今日创建的会议数(按 created_at
func (d *MeetingManageDAO) CountToday(ctx context.Context) (int64, error) {
var count int64
today := time.Now().Truncate(24 * time.Hour)
err := d.db.WithContext(ctx).
Model(&meetingModel.MeetingRoom{}).
Where("created_at >= ?", today).
Count(&count).Error
return count, err
}
// CountRecordings 录制总数
func (d *MeetingManageDAO) CountRecordings(ctx context.Context) (int64, error) {
var count int64
err := d.db.WithContext(ctx).Model(&meetingModel.MeetingRecording{}).Count(&count).Error
return count, err
}

View File

@@ -23,4 +23,8 @@ var AdminSet = wire.NewSet(
dao.NewMessageManageDAO, dao.NewMessageManageDAO,
service.NewMessageManageService, service.NewMessageManageService,
controller.NewMessageManageController, controller.NewMessageManageController,
// Phase B会议管理与录制审计共用一套接口
dao.NewMeetingManageDAO,
service.NewMeetingManageService,
controller.NewMeetingManageController,
) )

View File

@@ -17,6 +17,7 @@ func RegisterRoutes(
contactManageCtrl *controller.ContactManageController, contactManageCtrl *controller.ContactManageController,
groupManageCtrl *controller.GroupManageController, groupManageCtrl *controller.GroupManageController,
msgManageCtrl *controller.MessageManageController, msgManageCtrl *controller.MessageManageController,
meetingManageCtrl *controller.MeetingManageController,
jwtAuth gin.HandlerFunc, jwtAuth gin.HandlerFunc,
) { ) {
// 管理端路由组JWT 认证 + admin/super_admin 角色检查 // 管理端路由组JWT 认证 + admin/super_admin 角色检查
@@ -52,5 +53,11 @@ func RegisterRoutes(
adminGroup.GET("/messages/:id", msgManageCtrl.GetMessageDetail) adminGroup.GET("/messages/:id", msgManageCtrl.GetMessageDetail)
adminGroup.DELETE("/messages/:id", msgManageCtrl.DeleteMessage) adminGroup.DELETE("/messages/:id", msgManageCtrl.DeleteMessage)
adminGroup.PUT("/messages/:id/recall", msgManageCtrl.RecallMessage) adminGroup.PUT("/messages/:id/recall", msgManageCtrl.RecallMessage)
// 会议管理Phase B含录制审计
// 注意 /stats 必须放在 /:id 之前,否则会被 :id 路由吞掉gin trie 优先级问题)
adminGroup.GET("/meetings/stats", meetingManageCtrl.GetMeetingStats)
adminGroup.GET("/meetings", meetingManageCtrl.GetMeetingList)
adminGroup.GET("/meetings/:id", meetingManageCtrl.GetMeetingDetail)
} }
} }

View File

@@ -0,0 +1,268 @@
// Package service 提供 admin 模块的业务服务(会议管理)
package service
import (
"context"
"errors"
"time"
adminDAO "github.com/echochat/backend/app/admin/dao"
"github.com/echochat/backend/app/dto"
meetingModel "github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
)
// ErrMeetingNotFound 会议记录不存在
var ErrMeetingNotFound = errors.New("会议记录不存在")
// MeetingManageService 管理端会议查询服务
//
// 仅做"读"路径:列表 / 详情 / 统计。
// "强制结束会议" 这种写操作直接复用 meeting/service.MeetingService.EndRoom 即可admin_force 路径),
// 暂不在本服务里重复包装,避免与现有 RBAC/中间件冲突。
type MeetingManageService struct {
dao *adminDAO.MeetingManageDAO
}
// NewMeetingManageService Wire Provider
func NewMeetingManageService(dao *adminDAO.MeetingManageDAO) *MeetingManageService {
return &MeetingManageService{dao: dao}
}
// ---------- public methods ----------
// GetMeetingList 查询会议列表(分页 + 多条件)
func (s *MeetingManageService) GetMeetingList(ctx context.Context, req *dto.AdminMeetingListRequest) (*dto.AdminMeetingListResponse, error) {
funcName := "service.meeting_manage_service.GetMeetingList"
logs.Debug(ctx, funcName, "获取会议列表")
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
rows, total, err := s.dao.ListMeetings(ctx, req)
if err != nil {
return nil, err
}
list := make([]dto.AdminMeetingDTO, 0, len(rows))
for i := range rows {
list = append(list, rowToDTO(&rows[i]))
}
return &dto.AdminMeetingListResponse{
Total: total,
List: list,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
// GetMeetingDetail 查询会议详情(含参与者 + 录制)
func (s *MeetingManageService) GetMeetingDetail(ctx context.Context, id int64) (*dto.AdminMeetingDetailResponse, error) {
funcName := "service.meeting_manage_service.GetMeetingDetail"
logs.Debug(ctx, funcName, "获取会议详情", zap.Int64("id", id))
row, err := s.dao.GetMeetingByID(ctx, id)
if err != nil {
return nil, err
}
if row == nil {
return nil, ErrMeetingNotFound
}
participants, err := s.dao.ListParticipantsDetail(ctx, row.ID)
if err != nil {
logs.Warn(ctx, funcName, "拉取参与者明细失败(继续返回详情)",
zap.Int64("room_id", row.ID), zap.Error(err))
participants = nil
}
recordings, err := s.dao.ListRecordingsByRoom(ctx, row.ID)
if err != nil {
logs.Warn(ctx, funcName, "拉取录制列表失败(继续返回详情)",
zap.Int64("room_id", row.ID), zap.Error(err))
recordings = nil
}
pDTOs := make([]dto.AdminMeetingParticipantDTO, 0, len(participants))
for i := range participants {
pDTOs = append(pDTOs, participantToDTO(&participants[i]))
}
rDTOs := make([]dto.AdminMeetingRecordingDTO, 0, len(recordings))
for i := range recordings {
rDTOs = append(rDTOs, recordingToDTO(&recordings[i]))
}
return &dto.AdminMeetingDetailResponse{
AdminMeetingDTO: rowToDTO(row),
Participants: pDTOs,
Recordings: rDTOs,
}, nil
}
// GetStats 获取会议管理摘要统计(卡片)
func (s *MeetingManageService) GetStats(ctx context.Context) (*dto.AdminMeetingStatsResponse, error) {
funcName := "service.meeting_manage_service.GetStats"
logs.Debug(ctx, funcName, "获取会议统计")
total, err := s.dao.CountTotal(ctx)
if err != nil {
return nil, err
}
active, err := s.dao.CountActive(ctx)
if err != nil {
return nil, err
}
today, err := s.dao.CountToday(ctx)
if err != nil {
return nil, err
}
recordings, err := s.dao.CountRecordings(ctx)
if err != nil {
return nil, err
}
return &dto.AdminMeetingStatsResponse{
TotalCount: total,
ActiveCount: active,
TodayCount: today,
RecordingCount: recordings,
}, nil
}
// ---------- DTO 投影辅助 ----------
const datetimeLayout = "2006-01-02 15:04:05"
// formatTimePtr 把 *time.Time 转成展示字符串nil 返回空串
func formatTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return t.Format(datetimeLayout)
}
// formatStrPtr 把 *string 转成普通 stringnil 返回空串
func formatStrPtr(s *string) string {
if s == nil {
return ""
}
return *s
}
// typeLabel 1=即时 2=预约
func typeLabel(t int) string {
switch t {
case 1:
return "即时会议"
case 2:
return "预约会议"
default:
return "未知"
}
}
// statusLabel 0=未开始 1=进行中 2=已结束(与 meeting_rooms.status 对齐)
func statusLabel(s int) string {
switch s {
case 0:
return "未开始"
case 1:
return "进行中"
case 2:
return "已结束"
default:
return "未知"
}
}
// roleLabel 0=普通 1=主持人 2=联合主持人
func roleLabel(r int) string {
switch r {
case 1:
return "主持人"
case 2:
return "联合主持人"
default:
return "参会人"
}
}
// computeDuration 计算会议时长(秒)
// - 已结束ended_at - started_at
// - 进行中now - started_at
// - 未开始0
func computeDuration(row *adminDAO.MeetingListRow) int {
if row.StartedAt == nil {
return 0
}
end := time.Now()
if row.EndedAt != nil {
end = *row.EndedAt
}
d := end.Sub(*row.StartedAt)
if d < 0 {
return 0
}
return int(d.Seconds())
}
func rowToDTO(row *adminDAO.MeetingListRow) dto.AdminMeetingDTO {
return dto.AdminMeetingDTO{
ID: row.ID,
RoomCode: row.RoomCode,
Title: row.Title,
Type: row.Type,
TypeLabel: typeLabel(row.Type),
Status: row.Status,
StatusLabel: statusLabel(row.Status),
HostID: row.HostID,
HostNickname: row.HostNickname,
HostAvatar: row.HostAvatar,
MaxMembers: row.MaxMembers,
ParticipantCount: row.ParticipantCount,
RecordingCount: row.RecordingCount,
DurationSec: computeDuration(row),
ScheduledAt: formatTimePtr(row.ScheduledAt),
StartedAt: formatTimePtr(row.StartedAt),
EndedAt: formatTimePtr(row.EndedAt),
EndedReason: formatStrPtr(row.EndedReason),
CreatedAt: row.CreatedAt.Format(datetimeLayout),
}
}
func participantToDTO(p *adminDAO.ParticipantDetailRow) dto.AdminMeetingParticipantDTO {
return dto.AdminMeetingParticipantDTO{
UserID: p.UserID,
Nickname: p.Nickname,
Avatar: p.Avatar,
Role: p.Role,
RoleLabel: roleLabel(p.Role),
JoinedAt: p.JoinedAt.Format(datetimeLayout),
LeftAt: formatTimePtr(p.LeftAt),
LeftReason: formatStrPtr(p.LeftReason),
Duration: p.Duration,
}
}
func recordingToDTO(r *meetingModel.MeetingRecording) dto.AdminMeetingRecordingDTO {
out := dto.AdminMeetingRecordingDTO{
ID: r.ID,
StartedBy: r.StartedBy,
Status: r.Status,
FileURL: r.FileURL,
SizeBytes: r.SizeBytes,
DurationSec: r.DurationSec,
StartedAt: r.StartedAt.Format(datetimeLayout),
StoppedAt: formatTimePtr(r.StoppedAt),
}
// 仅 failed 暴露 failure_reasonrecording 状态下 FailureReason 字段被复用为本地路径,不应外泄
if r.Status == meetingModel.MeetingRecordingStatusFailed {
out.FailureReason = r.FailureReason
}
return out
}

View File

@@ -129,6 +129,24 @@ const (
// ====== 补充事件(非 §6.3 核心 11 事件,但业务必须)====== // ====== 补充事件(非 §6.3 核心 11 事件,但业务必须)======
MeetingWSEventChatMessage = "meeting.chat.message" // S→C会议内文字聊天广播REST SendChat 触发) MeetingWSEventChatMessage = "meeting.chat.message" // S→C会议内文字聊天广播REST SendChat 触发)
// ====== 屏幕共享Phase 3 引入2 个)======
// 屏幕流仍走普通的 sendTransport video Producer 路径appData.screen=true 区分),
// 这两个事件用于"会议内仅一份屏幕共享"的状态同步:
// - 启动时广播 producer_id + 共享者,前端将该流提升为大画面
// - 停止时广播,前端把屏幕 tile 撤掉,回到普通网格布局
// 后入者由 OnRoomJoin 内部 pushExistingRoomState 定向补推 started 事件,避免错过
MeetingWSEventScreenStarted = "meeting.screen.started" // S→C屏幕共享开始
MeetingWSEventScreenStopped = "meeting.screen.stopped" // S→C屏幕共享停止
// ====== 会议录制Phase B 引入2 个,仅 S→C======
// 启动 / 停止由 REST 接口POST /meetings/:code/recordings、DELETE …)触发,
// service 层落 DB + 调 media-server 后通过下面两个事件广播给会议内所有活跃成员:
// - started让所有客户端在 UI 上显示"正在录制"指示器
// - stopped附带最终状态ready / failed+ 文件 URLready 时),
// 便于前端立即跳转到回放或提示失败
MeetingWSEventRecordingStarted = "meeting.recording.started" // S→C录制开始
MeetingWSEventRecordingStopped = "meeting.recording.stopped" // S→C录制结束
) )
// MeetingWSClientEvents 客户端可主动发起的 WS 事件C→S白名单 // MeetingWSClientEvents 客户端可主动发起的 WS 事件C→S白名单

View File

@@ -140,3 +140,99 @@ type ActiveGroupItem struct {
Name string `json:"name"` // 群名称 Name string `json:"name"` // 群名称
Count int64 `json:"count"` // 消息数 Count int64 `json:"count"` // 消息数
} }
// ====== 管理端会议管理 DTOPhase B 扩展) ======
// AdminMeetingListRequest 管理端会议列表查询请求
//
// 与消息列表保持一致的分页/筛选风格:
// - Keyword模糊匹配 title 或 room_code方便客服按客户描述定位
// - Status0=未开始 / 1=进行中 / 2=已结束(与 meeting_rooms.status 一一对应)
// - HasRecordingtrue=仅返回有录制记录的会议,便于"录制审计"场景
// - StartTime / EndTime按 created_at 过滤YYYY-MM-DD 闭区间
type AdminMeetingListRequest struct {
Keyword string `form:"keyword"` // 模糊匹配 title / room_code
Status *int `form:"status"` // 0=未开始 1=进行中 2=已结束
HostID *int64 `form:"host_id"` // 主持人 ID 精确匹配
HasRecording *bool `form:"has_recording"` // true=仅含录制的会议
StartTime string `form:"start_time"` // YYYY-MM-DD
EndTime string `form:"end_time"` // YYYY-MM-DD
Page int `form:"page"`
PageSize int `form:"page_size"`
}
// AdminMeetingDTO 管理端会议条目(列表行)
//
// 字段选择原则:
// - 列表场景:会议号 / 标题 / 主持人 / 状态 / 时间 / 参与人数 / 录制数
// - 详情字段(参与者明细、录制列表)放到 GetMeetingDetail 单独返回,避免列表过宽
type AdminMeetingDTO struct {
ID int64 `json:"id"`
RoomCode string `json:"room_code"`
Title string `json:"title"`
Type int `json:"type"` // 1=即时 2=预约
TypeLabel string `json:"type_label"` // "即时会议" / "预约会议"
Status int `json:"status"` // 0/1/2
StatusLabel string `json:"status_label"` // "未开始" / "进行中" / "已结束"
HostID int64 `json:"host_id"`
HostNickname string `json:"host_nickname"`
HostAvatar string `json:"host_avatar"`
MaxMembers int `json:"max_members"`
ParticipantCount int `json:"participant_count"` // 历史累计入会人数(去重)
RecordingCount int `json:"recording_count"` // 该会议的录制条数
DurationSec int `json:"duration_sec"` // 会议时长(秒);结束前用 now-started_at结束后用 ended_at-started_at
ScheduledAt string `json:"scheduled_at"` // 预约会议开始时间,空表示即时会议
StartedAt string `json:"started_at"` // 实际开始
EndedAt string `json:"ended_at"` // 实际结束
EndedReason string `json:"ended_reason"` // host_ended / empty_ttl / admin_force / system_error
CreatedAt string `json:"created_at"`
}
// AdminMeetingListResponse 管理端会议列表响应(含统计摘要)
type AdminMeetingListResponse struct {
Total int64 `json:"total"`
List []AdminMeetingDTO `json:"list"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// AdminMeetingParticipantDTO 会议详情中的参与者条目
type AdminMeetingParticipantDTO struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Role int `json:"role"` // 0=普通 1=主持人 2=联合主持人
RoleLabel string `json:"role_label"`
JoinedAt string `json:"joined_at"`
LeftAt string `json:"left_at"` // 空表示仍在会议中
LeftReason string `json:"left_reason"` // self / kicked / host_end / empty_ttl / disconnect
Duration int `json:"duration"` // 秒
}
// AdminMeetingRecordingDTO 会议详情中的录制条目
type AdminMeetingRecordingDTO struct {
ID int64 `json:"id"`
StartedBy int64 `json:"started_by"`
Status string `json:"status"` // recording / uploading / ready / failed
FileURL string `json:"file_url"`
SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"`
FailureReason string `json:"failure_reason"` // 仅 failed 时返回
StartedAt string `json:"started_at"`
StoppedAt string `json:"stopped_at"`
}
// AdminMeetingDetailResponse 会议详情(含参与者列表 + 录制列表)
type AdminMeetingDetailResponse struct {
AdminMeetingDTO
Participants []AdminMeetingParticipantDTO `json:"participants"`
Recordings []AdminMeetingRecordingDTO `json:"recordings"`
}
// AdminMeetingStatsResponse 会议管理摘要统计(顶栏卡片)
type AdminMeetingStatsResponse struct {
TotalCount int64 `json:"total_count"` // 历史会议总数
ActiveCount int64 `json:"active_count"` // 当前进行中
TodayCount int64 `json:"today_count"` // 今日创建的会议
RecordingCount int64 `json:"recording_count"` // 录制总数
}

View File

@@ -0,0 +1,214 @@
package controller
import (
"errors"
"os"
"strconv"
"github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/app/meeting/service"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/utils"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// MeetingRecordingController 会议录制相关 REST 接口Phase B 引入)
//
// 路由(在 router.go 中挂载到 /api/v1/meeting/rooms/:code/recordings 下):
// - POST /rooms/:code/recordings 启动录制(仅 host
// - DELETE /rooms/:code/recordings/:id 停止录制(仅 host
// - GET /rooms/:code/recordings 列出录制host 或参与者)
type MeetingRecordingController struct {
recordingSvc *service.MeetingRecordingService
}
// NewMeetingRecordingController Wire Provider
func NewMeetingRecordingController(svc *service.MeetingRecordingService) *MeetingRecordingController {
return &MeetingRecordingController{recordingSvc: svc}
}
// handleErr 将录制相关领域错误映射成 HTTP 状态码
func (ctl *MeetingRecordingController) handleErr(c *gin.Context, err error, fallbackMsg string) {
switch {
case errors.Is(err, service.ErrMeetingNotFound),
errors.Is(err, service.ErrRecordingNotFound):
utils.ResponseNotFound(c, err.Error())
case errors.Is(err, service.ErrNotMeetingHost):
utils.ResponseForbidden(c, err.Error())
case errors.Is(err, service.ErrMediaServiceUnavailable):
utils.ResponseError(c, err.Error())
case errors.Is(err, service.ErrNotInMeeting),
errors.Is(err, service.ErrMeetingEnded),
errors.Is(err, service.ErrRecordingAlreadyActive),
errors.Is(err, service.ErrRecordingNoProducers),
errors.Is(err, service.ErrRecordingNotStoppable):
utils.ResponseBadRequest(c, err.Error())
default:
logs.Warn(c.Request.Context(), "controller.meeting_recording_controller.handleErr",
fallbackMsg, zap.Error(err))
utils.ResponseError(c, fallbackMsg)
}
}
// StartRecording POST /api/v1/meeting/rooms/:code/recordings
func (ctl *MeetingRecordingController) StartRecording(c *gin.Context) {
userID, ok := requireUserID(c)
if !ok {
return
}
code := c.Param("code")
rec, err := ctl.recordingSvc.StartRecording(c.Request.Context(), userID, code)
if err != nil {
ctl.handleErr(c, err, "启动录制失败")
return
}
utils.ResponseSuccess(c, recordingToDTO(rec))
}
// StopRecording DELETE /api/v1/meeting/rooms/:code/recordings/:id
func (ctl *MeetingRecordingController) StopRecording(c *gin.Context) {
userID, ok := requireUserID(c)
if !ok {
return
}
code := c.Param("code")
recordingID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || recordingID <= 0 {
utils.ResponseBadRequest(c, "录制 ID 非法")
return
}
rec, stopErr := ctl.recordingSvc.StopRecording(c.Request.Context(), userID, code, recordingID)
if stopErr != nil {
ctl.handleErr(c, stopErr, "停止录制失败")
return
}
utils.ResponseSuccess(c, recordingToDTO(rec))
}
// ListRecordings GET /api/v1/meeting/rooms/:code/recordings
func (ctl *MeetingRecordingController) ListRecordings(c *gin.Context) {
userID, ok := requireUserID(c)
if !ok {
return
}
code := c.Param("code")
list, err := ctl.recordingSvc.ListRecordings(c.Request.Context(), userID, code)
if err != nil {
ctl.handleErr(c, err, "查询录制列表失败")
return
}
out := make([]map[string]any, 0, len(list))
for i := range list {
out = append(out, recordingToDTO(&list[i]))
}
utils.ResponseSuccess(c, gin.H{"list": out})
}
// requireUserID 复用 controller 包内的 requireUserID在 meeting_controller.go 中定义)
// 这里通过同包私有函数访问,避免引入额外依赖
// recordingToDTO 录制记录的精简响应结构
// 不暴露 RemoteID / FileObject敏感的内部寻址字段供前端直接渲染列表 + 播放
func recordingToDTO(r *model.MeetingRecording) map[string]any {
if r == nil {
return nil
}
out := map[string]any{
"id": r.ID,
"room_id": r.RoomID,
"room_code": r.RoomCode,
"started_by": r.StartedBy,
"status": r.Status,
"file_url": r.FileURL,
"size_bytes": r.SizeBytes,
"duration_sec": r.DurationSec,
"failure_reason": "",
"started_at": r.StartedAt.Unix(),
}
// 仅在最终态 failed 时回显失败原因recording 状态下 FailureReason 字段被当作临时本地路径,不应外泄
if r.Status == model.MeetingRecordingStatusFailed {
out["failure_reason"] = r.FailureReason
}
if r.StoppedAt != nil {
out["stopped_at"] = r.StoppedAt.Unix()
}
return out
}
// ==================== Internal WebhookPhase B watchdog ====================
// mediaServerFailurePayload media-server 上报的失败 JSON 体
// 字段与 media-server/src/services/recording.service.ts::RecordingFailureCallback 对应
type mediaServerFailurePayload struct {
RecordingID string `json:"recordingId"` // media-server 端的 remoteIdUUID 字符串),非 Go 端主键
RoomCode string `json:"roomCode"`
RouterID string `json:"routerId"`
ExitCode *int `json:"exitCode"`
FailureReason string `json:"failureReason"`
}
// MediaServerFailureWebhook POST /internal/meeting/recordings/failure
//
// media-server 端 ffmpeg 异常退出时回调此接口(由 RECORDING_FAILURE_WEBHOOK_URL 配置)
// 鉴权:
// - 必须携带 Header `X-Internal-Secret`,值与环境变量 INTERNAL_WEBHOOK_SECRET 一致
// - 未配置 secret 时直接 403默认安全避免暴露内部端点
//
// 行为:
// - 解析 payload.recordingIdmedia-server remoteId→ 反查 Go 端 meeting_recordings 表
// - 委托给 MeetingRecordingService.HandleMediaServerFailure 落 failed + 广播
// - 始终返回 200webhook 语义:吞错避免 media-server 端反复重试)
func (ctl *MeetingRecordingController) MediaServerFailureWebhook(c *gin.Context) {
funcName := "controller.meeting_recording_controller.MediaServerFailureWebhook"
secret := os.Getenv("INTERNAL_WEBHOOK_SECRET")
if secret == "" {
logs.Warn(c.Request.Context(), funcName, "INTERNAL_WEBHOOK_SECRET 未配置,拒绝 webhook 请求")
utils.ResponseForbidden(c, "internal webhook not configured")
return
}
if c.GetHeader("X-Internal-Secret") != secret {
logs.Warn(c.Request.Context(), funcName, "webhook 鉴权失败",
zap.String("client_ip", c.ClientIP()))
utils.ResponseForbidden(c, "invalid internal secret")
return
}
var payload mediaServerFailurePayload
if err := c.ShouldBindJSON(&payload); err != nil {
logs.Warn(c.Request.Context(), funcName, "解析 payload 失败", zap.Error(err))
utils.ResponseBadRequest(c, "invalid payload")
return
}
if payload.RecordingID == "" {
utils.ResponseBadRequest(c, "missing recordingId")
return
}
// 反查 Go 端主键:通过 RemoteID 定位 meeting_recordings 行
// 调用方media-server持有的是 remoteIdUUID 字符串Go 端用 ID 主键管理
rec, err := ctl.recordingSvc.GetByRemoteID(c.Request.Context(), payload.RecordingID)
if err != nil {
logs.Warn(c.Request.Context(), funcName, "查询录制记录失败(按 remote_id",
zap.String("remote_id", payload.RecordingID), zap.Error(err))
// 仍 200避免 media-server 重试风暴
utils.ResponseSuccess(c, gin.H{"handled": false, "reason": "lookup_failed"})
return
}
if rec == nil {
logs.Info(c.Request.Context(), funcName, "未找到对应录制记录(可能已被清理)",
zap.String("remote_id", payload.RecordingID))
utils.ResponseSuccess(c, gin.H{"handled": false, "reason": "not_found"})
return
}
if err := ctl.recordingSvc.HandleMediaServerFailure(c.Request.Context(), rec.ID, payload.FailureReason); err != nil {
logs.Warn(c.Request.Context(), funcName, "处理失败 webhook 出错",
zap.Int64("recording_id", rec.ID), zap.Error(err))
utils.ResponseSuccess(c, gin.H{"handled": false, "reason": "internal_error"})
return
}
utils.ResponseSuccess(c, gin.H{"handled": true, "recording_id": rec.ID})
}

View File

@@ -0,0 +1,174 @@
package dao
import (
"context"
"errors"
"time"
"github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// MeetingRecordingDAO 会议录制数据访问对象Phase B 新增)
//
// 设计要点:
// - Create 后立刻获得 ID 主键,作为前端可见的 recordingId
// - 状态流转通过 UpdateStatus / UpdateForUpload / UpdateReady / UpdateFailed 等显式方法做,
// 避免业务层直接 db.Save 大对象造成不一致写
// - 每个状态切换都基于"前置状态白名单"行级 WHERE幂等 + 防止 race
type MeetingRecordingDAO struct {
db *gorm.DB
}
// NewMeetingRecordingDAO 构造 DAOWire Provider
func NewMeetingRecordingDAO(db *gorm.DB) *MeetingRecordingDAO {
return &MeetingRecordingDAO{db: db}
}
// Create 写入一条新录制记录recording 初始状态)
// 调用方需保证 RemoteID 已由 media-server 返回;若尚未拿到(先建本地壳记录),传空串即可,
// 后续用 UpdateRemoteID 补全。
func (d *MeetingRecordingDAO) Create(ctx context.Context, rec *model.MeetingRecording) error {
funcName := "dao.meeting_recording_dao.Create"
if err := d.db.WithContext(ctx).Create(rec).Error; err != nil {
logs.Error(ctx, funcName, "创建录制记录失败",
zap.String("room_code", rec.RoomCode),
zap.Int64("started_by", rec.StartedBy),
zap.Error(err))
return err
}
return nil
}
// GetByID 按主键查询,记录不存在返回 (nil, nil)
func (d *MeetingRecordingDAO) GetByID(ctx context.Context, id int64) (*model.MeetingRecording, error) {
var rec model.MeetingRecording
err := d.db.WithContext(ctx).First(&rec, id).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
// GetActiveByRoom 查询某会议正在进行中的录制status=recording 或 uploading
// 同会议同一时刻只允许 1 条活跃录制;用于"开始录制"前置防重 + EndRoom 自动停录
// 不存在返回 (nil, nil)
func (d *MeetingRecordingDAO) GetActiveByRoom(ctx context.Context, roomID int64) (*model.MeetingRecording, error) {
var rec model.MeetingRecording
err := d.db.WithContext(ctx).
Where("room_id = ? AND status IN ?", roomID, []string{
model.MeetingRecordingStatusRecording,
model.MeetingRecordingStatusUploading,
}).
Order("id DESC").
First(&rec).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
// ListByRoom 按 room_id 倒序列出全部录制(用于"录制列表"页)
// 不分页:单会议录制条数上限受业务约束(一般 ≤ 20如需分页可后续扩展
func (d *MeetingRecordingDAO) ListByRoom(ctx context.Context, roomID int64) ([]model.MeetingRecording, error) {
var list []model.MeetingRecording
err := d.db.WithContext(ctx).
Where("room_id = ?", roomID).
Order("id DESC").
Find(&list).Error
return list, err
}
// GetByRemoteID 按 media-server 返回的 remoteId 反查本地记录,不存在返回 (nil, nil)
// 供 webhook sink 在收到 media-server 的失败回调时定位本地行
func (d *MeetingRecordingDAO) GetByRemoteID(ctx context.Context, remoteID string) (*model.MeetingRecording, error) {
if remoteID == "" {
return nil, nil
}
var rec model.MeetingRecording
err := d.db.WithContext(ctx).Where("remote_id = ?", remoteID).First(&rec).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &rec, nil
}
// UpdateRemoteID 写入 media-server 返回的 recordingId
// 用于先 Create 本地记录、再调 media-server 拿到 RemoteID 后回填的两阶段流程
func (d *MeetingRecordingDAO) UpdateRemoteID(ctx context.Context, id int64, remoteID string) error {
return d.db.WithContext(ctx).
Model(&model.MeetingRecording{}).
Where("id = ?", id).
Update("remote_id", remoteID).Error
}
// MarkUploading 录制结束 → 上传中:仅当 status=recording 才推进,幂等
// 同时落 stopped_at + duration_sec
func (d *MeetingRecordingDAO) MarkUploading(ctx context.Context, id int64, stoppedAt time.Time, durationSec int) (int64, error) {
res := d.db.WithContext(ctx).
Model(&model.MeetingRecording{}).
Where("id = ? AND status = ?", id, model.MeetingRecordingStatusRecording).
Updates(map[string]interface{}{
"status": model.MeetingRecordingStatusUploading,
"stopped_at": stoppedAt,
"duration_sec": durationSec,
})
return res.RowsAffected, res.Error
}
// MarkReady 上传成功 → ready记录 file_url / file_object / size_bytes
// 仅当 status=uploading 才推进
func (d *MeetingRecordingDAO) MarkReady(ctx context.Context, id int64, fileURL, fileObject string, sizeBytes int64) (int64, error) {
res := d.db.WithContext(ctx).
Model(&model.MeetingRecording{}).
Where("id = ? AND status = ?", id, model.MeetingRecordingStatusUploading).
Updates(map[string]interface{}{
"status": model.MeetingRecordingStatusReady,
"file_url": fileURL,
"file_object": fileObject,
"size_bytes": sizeBytes,
})
return res.RowsAffected, res.Error
}
// MarkFailed 终态失败:从任意非 ready 状态都允许推进,记录 failure_reason
// 调用方需在 reason 为空时传一个有意义的兜底字符串(如 "unknown"
func (d *MeetingRecordingDAO) MarkFailed(ctx context.Context, id int64, reason string) (int64, error) {
if reason == "" {
reason = "unknown"
}
res := d.db.WithContext(ctx).
Model(&model.MeetingRecording{}).
Where("id = ? AND status != ?", id, model.MeetingRecordingStatusReady).
Updates(map[string]interface{}{
"status": model.MeetingRecordingStatusFailed,
"failure_reason": reason,
})
return res.RowsAffected, res.Error
}
// ListUnfinishedSince 查询启动时间早于 cutoff 但仍未到终态ready/failed的录制 ID
// 供兜底定时任务在 media-server 重启后清理"卡死的录制",避免 DB 永远停留在 recording 状态
func (d *MeetingRecordingDAO) ListUnfinishedSince(ctx context.Context, cutoff time.Time, limit int) ([]int64, error) {
var ids []int64
err := d.db.WithContext(ctx).
Model(&model.MeetingRecording{}).
Where("status IN ? AND started_at < ?",
[]string{model.MeetingRecordingStatusRecording, model.MeetingRecordingStatusUploading},
cutoff).
Order("started_at ASC").
Limit(limit).
Pluck("id", &ids).Error
return ids, err
}

View File

@@ -0,0 +1,44 @@
package model
import "time"
// MeetingRecording 会议录制记录,对应 meeting_recordings 表
//
// 生命周期状态:
// - recording → 录制进行中media-server ffmpeg 进程在跑,尚未上传)
// - uploading → media-server 已停止录制Go 后端正在拉取本地 mp4 上传到 MinIO
// - ready → MinIO 上传完成FileURL 可用
// - failed → 录制 / 上传失败FailureReason 记录原因
//
// RemoteID 是 media-server 内部的 recordingIdhex32Go 侧 stop / 状态反查时使用。
// 唯一索引落在 RemoteID 上,保证一对一关系。
type MeetingRecording struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
RoomID int64 `json:"room_id" gorm:"not null;index:idx_meeting_recordings_room"`
RoomCode string `json:"room_code" gorm:"size:32;not null;index:idx_meeting_recordings_room_code"`
StartedBy int64 `json:"started_by" gorm:"not null;index:idx_meeting_recordings_started_by"`
Status string `json:"status" gorm:"size:16;not null;default:recording"` // recording / uploading / ready / failed
RemoteID string `json:"remote_id" gorm:"size:64;uniqueIndex"` // media-server 侧 recordingId
FileURL string `json:"file_url" gorm:"size:512"`
FileObject string `json:"file_object" gorm:"size:255"` // MinIO 内的对象 Key便于鉴权下载/删除
SizeBytes int64 `json:"size_bytes" gorm:"default:0"`
DurationSec int `json:"duration_sec" gorm:"default:0"`
FailureReason string `json:"failure_reason" gorm:"size:255"`
StartedAt time.Time `json:"started_at" gorm:"not null;type:timestamp(0)"`
StoppedAt *time.Time `json:"stopped_at" gorm:"type:timestamp(0)"`
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"`
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"`
}
// TableName 指定数据库表名
func (MeetingRecording) TableName() string {
return "meeting_recordings"
}
// 录制状态常量;与表中 status 字段一一对应
const (
MeetingRecordingStatusRecording = "recording"
MeetingRecordingStatusUploading = "uploading"
MeetingRecordingStatusReady = "ready"
MeetingRecordingStatusFailed = "failed"
)

View File

@@ -20,11 +20,14 @@ var MeetingSet = wire.NewSet(
dao.NewMeetingRoomDAO, dao.NewMeetingRoomDAO,
dao.NewMeetingParticipantDAO, dao.NewMeetingParticipantDAO,
dao.NewMeetingChatDAO, dao.NewMeetingChatDAO,
dao.NewMeetingRecordingDAO, // Phase B 新增:录制记录 DAO
service.NewMeetingBroadcaster, service.NewMeetingBroadcaster,
service.NewMeetingLifecycleService, // Task 8 新增:会议生命周期状态机 service.NewMeetingLifecycleService, // Task 8 新增:会议生命周期状态机
service.NewMeetingService, service.NewMeetingService,
service.NewMeetingSignalService, service.NewMeetingSignalService,
service.NewMeetingRecordingService, // Phase B 新增:录制业务服务
controller.NewMeetingController, controller.NewMeetingController,
controller.NewMeetingRecordingController, // Phase B 新增:录制 REST 控制器
controller.NewMeetingWSHandler, controller.NewMeetingWSHandler,
task.NewMeetingCleanupTask, // Task 8 新增:生命周期兜底定时任务 task.NewMeetingCleanupTask, // Task 8 新增:生命周期兜底定时任务

View File

@@ -9,7 +9,13 @@ import (
// RegisterRoutes 注册 meeting 模块的全部前台路由 // RegisterRoutes 注册 meeting 模块的全部前台路由
// 所有接口统一挂载在 /api/v1/meeting/* 前缀下,均需 JWT 认证 // 所有接口统一挂载在 /api/v1/meeting/* 前缀下,均需 JWT 认证
// 设计文档 §6.2 的 12 个接口 Task 4 骨架阶段仅搭路由层,业务实现留待 Task 5/6/7 // 设计文档 §6.2 的 12 个接口 Task 4 骨架阶段仅搭路由层,业务实现留待 Task 5/6/7
func RegisterRoutes(r *gin.Engine, ctrl *controller.MeetingController, jwtAuth gin.HandlerFunc) { // Phase B 新增录制三接口POST/DELETE/GET /rooms/:code/recordings*
func RegisterRoutes(
r *gin.Engine,
ctrl *controller.MeetingController,
recordingCtrl *controller.MeetingRecordingController,
jwtAuth gin.HandlerFunc,
) {
authed := r.Group("/api/v1/meeting") authed := r.Group("/api/v1/meeting")
authed.Use(jwtAuth) authed.Use(jwtAuth)
{ {
@@ -29,5 +35,15 @@ func RegisterRoutes(r *gin.Engine, ctrl *controller.MeetingController, jwtAuth g
authed.POST("/rooms/:code/chats", ctrl.SendChat) authed.POST("/rooms/:code/chats", ctrl.SendChat)
authed.GET("/rooms/:code/chats", ctrl.ListChats) authed.GET("/rooms/:code/chats", ctrl.ListChats)
// 会议录制Phase B 引入)
authed.POST("/rooms/:code/recordings", recordingCtrl.StartRecording)
authed.DELETE("/rooms/:code/recordings/:id", recordingCtrl.StopRecording)
authed.GET("/rooms/:code/recordings", recordingCtrl.ListRecordings)
} }
// 内部 webhookPhase B watchdogmedia-server 异常退出回调
// 鉴权由 controller 内部用 X-Internal-Secret header + INTERNAL_WEBHOOK_SECRET 校验
// 不走 JWT因为调用方是 media-server 而非用户
r.POST("/internal/meeting/recordings/failure", recordingCtrl.MediaServerFailureWebhook)
} }

View File

@@ -314,14 +314,26 @@ func (h *HTTPMediaOrchestrator) CloseTransport(ctx context.Context, transportID
func (h *HTTPMediaOrchestrator) CreateProducer(ctx context.Context, req *CreateProducerReq) (string, error) { func (h *HTTPMediaOrchestrator) CreateProducer(ctx context.Context, req *CreateProducerReq) (string, error) {
funcName := "service.http_media_orchestrator.CreateProducer" funcName := "service.http_media_orchestrator.CreateProducer"
// 合并客户端 AppData 与服务端注入字段
// 服务端注入的 userId/roomCode 优先级最高,避免被客户端伪造覆盖
appData := map[string]any{}
if len(req.AppData) > 0 {
if err := json.Unmarshal(req.AppData, &appData); err != nil {
logs.Warn(ctx, funcName, "AppData 解析失败,已忽略",
zap.String("room_code", req.RoomCode),
zap.Int64("user_id", req.UserID),
zap.Error(err))
appData = map[string]any{}
}
}
appData["userId"] = req.UserID
appData["roomCode"] = req.RoomCode
reqBody := map[string]any{ reqBody := map[string]any{
"transportId": req.TransportID, "transportId": req.TransportID,
"kind": req.Kind, "kind": req.Kind,
"rtpParameters": req.RtpParameters, "rtpParameters": req.RtpParameters,
"appData": map[string]any{ "appData": appData,
"userId": req.UserID,
"roomCode": req.RoomCode,
},
} }
var resp struct { var resp struct {
ID string `json:"id"` ID string `json:"id"`
@@ -423,6 +435,67 @@ func (h *HTTPMediaOrchestrator) CloseConsumer(ctx context.Context, consumerID st
return err return err
} }
// StartRecording 调用 POST /internal/v1/recordingsPhase B 引入)
//
// 注意:录制启动会触发 ffmpeg spawn + 端口分配 + 250ms 等待启动,正常耗时约 500ms~1.5s
// 因此走 TimeoutMS默认 10s而非 CloseTimeoutMS。失败不重试避免重复 spawn ffmpeg
//
// 错误映射:
// - Node 404router 不存在)→ ErrMediaResourceNotFound
// - Node 409producer 不存在/不可消费)→ ErrMediaServerError业务层转为 "录制启动失败"
func (h *HTTPMediaOrchestrator) StartRecording(ctx context.Context, req *StartRecordingReq) (*StartRecordingResp, error) {
funcName := "service.http_media_orchestrator.StartRecording"
// Node 端实际入参字段名与 zod schema 对齐
body := map[string]any{
"routerId": req.RouterID,
"roomCode": req.RoomCode,
"producerIds": req.ProducerIDs,
}
var resp StartRecordingResp
if err := h.doRequest(ctx, requestOptions{
method: http.MethodPost,
path: "/internal/v1/recordings",
body: body,
timeoutMS: h.cfg.TimeoutMS,
funcName: funcName,
logFields: []zap.Field{
zap.String("room_code", req.RoomCode),
zap.String("router_id", req.RouterID),
zap.Int("producer_count", len(req.ProducerIDs)),
},
}, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// StopRecording 调用 DELETE /internal/v1/recordings/:idPhase B 引入)
//
// 时间预算ffmpeg 优雅退出最多 STOP_GRACE_MS=5s + media-server 自身处理 ~200ms
// 因此 timeout 需要明显 > 5s用 TimeoutMS默认 10s足够
//
// 404recording 不存在 / 已停止)映射为 ErrMediaResourceNotFound调用方一般视为幂等成功
// 不走 doCloseRequest 的重试链:录制 stop 不能重试(重试可能误伤新启动的同 id 录制)
func (h *HTTPMediaOrchestrator) StopRecording(ctx context.Context, recordingID string) (*StopRecordingResp, error) {
funcName := "service.http_media_orchestrator.StopRecording"
var resp StopRecordingResp
if err := h.doRequest(ctx, requestOptions{
method: http.MethodDelete,
path: fmt.Sprintf("/internal/v1/recordings/%s", recordingID),
body: nil,
timeoutMS: h.cfg.TimeoutMS,
funcName: funcName,
logFields: []zap.Field{
zap.String("recording_id", recordingID),
},
}, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// ====== 内部工具 ====== // ====== 内部工具 ======
// routerIDByRoomCode 从本地缓存反查 routerID缺失时返回 ErrMediaResourceNotFound // routerIDByRoomCode 从本地缓存反查 routerID缺失时返回 ErrMediaResourceNotFound

View File

@@ -67,6 +67,10 @@ type CreateProducerReq struct {
TransportID string `json:"transportId"` TransportID string `json:"transportId"`
Kind string `json:"kind"` // "audio" | "video" Kind string `json:"kind"` // "audio" | "video"
RtpParameters json.RawMessage `json:"rtpParameters"` // 直接转发给 Node由其做结构校验 RtpParameters json.RawMessage `json:"rtpParameters"` // 直接转发给 Node由其做结构校验
// AppData 客户端自定义元信息,例如 {"screen": true} 表示该 Producer 是屏幕共享流
// Phase 3 屏幕共享引入:与摄像头/麦克风 video Producer 同走 sendTransport
// 通过 appData.screen 区分,便于服务端识别并触发 meeting.screen.* 广播
AppData json.RawMessage `json:"appData,omitempty"`
} }
// CreateConsumerReq 创建 Consumer 请求 // CreateConsumerReq 创建 Consumer 请求
@@ -78,6 +82,38 @@ type CreateConsumerReq struct {
RtpCapabilities json.RawMessage `json:"rtpCapabilities"` RtpCapabilities json.RawMessage `json:"rtpCapabilities"`
} }
// ====== 会议录制 RPCPhase B 引入)======
// StartRecordingReq 启动录制请求
//
// ProducerIDs 指定要录的若干 Producer由业务层决定Phase B1 通常是 host 的
// audio + video 两个Phase B3 扩展为屏幕共享 + 多人音频后会更多)
type StartRecordingReq struct {
RouterID string `json:"routerId"`
RoomCode string `json:"roomCode"`
ProducerIDs []string `json:"producerIds"`
}
// StartRecordingResp media-server 启动成功后的响应
type StartRecordingResp struct {
RecordingID string `json:"recordingId"`
OutputPath string `json:"outputPath"` // media-server 本地 mp4 路径,用于停止后回拉上传
Tracks []struct {
ProducerID string `json:"producerId"`
Kind string `json:"kind"`
} `json:"tracks"`
}
// StopRecordingResp media-server 停止录制后返回的元数据
type StopRecordingResp struct {
RecordingID string `json:"recordingId"`
OutputPath string `json:"outputPath"`
SizeBytes int64 `json:"sizeBytes"`
DurationMS int64 `json:"durationMs"`
ExitCode *int `json:"exitCode,omitempty"` // ffmpeg 退出码nil = 进程未启动到退出阶段
FailureReason string `json:"failureReason,omitempty"` // 非空表示录制最终标记为 failed
}
// MediaOrchestrator 媒体服务器编排接口(设计 §6.6 NodeClient // MediaOrchestrator 媒体服务器编排接口(设计 §6.6 NodeClient
// Task 7 (2026-04-21) 起默认实现为 HTTPMediaOrchestrator通过 X-Internal-Token // Task 7 (2026-04-21) 起默认实现为 HTTPMediaOrchestrator通过 X-Internal-Token
// 调用 Node media-server 的 /internal/v1/* REST API // 调用 Node media-server 的 /internal/v1/* REST API
@@ -122,6 +158,16 @@ type MediaOrchestrator interface {
ResumeConsumer(ctx context.Context, consumerID string) error ResumeConsumer(ctx context.Context, consumerID string) error
// CloseConsumer 关闭指定 Consumer幂等 // CloseConsumer 关闭指定 Consumer幂等
CloseConsumer(ctx context.Context, consumerID string) error CloseConsumer(ctx context.Context, consumerID string) error
// ====== 录制Phase B 引入)======
// StartRecording 在 media-server 上启动一份会议录制
// 失败语义404 RouterNotFound / 409 producerId 不可消费等返回 ErrMediaServerError
// 成功返回 recordingIdhex32+ media-server 本地 mp4 输出路径
StartRecording(ctx context.Context, req *StartRecordingReq) (*StartRecordingResp, error)
// StopRecording 停止录制
// 幂等媒体侧已结束404返回 ErrMediaResourceNotFound调用方一般视为"已停止"
StopRecording(ctx context.Context, recordingID string) (*StopRecordingResp, error)
} }
// NoopMediaOrchestrator 本地调试占位实现Task 7 后默认不再使用) // NoopMediaOrchestrator 本地调试占位实现Task 7 后默认不再使用)
@@ -205,3 +251,18 @@ func (n *NoopMediaOrchestrator) ResumeConsumer(_ context.Context, _ string) erro
func (n *NoopMediaOrchestrator) CloseConsumer(_ context.Context, _ string) error { func (n *NoopMediaOrchestrator) CloseConsumer(_ context.Context, _ string) error {
return nil return nil
} }
// StartRecording 占位:返回伪造 recordingId 与空 outputPath
func (n *NoopMediaOrchestrator) StartRecording(_ context.Context, req *StartRecordingReq) (*StartRecordingResp, error) {
return &StartRecordingResp{
RecordingID: "noop-recording-" + req.RoomCode,
OutputPath: "",
}, nil
}
// StopRecording 占位:返回零大小元数据
func (n *NoopMediaOrchestrator) StopRecording(_ context.Context, recordingID string) (*StopRecordingResp, error) {
return &StopRecordingResp{
RecordingID: recordingID,
}, nil
}

View File

@@ -0,0 +1,469 @@
package service
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/meeting/dao"
"github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/config"
"github.com/echochat/backend/pkg/logs"
"github.com/minio/minio-go/v7"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// MeetingRecordingService 会议录制业务服务Phase B 引入)
//
// 职责边界:
// - 鉴权(仅 host 可启停)+ 状态机DB 表 meeting_recordings
// - 选定 host 的活跃 Producer 列表 → 委托给 MediaOrchestrator.StartRecording
// - 录制结束后从 media-server 本地拉取 mp4 → 上传到 MinIO → 标记 ready
// - 通过 MeetingBroadcaster 把 started / stopped 事件广播给会议内全员
//
// 设计取舍Phase B1
// - StopRecording 内同步执行 ffmpeg 停止 + MinIO 上传:录制时长一般 ≤ 30 分钟、单文件 ≤ 数百 MB
// 在 HTTP 处理协程内同步上传可接受;后续若需支持长会议再切换为后台 worker
// - 文件路径规则recordings/{roomCode}/{recordingID}-{remoteID}.mp4
// 便于按会议聚合 + 即使 DB 与对象存储错位也能从对象 Key 反查
// - 复用全局 MinIO Bucket与图片/语音同 bucketpublic-read后续可拆分专用 bucket
type MeetingRecordingService struct {
roomDAO *dao.MeetingRoomDAO
participantDAO *dao.MeetingParticipantDAO
recordingDAO *dao.MeetingRecordingDAO
redis *redis.Client
broadcaster *MeetingBroadcaster
mediaOrchestrator MediaOrchestrator
minioClient *minio.Client
minioCfg *config.MinioConfig
}
// NewMeetingRecordingService Wire Provider
func NewMeetingRecordingService(
roomDAO *dao.MeetingRoomDAO,
participantDAO *dao.MeetingParticipantDAO,
recordingDAO *dao.MeetingRecordingDAO,
rdb *redis.Client,
broadcaster *MeetingBroadcaster,
mediaOrchestrator MediaOrchestrator,
minioClient *minio.Client,
minioCfg *config.MinioConfig,
) *MeetingRecordingService {
return &MeetingRecordingService{
roomDAO: roomDAO,
participantDAO: participantDAO,
recordingDAO: recordingDAO,
redis: rdb,
broadcaster: broadcaster,
mediaOrchestrator: mediaOrchestrator,
minioClient: minioClient,
minioCfg: minioCfg,
}
}
// 录制相关领域错误(与 MeetingService 错误并列,使用同一 errors.Is 链)
var (
// ErrRecordingAlreadyActive 同一会议已有活跃录制recording / uploading禁止再次启动
ErrRecordingAlreadyActive = errors.New("当前会议已有正在进行的录制")
// ErrRecordingNoProducers host 在 Redis 资源追踪集合内找不到任何 producer
// 通常意味着 host 尚未开启麦克风/摄像头。规则:必须至少有 1 个 producer 才允许启动录制
ErrRecordingNoProducers = errors.New("无可录制的音视频流,请先开启麦克风或摄像头")
// ErrRecordingNotFound 指定 recordingID 不存在 / 已删除
ErrRecordingNotFound = errors.New("录制记录不存在")
// ErrRecordingNotStoppable 录制不在 recording 状态,无法停止
ErrRecordingNotStoppable = errors.New("录制已结束或正在收尾,无需重复操作")
)
// StartRecording 启动录制host
//
// 流程:
// 1. 鉴权room 存在、Active、调用方=host
// 2. 防重room 内不能已有活跃录制
// 3. 收集 host 自己持有的全部 Producer ID来自 resourceTrackKey 的 set 成员)
// 4. mediaOrchestrator.StartRecording → 拿到 recordingId 与本地 outputPath
// 5. DB 落 recording 记录(写入 RemoteID + OutputPath via FailureReason 字段暂存)
// 6. 异步广播 meeting.recording.started 给所有活跃成员
//
// 返回:刚创建的 MeetingRecording 记录(业务主键 ID 即"录制 ID"
func (s *MeetingRecordingService) StartRecording(ctx context.Context, userID int64, code string) (*model.MeetingRecording, error) {
funcName := "service.meeting_recording_service.StartRecording"
room, err := s.roomDAO.GetByCode(ctx, code)
if err != nil {
return nil, err
}
if room == nil {
return nil, ErrMeetingNotFound
}
if room.Status == constants.MeetingStatusEnded {
return nil, ErrMeetingEnded
}
if room.HostID != userID {
return nil, ErrNotMeetingHost
}
// 防重:同一会议同时仅允许 1 条活跃录制
if active, err := s.recordingDAO.GetActiveByRoom(ctx, room.ID); err != nil {
return nil, err
} else if active != nil {
return nil, ErrRecordingAlreadyActive
}
// 收集 host 当前活跃的 Producer ID
producerIDs, err := s.listHostProducerIDs(ctx, code, userID)
if err != nil {
return nil, err
}
if len(producerIDs) == 0 {
return nil, ErrRecordingNoProducers
}
// 反查 Router若房间初始化阶段 Router 已被释放(极端场景),转为媒体服务不可用
routerID, ok := s.mediaOrchestrator.ResolveRouterID(code)
if !ok || routerID == "" {
logs.Warn(ctx, funcName, "未能解析 Router ID会议媒体未就绪",
zap.String("room_code", code))
return nil, ErrMediaServiceUnavailable
}
// 调 media-server
resp, err := s.mediaOrchestrator.StartRecording(ctx, &StartRecordingReq{
RouterID: routerID,
RoomCode: code,
ProducerIDs: producerIDs,
})
if err != nil {
logs.Error(ctx, funcName, "media-server 启动录制失败",
zap.String("room_code", code), zap.Int64("user_id", userID), zap.Error(err))
return nil, err
}
// 写 DB使用 FailureReason 字段暂存 outputPathstop 阶段读出来上传 MinIO 后清空)
// 这样不需要为 outputPath 加额外列Phase B1 取舍stop 时 MarkUploading 会覆盖该字段
now := time.Now()
rec := &model.MeetingRecording{
RoomID: room.ID,
RoomCode: code,
StartedBy: userID,
Status: model.MeetingRecordingStatusRecording,
RemoteID: resp.RecordingID,
FailureReason: resp.OutputPath, // 临时占位stop 时清空
StartedAt: now,
}
if err := s.recordingDAO.Create(ctx, rec); err != nil {
// DB 写失败必须回滚 media-server 上的录制,否则 ffmpeg 会一直写本地磁盘
logs.Error(ctx, funcName, "录制 DB 写入失败,回滚 media-server",
zap.String("remote_id", resp.RecordingID), zap.Error(err))
if _, stopErr := s.mediaOrchestrator.StopRecording(ctx, resp.RecordingID); stopErr != nil {
logs.Warn(ctx, funcName, "回滚停止录制失败(孤儿 ffmpeg 进程将由 media-server 自身 TTL 兜底)",
zap.String("remote_id", resp.RecordingID), zap.Error(stopErr))
}
return nil, err
}
// WS 广播detach context 避免 HTTP 取消导致广播半成品)
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventRecordingStarted, map[string]interface{}{
"recording_id": rec.ID,
"started_by": userID,
"started_at": rec.StartedAt.Unix(),
})
logs.Info(ctx, funcName, "录制已启动",
zap.Int64("recording_id", rec.ID),
zap.String("room_code", code),
zap.Int64("user_id", userID),
zap.Int("producer_count", len(producerIDs)))
return rec, nil
}
// StopRecording 停止录制host
//
// 流程:
// 1. 鉴权 + 状态校验recording 必须存在、status=recording、调用方是 host容忍 EndRoom 路径以系统 userID 调用)
// 2. media-server stop拿到本地 outputPath / size / duration / exitCode
// 3. DB MarkUploadingstatus: recording → uploading同时清空临时 FailureReason
// 4. 上传本地 mp4 到 MinIOrecordings/{roomCode}/{id}-{remoteId}.mp4
// 5. DB MarkReady + 广播 meeting.recording.stopped(status=ready, file_url)
// 6. 任一步失败MarkFailed + 广播 stopped(status=failed, reason)
func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int64, code string, recordingID int64) (*model.MeetingRecording, error) {
funcName := "service.meeting_recording_service.StopRecording"
room, err := s.roomDAO.GetByCode(ctx, code)
if err != nil {
return nil, err
}
if room == nil {
return nil, ErrMeetingNotFound
}
if room.HostID != userID {
return nil, ErrNotMeetingHost
}
rec, err := s.recordingDAO.GetByID(ctx, recordingID)
if err != nil {
return nil, err
}
if rec == nil || rec.RoomID != room.ID {
return nil, ErrRecordingNotFound
}
if rec.Status != model.MeetingRecordingStatusRecording {
return nil, ErrRecordingNotStoppable
}
// 调 media-server stop404 视为已停止 → 用本地 DB 字段降级处理)
stopResp, stopErr := s.mediaOrchestrator.StopRecording(ctx, rec.RemoteID)
if stopErr != nil {
if !errors.Is(stopErr, ErrMediaResourceNotFound) {
logs.Error(ctx, funcName, "media-server 停止录制失败",
zap.Int64("recording_id", recordingID), zap.String("remote_id", rec.RemoteID), zap.Error(stopErr))
s.markFailedAndBroadcast(ctx, room.ID, rec, "media-server 停止录制失败")
return nil, stopErr
}
logs.Warn(ctx, funcName, "media-server 录制已不存在,按本地状态降级处理",
zap.Int64("recording_id", recordingID), zap.String("remote_id", rec.RemoteID))
stopResp = &StopRecordingResp{RecordingID: rec.RemoteID, OutputPath: rec.FailureReason}
}
// 转入 uploading 状态
durationSec := int(stopResp.DurationMS / 1000)
if _, err := s.recordingDAO.MarkUploading(ctx, rec.ID, time.Now(), durationSec); err != nil {
logs.Error(ctx, funcName, "标记 uploading 失败", zap.Int64("recording_id", rec.ID), zap.Error(err))
s.markFailedAndBroadcast(ctx, room.ID, rec, "数据库状态切换失败")
return nil, err
}
outputPath := stopResp.OutputPath
if outputPath == "" {
// 容忍 media-server 旧版本 / 404 降级:用 DB 暂存的本地路径
outputPath = rec.FailureReason
}
// 上传到 MinIO
objectKey := fmt.Sprintf("recordings/%s/%d-%s.mp4", strings.ToLower(code), rec.ID, rec.RemoteID)
fileURL, sizeBytes, uploadErr := s.uploadRecording(ctx, outputPath, objectKey)
if uploadErr != nil {
logs.Error(ctx, funcName, "录制文件上传 MinIO 失败",
zap.Int64("recording_id", rec.ID), zap.String("local_path", outputPath), zap.Error(uploadErr))
s.markFailedAndBroadcast(ctx, room.ID, rec, "录制文件上传失败")
return nil, uploadErr
}
if _, err := s.recordingDAO.MarkReady(ctx, rec.ID, fileURL, objectKey, sizeBytes); err != nil {
logs.Error(ctx, funcName, "标记 ready 失败", zap.Int64("recording_id", rec.ID), zap.Error(err))
s.markFailedAndBroadcast(ctx, room.ID, rec, "数据库状态切换失败ready")
return nil, err
}
// 重新拉一份最终态
final, _ := s.recordingDAO.GetByID(ctx, rec.ID)
if final == nil {
final = rec
final.Status = model.MeetingRecordingStatusReady
final.FileURL = fileURL
final.FileObject = objectKey
final.SizeBytes = sizeBytes
final.DurationSec = durationSec
}
// 上传成功后再尝试删除本地文件(失败仅 Warn不影响业务
if outputPath != "" {
if err := os.Remove(outputPath); err != nil && !errors.Is(err, os.ErrNotExist) {
logs.Warn(ctx, funcName, "删除 media-server 本地录制文件失败(忽略)",
zap.String("local_path", outputPath), zap.Error(err))
}
}
// 广播 stopped(ready)
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventRecordingStopped, map[string]interface{}{
"recording_id": final.ID,
"status": model.MeetingRecordingStatusReady,
"file_url": fileURL,
"size_bytes": sizeBytes,
"duration_sec": durationSec,
})
logs.Info(ctx, funcName, "录制已结束并完成上传",
zap.Int64("recording_id", final.ID),
zap.String("room_code", code),
zap.Int64("size_bytes", sizeBytes),
zap.Int("duration_sec", durationSec))
return final, nil
}
// ListRecordings 拉取某会议的全部录制记录(倒序)
// 仅会议参与者可见;前端用于"录制历史"入口
func (s *MeetingRecordingService) ListRecordings(ctx context.Context, userID int64, code string) ([]model.MeetingRecording, error) {
room, err := s.roomDAO.GetByCode(ctx, code)
if err != nil {
return nil, err
}
if room == nil {
return nil, ErrMeetingNotFound
}
// host 总是可见;其他人需在 participants 表里出现过即可(包括已离会)
if room.HostID != userID {
p, err := s.participantDAO.GetByRoomAndUser(ctx, room.ID, userID)
if err != nil {
return nil, err
}
if p == nil {
return nil, ErrNotInMeeting
}
}
return s.recordingDAO.ListByRoom(ctx, room.ID)
}
// StopActiveForEndRoom 在 EndRoom / EmptyTTL 路径上由系统强制停止当前活跃录制
//
// 与对外 StopRecording 不同:
// - 跳过 host 校验(调用方是系统)
// - 失败仅 Warn 不阻断会议结束流程
// - 仍然走完整的 media-server stop + MinIO 上传 + DB 状态机
func (s *MeetingRecordingService) StopActiveForEndRoom(ctx context.Context, room *model.MeetingRoom) {
funcName := "service.meeting_recording_service.StopActiveForEndRoom"
if room == nil {
return
}
active, err := s.recordingDAO.GetActiveByRoom(ctx, room.ID)
if err != nil {
logs.Warn(ctx, funcName, "查询活跃录制失败(忽略)",
zap.Int64("room_id", room.ID), zap.Error(err))
return
}
if active == nil || active.Status != model.MeetingRecordingStatusRecording {
return
}
if _, err := s.StopRecording(ctx, room.HostID, room.RoomCode, active.ID); err != nil {
logs.Warn(ctx, funcName, "EndRoom 路径停止录制失败(已记录,定时任务会兜底)",
zap.Int64("recording_id", active.ID), zap.Error(err))
}
}
// ====== 内部工具 ======
// listHostProducerIDs 从 host 的资源追踪 set 中取出全部 "producer:<id>" 的 id 部分
//
// 选取规则Phase B1
// - 录 host 自己持有的全部 Producer含麦克风、摄像头、屏幕共享
// - 不包含 transport / consumer
// - 数组顺序无关media-server 内部会自动按 kind 处理
func (s *MeetingRecordingService) listHostProducerIDs(ctx context.Context, roomCode string, hostID int64) ([]string, error) {
key := resourceTrackKey(roomCode, hostID)
members, err := s.redis.SMembers(ctx, key).Result()
if err != nil {
return nil, err
}
out := make([]string, 0, len(members))
for _, m := range members {
if id, ok := strings.CutPrefix(m, "producer:"); ok && id != "" {
out = append(out, id)
}
}
return out, nil
}
// uploadRecording 把本地 mp4 上传到 MinIO返回可访问 URL 与文件大小
func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath, objectKey string) (string, int64, error) {
if localPath == "" {
return "", 0, fmt.Errorf("本地录制文件路径为空")
}
stat, err := os.Stat(localPath)
if err != nil {
return "", 0, fmt.Errorf("stat 本地录制文件失败: %w", err)
}
if stat.Size() == 0 {
return "", 0, fmt.Errorf("本地录制文件为空0 byte疑似 ffmpeg 未写入任何数据")
}
f, err := os.Open(filepath.Clean(localPath))
if err != nil {
return "", 0, fmt.Errorf("打开本地录制文件失败: %w", err)
}
defer f.Close()
if _, err := s.minioClient.PutObject(ctx, s.minioCfg.Bucket, objectKey, f, stat.Size(), minio.PutObjectOptions{
ContentType: "video/mp4",
}); err != nil {
return "", 0, fmt.Errorf("PutObject 失败: %w", err)
}
scheme := "http"
if s.minioCfg.UseSSL {
scheme = "https"
}
url := fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectKey)
return url, stat.Size(), nil
}
// GetByRemoteID 反查本地录制记录(按 media-server 的 remoteId 字符串)
// webhook sink 入口前置查询,定位 Go 端主键
func (s *MeetingRecordingService) GetByRemoteID(ctx context.Context, remoteID string) (*model.MeetingRecording, error) {
return s.recordingDAO.GetByRemoteID(ctx, remoteID)
}
// HandleMediaServerFailure media-server 异常退出回调入口webhook sink
//
// 触发条件media-server 端 ffmpeg 在 Go 主动 Stop 之前自行退出,
// media-server 通过 RECORDING_FAILURE_WEBHOOK_URL 上报到本接口
//
// 行为:
// 1. 按 recordingID 查记录若已是终态ready / failed幂等返回 nil
// 2. 调 markFailedAndBroadcast 落 DB failed + 广播 meeting.recording.stopped(failed)
//
// 上层webhook controller需自行鉴权共享密钥 / 仅本地访问等)
func (s *MeetingRecordingService) HandleMediaServerFailure(ctx context.Context, recordingID int64, reason string) error {
funcName := "service.meeting_recording_service.HandleMediaServerFailure"
if recordingID <= 0 {
return fmt.Errorf("invalid recording id")
}
rec, err := s.recordingDAO.GetByID(ctx, recordingID)
if err != nil {
logs.Warn(ctx, funcName, "查询录制记录失败",
zap.Int64("recording_id", recordingID), zap.Error(err))
return err
}
if rec == nil {
logs.Warn(ctx, funcName, "录制记录不存在,忽略 webhook",
zap.Int64("recording_id", recordingID))
return nil
}
// 终态幂等
if rec.Status == model.MeetingRecordingStatusReady || rec.Status == model.MeetingRecordingStatusFailed {
logs.Info(ctx, funcName, "录制已处终态,忽略 webhook",
zap.Int64("recording_id", recordingID), zap.String("status", string(rec.Status)))
return nil
}
if reason == "" {
reason = "media-server 上报ffmpeg 异常退出"
}
s.markFailedAndBroadcast(ctx, rec.RoomID, rec, reason)
logs.Info(ctx, funcName, "已处理 media-server 失败 webhook",
zap.Int64("recording_id", recordingID), zap.String("reason", reason))
return nil
}
// markFailedAndBroadcast 标记失败并广播 stopped(failed)
// 内部容错DB / 广播任一失败仅 Warn不二次抛出
func (s *MeetingRecordingService) markFailedAndBroadcast(ctx context.Context, roomID int64, rec *model.MeetingRecording, reason string) {
if _, err := s.recordingDAO.MarkFailed(ctx, rec.ID, reason); err != nil {
logs.Warn(ctx, "service.meeting_recording_service.markFailedAndBroadcast",
"标记 failed 失败(忽略)",
zap.Int64("recording_id", rec.ID), zap.Error(err))
}
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), roomID, constants.MeetingWSEventRecordingStopped, map[string]interface{}{
"recording_id": rec.ID,
"status": model.MeetingRecordingStatusFailed,
"failure_reason": reason,
})
}

View File

@@ -80,6 +80,22 @@ type MeetingService struct {
onlineChecker OnlineChecker onlineChecker OnlineChecker
mediaOrchestrator MediaOrchestrator mediaOrchestrator MediaOrchestrator
lifecycleSvc *MeetingLifecycleService lifecycleSvc *MeetingLifecycleService
// recordingStopper 由 NewApp 在 Wire 注入完成后通过 SetRecordingStopper 注入
// 用 setter 而非构造函数参数:避免与 MeetingRecordingService 形成构造期循环依赖
// nil 时 EndRoom 跳过录制清理(向后兼容,生产路径始终非 nil
recordingStopper RecordingStopper
}
// RecordingStopper EndRoom / 空房 TTL 等系统路径强制停止活跃录制的钩子
// 由 *MeetingRecordingService 实现,通过 setter 注入,避免循环依赖
type RecordingStopper interface {
StopActiveForEndRoom(ctx context.Context, room *model.MeetingRoom)
}
// SetRecordingStopper 注入录制停止钩子(在 NewApp 完成 Wire 后调用)
func (s *MeetingService) SetRecordingStopper(stopper RecordingStopper) {
s.recordingStopper = stopper
} }
// NewMeetingService 创建 MeetingService 实例 // NewMeetingService 创建 MeetingService 实例
@@ -591,6 +607,12 @@ func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string)
_ = s.broadcaster.PublishToUser(ctx, p.UserID, constants.MeetingWSEventRoomEnded, payload) _ = s.broadcaster.PublishToUser(ctx, p.UserID, constants.MeetingWSEventRoomEnded, payload)
} }
// Phase B先停掉活跃录制再关 Router否则 CloseRouter 会触发录制 Consumer 异常退出,
// 录制文件可能丢失最后几秒。stopper 同步执行(含 ffmpeg 优雅退出 + MinIO 上传),失败仅 Warn 不阻断
if s.recordingStopper != nil {
s.recordingStopper.StopActiveForEndRoom(ctx, room)
}
if err := s.mediaOrchestrator.CloseRouter(ctx, code); err != nil { if err := s.mediaOrchestrator.CloseRouter(ctx, code); err != nil {
logs.Warn(ctx, funcName, "关闭 mediasoup Router 失败", zap.Error(err)) logs.Warn(ctx, funcName, "关闭 mediasoup Router 失败", zap.Error(err))
} }

View File

@@ -69,6 +69,39 @@ func memberStateKey(roomCode string, userID int64) string {
return fmt.Sprintf("echo:meeting:member_state:%s:%d", roomCode, userID) return fmt.Sprintf("echo:meeting:member_state:%s:%d", roomCode, userID)
} }
// screenOwnerKey Redis String记录当前会议正在共享屏幕的人 + Producer ID
// 值格式:"{userId}:{producerId}";为空 / Nil 表示当前无人共享
// 单字符串而非 Hash 是因为"会议同时只允许 1 份屏幕共享",无需多字段
//
// 生命周期:
// - OnProduceStart 收到 appData.screen=true 时 SET同时广播 meeting.screen.started
// - OnProducerClose / cleanupUserResources 命中该 Producer 时 DEL同时广播 meeting.screen.stopped
// - 房间销毁路径cleanupRoomRedisResidual一并 Del
func screenOwnerKey(roomCode string) string {
return fmt.Sprintf("echo:meeting:screen_owner:%s", roomCode)
}
// formatScreenOwnerValue / parseScreenOwnerValue 统一 screenOwnerKey 的取值格式
// 复用于 set / parse 双向,避免格式漂移
func formatScreenOwnerValue(userID int64, producerID string) string {
return fmt.Sprintf("%d:%s", userID, producerID)
}
func parseScreenOwnerValue(raw string) (int64, string, bool) {
if raw == "" {
return 0, "", false
}
parts := strings.SplitN(raw, ":", 2)
if len(parts) != 2 || parts[1] == "" {
return 0, "", false
}
var uid int64
if _, err := fmt.Sscanf(parts[0], "%d", &uid); err != nil || uid <= 0 {
return 0, "", false
}
return uid, parts[1], true
}
// resourceTTL 单个用户资源追踪集合 TTL // resourceTTL 单个用户资源追踪集合 TTL
// 设计:会议期间维持可达即可;若用户长期不活跃由断线清理接管 // 设计:会议期间维持可达即可;若用户长期不活跃由断线清理接管
// Task 16 Nit常量已迁出至 constants.MeetingResourceTrackTTLSeconds此处保留计算式 wrapper 方便调用侧零改动 // Task 16 Nit常量已迁出至 constants.MeetingResourceTrackTTLSeconds此处保留计算式 wrapper 方便调用侧零改动
@@ -237,6 +270,43 @@ func (s *MeetingSignalService) OnRoomJoin(ctx context.Context, userID int64, roo
func (s *MeetingSignalService) pushExistingRoomState(ctx context.Context, roomID int64, roomCode string, userID int64) { func (s *MeetingSignalService) pushExistingRoomState(ctx context.Context, roomID int64, roomCode string, userID int64) {
s.pushExistingProducers(ctx, roomID, roomCode, userID) s.pushExistingProducers(ctx, roomID, roomCode, userID)
s.pushExistingMemberStates(ctx, roomID, roomCode, userID) s.pushExistingMemberStates(ctx, roomID, roomCode, userID)
s.pushExistingScreenShare(ctx, roomCode, userID)
}
// pushExistingScreenShare 后入者定向补推当前屏幕共享状态Phase 3
// 复用 meeting.screen.started 事件语义,前端共用同一 handler无需新事件类型
// 没有人在共享 / Redis 异常 / 解析失败 都静默 no-op
func (s *MeetingSignalService) pushExistingScreenShare(ctx context.Context, roomCode string, userID int64) {
funcName := "service.meeting_signal_service.pushExistingScreenShare"
raw, err := s.redis.Get(ctx, screenOwnerKey(roomCode)).Result()
if err != nil {
if err != redis.Nil {
logs.Warn(ctx, funcName, "读取屏幕共享拥有者失败(忽略)",
zap.String("room_code", roomCode), zap.Error(err))
}
return
}
ownerUID, producerID, ok := parseScreenOwnerValue(raw)
if !ok {
return
}
if ownerUID == userID {
// 自己就是共享者(理论上不会发生:刚 join 不应已是 owner跳过避免回响
return
}
if err := s.broadcaster.PublishToUser(ctx, userID, constants.MeetingWSEventScreenStarted, map[string]interface{}{
"room_code": roomCode,
"user_id": ownerUID,
"producer_id": producerID,
"existing": true,
}); err != nil {
logs.Warn(ctx, funcName, "定向推送 existing screen.started 失败",
zap.Int64("to_user", userID),
zap.Int64("owner_user", ownerUID),
zap.String("producer_id", producerID),
zap.Error(err))
}
} }
// pushExistingProducers 向刚加入者定向推送房间里其他用户已产生的 producer 列表 // pushExistingProducers 向刚加入者定向推送房间里其他用户已产生的 producer 列表
@@ -384,7 +454,7 @@ func (s *MeetingSignalService) OnWSDisconnect(ctx context.Context, userID int64)
return return
} }
s.cleanupUserResources(ctx, room.RoomCode, userID) s.cleanupUserResources(ctx, room.ID, room.RoomCode, userID)
if s.lifecycleSvc != nil && room.HostID == userID { if s.lifecycleSvc != nil && room.HostID == userID {
s.lifecycleSvc.OnHostDisconnect(ctx, room.RoomCode, userID) s.lifecycleSvc.OnHostDisconnect(ctx, room.RoomCode, userID)
@@ -405,7 +475,7 @@ func (s *MeetingSignalService) OnRoomLeave(ctx context.Context, userID int64, ro
if err != nil { if err != nil {
return err return err
} }
s.cleanupUserResources(ctx, roomCode, userID) s.cleanupUserResources(ctx, room.ID, roomCode, userID)
// P2-8 修复:使用常量 MeetingLeftReasonDisconnect避免 "ws_disconnect" 等硬编码 // P2-8 修复:使用常量 MeetingLeftReasonDisconnect避免 "ws_disconnect" 等硬编码
// 与前端 MEETING_LEFT_REASON_LABEL 字面值不一致 // 与前端 MEETING_LEFT_REASON_LABEL 字面值不一致
@@ -524,6 +594,36 @@ type ProduceStartPayload struct {
TransportID string `json:"transport_id"` TransportID string `json:"transport_id"`
Kind string `json:"kind"` // "audio" | "video" Kind string `json:"kind"` // "audio" | "video"
RtpParameters json.RawMessage `json:"rtp_parameters"` RtpParameters json.RawMessage `json:"rtp_parameters"`
// AppData 客户端透传给 mediasoup Producer 的自定义元信息
// Phase 3 屏幕共享引入:约定 {"screen": true} 表示该 video Producer 是屏幕分享流;
// 服务端识别后会写入 screenOwnerKey + 广播 meeting.screen.started前端将该流提升大画面。
// 服务端会强制把 userId / roomCode 注入回 appData覆盖客户端伪造尝试见 HTTPMediaOrchestrator.CreateProducer
AppData json.RawMessage `json:"app_data,omitempty"`
}
// isScreenAppData 判断客户端 app_data 是否声明了屏幕共享标识
// 容错raw 为空 / 非对象 / 字段缺失均返回 false
func isScreenAppData(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return false
}
v, ok := m["screen"]
if !ok {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return x == "true" || x == "1"
case float64:
return x != 0
}
return false
} }
// ProduceStartResult 返回给客户端的 producerID // ProduceStartResult 返回给客户端的 producerID
@@ -544,25 +644,61 @@ func (s *MeetingSignalService) OnProduceStart(ctx context.Context, userID int64,
if err := s.assertOwnsResource(ctx, payload.RoomCode, userID, "transport", payload.TransportID); err != nil { if err := s.assertOwnsResource(ctx, payload.RoomCode, userID, "transport", payload.TransportID); err != nil {
return nil, err return nil, err
} }
// Phase 3识别屏幕共享意图强制 video kind 才允许(音频流不参与屏幕共享语义)
isScreen := payload.Kind == "video" && isScreenAppData(payload.AppData)
// Phase 3单会议同时仅允许 1 份屏幕共享。若已有他人在共享,直接拒绝;
// 同一用户重复请求(例如客户端重发)则容忍,由后续 SET 覆盖
if isScreen {
if existingRaw, err := s.redis.Get(ctx, screenOwnerKey(payload.RoomCode)).Result(); err == nil {
if ownerUID, _, ok := parseScreenOwnerValue(existingRaw); ok && ownerUID != userID {
return nil, fmt.Errorf("当前会议已有成员正在共享屏幕")
}
}
}
producerID, err := s.mediaOrchestrator.CreateProducer(ctx, &CreateProducerReq{ producerID, err := s.mediaOrchestrator.CreateProducer(ctx, &CreateProducerReq{
RoomCode: payload.RoomCode, RoomCode: payload.RoomCode,
UserID: userID, UserID: userID,
TransportID: payload.TransportID, TransportID: payload.TransportID,
Kind: payload.Kind, Kind: payload.Kind,
RtpParameters: payload.RtpParameters, RtpParameters: payload.RtpParameters,
AppData: payload.AppData,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.trackResource(ctx, payload.RoomCode, userID, "producer", producerID) s.trackResource(ctx, payload.RoomCode, userID, "producer", producerID)
// Phase 3屏幕共享额外维护 owner key + 触发 meeting.screen.started 广播
// 注意:先广播 producer.new保证消费者侧 Consumer 创建),再广播 screen.started标记大画面提升
if isScreen {
if err := s.redis.Set(ctx, screenOwnerKey(payload.RoomCode), formatScreenOwnerValue(userID, producerID), resourceTTL).Err(); err != nil {
logs.Warn(ctx, "service.meeting_signal_service.OnProduceStart", "写入屏幕共享拥有者失败(不阻断业务)",
zap.String("room_code", payload.RoomCode),
zap.Int64("user_id", userID),
zap.String("producer_id", producerID),
zap.Error(err))
}
}
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberProducerNew, map[string]interface{}{ go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberProducerNew, map[string]interface{}{
"room_code": payload.RoomCode, "room_code": payload.RoomCode,
"user_id": userID, "user_id": userID,
"producer_id": producerID, "producer_id": producerID,
"kind": payload.Kind, "kind": payload.Kind,
"screen": isScreen,
}, userID) }, userID)
if isScreen {
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventScreenStarted, map[string]interface{}{
"room_code": payload.RoomCode,
"user_id": userID,
"producer_id": producerID,
}) // 不排除任何人:共享者本人也接收,便于前端统一更新大画面 UI
}
return &ProduceStartResult{ProducerID: producerID}, nil return &ProduceStartResult{ProducerID: producerID}, nil
} }
@@ -653,6 +789,9 @@ func (s *MeetingSignalService) OnProducerClose(ctx context.Context, userID int64
} }
s.untrackResource(ctx, payload.RoomCode, userID, "producer", payload.ProducerID) s.untrackResource(ctx, payload.RoomCode, userID, "producer", payload.ProducerID)
// Phase 3若关闭的恰好是当前屏幕共享 Producer则一并清 screenOwnerKey + 广播 screen.stopped
s.maybeReleaseScreenOwner(ctx, room.ID, payload.RoomCode, userID, payload.ProducerID)
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberProducerNew, map[string]interface{}{ go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), room.ID, constants.MeetingWSEventMemberProducerNew, map[string]interface{}{
"room_code": payload.RoomCode, "room_code": payload.RoomCode,
"user_id": userID, "user_id": userID,
@@ -662,11 +801,57 @@ func (s *MeetingSignalService) OnProducerClose(ctx context.Context, userID int64
return nil return nil
} }
// maybeReleaseScreenOwner 检查并释放屏幕共享拥有者状态
// 当 producerID 恰好等于 screenOwnerKey 当前值的 producer 段时,认为这次关闭是屏幕分享停止:
// - DEL screenOwnerKey
// - 广播 meeting.screen.stopped 给所有活跃成员(含共享者本人)
//
// 非屏幕 Producer 或 key 已被他人覆盖时静默 no-op保证幂等
func (s *MeetingSignalService) maybeReleaseScreenOwner(ctx context.Context, roomID int64, roomCode string, userID int64, producerID string) {
funcName := "service.meeting_signal_service.maybeReleaseScreenOwner"
key := screenOwnerKey(roomCode)
raw, err := s.redis.Get(ctx, key).Result()
if err != nil {
// redis.Nil无人共享属正常路径其他错误仅 Warn 不阻塞
if err != redis.Nil {
logs.Warn(ctx, funcName, "读取屏幕共享拥有者失败(忽略)",
zap.String("room_code", roomCode), zap.Error(err))
}
return
}
ownerUID, ownerProducerID, ok := parseScreenOwnerValue(raw)
if !ok || ownerProducerID != producerID {
return
}
// 防御性日志:理论上 ownerUID 必然等于 userIDtrackResource 已校验归属)
if ownerUID != userID {
logs.Warn(ctx, funcName, "屏幕共享拥有者与关闭者不一致(仍按停止处理)",
zap.Int64("owner_uid", ownerUID),
zap.Int64("closer_uid", userID),
zap.String("producer_id", producerID))
}
if err := s.redis.Del(ctx, key).Err(); err != nil {
logs.Warn(ctx, funcName, "删除屏幕共享拥有者失败(忽略)",
zap.String("room_code", roomCode), zap.Error(err))
}
go s.broadcaster.BroadcastToMeeting(logs.DetachContext(ctx), roomID, constants.MeetingWSEventScreenStopped, map[string]interface{}{
"room_code": roomCode,
"user_id": ownerUID,
"producer_id": producerID,
})
}
// ========== 资源清理 ========== // ========== 资源清理 ==========
// cleanupUserResources 批量关闭指定用户在某会议的所有媒体资源 // cleanupUserResources 批量关闭指定用户在某会议的所有媒体资源
// WS 断开、主动离会、被踢时使用;依赖 Redis 集合中追踪的资源 ID // WS 断开、主动离会、被踢时使用;依赖 Redis 集合中追踪的资源 ID
func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCode string, userID int64) { //
// Phase 3 屏幕共享:调用方需提供 roomID便于在该用户恰好是屏幕共享者时触发
// meeting.screen.stopped 广播。roomID==0 时跳过屏幕广播(仅清 Redis保持向后兼容。
func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomID int64, roomCode string, userID int64) {
funcName := "service.meeting_signal_service.cleanupUserResources" funcName := "service.meeting_signal_service.cleanupUserResources"
key := resourceTrackKey(roomCode, userID) key := resourceTrackKey(roomCode, userID)
@@ -687,6 +872,11 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod
switch kind { switch kind {
case "producer": case "producer":
_ = s.mediaOrchestrator.CloseProducer(ctx, id) _ = s.mediaOrchestrator.CloseProducer(ctx, id)
// Phase 3若该 Producer 是当前屏幕共享流,释放 owner key 并广播 screen.stopped
// 复用 OnProducerClose 同款幂等逻辑roomID==0 跳过避免无效广播
if roomID > 0 {
s.maybeReleaseScreenOwner(ctx, roomID, roomCode, userID, id)
}
case "consumer": case "consumer":
_ = s.mediaOrchestrator.CloseConsumer(ctx, id) _ = s.mediaOrchestrator.CloseConsumer(ctx, id)
case "transport": case "transport":
@@ -714,7 +904,14 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod
// - package 私有:允许 meeting_service / meeting_lifecycle_service 等同包文件直接调用 // - package 私有:允许 meeting_service / meeting_lifecycle_service 等同包文件直接调用
func cleanupRoomRedisResidual(ctx context.Context, rdb *redis.Client, roomCode string, userIDs []int64) { func cleanupRoomRedisResidual(ctx context.Context, rdb *redis.Client, roomCode string, userIDs []int64) {
funcName := "service.meeting_signal_service.cleanupRoomRedisResidual" funcName := "service.meeting_signal_service.cleanupRoomRedisResidual"
if rdb == nil || len(userIDs) == 0 { if rdb == nil {
return
}
// Phase 3会议销毁路径必删 screenOwnerKey即使无活跃用户也清理
// 单独 Del 不依赖 pipeline因为会议销毁是低频路径多一次 RTT 可接受
_ = rdb.Del(ctx, screenOwnerKey(roomCode)).Err()
if len(userIDs) == 0 {
return return
} }
pipe := rdb.Pipeline() pipe := rdb.Pipeline()

View File

@@ -42,6 +42,7 @@ type App struct {
ContactManageController *adminController.ContactManageController // 管理端好友关系管理控制器 ContactManageController *adminController.ContactManageController // 管理端好友关系管理控制器
GroupManageController *adminController.GroupManageController // 管理端群组管理控制器 GroupManageController *adminController.GroupManageController // 管理端群组管理控制器
MessageManageController *adminController.MessageManageController // 管理端消息管理控制器 MessageManageController *adminController.MessageManageController // 管理端消息管理控制器
MeetingManageController *adminController.MeetingManageController // 管理端会议管理控制器Phase B
WSHandler *wsApp.Handler // WebSocket 连接处理器 WSHandler *wsApp.Handler // WebSocket 连接处理器
Hub *ws.Hub // WebSocket Hub 连接管理 Hub *ws.Hub // WebSocket Hub 连接管理
PubSub *ws.PubSub // Redis Pub/Sub 消息路由 PubSub *ws.PubSub // Redis Pub/Sub 消息路由
@@ -59,6 +60,8 @@ type App struct {
MeetingSignalService *meetingService.MeetingSignalService // 会议 WS 信令业务服务Task 6 落地) MeetingSignalService *meetingService.MeetingSignalService // 会议 WS 信令业务服务Task 6 落地)
MeetingLifecycleSvc *meetingService.MeetingLifecycleService // 会议生命周期状态机Task 8 落地) MeetingLifecycleSvc *meetingService.MeetingLifecycleService // 会议生命周期状态机Task 8 落地)
MeetingController *meetingController.MeetingController // 会议 REST 控制器 MeetingController *meetingController.MeetingController // 会议 REST 控制器
MeetingRecordingController *meetingController.MeetingRecordingController // 会议录制 REST 控制器Phase B 新增)
MeetingRecordingService *meetingService.MeetingRecordingService // 会议录制业务服务Phase B 新增)
MeetingWSHandler *meetingController.MeetingWSHandler // 会议 WS 事件 Handler构造时自动注册路由到 Hub MeetingWSHandler *meetingController.MeetingWSHandler // 会议 WS 事件 Handler构造时自动注册路由到 Hub
MeetingCleanupTask *meetingTask.MeetingCleanupTask // 会议生命周期兜底定时任务Task 8 落地) MeetingCleanupTask *meetingTask.MeetingCleanupTask // 会议生命周期兜底定时任务Task 8 落地)
} }
@@ -77,6 +80,7 @@ func NewApp(
contactManageCtrl *adminController.ContactManageController, contactManageCtrl *adminController.ContactManageController,
groupManageCtrl *adminController.GroupManageController, groupManageCtrl *adminController.GroupManageController,
msgManageCtrl *adminController.MessageManageController, msgManageCtrl *adminController.MessageManageController,
meetingManageCtrl *adminController.MeetingManageController,
wsHandler *wsApp.Handler, wsHandler *wsApp.Handler,
hub *ws.Hub, hub *ws.Hub,
pubsub *ws.PubSub, pubsub *ws.PubSub,
@@ -94,12 +98,16 @@ func NewApp(
meetingSignalSvc *meetingService.MeetingSignalService, meetingSignalSvc *meetingService.MeetingSignalService,
meetingLifecycleSvc *meetingService.MeetingLifecycleService, meetingLifecycleSvc *meetingService.MeetingLifecycleService,
meetingCtrl *meetingController.MeetingController, meetingCtrl *meetingController.MeetingController,
meetingRecordingCtrl *meetingController.MeetingRecordingController,
meetingRecordingSvc *meetingService.MeetingRecordingService,
meetingWSHandler *meetingController.MeetingWSHandler, meetingWSHandler *meetingController.MeetingWSHandler,
meetingCleanup *meetingTask.MeetingCleanupTask, meetingCleanup *meetingTask.MeetingCleanupTask,
) *App { ) *App {
wsHandler.SetOfflinePusher(offlinePusher) wsHandler.SetOfflinePusher(offlinePusher)
wsHandler.SetNotifyConnectHook(notifySvc) wsHandler.SetNotifyConnectHook(notifySvc)
wsHandler.SetMeetingDisconnectHook(meetingSignalSvc) wsHandler.SetMeetingDisconnectHook(meetingSignalSvc)
// Phase B通过 setter 注入录制停止钩子,避免 MeetingService ↔ MeetingRecordingService 构造期循环依赖
meetingSvc.SetRecordingStopper(meetingRecordingSvc)
return &App{ return &App{
Config: cfg, Config: cfg,
@@ -114,6 +122,7 @@ func NewApp(
ContactManageController: contactManageCtrl, ContactManageController: contactManageCtrl,
GroupManageController: groupManageCtrl, GroupManageController: groupManageCtrl,
MessageManageController: msgManageCtrl, MessageManageController: msgManageCtrl,
MeetingManageController: meetingManageCtrl,
WSHandler: wsHandler, WSHandler: wsHandler,
Hub: hub, Hub: hub,
PubSub: pubsub, PubSub: pubsub,
@@ -130,9 +139,11 @@ func NewApp(
MeetingService: meetingSvc, MeetingService: meetingSvc,
MeetingSignalService: meetingSignalSvc, MeetingSignalService: meetingSignalSvc,
MeetingLifecycleSvc: meetingLifecycleSvc, MeetingLifecycleSvc: meetingLifecycleSvc,
MeetingController: meetingCtrl, MeetingController: meetingCtrl,
MeetingWSHandler: meetingWSHandler, MeetingRecordingController: meetingRecordingCtrl,
MeetingCleanupTask: meetingCleanup, MeetingRecordingService: meetingRecordingSvc,
MeetingWSHandler: meetingWSHandler,
MeetingCleanupTask: meetingCleanup,
} }
} }

View File

@@ -80,6 +80,9 @@ func InitializeApp(cfg *config.Config) (*App, error) {
conversationDAO := im.ProvideConversationDAO(gormDB) conversationDAO := im.ProvideConversationDAO(gormDB)
messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub) messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub)
messageManageController := controller2.NewMessageManageController(messageManageService) messageManageController := controller2.NewMessageManageController(messageManageService)
meetingManageDAO := dao2.NewMeetingManageDAO(gormDB)
meetingManageService := service2.NewMeetingManageService(meetingManageDAO)
meetingManageController := controller2.NewMeetingManageController(meetingManageService)
serverConfig := provideServerConfig(cfg) serverConfig := provideServerConfig(cfg)
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, serverConfig, onlineService, authService) handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, serverConfig, onlineService, authService)
friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) friendGroupDAO := dao3.NewFriendGroupDAO(gormDB)
@@ -103,14 +106,17 @@ func InitializeApp(cfg *config.Config) (*App, error) {
meetingRoomDAO := dao6.NewMeetingRoomDAO(gormDB) meetingRoomDAO := dao6.NewMeetingRoomDAO(gormDB)
meetingParticipantDAO := dao6.NewMeetingParticipantDAO(gormDB) meetingParticipantDAO := dao6.NewMeetingParticipantDAO(gormDB)
meetingChatDAO := dao6.NewMeetingChatDAO(gormDB) meetingChatDAO := dao6.NewMeetingChatDAO(gormDB)
meetingRecordingDAO := dao6.NewMeetingRecordingDAO(gormDB)
meetingBroadcaster := service7.NewMeetingBroadcaster(meetingParticipantDAO, pubSub) meetingBroadcaster := service7.NewMeetingBroadcaster(meetingParticipantDAO, pubSub)
httpMediaOrchestrator := service7.NewHTTPMediaOrchestrator(cfg) httpMediaOrchestrator := service7.NewHTTPMediaOrchestrator(cfg)
meetingLifecycleService := service7.NewMeetingLifecycleService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, cfg) meetingLifecycleService := service7.NewMeetingLifecycleService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, cfg)
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, httpMediaOrchestrator, meetingLifecycleService) meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, httpMediaOrchestrator, meetingLifecycleService)
meetingSignalService := service7.NewMeetingSignalService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, meetingLifecycleService) meetingSignalService := service7.NewMeetingSignalService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, meetingLifecycleService)
meetingRecordingService := service7.NewMeetingRecordingService(meetingRoomDAO, meetingParticipantDAO, meetingRecordingDAO, client, meetingBroadcaster, httpMediaOrchestrator, minioClient, minioConfig)
meetingController := controller7.NewMeetingController(meetingService) meetingController := controller7.NewMeetingController(meetingService)
meetingRecordingController := controller7.NewMeetingRecordingController(meetingRecordingService)
meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub) meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub)
meetingCleanupTask := task2.NewMeetingCleanupTask(meetingLifecycleService, meetingRoomDAO, meetingChatDAO) meetingCleanupTask := task2.NewMeetingCleanupTask(meetingLifecycleService, meetingRoomDAO, meetingChatDAO)
app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, eventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, cleanupTask, meetingService, meetingSignalService, meetingLifecycleService, meetingController, meetingWSHandler, meetingCleanupTask) app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, meetingManageController, handler, hub, pubSub, onlineService, contactController, imController, eventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, cleanupTask, meetingService, meetingSignalService, meetingLifecycleService, meetingController, meetingRecordingController, meetingRecordingService, meetingWSHandler, meetingCleanupTask)
return app, nil return app, nil
} }

View File

@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/echochat/backend/app/im/model" "github.com/echochat/backend/app/im/model"
meetingModel "github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/app/provider" "github.com/echochat/backend/app/provider"
"github.com/echochat/backend/config" "github.com/echochat/backend/config"
"github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/logs"
@@ -56,6 +57,8 @@ func main() {
&model.Conversation{}, &model.Conversation{},
&model.ConversationMember{}, &model.ConversationMember{},
&model.Message{}, &model.Message{},
// Phase B 新增:会议录制元数据表
&meetingModel.MeetingRecording{},
); err != nil { ); err != nil {
logs.Fatal(ctx, "main", "IM 表迁移失败", zap.Error(err)) logs.Fatal(ctx, "main", "IM 表迁移失败", zap.Error(err))
} }

View File

@@ -38,12 +38,12 @@ func Setup(engine *gin.Engine, app *provider.App) {
// --- 各模块路由注册 --- // --- 各模块路由注册 ---
auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth) auth.RegisterRoutes(engine, app.AuthController, app.AdminAuthController, jwtAuth)
admin.RegisterRoutes(engine, app.UserManageController, app.OnlineController, app.ContactManageController, app.GroupManageController, app.MessageManageController, jwtAuth) admin.RegisterRoutes(engine, app.UserManageController, app.OnlineController, app.ContactManageController, app.GroupManageController, app.MessageManageController, app.MeetingManageController, jwtAuth)
wsApp.RegisterRoutes(engine, app.WSHandler) wsApp.RegisterRoutes(engine, app.WSHandler)
contact.RegisterRoutes(engine, app.ContactController, jwtAuth) contact.RegisterRoutes(engine, app.ContactController, jwtAuth)
imApp.RegisterRoutes(engine, app.IMController, jwtAuth) imApp.RegisterRoutes(engine, app.IMController, jwtAuth)
fileApp.RegisterRoutes(engine, app.FileController, jwtAuth) fileApp.RegisterRoutes(engine, app.FileController, jwtAuth)
groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth) groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth)
notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth) notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth)
meetingApp.RegisterRoutes(engine, app.MeetingController, jwtAuth) meetingApp.RegisterRoutes(engine, app.MeetingController, app.MeetingRecordingController, jwtAuth)
} }

View File

@@ -0,0 +1,107 @@
# 会议录制实现进度Phase B1→B4
启动日期2026-05-18
负责人Cascade pair-programming session
## 全局架构
```
[host 工具栏] → POST /meeting/rooms/{code}/recording/toggle
[Go MeetingRecordingService]
├─ 校验 host 权限 + 会议状态
├─ 落库 meeting_recordings(status=recording)
└─ 调 HTTPMediaOrchestrator.StartRecording(roomCode, producerIDs)
[media-server /internal/v1/recordings]
├─ 为每个 Producer 建 PlainTransportrtcpMux:false, comedia:false
├─ consume 该 Producer 拿到 negotiated rtpParameters
├─ 选未占用 UDP 端口对rtp+rtcp给 ffmpeg
├─ 写 SDP 文件(含 audio/video pt、codec、rtpmap、fmtp
├─ spawn ffmpeg: -protocol_whitelist file,udp,rtp -i input.sdp -c copy output.mp4
└─ transport.connect({ ip, port: ffmpegPort, rtcpPort: ffmpegRtcpPort })
[ffmpeg 写盘 → /tmp/recordings/{recordingId}.mp4]
[host 再次 toggle → 停止]
├─ media-server SIGINT ffmpeg → wait exit → close transports/consumers
├─ POST /api/v1/meeting/_internal/recordings/{id}/finalizecallback
└─ Go 流式拉取 mp4 → MinIO → 落库 status=ready + file_url + size + duration
[WS 广播 meeting.recording.started/stopped 给所有成员]
```
## 阶段拆分
### Phase B1 — 媒体层录制管线 + Go 控制端 ✏️ 进行中
**media-server**
- [ ] `services/recording.service.ts`起停录制、SDP 生成、ffmpeg 进程管理、端口池
- [ ] `routes/recording.route.ts``POST /internal/v1/recordings``DELETE /internal/v1/recordings/:id``GET /internal/v1/recordings/:id`
- [ ] `schemas/recording.schema.ts`zod 校验
- [ ] `app.ts`:注册路由
- [ ] 端口池实现 `utils/port-pool.ts`
**Go 后端**
- [ ] `app/meeting/model/meeting_recording.go`:表 ORM
- [ ] `app/meeting/dao/meeting_recording_dao.go`
- [ ] `app/meeting/service/interfaces.go` 扩展 MediaOrchestrator: `StartRecording / StopRecording`
- [ ] `app/meeting/service/http_media_orchestrator.go` 实现 RPC
- [ ] `cmd/server/main.go` AutoMigrate 加入 MeetingRecording
### Phase B2 — 业务层 + 持久化
- [ ] `service/meeting_recording_service.go`StartRecording/StopRecording/ListRecordings/GetRecording
- [ ] `controller/meeting_controller.go` 新增 4 个端点
- [ ] `router.go` 挂载路由
- [ ] `provider.go` wire 注册
- [ ] 内部回调端点 `/_internal/recordings/{id}/finalize`media-server 录完后回调Go 拉文件→ MinIO → 更新 DB
- [ ] MinIO 流式上传
### Phase B3 — WS 广播 + 自动停录
- [ ] `constants/meeting.go`MeetingWSEventRecordingStarted/Stopped
- [ ] `meeting_signal_service.go` / `meeting_broadcaster.go` 广播
- [ ] EndRoom / HandleEmptyRoomExpired 自动 StopRecording
- [ ] 前端 `constants/meeting.js` + store 监听
### Phase B4 — 前端 UI
- [ ] `MeetingToolbar.vue`:录制按钮(仅 host 可见)+ 红点动画
- [ ] `room.vue`:顶部"REC"指示条(任意成员都可见)
- [ ] 新页面 `pages/meeting/recordings.vue`:列表 + 下载
## 已知风险与决策
1. **ffmpeg 必须可用**:媒体服务的运行环境(开发:本地 PATH生产Docker 镜像 apt install ffmpeg。Dockerfile 暂不改,由你部署时确认。
2. **PlainTransport 端口分配**:用 dgram 试探可用 UDP 端口;初始范围 50000-59999避开 mediasoup `40000-40199`
3. **ffmpeg 启停时序**:录制启动顺序 = 起 ffmpeg(listen) → 100ms 延迟 → mediasoup transport.connect → consume停止顺序 = SIGINT ffmpeg → wait exit → close transports/consumers。
4. **录制视频编码**:用 `-c:v copy -c:a copy` 直接复制 RTP 载荷为 mp4不重编码节省 CPU。注意浏览器端 VP8/VP9 muxed 进 mp4 可能不被普通播放器识别,必要时改 `-c:v libx264 -preset ultrafast`。本期先用 copy。
5. **DB 表 schema**:参考下方 §SQL。
6. **Phase B 整体不强制阻塞**:每完成一个阶段都可以暂停验证。
## SQL
```sql
CREATE TABLE meeting_recordings (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
room_id BIGINT UNSIGNED NOT NULL,
room_code VARCHAR(32) NOT NULL,
started_by BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'recording', -- recording/uploading/ready/failed
remote_id VARCHAR(64), -- media-server 侧的 recordingId
file_url VARCHAR(512),
file_object VARCHAR(255),
size_bytes BIGINT UNSIGNED DEFAULT 0,
duration_sec INT UNSIGNED DEFAULT 0,
failure_reason VARCHAR(255),
started_at DATETIME NOT NULL,
stopped_at DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
KEY idx_room (room_id),
KEY idx_room_code (room_code),
KEY idx_started_by (started_by)
);
```
## 当前会话进度
- ✅ 调研完成MinIO 已就绪、media-server 架构吃透、HTTPMediaOrchestrator 模式吃透
- ⏩ 下一步:写 media-server 端口池 + recording.service.ts

View File

@@ -9,7 +9,7 @@
* - 响应中的 DTO 字段命名与后端 DTO 完全一致(下划线),前端按需再转换 * - 响应中的 DTO 字段命名与后端 DTO 完全一致(下划线),前端按需再转换
*/ */
import { get, post } from '@/utils/request' import { get, post, del } from '@/utils/request'
/** /**
* 统一拆包utils/request.js 返回完整 envelope { code, message, data, trace_id, time } * 统一拆包utils/request.js 返回完整 envelope { code, message, data, trace_id, time }
@@ -145,6 +145,36 @@ const listChats = (roomCode, params = {}) => {
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}/chats`, params)) return unwrap(get(`/api/v1/meeting/rooms/${roomCode}/chats`, params))
} }
// ==================== 会议录制Phase B 新增3 接口) ====================
/**
* 启动录制(仅 host
* @param {string} roomCode
* @returns {Promise<MeetingRecordingDTO>}
*/
const startRecording = (roomCode) => {
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/recordings`))
}
/**
* 停止录制(仅 host
* @param {string} roomCode
* @param {number|string} recordingID
* @returns {Promise<MeetingRecordingDTO>}
*/
const stopRecording = (roomCode, recordingID) => {
return unwrap(del(`/api/v1/meeting/rooms/${roomCode}/recordings/${recordingID}`))
}
/**
* 查询录制列表host 或参与者均可)
* @param {string} roomCode
* @returns {Promise<{ list: MeetingRecordingDTO[] }>}
*/
const listRecordings = (roomCode) => {
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}/recordings`))
}
export default { export default {
createRoom, createRoom,
getRoom, getRoom,
@@ -157,5 +187,8 @@ export default {
inviteUsers, inviteUsers,
redeemInvite, redeemInvite,
sendChat, sendChat,
listChats listChats,
startRecording,
stopRecording,
listRecordings
} }

View File

@@ -45,6 +45,54 @@
<text class="label">{{ videoEnabled ? '停止视频' : '开启视频' }}</text> <text class="label">{{ videoEnabled ? '停止视频' : '开启视频' }}</text>
</button> </button>
<button
class="btn"
:class="{ active: screenSharing, loading: screenLoading }"
:disabled="screenLoading || screenDisabled"
:title="screenDisabledReason || ''"
@click="emitIf('screen-toggle')"
>
<view class="icon">
<svg v-if="screenSharing" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- 共享中实心显示器 + 停止图示 -->
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
<line x1="6" y1="7" x2="18" y2="13"></line>
<line x1="18" y1="7" x2="6" y2="13"></line>
</svg>
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- 未共享空显示器 + 上箭头 -->
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
<polyline points="9 10 12 7 15 10"></polyline>
<line x1="12" y1="7" x2="12" y2="14"></line>
</svg>
</view>
<text class="label">{{ screenSharing ? '停止共享' : '共享屏幕' }}</text>
</button>
<!-- 录制按钮Phase B仅主持人可见recording=true 时红色脉动上传中禁用 -->
<button
v-if="showRecording"
class="btn btn-recording"
:class="{ active: recording, 'is-recording': recording, loading: recordingLoading }"
:disabled="recordingLoading"
@click="emitIf('recording-toggle')"
>
<view class="icon">
<svg v-if="recording" viewBox="0 0 24 24" width="22" height="22" fill="currentColor" stroke="none">
<circle cx="12" cy="12" r="6"></circle>
</svg>
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="7"></circle>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</view>
<text class="label">{{ recording ? '停止录制' : '开始录制' }}</text>
</button>
<button class="btn" @click="emitIf('invite')"> <button class="btn" @click="emitIf('invite')">
<view class="icon"> <view class="icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -102,15 +150,31 @@ const props = defineProps({
unreadChatCount: { type: Number, default: 0 }, unreadChatCount: { type: Number, default: 0 },
allowChat: { type: Boolean, default: true }, allowChat: { type: Boolean, default: true },
/** Task 15 原创特色 4全员静音氛围色 */ /** Task 15 原创特色 4全员静音氛围色 */
allMuted: { type: Boolean, default: false } allMuted: { type: Boolean, default: false },
/** Phase 3 屏幕共享:当前用户是否在共享屏幕(决定按钮文案/高亮) */
screenSharing: { type: Boolean, default: false },
/** 屏幕共享操作中getDisplayMedia / produce / closeProducer 同步等待) */
screenLoading: { type: Boolean, default: false },
/** 是否禁用屏幕共享按钮(有他人在共享 / 平台不支持等场景) */
screenDisabled: { type: Boolean, default: false },
/** 屏幕共享按钮禁用原因hover 提示文案) */
screenDisabledReason: { type: String, default: '' },
/** Phase B 录制:是否显示录制按钮(仅 host 显示) */
showRecording: { type: Boolean, default: false },
/** Phase B 录制:当前是否在录制中 */
recording: { type: Boolean, default: false },
/** Phase B 录制REST 调用 / 上传中 loading 态,禁用按钮 */
recordingLoading: { type: Boolean, default: false }
}) })
const emit = defineEmits(['mic-toggle', 'cam-toggle', 'invite', 'members', 'chat', 'leave']) const emit = defineEmits(['mic-toggle', 'cam-toggle', 'screen-toggle', 'recording-toggle', 'invite', 'members', 'chat', 'leave'])
/** 统一过滤 loading 态,避免重复点击 */ /** 统一过滤 loading 态,避免重复点击 */
const emitIf = (name) => { const emitIf = (name) => {
if (name === 'mic-toggle' && props.audioLoading) return if (name === 'mic-toggle' && props.audioLoading) return
if (name === 'cam-toggle' && props.videoLoading) return if (name === 'cam-toggle' && props.videoLoading) return
if (name === 'screen-toggle' && (props.screenLoading || props.screenDisabled)) return
if (name === 'recording-toggle' && props.recordingLoading) return
emit(name) emit(name)
} }
</script> </script>
@@ -191,6 +255,16 @@ const emitIf = (name) => {
.btn-leave .icon { background: #EF4444; } .btn-leave .icon { background: #EF4444; }
.btn-leave:hover .icon { background: #DC2626; } .btn-leave:hover .icon { background: #DC2626; }
/* Phase B 录制按钮:录制中红色脉动 */
.btn-recording.is-recording .icon {
background: #EF4444;
animation: recording-pulse 1.6s ease-in-out infinite;
}
@keyframes recording-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.55); }
50% { box-shadow: 0 0 0 12rpx rgba(239, 68, 68, 0); }
}
.badge { .badge {
position: absolute; position: absolute;
top: -6rpx; top: -6rpx;

View File

@@ -121,6 +121,27 @@ export const MEETING_WS_PRODUCER_CLOSE = 'meeting.producer.close' // C→S
// 聊天1 个) // 聊天1 个)
export const MEETING_WS_CHAT_MESSAGE = 'meeting.chat.message' // S→C export const MEETING_WS_CHAT_MESSAGE = 'meeting.chat.message' // S→C
// 屏幕共享Phase 3 新增2 个,仅 S→C启动/停止由 produce.start/producer.close 触发,无独立 C→S 事件)
export const MEETING_WS_SCREEN_STARTED = 'meeting.screen.started' // S→C
export const MEETING_WS_SCREEN_STOPPED = 'meeting.screen.stopped' // S→C
// 录制Phase B 新增2 个,仅 S→C启动/停止由 REST 触发,无独立 C→S 事件)
export const MEETING_WS_RECORDING_STARTED = 'meeting.recording.started' // S→C
export const MEETING_WS_RECORDING_STOPPED = 'meeting.recording.stopped' // S→C
// 录制状态(与后端 model.MeetingRecordingStatus* 对应)
export const MEETING_RECORDING_STATUS_RECORDING = 'recording'
export const MEETING_RECORDING_STATUS_UPLOADING = 'uploading'
export const MEETING_RECORDING_STATUS_READY = 'ready'
export const MEETING_RECORDING_STATUS_FAILED = 'failed'
export const MEETING_RECORDING_STATUS_LABEL = {
[MEETING_RECORDING_STATUS_RECORDING]: '录制中',
[MEETING_RECORDING_STATUS_UPLOADING]: '上传中',
[MEETING_RECORDING_STATUS_READY]: '已就绪',
[MEETING_RECORDING_STATUS_FAILED]: '失败'
}
/** C→S 客户端可主动发起的事件白名单 */ /** C→S 客户端可主动发起的事件白名单 */
export const MEETING_WS_CLIENT_EVENTS = [ export const MEETING_WS_CLIENT_EVENTS = [
MEETING_WS_ROOM_JOIN, MEETING_WS_ROOM_JOIN,
@@ -143,7 +164,11 @@ export const MEETING_WS_SERVER_EVENTS = [
MEETING_WS_MEMBER_KICKED, MEETING_WS_MEMBER_KICKED,
MEETING_WS_MEMBER_PRODUCER_NEW, MEETING_WS_MEMBER_PRODUCER_NEW,
MEETING_WS_HOST_CHANGED, MEETING_WS_HOST_CHANGED,
MEETING_WS_CHAT_MESSAGE MEETING_WS_CHAT_MESSAGE,
MEETING_WS_SCREEN_STARTED,
MEETING_WS_SCREEN_STOPPED,
MEETING_WS_RECORDING_STARTED,
MEETING_WS_RECORDING_STOPPED
] ]
// ==================== 本地会议状态机(前端专用,与后端 meeting_rooms.status 无关) ==================== // ==================== 本地会议状态机(前端专用,与后端 meeting_rooms.status 无关) ====================

View File

@@ -25,6 +25,12 @@
"navigationBarTitleText": "注册" "navigationBarTitleText": "注册"
} }
}, },
{
"path": "pages/auth/sso",
"style": {
"navigationBarTitleText": "登录中"
}
},
{ {
"path": "pages/chat/index", "path": "pages/chat/index",
"style": { "style": {
@@ -173,6 +179,12 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/meeting/recordings",
"style": {
"navigationBarTitleText": "会议录制"
}
},
{ {
"path": "pages/notify/index", "path": "pages/notify/index",
"style": { "style": {

View File

@@ -0,0 +1,71 @@
<!--
外部系统 SSO 桥接页cloud_law -> EchoChat
入参query
- payload: URI 编码的 JSON 字符串 { token, refresh_token, expires_in, user }
- redirect: URI 编码的目标 hash 路径例如 /pages/meeting/join?code=XXX-XXX-XXX
逻辑
1. 读取 payload写入 storagesaveToken + saveUserInfo
2. 同步 user store
3. 跳转 redirect默认 /pages/meeting/index
-->
<template>
<view class="sso">
<text class="sso-title">{{ statusText }}</text>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { saveToken, saveUserInfo } from '@/utils/storage'
import { useUserStore } from '@/store/user'
const statusText = ref('正在登录…')
onLoad((query) => {
try {
if (!query?.payload) {
statusText.value = '缺少 payload 参数'
return
}
const payload = JSON.parse(decodeURIComponent(query.payload))
if (!payload.token || !payload.user) {
statusText.value = 'payload 字段不完整'
return
}
// 写入持久化存储
saveToken(payload.token, payload.refresh_token || '', payload.expires_in || 0)
saveUserInfo(payload.user)
// 同步 pinia store
const userStore = useUserStore()
userStore.token = payload.token
userStore.userInfo = payload.user
const redirect = query.redirect ? decodeURIComponent(query.redirect) : '/pages/meeting/index'
statusText.value = '登录成功,跳转中…'
// redirectTo 不保留历史,避免返回到 sso 页造成循环
setTimeout(() => {
uni.redirectTo({ url: redirect })
}, 200)
} catch (e) {
console.error('[SSO] 解析失败', e)
statusText.value = `SSO 失败:${e.message || e}`
}
})
</script>
<style scoped>
.sso {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #F8FAFC;
}
.sso-title {
font-size: 30rpx;
color: #475569;
}
</style>

View File

@@ -0,0 +1,271 @@
<!--
会议录制列表 + 回放页Phase B
路由/pages/meeting/recordings?code=<room_code>
- 通过 query.code 拉取该房间的录制历史
- 仅会议参与者包括 host可访问后端 listRecordings 会做鉴权
- 列表按开始时间倒序每条展示 状态徽章 / 时长 / 大小 / 操作按钮
- status=ready 时可在线播放H5 <video>或下载其他状态禁用回放
-->
<template>
<view class="recordings">
<view class="header">
<text class="room-code">会议号 {{ roomCode || '—' }}</text>
<button class="refresh-btn" :disabled="loading" @click="loadList">刷新</button>
</view>
<view v-if="loading && list.length === 0" class="empty-state">
<text>加载中</text>
</view>
<view v-else-if="list.length === 0" class="empty-state">
<text>{{ errorMsg || '该会议暂无录制' }}</text>
</view>
<view v-else class="rec-list">
<view
v-for="item in list"
:key="item.id"
class="rec-card"
:class="{ active: playingId === item.id }"
>
<view class="rec-meta">
<view class="rec-row">
<view class="status-pill" :class="`status-${item.status}`">
{{ statusLabel(item.status) }}
</view>
<text class="rec-time">{{ formatStartTime(item.started_at) }}</text>
</view>
<view class="rec-row">
<text class="rec-info">时长 {{ formatDuration(item.duration_sec) }}</text>
<text class="rec-info">大小 {{ formatBytes(item.size_bytes) }}</text>
</view>
<view v-if="item.status === 'failed' && item.failure_reason" class="rec-failure">
失败原因{{ item.failure_reason }}
</view>
</view>
<view class="rec-actions">
<button
v-if="item.status === 'ready' && item.file_url"
class="action-btn primary"
@click="togglePlay(item)"
>
{{ playingId === item.id ? '收起' : '播放' }}
</button>
<button
v-if="item.status === 'ready' && item.file_url"
class="action-btn"
@click="copyLink(item)"
>
复制链接
</button>
</view>
<!-- 内联播放器 H5 <video>其他平台跳系统播放器 -->
<!-- #ifdef H5 -->
<view v-if="playingId === item.id && item.file_url" class="rec-player">
<video :src="item.file_url" controls autoplay class="player-el"></video>
</view>
<!-- #endif -->
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import meetingApi from '@/api/meeting'
import {
MEETING_RECORDING_STATUS_LABEL
} from '@/constants/meeting'
const roomCode = ref('')
const list = ref([])
const loading = ref(false)
const errorMsg = ref('')
const playingId = ref(null)
const statusLabel = (s) => MEETING_RECORDING_STATUS_LABEL[s] || s || '—'
const formatStartTime = (unix) => {
if (!unix) return '—'
const d = new Date(unix * 1000)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
const formatDuration = (sec) => {
if (!sec || sec <= 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const s = sec % 60
if (h > 0) return `${h}h ${m}m ${s}s`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const formatBytes = (b) => {
if (!b || b <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let n = b
let i = 0
while (n >= 1024 && i < units.length - 1) {
n /= 1024
i++
}
return `${n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`
}
const loadList = async () => {
if (!roomCode.value) {
errorMsg.value = '缺少会议号参数'
return
}
loading.value = true
errorMsg.value = ''
try {
const resp = await meetingApi.listRecordings(roomCode.value)
// 后端返回 { list: [...] }
const arr = Array.isArray(resp?.list) ? resp.list : []
// 按 started_at 倒序,最新优先
arr.sort((a, b) => (b.started_at || 0) - (a.started_at || 0))
list.value = arr
} catch (err) {
errorMsg.value = err?.message || '加载失败'
uni.showToast({ title: errorMsg.value, icon: 'none' })
} finally {
loading.value = false
}
}
const togglePlay = (item) => {
if (playingId.value === item.id) {
playingId.value = null
return
}
playingId.value = item.id
// #ifndef H5
// 非 H5 平台直接跳系统播放器
uni.navigateTo({
url: `/pages/common/video-player?src=${encodeURIComponent(item.file_url)}`
}).catch(() => {
// 兜底:未注册 video-player 页,用系统打开
plus?.runtime?.openURL?.(item.file_url)
})
// #endif
}
const copyLink = (item) => {
uni.setClipboardData({
data: item.file_url,
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
})
}
onLoad((query) => {
roomCode.value = (query?.code || '').toString()
})
onMounted(() => {
if (roomCode.value) {
loadList()
}
})
</script>
<style scoped>
.recordings {
min-height: 100vh;
background: #F9FAFB;
padding: 24rpx;
box-sizing: border-box;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.room-code {
font-size: 32rpx;
font-weight: 600;
color: #111827;
}
.refresh-btn {
font-size: 26rpx;
padding: 8rpx 24rpx;
border-radius: 999rpx;
background: #3B82F6;
color: #FFFFFF;
border: none;
}
.refresh-btn[disabled] { opacity: 0.5; }
.empty-state {
padding: 120rpx 0;
text-align: center;
color: #6B7280;
font-size: 28rpx;
}
.rec-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.rec-card {
background: #FFFFFF;
border: 1rpx solid #E5E7EB;
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.04);
transition: border-color 0.2s ease;
}
.rec-card.active { border-color: #3B82F6; }
.rec-meta { display: flex; flex-direction: column; gap: 8rpx; }
.rec-row { display: flex; align-items: center; gap: 16rpx; flex-wrap: wrap; }
.rec-time { font-size: 26rpx; color: #374151; font-weight: 500; }
.rec-info { font-size: 24rpx; color: #6B7280; }
.rec-failure {
font-size: 22rpx;
color: #DC2626;
margin-top: 6rpx;
}
.status-pill {
display: inline-flex;
align-items: center;
font-size: 22rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
font-weight: 500;
}
.status-recording { background: #FEE2E2; color: #DC2626; }
.status-uploading { background: #FEF3C7; color: #B45309; }
.status-ready { background: #D1FAE5; color: #065F46; }
.status-failed { background: #E5E7EB; color: #6B7280; }
.rec-actions {
display: flex;
gap: 12rpx;
margin-top: 16rpx;
}
.action-btn {
font-size: 24rpx;
padding: 8rpx 24rpx;
border-radius: 8rpx;
background: #F3F4F6;
color: #374151;
border: 1rpx solid #E5E7EB;
}
.action-btn.primary {
background: #3B82F6;
color: #FFFFFF;
border-color: #3B82F6;
}
.rec-player { margin-top: 16rpx; }
.player-el { width: 100%; max-height: 480rpx; border-radius: 12rpx; background: #000; }
</style>

View File

@@ -29,6 +29,16 @@
<view class="state-dot"></view> <view class="state-dot"></view>
<text>{{ stateText }}</text> <text>{{ stateText }}</text>
</view> </view>
<!-- Phase B 录制指示器所有人可见点击对 host 触发停止 host 仅展示 -->
<view
v-if="isRecording || recordingLoading"
class="recording-indicator"
:class="{ uploading: recordingLoading && !isRecording }"
:title="recordingHintText"
>
<view class="rec-dot"></view>
<text class="rec-text">{{ isRecording ? 'REC' : '上传中' }}</text>
</view>
<NetworkBadge :level="networkLevel" /> <NetworkBadge :level="networkLevel" />
<text class="timer">{{ durationText }}</text> <text class="timer">{{ durationText }}</text>
</view> </view>
@@ -36,8 +46,41 @@
<!-- 主区左视频 + 右侧聊天面板 chatVisible 时侧栏展开 --> <!-- 主区左视频 + 右侧聊天面板 chatVisible 时侧栏展开 -->
<view class="body"> <view class="body">
<view ref="videoCol" class="video-col"> <view ref="videoCol" class="video-col" :class="{ 'has-screen': !!screenTile }">
<VideoGrid :tiles="gridTiles"> <!-- Phase 3有屏幕共享时屏幕画面独占大画面区参与者网格降级为底部横向条带 -->
<view v-if="screenTile" class="screen-stage">
<view class="screen-stage-tile">
<VideoTile
:user-id="screenTile.userId"
:name="screenTile.name + '(屏幕共享)'"
:is-local="screenTile.isLocal"
:is-host="screenTile.isHost"
:audio-enabled="false"
:video-enabled="true"
:audio-track="null"
:video-track="screenTile.videoTrack"
:is-speaking="false"
/>
</view>
<view class="screen-strip">
<view v-for="t in stripTiles" :key="t.key" class="strip-cell">
<VideoTile
:user-id="t.userId"
:name="t.name"
:is-local="t.isLocal"
:is-host="t.isHost"
:audio-enabled="t.audioEnabled"
:video-enabled="t.videoEnabled"
:audio-track="t.audioTrack"
:video-track="t.videoTrack"
:is-speaking="!!speakingMap[t.userId]"
:compact="true"
/>
</view>
</view>
</view>
<VideoGrid v-else :tiles="gridTiles">
<template #tile="{ tile }"> <template #tile="{ tile }">
<VideoTile <VideoTile
:user-id="tile.userId" :user-id="tile.userId"
@@ -94,8 +137,17 @@
:unread-chat-count="unreadChatCount" :unread-chat-count="unreadChatCount"
:allow-chat="allowChat" :allow-chat="allowChat"
:all-muted="allMuted" :all-muted="allMuted"
:screen-sharing="screenSharingByMe"
:screen-loading="screenLoading"
:screen-disabled="screenDisabled"
:screen-disabled-reason="screenDisabledReason"
:show-recording="isHost"
:recording="isRecording"
:recording-loading="recordingLoading"
@mic-toggle="onMicToggle" @mic-toggle="onMicToggle"
@cam-toggle="onCamToggle" @cam-toggle="onCamToggle"
@screen-toggle="onScreenToggle"
@recording-toggle="onRecordingToggle"
@invite="openInvite" @invite="openInvite"
@members="openMembers" @members="openMembers"
@chat="openChat" @chat="openChat"
@@ -157,7 +209,12 @@ import {
MEETING_LOCAL_STATE_RECONNECTING, MEETING_LOCAL_STATE_RECONNECTING,
MEETING_LOCAL_STATE_LEAVING, MEETING_LOCAL_STATE_LEAVING,
MEETING_LOCAL_STATE_ENDED, MEETING_LOCAL_STATE_ENDED,
MEETING_ENDED_REASON_LABEL MEETING_ENDED_REASON_LABEL,
MEETING_RECORDING_STATUS_RECORDING,
MEETING_RECORDING_STATUS_UPLOADING,
MEETING_RECORDING_STATUS_READY,
MEETING_RECORDING_STATUS_FAILED,
MEETING_RECORDING_STATUS_LABEL
} from '@/constants/meeting' } from '@/constants/meeting'
import VideoGrid from '@/components/meeting/VideoGrid.vue' import VideoGrid from '@/components/meeting/VideoGrid.vue'
@@ -178,6 +235,7 @@ const chatVisible = ref(false)
const leaveDialogVisible = ref(false) const leaveDialogVisible = ref(false)
const audioLoading = ref(false) const audioLoading = ref(false)
const videoLoading = ref(false) const videoLoading = ref(false)
const screenLoading = ref(false)
const chatLoadingMore = ref(false) const chatLoadingMore = ref(false)
const networkLevel = ref(4) // 持续接入 getStats 后动态更新Task 16 进一步收敛) const networkLevel = ref(4) // 持续接入 getStats 后动态更新Task 16 进一步收敛)
const startedAt = ref(0) const startedAt = ref(0)
@@ -289,18 +347,94 @@ const durationText = computed(() => {
const audioEnabled = computed(() => meetingStore.localAudioEnabled) const audioEnabled = computed(() => meetingStore.localAudioEnabled)
const videoEnabled = computed(() => meetingStore.localVideoEnabled) const videoEnabled = computed(() => meetingStore.localVideoEnabled)
/** 监听 store.getLocalTrack 的变化localProducers 变化即 bump 版本 */ // ============ Phase 3 屏幕共享 ============
const localTrackVersion = computed(() => {
return `${meetingStore.localProducers.audio || ''}-${meetingStore.localProducers.video || ''}` /** 当前会议是否已有屏幕共享(任意成员) */
const screenSharingActive = computed(() => !!meetingStore.screenShare?.ownerUserId)
/** 屏幕共享者是否就是自己(决定工具栏按钮高亮 + 文案) */
const screenSharingByMe = computed(() => {
const owner = meetingStore.screenShare?.ownerUserId
return !!owner && owner === myUserId.value
})
/** H5 桌面浏览器才有 getDisplayMedia非 H5 / 移动端 / 老内核都禁用按钮 */
const screenSupported = computed(() => {
// #ifdef H5
return typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getDisplayMedia === 'function'
// #endif
// #ifndef H5
return false
// #endif
})
/** 按钮禁用条件:平台不支持 / 已有他人在共享 */
const screenDisabled = computed(() => {
if (!screenSupported.value) return true
if (screenSharingActive.value && !screenSharingByMe.value) return true
return false
})
const screenDisabledReason = computed(() => {
if (!screenSupported.value) return '当前浏览器不支持屏幕共享'
if (screenSharingActive.value && !screenSharingByMe.value) return '已有成员正在共享屏幕'
return ''
}) })
/** 远端 track 版本:让 tiles 在 slot.audio/slot.video 变化时重新计算 */ /**
* 屏幕共享大画面 tile当前会议有屏幕共享时返回 tile 描述对象,否则 null
*
* track 来源分两路:
* - 自己共享:从 store.getLocalTrack('screen') 取本地 track
* - 他人共享:从 store.getRemoteTrack(ownerUid, 'screen') 取远端 Consumer track
*
* 触发重算:依赖 screenShare reactive + localTrackVersion + remoteTrackVersion
*/
const screenTile = computed(() => {
const owner = meetingStore.screenShare?.ownerUserId
if (!owner) return null
// 触发依赖收集,让 store 内 producer/consumer 变化也能让本 computed 重算
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
localTrackVersion.value
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
remoteTrackVersion.value
const isLocal = owner === myUserId.value
const track = isLocal
? meetingStore.getLocalTrack('screen')
: meetingStore.getRemoteTrack(owner, 'screen')
// track 还没就绪时screen.started 已到但 producer.new 未到 / consumer 未建好)暂不显示大画面,
// 等下次重算_onProducerNew 完成后 remoteTrackVersion 变化触发)
if (!track) return null
return {
key: `screen_${owner}`,
userId: owner,
name: isLocal ? '你' : nameOf(owner),
isLocal,
isHost: owner === hostId.value,
videoTrack: track
}
})
/** 屏幕共享期间,底部条带显示参会者(含本地 camera tile */
const stripTiles = computed(() => {
if (!screenTile.value) return []
// 直接复用 tiles 列表(含本地);后续可按需限制数量
return tiles.value
})
/** 监听 store.getLocalTrack 的变化localProducers 变化即 bump 版本(含屏幕共享 producer */
const localTrackVersion = computed(() => {
const lp = meetingStore.localProducers
return `${lp.audio || ''}-${lp.video || ''}-${lp.screen || ''}`
})
/** 远端 track 版本:让 tiles 在 slot.audio/slot.video/slot.screen 变化时重新计算 */
const remoteTrackVersion = computed(() => { const remoteTrackVersion = computed(() => {
const parts = [] const parts = []
const map = meetingStore.remoteConsumers const map = meetingStore.remoteConsumers
Object.keys(map).forEach((uid) => { Object.keys(map).forEach((uid) => {
const slot = map[uid] const slot = map[uid]
parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}`) parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}:${slot?.screen?.id || ''}`)
}) })
return parts.join('|') return parts.join('|')
}) })
@@ -439,6 +573,76 @@ const onCamToggle = async () => {
} }
} }
/**
* 工具栏共享屏幕切换
*
* 状态机:
* - 未在共享 + 平台支持 + 无他人共享 → 调 startScreenShare
* - 自己已在共享 → 调 stopScreenShare
* - 其他场景由 MeetingToolbar.emitIf 拦截,不会进入本函数
*
* 容错getDisplayMedia 用户取消会抛 NotAllowedError对用户体验友好的提示是"已取消"
*/
const onScreenToggle = async () => {
if (screenLoading.value) return
screenLoading.value = true
try {
if (screenSharingByMe.value) {
await meetingStore.stopScreenShare()
} else {
await meetingStore.startScreenShare()
}
} catch (err) {
// NotAllowedError = 用户在系统选择器中点了取消,静默处理;其他错误才提示
if (err?.name !== 'NotAllowedError') {
uni.showToast({ title: err?.message || '屏幕共享失败', icon: 'none' })
}
} finally {
screenLoading.value = false
}
}
// ============ 录制Phase B ============
/** 当前是否处于"录制中"(按钮高亮 + 顶部红点) */
const isRecording = computed(() => meetingStore.recording.status === MEETING_RECORDING_STATUS_RECORDING)
/** 录制按钮 loadingREST 调用期间或后端异步上传期间均禁用 */
const recordingLoading = computed(() => meetingStore.recording.pending || meetingStore.recording.status === MEETING_RECORDING_STATUS_UPLOADING)
/** 录制中提示文案VideoGrid 上方红点 hover/aria */
const recordingHintText = computed(() => {
const r = meetingStore.recording
if (!r.status) return ''
return MEETING_RECORDING_STATUS_LABEL[r.status] || r.status
})
/** 切换录制:仅 host按钮 emitIf 已防抖,这里再做 try/catch + toast */
const onRecordingToggle = async () => {
if (!isHost.value) return
if (recordingLoading.value) return
try {
if (isRecording.value) {
await meetingStore.stopRecording()
uni.showToast({ title: '录制已停止,正在上传…', icon: 'none' })
} else {
await meetingStore.startRecording()
uni.showToast({ title: '已开始录制', icon: 'success' })
}
} catch (err) {
uni.showToast({ title: err?.message || '录制操作失败', icon: 'none' })
}
}
/** 监听录制终态ready → 提示已就绪failed → 错误提示 */
watch(() => meetingStore.recording.status, (newStatus, oldStatus) => {
if (newStatus === oldStatus) return
if (newStatus === MEETING_RECORDING_STATUS_READY) {
uni.showToast({ title: '录制已就绪', icon: 'success' })
} else if (newStatus === MEETING_RECORDING_STATUS_FAILED) {
const reason = meetingStore.recording.failureReason || '未知原因'
uni.showToast({ title: `录制失败:${reason}`, icon: 'none', duration: 3000 })
}
})
const openInvite = () => { inviteVisible.value = !inviteVisible.value } const openInvite = () => { inviteVisible.value = !inviteVisible.value }
const openMembers = () => { memberVisible.value = !memberVisible.value } const openMembers = () => { memberVisible.value = !memberVisible.value }
const openChat = () => { const openChat = () => {
@@ -733,6 +937,40 @@ onUnload(() => {
letter-spacing: 1rpx; letter-spacing: 1rpx;
} }
/* Phase B 录制状态指示器(顶栏) */
.recording-indicator {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: rgba(239, 68, 68, 0.18);
border: 1rpx solid rgba(239, 68, 68, 0.55);
color: #FCA5A5;
font-size: 20rpx;
font-weight: 600;
letter-spacing: 1rpx;
}
.recording-indicator.uploading {
background: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.55);
color: #FCD34D;
}
.rec-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #EF4444;
animation: rec-blink 1s infinite ease-in-out;
}
.recording-indicator.uploading .rec-dot {
background: #F59E0B;
}
@keyframes rec-blink {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
.body { .body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -748,6 +986,46 @@ onUnload(() => {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
/* Phase 3 屏幕共享专属布局:大画面 + 底部横向条带 */
.video-col.has-screen { flex-direction: column; }
.screen-stage {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12rpx;
padding: 12rpx;
}
.screen-stage-tile {
flex: 1;
min-height: 0;
background: #000;
border-radius: 16rpx;
overflow: hidden;
/* 屏幕画面用 contain 而非 cover避免桌面边缘内容被裁切 */
}
.screen-stage-tile :deep(video) {
object-fit: contain !important;
background: #000;
}
.screen-strip {
flex-shrink: 0;
height: 160rpx;
display: flex;
flex-direction: row;
gap: 12rpx;
overflow-x: auto;
overflow-y: hidden;
}
.screen-strip .strip-cell {
flex: 0 0 220rpx;
height: 100%;
border-radius: 12rpx;
overflow: hidden;
background: #111827;
}
.chat-col { .chat-col {
width: 0; width: 0;
min-height: 0; min-height: 0;

View File

@@ -46,6 +46,14 @@ import {
MEETING_WS_MEMBER_PRODUCER_NEW, MEETING_WS_MEMBER_PRODUCER_NEW,
MEETING_WS_HOST_CHANGED, MEETING_WS_HOST_CHANGED,
MEETING_WS_CHAT_MESSAGE, MEETING_WS_CHAT_MESSAGE,
MEETING_WS_SCREEN_STARTED,
MEETING_WS_SCREEN_STOPPED,
MEETING_WS_RECORDING_STARTED,
MEETING_WS_RECORDING_STOPPED,
MEETING_RECORDING_STATUS_RECORDING,
MEETING_RECORDING_STATUS_UPLOADING,
MEETING_RECORDING_STATUS_READY,
MEETING_RECORDING_STATUS_FAILED,
MEETING_ROLE_HOST, MEETING_ROLE_HOST,
MEETING_ENDED_REASON_LABEL, MEETING_ENDED_REASON_LABEL,
MEETING_ENDED_REASON_KICKED, MEETING_ENDED_REASON_KICKED,
@@ -103,10 +111,57 @@ export const useMeetingStore = defineStore('meeting', () => {
const localVideoEnabled = ref(false) const localVideoEnabled = ref(false)
/** /**
* 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId? } * 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId?, screen: producerId? }
* 用于启停麦克风/摄像头 * 用于启停麦克风/摄像头/屏幕共享
*
* Phase 3 屏幕共享screen 与 video 同为 video kind但走独立 Producer
* appData.screen=true 区分),便于单独开关,互不影响摄像头
*/ */
const localProducers = reactive({ audio: null, video: null }) const localProducers = reactive({ audio: null, video: null, screen: null })
/**
* 当前会议屏幕共享状态Phase 3
* - ownerUserId: 谁在共享含自己null 表示无人共享
* - producerId: 屏幕流 Producer ID远端用于匹配 consumer slot本地用于回查
*
* 数据来源:
* 1) 自己开启startScreenShare 成功后由 OnProduceStart 触发 meeting.screen.started 广播写入
* 2) 他人开启:收到 meeting.screen.started 后写入
* 3) 后入者OnRoomJoin 后端会定向补推 meeting.screen.startedexisting=true
*/
const screenShare = reactive({ ownerUserId: null, producerId: null })
/**
* 会议录制状态Phase B
*
* 字段:
* - id 后端 meeting_recordings.idnull 表示当前无录制
* - status recording / uploading / ready / failed
* - startedBy 发起录制的 host userId便于 UI 提示"由 XXX 录制"
* - startedAt unix 秒
* - stoppedAt unix 秒
* - fileUrl 最终回放 URL仅 ready
* - sizeBytes 文件大小(仅 ready
* - durationSec 录制时长(仅 ready
* - failureReason 失败原因(仅 failed
* - pending 本地"启停按钮防抖"标记:调 REST 期间禁用按钮,避免重复请求
*
* 数据流:
* - 进入会议时不主动拉历史录制;只通过 WS meeting.recording.started/stopped 维护
* - 同时只允许 1 条活跃录制 → 单对象足够,不必维护数组
*/
const recording = reactive({
id: null,
status: null,
startedBy: null,
startedAt: null,
stoppedAt: null,
fileUrl: '',
sizeBytes: 0,
durationSec: 0,
failureReason: '',
pending: false
})
/** /**
* 远端 Consumer 索引:{ [userId]: { audio?: Consumer, video?: Consumer, producerIds: Set } } * 远端 Consumer 索引:{ [userId]: { audio?: Consumer, video?: Consumer, producerIds: Set } }
@@ -254,6 +309,10 @@ export const useMeetingStore = defineStore('meeting', () => {
localVideoEnabled.value = false localVideoEnabled.value = false
localProducers.audio = null localProducers.audio = null
localProducers.video = null localProducers.video = null
localProducers.screen = null
screenShare.ownerUserId = null
screenShare.producerId = null
_resetRecordingState()
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k]) Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
_engine = null _engine = null
} }
@@ -269,6 +328,10 @@ export const useMeetingStore = defineStore('meeting', () => {
[MEETING_WS_MEMBER_PRODUCER_NEW]: _onProducerNew, [MEETING_WS_MEMBER_PRODUCER_NEW]: _onProducerNew,
[MEETING_WS_HOST_CHANGED]: _onHostChanged, [MEETING_WS_HOST_CHANGED]: _onHostChanged,
[MEETING_WS_CHAT_MESSAGE]: _onChatMessage, [MEETING_WS_CHAT_MESSAGE]: _onChatMessage,
[MEETING_WS_SCREEN_STARTED]: _onScreenStarted,
[MEETING_WS_SCREEN_STOPPED]: _onScreenStopped,
[MEETING_WS_RECORDING_STARTED]: _onRecordingStarted,
[MEETING_WS_RECORDING_STOPPED]: _onRecordingStopped,
// WS 连接生命周期:用于断线/重连期间维持会议会话,避免"连接断开 → 后端失去 roomCode 绑定 → 广播丢失" // WS 连接生命周期:用于断线/重连期间维持会议会话,避免"连接断开 → 后端失去 roomCode 绑定 → 广播丢失"
_connected: _onWsReconnected, _connected: _onWsReconnected,
_disconnected: _onWsDisconnected _disconnected: _onWsDisconnected
@@ -299,6 +362,10 @@ export const useMeetingStore = defineStore('meeting', () => {
// 表现为"重新发起会议后看不到自己的画面" // 表现为"重新发起会议后看不到自己的画面"
localProducers.audio = null localProducers.audio = null
localProducers.video = null localProducers.video = null
localProducers.screen = null
screenShare.ownerUserId = null
screenShare.producerId = null
_resetRecordingState()
localAudioEnabled.value = false localAudioEnabled.value = false
localVideoEnabled.value = false localVideoEnabled.value = false
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k]) Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
@@ -446,16 +513,20 @@ export const useMeetingStore = defineStore('meeting', () => {
try { try {
const consumer = await _engine.consume({ producerId: data.producer_id }) const consumer = await _engine.consume({ producerId: data.producer_id })
if (!remoteConsumers[data.user_id]) { if (!remoteConsumers[data.user_id]) {
// 外层 slot 保持 reactive 以便 slot.audio/slot.video 赋值能驱动 VideoTile 重渲染; // 外层 slot 保持 reactive 以便 slot.audio/slot.video/slot.screen 赋值能驱动 VideoTile 重渲染;
// 仅 Consumer 实例和 Set 本身用 markRaw 隔离 Vue 代理 // 仅 Consumer 实例和 Set 本身用 markRaw 隔离 Vue 代理
remoteConsumers[data.user_id] = { remoteConsumers[data.user_id] = {
audio: null, audio: null,
video: null, video: null,
screen: null,
producerIds: markRaw(new Set()) producerIds: markRaw(new Set())
} }
} }
const slot = remoteConsumers[data.user_id] const slot = remoteConsumers[data.user_id]
slot[consumer.kind] = markRaw(consumer) // Phase 3 屏幕共享:后端 producer.new 携带 screen=true 时挂到独立 screen 槽位,
// 避免覆盖摄像头 video 槽。consumer.kind 仍是 'video',不能用 kind 区分
const slotKey = data.screen ? 'screen' : consumer.kind
slot[slotKey] = markRaw(consumer)
slot.producerIds.add(data.producer_id) slot.producerIds.add(data.producer_id)
// track 已在 consumer.track 中,调用方(页面)可从 store 拿 consumer.track 挂到 DOM // track 已在 consumer.track 中,调用方(页面)可从 store 拿 consumer.track 挂到 DOM
@@ -466,6 +537,116 @@ export const useMeetingStore = defineStore('meeting', () => {
} }
} }
/**
* 收到 meeting.screen.started 广播 → 更新 screenShare 状态
*
* 数据驱动而非直接操作 consumerconsumer 已经由 _onProducerNew 自动建好(带 screen=true 路由到 slot.screen
* 本 handler 只负责标记"当前谁在共享 + 哪个 producer"UI 层据此把屏幕 tile 提升为大画面
*
* 兼容路径existing=true 表示后端 OnRoomJoin 内部补推(后入者首次连进来时拉取当前状态)
*/
const _onScreenStarted = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
screenShare.ownerUserId = data.user_id
screenShare.producerId = data.producer_id
_log('info', '[Meeting] 屏幕共享已开始', data.user_id, data.producer_id, data.existing ? '(existing)' : '')
}
/**
* 收到 meeting.screen.stopped 广播 → 清空 screenShare 状态
*
* 幂等保护:若当前 owner 不是 data.user_id说明已被新的 started 覆盖),不做处理
* 远端屏幕 Consumer 的清理由 meeting.member.producer.new(closed=true) 复用 _cleanupRemoteProducer 完成,
* 本 handler 不重复关闭,避免 race
*/
const _onScreenStopped = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
if (screenShare.ownerUserId && screenShare.ownerUserId !== data.user_id) {
_log('debug', '[Meeting] 忽略过期 screen.stopped', data.user_id)
return
}
screenShare.ownerUserId = null
screenShare.producerId = null
_log('info', '[Meeting] 屏幕共享已停止', data.user_id)
}
// ==================== 录制相关辅助 / 回调Phase B ====================
/** 重置 recording 状态_reset / _cleanupMedia / room.ended 复用) */
const _resetRecordingState = () => {
recording.id = null
recording.status = null
recording.startedBy = null
recording.startedAt = null
recording.stoppedAt = null
recording.fileUrl = ''
recording.sizeBytes = 0
recording.durationSec = 0
recording.failureReason = ''
recording.pending = false
}
/**
* 用 server 返回的 recording DTO 合并到本地 recording reactive
* 注意:服务端字段是 snake_case前端 store 字段是 camelCase
*/
const _applyRecordingDTO = (dto) => {
if (!dto || typeof dto !== 'object') return
if (dto.id != null) recording.id = dto.id
if (dto.status) recording.status = dto.status
if (dto.started_by != null) recording.startedBy = dto.started_by
if (dto.started_at != null) recording.startedAt = dto.started_at
if (dto.stopped_at != null) recording.stoppedAt = dto.stopped_at
if (typeof dto.file_url === 'string') recording.fileUrl = dto.file_url
if (typeof dto.size_bytes === 'number') recording.sizeBytes = dto.size_bytes
if (typeof dto.duration_sec === 'number') recording.durationSec = dto.duration_sec
if (typeof dto.failure_reason === 'string') recording.failureReason = dto.failure_reason
}
/**
* 收到 meeting.recording.started 广播
* 后端 payload: { room_code, recording_id, started_by, started_at, status='recording' }
*/
const _onRecordingStarted = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
// 与本地状态合并:可能本地刚 startRecording 拿到 DTO 后又收到广播id 必须一致才合并)
if (recording.id && recording.id !== data.recording_id) {
_log('warn', '[Meeting] 忽略不一致的 recording.started', data.recording_id, '当前', recording.id)
return
}
recording.id = data.recording_id
recording.status = data.status || MEETING_RECORDING_STATUS_RECORDING
recording.startedBy = data.started_by
recording.startedAt = data.started_at
recording.pending = false
_log('info', '[Meeting] 录制已开始', data.recording_id, data.started_by)
}
/**
* 收到 meeting.recording.stopped 广播
* payload: { room_code, recording_id, status: ready|failed, file_url?, size_bytes?, duration_sec?, failure_reason?, stopped_at }
* status=uploading 阶段后端不广播;前端只需在 ready/failed 时清理 pending、保留终态展示
*/
const _onRecordingStopped = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
if (recording.id && recording.id !== data.recording_id) {
_log('warn', '[Meeting] 忽略不一致的 recording.stopped', data.recording_id, '当前', recording.id)
return
}
recording.status = data.status || MEETING_RECORDING_STATUS_READY
recording.stoppedAt = data.stopped_at || Math.floor(Date.now() / 1000)
if (typeof data.file_url === 'string') recording.fileUrl = data.file_url
if (typeof data.size_bytes === 'number') recording.sizeBytes = data.size_bytes
if (typeof data.duration_sec === 'number') recording.durationSec = data.duration_sec
if (typeof data.failure_reason === 'string') recording.failureReason = data.failure_reason
recording.pending = false
_log('info', '[Meeting] 录制已停止', data.recording_id, data.status, data.file_url)
}
const _onHostChanged = (msg) => { const _onHostChanged = (msg) => {
const data = msg.data || {} const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
@@ -563,7 +744,8 @@ export const useMeetingStore = defineStore('meeting', () => {
const slot = remoteConsumers[userId] const slot = remoteConsumers[userId]
if (!slot) return if (!slot) return
slot.producerIds.delete(producerId) slot.producerIds.delete(producerId)
;['audio', 'video'].forEach(kind => { // Phase 3新增 screen 槽位一并扫描,关闭归属 producerId 的那一个
;['audio', 'video', 'screen'].forEach(kind => {
const consumer = slot[kind] const consumer = slot[kind]
if (!consumer) return if (!consumer) return
if (consumer.producerId && consumer.producerId !== producerId) return if (consumer.producerId && consumer.producerId !== producerId) return
@@ -572,7 +754,7 @@ export const useMeetingStore = defineStore('meeting', () => {
} }
slot[kind] = null slot[kind] = null
}) })
if (!slot.audio && !slot.video && slot.producerIds.size === 0) { if (!slot.audio && !slot.video && !slot.screen && slot.producerIds.size === 0) {
delete remoteConsumers[userId] delete remoteConsumers[userId]
} }
} }
@@ -580,12 +762,18 @@ export const useMeetingStore = defineStore('meeting', () => {
const _cleanupRemoteUser = (userId) => { const _cleanupRemoteUser = (userId) => {
const slot = remoteConsumers[userId] const slot = remoteConsumers[userId]
if (!slot) return if (!slot) return
;['audio', 'video'].forEach(kind => { ;['audio', 'video', 'screen'].forEach(kind => {
if (slot[kind] && !slot[kind].closed) { if (slot[kind] && !slot[kind].closed) {
try { slot[kind].close() } catch {} try { slot[kind].close() } catch {}
} }
}) })
delete remoteConsumers[userId] delete remoteConsumers[userId]
// Phase 3若离开者恰好是当前屏幕共享者本地兜底清空 screenShare
// (正常路径下后端的 cleanupUserResources 会广播 screen.stopped此处只是 race 防御)
if (screenShare.ownerUserId === userId) {
screenShare.ownerUserId = null
screenShare.producerId = null
}
} }
const _cleanupMedia = () => { const _cleanupMedia = () => {
@@ -1039,6 +1227,153 @@ export const useMeetingStore = defineStore('meeting', () => {
_broadcastSelfState({ video_enabled: false }) _broadcastSelfState({ video_enabled: false })
} }
/**
* 开启屏幕共享Phase 3
*
* 流程:
* 1) 前置校验:会议是否已有他人在共享(前端拦截,后端 OnProduceStart 也会兜底拒绝)
* 2) navigator.mediaDevices.getDisplayMedia 弹出系统选择器(仅 H5/桌面浏览器支持)
* 3) MediaEngine.produce 携带 appData={screen:true},让后端打 owner key + 广播 screen.started
* 4) 监听 track.onended用户点浏览器原生"停止共享"按钮时自动调 stopScreenShare 清理
*
* 不与摄像头互斥:摄像头开启时仍可叠加屏幕共享,两者各自占独立 Producer
*
* 失败处理getDisplayMedia 用户取消、设备不支持、后端拒绝等均抛错给调用方提示 toast
* produce 失败时主动 stop() track 释放屏幕采集,避免幽灵采集
*/
const startScreenShare = async () => {
if (!_engine) throw new Error('未连接')
if (localProducers.screen) return
if (typeof navigator === 'undefined' ||
!navigator.mediaDevices ||
typeof navigator.mediaDevices.getDisplayMedia !== 'function') {
throw new Error('当前浏览器不支持屏幕共享')
}
const myUid = useUserStore().userInfo?.id
if (screenShare.ownerUserId && screenShare.ownerUserId !== myUid) {
throw new Error('已有成员正在共享屏幕')
}
const stream = await navigator.mediaDevices.getDisplayMedia({
// 屏幕共享对帧率要求低、清晰度要求高,控制带宽优先保证清晰度
video: { frameRate: { ideal: 15, max: 30 } },
audio: false
})
const track = stream.getVideoTracks()[0]
if (!track) {
// 极端情况:用户授权了但 track 为空,主动 stop 整个 stream
try { stream.getTracks().forEach(t => t.stop()) } catch {}
throw new Error('未获取到屏幕共享视频流')
}
let producer
try {
producer = await _engine.produce({
kind: 'video',
track,
appData: { screen: true }
})
} catch (err) {
// produce 失败必须主动 stop() track否则浏览器顶部"正在共享屏幕"提示条会一直显示
try { track.stop() } catch {}
throw err
}
localProducers.screen = producer.id
// 浏览器原生"停止共享"按钮:用户从系统 UI 取消时 track 进入 ended
// 此时本地 producer.on('trackended') 也会触发关闭,这里加一层保险
// 注意监听 track 而不是 producerproducer 的 trackended handler 已经做了 closeProducer
// 本 listener 主要负责前端 store 状态收尾localProducers.screen 清空、广播由后端发)
track.addEventListener('ended', () => {
_log('info', '[Meeting] 屏幕共享 track 已结束(用户从浏览器原生 UI 取消)')
// 双保险:调一次 stopScreenShare幂等
stopScreenShare().catch((err) => _log('warn', '[Meeting] 自动停止屏幕共享失败', err))
})
}
/**
* 停止屏幕共享Phase 3
* - 关闭本地 screen ProducerMediaEngine 内部触发 producer.close WS 通知后端)
* - 后端 OnProducerClose 检测到这是 screenOwnerKey 对应的 producer会广播 screen.stopped
* - 本地 screenShare 状态由 _onScreenStopped 广播 handler 兜底清空(保证幂等)
* 幂等:未在共享时静默返回
*/
const stopScreenShare = async () => {
if (!_engine || !localProducers.screen) return
const pid = localProducers.screen
// 先清本地引用,防止 producer.close 走完前 UI 重复触发
localProducers.screen = null
try {
await _engine.closeProducer(pid)
} catch (err) {
_log('warn', '[Meeting] 关闭屏幕共享 Producer 失败(忽略,后端会兜底清理)', err)
}
}
// ==================== 录制 actionsPhase B ====================
/**
* 启动录制(仅 host 可调用)
* - REST 调用 startRecording → 后端 200 后会广播 meeting.recording.started
* - 本 action 在 REST 成功时同步本地 recording 状态pending=true 期间禁用按钮)
* - 失败时 recording.pending 置回 false错误向上抛由 UI toast
*/
const startRecording = async () => {
if (!currentRoom.value) {
throw new Error('当前未在会议中')
}
if (recording.pending) {
_log('debug', '[Meeting] startRecording 防抖:已有 pending 请求')
return
}
if (recording.id && recording.status === MEETING_RECORDING_STATUS_RECORDING) {
_log('debug', '[Meeting] startRecording当前已在录制跳过')
return
}
recording.pending = true
try {
const dto = await meetingApi.startRecording(currentRoom.value.room_code)
_applyRecordingDTO(dto)
_log('info', '[Meeting] 录制启动成功', dto?.id)
return dto
} catch (err) {
recording.pending = false
_log('error', '[Meeting] 录制启动失败', err)
throw err
}
}
/**
* 停止录制(仅 host 可调用)
* - REST 返回时录制已经处于 uploading异步上传 MinIO广播 stopped 在 ready/failed 终态触发
* - 本 action 仅负责发起 REST + pending 标记UI 等待广播终态
*/
const stopRecording = async () => {
if (!currentRoom.value) {
throw new Error('当前未在会议中')
}
if (!recording.id) {
_log('debug', '[Meeting] stopRecording当前无活跃录制')
return
}
if (recording.pending) {
_log('debug', '[Meeting] stopRecording 防抖:已有 pending 请求')
return
}
recording.pending = true
try {
const dto = await meetingApi.stopRecording(currentRoom.value.room_code, recording.id)
_applyRecordingDTO(dto)
// pending 保持 true由 _onRecordingStopped 广播置回 false终态
_log('info', '[Meeting] 录制停止已发起,等待终态广播', dto?.id, dto?.status)
return dto
} catch (err) {
recording.pending = false
_log('error', '[Meeting] 录制停止失败', err)
throw err
}
}
/** /**
* Task 15向房间其他成员广播自身音视频状态 * Task 15向房间其他成员广播自身音视频状态
* - 后端 meeting.member.state.changed 语义:操作自己无需 target_user_id * - 后端 meeting.member.state.changed 语义:操作自己无需 target_user_id
@@ -1099,7 +1434,7 @@ export const useMeetingStore = defineStore('meeting', () => {
/** /**
* 取得某个远端用户的 track供页面挂到 <video>/<audio> * 取得某个远端用户的 track供页面挂到 <video>/<audio>
* @param {number} userId * @param {number} userId
* @param {'audio'|'video'} kind * @param {'audio'|'video'|'screen'} kind - Phase 3 起新增 'screen' 槽位
* @returns {MediaStreamTrack|null} * @returns {MediaStreamTrack|null}
*/ */
const getRemoteTrack = (userId, kind) => { const getRemoteTrack = (userId, kind) => {
@@ -1243,6 +1578,7 @@ export const useMeetingStore = defineStore('meeting', () => {
localAudioEnabled, localAudioEnabled,
localVideoEnabled, localVideoEnabled,
localProducers, localProducers,
screenShare,
remoteConsumers, remoteConsumers,
draftCreatePayload, draftCreatePayload,
draftJoinPayload, draftJoinPayload,
@@ -1269,6 +1605,11 @@ export const useMeetingStore = defineStore('meeting', () => {
startLocalVideo, startLocalVideo,
stopLocalAudio, stopLocalAudio,
stopLocalVideo, stopLocalVideo,
startScreenShare,
stopScreenShare,
recording,
startRecording,
stopRecording,
getRemoteTrack, getRemoteTrack,
getLocalTrack, getLocalTrack,

View File

@@ -41,9 +41,9 @@ ENV NODE_ENV=production \
# mediasoup-worker 是原生二进制,运行时需要 libstdc++ 等基础库slim 默认已包含 # mediasoup-worker 是原生二进制,运行时需要 libstdc++ 等基础库slim 默认已包含
# 健康检查需要 curl # 健康检查需要 curl
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \ && apt-get install -y --no-install-recommends curl ca-certificates ffmpeg \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --shell /usr/sbin/nologin mediasoup && useradd --system --create-home --shell /usr/sbin/nologin mediasoupv
WORKDIR /app WORKDIR /app

View File

@@ -8,6 +8,7 @@ import { internalAuthPlugin } from './middlewares/internal-auth.js';
import { closeWorker, getWorkerSnapshot, startWorker } from './mediasoup/worker.js'; import { closeWorker, getWorkerSnapshot, startWorker } from './mediasoup/worker.js';
import { consumerRoutes } from './routes/consumer.route.js'; import { consumerRoutes } from './routes/consumer.route.js';
import { producerRoutes } from './routes/producer.route.js'; import { producerRoutes } from './routes/producer.route.js';
import { recordingRoutes } from './routes/recording.route.js';
import { routerRoutes } from './routes/router.route.js'; import { routerRoutes } from './routes/router.route.js';
import { transportRoutes } from './routes/transport.route.js'; import { transportRoutes } from './routes/transport.route.js';
import { getRouterStats } from './services/router.service.js'; import { getRouterStats } from './services/router.service.js';
@@ -78,6 +79,7 @@ export async function buildApp() {
await scope.register(transportRoutes); await scope.register(transportRoutes);
await scope.register(producerRoutes); await scope.register(producerRoutes);
await scope.register(consumerRoutes); await scope.register(consumerRoutes);
await scope.register(recordingRoutes);
}, },
{ prefix: '/internal/v1' }, { prefix: '/internal/v1' },
); );

View File

@@ -0,0 +1,44 @@
import type { FastifyInstance } from 'fastify';
import {
recordingIdParamSchema,
startRecordingBodySchema,
} from '../schemas/recording.schema.js';
import {
getRecording,
startRecording,
stopRecording,
} from '../services/recording.service.js';
/**
* 录制 REST 路由(挂载在 /internal/v1 前缀下)
*
* - POST /recordings 启动录制;返回 recordingId + 已绑定的 tracks
* - DELETE /recordings/:id 停止录制;返回文件大小、时长、退出码、失败原因
* - GET /recordings/:id 查询正在进行中的录制状态(结束后返 404
*
* 这些都是受 internalAuthPlugin 保护的内部接口,不直接暴露给客户端
*/
export async function recordingRoutes(app: FastifyInstance): Promise<void> {
app.post('/recordings', async (request, reply) => {
const body = startRecordingBodySchema.parse(request.body);
const result = await startRecording({
routerId: body.routerId,
roomCode: body.roomCode,
producerIds: body.producerIds,
});
reply.code(201);
return result;
});
app.delete('/recordings/:id', async (request) => {
const { id } = recordingIdParamSchema.parse(request.params);
const result = await stopRecording(id);
return result;
});
app.get('/recordings/:id', async (request) => {
const { id } = recordingIdParamSchema.parse(request.params);
return getRecording(id);
});
}

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
import { idStringSchema } from './common.js';
/**
* 录制启动请求 schema
*
* 字段约束:
* - routerId: 必传media-server 通过它定位 mediasoup Router
* - roomCode: 必传;用于日志聚合 / Go 侧关联
* - producerIds: 至少 1 个Phase B1 通常是 host 的 audio + video 两个;
* Phase B3 扩展为屏幕共享 + 多人音频时,最大值由 RECORDING_MAX_TRACKS 限制
*/
export const startRecordingBodySchema = z.object({
routerId: idStringSchema,
roomCode: z.string().min(1).max(64),
producerIds: z.array(idStringSchema).min(1).max(8),
});
export const recordingIdParamSchema = z.object({
id: idStringSchema,
});
export type StartRecordingBody = z.infer<typeof startRecordingBodySchema>;
export type RecordingIdParam = z.infer<typeof recordingIdParamSchema>;

View File

@@ -0,0 +1,781 @@
/**
* 录制服务:基于 mediasoup PlainTransport + ffmpeg 子进程
*
* 录制管线:
* ┌─────────────┐ RTP/RTCP ┌──────────────┐ SDP+stdin ┌────────┐ fs ┌─────────┐
* │ Producer X │ ───────────> │ PlainTransport│ ───────────────> │ ffmpeg │ ──────> │ *.mp4 │
* │ (audio/video)│ │ + Consumer │ (UDP loopback) │ (子进程)│ └─────────┘
* └─────────────┘ └──────────────┘ └────────┘
*
* 启动顺序(关键):
* 1) 为每个 Producer 创建 Consumer 拿到 negotiated rtpParameters
* Consumer 立即 pause避免 RTP 在 ffmpeg 起来前空打)
* 2) 给每个 Consumer 分配一对 UDP 端口ffmpeg 监听端)
* 3) 写 SDP 文件描述所有流
* 4) spawn ffmpeg 进程listen 在那些端口
* 5) 创建对应 PlainTransportrtcpMux=false, comedia=false
* 并 connect 到 ffmpeg 监听端口
* 6) resume ConsumerRTP 开始流向 ffmpeg
*
* 停止顺序:
* 1) SIGINT ffmpeg让 ffmpeg 关闭 mp4 moov boxSIGKILL 会导致文件损坏)
* 2) 等 ffmpeg 进程退出(超时 5s 兜底 SIGKILL
* 3) close Consumer / PlainTransport
* 4) 释放端口
* 5) stat 输出文件,返回大小 + 时长
*
* 关键约束:
* - PlainTransport 用 rtcpMux=false避免 RTCP 与 RTP 同端口ffmpeg 解析更可靠
* - comedia=false由 server 主动 connectIP/Port 是已知的本地回环
* - 不开启 SRTPloopback 不需要加密,降低 CPU 与配置复杂度)
* - Consumer 必须设置 paused=true 创建,否则 RTP 会先于 ffmpeg 启动到达
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type {
Consumer,
PlainTransport,
RtpCodecParameters,
RtpParameters,
} from 'mediasoup/types';
import { config } from '../config.js';
import { AppError, notFound } from '../utils/errors.js';
import { childLogger } from '../utils/logger.js';
import { allocatePortPair, releasePortPair } from '../utils/port-pool.js';
import { assertTestOnly } from '../utils/test-guard.js';
import { getProducer } from './producer.service.js';
import { getRouter } from './router.service.js';
const log = childLogger({ module: 'services.recording' });
// ============ 类型 ============
interface RecordingTrack {
/** 来源 producer ID供日志/排障) */
producerId: string;
/** Consumer ID停止时关闭 */
consumerId: string;
/** PlainTransport ID停止时关闭 */
transportId: string;
/** ffmpeg 监听端口RTP */
ffmpegRtpPort: number;
/** ffmpeg 监听端口RTCP */
ffmpegRtcpPort: number;
kind: 'audio' | 'video';
/** payload type用于 SDP / 跨进程串联 */
payloadType: number;
/** Codec mime例如 audio/opus / video/VP8 */
codecMime: string;
/** Codec clock rate */
clockRate: number;
/** Codec channels音频专用 */
channels?: number;
/** Codec sdpFmtp 字段(如 minptime=10;useinbandfec=1 */
sdpFmtp?: string;
}
interface RecordingEntry {
id: string;
roomCode: string;
routerId: string;
startedAt: number;
/** 各路 producer 对应的 track */
tracks: RecordingTrack[];
/** mediasoup Consumer 句柄(停止时 close */
consumers: Consumer[];
/** mediasoup PlainTransport 句柄(停止时 close */
transports: PlainTransport[];
/** ffmpeg 子进程 */
ffmpeg: ChildProcess;
/** SDP 临时文件 */
sdpFilePath: string;
/** 输出 mp4 文件 */
outputPath: string;
/** ffmpeg 退出 promisestop 时 await */
ffmpegExited: Promise<void>;
/** 停止时回填,避免重复 stop */
stopping: boolean;
/** 退出码stop 后填充供 callback 返回 */
exitCode: number | null;
/** 失败原因spawn 阶段或 stop 超时),供 finalize 上报 */
failureReason: string | null;
/** ffmpeg 是否在 stop 之前异常退出watchdog 标记,避免重复触发回调) */
unexpectedExit: boolean;
}
// ==================== Watchdog / 失败回调 ====================
/**
* ffmpeg 异常退出回调签名
*
* 触发条件ffmpeg 在 stop 调用之前自行退出(非零退出码或 spawn 失败)
* 调用方可注册 webhook、上报告警、写入 DB 标记失败等
*
* 注意:回调在 ffmpeg 'exit' 事件后异步触发setImmediate不会阻塞退出处理
* 实现方需保证内部错误不抛出到 Node 主循环,否则会被全局 unhandledRejection 接住。
*/
export type RecordingFailureCallback = (info: {
recordingId: string;
roomCode: string;
routerId: string;
exitCode: number | null;
failureReason: string;
}) => void | Promise<void>;
let failureCallback: RecordingFailureCallback | null = null;
/**
* 注册录制失败回调(覆盖式:只保留最近一次注册)
* 传 null 取消注册
*/
export function setRecordingFailureCallback(cb: RecordingFailureCallback | null): void {
failureCallback = cb;
}
/**
* 默认 webhook 实现:当 process.env.RECORDING_FAILURE_WEBHOOK_URL 设置时,
* 通过 fetch POST JSON 上报失败。Node 18+ 自带 fetch
*/
async function defaultWebhookCallback(info: Parameters<RecordingFailureCallback>[0]): Promise<void> {
const url = process.env.RECORDING_FAILURE_WEBHOOK_URL;
if (!url) return;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info),
});
if (!res.ok) {
log.warn(
{ recordingId: info.recordingId, status: res.status },
'recording failure webhook returned non-2xx',
);
}
} catch (err) {
log.warn(
{ recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
'recording failure webhook POST failed',
);
}
}
/**
* 内部触发失败回调:异步、错误吞掉
* 同时尝试用户注册的回调和默认 webhook两者独立
*/
function fireFailureCallback(entry: RecordingEntry): void {
if (entry.unexpectedExit) return; // 幂等:每条录制最多触发一次
entry.unexpectedExit = true;
const info = {
recordingId: entry.id,
roomCode: entry.roomCode,
routerId: entry.routerId,
exitCode: entry.exitCode,
failureReason: entry.failureReason ?? 'ffmpeg exited unexpectedly',
};
setImmediate(() => {
void defaultWebhookCallback(info);
if (failureCallback) {
try {
const r = failureCallback(info);
if (r && typeof (r as Promise<void>).catch === 'function') {
(r as Promise<void>).catch((err) => {
log.warn(
{ recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
'recording failure callback rejected',
);
});
}
} catch (err) {
log.warn(
{ recordingId: info.recordingId, err: err instanceof Error ? err.message : String(err) },
'recording failure callback threw',
);
}
}
});
}
const recordingMap = new Map<string, RecordingEntry>();
// 默认输出目录;由 RECORDING_OUTPUT_DIR 环境变量覆盖,便于 docker volume 挂载
const OUTPUT_DIR = process.env.RECORDING_OUTPUT_DIR
? process.env.RECORDING_OUTPUT_DIR
: join(tmpdir(), 'echochat-recordings');
const STOP_GRACE_MS = 5000;
// ============ 工具函数 ============
async function ensureOutputDir(): Promise<void> {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
}
function recordingId(): string {
// crypto.randomUUID 在 node 20+ 可用移除连字符以让文件名更紧凑、URL-safe
return randomUUID().replace(/-/g, '');
}
/**
* 从 Consumer 的 rtpParameters 提取录制需要的关键字段
* - 取第一个 codecmediasoup consumer 默认每个 Consumer 只协商一个主 codec
* - sdpFmtp把 codec.parameters 拼成 SDP a=fmtp 行格式
*/
function pickCodec(rtpParameters: RtpParameters): {
payloadType: number;
codecMime: string;
clockRate: number;
channels?: number;
sdpFmtp?: string;
} {
const codec: RtpCodecParameters | undefined = rtpParameters.codecs[0];
if (!codec) {
throw new AppError('MEDIASOUP_ERROR', 'consumer has no codecs in rtpParameters');
}
const params = codec.parameters || {};
const fmtpParts = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${k}=${typeof v === 'boolean' ? (v ? 1 : 0) : String(v)}`);
return {
payloadType: codec.payloadType,
codecMime: codec.mimeType,
clockRate: codec.clockRate,
channels: codec.channels,
sdpFmtp: fmtpParts.length > 0 ? fmtpParts.join(';') : undefined,
};
}
/**
* 把 mimeaudio/opus / video/VP8转成 SDP rtpmap 编码名
* SDP a=rtpmap 不接受 `video/` 前缀,要单独取编码名
*/
function mimeToCodecName(mime: string): string {
const idx = mime.indexOf('/');
return idx >= 0 ? mime.slice(idx + 1) : mime;
}
/**
* 生成 SDP 内容
*
* 注意 SDP 是从 ffmpeg 视角写的c= 行的地址、m= 行的端口都是 ffmpeg 监听处。
* mediasoup PlainTransport 之后 connect({ ip, port: ffmpegPort, rtcpPort }) 把流送过来。
*
* 一份 SDP 描述了一个会话里 1~N 条流(每条对应一个 producer 的 consumer
*/
function buildSdp(tracks: RecordingTrack[], localIp: string): string {
const lines: string[] = [
'v=0',
`o=- 0 0 IN IP4 ${localIp}`,
's=echochat-recording',
`c=IN IP4 ${localIp}`,
't=0 0',
];
for (const t of tracks) {
const codecName = mimeToCodecName(t.codecMime);
const rtpmapValue =
t.kind === 'audio'
? `${codecName}/${t.clockRate}/${t.channels ?? 2}`
: `${codecName}/${t.clockRate}`;
lines.push(`m=${t.kind} ${t.ffmpegRtpPort} RTP/AVP ${t.payloadType}`);
lines.push(`a=rtcp:${t.ffmpegRtcpPort}`);
lines.push(`a=rtpmap:${t.payloadType} ${rtpmapValue}`);
if (t.sdpFmtp) {
lines.push(`a=fmtp:${t.payloadType} ${t.sdpFmtp}`);
}
lines.push('a=recvonly');
}
// SDP 必须以 \r\n 分隔ffmpeg 解析行为更稳定
return lines.join('\r\n') + '\r\n';
}
/**
* 构造 ffmpeg 命令行参数
*
* -protocol_whitelist file,udp,rtp默认只允许 file需要显式开 udp/rtp
* -fflags +genpts从 RTP 时间戳生成 PTS
* -use_wallclock_as_timestamps 1避免 RTP 时间戳跳变导致 mp4 时长异常
* -c copy不重编码直接复用 RTP 载荷
* - 优点CPU 占用极低
* - 缺点:浏览器侧 VP8 进 mp4 容器在某些播放器不支持;后续可改 libx264
* -y覆盖已存在文件防止 recordingId 碰撞)
*/
function buildFfmpegArgs(sdpFilePath: string, outputPath: string): string[] {
return [
'-loglevel',
'info',
'-protocol_whitelist',
'file,udp,rtp',
'-fflags',
'+genpts',
'-use_wallclock_as_timestamps',
'1',
'-i',
sdpFilePath,
'-map',
'0:a?',
'-map',
'0:v?',
'-c',
'copy',
'-y',
outputPath,
];
}
// ============ 对外 API ============
export interface StartRecordingInput {
routerId: string;
roomCode: string;
/**
* 要录制的 producer ID 列表。
* 通常是 host 的 audio + video或者屏幕共享 video。
* Phase B1 阶段只录这些指定的 producer不混合所有成员后续 Phase B3 再扩展。
*/
producerIds: string[];
}
export interface StartRecordingOutput {
recordingId: string;
outputPath: string;
tracks: Array<{
producerId: string;
kind: 'audio' | 'video';
}>;
}
/**
* 启动录制
*
* 失败处理:
* - 任一步骤失败都会回滚已创建的资源(端口 / Transport / Consumer / ffmpeg 进程)
* - 不在 recordingMap 中留下"半态"条目,调用方可直接重试
*/
export async function startRecording(input: StartRecordingInput): Promise<StartRecordingOutput> {
const router = getRouter(input.routerId);
if (input.producerIds.length === 0) {
throw new AppError('CONFLICT', 'producerIds is empty');
}
await ensureOutputDir();
const id = recordingId();
const startedAt = Date.now();
const localIp = '127.0.0.1';
const outputPath = join(OUTPUT_DIR, `${id}.mp4`);
const sdpFilePath = join(OUTPUT_DIR, `${id}.sdp`);
// 已分配资源,用于失败时回滚
const allocatedPortPairs: Array<{ rtp: number; rtcp: number }> = [];
const createdTransports: PlainTransport[] = [];
const createdConsumers: Consumer[] = [];
let ffmpegProc: ChildProcess | null = null;
const rollback = async () => {
for (const c of createdConsumers) {
try {
c.close();
} catch {
/* ignored */
}
}
for (const t of createdTransports) {
try {
t.close();
} catch {
/* ignored */
}
}
for (const p of allocatedPortPairs) {
releasePortPair(p.rtp, p.rtcp);
}
if (ffmpegProc && ffmpegProc.exitCode === null) {
try {
ffmpegProc.kill('SIGKILL');
} catch {
/* ignored */
}
}
try {
await fs.unlink(sdpFilePath);
} catch {
/* ignored */
}
try {
await fs.unlink(outputPath);
} catch {
/* ignored */
}
};
try {
// ---------- Step 1: 预校验所有 producer 存在且可消费 ----------
// 不在这里建 ConsumerConsumer 必须依附 PlainTransport留到 Step 2 一起做
// 但 canConsume 失败要尽早抛错,避免分配资源后再失败导致回滚开销
const tracks: RecordingTrack[] = [];
for (const producerId of input.producerIds) {
// getProducer 不存在会抛 notFound等价于校验 producerId 合法
getProducer(producerId);
if (!router.canConsume({ producerId, rtpCapabilities: router.rtpCapabilities })) {
throw new AppError(
'CONFLICT',
`router cannot consume producer ${producerId} with its own rtpCapabilities`,
);
}
}
// ---------- Step 2: 为每个 producer 分配端口对 + 创建 PlainTransport ----------
for (const producerId of input.producerIds) {
const producer = getProducer(producerId);
const portPair = await allocatePortPair();
allocatedPortPairs.push(portPair);
const transport = await router.createPlainTransport({
listenIp: { ip: config.mediasoup.listenIp, announcedIp: undefined },
rtcpMux: false,
comedia: false,
enableSrtp: false,
appData: {
purpose: 'recording',
recordingId: id,
producerId,
},
});
createdTransports.push(transport);
// 创建 consumer必须传 router 自己的 rtpCapabilitiesconsumer 会与 producer 协商
const consumer = await transport.consume({
producerId,
rtpCapabilities: router.rtpCapabilities,
paused: true,
});
createdConsumers.push(consumer);
const codec = pickCodec(consumer.rtpParameters);
tracks.push({
producerId,
consumerId: consumer.id,
transportId: transport.id,
ffmpegRtpPort: portPair.rtp,
ffmpegRtcpPort: portPair.rtcp,
kind: producer.kind as 'audio' | 'video',
...codec,
});
}
// ---------- Step 3: 写 SDP 文件 ----------
const sdp = buildSdp(tracks, localIp);
await fs.writeFile(sdpFilePath, sdp, 'utf-8');
log.info({ recordingId: id, sdp }, 'SDP file generated');
// ---------- Step 4: spawn ffmpeg ----------
const ffmpegBin = process.env.FFMPEG_BIN || 'ffmpeg';
const args = buildFfmpegArgs(sdpFilePath, outputPath);
ffmpegProc = spawn(ffmpegBin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const exited = new Promise<void>((resolve) => {
ffmpegProc!.once('exit', (code, signal) => {
const entry = recordingMap.get(id);
if (entry) {
entry.exitCode = code;
if (!entry.stopping && code !== 0) {
entry.failureReason = `ffmpeg exited unexpectedly code=${code} signal=${signal ?? ''}`;
log.error(
{ recordingId: id, code, signal },
'ffmpeg exited unexpectedly before stop',
);
// Phase B watchdog异步触发失败回调webhook + 用户回调),不阻塞 exit 处理
fireFailureCallback(entry);
}
}
resolve();
});
});
ffmpegProc.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf-8');
// ffmpeg 默认大部分日志走 stderr只在 debug 级别落,避免日志爆炸
log.debug({ recordingId: id }, `[ffmpeg] ${text.trim()}`);
});
ffmpegProc.once('error', (err) => {
log.error({ recordingId: id, err: err.message }, 'ffmpeg spawn error');
});
// 等 ffmpeg 进入监听就绪:实践经验 200ms 足够 ffmpeg 解析 SDP + 绑定端口
// 这里加一个 250ms 的小延迟,避免过早 connect 导致前几个 RTP 包丢失
await new Promise((r) => setTimeout(r, 250));
if (ffmpegProc.exitCode !== null) {
throw new AppError(
'MEDIASOUP_ERROR',
`ffmpeg exited prematurely with code ${ffmpegProc.exitCode}`,
);
}
// ---------- Step 5: PlainTransport.connect 到 ffmpeg ----------
for (let i = 0; i < input.producerIds.length; i++) {
const transport = createdTransports[i];
const t = tracks[i];
await transport.connect({
ip: localIp,
port: t.ffmpegRtpPort,
rtcpPort: t.ffmpegRtcpPort,
});
}
// ---------- Step 6: resume consumersRTP 开始流向 ffmpeg ----------
for (const c of createdConsumers) {
await c.resume();
}
const entry: RecordingEntry = {
id,
roomCode: input.roomCode,
routerId: input.routerId,
startedAt,
tracks,
consumers: createdConsumers,
transports: createdTransports,
ffmpeg: ffmpegProc,
sdpFilePath,
outputPath,
ffmpegExited: exited,
stopping: false,
exitCode: null,
failureReason: null,
unexpectedExit: false,
};
recordingMap.set(id, entry);
log.info(
{
recordingId: id,
roomCode: input.roomCode,
producerCount: input.producerIds.length,
outputPath,
},
'recording started',
);
return {
recordingId: id,
outputPath,
tracks: tracks.map((t) => ({ producerId: t.producerId, kind: t.kind })),
};
} catch (err) {
log.error(
{ recordingId: id, err: err instanceof Error ? err.message : String(err) },
'startRecording failed, rolling back',
);
await rollback();
if (err instanceof AppError) throw err;
throw new AppError(
'MEDIASOUP_ERROR',
`startRecording failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
export interface StopRecordingOutput {
recordingId: string;
outputPath: string;
sizeBytes: number;
durationMs: number;
exitCode: number | null;
failureReason: string | null;
}
/**
* 停止录制
*
* 幂等:重复调用同一 recordingId 仅第一次执行清理,后续返回 notFound
*
* 时间线:
* - t=0: SIGINT ffmpeg
* - t≤5s: ffmpeg 优雅退出,写完 moov box
* - t>5s: 强制 SIGKILL文件可能损坏罕见记录 failureReason
* - 之后 close consumers/transports/释放端口
*/
export async function stopRecording(id: string): Promise<StopRecordingOutput> {
const entry = recordingMap.get(id);
if (!entry) {
throw notFound('recording', id);
}
if (entry.stopping) {
// 并发重复 stop返回当前快照避免重复 SIGINT
log.warn({ recordingId: id }, 'stopRecording called while already stopping');
}
entry.stopping = true;
// 1) SIGINT ffmpeg
let killed = false;
if (entry.ffmpeg.exitCode === null) {
try {
entry.ffmpeg.kill('SIGINT');
killed = true;
} catch (err) {
log.warn(
{ recordingId: id, err: err instanceof Error ? err.message : String(err) },
'failed to send SIGINT to ffmpeg',
);
}
}
// 2) 等 ffmpeg 退出 + 超时兜底
if (killed) {
const timeout = new Promise<void>((resolve) => {
setTimeout(() => {
if (entry.ffmpeg.exitCode === null) {
try {
entry.ffmpeg.kill('SIGKILL');
} catch {
/* ignored */
}
entry.failureReason = `ffmpeg did not exit within ${STOP_GRACE_MS}ms, forced SIGKILL`;
log.error({ recordingId: id }, entry.failureReason);
}
resolve();
}, STOP_GRACE_MS);
});
await Promise.race([entry.ffmpegExited, timeout]);
// race 后再 await exited 拿到最终退出码
await entry.ffmpegExited;
}
// 3) close consumers + transports
for (const c of entry.consumers) {
try {
c.close();
} catch {
/* ignored */
}
}
for (const t of entry.transports) {
try {
t.close();
} catch {
/* ignored */
}
}
// 4) 释放端口
for (const track of entry.tracks) {
releasePortPair(track.ffmpegRtpPort, track.ffmpegRtcpPort);
}
// 5) 删除 SDP 临时文件(保留 mp4
try {
await fs.unlink(entry.sdpFilePath);
} catch {
/* ignored */
}
// 6) stat 输出文件
let sizeBytes = 0;
try {
const st = await fs.stat(entry.outputPath);
sizeBytes = st.size;
} catch (err) {
if (!entry.failureReason) {
entry.failureReason = `output file missing: ${err instanceof Error ? err.message : String(err)}`;
}
}
const durationMs = Date.now() - entry.startedAt;
recordingMap.delete(id);
log.info(
{
recordingId: id,
durationMs,
sizeBytes,
exitCode: entry.exitCode,
failureReason: entry.failureReason,
},
'recording stopped',
);
return {
recordingId: id,
outputPath: entry.outputPath,
sizeBytes,
durationMs,
exitCode: entry.exitCode,
failureReason: entry.failureReason,
};
}
export interface RecordingInfo {
recordingId: string;
roomCode: string;
routerId: string;
startedAt: number;
durationMs: number;
outputPath: string;
trackCount: number;
ffmpegAlive: boolean;
}
export function getRecording(id: string): RecordingInfo {
const entry = recordingMap.get(id);
if (!entry) {
throw notFound('recording', id);
}
return {
recordingId: entry.id,
roomCode: entry.roomCode,
routerId: entry.routerId,
startedAt: entry.startedAt,
durationMs: Date.now() - entry.startedAt,
outputPath: entry.outputPath,
trackCount: entry.tracks.length,
ffmpegAlive: entry.ffmpeg.exitCode === null,
};
}
export function getRecordingStats(): { total: number } {
return { total: recordingMap.size };
}
/**
* 列出指定 roomCode 下的所有活跃录制(一般 ≤1
* 供 Go 后端 EndRoom / 资源清理时反查
*/
export function listRecordingsByRoom(roomCode: string): RecordingInfo[] {
const out: RecordingInfo[] = [];
for (const entry of recordingMap.values()) {
if (entry.roomCode === roomCode) {
out.push({
recordingId: entry.id,
roomCode: entry.roomCode,
routerId: entry.routerId,
startedAt: entry.startedAt,
durationMs: Date.now() - entry.startedAt,
outputPath: entry.outputPath,
trackCount: entry.tracks.length,
ffmpegAlive: entry.ffmpeg.exitCode === null,
});
}
}
return out;
}
/** 测试专用:复位 recordingMap停止所有进行中的录制 */
export async function _clearRecordings(): Promise<void> {
assertTestOnly('_clearRecordings');
const ids = [...recordingMap.keys()];
for (const id of ids) {
try {
await stopRecording(id);
} catch {
/* ignored */
}
}
recordingMap.clear();
}

View File

@@ -0,0 +1,112 @@
/**
* UDP 端口池:用于为录制 ffmpeg 子进程分配可用 RTP/RTCP 端口对
*
* 设计要点:
* - 端口范围与 mediasoup RTC 端口范围(默认 40000-40199必须不相交否则会与
* mediasoup 自身的 PlainTransport 本地绑定端口冲突
* - 通过 dgram.createSocket('udp4') 试探绑定来确认端口未被占用,不依赖 lsof / 系统接口
* - 一次申请通常成对返回 [rtp, rtcp],习惯上 rtp = even, rtcp = rtp+1RFC 3550 §11
* - 失败重试上限 ALLOC_RETRY超过则抛错此时通常意味着系统并发录制数过多
*
* 释放策略:
* - 调用方在录制 stop 时调 release(rtp, rtcp) 主动释放,避免端口长期被占
* - 若调用方忘记 release进程退出时端口自然回收OS 层),不会泄漏到下次启动
*
* 并发:
* - reservedPorts 是单进程内存集合,配合 dgram 试探绑定双重防御并发竞态
*/
import dgram from 'node:dgram';
import { childLogger } from './logger.js';
const log = childLogger({ module: 'utils.port-pool' });
const RECORDING_PORT_MIN = 50000;
const RECORDING_PORT_MAX = 59998;
const ALLOC_RETRY = 64;
const reservedPorts = new Set<number>();
function isInRange(port: number): boolean {
return port >= RECORDING_PORT_MIN && port <= RECORDING_PORT_MAX;
}
/**
* 试探指定端口是否可用:
* - 创建 UDP socket 尝试 bind
* - 成功立即 close 释放,返回 true
* - 失败EADDRINUSE / EACCES 等)返回 false
*/
function probeUdpPort(port: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = dgram.createSocket('udp4');
let resolved = false;
const finalize = (ok: boolean) => {
if (resolved) return;
resolved = true;
try {
socket.close();
} catch {
// already closed
}
resolve(ok);
};
socket.once('error', () => finalize(false));
try {
socket.bind(port, '0.0.0.0', () => finalize(true));
} catch {
finalize(false);
}
});
}
/**
* 分配一对 (rtp, rtcp) 端口rtp 为偶数rtcp = rtp + 1
*
* @returns { rtp, rtcp } 已通过试探确认空闲;调用方使用前不再二次试探
* 注意试探与实际使用之间存在 TOCTOU 窗口,调用方需对 ffmpeg listen 失败做兜底
* @throws 重试 ALLOC_RETRY 次仍失败时抛错
*/
export async function allocatePortPair(): Promise<{ rtp: number; rtcp: number }> {
for (let i = 0; i < ALLOC_RETRY; i++) {
// 在 [MIN, MAX-1] 内随机偶数;保证 +1 后 rtcp 仍 ≤ MAX+1
// RTP 端口必须偶数RTCP 必须 RTP+1mediasoup 默认行为)
const span = (RECORDING_PORT_MAX - RECORDING_PORT_MIN) >> 1;
const offset = Math.floor(Math.random() * span) * 2;
const rtp = RECORDING_PORT_MIN + offset;
const rtcp = rtp + 1;
if (!isInRange(rtp) || !isInRange(rtcp)) continue;
if (reservedPorts.has(rtp) || reservedPorts.has(rtcp)) continue;
// 双端口都试探
const [okRtp, okRtcp] = await Promise.all([probeUdpPort(rtp), probeUdpPort(rtcp)]);
if (!okRtp || !okRtcp) continue;
reservedPorts.add(rtp);
reservedPorts.add(rtcp);
return { rtp, rtcp };
}
throw new Error(`port-pool: failed to allocate UDP port pair after ${ALLOC_RETRY} retries`);
}
/**
* 释放端口对。幂等:未在 reservedPorts 中的端口忽略
*/
export function releasePortPair(rtp: number, rtcp: number): void {
reservedPorts.delete(rtp);
reservedPorts.delete(rtcp);
log.debug({ rtp, rtcp, reservedSize: reservedPorts.size }, 'port pair released');
}
export function getPortPoolStats(): { reserved: number; min: number; max: number } {
return {
reserved: reservedPorts.size,
min: RECORDING_PORT_MIN,
max: RECORDING_PORT_MAX,
};
}
/** 测试专用:复位端口集合 */
export function _clearPortPool(): void {
reservedPorts.clear();
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach } from 'vitest';
import dgram from 'node:dgram';
import {
allocatePortPair,
releasePortPair,
getPortPoolStats,
_clearPortPool,
} from '../../src/utils/port-pool.js';
describe('utils/port-pool', () => {
beforeEach(() => {
_clearPortPool();
});
it('allocates a pair where rtcp = rtp + 1 and rtp is even', async () => {
const { rtp, rtcp } = await allocatePortPair();
expect(rtcp).toBe(rtp + 1);
expect(rtp % 2).toBe(0);
expect(rtp).toBeGreaterThanOrEqual(50000);
expect(rtcp).toBeLessThanOrEqual(59999);
});
it('does not return the same pair twice while still reserved', async () => {
const seen = new Set<number>();
const pairs = await Promise.all(Array.from({ length: 8 }, () => allocatePortPair()));
for (const p of pairs) {
expect(seen.has(p.rtp)).toBe(false);
expect(seen.has(p.rtcp)).toBe(false);
seen.add(p.rtp);
seen.add(p.rtcp);
}
expect(getPortPoolStats().reserved).toBe(16);
});
it('release allows the pair to be re-issued', async () => {
const a = await allocatePortPair();
releasePortPair(a.rtp, a.rtcp);
expect(getPortPoolStats().reserved).toBe(0);
// 不一定立刻拿到完全相同的端口(随机分配),但能继续分配且不再含已释放的端口
const b = await allocatePortPair();
expect(b.rtp).not.toBe(undefined);
});
it('release is idempotent', () => {
releasePortPair(50000, 50001); // 未被分配过,应静默
expect(getPortPoolStats().reserved).toBe(0);
});
it('skips ports already bound by another socket', async () => {
// 强制占用一个偶数端口,确认 probe 会跳过它并最终返回别的端口
const occupiedRtp = 50100;
const blocker = dgram.createSocket('udp4');
await new Promise<void>((resolve, reject) => {
blocker.once('error', reject);
blocker.bind(occupiedRtp, '0.0.0.0', () => resolve());
});
try {
// 多次分配,确保命中冲突路径
const results = await Promise.all(Array.from({ length: 6 }, () => allocatePortPair()));
for (const r of results) {
expect(r.rtp).not.toBe(occupiedRtp);
}
} finally {
blocker.close();
}
});
});