视频会议保存

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,181 @@
// Package service 提供 transcribe 模块的业务服务
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
"github.com/echochat/backend/app/transcribe/dao"
)
// OpenAICompatibleClient 实现 OpenAI 兼容协议的 STT 调用
//
// 标准端点POST {base_url}/audio/transcriptions
// 形式multipart/form-data
// - file: 音频文件mp3/wav/m4a/mp4/...
// - model: 模型 code如 whisper-1 / qwen-audio-asr-flash
// - response_format: verbose_json拿 segments/ json仅 text
// - language: 可选,提示语言以加速识别
//
// 阿里云 DashScope / DeepSeek / OpenAI / 智谱(部分)/ 通义千问 都遵循该协议。
//
// 调用超时:默认 5 分钟(覆盖大部分 ≤30min 会议)。超时由调用方通过 ctx 控制更精细。
type OpenAICompatibleClient struct {
httpClient *http.Client
}
// NewOpenAICompatibleClient 创建实例
func NewOpenAICompatibleClient() *OpenAICompatibleClient {
return &OpenAICompatibleClient{
httpClient: &http.Client{Timeout: 5 * time.Minute},
}
}
// TranscribeResult openai 兼容响应verbose_json 模式)
//
// 字段非全列:仅取本服务关心的部分。多余字段被丢弃。
type TranscribeResult struct {
Text string `json:"text"`
Language string `json:"language"`
Duration float64 `json:"duration"`
Segments []TranscribeSegment `json:"segments"`
}
// TranscribeSegment 单段时间轴文字verbose_json
type TranscribeSegment struct {
ID int `json:"id"`
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
}
// Transcribe 调用 STT
//
// 流程:
// 1. 从 fileURL 下载音频字节HTTP GET复用 httpClient timeout
// 2. 构造 multipart 请求体
// 3. POST {base_url}/audio/transcriptions附 Authorization: Bearer {api_key}
// 4. 解析 JSON 响应
//
// 参数:
// - fileURL录制文件公网/内网可达 URLmeeting_recordings.file_url
// - language可选传 "" 让模型自检
func (c *OpenAICompatibleClient) Transcribe(ctx context.Context, cfg *dao.STTConfig, fileURL, language string) (*TranscribeResult, error) {
if cfg == nil {
return nil, fmt.Errorf("STT config is nil")
}
if fileURL == "" {
return nil, fmt.Errorf("file_url is empty")
}
// 1) 下载音频
audioBytes, filename, err := c.downloadAudio(ctx, fileURL)
if err != nil {
return nil, fmt.Errorf("下载录制文件失败: %w", err)
}
// 2) 拼装 multipart
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
filePart, err := writer.CreateFormFile("file", filename)
if err != nil {
return nil, fmt.Errorf("创建 multipart file 字段失败: %w", err)
}
if _, err := filePart.Write(audioBytes); err != nil {
return nil, fmt.Errorf("写入 multipart file 内容失败: %w", err)
}
if err := writer.WriteField("model", cfg.Model.ModelCode); err != nil {
return nil, fmt.Errorf("写入 model 字段失败: %w", err)
}
if err := writer.WriteField("response_format", "verbose_json"); err != nil {
return nil, fmt.Errorf("写入 response_format 字段失败: %w", err)
}
if language != "" {
_ = writer.WriteField("language", language)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("关闭 multipart writer 失败: %w", err)
}
// 3) 构造请求
endpoint := strings.TrimRight(cfg.Provider.BaseURL, "/") + "/audio/transcriptions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
if err != nil {
return nil, fmt.Errorf("构造 STT 请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+cfg.Key.APIKey)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("调用 STT 接口失败: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 STT 响应失败: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// 截断响应体,避免日志爆炸
preview := string(respBytes)
if len(preview) > 300 {
preview = preview[:300] + "..."
}
return nil, fmt.Errorf("STT 接口返回 %d: %s", resp.StatusCode, preview)
}
// 4) 解析
var result TranscribeResult
if err := json.Unmarshal(respBytes, &result); err != nil {
preview := string(respBytes)
if len(preview) > 300 {
preview = preview[:300] + "..."
}
return nil, fmt.Errorf("解析 STT 响应失败: %v, body=%s", err, preview)
}
if result.Text == "" && len(result.Segments) == 0 {
// 部分供应商在 response_format=json 模式只返回 text这里没拿到任何结果视为异常
return nil, fmt.Errorf("STT 响应内容为空")
}
return &result, nil
}
// downloadAudio 从给定 URL 拉取音频内容,返回字节流 + 推断的文件名
//
// 文件名仅作为 multipart 的 filename 参数,主要决定 Content-Type 推断;
// 取 URL path 末段,无后缀则默认 recording.mp4。
func (c *OpenAICompatibleClient) downloadAudio(ctx context.Context, fileURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, "", err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("下载失败 status=%d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
filename := path.Base(req.URL.Path)
if filename == "" || filename == "/" || !strings.Contains(filename, ".") {
filename = "recording.mp4"
}
return data, filename, nil
}

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)
}