视频会议保存
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user