From 23a74f737e36bd324f81f5ed30678cbd6ae45d1d Mon Sep 17 00:00:00 2001 From: duoaohui <928970622@qq.com> Date: Tue, 19 May 2026 17:21:40 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/file/service/file_service.go | 3 ++ .../service/meeting_recording_service.go | 46 ++++++++++++++----- backend/go-service/config/config.dev.yaml | 1 + backend/go-service/config/config.docker.yaml | 1 + backend/go-service/config/config.go | 11 +++-- deploy/.env.example | 1 + deploy/.env.public.example | 1 + deploy/docker-compose.dev.yml | 7 ++- frontend/src/store/meeting.js | 7 +++ 9 files changed, 59 insertions(+), 19 deletions(-) diff --git a/backend/go-service/app/file/service/file_service.go b/backend/go-service/app/file/service/file_service.go index bb372d5..a2e0533 100644 --- a/backend/go-service/app/file/service/file_service.go +++ b/backend/go-service/app/file/service/file_service.go @@ -259,6 +259,9 @@ func (s *FileService) UploadVoice(ctx context.Context, fileHeader *multipart.Fil // buildURL 根据 MinIO 配置构建文件访问 URL 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" if s.minioCfg.UseSSL { scheme = "https" diff --git a/backend/go-service/app/meeting/service/meeting_recording_service.go b/backend/go-service/app/meeting/service/meeting_recording_service.go index c2e6856..28a8758 100644 --- a/backend/go-service/app/meeting/service/meeting_recording_service.go +++ b/backend/go-service/app/meeting/service/meeting_recording_service.go @@ -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(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) - fileURL, sizeBytes, uploadErr := s.uploadRecording(ctx, uploadPath, objectKey, uploadContentType) + fileURL, sizeBytes, uploadErr := s.uploadRecording(finalizeCtx, uploadPath, objectKey, uploadContentType) 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)) // 转码产物上传失败时残留 mp4 也清掉,避免下次轮询误判 if transcodedPath != "" { _ = os.Remove(transcodedPath) } - s.markFailedAndBroadcast(ctx, room.ID, rec, "录制文件上传失败") + s.markFailedAndBroadcast(finalizeCtx, room.ID, rec, "录制文件上传失败") return nil, uploadErr } - if _, err := s.recordingDAO.MarkReady(ctx, rec.ID, fileURL, objectKey, sizeBytes); err != nil { - logs.Error(ctx, funcName, "标记 ready 失败", zap.Int64("recording_id", rec.ID), zap.Error(err)) - s.markFailedAndBroadcast(ctx, room.ID, rec, "数据库状态切换失败(ready)") + if rows, err := s.recordingDAO.MarkReady(finalizeCtx, rec.ID, fileURL, objectKey, sizeBytes); err != nil { + logs.Error(finalizeCtx, funcName, "标记 ready 失败", zap.Int64("recording_id", rec.ID), zap.Error(err)) + 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 } // 重新拉一份最终态 - final, _ := s.recordingDAO.GetByID(ctx, rec.ID) + final, _ := s.recordingDAO.GetByID(finalizeCtx, 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(ctx, funcName, "删除本地录制文件失败(忽略)", + logs.Warn(finalizeCtx, 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) diff --git a/backend/go-service/config/config.dev.yaml b/backend/go-service/config/config.dev.yaml index c198f47..039617b 100644 --- a/backend/go-service/config/config.dev.yaml +++ b/backend/go-service/config/config.dev.yaml @@ -53,6 +53,7 @@ minio: secret_key: "echochat_2026-1s32dswW@#" # 密钥(与 docker-compose 中的 MINIO_ROOT_PASSWORD 一致) bucket: "echochat" # 存储桶名称 use_ssl: false # 开发环境不使用 HTTPS + public_base_url: "http://yl.z64.cn/echochat" # Node media-server 接入配置(Phase 2e-2 Task 7 HTTPMediaOrchestrator) # 对应 media-server/.env 的 MEDIA_INTERNAL_TOKEN + HTTP_PORT(默认 3300) diff --git a/backend/go-service/config/config.docker.yaml b/backend/go-service/config/config.docker.yaml index 0f798f8..0294740 100644 --- a/backend/go-service/config/config.docker.yaml +++ b/backend/go-service/config/config.docker.yaml @@ -47,6 +47,7 @@ minio: secret_key: "echochat123456" bucket: "echochat" use_ssl: false + public_base_url: "http://yl.z64.cn/echochat" # Node media-server 接入配置(Phase 2e-2 Task 7) media_server: diff --git a/backend/go-service/config/config.go b/backend/go-service/config/config.go index bd42a7c..2073865 100644 --- a/backend/go-service/config/config.go +++ b/backend/go-service/config/config.go @@ -99,11 +99,12 @@ type MediaServerConfig struct { // MinioConfig MinIO 对象存储配置 type MinioConfig struct { - Endpoint string `mapstructure:"endpoint"` // MinIO 服务地址(host:port) - AccessKey string `mapstructure:"access_key"` // 访问密钥 - SecretKey string `mapstructure:"secret_key"` // 密钥 - Bucket string `mapstructure:"bucket"` // 存储桶名称 - UseSSL bool `mapstructure:"use_ssl"` // 是否使用 HTTPS + Endpoint string `mapstructure:"endpoint"` // MinIO 服务地址(host:port) + AccessKey string `mapstructure:"access_key"` // 访问密钥 + SecretKey string `mapstructure:"secret_key"` // 密钥 + Bucket string `mapstructure:"bucket"` // 存储桶名称 + UseSSL bool `mapstructure:"use_ssl"` // 是否使用 HTTPS + PublicBaseURL string `mapstructure:"public_base_url"` } // ServerConfig HTTP 服务配置 diff --git a/deploy/.env.example b/deploy/.env.example index 4d69dea..ddcf09a 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -25,6 +25,7 @@ REDIS_PASSWORD= MINIO_ROOT_USER=echochat MINIO_ROOT_PASSWORD=echochat123456 +MINIO_PUBLIC_BASE_URL=http://localhost:9200/echochat # ============================================================ # 3. Go backend diff --git a/deploy/.env.public.example b/deploy/.env.public.example index 841a6e3..32212ba 100644 --- a/deploy/.env.public.example +++ b/deploy/.env.public.example @@ -18,6 +18,7 @@ REDIS_PASSWORD=echochat_2026-1s32dswW@# MINIO_ROOT_USER=echochat MINIO_ROOT_PASSWORD=echochat_2026-1s32dswW@# +MINIO_PUBLIC_BASE_URL=http://yl.z64.cn/echochat # ============================================================ # Go backend diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index f405735..0c1578a 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -65,8 +65,11 @@ services: environment: CONFIG_NAME: config.docker # 透传关键环境变量,config.docker.yaml 内部引用(可选覆盖) - ECHOCHAT_JWT_SECRET: ${JWT_SECRET:-} - ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN: ${MEDIA_INTERNAL_TOKEN:-} + ECHOCHAT_JWT_SECRET: ${JWT_SECRET:-1b6d0271700f2db39b2ac7c44811235c55e4e0e61cc47d963f7212b29a39c98f} + 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 保持一致 # 未配置时 go-service 会拒绝所有 webhook(Forbidden),录制失败状态将无法及时落 failed ECHOCHAT_MEETING_INTERNAL_WEBHOOK_SECRET: ${INTERNAL_WEBHOOK_SECRET:-internal-webhook-12xqs} diff --git a/frontend/src/store/meeting.js b/frontend/src/store/meeting.js index 3eb6625..4623f8c 100644 --- a/frontend/src/store/meeting.js +++ b/frontend/src/store/meeting.js @@ -603,6 +603,13 @@ export const useMeetingStore = defineStore('meeting', () => { 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.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 + } } /**