视频会议保存
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// Package controller 提供 transcribe 模块的 admin HTTP 接口
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/echochat/backend/app/dto"
|
||||
"github.com/echochat/backend/app/transcribe/dao"
|
||||
"github.com/echochat/backend/app/transcribe/model"
|
||||
"github.com/echochat/backend/app/transcribe/service"
|
||||
"github.com/echochat/backend/pkg/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TranscribeController admin 端转写控制器
|
||||
type TranscribeController struct {
|
||||
svc *service.TranscribeService
|
||||
}
|
||||
|
||||
// NewTranscribeController 创建实例
|
||||
func NewTranscribeController(svc *service.TranscribeService) *TranscribeController {
|
||||
return &TranscribeController{svc: svc}
|
||||
}
|
||||
|
||||
// GetTranscript GET /admin/recordings/:id/transcript
|
||||
//
|
||||
// 返回值:
|
||||
// - 200 + transcript:存在时返回最新状态
|
||||
// - 200 + null:尚未发起转写(前端据此显示"开始转写"按钮)
|
||||
func (ctl *TranscribeController) GetTranscript(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
utils.ResponseBadRequest(c, "无效的录制 ID")
|
||||
return
|
||||
}
|
||||
|
||||
t, err := ctl.svc.GetByRecording(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
utils.ResponseError(c, "查询转写失败")
|
||||
return
|
||||
}
|
||||
utils.ResponseOK(c, toDTO(t))
|
||||
}
|
||||
|
||||
// SubmitTranscribe POST /admin/recordings/:id/transcribe
|
||||
//
|
||||
// body: { force: bool, language: string }
|
||||
//
|
||||
// 行为:异步启动 STT,返回 running 状态快照
|
||||
func (ctl *TranscribeController) SubmitTranscribe(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
utils.ResponseBadRequest(c, "无效的录制 ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.AdminTranscribeRequest
|
||||
// body 可选;POST 不带 body 也允许(默认 force=false language="")
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
t, err := ctl.svc.Submit(c.Request.Context(), id, req.Force, req.Language)
|
||||
if err != nil {
|
||||
ctl.handleError(c, err, t)
|
||||
return
|
||||
}
|
||||
utils.ResponseOK(c, toDTO(t))
|
||||
}
|
||||
|
||||
// handleError 统一业务错误映射
|
||||
func (ctl *TranscribeController) handleError(c *gin.Context, err error, t *model.Transcript) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrRecordingNotFound):
|
||||
utils.ResponseNotFound(c, err.Error())
|
||||
case errors.Is(err, service.ErrRecordingNotReady):
|
||||
utils.ResponseBadRequest(c, err.Error())
|
||||
case errors.Is(err, service.ErrSTTNotConfigured), errors.Is(err, dao.ErrLLMSourceDisabled):
|
||||
// 503 表示服务暂不可用,前端可据此提示"请联系管理员配置 STT"
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"code": 503,
|
||||
"message": "语音转写服务未配置或不可用",
|
||||
"data": nil,
|
||||
})
|
||||
case errors.Is(err, service.ErrTranscribeRunning):
|
||||
// 已在跑:返回当前进行中行,前端可继续轮询
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"code": 0,
|
||||
"message": "已有转写任务进行中",
|
||||
"data": toDTO(t),
|
||||
})
|
||||
case errors.Is(err, dao.ErrNoActiveSTT):
|
||||
utils.ResponseBadRequest(c, "未找到可用的 STT 模型或 API Key,请检查 LLM 配置中心")
|
||||
default:
|
||||
utils.ResponseError(c, "发起转写失败: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// toDTO 模型转 DTO,segments JSON 解码 + 状态中文标签
|
||||
func toDTO(t *model.Transcript) *dto.AdminTranscriptDTO {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
segs := []dto.AdminTranscriptSeg{}
|
||||
if t.Segments != "" {
|
||||
_ = json.Unmarshal([]byte(t.Segments), &segs)
|
||||
}
|
||||
return &dto.AdminTranscriptDTO{
|
||||
ID: t.ID,
|
||||
RecordingID: t.RecordingID,
|
||||
RoomID: t.RoomID,
|
||||
Status: t.Status,
|
||||
StatusLabel: statusLabel(t.Status),
|
||||
Text: t.Text,
|
||||
Segments: segs,
|
||||
Language: t.Language,
|
||||
DurationSec: t.DurationSec,
|
||||
ProviderCode: t.ProviderCode,
|
||||
ModelCode: t.ModelCode,
|
||||
ErrorMsg: t.ErrorMsg,
|
||||
StartedAt: dto.FormatTranscriptTimePtr(t.StartedAt),
|
||||
FinishedAt: dto.FormatTranscriptTimePtr(t.FinishedAt),
|
||||
CreatedAt: dto.FormatTranscriptTime(t.CreatedAt),
|
||||
UpdatedAt: dto.FormatTranscriptTime(t.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func statusLabel(s string) string {
|
||||
switch s {
|
||||
case model.TranscriptStatusPending:
|
||||
return "待处理"
|
||||
case model.TranscriptStatusRunning:
|
||||
return "转写中"
|
||||
case model.TranscriptStatusReady:
|
||||
return "已就绪"
|
||||
case model.TranscriptStatusFailed:
|
||||
return "失败"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
106
backend/go-service/app/transcribe/dao/llm_config_dao.go
Normal file
106
backend/go-service/app/transcribe/dao/llm_config_dao.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package dao 提供 transcribe 模块对外部 LLM 配置中心的只读访问
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/echochat/backend/app/transcribe/model/llm"
|
||||
"github.com/echochat/backend/pkg/db"
|
||||
)
|
||||
|
||||
// ErrLLMSourceDisabled LLM 配置库未启用
|
||||
var ErrLLMSourceDisabled = errors.New("LLM 配置中心未启用 (llm_source.enabled=false)")
|
||||
|
||||
// ErrNoActiveSTT 没有可用的 STT 模型/Key
|
||||
var ErrNoActiveSTT = errors.New("未找到可用的 STT 模型或 API Key")
|
||||
|
||||
// LLMConfigDAO 外部 LLM 配置只读 DAO(MySQL)
|
||||
//
|
||||
// 选择策略说明(PickActiveSTT):
|
||||
// 1. 在 t_llm_model 中找 model_type=4 (ASR) 且 status=1 deleted=0 ORDER BY sort ASC 的第一条
|
||||
// 2. 用其 provider_id 在 t_llm_provider 中找对应 provider,要求 status=1 deleted=0
|
||||
// 3. 在 t_llm_key 中按 provider_id 选 status=1 deleted=0 且 (expire_time IS NULL OR expire_time > NOW())
|
||||
// 且 (daily_limit=0 OR today_count < daily_limit) ORDER BY weight DESC, today_count ASC LIMIT 1
|
||||
//
|
||||
// 任何一步落空都返回 ErrNoActiveSTT,避免上游用半成品配置发起调用。
|
||||
type LLMConfigDAO struct {
|
||||
source *db.LLMSourceDB
|
||||
}
|
||||
|
||||
// NewLLMConfigDAO 创建实例
|
||||
func NewLLMConfigDAO(source *db.LLMSourceDB) *LLMConfigDAO {
|
||||
return &LLMConfigDAO{source: source}
|
||||
}
|
||||
|
||||
// IsEnabled 暴露给上层做"是否能转写"的快速判断,避免每次都 catch error
|
||||
func (d *LLMConfigDAO) IsEnabled() bool {
|
||||
return d != nil && d.source != nil && d.source.IsEnabled()
|
||||
}
|
||||
|
||||
// STTConfig 三表合一的运行时配置快照,用于本次转写调用
|
||||
type STTConfig struct {
|
||||
Provider llm.Provider
|
||||
Model llm.Model
|
||||
Key llm.Key
|
||||
}
|
||||
|
||||
// PickActiveSTT 按选择策略挑出一组可用的 (provider, model, key)
|
||||
//
|
||||
// 注意:不做事务,因为这是只读 + LLM 配置中心通常更新频率极低,
|
||||
// 偶发的"挑出后 key 立刻被禁用"由调用层捕获 401/403 后重试解决。
|
||||
func (d *LLMConfigDAO) PickActiveSTT(ctx context.Context) (*STTConfig, error) {
|
||||
if !d.IsEnabled() {
|
||||
return nil, ErrLLMSourceDisabled
|
||||
}
|
||||
gdb := d.source.DB()
|
||||
|
||||
// 1) 选 ASR 模型
|
||||
var model llm.Model
|
||||
err := gdb.WithContext(ctx).
|
||||
Where("deleted = 0 AND status = ? AND model_type = ?", llm.StatusEnabled, llm.ModelTypeASR).
|
||||
Order("sort ASC, id ASC").
|
||||
First(&model).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoActiveSTT, err)
|
||||
}
|
||||
|
||||
// 2) 模型对应的 provider 必须也启用
|
||||
var provider llm.Provider
|
||||
err = gdb.WithContext(ctx).
|
||||
Where("id = ? AND deleted = 0 AND status = ?", model.ProviderID, llm.StatusEnabled).
|
||||
First(&provider).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: provider not active for model %s", ErrNoActiveSTT, model.ModelCode)
|
||||
}
|
||||
|
||||
// 3) 选 key
|
||||
now := time.Now()
|
||||
var key llm.Key
|
||||
err = gdb.WithContext(ctx).
|
||||
Where("deleted = 0 AND status = ? AND provider_id = ?", llm.StatusEnabled, provider.ID).
|
||||
Where("expire_time IS NULL OR expire_time > ?", now).
|
||||
Where("daily_limit = 0 OR today_count < daily_limit").
|
||||
Order("weight DESC, today_count ASC, id ASC").
|
||||
First(&key).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: no usable key for provider %s", ErrNoActiveSTT, provider.ProviderCode)
|
||||
}
|
||||
|
||||
return &STTConfig{Provider: provider, Model: model, Key: key}, nil
|
||||
}
|
||||
|
||||
// ListASRModels 列出全部启用的 ASR 模型(admin 可视化用,可不接 UI,先备好接口)
|
||||
func (d *LLMConfigDAO) ListASRModels(ctx context.Context) ([]llm.Model, error) {
|
||||
if !d.IsEnabled() {
|
||||
return nil, ErrLLMSourceDisabled
|
||||
}
|
||||
var list []llm.Model
|
||||
err := d.source.DB().WithContext(ctx).
|
||||
Where("deleted = 0 AND status = ? AND model_type = ?", llm.StatusEnabled, llm.ModelTypeASR).
|
||||
Order("sort ASC, id ASC").
|
||||
Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
112
backend/go-service/app/transcribe/dao/transcript_dao.go
Normal file
112
backend/go-service/app/transcribe/dao/transcript_dao.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Package dao 提供 transcribe 模块的数据库访问操作
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/echochat/backend/app/transcribe/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TranscriptDAO 转写记录数据访问对象(PostgreSQL 主库)
|
||||
type TranscriptDAO struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTranscriptDAO 创建实例
|
||||
func NewTranscriptDAO(db *gorm.DB) *TranscriptDAO {
|
||||
return &TranscriptDAO{db: db}
|
||||
}
|
||||
|
||||
// GetByRecordingID 按 recording_id 获取转写记录(不存在返回 nil, nil)
|
||||
//
|
||||
// 之所以 nil 不视为错误:调用方常见模式是"取不到 → 创建新行",避免 ErrRecordNotFound 包装
|
||||
func (d *TranscriptDAO) GetByRecordingID(ctx context.Context, recordingID int64) (*model.Transcript, error) {
|
||||
var t model.Transcript
|
||||
err := d.db.WithContext(ctx).
|
||||
Where("recording_id = ?", recordingID).
|
||||
First(&t).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ListByRoomID 拉取一场会议下所有转写(按 recording_id 升序)
|
||||
// 用于会议详情页一次性带回多段录制的转写状态
|
||||
func (d *TranscriptDAO) ListByRoomID(ctx context.Context, roomID int64) ([]model.Transcript, error) {
|
||||
var list []model.Transcript
|
||||
err := d.db.WithContext(ctx).
|
||||
Where("room_id = ?", roomID).
|
||||
Order("recording_id ASC").
|
||||
Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// Upsert 插入或更新(按 recording_id unique 索引)
|
||||
// 用于"重新转写"路径:保留同一行 ID,状态/文本就地刷新
|
||||
func (d *TranscriptDAO) Upsert(ctx context.Context, t *model.Transcript) error {
|
||||
existing, err := d.GetByRecordingID(ctx, t.RecordingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return d.db.WithContext(ctx).Create(t).Error
|
||||
}
|
||||
t.ID = existing.ID
|
||||
return d.db.WithContext(ctx).Save(t).Error
|
||||
}
|
||||
|
||||
// MarkRunning 把指定 transcript 置为 running 状态,并填 started_at = now
|
||||
//
|
||||
// 用于异步任务起跑时的状态翻转,保证后端重启时 running 行不会卡死
|
||||
// (配合 RescueStuckRunning 兜底)。
|
||||
func (d *TranscriptDAO) MarkRunning(ctx context.Context, id int64) error {
|
||||
now := time.Now()
|
||||
return d.db.WithContext(ctx).
|
||||
Model(&model.Transcript{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": model.TranscriptStatusRunning,
|
||||
"started_at": now,
|
||||
"error_msg": "",
|
||||
}).Error
|
||||
}
|
||||
|
||||
// MarkReady 转写成功
|
||||
func (d *TranscriptDAO) MarkReady(ctx context.Context, id int64, text, segments, language string, durationSec int) error {
|
||||
now := time.Now()
|
||||
return d.db.WithContext(ctx).
|
||||
Model(&model.Transcript{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": model.TranscriptStatusReady,
|
||||
"text": text,
|
||||
"segments": segments,
|
||||
"language": language,
|
||||
"duration_sec": durationSec,
|
||||
"finished_at": now,
|
||||
"error_msg": "",
|
||||
}).Error
|
||||
}
|
||||
|
||||
// MarkFailed 转写失败
|
||||
func (d *TranscriptDAO) MarkFailed(ctx context.Context, id int64, errorMsg string) error {
|
||||
now := time.Now()
|
||||
if len(errorMsg) > 500 {
|
||||
errorMsg = errorMsg[:500]
|
||||
}
|
||||
return d.db.WithContext(ctx).
|
||||
Model(&model.Transcript{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"status": model.TranscriptStatusFailed,
|
||||
"error_msg": errorMsg,
|
||||
"finished_at": now,
|
||||
}).Error
|
||||
}
|
||||
103
backend/go-service/app/transcribe/model/llm/llm.go
Normal file
103
backend/go-service/app/transcribe/model/llm/llm.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Package llm 映射外部 LLM 配置中心 MySQL 的三张表
|
||||
//
|
||||
// 严格只读:本服务永远不应该 INSERT / UPDATE / DELETE 这些表,
|
||||
// DAO 中只用 Find / First / Where。所有写操作由配置中心自身负责。
|
||||
package llm
|
||||
|
||||
import "time"
|
||||
|
||||
// 模型类型枚举(对应 t_llm_model.model_type)
|
||||
const (
|
||||
ModelTypeChat = 1 // 文字对话
|
||||
ModelTypeVision = 2 // 视觉(图文)
|
||||
ModelTypeMultimodal = 3 // 多模态
|
||||
ModelTypeASR = 4 // 语音识别(本服务转写功能用此类型)
|
||||
)
|
||||
|
||||
// 通用状态枚举
|
||||
const (
|
||||
StatusDisabled = 0 // 停用
|
||||
StatusEnabled = 1 // 启用
|
||||
// t_llm_key 额外定义
|
||||
KeyStatusAutoDisabled = 2 // 连续失败自动停用
|
||||
)
|
||||
|
||||
// API 协议
|
||||
const (
|
||||
APIProtocolOpenAICompatible = "openai_compatible"
|
||||
APIProtocolCustom = "custom"
|
||||
)
|
||||
|
||||
// Provider 大模型提供商表 t_llm_provider(只读)
|
||||
//
|
||||
// 选择 provider 时使用:deleted=0 AND status=1 ORDER BY sort ASC
|
||||
type Provider struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
ProviderName string `gorm:"column:provider_name"`
|
||||
ProviderCode string `gorm:"column:provider_code"`
|
||||
BaseURL string `gorm:"column:base_url"`
|
||||
APIProtocol string `gorm:"column:api_protocol"` // openai_compatible / custom
|
||||
Status int `gorm:"column:status"`
|
||||
Sort int `gorm:"column:sort"`
|
||||
Remark string `gorm:"column:remark"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
Deleted int `gorm:"column:deleted"`
|
||||
}
|
||||
|
||||
// TableName 指定 MySQL 表名
|
||||
func (Provider) TableName() string { return "t_llm_provider" }
|
||||
|
||||
// Model 大模型表 t_llm_model(只读)
|
||||
//
|
||||
// 选择 STT 模型时使用:deleted=0 AND status=1 AND model_type=4 ORDER BY sort ASC
|
||||
type Model struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
ProviderID int64 `gorm:"column:provider_id"`
|
||||
ModelCode string `gorm:"column:model_code"`
|
||||
ModelName string `gorm:"column:model_name"`
|
||||
ModelType int `gorm:"column:model_type"`
|
||||
MaxTokens int `gorm:"column:max_tokens"`
|
||||
InputPrice float64 `gorm:"column:input_price"`
|
||||
OutputPrice float64 `gorm:"column:output_price"`
|
||||
Status int `gorm:"column:status"`
|
||||
Sort int `gorm:"column:sort"`
|
||||
Remark string `gorm:"column:remark"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
Deleted int `gorm:"column:deleted"`
|
||||
}
|
||||
|
||||
func (Model) TableName() string { return "t_llm_model" }
|
||||
|
||||
// Key 大模型 Key 池表 t_llm_key(只读)
|
||||
//
|
||||
// 选择策略(DAO.PickKey):
|
||||
// - WHERE deleted=0 AND status=1 AND provider_id=?
|
||||
// - AND (expire_time IS NULL OR expire_time > NOW())
|
||||
// - AND (daily_limit = 0 OR today_count < daily_limit)
|
||||
// - ORDER BY weight DESC, today_count ASC
|
||||
// - LIMIT 1
|
||||
//
|
||||
// 注意:本服务不写回 today_count / total_count / fail_count,
|
||||
// 因为这些字段属于 LLM 网关自身的统计职责,由网关在调用时自增。
|
||||
// 我们这边仅消费它们做选择权重。
|
||||
type Key struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
ProviderID int64 `gorm:"column:provider_id"`
|
||||
APIKey string `gorm:"column:api_key"`
|
||||
KeyAlias string `gorm:"column:key_alias"`
|
||||
Weight int `gorm:"column:weight"`
|
||||
DailyLimit int `gorm:"column:daily_limit"`
|
||||
TodayCount int `gorm:"column:today_count"`
|
||||
TotalCount int64 `gorm:"column:total_count"`
|
||||
FailCount int `gorm:"column:fail_count"`
|
||||
Status int `gorm:"column:status"` // 0/1/2
|
||||
LastUsedTime *time.Time `gorm:"column:last_used_time"`
|
||||
ExpireTime *time.Time `gorm:"column:expire_time"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
Deleted int `gorm:"column:deleted"`
|
||||
}
|
||||
|
||||
func (Key) TableName() string { return "t_llm_key" }
|
||||
48
backend/go-service/app/transcribe/model/transcript.go
Normal file
48
backend/go-service/app/transcribe/model/transcript.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Package model 提供 transcribe 模块的数据库模型
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Transcript 会议录制语音转写结果,对应 meeting_transcripts 表
|
||||
//
|
||||
// 一对一关联 meeting_recordings.id(unique 索引),同一段录制只保留最新一份转写结果,
|
||||
// 重新触发转写会就地更新(保持状态机:pending → running → ready/failed)。
|
||||
//
|
||||
// 字段设计:
|
||||
// - Text:拼接后的全文,便于列表/搜索
|
||||
// - Segments:JSONB 数组 [{start,end,text}],便于按时间轴渲染字幕
|
||||
// - Provider/ModelCode:本次实际使用的 LLM 配置快照,调试/审计用
|
||||
// - ErrorMsg:失败原因(HTTP 错误 / Key 不可用 / 文件下载失败等)
|
||||
//
|
||||
// PostgreSQL JSONB 字段在 GORM 里用 string 承载,由 service 层 json.Marshal/Unmarshal。
|
||||
type Transcript struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
RecordingID int64 `json:"recording_id" gorm:"not null;uniqueIndex:uk_transcripts_recording"` // 唯一关联录制
|
||||
RoomID int64 `json:"room_id" gorm:"not null;index:idx_transcripts_room"` // 冗余 room_id 便于按会议批量查
|
||||
Status string `json:"status" gorm:"size:16;not null;default:pending"` // pending / running / ready / failed
|
||||
Text string `json:"text" gorm:"type:text;not null;default:''"`
|
||||
Segments string `json:"segments" gorm:"type:jsonb;not null;default:'[]'"` // JSON 数组字符串
|
||||
Language string `json:"language" gorm:"size:16;not null;default:''"`
|
||||
DurationSec int `json:"duration_sec" gorm:"not null;default:0"`
|
||||
ProviderCode string `json:"provider_code" gorm:"size:32;not null;default:''"` // 来自 t_llm_provider.provider_code
|
||||
ModelCode string `json:"model_code" gorm:"size:64;not null;default:''"` // 来自 t_llm_model.model_code
|
||||
KeyID int64 `json:"key_id" gorm:"not null;default:0"` // 来自 t_llm_key.id,便于追踪 key 用量
|
||||
ErrorMsg string `json:"error_msg" gorm:"size:512;not null;default:''"`
|
||||
StartedAt *time.Time `json:"started_at" gorm:"type:timestamp(0)"`
|
||||
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp(0)"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"`
|
||||
}
|
||||
|
||||
// TableName 指定数据库表名
|
||||
func (Transcript) TableName() string {
|
||||
return "meeting_transcripts"
|
||||
}
|
||||
|
||||
// 状态常量
|
||||
const (
|
||||
TranscriptStatusPending = "pending"
|
||||
TranscriptStatusRunning = "running"
|
||||
TranscriptStatusReady = "ready"
|
||||
TranscriptStatusFailed = "failed"
|
||||
)
|
||||
18
backend/go-service/app/transcribe/provider.go
Normal file
18
backend/go-service/app/transcribe/provider.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Package transcribe 提供 transcribe 模块的 Wire Provider 集合
|
||||
package transcribe
|
||||
|
||||
import (
|
||||
"github.com/echochat/backend/app/transcribe/controller"
|
||||
"github.com/echochat/backend/app/transcribe/dao"
|
||||
"github.com/echochat/backend/app/transcribe/service"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
|
||||
// TranscribeSet 转写模块 Wire Provider 集合
|
||||
var TranscribeSet = wire.NewSet(
|
||||
dao.NewTranscriptDAO,
|
||||
dao.NewLLMConfigDAO,
|
||||
service.NewOpenAICompatibleClient,
|
||||
service.NewTranscribeService,
|
||||
controller.NewTranscribeController,
|
||||
)
|
||||
25
backend/go-service/app/transcribe/router.go
Normal file
25
backend/go-service/app/transcribe/router.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Package transcribe 提供 transcribe 模块的路由注册
|
||||
package transcribe
|
||||
|
||||
import (
|
||||
"github.com/echochat/backend/app/constants"
|
||||
"github.com/echochat/backend/app/transcribe/controller"
|
||||
"github.com/echochat/backend/pkg/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterRoutes 注册 admin 端的转写路由
|
||||
//
|
||||
// 全部走 JWT + admin 角色双重中间件,与 admin 模块同等权限。
|
||||
//
|
||||
// 路由:
|
||||
// - POST /api/v1/admin/recordings/:id/transcribe 提交转写任务
|
||||
// - GET /api/v1/admin/recordings/:id/transcript 查询转写结果
|
||||
func RegisterRoutes(r *gin.Engine, ctrl *controller.TranscribeController, jwtAuth gin.HandlerFunc) {
|
||||
g := r.Group("/api/v1/admin")
|
||||
g.Use(jwtAuth, middleware.RequireRole(constants.RoleAdmin, constants.RoleSuperAdmin))
|
||||
{
|
||||
g.POST("/recordings/:id/transcribe", ctrl.SubmitTranscribe)
|
||||
g.GET("/recordings/:id/transcript", ctrl.GetTranscript)
|
||||
}
|
||||
}
|
||||
@@ -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