视频会议保存

This commit is contained in:
duoaohui
2026-05-18 21:23:00 +08:00
parent fb965cc8c4
commit 5a5706c236
20 changed files with 1211 additions and 1 deletions

View File

@@ -0,0 +1,210 @@
// Package service 提供 transcribe 模块的业务服务
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
meetingModel "github.com/echochat/backend/app/meeting/model"
"github.com/echochat/backend/app/transcribe/dao"
"github.com/echochat/backend/app/transcribe/model"
"github.com/echochat/backend/pkg/logs"
"go.uber.org/zap"
"gorm.io/gorm"
)
// 业务错误
var (
ErrRecordingNotFound = errors.New("录制不存在")
ErrRecordingNotReady = errors.New("录制尚未就绪,无法转写")
ErrTranscribeRunning = errors.New("已有转写任务进行中,请稍后再试")
ErrSTTNotConfigured = errors.New("语音转写服务未配置")
)
// TranscribeService 转写编排服务
//
// 设计要点:
// - Submit 立即返回 (transcript, 已 running),真正的 STT 调用走 goroutine
// - 同一 recording 只允许一个 running 任务,重复提交直接返回当前进行中行
// - 转写超时 10 分钟;超时后任务自标记 failed
// - 服务重启会留下 running 状态的孤儿行,留给后续兜底任务处理(暂不在本期实现)
type TranscribeService struct {
db *gorm.DB
transcripts *dao.TranscriptDAO
llmConfig *dao.LLMConfigDAO
sttClient *OpenAICompatibleClient
}
// NewTranscribeService 创建实例
func NewTranscribeService(
db *gorm.DB,
transcripts *dao.TranscriptDAO,
llmConfig *dao.LLMConfigDAO,
sttClient *OpenAICompatibleClient,
) *TranscribeService {
return &TranscribeService{
db: db,
transcripts: transcripts,
llmConfig: llmConfig,
sttClient: sttClient,
}
}
// IsAvailable 转写功能是否可用LLM 配置库连通)
func (s *TranscribeService) IsAvailable() bool {
return s.llmConfig != nil && s.llmConfig.IsEnabled()
}
// Submit 提交转写任务
//
// 行为:
// - 录制必须存在且 status=ready
// - 如果已有 running 行,返回 ErrTranscribeRunning
// - 如果已有 ready 行且 force=false直接返回已有结果
// - 否则:插入或重置 transcript 行 → 启动 goroutine 跑真实调用 → 同步返回 running 状态
//
// 参数:
// - forcetrue 表示强制重跑(覆盖 ready 行)
// - language可选"" 表示自动检测
//
// 返回值为 Submit 时刻的 transcript 快照,调用方可继续轮询 Get 拿最新状态。
func (s *TranscribeService) Submit(ctx context.Context, recordingID int64, force bool, language string) (*model.Transcript, error) {
if !s.IsAvailable() {
return nil, ErrSTTNotConfigured
}
// 1) 校验录制存在且已就绪
var rec meetingModel.MeetingRecording
err := s.db.WithContext(ctx).Where("id = ?", recordingID).First(&rec).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRecordingNotFound
}
return nil, err
}
if rec.Status != meetingModel.MeetingRecordingStatusReady || rec.FileURL == "" {
return nil, ErrRecordingNotReady
}
// 2) 检查现有 transcript
existing, err := s.transcripts.GetByRecordingID(ctx, recordingID)
if err != nil {
return nil, err
}
if existing != nil {
switch existing.Status {
case model.TranscriptStatusRunning, model.TranscriptStatusPending:
return existing, ErrTranscribeRunning
case model.TranscriptStatusReady:
if !force {
return existing, nil
}
}
}
// 3) 创建/更新行为 pending即将进入 running
now := time.Now()
t := &model.Transcript{
RecordingID: recordingID,
RoomID: rec.RoomID,
Status: model.TranscriptStatusPending,
Segments: "[]",
StartedAt: &now,
}
if existing != nil {
t.ID = existing.ID
}
if err := s.transcripts.Upsert(ctx, t); err != nil {
return nil, fmt.Errorf("写入 transcript 失败: %w", err)
}
// 4) 取最新 IDUpsert 后 t.ID 已填)
if err := s.transcripts.MarkRunning(ctx, t.ID); err != nil {
return nil, fmt.Errorf("切换 running 状态失败: %w", err)
}
// 5) 异步执行真实调用
// 使用全新 context超时 10min不沿用入参 ctx避免 HTTP 请求结束后 ctx 被 cancel 中断后台任务
go s.runJob(t.ID, rec, language)
// 返回 running 状态快照
updated, _ := s.transcripts.GetByRecordingID(ctx, recordingID)
if updated != nil {
return updated, nil
}
return t, nil
}
// runJob goroutine 内执行真实 STT 调用
//
// 不返回错误:所有失败都写回数据库 status=failed + error_msg
func (s *TranscribeService) runJob(transcriptID int64, rec meetingModel.MeetingRecording, language string) {
const funcName = "TranscribeService.runJob"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
logs.Info(ctx, funcName, "STT 任务开始",
zap.Int64("transcript_id", transcriptID),
zap.Int64("recording_id", rec.ID),
)
cfg, err := s.llmConfig.PickActiveSTT(ctx)
if err != nil {
s.fail(ctx, transcriptID, "选择 STT 配置失败: "+err.Error())
return
}
// 把 provider/model/key 元信息写回 transcript便于审计
_ = s.db.WithContext(ctx).Model(&model.Transcript{}).
Where("id = ?", transcriptID).
Updates(map[string]any{
"provider_code": cfg.Provider.ProviderCode,
"model_code": cfg.Model.ModelCode,
"key_id": cfg.Key.ID,
}).Error
result, err := s.sttClient.Transcribe(ctx, cfg, rec.FileURL, language)
if err != nil {
s.fail(ctx, transcriptID, "STT 调用失败: "+err.Error())
return
}
segmentsJSON, err := json.Marshal(result.Segments)
if err != nil {
segmentsJSON = []byte("[]")
}
if err := s.transcripts.MarkReady(ctx, transcriptID,
result.Text, string(segmentsJSON),
result.Language, int(result.Duration),
); err != nil {
logs.Error(ctx, funcName, "写回 ready 状态失败", zap.Error(err))
return
}
logs.Info(ctx, funcName, "STT 任务完成",
zap.Int64("transcript_id", transcriptID),
zap.Int("text_len", len(result.Text)),
zap.Int("segments", len(result.Segments)),
)
}
// fail 统一失败收尾:写日志 + 落库
func (s *TranscribeService) fail(ctx context.Context, transcriptID int64, msg string) {
logs.Warn(ctx, "TranscribeService.fail", msg, zap.Int64("transcript_id", transcriptID))
if err := s.transcripts.MarkFailed(ctx, transcriptID, msg); err != nil {
logs.Error(ctx, "TranscribeService.fail", "写回 failed 状态失败", zap.Error(err))
}
}
// GetByRecording 拉取某段录制的转写记录(不存在返回 nil, nil
func (s *TranscribeService) GetByRecording(ctx context.Context, recordingID int64) (*model.Transcript, error) {
return s.transcripts.GetByRecordingID(ctx, recordingID)
}
// ListByRoom 拉取一场会议下所有转写
func (s *TranscribeService) ListByRoom(ctx context.Context, roomID int64) ([]model.Transcript, error) {
return s.transcripts.ListByRoomID(ctx, roomID)
}