218 lines
6.6 KiB
Go
218 lines
6.6 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"slogan-agent/common"
|
||
"slogan-agent/styleagent/agent"
|
||
"slogan-agent/styleagent/consts"
|
||
"slogan-agent/styleagent/dao"
|
||
"slogan-agent/styleagent/model/dto"
|
||
"slogan-agent/styleagent/model/entity"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gctx"
|
||
)
|
||
|
||
type avatarService struct{}
|
||
|
||
var AvatarService = new(avatarService)
|
||
|
||
// Build 构建化身:校验三视角全身照 → 写库(processing)→ 异步 Tripo 图像转 3D
|
||
func (s *avatarService) Build(ctx context.Context, userId int64) (*dto.AvatarBuildRes, error) {
|
||
photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
byType := make(map[int]*entity.UserPhoto, len(photos))
|
||
for _, p := range photos {
|
||
if _, ok := byType[p.Type]; !ok {
|
||
byType[p.Type] = p
|
||
}
|
||
}
|
||
for _, t := range []int{consts.PhotoTypeFullFront, consts.PhotoTypeFullSide, consts.PhotoTypeFullBack} {
|
||
if byType[t] == nil {
|
||
return nil, errors.New("请先上传三视角全身照(正面/侧面/背面)")
|
||
}
|
||
}
|
||
bodyMap := map[string]any{"height": 0, "weight": 0, "skin_tone": 0, "bust": 0, "waist": 0, "hip": 0, "shoulder": 0}
|
||
if b, err := dao.BodyMeasurement.GetByUser(ctx, userId); err == nil && b != nil {
|
||
bodyMap = map[string]any{
|
||
"height": b.Height, "weight": b.Weight, "skin_tone": b.SkinTone,
|
||
"bust": b.Bust, "waist": b.Waist, "hip": b.Hip, "shoulder": b.Shoulder,
|
||
}
|
||
}
|
||
snapshot := mustJSON(map[string]any{
|
||
"photo_front": byType[consts.PhotoTypeFullFront].Id,
|
||
"photo_side": byType[consts.PhotoTypeFullSide].Id,
|
||
"photo_back": byType[consts.PhotoTypeFullBack].Id,
|
||
"body": bodyMap,
|
||
})
|
||
|
||
// 防重复构建:同用户并发 Build 只允许一个提交 Tripo 任务(用户维度锁,仅覆盖快速 DB 段)
|
||
outcome, err := common.WithLock(ctx, fmt.Sprintf("avatar_build_%d", userId), 30*time.Second, 3, 200*time.Millisecond, func() (avatarBuildOutcome, error) {
|
||
existing, err := dao.AvatarModel.GetByUser(ctx, userId)
|
||
if err != nil {
|
||
return avatarBuildOutcome{}, err
|
||
}
|
||
if existing != nil {
|
||
if existing.BuildStatus == consts.AvatarBuildProcessing {
|
||
return avatarBuildOutcome{id: existing.Id}, nil
|
||
}
|
||
if err := dao.AvatarModel.Update(ctx, existing.Id, map[string]any{
|
||
"face_template_id": 0, "body_template_id": 0, "skin_tone_index": 0,
|
||
"glb_url": "",
|
||
"build_status": consts.AvatarBuildProcessing, "error": "",
|
||
"params_snapshot": snapshot,
|
||
}); err != nil {
|
||
return avatarBuildOutcome{}, err
|
||
}
|
||
return avatarBuildOutcome{id: existing.Id, fresh: true}, nil
|
||
}
|
||
id, err := dao.AvatarModel.Insert(ctx, &entity.AvatarModel{
|
||
UserId: userId,
|
||
BuildStatus: consts.AvatarBuildProcessing,
|
||
ParamsSnapshot: snapshot,
|
||
})
|
||
if err != nil {
|
||
return avatarBuildOutcome{}, err
|
||
}
|
||
return avatarBuildOutcome{id: id, fresh: true}, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if outcome.fresh {
|
||
if err := common.Submit(gctx.New(), "avatar", consts.DefaultAvatarPoolSize, func(ctx context.Context) {
|
||
s.buildJob(ctx, outcome.id, userId)
|
||
}); err != nil {
|
||
if uerr := dao.AvatarModel.Update(ctx, outcome.id, map[string]any{
|
||
"build_status": consts.AvatarBuildFailed, "error": "任务提交失败,请重试",
|
||
}); uerr != nil {
|
||
g.Log().Warningf(ctx, "标记化身构建失败状态失败: %v", uerr)
|
||
}
|
||
return nil, err
|
||
}
|
||
}
|
||
return &dto.AvatarBuildRes{AvatarId: outcome.id, Status: consts.AvatarBuildProcessing}, nil
|
||
}
|
||
|
||
// avatarBuildOutcome WithLock 回调产出:id 为化身记录主键,fresh 表示是否真正发起新构建
|
||
type avatarBuildOutcome struct {
|
||
id int64
|
||
fresh bool
|
||
}
|
||
|
||
// buildJob 异步构建:Tripo 上传三视角照片 → 提交任务 → 轮询 → 下载 GLB →(可选)渲染旋转帧
|
||
func (s *avatarService) buildJob(ctx context.Context, id, userId int64) {
|
||
fail := func(msg string) {
|
||
g.Log().Warningf(ctx, "avatar build failed: %s", msg)
|
||
if dbErr := dao.AvatarModel.Update(ctx, id, map[string]any{
|
||
"build_status": consts.AvatarBuildFailed, "error": msg,
|
||
"updated_at": "datetime('now','localtime')",
|
||
}); dbErr != nil {
|
||
g.Log().Warningf(ctx, "update avatar failed state: %v", dbErr)
|
||
}
|
||
}
|
||
|
||
tc := agent.NewTripoClient(ctx)
|
||
if !tc.Enabled() {
|
||
fail("请先在 config.yml 配置 avatar.tripo_api_key")
|
||
return
|
||
}
|
||
|
||
photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0)
|
||
if err != nil {
|
||
fail(fmt.Sprintf("读取照片失败: %v", err))
|
||
return
|
||
}
|
||
var pick [3]*entity.UserPhoto // 0=正面 1=侧面 2=背面
|
||
for _, p := range photos {
|
||
switch p.Type {
|
||
case consts.PhotoTypeFullFront:
|
||
if pick[0] == nil {
|
||
pick[0] = p
|
||
}
|
||
case consts.PhotoTypeFullSide:
|
||
if pick[1] == nil {
|
||
pick[1] = p
|
||
}
|
||
case consts.PhotoTypeFullBack:
|
||
if pick[2] == nil {
|
||
pick[2] = p
|
||
}
|
||
}
|
||
}
|
||
tokens := make([]string, 3)
|
||
for i, p := range pick {
|
||
if p == nil {
|
||
fail("构建前照片已被删除,请重新上传")
|
||
return
|
||
}
|
||
token, err := tc.UploadImage(ctx, strings.TrimPrefix(p.Url, "/"))
|
||
if err != nil {
|
||
fail(fmt.Sprintf("上传照片失败(视角 %d): %v", i+1, err))
|
||
return
|
||
}
|
||
tokens[i] = token
|
||
}
|
||
|
||
taskID, err := tc.SubmitMultiview(ctx, tokens[0], tokens[1], tokens[2])
|
||
if err != nil {
|
||
fail(fmt.Sprintf("提交 Tripo 任务失败: %v", err))
|
||
return
|
||
}
|
||
g.Log().Infof(ctx, "avatar tripo task submitted: %s", taskID)
|
||
|
||
glbURL, err := tc.PollTask(ctx, taskID)
|
||
if err != nil {
|
||
fail(fmt.Sprintf("Tripo 生成失败: %v", err))
|
||
return
|
||
}
|
||
|
||
glbPath := filepath.Join("workspace", "avatar", fmt.Sprintf("user_%d", userId), "avatar.glb")
|
||
if err := tc.DownloadGlb(ctx, glbURL, glbPath); err != nil {
|
||
fail(fmt.Sprintf("下载 GLB 失败: %v", err))
|
||
return
|
||
}
|
||
|
||
if dbErr := dao.AvatarModel.Update(ctx, id, map[string]any{
|
||
"glb_url": "/" + filepath.ToSlash(glbPath),
|
||
"build_status": consts.AvatarBuildDone, "error": "",
|
||
"updated_at": "datetime('now','localtime')",
|
||
}); dbErr != nil {
|
||
g.Log().Warningf(ctx, "update avatar done state: %v", dbErr)
|
||
}
|
||
}
|
||
|
||
// Get 我的化身
|
||
func (s *avatarService) Get(ctx context.Context, userId int64) (*dto.AvatarGetRes, error) {
|
||
a, err := dao.AvatarModel.GetByUser(ctx, userId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if a == nil {
|
||
return &dto.AvatarGetRes{}, nil
|
||
}
|
||
return &dto.AvatarGetRes{
|
||
FaceTemplateId: a.FaceTemplateId, BodyTemplateId: a.BodyTemplateId,
|
||
SkinToneIndex: a.SkinToneIndex, GlbUrl: a.GlbUrl,
|
||
BuildStatus: a.BuildStatus, Error: a.Error,
|
||
}, nil
|
||
}
|
||
|
||
func mustJSON(v any) string {
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
g.Log().Warningf(context.Background(), "avatar params marshal failed: %v", err)
|
||
return "{}"
|
||
}
|
||
return string(b)
|
||
}
|