Files
slogan/server/styleagent/service/avatar_model_service.go
T
2026-08-17 13:38:41 +08:00

218 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}