视频会议保存

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

View File

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