视频保存
This commit is contained in:
@@ -259,6 +259,9 @@ func (s *FileService) UploadVoice(ctx context.Context, fileHeader *multipart.Fil
|
|||||||
|
|
||||||
// buildURL 根据 MinIO 配置构建文件访问 URL
|
// buildURL 根据 MinIO 配置构建文件访问 URL
|
||||||
func (s *FileService) buildURL(objectName string) string {
|
func (s *FileService) buildURL(objectName string) string {
|
||||||
|
if base := strings.TrimRight(strings.TrimSpace(s.minioCfg.PublicBaseURL), "/"); base != "" {
|
||||||
|
return fmt.Sprintf("%s/%s", base, objectName)
|
||||||
|
}
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if s.minioCfg.UseSSL {
|
if s.minioCfg.UseSSL {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
|
|||||||
@@ -247,11 +247,16 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
|
|||||||
|
|
||||||
// 转入 uploading 状态
|
// 转入 uploading 状态
|
||||||
durationSec := int(stopResp.DurationMS / 1000)
|
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))
|
logs.Error(ctx, funcName, "标记 uploading 失败", zap.Int64("recording_id", rec.ID), zap.Error(err))
|
||||||
s.markFailedAndBroadcast(ctx, room.ID, rec, "数据库状态切换失败")
|
s.markFailedAndBroadcast(ctx, room.ID, rec, "数据库状态切换失败")
|
||||||
return nil, err
|
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
|
outputPath := stopResp.OutputPath
|
||||||
if 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)
|
// 方案 2:媒体管道仍出 .webm(VP8/Opus),go-service 同步用 ffmpeg 转码到 MP4(H264/AAC)
|
||||||
// 转码成功 → 上传 MP4,浏览器/STT 通用兼容、体积更小
|
// 转码成功 → 上传 MP4,浏览器/STT 通用兼容、体积更小
|
||||||
// 转码失败 → 回退上传原始 WebM,保证用户拿得到回放(不让转码引入回放黑洞)
|
// 转码失败 → 回退上传原始 WebM,保证用户拿得到回放(不让转码引入回放黑洞)
|
||||||
uploadPath, uploadExt, uploadContentType, transcodedPath := s.prepareUploadArtifact(ctx, outputPath, rec.ID)
|
uploadPath, uploadExt, uploadContentType, transcodedPath := s.prepareUploadArtifact(finalizeCtx, outputPath, rec.ID)
|
||||||
objectKey := fmt.Sprintf("recordings/%s/%d-%s%s", strings.ToLower(code), rec.ID, rec.RemoteID, uploadExt)
|
objectKey := fmt.Sprintf("recordings/%s/%d-%s%s", strings.ToLower(code), rec.ID, rec.RemoteID, uploadExt)
|
||||||
fileURL, sizeBytes, uploadErr := s.uploadRecording(ctx, uploadPath, objectKey, uploadContentType)
|
fileURL, sizeBytes, uploadErr := s.uploadRecording(finalizeCtx, uploadPath, objectKey, uploadContentType)
|
||||||
if uploadErr != nil {
|
if uploadErr != nil {
|
||||||
logs.Error(ctx, funcName, "录制文件上传 MinIO 失败",
|
logs.Error(finalizeCtx, funcName, "录制文件上传 MinIO 失败",
|
||||||
zap.Int64("recording_id", rec.ID), zap.String("local_path", uploadPath), zap.Error(uploadErr))
|
zap.Int64("recording_id", rec.ID), zap.String("local_path", uploadPath), zap.Error(uploadErr))
|
||||||
// 转码产物上传失败时残留 mp4 也清掉,避免下次轮询误判
|
// 转码产物上传失败时残留 mp4 也清掉,避免下次轮询误判
|
||||||
if transcodedPath != "" {
|
if transcodedPath != "" {
|
||||||
_ = os.Remove(transcodedPath)
|
_ = os.Remove(transcodedPath)
|
||||||
}
|
}
|
||||||
s.markFailedAndBroadcast(ctx, room.ID, rec, "录制文件上传失败")
|
s.markFailedAndBroadcast(finalizeCtx, room.ID, rec, "录制文件上传失败")
|
||||||
return nil, uploadErr
|
return nil, uploadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.recordingDAO.MarkReady(ctx, rec.ID, fileURL, objectKey, sizeBytes); err != nil {
|
if rows, err := s.recordingDAO.MarkReady(finalizeCtx, rec.ID, fileURL, objectKey, sizeBytes); err != nil {
|
||||||
logs.Error(ctx, funcName, "标记 ready 失败", zap.Int64("recording_id", rec.ID), zap.Error(err))
|
logs.Error(finalizeCtx, funcName, "标记 ready 失败", zap.Int64("recording_id", rec.ID), zap.Error(err))
|
||||||
s.markFailedAndBroadcast(ctx, room.ID, rec, "数据库状态切换失败(ready)")
|
s.markFailedAndBroadcast(finalizeCtx, 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新拉一份最终态
|
// 重新拉一份最终态
|
||||||
final, _ := s.recordingDAO.GetByID(ctx, rec.ID)
|
final, _ := s.recordingDAO.GetByID(finalizeCtx, rec.ID)
|
||||||
if final == nil {
|
if final == nil {
|
||||||
final = rec
|
final = rec
|
||||||
final.Status = model.MeetingRecordingStatusReady
|
final.Status = model.MeetingRecordingStatusReady
|
||||||
@@ -299,7 +309,7 @@ func (s *MeetingRecordingService) StopRecording(ctx context.Context, userID int6
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
logs.Warn(ctx, funcName, "删除本地录制文件失败(忽略)",
|
logs.Warn(finalizeCtx, funcName, "删除本地录制文件失败(忽略)",
|
||||||
zap.String("local_path", p), zap.Error(err))
|
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)
|
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"
|
scheme := "http"
|
||||||
if s.minioCfg.UseSSL {
|
if s.minioCfg.UseSSL {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectKey)
|
return fmt.Sprintf("%s://%s/%s/%s", scheme, s.minioCfg.Endpoint, s.minioCfg.Bucket, objectKey)
|
||||||
return url, stat.Size(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareUploadArtifact 决定最终上传到 MinIO 的产物
|
// prepareUploadArtifact 决定最终上传到 MinIO 的产物
|
||||||
@@ -557,6 +574,11 @@ func (s *MeetingRecordingService) transcodeWebmToMp4(ctx context.Context, webmPa
|
|||||||
return "", fmt.Errorf("ffmpeg 未安装或不在 PATH: %w", err)
|
return "", fmt.Errorf("ffmpeg 未安装或不在 PATH: %w", err)
|
||||||
}
|
}
|
||||||
mp4Path := strings.TrimSuffix(webmPath, filepath.Ext(webmPath)) + ".mp4"
|
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 时间,超时则视为失败回退
|
// 转码上限:单文件最长容忍 10 分钟 CPU 时间,超时则视为失败回退
|
||||||
tctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
|
tctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ minio:
|
|||||||
secret_key: "echochat_2026-1s32dswW@#" # 密钥(与 docker-compose 中的 MINIO_ROOT_PASSWORD 一致)
|
secret_key: "echochat_2026-1s32dswW@#" # 密钥(与 docker-compose 中的 MINIO_ROOT_PASSWORD 一致)
|
||||||
bucket: "echochat" # 存储桶名称
|
bucket: "echochat" # 存储桶名称
|
||||||
use_ssl: false # 开发环境不使用 HTTPS
|
use_ssl: false # 开发环境不使用 HTTPS
|
||||||
|
public_base_url: "http://yl.z64.cn/echochat"
|
||||||
|
|
||||||
# Node media-server 接入配置(Phase 2e-2 Task 7 HTTPMediaOrchestrator)
|
# Node media-server 接入配置(Phase 2e-2 Task 7 HTTPMediaOrchestrator)
|
||||||
# 对应 media-server/.env 的 MEDIA_INTERNAL_TOKEN + HTTP_PORT(默认 3300)
|
# 对应 media-server/.env 的 MEDIA_INTERNAL_TOKEN + HTTP_PORT(默认 3300)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ minio:
|
|||||||
secret_key: "echochat123456"
|
secret_key: "echochat123456"
|
||||||
bucket: "echochat"
|
bucket: "echochat"
|
||||||
use_ssl: false
|
use_ssl: false
|
||||||
|
public_base_url: "http://yl.z64.cn/echochat"
|
||||||
|
|
||||||
# Node media-server 接入配置(Phase 2e-2 Task 7)
|
# Node media-server 接入配置(Phase 2e-2 Task 7)
|
||||||
media_server:
|
media_server:
|
||||||
|
|||||||
@@ -99,11 +99,12 @@ type MediaServerConfig struct {
|
|||||||
|
|
||||||
// MinioConfig MinIO 对象存储配置
|
// MinioConfig MinIO 对象存储配置
|
||||||
type MinioConfig struct {
|
type MinioConfig struct {
|
||||||
Endpoint string `mapstructure:"endpoint"` // MinIO 服务地址(host:port)
|
Endpoint string `mapstructure:"endpoint"` // MinIO 服务地址(host:port)
|
||||||
AccessKey string `mapstructure:"access_key"` // 访问密钥
|
AccessKey string `mapstructure:"access_key"` // 访问密钥
|
||||||
SecretKey string `mapstructure:"secret_key"` // 密钥
|
SecretKey string `mapstructure:"secret_key"` // 密钥
|
||||||
Bucket string `mapstructure:"bucket"` // 存储桶名称
|
Bucket string `mapstructure:"bucket"` // 存储桶名称
|
||||||
UseSSL bool `mapstructure:"use_ssl"` // 是否使用 HTTPS
|
UseSSL bool `mapstructure:"use_ssl"` // 是否使用 HTTPS
|
||||||
|
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig HTTP 服务配置
|
// ServerConfig HTTP 服务配置
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ REDIS_PASSWORD=
|
|||||||
|
|
||||||
MINIO_ROOT_USER=echochat
|
MINIO_ROOT_USER=echochat
|
||||||
MINIO_ROOT_PASSWORD=echochat123456
|
MINIO_ROOT_PASSWORD=echochat123456
|
||||||
|
MINIO_PUBLIC_BASE_URL=http://localhost:9200/echochat
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 3. Go backend
|
# 3. Go backend
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ REDIS_PASSWORD=echochat_2026-1s32dswW@#
|
|||||||
|
|
||||||
MINIO_ROOT_USER=echochat
|
MINIO_ROOT_USER=echochat
|
||||||
MINIO_ROOT_PASSWORD=echochat_2026-1s32dswW@#
|
MINIO_ROOT_PASSWORD=echochat_2026-1s32dswW@#
|
||||||
|
MINIO_PUBLIC_BASE_URL=http://yl.z64.cn/echochat
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Go backend
|
# Go backend
|
||||||
|
|||||||
@@ -65,8 +65,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
CONFIG_NAME: config.docker
|
CONFIG_NAME: config.docker
|
||||||
# 透传关键环境变量,config.docker.yaml 内部引用(可选覆盖)
|
# 透传关键环境变量,config.docker.yaml 内部引用(可选覆盖)
|
||||||
ECHOCHAT_JWT_SECRET: ${JWT_SECRET:-}
|
ECHOCHAT_JWT_SECRET: ${JWT_SECRET:-1b6d0271700f2db39b2ac7c44811235c55e4e0e61cc47d963f7212b29a39c98f}
|
||||||
ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN: ${MEDIA_INTERNAL_TOKEN:-}
|
ECHOCHAT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-echochat}
|
||||||
|
ECHOCHAT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-echochat123456}
|
||||||
|
ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN: ${MEDIA_INTERNAL_TOKEN:-internal-token-22xqs}
|
||||||
|
ECHOCHAT_MINIO_PUBLIC_BASE_URL: ${MINIO_PUBLIC_BASE_URL:-http://yl.z64.cn/echochat}
|
||||||
# media-server 失败回调共享密钥:与 media-server 服务的 INTERNAL_WEBHOOK_SECRET 保持一致
|
# media-server 失败回调共享密钥:与 media-server 服务的 INTERNAL_WEBHOOK_SECRET 保持一致
|
||||||
# 未配置时 go-service 会拒绝所有 webhook(Forbidden),录制失败状态将无法及时落 failed
|
# 未配置时 go-service 会拒绝所有 webhook(Forbidden),录制失败状态将无法及时落 failed
|
||||||
ECHOCHAT_MEETING_INTERNAL_WEBHOOK_SECRET: ${INTERNAL_WEBHOOK_SECRET:-internal-webhook-12xqs}
|
ECHOCHAT_MEETING_INTERNAL_WEBHOOK_SECRET: ${INTERNAL_WEBHOOK_SECRET:-internal-webhook-12xqs}
|
||||||
|
|||||||
@@ -603,6 +603,13 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
if (typeof dto.size_bytes === 'number') recording.sizeBytes = dto.size_bytes
|
if (typeof dto.size_bytes === 'number') recording.sizeBytes = dto.size_bytes
|
||||||
if (typeof dto.duration_sec === 'number') recording.durationSec = dto.duration_sec
|
if (typeof dto.duration_sec === 'number') recording.durationSec = dto.duration_sec
|
||||||
if (typeof dto.failure_reason === 'string') recording.failureReason = dto.failure_reason
|
if (typeof dto.failure_reason === 'string') recording.failureReason = dto.failure_reason
|
||||||
|
if (
|
||||||
|
dto.status === MEETING_RECORDING_STATUS_RECORDING ||
|
||||||
|
dto.status === MEETING_RECORDING_STATUS_READY ||
|
||||||
|
dto.status === MEETING_RECORDING_STATUS_FAILED
|
||||||
|
) {
|
||||||
|
recording.pending = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user