视频会议保存
This commit is contained in:
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user