引包目录名调整

This commit is contained in:
2026-04-27 14:02:43 +08:00
parent 6ba2262a17
commit 11bf15e72b
48 changed files with 56 additions and 58 deletions

View 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
}
}

View 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 创建异步任务并通过轮询/批量领取获取结果")
}

View 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
}

View 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
}

View 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
}

View 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 语音转文本(预留)
// audioBase64base64 编码的音频数据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 暂未实现:后续接入语音识别模型后补齐")
}

View 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
}
// 保存视频IDPostgreSQL 使用 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
}