视频会议保存
This commit is contained in:
56
admin/src/api/meeting.js
Normal file
56
admin/src/api/meeting.js
Normal 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'
|
||||
})
|
||||
}
|
||||
@@ -74,6 +74,12 @@ const routes = [
|
||||
name: 'MessageStats',
|
||||
component: () => import('@/views/message/stats.vue'),
|
||||
meta: { title: '消息统计' }
|
||||
},
|
||||
{
|
||||
path: 'meeting/list',
|
||||
name: 'MeetingList',
|
||||
component: () => import('@/views/meeting/list.vue'),
|
||||
meta: { title: '会议记录' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@
|
||||
<el-menu-item index="/message/stats">消息统计</el-menu-item>
|
||||
</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-icon><Monitor /></el-icon>
|
||||
<template #title>在线监控</template>
|
||||
|
||||
654
admin/src/views/meeting/list.vue
Normal file
654
admin/src/views/meeting/list.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
286
backend/go-service/app/admin/dao/meeting_manage_dao.go
Normal file
286
backend/go-service/app/admin/dao/meeting_manage_dao.go
Normal 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,不另起 DTO;service 层再投影成 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
|
||||
}
|
||||
@@ -23,4 +23,8 @@ var AdminSet = wire.NewSet(
|
||||
dao.NewMessageManageDAO,
|
||||
service.NewMessageManageService,
|
||||
controller.NewMessageManageController,
|
||||
// Phase B:会议管理(与录制审计共用一套接口)
|
||||
dao.NewMeetingManageDAO,
|
||||
service.NewMeetingManageService,
|
||||
controller.NewMeetingManageController,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ func RegisterRoutes(
|
||||
contactManageCtrl *controller.ContactManageController,
|
||||
groupManageCtrl *controller.GroupManageController,
|
||||
msgManageCtrl *controller.MessageManageController,
|
||||
meetingManageCtrl *controller.MeetingManageController,
|
||||
jwtAuth gin.HandlerFunc,
|
||||
) {
|
||||
// 管理端路由组:JWT 认证 + admin/super_admin 角色检查
|
||||
@@ -52,5 +53,11 @@ func RegisterRoutes(
|
||||
adminGroup.GET("/messages/:id", msgManageCtrl.GetMessageDetail)
|
||||
adminGroup.DELETE("/messages/:id", msgManageCtrl.DeleteMessage)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
268
backend/go-service/app/admin/service/meeting_manage_service.go
Normal file
268
backend/go-service/app/admin/service/meeting_manage_service.go
Normal 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 转成普通 string;nil 返回空串
|
||||
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_reason;recording 状态下 FailureReason 字段被复用为本地路径,不应外泄
|
||||
if r.Status == meetingModel.MeetingRecordingStatusFailed {
|
||||
out.FailureReason = r.FailureReason
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -129,6 +129,24 @@ const (
|
||||
|
||||
// ====== 补充事件(非 §6.3 核心 11 事件,但业务必须)======
|
||||
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)+ 文件 URL(ready 时),
|
||||
// 便于前端立即跳转到回放或提示失败
|
||||
MeetingWSEventRecordingStarted = "meeting.recording.started" // S→C:录制开始
|
||||
MeetingWSEventRecordingStopped = "meeting.recording.stopped" // S→C:录制结束
|
||||
)
|
||||
|
||||
// MeetingWSClientEvents 客户端可主动发起的 WS 事件(C→S)白名单
|
||||
|
||||
@@ -140,3 +140,99 @@ type ActiveGroupItem struct {
|
||||
Name string `json:"name"` // 群名称
|
||||
Count int64 `json:"count"` // 消息数
|
||||
}
|
||||
|
||||
// ====== 管理端会议管理 DTO(Phase B 扩展) ======
|
||||
|
||||
// AdminMeetingListRequest 管理端会议列表查询请求
|
||||
//
|
||||
// 与消息列表保持一致的分页/筛选风格:
|
||||
// - Keyword:模糊匹配 title 或 room_code,方便客服按客户描述定位
|
||||
// - Status:0=未开始 / 1=进行中 / 2=已结束(与 meeting_rooms.status 一一对应)
|
||||
// - HasRecording:true=仅返回有录制记录的会议,便于"录制审计"场景
|
||||
// - 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"` // 录制总数
|
||||
}
|
||||
|
||||
@@ -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 Webhook(Phase B watchdog) ====================
|
||||
|
||||
// mediaServerFailurePayload media-server 上报的失败 JSON 体
|
||||
// 字段与 media-server/src/services/recording.service.ts::RecordingFailureCallback 对应
|
||||
type mediaServerFailurePayload struct {
|
||||
RecordingID string `json:"recordingId"` // media-server 端的 remoteId(UUID 字符串),非 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.recordingId(media-server remoteId)→ 反查 Go 端 meeting_recordings 表
|
||||
// - 委托给 MeetingRecordingService.HandleMediaServerFailure 落 failed + 广播
|
||||
// - 始终返回 200(webhook 语义:吞错避免 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)持有的是 remoteId(UUID 字符串),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})
|
||||
}
|
||||
174
backend/go-service/app/meeting/dao/meeting_recording_dao.go
Normal file
174
backend/go-service/app/meeting/dao/meeting_recording_dao.go
Normal 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 构造 DAO(Wire 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
|
||||
}
|
||||
44
backend/go-service/app/meeting/model/meeting_recording.go
Normal file
44
backend/go-service/app/meeting/model/meeting_recording.go
Normal 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 内部的 recordingId(hex32),Go 侧 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"
|
||||
)
|
||||
@@ -20,11 +20,14 @@ var MeetingSet = wire.NewSet(
|
||||
dao.NewMeetingRoomDAO,
|
||||
dao.NewMeetingParticipantDAO,
|
||||
dao.NewMeetingChatDAO,
|
||||
dao.NewMeetingRecordingDAO, // Phase B 新增:录制记录 DAO
|
||||
service.NewMeetingBroadcaster,
|
||||
service.NewMeetingLifecycleService, // Task 8 新增:会议生命周期状态机
|
||||
service.NewMeetingService,
|
||||
service.NewMeetingSignalService,
|
||||
service.NewMeetingRecordingService, // Phase B 新增:录制业务服务
|
||||
controller.NewMeetingController,
|
||||
controller.NewMeetingRecordingController, // Phase B 新增:录制 REST 控制器
|
||||
controller.NewMeetingWSHandler,
|
||||
task.NewMeetingCleanupTask, // Task 8 新增:生命周期兜底定时任务
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ import (
|
||||
// RegisterRoutes 注册 meeting 模块的全部前台路由
|
||||
// 所有接口统一挂载在 /api/v1/meeting/* 前缀下,均需 JWT 认证
|
||||
// 设计文档 §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.Use(jwtAuth)
|
||||
{
|
||||
@@ -29,5 +35,15 @@ func RegisterRoutes(r *gin.Engine, ctrl *controller.MeetingController, jwtAuth g
|
||||
|
||||
authed.POST("/rooms/:code/chats", ctrl.SendChat)
|
||||
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)
|
||||
}
|
||||
|
||||
// 内部 webhook(Phase B watchdog):media-server 异常退出回调
|
||||
// 鉴权由 controller 内部用 X-Internal-Secret header + INTERNAL_WEBHOOK_SECRET 校验
|
||||
// 不走 JWT,因为调用方是 media-server 而非用户
|
||||
r.POST("/internal/meeting/recordings/failure", recordingCtrl.MediaServerFailureWebhook)
|
||||
}
|
||||
|
||||
@@ -314,14 +314,26 @@ func (h *HTTPMediaOrchestrator) CloseTransport(ctx context.Context, transportID
|
||||
func (h *HTTPMediaOrchestrator) CreateProducer(ctx context.Context, req *CreateProducerReq) (string, error) {
|
||||
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{
|
||||
"transportId": req.TransportID,
|
||||
"kind": req.Kind,
|
||||
"rtpParameters": req.RtpParameters,
|
||||
"appData": map[string]any{
|
||||
"userId": req.UserID,
|
||||
"roomCode": req.RoomCode,
|
||||
},
|
||||
"appData": appData,
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
@@ -423,6 +435,67 @@ func (h *HTTPMediaOrchestrator) CloseConsumer(ctx context.Context, consumerID st
|
||||
return err
|
||||
}
|
||||
|
||||
// StartRecording 调用 POST /internal/v1/recordings(Phase B 引入)
|
||||
//
|
||||
// 注意:录制启动会触发 ffmpeg spawn + 端口分配 + 250ms 等待启动,正常耗时约 500ms~1.5s
|
||||
// 因此走 TimeoutMS(默认 10s)而非 CloseTimeoutMS。失败不重试,避免重复 spawn ffmpeg
|
||||
//
|
||||
// 错误映射:
|
||||
// - Node 404(router 不存在)→ ErrMediaResourceNotFound
|
||||
// - Node 409(producer 不存在/不可消费)→ 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/:id(Phase B 引入)
|
||||
//
|
||||
// 时间预算:ffmpeg 优雅退出最多 STOP_GRACE_MS=5s + media-server 自身处理 ~200ms
|
||||
// 因此 timeout 需要明显 > 5s;用 TimeoutMS(默认 10s)足够
|
||||
//
|
||||
// 404(recording 不存在 / 已停止)映射为 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
|
||||
|
||||
@@ -67,6 +67,10 @@ type CreateProducerReq struct {
|
||||
TransportID string `json:"transportId"`
|
||||
Kind string `json:"kind"` // "audio" | "video"
|
||||
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 请求
|
||||
@@ -78,6 +82,38 @@ type CreateConsumerReq struct {
|
||||
RtpCapabilities json.RawMessage `json:"rtpCapabilities"`
|
||||
}
|
||||
|
||||
// ====== 会议录制 RPC(Phase 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)
|
||||
// Task 7 (2026-04-21) 起默认实现为 HTTPMediaOrchestrator(通过 X-Internal-Token
|
||||
// 调用 Node media-server 的 /internal/v1/* REST API)。
|
||||
@@ -122,6 +158,16 @@ type MediaOrchestrator interface {
|
||||
ResumeConsumer(ctx context.Context, consumerID string) error
|
||||
// CloseConsumer 关闭指定 Consumer(幂等)
|
||||
CloseConsumer(ctx context.Context, consumerID string) error
|
||||
|
||||
// ====== 录制(Phase B 引入)======
|
||||
|
||||
// StartRecording 在 media-server 上启动一份会议录制
|
||||
// 失败语义:404 RouterNotFound / 409 producerId 不可消费等返回 ErrMediaServerError
|
||||
// 成功返回 recordingId(hex32)+ 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 后默认不再使用)
|
||||
@@ -205,3 +251,18 @@ func (n *NoopMediaOrchestrator) ResumeConsumer(_ context.Context, _ string) erro
|
||||
func (n *NoopMediaOrchestrator) CloseConsumer(_ context.Context, _ string) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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(与图片/语音同 bucket,public-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 字段暂存 outputPath,stop 阶段读出来上传 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 MarkUploading(status: recording → uploading),同时清空临时 FailureReason
|
||||
// 4. 上传本地 mp4 到 MinIO(recordings/{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 stop(404 视为已停止 → 用本地 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,
|
||||
})
|
||||
}
|
||||
@@ -80,6 +80,22 @@ type MeetingService struct {
|
||||
onlineChecker OnlineChecker
|
||||
mediaOrchestrator MediaOrchestrator
|
||||
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 实例
|
||||
@@ -591,6 +607,12 @@ func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string)
|
||||
_ = 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 {
|
||||
logs.Warn(ctx, funcName, "关闭 mediasoup Router 失败", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -69,6 +69,39 @@ func memberStateKey(roomCode string, userID int64) string {
|
||||
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
|
||||
// 设计:会议期间维持可达即可;若用户长期不活跃由断线清理接管
|
||||
// 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) {
|
||||
s.pushExistingProducers(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 列表
|
||||
@@ -384,7 +454,7 @@ func (s *MeetingSignalService) OnWSDisconnect(ctx context.Context, userID int64)
|
||||
return
|
||||
}
|
||||
|
||||
s.cleanupUserResources(ctx, room.RoomCode, userID)
|
||||
s.cleanupUserResources(ctx, room.ID, room.RoomCode, userID)
|
||||
|
||||
if s.lifecycleSvc != nil && room.HostID == 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 {
|
||||
return err
|
||||
}
|
||||
s.cleanupUserResources(ctx, roomCode, userID)
|
||||
s.cleanupUserResources(ctx, room.ID, roomCode, userID)
|
||||
|
||||
// P2-8 修复:使用常量 MeetingLeftReasonDisconnect,避免 "ws_disconnect" 等硬编码
|
||||
// 与前端 MEETING_LEFT_REASON_LABEL 字面值不一致
|
||||
@@ -524,6 +594,36 @@ type ProduceStartPayload struct {
|
||||
TransportID string `json:"transport_id"`
|
||||
Kind string `json:"kind"` // "audio" | "video"
|
||||
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
|
||||
@@ -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 {
|
||||
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{
|
||||
RoomCode: payload.RoomCode,
|
||||
UserID: userID,
|
||||
TransportID: payload.TransportID,
|
||||
Kind: payload.Kind,
|
||||
RtpParameters: payload.RtpParameters,
|
||||
AppData: payload.AppData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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{}{
|
||||
"room_code": payload.RoomCode,
|
||||
"user_id": userID,
|
||||
"producer_id": producerID,
|
||||
"kind": payload.Kind,
|
||||
"screen": isScreen,
|
||||
}, 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
|
||||
}
|
||||
|
||||
@@ -653,6 +789,9 @@ func (s *MeetingSignalService) OnProducerClose(ctx context.Context, userID int64
|
||||
}
|
||||
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{}{
|
||||
"room_code": payload.RoomCode,
|
||||
"user_id": userID,
|
||||
@@ -662,11 +801,57 @@ func (s *MeetingSignalService) OnProducerClose(ctx context.Context, userID int64
|
||||
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 必然等于 userID(trackResource 已校验归属)
|
||||
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 批量关闭指定用户在某会议的所有媒体资源
|
||||
// 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"
|
||||
|
||||
key := resourceTrackKey(roomCode, userID)
|
||||
@@ -687,6 +872,11 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod
|
||||
switch kind {
|
||||
case "producer":
|
||||
_ = 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":
|
||||
_ = s.mediaOrchestrator.CloseConsumer(ctx, id)
|
||||
case "transport":
|
||||
@@ -714,7 +904,14 @@ func (s *MeetingSignalService) cleanupUserResources(ctx context.Context, roomCod
|
||||
// - package 私有:允许 meeting_service / meeting_lifecycle_service 等同包文件直接调用
|
||||
func cleanupRoomRedisResidual(ctx context.Context, rdb *redis.Client, roomCode string, userIDs []int64) {
|
||||
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
|
||||
}
|
||||
pipe := rdb.Pipeline()
|
||||
|
||||
@@ -42,6 +42,7 @@ type App struct {
|
||||
ContactManageController *adminController.ContactManageController // 管理端好友关系管理控制器
|
||||
GroupManageController *adminController.GroupManageController // 管理端群组管理控制器
|
||||
MessageManageController *adminController.MessageManageController // 管理端消息管理控制器
|
||||
MeetingManageController *adminController.MeetingManageController // 管理端会议管理控制器(Phase B)
|
||||
WSHandler *wsApp.Handler // WebSocket 连接处理器
|
||||
Hub *ws.Hub // WebSocket Hub 连接管理
|
||||
PubSub *ws.PubSub // Redis Pub/Sub 消息路由
|
||||
@@ -59,6 +60,8 @@ type App struct {
|
||||
MeetingSignalService *meetingService.MeetingSignalService // 会议 WS 信令业务服务(Task 6 落地)
|
||||
MeetingLifecycleSvc *meetingService.MeetingLifecycleService // 会议生命周期状态机(Task 8 落地)
|
||||
MeetingController *meetingController.MeetingController // 会议 REST 控制器
|
||||
MeetingRecordingController *meetingController.MeetingRecordingController // 会议录制 REST 控制器(Phase B 新增)
|
||||
MeetingRecordingService *meetingService.MeetingRecordingService // 会议录制业务服务(Phase B 新增)
|
||||
MeetingWSHandler *meetingController.MeetingWSHandler // 会议 WS 事件 Handler(构造时自动注册路由到 Hub)
|
||||
MeetingCleanupTask *meetingTask.MeetingCleanupTask // 会议生命周期兜底定时任务(Task 8 落地)
|
||||
}
|
||||
@@ -77,6 +80,7 @@ func NewApp(
|
||||
contactManageCtrl *adminController.ContactManageController,
|
||||
groupManageCtrl *adminController.GroupManageController,
|
||||
msgManageCtrl *adminController.MessageManageController,
|
||||
meetingManageCtrl *adminController.MeetingManageController,
|
||||
wsHandler *wsApp.Handler,
|
||||
hub *ws.Hub,
|
||||
pubsub *ws.PubSub,
|
||||
@@ -94,12 +98,16 @@ func NewApp(
|
||||
meetingSignalSvc *meetingService.MeetingSignalService,
|
||||
meetingLifecycleSvc *meetingService.MeetingLifecycleService,
|
||||
meetingCtrl *meetingController.MeetingController,
|
||||
meetingRecordingCtrl *meetingController.MeetingRecordingController,
|
||||
meetingRecordingSvc *meetingService.MeetingRecordingService,
|
||||
meetingWSHandler *meetingController.MeetingWSHandler,
|
||||
meetingCleanup *meetingTask.MeetingCleanupTask,
|
||||
) *App {
|
||||
wsHandler.SetOfflinePusher(offlinePusher)
|
||||
wsHandler.SetNotifyConnectHook(notifySvc)
|
||||
wsHandler.SetMeetingDisconnectHook(meetingSignalSvc)
|
||||
// Phase B:通过 setter 注入录制停止钩子,避免 MeetingService ↔ MeetingRecordingService 构造期循环依赖
|
||||
meetingSvc.SetRecordingStopper(meetingRecordingSvc)
|
||||
|
||||
return &App{
|
||||
Config: cfg,
|
||||
@@ -114,6 +122,7 @@ func NewApp(
|
||||
ContactManageController: contactManageCtrl,
|
||||
GroupManageController: groupManageCtrl,
|
||||
MessageManageController: msgManageCtrl,
|
||||
MeetingManageController: meetingManageCtrl,
|
||||
WSHandler: wsHandler,
|
||||
Hub: hub,
|
||||
PubSub: pubsub,
|
||||
@@ -131,6 +140,8 @@ func NewApp(
|
||||
MeetingSignalService: meetingSignalSvc,
|
||||
MeetingLifecycleSvc: meetingLifecycleSvc,
|
||||
MeetingController: meetingCtrl,
|
||||
MeetingRecordingController: meetingRecordingCtrl,
|
||||
MeetingRecordingService: meetingRecordingSvc,
|
||||
MeetingWSHandler: meetingWSHandler,
|
||||
MeetingCleanupTask: meetingCleanup,
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
||||
conversationDAO := im.ProvideConversationDAO(gormDB)
|
||||
messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub)
|
||||
messageManageController := controller2.NewMessageManageController(messageManageService)
|
||||
meetingManageDAO := dao2.NewMeetingManageDAO(gormDB)
|
||||
meetingManageService := service2.NewMeetingManageService(meetingManageDAO)
|
||||
meetingManageController := controller2.NewMeetingManageController(meetingManageService)
|
||||
serverConfig := provideServerConfig(cfg)
|
||||
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, serverConfig, onlineService, authService)
|
||||
friendGroupDAO := dao3.NewFriendGroupDAO(gormDB)
|
||||
@@ -103,14 +106,17 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
||||
meetingRoomDAO := dao6.NewMeetingRoomDAO(gormDB)
|
||||
meetingParticipantDAO := dao6.NewMeetingParticipantDAO(gormDB)
|
||||
meetingChatDAO := dao6.NewMeetingChatDAO(gormDB)
|
||||
meetingRecordingDAO := dao6.NewMeetingRecordingDAO(gormDB)
|
||||
meetingBroadcaster := service7.NewMeetingBroadcaster(meetingParticipantDAO, pubSub)
|
||||
httpMediaOrchestrator := service7.NewHTTPMediaOrchestrator(cfg)
|
||||
meetingLifecycleService := service7.NewMeetingLifecycleService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, cfg)
|
||||
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, 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)
|
||||
meetingRecordingController := controller7.NewMeetingRecordingController(meetingRecordingService)
|
||||
meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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/config"
|
||||
"github.com/echochat/backend/pkg/logs"
|
||||
@@ -56,6 +57,8 @@ func main() {
|
||||
&model.Conversation{},
|
||||
&model.ConversationMember{},
|
||||
&model.Message{},
|
||||
// Phase B 新增:会议录制元数据表
|
||||
&meetingModel.MeetingRecording{},
|
||||
); err != nil {
|
||||
logs.Fatal(ctx, "main", "IM 表迁移失败", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ func Setup(engine *gin.Engine, app *provider.App) {
|
||||
|
||||
// --- 各模块路由注册 ---
|
||||
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)
|
||||
contact.RegisterRoutes(engine, app.ContactController, jwtAuth)
|
||||
imApp.RegisterRoutes(engine, app.IMController, jwtAuth)
|
||||
fileApp.RegisterRoutes(engine, app.FileController, jwtAuth)
|
||||
groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth)
|
||||
notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth)
|
||||
meetingApp.RegisterRoutes(engine, app.MeetingController, jwtAuth)
|
||||
meetingApp.RegisterRoutes(engine, app.MeetingController, app.MeetingRecordingController, jwtAuth)
|
||||
}
|
||||
|
||||
107
docs/plans/2026-05-18-recording-progress.md
Normal file
107
docs/plans/2026-05-18-recording-progress.md
Normal 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 建 PlainTransport(rtcpMux: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}/finalize(callback)
|
||||
└─ 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
|
||||
@@ -9,7 +9,7 @@
|
||||
* - 响应中的 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 },
|
||||
@@ -145,6 +145,36 @@ const listChats = (roomCode, 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 {
|
||||
createRoom,
|
||||
getRoom,
|
||||
@@ -157,5 +187,8 @@ export default {
|
||||
inviteUsers,
|
||||
redeemInvite,
|
||||
sendChat,
|
||||
listChats
|
||||
listChats,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
listRecordings
|
||||
}
|
||||
|
||||
@@ -45,6 +45,54 @@
|
||||
<text class="label">{{ videoEnabled ? '停止视频' : '开启视频' }}</text>
|
||||
</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')">
|
||||
<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">
|
||||
@@ -102,15 +150,31 @@ const props = defineProps({
|
||||
unreadChatCount: { type: Number, default: 0 },
|
||||
allowChat: { type: Boolean, default: true },
|
||||
/** 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 态,避免重复点击 */
|
||||
const emitIf = (name) => {
|
||||
if (name === 'mic-toggle' && props.audioLoading) 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)
|
||||
}
|
||||
</script>
|
||||
@@ -191,6 +255,16 @@ const emitIf = (name) => {
|
||||
.btn-leave .icon { background: #EF4444; }
|
||||
.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 {
|
||||
position: absolute;
|
||||
top: -6rpx;
|
||||
|
||||
@@ -121,6 +121,27 @@ export const MEETING_WS_PRODUCER_CLOSE = 'meeting.producer.close' // C→S
|
||||
// 聊天(1 个)
|
||||
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 客户端可主动发起的事件白名单 */
|
||||
export const MEETING_WS_CLIENT_EVENTS = [
|
||||
MEETING_WS_ROOM_JOIN,
|
||||
@@ -143,7 +164,11 @@ export const MEETING_WS_SERVER_EVENTS = [
|
||||
MEETING_WS_MEMBER_KICKED,
|
||||
MEETING_WS_MEMBER_PRODUCER_NEW,
|
||||
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 无关) ====================
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/sso",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
@@ -173,6 +179,12 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/recordings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议录制"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
|
||||
71
frontend/src/pages/auth/sso.vue
Normal file
71
frontend/src/pages/auth/sso.vue
Normal 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,写入 storage(saveToken + 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>
|
||||
271
frontend/src/pages/meeting/recordings.vue
Normal file
271
frontend/src/pages/meeting/recordings.vue
Normal 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>
|
||||
@@ -29,6 +29,16 @@
|
||||
<view class="state-dot"></view>
|
||||
<text>{{ stateText }}</text>
|
||||
</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" />
|
||||
<text class="timer">{{ durationText }}</text>
|
||||
</view>
|
||||
@@ -36,8 +46,41 @@
|
||||
|
||||
<!-- 主区:左视频 + 右侧聊天面板(仅 chatVisible 时侧栏展开) -->
|
||||
<view class="body">
|
||||
<view ref="videoCol" class="video-col">
|
||||
<VideoGrid :tiles="gridTiles">
|
||||
<view ref="videoCol" class="video-col" :class="{ 'has-screen': !!screenTile }">
|
||||
<!-- 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 }">
|
||||
<VideoTile
|
||||
:user-id="tile.userId"
|
||||
@@ -94,8 +137,17 @@
|
||||
:unread-chat-count="unreadChatCount"
|
||||
:allow-chat="allowChat"
|
||||
: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"
|
||||
@cam-toggle="onCamToggle"
|
||||
@screen-toggle="onScreenToggle"
|
||||
@recording-toggle="onRecordingToggle"
|
||||
@invite="openInvite"
|
||||
@members="openMembers"
|
||||
@chat="openChat"
|
||||
@@ -157,7 +209,12 @@ import {
|
||||
MEETING_LOCAL_STATE_RECONNECTING,
|
||||
MEETING_LOCAL_STATE_LEAVING,
|
||||
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'
|
||||
|
||||
import VideoGrid from '@/components/meeting/VideoGrid.vue'
|
||||
@@ -178,6 +235,7 @@ const chatVisible = ref(false)
|
||||
const leaveDialogVisible = ref(false)
|
||||
const audioLoading = ref(false)
|
||||
const videoLoading = ref(false)
|
||||
const screenLoading = ref(false)
|
||||
const chatLoadingMore = ref(false)
|
||||
const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16 进一步收敛)
|
||||
const startedAt = ref(0)
|
||||
@@ -289,18 +347,94 @@ const durationText = computed(() => {
|
||||
const audioEnabled = computed(() => meetingStore.localAudioEnabled)
|
||||
const videoEnabled = computed(() => meetingStore.localVideoEnabled)
|
||||
|
||||
/** 监听 store.getLocalTrack 的变化:localProducers 变化即 bump 版本 */
|
||||
const localTrackVersion = computed(() => {
|
||||
return `${meetingStore.localProducers.audio || ''}-${meetingStore.localProducers.video || ''}`
|
||||
// ============ Phase 3 屏幕共享 ============
|
||||
|
||||
/** 当前会议是否已有屏幕共享(任意成员) */
|
||||
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 parts = []
|
||||
const map = meetingStore.remoteConsumers
|
||||
Object.keys(map).forEach((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('|')
|
||||
})
|
||||
@@ -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)
|
||||
/** 录制按钮 loading:REST 调用期间或后端异步上传期间均禁用 */
|
||||
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 openMembers = () => { memberVisible.value = !memberVisible.value }
|
||||
const openChat = () => {
|
||||
@@ -733,6 +937,40 @@ onUnload(() => {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -748,6 +986,46 @@ onUnload(() => {
|
||||
display: flex;
|
||||
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 {
|
||||
width: 0;
|
||||
min-height: 0;
|
||||
|
||||
@@ -46,6 +46,14 @@ import {
|
||||
MEETING_WS_MEMBER_PRODUCER_NEW,
|
||||
MEETING_WS_HOST_CHANGED,
|
||||
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_ENDED_REASON_LABEL,
|
||||
MEETING_ENDED_REASON_KICKED,
|
||||
@@ -103,10 +111,57 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
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.started(existing=true)
|
||||
*/
|
||||
const screenShare = reactive({ ownerUserId: null, producerId: null })
|
||||
|
||||
/**
|
||||
* 会议录制状态(Phase B)
|
||||
*
|
||||
* 字段:
|
||||
* - id 后端 meeting_recordings.id;null 表示当前无录制
|
||||
* - 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 } }
|
||||
@@ -254,6 +309,10 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
localVideoEnabled.value = false
|
||||
localProducers.audio = null
|
||||
localProducers.video = null
|
||||
localProducers.screen = null
|
||||
screenShare.ownerUserId = null
|
||||
screenShare.producerId = null
|
||||
_resetRecordingState()
|
||||
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
|
||||
_engine = null
|
||||
}
|
||||
@@ -269,6 +328,10 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
[MEETING_WS_MEMBER_PRODUCER_NEW]: _onProducerNew,
|
||||
[MEETING_WS_HOST_CHANGED]: _onHostChanged,
|
||||
[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 绑定 → 广播丢失"
|
||||
_connected: _onWsReconnected,
|
||||
_disconnected: _onWsDisconnected
|
||||
@@ -299,6 +362,10 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
// 表现为"重新发起会议后看不到自己的画面"
|
||||
localProducers.audio = null
|
||||
localProducers.video = null
|
||||
localProducers.screen = null
|
||||
screenShare.ownerUserId = null
|
||||
screenShare.producerId = null
|
||||
_resetRecordingState()
|
||||
localAudioEnabled.value = false
|
||||
localVideoEnabled.value = false
|
||||
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
|
||||
@@ -446,16 +513,20 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
try {
|
||||
const consumer = await _engine.consume({ producerId: data.producer_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 代理
|
||||
remoteConsumers[data.user_id] = {
|
||||
audio: null,
|
||||
video: null,
|
||||
screen: null,
|
||||
producerIds: markRaw(new Set())
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
// track 已在 consumer.track 中,调用方(页面)可从 store 拿 consumer.track 挂到 DOM
|
||||
@@ -466,6 +537,116 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到 meeting.screen.started 广播 → 更新 screenShare 状态
|
||||
*
|
||||
* 数据驱动而非直接操作 consumer:consumer 已经由 _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 data = msg.data || {}
|
||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||
@@ -563,7 +744,8 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot) return
|
||||
slot.producerIds.delete(producerId)
|
||||
;['audio', 'video'].forEach(kind => {
|
||||
// Phase 3:新增 screen 槽位一并扫描,关闭归属 producerId 的那一个
|
||||
;['audio', 'video', 'screen'].forEach(kind => {
|
||||
const consumer = slot[kind]
|
||||
if (!consumer) return
|
||||
if (consumer.producerId && consumer.producerId !== producerId) return
|
||||
@@ -572,7 +754,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
}
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -580,12 +762,18 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
const _cleanupRemoteUser = (userId) => {
|
||||
const slot = remoteConsumers[userId]
|
||||
if (!slot) return
|
||||
;['audio', 'video'].forEach(kind => {
|
||||
;['audio', 'video', 'screen'].forEach(kind => {
|
||||
if (slot[kind] && !slot[kind].closed) {
|
||||
try { slot[kind].close() } catch {}
|
||||
}
|
||||
})
|
||||
delete remoteConsumers[userId]
|
||||
// Phase 3:若离开者恰好是当前屏幕共享者,本地兜底清空 screenShare
|
||||
// (正常路径下后端的 cleanupUserResources 会广播 screen.stopped,此处只是 race 防御)
|
||||
if (screenShare.ownerUserId === userId) {
|
||||
screenShare.ownerUserId = null
|
||||
screenShare.producerId = null
|
||||
}
|
||||
}
|
||||
|
||||
const _cleanupMedia = () => {
|
||||
@@ -1039,6 +1227,153 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
_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 而不是 producer:producer 的 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 Producer(MediaEngine 内部触发 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 录制 actions(Phase 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:向房间其他成员广播自身音视频状态
|
||||
* - 后端 meeting.member.state.changed 语义:操作自己无需 target_user_id
|
||||
@@ -1099,7 +1434,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
/**
|
||||
* 取得某个远端用户的 track(供页面挂到 <video>/<audio>)
|
||||
* @param {number} userId
|
||||
* @param {'audio'|'video'} kind
|
||||
* @param {'audio'|'video'|'screen'} kind - Phase 3 起新增 'screen' 槽位
|
||||
* @returns {MediaStreamTrack|null}
|
||||
*/
|
||||
const getRemoteTrack = (userId, kind) => {
|
||||
@@ -1243,6 +1578,7 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
localAudioEnabled,
|
||||
localVideoEnabled,
|
||||
localProducers,
|
||||
screenShare,
|
||||
remoteConsumers,
|
||||
draftCreatePayload,
|
||||
draftJoinPayload,
|
||||
@@ -1269,6 +1605,11 @@ export const useMeetingStore = defineStore('meeting', () => {
|
||||
startLocalVideo,
|
||||
stopLocalAudio,
|
||||
stopLocalVideo,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
recording,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
getRemoteTrack,
|
||||
getLocalTrack,
|
||||
|
||||
|
||||
@@ -41,9 +41,9 @@ ENV NODE_ENV=production \
|
||||
# mediasoup-worker 是原生二进制,运行时需要 libstdc++ 等基础库,slim 默认已包含
|
||||
# 健康检查需要 curl
|
||||
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/* \
|
||||
&& useradd --system --create-home --shell /usr/sbin/nologin mediasoup
|
||||
&& useradd --system --create-home --shell /usr/sbin/nologin mediasoupv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { internalAuthPlugin } from './middlewares/internal-auth.js';
|
||||
import { closeWorker, getWorkerSnapshot, startWorker } from './mediasoup/worker.js';
|
||||
import { consumerRoutes } from './routes/consumer.route.js';
|
||||
import { producerRoutes } from './routes/producer.route.js';
|
||||
import { recordingRoutes } from './routes/recording.route.js';
|
||||
import { routerRoutes } from './routes/router.route.js';
|
||||
import { transportRoutes } from './routes/transport.route.js';
|
||||
import { getRouterStats } from './services/router.service.js';
|
||||
@@ -78,6 +79,7 @@ export async function buildApp() {
|
||||
await scope.register(transportRoutes);
|
||||
await scope.register(producerRoutes);
|
||||
await scope.register(consumerRoutes);
|
||||
await scope.register(recordingRoutes);
|
||||
},
|
||||
{ prefix: '/internal/v1' },
|
||||
);
|
||||
|
||||
44
media-server/src/routes/recording.route.ts
Normal file
44
media-server/src/routes/recording.route.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
25
media-server/src/schemas/recording.schema.ts
Normal file
25
media-server/src/schemas/recording.schema.ts
Normal 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>;
|
||||
781
media-server/src/services/recording.service.ts
Normal file
781
media-server/src/services/recording.service.ts
Normal 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) 创建对应 PlainTransport(rtcpMux=false, comedia=false)
|
||||
* 并 connect 到 ffmpeg 监听端口
|
||||
* 6) resume Consumer,RTP 开始流向 ffmpeg
|
||||
*
|
||||
* 停止顺序:
|
||||
* 1) SIGINT ffmpeg(让 ffmpeg 关闭 mp4 moov box;SIGKILL 会导致文件损坏)
|
||||
* 2) 等 ffmpeg 进程退出(超时 5s 兜底 SIGKILL)
|
||||
* 3) close Consumer / PlainTransport
|
||||
* 4) 释放端口
|
||||
* 5) stat 输出文件,返回大小 + 时长
|
||||
*
|
||||
* 关键约束:
|
||||
* - PlainTransport 用 rtcpMux=false:避免 RTCP 与 RTP 同端口,ffmpeg 解析更可靠
|
||||
* - comedia=false:由 server 主动 connect,IP/Port 是已知的本地回环
|
||||
* - 不开启 SRTP(loopback 不需要加密,降低 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 退出 promise,stop 时 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 提取录制需要的关键字段
|
||||
* - 取第一个 codec:mediasoup 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 mime(audio/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 存在且可消费 ----------
|
||||
// 不在这里建 Consumer:Consumer 必须依附 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 自己的 rtpCapabilities,consumer 会与 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 consumers,RTP 开始流向 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();
|
||||
}
|
||||
112
media-server/src/utils/port-pool.ts
Normal file
112
media-server/src/utils/port-pool.ts
Normal 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+1(RFC 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+1(mediasoup 默认行为)
|
||||
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();
|
||||
}
|
||||
69
media-server/tests/utils/port-pool.spec.ts
Normal file
69
media-server/tests/utils/port-pool.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user