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 {