This commit is contained in:
2026-08-03 10:29:31 +08:00
parent 878468cd6d
commit a338865025
20 changed files with 613 additions and 398 deletions
+18 -7
View File
@@ -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-glnode_bin 需指向 gl 有预编译二进制的 Node 版本
# 3D 化身生成(Tripo 图像转 3Dkey 为空时 /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-glnode_bin 需指向 gl 有预编译二进制的 Node 版本)
render:
enabled: true
node_bin: "/Users/zhangbin/.nvm/versions/node/v18.20.4/bin/node"
-138
View File
@@ -1,138 +0,0 @@
// 程序化生成简化人体化身 GLB(脸型/体型/肤色三参数组合)。
// 用法: node gen-templates.js --out <dir> [--faces 6] [--bodies 6] [--skins 5]
// 索引对齐 MatchTemplatesface 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 无 FileReaderGLTFExporter 二进制导出需要它(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}`);
+114
View File
@@ -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
}
-9
View File
@@ -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)
}
-42
View File
@@ -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
}
+212
View File
@@ -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)
}
+13 -11
View File
@@ -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)
-13
View File
@@ -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
}
+88 -34
View File
@@ -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-proimage-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 URIhttp(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 URIdashscope 无法访问相对路径),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("万相任务超时")
+6 -20
View File
@@ -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
@@ -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
}
+104 -56
View File
@@ -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)
}
+11 -42
View File
@@ -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)
}
}
@@ -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
}
+1 -24
View File
@@ -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()
@@ -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 {