@@ -247,11 +247,16 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
// 转入 uploading 状态
durationSec := int ( stopResp . DurationMS / 1000 )
if _ , err := s . recordingDAO . MarkUploading ( ctx , rec . ID , time . Now ( ) , durationSec ) ; err != nil {
if rows , 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
} else if rows == 0 {
logs . Warn ( ctx , funcName , "标记 uploading 未更新任何记录" ,
zap . Int64 ( "recording_id" , rec . ID ) , zap . String ( "status" , string ( rec . Status ) ) )
return nil , ErrRecordingNotStoppable
}
finalizeCtx := logs . DetachContext ( ctx )
outputPath := stopResp . OutputPath
if outputPath == "" {
@@ -262,28 +267,33 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
// 方案 2: 媒体管道仍出 .webm( VP8/Opus) , go-service 同步用 ffmpeg 转码到 MP4(H264/AAC)
// 转码成功 → 上传 MP4, 浏览器/STT 通用兼容、体积更小
// 转码失败 → 回退上传原始 WebM, 保证用户拿得到回放( 不让转码引入回放黑洞)
uploadPath , uploadExt , uploadContentType , transcodedPath := s . prepareUploadArtifact ( c tx, outputPath , rec . ID )
uploadPath , uploadExt , uploadContentType , transcodedPath := s . prepareUploadArtifact ( finalizeC tx, outputPath , rec . ID )
objectKey := fmt . Sprintf ( "recordings/%s/%d-%s%s" , strings . ToLower ( code ) , rec . ID , rec . RemoteID , uploadExt )
fileURL , sizeBytes , uploadErr := s . uploadRecording ( c tx, uploadPath , objectKey , uploadContentType )
fileURL , sizeBytes , uploadErr := s . uploadRecording ( finalizeC tx, uploadPath , objectKey , uploadContentType )
if uploadErr != nil {
logs . Error ( c tx, funcName , "录制文件上传 MinIO 失败" ,
logs . Error ( finalizeC tx, funcName , "录制文件上传 MinIO 失败" ,
zap . Int64 ( "recording_id" , rec . ID ) , zap . String ( "local_path" , uploadPath ) , zap . Error ( uploadErr ) )
// 转码产物上传失败时残留 mp4 也清掉,避免下次轮询误判
if transcodedPath != "" {
_ = os . Remove ( transcodedPath )
}
s . markFailedAndBroadcast ( c tx, room . ID , rec , "录制文件上传失败" )
s . markFailedAndBroadcast ( finalizeC tx, room . ID , rec , "录制文件上传失败" )
return nil , uploadErr
}
if _ , err := s . recordingDAO . MarkReady ( c tx, rec . ID , fileURL , objectKey , sizeBytes ) ; err != nil {
logs . Error ( c tx, funcName , "标记 ready 失败" , zap . Int64 ( "recording_id" , rec . ID ) , zap . Error ( err ) )
s . markFailedAndBroadcast ( c tx, room . ID , rec , "数据库状态切换失败( ready) " )
if rows , err := s . recordingDAO . MarkReady ( finalizeC tx, rec . ID , fileURL , objectKey , sizeBytes ) ; err != nil {
logs . Error ( finalizeC tx, funcName , "标记 ready 失败" , zap . Int64 ( "recording_id" , rec . ID ) , zap . Error ( err ) )
s . markFailedAndBroadcast ( finalizeC tx, room . ID , rec , "数据库状态切换失败( ready) " )
return nil , err
} else if rows == 0 {
err := fmt . Errorf ( "标记 ready 未更新任何记录" )
logs . Error ( finalizeCtx , funcName , "标记 ready 未更新任何记录" , zap . Int64 ( "recording_id" , rec . ID ) )
s . markFailedAndBroadcast ( finalizeCtx , room . ID , rec , "数据库状态切换失败( ready) " )
return nil , err
}
// 重新拉一份最终态
final , _ := s . recordingDAO . GetByID ( c tx, rec . ID )
final , _ := s . recordingDAO . GetByID ( finalizeC tx, rec . ID )
if final == nil {
final = rec
final . Status = model . MeetingRecordingStatusReady
@@ -299,7 +309,7 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
continue
}
if err := os . Remove ( p ) ; err != nil && ! errors . Is ( err , os . ErrNotExist ) {
logs . Warn ( c tx, funcName , "删除本地录制文件失败(忽略)" ,
logs . Warn ( finalizeC tx, funcName , "删除本地录制文件失败(忽略)" ,
zap . String ( "local_path" , p ) , zap . Error ( err ) )
}
}
@@ -488,12 +498,19 @@ func (s *MeetingRecordingService) uploadRecording(ctx context.Context, localPath
return "" , 0 , fmt . Errorf ( "PutObject 失败: %w" , err )
}
url := s . buildRecordingURL ( objectKey )
return url , stat . Size ( ) , nil
}
func ( s * MeetingRecordingService ) buildRecordingURL ( objectKey string ) string {
if base := strings . TrimRight ( strings . TrimSpace ( s . minioCfg . PublicBaseURL ) , "/" ) ; base != "" {
return fmt . Sprintf ( "%s/%s" , base , objectKey )
}
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
return fmt . Sprintf ( "%s://%s/%s/%s" , scheme , s . minioCfg . Endpoint , s . minioCfg . Bucket , objectKey )
}
// prepareUploadArtifact 决定最终上传到 MinIO 的产物
@@ -557,6 +574,11 @@ func (s *MeetingRecordingService) transcodeWebmToMp4(ctx context.Context, webmPa
return "" , fmt . Errorf ( "ffmpeg 未安装或不在 PATH: %w" , err )
}
mp4Path := strings . TrimSuffix ( webmPath , filepath . Ext ( webmPath ) ) + ".mp4"
logs . Info ( ctx , "service.meeting_recording_service.transcodeWebmToMp4" , "开始 WebM→MP4 转码" ,
zap . String ( "ffmpeg_path" , ffmpegPath ) ,
zap . Int ( "timeout_seconds" , timeoutSec ) ,
zap . String ( "webm_path" , webmPath ) ,
zap . String ( "mp4_path" , mp4Path ) )
// 转码上限:单文件最长容忍 10 分钟 CPU 时间,超时则视为失败回退
tctx , cancel := context . WithTimeout ( ctx , time . Duration ( timeoutSec ) * time . Second )