视频会议保存
This commit is contained in:
@@ -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:录制文件公网/内网可达 URL(meeting_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
|
||||
}
|
||||
210
backend/go-service/app/transcribe/service/transcribe_service.go
Normal file
210
backend/go-service/app/transcribe/service/transcribe_service.go
Normal 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 状态
|
||||
//
|
||||
// 参数:
|
||||
// - force:true 表示强制重跑(覆盖 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) 取最新 ID(Upsert 后 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)
|
||||
}
|
||||
Reference in New Issue
Block a user