视频会议保存

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
}