diff --git a/config.yml b/config.yml
index 829fd39..51d9b06 100644
--- a/config.yml
+++ b/config.yml
@@ -25,17 +25,19 @@ geo:
amap_key: ""
amap_base: "https://restapi.amap.com"
-# 图像生成供应商配置(空则使用 mock)
+# 图像生成供应商配置(真实调用,不支持 mock)
imagegen:
- supplier: "mock" # mock | wanx
- wanx_api_key: ""
- wanx_model: "wanx-v2"
+ supplier: "wanx" # wanx
+ wanx_api_key: "sk-ws-H.RPMDIPI.Ba0s.MEUCIDmDkIKUzhk_TCC6hckvIZCP6LNVCnE-VDCdYH5yDTUjAiEAqfG-wUWGcVJyNuyxWsUFdKD_oHkPn3TxdxDcYcmlU24"
+ wanx_model: "wan2.7-image-pro"
+ wanx_base: "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation"
+ wanx_task_base: "https://dashscope.aliyuncs.com/api/v1/tasks"
# 大模型配置(OpenAI 兼容,如通义/DeepSeek/Kimi)
llm:
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
- api_key: ""
- model_name: "qwen-plus"
+ api_key: "sk-ws-H.RPMDIPI.Ba0s.MEUCIDmDkIKUzhk_TCC6hckvIZCP6LNVCnE-VDCdYH5yDTUjAiEAqfG-wUWGcVJyNuyxWsUFdKD_oHkPn3TxdxDcYcmlU24"
+ model_name: "qwen3.7-plus"
max_tokens: 4096
temperature: 0.8
@@ -52,7 +54,16 @@ ad:
limit_effect_extra: 2
limit_vip_trial: 1
-# 3D 化身预渲染(Node + headless-gl,node_bin 需指向 gl 有预编译二进制的 Node 版本)
+# 3D 化身生成(Tripo 图像转 3D,key 为空时 /avatar/build 返回失败并提示配置)
+avatar:
+ tripo_api_key: ""
+ tripo_base: "https://api.tripo3d.ai/v2/openapi"
+ tripo_model_version: "v2.5-20250123"
+ poll_interval: 5 # 秒
+ poll_timeout: 900 # 秒(15 分钟上限)
+ render_frames: true # 是否用 Tripo GLB 本地渲染旋转帧预览(frames_url)
+
+# 3D 化身帧序列预渲染(Node + headless-gl,node_bin 需指向 gl 有预编译二进制的 Node 版本)
render:
enabled: true
node_bin: "/Users/zhangbin/.nvm/versions/node/v18.20.4/bin/node"
diff --git a/scripts/avatar-render/gen-templates.js b/scripts/avatar-render/gen-templates.js
deleted file mode 100644
index cf32a3e..0000000
--- a/scripts/avatar-render/gen-templates.js
+++ /dev/null
@@ -1,138 +0,0 @@
-// 程序化生成简化人体化身 GLB(脸型/体型/肤色三参数组合)。
-// 用法: node gen-templates.js --out
[--faces 6] [--bodies 6] [--skins 5]
-// 索引对齐 MatchTemplates:face 0 起、body/skin 1 起;已存在的 GLB 跳过(幂等)。
-import { argv } from 'node:process';
-import fs from 'node:fs';
-import path from 'node:path';
-import * as THREE from 'three';
-import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
-
-// Node 无 FileReader,GLTFExporter 二进制导出需要它(onloadend 回调风格)
-if (typeof globalThis.FileReader === 'undefined') {
- globalThis.FileReader = class {
- constructor() { this.result = null; }
- set onloadend(fn) { this._onloadend = fn; }
- get onloadend() { return this._onloadend; }
- readAsArrayBuffer(blob) {
- blob.arrayBuffer().then((buf) => {
- this.result = buf;
- this._onloadend && this._onloadend();
- });
- }
- };
-}
-
-function parseArgs() {
- const a = { faces: 6, bodies: 6, skins: 5 };
- for (let i = 2; i < argv.length; i++) {
- if (argv[i].startsWith('--')) {
- const key = argv[i].slice(2);
- const val = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : true;
- a[key] = val;
- }
- }
- a.out = a.out || '.';
- return a;
-}
-
-// 5 档肤色(PBR base color)
-const SKIN_TONES = ['#FDE4CF', '#F3C39B', '#D99B6C', '#A96B3F', '#6B4226'];
-
-// 脸型变体:发型形状/颜色差异
-const FACE_HAIR = [
- { style: 'short', color: '#2B2B2B' }, // 短发
- { style: 'fringe', color: '#4A3728' }, // 刘海长发
- { style: 'bob', color: '#8C5A2B' }, // 波波头
-];
-
-// 体型变体:宽度/高度比例
-const BODY_VARIANTS = [
- { name: 'slim', width: 0.85, height: 1.0 },
- { name: 'normal', width: 1.0, height: 1.0 },
- { name: 'broad', width: 1.18, height: 0.96 },
-];
-
-function buildAvatar(faceIdx, bodyIdx, skinIdx) {
- const face = FACE_HAIR[faceIdx % FACE_HAIR.length];
- const body = BODY_VARIANTS[bodyIdx % BODY_VARIANTS.length];
- const skin = SKIN_TONES[skinIdx % SKIN_TONES.length];
-
- const root = new THREE.Group();
- const skinMat = new THREE.MeshStandardMaterial({ color: skin, roughness: 0.7 });
- const hairMat = new THREE.MeshStandardMaterial({ color: face.color, roughness: 0.85 });
- const clothMat = new THREE.MeshStandardMaterial({ color: '#3D5A80', roughness: 0.8 });
-
- const W = body.width;
- const H = body.height;
-
- // 躯干:胶囊体
- const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.22 * W, 0.5, 8, 16), clothMat);
- torso.position.y = 1.12 * H;
- root.add(torso);
-
- // 头:球体
- const head = new THREE.Mesh(new THREE.SphereGeometry(0.16 * W, 24, 18), skinMat);
- head.position.y = 1.68 * H;
- root.add(head);
-
- // 发型:覆盖头顶的半球壳(按脸型变体)
- const hair = new THREE.Mesh(new THREE.SphereGeometry(0.175 * W, 24, 12, 0, Math.PI * 2, 0, Math.PI * 0.52), hairMat);
- hair.position.y = 1.68 * H + 0.03;
- root.add(hair);
- if (face.style === 'fringe') {
- const fringe = new THREE.Mesh(new THREE.BoxGeometry(0.16 * W, 0.05, 0.2 * W), hairMat);
- fringe.position.y = 1.7 * H;
- fringe.position.z = -0.12 * W;
- fringe.rotation.x = -0.25;
- root.add(fringe);
- } else if (face.style === 'bob') {
- const back = new THREE.Mesh(new THREE.BoxGeometry(0.3 * W, 0.3, 0.06), hairMat);
- back.position.y = 1.52 * H;
- back.position.z = 0.14 * W;
- root.add(back);
- }
-
- // 上肢
- for (const side of [-1, 1]) {
- const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.075 * W, 0.42, 6, 10), skinMat);
- arm.position.set(side * 0.32 * W, 1.32 * H, 0);
- arm.rotation.z = side * 0.06;
- root.add(arm);
- }
-
- // 下肢
- for (const side of [-1, 1]) {
- const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * W, 0.62, 6, 10), clothMat);
- leg.position.set(side * 0.12 * W, 0.55 * H, 0);
- root.add(leg);
- }
-
- return root;
-}
-
-const args = parseArgs();
-fs.mkdirSync(args.out, { recursive: true });
-const exporter = new GLTFExporter();
-
-let generated = 0;
-for (let f = 0; f < args.faces; f++) {
- for (let b = 1; b <= args.bodies; b++) {
- for (let s = 1; s <= args.skins; s++) {
- const name = `avatar_f${f}_b${b}_s${s}.glb`;
- const outPath = path.join(args.out, name);
- if (fs.existsSync(outPath)) continue;
- const scene = buildAvatar(f, b, s);
- const buf = await new Promise((resolve, reject) => {
- exporter.parse(
- scene,
- (result) => resolve(Buffer.from(result)),
- (err) => reject(err),
- { binary: true }
- );
- });
- fs.writeFileSync(outPath, buf);
- generated++;
- }
- }
-}
-console.log(`generated ${generated} GLB (skipped existing) -> ${args.out}`);
diff --git a/scripts/gen_user_photos/main.go b/scripts/gen_user_photos/main.go
new file mode 100644
index 0000000..5c9b290
--- /dev/null
+++ b/scripts/gen_user_photos/main.go
@@ -0,0 +1,114 @@
+package main
+
+// 为指定用户生成一套三视角全身照(真实调用 imagegen,非 mock):
+// go run scripts/gen_user_photos/main.go [username]
+// 默认用户 wenwu901。已存在同视角照片时跳过;图片存 workspace/user_{id}/photos/,记录写入 slogan_user_photo。
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
+ "github.com/gogf/gf/v2/frame/g"
+
+ "slogan-agent/styleagent/agent"
+ "slogan-agent/styleagent/consts"
+ "slogan-agent/styleagent/dao"
+ "slogan-agent/styleagent/model/entity"
+)
+
+const personDesc = "一位穿浅蓝色衬衫与深灰色西裤的亚洲年轻女性,干净利落的黑色短发,身材匀称"
+
+var views = []struct {
+ angle string
+ photoT int
+ prompt string
+}{
+ {angle: "front", photoT: consts.PhotoTypeFullFront, prompt: personDesc + ",全身正面照,站直面对镜头,双手自然下垂,纯白背景,高清写实,全身入镜"},
+ {angle: "side", photoT: consts.PhotoTypeFullSide, prompt: personDesc + ",全身侧面照,侧身站立目视前方,纯白背景,高清写实,全身入镜"},
+ {angle: "back", photoT: consts.PhotoTypeFullBack, prompt: personDesc + ",全身背面照,背对镜头站立,纯白背景,高清写实,全身入镜"},
+}
+
+func main() {
+ username := "wenwu901"
+ if len(os.Args) > 1 {
+ username = os.Args[1]
+ }
+ ctx := context.Background()
+
+ var user entity.User
+ if err := g.DB().Model(consts.TableNameUser).Ctx(ctx).
+ Where("username", username).Scan(&user); err != nil || user.Id == 0 {
+ panic(fmt.Sprintf("用户 %s 不存在: %v", username, err))
+ }
+ fmt.Printf("用户: %s (id=%d)\n", username, user.Id)
+
+ existing, err := dao.UserPhoto.ListByUser(ctx, user.Id, 0)
+ if err != nil {
+ panic(err)
+ }
+ have := map[int]bool{}
+ for _, p := range existing {
+ have[p.Type] = true
+ }
+
+ client, err := agent.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "wanx").String())
+ if err != nil {
+ panic(err)
+ }
+
+ dir := filepath.Join("workspace", fmt.Sprintf("user_%d", user.Id), "photos")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ panic(err)
+ }
+
+ // 三视角用同一 seed,保证人物一致
+ seed := time.Now().UnixNano() % 1_000_000
+ for _, v := range views {
+ if have[v.photoT] {
+ fmt.Printf("视角 %s 已有照片,跳过\n", v.angle)
+ continue
+ }
+ fmt.Printf("生成 %s 视角...\n", v.angle)
+ url, err := client.Generate(ctx, &agent.GenerateReq{
+ Prompt: v.prompt, Angle: v.angle, Seed: seed,
+ })
+ if err != nil {
+ panic(fmt.Sprintf("生成 %s 失败: %v", v.angle, err))
+ }
+ path := filepath.Join(dir, fmt.Sprintf("%d_%s.png", time.Now().UnixNano(), v.angle))
+ if err := download(url, path); err != nil {
+ panic(fmt.Sprintf("保存 %s 失败: %v", v.angle, err))
+ }
+ if _, err := dao.UserPhoto.Insert(ctx, &entity.UserPhoto{
+ UserId: user.Id, Type: v.photoT, Url: "/" + filepath.ToSlash(path), Status: 1,
+ }); err != nil {
+ panic(fmt.Sprintf("入库 %s 失败: %v", v.angle, err))
+ }
+ fmt.Printf("%s 完成: %s\n", v.angle, path)
+ }
+ fmt.Println("照片套生成完毕")
+}
+
+func download(url, dest string) error {
+ resp, err := http.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("下载失败: http %d", resp.StatusCode)
+ }
+ out, err := os.Create(dest)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, resp.Body)
+ return err
+}
diff --git a/styleagent/agent/avatar_glb_packer.go b/styleagent/agent/avatar_glb_packer.go
deleted file mode 100644
index 3ec6a87..0000000
--- a/styleagent/agent/avatar_glb_packer.go
+++ /dev/null
@@ -1,9 +0,0 @@
-package agent
-
-import "fmt"
-
-// PackGlbUrl v1 打包:组合模板 URL(头部/身体/发型分层,App 端组合渲染)
-func PackGlbUrl(faceTemplateId, bodyTemplateId, skinToneIndex int) string {
- return fmt.Sprintf("/workspace/templates/avatar_f%d_b%d_s%d.glb",
- faceTemplateId, bodyTemplateId, skinToneIndex)
-}
diff --git a/styleagent/agent/avatar_matcher.go b/styleagent/agent/avatar_matcher.go
deleted file mode 100644
index 95bb9eb..0000000
--- a/styleagent/agent/avatar_matcher.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package agent
-
-// FaceFeature 从照片+用户填写提取的化身特征(v1:肤色/身高/体重来自身形参数,照片贴图后续增强)
-type FaceFeature struct {
- SkinTone int // 1-5
- HeightCm int
- WeightKg int
-}
-
-// 预烘焙模板库索引(构建期产物,运行时只读常量)
-const (
- FaceTemplateCount = 20
- BodyTemplateCount = 6
- SkinToneLevels = 5
- DefaultFaceTemplate = 5
- DefaultBodyTemplate = 3
-)
-
-// MatchTemplates 特征 → 模板索引
-// 身体模板:身高 145-190cm 映射 6 档;肤色 1-5 直接映射皮肤贴图档
-func MatchTemplates(f *FaceFeature) (faceId, bodyId, skinIdx int) {
- if f == nil {
- return DefaultFaceTemplate, DefaultBodyTemplate, 3
- }
- skinIdx = f.SkinTone
- if skinIdx < 1 {
- skinIdx = 1
- }
- if skinIdx > SkinToneLevels {
- skinIdx = SkinToneLevels
- }
- bodyId = (f.HeightCm-145)/8 + 1
- if bodyId < 1 {
- bodyId = 1
- }
- if bodyId > BodyTemplateCount {
- bodyId = BodyTemplateCount
- }
- // v1 脸型固定默认模板(AI 人脸特征提取后替换,见 spec v2)
- faceId = DefaultFaceTemplate
- return
-}
diff --git a/styleagent/agent/avatar_tripo_client.go b/styleagent/agent/avatar_tripo_client.go
new file mode 100644
index 0000000..d623f20
--- /dev/null
+++ b/styleagent/agent/avatar_tripo_client.go
@@ -0,0 +1,212 @@
+package agent
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// TripoClient 3D 化身客户端(图像转 3D:上传图片 → 提交 multiview 任务 → 轮询 → 下载 GLB)
+type TripoClient struct {
+ apiKey string
+ base string
+ version string
+ pollInterval time.Duration
+ pollTimeout time.Duration
+}
+
+func NewTripoClient(ctx context.Context) *TripoClient {
+ return &TripoClient{
+ apiKey: g.Cfg().MustGet(ctx, "avatar.tripo_api_key", "").String(),
+ base: g.Cfg().MustGet(ctx, "avatar.tripo_base", "https://api.tripo3d.ai/v2/openapi").String(),
+ version: g.Cfg().MustGet(ctx, "avatar.tripo_model_version", "v2.5-20250123").String(),
+ pollInterval: time.Duration(g.Cfg().MustGet(ctx, "avatar.poll_interval", 5).Int()) * time.Second,
+ pollTimeout: time.Duration(g.Cfg().MustGet(ctx, "avatar.poll_timeout", 900).Int()) * time.Second,
+ }
+}
+
+// Enabled 是否已配置 API Key
+func (c *TripoClient) Enabled() bool { return c.apiKey != "" }
+
+// UploadImage 上传单张图片,返回 file_token
+func (c *TripoClient) UploadImage(ctx context.Context, filePath string) (string, error) {
+ body := &bytes.Buffer{}
+ w := multipart.NewWriter(body)
+ f, err := os.Open(filePath)
+ if err != nil {
+ return "", fmt.Errorf("打开图片失败: %w", err)
+ }
+ defer f.Close()
+ fw, err := w.CreateFormFile("file", filepath.Base(filePath))
+ if err != nil {
+ return "", err
+ }
+ if _, err := io.Copy(fw, f); err != nil {
+ return "", err
+ }
+ w.Close()
+
+ req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/upload/sts", body)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+
+ data, err := c.do(req)
+ if err != nil {
+ return "", err
+ }
+ for _, key := range []string{"file_token", "image_token", "token"} {
+ if v, ok := data[key].(string); ok && v != "" {
+ return v, nil
+ }
+ }
+ return "", fmt.Errorf("Tripo 上传响应缺少 file_token: %s", mustJSONStr(data))
+}
+
+// SubmitMultiview 提交多视角转 3D 任务(front 必填,left/back 可空),返回 task_id
+func (c *TripoClient) SubmitMultiview(ctx context.Context, front, left, back string) (string, error) {
+ files := make([]map[string]string, 0, 3)
+ for _, t := range []string{front, left, back} {
+ if t != "" {
+ files = append(files, map[string]string{"type": "image", "file_token": t})
+ }
+ }
+ body, err := json.Marshal(map[string]any{
+ "type": "multiview_to_model",
+ "model_version": c.version,
+ "files": files,
+ "texture": true,
+ "pbr": true,
+ })
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/task", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ req.Header.Set("Content-Type", "application/json")
+
+ data, err := c.do(req)
+ if err != nil {
+ return "", err
+ }
+ taskID, _ := data["task_id"].(string)
+ if taskID == "" {
+ return "", fmt.Errorf("Tripo 提交任务响应缺少 task_id: %s", mustJSONStr(data))
+ }
+ return taskID, nil
+}
+
+// PollTask 轮询任务直到 success/failed,成功返回 GLB 下载地址
+func (c *TripoClient) PollTask(ctx context.Context, taskID string) (string, error) {
+ deadline := time.Now().Add(c.pollTimeout)
+ for {
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ default:
+ }
+ req, err := http.NewRequestWithContext(ctx, "GET", c.base+"/task/"+taskID, nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ data, err := c.do(req)
+ if err != nil {
+ return "", err
+ }
+ status, _ := data["status"].(string)
+ switch status {
+ case "success":
+ if output, ok := data["output"].(map[string]any); ok {
+ if pbr, ok := output["pbr_model"].(map[string]any); ok {
+ if url, ok := pbr["url"].(string); ok && url != "" {
+ return url, nil
+ }
+ }
+ }
+ return "", fmt.Errorf("Tripo 任务成功但无模型下载地址")
+ case "failed", "cancelled", "expired":
+ msg, _ := data["error"].(string)
+ if msg == "" {
+ msg = mustJSONStr(data)
+ }
+ return "", fmt.Errorf("Tripo 任务%s: %s", status, msg)
+ }
+ if time.Now().After(deadline) {
+ return "", fmt.Errorf("Tripo 任务超时(%s)", taskID)
+ }
+ time.Sleep(c.pollInterval)
+ }
+}
+
+// DownloadGlb 下载 GLB 到 destPath(下载地址约 5 分钟过期,任务成功后应立即调用)
+func (c *TripoClient) DownloadGlb(ctx context.Context, url, destPath string) error {
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("下载 GLB 失败: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("下载 GLB 失败: http %d", resp.StatusCode)
+ }
+ if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
+ return err
+ }
+ out, err := os.Create(destPath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ if _, err := io.Copy(out, resp.Body); err != nil {
+ return err
+ }
+ return nil
+}
+
+// do 统一请求:非 2xx 或 code != 0 时返回业务错误
+func (c *TripoClient) do(req *http.Request) (map[string]any, error) {
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("Tripo 请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
+ var r struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Data map[string]any `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return nil, fmt.Errorf("Tripo 响应解析失败: %s", string(raw))
+ }
+ if resp.StatusCode != http.StatusOK || r.Code != 0 {
+ return nil, fmt.Errorf("Tripo 接口错误 code=%d msg=%s", r.Code, r.Message)
+ }
+ return r.Data, nil
+}
+
+func mustJSONStr(v any) string {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return fmt.Sprintf("%v", v)
+ }
+ return string(b)
+}
diff --git a/styleagent/agent/imagegen_client.go b/styleagent/agent/imagegen_client.go
index aeee770..584f73a 100644
--- a/styleagent/agent/imagegen_client.go
+++ b/styleagent/agent/imagegen_client.go
@@ -7,36 +7,38 @@ import (
"github.com/gogf/gf/v2/frame/g"
)
-// ImageGenClient 效果图生成客户端
+// ImageGenClient 图像生成客户端(真实调用,无 mock)
type ImageGenClient interface {
- // Generate 生成单张效果图,返回图片 URL
+ // Generate 生成单张图片,返回图片 URL(可传 BaseImageURL 做图生图,为空则文生图)
Generate(ctx context.Context, req *GenerateReq) (string, error)
}
// GenerateReq 生成请求
type GenerateReq struct {
- BaseImageURL string // 用户全身照
+ BaseImageURL string // 用户全身照(本地 /workspace 路径或 http(s) URL,空为文生图)
Prompt string // 方案描述
Angle string // 正面/侧面/背面
Seed int64
}
-// NewClient 按供应商创建客户端(config 未配置 Key 时强制 mock)
-func NewClient(supplier string) ImageGenClient {
+// NewClient 创建真实图像生成客户端;未配置供应商或 Key 时返回错误(不再降级 mock)
+func NewClient(supplier string) (ImageGenClient, error) {
if supplier == "wanx" {
key := g.Cfg().MustGet(context.Background(), "imagegen.wanx_api_key", "").String()
if key != "" {
return &wanxClient{
- apiKey: key,
- model: g.Cfg().MustGet(context.Background(), "imagegen.wanx_model", "wanx-v2").String(),
- base: "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis",
- }
+ apiKey: key,
+ model: g.Cfg().MustGet(context.Background(), "imagegen.wanx_model", "wan2.7-image-pro").String(),
+ base: g.Cfg().MustGet(context.Background(), "imagegen.wanx_base", "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation").String(),
+ taskBase: g.Cfg().MustGet(context.Background(), "imagegen.wanx_task_base", "https://dashscope.aliyuncs.com/api/v1/tasks").String(),
+ }, nil
}
+ return nil, fmt.Errorf("imagegen 未配置:请在 config.yml 设置 imagegen.wanx_api_key")
}
- return &mockClient{}
+ return nil, fmt.Errorf("imagegen 供应商不支持:%s(当前仅支持 wanx)", supplier)
}
-// buildPrompt 组装方案描述 prompt
+// buildPrompt 组装图片生成提示词
func buildPrompt(planDesc, hairstyle, hairColor, angle string) string {
return fmt.Sprintf("时尚穿搭效果图,%s;发型:%s(发色 %s);角度:%s;人物写实、高清、全身、纯色背景",
planDesc, hairstyle, hairColor, angle)
diff --git a/styleagent/agent/imagegen_mock_client.go b/styleagent/agent/imagegen_mock_client.go
deleted file mode 100644
index 3dc9425..0000000
--- a/styleagent/agent/imagegen_mock_client.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package agent
-
-import (
- "context"
- "fmt"
-)
-
-type mockClient struct{}
-
-// Generate mock 客户端:返回占位图路径(开发联调用,不真实调用)
-func (c *mockClient) Generate(ctx context.Context, req *GenerateReq) (string, error) {
- return fmt.Sprintf("/workspace/mock/effect_%s.png", req.Angle), nil
-}
diff --git a/styleagent/agent/imagegen_wanx_client.go b/styleagent/agent/imagegen_wanx_client.go
index 2e2b7fb..4ad78eb 100644
--- a/styleagent/agent/imagegen_wanx_client.go
+++ b/styleagent/agent/imagegen_wanx_client.go
@@ -3,65 +3,93 @@ package agent
import (
"bytes"
"context"
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
+ "os"
+ "strings"
"time"
)
-// wanxClient 通义万相人像写真(image-synthesis 异步接口 + 轮询)
+// wanxClient 通义万相图像生成(wan2.7-image-pro:image-generation 异步接口 + 轮询)
type wanxClient struct {
- apiKey string
- model string
- base string
+ apiKey string
+ model string
+ base string // 任务提交端点
+ taskBase string // 任务查询端点
}
type wanxSubmitReq struct {
Model string `json:"model"`
Input wanxInput `json:"input"`
- Parameters map[string]any `json:"parameters,omitempty"`
+ Parameters map[string]any `json:"parameters"`
}
type wanxInput struct {
- Prompt string `json:"prompt"`
- BaseImageURL string `json:"base_image_url,omitempty"`
- BaseImagePath string `json:"base_image_path,omitempty"`
+ Messages []wanxMessage `json:"messages"`
}
-type wanxResp struct {
- Output struct {
- TaskID string `json:"task_id"`
- TaskStatus string `json:"task_status"`
- Results []struct {
- URL string `json:"url"`
- } `json:"results"`
- } `json:"output"`
- Code string `json:"code"`
- Message string `json:"message"`
+type wanxMessage struct {
+ Role string `json:"role"`
+ Content []wanxInputContent `json:"content"`
+}
+
+// 请求侧 content 元素(图生图传 image_url 对象)
+type wanxInputContent struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL *wanxImageURL `json:"image_url,omitempty"`
+}
+
+type wanxImageURL struct {
+ URL string `json:"url"`
+}
+
+// 响应侧 content 元素(图片在 image 字段)
+type wanxContent struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ Image string `json:"image,omitempty"`
}
type wanxTaskResp struct {
Output struct {
TaskStatus string `json:"task_status"`
- Results []struct {
- URL string `json:"url"`
- } `json:"results"`
+ Message string `json:"message"`
+ Code string `json:"code"`
+ Choices []struct {
+ Message struct {
+ Content []wanxContent `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
} `json:"output"`
- Code string `json:"code"`
- Message string `json:"message"`
}
-// Generate 提交任务并轮询直到完成,失败返回错误(由上层降级 mock)
+// Generate 文生图或图生图(BaseImageURL 本地路径转 data URI,http(s) 直传),异步任务 + 轮询
func (c *wanxClient) Generate(ctx context.Context, req *GenerateReq) (string, error) {
+ content := make([]wanxInputContent, 0, 2)
+ if req.BaseImageURL != "" {
+ imgURL, err := resolveImageURL(req.BaseImageURL)
+ if err != nil {
+ return "", err
+ }
+ content = append(content, wanxInputContent{Type: "image_url", ImageURL: &wanxImageURL{URL: imgURL}})
+ }
+ content = append(content, wanxInputContent{Type: "text", Text: buildPrompt(req.Prompt, "", "", req.Angle)})
+
body, err := json.Marshal(wanxSubmitReq{
- Model: c.model,
- Input: wanxInput{Prompt: buildPrompt(req.Prompt, "", "", req.Angle), BaseImageURL: req.BaseImageURL},
+ Model: c.model,
+ Input: wanxInput{Messages: []wanxMessage{
+ {Role: "user", Content: content},
+ }},
Parameters: map[string]any{"n": 1, "size": "768*1024", "seed": req.Seed},
})
if err != nil {
return "", err
}
+
taskID, err := c.submit(ctx, body)
if err != nil {
return "", err
@@ -73,6 +101,23 @@ func (c *wanxClient) Generate(ctx context.Context, req *GenerateReq) (string, er
return url, nil
}
+// resolveImageURL 本地 /workspace 路径转 data URI(dashscope 无法访问相对路径),http(s) 原样返回
+func resolveImageURL(raw string) (string, error) {
+ if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
+ return raw, nil
+ }
+ path := strings.TrimPrefix(raw, "/")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("读取参考图失败 %s: %w", raw, err)
+ }
+ ext := "png"
+ if i := strings.LastIndex(path, "."); i >= 0 {
+ ext = strings.TrimPrefix(path[i+1:], ".")
+ }
+ return fmt.Sprintf("data:image/%s;base64,%s", ext, base64.StdEncoding.EncodeToString(data)), nil
+}
+
func (c *wanxClient) submit(ctx context.Context, body []byte) (string, error) {
req, err := http.NewRequestWithContext(ctx, "POST", c.base, bytes.NewReader(body))
if err != nil {
@@ -88,9 +133,15 @@ func (c *wanxClient) submit(ctx context.Context, body []byte) (string, error) {
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
- var r wanxResp
+ var r struct {
+ Output struct {
+ TaskID string `json:"task_id"`
+ } `json:"output"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+ }
if err := json.Unmarshal(data, &r); err != nil {
- return "", fmt.Errorf("万相响应解析失败: %s", string(data))
+ return "", fmt.Errorf("万相提交响应解析失败: %s", string(data))
}
if r.Output.TaskID == "" {
return "", fmt.Errorf("万相提交失败 code=%s msg=%s", r.Code, r.Message)
@@ -99,15 +150,14 @@ func (c *wanxClient) submit(ctx context.Context, body []byte) (string, error) {
}
func (c *wanxClient) poll(ctx context.Context, taskID string) (string, error) {
- taskURL := c.base + "?task_id=" + taskID
client := &http.Client{Timeout: 30 * time.Second}
- for i := 0; i < 60; i++ {
+ for i := 0; i < 120; i++ {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(5 * time.Second):
}
- req, err := http.NewRequestWithContext(ctx, "GET", taskURL, nil)
+ req, err := http.NewRequestWithContext(ctx, "GET", c.taskBase+"/"+taskID, nil)
if err != nil {
return "", err
}
@@ -124,12 +174,16 @@ func (c *wanxClient) poll(ctx context.Context, taskID string) (string, error) {
}
switch r.Output.TaskStatus {
case "SUCCEEDED":
- if len(r.Output.Results) > 0 && r.Output.Results[0].URL != "" {
- return r.Output.Results[0].URL, nil
+ for _, ch := range r.Output.Choices {
+ for _, ct := range ch.Message.Content {
+ if ct.Type == "image" && ct.Image != "" {
+ return ct.Image, nil
+ }
+ }
}
return "", fmt.Errorf("万相任务成功但无结果")
case "FAILED":
- return "", fmt.Errorf("万相任务失败: %s", r.Message)
+ return "", fmt.Errorf("万相任务失败: %s", r.Output.Message)
}
}
return "", fmt.Errorf("万相任务超时")
diff --git a/styleagent/agent/render.go b/styleagent/agent/render.go
index 2439c97..f6864a1 100644
--- a/styleagent/agent/render.go
+++ b/styleagent/agent/render.go
@@ -20,28 +20,22 @@ const (
)
// RenderAvatarFrames 将化身 GLB 预渲染为绕 Y 轴旋转帧序列,返回帧目录访问 URL。
-// 组合键 {face}_{body}_{skin};帧目录已就绪直接复用,GLB 缺失先程序化生成模板。
-// render.enabled=false 或渲染失败时返回 error,由调用方降级。
-func RenderAvatarFrames(ctx context.Context, faceId, bodyId, skinIdx int) (framesURL string, err error) {
+// 帧目录已就绪直接复用;render.enabled=false 或渲染失败时返回 error,由调用方降级。
+func RenderAvatarFrames(ctx context.Context, glb string, outKey string) (framesURL string, err error) {
if !g.Cfg().MustGet(ctx, "render.enabled", true).Bool() {
return "", errors.New("3D 渲染服务未启用")
}
- key := fmt.Sprintf("f%d_b%d_s%d", faceId, bodyId, skinIdx)
- dir := filepath.Join("workspace", "avatar_frames", key)
+ dir := filepath.Join("workspace", "avatar_frames", outKey)
if framesReady(dir) {
- return "/workspace/avatar_frames/" + key, nil
+ return "/workspace/avatar_frames/" + outKey, nil
}
-
- glb := filepath.Join("workspace", "templates", fmt.Sprintf("avatar_f%d_b%d_s%d.glb", faceId, bodyId, skinIdx))
if _, err := os.Stat(glb); err != nil {
- if err := runTemplates(ctx); err != nil {
- return "", fmt.Errorf("生成化身模板失败: %w", err)
- }
+ return "", fmt.Errorf("化身 GLB 不存在: %w", err)
}
if err := runRender(ctx, glb, dir); err != nil {
return "", err
}
- return "/workspace/avatar_frames/" + key, nil
+ return "/workspace/avatar_frames/" + outKey, nil
}
func framesReady(dir string) bool {
@@ -70,14 +64,6 @@ func nodeBin(ctx context.Context) string {
return bin
}
-func runTemplates(ctx context.Context) error {
- if err := os.MkdirAll(filepath.Join("workspace", "templates"), 0o755); err != nil {
- return err
- }
- return execNode(ctx, filepath.Join("scripts", "avatar-render", "gen-templates.js"),
- "--out", filepath.Join("workspace", "templates"))
-}
-
func runRender(ctx context.Context, glb, out string) error {
if err := os.MkdirAll(out, 0o755); err != nil {
return err
diff --git a/styleagent/controller/ad_controller.go b/styleagent/controller/ad_reward_log_controller.go
similarity index 100%
rename from styleagent/controller/ad_controller.go
rename to styleagent/controller/ad_reward_log_controller.go
diff --git a/styleagent/controller/avatar_controller.go b/styleagent/controller/avatar_model_controller.go
similarity index 100%
rename from styleagent/controller/avatar_controller.go
rename to styleagent/controller/avatar_model_controller.go
diff --git a/styleagent/controller/cps_click_log_controller.go b/styleagent/controller/cps_click_log_controller.go
index f0f9af8..0bcc1db 100644
--- a/styleagent/controller/cps_click_log_controller.go
+++ b/styleagent/controller/cps_click_log_controller.go
@@ -12,7 +12,7 @@ import (
// MyRecent 最近优惠(点击日志 → 商品)
func (c *cps) MyRecent(ctx context.Context, req *dto.CpsMyRecentReq) (res *dto.CpsMyRecentRes, err error) {
- list, err := service.CpsProductService.MyRecent(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
+ list, err := service.CpsClickLogService.MyRecent(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
if err != nil {
return nil, err
}
diff --git a/styleagent/controller/hairstyle_controller.go b/styleagent/controller/hairstyle_asset_controller.go
similarity index 100%
rename from styleagent/controller/hairstyle_controller.go
rename to styleagent/controller/hairstyle_asset_controller.go
diff --git a/styleagent/controller/wardrobe_controller.go b/styleagent/controller/wardrobe_item_controller.go
similarity index 100%
rename from styleagent/controller/wardrobe_controller.go
rename to styleagent/controller/wardrobe_item_controller.go
diff --git a/styleagent/service/avatar_model_service.go b/styleagent/service/avatar_model_service.go
index 4cb4dde..d0153a8 100644
--- a/styleagent/service/avatar_model_service.go
+++ b/styleagent/service/avatar_model_service.go
@@ -4,6 +4,9 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
+ "path/filepath"
+ "strings"
"slogan-agent/styleagent/agent"
"slogan-agent/styleagent/consts"
@@ -14,72 +17,50 @@ import (
"github.com/gogf/gf/v2/os/gctx"
)
-type avatarService struct {
- renderFunc func(ctx context.Context, faceId, bodyId, skinIdx int) (string, error)
-}
+type avatarService struct{}
var AvatarService = new(avatarService)
-func init() {
- AvatarService.renderFunc = agent.RenderAvatarFrames
-}
-
-// Build 构建化身:模板匹配 + 写库(processing),异步预渲染帧序列
+// Build 构建化身:校验三视角全身照 → 写库(processing)→ 异步 Tripo 图像转 3D
func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.AvatarModel, error) {
photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0)
if err != nil {
return nil, err
}
- var hasHead, hasFull bool
+ byType := make(map[int]*entity.UserPhoto, len(photos))
for _, p := range photos {
- if p.Type == consts.PhotoTypeHeadshot {
- hasHead = true
- }
- if p.Type >= consts.PhotoTypeFullFront {
- hasFull = true
+ if _, ok := byType[p.Type]; !ok {
+ byType[p.Type] = p
}
}
- if !hasHead {
- return nil, errors.New("请先上传大头照")
- }
- if !hasFull {
- return nil, errors.New("请先上传全身照")
- }
-
- bm, _ := dao.BodyMeasurement.GetByUser(ctx, userId) // 无测量记录时为 nil,用默认参数
- feature := &agent.FaceFeature{SkinTone: 3, HeightCm: 170, WeightKg: 60}
- if bm != nil {
- feature = &agent.FaceFeature{SkinTone: bm.SkinTone, HeightCm: bm.Height, WeightKg: bm.Weight}
- }
- faceId, bodyId, skinIdx := agent.MatchTemplates(feature)
-
- base := map[string]any{
- "face_template_id": faceId, "body_template_id": bodyId,
- "skin_tone_index": skinIdx, "glb_url": agent.PackGlbUrl(faceId, bodyId, skinIdx),
- "build_status": consts.AvatarBuildProcessing, "error": "",
- "params_snapshot": mustJSON(map[string]any{
- "height_cm": feature.HeightCm, "weight_kg": feature.WeightKg, "skin_tone": skinIdx,
- }),
+ for _, t := range []int{consts.PhotoTypeFullFront, consts.PhotoTypeFullSide, consts.PhotoTypeFullBack} {
+ if byType[t] == nil {
+ return nil, errors.New("请先上传三视角全身照(正面/侧面/背面)")
+ }
}
+ snapshot := mustJSON(map[string]any{
+ "photo_front": byType[consts.PhotoTypeFullFront].Id,
+ "photo_side": byType[consts.PhotoTypeFullSide].Id,
+ "photo_back": byType[consts.PhotoTypeFullBack].Id,
+ })
var record *entity.AvatarModel
existing, _ := dao.AvatarModel.GetByUser(ctx, userId)
if existing != nil {
- if err := dao.AvatarModel.Update(ctx, existing.Id, base); err != 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": "", "frames_url": "",
+ "build_status": consts.AvatarBuildProcessing, "error": "",
+ "params_snapshot": snapshot,
+ }); err != nil {
return nil, err
}
record = existing
} else {
id, err := dao.AvatarModel.Insert(ctx, &entity.AvatarModel{
UserId: userId,
- FaceTemplateId: faceId,
- BodyTemplateId: bodyId,
- SkinToneIndex: skinIdx,
- GlbUrl: agent.PackGlbUrl(faceId, bodyId, skinIdx),
BuildStatus: consts.AvatarBuildProcessing,
- ParamsSnapshot: mustJSON(map[string]any{
- "height_cm": feature.HeightCm, "weight_kg": feature.WeightKg, "skin_tone": skinIdx,
- }),
+ ParamsSnapshot: snapshot,
})
if err != nil {
return nil, err
@@ -87,35 +68,102 @@ func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.Avatar
record = &entity.AvatarModel{Id: id, UserId: userId}
}
- go s.renderJob(gctx.New(), record.Id, faceId, bodyId, skinIdx)
- record.FaceTemplateId = faceId
- record.BodyTemplateId = bodyId
- record.SkinToneIndex = skinIdx
- record.GlbUrl = agent.PackGlbUrl(faceId, bodyId, skinIdx)
+ go s.buildJob(gctx.New(), record.Id, userId)
record.BuildStatus = consts.AvatarBuildProcessing
return record, nil
}
-// renderJob 异步预渲染帧序列:成功写 frames_url+done,失败写 error+failed(客户端降级为静态占位)
-func (s *avatarService) renderJob(ctx context.Context, id int64, faceId, bodyId, skinIdx int) {
- framesURL, err := s.renderFunc(ctx, faceId, bodyId, skinIdx)
- if err != nil {
+// 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": err.Error(),
+ "build_status": consts.AvatarBuildFailed, "error": msg,
"updated_at": "datetime('now','localtime')",
}); dbErr != nil {
- g.Log().Warningf(ctx, "update avatar render failed state: %v", dbErr)
+ 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
+ }
+
+ framesURL := ""
+ if g.Cfg().MustGet(ctx, "avatar.render_frames", true).Bool() {
+ framesURL, err = agent.RenderAvatarFrames(ctx, glbPath, fmt.Sprintf("user_%d", userId))
+ if err != nil {
+ g.Log().Warningf(ctx, "avatar frames render skipped: %v", err)
+ }
+ }
+
if dbErr := dao.AvatarModel.Update(ctx, id, map[string]any{
- "frames_url": framesURL, "build_status": consts.AvatarBuildDone, "error": "",
+ "glb_url": "/" + filepath.ToSlash(glbPath), "frames_url": framesURL,
+ "build_status": consts.AvatarBuildDone, "error": "",
"updated_at": "datetime('now','localtime')",
}); dbErr != nil {
- g.Log().Warningf(ctx, "update avatar render done state: %v", dbErr)
+ g.Log().Warningf(ctx, "update avatar done state: %v", dbErr)
}
}
+// Get 我的化身
func (s *avatarService) Get(ctx context.Context, userId int64) (*entity.AvatarModel, error) {
return dao.AvatarModel.GetByUser(ctx, userId)
}
diff --git a/styleagent/service/avatar_model_service_test.go b/styleagent/service/avatar_model_service_test.go
index 642dcb8..ec4934c 100644
--- a/styleagent/service/avatar_model_service_test.go
+++ b/styleagent/service/avatar_model_service_test.go
@@ -2,11 +2,11 @@ package service
import (
"context"
- "errors"
"testing"
"time"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
+ "github.com/gogf/gf/v2/frame/g"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
@@ -21,11 +21,10 @@ func avatarRecord(userId int64) *entity.AvatarModel {
}
}
-func TestRenderJobSuccessStateMachine(t *testing.T) {
- old := AvatarService.renderFunc
- defer func() { AvatarService.renderFunc = old }()
- AvatarService.renderFunc = func(_ context.Context, faceId, bodyId, skinIdx int) (string, error) {
- return "/workspace/avatar_frames/f0_b1_s2", nil
+// buildJob 未配置 Tripo key 时应置 failed 并提示配置(配置了 key 则跳过,避免真实网络调用)
+func TestBuildJobMissingKeyFailed(t *testing.T) {
+ if key := g.Cfg().MustGet(context.Background(), "avatar.tripo_api_key", "").String(); key != "" {
+ t.Skip("已配置 avatar.tripo_api_key,跳过(避免真实 Tripo 调用)")
}
userId := time.Now().UnixNano()
@@ -34,49 +33,19 @@ func TestRenderJobSuccessStateMachine(t *testing.T) {
t.Fatalf("插入测试化身失败: %v", err)
}
- AvatarService.renderJob(context.Background(), id, 0, 1, 2)
-
- got, err := dao.AvatarModel.GetByUser(context.Background(), userId)
- if err != nil || got == nil {
- t.Fatalf("读取化身失败: %v", err)
- }
- if got.BuildStatus != consts.AvatarBuildDone {
- t.Fatalf("渲染成功应置 done: got=%s", got.BuildStatus)
- }
- if got.FramesUrl != "/workspace/avatar_frames/f0_b1_s2" {
- t.Fatalf("应写 frames_url: got=%q", got.FramesUrl)
- }
- if got.Error != "" {
- t.Fatalf("成功时不应有 error: %q", got.Error)
- }
-}
-
-func TestRenderJobFailedFallback(t *testing.T) {
- old := AvatarService.renderFunc
- defer func() { AvatarService.renderFunc = old }()
- AvatarService.renderFunc = func(_ context.Context, _, _, _ int) (string, error) {
- return "", errors.New("3D 渲染服务未就绪")
- }
-
- userId := time.Now().UnixNano() + 1
- id, err := dao.AvatarModel.Insert(context.Background(), avatarRecord(userId))
- if err != nil {
- t.Fatalf("插入测试化身失败: %v", err)
- }
-
- AvatarService.renderJob(context.Background(), id, 0, 1, 2)
+ AvatarService.buildJob(context.Background(), id, userId)
got, err := dao.AvatarModel.GetByUser(context.Background(), userId)
if err != nil || got == nil {
t.Fatalf("读取化身失败: %v", err)
}
if got.BuildStatus != consts.AvatarBuildFailed {
- t.Fatalf("渲染失败应置 failed(降级): got=%s", got.BuildStatus)
- }
- if got.FramesUrl != "" {
- t.Fatalf("失败时不应写 frames_url: got=%q", got.FramesUrl)
+ t.Fatalf("未配置 key 应置 failed: got=%s", got.BuildStatus)
}
if got.Error == "" {
- t.Fatal("失败时应记录错误文案")
+ t.Fatal("未配置 key 时应记录错误文案")
+ }
+ if got.GlbUrl != "" {
+ t.Fatalf("失败时不应写 glb_url: got=%q", got.GlbUrl)
}
}
diff --git a/styleagent/service/cps_click_log_service.go b/styleagent/service/cps_click_log_service.go
new file mode 100644
index 0000000..eca5bad
--- /dev/null
+++ b/styleagent/service/cps_click_log_service.go
@@ -0,0 +1,40 @@
+package service
+
+import (
+ "context"
+ "slogan-agent/styleagent/dao"
+ "slogan-agent/styleagent/model/entity"
+)
+
+type cpsClickLogService struct{}
+
+var CpsClickLogService = new(cpsClickLogService)
+
+// Click 记录商品点击日志
+func (s *cpsClickLogService) Click(ctx context.Context, log *entity.CpsClickLog) error {
+ _, err := dao.CpsClickLog.Insert(ctx, log)
+ return err
+}
+
+// MyRecent 最近优惠(点击日志 → 商品信息,去重倒序)
+func (s *cpsClickLogService) MyRecent(ctx context.Context, userId int64) ([]*entity.CpsProduct, error) {
+ logs, err := dao.CpsClickLog.ListByUser(ctx, userId, 20)
+ if err != nil {
+ return nil, err
+ }
+ seen := make(map[string]bool, len(logs))
+ out := make([]*entity.CpsProduct, 0, len(logs))
+ for _, log := range logs {
+ key := log.Source + ":" + log.OuterId
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ prod, err := dao.CpsProduct.GetByOuter(ctx, log.Source, log.OuterId)
+ if err != nil || prod == nil {
+ continue
+ }
+ out = append(out, prod)
+ }
+ return out, nil
+}
diff --git a/styleagent/service/cps_product_service.go b/styleagent/service/cps_product_service.go
index eda5302..5ab7cf6 100644
--- a/styleagent/service/cps_product_service.go
+++ b/styleagent/service/cps_product_service.go
@@ -159,7 +159,7 @@ func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int
if err != nil {
return "", err
}
- _, _ = dao.CpsClickLog.Insert(ctx, &entity.CpsClickLog{
+ _ = CpsClickLogService.Click(ctx, &entity.CpsClickLog{
UserId: userId,
Source: prod.Source,
OuterId: prod.OuterId,
@@ -172,29 +172,6 @@ func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int
return link, nil
}
-// MyRecent 最近优惠(点击日志 → 商品信息,去重倒序)
-func (s *cpsProductService) MyRecent(ctx context.Context, userId int64) ([]*entity.CpsProduct, error) {
- logs, err := dao.CpsClickLog.ListByUser(ctx, userId, 20)
- if err != nil {
- return nil, err
- }
- seen := make(map[string]bool, len(logs))
- out := make([]*entity.CpsProduct, 0, len(logs))
- for _, log := range logs {
- key := log.Source + ":" + log.OuterId
- if seen[key] {
- continue
- }
- seen[key] = true
- prod, err := dao.CpsProduct.GetByOuter(ctx, log.Source, log.OuterId)
- if err != nil || prod == nil {
- continue
- }
- out = append(out, prod)
- }
- return out, nil
-}
-
// StartSyncLoop 定时同步联盟商品(main 启动;未配置任何 key 时空转)
func (s *cpsProductService) StartSyncLoop(ctx context.Context) {
spec := g.Cfg().MustGet(ctx, "cps.sync_cron", "0 4 * * *").String()
diff --git a/styleagent/service/plan_effect_image_service.go b/styleagent/service/plan_effect_image_service.go
index 7169ed0..cd7568e 100644
--- a/styleagent/service/plan_effect_image_service.go
+++ b/styleagent/service/plan_effect_image_service.go
@@ -62,7 +62,11 @@ func (s *effectImageService) run(ctx context.Context, planId, userId int64) {
}
}
- client := agent.NewClient(g.Cfg().MustGet(ctx, "agent.supplier", "mock").String())
+ client, err := agent.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "wanx").String())
+ if err != nil {
+ g.Log().Warningf(ctx, "效果图生成不可用: %v", err)
+ return
+ }
for i, angle := range effectAngles {
cacheKey := effectCacheKey(plan, angle)
if url, ok := agent.CacheGet(cacheKey); ok {