Add 'server/' from commit 'e64421295fff83acbb6d6ab3d3b27f3ef8368f00'

git-subtree-dir: server
git-subtree-mainline: c4e617ada7
git-subtree-split: e64421295f
This commit is contained in:
2026-08-04 15:02:35 +08:00
176 changed files with 13532 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 构建与运行时产物(避免把生产数据库与图片塞进构建上下文)
data/
workspace/
*.db
# 渲染器依赖:由 Dockerfile renderer stage 安装,宿主 node_modules 平台不匹配,排除以防覆盖
scripts/avatar-render/node_modules/
# VCS 与 IDE
.git
.gitignore
.idea/
.vscode/
.DS_Store
+17
View File
@@ -0,0 +1,17 @@
# 数据库与运行时产物
slogan.db
*.db
data/
slogan-agent
workspace/
# IDE
.idea/
*.iml
.vscode/
# 系统
.DS_Store
# 渲染器依赖
scripts/avatar-render/node_modules/
+34
View File
@@ -0,0 +1,34 @@
FROM golang:alpine AS builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache git ca-certificates tzdata
ENV TZ=Asia/Shanghai
ENV GO111MODULE=on
ENV GOPROXY=https://goproxy.cn,direct
ENV CGO_ENABLED=0
ENV GOTOOLCHAIN=auto
WORKDIR /build
COPY . .
RUN go mod download
RUN go build -ldflags="-s -w" -o main ./main.go
# 3D 化身渲染器(Node + headless-gl,需原生编译)
FROM node:20-alpine AS renderer
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache git python3 make g++ mesa mesa-dev ca-certificates tzdata
WORKDIR /render
COPY scripts/avatar-render/package*.json ./
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com
FROM alpine:3.19
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata libstdc++ libgcc mesa nodejs
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY --from=builder /build/config.yml .
COPY --from=builder /build/main .
COPY --from=renderer /render/node_modules ./scripts/avatar-render/node_modules
COPY scripts/avatar-render/ ./scripts/avatar-render/
RUN mkdir -p /app/workspace /app/data
EXPOSE 3007
ENTRYPOINT ["./main"]
+34
View File
@@ -0,0 +1,34 @@
package common
import (
"errors"
"github.com/golang-jwt/jwt/v5"
)
const jwtSecret = "slogan-agent-jwt-secret-2026"
type JwtClaims struct {
UserId int64 `json:"user_id"`
Role string `json:"role"`
AgentId int64 `json:"agent_id,omitempty"`
jwt.RegisteredClaims
}
func GetJwtSecret() string {
return jwtSecret
}
func ParseToken(tokenStr string) (*JwtClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*JwtClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
+91
View File
@@ -0,0 +1,91 @@
package common
import (
"net/http"
"strings"
"github.com/gogf/gf/v2/net/ghttp"
)
var publicPaths = []string{
"/user/login",
"/user/register",
"/hairstyle/list",
"/api.json",
"/member/order/notify",
}
func Auth(r *ghttp.Request) {
path := r.URL.Path
// 公开路径(精确匹配)
for _, p := range publicPaths {
if path == p {
r.Middleware.Next()
return
}
}
// workspace 文件通过前缀匹配放行(浏览器图片请求不带 Authorization
if strings.HasPrefix(path, "/workspace/") {
r.Middleware.Next()
return
}
auth := r.Header.Get("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
r.Response.WriteJson(ghttp.DefaultHandlerResponse{
Code: http.StatusUnauthorized,
Message: "未登录或登录已过期",
})
r.Exit()
return
}
claims, err := ParseToken(auth[7:])
if err != nil {
r.Response.WriteJson(ghttp.DefaultHandlerResponse{
Code: http.StatusUnauthorized,
Message: "登录已过期,请重新登录",
})
r.Exit()
return
}
r.SetCtxVar("userId", claims.UserId)
r.SetCtxVar("role", claims.Role)
r.SetCtxVar("agentId", claims.AgentId)
r.Middleware.Next()
}
func GetUserId(r *ghttp.Request) int64 {
v := r.GetCtxVar("userId")
if v == nil {
return 0
}
return v.Int64()
}
func GetRole(r *ghttp.Request) string {
v := r.GetCtxVar("role")
if v == nil {
return ""
}
return v.String()
}
func GetAgentId(r *ghttp.Request) int64 {
v := r.GetCtxVar("agentId")
if v == nil {
return 0
}
return v.Int64()
}
func CheckAdmin(r *ghttp.Request) bool {
return GetRole(r) == "admin"
}
func CheckAgent(r *ghttp.Request) bool {
return GetRole(r) == "agent"
}
+55
View File
@@ -0,0 +1,55 @@
package common
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
func prepareInsertData(data any) map[string]any {
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
delete(m, "id")
m["created_at"] = gtime.Now().Format("Y-m-d H:i:s")
m["updated_at"] = gtime.Now().Format("Y-m-d H:i:s")
delete(m, "deleted_at")
return m
}
func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, err error) {
m := prepareInsertData(data)
r, err := g.DB().Model(table).Ctx(ctx).Data(m).Insert()
if err != nil {
return 0, err
}
if r == nil {
return 0, nil
}
return r.LastInsertId()
}
func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err error) {
r, err := g.DB().Model(table).Ctx(ctx).
Cache(gdb.CacheOption{Duration: CacheTTL(), Name: table + "_GetOneByPk_" + gconv.String(pk)}).
Where("id", pk).One()
if err != nil {
return nil, err
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
func UpdateByPk(ctx context.Context, table string, pk int64, data any) error {
_, err := g.DB().Model(table).Ctx(ctx).Data(data).Where("id", pk).Update()
return err
}
func DeleteByPk(ctx context.Context, table string, pk int64) error {
_, err := g.DB().Model(table).Ctx(ctx).Unscoped().Where("id", pk).Delete()
return err
}
+22
View File
@@ -0,0 +1,22 @@
package common
import (
"context"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
)
var (
cacheTTL time.Duration
cacheTTLOnce sync.Once
)
// CacheTTL returns the database query cache TTL from config
func CacheTTL() time.Duration {
cacheTTLOnce.Do(func() {
cacheTTL = time.Duration(g.Cfg().MustGet(context.Background(), "database.cache.ttl", 60).Int()) * time.Second
})
return cacheTTL
}
+51
View File
@@ -0,0 +1,51 @@
package common
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gogf/gf/v2/net/ghttp"
)
var allowedImageExt = map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true}
// SaveUploadedFile 保存上传文件到 workspace/{subDir},返回访问路径 /workspace/{subDir}/{filename}
func SaveUploadedFile(file *ghttp.UploadFile, subDir string) (string, error) {
if file == nil {
return "", errors.New("未收到文件")
}
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedImageExt[ext] {
return "", errors.New("仅支持 jpg/jpeg/png/webp 格式")
}
if file.Size > 10*1024*1024 {
return "", errors.New("单张图片不能超过 10MB")
}
dir := filepath.Join("workspace", subDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
path := filepath.Join(dir, filename)
if _, err := file.Save(path); err != nil {
return "", err
}
return "/" + filepath.ToSlash(filepath.Join("workspace", subDir, filename)), nil
}
// RemoveWorkspaceFile 删除 workspace 下文件(路径穿越防护)
func RemoveWorkspaceFile(url string) error {
rel := strings.TrimPrefix(url, "/workspace/")
if rel == "" || strings.Contains(rel, "..") {
return errors.New("非法文件路径")
}
abs := filepath.Join("workspace", rel)
if _, err := os.Stat(abs); os.IsNotExist(err) {
return nil
}
return os.Remove(abs)
}
+50
View File
@@ -0,0 +1,50 @@
package common
import (
"fmt"
"reflect"
"regexp"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
var Httpserver = g.Server()
func init() {
err := gtime.SetTimeZone("Asia/Shanghai")
if err != nil {
panic("设置时区失败")
}
Httpserver.SetOpenApiPath("/api.json")
// 全局 panic 恢复(最先注册,作为最外层包裹)
Httpserver.BindMiddlewareDefault(ghttp.MiddlewareHandlerResponse)
// CORS - allow all origins
Httpserver.BindMiddlewareDefault(func(r *ghttp.Request) {
r.Response.CORS(r.Response.DefaultCORSOptions())
r.Middleware.Next()
})
// JWT 鉴权
Httpserver.BindMiddlewareDefault(Auth)
}
// RouteRegister 根据控制器结构体名称自动注册路由
func RouteRegister(controllers []interface{}) {
re := regexp.MustCompile("[A-Z]")
for _, t := range controllers {
sName := reflect.ValueOf(t).Elem().Type().Name()
convertedStr := re.ReplaceAllStringFunc(sName, func(s string) string {
return fmt.Sprintf("-%s", strings.ToLower(s))
})
convertedStr = strings.ReplaceAll(convertedStr, "_", "-")
if len(convertedStr) > 0 && convertedStr[0] == '-' {
convertedStr = convertedStr[1:]
}
Httpserver.Group("/"+convertedStr, func(group *ghttp.RouterGroup) {
group.Bind(t)
})
}
go Httpserver.Run()
}
+249
View File
@@ -0,0 +1,249 @@
package common
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
)
// ImageFileToBase64 reads an image file and returns a data:image/...;base64 string.
func ImageFileToBase64(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
ext := strings.ToLower(pathExt(path))
mime := "image/png"
switch ext {
case ".jpg", ".jpeg":
mime = "image/jpeg"
case ".gif":
mime = "image/gif"
case ".webp":
mime = "image/webp"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// pathExt extracts the extension from a path.
func pathExt(path string) string {
for i := len(path) - 1; i >= 0 && path[i] != '/'; i-- {
if path[i] == '.' {
return path[i:]
}
}
return ""
}
// BuildSchemaRequest validates input values against a JSON schema definition,
// fills in default values for missing optional fields,
// and returns the result matching the schema's nested structure.
//
// The schema format follows test.json convention:
//
// {
// "section": {
// "field_name": {
// "type": "string|integer|number|boolean|array|object",
// "required": true|false,
// "default": value,
// "enum": [...],
// "min": number,
// "max": number,
// "max_chars": number,
// "min_items": number,
// "max_items": number
// }
// }
// }
//
// input is a flat map like {"prompt": "hello", "duration": 5}.
// Fields not present in input but with a "default" in the schema are filled automatically.
// Nodes without "type" are treated as grouping sections and recursed into.
// When validate is false, required/type/range/enum checks are skipped (only structure + defaults).
func BuildSchemaRequest(schema map[string]any, input map[string]any, validate bool) (map[string]any, error) {
result := make(map[string]any)
for key, val := range schema {
fieldDef, ok := val.(map[string]any)
if !ok {
result[key] = val
continue
}
if _, hasType := fieldDef["type"]; hasType {
processed, err := processField(key, fieldDef, input, validate)
if err != nil {
return nil, err
}
if processed != nil {
result[key] = processed
}
continue
}
nested, err := BuildSchemaRequest(fieldDef, input, validate)
if err != nil {
return nil, err
}
if len(nested) > 0 {
result[key] = nested
}
}
return result, nil
}
func processField(name string, def map[string]any, input map[string]any, validate bool) (any, error) {
fieldType, _ := def["type"].(string)
required, _ := def["required"].(bool)
rawVal, exists := input[name]
if !exists {
if validate && required {
return nil, fmt.Errorf("%s", def["description"])
}
if dflt, ok := def["default"]; ok {
return convertDefault(dflt, fieldType), nil
}
return nil, nil
}
if !validate {
return rawVal, nil
}
switch fieldType {
case "string":
s, ok := rawVal.(string)
if !ok {
return nil, fmt.Errorf("'%s' must be a string", name)
}
if maxChars, ok := def["max_chars"].(float64); ok && len([]rune(s)) > int(maxChars) {
return nil, fmt.Errorf("'%s' exceeds max length of %d", name, int(maxChars))
}
if enum, ok := def["enum"].([]any); ok && len(enum) > 0 {
if !containsValue(enum, s) {
return nil, fmt.Errorf("'%s' must be one of %v", name, enum)
}
}
return s, nil
case "integer":
v, err := toInt(rawVal)
if err != nil {
return nil, fmt.Errorf("'%s' must be an integer", name)
}
if minVal, ok := def["min"].(float64); ok && v < int(minVal) {
return nil, fmt.Errorf("'%s' must be >= %d", name, int(minVal))
}
if maxVal, ok := def["max"].(float64); ok && v > int(maxVal) {
return nil, fmt.Errorf("'%s' must be <= %d", name, int(maxVal))
}
return v, nil
case "number":
v, ok := rawVal.(float64)
if !ok {
if iv, err := toInt(rawVal); err == nil {
v = float64(iv)
} else {
return nil, fmt.Errorf("'%s' must be a number", name)
}
}
if minVal, ok := def["min"].(float64); ok && v < minVal {
return nil, fmt.Errorf("'%s' must be >= %v", name, minVal)
}
if maxVal, ok := def["max"].(float64); ok && v > maxVal {
return nil, fmt.Errorf("'%s' must be <= %v", name, maxVal)
}
return v, nil
case "boolean":
_, ok := rawVal.(bool)
if !ok {
return nil, fmt.Errorf("'%s' must be a boolean", name)
}
return rawVal, nil
case "array":
arr, ok := rawVal.([]any)
if !ok {
return nil, fmt.Errorf("'%s' must be an array", name)
}
if minItems, ok := def["min_items"].(float64); ok && len(arr) < int(minItems) {
return nil, fmt.Errorf("'%s' must have at least %d items", name, int(minItems))
}
if maxItems, ok := def["max_items"].(float64); ok && len(arr) > int(maxItems) {
return nil, fmt.Errorf("'%s' must have at most %d items", name, int(maxItems))
}
if itemsDef, ok := def["items"].(map[string]any); ok {
items, err := processArrayItems(arr, itemsDef, validate)
if err != nil {
return nil, fmt.Errorf("'%s': %w", name, err)
}
return items, nil
}
return arr, nil
}
return rawVal, nil
}
func processArrayItems(arr []any, itemsDef map[string]any, validate bool) ([]any, error) {
itemType, _ := itemsDef["type"].(string)
if itemType != "object" {
return arr, nil
}
props, _ := itemsDef["properties"].(map[string]any)
if props == nil {
return arr, nil
}
result := make([]any, len(arr))
for i, item := range arr {
itemMap, ok := item.(map[string]any)
if !ok {
result[i] = item
continue
}
processed, err := BuildSchemaRequest(props, itemMap, validate)
if err != nil {
return nil, fmt.Errorf("item[%d]: %w", i, err)
}
result[i] = processed
}
return result, nil
}
func toInt(v any) (int, error) {
switch val := v.(type) {
case float64:
return int(val), nil
case int:
return val, nil
case int64:
return int(val), nil
case json.Number:
n, err := val.Int64()
return int(n), err
default:
return 0, fmt.Errorf("cannot convert %T to int", v)
}
}
func convertDefault(dflt any, fieldType string) any {
if fieldType == "integer" {
if f, ok := dflt.(float64); ok {
return int(f)
}
}
return dflt
}
func containsValue(arr []any, val any) bool {
for _, v := range arr {
if v == val {
return true
}
}
return false
}
+100
View File
@@ -0,0 +1,100 @@
# SQLite 落盘到 data/ 子目录:本地开发与容器都便于挂载持久化(data/ 已入 .gitignore
database:
default:
name: data/slogan.db
type: sqlite
debug: false
plan:
name: data/slogan_plan.db
type: sqlite
debug: false
pay:
name: data/slogan_pay.db
type: sqlite
debug: false
cps:
name: data/slogan_cps.db
type: sqlite
debug: false
cache:
ttl: 60
server:
address: :3007
name: slogan
workerId: 1
clientMaxBodySize: 209715200
requestTimeout: 3000
chat:
timeout: 300
max_retries: 3
# 和风天气 API Key(v7,免费版)
weather:
qweather_key: ""
qweather_base: "https://devapi.qweather.com"
# 高德地理编码 Key
geo:
amap_key: ""
amap_base: "https://restapi.amap.com"
# 图像生成供应商配置(真实调用,不支持 mock)
imagegen:
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: "sk-ws-H.RPMDIPI.Ba0s.MEUCIDmDkIKUzhk_TCC6hckvIZCP6LNVCnE-VDCdYH5yDTUjAiEAqfG-wUWGcVJyNuyxWsUFdKD_oHkPn3TxdxDcYcmlU24"
model_name: "qwen3.7-plus"
max_tokens: 4096
temperature: 0.8
# 支付(虎皮棋聚合支付,key 为空则支付功能降级关闭)
payment:
xunhu_appid: ""
xunhu_appsecret: ""
notify_url: "http://localhost:3007/member/order/notify" # 生产需公网可达
channel: "alipay,wechat"
api_base: "https://api.xunhupay.com"
# 广告激励限频(自然日)
ad:
limit_effect_extra: 2
limit_vip_trial: 1
# 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"
# CPS 联盟(key 全空则联盟入口优雅降级隐藏)
cps:
sync_cron: "0 4 * * *" # 联盟商品定时同步
meituan_appkey: ""
meituan_secret: ""
meituan_pid: ""
meituan_base: "https://openapi.meituan.com"
jd_appkey: ""
jd_secret: ""
jd_site_id: ""
jd_pid: ""
jd_base: "https://api.jd.com/routerjson"
tb_appkey: ""
tb_secret: ""
tb_pid: ""
tb_adzone_id: ""
tb_base: "https://eco.taobao.com/router/rest"
+15
View File
@@ -0,0 +1,15 @@
services:
slogan-agent:
build: .
container_name: slogan-agent
restart: unless-stopped
ports:
- "3007:3007"
volumes:
# SQLite 数据库(config.yml 已指向 data/ 子目录,容器内 /app/data 与宿主机 ./data 互通)
- ./data:/app/data
# 生成的图片 / GLB 等运行时产物
- ./workspace:/app/workspace
# 容器内运行前需在 config.yml 调整:
# render.node_bin → "/usr/bin/node"(镜像内置 alpine node,非 macOS nvm 路径)
# payment.notify_url → 公网可达地址(支付回调容器内 localhost 不可达)
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,933 @@
# slogan-agent MVP 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现 slogan-agent 服务端 MVP:登录 → 照片/衣橱/身形管理 → 化身构建(v1 模板匹配)→ 穿搭生成(规则评分 + Agent 规划 + 兜底)→ 效果图按需生成,全链路可运行。
**Architecture:** Go 单体 + GoFrame v2 + SQLite,严格遵循 video-factory 分层规范(controller → service → dao),包级单例,RouteRegister 反射注册路由,JWT 鉴权,OpenAI 兼容 LLM。规则评分零 LLM 成本,效果图按需生成 + 缓存。
**Tech Stack:** Go 1.22+ / GoFrame v2.10 / SQLite / JWT / bcrypt / OpenAI 兼容 API / 和风天气 API
**参考代码(必须阅读后复制模式):**
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/`http.go / auth.go / base_dao.go / cache.go / auth_middleware.go / util.go
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/agent/chat_model.go`(直接复用整个文件,改包名)
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/`controller/service/dao/model 全部模式)
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/main.go`(入口模式)
**数据库:** `slogan.db`config.yml 配置),表名前缀 `slogan_`。所有 init() 自动建表 + ALTER 迁移兼容。
**模块路径:** `slogan-agent`go.mod module name),业务包 `styleagent`
---
## 数据库表总览(Task 2-3 建全)
| 表 | 关键字段 |
|----|---------|
| `slogan_user` | id, role(default 'user'), username, phone, password, name, created_at, updated_at |
| `slogan_user_photo` | id, user_id, type(1大头照 2全身正面 3全身侧面 4全身背面), url, status, created_at |
| `slogan_wardrobe_item` | id, user_id, photo_url, category(上衣/下装/鞋/配饰), season(春/夏/秋/冬/四季), style_tags, color_info, status, created_at |
| `slogan_body_measurement` | id, user_id, height, weight, skin_tone(1-5), fit_params(JSON), updated_at |
| `slogan_avatar_model` | id, user_id, face_template_id, body_template_id, skin_tone_index, face_texture_url, glb_url, build_status(pending/processing/done/failed), error, params_snapshot(JSON), created_at |
| `slogan_hairstyle_asset` | id, name, style_tag, glb_url, thumb_url, applicable_face, sort |
| `slogan_outfit_generation_task` | id, user_id, start_date, end_date, location, weather_snapshot(JSON), status(pending/planning/scoring/rendering/done/failed), error, model_name, created_at |
| `slogan_outfit_plan` | id, task_id, user_id, date_range, location, source(wardrobe/recommend), score, main_flag(0/1), hairstyle_id, hair_color, weather_ref(JSON), created_at |
| `slogan_plan_outfit_item` | id, plan_id, slot(发型/上衣/下装/鞋/配饰), source, wardrobe_item_id, product_name, name, desc |
| `slogan_plan_effect_image` | id, plan_id, angle(正面/侧面/背面), url, status, prompt_snapshot, created_at |
| `slogan_plan_review` | id, plan_id, user_id, action(fav/unfav), note, created_at |
| `slogan_scoring_rule` | id, dimension, rule_type, rules_json, enabled, version |
---
### Task 1: 项目骨架(go.mod / config / main.go / common 复制)
**Files:**
- Create: `go.mod`
- Create: `config.yml`
- Create: `main.go`
- Copy: `common/http.go`, `common/auth.go`, `common/base_dao.go`, `common/cache.go`, `common/util.go`, `common/auth_middleware.go`(从 video-factory 复制,改 package 注释即可,无需改逻辑)
- [ ] **Step 1: 创建 go.mod**
```bash
cd slogan-agent
go mod init slogan-agent
```
- [ ] **Step 2: 创建 config.yml**
```yaml
database:
default:
name: slogan.db
type: sqlite
debug: false
cache:
ttl: 60
server:
address: :3007
name: slogan
workerId: 1
clientMaxBodySize: 209715200
requestTimeout: 3000
chat:
timeout: 300
max_retries: 3
```
- [ ] **Step 3: 复制 common 包**
```bash
cp /Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/{http.go,auth.go,base_dao.go,cache.go,util.go,auth_middleware.go} common/
```
注意:auth_middleware.go 和 cache.go / util.go 需检查是否有对 video-factory 特定包的 import,如有则调整。auth.go 中 jwtSecret 改为 slogan 自己的密钥。
- [ ] **Step 4: 创建 main.go**(模式同 video-factory main.go,路由表注册 controllerworkspace 鉴权静态服务,端口 3007)
- [ ] **Step 5: 添加依赖并编译**
```bash
go mod tidy
go build ./...
```
Expected: 编译通过(common 包复制可能依赖 gtime/gcachetidy 解决)。
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: slogan-agent skeleton with common package"
```
---
### Task 2: consts 与全部 entity
**Files:**
- Create: `styleagent/consts/table_name.go`(全部表名常量)
- Create: `styleagent/consts/status.go`(任务状态/照片类型/方案来源常量)
- Create: `styleagent/model/entity/`12 个文件:user.go, user_photo.go, wardrobe_item.go, body_measurement.go, avatar_model.go, hairstyle_asset.go, outfit_generation_task.go, outfit_plan.go, plan_outfit_item.go, plan_effect_image.go, plan_review.go, scoring_rule.go
- [ ] **Step 1: consts/table_name.go**
```go
package consts
const (
TableNameUser = "slogan_user"
TableNameUserPhoto = "slogan_user_photo"
TableNameWardrobeItem = "slogan_wardrobe_item"
TableNameBodyMeasurement = "slogan_body_measurement"
TableNameAvatarModel = "slogan_avatar_model"
TableNameHairstyleAsset = "slogan_hairstyle_asset"
TableNameOutfitGenTask = "slogan_outfit_generation_task"
TableNameOutfitPlan = "slogan_outfit_plan"
TableNamePlanOutfitItem = "slogan_plan_outfit_item"
TableNamePlanEffectImage = "slogan_plan_effect_image"
TableNamePlanReview = "slogan_plan_review"
TableNameScoringRule = "slogan_scoring_rule"
)
```
- [ ] **Step 2: consts/status.go**
```go
package consts
// 照片类型
const (
PhotoTypeHeadshot = 1 // 大头照
PhotoTypeFullFront = 2 // 全身正面
PhotoTypeFullSide = 3 // 全身侧面
PhotoTypeFullBack = 4 // 全身背面
)
// 生成任务状态
const (
TaskStatusPending = "pending"
TaskStatusPlanning = "planning"
TaskStatusScoring = "scoring"
TaskStatusRendering = "rendering"
TaskStatusDone = "done"
TaskStatusFailed = "failed"
)
// 方案来源
const (
PlanSourceWardrobe = "wardrobe"
PlanSourceRecommend = "recommend"
)
// 化身构建状态
const (
AvatarBuildPending = "pending"
AvatarBuildProcessing = "processing"
AvatarBuildDone = "done"
AvatarBuildFailed = "failed"
)
// 评分阈值(可被 scoring_rule 配置覆盖)
const DefaultScoreThreshold = 75
```
- [ ] **Step 3: entity 文件**orm tag 模式同 video-factory entity/user.go;全部含 CreatedAt/UpdatedAt `*gtime.Time`;字段完全对齐 Task 表格总览)
- [ ] **Step 4: 编译检查** `go build ./...`
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: consts and entities"`
---
### Task 3: 全部 DAOinit 自动建表)
**Files:**
- Create: `styleagent/dao/user_dao.go`(完整示例,含建表 + CRUD + 缓存)
- Create: 其余 11 个 dao 文件(user_photo / wardrobe_item / body_measurement / avatar_model / hairstyle_asset / outfit_generation_task / outfit_plan / plan_outfit_item / plan_effect_image / plan_review / scoring_rule
- [ ] **Step 1: user_dao.go**(模式:video-factory dao/user_dao.go
```go
package dao
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
)
var User = &userDao{}
type userDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUser+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL DEFAULT 'user',
username TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create user table failed: %v", err)
}
_, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_username ON "+consts.TableNameUser+"(username) WHERE username != ''")
_, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_phone ON "+consts.TableNameUser+"(phone) WHERE phone != ''")
}
// 方法:Insert / GetOne / GetByAccount / Update / UpdateFields(复制 video-factory user_dao 对应方法,表名换 consts.TableNameUser
```
- [ ] **Step 2: 其余 11 个 dao**:每个含 init() 建表 + 核心查询方法(按字段):ListByUseruser_photo/wardrobe_item 按 user_id 分页)、GetByUserAndType、GetByUseravatar/body 单行)、ListByPlanplan_outfit_item/plan_effect_image)、GetByTaskoutfit_plan 列表)、ListAllhairstyle_asset 按 sort)、GetEnabledscoring_rule)、UpdateStatustask 状态流转)
- [ ] **Step 3: 建表自检**(先写 dao 测试或直接启动临时 main 验证)
```bash
go build ./... && go run main.go 2>&1 | head -5
# 验证 slogan.db 生成且无建表错误日志
```
- [ ] **Step 4: Commit** `git add -A && git commit -m "feat: dao layer with auto table creation"`
---
### Task 4: 全部 DTO(请求/响应 + g.Meta 路由)
**Files:**
- Create: `styleagent/model/dto/user_dto.go`LoginReq/LoginRes/ProfileRes
- Create: `styleagent/model/dto/user_photo_dto.go`
- Create: `styleagent/model/dto/wardrobe_dto.go`
- Create: `styleagent/model/dto/body_measurement_dto.go`
- Create: `styleagent/model/dto/avatar_dto.go`
- Create: `styleagent/model/dto/hairstyle_dto.go`
- Create: `styleagent/model/dto/outfit_dto.go`
- [ ] **Step 1: 关键 dto 内容**
```go
// user_dto.go
type LoginReq struct {
g.Meta `path:"/login" method:"post" tags:"用户" summary:"登录"`
Account string `v:"required" json:"account"`
Password string `v:"required" json:"password"`
}
type LoginRes struct {
Token string `json:"token"`
User *LoginUser `json:"user"`
}
type LoginUser struct {
Id int64 `json:"id"`
Role string `json:"role"`
Name string `json:"name"`
}
// user_photo_dto.go
type UserPhotoUploadReq struct {
g.Meta `path:"/upload" method:"post" tags:"照片" summary:"上传照片"`
Type int `v:"required|in:1,2,3,4" json:"type"`
// 文件字段:GoFrame 自动绑定 upload 文件(r.GetUploadFile
}
type UserPhotoUploadRes struct { Id int64 `json:"id"` }
type UserPhotoListReq struct {
g.Meta `path:"/list" method:"get" tags:"照片" summary:"照片列表"`
Type int `json:"type"` // 可空
}
type UserPhotoListRes struct {
List []*entity.UserPhoto `json:"list"`
}
type UserPhotoDeleteReq struct {
g.Meta `path:"/delete" method:"post" tags:"照片" summary:"删除照片"`
Id int64 `v:"required" json:"id"`
}
// wardrobe_dto.go
type WardrobeUploadReq struct {
g.Meta `path:"/upload" method:"post" tags:"衣橱" summary:"上传服装"`
Category string `v:"required" json:"category"`
Season string `json:"season"`
StyleTags string `json:"style_tags"`
ColorInfo string `json:"color_info"`
// 文件字段同上
}
type WardrobeListReq struct {
g.Meta `path:"/list" method:"get" tags:"衣橱" summary:"衣橱列表"`
Category string `json:"category"`
}
type WardrobeListRes struct { List []*entity.WardrobeItem `json:"list"` }
type WardrobeUpdateReq struct {
g.Meta `path:"/update" method:"post" tags:"衣橱" summary:"更新服装"`
Id int64 `v:"required" json:"id"`
Category string `json:"category"`
Season string `json:"season"`
StyleTags string `json:"style_tags"`
}
type WardrobeDeleteReq struct {
g.Meta `path:"/delete" method:"post" tags:"衣橱" summary:"删除服装"`
Id int64 `v:"required" json:"id"`
}
// body_measurement_dto.go
type BodyMeasurementSaveReq struct {
g.Meta `path:"/save" method:"post" tags:"身形" summary:"保存身形参数"`
Height int `json:"height"`
Weight int `json:"weight"`
SkinTone int `v:"in:1,2,3,4,5" json:"skin_tone"`
FitParams string `json:"fit_params"`
}
type BodyMeasurementGetRes struct {
Height int `json:"height"`
Weight int `json:"weight"`
SkinTone int `json:"skin_tone"`
FitParams string `json:"fit_params"`
}
// avatar_dto.go
type AvatarBuildReq struct {
g.Meta `path:"/build" method:"post" tags:"化身" summary:"构建化身"`
}
type AvatarBuildRes struct { TaskId int64 `json:"task_id"` }
type AvatarGetRes struct {
FaceTemplateId int `json:"face_template_id"`
BodyTemplateId int `json:"body_template_id"`
SkinToneIndex int `json:"skin_tone_index"`
GlbUrl string `json:"glb_url"`
BuildStatus string `json:"build_status"`
}
// hairstyle_dto.go
type HairstyleListRes struct { List []*entity.HairstyleAsset `json:"list"` }
// outfit_dto.go
type OutfitGenerateReq struct {
g.Meta `path:"/generate" method:"post" tags:"穿搭" summary:"生成穿搭方案"`
StartDate string `v:"required|date" json:"start_date"`
EndDate string `v:"required|date" json:"end_date"`
Location string `v:"required" json:"location"`
}
type OutfitGenerateRes struct { TaskId int64 `json:"task_id"` }
type OutfitTaskStatusReq struct {
g.Meta `path:"/task/status" method:"get" tags:"穿搭" summary:"任务状态"`
TaskId int64 `v:"required" json:"task_id"`
}
type OutfitTaskStatusRes struct {
Status string `json:"status"`
Error string `json:"error"`
}
type OutfitPlanListReq struct {
g.Meta `path:"/plan/list" method:"get" tags:"穿搭" summary:"方案列表"`
}
type OutfitPlanListRes struct { List []*entity.OutfitPlan `json:"list"` }
type OutfitPlanDetailReq struct {
g.Meta `path:"/plan/detail" method:"get" tags:"穿搭" summary:"方案详情"`
PlanId int64 `v:"required" json:"plan_id"`
}
type OutfitPlanDetailRes struct {
Plan *entity.OutfitPlan `json:"plan"`
Items []*entity.PlanOutfitItem `json:"items"`
Images []*entity.PlanEffectImage `json:"images"`
Hairstyle *entity.HairstyleAsset `json:"hairstyle,omitempty"`
}
type OutfitSelectMainReq struct {
g.Meta `path:"/plan/select-main" method:"post" tags:"穿搭" summary:"选定主方案"`
PlanId int64 `v:"required" json:"plan_id"`
}
type OutfitReviewReq struct {
g.Meta `path:"/plan/review" method:"post" tags:"穿搭" summary:"方案反馈"`
PlanId int64 `v:"required" json:"plan_id"`
Action string `v:"required|in:fav,unfav" json:"action"`
Note string `json:"note"`
}
```
- [ ] **Step 2: 编译检查** `go build ./...`entity 引入路径)
- [ ] **Step 3: Commit** `git add -A && git commit -m "feat: dto layer with route metadata"`
---
### Task 5: 用户域(user controller + service
**Files:**
- Create: `styleagent/controller/user_controller.go`
- Create: `styleagent/service/user_service.go`
- Test: `styleagent/service/user_service_test.go`
- [ ] **Step 1: 写失败测试**
```go
// user_service_test.go
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoginSuccess(t *testing.T) {
ctx := context.Background()
// 注册新用户
userId, err := UserService.Register(ctx, "test_user_1", "password123")
assert.NoError(t, err)
assert.True(t, userId > 0)
_, token, err := UserService.Login(ctx, "test_user_1", "password123")
assert.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestLoginWrongPassword(t *testing.T) {
ctx := context.Background()
_, _, err := UserService.Login(ctx, "test_user_1", "wrong")
assert.Error(t, err)
}
```
- [ ] **Step 2: 运行确认失败** `go test ./styleagent/service/ -run TestLogin -v`
Expected: FAIL(编译失败/未定义 UserService
- [ ] **Step 3: 实现 user_service.go**(复制 video-factory user_service.go 模式 + Register 方法,bcrypt 哈希密码,JWT 7 天;测试需要独立 DB —— 测试用 `test_slogan.db`,在 TestMain 中切换 g.DB 配置)
- [ ] **Step 4: 实现 user_controller.go**Login / ChangePassword / Profile 三个方法绑定 dto
- [ ] **Step 5: 运行确认通过** `go test ./styleagent/service/ -run TestLogin -v` → PASS
- [ ] **Step 6: Commit** `git add -A && git commit -m "feat: user domain login/register"`
---
### Task 6: 照片/衣橱/身形域(上传 + 列表 + 删除)
**Files:**
- Create: `styleagent/controller/user_photo_controller.go`, `wardrobe_controller.go`, `body_measurement_controller.go`
- Create: `styleagent/service/user_photo_service.go`, `wardrobe_service.go`, `body_measurement_service.go`
- Create: `styleagent/service/file_storage.go`(文件保存封装:`SaveUploadedFile(file, subDir)``workspace/user_{id}/photos/xxx.jpg`,返回相对路径)
- [ ] **Step 1: file_storage.go**(模式:video-factory character_service 的文件保存逻辑)
```go
package service
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// SaveUploadedFile 保存上传文件到 workspace/{subDir},返回 "workspace/{subDir}/{filename}"
func SaveUploadedFile(file *ghttp.UploadFile, subDir string) (string, error) {
dir := filepath.Join("workspace", subDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), file.Filename)
path := filepath.Join(dir, filename)
if err := file.Save(path); err != nil {
return "", err
}
return "/" + filepath.ToSlash(filepath.Join("workspace", subDir, filename)), nil
}
```
- [ ] **Step 2: 写失败测试**(user_photo:上传→列表→删除;wardrobe 同理;bodysave→get 往返)
- [ ] **Step 3: 实现三个 service**upload 校验(单张 ≤10MB、jpg/png/webp 扩展名校验)→ SaveUploadedFile → dao.Insertlist 按 user_iddelete 校验归属(id + user_id 双条件)后删除文件 + 记录
- [ ] **Step 4: 实现三个 controller**Upload 方法用 `r.GetUploadFile("file")` 获取文件(controller 直接拿 request 时用 `*ghttp.Request` 参数)
```go
func (c *userPhoto) Upload(ctx context.Context, req *dto.UserPhotoUploadReq, r *ghttp.Request) (res *dto.UserPhotoUploadRes, err error) {
file := r.GetUploadFile("file")
userId := common.GetUserId(ctx)
url, err := service.UserPhotoService.Upload(ctx, userId, req.Type, file)
if err != nil {
return nil, err
}
return &dto.UserPhotoUploadRes{Id: url.Id}, nil
}
```
注意:GetUserId(ctx) 从 auth 中间件注入的 ctx 读取(auth_middleware.go 已有实现,按 video-factory 方式调用)。
- [ ] **Step 5: 测试通过** `go test ./styleagent/service/ -v`
- [ ] **Step 6: Commit** `git add -A && git commit -m "feat: photo/wardrobe/body domains"`
---
### Task 7: 化身域(v1 模板匹配 + build 任务)
**Files:**
- Create: `styleagent/avatar/template_matcher.go`
- Create: `styleagent/avatar/glb_packer.go`
- Create: `styleagent/service/avatar_service.go`
- Create: `styleagent/controller/avatar_controller.go`
- Test: `styleagent/avatar/template_matcher_test.go`
- [ ] **Step 1: 写失败测试**template_matcher:给定模拟特征(肤色 1-5 + 身高 cm + 胖瘦 1-5)→ 返回 face_template_id/body_template_id 索引)
```go
package avatar
import "testing"
func TestMatchTemplates(t *testing.T) {
f := &FaceFeature{SkinTone: 3, HeightCm: 175, Build: 3}
faceId, bodyId, skinIdx := MatchTemplates(f)
if faceId < 1 || faceId > 20 || bodyId < 1 || bodyId > 6 || skinIdx < 1 || skinIdx > 5 {
t.Fatalf("out of range: face=%d body=%d skin=%d", faceId, bodyId, skinIdx)
}
}
```
- [ ] **Step 2: 确认失败** `go test ./styleagent/avatar/ -v`
- [ ] **Step 3: 实现 template_matcher.go**
```go
package avatar
// FaceFeature 从照片+用户填写提取的化身特征(v1 简化:照片仅做肤色采样,其余用户填写/默认)
type FaceFeature struct {
SkinTone int // 1-5
HeightCm int
Build int // 1-5 瘦~胖
}
// 预烘焙模板库索引(构建期产物,运行时只读常量)
const (
FaceTemplateCount = 20
BodyTemplateCount = 6
SkinToneLevels = 5
DefaultFaceTemplate = 5
DefaultBodyTemplate = 3
)
// MatchTemplates 特征 → 模板索引(v1 规则映射:肤色→皮肤档,身高+体型→身体模板,脸型由照片后续 AI 提取后替换)
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 }
// 身体模板:身高 150-190 → 6 档
bodyId = (f.HeightCm - 145) / 8
if bodyId < 1 { bodyId = 1 }
if bodyId > BodyTemplateCount { bodyId = BodyTemplateCount }
// v1 脸型固定默认模板(AI 人脸特征提取后替换,见 spec v2)
faceId = DefaultFaceTemplate
return
}
```
- [ ] **Step 4: glb_packer.go**v1:拼 URL —— `/workspace/templates/face_{id}.glb``body_{id}.glb`,组合 avatar GLB 记录;真实打包后续)
- [ ] **Step 5: avatar_service.go**Build(ctx, userId):检查照片齐备(大头照+至少1张全身)→ 读 body_measurement → MatchTemplates → 插入 avatar_modelbuild_status=pending)→ 异步 goroutine 执行 processing → donev1 同步简化:直接 done + glb_url 用 packer 生成的路径);Get(ctx, userId) 返回最新 avatar_model
- [ ] **Step 6: avatar_controller.go**Build/Get 绑定 dtoBuild 返回 task 语义(v1 直接返回 avatar 记录 id
- [ ] **Step 7: 测试通过** + `go build ./...` + **Commit** `git commit -m "feat: avatar domain with template matching"`
---
### Task 8: 发型资产列表(静态 seed)
**Files:**
- Create: `styleagent/controller/hairstyle_controller.go`
- Create: `styleagent/service/hairstyle_service.go`
- Modify: `styleagent/dao/hairstyle_asset_dao.go`init 时 seed 8 个默认发型)
- [ ] **Step 1: dao init seed**(插入 8 条:短发/中发/长发/卷发/寸头/马尾/丸子头/波浪卷,style_tag、glb_url=`/workspace/templates/hairstyle_{id}.glb`、sort
- [ ] **Step 2: 测试**List 返回按 sort 排序的 8 条(dao 测试)
- [ ] **Step 3: service + controller 绑定**`go build ./...`**Commit**
---
### Task 9: 规则引擎评分(5 维度,零 LLM)
**Files:**
- Create: `styleagent/scoring/rules.go`ScoreContext + CandidateOutfit
- Create: `styleagent/scoring/weather_rule.go`
- Create: `styleagent/scoring/occasion_rule.go`
- Create: `styleagent/scoring/color_rule.go`
- Create: `styleagent/scoring/completeness_rule.go`
- Create: `styleagent/scoring/style_rule.go`
- Create: `styleagent/scoring/engine.go`(总分聚合 + 阈值判定)
- Test: `styleagent/scoring/engine_test.go`
- [ ] **Step 1: 写失败测试**(关键边界:冬季温度带外套得分高于无外套;色调和谐组合得分高于冲突组合;缺鞋减分;总分 ≥ 阈值判定通过)
```go
package scoring
import "testing"
func TestWinterOuterwearBonus(t *testing.T) {
ctx := ScoreContext{
TempAvg: 5, // 冬季
Occasion: "通勤",
Weekday: "workday",
Wardrobe: []WardrobeItem{{Category: "上衣", ColorInfo: "#333333"}, {Category: "下装", ColorInfo: "#1a1a1a"}},
}
withJacket := CandidateOutfit{Items: ctx.Wardrobe, HasOuterwear: true}
noJacket := CandidateOutfit{Items: ctx.Wardrobe, HasOuterwear: false}
if weatherScore(withJacket) <= weatherScore(noJacket) {
t.Fatal("winter should favor outerwear")
}
}
```
- [ ] **Step 2: 确认失败** `go test ./styleagent/scoring/ -v`
- [ ] **Step 3: 实现 5 个规则文件**(均为纯函数,输入输出确定):
```go
// rules.go 公共类型
type WardrobeItem struct {
Category string // 上衣/下装/鞋/配饰
Season string // 春/夏/秋/冬/四季
ColorInfo string // 如 #RRGGBB 或 "黑/白/红"
StyleTags string
}
type CandidateOutfit struct {
Items []WardrobeItem
HasOuterwear bool
}
type ScoreContext struct {
TempAvg int // 平均温度℃
Season string
Occasion string // 通勤/约会/聚会/运动
Weekday string // workday/weekend/holiday
StyleTags []string // 用户偏好
}
// weather_rule.go 温度档位表
func weatherScore(o CandidateOutfit, ctx ScoreContext) int {
// 25 分制:温度匹配每件服装 season 加 5 分;<10℃ 无外套扣 10 分;>30℃ 有外套扣 8 分
}
// occasion_rule.go 场合规则表
func occasionScore(o CandidateOutfit, ctx ScoreContext) int {
// 25 分制:场合→类别规则(约会加分:正装/裙装;运动加分:运动服)基础分 15 + 匹配项各 5
}
// color_rule.go 色相环相似度
func colorScore(o CandidateOutfit) int {
// 20 分制:同色系 20;邻近色 15;对比色 8;随机冲突 3
}
// completeness_rule.go
func completenessScore(o CandidateOutfit) int {
// 20 分制:上衣+5 下装+5 鞋+5 配饰+5
}
// style_rule.go 用户偏好
func styleScore(o CandidateOutfit, ctx ScoreContext) int {
// 10 分制:命中用户 styleTags 每项 +2
}
// engine.go
func Score(c *CandidateOutfit, ctx *ScoreContext) int {
return weatherScore(*c, *ctx) + occasionScore(*c, *ctx) + colorScore(*c) +
completenessScore(*c) + styleScore(*c, *ctx)
}
func IsPass(score int, threshold int) bool { return score >= threshold }
```
- [ ] **Step 4: 测试通过**(含色相解析测试:`#ff0000``#ff6666` 同色系;`#ff0000``#00ff00` 对比色)
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: rule-based scoring engine"`
---
### Task 10: 天气适配(和风 + 高德 + 缓存)
**Files:**
- Create: `styleagent/weather/qweather.go`
- Create: `styleagent/weather/geo.go`
- Create: `styleagent/weather/cache.go`
- Create: `styleagent/service/weather_service.go`(供 outfit service 调用)
- Test: `styleagent/weather/cache_test.go`
- [ ] **Step 1: 写失败测试**cacheget→miss→set→hitTTL 过期)
- [ ] **Step 2: 实现 cache.go**(内存 map + mutexkey=`{city}:{date}`TTL 6h
- [ ] **Step 3: 实现 qweather.go**`GetDaily(ctx, cityCode, startDate, endDate) ([]DayWeather, error)`,和风 `v7/weather/7d` 接口,Key 从 `config.yml``weather.qweather_key` 读取(空则返回 error 提示配置缺失)
```go
type DayWeather struct {
Date string `json:"date"`
TempMax int `json:"temp_max"`
TempMin int `json:"temp_min"`
TextDay string `json:"text_day"`
}
// 返回该日期范围内平均温度(用于评分)+ 每日天气
func GetDaily(ctx context.Context, cityCode string, startDate, endDate string) (*WeatherResult, error)
```
- [ ] **Step 4: 实现 geo.go**`GetCityCode(ctx, location) (string, error)` —— 高德地理编码 API,Key 从配置读;失败时降级:直接以 location 为 cityCode 缓存并返回默认天气(config 开启 mock 时)
- [ ] **Step 5: weather_service.go**:封装 `GetWeather(ctx, location, startDate, endDate)` → 先查缓存 → 未命中调 API → 存缓存;测试用 mock API 响应(httptest server 或注入接口)
- [ ] **Step 6: 测试通过** + **Commit** `git commit -m "feat: weather adapter with cache"`
---
### Task 11: Agentchat_model 复用 + 方案规划/兜底)
**Files:**
- Copy: `styleagent/agent/chat_model.go`(从 video-factory 复制,改 import 路径)
- Copy: `styleagent/agent/types.go`ChatRequest/ChatMessage/ChatResponse/ToolCall
- Create: `styleagent/agent/outfit_agent.go`(规划 + 兜底两函数)
- Create: `styleagent/agent/output.go`JSON Schema 校验)
- Create: `styleagent/agent/agent_config.go`(从 model_config 表读取 LLM 配置,未配置时返回错误)
- Test: `styleagent/agent/output_test.go`
- [ ] **Step 1: 复制 chat_model.go + types.go**,改包路径,`go build ./...` 通过
- [ ] **Step 2: 写失败测试**output 解析:合法 JSON 解析为 PlanOutput;缺字段报错;非法 JSON 报错)
```go
// output.go 规划输出结构
type PlanOutput struct {
Plans []PlanCandidate `json:"plans"`
}
type PlanCandidate struct {
Title string `json:"title"`
Hairstyle string `json:"hairstyle"` // 发型名称(匹配资产库)
HairColor string `json:"hair_color"` // 如 #A0522D
Items []PlanItemOut `json:"items"`
}
type PlanItemOut struct {
Slot string `json:"slot"` // 上衣/下装/鞋/配饰
ItemId int64 `json:"item_id,omitempty"` // 衣橱条目(wardrobe 来源)
Name string `json:"name"`
Desc string `json:"desc"`
NewItem bool `json:"new_item"` // 是否为推荐新服装
}
```
- [ ] **Step 3: 确认失败** `go test ./styleagent/agent/ -v`
- [ ] **Step 4: 实现 output.go 校验**json.Unmarshal + 必填字段检查:plans 非空、每套 items 至少 1 件)
- [ ] **Step 5: 实现 outfit_agent.go**
```go
// PlanOutfits 规则预筛候选 → LLM 润色规划(1 次调用)
func PlanOutfits(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string, candidates []CandidateData) (*PlanOutput, error)
// CreateRecommendPlan 兜底创作(全低分时调用,1 次调用)
func CreateRecommendPlan(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string) (*PlanOutput, error)
```
system prompt 要点(写入 agent/prompt.go 常量):角色是穿搭顾问;输出严格 JSON;天气/场合约束注入;仅输出 JSON 无额外文字。
- [ ] **Step 6: agent_config.go**:从 `model_config` 表(复用 video-factory 结构:system 配置 + 可覆盖)读取 base_url/api_key/model_name,用 gcache 缓存 60s;未配置返回明确错误。
- [ ] **Step 7: 测试通过**output 校验单测;agent 调用用 httptest mock OpenAI 端点)+ **Commit**
---
### Task 12: 穿搭生成编排(outfit service 核心)
**Files:**
- Create: `styleagent/service/outfit_service.go`Generate 编排 + 评分 + 兜底 + 落库)
- Test: `styleagent/service/outfit_service_test.go`(核心逻辑 mockweather/agent 注入接口)
- [ ] **Step 1: 写失败测试**(核心流程:衣橱 3 件 → 规则预筛 3 套 → 评分 → 全低分时触发兜底 → 落库 plan + items;高分时直接落库)
- [ ] **Step 2: 确认失败**
- [ ] **Step 3: 实现 outfit_service.go**
```go
type outfitService struct{}
var OutfitService = new(outfitService)
// Generate 创建生成任务并同步执行核心流程(v1 同步;异步任务表见 Task 13)
func (s *outfitService) Generate(ctx context.Context, userId int64, req *dto.OutfitGenerateReq) (int64, error) {
// 1. 校验衣橱非空(<3 件返回 "衣橱服装不足,请先添加至少 3 件服装")
// 2. 天气获取(weather_service
// 3. 规则预筛:衣橱 × 季节温度 × 场合 → 3 套候选(组合算法:按 category 分组随机/轮询组合)
// 4. 创建任务记录(planning)→ Agent.PlanOutfits1 次 LLM
// 5. 规则评分每套 → 任务状态 scoring
// 6. 3 套全 < 阈值 → Agent.CreateRecommendPlan1 次 LLM)→ 新套装标 recommend
// 7. 落库 outfit_planhairstyle_id 匹配资产库)+ plan_outfit_item(来源标注)
// 8. 任务 → done;返回 task_id
}
```
- [ ] **Step 4: 预筛组合算法**outfit_combiner.go):按 category 将衣橱分组,按温度过滤 season,生成最多 3 个互不相同组合(确定性:按 id 排序轮询),每个组合带 HasOuterwear 标记
- [ ] **Step 5: 测试通过** + `go build ./...` + **Commit**
---
### Task 13: 穿搭 controller + 异步任务表
**Files:**
- Create: `styleagent/controller/outfit_controller.go`
- Modify: `styleagent/service/outfit_service.go`(异步化:Generate 只建任务返回 task_idworker goroutine 执行;GetTaskStatus / ListPlans / GetPlanDetail / SelectMain / Review
- [ ] **Step 1: 异步化改造**Generate 插入任务(pending)→ 启动 goroutine 执行核心流程(含任务状态流转 pending→planning→scoring→done/failed + error 记录);`startWorker(ctx)` 守护恢复未完成任务(main.go 启动时调用,模式同 video-factory StartVideoPoller
- [ ] **Step 2: controller 绑定 6 个 dto 方法**Generate/TaskStatus/PlanList/PlanDetail/SelectMain/Review
- [ ] **Step 3: GetPlanDetail**:查 plan + items + images + hairstyle 资产,组装 OutfitPlanDetailRes
- [ ] **Step 4: SelectMain**:置 main_flag(事务:同 task 其他 plan 清零)+ 触发效果图任务(Task 14 后接通)
- [ ] **Step 5: 编译 + 冒烟测试**TestMain 起 gtest server:登录 → 上传 → generate → 轮询 → detail**Commit**
---
### Task 14: 效果图生成(ImageGenClient 接口 + wanx + mock + 缓存)
**Files:**
- Create: `styleagent/imagegen/client.go`(接口 + Factory
- Create: `styleagent/imagegen/wanx_client.go`
- Create: `styleagent/imagegen/mock_client.go`
- Create: `styleagent/imagegen/cache.go`
- Create: `styleagent/service/effect_image_service.go`(异步任务执行:选主方案后生成 3 视角)
- Test: `styleagent/imagegen/cache_test.go` + `mock_client_test.go`
- [ ] **Step 1: 写失败测试**cacheplan 内容 hash → 命中/未命中;mock client:调用返回固定 URL
- [ ] **Step 2: 实现 client.go**
```go
type ImageGenClient interface {
// Generate 生成单张效果图,返回图片 URL
Generate(ctx context.Context, req *GenerateReq) (string, error)
}
type GenerateReq struct {
BaseImageURL string // 用户全身照
Prompt string // 方案描述
Angle string // 正面/侧面/背面
Seed int64
}
func NewClient(supplier string) ImageGenClient // wanx | mockconfig 无 key 时强制 mock
```
- [ ] **Step 3: mock_client.go**:返回 `/workspace/mock/effect_{angle}.png` 占位路径(不真实调用,开发联调用)
- [ ] **Step 4: wanx_client.go**:通义万相人像写真类 API`image-sync` 或异步轮询接口),Key/模型从 `imagegen_config` 表读;v1 实现为"调用 + 轮询结果"封装;错误降级 mock
- [ ] **Step 5: effect_image_service.go**SelectMain 后 goroutine:按 plan 内容 hash 查缓存 → 未命中调用 ImageGenClient 逐角度生成(3 张)→ 存 plan_effect_image + 任务 rendering→done;每日免费次数校验(user 维度,默认 3 次/天,scoring_rule 表配置)
- [ ] **Step 6: 测试通过** + **Commit**
---
### Task 14.5: 商业化基础(partner_store 列表 + seed
**Files:**
- Create: `styleagent/model/entity/partner_store.go`id, name, type(1形象设计 2服装门店), lat, lng, address, commission_policy, status, created_at
- Modify: `styleagent/consts/table_name.go`+`TableNamePartnerStore = "slogan_partner_store"`
- Create: `styleagent/dao/partner_store_dao.go`(建表 + init seed 4 条示例门店 + ListByType
- Create: `styleagent/model/dto/partner_store_dto.go``StoreListReq` path `/list` + `StoreListRes{List []*entity.PartnerStore}`
- Create: `styleagent/service/partner_store_service.go`
- Create: `styleagent/controller/partner_store_controller.go`
- Modify: `main.go`(注册 `controller.PartnerStore`
- [ ] **Step 1: entity + dao**(模式同 Task 3;seed:2 条形象设计 + 2 条服装门店,坐标覆盖城市)
- [ ] **Step 2: dto + service + controller**List 支持 `type` 筛选,0 返回全部)
- [ ] **Step 3: `go build ./...` + 冒烟**GET /partner-store/list 返回 seed 数据)+ **Commit** `git commit -m "feat: partner store domain"`
---
### Task 15: 集成冒烟 + Dockerfile
**Files:**
- Create: `Dockerfile`(复用 video-factory 多阶段构建模式)
- Create: `docs/项目文档.md`(服务端文档,模式同 video-factory 项目文档)
- Create: `docs/api.json` 导出(启动后 GoFrame OpenAPI
- [ ] **Step 1: Dockerfile**golang:1.22 builder + alpine runtime,复制 video-factory Dockerfile 改端口)
- [ ] **Step 2: 全链路冒烟**`go run main.go` → curl 全流程:
1. `POST /user/login`(注册后)→ token
2. `POST /user-photo/upload`-F file=@headshot.jpg -F type=1
3. `POST /wardrobe/upload` × 3
4. `POST /body-measurement/save`
5. `POST /avatar/build` → get
6. `POST /outfit/generate` → task status 轮询 → done
7. `GET /outfit/plan/list` → detail
8. `POST /outfit/plan/select-main` → effect imagesmock 路径)
9. `GET /hairstyle/list`
- [ ] **Step 3: 验证响应格式统一** `{"code":0,"message":"OK","data":...}`
- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete with dockerfile and docs"`
---
## Self-Review 备注(执行前已知项)
- 测试 DB`styleagent/service` 单测使用独立 sqlite 文件 `test_slogan.db`(TestMain 设置),避免污染开发库
- GetUserId(ctx):确认 auth_middleware.go 注入的 key(复制 video-factory 后保持一致)
- 上传文件字段名统一 `file`
- 天气/LLM/图像 Key 全部从 config.yml / 配置表读取,代码不入 Key
@@ -0,0 +1,293 @@
# 商业化四支柱设计(后端)· slogan-agent
> **目标:** 以「个人形象设计」为主题业务,落地四支柱收入:VIP 会员充值、穿山甲广告、线下门店引流(OTA 联盟)、线上商品(电商联盟 CPS)。
> **核心原则:** 商业化从「方案/单品」长出,不做泛化场景广场。所有推荐由方案已有字段驱动,**零新增 LLM 调用**。
## 1. 总体架构
```
Appslogan-app
│ 会员中心/方案详情商业化入口/衣橱升级款/广告位
slogan-agent 新增模块
├─ 会员模块 member_plan / payment_order / user_member / pay_notify_log
├─ 广告激励 ad_reward_log + 发放权益
├─ CPS 统一引擎 cps_category / cps_product / cps_click_log / scene_category_map
│ └─ 适配器:美团联盟(OTA 到店) / 京东联盟(电商) / 淘宝客(美妆配饰)
└─ 配置 config.ymlcps.payment.ad 配置段,Key 默认空 → 模块自动降级)
├─▶ 虎皮椒聚合支付(微信/支付宝收银台,iOS WebView
├─▶ 美团联盟 API(选品 + 转链,pid 归因)
├─▶ 京东联盟 API(选品 + 转链)
└─▶ 淘宝客 API(选品 + 淘口令)
```
**模块降级原则**:与现有 `llm/weather/geo` 配置段同模式 —— 支付/CPS 相关 key 未配置时,接口返回明确错误信息(如"支付未开通,请在 config.yml 配置"),App 端隐藏对应入口,不影响主功能闭环。
## 2. 支柱 A:VIP 会员与聚合支付
### 2.1 支付服务商:虎皮棋(xunhupay)
- 个人可开通、无营业执照门槛、微信+支付宝双通道、收银台 URL 模式(App WebView 打开)
- 下单:`POST /v1/payment`RSA 签名请求);回调:`POST notify_url`(验签后解析)
- **签名/验签细节以官方最新文档为准**,实现时封装在 `payment/gateway.go` 适配器内,与业务解耦
- 金额一律以「分」为单位存库,避免浮点误差
### 2.2 数据模型(dao init 自动建表,沿用 SQLite 规范)
```sql
CREATE TABLE IF NOT EXISTS member_plan (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
price_fen INTEGER NOT NULL DEFAULT 0, -- 金额(分)
duration_days INTEGER NOT NULL DEFAULT 30, -- 时长(天)
features TEXT NOT NULL DEFAULT '[]', -- 权益 JSON["effect_unlimited","ai_priority","cps_commission_x15","store_discount"]
sort INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS payment_order (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE, -- 业务订单号
user_id INTEGER NOT NULL DEFAULT 0,
plan_id INTEGER NOT NULL DEFAULT 0,
amount_fen INTEGER NOT NULL DEFAULT 0,
channel TEXT NOT NULL DEFAULT '', -- alipay | wechat
status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | closed
trade_no TEXT NOT NULL DEFAULT '', -- 第三方交易号
notify_raw TEXT NOT NULL DEFAULT '', -- 回调原文(审计)
paid_at DATETIME,
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_payment_order_user ON payment_order(user_id, created_at);
CREATE TABLE IF NOT EXISTS user_member (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
plan_id INTEGER NOT NULL DEFAULT 0,
expire_at DATETIME,
source TEXT NOT NULL DEFAULT 'vip_pay', -- vip_pay | ad_trial | gift
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS pay_notify_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
sign TEXT NOT NULL DEFAULT '',
remote_ip TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ok', -- ok | bad_sign | duplicate | no_order
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
```
### 2.3 接口(RouteRegister 2 参 handler`common.GetUserId(g.RequestFromCtx(ctx))` 取用户)
| 路径 | 方法 | 请求 | 响应 | 说明 |
|---|---|---|---|---|
| `/member/plan/list` | GET | - | `{list: [member_plan]}` | 上架套餐 |
| `/member/status` | GET | - | `{member: {...}, is_vip, expire_at}` | 我的会员状态 |
| `/member/order/create` | POST | `{plan_id}` | `{order_no, pay_url}` | 下单 → 虎皮棋收银台 URL |
| `/member/order/notify` | POST | 表单回调 | `"success"` | **publicPaths 放行**;验签 → 幂等 → 订单 paid → 开通/续期会员 |
| `/member/order/status` | GET | `{order_no}` | `{status}` | App 轮询 |
**支付时序**
```
App → POST /member/order/create → 后端生成订单 + 调虎皮棋下单 → 返回 pay_url
App → WebView 打开 pay_url(用户完成支付)
虎皮棋 → POST /member/order/notifyRSA 验签)
后端 → 幂等校验(order_no 状态机 pending→paid,重复回调忽略并记 pay_notify_log
后端 → 更新 user_member(续费:expire_at 在原有效期上叠加,min 逻辑;过期则从现在起算)
App → GET /member/order/status 轮询(间隔 2s,超时 60s)→ 展示开通成功
```
**幂等与安全**:回调必须验签(失败记 `bad_sign` 并返回非 success);`order_no` 唯一 + 状态机保证只开通一次;回调日志全量入库审计;退款 MVP 阶段客服手动处理(标记 order closed + 人工延退会员)。
## 3. 支柱 B:广告激励
### 3.1 数据模型
```sql
CREATE TABLE IF NOT EXISTS ad_reward_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
ad_type TEXT NOT NULL DEFAULT '', -- effect_extra(效果图+1) | vip_trial(体验会员1天)
reward_key TEXT NOT NULL DEFAULT '', -- "2026-07-31:effect_extra" 自然日去重粒度
status TEXT NOT NULL DEFAULT 'ok',
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON ad_reward_log(user_id, reward_key);
```
### 3.2 接口
| 路径 | 方法 | 请求 | 响应 | 说明 |
|---|---|---|---|---|
| `/ad/reward/claim` | POST | `{ad_type}` | `{reward: {...}}` | 发放权益(限频见下) |
**风控**(防刷,纯服务端计数,不信任客户端):
- `ad_type=effect_extra`:每日每用户限 **2 次**`reward_key` 唯一索引 + 计数),发放后效果图当日额外 +1 次
- `ad_type=vip_trial`:每日每用户限 **1 次**,发放 1 天体验会员(写 user_membersource=ad_trial,到期自动失效)
- 效果图限额判定逻辑改造:`EffectImageService.GenerateForPlan``CountByUserToday` 判断改为 `当日已用 ≤ 基础额度(3) + 额外次数(ad_reward_log 当日 count)`;额外次数次日归零(不落独立表,按日查询即可)
## 4. 支柱 C/D:统一 CPS 引擎
### 4.1 核心抽象
```go
// cps/provider.go —— 数据源适配器接口(包级单例:cps.Providers 注册表)
type Provider interface {
Source() string // meituan_ota | jd_ecom | tb_ecom
SyncProducts(ctx, city string, catCode string) ([]CpsProduct, error) // 定时选品池同步
Search(ctx, keyword string, catCode string, page int) ([]CpsProduct, error) // 实时搜索兜底
GetLink(ctx, outerId string) (string, error) // 转链(带 pid),结果按 outerId 缓存 24h
}
```
- 统一 `cps_product` 选品池:联盟商品定时同步入库,列表读库(不实时调联盟);搜索接口实时兜底
- 转链结果缓存(与 imagegen cache 同模式),点击时写 `cps_click_log`
- 未配置某联盟 key → 该 source 降级(列表为空 + App 隐藏入口)
### 4.2 数据模型
```sql
CREATE TABLE IF NOT EXISTS cps_category (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE, -- haircut / clothing / beauty / food / hotel / ticket / transport / digital ...
name TEXT NOT NULL DEFAULT '',
parent_code TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', -- meituan_ota / jd_ecom / tb_ecom
source_cat_id TEXT NOT NULL DEFAULT '', -- 联盟侧类目 ID
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS cps_product (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT '',
outer_id TEXT NOT NULL DEFAULT '', -- 联盟商品 ID
category_code TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
cover_url TEXT NOT NULL DEFAULT '',
price_fen INTEGER NOT NULL DEFAULT 0,
shop_name TEXT NOT NULL DEFAULT '',
commission_rate INTEGER NOT NULL DEFAULT 0, -- 万分比
city TEXT NOT NULL DEFAULT '', -- OTA 到店类目按城市
scene_tags TEXT NOT NULL DEFAULT '[]', -- 场合标签 ["通勤","约会","旅行"]
raw TEXT NOT NULL DEFAULT '', -- 联盟原始数据 JSON
status INTEGER NOT NULL DEFAULT 1,
sync_at DATETIME,
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON cps_product(source, category_code, status);
CREATE TABLE IF NOT EXISTS cps_click_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
outer_id TEXT NOT NULL DEFAULT '',
scene TEXT NOT NULL DEFAULT '', -- plan_haircut / plan_item / plan_occasion / wardrobe_upgrade / member_benefit
plan_id INTEGER NOT NULL DEFAULT 0,
category_code TEXT NOT NULL DEFAULT '',
deeplink TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_cps_click_user ON cps_click_log(user_id, created_at);
CREATE TABLE IF NOT EXISTS scene_category_map ( -- 方案字段 → 联盟类目映射(零 LLM 推荐核心)
id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_type TEXT NOT NULL DEFAULT '', -- haircut / item_buy / item_upgrade / occasion
occasion TEXT NOT NULL DEFAULT '', -- 通勤/约会/旅行/运动/商务(occasion 场景)
source TEXT NOT NULL DEFAULT '',
category_code TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0
);
```
### 4.3 方案驱动推荐(核心:用已有方案字段,零新增 LLM 调用)
| 入口 | 方案字段 | 映射 | 推荐内容 |
|---|---|---|---|
| 发型卡「做同款发型」 | 发型名 + 城市 | scene_type=haircut → 丽人类目 | 理发/造型店联盟券(美团) |
| 穿衣清单「买同款」 | 单品 name/Desc | 京东联盟搜索关键词 | 电商同款卡片 |
| 穿衣清单「到店试穿」 | 单品风格 tags + 城市 | scene_type=item_upgrade → 服装类目 | 服装店联盟券(美团) |
| 场合卡「延伸优惠」 | occasion + 地点 | scene_type=occasion 映射表 | 约会→餐厅+丽人;旅行→酒店/车票/当地丽人 |
| 衣橱「找升级款」 | 旧款 category + style_tags | 京东搜索相似款 | 电商升级款 |
### 4.4 接口
| 路径 | 方法 | 请求 | 响应 | 说明 |
|---|---|---|---|---|
| `/cps/category/list` | GET | - | `{list: [cps_category]}` | 统一分类树 |
| `/cps/product/list` | GET | `{source, category_code, city, page}` | `{list, has_more}` | 选品池分页 |
| `/cps/product/link` | POST | `{product_id, scene, plan_id}` | `{deeplink}` | 转链(缓存 24h)+ 记点击日志 |
| `/cps/plan/recommend` | GET | `{plan_id, scene}` | `{list: [推荐项]}` | 方案驱动推荐(发型卡/单品/场合) |
| `/cps/wardrobe/upgrade` | GET | `{item_id}` | `{list}` | 衣橱旧款升级款 |
| `/cps/my/recent` | GET | - | `{list: [点击记录]}` | 我的优惠记录(含返现状态占位) |
**归因**:转链 URL 内嵌联盟 pid(下单时由适配器生成),联盟侧自动归因;`cps_click_log` 用于转化分析,结算数据以联盟后台为准。
## 5. 会员权益实现
- `effect_unlimited`:效果图限额判定跳过(`EffectImageService``IsVip(userId)` 查询)
- `ai_priority``outfit_service.Generate` 任务插入优先级字段(MVP 可用简单 FIFO + vip 优先标记,或仅权益展示占位)
- `cps_commission_x15`VIP 购买 CPS 佣金 ×1.5 —— 结算在联盟后台,**MVP 仅权益展示**(文案"返现加成 1.5x"),真实返现二期(需联盟侧对账)
- `store_discount`:品牌合作门店(partner_store)展示"会员价"标识,到店出示会员状态(App 会员码页),自营核销二期
## 6. 配置(config.yml 新增段,Key 默认空)
```yaml
payment:
xunhu_appid: ""
xunhu_appsecret: ""
notify_url: "http://<公网>/member/order/notify" # 回调需公网可达
channel: "alipay,wechat"
ad:
limit_effect_extra: 2 # 每日激励视频次数(效果图)
limit_vip_trial: 1
cps:
meituan_appkey: ""
meituan_pid: ""
meituan_shop_id: ""
jd_appkey: ""
jd_secret: ""
jd_pid: ""
tb_appkey: ""
tb_secret: ""
tb_pid: ""
sync_cron: "0 4 * * *" # 选品池定时同步
```
## 7. 合规与风控
- **支付**:金额单位分;回调幂等 + 验签;`pay_notify_log` 全量审计;退款人工处理(记录到订单)
- **iOS 合规**iOS 端 WebView 支付为国内惯例做法,需在 App Store 审核时注意(虚拟商品 IAP 政策风险,上线策略:iOS 端主推激励广告+门店引流,充值入口弱化或按要求接 IAP)
- **广告**:隐私政策披露第三方 SDK 收集信息;提供个性化广告关闭入口(穿山甲 SDK 提供)
- **CPS**:各联盟 API 需个人/企业账号申请(美团联盟、京东联盟、淘宝客均可个人申请);跳转链接遵守联盟推广规范(不得截流/改链接);禁用敏感类目(医疗、成人等)
- **激励防刷**`ad_reward_log` 唯一索引 + 自然日限频;异常用户(同设备多账号)风控日志记录
## 8. 分期实施与成本
| 分期 | 内容 | 后端工作量 | 依赖 |
|---|---|---|---|
| **P0** | 会员全链路(4 表 + 5 接口 + 虎皮棋适配器 + 回调验签)+ 广告激励(1 表 + 1 接口 + 效果图限额改造) | ~2 人日 | 虎皮棋账号 |
| **P1** | CPS 引擎(4 表 + 6 接口 + 美团适配器 + 方案驱动推荐)+ 转链缓存 + 点击日志 | ~2.5 人日 | 美团联盟账号 |
| **P2** | 京东/淘宝适配器 + 会员返现加成 + 收益看板 + 风控报表 | ~2 人日 | 京东/淘宝联盟账号 |
- **服务器成本**:零新增基础设施(SQLite 表均小体量,选品池定时同步 + 转链缓存)
- **模型成本**:零新增 LLM 调用(类目映射 + 关键词匹配)
- **维护成本**:联盟 API 变更由适配器隔离;第三方故障 → 接口降级返回错误,App 隐藏入口
## 9. 开发规范约束(沿用 video-factory 规范)
- Controller→Service→DAO 三层,包级单例(`var MemberService = new(memberService)`
- RouteRegister 反射路由,**handler 必须 2 参** `func(ctx context.Context, req *BizReq) (*BizRes, error)`struct 名 kebab-case`member_plan``/member/plan`
- 用户 ID 一律 `common.GetUserId(g.RequestFromCtx(ctx))`
- 每表一 DAO`dao/member_plan_dao.go` 等),`init()``CREATE TABLE IF NOT EXISTS` + seed
- 统一响应 `{"code":0,"message":"OK","data":...}``/member/order/notify` 加入 publicPaths
- 外部服务(支付/联盟)全部走包内适配器(`payment/``cps/`),业务层不直接感知
- 配置默认空 → 降级不 panic(与 llm/weather 同模式)
@@ -0,0 +1,301 @@
# slogan-agent 服务端设计方案
> 日期:2026-07-31
> 关联:slogan-app 设计方案(App 端)见 slogan-app 仓库对应文档
## 1. 项目概述
slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现。
本仓库为服务端(slogan-agent),提供:用户/照片/衣橱/身形管理、3D 化身构建、穿搭方案生成(规则评分 + Agent)、效果图生成、天气服务、商业化渠道(CPS 电商/门店导流/订阅)。
## 2. 开发规范约束(严格遵守 video-factory
本服务端**架构与代码规范严格遵守** `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory` 的既有规范:
| 规范点 | 约束 |
|--------|------|
| 技术栈 | Go 1.22+ / GoFrame v2 (github.com/gogf/gf/v2) / SQLiteGoFrame ORM 驱动) |
| 认证 | JWT (golang-jwt/jwt/v5)`/user/login` 公开,其余全部经 auth 中间件,7 天过期,bcrypt 密码 |
| 分层 | Controller → Service → DAO → SQLite;每一层独立包,包级变量单例(`var XxxService = new(xxxService)` |
| 路由 | `RouteRegister`common/http/http.go)反射注册,kebab-case 前缀,如 `/outfit/generate` |
| 响应 | 统一 JSON `{"code":0,"message":"OK","data":...}` |
| DAO | 每张表一个 DAO`init()` 自动建表 + ALTER TABLE 兼容迁移 |
| 模型 | `model/entity/`(表实体)+ `model/dto/`(请求响应,含 g.Meta 路由)+ `model/domain/` |
| Agent | 复用 video-factory ReAct 引擎模式:chat_model.goOpenAI 兼容 API,指数退避重试)+ react_agent.go + tools.go + context.go |
| 模型配置 | 系统配置 + 用户配置 → MergedModelConfig(复用 model_config / user_model_config 表模式) |
| 异步任务 | 生成任务表 + 后台轮询(复用 GenerationService.StartPoller 模式,15s 间隔) |
| 文件存储 | `workspace/` 目录 + JWT 鉴权静态文件服务(BindHandler 方式,防路径穿越) |
| 参数校验 | gvalidmain.go 注册自定义规则) |
| 部署 | 单体服务,Docker(复用 video-factory Dockerfile 模式),端口 3006 规则下自定 |
**新增加固规则**(本项目的领域约束):
- 所有涉及 LLM / 图像生成的调用必须经过"供应商适配层"chat_model / imagegen),禁止业务代码直连第三方 SDK
- 所有外部 API(人脸/天气/地理编码)必须封装为 service 层适配器,Key 存配置表不入代码
- 费用敏感:LLM/图像调用全部走任务表异步化 + 缓存,禁止同步阻塞式出图
## 3. 总体架构
```
Flutter App (slogan-app)
│ HTTPS + JWT
slogan-agent (Go 单体)
├── controller → service → dao → SQLite
├── avatar/ 3D 化身管线(预烘焙模板匹配 + 贴图合成 + GLB 输出)
├── scoring/ 规则引擎评分(零 LLM 成本)
├── agent/ 轻量 Agent(方案规划 / 兜底创作)
├── imagegen/ 效果图客户端(多供应商适配 + 缓存)
├── weather/ 天气适配(和风天气 + 缓存)
├── commercial/ CPS 商品 / 门店 / 导流 / 订阅
├── assets/avatar-templates/ 预烘焙模板库(构建期产物,运行时只读)
└── workspace/ 用户照片 / GLB / 效果图
```
## 4. 项目结构
```
slogan-agent/
├── main.go # 入口:RouteRegister + workspace 鉴权文件服务 + 后台轮询
├── common/ # 复用 video-factoryauth / cache / http / base_dao
├── styleagent/ # 业务模块(对应 shortdrama
│ ├── controller/ # user / user-photo / wardrobe / body-measurement /
│ │ # avatar / outfit / hairstyle / product-recommend /
│ │ # partner-store / store-lead / subscription
│ ├── service/ # 对应业务逻辑(每域一个)
│ ├── dao/ # 每表一个
│ ├── model/
│ │ ├── entity/ # 表实体
│ │ ├── dto/ # 请求/响应 + g.Meta 路由
│ │ └── domain/
│ │ ├── outfit_plan.go # 方案领域模型 + JSON 解析校验
│ │ └── avatar_profile.go # 化身参数配置
│ ├── avatar/ # 3D 化身管线
│ │ ├── template_matcher.go # 特征 → 模板匹配
│ │ ├── texture_composer.go # 面部照片贴图合成
│ │ ├── glb_packer.go # 头部/身体/发型 GLB 组合打包
│ │ └── template_builder/ # 构建期烘焙脚本(MakeHuman/MPFB+BlenderCI 运行,不入运行时)
│ ├── scoring/ # 规则引擎评分
│ │ ├── rules.go # 规则定义与配置加载
│ │ ├── weather_rule.go # 天气适宜度
│ │ ├── occasion_rule.go # 场合匹配
│ │ ├── color_rule.go # 色彩和谐
│ │ └── completeness_rule.go # 层次完整度
│ ├── agent/ # 轻量 Agent
│ │ ├── chat_model.go # OpenAI 兼容调用(含重试/限流,复用模式)
│ │ ├── outfit_agent.go # 方案规划 / 兜底创作
│ │ ├── tools.go # get_weather / list_wardrobe / score_outfit / create_plan
│ │ └── output.go # 输出 JSON Schema 校验
│ ├── imagegen/
│ │ ├── client.go # ImageGenClient 接口
│ │ ├── wanx_client.go # 通义万相
│ │ ├── jimeng_client.go # 即梦
│ │ └── cache.go # 按快照 hash 缓存
│ ├── weather/
│ │ ├── qweather.go # 和风天气适配
│ │ └── geo.go # 地点 → 城市编码(高德)
│ ├── commercial/
│ │ ├── cps.go # CPS 商品检索
│ │ ├── store.go # 合作门店 LBS
│ │ └── subscription.go # 订阅权益
│ └── consts/
│ ├── public/table_name.go # 表名常量
│ ├── public/content_type.go # 照片类型/方案来源/任务状态
│ └── status.go # 任务状态常量
├── assets/avatar-templates/ # 预烘焙模板(20 头部 GLB + 6 身体 GLB + 5 档皮肤贴图 + 发型 GLB)
└── workspace/ # 用户数据(照片/GLB/效果图)
```
## 5. 数据库设计(每表一个 DAO/Service/Controller
### 用户域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `user` | 复用 video-factory 用户模型(role 扩展:user) | 账号密码登录 v1,手机号绑定留扩展 |
| `user_photo` | id / user_id / type(1大头照 2全身正面 3全身侧面 4全身背面) / url / status | 3D 构建用原图 |
| `wardrobe_item` | id / user_id / photo_url / category(上衣/下装/鞋/配饰) / season / style_tags / color_info / status | 服装资产 |
| `body_measurement` | id / user_id / height / weight / skin_tone / fit_params(JSON) | 用户填写 + 照片估算合并 |
### 化身域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `avatar_model` | id / user_id / face_template_id / body_template_id / skin_tone_index / face_texture_url / glb_url / build_status / params_snapshot(JSON) | 3D 化身 |
| `hairstyle_asset` | id / name / style_tag / glb_url / thumb_url / applicable_face / sort | 发型资产库(静态维护) |
| `outfit_asset` | id / name / style_tag / season / glb_url / cc0_source | 服装简模资产库(少量 CC0) |
### 生成域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `outfit_generation_task` | id / user_id / start_date / end_date / location / weather_snapshot(JSON) / status(planning→scored→rendering→done/failed) / model_name / error | 生成任务(轮询) |
| `outfit_plan` | id / task_id / user_id / date_range / location / source(wardrobe/recommend) / score / main_flag / hairstyle_id / hair_color / weather_ref(JSON) | 穿搭方案 |
| `plan_outfit_item` | id / plan_id / slot(发型/上衣/下装/鞋/配饰) / source(wardrobe/recommend) / wardrobe_item_id(可空) / product_recommend_id(可空) / name / desc | 方案条目 |
| `plan_effect_image` | id / plan_id / angle(正面/侧面/背面) / url / status / prompt_snapshot | 2D 效果图 |
| `plan_review` | id / plan_id / user_id / action(fav/unfav) / note | 用户反馈 → 回流 Agent |
### 商业域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `product_recommend` | id / plan_id(可空,全局备选) / product_name / channel(淘宝/京东/抖音/拼多多) / cps_url / price / commission_rate / image_url / status | CPS 商品 |
| `partner_store` | id / name / type(1形象设计 2服装门店) / lat / lng / address / commission_policy(JSON) / status | 合作门店 |
| `store_lead` | id / user_id / plan_id / store_id / status(created→visited→settled/cancelled) / create_time | 导流订单 |
| `subscription` | id / user_id / plan_type(standard/pro) / start_time / end_time / status | 会员订阅 |
| `model_config` / `user_model_config` | 复用 video-factory 表结构 | 模型配置 |
| `imagegen_config` | id / supplier / api_key / model_name / price_tier / enabled | 图像生成供应商配置 |
| `scoring_rule` | id / dimension / rule_type / rules_json / enabled / version | 评分规则配置(第 7 节),内置默认值 + 可配置 |
## 6. 3D 化身管线(预烘焙模板 + 运行时匹配)
### 核心理念
所有"昂贵且不稳定"的环节在**构建期**完成;运行时只做轻量匹配与合成,服务器成本趋近于零。
### 构建期(CI 或发布流水线,一次性执行)
1. MakeHuman(CC0 资产,官方导出可商用)生成参数化角色基底
2. MPFB + Blender headless 脚本烘焙:
- 20 个头部 GLB(脸型差异,PBR 材质)
- 6 个身体 GLB(体型差异:身高×胖瘦组合)
- 5 档皮肤贴图(肤色深浅)
- 10-15 个发型 GLB(CC0/自建,含发色可调材质)
3. glTF-Transform 压缩优化,产物提交 `assets/avatar-templates/`
### 运行时(用户触发 build)
```
用户照片(大头照+全身) + 身形参数
→ ① 特征提取:国内人脸 API(腾讯/阿里,免费额度)→ 脸型/五官特征向量
→ ② 模板匹配:特征向量 → 最近脸型模板(余弦距离,阈值外降级到用户滑杆微调)
→ ③ 贴图合成:大头照人脸区域 → 面部贴图(对齐模板 UV,肤色按色阶匹配 5 档)
→ ④ 打包:组合 头部模板 + 身体模板 + 皮肤贴图 → avatar GLB(头部/身体/发型分离存储,App 端组合换装)
→ ⑤ 保存 avatar_model 记录(build 任务异步,状态机 pending→processing→done/failed
```
### v1 边界声明
- 化身定位"高相似度虚拟形象"(脸型/肤色/身形贴近),非照片级真人重建
- 发型为资产库切换,不做 AI 重建用户真实发型
- 用户可在 App 端用滑杆微调身形/肤色(参数化信息与照片估算合并),滑杆调整即时反映在 GLB 缩放参数上(运行时零渲染成本)
## 7. 规则引擎评分(零 LLM 成本)
每个候选方案多维度打分,总分 100:
| 维度 | 权重 | 规则来源 |
|------|------|---------|
| 天气适宜度 | 25 | 温度区间 × 服装厚度匹配表(如 <10°C 需外套;25-32°C 短袖) |
| 场合匹配 | 25 | 日期类型(工作日/周末/节假日)→ 场合(通勤/约会/聚会)→ 服装类别规则表 |
| 色彩和谐 | 20 | 色相环配色表(同类色/邻近色/对比色得分) |
| 层次完整度 | 20 | 上衣/下装/鞋/配饰齐全度 + 可穿性(衣橱库存覆盖) |
| 风格一致性 | 10 | 服装 style_tags 与用户画像(历史收藏偏好)匹配度 |
- 规则表配置存库(`scoring_rule` 可配置,后台可调,v1 内置默认值常量 + 配置表扩展)
- 阈值 75 分可配置
- 全部低于阈值 → 判定"无合格衣橱方案",触发 Agent 兜底创作
- 免费用户效果图次数:每日 N 次(默认 3 次,配置可调);pro 订阅不限
## 8. 穿搭生成流程(Agent + 评分 + 兜底)
```
POST /outfit/generate {start_date, end_date, location}
→ ① 天气获取(和风 API,按 城市+日期 缓存 6h;地点经高德地理编码)
→ ② 规则引擎预筛:衣橱 × 天气 × 场合 → 3 套候选组合(零 LLM)
→ ③ Agent 规划(1 次 LLM 调用):
│ 工具:get_weather / list_wardrobe / score_outfit(规则引擎) / create_plan
│ 输出:3 套方案结构化 JSON(每套含发型建议/发色/服装条目)
→ ④ 规则评分:≥75 → source=wardrobe3 套全 <75 → LLM 兜底创作(1 次调用):
│ 输入:用户画像 + 天气 + 场合 + 衣橱摘要
│ 输出:高分方案 JSON(含 1-3 件新服装推荐,带品类/风格/价格带)
│ 方案标记 source=recommend,新服装关联 CPS 商品检索
→ ⑤ 保存方案(outfit_plan + plan_outfit_item),任务状态 → done
→ ⑥ App 端 3D 即时呈现 3 套方案(无额外成本);用户选定主方案后:
→ ⑦ 效果图按需生成(见下节),缓存命中则免费
```
**成本控制**
- 每次生成 LLM 调用 ≤ 2 次(规划 + 兜底,兜底仅全低分时触发)
- 评分 100% 规则引擎
- 工具调用控制在 3-5 次内(ReAct 最大步数 8
## 9. 效果图生成(按需 + 缓存 + 多供应商)
- 供应商适配器:`ImageGenClient` 接口,实现 通义万相(人像写真类 API)/ 即梦,配置表切换
- 输入:用户全身照 + 方案条目描述 + 人像一致性参数 + 视角(正面/侧面/背面)
- 触发:用户选定主方案后自动生成 3 视角;其余方案需用户主动请求(免费次数内/订阅权益检查)
- 缓存:key = md5(user_id + wardrobe_snapshot + plan_content),命中直接返回已生成图
- 异步:任务表 + 轮询(复用 StartPoller 模式)
- 失败重试 1 次,仍失败则标记 failed 并降级提示(3D 方案仍可用)
## 10. 商业化模块
| 渠道 | 实现 |
|------|------|
| 服装电商 CPS | `product_recommend` 表;兜底方案新服装检索 CPS 商品(淘宝联盟/京东联盟/抖音电商),App 端展示跳转,按成交佣金分成 |
| 形象设计门店 | `partner_store` type=1;发型/造型方案 LBS 推荐附近合作店(理发/造型师),`store_lead` 导流 + 到店核销 |
| 服装门店渠道 | `partner_store` type=2;本地服装门店展示 + 方案一键到店 |
| 会员订阅 | `subscription`standard(免费基础)/ pro(无限生成/高清效果图/方案全量效果图解锁) |
## 11. API 路由表(所有请求 JWT 鉴权,除 /user/login
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/user/login` | 登录(公开) |
| POST | `/user/change-password` | 修改密码 |
| GET | `/user/profile` | 个人资料 |
| POST | `/user-photo/upload` | 上传照片(type:大头照/全身正面/侧面/背面) |
| GET | `/user-photo/list` | 照片列表 |
| POST | `/user-photo/delete` | 删除照片 |
| POST | `/wardrobe/upload` | 上传服装(分类/季节/风格标签) |
| GET | `/wardrobe/list` | 衣橱列表 |
| POST | `/wardrobe/update` | 更新服装信息 |
| POST | `/wardrobe/delete` | 删除服装 |
| POST | `/body-measurement/save` | 保存身形参数 |
| GET | `/body-measurement/get` | 获取身形参数 |
| POST | `/avatar/build` | 触发化身构建任务 |
| GET | `/avatar/get` | 化身信息(GLB 地址/状态) |
| POST | `/avatar/rebuild` | 重新构建化身 |
| GET | `/hairstyle/list` | 发型资产列表 |
| POST | `/outfit/generate` | 生成穿搭方案(日期范围+地点) |
| GET | `/outfit/task/status` | 生成任务状态轮询 |
| GET | `/outfit/plan/list` | 方案列表(历史) |
| GET | `/outfit/plan/detail` | 方案详情(3D 配置 + 条目 + 商品/门店) |
| POST | `/outfit/plan/select-main` | 选定主方案(触发效果图生成) |
| POST | `/outfit/plan/effect-image/generate` | 补生成某方案效果图(权益检查) |
| POST | `/outfit/plan/review` | 方案反馈(收藏/点赞/备注) |
| GET | `/product-recommend/list` | 方案关联 CPS 商品 |
| GET | `/partner-store/list` | 附近合作门店(lat/lng |
| POST | `/store-lead/create` | 创建导流订单 |
| POST | `/store-lead/confirm` | 到店核销 |
| POST | `/subscription/create` | 创建订阅 |
| GET | `/subscription/status` | 订阅状态 |
## 12. 错误处理与异步任务
- 任务状态机:`pending → processing → done / failed`,失败写 `error` 字段,App 轮询展示
- 外部 API(人脸/天气/LLM/图像)统一超时与指数退避重试;Key 失效/欠费返回明确错误码
- 图片上传限制:单张 ≤ 10MB,格式 jpg/png/webp,服务端校验 + 压缩(宽边 ≤ 2048)
- 路径安全:workspace 文件服务防 `..` 穿越(复用 video-factory BindHandler 实现)
## 13. 测试策略
- DAO/Service:表驱动单测(SQLite 内存库),覆盖评分规则各维度边界(温度档位/色彩组合/阈值判定)
- Agent:输出 JSON Schema 校验测试 + 工具 mockchat_model 接口化)
- 化身管线:模板匹配单元测试(特征向量 → 模板索引)+ 贴图合成冒烟
- Controller:路由注册冒烟 + 鉴权中间件测试
- 关键流程集成测试:generate → 评分 → 兜底 → 出图(全 mock 外部 API)
## 14. 成本估算与部署(初期 1 万次生成/月)
| 项目 | 月成本 | 说明 |
|------|--------|------|
| 服务器 | ~¥150 | 2C4G 轻量云,Docker 部署单体 |
| LLM | ~¥1000 | DeepSeek/Qwen~¥0.1/次(≤2 次调用 + 工具) |
| 图像生成 | ~¥7000 | 主方案 3 视角 ≈ ¥0.7/次;pro 订阅用户分摊成本 |
| 人脸 API / 天气 | 免费额度内 | 缓存 + 免费版 |
| **单次生成总成本** | **~¥0.8** | 其中图像生成占大头,已按最优策略控制 |
- 存储 v1 本地 workspace(可迁 OSS/COS,存储接口抽象预留)
- 规模化信号:存储 > 50GB 或单机 CPU 持续 >70% → 迁对象存储 + 拆分轮询 worker
+104
View File
@@ -0,0 +1,104 @@
# slogan-agent 服务端
人形象设计应用(slogan)的服务端。用户上传个人照片与服装照片,指定日期地点后由大模型生成穿搭方案(含发型),支持 3D 化身与效果图查看。
技术栈:Go 1.22+ / GoFrame v2 / SQLite / JWT / OpenAI 兼容大模型 / 和风天气 + 高德地理编码。
## 快速开始
```bash
go mod tidy
go build -o slogan-agent .
./slogan-agent
```
服务默认监听 `:3007`,首次启动自动建库建表(`slogan.db`)。
### 必要配置(config.yml
| 配置项 | 说明 |
|--------|------|
| `llm.base_url / api_key / model_name` | 大模型(OpenAI 兼容,如通义/DeepSeek/Kimi),未配置时生成任务失败并返回明确错误 |
| `weather.qweather_key` | 和风天气 v7 Key(免费版即可),用于 7 天预报 |
| `geo.amap_key` | 高德地理编码 Key,地点 → adcode |
| `imagegen.supplier` | 效果图供应商:`mock`(占位图,开发用)或 `wanx`(通义万相,需配 `wanx_api_key` |
未配置天气/LLM Key 时接口返回明确错误提示,服务本身可正常启动。
## 接口总览
统一响应格式:`{"code":0,"message":"OK","data":...}``code != 0` 为业务错误。除公开接口外需 `Authorization: Bearer <token>`JWT7 天有效)。
| 模块 | 路径 | 说明 | 公开 |
|------|------|------|------|
| 用户 | `POST /user/register` | 注册 | 是 |
| 用户 | `POST /user/login` | 登录,返回 token | 是 |
| 用户 | `POST /user/change-password` | 修改密码 | |
| 用户 | `GET /user/profile` | 个人信息 | |
| 照片 | `POST /user-photo/upload` | 上传照片(type: 1 大头 2 全身正面 3 侧面 4 背面) | |
| 照片 | `GET /user-photo/list` | 照片列表(type 可筛选) | |
| 照片 | `POST /user-photo/delete` | 删除照片 | |
| 衣橱 | `POST /wardrobe/upload` | 上传服装(category: 上衣/下装/鞋/配饰,season, style_tags, color_info | |
| 衣橱 | `GET /wardrobe/list` | 衣橱列表 | |
| 衣橱 | `POST /wardrobe/update` | 更新服装信息 | |
| 衣橱 | `POST /wardrobe/delete` | 删除服装 | |
| 身形 | `POST /body-measurement/save` | 保存身形(height/weight/skin_tone | |
| 身形 | `GET /body-measurement/get` | 查询身形 | |
| 化身 | `POST /avatar/build` | 构建 3D 化身(模板匹配,v1 同步) | |
| 化身 | `GET /avatar/get` | 化身信息(glb_url | |
| 发型 | `GET /hairstyle/list` | 发型资产库 | 是 |
| 穿搭 | `POST /outfit/generate` | 生成穿搭方案(异步任务,body: start_date/end_date/location | |
| 穿搭 | `GET /outfit/task/status` | 任务状态(pending→planning→scoring→done/failed | |
| 穿搭 | `GET /outfit/plan/list` | 方案列表 | |
| 穿搭 | `GET /outfit/plan/detail` | 方案详情(items + hairstyle + effect images | |
| 穿搭 | `POST /outfit/plan/select-main` | 选定主方案(触发 3 视角效果图生成) | |
| 穿搭 | `POST /outfit/plan/review` | 方案反馈(fav/unfav | |
| 门店 | `GET /partner-store/list` | 合作门店(type: 1 形象设计 2 服装门店,0 全部) | |
| 静态 | `GET /workspace/*` | 上传文件与模板资产(鉴权放行) | |
OpenAPI 文档:`http://127.0.0.1:3007/api.json`
## 生成流程(outfit/generate
```
pending → planning(天气获取 → 规则预筛 3 套候选 → LLM 规划 1 次调用)
→ scoring(规则引擎 5 维评分:天气 25/场合 25/色彩 20/完整度 20/风格 10,阈值 75
→ 全低分 → LLM 兜底创作(1 次调用,recommend 方案)
→ 落库 outfit_plan + plan_outfit_item
→ done
```
- 衣橱不足 3 件、日期倒挂、Key 未配置等均在任务结果中返回明确错误
- 服务重启时未完成任务标记 failed(避免重复消耗模型费用)
- 效果图按需生成:选主方案后异步生成 正面/侧面/背面 3 张,内容 hash 缓存 24h,每日限 3 次(可配 `scoring_rule``effect_limit` 维度)
## 数据模型
13 张表:`slogan_user``slogan_user_photo``slogan_wardrobe_item``slogan_body_measurement``slogan_avatar_model``slogan_hairstyle_asset`seed 8 发型)、`slogan_outfit_generation_task``slogan_outfit_plan``slogan_plan_outfit_item``slogan_plan_effect_image``slogan_plan_review``slogan_scoring_rule``slogan_partner_store`seed 4 门店)。
## 目录结构
```
main.go 入口:路由注册 + workspace 静态服务 + 任务恢复
common/ 统一响应/RouteRegister/JWT 鉴权/工具
styleagent/
controller/ Controller 层(反射路由,struct 名 → kebab-case URL
service/ 业务层(生成编排/化身/衣橱/效果图)
dao/ 每表一 DAOinit 自动建表 + seed
model/entity|dto/ 实体与请求响应结构
agent/ LLM 调用(OpenAI 兼容,重试/工具调用)+ 方案规划/兜底
scoring/ 规则评分引擎(零 LLM 成本)
weather/ 和风天气 + 高德地理编码 + 缓存
imagegen/ 效果图客户端(mock/wanx+ 缓存
avatar/ 3D 化身模板匹配
consts/ 常量
```
## 部署
```bash
docker build -t slogan-agent .
docker run -d -p 3007:3007 -v /data/slogan:/app/workspace -v /data/slogan/slogan.db:/app/slogan.db slogan-agent
```
生产部署前在 config.yml 填写 llm/weather/geo/imagegen 的真实 Key。
+47
View File
@@ -0,0 +1,47 @@
module slogan-agent
go 1.26.1
require (
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.38.0
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+104
View File
@@ -0,0 +1,104 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g=
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"context"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
commonHttp "slogan-agent/common"
"slogan-agent/styleagent/controller"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
)
func main() {
// ==================== API 路由(RouteRegister 反射注册,kebab-case 前缀) ====================
commonHttp.RouteRegister([]interface{}{
controller.User,
controller.UserPhoto,
controller.Wardrobe,
controller.BodyMeasurement,
controller.Avatar,
controller.Hairstyle,
controller.Outfit,
controller.PartnerStore,
controller.Member,
controller.Ad,
controller.Cps,
})
// 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装)
commonHttp.Httpserver.Group("/member/order", func(group *ghttp.RouterGroup) {
group.POST("/notify", controller.MemberNotify)
})
// ==================== Workspace 文件服务(鉴权保护) ====================
commonHttp.Httpserver.BindHandler("/workspace/*", func(r *ghttp.Request) {
relPath := strings.TrimPrefix(r.URL.Path, "/workspace/")
if relPath == "" || strings.Contains(relPath, "..") {
r.Response.WriteStatus(http.StatusForbidden)
return
}
filePath := filepath.Join("workspace", relPath)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
r.Response.WriteStatus(http.StatusNotFound)
return
}
r.Response.ServeFile(filePath)
})
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// 恢复未完成的生成任务(重启后标记失败,避免重复消耗 LLM 费用)
service.OutfitService.StartWorker(ctx)
// CPS 联盟商品定时同步(未配置 key 时空转)
service.CpsProductService.StartSyncLoop(ctx)
g.Log().Info(ctx, "slogan-agent started on :3007")
<-ctx.Done()
g.Log().Info(ctx, "shutting down...")
time.Sleep(1 * time.Second)
g.Log().Info(ctx, "bye")
}
+713
View File
@@ -0,0 +1,713 @@
{
"name": "avatar-render",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "avatar-render",
"version": "1.0.0",
"dependencies": {
"gl": "^9.0.0-rc.10",
"pngjs": "^7.0.0",
"three": "0.162.0"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/abbrev": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
"integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bit-twiddle": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz",
"integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA=="
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"engines": {
"node": ">=18"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"engines": {
"node": ">=6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"engines": {
"node": ">=6"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
"node_modules/gl": {
"version": "9.0.0-rc.10",
"resolved": "https://registry.npmjs.org/gl/-/gl-9.0.0-rc.10.tgz",
"integrity": "sha512-G6lYaWoan0d2d8UO0UmaSS8zqyyZwYt6q2dFVJ2hD62sRWUwkMIH+Jp7gEoQesLlo28Nzelh+GIYz1qOUa5WmQ==",
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
"bit-twiddle": "^1.0.2",
"glsl-tokenizer": "^2.1.5",
"nan": "^2.26.2",
"node-gyp": "^12.2.0",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/glsl-tokenizer": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz",
"integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==",
"dependencies": {
"through2": "^0.6.3"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
},
"node_modules/isexe": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
"integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
"engines": {
"node": ">=20"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-gyp": {
"version": "12.4.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
"integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
"semver": "^7.3.5",
"tar": "^7.5.4",
"tinyglobby": "^0.2.12",
"undici": "^6.25.0",
"which": "^6.0.0"
},
"bin": {
"node-gyp": "bin/node-gyp.js"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/nopt": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
"integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==",
"dependencies": {
"abbrev": "^4.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proc-log": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar": {
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/three": {
"version": "0.162.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.162.0.tgz",
"integrity": "sha512-xfCYj4RnlozReCmUd+XQzj6/5OjDNHBy5nT6rVwrOKGENAvpXe2z1jL+DZYaMu4/9pNsjH/4Os/VvS9IrH7IOQ=="
},
"node_modules/through2": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz",
"integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==",
"dependencies": {
"readable-stream": ">=1.0.33-1 <1.1.0-0",
"xtend": ">=4.0.0 <4.1.0-0"
}
},
"node_modules/through2/node_modules/readable-stream": {
"version": "1.0.34",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
"integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"node_modules/through2/node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/undici": {
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"engines": {
"node": ">=18.17"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/which": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
"integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
"dependencies": {
"isexe": "^4.0.0"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"engines": {
"node": ">=18"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "avatar-render",
"version": "1.0.0",
"private": true,
"description": "服务端 3D 化身预渲染:GLB -> 36 帧旋转 PNGthree.js + headless-gl + pngjs",
"type": "module",
"dependencies": {
"gl": "^9.0.0-rc.10",
"pngjs": "^7.0.0",
"three": "0.162.0"
}
}
+115
View File
@@ -0,0 +1,115 @@
// 化身 GLB -> 36 帧旋转 PNG(绕 Y 轴 10° 步进),服务端预渲染。
// 用法: node render.js --glb <path> --out <dir> [--frames 36] [--size 256x512]
import { argv } from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
import createGL from 'gl';
import { PNG } from 'pngjs';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
function parseArgs() {
const a = {};
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;
}
}
if (!a.glb || !a.out) {
console.error('用法: node render.js --glb <path> --out <dir> [--frames 36] [--size 256x512]');
process.exit(1);
}
a.frames = a.frames === true ? 36 : parseInt(a.frames, 10) || 36;
const [w, h] = (a.size === true ? '256x512' : String(a.size)).split('x').map(Number);
a.width = w || 256;
a.height = h || 512;
return a;
}
const args = parseArgs();
const { width, height, frames } = args;
const gl = createGL(width, height, { preserveDrawingBuffer: true });
if (!gl) {
console.error('headless-gl 初始化失败(容器内需 mesa/libglvnd');
process.exit(2);
}
const canvas = {
width,
height,
style: {},
addEventListener: () => {},
removeEventListener: () => {},
clientWidth: width,
clientHeight: height,
getContext: () => gl,
};
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: false });
renderer.setClearColor(0xffffff, 1);
renderer.setSize(width, height, false);
const scene = new THREE.Scene();
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.4);
dirLight.position.set(3, 6, 4);
scene.add(dirLight);
scene.add(new THREE.DirectionalLight(0xffffff, 0.5).translateY(-4).translateX(-3));
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 100);
const loader = new GLTFLoader();
const loadGlb = () =>
new Promise((resolve, reject) => {
// Buffer 需转成 ArrayBuffer 才能触发 GLB 头解析
const bin = fs.readFileSync(args.glb);
const ab = bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength);
loader.parse(ab, '', (gltf) => resolve(gltf.scene), (err) => reject(err));
});
loadGlb()
.then((object) => {
scene.add(object);
render(object);
})
.catch((err) => {
console.error('GLB 解析失败:', err && err.message ? err.message : err);
process.exit(3);
});
function render(object) {
// 包围盒 -> 相机半径与观测高度
const box = new THREE.Box3().setFromObject(object);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const radius = Math.max(size.x, size.z) * 1.6 + 0.6;
const lookY = center.y + size.y * 0.35;
const cameraY = center.y + size.y * 0.35;
fs.mkdirSync(args.out, { recursive: true });
const pixels = new Uint8Array(width * height * 4);
const rowSize = width * 4;
const png = new PNG({ width, height });
for (let i = 0; i < frames; i++) {
const angle = (i / frames) * Math.PI * 2;
camera.position.set(Math.sin(angle) * radius, cameraY, Math.cos(angle) * radius);
camera.lookAt(0, lookY, 0);
renderer.render(scene, camera);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const buf = Buffer.from(pixels.buffer);
for (let y = 0; y < height; y++) {
buf.copy(png.data, y * rowSize, (height - 1 - y) * rowSize, (height - y) * rowSize);
}
const out = path.join(args.out, `frame_${String(i).padStart(3, '0')}.png`);
fs.writeFileSync(out, PNG.sync.write(png));
}
console.log(`rendered ${frames} frames -> ${args.out}`);
process.exit(0);
}
+232
View File
@@ -0,0 +1,232 @@
package main
// 为 wenwu901 真实生成一套 AI 穿搭方案(真实调用 imagegen 与 LLM,非 mock):
// go run scripts/gen_outfit_plan/main.go
// 步骤:补衣橱(8 件单品,imagegen 生成服装图)→ 调 OutfitService.Generate →
// 轮询任务到 done → 选主方案(触发效果图异步生成)→ 等 3 张效果图完成。
// 前置:config.yml 已配置 geo.amap_key + weather.qweather_key(天气硬依赖)。
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"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/dto"
"slogan-agent/styleagent/model/entity"
"slogan-agent/styleagent/service"
)
const username = "wenwu901"
type garment struct {
name string
category string
style string
color string
prompt string
}
var garments = []garment{
{"白色长袖衬衫", "上衣", "休闲", "白色", "纯白背景的白色长袖衬衫商品图,正面展示,高清,电商风格"},
{"灰色圆领T恤", "上衣", "休闲", "灰色", "纯白背景的灰色圆领T恤商品图,正面展示,高清,电商风格"},
{"深蓝夹克外套", "上衣", "外套", "深蓝", "纯白背景的深蓝色夹克外套商品图,正面展示,高清,电商风格"},
{"深灰休闲长裤", "下装", "休闲", "深灰", "纯白背景的深灰色休闲长裤商品图,正面展示,高清,电商风格"},
{"蓝色牛仔裤", "下装", "休闲", "蓝色", "纯白背景的蓝色牛仔裤商品图,正面展示,高清,电商风格"},
{"白色运动鞋", "鞋", "休闲", "白色", "纯白背景的白色运动鞋商品图,侧面展示,高清,电商风格"},
{"棕色皮鞋", "鞋", "商务", "棕色", "纯白背景的棕色皮鞋商品图,侧面展示,高清,电商风格"},
{"黑色双肩背包", "配饰", "休闲", "黑色", "纯白背景的黑色双肩背包商品图,正面展示,高清,电商风格"},
}
func main() {
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)
if err := ensureWardrobe(ctx, user.Id); err != nil {
panic(err)
}
// 幂等:已有方案则只重试效果图(主方案 → select-main → 等 3 张 done
var mainPlan *entity.OutfitPlan
existing, err := dao.OutfitPlan.ListByUser(ctx, user.Id)
if err != nil {
panic(fmt.Sprintf("读取方案列表失败: %v", err))
}
for _, p := range existing {
if p.MainFlag == 1 {
mainPlan = p
}
}
if len(existing) == 0 {
taskId, err := service.OutfitService.Generate(ctx, user.Id, &dto.OutfitGenerateReq{
StartDate: "2026-08-01", EndDate: "2026-08-07", Location: "上海", Occasion: "通勤",
})
if err != nil {
if strings.Contains(err.Error(), "未配置") {
panic(fmt.Sprintf("%v\n请先在 config.yml 配置 geo.amap_key / weather.qweather_key 后重跑", err))
}
panic(fmt.Sprintf("发起方案生成失败: %v", err))
}
fmt.Printf("生成任务已提交: task_id=%d,轮询中...\n", taskId)
waitTaskDone(ctx, taskId, user.Id)
plans, err := dao.OutfitPlan.ListByTask(ctx, taskId)
if err != nil || len(plans) == 0 {
panic(fmt.Sprintf("任务完成但无方案: %v", err))
}
mainPlan = plans[0]
for _, p := range plans {
if p.MainFlag == 1 {
mainPlan = p
}
}
} else if mainPlan == nil {
panic("已有方案但无主方案,请先选主方案")
}
fmt.Printf("主方案: id=%d %s 评分=%d\n", mainPlan.Id, mainPlan.Title, mainPlan.Score)
if err := service.OutfitPlanService.SelectMain(ctx, user.Id, mainPlan.Id); err != nil {
panic(fmt.Sprintf("选主方案失败: %v", err))
}
waitEffects(ctx, mainPlan.Id)
all, err := dao.OutfitPlan.ListByUser(ctx, user.Id)
if err != nil {
panic(fmt.Sprintf("读取方案列表失败: %v", err))
}
fmt.Printf("完成!wenwu901 现有 %d 套方案:\n", len(all))
for _, p := range all {
fmt.Printf(" - plan %d: %s(评分 %d%s\n", p.Id, p.Title, p.Score, p.Source)
}
}
// ensureWardrobe 为指定用户补齐 8 件衣橱单品(同 Category 已有则跳过该分类),服装图用 imagegen 生成
func ensureWardrobe(ctx context.Context, userId int64) error {
existing, err := dao.WardrobeItem.ListAllByUser(ctx, userId)
if err != nil {
return err
}
have := map[string]bool{}
for _, it := range existing {
have[it.Category] = true
}
need := make([]garment, 0, len(garments))
for _, ga := range garments {
if !have[ga.category] {
need = append(need, ga)
}
}
if len(need) == 0 {
fmt.Println("衣橱 4 类已齐,跳过补衣橱")
return nil
}
client, err := agent.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "wanx").String())
if err != nil {
return err
}
dir := filepath.Join("workspace", fmt.Sprintf("user_%d", userId), "wardrobe")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, ga := range need {
fmt.Printf("生成服装图: %s...\n", ga.name)
url, err := client.Generate(ctx, &agent.GenerateReq{
Prompt: ga.prompt, Seed: time.Now().UnixNano() % 1_000_000,
})
if err != nil {
return fmt.Errorf("生成 %s 服装图失败: %w", ga.name, err)
}
path := filepath.Join(dir, fmt.Sprintf("%d_%s.png", time.Now().UnixNano(), ga.name))
if err := download(url, path); err != nil {
return fmt.Errorf("保存 %s 失败: %w", ga.name, err)
}
if _, err := dao.WardrobeItem.Insert(ctx, &entity.WardrobeItem{
UserId: userId, PhotoUrl: "/" + filepath.ToSlash(path),
Name: ga.name, Category: ga.category, Season: "四季", StyleTags: ga.style,
ColorInfo: ga.color, Status: 1,
}); err != nil {
return fmt.Errorf("入库 %s 失败: %w", ga.name, err)
}
fmt.Printf("%s 完成: %s\n", ga.name, path)
}
fmt.Println("衣橱补齐完毕")
return nil
}
func waitTaskDone(ctx context.Context, taskId, userId int64) {
for i := 0; i < 30; i++ {
task, err := dao.OutfitGenTask.GetOne(ctx, taskId, userId)
if err != nil || task == nil {
panic(fmt.Sprintf("读取任务失败: %v", err))
}
switch task.Status {
case consts.TaskStatusDone:
fmt.Println("方案生成完成")
return
case consts.TaskStatusFailed:
panic(fmt.Sprintf("方案生成失败: %s", task.Error))
}
time.Sleep(10 * time.Second)
}
panic("方案生成超时(5 分钟)")
}
// waitEffects 等主方案的 3 张效果图(正面/侧面/背面)全部 done
func waitEffects(ctx context.Context, planId int64) {
for i := 0; i < 20; i++ {
images, err := dao.PlanEffectImage.ListByPlan(ctx, planId)
if err != nil {
panic(fmt.Sprintf("读取效果图列表失败: %v", err))
}
done := 0
for _, im := range images {
if im.Status == consts.EffectStatusDone {
done++
}
}
if done >= 3 {
fmt.Printf("效果图 3 张完成\n")
return
}
if i == 19 {
fmt.Printf("警告: 效果图超时(完成 %d/3),可稍后查看\n", done)
return
}
time.Sleep(15 * time.Second)
}
}
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
}
+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
}
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# 路由快照:输出 /api.json 的全部路径(排序去重)
# 用法:bash scripts/routes.sh > /tmp/routes-before.txt
set -e
BASE="${BASE:-http://localhost:3007}"
curl -s "$BASE/api.json" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for p in sorted(d.get('paths', {}).keys()):
print(p)
"
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# 全路径冒烟:register/login → token → 依次打全部接口,断言 HTTP 200 + code 符合预期
# 用法:bash scripts/smoke.sh
# 约定:check_code 第三参 = 允许的降级 code 列表(逗号分隔,默认只许 0)
set -e
BASE="${BASE:-http://localhost:3007}"
FAIL=0
say() { echo "[smoke] $*"; }
fail() { echo "[smoke] FAIL: $*"; FAIL=1; }
check_code() {
local name="$1" body="$2" allow="$3"
local code
code=$(echo "$body" | python3 -c "import json,sys; print(json.load(sys.stdin).get('code','?'))" 2>/dev/null || echo "?")
if [ "$code" = "0" ] || echo ",$allow," | grep -q ",$code,"; then
say "OK: $name"
else
fail "$name: unexpected code=$code (allow: $allow) body=$(echo "$body" | head -c 200)"
fi
}
# 1. 注册 + 登录拿 token
USER="smoke_$(date +%s)"
REG=$(curl -s -X POST "$BASE/user/register" -H 'Content-Type: application/json' -d "{\"account\":\"$USER\",\"password\":\"smoketest123\"}")
say "register: $(echo "$REG" | head -c 120)"
LOGIN=$(curl -s -X POST "$BASE/user/login" -H 'Content-Type: application/json' -d "{\"account\":\"$USER\",\"password\":\"smoketest123\"}")
TOKEN=$(echo "$LOGIN" | python3 -c "import json,sys; print(json.load(sys.stdin).get('data',{}).get('token',''))" 2>/dev/null)
if [ -z "$TOKEN" ]; then
fail "login: no token in $(echo "$LOGIN" | head -c 200)"
exit 1
fi
say "login OK, token len=${#TOKEN}"
AUTH="Authorization: Bearer $TOKEN"
# 2. GET 接口(第三参允许的 code:50=未开通/降级/无数据)
for item in \
"GET /user/profile 0" \
"GET /user-photo/list 0" \
"GET /wardrobe/list 0" \
"GET /body-measurement/get 0" \
"GET /avatar/get 0" \
"GET /hairstyle/list 0" \
"GET /outfit/task/status?task_id=0 50" \
"GET /outfit/plan/list 0" \
"GET /partner-store/list 0" \
"GET /member/plan/list 0" \
"GET /member/status 0" \
"GET /cps/category/list 0" \
"GET /cps/product/list?source=meituan_ota&category_code=beauty 0" \
"GET /cps/plan/recommend?plan_id=0&scene=haircut 50" \
"GET /cps/wardrobe/upgrade?item_id=0 50" \
"GET /cps/my/recent 0" ; do
set -- $item
METHOD="$1"; PATH_="$2"; ALLOW="${3:-0}"
RESP=$(curl -s -X "$METHOD" "$BASE$PATH_" -H "$AUTH")
check_code "$PATH_" "$RESP" "$ALLOW"
done
# 3. POST 接口
post_check() {
local name="$1" json="$2" allow="${3:-0}"
local resp
resp=$(curl -s -X POST "$BASE$name" -H "$AUTH" -H 'Content-Type: application/json' -d "$json")
check_code "$name" "$resp" "$allow"
}
post_check "/body-measurement/save" '{"height_cm":175,"weight_kg":65}'
post_check "/outfit/plan/review" '{"plan_id":0,"action":"fav"}' "50"
post_check "/member/order/create" '{"plan_id":1}' "50"
post_check "/ad/reward/claim" '{"ad_type":"effect_extra"}' "50"
post_check "/outfit/generate" '{"start_date":"2026-08-01","end_date":"2026-08-07","location":"上海"}' "50"
post_check "/cps/product/link" '{"product_id":0,"scene":"item_buy"}' "50,51"
post_check "/user/change-password" '{"old_password":"smoketest123","new_password":"smoketest456"}'
# 4. 裸回调(无鉴权;no_order 预期返回 fail
NOTIFY=$(curl -s -X POST "$BASE/member/order/notify" -H 'Content-Type: application/x-www-form-urlencoded' -d 'out_trade_no=nonexist&trade_no=x&amount=0&status=paid')
say "notify(no_order)=$NOTIFY"
# 5. workspace 静态文件
WS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/workspace/nonexist.png" -H "$AUTH")
say "workspace/404: $WS"
if [ "$FAIL" = "0" ]; then
say "ALL SMOKE PASS"
else
say "SMOKE HAS FAILURES"
exit 1
fi
+238
View File
@@ -0,0 +1,238 @@
// 一次性迁移工具:把单文件 slogan.db 拆分为 4 个 SQLite 库
//
// slogan.db 主库(用户域 + 低频配置)
// slogan_plan.db 穿搭方案域
// slogan_pay.db 会员/支付域
// slogan_cps.db CPS 联盟域
//
// 用法:在 slogan-agent 目录执行 `go run ./scripts/split_db`
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
)
const mainDB = "slogan.db"
var groups = []struct {
file string
tables []string
}{
{"slogan_plan.db", []string{
"slogan_outfit_generation_task", "slogan_outfit_plan", "slogan_plan_outfit_item",
"slogan_plan_effect_image", "slogan_plan_review", "slogan_hairstyle_asset",
}},
{"slogan_pay.db", []string{
"slogan_member_plan", "slogan_user_member", "slogan_payment_order",
"slogan_pay_notify_log", "slogan_ad_reward_log",
}},
{"slogan_cps.db", []string{
"slogan_cps_category", "slogan_cps_product", "slogan_cps_click_log",
"slogan_scene_category_map",
}},
}
func main() {
dir, err := os.Getwd()
if err != nil {
fatal("getwd: %v", err)
}
mainPath := filepath.Join(dir, mainDB)
if _, err := os.Stat(mainPath); err != nil {
fatal("slogan.db 不存在(请在 slogan-agent 目录执行): %v", err)
}
backup := filepath.Join(dir, "slogan_backup_"+time.Now().Format("20060102_150405")+".db")
if err := copyFile(mainPath, backup); err != nil {
fatal("备份失败: %v", err)
}
fmt.Printf("已备份 -> %s\n", backup)
src, err := sql.Open("sqlite", mainPath)
if err != nil {
fatal("open %s: %v", mainDB, err)
}
src.SetMaxOpenConns(1)
defer src.Close()
ddl, err := loadDDL(src)
if err != nil {
fatal("读取 DDL: %v", err)
}
srcAbs, err := filepath.Abs(mainPath)
if err != nil {
fatal("abs: %v", err)
}
failed := false
for _, g := range groups {
if err := migrateGroup(dir, src, srcAbs, g.file, g.tables, ddl); err != nil {
fmt.Printf("❌ %s 迁移失败: %v\n", g.file, err)
failed = true
}
}
if failed {
os.Exit(1)
}
// 主库删除已迁出的表(连带索引),清理自增序列残留
for _, g := range groups {
for _, t := range g.tables {
if _, err := src.Exec("DROP TABLE IF EXISTS " + t); err != nil {
fatal("DROP %s: %v", t, err)
}
}
names := quoteList(g.tables)
if _, err := src.Exec("DELETE FROM sqlite_sequence WHERE name IN (" + names + ")"); err != nil {
fmt.Printf("⚠ 清理 sqlite_sequence 失败(可忽略): %v\n", err)
}
}
fmt.Printf("✅ %s 主库已清理,剩余表:\n", mainDB)
if err := listTables(src, mainDB); err != nil {
fatal("list: %v", err)
}
fmt.Println("✅ 拆分完成")
}
// migrateGroup 新建目标库文件并拷贝表 + 索引 + 校验行数
func migrateGroup(dir string, src *sql.DB, srcAbs, file string, tables []string, ddl map[string][]string) error {
dstPath := filepath.Join(dir, file)
_ = os.Remove(dstPath) // 覆盖上次失败残留
dst, err := sql.Open("sqlite", dstPath)
if err != nil {
return err
}
dst.SetMaxOpenConns(1)
defer dst.Close()
if _, err := dst.Exec(fmt.Sprintf("ATTACH DATABASE %q AS src", srcAbs)); err != nil {
return fmt.Errorf("attach: %w", err)
}
defer dst.Exec("DETACH DATABASE src")
for _, t := range tables {
createDDL, ok := firstByType(ddl[t], "table")
if !ok {
return fmt.Errorf("表 %s 未找到建表 DDL", t)
}
if _, err := dst.Exec(createDDL); err != nil {
return fmt.Errorf("create %s: %w", t, err)
}
if _, err := dst.Exec(fmt.Sprintf("INSERT INTO %s SELECT * FROM src.%s", t, t)); err != nil {
return fmt.Errorf("copy %s: %w", t, err)
}
}
for _, t := range tables {
for _, idx := range ddl[t] {
if !strings.HasPrefix(idx, "CREATE INDEX") && !strings.HasPrefix(idx, "CREATE UNIQUE INDEX") {
continue
}
if _, err := dst.Exec(idx); err != nil {
return fmt.Errorf("index %s: %w", idx, err)
}
}
}
// 校验行数
for _, t := range tables {
srcN, dstN, err := countPair(src, dst, t)
if err != nil {
return err
}
if srcN != dstN {
return fmt.Errorf("%s 行数不一致: src=%d dst=%d", t, srcN, dstN)
}
fmt.Printf("✅ %-24s %8d 行\n", t, dstN)
}
return nil
}
func loadDDL(db *sql.DB) (map[string][]string, error) {
rows, err := db.Query("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND type IN ('table','index') ORDER BY type DESC")
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string][]string{}
for rows.Next() {
var typ, name, sqlText string
if err := rows.Scan(&typ, &name, &sqlText); err != nil {
return nil, err
}
if strings.HasPrefix(sqlText, "CREATE TABLE") {
out[name] = append([]string{sqlText}, out[name]...) // table 放最前
} else {
out[name] = append(out[name], sqlText)
}
}
return out, rows.Err()
}
func firstByType(ddls []string, prefix string) (string, bool) {
for _, d := range ddls {
if strings.HasPrefix(d, "CREATE TABLE") {
return d, true
}
}
return "", false
}
func countPair(src, dst *sql.DB, table string) (int, int, error) {
srcN, err := count(src, table)
if err != nil {
return 0, 0, err
}
dstN, err := count(dst, table)
if err != nil {
return 0, 0, err
}
return srcN, dstN, nil
}
func count(db *sql.DB, table string) (int, error) {
var n int
err := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n)
return n, err
}
func listTables(db *sql.DB, file string) error {
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return err
}
fmt.Printf(" %s\n", name)
}
return rows.Err()
}
func quoteList(items []string) string {
q := make([]string, len(items))
for i, s := range items {
q[i] = "'" + s + "'"
}
return strings.Join(q, ",")
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
func fatal(format string, args ...any) {
fmt.Printf("❌ "+format+"\n", args...)
os.Exit(1)
}
+37
View File
@@ -0,0 +1,37 @@
package agent
import (
"context"
"fmt"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
)
var modelCfgCache = gcache.New()
// GetModelConfig 从 config.yml 读取 LLM 配置(缓存 60s),未配置返回明确错误
func GetModelConfig(ctx context.Context) (*ModelConfig, error) {
cacheKey := "llm:model_config"
v, err := modelCfgCache.Get(ctx, cacheKey)
if err == nil && !v.IsNil() {
if cfg, ok := v.Val().(*ModelConfig); ok {
return cfg, nil
}
}
cfg := &ModelConfig{
BaseURL: g.Cfg().MustGet(ctx, "llm.base_url", "").String(),
APIKey: g.Cfg().MustGet(ctx, "llm.api_key", "").String(),
ModelName: g.Cfg().MustGet(ctx, "llm.model_name", "").String(),
MaxTokens: g.Cfg().MustGet(ctx, "llm.max_tokens", 4096).Int(),
Temperature: g.Cfg().MustGet(ctx, "llm.temperature", 0.8).Float32(),
Timeout: time.Duration(g.Cfg().MustGet(ctx, "chat.timeout", 300).Int()) * time.Second,
MaxRetries: g.Cfg().MustGet(ctx, "chat.max_retries", 3).Int(),
}
if cfg.APIKey == "" || cfg.ModelName == "" || cfg.BaseURL == "" {
return nil, fmt.Errorf("LLM 未配置:请在 config.yml 设置 llm.base_url / llm.api_key / llm.model_name")
}
_ = modelCfgCache.Set(ctx, cacheKey, cfg, 60*time.Second)
return cfg, nil
}
@@ -0,0 +1,218 @@
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)
if status == "" {
return "", fmt.Errorf("Tripo 任务响应缺少 status: %s", mustJSONStr(data))
}
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, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 Tripo 响应失败: %w", err)
}
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)
}
+313
View File
@@ -0,0 +1,313 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// ModelConfig 模型配置
type ModelConfig struct {
ModelName string // 对话模型名
APIKey string // API密钥
BaseURL string // API地址
MaxTokens int // 最大Token数
Temperature float32 // 温度参数
Timeout time.Duration // HTTP请求超时(0表示默认)
MaxRetries int // 最大重试次数(0表示默认3次)
}
// CallChatModel 调用大模型聊天接口(OpenAI 兼容格式)
func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*ChatResponse, error) {
if cfg == nil {
return nil, fmt.Errorf("model config cannot be empty")
}
if cfg.APIKey == "" {
return nil, fmt.Errorf("APIKey not configured")
}
if cfg.ModelName == "" {
return nil, fmt.Errorf("model name not configured")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("API address not configured")
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 300 * time.Second
}
body, err := buildReqBody(cfg.ModelName, req)
if err != nil {
return nil, err
}
url := trimSlashes(cfg.BaseURL)
var lastErr error
maxRetries := cfg.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
g.Log().Debugf(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=%d body_size=%d", cfg.ModelName, timeout, maxRetries, len(body))
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(1<<(attempt-1)) * time.Second
g.Log().Infof(ctx, "ChatAPI 重试第%d次(等待%v)", attempt, wait)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
result, doErr := doChatRequest(ctx, url, cfg.APIKey, body, timeout)
if doErr == nil {
g.Log().Debugf(ctx, "ChatAPI 调用成功 url=%s tool_calls=%d content_len=%d",
url, len(result.ToolCalls), len(result.Content))
return result, nil
}
lastErr = doErr
g.Log().Warningf(ctx, "ChatAPI request failed (attempt=%d/%d): %v", attempt+1, maxRetries+1, doErr)
// 只有限流或服务端错误才重试
errStr := lastErr.Error()
if !strings.Contains(errStr, "limit_requests") &&
!strings.Contains(errStr, "limit_tokens") &&
!strings.Contains(errStr, "500") &&
!strings.Contains(errStr, "502") &&
!strings.Contains(errStr, "503") {
break
}
}
g.Log().Errorf(ctx, "ChatAPI failed after %d retries: %v", maxRetries+1, lastErr)
return nil, lastErr
}
func doChatRequest(ctx context.Context, url, apiKey string, body []byte, timeout time.Duration) (*ChatResponse, error) {
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
start := time.Now()
client := &http.Client{Timeout: timeout}
resp, err := client.Do(httpReq)
elapsed := time.Since(start)
if err != nil {
return nil, fmt.Errorf("request failed (elapsed %v): %w", elapsed, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response failed (status=%d): %w", resp.StatusCode, err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API response error status=%d body=%s", resp.StatusCode, string(respBody))
}
g.Log().Debugf(ctx, "ChatAPI 响应完成 status=%d body_len=%d elapsed=%v",
resp.StatusCode, len(respBody), elapsed)
return parseRespBody(ctx, respBody)
}
// ==================== 内部实现 ====================
type apiReqBody struct {
Model string `json:"model"`
Messages []apiMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float32 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []apiToolDef `json:"tools,omitempty"`
}
// apiMessage 用于JSON序列化的消息体(适配OpenAI format
type apiMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []apiToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type apiToolDef struct {
Type string `json:"type"`
Function apiToolFunction `json:"function"`
}
type apiToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
type apiRespBody struct {
Choices []apiChoice `json:"choices"`
Error *struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"error,omitempty"`
}
type apiChoice struct {
Index int `json:"index"`
Message apiRespMsg `json:"message"`
FinishReason string `json:"finish_reason"`
}
// apiRespMsg 响应消息体(arguments 使用 json.RawMessage 兼容对象和字符串)
type apiRespMsg struct {
Content string `json:"content"`
ToolCalls []apiRespToolCall `json:"tool_calls,omitempty"`
}
type apiRespToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function apiRespFuncCall `json:"function"`
}
type apiRespFuncCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type apiToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function apiReqFuncCall `json:"function"`
}
// apiReqFuncCall 请求中的 function callarguments 为 json.RawMessage 避免二次编码)
type apiReqFuncCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
func buildReqBody(model string, req *ChatRequest) ([]byte, error) {
body := apiReqBody{
Model: model,
Messages: toAPIMessages(req.Messages),
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
Stream: req.Stream,
}
if len(req.Tools) > 0 {
body.Tools = make([]apiToolDef, 0, len(req.Tools))
for _, t := range req.Tools {
body.Tools = append(body.Tools, apiToolDef{
Type: "function",
Function: apiToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
}
return json.Marshal(body)
}
func toAPIMessages(msgs []*ChatMessage) []apiMessage {
out := make([]apiMessage, 0, len(msgs))
for _, m := range msgs {
om := apiMessage{
Role: m.Role,
Content: m.Content,
ToolCallID: m.ToolCallID,
Name: m.Name,
}
if len(m.ToolCalls) > 0 {
om.ToolCalls = make([]apiToolCall, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
args := tc.Arguments
if args == "" || !json.Valid([]byte(args)) {
args = "{}"
}
om.ToolCalls = append(om.ToolCalls, apiToolCall{
ID: tc.ID,
Type: "function",
Function: apiReqFuncCall{
Name: tc.Name,
Arguments: json.RawMessage(args),
},
})
}
}
out = append(out, om)
}
return out
}
func parseRespBody(ctx context.Context, data []byte) (*ChatResponse, error) {
var resp apiRespBody
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("parse response failed: %s", string(data))
}
if resp.Error != nil {
return nil, fmt.Errorf("API error(code=%s): %s", resp.Error.Code, resp.Error.Message)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("empty response")
}
msg := resp.Choices[0].Message
cr := &ChatResponse{Content: msg.Content}
// 检测 finish_reason 是否为 length(被 max_tokens 截断)
if resp.Choices[0].FinishReason == "length" {
g.Log().Warningf(ctx, "ChatAPI response truncated (finish_reason=length), content_len=%d, consider increasing max_tokens", len(msg.Content))
}
if len(msg.ToolCalls) > 0 {
cr.ToolCalls = make([]*ToolCall, 0, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
args := resolveArguments(tc.Function.Arguments)
cr.ToolCalls = append(cr.ToolCalls, &ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: args,
})
}
}
return cr, nil
}
// resolveArguments 将 json.RawMessage 的参数转为字符串
// API 可能返回 "arguments": "{\"key\":\"val\"}"(字符串)或 "arguments": {"key":"val"}(对象)
func resolveArguments(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
// 如果是 JSON 字符串(以 " 开头),直接提取字符串值
if raw[0] == '"' {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
}
// 否则是 JSON 对象,重新序列化回字符串
return string(raw)
}
func trimSlashes(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
+233
View File
@@ -0,0 +1,233 @@
package agent
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// 京东联盟适配器(电商类目)
// 接口以 api.jd.com 开放平台为准:jd.union.open.goods.query(选品)/ jd.union.open.promotion.common.get(转链)
type jdProvider struct{}
func (jdProvider) Source() string { return "jd_ecom" }
func (jdProvider) Enabled() bool {
ctx := context.Background()
return g.Cfg().MustGet(ctx, "cps.jd_appkey", "").String() != "" &&
g.Cfg().MustGet(ctx, "cps.jd_secret", "").String() != ""
}
func (jdProvider) apiBase(ctx context.Context) string {
return strings.TrimRight(g.Cfg().MustGet(ctx, "cps.jd_base",
"https://api.jd.com/routerjson").String(), "/")
}
// SyncProducts 商品选品(按类目)
func (p jdProvider) SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error) {
biz := map[string]any{
"goodsReqDTO": map[string]any{
"cid1": catCode,
"pageIndex": 1,
"pageSize": 20,
"eliteId": 1,
"sortName": "inOrderCount30Days",
"sort": "desc",
"fields": "skuId,skuName,imageUrl,priceInfo,shopName,commissionInfo,categoryInfo",
},
}
resp, err := p.doRequest(ctx, "jd.union.open.goods.query", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode)
}
// Search 关键词实时搜索
func (p jdProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error) {
biz := map[string]any{
"goodsReqDTO": map[string]any{
"keyword": keyword,
"pageIndex": page,
"pageSize": 20,
"sortName": "inOrderCount30Days",
"sort": "desc",
"fields": "skuId,skuName,imageUrl,priceInfo,shopName,commissionInfo,categoryInfo",
},
}
resp, err := p.doRequest(ctx, "jd.union.open.goods.query", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode)
}
// GetLink 转链(pid 归因)
func (p jdProvider) GetLink(ctx context.Context, outerId string) (string, error) {
biz := map[string]any{
"promotionCodeReq": map[string]any{
"materialId": "https://item.jd.com/" + outerId + ".html",
"siteId": g.Cfg().MustGet(ctx, "cps.jd_site_id", "").String(),
"positionId": g.Cfg().MustGet(ctx, "cps.jd_pid", "").String(),
"type": 1,
},
}
resp, err := p.doRequest(ctx, "jd.union.open.promotion.common.get", biz)
if err != nil {
return "", err
}
var d struct {
Result string `json:"jd_union_open_promotion_common_get_responce"`
}
if err := json.Unmarshal(resp, &d); err != nil {
return "", err
}
var inner struct {
Result []struct {
Data struct {
ClickURL string `json:"clickURL"`
} `json:"data"`
Code int `json:"code"`
Message string `json:"message"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(d.Result), &inner); err != nil {
return "", err
}
if len(inner.Result) == 0 || inner.Result[0].Data.ClickURL == "" {
return "", fmt.Errorf("京东转链失败: %s", firstResultMsg(inner.Result))
}
return inner.Result[0].Data.ClickURL, nil
}
func firstResultMsg(results []struct {
Data struct {
ClickURL string `json:"clickURL"`
} `json:"data"`
Code int `json:"code"`
Message string `json:"message"`
}) string {
if len(results) == 0 {
return "empty result"
}
return results[0].Message
}
// doRequest 京东签名请求(sign = MD5(secret + 参数键值排序拼接 + secret),大写)
func (p jdProvider) doRequest(ctx context.Context, method string, biz map[string]any) ([]byte, error) {
appKey := g.Cfg().MustGet(ctx, "cps.jd_appkey", "").String()
secret := g.Cfg().MustGet(ctx, "cps.jd_secret", "").String()
payload, err := json.Marshal(biz)
if err != nil {
return nil, err
}
params := map[string]string{
"method": method,
"app_key": appKey,
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
"format": "json",
"v": "1.0",
"sign_method": "md5",
"360buy_param_json": string(payload),
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
sb.WriteString(secret)
for _, k := range keys {
sb.WriteString(k)
sb.WriteString(params[k])
}
sb.WriteString(secret)
sum := md5.Sum([]byte(sb.String()))
params["sign"] = strings.ToUpper(hex.EncodeToString(sum[:]))
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
p.apiBase(ctx), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("京东接口 %s 返回 %d: %s", method, resp.StatusCode, string(body))
}
return body, nil
}
func (p jdProvider) parseProducts(body []byte, catCode string) ([]CpsProduct, error) {
var d struct {
Result string `json:"jd_union_open_goods_query_responce"`
}
if err := json.Unmarshal(body, &d); err != nil {
return nil, fmt.Errorf("京东选品响应解析失败: %v", err)
}
var inner struct {
Result []struct {
SkuID int64 `json:"skuId"`
SkuName string `json:"skuName"`
ImageURL string `json:"imageUrl"`
ShopName string `json:"shopName"`
PriceInfo struct {
Price float64 `json:"price"`
} `json:"priceInfo"`
CommissionInfo struct {
Commission float64 `json:"commission"`
} `json:"commissionInfo"`
CategoryInfo struct {
Cid1 int64 `json:"cid1"`
} `json:"categoryInfo"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(d.Result), &inner); err != nil {
return nil, err
}
out := make([]CpsProduct, 0, len(inner.Result))
for _, it := range inner.Result {
rate := 0
if it.PriceInfo.Price > 0 {
rate = int(it.CommissionInfo.Commission / it.PriceInfo.Price * 10000)
}
out = append(out, CpsProduct{
Source: p.Source(),
OuterId: strconv.FormatInt(it.SkuID, 10),
CategoryCode: catCode,
Name: it.SkuName,
CoverUrl: it.ImageURL,
PriceFen: int64(it.PriceInfo.Price * 100),
ShopName: it.ShopName,
CommissionRate: rate,
})
}
return out, nil
}
+177
View File
@@ -0,0 +1,177 @@
package agent
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// 美团联盟适配器(到店 OTA 类目:丽人/服装/餐厅/酒店/票务)
// 接口以 union.meituan.com 开放平台为准:选品(商品/POI 搜索)+ 转链(生成带 pid 的推广链接)
type meituanProvider struct{}
func (meituanProvider) Source() string { return "meituan_ota" }
func (meituanProvider) Enabled() bool {
ctx := context.Background()
return g.Cfg().MustGet(ctx, "cps.meituan_appkey", "").String() != "" &&
g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String() != ""
}
func (meituanProvider) apiBase(ctx context.Context) string {
return strings.TrimRight(g.Cfg().MustGet(ctx, "cps.meituan_base",
"https://openapi.meituan.com").String(), "/")
}
// SyncProducts 到店 POI/商品选品(按类目 + 城市)
func (p meituanProvider) SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error) {
biz := map[string]any{
"cityName": city,
"categoryId": catCode,
"pageNo": 1,
"pageSize": 50,
"isActivity": 0,
"promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(),
}
resp, err := p.doRequest(ctx, "union/search", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode, city)
}
// Search 实时搜索兜底
func (p meituanProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error) {
biz := map[string]any{
"keyword": keyword,
"categoryId": catCode,
"pageNo": page,
"pageSize": 20,
"promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(),
}
resp, err := p.doRequest(ctx, "union/search", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode, "")
}
// GetLink 转链(pid 归因)
func (p meituanProvider) GetLink(ctx context.Context, outerId string) (string, error) {
biz := map[string]any{
"poiId": outerId,
"promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(),
}
resp, err := p.doRequest(ctx, "union/link", biz)
if err != nil {
return "", err
}
var d struct {
Data struct {
Link string `json:"link"`
} `json:"data"`
}
if err := json.Unmarshal(resp, &d); err != nil {
return "", err
}
if d.Data.Link == "" {
return "", fmt.Errorf("美团转链返回空")
}
return d.Data.Link, nil
}
// doRequest 美团联盟签名请求(sign = MD5(appkey + secret + ts)
func (p meituanProvider) doRequest(ctx context.Context, path string, biz map[string]any) ([]byte, error) {
appKey := g.Cfg().MustGet(ctx, "cps.meituan_appkey", "").String()
secret := g.Cfg().MustGet(ctx, "cps.meituan_secret", "").String()
ts := fmt.Sprint(time.Now().Unix())
payload, err := json.Marshal(biz)
if err != nil {
return nil, err
}
h := md5.New()
io.WriteString(h, appKey+secret+ts)
sign := hex.EncodeToString(h.Sum(nil))
form := url.Values{}
form.Set("appkey", appKey)
form.Set("ts", ts)
form.Set("sign", sign)
form.Set("biz", string(payload))
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
p.apiBase(ctx)+"/"+path, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("美团接口 %s 返回 %d: %s", path, resp.StatusCode, string(body))
}
return body, nil
}
func (p meituanProvider) parseProducts(body []byte, catCode, city string) ([]CpsProduct, error) {
var d struct {
Data struct {
List []struct {
PoiId string `json:"poiId"`
Title string `json:"title"`
ImageUrl string `json:"imageUrl"`
LowPrice string `json:"lowPrice"`
ShopName string `json:"shopName"`
CpsCoupon int `json:"cpsCoupon"`
Category string `json:"category"`
} `json:"list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &d); err != nil {
return nil, fmt.Errorf("美团选品响应解析失败: %v", err)
}
out := make([]CpsProduct, 0, len(d.Data.List))
for _, it := range d.Data.List {
price := parseFen(it.LowPrice)
out = append(out, CpsProduct{
Source: p.Source(),
OuterId: it.PoiId,
CategoryCode: catCode,
Name: it.Title,
CoverUrl: it.ImageUrl,
PriceFen: price,
ShopName: it.ShopName,
CommissionRate: it.CpsCoupon,
City: city,
})
}
return out, nil
}
// parseFen 金额字符串(元)→ 分
func parseFen(amount string) int64 {
f, err := strconv.ParseFloat(amount, 64)
if err != nil {
return 0
}
return int64(f * 100)
}
+208
View File
@@ -0,0 +1,208 @@
package agent
import (
"context"
"crypto/hmac"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// 淘宝联盟适配器(电商类目)
// 接口以 eco.taobao.com TOP 开放平台为准:taobao.tbk.dg.material.optional(选品)/ taobao.tbk.tpwd.create(淘口令转链)
type tbProvider struct{}
func (tbProvider) Source() string { return "tb_ecom" }
func (tbProvider) Enabled() bool {
ctx := context.Background()
return g.Cfg().MustGet(ctx, "cps.tb_appkey", "").String() != "" &&
g.Cfg().MustGet(ctx, "cps.tb_secret", "").String() != "" &&
g.Cfg().MustGet(ctx, "cps.tb_pid", "").String() != ""
}
func (tbProvider) apiBase(ctx context.Context) string {
return strings.TrimRight(g.Cfg().MustGet(ctx, "cps.tb_base",
"https://eco.taobao.com/router/rest").String(), "/")
}
// SyncProducts 选品(按类目,cat 传淘宝叶子类目 ID)
func (p tbProvider) SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error) {
biz := map[string]any{
"adzone_id": g.Cfg().MustGet(ctx, "cps.tb_adzone_id", "").String(),
"cat": catCode,
"page_no": 1,
"page_size": 20,
"sort": "total_sales_des",
}
resp, err := p.doRequest(ctx, "taobao.tbk.dg.material.optional", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode)
}
// Search 关键词实时搜索
func (p tbProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error) {
biz := map[string]any{
"adzone_id": g.Cfg().MustGet(ctx, "cps.tb_adzone_id", "").String(),
"q": keyword,
"page_no": page,
"page_size": 20,
"sort": "total_sales_des",
}
resp, err := p.doRequest(ctx, "taobao.tbk.dg.material.optional", biz)
if err != nil {
return nil, err
}
return p.parseProducts(resp, catCode)
}
// GetLink 淘口令转链(pid 归因;返回口令文本,客户端复制跳转)
func (p tbProvider) GetLink(ctx context.Context, outerId string) (string, error) {
biz := map[string]any{
"text": "好物分享",
"url": "https://item.taobao.com/item.htm?id=" + outerId,
"user_id": g.Cfg().MustGet(ctx, "cps.tb_pid", "").String(),
}
resp, err := p.doRequest(ctx, "taobao.tbk.tpwd.create", biz)
if err != nil {
return "", err
}
var d struct {
Data struct {
Model string `json:"model"`
} `json:"data"`
}
if err := json.Unmarshal(resp, &d); err != nil {
return "", err
}
if d.Data.Model == "" {
return "", fmt.Errorf("淘宝转链返回空")
}
return d.Data.Model, nil
}
// doRequest 淘宝 TOP 签名请求(sign = HMAC-MD5(参数键排序拼接, secret),大写)
// 公共参数与业务参数统一排序拼接,sign_method=hmac
func (p tbProvider) doRequest(ctx context.Context, method string, biz map[string]any) ([]byte, error) {
appKey := g.Cfg().MustGet(ctx, "cps.tb_appkey", "").String()
secret := g.Cfg().MustGet(ctx, "cps.tb_secret", "").String()
params := map[string]string{
"method": method,
"app_key": appKey,
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
"format": "json",
"v": "2.0",
"sign_method": "hmac",
}
for k, v := range biz {
params[k] = fmt.Sprint(v)
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
sb.WriteString(k)
sb.WriteString(params[k])
}
mac := hmac.New(md5.New, []byte(secret))
mac.Write([]byte(sb.String()))
params["sign"] = strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
p.apiBase(ctx), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("淘宝接口 %s 返回 %d: %s", method, resp.StatusCode, string(body))
}
// TOP 错误响应 {error_response:{code,msg}} 以 HTTP 200 返回,必须显式拦截
var er struct {
ErrorResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
} `json:"error_response"`
}
if json.Unmarshal(body, &er) == nil && er.ErrorResponse.Code != 0 {
return nil, fmt.Errorf("淘宝接口 %s 错误 %d: %s", method, er.ErrorResponse.Code, er.ErrorResponse.Msg)
}
return body, nil
}
func (p tbProvider) parseProducts(body []byte, catCode string) ([]CpsProduct, error) {
var d struct {
Resp struct {
ResultList struct {
MapData []struct {
NumIID int64 `json:"num_iid"`
Title string `json:"title"`
PictURL string `json:"pict_url"`
ZkFinalPrice string `json:"zk_final_price"`
ShopTitle string `json:"shop_title"`
CommissionRate string `json:"commission_rate"`
} `json:"map_data"`
} `json:"result_list"`
} `json:"tbk_dg_material_optional_response"`
}
if err := json.Unmarshal(body, &d); err != nil {
return nil, fmt.Errorf("淘宝选品响应解析失败: %v", err)
}
out := make([]CpsProduct, 0, len(d.Resp.ResultList.MapData))
for _, it := range d.Resp.ResultList.MapData {
// commission_rate 是百分比字符串(如 "3.5" = 3.5%),转万分比
rate := parseRateWanfen(it.CommissionRate)
out = append(out, CpsProduct{
Source: p.Source(),
OuterId: strconv.FormatInt(it.NumIID, 10),
CategoryCode: catCode,
Name: it.Title,
CoverUrl: it.PictURL,
PriceFen: parseFen(it.ZkFinalPrice),
ShopName: it.ShopTitle,
CommissionRate: rate,
})
}
return out, nil
}
// parseRateWanfen 佣金百分比字符串("3.5" 表示 3.5%)→ 万分比(350)
func parseRateWanfen(percent string) int {
f, err := strconv.ParseFloat(strings.TrimSpace(percent), 64)
if err != nil {
return 0
}
return int(f * 100)
}
+34
View File
@@ -0,0 +1,34 @@
package agent
import "context"
// CpsProduct 联盟商品统一结构(各联盟适配器归一化后返回)
type CpsProduct struct {
Source string
OuterId string
CategoryCode string
Name string
CoverUrl string
PriceFen int64
ShopName string
CommissionRate int // 万分比
City string
SceneTags []string
Raw string
}
// CpsProvider 联盟数据源适配器接口
type Provider interface {
Source() string // meituan_ota | jd_ecom | tb_ecom
Enabled() bool // 未配置 key → false(优雅降级)
SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error)
Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error)
GetLink(ctx context.Context, outerId string) (string, error) // 转链(带 pid 归因)
}
// CpsProviders 联盟注册表(仅 Enabled 的入 service 注册表)
var CpsProviders = []Provider{
meituanProvider{},
jdProvider{},
tbProvider{},
}
+21
View File
@@ -0,0 +1,21 @@
package agent
import "time"
// 效果图 URL 缓存(key: 方案内容 hash:角度,24h TTL,复用泛型 TTL 缓存)
var effectCache = NewTTLCache(24 * time.Hour)
// CacheGet 读取缓存 URL
func CacheGet(key string) (string, bool) {
v, ok := effectCache.Get(key)
if !ok {
return "", false
}
s, _ := v.(string)
return s, s != ""
}
// CacheSet 写入缓存 URL
func CacheSet(key, url string) {
effectCache.Set(key, url)
}
@@ -0,0 +1,45 @@
package agent
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
// ImageGenClient 图像生成客户端(真实调用,无 mock)
type ImageGenClient interface {
// Generate 生成单张图片,返回图片 URL(可传 BaseImageURL 做图生图,为空则文生图)
Generate(ctx context.Context, req *GenerateReq) (string, error)
}
// GenerateReq 生成请求
type GenerateReq struct {
BaseImageURL string // 用户全身照(本地 /workspace 路径或 http(s) URL,空为文生图)
Prompt string // 方案描述
Angle string // 正面/侧面/背面
Seed int64
}
// 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", "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 nil, fmt.Errorf("imagegen 供应商不支持:%s(当前仅支持 wanx)", supplier)
}
// buildPrompt 组装图片生成提示词
func buildPrompt(planDesc, hairstyle, hairColor, angle string) string {
return fmt.Sprintf("时尚穿搭效果图,%s;发型:%s(发色 %s);角度:%s;人物写实、高清、全身、纯色背景",
planDesc, hairstyle, hairColor, angle)
}
@@ -0,0 +1,191 @@
package agent
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// wanxClient 通义万相图像生成(wan2.7-image-proimage-generation 异步接口 + 轮询)
type wanxClient struct {
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"`
}
type wanxInput struct {
Messages []wanxMessage `json:"messages"`
}
type wanxMessage struct {
Role string `json:"role"`
Content []wanxInputContent `json:"content"`
}
// 请求侧 content 元素:wan2.7-image-pro 原生格式直接放 text / image 字段,
// 不能用 OpenAI 兼容的 {"type":"image_url"} 形式(会报 "Either 'text' or 'image' must be provided, but not both"
type wanxInputContent struct {
Text string `json:"text,omitempty"`
Image string `json:"image,omitempty"`
}
// 响应侧 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"`
Message string `json:"message"`
Code string `json:"code"`
Choices []struct {
Message struct {
Content []wanxContent `json:"content"`
} `json:"message"`
} `json:"choices"`
} `json:"output"`
}
// Generate 文生图或图生图(BaseImageURL 本地路径转 data URIhttp(s) 直传),异步任务 + 轮询
// 注:wan2.7-image-pro 图生图需同一条 user 消息中并列 {"text"} 与 {"image"} 两个 content 元素
func (c *wanxClient) Generate(ctx context.Context, req *GenerateReq) (string, error) {
content := []wanxInputContent{{Text: buildPrompt(req.Prompt, "", "", req.Angle)}}
if req.BaseImageURL != "" {
imgURL, err := resolveImageURL(req.BaseImageURL)
if err != nil {
return "", err
}
content = append(content, wanxInputContent{Image: imgURL})
}
messages := []wanxMessage{{Role: "user", Content: content}}
body, err := json.Marshal(wanxSubmitReq{
Model: c.model,
Input: wanxInput{Messages: messages},
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
}
url, err := c.poll(ctx, taskID)
if err != nil {
return "", err
}
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 {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-DashScope-Async", "enable")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取万相提交响应失败: %w", err)
}
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))
}
if r.Output.TaskID == "" {
return "", fmt.Errorf("万相提交失败 code=%s msg=%s", r.Code, r.Message)
}
return r.Output.TaskID, nil
}
func (c *wanxClient) poll(ctx context.Context, taskID string) (string, error) {
client := &http.Client{Timeout: 30 * time.Second}
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", c.taskBase+"/"+taskID, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := client.Do(req)
if err != nil {
return "", err
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return "", fmt.Errorf("读取万相任务响应失败: %w", err)
}
var r wanxTaskResp
if err := json.Unmarshal(data, &r); err != nil {
return "", fmt.Errorf("万相任务查询解析失败: %s", string(data))
}
switch r.Output.TaskStatus {
case "SUCCEEDED":
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.Output.Message)
}
}
return "", fmt.Errorf("万相任务超时")
}
+56
View File
@@ -0,0 +1,56 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
// CandidateData 预筛候选服装(供 LLM 选择组合)
type CandidateData struct {
SetId int64 `json:"set_id"` // 所属预筛组合编号
ItemId int64 `json:"item_id"` // 衣橱条目 id
Category string `json:"category"`
Name string `json:"name"`
Color string `json:"color"`
Season string `json:"season"`
Style string `json:"style"`
}
// PlanOutfits 规则预筛候选 → LLM 润色规划(1 次调用)
func PlanOutfits(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string, candidates []CandidateData) (*PlanOutput, error) {
candJSON, err := json.Marshal(candidates)
if err != nil {
return nil, fmt.Errorf("候选序列化失败: %w", err)
}
msg := userInput + "\n候选服装(JSON,set_id 表示第几套预选,请在同一套内选择):" + string(candJSON)
return callPlan(ctx, cfg, sysPrompt, msg)
}
// CreateRecommendPlan 兜底创作(全低分时调用,1 次调用)
func CreateRecommendPlan(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string) (*PlanOutput, error) {
return callPlan(ctx, cfg, sysPrompt, userInput)
}
func callPlan(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string) (*PlanOutput, error) {
req := &ChatRequest{
Messages: []*ChatMessage{
{Role: RoleSystem, Content: sysPrompt},
{Role: RoleUser, Content: userInput},
},
MaxTokens: cfg.MaxTokens,
Temperature: cfg.Temperature,
}
resp, err := CallChatModel(ctx, cfg, req)
if err != nil {
return nil, err
}
out, err := ParsePlanOutput(resp.Content)
if err != nil {
g.Log().Warningf(ctx, "LLM 方案输出解析失败: %v\n原始输出: %s", err, resp.Content)
return nil, err
}
return out, nil
}
+71
View File
@@ -0,0 +1,71 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
)
// PlanOutput 大模型输出的穿搭方案集合
type PlanOutput struct {
Plans []PlanCandidate `json:"plans"`
}
// PlanCandidate 一套穿搭方案
type PlanCandidate struct {
Title string `json:"title"`
Hairstyle string `json:"hairstyle"` // 发型名称(匹配资产库)
HairColor string `json:"hair_color"` // 如 #A0522D
Items []PlanItemOut `json:"items"`
}
// PlanItemOut 方案内一件单品
type PlanItemOut struct {
Slot string `json:"slot"` // 上衣/下装/鞋/配饰
ItemId int64 `json:"item_id,omitempty"` // 衣橱条目(wardrobe 来源)
Name string `json:"name"` // 单品名
Desc string `json:"desc"` // 搭配说明
NewItem bool `json:"new_item"` // 是否为推荐新服装
}
// ParsePlanOutput 解析并校验 LLM 输出(去除 markdown 代码围栏后 json.Unmarshal
func ParsePlanOutput(raw string) (*PlanOutput, error) {
text := strings.TrimSpace(raw)
// 容忍 ```json ... ``` 代码围栏
if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```")
if idx := strings.Index(text, "\n"); idx >= 0 {
text = text[idx+1:]
}
text = strings.TrimSuffix(strings.TrimSpace(text), "```")
}
var out PlanOutput
if err := json.Unmarshal([]byte(text), &out); err != nil {
return nil, fmt.Errorf("方案 JSON 解析失败: %w", err)
}
if len(out.Plans) == 0 {
return nil, fmt.Errorf("方案输出为空(plans 缺失)")
}
for i, p := range out.Plans {
if strings.TrimSpace(p.Title) == "" {
return nil, fmt.Errorf("方案 %d 缺少 title", i+1)
}
if len(p.Items) == 0 {
return nil, fmt.Errorf("方案 %d 缺少 items", i+1)
}
for _, it := range p.Items {
if !isValidSlot(it.Slot) {
return nil, fmt.Errorf("方案 %d 含非法 slot: %s", i+1, it.Slot)
}
}
}
return &out, nil
}
func isValidSlot(slot string) bool {
switch slot {
case "上衣", "下装", "鞋", "配饰":
return true
}
return false
}
+44
View File
@@ -0,0 +1,44 @@
package agent
import "testing"
func TestParsePlanOutput_Valid(t *testing.T) {
raw := `{"plans":[{"title":"通勤清爽","hairstyle":"清爽短发","hair_color":"#2B2B2B","items":[{"slot":"上衣","item_id":1,"name":"白衬衫","desc":"正式","new_item":false},{"slot":"鞋","item_id":3,"name":"小白鞋","desc":"百搭","new_item":false}]}]}`
out, err := ParsePlanOutput(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Plans) != 1 || out.Plans[0].Title != "通勤清爽" {
t.Fatalf("wrong parse result: %+v", out.Plans)
}
}
func TestParsePlanOutput_CodeFence(t *testing.T) {
raw := "```json\n{\"plans\":[{\"title\":\"周末约会\",\"hairstyle\":\"波浪卷发\",\"hair_color\":\"#8B4513\",\"items\":[{\"slot\":\"下装\",\"item_id\":0,\"name\":\"A字裙\",\"desc\":\"飘逸\",\"new_item\":true}]}]}\n```"
out, err := ParsePlanOutput(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Plans) != 1 || !out.Plans[0].Items[0].NewItem {
t.Fatalf("wrong parse result: %+v", out.Plans)
}
}
func TestParsePlanOutput_MissingPlans(t *testing.T) {
if _, err := ParsePlanOutput(`{"plans":[]}`); err == nil {
t.Fatal("expected error for empty plans")
}
}
func TestParsePlanOutput_InvalidJSON(t *testing.T) {
if _, err := ParsePlanOutput(`not json`); err == nil {
t.Fatal("expected error for invalid json")
}
}
func TestParsePlanOutput_InvalidSlot(t *testing.T) {
raw := `{"plans":[{"title":"x","hairstyle":"y","hair_color":"#fff","items":[{"slot":"帽子","item_id":1,"name":"a","desc":"b","new_item":false}]}]}`
if _, err := ParsePlanOutput(raw); err == nil {
t.Fatal("expected error for invalid slot")
}
}
+39
View File
@@ -0,0 +1,39 @@
package agent
// SystemPromptPlan 穿搭规划系统提示词
func SystemPromptPlan() string {
return planSystemPrompt
}
const planSystemPrompt = `你是一位资深穿搭顾问与形象设计师,为用户的服装搭配与发型设计提供方案。
你的任务:根据用户输入(天气、场合、候选服装、身形)输出穿搭方案。
输出要求:
1. 只输出一个 JSON 对象,不要输出任何解释文字、前后缀或 markdown 代码围栏。
2. JSON 结构:
{"plans":[{"title":"方案名称","hairstyle":"发型名称","hair_color":"#十六进制色值","items":[{"slot":"上衣|下装|鞋|配饰","item_id":0,"name":"单品名称","desc":"搭配理由(不超过20字)","new_item":false}]}]}
3. slot 只能是:上衣/下装/鞋/配饰,每套方案 3-5 件单品。
4. 候选服装通过 item_id 引用:引用已有服装时 item_id 必须等于候选中的 id 且 new_item=false;确需新推荐的服装 item_id=0 且 new_item=true(最多 1 件)。
5. hairstyle 从候选发型列表中选一个最匹配的名称;hair_color 给出与该发色对应的十六进制颜色。
6. 充分考虑天气冷暖、场合正式程度与色彩协调。`
// BuildPlanUserInput 组装规划用 user 消息
func BuildPlanUserInput(weatherDesc, occasion string, candidates string, hairstyles string, bodyDesc string) string {
return "天气与日期:" + weatherDesc +
"\n场合:" + occasion +
"\n用户身形:" + bodyDesc +
"\n候选服装(JSON):" + candidates +
"\n可用发型(名称,风格):" + hairstyles +
"\n请输出 3 套方案。"
}
// BuildFallbackUserInput 组装兜底创作用 user 消息(全低分时)
func BuildFallbackUserInput(weatherDesc, occasion string, wardrobe string, hairstyles string, bodyDesc string) string {
return "天气与日期:" + weatherDesc +
"\n场合:" + occasion +
"\n用户身形:" + bodyDesc +
"\n用户已有服装(JSON,可选用):" + wardrobe +
"\n可用发型(名称,风格):" + hairstyles +
"\n已有服装搭配效果不佳,请重新设计 3 套高分方案(可新推荐服装,每套最多 2 件 new_item)。"
}
+85
View File
@@ -0,0 +1,85 @@
package agent
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
const (
renderFramesCount = 36
renderFrameSize = "256x512"
renderTimeout = 15 * time.Minute
)
// RenderAvatarFrames 将化身 GLB 预渲染为绕 Y 轴旋转帧序列,返回帧目录访问 URL。
// 帧目录已就绪直接复用;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 渲染服务未启用")
}
dir := filepath.Join("workspace", "avatar_frames", outKey)
if framesReady(dir) {
return "/workspace/avatar_frames/" + outKey, nil
}
if _, err := os.Stat(glb); err != nil {
return "", fmt.Errorf("化身 GLB 不存在: %w", err)
}
if err := runRender(ctx, glb, dir); err != nil {
return "", err
}
return "/workspace/avatar_frames/" + outKey, nil
}
func framesReady(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
count := 0
for _, e := range entries {
if strings.HasPrefix(e.Name(), "frame_") && strings.HasSuffix(e.Name(), ".png") {
count++
}
}
return count >= renderFramesCount
}
func nodeBin(ctx context.Context) string {
bin := g.Cfg().MustGet(ctx, "render.node_bin", "node").String()
if bin == "" {
return "node"
}
// 配置路径不存在(如容器环境)→ 回退 PATH 中的 node
if _, err := os.Stat(bin); err != nil {
return "node"
}
return bin
}
func runRender(ctx context.Context, glb, out string) error {
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
return execNode(ctx, filepath.Join("scripts", "avatar-render", "render.js"),
"--glb", glb, "--out", out,
"--frames", fmt.Sprint(renderFramesCount), "--size", renderFrameSize)
}
func execNode(ctx context.Context, script string, args ...string) error {
cmdCtx, cancel := context.WithTimeout(ctx, renderTimeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, nodeBin(ctx), append([]string{script}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("node 渲染失败: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
@@ -0,0 +1,106 @@
package agent
import (
"strconv"
"strings"
)
// colorScore 色彩和谐(20 分制):色相环角度差评估
// 同色系(≤30°) 20;邻近(≤60°) 15;对比(≤150°) 8;冲突 3
func colorScore(o CandidateOutfit) int {
var hues []int
for _, it := range o.Items {
h, ok := parseHue(it.ColorInfo)
if ok {
hues = append(hues, h)
}
}
if len(hues) < 2 {
return 12 // 无色彩信息给中性分
}
total := 0
pairs := 0
for i := 0; i < len(hues); i++ {
for j := i + 1; j < len(hues); j++ {
diff := hueDiff(hues[i], hues[j])
switch {
case diff <= 30:
total += 20
case diff <= 60:
total += 15
case diff <= 150:
total += 8
default:
total += 3
}
pairs++
}
}
return total / pairs
}
// parseHue 解析 #RRGGBB 或中文色名 → 色相角(0-360)
func parseHue(color string) (int, bool) {
c := strings.TrimSpace(color)
if strings.HasPrefix(c, "#") && len(c) == 7 {
r, e1 := strconv.ParseInt(c[1:3], 16, 32)
gg, e2 := strconv.ParseInt(c[3:5], 16, 32)
b, e3 := strconv.ParseInt(c[5:7], 16, 32)
if e1 == nil && e2 == nil && e3 == nil {
return rgbToHue(float64(r), float64(gg), float64(b)), true
}
}
named := map[string]int{
"红": 0, "橙": 30, "黄": 60, "绿": 120, "青": 180, "蓝": 240, "紫": 280,
"粉": 340, "黑": 360, "白": 360, "灰": 360, "棕": 25, "卡其": 45, "牛仔": 220,
}
for name, h := range named {
if strings.Contains(c, name) {
return h, true
}
}
return 0, false
}
func rgbToHue(r, g, b float64) int {
max, min := r, g
if g > max {
max = g
}
if b > max {
max = b
}
if g < min {
min = g
}
if b < min {
min = b
}
if max == min {
return 0
}
var h float64
switch max {
case r:
h = 60 * (g - b) / (max - min)
case g:
h = 60*(b-r)/(max-min) + 120
default:
h = 60*(r-g)/(max-min) + 240
}
if h < 0 {
h += 360
}
return int(h)
}
func hueDiff(a, b int) int {
d := a - b
if d < 0 {
d = -d
}
if d > 180 {
d = 360 - d
}
return d
}
@@ -0,0 +1,72 @@
package agent
// completenessScore 层次完整度(20 分制):上衣+5 下装+5 鞋+5 配饰+5
func completenessScore(o CandidateOutfit) int {
score := 0
for _, it := range o.Items {
switch it.Category {
case "上衣":
score += 5
case "下装":
score += 5
case "鞋":
score += 5
case "配饰":
score += 5
}
}
if o.HasOuterwear {
score += 2
}
if score > 20 {
return 20
}
return score
}
// styleScore 风格一致性(10 分制):命中用户偏好标签每项 +2
func styleScore(o CandidateOutfit, ctx ScoreContext) int {
if len(ctx.StyleTags) == 0 {
return 5
}
score := 0
for _, it := range o.Items {
for _, tag := range ctx.StyleTags {
if tag != "" && it.StyleTags != "" && containsTag(it.StyleTags, tag) {
score += 2
}
}
}
if score > 10 {
return 10
}
return score
}
func containsTag(tags, tag string) bool {
for _, t := range splitTags(tags) {
if t == tag {
return true
}
}
return false
}
func splitTags(s string) []string {
var out []string
cur := ""
for _, c := range s {
if c == ',' || c == '' || c == ' ' {
if cur != "" {
out = append(out, cur)
cur = ""
}
continue
}
cur += string(c)
}
if cur != "" {
out = append(out, cur)
}
return out
}
+12
View File
@@ -0,0 +1,12 @@
package agent
// Score 总分(100 分制)
func Score(c *CandidateOutfit, ctx *ScoreContext) int {
return weatherScore(*c, *ctx) + occasionScore(*c, *ctx) + colorScore(*c) +
completenessScore(*c) + styleScore(*c, *ctx)
}
// IsPass 是否达到阈值
func IsPass(score, threshold int) bool {
return score >= threshold
}
@@ -0,0 +1,29 @@
package agent
import "strings"
// occasionScore 场合匹配(25 分制):基础 15 + 场合类别匹配项 +5
var occasionCategory = map[string][]string{
"通勤": {"西装", "衬衫", "休闲", "通勤"},
"约会": {"裙装", "连衣裙", "优雅", "约会", "浪漫"},
"聚会": {"潮流", "时尚", "个性", "派对"},
"运动": {"运动", "休闲", "T恤", "卫衣"},
}
func occasionScore(o CandidateOutfit, ctx ScoreContext) int {
score := 15
allowed := occasionCategory[ctx.Occasion]
for _, it := range o.Items {
tags := it.StyleTags
for _, a := range allowed {
if a != "" && strings.Contains(tags, a) {
score += 5
break
}
}
}
if score > 25 {
return 25
}
return score
}
+28
View File
@@ -0,0 +1,28 @@
package agent
// NewItemBaseScore 推荐新品(用户衣橱不具备)的单品基础分,
// 无衣橱属性(颜色/风格)无法走规则评分,直接给固定分值并入方案总分
const NewItemBaseScore = 15
// WardrobeItem 评分用服装条目(从衣橱 entity 转换)
type WardrobeItem struct {
Category string // 上衣/下装/鞋/配饰
Season string // 春/夏/秋/冬/四季
ColorInfo string // 如 #RRGGBB
StyleTags string
}
// CandidateOutfit 候选组合
type CandidateOutfit struct {
Items []WardrobeItem
HasOuterwear bool
}
// ScoreContext 评分上下文
type ScoreContext struct {
TempAvg int // 日期范围平均温度℃
Season string // 春/夏/秋/冬
Occasion string // 通勤/约会/聚会/运动
Weekday string // workday/weekend/holiday
StyleTags []string // 用户偏好标签
}
@@ -0,0 +1,36 @@
package agent
// weatherScore 天气适宜度(25 分制)
// 温度匹配每件服装季节 +5;<10℃ 无外套 -10>30℃ 有外套 -8
func weatherScore(o CandidateOutfit, ctx ScoreContext) int {
score := 0
for _, it := range o.Items {
switch {
case ctx.TempAvg >= 28 && it.Season == "夏":
score += 5
case ctx.TempAvg >= 18 && ctx.TempAvg < 28 && it.Season == "春":
score += 5
case ctx.TempAvg >= 18 && ctx.TempAvg < 28 && it.Season == "秋":
score += 5
case ctx.TempAvg < 18 && it.Season == "冬":
score += 5
case it.Season == "四季":
score += 4
default:
score += 2
}
}
if ctx.TempAvg < 10 && !o.HasOuterwear {
score -= 10
}
if ctx.TempAvg > 30 && o.HasOuterwear {
score -= 8
}
if score < 0 {
return 0
}
if score > 25 {
return 25
}
return score
}
+55
View File
@@ -0,0 +1,55 @@
package agent
import "context"
// ==================== 工具 ====================
// ToolInfo 工具定义(包含执行函数)
type ToolInfo struct {
Name string
Description string
Parameters map[string]any
Func func(ctx context.Context, args map[string]any) (string, error)
}
// ToolCall 模型请求的工具调用
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ==================== 聊天消息 ====================
// ChatMessage 对话消息
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []*ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
// ChatRequest 聊天请求
type ChatRequest struct {
Messages []*ChatMessage
MaxTokens int
Temperature float32
Stream bool
Tools []*ToolInfo
}
// ChatResponse 聊天响应
type ChatResponse struct {
Content string
ToolCalls []*ToolCall
}
// ==================== 角色常量 ====================
const (
RoleSystem = "system"
RoleUser = "user"
RoleAssistant = "assistant"
RoleTool = "tool"
)
+42
View File
@@ -0,0 +1,42 @@
package agent
import (
"sync"
"time"
)
// 通用 TTL 缓存:天气结果(*WeatherResult)、CPS 转链(string)等均可复用
type cacheEntry struct {
data any
expiresAt time.Time
}
type Cache struct {
mu sync.Mutex
ttl time.Duration
items map[string]cacheEntry
}
func NewTTLCache(ttl time.Duration) *Cache {
return &Cache{ttl: ttl, items: make(map[string]cacheEntry)}
}
func (c *Cache) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.items[key]
if !ok {
return nil, false
}
if time.Now().After(e.expiresAt) {
delete(c.items, key)
return nil, false
}
return e.data, true
}
func (c *Cache) Set(key string, data any) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = cacheEntry{data: data, expiresAt: time.Now().Add(c.ttl)}
}
+54
View File
@@ -0,0 +1,54 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/gogf/gf/v2/frame/g"
)
type amapResp struct {
Status string `json:"status"`
Geocodes []struct {
Adcode string `json:"adcode"`
} `json:"geocodes"`
}
// GetCityCode 高德地理编码:地点 → 城市 adcode(和风 location 参数)
// 失败时返回原始 location(降级:和风不支持则报错由上层处理)
func GetCityCode(ctx context.Context, location string) (string, error) {
key := g.Cfg().MustGet(ctx, "geo.amap_key", "").String()
if key == "" {
return "", fmt.Errorf("高德地理编码 Key 未配置 (geo.amap_key)")
}
base := g.Cfg().MustGet(ctx, "geo.amap_base", "https://restapi.amap.com").String()
u := fmt.Sprintf("%s/v3/geocode/geo?address=%s&key=%s",
base, url.QueryEscape(location), key)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return "", err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("高德地理编码失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var ar amapResp
if err := json.Unmarshal(body, &ar); err != nil {
return "", fmt.Errorf("高德响应解析失败: %w", err)
}
if ar.Status != "1" || len(ar.Geocodes) == 0 || ar.Geocodes[0].Adcode == "" {
return "", fmt.Errorf("无法定位地点: %s", location)
}
return ar.Geocodes[0].Adcode, nil
}
+116
View File
@@ -0,0 +1,116 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/gogf/gf/v2/frame/g"
)
type DayWeather struct {
Date string `json:"date"`
TempMax int `json:"temp_max"`
TempMin int `json:"temp_min"`
TextDay string `json:"text_day"`
}
type WeatherResult struct {
CityCode string `json:"city_code"`
Days []DayWeather `json:"days"`
// AvgTemp 日期范围平均温度(评分用)
AvgTemp int `json:"avg_temp"`
// Season 按平均温度推断季节
Season string `json:"season"`
}
type qweatherDaily struct {
FxDate string `json:"fxDate"`
TempMax string `json:"tempMax"`
TempMin string `json:"tempMin"`
TextDay string `json:"textDay"`
}
type qweatherResp struct {
Code string `json:"code"`
Update string `json:"updateTime"`
Daily []qweatherDaily `json:"daily"`
}
// GetDaily 调用和风 7 天预报(v7),按日期范围过滤
func GetDaily(ctx context.Context, cityCode, startDate, endDate string) (*WeatherResult, error) {
key := g.Cfg().MustGet(ctx, "weather.qweather_key", "").String()
if key == "" {
return nil, fmt.Errorf("和风天气 API Key 未配置 (weather.qweather_key)")
}
base := g.Cfg().MustGet(ctx, "weather.qweather_base", "https://devapi.qweather.com").String()
url := fmt.Sprintf("%s/v7/weather/7d?location=%s&key=%s", base, cityCode, key)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("和风天气请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var qr qweatherResp
if err := json.Unmarshal(body, &qr); err != nil {
return nil, fmt.Errorf("和风天气响应解析失败: %w", err)
}
if qr.Code != "200" {
return nil, fmt.Errorf("和风天气错误码: %s", qr.Code)
}
result := &WeatherResult{CityCode: cityCode}
total := 0
count := 0
for _, d := range qr.Daily {
if d.FxDate < startDate || d.FxDate > endDate {
continue
}
maxV, err := strconv.Atoi(d.TempMax)
if err != nil {
return nil, fmt.Errorf("和风天气温度解析失败(tempMax=%q: %w", d.TempMax, err)
}
minV, err := strconv.Atoi(d.TempMin)
if err != nil {
return nil, fmt.Errorf("和风天气温度解析失败(tempMin=%q: %w", d.TempMin, err)
}
result.Days = append(result.Days, DayWeather{
Date: d.FxDate, TempMax: maxV, TempMin: minV, TextDay: d.TextDay,
})
total += maxV + minV
count += 2
}
if count == 0 {
// 日期范围超出 7 天窗口:返回空并提示
return result, fmt.Errorf("日期范围超出预报窗口(最多 7 天),请检查日期")
}
result.AvgTemp = total / count
result.Season = inferSeason(result.AvgTemp)
return result, nil
}
func inferSeason(avgTemp int) string {
switch {
case avgTemp >= 25:
return "夏"
case avgTemp >= 15:
return "春"
case avgTemp >= 5:
return "秋"
default:
return "冬"
}
}
+27
View File
@@ -0,0 +1,27 @@
package consts
// CPS 数据源
const (
CpsSourceMeituanOta = "meituan_ota"
CpsSourceJdEcom = "jd_ecom"
CpsSourceTbEcom = "tb_ecom"
)
// CPS 推荐场景(scene_category_map.scene_type
const (
CpsSceneHaircut = "haircut" // 发型卡「做同款发型」
CpsSceneItemBuy = "item_buy" // 穿衣清单「买同款」
CpsSceneItemUpgrade = "item_upgrade" // 穿衣清单「到店试穿」
CpsSceneOccasion = "occasion" // 场合卡「延伸优惠」
CpsSceneWardrobeUpgrade = "wardrobe_upgrade" // 衣橱「找升级款」
CpsSceneMemberBenefit = "member_benefit" // 会员中心最近优惠
)
// 点击日志场景(cps_click_log.scene
const (
CpsClickScenePlanHaircut = "plan_haircut"
CpsClickScenePlanItem = "plan_item"
CpsClickScenePlanOccasion = "plan_occasion"
CpsClickSceneWardrobeUpgrade = "wardrobe_upgrade"
CpsClickSceneMemberBenefit = "member_benefit"
)
+8
View File
@@ -0,0 +1,8 @@
package consts
// 数据库组:config.yml database.* 中的分组名,与 slogans 各域 SQLite 文件一一对应
const (
DBGroupPlan = "plan"
DBGroupPay = "pay"
DBGroupCps = "cps"
)
+75
View File
@@ -0,0 +1,75 @@
package consts
// 照片类型
const (
PhotoTypeHeadshot = 1 // 大头照
PhotoTypeFullFront = 2 // 全身正面
PhotoTypeFullSide = 3 // 全身侧面
PhotoTypeFullBack = 4 // 全身背面
)
// 生成任务状态
const (
TaskStatusPending = "pending"
TaskStatusPlanning = "planning"
TaskStatusScoring = "scoring"
TaskStatusRendering = "rendering"
TaskStatusDone = "done"
TaskStatusFailed = "failed"
)
// 方案来源
const (
PlanSourceWardrobe = "wardrobe"
PlanSourceRecommend = "recommend"
)
// 化身构建状态
const (
AvatarBuildPending = "pending"
AvatarBuildProcessing = "processing"
AvatarBuildDone = "done"
AvatarBuildFailed = "failed"
)
// 效果图状态
const (
EffectStatusPending = "pending"
EffectStatusRendering = "rendering"
EffectStatusDone = "done"
EffectStatusFailed = "failed"
)
// 方案条目 slot
const (
SlotHairstyle = "发型"
SlotTop = "上衣"
SlotBottom = "下装"
SlotShoes = "鞋"
SlotAccessory = "配饰"
)
// 评分阈值(可被 scoring_rule 配置覆盖)
const DefaultScoreThreshold = 75
// 免费用户每日效果图次数
const DefaultDailyEffectLimit = 3
// 支付订单状态
const (
PayStatusPending = "pending"
PayStatusPaid = "paid"
PayStatusClosed = "closed"
)
// 广告激励类型
const (
AdTypeEffectExtra = "effect_extra"
AdTypeVipTrial = "vip_trial"
)
// 会员开通来源
const (
MemberSourceVipPay = "vip_pay"
MemberSourceAdTrial = "ad_trial"
)
+26
View File
@@ -0,0 +1,26 @@
package consts
const (
TableNameUser = "slogan_user"
TableNameUserPhoto = "slogan_user_photo"
TableNameWardrobeItem = "slogan_wardrobe_item"
TableNameBodyMeasurement = "slogan_body_measurement"
TableNameAvatarModel = "slogan_avatar_model"
TableNameHairstyleAsset = "slogan_hairstyle_asset"
TableNameOutfitGenTask = "slogan_outfit_generation_task"
TableNameOutfitPlan = "slogan_outfit_plan"
TableNamePlanOutfitItem = "slogan_plan_outfit_item"
TableNamePlanEffectImage = "slogan_plan_effect_image"
TableNamePlanReview = "slogan_plan_review"
TableNameScoringRule = "slogan_scoring_rule"
TableNamePartnerStore = "slogan_partner_store"
TableNameMemberPlan = "slogan_member_plan"
TableNamePaymentOrder = "slogan_payment_order"
TableNameUserMember = "slogan_user_member"
TableNamePayNotifyLog = "slogan_pay_notify_log"
TableNameAdRewardLog = "slogan_ad_reward_log"
TableNameCpsCategory = "slogan_cps_category"
TableNameCpsProduct = "slogan_cps_product"
TableNameCpsClickLog = "slogan_cps_click_log"
TableNameSceneCategoryMap = "slogan_scene_category_map"
)
@@ -0,0 +1,26 @@
package controller
import (
"context"
commonHttp "slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
type ad struct{}
var Ad = new(ad)
// RewardClaim 领取广告激励(限频:effect_extra 每日 2 次 / vip_trial 每日 1 次)
func (c *ad) RewardClaim(ctx context.Context, req *dto.AdRewardClaimReq) (res *dto.AdRewardClaimRes, err error) {
result, err := service.AdService.Claim(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.AdType)
if err != nil {
return nil, err
}
return &dto.AdRewardClaimRes{Reward: &dto.AdRewardInfo{
AdType: result.AdType, RemainingToday: result.RemainingToday,
}}, nil
}
@@ -0,0 +1,39 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
type avatar struct{}
var Avatar = new(avatar)
func (c *avatar) Build(ctx context.Context, req *dto.AvatarBuildReq) (res *dto.AvatarBuildRes, err error) {
a, err := service.AvatarService.Build(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
if err != nil {
return nil, err
}
return &dto.AvatarBuildRes{AvatarId: a.Id, Status: a.BuildStatus}, nil
}
func (c *avatar) Get(ctx context.Context, req *dto.AvatarGetReq) (res *dto.AvatarGetRes, err error) {
a, err := service.AvatarService.Get(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
if err != nil || a == nil {
return &dto.AvatarGetRes{}, nil
}
return &dto.AvatarGetRes{
FaceTemplateId: a.FaceTemplateId,
BodyTemplateId: a.BodyTemplateId,
SkinToneIndex: a.SkinToneIndex,
GlbUrl: a.GlbUrl,
FramesUrl: a.FramesUrl,
BuildStatus: a.BuildStatus,
Error: a.Error,
}, nil
}
@@ -0,0 +1,49 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/model/entity"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
type body_measurement struct{}
var BodyMeasurement = new(body_measurement)
func (c *body_measurement) Save(ctx context.Context, req *dto.BodyMeasurementSaveReq) (res *struct{}, err error) {
if err := service.BodyMeasurementService.Save(ctx, common.GetUserId(g.RequestFromCtx(ctx)), &entity.BodyMeasurement{
Height: req.Height,
Weight: req.Weight,
SkinTone: req.SkinTone,
Bust: req.Bust,
Waist: req.Waist,
Hip: req.Hip,
Shoulder: req.Shoulder,
FitParams: req.FitParams,
}); err != nil {
return nil, err
}
return &struct{}{}, nil
}
func (c *body_measurement) Get(ctx context.Context, req *dto.BodyMeasurementGetReq) (res *dto.BodyMeasurementGetRes, err error) {
b, err := service.BodyMeasurementService.Get(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
if err != nil || b == nil {
return &dto.BodyMeasurementGetRes{}, nil
}
return &dto.BodyMeasurementGetRes{
Height: b.Height,
Weight: b.Weight,
SkinTone: b.SkinTone,
Bust: b.Bust,
Waist: b.Waist,
Hip: b.Hip,
Shoulder: b.Shoulder,
FitParams: b.FitParams,
}, nil
}
@@ -0,0 +1,17 @@
package controller
import (
"context"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
)
// CategoryList 联盟分类列表(客户端 chips)
func (c *cps) CategoryList(ctx context.Context, req *dto.CpsCategoryListReq) (res *dto.CpsCategoryListRes, err error) {
list, err := service.CpsCategoryService.List(ctx)
if err != nil {
return nil, err
}
return &dto.CpsCategoryListRes{List: list}, nil
}
@@ -0,0 +1,20 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
// MyRecent 最近优惠(点击日志 → 商品)
func (c *cps) MyRecent(ctx context.Context, req *dto.CpsMyRecentReq) (res *dto.CpsMyRecentRes, err error) {
list, err := service.CpsClickLogService.MyRecent(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
if err != nil {
return nil, err
}
return &dto.CpsMyRecentRes{List: list}, nil
}
@@ -0,0 +1,35 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
// cps 共享 struct/cps/* 簇的 6 个 handler 分文件挂在同一个类型上(组前缀路由零变更)
type cps struct{}
var Cps = new(cps)
// ProductList 选品池分页列表
func (c *cps) ProductList(ctx context.Context, req *dto.CpsProductListReq) (res *dto.CpsProductListRes, err error) {
list, hasMore, err := service.CpsProductService.ListByCategory(ctx, req.Source, req.CategoryCode, req.City, req.Page, 0)
if err != nil {
return nil, err
}
return &dto.CpsProductListRes{List: list, HasMore: hasMore}, nil
}
// ProductLink 商品转链(记录点击日志)
func (c *cps) ProductLink(ctx context.Context, req *dto.CpsProductLinkReq) (res *dto.CpsProductLinkRes, err error) {
r := g.RequestFromCtx(ctx)
link, err := service.CpsProductService.ClickLink(ctx, common.GetUserId(r), req.ProductId, req.Scene, req.PlanId, r.GetClientIp())
if err != nil {
return nil, err
}
return &dto.CpsProductLinkRes{Deeplink: link}, nil
}
@@ -0,0 +1,20 @@
package controller
import (
"context"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
)
type hairstyle struct{}
var Hairstyle = new(hairstyle)
func (c *hairstyle) List(ctx context.Context, req *dto.HairstyleListReq) (res *dto.HairstyleListRes, err error) {
list, err := service.HairstyleService.List(ctx)
if err != nil {
return nil, err
}
return &dto.HairstyleListRes{List: list}, nil
}
@@ -0,0 +1,37 @@
package controller
import (
"context"
commonHttp "slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
// member 为 /member/* 簇共享控制器类型(struct 名 kebab-case 决定路由前缀,
// 多表拆分文件但保持同一类型,避免路径漂移)
type member struct{}
var Member = new(member)
// PlanList 会员套餐列表
func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res *dto.MemberPlanListRes, err error) {
list, err := service.MemberPlanService.PlanList(ctx)
if err != nil {
return nil, err
}
return &dto.MemberPlanListRes{List: list}, nil
}
// Status 我的会员状态
func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto.MemberStatusRes, err error) {
st, err := service.MemberPlanService.Status(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)))
if err != nil {
return nil, err
}
return &dto.MemberStatusRes{
IsVip: st.IsVip, ExpireAt: st.ExpireAt, PlanName: st.PlanName, Benefits: st.Benefits,
}, nil
}
@@ -0,0 +1,35 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
// outfit 为 /outfit/* 簇共享控制器类型(struct 名 kebab-case 决定路由前缀,
// 多表拆分文件但保持同一类型,避免路径漂移)
type outfit struct{}
var Outfit = new(outfit)
// Generate 生成穿搭方案(异步任务)
func (c *outfit) Generate(ctx context.Context, req *dto.OutfitGenerateReq) (res *dto.OutfitGenerateRes, err error) {
taskId, err := service.OutfitService.Generate(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req)
if err != nil {
return nil, err
}
return &dto.OutfitGenerateRes{TaskId: taskId}, nil
}
// TaskStatus 查询生成任务状态
func (c *outfit) TaskStatus(ctx context.Context, req *dto.OutfitTaskStatusReq) (res *dto.OutfitTaskStatusRes, err error) {
status, msg, err := service.OutfitService.GetTaskStatus(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.TaskId)
if err != nil {
return nil, err
}
return &dto.OutfitTaskStatusRes{Status: status, Error: msg}, nil
}
@@ -0,0 +1,33 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
// PlanList 方案列表
func (c *outfit) PlanList(ctx context.Context, req *dto.OutfitPlanListReq) (res *dto.OutfitPlanListRes, err error) {
list, err := service.OutfitPlanService.ListPlans(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
if err != nil {
return nil, err
}
return &dto.OutfitPlanListRes{List: list}, nil
}
// PlanDetail 方案详情(items + images + hairstyle
func (c *outfit) PlanDetail(ctx context.Context, req *dto.OutfitPlanDetailReq) (res *dto.OutfitPlanDetailRes, err error) {
return service.OutfitPlanService.GetPlanDetail(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId)
}
// SelectMain 选定主方案(触发效果图生成)
func (c *outfit) SelectMain(ctx context.Context, req *dto.OutfitSelectMainReq) (res *struct{}, err error) {
if err = service.OutfitPlanService.SelectMain(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId); err != nil {
return nil, err
}
return &struct{}{}, nil
}
@@ -0,0 +1,21 @@
package controller
import (
"context"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
)
type partner_store struct{}
var PartnerStore = new(partner_store)
// List 合作门店列表
func (c *partner_store) List(ctx context.Context, req *dto.StoreListReq) (res *dto.StoreListRes, err error) {
list, err := service.PartnerStoreService.List(ctx, req.Type)
if err != nil {
return nil, err
}
return &dto.StoreListRes{List: list}, nil
}
@@ -0,0 +1,3 @@
package controller
// pay_notify_log 表控制器:无独立路由 handler(回调日志由 /member/order/notify 审计写入)
@@ -0,0 +1,74 @@
package controller
import (
"context"
"errors"
"fmt"
commonHttp "slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// OrderCreate 下单 → 返回支付 URL
func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq) (res *dto.MemberOrderCreateRes, err error) {
order, payURL, err := service.PaymentOrderService.CreateMemberOrder(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.PlanId)
if err != nil {
return nil, err
}
return &dto.MemberOrderCreateRes{OrderNo: order.OrderNo, PayUrl: payURL}, nil
}
// OrderStatus 订单状态(App 轮询)
func (c *member) OrderStatus(ctx context.Context, req *dto.MemberOrderStatusReq) (res *dto.MemberOrderStatusRes, err error) {
order, err := service.PaymentOrderService.OrderStatus(ctx, req.OrderNo)
if err != nil || order == nil {
return nil, errors.New("订单不存在")
}
paidAt := ""
if order.PaidAt != nil {
paidAt = order.PaidAt.Format("Y-m-d H:i:s")
}
return &dto.MemberOrderStatusRes{Status: order.Status, TradeNo: order.TradeNo, PaidAt: paidAt}, nil
}
// MemberNotify 虎皮棋支付回调:验签 → 幂等开通 → 返回裸文本 "success"
// 虎皮棋要求回调响应体为字面 "success",故不走统一 JSON 包装(main.go 手动绑定)
func MemberNotify(r *ghttp.Request) {
ctx := r.Context()
body := r.GetBodyString()
hash := r.Get("hash").String()
orderNo := r.Get("trade_order_id").String()
remoteIP := r.GetClientIp()
params := make(map[string]string)
for k, v := range r.GetRequestMap() {
params[k] = fmt.Sprint(v)
}
ok := service.PaymentOrderService.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String())
if !ok {
if err := service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, "bad_sign"); err != nil {
g.Log().Warningf(ctx, "写入支付回调日志失败(bad_sign: %v", err)
}
r.Response.Write("fail")
r.ExitAll()
return
}
state, err := service.PaymentOrderService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body)
if logErr := service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, state); logErr != nil {
g.Log().Warningf(ctx, "写入支付回调日志失败: %v", logErr)
}
// duplicate(幂等重复回调)同样返回 success,避免支付渠道无限重试
if err != nil || state == "no_order" {
r.Response.Write("fail")
r.ExitAll()
return
}
r.Response.Write("success")
r.ExitAll()
}
@@ -0,0 +1,4 @@
package controller
// plan_effect_image 表控制器:无独立路由 handler,逻辑归属 outfit/* 簇
// (效果图由选定主方案后异步生成,经 /outfit/plan/detail 返回)
@@ -0,0 +1,4 @@
package controller
// plan_outfit_item 表控制器:无独立路由 handler,逻辑归属 outfit/* 簇
// (方案条目数据由 /outfit/plan/detail 承载)
@@ -0,0 +1,19 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
// Review 方案反馈
func (c *outfit) Review(ctx context.Context, req *dto.OutfitReviewReq) (res *struct{}, err error) {
if err = service.PlanReviewService.Review(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId, req.Action, req.Note); err != nil {
return nil, err
}
return &struct{}{}, nil
}
@@ -0,0 +1,35 @@
package controller
import (
"context"
"errors"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
// PlanRecommend 方案驱动推荐(发型/买同款/到店试穿/场合)
func (c *cps) PlanRecommend(ctx context.Context, req *dto.CpsPlanRecommendReq) (res *dto.CpsPlanRecommendRes, err error) {
userId := common.GetUserId(g.RequestFromCtx(ctx))
plan, err := service.OutfitPlanService.GetPlan(ctx, userId, req.PlanId)
if err != nil || plan == nil {
return nil, errors.New("方案不存在")
}
list, err := service.SceneCategoryMapService.Recommend(ctx, plan, req.Scene)
if err != nil {
return nil, err
}
return &dto.CpsPlanRecommendRes{List: list}, nil
}
// WardrobeUpgrade 衣橱升级款
func (c *cps) WardrobeUpgrade(ctx context.Context, req *dto.CpsWardrobeUpgradeReq) (res *dto.CpsWardrobeUpgradeRes, err error) {
list, err := service.SceneCategoryMapService.WardrobeUpgrade(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.ItemId)
if err != nil {
return nil, err
}
return &dto.CpsWardrobeUpgradeRes{List: list}, nil
}
@@ -0,0 +1,3 @@
package controller
// scoring_rule 表控制器:无独立路由 handler,规则在服务端读取(评分阈值/效果图额度)
@@ -0,0 +1,47 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
type user struct{}
var User = new(user)
func (c *user) Register(ctx context.Context, req *dto.RegisterReq) (res *struct{}, err error) {
_, err = service.UserService.Register(ctx, req.Account, req.Password, req.Name)
if err != nil {
return nil, err
}
return &struct{}{}, nil
}
func (c *user) Login(ctx context.Context, req *dto.LoginReq) (res *dto.LoginRes, err error) {
user, token, err := service.UserService.Login(ctx, req.Account, req.Password)
if err != nil {
return nil, err
}
return &dto.LoginRes{
Token: token,
User: &dto.LoginUser{Id: user.Id, Role: user.Role, Name: user.Name},
}, nil
}
func (c *user) ChangePassword(ctx context.Context, req *dto.ChangePasswordReq) (res *struct{}, err error) {
return nil, service.UserService.ChangePassword(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.OldPassword, req.NewPassword)
}
func (c *user) Profile(ctx context.Context, req *dto.ProfileReq) (res *dto.ProfileRes, err error) {
user, err := dao.User.GetOne(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
if err != nil || user == nil {
return nil, err
}
return &dto.ProfileRes{Id: user.Id, Role: user.Role, Name: user.Name, Username: user.Username, Phone: user.Phone}, nil
}
@@ -0,0 +1,4 @@
package controller
// user_member 表控制器:无独立路由 handler(会员状态经 /member/status 返回,
// 开通由支付回调与广告激励写入)
@@ -0,0 +1,38 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
type user_photo struct{}
var UserPhoto = new(user_photo)
func (c *user_photo) Upload(ctx context.Context, req *dto.UserPhotoUploadReq) (res *dto.UserPhotoUploadRes, err error) {
id, err := service.UserPhotoService.Upload(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Type, g.RequestFromCtx(ctx).GetUploadFile("file"))
if err != nil {
return nil, err
}
return &dto.UserPhotoUploadRes{Id: id}, nil
}
func (c *user_photo) List(ctx context.Context, req *dto.UserPhotoListReq) (res *dto.UserPhotoListRes, err error) {
list, err := service.UserPhotoService.List(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Type)
if err != nil {
return nil, err
}
return &dto.UserPhotoListRes{List: list}, nil
}
func (c *user_photo) Delete(ctx context.Context, req *dto.UserPhotoDeleteReq) (res *struct{}, err error) {
if err := service.UserPhotoService.Delete(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id); err != nil {
return nil, err
}
return &struct{}{}, nil
}
@@ -0,0 +1,61 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/model/entity"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
type wardrobe struct{}
var Wardrobe = new(wardrobe)
func (c *wardrobe) Upload(ctx context.Context, req *dto.WardrobeUploadReq) (res *dto.WardrobeUploadRes, err error) {
id, err := service.WardrobeService.Upload(ctx, common.GetUserId(g.RequestFromCtx(ctx)), entity.WardrobeItem{
Category: req.Category,
Season: req.Season,
StyleTags: req.StyleTags,
ColorInfo: req.ColorInfo,
}, g.RequestFromCtx(ctx).GetUploadFile("file"))
if err != nil {
return nil, err
}
return &dto.WardrobeUploadRes{Id: id}, nil
}
func (c *wardrobe) List(ctx context.Context, req *dto.WardrobeListReq) (res *dto.WardrobeListRes, err error) {
list, err := service.WardrobeService.List(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Category)
if err != nil {
return nil, err
}
return &dto.WardrobeListRes{List: list}, nil
}
func (c *wardrobe) Update(ctx context.Context, req *dto.WardrobeUpdateReq) (res *struct{}, err error) {
data := map[string]any{}
if req.Category != "" {
data["category"] = req.Category
}
if req.Season != "" {
data["season"] = req.Season
}
if req.StyleTags != "" {
data["style_tags"] = req.StyleTags
}
if err := service.WardrobeService.Update(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id, data); err != nil {
return nil, err
}
return &struct{}{}, nil
}
func (c *wardrobe) Delete(ctx context.Context, req *dto.WardrobeDeleteReq) (res *struct{}, err error) {
if err := service.WardrobeService.Delete(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id); err != nil {
return nil, err
}
return &struct{}{}, nil
}
@@ -0,0 +1,74 @@
package dao
import (
"context"
"errors"
"fmt"
"time"
"slogan-agent/styleagent/consts"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
var AdRewardLog = &adRewardLogDao{}
type adRewardLogDao struct{}
func init() {
ctx := context.Background()
_, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
ad_type TEXT NOT NULL DEFAULT '',
reward_key TEXT NOT NULL DEFAULT '',
slot INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'ok',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create ad_reward_log table failed: %v", err)
}
// 唯一索引兜底并发:同一 (user, day, type) 最多 limit 个 slot(如 effect_extra 2 / vip_trial 1
if _, err := dbPay().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key, slot)`); err != nil {
g.Log().Warningf(ctx, "create index idx_ad_reward_unique failed: %v", err)
}
}
// rewardKey 自然日去重粒度:"2026-07-31:effect_extra"
func rewardKey(adType string) string {
return fmt.Sprintf("%s:%s", time.Now().Format("2006-01-02"), adType)
}
// InsertTx 事务版本:领取记录与会员赠送原子提交
func (d *adRewardLogDao) InsertTx(ctx context.Context, tx gdb.TX, userId int64, adType string, limit int) (int64, error) {
for slot := 1; slot <= limit; slot++ {
r, err := tx.Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{
"user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok",
}).Insert()
if err == nil {
return r.LastInsertId()
}
}
return 0, errors.New("ad reward quota exhausted")
}
func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) {
n, err := dbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx).
Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count()
return int(n), err
}
// Insert 领取记录:在 1..limit 的 slot 中找一个空闲位写入;全满(唯一索引冲突)返回错误 → 视为限频
func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string, limit int) (int64, error) {
for slot := 1; slot <= limit; slot++ {
r, err := dbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{
"user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok",
}).Insert()
if err == nil {
return r.LastInsertId()
}
}
return 0, errors.New("ad reward quota exhausted")
}
+67
View File
@@ -0,0 +1,67 @@
package dao
import (
"context"
"strings"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var AvatarModel = &avatarModelDao{}
type avatarModelDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAvatarModel+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
face_template_id INTEGER NOT NULL DEFAULT 0,
body_template_id INTEGER NOT NULL DEFAULT 0,
skin_tone_index INTEGER NOT NULL DEFAULT 0,
face_texture_url TEXT NOT NULL DEFAULT '',
glb_url TEXT NOT NULL DEFAULT '',
build_status TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',
params_snapshot TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create avatar_model table failed: %v", err)
}
// 旧库补列(CREATE TABLE IF NOT EXISTS 不给已存在表加列,忽略 duplicate column 错误)
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameAvatarModel+" ADD COLUMN frames_url TEXT NOT NULL DEFAULT ''"); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
g.Log().Warningf(ctx, "alter avatar_model add frames_url failed: %v", err)
}
}
}
func (d *avatarModelDao) Insert(ctx context.Context, data *entity.AvatarModel) (int64, error) {
r, err := g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameAvatarModel+" (user_id, face_template_id, body_template_id, skin_tone_index, face_texture_url, glb_url, build_status, error, params_snapshot, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
data.UserId, data.FaceTemplateId, data.BodyTemplateId, data.SkinToneIndex,
data.FaceTextureUrl, data.GlbUrl, data.BuildStatus, data.Error, data.ParamsSnapshot)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *avatarModelDao) GetByUser(ctx context.Context, userId int64) (*entity.AvatarModel, error) {
var a entity.AvatarModel
err := g.DB().Model(consts.TableNameAvatarModel).Ctx(ctx).
Where("user_id", userId).OrderDesc("id").Scan(&a)
if err != nil || a.Id == 0 {
return nil, err
}
return &a, nil
}
func (d *avatarModelDao) Update(ctx context.Context, id int64, data map[string]any) error {
_, err := g.DB().Model(consts.TableNameAvatarModel).Ctx(ctx).Data(data).Where("id", id).Update()
return err
}
@@ -0,0 +1,75 @@
package dao
import (
"context"
"strings"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var BodyMeasurement = &bodyMeasurementDao{}
type bodyMeasurementDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameBodyMeasurement+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
height INTEGER NOT NULL DEFAULT 0,
weight INTEGER NOT NULL DEFAULT 0,
skin_tone INTEGER NOT NULL DEFAULT 3,
fit_params TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create body_measurement table failed: %v", err)
}
// 旧库补列(CREATE TABLE IF NOT EXISTS 不给已存在表加列,忽略 duplicate column 错误)
for _, col := range []string{
"bust INTEGER NOT NULL DEFAULT 0",
"waist INTEGER NOT NULL DEFAULT 0",
"hip INTEGER NOT NULL DEFAULT 0",
"shoulder INTEGER NOT NULL DEFAULT 0",
} {
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameBodyMeasurement+" ADD COLUMN "+col); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
g.Log().Warningf(ctx, "alter body_measurement add column failed: %v", err)
}
}
}
}
func (d *bodyMeasurementDao) Save(ctx context.Context, data *entity.BodyMeasurement) error {
r, err := g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx).
Where("user_id", data.UserId).One()
if err != nil {
return err
}
if r == nil {
_, err = g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameBodyMeasurement+" (user_id, height, weight, skin_tone, bust, waist, hip, shoulder, fit_params, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
data.UserId, data.Height, data.Weight, data.SkinTone,
data.Bust, data.Waist, data.Hip, data.Shoulder, data.FitParams)
return err
}
_, err = g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx).Data(g.Map{
"height": data.Height, "weight": data.Weight, "skin_tone": data.SkinTone,
"bust": data.Bust, "waist": data.Waist, "hip": data.Hip, "shoulder": data.Shoulder,
"fit_params": data.FitParams, "updated_at": "datetime('now','localtime')",
}).Where("user_id", data.UserId).Update()
return err
}
func (d *bodyMeasurementDao) GetByUser(ctx context.Context, userId int64) (*entity.BodyMeasurement, error) {
var b entity.BodyMeasurement
err := g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx).
Where("user_id", userId).Scan(&b)
if err != nil || b.Id == 0 {
return nil, err
}
return &b, nil
}
+57
View File
@@ -0,0 +1,57 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var CpsCategory = &cpsCategoryDao{}
type cpsCategoryDao struct{}
func init() {
ctx := context.Background()
_, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsCategory+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
parent_code TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
source_cat_id TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create cps_category table failed: %v", err)
}
seedCpsCategories(ctx)
}
func seedCpsCategories(ctx context.Context) {
base := []struct{ code, name, source, sourceCatId string }{
{"beauty", "丽人", consts.CpsSourceMeituanOta, ""},
{"clothing", "服装", consts.CpsSourceMeituanOta, ""},
{"food", "餐厅", consts.CpsSourceMeituanOta, ""},
{"hotel", "酒店", consts.CpsSourceMeituanOta, ""},
{"ticket", "票务", consts.CpsSourceMeituanOta, ""},
{"digital", "数码", consts.CpsSourceJdEcom, ""},
}
for i, c := range base {
if _, err := dbCps().Exec(ctx,
"INSERT OR IGNORE INTO "+consts.TableNameCpsCategory+
" (code, name, parent_code, source, source_cat_id, sort) VALUES (?, ?, '', ?, ?, ?)",
c.code, c.name, c.source, c.sourceCatId, i); err != nil {
g.Log().Warningf(ctx, "seed cps_category %s failed: %v", c.code, err)
}
}
}
func (d *cpsCategoryDao) List(ctx context.Context) ([]*entity.CpsCategory, error) {
var list []*entity.CpsCategory
err := dbCps().Model(consts.TableNameCpsCategory).Ctx(ctx).
OrderAsc("sort").OrderAsc("id").Scan(&list)
return list, err
}
@@ -0,0 +1,55 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var CpsClickLog = &cpsClickLogDao{}
type cpsClickLogDao struct{}
func init() {
ctx := context.Background()
_, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsClickLog+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
outer_id TEXT NOT NULL DEFAULT '',
scene TEXT NOT NULL DEFAULT '',
plan_id INTEGER NOT NULL DEFAULT 0,
category_code TEXT NOT NULL DEFAULT '',
deeplink TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create cps_click_log table failed: %v", err)
}
_, err = dbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_click_user ON "+
consts.TableNameCpsClickLog+"(user_id, created_at)")
if err != nil {
g.Log().Warningf(ctx, "create cps_click_log index failed: %v", err)
}
}
func (d *cpsClickLogDao) Insert(ctx context.Context, log *entity.CpsClickLog) (int64, error) {
r, err := dbCps().Exec(ctx, "INSERT INTO "+consts.TableNameCpsClickLog+
" (user_id, source, outer_id, scene, plan_id, category_code, deeplink, ip, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
log.UserId, log.Source, log.OuterId, log.Scene, log.PlanId,
log.CategoryCode, log.Deeplink, log.Ip)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *cpsClickLogDao) ListByUser(ctx context.Context, userId int64, limit int) ([]*entity.CpsClickLog, error) {
var list []*entity.CpsClickLog
err := dbCps().Model(consts.TableNameCpsClickLog).Ctx(ctx).
Where("user_id", userId).OrderDesc("id").Limit(limit).Scan(&list)
return list, err
}
+143
View File
@@ -0,0 +1,143 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
var CpsProduct = &cpsProductDao{}
type cpsProductDao struct{}
func init() {
ctx := context.Background()
_, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsProduct+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT '',
outer_id TEXT NOT NULL DEFAULT '',
category_code TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
cover_url TEXT NOT NULL DEFAULT '',
price_fen INTEGER NOT NULL DEFAULT 0,
shop_name TEXT NOT NULL DEFAULT '',
commission_rate INTEGER NOT NULL DEFAULT 0,
city TEXT NOT NULL DEFAULT '',
scene_tags TEXT NOT NULL DEFAULT '[]',
raw TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 1,
sync_at DATETIME,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create cps_product table failed: %v", err)
}
_, err = dbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON "+
consts.TableNameCpsProduct+"(source, category_code, status)")
if err != nil {
g.Log().Warningf(ctx, "create cps_product index failed: %v", err)
}
// Upsert 的 ON CONFLICT 依赖唯一索引
_, err = dbCps().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_cps_product_outer ON "+
consts.TableNameCpsProduct+"(source, outer_id)")
if err != nil {
g.Log().Warningf(ctx, "create cps_product unique index failed: %v", err)
}
}
func (d *cpsProductDao) Upsert(ctx context.Context, p *entity.CpsProduct) error {
_, err := dbCps().Exec(ctx, `INSERT INTO `+consts.TableNameCpsProduct+
` (source, outer_id, category_code, name, cover_url, price_fen, shop_name, commission_rate, city, scene_tags, raw, status, sync_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now','localtime'), datetime('now','localtime'))
ON CONFLICT(source, outer_id) DO UPDATE SET
name=excluded.name, cover_url=excluded.cover_url, price_fen=excluded.price_fen,
shop_name=excluded.shop_name, commission_rate=excluded.commission_rate,
city=excluded.city, scene_tags=excluded.scene_tags, raw=excluded.raw,
status=1, sync_at=datetime('now','localtime')`,
p.Source, p.OuterId, p.CategoryCode, p.Name, p.CoverUrl, p.PriceFen,
p.ShopName, p.CommissionRate, p.City, p.SceneTags, p.Raw)
return err
}
func (d *cpsProductDao) ListByCategory(ctx context.Context, source, categoryCode, city string, page, pageSize int) ([]*entity.CpsProduct, error) {
var list []*entity.CpsProduct
m := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx).
Where("status", 1).Where("source", source).Where("category_code", categoryCode)
if city != "" {
m = m.Where("city", city)
}
err := m.OrderDesc("sync_at").Limit(pageSize).Offset((page - 1) * pageSize).Scan(&list)
return list, err
}
func (d *cpsProductDao) CountByCategory(ctx context.Context, source, categoryCode, city string) (int, error) {
m := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx).
Where("status", 1).Where("source", source).Where("category_code", categoryCode)
if city != "" {
m = m.Where("city", city)
}
return m.Count()
}
func (d *cpsProductDao) Get(ctx context.Context, id int64) (*entity.CpsProduct, error) {
var p entity.CpsProduct
err := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx).Where("id", id).Scan(&p)
if err != nil || p.Id == 0 {
return nil, err
}
return &p, nil
}
// cpsUpsertBatchSize 每批条数:11 参数/条 × 80 = 880 < SQLite 变量上限 999
const cpsUpsertBatchSize = 80
// UpsertBatch 批量 Upsert(单条 multi-row SQL + ON CONFLICT),每批独立事务,批间失败互不影响
func (d *cpsProductDao) UpsertBatch(ctx context.Context, list []*entity.CpsProduct) error {
for start := 0; start < len(list); start += cpsUpsertBatchSize {
end := start + cpsUpsertBatchSize
if end > len(list) {
end = len(list)
}
batch := list[start:end]
sqlText, args := buildCpsUpsertSQL(batch)
if err := dbCps().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := tx.Ctx(ctx).Exec(sqlText, args...)
return err
}); err != nil {
return err
}
}
return nil
}
func buildCpsUpsertSQL(batch []*entity.CpsProduct) (string, []any) {
var sb strings.Builder
sb.WriteString("INSERT INTO " + consts.TableNameCpsProduct +
" (source, outer_id, category_code, name, cover_url, price_fen, shop_name, commission_rate, city, scene_tags, raw, status, sync_at, created_at) VALUES ")
args := make([]any, 0, len(batch)*11)
for i, p := range batch {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString("(?,?,?,?,?,?,?,?,?,?,?,1,datetime('now','localtime'),datetime('now','localtime'))")
args = append(args, p.Source, p.OuterId, p.CategoryCode, p.Name, p.CoverUrl, p.PriceFen,
p.ShopName, p.CommissionRate, p.City, p.SceneTags, p.Raw)
}
sb.WriteString(" ON CONFLICT(source, outer_id) DO UPDATE SET name=excluded.name, cover_url=excluded.cover_url, price_fen=excluded.price_fen, shop_name=excluded.shop_name, commission_rate=excluded.commission_rate, city=excluded.city, scene_tags=excluded.scene_tags, raw=excluded.raw, status=1, sync_at=datetime('now','localtime')")
return sb.String(), args
}
// GetByOuter 按联盟来源 + 外部 ID 取商品(点击日志回填商品信息用)
func (d *cpsProductDao) GetByOuter(ctx context.Context, source, outerId string) (*entity.CpsProduct, error) {
var p entity.CpsProduct
err := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx).
Where("source", source).Where("outer_id", outerId).Scan(&p)
if err != nil || p.Id == 0 {
return nil, err
}
return &p, nil
}
+11
View File
@@ -0,0 +1,11 @@
package dao
import (
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// 数据库组归属:DAO 按业务域拆分到独立 SQLite 文件,经所属组访问
func dbPlan() gdb.DB { return g.DB("plan") }
func dbPay() gdb.DB { return g.DB("pay") }
func dbCps() gdb.DB { return g.DB("cps") }
@@ -0,0 +1,97 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"strings"
"github.com/gogf/gf/v2/frame/g"
)
var HairstyleAsset = &hairstyleAssetDao{}
type hairstyleAssetDao struct{}
func init() {
ctx := context.Background()
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameHairstyleAsset+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
style_tag TEXT NOT NULL DEFAULT '',
glb_url TEXT NOT NULL DEFAULT '',
thumb_url TEXT NOT NULL DEFAULT '',
applicable_face TEXT NOT NULL DEFAULT 'all',
sort INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create hairstyle_asset table failed: %v", err)
}
seedHairstyles(ctx)
}
func seedHairstyles(ctx context.Context) {
r, err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Count()
if err != nil || r > 0 {
return
}
items := []struct {
name, tag, face string
sort int
}{
{"清爽短发", "清爽", "all", 1},
{"中分微卷", "温婉", "all", 2},
{"披肩长发", "优雅", "all", 3},
{"自然直发", "简约", "all", 4},
{"利落寸头", "干练", "all", 5},
{"高马尾", "活力", "all", 6},
{"丸子头", "可爱", "all", 7},
{"波浪卷发", "浪漫", "all", 8},
}
// 批量 multi-row INSERT(种子数据一次性写入)
var sb strings.Builder
sb.WriteString("INSERT INTO " + consts.TableNameHairstyleAsset +
" (name, style_tag, glb_url, thumb_url, applicable_face, sort, created_at) VALUES ")
args := make([]any, 0, len(items)*6)
for i, it := range items {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString("(?,?,?,?,?,?,datetime('now','localtime'))")
args = append(args, it.name, it.tag, "/workspace/templates/hairstyle_"+itoa(i+1)+".glb",
"/workspace/templates/hairstyle_thumb_"+itoa(i+1)+".png", it.face, it.sort)
}
if _, err := dbPlan().Exec(ctx, sb.String(), args...); err != nil {
g.Log().Warningf(ctx, "seed hairstyle_asset failed: %v", err)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
func (d *hairstyleAssetDao) ListAll(ctx context.Context) ([]*entity.HairstyleAsset, error) {
var list []*entity.HairstyleAsset
err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).OrderAsc("sort").Scan(&list)
return list, err
}
func (d *hairstyleAssetDao) GetOne(ctx context.Context, id int64) (*entity.HairstyleAsset, error) {
var h entity.HairstyleAsset
err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Where("id", id).Scan(&h)
if err != nil || h.Id == 0 {
return nil, err
}
return &h, nil
}
+79
View File
@@ -0,0 +1,79 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
var MemberPlan = &memberPlanDao{}
type memberPlanDao struct{}
func init() {
ctx := context.Background()
_, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMemberPlan+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
price_fen INTEGER NOT NULL DEFAULT 0,
duration_days INTEGER NOT NULL DEFAULT 30,
features TEXT NOT NULL DEFAULT '[]',
sort INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create member_plan table failed: %v", err)
}
seedMemberPlans(ctx)
}
func seedMemberPlans(ctx context.Context) {
r, err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).Count()
if err != nil || r > 0 {
return
}
plans := []struct {
name string
price int
days int
features string
sort int
}{
{"月卡 ¥29.9", 2990, 30, `["effect_unlimited","cps_commission_x15"]`, 1},
{"年卡 ¥199", 19900, 365, `["effect_unlimited","ai_priority","cps_commission_x15","store_discount"]`, 2},
}
for _, p := range plans {
if _, err := dbPay().Exec(ctx,
"INSERT INTO "+consts.TableNameMemberPlan+" (name, price_fen, duration_days, features, sort, status, created_at) VALUES (?, ?, ?, ?, ?, 1, datetime('now','localtime'))",
p.name, p.price, p.days, p.features, p.sort); err != nil {
g.Log().Warningf(ctx, "seed member_plan %s failed: %v", p.name, err)
}
}
}
func (d *memberPlanDao) ListEnabled(ctx context.Context) ([]*entity.MemberPlan, error) {
var list []*entity.MemberPlan
err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).
Where("status", 1).OrderAsc("sort").OrderAsc("id").Scan(&list)
return list, err
}
// GetOneTx 事务版本:支付回调事务内读取套餐配置
func (d *memberPlanDao) GetOneTx(ctx context.Context, tx gdb.TX, id int64) (*entity.MemberPlan, error) {
var p *entity.MemberPlan
err := tx.Model(consts.TableNameMemberPlan).Ctx(ctx).
Where("id", id).Where("status", 1).Scan(&p)
return p, err
}
func (d *memberPlanDao) GetOne(ctx context.Context, id int64) (*entity.MemberPlan, error) {
var p *entity.MemberPlan
err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).
Where("id", id).Where("status", 1).Scan(&p)
return p, err
}
@@ -0,0 +1,87 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
var OutfitGenTask = &outfitGenTaskDao{}
type outfitGenTaskDao struct{}
func init() {
ctx := context.Background()
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitGenTask+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
start_date TEXT NOT NULL DEFAULT '',
end_date TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
weather_snapshot TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
error TEXT NOT NULL DEFAULT '',
model_name TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create outfit_generation_task table failed: %v", err)
}
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_gen_task_user ON "+consts.TableNameOutfitGenTask+"(user_id, created_at)"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_gen_task_user failed: %v", err)
}
}
func (d *outfitGenTaskDao) Insert(ctx context.Context, data *entity.OutfitGenerationTask) (int64, error) {
r, err := dbPlan().Exec(ctx,
"INSERT INTO "+consts.TableNameOutfitGenTask+" (user_id, start_date, end_date, location, weather_snapshot, status, error, model_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
data.UserId, data.StartDate, data.EndDate, data.Location, data.WeatherSnapshot, data.Status, data.Error, data.ModelName)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *outfitGenTaskDao) GetOne(ctx context.Context, id, userId int64) (*entity.OutfitGenerationTask, error) {
var t entity.OutfitGenerationTask
err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).
Where("id", id).Where("user_id", userId).Scan(&t)
if err != nil || t.Id == 0 {
return nil, err
}
return &t, nil
}
func (d *outfitGenTaskDao) Update(ctx context.Context, id int64, data g.Map) error {
_, err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).
Data(data).Where("id", id).Update()
return err
}
func (d *outfitGenTaskDao) UpdateStatus(ctx context.Context, id int64, status, errMsg string) error {
_, err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{
"status": status, "error": errMsg, "updated_at": "datetime('now','localtime')",
}).Where("id", id).Update()
return err
}
// UpdateStatusTx 事务版本:方案落库事务内同步任务状态
func (d *outfitGenTaskDao) UpdateStatusTx(ctx context.Context, tx gdb.TX, id int64, status, errMsg string) error {
_, err := tx.Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{
"status": status, "error": errMsg, "updated_at": "datetime('now','localtime')",
}).Where("id", id).Update()
return err
}
// ListUnfinished 返回未完成的任务(重启恢复用)
func (d *outfitGenTaskDao) ListUnfinished(ctx context.Context) ([]*entity.OutfitGenerationTask, error) {
var list []*entity.OutfitGenerationTask
err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).
Where("status NOT IN (?)", g.Slice{consts.TaskStatusDone, consts.TaskStatusFailed}).
OrderAsc("id").Limit(50).Scan(&list)
return list, err
}
+119
View File
@@ -0,0 +1,119 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
var OutfitPlan = &outfitPlanDao{}
type outfitPlanDao struct{}
func init() {
ctx := context.Background()
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitPlan+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
date_range TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'wardrobe',
score INTEGER NOT NULL DEFAULT 0,
main_flag INTEGER NOT NULL DEFAULT 0,
hairstyle_id INTEGER NOT NULL DEFAULT 0,
hair_color TEXT NOT NULL DEFAULT '',
weather_ref TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create outfit_plan table failed: %v", err)
}
// 容错迁移:CREATE TABLE IF NOT EXISTS 不给旧库加列,duplicate column 错误可忽略
if _, err := dbPlan().Exec(ctx, "ALTER TABLE "+consts.TableNameOutfitPlan+
" ADD COLUMN occasion TEXT NOT NULL DEFAULT ''"); err != nil {
g.Log().Warningf(ctx, "migrate outfit_plan.occasion skipped: %v", err)
}
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_user ON "+consts.TableNameOutfitPlan+"(user_id, created_at)"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_plan_user failed: %v", err)
}
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_task ON "+consts.TableNameOutfitPlan+"(task_id)"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_plan_task failed: %v", err)
}
}
func (d *outfitPlanDao) Insert(ctx context.Context, data *entity.OutfitPlan) (int64, error) {
r, err := dbPlan().Exec(ctx,
"INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, occasion, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
data.TaskId, data.UserId, data.DateRange, data.Location, data.Title, data.Source,
data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef, data.Occasion)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
// ===== 事务版本(runGenerateTask / SelectMain 流程使用,保证方案+单品原子落库) =====
func (d *outfitPlanDao) InsertTx(ctx context.Context, tx gdb.TX, data *entity.OutfitPlan) (int64, error) {
r, err := tx.Ctx(ctx).Exec(
"INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, occasion, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
data.TaskId, data.UserId, data.DateRange, data.Location, data.Title, data.Source,
data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef, data.Occasion)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *outfitPlanDao) ClearMainFlagTx(ctx context.Context, tx gdb.TX, taskId int64) error {
_, err := tx.Model(consts.TableNameOutfitPlan).Ctx(ctx).
Data(g.Map{"main_flag": 0}).Where("task_id", taskId).Update()
return err
}
func (d *outfitPlanDao) SetMainFlagTx(ctx context.Context, tx gdb.TX, id int64) error {
_, err := tx.Model(consts.TableNameOutfitPlan).Ctx(ctx).
Data(g.Map{"main_flag": 1}).Where("id", id).Update()
return err
}
func (d *outfitPlanDao) ListByUser(ctx context.Context, userId int64) ([]*entity.OutfitPlan, error) {
var list []*entity.OutfitPlan
err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
Where("user_id", userId).OrderDesc("id").Limit(50).Scan(&list)
return list, err
}
func (d *outfitPlanDao) ListByTask(ctx context.Context, taskId int64) ([]*entity.OutfitPlan, error) {
var list []*entity.OutfitPlan
err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
Where("task_id", taskId).OrderAsc("id").Scan(&list)
return list, err
}
func (d *outfitPlanDao) GetOne(ctx context.Context, id, userId int64) (*entity.OutfitPlan, error) {
var p entity.OutfitPlan
err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
Where("id", id).Where("user_id", userId).Scan(&p)
if err != nil || p.Id == 0 {
return nil, err
}
return &p, nil
}
func (d *outfitPlanDao) ClearMainFlag(ctx context.Context, taskId int64) error {
_, err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
Data(g.Map{"main_flag": 0}).Where("task_id", taskId).Update()
return err
}
func (d *outfitPlanDao) SetMainFlag(ctx context.Context, id int64) error {
_, err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
Data(g.Map{"main_flag": 1}).Where("id", id).Update()
return err
}
@@ -0,0 +1,66 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var PartnerStore = &partnerStoreDao{}
type partnerStoreDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePartnerStore+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
type INTEGER NOT NULL DEFAULT 1,
lat REAL NOT NULL DEFAULT 0,
lng REAL NOT NULL DEFAULT 0,
address TEXT NOT NULL DEFAULT '',
commission_policy TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create partner_store table failed: %v", err)
}
seedStores(ctx)
}
func seedStores(ctx context.Context) {
r, err := g.DB().Model(consts.TableNamePartnerStore).Ctx(ctx).Count()
if err != nil || r > 0 {
return
}
items := []struct {
name, addr, policy string
typ int
lat, lng float64
}{
{"焕新造型工作室", "北京市朝阳区望京SOHO T1-1102", "到店核销佣金 8%", 1, 39.9965, 116.4816},
{"发型研究所(国贸店)", "北京市朝阳区建国门外大街1号", "到店核销佣金 10%", 1, 39.9087, 116.4575},
{"潮服集合店", "北京市朝阳区三里屯太古里19号", "到店核销佣金 6%", 2, 39.9374, 116.4556},
{"简约风服装馆", "北京市海淀区中关村大街27号", "到店核销佣金 6%", 2, 39.9822, 116.3171},
}
for _, it := range items {
if _, err := g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNamePartnerStore+" (name, type, lat, lng, address, commission_policy, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, datetime('now','localtime'))",
it.name, it.typ, it.lat, it.lng, it.addr, it.policy); err != nil {
g.Log().Warningf(ctx, "seed partner_store %s failed: %v", it.name, err)
}
}
}
func (d *partnerStoreDao) List(ctx context.Context, storeType int) ([]*entity.PartnerStore, error) {
m := g.DB().Model(consts.TableNamePartnerStore).Ctx(ctx).Where("status", 1)
if storeType > 0 {
m = m.Where("type", storeType)
}
var list []*entity.PartnerStore
err := m.OrderAsc("id").Scan(&list)
return list, err
}
@@ -0,0 +1,38 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var PayNotifyLog = &payNotifyLogDao{}
type payNotifyLogDao struct{}
func init() {
ctx := context.Background()
_, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePayNotifyLog+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
sign TEXT NOT NULL DEFAULT '',
remote_ip TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ok',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create pay_notify_log table failed: %v", err)
}
}
func (d *payNotifyLogDao) Insert(ctx context.Context, log *entity.PayNotifyLog) error {
_, err := dbPay().Model(consts.TableNamePayNotifyLog).Ctx(ctx).Data(g.Map{
"order_no": log.OrderNo, "body": log.Body, "sign": log.Sign,
"remote_ip": log.RemoteIp, "status": log.Status,
}).Insert()
return err
}
@@ -0,0 +1,94 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
var PaymentOrder = &paymentOrderDao{}
type paymentOrderDao struct{}
func init() {
ctx := context.Background()
_, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePaymentOrder+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL DEFAULT 0,
plan_id INTEGER NOT NULL DEFAULT 0,
amount_fen INTEGER NOT NULL DEFAULT 0,
channel TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
trade_no TEXT NOT NULL DEFAULT '',
notify_raw TEXT NOT NULL DEFAULT '',
paid_at DATETIME,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create payment_order table failed: %v", err)
}
if _, err := dbPay().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`); err != nil {
g.Log().Warningf(ctx, "create index idx_payment_order_user failed: %v", err)
}
}
func (d *paymentOrderDao) Insert(ctx context.Context, order *entity.PaymentOrder) (int64, error) {
r, err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).Data(g.Map{
"order_no": order.OrderNo, "user_id": order.UserId, "plan_id": order.PlanId,
"amount_fen": order.AmountFen, "channel": order.Channel, "status": order.Status,
}).Insert()
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *paymentOrderDao) GetByOrderNo(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) {
var o *entity.PaymentOrder
err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).
Where("order_no", orderNo).Scan(&o)
return o, err
}
// MarkPaid 状态机 pending→paid(只更新 pending 行,返回是否成功,回调并发安全)
func (d *paymentOrderDao) MarkPaid(ctx context.Context, orderNo, tradeNo, notifyRaw string) (bool, error) {
r, err := dbPay().Exec(ctx,
"UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?",
consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending)
if err != nil {
return false, err
}
n, _ := r.RowsAffected()
return n > 0, nil
}
// ===== 事务版本(HandlePaidNotify 回调流程使用,保证订单状态与会员开通原子) =====
func (d *paymentOrderDao) GetByOrderNoTx(ctx context.Context, tx gdb.TX, orderNo string) (*entity.PaymentOrder, error) {
var o *entity.PaymentOrder
err := tx.Model(consts.TableNamePaymentOrder).Ctx(ctx).Where("order_no", orderNo).Scan(&o)
return o, err
}
func (d *paymentOrderDao) MarkPaidTx(ctx context.Context, tx gdb.TX, orderNo, tradeNo, notifyRaw string) (bool, error) {
r, err := tx.Ctx(ctx).Exec(
"UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?",
consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending)
if err != nil {
return false, err
}
n, _ := r.RowsAffected()
return n > 0, nil
}
func (d *paymentOrderDao) GetByUser(ctx context.Context, userId int64) ([]*entity.PaymentOrder, error) {
var list []*entity.PaymentOrder
err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).
Where("user_id", userId).OrderDesc("id").Limit(20).Scan(&list)
return list, err
}
@@ -0,0 +1,96 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"strings"
"github.com/gogf/gf/v2/frame/g"
)
var PlanEffectImage = &planEffectImageDao{}
type planEffectImageDao struct{}
func init() {
ctx := context.Background()
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanEffectImage+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL,
angle TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
prompt_snapshot TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create plan_effect_image table failed: %v", err)
}
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_effect_plan ON "+consts.TableNamePlanEffectImage+"(plan_id)"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_effect_plan failed: %v", err)
}
}
func (d *planEffectImageDao) Insert(ctx context.Context, data *entity.PlanEffectImage) (int64, error) {
r, err := dbPlan().Exec(ctx,
"INSERT INTO "+consts.TableNamePlanEffectImage+" (plan_id, angle, url, status, prompt_snapshot, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
data.PlanId, data.Angle, data.Url, data.Status, data.PromptSnapshot)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
// InsertBatch 批量插入(单条 multi-row SQL,缓存命中已生成的记录直接落库)
func (d *planEffectImageDao) InsertBatch(ctx context.Context, list []*entity.PlanEffectImage) error {
if len(list) == 0 {
return nil
}
var sb strings.Builder
sb.WriteString("INSERT INTO " + consts.TableNamePlanEffectImage +
" (plan_id, angle, url, status, prompt_snapshot, created_at, updated_at) VALUES ")
args := make([]any, 0, len(list)*5)
for i, it := range list {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString("(?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime'))")
args = append(args, it.PlanId, it.Angle, it.Url, it.Status, it.PromptSnapshot)
}
_, err := dbPlan().Exec(ctx, sb.String(), args...)
return err
}
func (d *planEffectImageDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanEffectImage, error) {
var list []*entity.PlanEffectImage
err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).
Where("plan_id", planId).OrderAsc("id").Scan(&list)
return list, err
}
// CountByUserToday 统计用户当日已生成的效果图数量(join outfit_plan 拿 user_id
func (d *planEffectImageDao) CountByUserToday(ctx context.Context, userId int64) (int, error) {
n, err := dbPlan().Model(consts.TableNamePlanEffectImage+" p").
InnerJoin(consts.TableNameOutfitPlan+" o", "p.plan_id = o.id").
Ctx(ctx).
Where("o.user_id", userId).
Where("date(p.created_at) = date('now','localtime')").
Where("p.status IN (?)", g.Slice{consts.EffectStatusDone, consts.EffectStatusRendering}).
Count()
return n, err
}
func (d *planEffectImageDao) UpdateStatus(ctx context.Context, id int64, status, url string) error {
_, err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).Data(g.Map{
"status": status, "url": url, "updated_at": "datetime('now','localtime')",
}).Where("id", id).Update()
return err
}
func (d *planEffectImageDao) DeleteByPlan(ctx context.Context, planId int64) error {
_, err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).
Unscoped().Where("plan_id", planId).Delete()
return err
}
@@ -0,0 +1,79 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
var PlanOutfitItem = &planOutfitItemDao{}
type planOutfitItemDao struct{}
func init() {
ctx := context.Background()
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanOutfitItem+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL,
slot TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'wardrobe',
wardrobe_item_id INTEGER NOT NULL DEFAULT 0,
product_name TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
desc TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create plan_outfit_item table failed: %v", err)
}
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_item ON "+consts.TableNamePlanOutfitItem+"(plan_id)"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_plan_item failed: %v", err)
}
}
func (d *planOutfitItemDao) Insert(ctx context.Context, data *entity.PlanOutfitItem) (int64, error) {
r, err := dbPlan().Exec(ctx,
"INSERT INTO "+consts.TableNamePlanOutfitItem+" (plan_id, slot, source, wardrobe_item_id, product_name, name, desc, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
data.PlanId, data.Slot, data.Source, data.WardrobeItemId, data.ProductName, data.Name, data.Desc)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
// InsertBatchTx 批量插入所有方案的单品(单条 multi-row SQL,事务内)
func (d *planOutfitItemDao) InsertBatchTx(ctx context.Context, tx gdb.TX, items []*entity.PlanOutfitItem) error {
if len(items) == 0 {
return nil
}
var sb strings.Builder
sb.WriteString("INSERT INTO " + consts.TableNamePlanOutfitItem +
" (plan_id, slot, source, wardrobe_item_id, product_name, name, desc, created_at) VALUES ")
args := make([]any, 0, len(items)*7)
for i, it := range items {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString("(?,?,?,?,?,?,?,datetime('now','localtime'))")
args = append(args, it.PlanId, it.Slot, it.Source, it.WardrobeItemId, it.ProductName, it.Name, it.Desc)
}
_, err := tx.Ctx(ctx).Exec(sb.String(), args...)
return err
}
func (d *planOutfitItemDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanOutfitItem, error) {
var list []*entity.PlanOutfitItem
err := dbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx).
Where("plan_id", planId).OrderAsc("id").Scan(&list)
return list, err
}
func (d *planOutfitItemDao) DeleteByPlan(ctx context.Context, planId int64) error {
_, err := dbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx).
Unscoped().Where("plan_id", planId).Delete()
return err
}
+45
View File
@@ -0,0 +1,45 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var PlanReview = &planReviewDao{}
type planReviewDao struct{}
func init() {
ctx := context.Background()
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanReview+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
action TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create plan_review table failed: %v", err)
}
}
func (d *planReviewDao) Insert(ctx context.Context, data *entity.PlanReview) (int64, error) {
r, err := dbPlan().Exec(ctx,
"INSERT INTO "+consts.TableNamePlanReview+" (plan_id, user_id, action, note, created_at) VALUES (?, ?, ?, ?, datetime('now','localtime'))",
data.PlanId, data.UserId, data.Action, data.Note)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *planReviewDao) ListByUserAndPlan(ctx context.Context, userId, planId int64) ([]*entity.PlanReview, error) {
var list []*entity.PlanReview
err := dbPlan().Model(consts.TableNamePlanReview).Ctx(ctx).
Where("user_id", userId).Where("plan_id", planId).OrderDesc("id").Limit(20).Scan(&list)
return list, err
}
@@ -0,0 +1,63 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var SceneCategoryMap = &sceneCategoryMapDao{}
type sceneCategoryMapDao struct{}
func init() {
ctx := context.Background()
_, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSceneCategoryMap+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_type TEXT NOT NULL DEFAULT '',
occasion TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
category_code TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create scene_category_map table failed: %v", err)
}
seedSceneCategoryMap(ctx)
}
func seedSceneCategoryMap(ctx context.Context) {
seeds := []*entity.SceneCategoryMap{
{SceneType: consts.CpsSceneHaircut, Source: consts.CpsSourceMeituanOta, CategoryCode: "beauty"},
{SceneType: consts.CpsSceneItemBuy, Source: consts.CpsSourceJdEcom, CategoryCode: "clothing"},
{SceneType: consts.CpsSceneItemUpgrade, Source: consts.CpsSourceMeituanOta, CategoryCode: "clothing"},
{SceneType: consts.CpsSceneOccasion, Occasion: "通勤", Source: consts.CpsSourceMeituanOta, CategoryCode: "clothing", Priority: 1},
{SceneType: consts.CpsSceneOccasion, Occasion: "约会", Source: consts.CpsSourceMeituanOta, CategoryCode: "food", Priority: 1},
{SceneType: consts.CpsSceneOccasion, Occasion: "旅行", Source: consts.CpsSourceMeituanOta, CategoryCode: "hotel", Priority: 1},
{SceneType: consts.CpsSceneOccasion, Occasion: "运动", Source: consts.CpsSourceMeituanOta, CategoryCode: "ticket", Priority: 1},
}
for _, s := range seeds {
if _, err := dbCps().Exec(ctx,
"INSERT OR IGNORE INTO "+consts.TableNameSceneCategoryMap+
" (scene_type, occasion, source, category_code, priority) VALUES (?, ?, ?, ?, ?)",
s.SceneType, s.Occasion, s.Source, s.CategoryCode, s.Priority); err != nil {
g.Log().Warningf(ctx, "seed scene_category_map %s/%s failed: %v", s.SceneType, s.Occasion, err)
}
}
}
// QueryByScene 场景 → 映射列表(occasion 精确匹配优先,通用匹配兜底)
func (d *sceneCategoryMapDao) QueryByScene(ctx context.Context, sceneType, occasion string) ([]*entity.SceneCategoryMap, error) {
var list []*entity.SceneCategoryMap
m := dbCps().Model(consts.TableNameSceneCategoryMap).Ctx(ctx).Where("scene_type", sceneType)
if occasion != "" {
m = m.Where("occasion", occasion).OrderAsc("priority")
} else {
m = m.Where("occasion", "")
}
err := m.Scan(&list)
return list, err
}
+36
View File
@@ -0,0 +1,36 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var ScoringRule = &scoringRuleDao{}
type scoringRuleDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameScoringRule+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dimension TEXT NOT NULL DEFAULT '',
rule_type TEXT NOT NULL DEFAULT '',
rules_json TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create scoring_rule table failed: %v", err)
}
}
func (d *scoringRuleDao) ListEnabled(ctx context.Context) ([]*entity.ScoringRule, error) {
var list []*entity.ScoringRule
err := g.DB().Model(consts.TableNameScoringRule).Ctx(ctx).
Where("enabled", 1).OrderAsc("id").Scan(&list)
return list, err
}
+95
View File
@@ -0,0 +1,95 @@
package dao
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
)
var User = &userDao{}
type userDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUser+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL DEFAULT 'user',
username TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create user table failed: %v", err)
}
if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_username ON "+consts.TableNameUser+"(username) WHERE username != ''"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_user_username failed: %v", err)
}
if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_phone ON "+consts.TableNameUser+"(phone) WHERE phone != ''"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_user_phone failed: %v", err)
}
}
func clearUserCache(ctx context.Context, id int64) {
_, _ = gcache.Remove(ctx, "user_GetOne_"+gconv.String(id))
_, _ = gcache.Remove(ctx, "user_GetByAccount_")
}
func (d *userDao) Insert(ctx context.Context, data *entity.User) (int64, error) {
r, err := g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameUser+" (role, username, phone, password, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
data.Role, data.Username, data.Phone, data.Password, data.Name)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *userDao) GetOne(ctx context.Context, id int64) (*entity.User, error) {
var u entity.User
err := g.DB().Model(consts.TableNameUser).Ctx(ctx).
Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "user_GetOne_" + gconv.String(id)}).
Where("id", id).Scan(&u)
if err != nil {
return nil, err
}
if u.Id == 0 {
return nil, nil
}
return &u, nil
}
func (d *userDao) GetByAccount(ctx context.Context, account string) (*entity.User, error) {
var u entity.User
err := g.DB().Model(consts.TableNameUser).Ctx(ctx).
Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "user_GetByAccount_" + account}).
Where("username = ? OR phone = ?", account, account).Scan(&u)
if err != nil {
return nil, err
}
if u.Id == 0 {
return nil, nil
}
return &u, nil
}
func (d *userDao) Update(ctx context.Context, data *entity.User) error {
_, err := g.DB().Model(consts.TableNameUser).Ctx(ctx).Data(data).Where("id", data.Id).Update()
clearUserCache(ctx, data.Id)
return err
}
func (d *userDao) UpdateFields(ctx context.Context, id int64, data g.Map) error {
_, err := g.DB().Model(consts.TableNameUser).Ctx(ctx).Data(data).Where("id", id).Update()
clearUserCache(ctx, id)
return err
}

Some files were not shown because too many files have changed in this diff Show More