视频会议保存

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