引包目录名调整
This commit is contained in:
195
digital-human/service/async_task_service.go
Normal file
195
digital-human/service/async_task_service.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ai-agent/digital-human/consts"
|
||||
"ai-agent/digital-human/consts/public"
|
||||
"ai-agent/digital-human/dao"
|
||||
"ai-agent/digital-human/model/dto"
|
||||
"ai-agent/digital-human/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type asyncTaskService struct{}
|
||||
|
||||
// AsyncTask 异步任务同步服务(供定时任务/业务轮询调用)
|
||||
var AsyncTask = new(asyncTaskService)
|
||||
|
||||
// Sync
|
||||
// 1) 扫描 digital_human_async_task_ref 中 state=0/1 的记录(业务“生成中”)
|
||||
// 2) 组装 task_id 批量请求 model-asynch /task/get-task-batch
|
||||
// 3) 中间件状态映射到业务状态(业务只维护三态:0生成中/1成功/2失败):
|
||||
// - 中间件 0/1/3(能查到 task_id) -> 业务 0(生成中)
|
||||
// - 中间件 2/4(成功/已下载) -> 业务 1(成功)
|
||||
// - 中间件 查不到 task_id(返回列表缺失) -> 业务 2(失败)
|
||||
//
|
||||
// 4) 绑定表仅用于“待同步列表”,因此:
|
||||
// - 对中间件 0/1/3 不额外写库(减少查询/更新开销)
|
||||
// - 对成功(2/4)与缺失(task_id 查不到)才更新绑定表
|
||||
func (s *asyncTaskService) Sync(ctx context.Context, req *dto.SyncAsyncTasksReq) (res *dto.SyncAsyncTasksRes, err error) {
|
||||
limit := 200
|
||||
if req != nil && req.Limit > 0 {
|
||||
limit = req.Limit
|
||||
}
|
||||
refs, err := dao.AsyncTaskRef.ListPending(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskIDs := make([]string, 0, len(refs))
|
||||
refMap := make(map[string]*entity.AsyncTaskRef, len(refs))
|
||||
for _, r := range refs {
|
||||
if r == nil || r.TaskID == "" {
|
||||
continue
|
||||
}
|
||||
taskIDs = append(taskIDs, r.TaskID)
|
||||
refMap[r.TaskID] = r
|
||||
}
|
||||
|
||||
out := &dto.SyncAsyncTasksRes{
|
||||
Total: len(taskIDs),
|
||||
List: make([]dto.SyncAsyncTasksItem, 0, len(taskIDs)),
|
||||
}
|
||||
if len(taskIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
items, err := getModelAsynchTaskBatch(ctx, taskIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
handled := 0
|
||||
|
||||
for _, it := range items {
|
||||
r := refMap[it.TaskID]
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
seen[it.TaskID] = struct{}{}
|
||||
|
||||
switch it.State {
|
||||
case 0, 1, 3:
|
||||
// 排队中/执行中/失败(可能重试):业务侧仍视为生成中,不更新绑定表,减少更新开销
|
||||
case 2, 4:
|
||||
// 成功/已下载:业务侧写入 oss_file 并标记成功
|
||||
if it.OssFile == "" {
|
||||
errMsg := "中间件返回空oss地址"
|
||||
_ = s.updateBizFailed(ctx, r, errMsg)
|
||||
_, _ = dao.AsyncTaskRef.UpdateByTaskID(ctx, it.TaskID, gdb.Map{
|
||||
entity.AsyncTaskRefCol.State: it.State,
|
||||
entity.AsyncTaskRefCol.OssFile: "",
|
||||
entity.AsyncTaskRefCol.ErrorMsg: errMsg,
|
||||
})
|
||||
out.List = append(out.List, dto.SyncAsyncTasksItem{
|
||||
TaskID: it.TaskID,
|
||||
State: it.State,
|
||||
TableName: r.TableName,
|
||||
BizID: fmt.Sprintf("%d", r.BizID),
|
||||
OssFile: "",
|
||||
ErrorMsg: errMsg,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if err := s.updateBizSuccess(ctx, r, it.OssFile); err != nil {
|
||||
errMsg := fmt.Sprintf("生成音频失败: %v", err)
|
||||
_ = s.updateBizFailed(ctx, r, errMsg)
|
||||
_, _ = dao.AsyncTaskRef.UpdateByTaskID(ctx, it.TaskID, gdb.Map{
|
||||
entity.AsyncTaskRefCol.State: it.State,
|
||||
entity.AsyncTaskRefCol.OssFile: it.OssFile,
|
||||
entity.AsyncTaskRefCol.ErrorMsg: errMsg,
|
||||
})
|
||||
out.List = append(out.List, dto.SyncAsyncTasksItem{
|
||||
TaskID: it.TaskID,
|
||||
State: it.State,
|
||||
TableName: r.TableName,
|
||||
BizID: fmt.Sprintf("%d", r.BizID),
|
||||
OssFile: it.OssFile,
|
||||
ErrorMsg: errMsg,
|
||||
})
|
||||
continue
|
||||
}
|
||||
handled++
|
||||
_, _ = dao.AsyncTaskRef.UpdateByTaskID(ctx, it.TaskID, gdb.Map{
|
||||
entity.AsyncTaskRefCol.State: it.State,
|
||||
entity.AsyncTaskRefCol.OssFile: it.OssFile,
|
||||
entity.AsyncTaskRefCol.ErrorMsg: "",
|
||||
})
|
||||
default:
|
||||
// 其他状态:不处理
|
||||
}
|
||||
|
||||
out.List = append(out.List, dto.SyncAsyncTasksItem{
|
||||
TaskID: it.TaskID,
|
||||
State: it.State,
|
||||
TableName: r.TableName,
|
||||
BizID: fmt.Sprintf("%d", r.BizID),
|
||||
OssFile: it.OssFile,
|
||||
ErrorMsg: "",
|
||||
})
|
||||
}
|
||||
|
||||
// 处理“查不到 task_id”的情况:
|
||||
// 中间件对失败重试耗尽的任务会硬删除,批量接口不会返回该 task_id。
|
||||
// 业务侧把这种情况视为失败终态,并软删除绑定记录,避免重复轮询。
|
||||
for _, taskID := range taskIDs {
|
||||
if _, ok := seen[taskID]; ok {
|
||||
continue
|
||||
}
|
||||
r := refMap[taskID]
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
msg := "模型任务不存在已失败"
|
||||
_ = s.updateBizFailed(ctx, r, msg)
|
||||
_, _ = dao.AsyncTaskRef.UpdateByTaskID(ctx, taskID, gdb.Map{
|
||||
entity.AsyncTaskRefCol.State: 3,
|
||||
entity.AsyncTaskRefCol.ErrorMsg: msg,
|
||||
"deleted_at": gtime.Now(),
|
||||
})
|
||||
out.List = append(out.List, dto.SyncAsyncTasksItem{
|
||||
TaskID: taskID,
|
||||
State: 3,
|
||||
TableName: r.TableName,
|
||||
BizID: fmt.Sprintf("%d", r.BizID),
|
||||
OssFile: "",
|
||||
ErrorMsg: msg,
|
||||
})
|
||||
}
|
||||
|
||||
out.Handled = handled
|
||||
g.Log().Infof(ctx, "[AsyncTask.Sync] total=%d handled=%d", out.Total, out.Handled)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// updateBizSuccess 更新业务侧状态为成功
|
||||
func (s *asyncTaskService) updateBizSuccess(ctx context.Context, ref *entity.AsyncTaskRef, ossFile string) error {
|
||||
switch ref.TableName {
|
||||
case public.TableNameAudio:
|
||||
_, err := dao.Audio.UpdateStatus(ctx, ref.BizID, consts.AudioStatusSuccess, "", ossFile, 0, "")
|
||||
return err
|
||||
case public.TableNameCustomVoice:
|
||||
_, err := dao.CustomVoice.UpdateStatus(ctx, ref.BizID, 1, "", ossFile)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("未知 table_name=%s", ref.TableName)
|
||||
}
|
||||
}
|
||||
|
||||
// updateBizFailed 更新业务侧状态为失败
|
||||
func (s *asyncTaskService) updateBizFailed(ctx context.Context, ref *entity.AsyncTaskRef, msg string) error {
|
||||
switch ref.TableName {
|
||||
case public.TableNameAudio:
|
||||
_, err := dao.Audio.UpdateStatus(ctx, ref.BizID, consts.AudioStatusFailed, msg, "", 0, "")
|
||||
return err
|
||||
case public.TableNameCustomVoice:
|
||||
_, err := dao.CustomVoice.UpdateStatus(ctx, ref.BizID, 2, msg, "")
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
268
digital-human/service/audio_service.go
Normal file
268
digital-human/service/audio_service.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
"ai-agent/digital-human/consts"
|
||||
"ai-agent/digital-human/consts/public"
|
||||
"ai-agent/digital-human/dao"
|
||||
"ai-agent/digital-human/model/dto"
|
||||
"ai-agent/digital-human/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type audio struct{}
|
||||
|
||||
// Audio 音频服务
|
||||
var Audio = new(audio)
|
||||
|
||||
// UploadFileResponse OSS 文件上传响应结构
|
||||
type UploadFileResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
FileURL string `json:"fileURL" dc:"上传地址"`
|
||||
FileSize int `json:"fileSize" dc:"文件大小"`
|
||||
FileName string `json:"fileName" dc:"文件名称"`
|
||||
FileFormat string `json:"fileFormat" dc:"文件格式"`
|
||||
FileAddressPrefix string `json:"fileAddressPrefix"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Create 创建音频
|
||||
func (s *audio) Create(ctx context.Context, req *dto.CreateAudioReq) (res *dto.CreateAudioRes, err error) {
|
||||
// 设置默认音色
|
||||
if req.Voice == "" {
|
||||
req.Voice = "Serena" // 默认音色
|
||||
}
|
||||
if req.VoiceType == "" {
|
||||
req.VoiceType = "Preset" // 默认预设音色
|
||||
}
|
||||
|
||||
// 如果是自定义音色,验证音色是否存在
|
||||
if req.VoiceType == "custom" && req.CustomVoice != "" {
|
||||
customVoiceID := gconv.Int64(req.CustomVoice)
|
||||
_, err := dao.CustomVoice.GetOne(ctx, customVoiceID)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "自定义音色不存在: %s", req.CustomVoice)
|
||||
}
|
||||
}
|
||||
|
||||
// 插入数据库(初始状态为生成中)
|
||||
audioID, err := dao.Audio.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 通过 model-asynch 创建异步任务(由中间件执行模型调用与产物落 OSS)
|
||||
// 约定:
|
||||
// - custom(克隆音色) -> base 模型(需要参考音频/参考文本) 否则 -> customvoice 模型
|
||||
var taskID string
|
||||
if req.VoiceType == "custom" {
|
||||
customVoiceID := gconv.Int64(req.CustomVoice)
|
||||
// 1. 先获取自定义音色详情
|
||||
cv, err := dao.CustomVoice.GetOne(ctx, customVoiceID)
|
||||
if err != nil {
|
||||
_, _ = dao.Audio.UpdateStatus(ctx, audioID, consts.AudioStatusFailed, "获取自定义音色失败: "+err.Error(), "", 0, "")
|
||||
return nil, err
|
||||
}
|
||||
// 2. 调用模型生成音频
|
||||
refAudioBase64 := base64.StdEncoding.EncodeToString(cv.ReferenceAudio)
|
||||
xVectorOnlyMode := false
|
||||
if cv.Text == "" {
|
||||
xVectorOnlyMode = true
|
||||
}
|
||||
taskID, err = TTS.CreateBaseTask(asyncCtx(ctx), req.ScriptText, "Auto", cv.Text, cv.OssFile, refAudioBase64, xVectorOnlyMode, 1.0)
|
||||
} else {
|
||||
// 1. 调用模型生成音频
|
||||
taskID, err = TTS.CreateCustomVoiceTask(asyncCtx(ctx), req.ScriptText, req.Voice, "Auto", "", 1.0)
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = dao.Audio.UpdateStatus(ctx, audioID, consts.AudioStatusFailed, "创建异步任务失败: "+err.Error(), "", 0, "")
|
||||
return nil, err
|
||||
}
|
||||
_, _ = dao.AsyncTaskRef.Insert(ctx, &entity.AsyncTaskRef{
|
||||
TaskID: taskID,
|
||||
State: 0,
|
||||
TableName: public.TableNameAudio,
|
||||
BizID: audioID,
|
||||
})
|
||||
res = &dto.CreateAudioRes{
|
||||
Id: audioID,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取音频列表
|
||||
func (s *audio) List(ctx context.Context, req *dto.ListAudioReq) (res *dto.ListAudioRes, err error) {
|
||||
audioList, total, err := dao.Audio.List(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res = &dto.ListAudioRes{
|
||||
Total: int64(total),
|
||||
List: make([]*dto.AudioListItem, 0, len(audioList)),
|
||||
}
|
||||
for _, audio := range audioList {
|
||||
res.List = append(res.List, &dto.AudioListItem{
|
||||
ID: audio.Id,
|
||||
Name: audio.Name,
|
||||
Description: audio.Description,
|
||||
ScriptText: audio.ScriptText,
|
||||
AudioURL: audio.AudioURL,
|
||||
Status: audio.Status,
|
||||
ErrorMsg: audio.ErrorMsg,
|
||||
Duration: audio.Duration,
|
||||
ExternalID: audio.ExternalID,
|
||||
Voice: audio.Voice,
|
||||
VoiceType: audio.VoiceType,
|
||||
CustomVoice: audio.CustomVoice,
|
||||
CreatedAt: audio.CreatedAt,
|
||||
UpdatedAt: audio.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetOne 获取单个音频
|
||||
func (s *audio) GetOne(ctx context.Context, id int64) (*dto.GetAudioRes, error) {
|
||||
audioOne, err := dao.Audio.GetOne(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.GetAudioRes{
|
||||
ID: audioOne.Id,
|
||||
Name: audioOne.Name,
|
||||
Description: audioOne.Description,
|
||||
ScriptText: audioOne.ScriptText,
|
||||
AudioURL: audioOne.AudioURL,
|
||||
Status: audioOne.Status,
|
||||
ErrorMsg: audioOne.ErrorMsg,
|
||||
Duration: audioOne.Duration,
|
||||
ExternalID: audioOne.ExternalID,
|
||||
Voice: audioOne.Voice,
|
||||
VoiceType: audioOne.VoiceType,
|
||||
CustomVoice: audioOne.CustomVoice,
|
||||
CreatedAt: audioOne.CreatedAt,
|
||||
UpdatedAt: audioOne.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update 更新音频
|
||||
func (s *audio) Update(ctx context.Context, req *dto.UpdateAudioReq) (err error) {
|
||||
// 先获取原始音频信息
|
||||
audioOne, err := dao.Audio.GetOne(ctx, req.ID)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "获取原始音频信息失败")
|
||||
}
|
||||
// 修改字段
|
||||
if !g.IsEmpty(req.Name) {
|
||||
audioOne.Name = req.Name
|
||||
}
|
||||
if !g.IsEmpty(req.Description) {
|
||||
audioOne.Description = req.Description
|
||||
}
|
||||
if !g.IsEmpty(req.Voice) {
|
||||
audioOne.Voice = req.Voice
|
||||
}
|
||||
if !g.IsEmpty(req.VoiceType) {
|
||||
audioOne.VoiceType = req.VoiceType
|
||||
}
|
||||
if !g.IsEmpty(req.CustomVoice) {
|
||||
audioOne.CustomVoice = req.CustomVoice
|
||||
}
|
||||
_, err = dao.Audio.Update(ctx, req.ID, audioOne)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete 删除音频
|
||||
func (s *audio) Delete(ctx context.Context, id int64) error {
|
||||
_, err := dao.Audio.Delete(ctx, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate 重新生成音频
|
||||
func (s *audio) Generate(ctx context.Context, req *dto.GenerateAudioReq) (res *dto.GenerateAudioRes, err error) {
|
||||
// 获取音频信息
|
||||
audioOne, err := dao.Audio.GetOne(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取音频信息失败")
|
||||
}
|
||||
|
||||
// 重置状态为生成中
|
||||
_, err = dao.Audio.UpdateStatus(ctx, req.ID, consts.AudioStatusGenerating, "", "", 0, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建请求
|
||||
createReq := &dto.CreateAudioReq{
|
||||
Name: audioOne.Name,
|
||||
Description: audioOne.Description,
|
||||
ScriptText: audioOne.ScriptText,
|
||||
Voice: audioOne.Voice,
|
||||
VoiceType: audioOne.VoiceType,
|
||||
CustomVoice: audioOne.CustomVoice,
|
||||
}
|
||||
|
||||
// 异步重新生成音频
|
||||
var taskID string
|
||||
if createReq.VoiceType == "custom" {
|
||||
customVoiceID := gconv.Int64(createReq.CustomVoice)
|
||||
cv, err := dao.CustomVoice.GetOne(ctx, customVoiceID)
|
||||
if err != nil {
|
||||
_, _ = dao.Audio.UpdateStatus(ctx, req.ID, consts.AudioStatusFailed, "获取自定义音色失败: "+err.Error(), "", 0, "")
|
||||
return nil, err
|
||||
}
|
||||
refAudioBase64 := ""
|
||||
if cv != nil && len(cv.ReferenceAudio) > 0 {
|
||||
refAudioBase64 = base64.StdEncoding.EncodeToString(cv.ReferenceAudio)
|
||||
}
|
||||
refText := ""
|
||||
if cv != nil {
|
||||
refText = cv.Text
|
||||
}
|
||||
xVectorOnlyMode := false
|
||||
if refText == "" {
|
||||
xVectorOnlyMode = true
|
||||
}
|
||||
taskID, err = TTS.CreateBaseTask(asyncCtx(ctx), createReq.ScriptText, "Auto", refText, cv.OssFile, refAudioBase64, xVectorOnlyMode, 1.0)
|
||||
} else {
|
||||
taskID, err = TTS.CreateCustomVoiceTask(asyncCtx(ctx), createReq.ScriptText, createReq.Voice, "Auto", "", 1.0)
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = dao.Audio.UpdateStatus(ctx, req.ID, consts.AudioStatusFailed, "创建异步任务失败: "+err.Error(), "", 0, "")
|
||||
return nil, err
|
||||
}
|
||||
_, _ = dao.AsyncTaskRef.Insert(ctx, &entity.AsyncTaskRef{
|
||||
TaskID: taskID,
|
||||
State: 0,
|
||||
TableName: public.TableNameAudio,
|
||||
BizID: req.ID,
|
||||
})
|
||||
|
||||
res = &dto.GenerateAudioRes{
|
||||
TaskID: gconv.String(req.ID),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetStatusOptions 获取状态选项
|
||||
func (s *audio) GetStatusOptions(ctx context.Context, req *dto.GetAudioStatusOptionsReq) (res *dto.GetAudioStatusOptionsRes, err error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
res = new(dto.GetAudioStatusOptionsRes)
|
||||
res.Options = consts.GetAllAudioStatusKeyValue()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// TTS 文本转语音(使用 Qwen3-TTS)
|
||||
func (s *audio) TTS(ctx context.Context, req *dto.TTSReq) (res *dto.TTSRes, err error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
return nil, gerror.New("该接口已迁移为异步:请使用 CreateAudio 创建异步任务并通过轮询/批量领取获取结果")
|
||||
}
|
||||
106
digital-human/service/custom_voice_service.go
Normal file
106
digital-human/service/custom_voice_service.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
"ai-agent/digital-human/consts/public"
|
||||
"ai-agent/digital-human/dao"
|
||||
"ai-agent/digital-human/model/dto"
|
||||
"ai-agent/digital-human/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type customVoice struct{}
|
||||
|
||||
// CustomVoice 自定义音色服务
|
||||
var CustomVoice = new(customVoice)
|
||||
|
||||
// CreateCustomVoice 创建自定义音色
|
||||
func (s *customVoice) CreateCustomVoice(ctx context.Context, req *dto.CreateCustomVoiceReq) (res *dto.CreateCustomVoiceRes, err error) {
|
||||
g.Log().Infof(ctx, "创建自定义音色: name=%s, voiceType=%s", req.Name, req.VoiceType)
|
||||
// 插入数据库(状态:生成中)
|
||||
voiceID, err := dao.CustomVoice.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch req.VoiceType {
|
||||
case "design":
|
||||
// 设计音频:按模型约定只传 text + instruct
|
||||
taskID, err := TTS.CreateVoiceDesignTask(asyncCtx(ctx), req.Text, req.Description, "", 0)
|
||||
if err != nil {
|
||||
_, _ = dao.CustomVoice.UpdateStatus(ctx, voiceID, 2, "创建异步任务失败: "+err.Error(), "")
|
||||
return nil, err
|
||||
}
|
||||
_, _ = dao.AsyncTaskRef.Insert(ctx, &entity.AsyncTaskRef{
|
||||
TaskID: taskID,
|
||||
State: 0,
|
||||
TableName: public.TableNameCustomVoice,
|
||||
BizID: voiceID,
|
||||
})
|
||||
res = &dto.CreateCustomVoiceRes{VoiceID: gconv.String(voiceID)}
|
||||
g.Log().Infof(ctx, "自定义音色创建成功: voiceId=%d taskId=%s", voiceID, taskID)
|
||||
case "clone":
|
||||
// TODO : 克隆音色:使用语音转文字暂预留,后续找模型对应处理
|
||||
refAudioBase64 := base64.StdEncoding.EncodeToString(req.ReferenceAudio)
|
||||
taskID, err := TTS.SpeechToText(asyncCtx(ctx), refAudioBase64)
|
||||
if err != nil {
|
||||
_, _ = dao.CustomVoice.UpdateStatus(ctx, voiceID, 2, "创建异步任务失败: "+err.Error(), "")
|
||||
return nil, err
|
||||
}
|
||||
_, _ = dao.AsyncTaskRef.Insert(ctx, &entity.AsyncTaskRef{
|
||||
TaskID: taskID,
|
||||
State: 0,
|
||||
TableName: public.TableNameCustomVoice,
|
||||
BizID: voiceID,
|
||||
})
|
||||
res = &dto.CreateCustomVoiceRes{VoiceID: gconv.String(voiceID)}
|
||||
g.Log().Infof(ctx, "克隆音色成功: voiceId=%d taskId=%s", voiceID, taskID)
|
||||
default:
|
||||
return nil, gerror.New("不支持的音色类型")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ListCustomVoices 获取自定义音色列表
|
||||
func (s *customVoice) ListCustomVoices(ctx context.Context, req *dto.ListCustomVoiceReq) (res *dto.ListCustomVoiceRes, err error) {
|
||||
customVoices, total, err := dao.CustomVoice.List(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = &dto.ListCustomVoiceRes{
|
||||
Total: int64(total),
|
||||
List: make([]*dto.CustomVoiceItem, 0, len(customVoices)),
|
||||
}
|
||||
|
||||
for _, cv := range customVoices {
|
||||
res.List = append(res.List, dao.CustomVoice.GetCustomVoiceItem(cv))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteCustomVoice 删除自定义音色
|
||||
func (s *customVoice) DeleteCustomVoice(ctx context.Context, req *dto.DeleteCustomVoiceReq) (err error) {
|
||||
// 验证音色是否存在
|
||||
voiceID := gconv.Int64(req.VoiceID)
|
||||
|
||||
_, err = dao.CustomVoice.GetOne(ctx, voiceID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "音色不存在: %s", req.VoiceID)
|
||||
}
|
||||
|
||||
// 删除音色
|
||||
_, err = dao.CustomVoice.Delete(ctx, voiceID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "删除音色失败: %s", req.VoiceID)
|
||||
}
|
||||
|
||||
g.Log().Infof(ctx, "自定义音色删除成功: voiceId=%s", req.VoiceID)
|
||||
return nil
|
||||
}
|
||||
179
digital-human/service/digitalhuman_service.go
Normal file
179
digital-human/service/digitalhuman_service.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"ai-agent/digital-human/consts"
|
||||
"ai-agent/digital-human/dao"
|
||||
"ai-agent/digital-human/model/dto"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type digitalHuman struct{}
|
||||
|
||||
// DigitalHuman 数字人形象服务
|
||||
var DigitalHuman = new(digitalHuman)
|
||||
|
||||
// Create 创建数字人形象
|
||||
func (s *digitalHuman) Create(ctx context.Context, req *dto.CreateDigitalHumanReq) (res *dto.CreateDigitalHumanRes, err error) {
|
||||
count, err := dao.DigitalHuman.Count(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, errors.New("数字人形象名称已存在")
|
||||
}
|
||||
// 插入数据库
|
||||
ids, err := dao.DigitalHuman.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// PostgreSQL 使用 int64
|
||||
id := ids[0].(int64)
|
||||
res = &dto.CreateDigitalHumanRes{
|
||||
Id: id,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// List 获取数字人形象列表
|
||||
func (s *digitalHuman) List(ctx context.Context, req *dto.ListDigitalHumanReq) (res *dto.ListDigitalHumanRes, error error) {
|
||||
digitalHumanList, total, err := dao.DigitalHuman.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListDigitalHumanRes{
|
||||
Total: total,
|
||||
}
|
||||
b, err := json.Marshal(digitalHumanList)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(b, &res.List)
|
||||
return
|
||||
}
|
||||
|
||||
// GetOne 获取单个数字人形象
|
||||
func (s *digitalHuman) GetOne(ctx context.Context, id int64) (*dto.GetDigitalHumanRes, error) {
|
||||
digitalHumanOne, err := dao.DigitalHuman.GetOne(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var createdAt, updatedAt *gtime.Time
|
||||
if digitalHumanOne.CreatedAt != nil {
|
||||
createdAt = digitalHumanOne.CreatedAt
|
||||
}
|
||||
if digitalHumanOne.UpdatedAt != nil {
|
||||
updatedAt = digitalHumanOne.UpdatedAt
|
||||
}
|
||||
return &dto.GetDigitalHumanRes{
|
||||
ID: digitalHumanOne.Id,
|
||||
Name: digitalHumanOne.Name,
|
||||
Description: digitalHumanOne.Description,
|
||||
ImageURL: digitalHumanOne.AvatarURL,
|
||||
VideoURL: digitalHumanOne.VideoURL,
|
||||
Status: digitalHumanOne.Status,
|
||||
Tags: digitalHumanOne.Tags,
|
||||
Gender: digitalHumanOne.Gender,
|
||||
Age: digitalHumanOne.Age,
|
||||
Style: digitalHumanOne.Style,
|
||||
ExternalID: digitalHumanOne.ExternalID,
|
||||
Metadata: digitalHumanOne.Metadata,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update 更新数字人形象
|
||||
func (s *digitalHuman) Update(ctx context.Context, req *dto.UpdateDigitalHumanReq) error {
|
||||
// 先获取原始数字人形象信息
|
||||
digitalHumanOne, err := dao.DigitalHuman.GetOne(ctx, req.ID)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "获取原始数字人形象信息失败")
|
||||
}
|
||||
// 修改字段
|
||||
if !g.IsEmpty(req.Name) {
|
||||
digitalHumanOne.Name = req.Name
|
||||
}
|
||||
if !g.IsEmpty(req.Description) {
|
||||
digitalHumanOne.Description = req.Description
|
||||
}
|
||||
if !g.IsEmpty(req.ImageURL) {
|
||||
digitalHumanOne.AvatarURL = req.ImageURL
|
||||
}
|
||||
if !g.IsEmpty(req.VideoURL) {
|
||||
digitalHumanOne.VideoURL = req.VideoURL
|
||||
}
|
||||
digitalHumanOne.Status = req.Status
|
||||
if req.Tags != nil {
|
||||
digitalHumanOne.Tags = req.Tags
|
||||
}
|
||||
if !g.IsEmpty(req.Gender) {
|
||||
digitalHumanOne.Gender = req.Gender
|
||||
}
|
||||
if !g.IsEmpty(req.Age) {
|
||||
digitalHumanOne.Age = req.Age
|
||||
}
|
||||
if !g.IsEmpty(req.Style) {
|
||||
digitalHumanOne.Style = req.Style
|
||||
}
|
||||
if !g.IsEmpty(req.ExternalID) {
|
||||
digitalHumanOne.ExternalID = req.ExternalID
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
digitalHumanOne.Metadata = req.Metadata
|
||||
}
|
||||
|
||||
return dao.DigitalHuman.Update(ctx, req.ID, digitalHumanOne)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新数字人形象状态
|
||||
func (s *digitalHuman) UpdateStatus(ctx context.Context, id int64, status consts.DigitalHumanStatus) error {
|
||||
_, err := dao.DigitalHuman.UpdateStatus(ctx, id, status)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete 删除数字人形象
|
||||
func (s *digitalHuman) Delete(ctx context.Context, id int64) error {
|
||||
return dao.DigitalHuman.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// GetStatusOptions 获取状态选项
|
||||
func (s *digitalHuman) GetStatusOptions(ctx context.Context, req *dto.GetDigitalHumanStatusOptionsReq) (res *dto.GetDigitalHumanStatusOptionsRes, err error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
res = new(dto.GetDigitalHumanStatusOptionsRes)
|
||||
res.Options = consts.GetAllStatusKeyValue()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetGenderOptions 获取性别选项
|
||||
func (s *digitalHuman) GetGenderOptions(ctx context.Context, req *dto.GetGenderOptionsReq) (res *dto.GetGenderOptionsRes, err error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
res = new(dto.GetGenderOptionsRes)
|
||||
res.Options = consts.GetAllGenderKeyValue()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetAgeOptions 获取年龄段选项
|
||||
func (s *digitalHuman) GetAgeOptions(ctx context.Context, req *dto.GetAgeOptionsReq) (res *dto.GetAgeOptionsRes, err error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
res = new(dto.GetAgeOptionsRes)
|
||||
res.Options = consts.GetAllAgeKeyValue()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetStyleOptions 获取风格选项
|
||||
func (s *digitalHuman) GetStyleOptions(ctx context.Context, req *dto.GetStyleOptionsReq) (res *dto.GetStyleOptionsRes, err error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
res = new(dto.GetStyleOptionsRes)
|
||||
res.Options = consts.GetAllStyleKeyValue()
|
||||
return res, nil
|
||||
}
|
||||
219
digital-human/service/http_wrapper.go
Normal file
219
digital-human/service/http_wrapper.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
stdhttp "net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
commonHttp "gitea.com/red-future/common/http"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var commonHttpTransportMu sync.Mutex
|
||||
|
||||
// asyncCtx 异步上下文处理
|
||||
func asyncCtx(ctx context.Context) context.Context {
|
||||
asyncCtx := context.WithoutCancel(ctx)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
if token := r.Header.Get("Authorization"); token != "" {
|
||||
asyncCtx = context.WithValue(asyncCtx, "token", token)
|
||||
}
|
||||
}
|
||||
if user, uErr := utils.GetUserInfo(ctx); uErr == nil && user != nil {
|
||||
asyncCtx = context.WithValue(asyncCtx, "user", user)
|
||||
}
|
||||
return asyncCtx
|
||||
}
|
||||
|
||||
// setCommonHttpResponseHeaderTimeout 调整公共 HTTP 客户端响应头超时,避免长时推理被 30s 默认值打断。
|
||||
func setCommonHttpResponseHeaderTimeout(d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
commonHttpTransportMu.Lock()
|
||||
defer commonHttpTransportMu.Unlock()
|
||||
if tr, ok := commonHttp.Httpclient.Transport.(*stdhttp.Transport); ok && tr != nil {
|
||||
if tr.ResponseHeaderTimeout < d {
|
||||
tr.ResponseHeaderTimeout = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// forwardHeaders 透传调用链路中必须的头信息,优先使用异步上下文里固化的 token。
|
||||
func forwardHeaders(ctx context.Context) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
if token, ok := ctx.Value("token").(string); ok && token != "" {
|
||||
headers["Authorization"] = token
|
||||
}
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
if headers["Authorization"] == "" {
|
||||
if token := r.Header.Get("Authorization"); token != "" {
|
||||
headers["Authorization"] = token
|
||||
}
|
||||
}
|
||||
if userInfo := r.Header.Get("X-User-Info"); userInfo != "" {
|
||||
headers["X-User-Info"] = userInfo
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// commonPostJSON 使用 common/http 的底层客户端直连 JSON 接口,适配非统一响应包装结构。
|
||||
func commonPostJSON(ctx context.Context, url string, headers map[string]string, req any, resp any) error {
|
||||
client := commonHttp.Httpclient.Clone().ContentJson()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if d := time.Until(deadline); d > 0 {
|
||||
client.SetTimeout(d)
|
||||
}
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
client.SetHeaderMap(headers)
|
||||
}
|
||||
r, err := client.DoRequest(ctx, stdhttp.MethodPost, url, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取响应失败")
|
||||
}
|
||||
if r.StatusCode != stdhttp.StatusOK {
|
||||
return gerror.Newf("HTTP状态码异常: %d, body: %s", r.StatusCode, string(body))
|
||||
}
|
||||
if err := json.Unmarshal(body, resp); err != nil {
|
||||
return gerror.Wrapf(err, "解析响应失败, body: %s", string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commonPostMultipartFile(ctx context.Context, url string, headers map[string]string, form map[string]string, fileField string, filePath string, resp any) error {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
for k, v := range form {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if err := writer.WriteField(k, v); err != nil {
|
||||
return gerror.Wrapf(err, "写入表单字段失败: %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "打开文件失败: %s", filePath)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
part, err := writer.CreateFormFile(fileField, filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "创建表单文件失败: %s", fileField)
|
||||
}
|
||||
if _, err := io.Copy(part, f); err != nil {
|
||||
return gerror.Wrap(err, "写入文件内容失败")
|
||||
}
|
||||
|
||||
contentType := writer.FormDataContentType()
|
||||
if err := writer.Close(); err != nil {
|
||||
return gerror.Wrap(err, "关闭表单写入器失败")
|
||||
}
|
||||
|
||||
client := commonHttp.Httpclient.Clone()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if d := time.Until(deadline); d > 0 {
|
||||
client.SetTimeout(d)
|
||||
}
|
||||
}
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
headers["Content-Type"] = contentType
|
||||
client.SetHeaderMap(headers)
|
||||
|
||||
r, err := client.DoRequest(ctx, stdhttp.MethodPost, url, body.Bytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取响应失败")
|
||||
}
|
||||
if r.StatusCode != stdhttp.StatusOK {
|
||||
return gerror.Newf("HTTP状态码异常: %d, body: %s", r.StatusCode, string(raw))
|
||||
}
|
||||
if err := json.Unmarshal(raw, resp); err != nil {
|
||||
return gerror.Wrapf(err, "解析响应失败, body: %s", string(raw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------- model-asynch 调用封装 --------------------------
|
||||
|
||||
const modelAsynchServiceName = "model-asynch"
|
||||
|
||||
type modelAsynchCreateTaskReq struct {
|
||||
ModelName string `json:"modelName"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
RequestPayload any `json:"requestPayload"`
|
||||
}
|
||||
|
||||
type modelAsynchCreateTaskRes struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
// createModelAsynchTask 调用 model-asynch 创建任务
|
||||
// 注意:路由以 GoFrame 默认输出为准(通常为 /task/create-task)
|
||||
func createModelAsynchTask(ctx context.Context, modelName string, payload any, inputRef string) (taskID string, err error) {
|
||||
taskUrl := g.Cfg().MustGet(ctx, "model-asynch.addr", "127.0.0.1:8080")
|
||||
headers := forwardHeaders(ctx)
|
||||
req := &modelAsynchCreateTaskReq{
|
||||
ModelName: modelName,
|
||||
InputRef: inputRef,
|
||||
RequestPayload: payload,
|
||||
}
|
||||
var res modelAsynchCreateTaskRes
|
||||
if err := commonHttp.Post(ctx, fmt.Sprintf("%s/task/createTask", taskUrl), headers, &res, req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return res.TaskID, nil
|
||||
}
|
||||
|
||||
type modelAsynchBatchReq struct {
|
||||
TaskIDs []string `json:"taskIds"`
|
||||
}
|
||||
|
||||
type modelAsynchBatchItem struct {
|
||||
TaskID string `json:"taskId"`
|
||||
State int `json:"state"`
|
||||
OssFile string `json:"ossFile"`
|
||||
}
|
||||
|
||||
type modelAsynchBatchRes struct {
|
||||
List []modelAsynchBatchItem `json:"list"`
|
||||
}
|
||||
|
||||
// getModelAsynchTaskBatch 批量查询任务(成功 2->4 的逻辑由中间件内部处理)
|
||||
func getModelAsynchTaskBatch(ctx context.Context, taskIDs []string) (items []modelAsynchBatchItem, err error) {
|
||||
taskUrl := g.Cfg().MustGet(ctx, "model-asynch.addr", "127.0.0.1:8080")
|
||||
headers := forwardHeaders(ctx)
|
||||
req := &modelAsynchBatchReq{TaskIDs: taskIDs}
|
||||
var res modelAsynchBatchRes
|
||||
if err := commonHttp.Post(ctx, fmt.Sprintf("%s/task/getTaskBatch", taskUrl), headers, &res, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.List, nil
|
||||
}
|
||||
117
digital-human/service/tts_service.go
Normal file
117
digital-human/service/tts_service.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
|
||||
"ai-agent/digital-human/consts/public"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type tts struct{}
|
||||
|
||||
// TTS 统一的模型异步调用封装(通过 model-asynch 中间件)
|
||||
var TTS = new(tts)
|
||||
|
||||
// CreateVoiceDesignTask 设计音频任务(VoiceDesign)
|
||||
func (s *tts) CreateVoiceDesignTask(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
instruct string,
|
||||
language string, // 空则 Auto
|
||||
speed float64, // <=0 则 1.0
|
||||
) (taskID string, err error) {
|
||||
if language == "" {
|
||||
language = "Auto"
|
||||
}
|
||||
if speed <= 0 {
|
||||
speed = 1.0
|
||||
}
|
||||
payload := map[string]any{
|
||||
"text": text,
|
||||
"language": language,
|
||||
"instruct": instruct,
|
||||
"speed": speed,
|
||||
"response_format": "wav",
|
||||
}
|
||||
g.Log().Info(ctx, "[CreateVoiceDesignTask] %v", payload)
|
||||
return createModelAsynchTask(ctx, public.ModelNameVoiceDesign, payload, "")
|
||||
}
|
||||
|
||||
// CreateCustomVoiceTask 预设音色(CustomVoice)任务
|
||||
// - speaker: 预设说话人(如 Vivian/Serena/Ryan/...)
|
||||
// - instruct: 可选,情绪/风格控制
|
||||
func (s *tts) CreateCustomVoiceTask(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
speaker string,
|
||||
language string, // 例如 "Chinese"/"English"/"Auto",空则默认 "Auto"
|
||||
instruct string, // 可空
|
||||
speed float64, // 0.5~2.0,<=0 则默认 1.0
|
||||
) (taskID string, err error) {
|
||||
if language == "" {
|
||||
language = "Auto"
|
||||
}
|
||||
if speed <= 0 {
|
||||
speed = 1.0
|
||||
}
|
||||
payload := map[string]any{
|
||||
"text": text,
|
||||
"language": language,
|
||||
"speaker": speaker,
|
||||
"instruct": instruct,
|
||||
"speed": speed,
|
||||
"response_format": "wav", // 建议统一用 wav
|
||||
}
|
||||
g.Log().Info(ctx, "[CreateCustomVoiceTask] %v", payload)
|
||||
return createModelAsynchTask(ctx, public.ModelNameCustomVoice, payload, "")
|
||||
}
|
||||
|
||||
// CreateBaseTask 声音克隆(Base / clone)任务
|
||||
// 说明:ref_audio_url 与 ref_audio_base64 二选一
|
||||
func (s *tts) CreateBaseTask(
|
||||
ctx context.Context,
|
||||
text string,
|
||||
language string, // 例如 "Chinese"/"English"/"Auto",空则默认 "Auto"
|
||||
refText string, // 当 xVectorOnlyMode=false 时必填
|
||||
refAudioURL string, // 可空
|
||||
refAudioBase64 string, // 可空(不带 data: 前缀也可以)
|
||||
xVectorOnlyMode bool, // true=不需要 refText,但质量可能下降
|
||||
speed float64, // 0.5~2.0,<=0 则默认 1.0
|
||||
) (taskID string, err error) {
|
||||
if language == "" {
|
||||
language = "Auto"
|
||||
}
|
||||
if speed <= 0 {
|
||||
speed = 1.0
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"text": text,
|
||||
"language": language,
|
||||
"ref_text": refText,
|
||||
"ref_audio_url": refAudioURL,
|
||||
"ref_audio_base64": refAudioBase64,
|
||||
"x_vector_only_mode": xVectorOnlyMode,
|
||||
"speed": speed,
|
||||
"response_format": "wav",
|
||||
}
|
||||
g.Log().Info(ctx, "[CreateBaseTask] %v", payload)
|
||||
return createModelAsynchTask(ctx, public.ModelNameBase, payload, "")
|
||||
}
|
||||
|
||||
// SpeechToText 语音转文本(预留)
|
||||
// audioBase64:base64 编码的音频数据(WAV/MP3等)
|
||||
func (s *tts) SpeechToText(ctx context.Context, audioBase64 string) (text string, err error) {
|
||||
_ = ctx
|
||||
if audioBase64 == "" {
|
||||
return "", gerror.New("audioBase64 不能为空")
|
||||
}
|
||||
// 简单校验 base64 合法性
|
||||
if _, err := base64.StdEncoding.DecodeString(audioBase64); err != nil {
|
||||
return "", gerror.Wrap(err, "audioBase64 非法")
|
||||
}
|
||||
return "", gerror.New("SpeechToText 暂未实现:后续接入语音识别模型后补齐")
|
||||
}
|
||||
218
digital-human/service/video_service.go
Normal file
218
digital-human/service/video_service.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"ai-agent/digital-human/consts"
|
||||
"ai-agent/digital-human/dao"
|
||||
"ai-agent/digital-human/model/dto"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type video struct{}
|
||||
|
||||
// Video 视频服务
|
||||
var Video = new(video)
|
||||
|
||||
// Create 创建视频
|
||||
func (s *video) Create(ctx context.Context, req *dto.CreateVideoReq) (res *dto.CreateVideoRes, err error) {
|
||||
// 验证数字人形象是否存在且启用
|
||||
digitalHumanOne, err := dao.DigitalHuman.GetOne(ctx, req.DigitalHumanID)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "数字人形象不存在")
|
||||
}
|
||||
if digitalHumanOne.Status != consts.DigitalHumanStatusActive {
|
||||
return nil, errors.New("数字人形象未启用")
|
||||
}
|
||||
|
||||
// 验证音频是否存在且已生成成功
|
||||
audioOne, err := dao.Audio.GetOne(ctx, req.AudioID)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "音频不存在")
|
||||
}
|
||||
if audioOne.Status != consts.AudioStatusSuccess {
|
||||
return nil, errors.New("音频未生成成功,无法合成视频")
|
||||
}
|
||||
|
||||
// 创建视频记录(初始状态为生成中)
|
||||
ids, err := dao.Video.Insert(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 保存视频ID(PostgreSQL 使用 int64)
|
||||
videoID := ids[0].(int64)
|
||||
|
||||
// 异步生成视频
|
||||
go s.generateVideo(ctx, req.DigitalHumanID, digitalHumanOne.Name, req.AudioID, audioOne.AudioURL, audioOne.Duration, req.Resolution, videoID)
|
||||
|
||||
res = &dto.CreateVideoRes{
|
||||
Id: videoID,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// generateVideo 生成视频(异步处理)
|
||||
func (s *video) generateVideo(ctx context.Context, digitalHumanID int64, digitalHumanName string, audioID int64, audioURL string, duration int, resolution consts.Resolution, videoID int64) {
|
||||
// 更新视频状态,设置音频URL和时长
|
||||
_, _ = dao.Video.UpdateStatus(ctx, videoID, consts.VideoStatusGenerating, "", audioURL, duration, "", "")
|
||||
|
||||
// 调用数字人形象与音频合成服务
|
||||
videoURL, thumbnailURL, externalTaskID, err := s.synthesizeVideo(ctx, digitalHumanID, audioURL, resolution)
|
||||
if err != nil {
|
||||
// 视频合成失败
|
||||
_, _ = dao.Video.UpdateStatus(ctx, videoID, consts.VideoStatusFailed, "视频合成失败: "+err.Error(), "", 0, "", "")
|
||||
return
|
||||
}
|
||||
|
||||
// 更新视频生成状态为成功
|
||||
_, _ = dao.Video.UpdateStatus(ctx, videoID, consts.VideoStatusSuccess, "", videoURL, duration, thumbnailURL, externalTaskID)
|
||||
}
|
||||
|
||||
// synthesizeVideo 合成视频(模拟)
|
||||
func (s *video) synthesizeVideo(ctx context.Context, digitalHumanID int64, audioURL string, resolution consts.Resolution) (videoURL string, thumbnailURL string, externalTaskID string, err error) {
|
||||
// TODO: 调用真实的数字人视频合成服务API
|
||||
// 这里模拟返回
|
||||
g.Log().Info(ctx, "合成视频,数字人ID:", digitalHumanID, "音频URL:", audioURL, "分辨率:", resolution)
|
||||
|
||||
// 模拟外部任务ID(使用雪花算法或UUID)
|
||||
externalTaskID = gconv.String(digitalHumanID) + "-" + gconv.String(gtime.Timestamp())
|
||||
|
||||
// 模拟视频URL(实际应该从视频合成服务获取)
|
||||
videoURL = "https://example.com/video/" + externalTaskID + ".mp4"
|
||||
|
||||
// 模拟缩略图URL
|
||||
thumbnailURL = "https://example.com/video/" + externalTaskID + "_thumb.jpg"
|
||||
|
||||
return videoURL, thumbnailURL, externalTaskID, nil
|
||||
}
|
||||
|
||||
// List 获取视频列表
|
||||
func (s *video) List(ctx context.Context, req *dto.ListVideoReq) (res *dto.ListVideoRes, error error) {
|
||||
videoList, total, err := dao.Video.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListVideoRes{
|
||||
Total: total,
|
||||
}
|
||||
b, err := json.Marshal(videoList)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = json.Unmarshal(b, &res.List)
|
||||
return
|
||||
}
|
||||
|
||||
// GetOne 获取单个视频
|
||||
func (s *video) GetOne(ctx context.Context, id int64) (*dto.GetVideoRes, error) {
|
||||
videoOne, err := dao.Video.GetOne(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var createdAt, updatedAt *gtime.Time
|
||||
if videoOne.CreatedAt != nil {
|
||||
createdAt = videoOne.CreatedAt
|
||||
}
|
||||
if videoOne.UpdatedAt != nil {
|
||||
updatedAt = videoOne.UpdatedAt
|
||||
}
|
||||
return &dto.GetVideoRes{
|
||||
ID: videoOne.Id,
|
||||
Name: videoOne.Name,
|
||||
Description: videoOne.Description,
|
||||
DigitalHumanID: videoOne.DigitalHumanID,
|
||||
DigitalHumanName: videoOne.DigitalHumanName,
|
||||
AudioID: videoOne.AudioID,
|
||||
AudioURL: "",
|
||||
VideoURL: videoOne.VideoURL,
|
||||
Status: videoOne.Status,
|
||||
ErrorMsg: videoOne.ErrorMsg,
|
||||
Duration: videoOne.Duration,
|
||||
Resolution: videoOne.Resolution,
|
||||
ThumbnailURL: videoOne.ThumbnailURL,
|
||||
ExternalTaskID: videoOne.ExternalID,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update 更新视频
|
||||
func (s *video) Update(ctx context.Context, req *dto.UpdateVideoReq) error {
|
||||
// 先获取原始视频信息
|
||||
videoOne, err := dao.Video.GetOne(ctx, req.ID)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "获取原始视频信息失败")
|
||||
}
|
||||
// 修改字段
|
||||
if !g.IsEmpty(req.Name) {
|
||||
videoOne.Name = req.Name
|
||||
}
|
||||
if !g.IsEmpty(req.Description) {
|
||||
videoOne.Description = req.Description
|
||||
}
|
||||
|
||||
return dao.Video.Update(ctx, req.ID, videoOne)
|
||||
}
|
||||
|
||||
// Delete 删除视频
|
||||
func (s *video) Delete(ctx context.Context, id int64) error {
|
||||
return dao.Video.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// Generate 重新生成视频
|
||||
func (s *video) Generate(ctx context.Context, req *dto.GenerateVideoReq) (res *dto.GenerateVideoRes, err error) {
|
||||
// 获取视频信息
|
||||
videoOne, err := dao.Video.GetOne(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取视频信息失败")
|
||||
}
|
||||
|
||||
// 验证音频是否仍然有效(已生成成功)
|
||||
if videoOne.AudioID != 0 {
|
||||
audioOne, err := dao.Audio.GetOne(ctx, videoOne.AudioID)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取音频信息失败")
|
||||
}
|
||||
if audioOne.Status != consts.AudioStatusSuccess {
|
||||
return nil, errors.New("音频未生成成功,无法合成视频")
|
||||
}
|
||||
}
|
||||
|
||||
// 重置状态为生成中
|
||||
_, err = dao.Video.UpdateStatus(ctx, req.ID, consts.VideoStatusGenerating, "", "", 0, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 异步重新生成视频
|
||||
go s.generateVideo(ctx, videoOne.DigitalHumanID, videoOne.DigitalHumanName, videoOne.AudioID, "", videoOne.Duration, videoOne.Resolution, req.ID)
|
||||
|
||||
res = &dto.GenerateVideoRes{
|
||||
TaskID: gconv.String(req.ID),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetStatusOptions 获取状态选项
|
||||
func (s *video) GetStatusOptions(ctx context.Context, req *dto.GetVideoStatusOptionsReq) (res *dto.GetVideoStatusOptionsRes, err error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
res = new(dto.GetVideoStatusOptionsRes)
|
||||
res.Options = consts.GetAllVideoStatusKeyValue()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetResolutionOptions 获取分辨率选项
|
||||
func (s *video) GetResolutionOptions(ctx context.Context, req *dto.GetResolutionOptionsReq) (res *dto.GetResolutionOptionsRes, err error) {
|
||||
_ = ctx
|
||||
_ = req
|
||||
res = new(dto.GetResolutionOptionsRes)
|
||||
res.Options = consts.GetResolutionOptions()
|
||||
return res, nil
|
||||
}
|
||||
Reference in New Issue
Block a user