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