diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..b184c4a --- /dev/null +++ b/server/.dockerignore @@ -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 diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..380ec5e --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,17 @@ +# 数据库与运行时产物 +slogan.db +*.db +data/ +slogan-agent +workspace/ + +# IDE +.idea/ +*.iml +.vscode/ + +# 系统 +.DS_Store + +# 渲染器依赖 +scripts/avatar-render/node_modules/ diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..a67908d --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/common/auth.go b/server/common/auth.go new file mode 100644 index 0000000..b5b4c6a --- /dev/null +++ b/server/common/auth.go @@ -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 +} diff --git a/server/common/auth_middleware.go b/server/common/auth_middleware.go new file mode 100644 index 0000000..289c1dd --- /dev/null +++ b/server/common/auth_middleware.go @@ -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" +} diff --git a/server/common/base_dao.go b/server/common/base_dao.go new file mode 100644 index 0000000..6bff01c --- /dev/null +++ b/server/common/base_dao.go @@ -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 +} diff --git a/server/common/cache.go b/server/common/cache.go new file mode 100644 index 0000000..8251e9c --- /dev/null +++ b/server/common/cache.go @@ -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 +} diff --git a/server/common/file_storage.go b/server/common/file_storage.go new file mode 100644 index 0000000..c5da339 --- /dev/null +++ b/server/common/file_storage.go @@ -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) +} diff --git a/server/common/http.go b/server/common/http.go new file mode 100644 index 0000000..e9631ea --- /dev/null +++ b/server/common/http.go @@ -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() +} diff --git a/server/common/util.go b/server/common/util.go new file mode 100644 index 0000000..0c659fb --- /dev/null +++ b/server/common/util.go @@ -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 +} diff --git a/server/config.yml b/server/config.yml new file mode 100644 index 0000000..910f93c --- /dev/null +++ b/server/config.yml @@ -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 图像转 3D,key 为空时 /avatar/build 返回失败并提示配置) +avatar: + tripo_api_key: "" + tripo_base: "https://api.tripo3d.ai/v2/openapi" + tripo_model_version: "v2.5-20250123" + poll_interval: 5 # 秒 + poll_timeout: 900 # 秒(15 分钟上限) + render_frames: true # 是否用 Tripo GLB 本地渲染旋转帧预览(frames_url) + +# 3D 化身帧序列预渲染(Node + headless-gl,node_bin 需指向 gl 有预编译二进制的 Node 版本) +render: + enabled: true + node_bin: "/Users/zhangbin/.nvm/versions/node/v18.20.4/bin/node" + +# 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" diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..d523f72 --- /dev/null +++ b/server/docker-compose.yml @@ -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 不可达) diff --git a/server/docs/api.json b/server/docs/api.json new file mode 100644 index 0000000..9b9787d --- /dev/null +++ b/server/docs/api.json @@ -0,0 +1 @@ +{"openapi":"3.0.0","components":{"schemas":{"slogan-agent.styleagent.model.dto.AvatarBuildReq":{"properties":{},"type":"object"},"slogan-agent.styleagent.model.dto.AvatarBuildRes":{"properties":{"avatar_id":{"format":"int64","type":"integer"},"status":{"format":"string","type":"string"}},"type":"object"},"struct":{"properties":{},"type":"object"},"slogan-agent.styleagent.model.dto.AvatarGetRes":{"properties":{"face_template_id":{"format":"int","type":"integer"},"body_template_id":{"format":"int","type":"integer"},"skin_tone_index":{"format":"int","type":"integer"},"glb_url":{"format":"string","type":"string"},"build_status":{"format":"string","type":"string"},"error":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.BodyMeasurementGetRes":{"properties":{"height":{"format":"int","type":"integer"},"weight":{"format":"int","type":"integer"},"skin_tone":{"format":"int","type":"integer"},"fit_params":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.BodyMeasurementSaveReq":{"properties":{"height":{"format":"int","type":"integer"},"weight":{"format":"int","type":"integer"},"skin_tone":{"enum":[1,2,3,4,5],"format":"int","type":"integer"},"fit_params":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.HairstyleListRes":{"properties":{"list":{"format":"[]*entity.HairstyleAsset","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.HairstyleAsset","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.entity.HairstyleAsset":{"properties":{"id":{"format":"int64","type":"integer"},"name":{"format":"string","type":"string"},"style_tag":{"format":"string","type":"string"},"glb_url":{"format":"string","type":"string"},"thumb_url":{"format":"string","type":"string"},"applicable_face":{"format":"string","type":"string"},"sort":{"format":"int","type":"integer"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitGenerateReq":{"properties":{"start_date":{"format":"string","type":"string"},"end_date":{"format":"string","type":"string"},"location":{"format":"string","type":"string"}},"required":["start_date","end_date","location"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitGenerateRes":{"properties":{"task_id":{"format":"int64","type":"integer"}},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitPlanDetailReq":{"properties":{"plan_id":{"format":"int64","type":"integer"}},"required":["plan_id"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitPlanDetailRes":{"properties":{"plan":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.OutfitPlan","description":""},"items":{"format":"[]*entity.PlanOutfitItem","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.PlanOutfitItem","description":""},"type":"array"},"images":{"format":"[]*entity.PlanEffectImage","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.PlanEffectImage","description":""},"type":"array"},"hairstyle":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.HairstyleAsset","description":""}},"type":"object"},"slogan-agent.styleagent.model.entity.OutfitPlan":{"properties":{"id":{"format":"int64","type":"integer"},"task_id":{"format":"int64","type":"integer"},"user_id":{"format":"int64","type":"integer"},"date_range":{"format":"string","type":"string"},"location":{"format":"string","type":"string"},"title":{"format":"string","type":"string"},"source":{"format":"string","type":"string"},"score":{"format":"int","type":"integer"},"main_flag":{"format":"int","type":"integer"},"hairstyle_id":{"format":"int64","type":"integer"},"hair_color":{"format":"string","type":"string"},"weather_ref":{"format":"string","type":"string"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.entity.PlanOutfitItem":{"properties":{"id":{"format":"int64","type":"integer"},"plan_id":{"format":"int64","type":"integer"},"slot":{"format":"string","type":"string"},"source":{"format":"string","type":"string"},"wardrobe_item_id":{"format":"int64","type":"integer"},"product_name":{"format":"string","type":"string"},"name":{"format":"string","type":"string"},"desc":{"format":"string","type":"string"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.entity.PlanEffectImage":{"properties":{"id":{"format":"int64","type":"integer"},"plan_id":{"format":"int64","type":"integer"},"angle":{"format":"string","type":"string"},"url":{"format":"string","type":"string"},"status":{"format":"string","type":"string"},"prompt_snapshot":{"format":"string","type":"string"},"created_at":{"format":"*gtime.Time","type":"string"},"updated_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitPlanListReq":{"properties":{},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitPlanListRes":{"properties":{"list":{"format":"[]*entity.OutfitPlan","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.OutfitPlan","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitReviewReq":{"properties":{"plan_id":{"format":"int64","type":"integer"},"action":{"enum":["fav","unfav"],"format":"string","type":"string"},"note":{"format":"string","type":"string"}},"required":["plan_id","action"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitSelectMainReq":{"properties":{"plan_id":{"format":"int64","type":"integer"}},"required":["plan_id"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitTaskStatusReq":{"properties":{"task_id":{"format":"int64","type":"integer"}},"required":["task_id"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitTaskStatusRes":{"properties":{"status":{"format":"string","type":"string"},"error":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.StoreListReq":{"properties":{"type":{"format":"int","type":"integer"}},"type":"object"},"slogan-agent.styleagent.model.dto.StoreListRes":{"properties":{"list":{"format":"[]*entity.PartnerStore","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.PartnerStore","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.entity.PartnerStore":{"properties":{"id":{"format":"int64","type":"integer"},"name":{"format":"string","type":"string"},"type":{"format":"int","type":"integer"},"lat":{"format":"float64","type":"number"},"lng":{"format":"float64","type":"number"},"address":{"format":"string","type":"string"},"commission_policy":{"format":"string","type":"string"},"status":{"format":"int","type":"integer"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoDeleteReq":{"properties":{"id":{"format":"int64","type":"integer"}},"required":["id"],"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoListReq":{"properties":{"type":{"format":"int","type":"integer"}},"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoListRes":{"properties":{"list":{"format":"[]*entity.UserPhoto","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.UserPhoto","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.entity.UserPhoto":{"properties":{"id":{"format":"int64","type":"integer"},"user_id":{"format":"int64","type":"integer"},"type":{"format":"int","type":"integer"},"url":{"format":"string","type":"string"},"status":{"format":"int","type":"integer"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoUploadReq":{"properties":{"type":{"enum":[1,2,3,4],"format":"int","type":"integer"}},"required":["type"],"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoUploadRes":{"properties":{"id":{"format":"int64","type":"integer"}},"type":"object"},"slogan-agent.styleagent.model.dto.ChangePasswordReq":{"properties":{"old_password":{"format":"string","type":"string"},"new_password":{"format":"string","minLength":6,"type":"string"}},"required":["old_password","new_password"],"type":"object"},"slogan-agent.styleagent.model.dto.LoginReq":{"properties":{"account":{"format":"string","type":"string"},"password":{"format":"string","type":"string"}},"required":["account","password"],"type":"object"},"slogan-agent.styleagent.model.dto.LoginRes":{"properties":{"token":{"format":"string","type":"string"},"user":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.LoginUser","description":""}},"type":"object"},"slogan-agent.styleagent.model.dto.LoginUser":{"properties":{"id":{"format":"int64","type":"integer"},"role":{"format":"string","type":"string"},"name":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.ProfileRes":{"properties":{"id":{"format":"int64","type":"integer"},"role":{"format":"string","type":"string"},"name":{"format":"string","type":"string"},"username":{"format":"string","type":"string"},"phone":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.RegisterReq":{"properties":{"account":{"format":"string","type":"string"},"password":{"format":"string","minLength":6,"type":"string"},"name":{"format":"string","type":"string"}},"required":["account","password"],"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeDeleteReq":{"properties":{"id":{"format":"int64","type":"integer"}},"required":["id"],"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeListReq":{"properties":{"category":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeListRes":{"properties":{"list":{"format":"[]*entity.WardrobeItem","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.WardrobeItem","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.entity.WardrobeItem":{"properties":{"id":{"format":"int64","type":"integer"},"user_id":{"format":"int64","type":"integer"},"photo_url":{"format":"string","type":"string"},"category":{"format":"string","type":"string"},"season":{"format":"string","type":"string"},"style_tags":{"format":"string","type":"string"},"color_info":{"format":"string","type":"string"},"status":{"format":"int","type":"integer"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeUpdateReq":{"properties":{"id":{"format":"int64","type":"integer"},"category":{"format":"string","type":"string"},"season":{"format":"string","type":"string"},"style_tags":{"format":"string","type":"string"}},"required":["id"],"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeUploadReq":{"properties":{"category":{"enum":["上衣","下装","鞋","配饰"],"format":"string","type":"string"},"season":{"format":"string","type":"string"},"style_tags":{"format":"string","type":"string"},"color_info":{"format":"string","type":"string"}},"required":["category"],"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeUploadRes":{"properties":{"id":{"format":"int64","type":"integer"}},"type":"object"}}},"info":{"title":"","version":""},"paths":{"/avatar/build":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarBuildReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarBuildRes","description":""}}},"description":""}},"summary":"构建化身","tags":["化身"]}},"/avatar/get":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"head":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"options":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"patch":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"post":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"put":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"trace":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}}},"/body-measurement/get":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"head":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"options":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"patch":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"post":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"put":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"trace":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}}},"/body-measurement/save":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementSaveReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"保存身形参数","tags":["身形"]}},"/hairstyle/list":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"head":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"options":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"patch":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"post":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"put":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"trace":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}}},"/outfit/generate":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitGenerateReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitGenerateRes","description":""}}},"description":""}},"summary":"生成穿搭方案","tags":["穿搭"]}},"/outfit/plan/detail":{"get":{"parameters":[{"in":"query","name":"plan_id","required":true,"schema":{"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitPlanDetailRes","description":""}}},"description":""}},"summary":"方案详情","tags":["穿搭"]}},"/outfit/plan/list":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitPlanListRes","description":""}}},"description":""}},"summary":"方案列表","tags":["穿搭"]}},"/outfit/plan/review":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitReviewReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"方案反馈","tags":["穿搭"]}},"/outfit/plan/select-main":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitSelectMainReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"选定主方案","tags":["穿搭"]}},"/outfit/task/status":{"get":{"parameters":[{"in":"query","name":"task_id","required":true,"schema":{"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitTaskStatusRes","description":""}}},"description":""}},"summary":"任务状态","tags":["穿搭"]}},"/partner-store/list":{"get":{"parameters":[{"in":"query","name":"type","schema":{"format":"int","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.StoreListRes","description":""}}},"description":""}},"summary":"合作门店列表","tags":["门店"]}},"/user-photo/delete":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.UserPhotoDeleteReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"删除照片","tags":["照片"]}},"/user-photo/list":{"get":{"parameters":[{"in":"query","name":"type","schema":{"format":"int","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.UserPhotoListRes","description":""}}},"description":""}},"summary":"照片列表","tags":["照片"]}},"/user-photo/upload":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.UserPhotoUploadReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.UserPhotoUploadRes","description":""}}},"description":""}},"summary":"上传照片","tags":["照片"]}},"/user/change-password":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ChangePasswordReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"修改密码","tags":["用户"]}},"/user/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.LoginReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.LoginRes","description":""}}},"description":""}},"summary":"登录","tags":["用户"]}},"/user/profile":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"head":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"options":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"patch":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"post":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"put":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"trace":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}}},"/user/register":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.RegisterReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"注册","tags":["用户"]}},"/wardrobe/delete":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeDeleteReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"删除服装","tags":["衣橱"]}},"/wardrobe/list":{"get":{"parameters":[{"in":"query","name":"category","schema":{"format":"string","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeListRes","description":""}}},"description":""}},"summary":"衣橱列表","tags":["衣橱"]}},"/wardrobe/update":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeUpdateReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"更新服装","tags":["衣橱"]}},"/wardrobe/upload":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeUploadReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeUploadRes","description":""}}},"description":""}},"summary":"上传服装","tags":["衣橱"]}}}} \ No newline at end of file diff --git a/server/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md b/server/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md new file mode 100644 index 0000000..9821f7c --- /dev/null +++ b/server/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md @@ -0,0 +1,1485 @@ +# 商业化 P0 实现计划(后端)· slogan-agent + +> **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:** 落地商业化四支柱中的「VIP 会员充值(虎皮椒聚合支付)」与「广告激励(效果图加次 / 体验会员)」,key 未配置时优雅降级。 + +**Architecture:** 沿用 Controller→Service→DAO 三层 + 包级单例。新增 `payment/` 适配器包隔离虎皮椒签名/HTTP;`dao/` 五张新表(slogan_ 前缀沿用仓库规范);效果图限额改造为「基础额度 3 + 广告额外次数」,VIP 用户不限。回调 `/member/order/notify` 因需返回裸文本 `"success"`,用标准 handler 手动绑定并加入 publicPaths。 + +**Tech Stack:** Go 1.22 / GoFrame v2 / SQLite / crypto/md5 / GoFrame http client + +**关联 spec:** `docs/superpowers/specs/2026-07-31-commerce-monetization-design.md` 支柱 A/B + 第 6 节配置。 + +**测试方式(沿用仓库惯例):** 纯逻辑用 `go test`(签名算法、续期计算、限频数学);DAO/接口用启动服务 + sqlite3 + curl 冒烟;支付全链路用本地 mock 虎皮椒服务(`payment.api_base` 可覆盖)。 + +--- + +## 任务总览与文件映射 + +| 任务 | 文件 | +|---|---| +| T1 | `consts/table_name.go`、`consts/status.go`、5 个 entity | +| T2 | `dao/member_plan_dao.go`(含 seed) | +| T3 | `dao/payment_order_dao.go`、`dao/user_member_dao.go`、`dao/pay_notify_log_dao.go` | +| T4 | `payment/gateway.go` + `payment/gateway_test.go` | +| T5 | `service/member_service.go` + `service/member_service_test.go` | +| T6 | `model/dto/dto.go` 追加、`controller/member_controller.go`、`common/auth_middleware.go`、`main.go`、`config.yml` | +| T7 | `dao/ad_reward_log_dao.go`、`service/ad_service.go` + 测试、`controller/ad_controller.go` | +| T8 | `service/effect_image_service.go` 限额改造 | +| T9 | 构建 + 全链路冒烟 | + +--- + +### Task 1: 表名常量、状态常量、5 个 Entity + +**Files:** +- Modify: `styleagent/consts/table_name.go` +- Modify: `styleagent/consts/status.go` +- Create: `styleagent/model/entity/member_plan.go` +- Create: `styleagent/model/entity/payment_order.go` +- Create: `styleagent/model/entity/user_member.go` +- Create: `styleagent/model/entity/pay_notify_log.go` +- Create: `styleagent/model/entity/ad_reward_log.go` + +- [ ] **Step 1: 追加表名常量**(`table_name.go` 末尾) + +```go + TableNameMemberPlan = "slogan_member_plan" + TableNamePaymentOrder = "slogan_payment_order" + TableNameUserMember = "slogan_user_member" + TableNamePayNotifyLog = "slogan_pay_notify_log" + TableNameAdRewardLog = "slogan_ad_reward_log" +``` + +- [ ] **Step 2: 追加状态/类型常量**(`status.go` 末尾) + +```go +// 支付订单状态 +const ( + PayStatusPending = "pending" + PayStatusPaid = "paid" + PayStatusClosed = "closed" +) + +// 广告激励类型 +const ( + AdTypeEffectExtra = "effect_extra" + AdTypeVipTrial = "vip_trial" +) + +// 会员开通来源 +const ( + MemberSourceVipPay = "vip_pay" + MemberSourceAdTrial = "ad_trial" +) +``` + +- [ ] **Step 3: 创建 5 个 Entity**(或m/json 标签,gtime 时间字段,与 spec SQL 列一一对应) + +`model/entity/member_plan.go`: +```go +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type MemberPlan struct { + Id int64 `orm:"id" json:"id"` + Name string `orm:"name" json:"name"` + PriceFen int `orm:"price_fen" json:"price_fen"` + DurationDays int `orm:"duration_days" json:"duration_days"` + Features string `orm:"features" json:"features"` // 权益 JSON 数组字符串 + Sort int `orm:"sort" json:"sort"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} +``` + +`model/entity/payment_order.go`: +```go +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type PaymentOrder struct { + Id int64 `orm:"id" json:"id"` + OrderNo string `orm:"order_no" json:"order_no"` + UserId int64 `orm:"user_id" json:"user_id"` + PlanId int64 `orm:"plan_id" json:"plan_id"` + AmountFen int `orm:"amount_fen" json:"amount_fen"` + Channel string `orm:"channel" json:"channel"` + Status string `orm:"status" json:"status"` + TradeNo string `orm:"trade_no" json:"trade_no"` + NotifyRaw string `orm:"notify_raw" json:"-"` + PaidAt *gtime.Time `orm:"paid_at" json:"paid_at"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} +``` + +`model/entity/user_member.go`: +```go +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type UserMember struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + PlanId int64 `orm:"plan_id" json:"plan_id"` + ExpireAt *gtime.Time `orm:"expire_at" json:"expire_at"` + Source string `orm:"source" json:"source"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} +``` + +`model/entity/pay_notify_log.go`: +```go +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type PayNotifyLog struct { + Id int64 `orm:"id" json:"id"` + OrderNo string `orm:"order_no" json:"order_no"` + Body string `orm:"body" json:"body"` + Sign string `orm:"sign" json:"sign"` + RemoteIp string `orm:"remote_ip" json:"remote_ip"` + Status string `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} +``` + +`model/entity/ad_reward_log.go`: +```go +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type AdRewardLog struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + AdType string `orm:"ad_type" json:"ad_type"` + RewardKey string `orm:"reward_key" json:"reward_key"` + Status string `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} +``` + +- [ ] **Step 4: 编译验证** + +Run: `go build ./...` Expected: 无输出(成功) + +- [ ] **Step 5: 提交** + +```bash +git add styleagent/consts styleagent/model/entity +git commit -m "feat: 商业化 P0 表名/状态常量与实体定义" +``` + +--- + +### Task 2: member_plan DAO(建表 + seed 套餐) + +**Files:** +- Create: `styleagent/dao/member_plan_dao.go` + +- [ ] **Step 1: 创建 DAO**(沿用 partner_store_dao 模式:init 建表 + seed,包级单例) + +```go +package dao + +import ( + "context" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +var MemberPlan = &memberPlanDao{} + +type memberPlanDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB().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 := g.DB().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 { + _, _ = g.DB().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) + } +} + +func (d *memberPlanDao) ListEnabled(ctx context.Context) ([]*entity.MemberPlan, error) { + var list []*entity.MemberPlan + err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx). + Where("status", 1).OrderAsc("sort").OrderAsc("id").Scan(&list) + return list, err +} + +func (d *memberPlanDao) GetOne(ctx context.Context, id int64) (*entity.MemberPlan, error) { + var p *entity.MemberPlan + err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx). + Where("id", id).Where("status", 1).Scan(&p) + return p, err +} +``` + +- [ ] **Step 2: 编译** + +Run: `go build ./...` Expected: 成功 + +- [ ] **Step 3: 提交** + +```bash +git add styleagent/dao/member_plan_dao.go +git commit -m "feat: 会员套餐 DAO(建表 + 月卡/年卡 seed)" +``` + +--- + +### Task 3: payment_order / user_member / pay_notify_log 三个 DAO + +**Files:** +- Create: `styleagent/dao/payment_order_dao.go` +- Create: `styleagent/dao/user_member_dao.go` +- Create: `styleagent/dao/pay_notify_log_dao.go` + +- [ ] **Step 1: payment_order_dao** + +```go +package dao + +import ( + "context" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +var PaymentOrder = &paymentOrderDao{} + +type paymentOrderDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB().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) + } + _, _ = g.DB().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`) +} + +func (d *paymentOrderDao) Insert(ctx context.Context, order *entity.PaymentOrder) (int64, error) { + r, err := g.DB().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 := g.DB().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 := g.DB().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 +} + +func (d *paymentOrderDao) GetByUser(ctx context.Context, userId int64) ([]*entity.PaymentOrder, error) { + var list []*entity.PaymentOrder + err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx). + Where("user_id", userId).OrderDesc("id").Limit(20).Scan(&list) + return list, err +} +``` + +- [ ] **Step 2: user_member_dao**(续期计算放 Service,DAO 只做读写;upsert 用 ON CONFLICT) + +```go +package dao + +import ( + "context" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +var UserMember = &userMemberDao{} + +type userMemberDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserMember+` ( + 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', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME + )`) + if err != nil { + g.Log().Warningf(ctx, "create user_member table failed: %v", err) + } +} + +func (d *userMemberDao) GetByUser(ctx context.Context, userId int64) (*entity.UserMember, error) { + var m *entity.UserMember + err := g.DB().Model(consts.TableNameUserMember).Ctx(ctx). + Where("user_id", userId).Scan(&m) + return m, err +} + +// Upsert 无则插入有则整体覆盖(expire_at 由 Service 算好传入) +func (d *userMemberDao) Upsert(ctx context.Context, userId, planId int64, expireAt, source string) error { + _, err := g.DB().Exec(ctx, + "INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+ + "ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')", + userId, planId, expireAt, source) + return err +} + +// IsVip 当前是否会员(未过期) +func (d *userMemberDao) IsVip(ctx context.Context, userId int64) bool { + n, err := g.DB().Model(consts.TableNameUserMember).Ctx(ctx). + Where("user_id", userId).Where("expire_at > datetime('now','localtime')").Count() + return err == nil && n > 0 +} +``` + +- [ ] **Step 3: pay_notify_log_dao** + +```go +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 := g.DB().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 := g.DB().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 +} +``` + +- [ ] **Step 4: 编译 + 建表冒烟** + +Run: `go build ./... && go run main.go &` → 等服务启动后: +Run: `sqlite3 slogan.db ".tables" | tr ' ' '\n' | grep -E "member|payment|notify"` +Expected: 输出 `slogan_member_plan` `slogan_payment_order` `slogan_user_member` `slogan_pay_notify_log` `slogan_ad_reward_log`(ad_reward 表 T7 才建,此处 4 张)+ 杀掉后台进程 + +- [ ] **Step 5: 提交** + +```bash +git add styleagent/dao +git commit -m "feat: 支付订单/会员/回调日志 DAO" +``` + +--- + +### Task 4: payment 适配器(虎皮棋签名算法,TDD) + +**Files:** +- Create: `styleagent/payment/gateway.go` +- Test: `styleagent/payment/gateway_test.go` + +> 签名算法(参数名升序拼接 `key=value&...` + secret → md5)为虎皮棋经典签名约定;**真实签名规则以官方最新文档为准**,全部收敛在本包内。`api_base` 可配置以便冒烟指向本地 mock。 + +- [ ] **Step 1: 先写失败测试**(`payment/gateway_test.go`) + +```go +package payment + +import "testing" + +func TestSignDeterministic(t *testing.T) { + params := map[string]string{ + "appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90", + } + s1 := Sign(params, "secret123") + s2 := Sign(params, "secret123") + if s1 != s2 { + t.Fatalf("相同参数签名应一致: %s != %s", s1, s2) + } + if s1 == "" { + t.Fatal("签名不应为空") + } +} + +func TestSignChangesWithSecret(t *testing.T) { + params := map[string]string{"appid": "1000", "trade_order_id": "ORDER001"} + if Sign(params, "a") == Sign(params, "b") { + t.Fatal("不同 secret 签名应不同") + } +} + +func TestVerifyNotify(t *testing.T) { + params := map[string]string{ + "appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90", + "status": "OD", "hash": "", + } + hash := Sign(params, "secret123") + if !VerifyNotify(params, hash, "secret123") { + t.Fatal("正确签名应通过验签") + } + params["total_fee"] = "0.01" + if VerifyNotify(params, hash, "secret123") { + t.Fatal("篡改参数后应验签失败") + } + if VerifyNotify(params, hash, "wrong-secret") { + t.Fatal("错误 secret 应验签失败") + } +} + +func TestGetConfigDisabledWhenEmpty(t *testing.T) { + cfg := GetConfig(t.Context()) + if cfg.Enabled { + t.Fatal("默认配置(key 为空)应 disabled") + } +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `go test ./styleagent/payment/...` Expected: FAIL(Sign/VerifyNotify/GetConfig 未定义) + +- [ ] **Step 3: 实现 gateway.go** + +```go +package payment + +import ( + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" +) + +// 虎皮棋聚合支付适配器:签名/HTTP 细节全部收敛在本包,业务层不感知。 +// 注意:签名规则与字段名以官方最新文档为准(当前实现为经典 md5 约定)。 + +type Config struct { + AppId string + AppSecret string + NotifyUrl string + Channel string // 逗号分隔,如 "alipay,wechat" + ApiBase string + Enabled bool +} + +func GetConfig(ctx context.Context) Config { + cfg := Config{ + AppId: g.Cfg().MustGet(ctx, "payment.xunhu_appid", "").String(), + AppSecret: g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String(), + NotifyUrl: g.Cfg().MustGet(ctx, "payment.notify_url", "").String(), + Channel: g.Cfg().MustGet(ctx, "payment.channel", "alipay").String(), + ApiBase: g.Cfg().MustGet(ctx, "payment.api_base", "https://api.xunhupay.com").String(), + } + cfg.Enabled = cfg.AppId != "" && cfg.AppSecret != "" + return cfg +} + +// CreateOrder 创建支付单,返回收银台/支付 URL(金额单位:分) +func CreateOrder(ctx context.Context, orderNo string, amountFen int) (payURL string, err error) { + cfg := GetConfig(ctx) + if !cfg.Enabled { + return "", errors.New("支付未开通,请在 config.yml 配置 payment") + } + channel := "alipay" + if first := strings.Split(cfg.Channel, ",")[0]; first != "" { + channel = first + } + params := map[string]string{ + "appid": cfg.AppId, + "trade_order_id": orderNo, + "total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100), + "title": "形象会员", + "notify_url": cfg.NotifyUrl, + "type": channel, + "version": "1.1", + "nonce_str": nonce(), + } + params["hash"] = Sign(params, cfg.AppSecret) + + var resp struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + Url string `json:"url"` + } + if _, err := g.Client().SetTimeout(10 * time.Second).Post(context.Background(), cfg.ApiBase+"/payment/do.html", params, &resp); err != nil { + return "", fmt.Errorf("虎皮棋下单失败: %w", err) + } + if resp.Errcode != 0 { + return "", fmt.Errorf("虎皮棋下单失败: %s", resp.Errmsg) + } + if resp.Url == "" { + return "", errors.New("虎皮棋下单失败: 返回为空") + } + return resp.Url, nil +} + +// Sign 参数名升序拼接 key=value,追加 secret 后 md5 hex +func Sign(params map[string]string, secret string) string { + keys := make([]string, 0, len(params)) + for k := range params { + if params[k] == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + for i, k := range keys { + if i > 0 { + sb.WriteString("&") + } + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(params[k]) + } + sb.WriteString(secret) + sum := md5.Sum([]byte(sb.String())) + return hex.EncodeToString(sum[:]) +} + +// VerifyNotify 验签:复制参数去掉 hash 后重算签名比较 +func VerifyNotify(params map[string]string, hash, secret string) bool { + if hash == "" || secret == "" { + return false + } + cp := make(map[string]string, len(params)) + for k, v := range params { + if k != "hash" { + cp[k] = v + } + } + return Sign(cp, secret) == strings.ToLower(hash) +} + +func nonce() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} +``` + +> `g.Client().SetTimeout(10 * time.Second)` 为 10 秒超时,需 import "time"。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `go test ./styleagent/payment/... -v` Expected: PASS(4 个用例) + +- [ ] **Step 5: 提交** + +```bash +git add styleagent/payment +git commit -m "feat: 虎皮棋支付适配器(签名/下单/验签,未配置降级)" +``` + +--- + +### Task 5: member service(套餐/状态/下单/回调处理,TDD 续期计算) + +**Files:** +- Create: `styleagent/service/member_service.go` +- Test: `styleagent/service/member_service_test.go` + +- [ ] **Step 1: 先写失败测试**(续期纯函数) + +```go +package service + +import ( + "testing" + "time" + + "github.com/gogf/gf/v2/os/gtime" +) + +func TestNextExpireFromNow(t *testing.T) { + got := NextExpire(nil, 30) + want := time.Now().Add(30 * 24 * time.Hour).Format("2006-01-02 15:04:05") + gotT, _ := time.Parse("2006-01-02 15:04:05", got) + wantT, _ := time.Parse("2006-01-02 15:04:05", want) + if !gotT.Equal(wantT) { + t.Fatalf("过期会员应从现在起算: got=%s want~%s", got, want) + } +} + +func TestNextExpireStackOnFuture(t *testing.T) { + base := gtime.NewFromTime(time.Now().Add(10 * 24 * time.Hour)) + got := NextExpire(base, 30) + gotT, _ := time.Parse("2006-01-02 15:04:05", got) + if gotT.Before(base.Time) { + t.Fatalf("未过期会员应叠加: got=%s base=%s", got, base.Format("2006-01-02 15:04:05")) + } +} +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `go test ./styleagent/service/... -run "TestNextExpire"` Expected: FAIL(NextExpire 未定义) + +- [ ] **Step 3: 实现 member_service.go** + +```go +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" + "slogan-agent/styleagent/payment" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +type memberService struct{} + +var MemberService = new(memberService) + +func (s *memberService) PlanList(ctx context.Context) ([]*entity.MemberPlan, error) { + return dao.MemberPlan.ListEnabled(ctx) +} + +type MemberStatus struct { + IsVip bool `json:"is_vip"` + ExpireAt string `json:"expire_at"` + PlanName string `json:"plan_name"` + Benefits []string `json:"benefits"` +} + +func (s *memberService) Status(ctx context.Context, userId int64) (*MemberStatus, error) { + st := &MemberStatus{Benefits: make([]string, 0)} + um, err := dao.UserMember.GetByUser(ctx, userId) + if err != nil { + return nil, err + } + if um == nil || um.ExpireAt == nil || um.ExpireAt.Time.Before(time.Now()) { + return st, nil + } + st.IsVip = true + st.ExpireAt = um.ExpireAt.Format("2006-01-02 15:04:05") + if plan, _ := dao.MemberPlan.GetOne(ctx, um.PlanId); plan != nil { + st.PlanName = plan.Name + st.Benefits = parseBenefits(plan.Features) + } + return st, nil +} + +// CreateOrder 下单:生成业务订单号 → 虎皮棋下单 → 返回支付 URL +func (s *memberService) CreateOrder(ctx context.Context, userId, planId int64) (*entity.PaymentOrder, string, error) { + plan, err := dao.MemberPlan.GetOne(ctx, planId) + if err != nil { + return nil, "", err + } + if plan == nil { + return nil, "", errors.New("套餐不存在") + } + order := &entity.PaymentOrder{ + OrderNo: fmt.Sprintf("M%d%d", time.Now().UnixNano()/1e6, userId%1000), + UserId: userId, + PlanId: planId, + AmountFen: plan.PriceFen, + Channel: "alipay", + Status: consts.PayStatusPending, + } + if _, err := dao.PaymentOrder.Insert(ctx, order); err != nil { + return nil, "", err + } + payURL, err := payment.CreateOrder(ctx, order.OrderNo, plan.PriceFen) + if err != nil { + return nil, "", err + } + return order, payURL, nil +} + +func (s *memberService) OrderStatus(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) { + return dao.PaymentOrder.GetByOrderNo(ctx, orderNo) +} + +// HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,成功开通/续期 +func (s *memberService) HandlePaidNotify(ctx context.Context, orderNo, tradeNo, notifyRaw string) (string, error) { + order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo) + if err != nil { + return "no_order", err + } + if order == nil { + return "no_order", nil + } + ok, err := dao.PaymentOrder.MarkPaid(ctx, orderNo, tradeNo, notifyRaw) + if err != nil { + return "no_order", err + } + if !ok { + return "duplicate", nil // 已是 paid 或已关闭 + } + days := 30 + if plan, _ := dao.MemberPlan.GetOne(ctx, order.PlanId); plan != nil { + days = plan.DurationDays + } + um, _ := dao.UserMember.GetByUser(ctx, order.UserId) + expireAt := NextExpire(um.ExpireAt, days) + if err := dao.UserMember.Upsert(ctx, order.UserId, order.PlanId, expireAt, consts.MemberSourceVipPay); err != nil { + return "no_order", err + } + g.Log().Infof(ctx, "会员开通成功 user=%d order=%s expire=%s", order.UserId, orderNo, expireAt) + return "ok", nil +} + +// NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算 +func NextExpire(old *gtime.Time, days int) string { + base := time.Now() + if old != nil && old.Time.After(base) { + base = old.Time + } + return base.Add(time.Duration(days) * 24 * time.Hour).Format("2006-01-02 15:04:05") +} + +func parseBenefits(features string) []string { + var list []string + _ = json.Unmarshal([]byte(features), &list) + if list == nil { + list = make([]string, 0) + } + return list +} +``` + +> 需要 import `"encoding/json"`。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `go test ./styleagent/service/... -run "TestNextExpire" -v` Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add styleagent/service/member_service.go styleagent/service/member_service_test.go +git commit -m "feat: 会员服务(套餐/状态/下单/回调幂等续期)" +``` + +--- + +### Task 6: member controller + DTO + 回调路由 + 配置 + +**Files:** +- Modify: `styleagent/model/dto/dto.go`(追加) +- Create: `styleagent/controller/member_controller.go` +- Modify: `common/auth_middleware.go`(publicPaths) +- Modify: `main.go`(注册控制器 + 回调裸文本路由) +- Modify: `config.yml`(payment 段) + +- [ ] **Step 1: dto.go 追加会员 DTO** + +```go +type MemberPlanListReq struct { + g.Meta `path:"/plan/list" method:"get" tags:"会员" summary:"会员套餐列表"` +} + +type MemberPlanListRes struct { + List []*entity.MemberPlan `json:"list"` +} + +type MemberStatusReq struct { + g.Meta `path:"/status" method:"get" tags:"会员" summary:"我的会员状态"` +} + +type MemberStatusRes struct { + IsVip bool `json:"is_vip"` + ExpireAt string `json:"expire_at"` + PlanName string `json:"plan_name"` + Benefits []string `json:"benefits"` +} + +type MemberOrderCreateReq struct { + g.Meta `path:"/order/create" method:"post" tags:"会员" summary:"创建支付订单"` + PlanId int64 `v:"required" json:"plan_id"` +} + +type MemberOrderCreateRes struct { + OrderNo string `json:"order_no"` + PayUrl string `json:"pay_url"` +} + +type MemberOrderStatusReq struct { + g.Meta `path:"/order/status" method:"get" tags:"会员" summary:"订单状态"` + OrderNo string `v:"required" json:"order_no"` +} + +type MemberOrderStatusRes struct { + Status string `json:"status"` + TradeNo string `json:"trade_no"` + PaidAt string `json:"paid_at"` +} +``` + +- [ ] **Step 2: member_controller.go**(struct 名 `member` → 路由前缀 `/member`) + +```go +package controller + +import ( + "context" + + commonHttp "slogan-agent/common" + "slogan-agent/styleagent/model/dto" + "slogan-agent/styleagent/service" +) + +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.MemberService.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.MemberService.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 +} + +// OrderCreate 下单 → 返回支付 URL +func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq) (res *dto.MemberOrderCreateRes, err error) { + order, payURL, err := service.MemberService.CreateOrder(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.MemberService.OrderStatus(ctx, req.OrderNo) + if err != nil || order == nil { + return nil, errors.New("订单不存在") + } + paidAt := "" + if order.PaidAt != nil { + paidAt = order.PaidAt.Format("2006-01-02 15:04:05") + } + return &dto.MemberOrderStatusRes{Status: order.Status, TradeNo: order.TradeNo, PaidAt: paidAt}, nil +} +``` + +> 需要 import `"errors"` 与 `"github.com/gogf/gf/v2/frame/g"`(`g.RequestFromCtx`)。 + +- [ ] **Step 3: 回调裸文本 handler(member_controller.go 追加,不用 2 参签名)** + +```go +// MemberNotify 虎皮棋支付回调:验签 → 幂等开通 → 返回裸文本 "success" +// 虎皮棋要求回调响应体为字面 "success",故不走统一 JSON 包装 +func MemberNotify(r *ghttp.Request) { + ctx := r.Context() + body := r.GetRawString() + 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 := payment.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String()) + + if !ok { + _ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{ + OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: "bad_sign", + }) + r.Response.Write("fail") + r.ExitAll() + return + } + + state, err := service.MemberService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body) + _ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{ + OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: state, + }) + if err != nil || state != "ok" { + r.Response.Write("fail") + r.ExitAll() + return + } + r.Response.Write("success") + r.ExitAll() +} +``` + +> import 追加:`"fmt"`、`"github.com/gogf/gf/v2/net/ghttp"`、`"slogan-agent/styleagent/dao"`、`"slogan-agent/styleagent/model/entity"`、`"slogan-agent/styleagent/payment"`。 + +- [ ] **Step 4: publicPaths 放行回调**(`common/auth_middleware.go` 的 publicPaths 数组加一项) + +```go + "/member/order/notify", +``` + +- [ ] **Step 5: main.go 注册**(RouteRegister 数组加 `controller.Member`,并手动绑定回调路由) + +```go + commonHttp.RouteRegister([]interface{}{ + controller.User, + controller.UserPhoto, + controller.Wardrobe, + controller.BodyMeasurement, + controller.Avatar, + controller.Hairstyle, + controller.Outfit, + controller.PartnerStore, + controller.Member, + }) + + // 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装) + commonHttp.Httpserver.Group("/member/order", func(group *ghttp.RouterGroup) { + group.POST("/notify", controller.MemberNotify) + }) +``` + +- [ ] **Step 6: config.yml 追加 payment 段** + +```yaml +# 支付(虎皮棋聚合支付,key 为空则支付功能降级关闭) +payment: + xunhu_appid: "" + xunhu_appsecret: "" + notify_url: "http://localhost:3007/member/order/notify" # 生产需公网可达 + channel: "alipay,wechat" + api_base: "https://api.xunhupay.com" +``` + +- [ ] **Step 7: 编译 + 冒烟(降级路径)** + +Run: `go build ./...` Expected: 成功 +Run: 启动服务 → `curl -s http://127.0.0.1:3007/member/plan/list -H "Authorization: Bearer $TOKEN"` +Expected: `{"code":0,...,"data":{"list":[{"name":"月卡 ¥29.9","price_fen":2990,...},...]}}`(2 个套餐) +Run: `curl -s -X POST http://127.0.0.1:3007/member/order/create -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"plan_id":1}'` +Expected: code=0 但 data 为 null?—— 不对:CreateOrder 返回 error → 统一响应 `{"code":-1,"message":"支付未开通,请在 config.yml 配置 payment",...}`(降级路径正确) +Run: `curl -s http://127.0.0.1:3007/member/order/notify` (无鉴权) +Expected: 返回 `fail`(key 为空验签失败),且 `pay_notify_log` 多一条 bad_sign 记录 +Run: `sqlite3 slogan.db "SELECT status FROM slogan_pay_notify_log ORDER BY id DESC LIMIT 1"` +Expected: `bad_sign` + +- [ ] **Step 8: 提交** + +```bash +git add styleagent/model/dto/dto.go styleagent/controller/member_controller.go common/auth_middleware.go main.go config.yml +git commit -m "feat: 会员接口(套餐/状态/下单/回调)+ 支付配置" +``` + +--- + +### Task 7: 广告激励(ad_reward DAO + service + controller,TDD 限频) + +**Files:** +- Create: `styleagent/dao/ad_reward_log_dao.go` +- Create: `styleagent/service/ad_service.go` +- Test: `styleagent/service/ad_service_test.go` +- Create: `styleagent/controller/ad_controller.go` +- Modify: `styleagent/model/dto/dto.go` +- Modify: `main.go`、`config.yml` + +- [ ] **Step 1: ad_reward_log DAO**(唯一索引 `user_id+reward_key` 防并发重复) + +```go +package dao + +import ( + "context" + "fmt" + "time" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +var AdRewardLog = &adRewardLogDao{} + +type adRewardLogDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB().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 '', + 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) + } + _, _ = g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key)`) +} + +// rewardKey 自然日去重粒度:"2026-07-31:effect_extra" +func rewardKey(adType string) string { + return fmt.Sprintf("%s:%s", time.Now().Format("2006-01-02"), adType) +} + +func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) { + n, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx). + Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count() + return int(n), err +} + +// Insert 领取记录(唯一索引冲突即返回错误 → 视为限频) +func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string) (int64, error) { + r, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{ + "user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "status": "ok", + }).Insert() + if err != nil { + return 0, err + } + return r.LastInsertId() +} +``` + +- [ ] **Step 2: 先写限频数学失败测试**(`service/ad_service_test.go`) + +```go +package service + +import "testing" + +func TestRewardRemaining(t *testing.T) { + if got := rewardRemaining(2, 0); got != 2 { + t.Fatalf("未领取时剩余应为 2, got %d", got) + } + if got := rewardRemaining(2, 2); got != 0 { + t.Fatalf("已用满时剩余应为 0, got %d", got) + } + if got := rewardRemaining(1, 1); got != 0 { + t.Fatalf("vip_trial 已用完剩余应为 0, got %d", got) + } +} + +func TestRewardQuota(t *testing.T) { + if got := rewardQuota(t.Context(), "effect_extra"); got != 2 { + t.Fatalf("默认每日 2 次, got %d", got) + } +} +``` + +- [ ] **Step 3: 运行确认失败** + +Run: `go test ./styleagent/service/... -run "TestReward"` Expected: FAIL(函数未定义) + +- [ ] **Step 4: 实现 ad_service.go** + +```go +package service + +import ( + "context" + "errors" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + + "github.com/gogf/gf/v2/frame/g" +) + +type adService struct{} + +var AdService = new(adService) + +type AdRewardResult struct { + AdType string `json:"ad_type"` + RemainingToday int `json:"remaining_today"` +} + +// Claim 领取广告激励:服务端限频计数,不信任客户端 +func (s *adService) Claim(ctx context.Context, userId int64, adType string) (*AdRewardResult, error) { + if adType != consts.AdTypeEffectExtra && adType != consts.AdTypeVipTrial { + return nil, errors.New("无效的广告类型") + } + limit := rewardQuota(ctx, adType) + used, err := dao.AdRewardLog.CountTodayByType(ctx, userId, adType) + if err != nil { + return nil, err + } + if used >= limit { + return nil, errors.New("今日次数已用完") + } + if _, err := dao.AdRewardLog.Insert(ctx, userId, adType); err != nil { + return nil, errors.New("今日次数已用完") // 唯一索引兜底并发 + } + if adType == consts.AdTypeVipTrial { + _ = dao.UserMember.Upsert(ctx, userId, 0, NextExpire(nil, 1), consts.MemberSourceAdTrial) + } + return &AdRewardResult{AdType: adType, RemainingToday: limit - used - 1}, nil +} + +func rewardQuota(ctx context.Context, adType string) int { + if adType == consts.AdTypeVipTrial { + return g.Cfg().MustGet(ctx, "ad.limit_vip_trial", 1).Int() + } + return g.Cfg().MustGet(ctx, "ad.limit_effect_extra", 2).Int() +} + +func rewardRemaining(limit, used int) int { + if r := limit - used; r > 0 { + return r + } + return 0 +} +``` + +- [ ] **Step 5: DTO 追加**(AdRewardInfo 定义在 dto 包内,controller 从 service 结果转换) + +```go +type AdRewardClaimReq struct { + g.Meta `path:"/reward/claim" method:"post" tags:"广告" summary:"领取广告激励"` + AdType string `v:"required|in:effect_extra,vip_trial" json:"ad_type"` +} + +type AdRewardInfo struct { + AdType string `json:"ad_type"` + RemainingToday int `json:"remaining_today"` +} + +type AdRewardClaimRes struct { + Reward *AdRewardInfo `json:"reward"` +} +``` + +- [ ] **Step 6: ad_controller.go**(struct 名 `ad` → 前缀 `/ad`) + +```go +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 +} +``` + +- [ ] **Step 7: 注册 + 配置**(main.go RouteRegister 加 `controller.Ad`;config.yml 加 ad 段) + +```yaml +# 广告激励限频(自然日) +ad: + limit_effect_extra: 2 + limit_vip_trial: 1 +``` + +- [ ] **Step 8: 测试 + 编译 + 冒烟** + +Run: `go test ./styleagent/service/... -run "TestReward" -v` Expected: PASS +Run: `go build ./...` Expected: 成功 +Run: 启动服务 → 依次 `curl -X POST .../ad/reward/claim -d '{"ad_type":"effect_extra"}'` 三次(带 TOKEN) +Expected: 前两次 `{"code":0,...,"data":{"reward":{"ad_type":"effect_extra","remaining_today":1}}} / remaining_today:0`,第三次 `{"code":-1,"message":"今日次数已用完"}` +Run: `sqlite3 slogan.db "SELECT reward_key, COUNT(*) FROM slogan_ad_reward_log GROUP BY reward_key"` +Expected: 当天 key 计数 2 + +- [ ] **Step 9: 提交** + +```bash +git add styleagent/dao/ad_reward_log_dao.go styleagent/service/ad_service.go styleagent/service/ad_service_test.go styleagent/controller/ad_controller.go styleagent/model/dto/dto.go main.go config.yml +git commit -m "feat: 广告激励(效果图加次/体验会员,服务端限频防刷)" +``` + +--- + +### Task 8: 效果图限额改造(VIP 不限 + 广告加次) + +**Files:** +- Modify: `styleagent/service/effect_image_service.go`(`run` 方法限额判定) + +- [ ] **Step 1: 修改限额判定**(原 40-44 行) + +```go + // 每日限额:VIP 不限;普通用户 = 基础额度 + 广告激励额外次数 + if !dao.UserMember.IsVip(ctx, userId) { + limit := dailyEffectLimit(ctx) + if limit > 0 { + used, _ := dao.PlanEffectImage.CountByUserToday(ctx, userId) + extra, _ := dao.AdRewardLog.CountTodayByType(ctx, userId, consts.AdTypeEffectExtra) + if used >= limit+extra { + g.Log().Warningf(ctx, "效果图任务: 用户 %d 当日次数已用尽(%d/%d)", userId, used, limit+extra) + return + } + } + } +``` + +- [ ] **Step 2: 编译** + +Run: `go build ./...` Expected: 成功 + +- [ ] **Step 3: 提交** + +```bash +git add styleagent/service/effect_image_service.go +git commit -m "feat: 效果图限额支持 VIP 不限与广告加次" +``` + +--- + +### Task 9: 全链路冒烟(mock 虎皮棋 + 回调验签 + 会员开通) + +**Files:** 无(临时文件 `/tmp/mock_xunhu.py`) + +- [ ] **Step 1: 写本地 mock 虎皮棋服务**(`/tmp/mock_xunhu.py`,签名算法与服务端一致) + +```python +import hashlib, json +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import parse_qs + +SECRET = "test-secret" +APPID = "test-appid" + +def sign(params: dict) -> str: + parts = [] + for k in sorted(params): + if params[k] == "": + continue + parts.append(f"{k}={params[k]}") + return hashlib.md5(("&".join(parts) + SECRET).encode()).hexdigest() + +class H(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length).decode() + p = {k: v[0] for k, v in parse_qs(body).items()} + if self.path == "/payment/do.html": + resp = {"errcode": 0, "errmsg": "ok", "url": "http://localhost:3998/pay?order=" + p["trade_order_id"]} + self.send_response(200); self.send_header("Content-Type", "application/json") + self.end_headers(); self.wfile.write(json.dumps(resp).encode()) + else: + self.send_response(404); self.end_headers() + def do_GET(self): + self.send_response(200); self.end_headers() + self.wfile.write(b"fake cashier page") + +HTTPServer(("127.0.0.1", 3998), H).serve_forever() +``` + +- [ ] **Step 2: 临时配置指向 mock**(`config.yml` payment 段替换;先备份) + +```yaml +payment: + xunhu_appid: "test-appid" + xunhu_appsecret: "test-secret" + notify_url: "http://localhost:3007/member/order/notify" + channel: "alipay,wechat" + api_base: "http://127.0.0.1:3998" +``` + +- [ ] **Step 3: 重启服务,执行全链路** + +```bash +cp config.yml /tmp/config.yml.bak +python3 /tmp/mock_xunhu.py & # mock 先起 +go run main.go & # 后端(先改好 config.yml) +# 1) 下单 +curl -s -X POST http://127.0.0.1:3007/member/order/create -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"plan_id":1}' +# → {"order_no":"M...","pay_url":"http://localhost:3998/pay?order=M..."} +# 2) 模拟回调(用 mock 的签名算法对参数签名,python 现场算) +ORDER=上面返回的order_no +PAYLOAD=$(python3 -c " +import sys +p = {'appid':'test-appid','trade_order_id':'$ORDER','total_fee':'29.90','status':'OD','transaction_id':'TX001','notify_url':'http://localhost:3007/member/order/notify'} +def sign(d): + parts=[] + for k in sorted(d): + if d[k]=='': continue + parts.append(f'{k}={d[k]}') + import hashlib + return hashlib.md5(('&'.join(parts)+'test-secret').encode()).hexdigest() +h = sign(p) +p['hash']=h +import urllib.parse +print(urllib.parse.urlencode(p)) +") +curl -s -X POST http://127.0.0.1:3007/member/order/notify -d "$PAYLOAD" +# → success +# 3) 订单状态 +curl -s "http://127.0.0.1:3007/member/order/status?order_no=$ORDER" -H "Authorization: Bearer $TOKEN" +# → {"status":"paid","trade_no":"TX001",...} +# 4) 会员状态 +curl -s http://127.0.0.1:3007/member/status -H "Authorization: Bearer $TOKEN" +# → {"is_vip":true,"expire_at":"...","plan_name":"月卡 ¥29.9","benefits":["effect_unlimited","cps_commission_x15"]} +# 5) 幂等:重复回调 +curl -s -X POST http://127.0.0.1:3007/member/order/notify -d "$PAYLOAD" +# → success(幂等);pay_notify_log 出现 duplicate 记录 +sqlite3 slogan.db "SELECT status FROM slogan_pay_notify_log ORDER BY id DESC LIMIT 2" +# → duplicate / ok +``` + +- [ ] **Step 4: 验证 VIP 效果图限额 + 广告加次** + +```bash +# vip 用户选定主方案 → 效果图生成不再受 3 次限制(日志无"当日次数已用尽") +# 非 vip:先领 2 次 effect_extra,再生成 → 当日额度 3+2=5(多生成 2 张验证计数生效) +``` + +- [ ] **Step 5: 恢复配置并清理** + +```bash +cp /tmp/config.yml.bak config.yml +kill %1 %2 2>/dev/null +git diff --stat # 期望无 config.yml 变更 +``` + +- [ ] **Step 6: 提交最终状态** + +```bash +git status # 确认无残留 +git log --oneline -5 +``` + +--- + +## 自检清单 + +- [ ] 5 张表名、5 个 entity 与 spec SQL 列一致(表名前缀 slogan_ 为仓库规范差异,已注明) +- [ ] `/member/order/notify` 在 publicPaths + 手动裸文本绑定,验签失败记 bad_sign +- [ ] 支付/广告 key 未配置 → 明确错误信息,不 panic +- [ ] 回调幂等:MarkPaid 状态机 + pay_notify_log 审计 +- [ ] 效果图限额 = 基础 3 + 广告额外次数;VIP 跳过限额 +- [ ] `go build`、`go test ./...`、全链路冒烟全部通过 + +## 后续计划(P1,不在本计划内) + +CPS 统一引擎(4 表 + Provider 抽象 + 美团联盟适配器 + 方案驱动推荐 + 6 接口)、客户端会员中心与 CPS 入口、P2 京东/淘宝适配器与广告 SDK。 diff --git a/server/docs/superpowers/plans/2026-07-31-slogan-agent-mvp.md b/server/docs/superpowers/plans/2026-07-31-slogan-agent-mvp.md new file mode 100644 index 0000000..f2df8a7 --- /dev/null +++ b/server/docs/superpowers/plans/2026-07-31-slogan-agent-mvp.md @@ -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,路由表注册 controller,workspace 鉴权静态服务,端口 3007) + +- [ ] **Step 5: 添加依赖并编译** + +```bash +go mod tidy +go build ./... +``` + +Expected: 编译通过(common 包复制可能依赖 gtime/gcache,tidy 解决)。 + +- [ ] **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: 全部 DAO(init 自动建表) + +**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() 建表 + 核心查询方法(按字段):ListByUser(user_photo/wardrobe_item 按 user_id 分页)、GetByUserAndType、GetByUser(avatar/body 单行)、ListByPlan(plan_outfit_item/plan_effect_image)、GetByTask(outfit_plan 列表)、ListAll(hairstyle_asset 按 sort)、GetEnabled(scoring_rule)、UpdateStatus(task 状态流转) + +- [ ] **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 同理;body:save→get 往返) + +- [ ] **Step 3: 实现三个 service**:upload 校验(单张 ≤10MB、jpg/png/webp 扩展名校验)→ SaveUploadedFile → dao.Insert;list 按 user_id;delete 校验归属(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_model(build_status=pending)→ 异步 goroutine 执行 processing → done(v1 同步简化:直接 done + glb_url 用 packer 生成的路径);Get(ctx, userId) 返回最新 avatar_model + +- [ ] **Step 6: avatar_controller.go**:Build/Get 绑定 dto;Build 返回 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: 写失败测试**(cache:get→miss→set→hit;TTL 过期) + +- [ ] **Step 2: 实现 cache.go**(内存 map + mutex,key=`{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: Agent(chat_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`(核心逻辑 mock:weather/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.PlanOutfits(1 次 LLM) + // 5. 规则评分每套 → 任务状态 scoring + // 6. 3 套全 < 阈值 → Agent.CreateRecommendPlan(1 次 LLM)→ 新套装标 recommend + // 7. 落库 outfit_plan(hairstyle_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_id,worker 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: 写失败测试**(cache:plan 内容 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 | mock(config 无 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 images(mock 路径) + 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 diff --git a/server/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md b/server/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md new file mode 100644 index 0000000..e7528ee --- /dev/null +++ b/server/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md @@ -0,0 +1,293 @@ +# 商业化四支柱设计(后端)· slogan-agent + +> **目标:** 以「个人形象设计」为主题业务,落地四支柱收入:VIP 会员充值、穿山甲广告、线下门店引流(OTA 联盟)、线上商品(电商联盟 CPS)。 +> **核心原则:** 商业化从「方案/单品」长出,不做泛化场景广场。所有推荐由方案已有字段驱动,**零新增 LLM 调用**。 + +## 1. 总体架构 + +``` +App(slogan-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.yml(cps.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/notify(RSA 验签) +后端 → 幂等校验(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_member,source=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 同模式) diff --git a/server/docs/superpowers/specs/2026-07-31-slogan-agent-design.md b/server/docs/superpowers/specs/2026-07-31-slogan-agent-design.md new file mode 100644 index 0000000..92031dc --- /dev/null +++ b/server/docs/superpowers/specs/2026-07-31-slogan-agent-design.md @@ -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) / SQLite(GoFrame 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.go(OpenAI 兼容 API,指数退避重试)+ react_agent.go + tools.go + context.go | +| 模型配置 | 系统配置 + 用户配置 → MergedModelConfig(复用 model_config / user_model_config 表模式) | +| 异步任务 | 生成任务表 + 后台轮询(复用 GenerationService.StartPoller 模式,15s 间隔) | +| 文件存储 | `workspace/` 目录 + JWT 鉴权静态文件服务(BindHandler 方式,防路径穿越) | +| 参数校验 | gvalid(main.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-factory(auth / 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+Blender,CI 运行,不入运行时) +│ ├── 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=wardrobe;3 套全 <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 校验测试 + 工具 mock(chat_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 diff --git a/server/docs/项目文档.md b/server/docs/项目文档.md new file mode 100644 index 0000000..3dabfd2 --- /dev/null +++ b/server/docs/项目文档.md @@ -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 `(JWT,7 天有效)。 + +| 模块 | 路径 | 说明 | 公开 | +|------|------|------|------| +| 用户 | `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/ 每表一 DAO(init 自动建表 + 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。 diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..bb896b6 --- /dev/null +++ b/server/go.mod @@ -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 +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..a092557 --- /dev/null +++ b/server/go.sum @@ -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= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..c26e571 --- /dev/null +++ b/server/main.go @@ -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") +} diff --git a/server/scripts/avatar-render/package-lock.json b/server/scripts/avatar-render/package-lock.json new file mode 100644 index 0000000..1f63996 --- /dev/null +++ b/server/scripts/avatar-render/package-lock.json @@ -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" + } + } + } +} diff --git a/server/scripts/avatar-render/package.json b/server/scripts/avatar-render/package.json new file mode 100644 index 0000000..b6a7337 --- /dev/null +++ b/server/scripts/avatar-render/package.json @@ -0,0 +1,12 @@ +{ + "name": "avatar-render", + "version": "1.0.0", + "private": true, + "description": "服务端 3D 化身预渲染:GLB -> 36 帧旋转 PNG(three.js + headless-gl + pngjs)", + "type": "module", + "dependencies": { + "gl": "^9.0.0-rc.10", + "pngjs": "^7.0.0", + "three": "0.162.0" + } +} diff --git a/server/scripts/avatar-render/render.js b/server/scripts/avatar-render/render.js new file mode 100644 index 0000000..ceac7c3 --- /dev/null +++ b/server/scripts/avatar-render/render.js @@ -0,0 +1,115 @@ +// 化身 GLB -> 36 帧旋转 PNG(绕 Y 轴 10° 步进),服务端预渲染。 +// 用法: node render.js --glb --out [--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 --out [--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); +} diff --git a/server/scripts/gen_outfit_plan/main.go b/server/scripts/gen_outfit_plan/main.go new file mode 100644 index 0000000..42f5efb --- /dev/null +++ b/server/scripts/gen_outfit_plan/main.go @@ -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 +} diff --git a/server/scripts/gen_user_photos/main.go b/server/scripts/gen_user_photos/main.go new file mode 100644 index 0000000..5c9b290 --- /dev/null +++ b/server/scripts/gen_user_photos/main.go @@ -0,0 +1,114 @@ +package main + +// 为指定用户生成一套三视角全身照(真实调用 imagegen,非 mock): +// go run scripts/gen_user_photos/main.go [username] +// 默认用户 wenwu901。已存在同视角照片时跳过;图片存 workspace/user_{id}/photos/,记录写入 slogan_user_photo。 + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/frame/g" + + "slogan-agent/styleagent/agent" + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +const personDesc = "一位穿浅蓝色衬衫与深灰色西裤的亚洲年轻女性,干净利落的黑色短发,身材匀称" + +var views = []struct { + angle string + photoT int + prompt string +}{ + {angle: "front", photoT: consts.PhotoTypeFullFront, prompt: personDesc + ",全身正面照,站直面对镜头,双手自然下垂,纯白背景,高清写实,全身入镜"}, + {angle: "side", photoT: consts.PhotoTypeFullSide, prompt: personDesc + ",全身侧面照,侧身站立目视前方,纯白背景,高清写实,全身入镜"}, + {angle: "back", photoT: consts.PhotoTypeFullBack, prompt: personDesc + ",全身背面照,背对镜头站立,纯白背景,高清写实,全身入镜"}, +} + +func main() { + username := "wenwu901" + if len(os.Args) > 1 { + username = os.Args[1] + } + ctx := context.Background() + + var user entity.User + if err := g.DB().Model(consts.TableNameUser).Ctx(ctx). + Where("username", username).Scan(&user); err != nil || user.Id == 0 { + panic(fmt.Sprintf("用户 %s 不存在: %v", username, err)) + } + fmt.Printf("用户: %s (id=%d)\n", username, user.Id) + + existing, err := dao.UserPhoto.ListByUser(ctx, user.Id, 0) + if err != nil { + panic(err) + } + have := map[int]bool{} + for _, p := range existing { + have[p.Type] = true + } + + client, err := agent.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "wanx").String()) + if err != nil { + panic(err) + } + + dir := filepath.Join("workspace", fmt.Sprintf("user_%d", user.Id), "photos") + if err := os.MkdirAll(dir, 0o755); err != nil { + panic(err) + } + + // 三视角用同一 seed,保证人物一致 + seed := time.Now().UnixNano() % 1_000_000 + for _, v := range views { + if have[v.photoT] { + fmt.Printf("视角 %s 已有照片,跳过\n", v.angle) + continue + } + fmt.Printf("生成 %s 视角...\n", v.angle) + url, err := client.Generate(ctx, &agent.GenerateReq{ + Prompt: v.prompt, Angle: v.angle, Seed: seed, + }) + if err != nil { + panic(fmt.Sprintf("生成 %s 失败: %v", v.angle, err)) + } + path := filepath.Join(dir, fmt.Sprintf("%d_%s.png", time.Now().UnixNano(), v.angle)) + if err := download(url, path); err != nil { + panic(fmt.Sprintf("保存 %s 失败: %v", v.angle, err)) + } + if _, err := dao.UserPhoto.Insert(ctx, &entity.UserPhoto{ + UserId: user.Id, Type: v.photoT, Url: "/" + filepath.ToSlash(path), Status: 1, + }); err != nil { + panic(fmt.Sprintf("入库 %s 失败: %v", v.angle, err)) + } + fmt.Printf("%s 完成: %s\n", v.angle, path) + } + fmt.Println("照片套生成完毕") +} + +func download(url, dest string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载失败: http %d", resp.StatusCode) + } + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} diff --git a/server/scripts/routes.sh b/server/scripts/routes.sh new file mode 100644 index 0000000..35f55c5 --- /dev/null +++ b/server/scripts/routes.sh @@ -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) +" diff --git a/server/scripts/smoke.sh b/server/scripts/smoke.sh new file mode 100644 index 0000000..69768f9 --- /dev/null +++ b/server/scripts/smoke.sh @@ -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 diff --git a/server/scripts/split_db/main.go b/server/scripts/split_db/main.go new file mode 100644 index 0000000..f789493 --- /dev/null +++ b/server/scripts/split_db/main.go @@ -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) +} diff --git a/server/styleagent/agent/agent_config.go b/server/styleagent/agent/agent_config.go new file mode 100644 index 0000000..6bf4659 --- /dev/null +++ b/server/styleagent/agent/agent_config.go @@ -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 +} diff --git a/server/styleagent/agent/avatar_tripo_client.go b/server/styleagent/agent/avatar_tripo_client.go new file mode 100644 index 0000000..c07d42a --- /dev/null +++ b/server/styleagent/agent/avatar_tripo_client.go @@ -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) +} diff --git a/server/styleagent/agent/chat_model.go b/server/styleagent/agent/chat_model.go new file mode 100644 index 0000000..41ead60 --- /dev/null +++ b/server/styleagent/agent/chat_model.go @@ -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 call(arguments 为 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 +} diff --git a/server/styleagent/agent/cps_jd.go b/server/styleagent/agent/cps_jd.go new file mode 100644 index 0000000..a5c7714 --- /dev/null +++ b/server/styleagent/agent/cps_jd.go @@ -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 +} diff --git a/server/styleagent/agent/cps_meituan.go b/server/styleagent/agent/cps_meituan.go new file mode 100644 index 0000000..e4c325f --- /dev/null +++ b/server/styleagent/agent/cps_meituan.go @@ -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) +} diff --git a/server/styleagent/agent/cps_tb.go b/server/styleagent/agent/cps_tb.go new file mode 100644 index 0000000..683dbf0 --- /dev/null +++ b/server/styleagent/agent/cps_tb.go @@ -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) +} diff --git a/server/styleagent/agent/cps_types.go b/server/styleagent/agent/cps_types.go new file mode 100644 index 0000000..017843b --- /dev/null +++ b/server/styleagent/agent/cps_types.go @@ -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{}, +} diff --git a/server/styleagent/agent/imagegen_cache.go b/server/styleagent/agent/imagegen_cache.go new file mode 100644 index 0000000..0b64878 --- /dev/null +++ b/server/styleagent/agent/imagegen_cache.go @@ -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) +} diff --git a/server/styleagent/agent/imagegen_client.go b/server/styleagent/agent/imagegen_client.go new file mode 100644 index 0000000..584f73a --- /dev/null +++ b/server/styleagent/agent/imagegen_client.go @@ -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) +} diff --git a/server/styleagent/agent/imagegen_wanx_client.go b/server/styleagent/agent/imagegen_wanx_client.go new file mode 100644 index 0000000..d9e455f --- /dev/null +++ b/server/styleagent/agent/imagegen_wanx_client.go @@ -0,0 +1,191 @@ +package agent + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// wanxClient 通义万相图像生成(wan2.7-image-pro:image-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 URI,http(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 URI(dashscope 无法访问相对路径),http(s) 原样返回 +func resolveImageURL(raw string) (string, error) { + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + return raw, nil + } + path := strings.TrimPrefix(raw, "/") + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("读取参考图失败 %s: %w", raw, err) + } + ext := "png" + if i := strings.LastIndex(path, "."); i >= 0 { + ext = strings.TrimPrefix(path[i+1:], ".") + } + return fmt.Sprintf("data:image/%s;base64,%s", ext, base64.StdEncoding.EncodeToString(data)), nil +} + +func (c *wanxClient) submit(ctx context.Context, body []byte) (string, error) { + req, err := http.NewRequestWithContext(ctx, "POST", c.base, bytes.NewReader(body)) + if err != nil { + 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("万相任务超时") +} diff --git a/server/styleagent/agent/outfit_agent.go b/server/styleagent/agent/outfit_agent.go new file mode 100644 index 0000000..5268275 --- /dev/null +++ b/server/styleagent/agent/outfit_agent.go @@ -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 +} diff --git a/server/styleagent/agent/output.go b/server/styleagent/agent/output.go new file mode 100644 index 0000000..254a2ca --- /dev/null +++ b/server/styleagent/agent/output.go @@ -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 +} diff --git a/server/styleagent/agent/output_test.go b/server/styleagent/agent/output_test.go new file mode 100644 index 0000000..dd0fe8c --- /dev/null +++ b/server/styleagent/agent/output_test.go @@ -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") + } +} diff --git a/server/styleagent/agent/prompt.go b/server/styleagent/agent/prompt.go new file mode 100644 index 0000000..1aeec93 --- /dev/null +++ b/server/styleagent/agent/prompt.go @@ -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)。" +} diff --git a/server/styleagent/agent/render.go b/server/styleagent/agent/render.go new file mode 100644 index 0000000..f6864a1 --- /dev/null +++ b/server/styleagent/agent/render.go @@ -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 +} diff --git a/server/styleagent/agent/scoring_color_rule.go b/server/styleagent/agent/scoring_color_rule.go new file mode 100644 index 0000000..5db5a1a --- /dev/null +++ b/server/styleagent/agent/scoring_color_rule.go @@ -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 +} diff --git a/server/styleagent/agent/scoring_completeness_rule.go b/server/styleagent/agent/scoring_completeness_rule.go new file mode 100644 index 0000000..a50eac3 --- /dev/null +++ b/server/styleagent/agent/scoring_completeness_rule.go @@ -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 +} diff --git a/server/styleagent/agent/scoring_engine.go b/server/styleagent/agent/scoring_engine.go new file mode 100644 index 0000000..a27ac97 --- /dev/null +++ b/server/styleagent/agent/scoring_engine.go @@ -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 +} diff --git a/server/styleagent/agent/scoring_occasion_rule.go b/server/styleagent/agent/scoring_occasion_rule.go new file mode 100644 index 0000000..eaff2cb --- /dev/null +++ b/server/styleagent/agent/scoring_occasion_rule.go @@ -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 +} diff --git a/server/styleagent/agent/scoring_rules.go b/server/styleagent/agent/scoring_rules.go new file mode 100644 index 0000000..2d824bd --- /dev/null +++ b/server/styleagent/agent/scoring_rules.go @@ -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 // 用户偏好标签 +} diff --git a/server/styleagent/agent/scoring_weather_rule.go b/server/styleagent/agent/scoring_weather_rule.go new file mode 100644 index 0000000..3bfd500 --- /dev/null +++ b/server/styleagent/agent/scoring_weather_rule.go @@ -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 +} diff --git a/server/styleagent/agent/types.go b/server/styleagent/agent/types.go new file mode 100644 index 0000000..3fe6a67 --- /dev/null +++ b/server/styleagent/agent/types.go @@ -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" +) diff --git a/server/styleagent/agent/weather_cache.go b/server/styleagent/agent/weather_cache.go new file mode 100644 index 0000000..e850c51 --- /dev/null +++ b/server/styleagent/agent/weather_cache.go @@ -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)} +} diff --git a/server/styleagent/agent/weather_geo.go b/server/styleagent/agent/weather_geo.go new file mode 100644 index 0000000..0528f21 --- /dev/null +++ b/server/styleagent/agent/weather_geo.go @@ -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 +} diff --git a/server/styleagent/agent/weather_qweather.go b/server/styleagent/agent/weather_qweather.go new file mode 100644 index 0000000..f4f5413 --- /dev/null +++ b/server/styleagent/agent/weather_qweather.go @@ -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 "冬" + } +} diff --git a/server/styleagent/consts/cps.go b/server/styleagent/consts/cps.go new file mode 100644 index 0000000..674a51d --- /dev/null +++ b/server/styleagent/consts/cps.go @@ -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" +) diff --git a/server/styleagent/consts/db_group.go b/server/styleagent/consts/db_group.go new file mode 100644 index 0000000..0f8ca28 --- /dev/null +++ b/server/styleagent/consts/db_group.go @@ -0,0 +1,8 @@ +package consts + +// 数据库组:config.yml database.* 中的分组名,与 slogans 各域 SQLite 文件一一对应 +const ( + DBGroupPlan = "plan" + DBGroupPay = "pay" + DBGroupCps = "cps" +) diff --git a/server/styleagent/consts/status.go b/server/styleagent/consts/status.go new file mode 100644 index 0000000..a42e542 --- /dev/null +++ b/server/styleagent/consts/status.go @@ -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" +) diff --git a/server/styleagent/consts/table_name.go b/server/styleagent/consts/table_name.go new file mode 100644 index 0000000..3cf14b2 --- /dev/null +++ b/server/styleagent/consts/table_name.go @@ -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" +) diff --git a/server/styleagent/controller/ad_reward_log_controller.go b/server/styleagent/controller/ad_reward_log_controller.go new file mode 100644 index 0000000..b5387c4 --- /dev/null +++ b/server/styleagent/controller/ad_reward_log_controller.go @@ -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 +} diff --git a/server/styleagent/controller/avatar_model_controller.go b/server/styleagent/controller/avatar_model_controller.go new file mode 100644 index 0000000..025b5f8 --- /dev/null +++ b/server/styleagent/controller/avatar_model_controller.go @@ -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 +} diff --git a/server/styleagent/controller/body_measurement_controller.go b/server/styleagent/controller/body_measurement_controller.go new file mode 100644 index 0000000..5779ca6 --- /dev/null +++ b/server/styleagent/controller/body_measurement_controller.go @@ -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 +} diff --git a/server/styleagent/controller/cps_category_controller.go b/server/styleagent/controller/cps_category_controller.go new file mode 100644 index 0000000..12edf4f --- /dev/null +++ b/server/styleagent/controller/cps_category_controller.go @@ -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 +} diff --git a/server/styleagent/controller/cps_click_log_controller.go b/server/styleagent/controller/cps_click_log_controller.go new file mode 100644 index 0000000..0bcc1db --- /dev/null +++ b/server/styleagent/controller/cps_click_log_controller.go @@ -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 +} diff --git a/server/styleagent/controller/cps_product_controller.go b/server/styleagent/controller/cps_product_controller.go new file mode 100644 index 0000000..f391da3 --- /dev/null +++ b/server/styleagent/controller/cps_product_controller.go @@ -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 +} diff --git a/server/styleagent/controller/hairstyle_asset_controller.go b/server/styleagent/controller/hairstyle_asset_controller.go new file mode 100644 index 0000000..d0f3dfe --- /dev/null +++ b/server/styleagent/controller/hairstyle_asset_controller.go @@ -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 +} diff --git a/server/styleagent/controller/member_plan_controller.go b/server/styleagent/controller/member_plan_controller.go new file mode 100644 index 0000000..c114091 --- /dev/null +++ b/server/styleagent/controller/member_plan_controller.go @@ -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 +} diff --git a/server/styleagent/controller/outfit_generation_task_controller.go b/server/styleagent/controller/outfit_generation_task_controller.go new file mode 100644 index 0000000..63fe6e9 --- /dev/null +++ b/server/styleagent/controller/outfit_generation_task_controller.go @@ -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 +} diff --git a/server/styleagent/controller/outfit_plan_controller.go b/server/styleagent/controller/outfit_plan_controller.go new file mode 100644 index 0000000..4efba71 --- /dev/null +++ b/server/styleagent/controller/outfit_plan_controller.go @@ -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 +} diff --git a/server/styleagent/controller/partner_store_controller.go b/server/styleagent/controller/partner_store_controller.go new file mode 100644 index 0000000..1d9f34d --- /dev/null +++ b/server/styleagent/controller/partner_store_controller.go @@ -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 +} diff --git a/server/styleagent/controller/pay_notify_log_controller.go b/server/styleagent/controller/pay_notify_log_controller.go new file mode 100644 index 0000000..8bc6444 --- /dev/null +++ b/server/styleagent/controller/pay_notify_log_controller.go @@ -0,0 +1,3 @@ +package controller + +// pay_notify_log 表控制器:无独立路由 handler(回调日志由 /member/order/notify 审计写入) diff --git a/server/styleagent/controller/payment_order_controller.go b/server/styleagent/controller/payment_order_controller.go new file mode 100644 index 0000000..e30358f --- /dev/null +++ b/server/styleagent/controller/payment_order_controller.go @@ -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() +} diff --git a/server/styleagent/controller/plan_effect_image_controller.go b/server/styleagent/controller/plan_effect_image_controller.go new file mode 100644 index 0000000..343e2fe --- /dev/null +++ b/server/styleagent/controller/plan_effect_image_controller.go @@ -0,0 +1,4 @@ +package controller + +// plan_effect_image 表控制器:无独立路由 handler,逻辑归属 outfit/* 簇 +// (效果图由选定主方案后异步生成,经 /outfit/plan/detail 返回) diff --git a/server/styleagent/controller/plan_outfit_item_controller.go b/server/styleagent/controller/plan_outfit_item_controller.go new file mode 100644 index 0000000..5de0273 --- /dev/null +++ b/server/styleagent/controller/plan_outfit_item_controller.go @@ -0,0 +1,4 @@ +package controller + +// plan_outfit_item 表控制器:无独立路由 handler,逻辑归属 outfit/* 簇 +// (方案条目数据由 /outfit/plan/detail 承载) diff --git a/server/styleagent/controller/plan_review_controller.go b/server/styleagent/controller/plan_review_controller.go new file mode 100644 index 0000000..bac9985 --- /dev/null +++ b/server/styleagent/controller/plan_review_controller.go @@ -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 +} diff --git a/server/styleagent/controller/scene_category_map_controller.go b/server/styleagent/controller/scene_category_map_controller.go new file mode 100644 index 0000000..960a140 --- /dev/null +++ b/server/styleagent/controller/scene_category_map_controller.go @@ -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 +} diff --git a/server/styleagent/controller/scoring_rule_controller.go b/server/styleagent/controller/scoring_rule_controller.go new file mode 100644 index 0000000..07219b9 --- /dev/null +++ b/server/styleagent/controller/scoring_rule_controller.go @@ -0,0 +1,3 @@ +package controller + +// scoring_rule 表控制器:无独立路由 handler,规则在服务端读取(评分阈值/效果图额度) diff --git a/server/styleagent/controller/user_controller.go b/server/styleagent/controller/user_controller.go new file mode 100644 index 0000000..4875868 --- /dev/null +++ b/server/styleagent/controller/user_controller.go @@ -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 +} diff --git a/server/styleagent/controller/user_member_controller.go b/server/styleagent/controller/user_member_controller.go new file mode 100644 index 0000000..43bf417 --- /dev/null +++ b/server/styleagent/controller/user_member_controller.go @@ -0,0 +1,4 @@ +package controller + +// user_member 表控制器:无独立路由 handler(会员状态经 /member/status 返回, +// 开通由支付回调与广告激励写入) diff --git a/server/styleagent/controller/user_photo_controller.go b/server/styleagent/controller/user_photo_controller.go new file mode 100644 index 0000000..1d47502 --- /dev/null +++ b/server/styleagent/controller/user_photo_controller.go @@ -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 +} diff --git a/server/styleagent/controller/wardrobe_item_controller.go b/server/styleagent/controller/wardrobe_item_controller.go new file mode 100644 index 0000000..b8d3ec7 --- /dev/null +++ b/server/styleagent/controller/wardrobe_item_controller.go @@ -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 +} diff --git a/server/styleagent/dao/ad_reward_log_dao.go b/server/styleagent/dao/ad_reward_log_dao.go new file mode 100644 index 0000000..1b935be --- /dev/null +++ b/server/styleagent/dao/ad_reward_log_dao.go @@ -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") +} diff --git a/server/styleagent/dao/avatar_model_dao.go b/server/styleagent/dao/avatar_model_dao.go new file mode 100644 index 0000000..9304135 --- /dev/null +++ b/server/styleagent/dao/avatar_model_dao.go @@ -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 +} diff --git a/server/styleagent/dao/body_measurement_dao.go b/server/styleagent/dao/body_measurement_dao.go new file mode 100644 index 0000000..5edb859 --- /dev/null +++ b/server/styleagent/dao/body_measurement_dao.go @@ -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 +} diff --git a/server/styleagent/dao/cps_category_dao.go b/server/styleagent/dao/cps_category_dao.go new file mode 100644 index 0000000..a06f391 --- /dev/null +++ b/server/styleagent/dao/cps_category_dao.go @@ -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 +} diff --git a/server/styleagent/dao/cps_click_log_dao.go b/server/styleagent/dao/cps_click_log_dao.go new file mode 100644 index 0000000..d7d61ee --- /dev/null +++ b/server/styleagent/dao/cps_click_log_dao.go @@ -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 +} diff --git a/server/styleagent/dao/cps_product_dao.go b/server/styleagent/dao/cps_product_dao.go new file mode 100644 index 0000000..e5d5eac --- /dev/null +++ b/server/styleagent/dao/cps_product_dao.go @@ -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 +} diff --git a/server/styleagent/dao/db.go b/server/styleagent/dao/db.go new file mode 100644 index 0000000..ef3e761 --- /dev/null +++ b/server/styleagent/dao/db.go @@ -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") } diff --git a/server/styleagent/dao/hairstyle_asset_dao.go b/server/styleagent/dao/hairstyle_asset_dao.go new file mode 100644 index 0000000..f9b3c43 --- /dev/null +++ b/server/styleagent/dao/hairstyle_asset_dao.go @@ -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 +} diff --git a/server/styleagent/dao/member_plan_dao.go b/server/styleagent/dao/member_plan_dao.go new file mode 100644 index 0000000..2276b28 --- /dev/null +++ b/server/styleagent/dao/member_plan_dao.go @@ -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 +} diff --git a/server/styleagent/dao/outfit_generation_task_dao.go b/server/styleagent/dao/outfit_generation_task_dao.go new file mode 100644 index 0000000..02bb3b6 --- /dev/null +++ b/server/styleagent/dao/outfit_generation_task_dao.go @@ -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 +} diff --git a/server/styleagent/dao/outfit_plan_dao.go b/server/styleagent/dao/outfit_plan_dao.go new file mode 100644 index 0000000..1e24c8c --- /dev/null +++ b/server/styleagent/dao/outfit_plan_dao.go @@ -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 +} diff --git a/server/styleagent/dao/partner_store_dao.go b/server/styleagent/dao/partner_store_dao.go new file mode 100644 index 0000000..083c398 --- /dev/null +++ b/server/styleagent/dao/partner_store_dao.go @@ -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 +} diff --git a/server/styleagent/dao/pay_notify_log_dao.go b/server/styleagent/dao/pay_notify_log_dao.go new file mode 100644 index 0000000..c495652 --- /dev/null +++ b/server/styleagent/dao/pay_notify_log_dao.go @@ -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 +} diff --git a/server/styleagent/dao/payment_order_dao.go b/server/styleagent/dao/payment_order_dao.go new file mode 100644 index 0000000..ab77f14 --- /dev/null +++ b/server/styleagent/dao/payment_order_dao.go @@ -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 +} diff --git a/server/styleagent/dao/plan_effect_image_dao.go b/server/styleagent/dao/plan_effect_image_dao.go new file mode 100644 index 0000000..0c0e934 --- /dev/null +++ b/server/styleagent/dao/plan_effect_image_dao.go @@ -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 +} diff --git a/server/styleagent/dao/plan_outfit_item_dao.go b/server/styleagent/dao/plan_outfit_item_dao.go new file mode 100644 index 0000000..2b737db --- /dev/null +++ b/server/styleagent/dao/plan_outfit_item_dao.go @@ -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 +} diff --git a/server/styleagent/dao/plan_review_dao.go b/server/styleagent/dao/plan_review_dao.go new file mode 100644 index 0000000..bab431f --- /dev/null +++ b/server/styleagent/dao/plan_review_dao.go @@ -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 +} diff --git a/server/styleagent/dao/scene_category_map_dao.go b/server/styleagent/dao/scene_category_map_dao.go new file mode 100644 index 0000000..5d871f8 --- /dev/null +++ b/server/styleagent/dao/scene_category_map_dao.go @@ -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 +} diff --git a/server/styleagent/dao/scoring_rule_dao.go b/server/styleagent/dao/scoring_rule_dao.go new file mode 100644 index 0000000..b29b939 --- /dev/null +++ b/server/styleagent/dao/scoring_rule_dao.go @@ -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 +} diff --git a/server/styleagent/dao/user_dao.go b/server/styleagent/dao/user_dao.go new file mode 100644 index 0000000..87a1f21 --- /dev/null +++ b/server/styleagent/dao/user_dao.go @@ -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 +} diff --git a/server/styleagent/dao/user_member_dao.go b/server/styleagent/dao/user_member_dao.go new file mode 100644 index 0000000..0a718f8 --- /dev/null +++ b/server/styleagent/dao/user_member_dao.go @@ -0,0 +1,71 @@ +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 UserMember = &userMemberDao{} + +type userMemberDao struct{} + +func init() { + ctx := context.Background() + _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserMember+` ( + 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', + created_at DATETIME DEFAULT (datetime('now','localtime')), + updated_at DATETIME + )`) + if err != nil { + g.Log().Warningf(ctx, "create user_member table failed: %v", err) + } +} + +func (d *userMemberDao) GetByUser(ctx context.Context, userId int64) (*entity.UserMember, error) { + var m *entity.UserMember + err := dbPay().Model(consts.TableNameUserMember).Ctx(ctx). + Where("user_id", userId).Scan(&m) + return m, err +} + +// Upsert 无则插入有则整体覆盖(expire_at 由 Service 算好传入) +func (d *userMemberDao) Upsert(ctx context.Context, userId, planId int64, expireAt, source string) error { + _, err := dbPay().Exec(ctx, + "INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+ + "ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')", + userId, planId, expireAt, source) + return err +} + +// GetByUserTx 事务版本:事务内读取当前会员状态,避免跨连接读到并发中间态 +func (d *userMemberDao) GetByUserTx(ctx context.Context, tx gdb.TX, userId int64) (*entity.UserMember, error) { + var m *entity.UserMember + err := tx.Model(consts.TableNameUserMember).Ctx(ctx). + Where("user_id", userId).Scan(&m) + return m, err +} + +// UpsertTx 事务版本:支付回调/广告奖励流程使用,保证与订单状态原子 +func (d *userMemberDao) UpsertTx(ctx context.Context, tx gdb.TX, userId, planId int64, expireAt, source string) error { + _, err := tx.Ctx(ctx).Exec( + "INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+ + "ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')", + userId, planId, expireAt, source) + return err +} + +// IsVip 当前是否会员(未过期) +func (d *userMemberDao) IsVip(ctx context.Context, userId int64) bool { + n, err := dbPay().Model(consts.TableNameUserMember).Ctx(ctx). + Where("user_id", userId).Where("expire_at > datetime('now','localtime')").Count() + return err == nil && n > 0 +} diff --git a/server/styleagent/dao/user_photo_dao.go b/server/styleagent/dao/user_photo_dao.go new file mode 100644 index 0000000..962394d --- /dev/null +++ b/server/styleagent/dao/user_photo_dao.go @@ -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 UserPhoto = &userPhotoDao{} + +type userPhotoDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserPhoto+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type INTEGER NOT NULL, + url 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 user_photo table failed: %v", err) + } + if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_user_photo_user ON "+consts.TableNameUserPhoto+"(user_id, type)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_user_photo_user failed: %v", err) + } +} + +func (d *userPhotoDao) Insert(ctx context.Context, data *entity.UserPhoto) (int64, error) { + r, err := g.DB().Exec(ctx, + "INSERT INTO "+consts.TableNameUserPhoto+" (user_id, type, url, status, created_at) VALUES (?, ?, ?, ?, datetime('now','localtime'))", + data.UserId, data.Type, data.Url, data.Status) + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *userPhotoDao) ListByUser(ctx context.Context, userId int64, photoType int) ([]*entity.UserPhoto, error) { + m := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx).Where("user_id", userId).Where("status", 1) + if photoType > 0 { + m = m.Where("type", photoType) + } + var list []*entity.UserPhoto + err := m.OrderAsc("type").OrderAsc("id").Scan(&list) + return list, err +} + +func (d *userPhotoDao) GetOne(ctx context.Context, id, userId int64) (*entity.UserPhoto, error) { + var p entity.UserPhoto + err := g.DB().Model(consts.TableNameUserPhoto).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 *userPhotoDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx).Unscoped().Where("id", id).Delete() + return err +} diff --git a/server/styleagent/dao/wardrobe_item_dao.go b/server/styleagent/dao/wardrobe_item_dao.go new file mode 100644 index 0000000..682d2b1 --- /dev/null +++ b/server/styleagent/dao/wardrobe_item_dao.go @@ -0,0 +1,86 @@ +package dao + +import ( + "context" + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +var WardrobeItem = &wardrobeItemDao{} + +type wardrobeItemDao struct{} + +func init() { + ctx := context.Background() + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameWardrobeItem+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + photo_url TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + season TEXT NOT NULL DEFAULT '四季', + style_tags TEXT NOT NULL DEFAULT '', + color_info 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 wardrobe_item table failed: %v", err) + } + // 容错迁移:CREATE TABLE IF NOT EXISTS 不给旧库加列,duplicate column 错误可忽略 + if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameWardrobeItem+ + " ADD COLUMN name TEXT NOT NULL DEFAULT ''"); err != nil { + g.Log().Warningf(ctx, "migrate wardrobe_item.name skipped: %v", err) + } + if _, err := g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_wardrobe_user ON "+consts.TableNameWardrobeItem+"(user_id, category)"); err != nil { + g.Log().Warningf(ctx, "create index idx_slogan_wardrobe_user failed: %v", err) + } +} + +func (d *wardrobeItemDao) Insert(ctx context.Context, data *entity.WardrobeItem) (int64, error) { + r, err := g.DB().Exec(ctx, + "INSERT INTO "+consts.TableNameWardrobeItem+" (user_id, photo_url, name, category, season, style_tags, color_info, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", + data.UserId, data.PhotoUrl, data.Name, data.Category, data.Season, data.StyleTags, data.ColorInfo, data.Status) + if err != nil { + return 0, err + } + return r.LastInsertId() +} + +func (d *wardrobeItemDao) ListByUser(ctx context.Context, userId int64, category string) ([]*entity.WardrobeItem, error) { + m := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Where("user_id", userId).Where("status", 1) + if category != "" { + m = m.Where("category", category) + } + var list []*entity.WardrobeItem + err := m.OrderAsc("id").Scan(&list) + return list, err +} + +func (d *wardrobeItemDao) ListAllByUser(ctx context.Context, userId int64) ([]*entity.WardrobeItem, error) { + var list []*entity.WardrobeItem + err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx). + Where("user_id", userId).Where("status", 1).OrderAsc("id").Scan(&list) + return list, err +} + +func (d *wardrobeItemDao) GetOne(ctx context.Context, id, userId int64) (*entity.WardrobeItem, error) { + var w entity.WardrobeItem + err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx). + Where("id", id).Where("user_id", userId).Scan(&w) + if err != nil || w.Id == 0 { + return nil, err + } + return &w, nil +} + +func (d *wardrobeItemDao) Update(ctx context.Context, id int64, data map[string]any) error { + _, err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Data(data).Where("id", id).Update() + return err +} + +func (d *wardrobeItemDao) Delete(ctx context.Context, id int64) error { + _, err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Unscoped().Where("id", id).Delete() + return err +} diff --git a/server/styleagent/model/dto/ad_reward_log_dto.go b/server/styleagent/model/dto/ad_reward_log_dto.go new file mode 100644 index 0000000..b57867b --- /dev/null +++ b/server/styleagent/model/dto/ad_reward_log_dto.go @@ -0,0 +1,19 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +type AdRewardClaimReq struct { + g.Meta `path:"/reward/claim" method:"post" tags:"广告" summary:"领取广告激励"` + AdType string `v:"required|in:effect_extra,vip_trial" json:"ad_type"` +} + +type AdRewardInfo struct { + AdType string `json:"ad_type"` + RemainingToday int `json:"remaining_today"` +} + +type AdRewardClaimRes struct { + Reward *AdRewardInfo `json:"reward"` +} diff --git a/server/styleagent/model/dto/avatar_model_dto.go b/server/styleagent/model/dto/avatar_model_dto.go new file mode 100644 index 0000000..e4eb805 --- /dev/null +++ b/server/styleagent/model/dto/avatar_model_dto.go @@ -0,0 +1,28 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +type AvatarBuildReq struct { + g.Meta `path:"/build" method:"post" tags:"化身" summary:"构建化身"` +} + +type AvatarBuildRes struct { + AvatarId int64 `json:"avatar_id"` + Status string `json:"status"` +} + +type AvatarGetReq struct { + g.Meta `path:"/get" method:"get" tags:"化身" summary:"我的化身"` +} + +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"` + FramesUrl string `json:"frames_url"` + BuildStatus string `json:"build_status"` + Error string `json:"error"` +} diff --git a/server/styleagent/model/dto/body_measurement_dto.go b/server/styleagent/model/dto/body_measurement_dto.go new file mode 100644 index 0000000..4473efc --- /dev/null +++ b/server/styleagent/model/dto/body_measurement_dto.go @@ -0,0 +1,32 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +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"` + Bust int `json:"bust"` + Waist int `json:"waist"` + Hip int `json:"hip"` + Shoulder int `json:"shoulder"` + FitParams string `json:"fit_params"` +} + +type BodyMeasurementGetReq struct { + g.Meta `path:"/get" method:"get" tags:"身形" summary:"我的身形参数"` +} + +type BodyMeasurementGetRes struct { + Height int `json:"height"` + Weight int `json:"weight"` + SkinTone int `json:"skin_tone"` + Bust int `json:"bust"` + Waist int `json:"waist"` + Hip int `json:"hip"` + Shoulder int `json:"shoulder"` + FitParams string `json:"fit_params"` +} diff --git a/server/styleagent/model/dto/cps_category_dto.go b/server/styleagent/model/dto/cps_category_dto.go new file mode 100644 index 0000000..b54855a --- /dev/null +++ b/server/styleagent/model/dto/cps_category_dto.go @@ -0,0 +1,15 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type CpsCategoryListReq struct { + g.Meta `path:"/category/list" method:"get" tags:"CPS" summary:"统一分类列表"` +} + +type CpsCategoryListRes struct { + List []*entity.CpsCategory `json:"list"` +} diff --git a/server/styleagent/model/dto/cps_click_log_dto.go b/server/styleagent/model/dto/cps_click_log_dto.go new file mode 100644 index 0000000..b1f901f --- /dev/null +++ b/server/styleagent/model/dto/cps_click_log_dto.go @@ -0,0 +1,15 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type CpsMyRecentReq struct { + g.Meta `path:"/my/recent" method:"get" tags:"CPS" summary:"我的优惠记录"` +} + +type CpsMyRecentRes struct { + List []*entity.CpsProduct `json:"list"` +} diff --git a/server/styleagent/model/dto/cps_product_dto.go b/server/styleagent/model/dto/cps_product_dto.go new file mode 100644 index 0000000..0ee7838 --- /dev/null +++ b/server/styleagent/model/dto/cps_product_dto.go @@ -0,0 +1,31 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type CpsProductListReq struct { + g.Meta `path:"/product/list" method:"get" tags:"CPS" summary:"选品池分页列表"` + Source string `json:"source"` + CategoryCode string `json:"category_code"` + City string `json:"city"` + Page int `json:"page"` +} + +type CpsProductListRes struct { + List []*entity.CpsProduct `json:"list"` + HasMore bool `json:"has_more"` +} + +type CpsProductLinkReq struct { + g.Meta `path:"/product/link" method:"post" tags:"CPS" summary:"商品转链"` + ProductId int64 `v:"required" json:"product_id"` + Scene string `json:"scene"` + PlanId int64 `json:"plan_id"` +} + +type CpsProductLinkRes struct { + Deeplink string `json:"deeplink"` +} diff --git a/server/styleagent/model/dto/hairstyle_asset_dto.go b/server/styleagent/model/dto/hairstyle_asset_dto.go new file mode 100644 index 0000000..c611f4b --- /dev/null +++ b/server/styleagent/model/dto/hairstyle_asset_dto.go @@ -0,0 +1,15 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type HairstyleListReq struct { + g.Meta `path:"/list" method:"get" tags:"发型" summary:"发型资产列表"` +} + +type HairstyleListRes struct { + List []*entity.HairstyleAsset `json:"list"` +} diff --git a/server/styleagent/model/dto/member_plan_dto.go b/server/styleagent/model/dto/member_plan_dto.go new file mode 100644 index 0000000..28496df --- /dev/null +++ b/server/styleagent/model/dto/member_plan_dto.go @@ -0,0 +1,26 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type MemberPlanListReq struct { + g.Meta `path:"/plan/list" method:"get" tags:"会员" summary:"会员套餐列表"` +} + +type MemberPlanListRes struct { + List []*entity.MemberPlan `json:"list"` +} + +type MemberStatusReq struct { + g.Meta `path:"/status" method:"get" tags:"会员" summary:"我的会员状态"` +} + +type MemberStatusRes struct { + IsVip bool `json:"is_vip"` + ExpireAt string `json:"expire_at"` + PlanName string `json:"plan_name"` + Benefits []string `json:"benefits"` +} diff --git a/server/styleagent/model/dto/outfit_generation_task_dto.go b/server/styleagent/model/dto/outfit_generation_task_dto.go new file mode 100644 index 0000000..fb38f46 --- /dev/null +++ b/server/styleagent/model/dto/outfit_generation_task_dto.go @@ -0,0 +1,27 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +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"` + Occasion string `v:"in:通勤,约会,聚会,运动" json:"occasion"` +} + +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"` +} diff --git a/server/styleagent/model/dto/outfit_plan_dto.go b/server/styleagent/model/dto/outfit_plan_dto.go new file mode 100644 index 0000000..4c4af89 --- /dev/null +++ b/server/styleagent/model/dto/outfit_plan_dto.go @@ -0,0 +1,32 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +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"` +} diff --git a/server/styleagent/model/dto/partner_store_dto.go b/server/styleagent/model/dto/partner_store_dto.go new file mode 100644 index 0000000..967de48 --- /dev/null +++ b/server/styleagent/model/dto/partner_store_dto.go @@ -0,0 +1,16 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type StoreListReq struct { + g.Meta `path:"/list" method:"get" tags:"门店" summary:"合作门店列表"` + Type int `json:"type"` +} + +type StoreListRes struct { + List []*entity.PartnerStore `json:"list"` +} diff --git a/server/styleagent/model/dto/pay_notify_log_dto.go b/server/styleagent/model/dto/pay_notify_log_dto.go new file mode 100644 index 0000000..3868774 --- /dev/null +++ b/server/styleagent/model/dto/pay_notify_log_dto.go @@ -0,0 +1,3 @@ +package dto + +// 支付回调日志(pay_notify_log)为服务端记录,无 HTTP 接口。 diff --git a/server/styleagent/model/dto/payment_order_dto.go b/server/styleagent/model/dto/payment_order_dto.go new file mode 100644 index 0000000..7303d04 --- /dev/null +++ b/server/styleagent/model/dto/payment_order_dto.go @@ -0,0 +1,26 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +type MemberOrderCreateReq struct { + g.Meta `path:"/order/create" method:"post" tags:"会员" summary:"创建支付订单"` + PlanId int64 `v:"required" json:"plan_id"` +} + +type MemberOrderCreateRes struct { + OrderNo string `json:"order_no"` + PayUrl string `json:"pay_url"` +} + +type MemberOrderStatusReq struct { + g.Meta `path:"/order/status" method:"get" tags:"会员" summary:"订单状态"` + OrderNo string `v:"required" json:"order_no"` +} + +type MemberOrderStatusRes struct { + Status string `json:"status"` + TradeNo string `json:"trade_no"` + PaidAt string `json:"paid_at"` +} diff --git a/server/styleagent/model/dto/plan_effect_image_dto.go b/server/styleagent/model/dto/plan_effect_image_dto.go new file mode 100644 index 0000000..e4f8d1e --- /dev/null +++ b/server/styleagent/model/dto/plan_effect_image_dto.go @@ -0,0 +1,3 @@ +package dto + +// 方案效果图(plan_effect_image)无独立 HTTP 接口,由方案选定流程在服务端生成,客户端经方案详情获取。 diff --git a/server/styleagent/model/dto/plan_outfit_item_dto.go b/server/styleagent/model/dto/plan_outfit_item_dto.go new file mode 100644 index 0000000..fba399e --- /dev/null +++ b/server/styleagent/model/dto/plan_outfit_item_dto.go @@ -0,0 +1,3 @@ +package dto + +// 方案穿搭项(plan_outfit_item)无独立 HTTP 接口,请求/响应经 outfit_plan_dto.go 透传。 diff --git a/server/styleagent/model/dto/plan_review_dto.go b/server/styleagent/model/dto/plan_review_dto.go new file mode 100644 index 0000000..7493c00 --- /dev/null +++ b/server/styleagent/model/dto/plan_review_dto.go @@ -0,0 +1,12 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +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"` +} diff --git a/server/styleagent/model/dto/scene_category_map_dto.go b/server/styleagent/model/dto/scene_category_map_dto.go new file mode 100644 index 0000000..839eaf7 --- /dev/null +++ b/server/styleagent/model/dto/scene_category_map_dto.go @@ -0,0 +1,26 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type CpsPlanRecommendReq struct { + g.Meta `path:"/plan/recommend" method:"get" tags:"CPS" summary:"方案驱动推荐"` + PlanId int64 `v:"required" json:"plan_id"` + Scene string `v:"required|in:haircut,item_buy,item_upgrade,occasion" json:"scene"` +} + +type CpsPlanRecommendRes struct { + List []*entity.CpsProduct `json:"list"` +} + +type CpsWardrobeUpgradeReq struct { + g.Meta `path:"/wardrobe/upgrade" method:"get" tags:"CPS" summary:"衣橱升级款"` + ItemId int64 `v:"required" json:"item_id"` +} + +type CpsWardrobeUpgradeRes struct { + List []*entity.CpsProduct `json:"list"` +} diff --git a/server/styleagent/model/dto/scoring_rule_dto.go b/server/styleagent/model/dto/scoring_rule_dto.go new file mode 100644 index 0000000..598681b --- /dev/null +++ b/server/styleagent/model/dto/scoring_rule_dto.go @@ -0,0 +1,3 @@ +package dto + +// 评分规则(scoring_rule)为服务端内部配置,无 HTTP 接口。 diff --git a/server/styleagent/model/dto/user_dto.go b/server/styleagent/model/dto/user_dto.go new file mode 100644 index 0000000..f23f863 --- /dev/null +++ b/server/styleagent/model/dto/user_dto.go @@ -0,0 +1,47 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +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"` +} + +type ChangePasswordReq struct { + g.Meta `path:"/change-password" method:"post" tags:"用户" summary:"修改密码"` + OldPassword string `v:"required" json:"old_password"` + NewPassword string `v:"required|min-length:6" json:"new_password"` +} + +type RegisterReq struct { + g.Meta `path:"/register" method:"post" tags:"用户" summary:"注册"` + Account string `v:"required" json:"account"` + Password string `v:"required|min-length:6" json:"password"` + Name string `json:"name"` +} + +type ProfileReq struct { + g.Meta `path:"/profile" method:"get" tags:"用户" summary:"我的资料"` +} + +type ProfileRes struct { + Id int64 `json:"id"` + Role string `json:"role"` + Name string `json:"name"` + Username string `json:"username"` + Phone string `json:"phone"` +} diff --git a/server/styleagent/model/dto/user_member_dto.go b/server/styleagent/model/dto/user_member_dto.go new file mode 100644 index 0000000..a59c18b --- /dev/null +++ b/server/styleagent/model/dto/user_member_dto.go @@ -0,0 +1,3 @@ +package dto + +// 用户会员关系(user_member)由支付回调/下单流程维护,状态经 member_plan_dto.go 返回。 diff --git a/server/styleagent/model/dto/user_photo_dto.go b/server/styleagent/model/dto/user_photo_dto.go new file mode 100644 index 0000000..20a61fb --- /dev/null +++ b/server/styleagent/model/dto/user_photo_dto.go @@ -0,0 +1,30 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type UserPhotoUploadReq struct { + g.Meta `path:"/upload" method:"post" tags:"照片" summary:"上传照片"` + Type int `v:"required|in:1,2,3,4" json:"type"` +} + +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"` +} diff --git a/server/styleagent/model/dto/wardrobe_item_dto.go b/server/styleagent/model/dto/wardrobe_item_dto.go new file mode 100644 index 0000000..e3c70bd --- /dev/null +++ b/server/styleagent/model/dto/wardrobe_item_dto.go @@ -0,0 +1,41 @@ +package dto + +import ( + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type WardrobeUploadReq struct { + g.Meta `path:"/upload" method:"post" tags:"衣橱" summary:"上传服装"` + Category string `v:"required|in:上衣,下装,鞋,配饰" json:"category"` + Season string `json:"season"` + StyleTags string `json:"style_tags"` + ColorInfo string `json:"color_info"` +} + +type WardrobeUploadRes struct { + Id int64 `json:"id"` +} + +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"` +} diff --git a/server/styleagent/model/entity/ad_reward_log.go b/server/styleagent/model/entity/ad_reward_log.go new file mode 100644 index 0000000..bd66fb1 --- /dev/null +++ b/server/styleagent/model/entity/ad_reward_log.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type AdRewardLog struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + AdType string `orm:"ad_type" json:"ad_type"` + RewardKey string `orm:"reward_key" json:"reward_key"` + Slot int `orm:"slot" json:"slot"` + Status string `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/avatar_model.go b/server/styleagent/model/entity/avatar_model.go new file mode 100644 index 0000000..a0f8a7b --- /dev/null +++ b/server/styleagent/model/entity/avatar_model.go @@ -0,0 +1,19 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type AvatarModel struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + FaceTemplateId int `orm:"face_template_id" json:"face_template_id"` + BodyTemplateId int `orm:"body_template_id" json:"body_template_id"` + SkinToneIndex int `orm:"skin_tone_index" json:"skin_tone_index"` + FaceTextureUrl string `orm:"face_texture_url" json:"face_texture_url"` + GlbUrl string `orm:"glb_url" json:"glb_url"` + FramesUrl string `orm:"frames_url" json:"frames_url"` + BuildStatus string `orm:"build_status" json:"build_status"` + Error string `orm:"error" json:"error"` + ParamsSnapshot string `orm:"params_snapshot" json:"params_snapshot"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/server/styleagent/model/entity/body_measurement.go b/server/styleagent/model/entity/body_measurement.go new file mode 100644 index 0000000..d4d5039 --- /dev/null +++ b/server/styleagent/model/entity/body_measurement.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type BodyMeasurement struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + Height int `orm:"height" json:"height"` + Weight int `orm:"weight" json:"weight"` + SkinTone int `orm:"skin_tone" json:"skin_tone"` + Bust int `orm:"bust" json:"bust"` + Waist int `orm:"waist" json:"waist"` + Hip int `orm:"hip" json:"hip"` + Shoulder int `orm:"shoulder" json:"shoulder"` + FitParams string `orm:"fit_params" json:"fit_params"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/server/styleagent/model/entity/cps_category.go b/server/styleagent/model/entity/cps_category.go new file mode 100644 index 0000000..c5a023e --- /dev/null +++ b/server/styleagent/model/entity/cps_category.go @@ -0,0 +1,14 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type CpsCategory struct { + Id int64 `orm:"id" json:"id"` + Code string `orm:"code" json:"code"` + Name string `orm:"name" json:"name"` + ParentCode string `orm:"parent_code" json:"parent_code"` + Source string `orm:"source" json:"source"` + SourceCatId string `orm:"source_cat_id" json:"source_cat_id"` + Sort int `orm:"sort" json:"sort"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/cps_click_log.go b/server/styleagent/model/entity/cps_click_log.go new file mode 100644 index 0000000..036ba75 --- /dev/null +++ b/server/styleagent/model/entity/cps_click_log.go @@ -0,0 +1,16 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type CpsClickLog struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + Source string `orm:"source" json:"source"` + OuterId string `orm:"outer_id" json:"outer_id"` + Scene string `orm:"scene" json:"scene"` + PlanId int64 `orm:"plan_id" json:"plan_id"` + CategoryCode string `orm:"category_code" json:"category_code"` + Deeplink string `orm:"deeplink" json:"deeplink"` + Ip string `orm:"ip" json:"ip"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/cps_product.go b/server/styleagent/model/entity/cps_product.go new file mode 100644 index 0000000..8a46330 --- /dev/null +++ b/server/styleagent/model/entity/cps_product.go @@ -0,0 +1,21 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type CpsProduct struct { + Id int64 `orm:"id" json:"id"` + Source string `orm:"source" json:"source"` + OuterId string `orm:"outer_id" json:"outer_id"` + CategoryCode string `orm:"category_code" json:"category_code"` + Name string `orm:"name" json:"name"` + CoverUrl string `orm:"cover_url" json:"cover_url"` + PriceFen int64 `orm:"price_fen" json:"price_fen"` + ShopName string `orm:"shop_name" json:"shop_name"` + CommissionRate int `orm:"commission_rate" json:"commission_rate"` + City string `orm:"city" json:"city"` + SceneTags string `orm:"scene_tags" json:"scene_tags"` + Raw string `orm:"raw" json:"raw"` + Status int `orm:"status" json:"status"` + SyncAt *gtime.Time `orm:"sync_at" json:"sync_at"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/hairstyle_asset.go b/server/styleagent/model/entity/hairstyle_asset.go new file mode 100644 index 0000000..6f67a27 --- /dev/null +++ b/server/styleagent/model/entity/hairstyle_asset.go @@ -0,0 +1,14 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type HairstyleAsset struct { + Id int64 `orm:"id" json:"id"` + Name string `orm:"name" json:"name"` + StyleTag string `orm:"style_tag" json:"style_tag"` + GlbUrl string `orm:"glb_url" json:"glb_url"` + ThumbUrl string `orm:"thumb_url" json:"thumb_url"` + ApplicableFace string `orm:"applicable_face" json:"applicable_face"` + Sort int `orm:"sort" json:"sort"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/member_plan.go b/server/styleagent/model/entity/member_plan.go new file mode 100644 index 0000000..9263f50 --- /dev/null +++ b/server/styleagent/model/entity/member_plan.go @@ -0,0 +1,14 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type MemberPlan struct { + Id int64 `orm:"id" json:"id"` + Name string `orm:"name" json:"name"` + PriceFen int `orm:"price_fen" json:"price_fen"` + DurationDays int `orm:"duration_days" json:"duration_days"` + Features string `orm:"features" json:"features"` // 权益 JSON 数组字符串 + Sort int `orm:"sort" json:"sort"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/outfit_generation_task.go b/server/styleagent/model/entity/outfit_generation_task.go new file mode 100644 index 0000000..84d40ce --- /dev/null +++ b/server/styleagent/model/entity/outfit_generation_task.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type OutfitGenerationTask struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + StartDate string `orm:"start_date" json:"start_date"` + EndDate string `orm:"end_date" json:"end_date"` + Location string `orm:"location" json:"location"` + WeatherSnapshot string `orm:"weather_snapshot" json:"weather_snapshot"` + Status string `orm:"status" json:"status"` + Error string `orm:"error" json:"error"` + ModelName string `orm:"model_name" json:"model_name"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/server/styleagent/model/entity/outfit_plan.go b/server/styleagent/model/entity/outfit_plan.go new file mode 100644 index 0000000..7b3f978 --- /dev/null +++ b/server/styleagent/model/entity/outfit_plan.go @@ -0,0 +1,20 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type OutfitPlan struct { + Id int64 `orm:"id" json:"id"` + TaskId int64 `orm:"task_id" json:"task_id"` + UserId int64 `orm:"user_id" json:"user_id"` + DateRange string `orm:"date_range" json:"date_range"` + Location string `orm:"location" json:"location"` + Title string `orm:"title" json:"title"` + Source string `orm:"source" json:"source"` + Score int `orm:"score" json:"score"` + MainFlag int `orm:"main_flag" json:"main_flag"` + HairstyleId int64 `orm:"hairstyle_id" json:"hairstyle_id"` + HairColor string `orm:"hair_color" json:"hair_color"` + WeatherRef string `orm:"weather_ref" json:"weather_ref"` + Occasion string `orm:"occasion" json:"occasion"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/partner_store.go b/server/styleagent/model/entity/partner_store.go new file mode 100644 index 0000000..11a67df --- /dev/null +++ b/server/styleagent/model/entity/partner_store.go @@ -0,0 +1,15 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type PartnerStore struct { + Id int64 `orm:"id" json:"id"` + Name string `orm:"name" json:"name"` + Type int `orm:"type" json:"type"` + Lat float64 `orm:"lat" json:"lat"` + Lng float64 `orm:"lng" json:"lng"` + Address string `orm:"address" json:"address"` + CommissionPolicy string `orm:"commission_policy" json:"commission_policy"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/pay_notify_log.go b/server/styleagent/model/entity/pay_notify_log.go new file mode 100644 index 0000000..6c8622f --- /dev/null +++ b/server/styleagent/model/entity/pay_notify_log.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type PayNotifyLog struct { + Id int64 `orm:"id" json:"id"` + OrderNo string `orm:"order_no" json:"order_no"` + Body string `orm:"body" json:"body"` + Sign string `orm:"sign" json:"sign"` + RemoteIp string `orm:"remote_ip" json:"remote_ip"` + Status string `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/payment_order.go b/server/styleagent/model/entity/payment_order.go new file mode 100644 index 0000000..9ac72bd --- /dev/null +++ b/server/styleagent/model/entity/payment_order.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type PaymentOrder struct { + Id int64 `orm:"id" json:"id"` + OrderNo string `orm:"order_no" json:"order_no"` + UserId int64 `orm:"user_id" json:"user_id"` + PlanId int64 `orm:"plan_id" json:"plan_id"` + AmountFen int `orm:"amount_fen" json:"amount_fen"` + Channel string `orm:"channel" json:"channel"` + Status string `orm:"status" json:"status"` + TradeNo string `orm:"trade_no" json:"trade_no"` + NotifyRaw string `orm:"notify_raw" json:"-"` + PaidAt *gtime.Time `orm:"paid_at" json:"paid_at"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/plan_effect_image.go b/server/styleagent/model/entity/plan_effect_image.go new file mode 100644 index 0000000..a76447d --- /dev/null +++ b/server/styleagent/model/entity/plan_effect_image.go @@ -0,0 +1,14 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type PlanEffectImage struct { + Id int64 `orm:"id" json:"id"` + PlanId int64 `orm:"plan_id" json:"plan_id"` + Angle string `orm:"angle" json:"angle"` + Url string `orm:"url" json:"url"` + Status string `orm:"status" json:"status"` + PromptSnapshot string `orm:"prompt_snapshot" json:"prompt_snapshot"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/server/styleagent/model/entity/plan_outfit_item.go b/server/styleagent/model/entity/plan_outfit_item.go new file mode 100644 index 0000000..879dba6 --- /dev/null +++ b/server/styleagent/model/entity/plan_outfit_item.go @@ -0,0 +1,15 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type PlanOutfitItem struct { + Id int64 `orm:"id" json:"id"` + PlanId int64 `orm:"plan_id" json:"plan_id"` + Slot string `orm:"slot" json:"slot"` + Source string `orm:"source" json:"source"` + WardrobeItemId int64 `orm:"wardrobe_item_id" json:"wardrobe_item_id"` + ProductName string `orm:"product_name" json:"product_name"` + Name string `orm:"name" json:"name"` + Desc string `orm:"desc" json:"desc"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/plan_review.go b/server/styleagent/model/entity/plan_review.go new file mode 100644 index 0000000..0d160c8 --- /dev/null +++ b/server/styleagent/model/entity/plan_review.go @@ -0,0 +1,12 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type PlanReview struct { + Id int64 `orm:"id" json:"id"` + PlanId int64 `orm:"plan_id" json:"plan_id"` + UserId int64 `orm:"user_id" json:"user_id"` + Action string `orm:"action" json:"action"` + Note string `orm:"note" json:"note"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/scene_category_map.go b/server/styleagent/model/entity/scene_category_map.go new file mode 100644 index 0000000..b86ba8c --- /dev/null +++ b/server/styleagent/model/entity/scene_category_map.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type SceneCategoryMap struct { + Id int64 `orm:"id" json:"id"` + SceneType string `orm:"scene_type" json:"scene_type"` + Occasion string `orm:"occasion" json:"occasion"` + Source string `orm:"source" json:"source"` + CategoryCode string `orm:"category_code" json:"category_code"` + Priority int `orm:"priority" json:"priority"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/scoring_rule.go b/server/styleagent/model/entity/scoring_rule.go new file mode 100644 index 0000000..e365abb --- /dev/null +++ b/server/styleagent/model/entity/scoring_rule.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type ScoringRule struct { + Id int64 `orm:"id" json:"id"` + Dimension string `orm:"dimension" json:"dimension"` + RuleType string `orm:"rule_type" json:"rule_type"` + RulesJson string `orm:"rules_json" json:"rules_json"` + Enabled int `orm:"enabled" json:"enabled"` + Version int `orm:"version" json:"version"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/user.go b/server/styleagent/model/entity/user.go new file mode 100644 index 0000000..c9ac74e --- /dev/null +++ b/server/styleagent/model/entity/user.go @@ -0,0 +1,14 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type User struct { + Id int64 `orm:"id" json:"id"` + Role string `orm:"role" json:"role"` + Username string `orm:"username" json:"username"` + Phone string `orm:"phone" json:"phone"` + Password string `orm:"password" json:"-"` + Name string `orm:"name" json:"name"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/server/styleagent/model/entity/user_member.go b/server/styleagent/model/entity/user_member.go new file mode 100644 index 0000000..2b63c72 --- /dev/null +++ b/server/styleagent/model/entity/user_member.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type UserMember struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + PlanId int64 `orm:"plan_id" json:"plan_id"` + ExpireAt *gtime.Time `orm:"expire_at" json:"expire_at"` + Source string `orm:"source" json:"source"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` +} diff --git a/server/styleagent/model/entity/user_photo.go b/server/styleagent/model/entity/user_photo.go new file mode 100644 index 0000000..c96cd98 --- /dev/null +++ b/server/styleagent/model/entity/user_photo.go @@ -0,0 +1,12 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type UserPhoto struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + Type int `orm:"type" json:"type"` + Url string `orm:"url" json:"url"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/model/entity/wardrobe_item.go b/server/styleagent/model/entity/wardrobe_item.go new file mode 100644 index 0000000..bfb63f0 --- /dev/null +++ b/server/styleagent/model/entity/wardrobe_item.go @@ -0,0 +1,16 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +type WardrobeItem struct { + Id int64 `orm:"id" json:"id"` + UserId int64 `orm:"user_id" json:"user_id"` + PhotoUrl string `orm:"photo_url" json:"photo_url"` + Name string `orm:"name" json:"name"` + Category string `orm:"category" json:"category"` + Season string `orm:"season" json:"season"` + StyleTags string `orm:"style_tags" json:"style_tags"` + ColorInfo string `orm:"color_info" json:"color_info"` + Status int `orm:"status" json:"status"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` +} diff --git a/server/styleagent/service/ad_reward_log_service.go b/server/styleagent/service/ad_reward_log_service.go new file mode 100644 index 0000000..8be976e --- /dev/null +++ b/server/styleagent/service/ad_reward_log_service.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "errors" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +type adService struct{} + +var AdService = new(adService) + +type AdRewardResult struct { + AdType string `json:"ad_type"` + RemainingToday int `json:"remaining_today"` +} + +// Claim 领取广告激励:服务端限频计数,不信任客户端 +func (s *adService) Claim(ctx context.Context, userId int64, adType string) (*AdRewardResult, error) { + if adType != consts.AdTypeEffectExtra && adType != consts.AdTypeVipTrial { + return nil, errors.New("无效的广告类型") + } + limit := rewardQuota(ctx, adType) + used, err := dao.AdRewardLog.CountTodayByType(ctx, userId, adType) + if err != nil { + return nil, err + } + if used >= limit { + return nil, errors.New("今日次数已用完") + } + // 领取记录 + 会员赠送同一事务,避免"次数记了会员没送" + err = g.DB(consts.DBGroupPay).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, err := dao.AdRewardLog.InsertTx(ctx, tx, userId, adType, limit); err != nil { + return err + } + if adType == consts.AdTypeVipTrial { + return dao.UserMember.UpsertTx(ctx, tx, userId, 0, NextExpire(nil, 1), consts.MemberSourceAdTrial) + } + return nil + }) + if err != nil { + return nil, errors.New("今日次数已用完") // 唯一索引兜底并发 + } + return &AdRewardResult{AdType: adType, RemainingToday: limit - used - 1}, nil +} + +func rewardQuota(ctx context.Context, adType string) int { + if adType == consts.AdTypeVipTrial { + return g.Cfg().MustGet(ctx, "ad.limit_vip_trial", 1).Int() + } + return g.Cfg().MustGet(ctx, "ad.limit_effect_extra", 2).Int() +} + +func rewardRemaining(limit, used int) int { + if r := limit - used; r > 0 { + return r + } + return 0 +} diff --git a/server/styleagent/service/ad_reward_log_service_test.go b/server/styleagent/service/ad_reward_log_service_test.go new file mode 100644 index 0000000..e59fce7 --- /dev/null +++ b/server/styleagent/service/ad_reward_log_service_test.go @@ -0,0 +1,25 @@ +package service + +import ( + "testing" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" +) + +func TestRewardRemaining(t *testing.T) { + if got := rewardRemaining(2, 0); got != 2 { + t.Fatalf("未领取时剩余应为 2, got %d", got) + } + if got := rewardRemaining(2, 2); got != 0 { + t.Fatalf("已用满时剩余应为 0, got %d", got) + } + if got := rewardRemaining(1, 1); got != 0 { + t.Fatalf("vip_trial 已用完剩余应为 0, got %d", got) + } +} + +func TestRewardQuota(t *testing.T) { + if got := rewardQuota(t.Context(), "effect_extra"); got != 2 { + t.Fatalf("默认每日 2 次, got %d", got) + } +} diff --git a/server/styleagent/service/avatar_model_service.go b/server/styleagent/service/avatar_model_service.go new file mode 100644 index 0000000..89a42c8 --- /dev/null +++ b/server/styleagent/service/avatar_model_service.go @@ -0,0 +1,189 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + + "slogan-agent/styleagent/agent" + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" +) + +type avatarService struct{} + +var AvatarService = new(avatarService) + +// Build 构建化身:校验三视角全身照 → 写库(processing)→ 异步 Tripo 图像转 3D +func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.AvatarModel, error) { + photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0) + if err != nil { + return nil, err + } + byType := make(map[int]*entity.UserPhoto, len(photos)) + for _, p := range photos { + if _, ok := byType[p.Type]; !ok { + byType[p.Type] = p + } + } + for _, t := range []int{consts.PhotoTypeFullFront, consts.PhotoTypeFullSide, consts.PhotoTypeFullBack} { + if byType[t] == nil { + return nil, errors.New("请先上传三视角全身照(正面/侧面/背面)") + } + } + bodyMap := map[string]any{"height": 0, "weight": 0, "skin_tone": 0, "bust": 0, "waist": 0, "hip": 0, "shoulder": 0} + if b, err := dao.BodyMeasurement.GetByUser(ctx, userId); err == nil && b != nil { + bodyMap = map[string]any{ + "height": b.Height, "weight": b.Weight, "skin_tone": b.SkinTone, + "bust": b.Bust, "waist": b.Waist, "hip": b.Hip, "shoulder": b.Shoulder, + } + } + snapshot := mustJSON(map[string]any{ + "photo_front": byType[consts.PhotoTypeFullFront].Id, + "photo_side": byType[consts.PhotoTypeFullSide].Id, + "photo_back": byType[consts.PhotoTypeFullBack].Id, + "body": bodyMap, + }) + + var record *entity.AvatarModel + existing, err := dao.AvatarModel.GetByUser(ctx, userId) + if err != nil { + return nil, err + } + if existing != nil { + if err := dao.AvatarModel.Update(ctx, existing.Id, map[string]any{ + "face_template_id": 0, "body_template_id": 0, "skin_tone_index": 0, + "glb_url": "", "frames_url": "", + "build_status": consts.AvatarBuildProcessing, "error": "", + "params_snapshot": snapshot, + }); err != nil { + return nil, err + } + record = existing + } else { + id, err := dao.AvatarModel.Insert(ctx, &entity.AvatarModel{ + UserId: userId, + BuildStatus: consts.AvatarBuildProcessing, + ParamsSnapshot: snapshot, + }) + if err != nil { + return nil, err + } + record = &entity.AvatarModel{Id: id, UserId: userId} + } + + go s.buildJob(gctx.New(), record.Id, userId) + record.BuildStatus = consts.AvatarBuildProcessing + return record, nil +} + +// buildJob 异步构建:Tripo 上传三视角照片 → 提交任务 → 轮询 → 下载 GLB →(可选)渲染旋转帧 +func (s *avatarService) buildJob(ctx context.Context, id, userId int64) { + fail := func(msg string) { + g.Log().Warningf(ctx, "avatar build failed: %s", msg) + if dbErr := dao.AvatarModel.Update(ctx, id, map[string]any{ + "build_status": consts.AvatarBuildFailed, "error": msg, + "updated_at": "datetime('now','localtime')", + }); dbErr != nil { + g.Log().Warningf(ctx, "update avatar failed state: %v", dbErr) + } + } + + tc := agent.NewTripoClient(ctx) + if !tc.Enabled() { + fail("请先在 config.yml 配置 avatar.tripo_api_key") + return + } + + photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0) + if err != nil { + fail(fmt.Sprintf("读取照片失败: %v", err)) + return + } + var pick [3]*entity.UserPhoto // 0=正面 1=侧面 2=背面 + for _, p := range photos { + switch p.Type { + case consts.PhotoTypeFullFront: + if pick[0] == nil { + pick[0] = p + } + case consts.PhotoTypeFullSide: + if pick[1] == nil { + pick[1] = p + } + case consts.PhotoTypeFullBack: + if pick[2] == nil { + pick[2] = p + } + } + } + tokens := make([]string, 3) + for i, p := range pick { + if p == nil { + fail("构建前照片已被删除,请重新上传") + return + } + token, err := tc.UploadImage(ctx, strings.TrimPrefix(p.Url, "/")) + if err != nil { + fail(fmt.Sprintf("上传照片失败(视角 %d): %v", i+1, err)) + return + } + tokens[i] = token + } + + taskID, err := tc.SubmitMultiview(ctx, tokens[0], tokens[1], tokens[2]) + if err != nil { + fail(fmt.Sprintf("提交 Tripo 任务失败: %v", err)) + return + } + g.Log().Infof(ctx, "avatar tripo task submitted: %s", taskID) + + glbURL, err := tc.PollTask(ctx, taskID) + if err != nil { + fail(fmt.Sprintf("Tripo 生成失败: %v", err)) + return + } + + glbPath := filepath.Join("workspace", "avatar", fmt.Sprintf("user_%d", userId), "avatar.glb") + if err := tc.DownloadGlb(ctx, glbURL, glbPath); err != nil { + fail(fmt.Sprintf("下载 GLB 失败: %v", err)) + return + } + + framesURL := "" + if g.Cfg().MustGet(ctx, "avatar.render_frames", true).Bool() { + framesURL, err = agent.RenderAvatarFrames(ctx, glbPath, fmt.Sprintf("user_%d", userId)) + if err != nil { + g.Log().Warningf(ctx, "avatar frames render skipped: %v", err) + } + } + + if dbErr := dao.AvatarModel.Update(ctx, id, map[string]any{ + "glb_url": "/" + filepath.ToSlash(glbPath), "frames_url": framesURL, + "build_status": consts.AvatarBuildDone, "error": "", + "updated_at": "datetime('now','localtime')", + }); dbErr != nil { + g.Log().Warningf(ctx, "update avatar done state: %v", dbErr) + } +} + +// Get 我的化身 +func (s *avatarService) Get(ctx context.Context, userId int64) (*entity.AvatarModel, error) { + return dao.AvatarModel.GetByUser(ctx, userId) +} + +func mustJSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + g.Log().Warningf(context.Background(), "avatar params marshal failed: %v", err) + return "{}" + } + return string(b) +} diff --git a/server/styleagent/service/avatar_model_service_test.go b/server/styleagent/service/avatar_model_service_test.go new file mode 100644 index 0000000..ec4934c --- /dev/null +++ b/server/styleagent/service/avatar_model_service_test.go @@ -0,0 +1,51 @@ +package service + +import ( + "context" + "testing" + "time" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/frame/g" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +func avatarRecord(userId int64) *entity.AvatarModel { + return &entity.AvatarModel{ + UserId: userId, + BuildStatus: consts.AvatarBuildProcessing, + Error: "", + } +} + +// buildJob 未配置 Tripo key 时应置 failed 并提示配置(配置了 key 则跳过,避免真实网络调用) +func TestBuildJobMissingKeyFailed(t *testing.T) { + if key := g.Cfg().MustGet(context.Background(), "avatar.tripo_api_key", "").String(); key != "" { + t.Skip("已配置 avatar.tripo_api_key,跳过(避免真实 Tripo 调用)") + } + + userId := time.Now().UnixNano() + id, err := dao.AvatarModel.Insert(context.Background(), avatarRecord(userId)) + if err != nil { + t.Fatalf("插入测试化身失败: %v", err) + } + + AvatarService.buildJob(context.Background(), id, userId) + + got, err := dao.AvatarModel.GetByUser(context.Background(), userId) + if err != nil || got == nil { + t.Fatalf("读取化身失败: %v", err) + } + if got.BuildStatus != consts.AvatarBuildFailed { + t.Fatalf("未配置 key 应置 failed: got=%s", got.BuildStatus) + } + if got.Error == "" { + t.Fatal("未配置 key 时应记录错误文案") + } + if got.GlbUrl != "" { + t.Fatalf("失败时不应写 glb_url: got=%q", got.GlbUrl) + } +} diff --git a/server/styleagent/service/body_measurement_service.go b/server/styleagent/service/body_measurement_service.go new file mode 100644 index 0000000..bc645ce --- /dev/null +++ b/server/styleagent/service/body_measurement_service.go @@ -0,0 +1,42 @@ +package service + +import ( + "context" + + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +type bodyMeasurementService struct{} + +var BodyMeasurementService = new(bodyMeasurementService) + +func (s *bodyMeasurementService) Save(ctx context.Context, userId int64, req *entity.BodyMeasurement) error { + if req.Height == 0 { + req.Height = 170 + } + if req.Weight == 0 { + req.Weight = 60 + } + if req.SkinTone == 0 { + req.SkinTone = 3 + } + if req.Bust == 0 { + req.Bust = 88 + } + if req.Waist == 0 { + req.Waist = 70 + } + if req.Hip == 0 { + req.Hip = 92 + } + if req.Shoulder == 0 { + req.Shoulder = 42 + } + req.UserId = userId + return dao.BodyMeasurement.Save(ctx, req) +} + +func (s *bodyMeasurementService) Get(ctx context.Context, userId int64) (*entity.BodyMeasurement, error) { + return dao.BodyMeasurement.GetByUser(ctx, userId) +} diff --git a/server/styleagent/service/cps_category_service.go b/server/styleagent/service/cps_category_service.go new file mode 100644 index 0000000..1d6342a --- /dev/null +++ b/server/styleagent/service/cps_category_service.go @@ -0,0 +1,17 @@ +package service + +import ( + "context" + + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +type cpsCategoryService struct{} + +var CpsCategoryService = new(cpsCategoryService) + +// List 联盟分类(客户端 chips;未配置任何 key 时也无分类,前端隐藏入口) +func (s *cpsCategoryService) List(ctx context.Context) ([]*entity.CpsCategory, error) { + return dao.CpsCategory.List(ctx) +} diff --git a/server/styleagent/service/cps_click_log_service.go b/server/styleagent/service/cps_click_log_service.go new file mode 100644 index 0000000..eca5bad --- /dev/null +++ b/server/styleagent/service/cps_click_log_service.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +type cpsClickLogService struct{} + +var CpsClickLogService = new(cpsClickLogService) + +// Click 记录商品点击日志 +func (s *cpsClickLogService) Click(ctx context.Context, log *entity.CpsClickLog) error { + _, err := dao.CpsClickLog.Insert(ctx, log) + return err +} + +// MyRecent 最近优惠(点击日志 → 商品信息,去重倒序) +func (s *cpsClickLogService) MyRecent(ctx context.Context, userId int64) ([]*entity.CpsProduct, error) { + logs, err := dao.CpsClickLog.ListByUser(ctx, userId, 20) + if err != nil { + return nil, err + } + seen := make(map[string]bool, len(logs)) + out := make([]*entity.CpsProduct, 0, len(logs)) + for _, log := range logs { + key := log.Source + ":" + log.OuterId + if seen[key] { + continue + } + seen[key] = true + prod, err := dao.CpsProduct.GetByOuter(ctx, log.Source, log.OuterId) + if err != nil || prod == nil { + continue + } + out = append(out, prod) + } + return out, nil +} diff --git a/server/styleagent/service/cps_product_service.go b/server/styleagent/service/cps_product_service.go new file mode 100644 index 0000000..910143b --- /dev/null +++ b/server/styleagent/service/cps_product_service.go @@ -0,0 +1,195 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + "slogan-agent/styleagent/agent" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcron" +) + +type cpsProductService struct { + providers []agent.Provider + getLinkCache *agent.Cache +} + +var CpsProductService = new(cpsProductService) + +func init() { + CpsProductService.providers = agent.CpsProviders + CpsProductService.getLinkCache = agent.NewTTLCache(24 * time.Hour) +} + +// enabledProviders 仅保留配置了 key 的联盟源(未配置 key 优雅降级) +func (s *cpsProductService) enabledProviders() []agent.Provider { + out := make([]agent.Provider, 0, len(s.providers)) + for _, p := range s.providers { + if p.Enabled() { + out = append(out, p) + } + } + return out +} + +// GetLink 转链(24h 缓存;缓存 key 带 source 前缀避免跨联盟 outerId 冲突) +func (s *cpsProductService) GetLink(ctx context.Context, outerId string) (string, error) { + providers := s.enabledProviders() + if len(providers) == 0 { + return "", errors.New("CPS 未开通") + } + for _, p := range providers { + key := p.Source() + ":" + outerId + if v, ok := s.getLinkCache.Get(key); ok { + return v.(string), nil + } + link, err := p.GetLink(ctx, outerId) + if err != nil { + g.Log().Warningf(ctx, "联盟 %s 转链失败: %v", p.Source(), err) + continue + } + s.getLinkCache.Set(key, link) + return link, nil + } + return "", errors.New("所有联盟转链失败") +} + +// Search 聚合搜索(单个联盟失败不阻断整体,返回空列表而非错误) +func (s *cpsProductService) Search(ctx context.Context, keyword, catCode string, page int) ([]agent.CpsProduct, error) { + providers := s.enabledProviders() + if len(providers) == 0 { + return nil, nil + } + var out []agent.CpsProduct + for _, p := range providers { + list, err := p.Search(ctx, keyword, catCode, page) + if err != nil { + g.Log().Warningf(ctx, "联盟 %s 搜索失败: %v", p.Source(), err) + continue + } + out = append(out, list...) + } + return out, nil +} + +// SyncProducts 全量同步联盟商品(仅 Enabled 源;单联盟/单类目失败跳过不中断) +func (s *cpsProductService) SyncProducts(ctx context.Context, city, catCode string) error { + cats, err := dao.CpsCategory.List(ctx) + if err != nil { + return err + } + for _, p := range s.enabledProviders() { + for _, c := range cats { + if c.Source != p.Source() { + continue + } + s.syncCategory(ctx, p, city, c) + } + } + return nil +} + +func (s *cpsProductService) syncCategory(ctx context.Context, p agent.Provider, city string, c *entity.CpsCategory) { + products, err := p.SyncProducts(ctx, city, c.Code) + if err != nil { + g.Log().Warningf(ctx, "联盟 %s 同步类目 %s 失败: %v", p.Source(), c.Code, err) + return + } + list := make([]*entity.CpsProduct, 0, len(products)) + for i := range products { + prod := &products[i] + if prod.Source == "" { + prod.Source = p.Source() + } + if prod.CategoryCode == "" { + prod.CategoryCode = c.Code + } + list = append(list, toCpsProductEntity(prod)) + } + // 批量 Upsert(multi-row SQL,每批独立事务),避免逐条执行的 N+1 + if err := dao.CpsProduct.UpsertBatch(ctx, list); err != nil { + g.Log().Warningf(ctx, "批量写入商品失败(类目 %s): %v", c.Code, err) + } +} + +func toCpsProductEntity(p *agent.CpsProduct) *entity.CpsProduct { + return &entity.CpsProduct{ + Source: p.Source, + OuterId: p.OuterId, + CategoryCode: p.CategoryCode, + Name: p.Name, + CoverUrl: p.CoverUrl, + PriceFen: p.PriceFen, + ShopName: p.ShopName, + CommissionRate: p.CommissionRate, + City: p.City, + SceneTags: strings.Join(p.SceneTags, ","), + Raw: p.Raw, + } +} + +// ListByCategory 分页商品列表(hasMore 供客户端上滑分页) +func (s *cpsProductService) ListByCategory(ctx context.Context, source, categoryCode, city string, page, pageSize int) ([]*entity.CpsProduct, bool, error) { + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + total, err := dao.CpsProduct.CountByCategory(ctx, source, categoryCode, city) + if err != nil { + return nil, false, err + } + list, err := dao.CpsProduct.ListByCategory(ctx, source, categoryCode, city, page, pageSize) + if err != nil { + return nil, false, err + } + return list, page*pageSize < total, nil +} + +// ClickLink 取转链并记录点击日志(/cps/product/link 调用) +func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int64, scene string, planId int64, ip string) (string, error) { + prod, err := dao.CpsProduct.Get(ctx, productId) + if err != nil || prod == nil { + return "", errors.New("商品不存在") + } + link, err := s.GetLink(ctx, prod.OuterId) + if err != nil { + return "", err + } + if err := CpsClickLogService.Click(ctx, &entity.CpsClickLog{ + UserId: userId, + Source: prod.Source, + OuterId: prod.OuterId, + Scene: scene, + PlanId: planId, + CategoryCode: prod.CategoryCode, + Deeplink: link, + Ip: ip, + }); err != nil { + g.Log().Warningf(ctx, "记录 CPS 点击日志失败: %v", err) + } + return link, nil +} + +// StartSyncLoop 定时同步联盟商品(main 启动;未配置任何 key 时空转) +func (s *cpsProductService) StartSyncLoop(ctx context.Context) { + spec := g.Cfg().MustGet(ctx, "cps.sync_cron", "0 4 * * *").String() + if _, err := gcron.Add(ctx, spec, func(ctx context.Context) { + if len(s.enabledProviders()) == 0 { + return + } + g.Log().Info(ctx, "CPS 定时同步开始") + if err := s.SyncProducts(ctx, "", ""); err != nil { + g.Log().Warningf(ctx, "CPS 定时同步失败: %v", err) + } + g.Log().Info(ctx, "CPS 定时同步结束") + }); err != nil { + g.Log().Warningf(ctx, "CPS 定时同步注册失败: %v", err) + } +} diff --git a/server/styleagent/service/cps_product_service_test.go b/server/styleagent/service/cps_product_service_test.go new file mode 100644 index 0000000..88a13a4 --- /dev/null +++ b/server/styleagent/service/cps_product_service_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + + "slogan-agent/styleagent/agent" +) + +type stubCpsProvider struct { + enabled bool + link string + linkErr error + calls int + search []agent.CpsProduct + lastKw string + lastCat string +} + +func (p *stubCpsProvider) Source() string { return "stub" } +func (p *stubCpsProvider) Enabled() bool { return p.enabled } +func (p *stubCpsProvider) SyncProducts(ctx context.Context, city, catCode string) ([]agent.CpsProduct, error) { + return nil, nil +} +func (p *stubCpsProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]agent.CpsProduct, error) { + if !p.enabled { + return nil, errors.New("not enabled") + } + p.lastKw = keyword + p.lastCat = catCode + return p.search, nil +} +func (p *stubCpsProvider) GetLink(ctx context.Context, outerId string) (string, error) { + p.calls++ + if p.linkErr != nil { + return "", p.linkErr + } + return p.link, nil +} + +func TestCpsDisabledHasNoProviders(t *testing.T) { + s := &cpsProductService{providers: []agent.Provider{&stubCpsProvider{enabled: false}}} + if len(s.enabledProviders()) != 0 { + t.Fatal("未配置 key 的 provider 不应进入注册表") + } +} + +func TestCpsGetLinkCache(t *testing.T) { + p := &stubCpsProvider{enabled: true, link: "https://t.cn/abc"} + s := &cpsProductService{providers: []agent.Provider{p}, getLinkCache: agent.NewTTLCache(24 * time.Hour)} + + link1, err := s.GetLink(context.Background(), "outer1") + if err != nil || link1 != "https://t.cn/abc" { + t.Fatalf("首次转链失败: %v %q", err, link1) + } + link2, err := s.GetLink(context.Background(), "outer1") + if err != nil || link2 != "https://t.cn/abc" { + t.Fatalf("缓存命中失败: %v %q", err, link2) + } + if p.calls != 1 { + t.Fatalf("转链应只调用联盟 1 次(缓存),实际 %d 次", p.calls) + } +} + +func TestCpsGetLinkNoProvider(t *testing.T) { + s := &cpsProductService{providers: []agent.Provider{}} + if _, err := s.GetLink(context.Background(), "outer1"); err == nil { + t.Fatal("无 provider 时转链应返回错误") + } +} + +func TestCpsSearchDegradesEmpty(t *testing.T) { + s := &cpsProductService{providers: []agent.Provider{}} + list, err := s.Search(context.Background(), "西装", "", 1) + if err != nil { + t.Fatalf("降级应返回空而非错误: %v", err) + } + if len(list) != 0 { + t.Fatalf("降级应返回空列表: %d", len(list)) + } +} diff --git a/server/styleagent/service/hairstyle_asset_service.go b/server/styleagent/service/hairstyle_asset_service.go new file mode 100644 index 0000000..4a04012 --- /dev/null +++ b/server/styleagent/service/hairstyle_asset_service.go @@ -0,0 +1,16 @@ +package service + +import ( + "context" + + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +type hairstyleService struct{} + +var HairstyleService = new(hairstyleService) + +func (s *hairstyleService) List(ctx context.Context) ([]*entity.HairstyleAsset, error) { + return dao.HairstyleAsset.ListAll(ctx) +} diff --git a/server/styleagent/service/member_plan_service.go b/server/styleagent/service/member_plan_service.go new file mode 100644 index 0000000..3847b36 --- /dev/null +++ b/server/styleagent/service/member_plan_service.go @@ -0,0 +1,58 @@ +package service + +import ( + "context" + "encoding/json" + "time" + + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type memberPlanService struct{} + +var MemberPlanService = new(memberPlanService) + +func (s *memberPlanService) PlanList(ctx context.Context) ([]*entity.MemberPlan, error) { + return dao.MemberPlan.ListEnabled(ctx) +} + +type MemberStatus struct { + IsVip bool `json:"is_vip"` + ExpireAt string `json:"expire_at"` + PlanName string `json:"plan_name"` + Benefits []string `json:"benefits"` +} + +func (s *memberPlanService) Status(ctx context.Context, userId int64) (*MemberStatus, error) { + st := &MemberStatus{Benefits: make([]string, 0)} + um, err := dao.UserMember.GetByUser(ctx, userId) + if err != nil { + return nil, err + } + if um == nil || um.ExpireAt == nil || um.ExpireAt.Time.Before(time.Now()) { + return st, nil + } + st.IsVip = true + st.ExpireAt = um.ExpireAt.Format("Y-m-d H:i:s") + if plan, err := dao.MemberPlan.GetOne(ctx, um.PlanId); err != nil { + g.Log().Warningf(ctx, "读取会员套餐 %d 失败: %v", um.PlanId, err) + } else if plan != nil { + st.PlanName = plan.Name + st.Benefits = parseBenefits(ctx, plan.Features) + } + return st, nil +} + +func parseBenefits(ctx context.Context, features string) []string { + var list []string + if err := json.Unmarshal([]byte(features), &list); err != nil { + g.Log().Warningf(ctx, "解析套餐权益失败(features=%q): %v", features, err) + } + if list == nil { + list = make([]string, 0) + } + return list +} diff --git a/server/styleagent/service/member_service_test.go b/server/styleagent/service/member_service_test.go new file mode 100644 index 0000000..ef16f0f --- /dev/null +++ b/server/styleagent/service/member_service_test.go @@ -0,0 +1,28 @@ +package service + +import ( + "testing" + "time" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/os/gtime" +) + +func TestNextExpireFromNow(t *testing.T) { + got := NextExpire(nil, 30) + want := time.Now().Add(30 * 24 * time.Hour).Format("2006-01-02 15:04:05") + gotT, _ := time.Parse("2006-01-02 15:04:05", got) + wantT, _ := time.Parse("2006-01-02 15:04:05", want) + if !gotT.Equal(wantT) { + t.Fatalf("过期会员应从现在起算: got=%s want~%s", got, want) + } +} + +func TestNextExpireStackOnFuture(t *testing.T) { + base := gtime.NewFromTime(time.Now().Add(10 * 24 * time.Hour)) + got := NextExpire(base, 30) + gotT, _ := time.Parse("2006-01-02 15:04:05", got) + if gotT.Before(base.Time) { + t.Fatalf("未过期会员应叠加: got=%s base=%s", got, base.Format("2006-01-02 15:04:05")) + } +} diff --git a/server/styleagent/service/outfit_generation_task_service.go b/server/styleagent/service/outfit_generation_task_service.go new file mode 100644 index 0000000..9b280b2 --- /dev/null +++ b/server/styleagent/service/outfit_generation_task_service.go @@ -0,0 +1,367 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "slogan-agent/styleagent/agent" + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" +) + +type outfitService struct{} + +var OutfitService = new(outfitService) + +// Generate 创建生成任务(pending)并异步执行核心流程 +func (s *outfitService) Generate(ctx context.Context, userId int64, req *dto.OutfitGenerateReq) (int64, error) { + if req.StartDate > req.EndDate { + return 0, errors.New("开始日期不能晚于结束日期") + } + items, err := dao.WardrobeItem.ListAllByUser(ctx, userId) + if err != nil { + return 0, err + } + if len(items) < 3 { + return 0, errors.New("衣橱服装不足,请先添加至少 3 件服装") + } + taskId, err := dao.OutfitGenTask.Insert(ctx, &entity.OutfitGenerationTask{ + UserId: userId, StartDate: req.StartDate, EndDate: req.EndDate, + Location: req.Location, Status: consts.TaskStatusPending, + }) + if err != nil { + return 0, err + } + // 异步执行:传入独立 ctx(请求结束不中断任务) + go runGenerateTask(gctx.New(), taskId, userId, normalizeOccasion(req.Occasion)) + return taskId, nil +} + +// normalizeOccasion 空场景默认通勤(评分引擎按 通勤/约会/聚会/运动 匹配) +func normalizeOccasion(o string) string { + if o == "" { + return "通勤" + } + return o +} + +// StartWorker 服务启动时恢复未完成任务(标记失败,避免重启后重复消耗 LLM 费用) +func (s *outfitService) StartWorker(ctx context.Context) { + tasks, err := dao.OutfitGenTask.ListUnfinished(ctx) + if err != nil { + g.Log().Warningf(ctx, "恢复未完成任务失败: %v", err) + return + } + for _, t := range tasks { + if err := dao.OutfitGenTask.UpdateStatus(ctx, t.Id, consts.TaskStatusFailed, "服务重启,任务中断,请重新生成"); err != nil { + g.Log().Warningf(ctx, "标记任务 %d failed 失败: %v", t.Id, err) + continue + } + g.Log().Infof(ctx, "任务 %d 已标记 failed(服务重启)", t.Id) + } +} + +// runGenerateTask 任务核心流程:planning → scoring → done/failed +func runGenerateTask(ctx context.Context, taskId, userId int64, occasion string) { + setTask := func(status, msg string) { + if err := dao.OutfitGenTask.UpdateStatus(ctx, taskId, status, msg); err != nil { + g.Log().Warningf(ctx, "更新任务 %d 状态 %s 失败: %v", taskId, status, err) + } + } + fail := func(err error) { + setTask(consts.TaskStatusFailed, err.Error()) + g.Log().Errorf(ctx, "生成任务 %d 失败: %v", taskId, err) + } + + task, err := dao.OutfitGenTask.GetOne(ctx, taskId, userId) + if err != nil { + fail(fmt.Errorf("读取任务失败: %w", err)) + return + } + if task == nil { + return + } + + // 1. 天气(评分依赖;接口不可用时降级默认天气,配置 key 后走真实数据) + setTask(consts.TaskStatusPlanning, "") + weatherResult, err := GetWeather(ctx, task.Location, task.StartDate, task.EndDate) + if err != nil { + g.Log().Warningf(ctx, "天气获取失败(%v),使用默认天气继续", err) + weatherResult = &agent.WeatherResult{ + CityCode: task.Location, + Days: []agent.DayWeather{ + {Date: task.StartDate, TempMax: 30, TempMin: 24, TextDay: "晴"}, + }, + AvgTemp: 27, + Season: "夏", + } + } + weatherJSON, marshalErr := json.Marshal(weatherResult) + if marshalErr != nil { + g.Log().Warningf(ctx, "序列化天气数据失败: %v", marshalErr) + } + if err := dao.OutfitGenTask.Update(ctx, taskId, g.Map{"weather_snapshot": string(weatherJSON), "model_name": g.Cfg().MustGet(ctx, "llm.model_name", "").String()}); err != nil { + g.Log().Warningf(ctx, "写入任务 %d 天气快照失败: %v", taskId, err) + } + + // 2. LLM 配置 + cfg, err := agent.GetModelConfig(ctx) + if err != nil { + fail(err) + return + } + + // 3. 预筛 3 套候选 + items, err := dao.WardrobeItem.ListAllByUser(ctx, userId) + if err != nil { + fail(err) + return + } + sets := combineCandidates(items, weatherResult.Season, 3) + if len(sets) == 0 { + fail(errors.New("没有符合当前季节的服装,请补充衣橱")) + return + } + + // 4. LLM 规划(1 次调用) + hairstyles := hairstyleListText(ctx) + bodyDesc := bodyDescText(ctx, userId) + candidates := make([]agent.CandidateData, 0, len(sets)*3) + for si, set := range sets { + for _, it := range set.Items { + candidates = append(candidates, agent.CandidateData{ + SetId: int64(si + 1), ItemId: it.Id, Category: it.Category, + Name: it.Name, Color: it.ColorInfo, Season: it.Season, Style: it.StyleTags, + }) + } + } + userInput := agent.BuildPlanUserInput(weatherSummaryText(weatherResult), occasion, "", hairstyles, bodyDesc) + out, err := agent.PlanOutfits(ctx, cfg, agent.SystemPromptPlan(), userInput, candidates) + if err != nil { + fail(err) + return + } + + // 5. 规则评分(衣橱预筛方案) + setTask(consts.TaskStatusScoring, "") + plans := out.Plans + ctxScore := agent.ScoreContext{ + TempAvg: weatherResult.AvgTemp, Season: weatherResult.Season, + Occasion: occasion, Weekday: weekdayOf(task.StartDate), + } + + // 6. 追加 AI 推荐方案(新品为主,与衣橱方案合并评分落库) + wardrobeJSON, marshalErr := json.Marshal(items) + if marshalErr != nil { + g.Log().Warningf(ctx, "序列化衣橱数据失败(影响 AI 推荐方案): %v", marshalErr) + } + fallbackInput := agent.BuildFallbackUserInput(weatherSummaryText(weatherResult), occasion, string(wardrobeJSON), hairstyles, bodyDesc) + fallback, err := agent.CreateRecommendPlan(ctx, cfg, agent.SystemPromptPlan(), fallbackInput) + if err != nil { + fail(err) + return + } + plans = append(plans, fallback.Plans...) + + scores := make([]int, len(plans)) + for i, p := range plans { + scores[i] = scorePlan(p, items, ctxScore) + } + + // 7. 落库 plan + items(单事务:方案+单品批量写入+任务完成原子提交,失败整体回滚不留半套方案) + hairstylesAll, err := dao.HairstyleAsset.ListAll(ctx) + if err != nil { + g.Log().Warningf(ctx, "读取发型库失败,方案发型将不匹配: %v", err) + } + dateRange := task.StartDate + " ~ " + task.EndDate + weatherRef := weatherSummaryText(weatherResult) + err = g.DB(consts.DBGroupPlan).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + var items []*entity.PlanOutfitItem + for i, p := range plans { + planId, err := dao.OutfitPlan.InsertTx(ctx, tx, &entity.OutfitPlan{ + TaskId: taskId, UserId: userId, DateRange: dateRange, Location: task.Location, + Title: p.Title, Source: planSource(p), Score: scores[i], + HairstyleId: matchHairstyle(p.Hairstyle, hairstylesAll), HairColor: p.HairColor, + WeatherRef: weatherRef, Occasion: occasion, + }) + if err != nil { + return err + } + for _, it := range p.Items { + source := consts.PlanSourceWardrobe + productName := "" + if it.NewItem || it.ItemId == 0 { + source = consts.PlanSourceRecommend + productName = it.Name + } + items = append(items, &entity.PlanOutfitItem{ + PlanId: planId, Slot: it.Slot, Source: source, + WardrobeItemId: it.ItemId, ProductName: productName, Name: it.Name, Desc: it.Desc, + }) + } + } + if err := dao.PlanOutfitItem.InsertBatchTx(ctx, tx, items); err != nil { + return err + } + return dao.OutfitGenTask.UpdateStatusTx(ctx, tx, taskId, consts.TaskStatusDone, "") + }) + if err != nil { + fail(err) + return + } + g.Log().Infof(ctx, "任务 %d 完成,共 %d 套方案", taskId, len(plans)) +} + +// scorePlan 分别打分:衣橱单品走规则评分,推荐新品按件数加基础分,合并为方案总分 +func scorePlan(p agent.PlanCandidate, items []*entity.WardrobeItem, ctxScore agent.ScoreContext) int { + var out agent.CandidateOutfit + byId := map[int64]*entity.WardrobeItem{} + for _, it := range items { + byId[it.Id] = it + } + newItems := 0 + for _, it := range p.Items { + if w := byId[it.ItemId]; w != nil { + out.Items = append(out.Items, agent.WardrobeItem{ + Category: w.Category, Season: w.Season, ColorInfo: w.ColorInfo, StyleTags: w.StyleTags, + }) + } else { + newItems++ + } + } + return agent.Score(&out, &ctxScore) + newItems*agent.NewItemBaseScore +} + +// ==================== 查询 ==================== + +func (s *outfitService) GetTaskStatus(ctx context.Context, userId, taskId int64) (string, string, error) { + t, err := dao.OutfitGenTask.GetOne(ctx, taskId, userId) + if err != nil || t == nil { + return "", "", errors.New("任务不存在") + } + return t.Status, t.Error, nil +} + +// ==================== 天气(原 weather_service 内联) ==================== + +var weatherCache = agent.NewTTLCache(6 * time.Hour) + +// GetWeather 地点 + 日期范围 → 天气结果(高德地理编码 + 和风 7 天预报,缓存 6 小时) +func GetWeather(ctx context.Context, location, startDate, endDate string) (*agent.WeatherResult, error) { + cityCode, err := agent.GetCityCode(ctx, location) + if err != nil { + return nil, err + } + cacheKey := fmt.Sprintf("%s:%s:%s", cityCode, startDate, endDate) + if v, ok := weatherCache.Get(cacheKey); ok { + if result, ok := v.(*agent.WeatherResult); ok { + return result, nil + } + } + result, err := agent.GetDaily(ctx, cityCode, startDate, endDate) + if err != nil { + return nil, err + } + weatherCache.Set(cacheKey, result) + return result, nil +} + +func weatherSummaryText(w *agent.WeatherResult) string { + return fmt.Sprintf("%s(%s),平均 %d℃,%d 天", w.CityCode, w.Season, w.AvgTemp, len(w.Days)) +} + +// ==================== 预筛组合(原 outfit_combiner 内联) ==================== + +// candidateSet 一套预筛组合 +type candidateSet struct { + Items []*entity.WardrobeItem + HasOuterwear bool +} + +// combineCandidates 预筛组合算法: +// 按 category 分组 → 按季节过滤 → 确定性轮询组合,最多 3 套互不相同(含外套标记) +func combineCandidates(items []*entity.WardrobeItem, season string, maxSets int) []candidateSet { + groups := map[string][]*entity.WardrobeItem{} + for _, it := range items { + if season != "" && it.Season != "" && it.Season != "四季" && it.Season != season { + continue + } + groups[it.Category] = append(groups[it.Category], it) + } + if len(groups) == 0 || maxSets <= 0 { + return nil + } + + var sets []candidateSet + cats := []string{"上衣", "下装", "鞋", "配饰"} + for i := 0; i < maxSets; i++ { + set := candidateSet{} + hasOuterwear := false + for _, cat := range cats { + g := groups[cat] + if len(g) == 0 { + continue + } + it := g[i%len(g)] + set.Items = append(set.Items, it) + if isOuterwear(it) { + hasOuterwear = true + } + } + if len(set.Items) == 0 { + break + } + set.HasOuterwear = hasOuterwear + sets = append(sets, set) + } + return sets +} + +func isOuterwear(it *entity.WardrobeItem) bool { + return it.Category == "上衣" && (it.StyleTags == "" || it.StyleTags == "外套") +} + +// toScoringOutfit 转评分用候选 +func toScoringOutfit(set candidateSet) agent.CandidateOutfit { + o := agent.CandidateOutfit{HasOuterwear: set.HasOuterwear} + for _, it := range set.Items { + o.Items = append(o.Items, agent.WardrobeItem{ + Category: it.Category, + Season: it.Season, + ColorInfo: it.ColorInfo, + StyleTags: it.StyleTags, + }) + } + return o +} + +// ==================== 内部辅助 ==================== + +func bodyDescText(ctx context.Context, userId int64) string { + bm, err := dao.BodyMeasurement.GetByUser(ctx, userId) + if err != nil || bm == nil { + return "身高 170cm,体重 60kg(默认)" + } + return fmt.Sprintf("身高 %dcm,体重 %dkg,肤色 %d 档", bm.Height, bm.Weight, bm.SkinTone) +} + +func weekdayOf(date string) string { + d, err := time.Parse("2006-01-02", date) + if err != nil { + return "workday" + } + wd := d.Weekday() + if wd == time.Saturday || wd == time.Sunday { + return "weekend" + } + return "workday" +} diff --git a/server/styleagent/service/outfit_generation_task_service_test.go b/server/styleagent/service/outfit_generation_task_service_test.go new file mode 100644 index 0000000..6acf085 --- /dev/null +++ b/server/styleagent/service/outfit_generation_task_service_test.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "testing" + + "github.com/gogf/gf/v2/frame/g" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "slogan-agent/styleagent/model/dto" +) + +func TestGenerateReqOccasionValidation(t *testing.T) { + valid := []string{"", "通勤", "约会", "聚会", "运动"} + for _, v := range valid { + req := &dto.OutfitGenerateReq{ + StartDate: "2026-08-01", EndDate: "2026-08-02", + Location: "上海", Occasion: v, + } + if err := g.Validator().Data(req).Run(context.Background()); err != nil { + t.Fatalf("occasion %q 应通过校验: %v", v, err) + } + } + req := &dto.OutfitGenerateReq{ + StartDate: "2026-08-01", EndDate: "2026-08-02", + Location: "上海", Occasion: "随便", + } + if err := g.Validator().Data(req).Run(context.Background()); err == nil { + t.Fatal("非法 occasion 应被拒绝") + } +} + +func TestNormalizeOccasion(t *testing.T) { + if got := normalizeOccasion(""); got != "通勤" { + t.Fatalf("空场景应默认通勤, got %q", got) + } + if got := normalizeOccasion("约会"); got != "约会" { + t.Fatalf("非空场景应原样返回, got %q", got) + } +} diff --git a/server/styleagent/service/outfit_plan_service.go b/server/styleagent/service/outfit_plan_service.go new file mode 100644 index 0000000..b61f4be --- /dev/null +++ b/server/styleagent/service/outfit_plan_service.go @@ -0,0 +1,108 @@ +package service + +import ( + "context" + "errors" + + "slogan-agent/styleagent/agent" + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" +) + +type outfitPlanService struct{} + +var OutfitPlanService = new(outfitPlanService) + +func (s *outfitPlanService) ListPlans(ctx context.Context, userId int64) ([]*entity.OutfitPlan, error) { + return dao.OutfitPlan.ListByUser(ctx, userId) +} + +// GetPlan 按用户取方案(CPS 方案驱动推荐入口用) +func (s *outfitPlanService) GetPlan(ctx context.Context, userId, planId int64) (*entity.OutfitPlan, error) { + return dao.OutfitPlan.GetOne(ctx, planId, userId) +} + +func (s *outfitPlanService) GetPlanDetail(ctx context.Context, userId, planId int64) (*dto.OutfitPlanDetailRes, error) { + plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) + if err != nil || plan == nil { + return nil, errors.New("方案不存在") + } + res := &dto.OutfitPlanDetailRes{Plan: plan} + res.Items, err = PlanOutfitItemService.ListByPlan(ctx, planId) + if err != nil { + return nil, err + } + res.Images, err = dao.PlanEffectImage.ListByPlan(ctx, planId) + if err != nil { + return nil, err + } + if plan.HairstyleId > 0 { + res.Hairstyle, err = dao.HairstyleAsset.GetOne(ctx, plan.HairstyleId) + if err != nil { + g.Log().Warningf(ctx, "读取发型 %d 失败: %v", plan.HairstyleId, err) + } + } + return res, nil +} + +// SelectMain 选定主方案(同任务其他方案清零,同一事务保证不出现无主方案)+ 异步生成效果图 +func (s *outfitPlanService) SelectMain(ctx context.Context, userId, planId int64) error { + plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) + if err != nil || plan == nil { + return errors.New("方案不存在") + } + err = g.DB(consts.DBGroupPlan).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if err := dao.OutfitPlan.ClearMainFlagTx(ctx, tx, plan.TaskId); err != nil { + return err + } + return dao.OutfitPlan.SetMainFlagTx(ctx, tx, planId) + }) + if err != nil { + return err + } + // 异步生成 3 视角效果图 + EffectImageService.GenerateForPlan(gctx.New(), planId, userId) + return nil +} + +func planSource(p agent.PlanCandidate) string { + for _, it := range p.Items { + if it.NewItem || it.ItemId == 0 { + return consts.PlanSourceRecommend + } + } + return consts.PlanSourceWardrobe +} + +func matchHairstyle(name string, all []*entity.HairstyleAsset) int64 { + if name == "" { + return 0 + } + for _, h := range all { + if h.Name == name { + return h.Id + } + } + return 0 +} + +func hairstyleListText(ctx context.Context) string { + all, err := dao.HairstyleAsset.ListAll(ctx) + if err != nil { + return "" + } + text := "" + for i, h := range all { + if i > 0 { + text += ";" + } + text += h.Name + "," + h.StyleTag + } + return text +} diff --git a/server/styleagent/service/partner_store_service.go b/server/styleagent/service/partner_store_service.go new file mode 100644 index 0000000..c7f5cde --- /dev/null +++ b/server/styleagent/service/partner_store_service.go @@ -0,0 +1,17 @@ +package service + +import ( + "context" + + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +type partnerStoreService struct{} + +var PartnerStoreService = new(partnerStoreService) + +// List 合作门店列表(type 为 0 返回全部) +func (s *partnerStoreService) List(ctx context.Context, storeType int) ([]*entity.PartnerStore, error) { + return dao.PartnerStore.List(ctx, storeType) +} diff --git a/server/styleagent/service/pay_notify_log_service.go b/server/styleagent/service/pay_notify_log_service.go new file mode 100644 index 0000000..3901770 --- /dev/null +++ b/server/styleagent/service/pay_notify_log_service.go @@ -0,0 +1,19 @@ +package service + +import ( + "context" + + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +type payNotifyLogService struct{} + +var PayNotifyLogService = new(payNotifyLogService) + +// Insert 回调日志全量入库(审计) +func (s *payNotifyLogService) Insert(ctx context.Context, orderNo, body, sign, remoteIP, status string) error { + return dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{ + OrderNo: orderNo, Body: body, Sign: sign, RemoteIp: remoteIP, Status: status, + }) +} diff --git a/server/styleagent/service/payment_order_service.go b/server/styleagent/service/payment_order_service.go new file mode 100644 index 0000000..b1adcec --- /dev/null +++ b/server/styleagent/service/payment_order_service.go @@ -0,0 +1,236 @@ +package service + +import ( + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "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/gtime" +) + +type paymentOrderService struct{} + +var PaymentOrderService = new(paymentOrderService) + +// ==================== 虎皮棋聚合支付适配器(签名/HTTP 细节收敛在本文件,业务层不感知) ==================== +// 注意:签名规则与字段名以官方最新文档为准(当前实现为经典 md5 约定)。 + +type paymentConfig struct { + AppId string + AppSecret string + NotifyUrl string + Channel string // 逗号分隔,如 "alipay,wechat" + ApiBase string + Enabled bool +} + +func (s *paymentOrderService) getPaymentConfig(ctx context.Context) paymentConfig { + cfg := paymentConfig{ + AppId: g.Cfg().MustGet(ctx, "payment.xunhu_appid", "").String(), + AppSecret: g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String(), + NotifyUrl: g.Cfg().MustGet(ctx, "payment.notify_url", "").String(), + Channel: g.Cfg().MustGet(ctx, "payment.channel", "alipay").String(), + ApiBase: g.Cfg().MustGet(ctx, "payment.api_base", "https://api.xunhupay.com").String(), + } + cfg.Enabled = cfg.AppId != "" && cfg.AppSecret != "" + return cfg +} + +// CreateOrder 创建支付单,返回收银台/支付 URL(金额单位:分) +func (s *paymentOrderService) CreateOrder(ctx context.Context, orderNo string, amountFen int) (payURL string, err error) { + cfg := s.getPaymentConfig(ctx) + if !cfg.Enabled { + return "", errors.New("支付未开通,请在 config.yml 配置 payment") + } + channel := "alipay" + if first := strings.Split(cfg.Channel, ",")[0]; first != "" { + channel = first + } + params := map[string]string{ + "appid": cfg.AppId, + "trade_order_id": orderNo, + "total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100), + "title": "形象会员", + "notify_url": cfg.NotifyUrl, + "type": channel, + "version": "1.1", + "nonce_str": paymentNonce(), + } + params["hash"] = paymentSign(params, cfg.AppSecret) + + var resp struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + Url string `json:"url"` + } + // 注意:Post 的最后一个参数不会自动解析响应体,需手动读取后反序列化 + respRaw, err := g.Client().SetTimeout(10*time.Second).Post(context.Background(), cfg.ApiBase+"/payment/do.html", params) + if err != nil { + return "", fmt.Errorf("虎皮棋下单失败: %w", err) + } + defer respRaw.Close() + if err := json.Unmarshal(respRaw.ReadAll(), &resp); err != nil { + return "", fmt.Errorf("虎皮棋下单失败: %w", err) + } + if resp.Errcode != 0 { + return "", fmt.Errorf("虎皮棋下单失败: %s", resp.Errmsg) + } + if resp.Url == "" { + return "", errors.New("虎皮棋下单失败: 返回为空") + } + return resp.Url, nil +} + +// VerifyNotify 验签:复制参数去掉 hash 后重算签名比较 +func (s *paymentOrderService) VerifyNotify(params map[string]string, hash, secret string) bool { + if hash == "" || secret == "" { + return false + } + cp := make(map[string]string, len(params)) + for k, v := range params { + if k != "hash" { + cp[k] = v + } + } + return paymentSign(cp, secret) == strings.ToLower(hash) +} + +// paymentSign 参数名升序拼接 key=value,追加 secret 后 md5 hex +func paymentSign(params map[string]string, secret string) string { + keys := make([]string, 0, len(params)) + for k := range params { + if params[k] == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var sb strings.Builder + for i, k := range keys { + if i > 0 { + sb.WriteString("&") + } + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(params[k]) + } + sb.WriteString(secret) + sum := md5.Sum([]byte(sb.String())) + return hex.EncodeToString(sum[:]) +} + +func paymentNonce() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// ==================== 订单业务 ==================== + +// CreateMemberOrder 下单:生成业务订单号 → 虎皮棋下单 → 返回支付 URL +func (s *paymentOrderService) CreateMemberOrder(ctx context.Context, userId, planId int64) (*entity.PaymentOrder, string, error) { + plan, err := dao.MemberPlan.GetOne(ctx, planId) + if err != nil { + return nil, "", err + } + if plan == nil { + return nil, "", errors.New("套餐不存在") + } + order := &entity.PaymentOrder{ + OrderNo: fmt.Sprintf("M%d%d", time.Now().UnixNano()/1e6, userId%1000), + UserId: userId, + PlanId: planId, + AmountFen: plan.PriceFen, + Channel: "alipay", + Status: consts.PayStatusPending, + } + if _, err := dao.PaymentOrder.Insert(ctx, order); err != nil { + return nil, "", err + } + payURL, err := s.CreateOrder(ctx, order.OrderNo, plan.PriceFen) + if err != nil { + return nil, "", err + } + return order, payURL, nil +} + +func (s *paymentOrderService) OrderStatus(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) { + return dao.PaymentOrder.GetByOrderNo(ctx, orderNo) +} + +// HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,订单标记与会员开通同一事务,避免"扣款成功会员未开通" +func (s *paymentOrderService) HandlePaidNotify(ctx context.Context, orderNo, tradeNo, notifyRaw string) (string, error) { + var ( + result string + userId int64 + expireAt string + ) + err := g.DB(consts.DBGroupPay).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + order, err := dao.PaymentOrder.GetByOrderNoTx(ctx, tx, orderNo) + if err != nil { + return err + } + if order == nil { + result = "no_order" + return nil + } + ok, err := dao.PaymentOrder.MarkPaidTx(ctx, tx, orderNo, tradeNo, notifyRaw) + if err != nil { + return err + } + if !ok { + result = "duplicate" // 已是 paid 或已关闭 + return nil + } + days := 30 + if plan, err := dao.MemberPlan.GetOneTx(ctx, tx, order.PlanId); err != nil { + return err + } else if plan != nil { + days = plan.DurationDays + } + um, err := dao.UserMember.GetByUserTx(ctx, tx, order.UserId) + if err != nil { + return err + } + var oldExpire *gtime.Time + if um != nil { + oldExpire = um.ExpireAt + } + expireAt = NextExpire(oldExpire, days) + if err := dao.UserMember.UpsertTx(ctx, tx, order.UserId, order.PlanId, expireAt, consts.MemberSourceVipPay); err != nil { + return err + } + userId = order.UserId + result = "ok" + return nil + }) + if err != nil { + return "no_order", err + } + if result == "ok" { + g.Log().Infof(ctx, "会员开通成功 user=%d order=%s expire=%s", userId, orderNo, expireAt) + } + return result, nil +} + +// NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算 +func NextExpire(old *gtime.Time, days int) string { + base := time.Now() + if old != nil && old.Time.After(base) { + base = old.Time + } + return base.Add(time.Duration(days) * 24 * time.Hour).Format("2006-01-02 15:04:05") +} diff --git a/server/styleagent/service/payment_order_test.go b/server/styleagent/service/payment_order_test.go new file mode 100644 index 0000000..db28f94 --- /dev/null +++ b/server/styleagent/service/payment_order_test.go @@ -0,0 +1,56 @@ +package service + +import "testing" + +func TestPaymentSignDeterministic(t *testing.T) { + params := map[string]string{ + "appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90", + } + s1 := paymentSign(params, "secret123") + s2 := paymentSign(params, "secret123") + if s1 != s2 { + t.Fatalf("相同参数签名应一致: %s != %s", s1, s2) + } + if s1 == "" { + t.Fatal("签名不应为空") + } +} + +func TestPaymentSignChangesWithSecret(t *testing.T) { + params := map[string]string{"appid": "1000", "trade_order_id": "ORDER001"} + if paymentSign(params, "a") == paymentSign(params, "b") { + t.Fatal("不同 secret 签名应不同") + } +} + +func TestPaymentVerifyNotify(t *testing.T) { + params := map[string]string{ + "appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90", + "status": "OD", "hash": "", + } + hash := paymentSign(params, "secret123") + if !PaymentOrderService.VerifyNotify(params, hash, "secret123") { + t.Fatal("正确签名应通过验签") + } + params["total_fee"] = "0.01" + if PaymentOrderService.VerifyNotify(params, hash, "secret123") { + t.Fatal("篡改参数后应验签失败") + } + if PaymentOrderService.VerifyNotify(params, hash, "wrong-secret") { + t.Fatal("错误 secret 应验签失败") + } +} + +func TestPaymentConfigDisabledWhenEmpty(t *testing.T) { + cfg := PaymentOrderService.getPaymentConfig(t.Context()) + if cfg.Enabled { + t.Fatal("默认配置(key 为空)应 disabled") + } +} + +func TestNextExpireFormat(t *testing.T) { + exp := NextExpire(nil, 30) + if len(exp) != 19 { + t.Fatalf("过期时间应 YYYY-MM-DD HH:MM:SS 格式: %q", exp) + } +} diff --git a/server/styleagent/service/plan_effect_image_service.go b/server/styleagent/service/plan_effect_image_service.go new file mode 100644 index 0000000..53b8789 --- /dev/null +++ b/server/styleagent/service/plan_effect_image_service.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "strings" + + "slogan-agent/styleagent/agent" + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type effectImageService struct{} + +var EffectImageService = new(effectImageService) + +var effectAngles = []string{"正面", "侧面", "背面"} + +// GenerateForPlan 选定主方案后异步生成 3 视角效果图 +func (s *effectImageService) GenerateForPlan(ctx context.Context, planId, userId int64) { + go s.run(ctx, planId, userId) +} + +func (s *effectImageService) run(ctx context.Context, planId, userId int64) { + plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) + if err != nil || plan == nil { + g.Log().Errorf(ctx, "效果图任务: 方案不存在 planId=%d", planId) + return + } + // 每日限额:VIP 不限;普通用户 = 基础额度 + 广告激励额外次数 + if !dao.UserMember.IsVip(ctx, userId) { + limit := ScoringRuleService.EffectLimit(ctx) + if limit > 0 { + used, err := dao.PlanEffectImage.CountByUserToday(ctx, userId) + if err != nil { + g.Log().Warningf(ctx, "统计当日效果图数量失败(本次放行): %v", err) + used = 0 + } + extra, err := dao.AdRewardLog.CountTodayByType(ctx, userId, consts.AdTypeEffectExtra) + if err != nil { + g.Log().Warningf(ctx, "统计广告奖励次数失败(本次放行): %v", err) + extra = 0 + } + if used >= limit+extra { + g.Log().Warningf(ctx, "效果图任务: 用户 %d 当日次数已用尽(%d/%d)", userId, used, limit+extra) + return + } + } + } + + if err := dao.OutfitGenTask.UpdateStatus(ctx, plan.TaskId, consts.TaskStatusRendering, ""); err != nil { + g.Log().Warningf(ctx, "更新任务 %d 为渲染中失败: %v", plan.TaskId, err) + } + defer func() { + if err := dao.OutfitGenTask.UpdateStatus(ctx, plan.TaskId, consts.TaskStatusDone, ""); err != nil { + g.Log().Warningf(ctx, "更新任务 %d 为完成失败: %v", plan.TaskId, err) + } + }() + + items, err := dao.PlanOutfitItem.ListByPlan(ctx, planId) + if err != nil { + g.Log().Warningf(ctx, "读取方案单品失败(效果图描述将缺单品): %v", err) + } + planDesc := planTitleDesc(plan.Title, items) + + // 用户全身正面照作 base image + baseImageURL := "" + if photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0); err == nil { + for _, p := range photos { + if p.Type == consts.PhotoTypeFullFront { + baseImageURL = p.Url + break + } + } + } + + client, err := agent.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "wanx").String()) + if err != nil { + g.Log().Warningf(ctx, "效果图生成不可用: %v", err) + return + } + // 缓存命中记录批量落库(1 条 multi-row SQL),未命中逐个插入并生成 + var cached []*entity.PlanEffectImage + var pending []*entity.PlanEffectImage + for _, angle := range effectAngles { + if url, ok := agent.CacheGet(effectCacheKey(plan, angle)); ok { + cached = append(cached, &entity.PlanEffectImage{ + PlanId: planId, Angle: angle, Url: url, Status: consts.EffectStatusDone, + PromptSnapshot: planDesc, + }) + } else { + pending = append(pending, &entity.PlanEffectImage{ + PlanId: planId, Angle: angle, Status: consts.EffectStatusRendering, + PromptSnapshot: planDesc, + }) + } + } + if err := dao.PlanEffectImage.InsertBatch(ctx, cached); err != nil { + g.Log().Warningf(ctx, "效果图批量落库失败: %v", err) + } + for i, rec := range pending { + recId, err := dao.PlanEffectImage.Insert(ctx, rec) + if err != nil { + continue + } + url, err := client.Generate(ctx, &agent.GenerateReq{ + BaseImageURL: baseImageURL, Prompt: planDesc, Angle: rec.Angle, Seed: plan.Id*100 + int64(i), + }) + if err != nil { + g.Log().Warningf(ctx, "效果图生成失败 plan=%d angle=%s: %v", planId, rec.Angle, err) + if updErr := dao.PlanEffectImage.UpdateStatus(ctx, recId, consts.EffectStatusFailed, ""); updErr != nil { + g.Log().Warningf(ctx, "标记效果图失败状态失败: %v", updErr) + } + continue + } + agent.CacheSet(effectCacheKey(plan, rec.Angle), url) + if updErr := dao.PlanEffectImage.UpdateStatus(ctx, recId, consts.EffectStatusDone, url); updErr != nil { + g.Log().Warningf(ctx, "回写效果图完成状态失败: %v", updErr) + } + } + g.Log().Infof(ctx, "方案 %d 效果图生成完成", planId) +} + +func planTitleDesc(title string, items []*entity.PlanOutfitItem) string { + var sb strings.Builder + sb.WriteString("方案:") + sb.WriteString(title) + sb.WriteString(";") + for _, it := range items { + sb.WriteString(it.Slot) + sb.WriteString(":") + sb.WriteString(it.Name) + sb.WriteString(";") + } + return strings.TrimSuffix(sb.String(), ";") +} + +func effectCacheKey(plan *entity.OutfitPlan, angle string) string { + sum := md5.Sum([]byte(fmt.Sprintf("%d:%s:%s", plan.Id, plan.Title, angle))) + return "plan:" + hex.EncodeToString(sum[:]) +} diff --git a/server/styleagent/service/plan_outfit_item_service.go b/server/styleagent/service/plan_outfit_item_service.go new file mode 100644 index 0000000..254352a --- /dev/null +++ b/server/styleagent/service/plan_outfit_item_service.go @@ -0,0 +1,16 @@ +package service + +import ( + "context" + + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +type planOutfitItemService struct{} + +var PlanOutfitItemService = new(planOutfitItemService) + +func (s *planOutfitItemService) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanOutfitItem, error) { + return dao.PlanOutfitItem.ListByPlan(ctx, planId) +} diff --git a/server/styleagent/service/plan_review_service.go b/server/styleagent/service/plan_review_service.go new file mode 100644 index 0000000..b5c9fab --- /dev/null +++ b/server/styleagent/service/plan_review_service.go @@ -0,0 +1,22 @@ +package service + +import ( + "context" + "errors" + + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +type planReviewService struct{} + +var PlanReviewService = new(planReviewService) + +func (s *planReviewService) Review(ctx context.Context, userId, planId int64, action, note string) error { + plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) + if err != nil || plan == nil { + return errors.New("方案不存在") + } + _, err = dao.PlanReview.Insert(ctx, &entity.PlanReview{PlanId: planId, UserId: userId, Action: action, Note: note}) + return err +} diff --git a/server/styleagent/service/scene_category_map_service.go b/server/styleagent/service/scene_category_map_service.go new file mode 100644 index 0000000..8eb5cd6 --- /dev/null +++ b/server/styleagent/service/scene_category_map_service.go @@ -0,0 +1,119 @@ +package service + +import ( + "context" + "errors" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +// sceneCategoryMapService 方案驱动推荐(零新增 LLM:场景映射表 + 联盟选品/搜索) +type sceneCategoryMapService struct { + productSvc *cpsProductService // 测试注入;nil 时用全局 CpsProductService +} + +var SceneCategoryMapService = new(sceneCategoryMapService) + +// Recommend 按场景给方案推荐联盟商品;查不到返回空列表(客户端隐藏入口) +func (s *sceneCategoryMapService) Recommend(ctx context.Context, plan *entity.OutfitPlan, scene string) ([]*entity.CpsProduct, error) { + svc := s.productSvc + if svc == nil { + svc = CpsProductService + } + switch scene { + case consts.CpsSceneHaircut: + return s.recommendHaircut(ctx, svc, plan) + case consts.CpsSceneItemBuy: + return s.recommendItemBuy(ctx, svc, plan) + case consts.CpsSceneItemUpgrade: + return s.recommendItemUpgrade(ctx, svc, plan) + case consts.CpsSceneOccasion: + return s.recommendOccasion(ctx, svc, plan) + } + return nil, nil +} + +// recommendHaircut 发型 → 映射表丽人(美团到店),城市过滤 +func (s *sceneCategoryMapService) recommendHaircut(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) { + if plan.HairstyleId <= 0 { + return nil, nil + } + return s.byScene(ctx, svc, consts.CpsSceneHaircut, "", plan.Location) +} + +// recommendOccasion 场合 → 映射表(通勤/约会/旅行/运动),城市过滤 +func (s *sceneCategoryMapService) recommendOccasion(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) { + return s.byScene(ctx, svc, consts.CpsSceneOccasion, plan.Occasion, plan.Location) +} + +// byScene 场景映射表(priority 最高者)→ 分页商品 +func (s *sceneCategoryMapService) byScene(ctx context.Context, svc *cpsProductService, sceneType, occasion, city string) ([]*entity.CpsProduct, error) { + maps, err := dao.SceneCategoryMap.QueryByScene(ctx, sceneType, occasion) + if err != nil { + return nil, err + } + if len(maps) == 0 { + return nil, nil + } + list, _, err := svc.ListByCategory(ctx, maps[0].Source, maps[0].CategoryCode, city, 1, 10) + return list, err +} + +// recommendItemBuy 买同款:推荐条目名作关键词 → 电商搜索 +func (s *sceneCategoryMapService) recommendItemBuy(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) { + items, err := dao.PlanOutfitItem.ListByPlan(ctx, plan.Id) + if err != nil { + return nil, err + } + keyword := "" + for _, it := range items { + if it.Source == consts.PlanSourceRecommend && it.Name != "" { + keyword = it.Name + break + } + } + if keyword == "" { + return nil, nil + } + return s.search(ctx, svc, keyword, "") +} + +// recommendItemUpgrade 到店试穿:首条条目名 → 服装类目(美团) +func (s *sceneCategoryMapService) recommendItemUpgrade(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) { + items, err := dao.PlanOutfitItem.ListByPlan(ctx, plan.Id) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, nil + } + return s.search(ctx, svc, items[0].Name, "clothing") +} + +// WardrobeUpgrade 衣橱升级款:物品品类作关键词 → 服装类目搜索 +func (s *sceneCategoryMapService) WardrobeUpgrade(ctx context.Context, userId, itemId int64) ([]*entity.CpsProduct, error) { + item, err := dao.WardrobeItem.GetOne(ctx, itemId, userId) + if err != nil || item == nil { + return nil, errors.New("衣橱物品不存在") + } + svc := s.productSvc + if svc == nil { + svc = CpsProductService + } + return s.search(ctx, svc, item.Category, "clothing") +} + +// search 联盟实时搜索,失败降级为空列表而非错误 +func (s *sceneCategoryMapService) search(ctx context.Context, svc *cpsProductService, keyword, catCode string) ([]*entity.CpsProduct, error) { + raw, err := svc.Search(ctx, keyword, catCode, 1) + if err != nil { + return nil, nil + } + out := make([]*entity.CpsProduct, 0, len(raw)) + for i := range raw { + out = append(out, toCpsProductEntity(&raw[i])) + } + return out, nil +} diff --git a/server/styleagent/service/scene_category_map_service_test.go b/server/styleagent/service/scene_category_map_service_test.go new file mode 100644 index 0000000..ec6a985 --- /dev/null +++ b/server/styleagent/service/scene_category_map_service_test.go @@ -0,0 +1,139 @@ +package service + +import ( + "context" + "testing" + "time" + + "slogan-agent/styleagent/agent" + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" +) + +func newTestSceneSvc(providers []agent.Provider) *sceneCategoryMapService { + return &sceneCategoryMapService{productSvc: &cpsProductService{ + providers: providers, + getLinkCache: agent.NewTTLCache(24 * time.Hour), + }} +} + +func upsertTestProduct(t *testing.T, source, outerId, catCode, city, name string, priceFen int64) { + t.Helper() + err := dao.CpsProduct.Upsert(context.Background(), &entity.CpsProduct{ + Source: source, OuterId: outerId, CategoryCode: catCode, + Name: name, PriceFen: priceFen, City: city, + }) + if err != nil { + t.Fatalf("插入测试商品失败: %v", err) + } +} + +func TestRecommendHaircut(t *testing.T) { + upsertTestProduct(t, consts.CpsSourceMeituanOta, "mt-beauty-1", "beauty", "北京", "明星剪发", 8800) + svc := newTestSceneSvc(nil) + list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{HairstyleId: 5, Location: "北京"}, consts.CpsSceneHaircut) + if err != nil { + t.Fatalf("发型场景推荐失败: %v", err) + } + if len(list) != 1 || list[0].Name != "明星剪发" { + t.Fatalf("发型场景应命中丽人商品,实际 %d 条", len(list)) + } +} + +func TestRecommendHaircutNoHairstyle(t *testing.T) { + svc := newTestSceneSvc(nil) + list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{HairstyleId: 0}, consts.CpsSceneHaircut) + if err != nil { + t.Fatalf("无发型时应返回空而非错误: %v", err) + } + if len(list) != 0 { + t.Fatalf("无发型应返回空列表: %d", len(list)) + } +} + +func TestRecommendItemBuy(t *testing.T) { + ctx := context.Background() + planId := insertTestPlan(ctx, t) + _, err := dao.PlanOutfitItem.Insert(ctx, &entity.PlanOutfitItem{ + PlanId: planId, Slot: consts.SlotTop, Source: consts.PlanSourceRecommend, Name: "黑色西装外套", + }) + if err != nil { + t.Fatalf("插入方案条目失败: %v", err) + } + p := &stubCpsProvider{enabled: true, search: []agent.CpsProduct{ + {Source: consts.CpsSourceJdEcom, OuterId: "jd1", Name: "黑色西装外套 男", PriceFen: 19900}, + {Source: consts.CpsSourceJdEcom, OuterId: "jd2", Name: "黑色西装裤 男", PriceFen: 9900}, + }} + svc := newTestSceneSvc([]agent.Provider{p}) + list, err := svc.Recommend(ctx, &entity.OutfitPlan{Id: planId}, consts.CpsSceneItemBuy) + if err != nil { + t.Fatalf("买同款推荐失败: %v", err) + } + if len(list) != 2 { + t.Fatalf("买同款应返回 2 条,实际 %d", len(list)) + } + if p.lastKw != "黑色西装外套" { + t.Fatalf("买同款关键词应为推荐条目名,实际 %q", p.lastKw) + } +} + +func TestRecommendItemUpgrade(t *testing.T) { + ctx := context.Background() + planId := insertTestPlan(ctx, t) + _, err := dao.PlanOutfitItem.Insert(ctx, &entity.PlanOutfitItem{ + PlanId: planId, Slot: consts.SlotTop, Source: consts.PlanSourceWardrobe, Name: "旧卫衣", + }) + if err != nil { + t.Fatalf("插入方案条目失败: %v", err) + } + p := &stubCpsProvider{enabled: true, search: []agent.CpsProduct{ + {Source: consts.CpsSourceMeituanOta, OuterId: "mt-up", Name: "西装定制店", PriceFen: 29900}, + }} + svc := newTestSceneSvc([]agent.Provider{p}) + list, err := svc.Recommend(ctx, &entity.OutfitPlan{Id: planId}, consts.CpsSceneItemUpgrade) + if err != nil { + t.Fatalf("到店试穿推荐失败: %v", err) + } + if len(list) != 1 { + t.Fatalf("到店试穿应返回 1 条,实际 %d", len(list)) + } + if p.lastCat != "clothing" { + t.Fatalf("到店试穿应带服装类目,实际 %q", p.lastCat) + } +} + +func TestRecommendOccasion(t *testing.T) { + upsertTestProduct(t, consts.CpsSourceMeituanOta, "mt-food-1", "food", "上海", "烛光晚餐双人套餐", 68800) + svc := newTestSceneSvc(nil) + list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{Occasion: "约会", Location: "上海"}, consts.CpsSceneOccasion) + if err != nil { + t.Fatalf("场合推荐失败: %v", err) + } + if len(list) != 1 || list[0].Name != "烛光晚餐双人套餐" { + t.Fatalf("约会场合应命中餐厅商品,实际 %d 条", len(list)) + } +} + +func TestRecommendEmptyDegrade(t *testing.T) { + svc := newTestSceneSvc(nil) + list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{Occasion: "旅行", Location: "北京"}, consts.CpsSceneOccasion) + if err != nil { + t.Fatalf("无商品时应返回空而非错误: %v", err) + } + if len(list) != 0 { + t.Fatalf("无商品应返回空列表: %d", len(list)) + } +} + +func insertTestPlan(ctx context.Context, t *testing.T) int64 { + t.Helper() + id, err := dao.OutfitPlan.Insert(ctx, &entity.OutfitPlan{ + TaskId: 999001, UserId: 999001, DateRange: "2026-08-01 ~ 2026-08-07", + Location: "北京", Title: "测试方案", Source: consts.PlanSourceWardrobe, + }) + if err != nil { + t.Fatalf("插入测试方案失败: %v", err) + } + return id +} diff --git a/server/styleagent/service/scoring_rule_service.go b/server/styleagent/service/scoring_rule_service.go new file mode 100644 index 0000000..040ffa4 --- /dev/null +++ b/server/styleagent/service/scoring_rule_service.go @@ -0,0 +1,53 @@ +package service + +import ( + "context" + "encoding/json" + + "slogan-agent/styleagent/consts" + "slogan-agent/styleagent/dao" +) + +type scoringRuleService struct{} + +var ScoringRuleService = new(scoringRuleService) + +// Threshold 方案及格分(scoring_rule.dimension=threshold) +func (s *scoringRuleService) Threshold(ctx context.Context) int { + threshold := consts.DefaultScoreThreshold + rules, err := dao.ScoringRule.ListEnabled(ctx) + if err != nil { + return threshold + } + for _, r := range rules { + if r.Dimension == "threshold" { + var v struct { + Pass int `json:"pass"` + } + if json.Unmarshal([]byte(r.RulesJson), &v) == nil && v.Pass > 0 { + return v.Pass + } + } + } + return threshold +} + +// EffectLimit 每日效果图基础额度(scoring_rule.dimension=effect_limit) +func (s *scoringRuleService) EffectLimit(ctx context.Context) int { + limit := consts.DefaultDailyEffectLimit + rules, err := dao.ScoringRule.ListEnabled(ctx) + if err != nil { + return limit + } + for _, r := range rules { + if r.Dimension == "effect_limit" { + var v struct { + Daily int `json:"daily"` + } + if json.Unmarshal([]byte(r.RulesJson), &v) == nil && v.Daily > 0 { + return v.Daily + } + } + } + return limit +} diff --git a/server/styleagent/service/user_member_service.go b/server/styleagent/service/user_member_service.go new file mode 100644 index 0000000..ea3e0ac --- /dev/null +++ b/server/styleagent/service/user_member_service.go @@ -0,0 +1,28 @@ +package service + +import ( + "context" + + "slogan-agent/styleagent/dao" + + "github.com/gogf/gf/v2/os/gtime" +) + +type userMemberService struct{} + +var UserMemberService = new(userMemberService) + +// IsVip 用户是否有效会员(用于效果图限额等权益判定) +func (s *userMemberService) IsVip(ctx context.Context, userId int64) bool { + return dao.UserMember.IsVip(ctx, userId) +} + +// Upsert 开通/续期会员 +func (s *userMemberService) Upsert(ctx context.Context, userId, planId int64, expireAt, source string) error { + return dao.UserMember.Upsert(ctx, userId, planId, expireAt, source) +} + +// NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算 +func (s *userMemberService) NextExpire(old *gtime.Time, days int) string { + return NextExpire(old, days) +} diff --git a/server/styleagent/service/user_photo_service.go b/server/styleagent/service/user_photo_service.go new file mode 100644 index 0000000..5a17f8f --- /dev/null +++ b/server/styleagent/service/user_photo_service.go @@ -0,0 +1,45 @@ +package service + +import ( + "context" + "errors" + "fmt" + + commonHttp "slogan-agent/common" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/net/ghttp" +) + +type userPhotoService struct{} + +var UserPhotoService = new(userPhotoService) + +func (s *userPhotoService) Upload(ctx context.Context, userId int64, photoType int, file *ghttp.UploadFile) (int64, error) { + url, err := commonHttp.SaveUploadedFile(file, fmt.Sprintf("user_%d/photos", userId)) + if err != nil { + return 0, err + } + return dao.UserPhoto.Insert(ctx, &entity.UserPhoto{ + UserId: userId, + Type: photoType, + Url: url, + Status: 1, + }) +} + +func (s *userPhotoService) List(ctx context.Context, userId int64, photoType int) ([]*entity.UserPhoto, error) { + return dao.UserPhoto.ListByUser(ctx, userId, photoType) +} + +func (s *userPhotoService) Delete(ctx context.Context, userId, id int64) error { + p, err := dao.UserPhoto.GetOne(ctx, id, userId) + if err != nil || p == nil { + return errors.New("照片不存在") + } + if err := commonHttp.RemoveWorkspaceFile(p.Url); err != nil { + return err + } + return dao.UserPhoto.Delete(ctx, id) +} diff --git a/server/styleagent/service/user_service.go b/server/styleagent/service/user_service.go new file mode 100644 index 0000000..428546f --- /dev/null +++ b/server/styleagent/service/user_service.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + "errors" + "time" + + "slogan-agent/common" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type userService struct{} + +var UserService = new(userService) + +func (s *userService) Register(ctx context.Context, account, password, name string) (int64, error) { + if account == "" || password == "" { + return 0, errors.New("账号和密码不能为空") + } + existing, err := dao.User.GetByAccount(ctx, account) + if err != nil { + return 0, err + } + if existing != nil { + return 0, errors.New("账号已存在") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return 0, err + } + if name == "" { + name = account + } + return dao.User.Insert(ctx, &entity.User{ + Role: "user", + Username: account, + Password: string(hash), + Name: name, + }) +} + +func (s *userService) Login(ctx context.Context, account, password string) (*entity.User, string, error) { + if account == "" { + return nil, "", errors.New("请输入账号") + } + user, err := dao.User.GetByAccount(ctx, account) + if err != nil { + return nil, "", err + } + if user == nil { + return nil, "", errors.New("账号不存在") + } + if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { + return nil, "", errors.New("密码错误") + } + now := time.Now() + claims := common.JwtClaims{ + UserId: user.Id, + Role: user.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(common.GetJwtSecret())) + if err != nil { + return nil, "", err + } + return user, token, nil +} + +func (s *userService) ChangePassword(ctx context.Context, userId int64, oldPwd, newPwd string) error { + user, err := dao.User.GetOne(ctx, userId) + if err != nil { + return err + } + if user == nil { + return errors.New("用户不存在") + } + if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPwd)) != nil { + return errors.New("原密码错误") + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost) + if err != nil { + return err + } + return dao.User.UpdateFields(ctx, userId, map[string]any{"password": string(hash)}) +} diff --git a/server/styleagent/service/wardrobe_item_service.go b/server/styleagent/service/wardrobe_item_service.go new file mode 100644 index 0000000..a8ffa43 --- /dev/null +++ b/server/styleagent/service/wardrobe_item_service.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "errors" + "fmt" + + commonHttp "slogan-agent/common" + "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/entity" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +type wardrobeService struct{} + +var WardrobeService = new(wardrobeService) + +func (s *wardrobeService) Upload(ctx context.Context, userId int64, req entity.WardrobeItem, file *ghttp.UploadFile) (int64, error) { + url, err := commonHttp.SaveUploadedFile(file, fmt.Sprintf("user_%d/wardrobe", userId)) + if err != nil { + return 0, err + } + req.UserId = userId + req.PhotoUrl = url + req.Status = 1 + if req.Season == "" { + req.Season = "四季" + } + return dao.WardrobeItem.Insert(ctx, &req) +} + +func (s *wardrobeService) List(ctx context.Context, userId int64, category string) ([]*entity.WardrobeItem, error) { + return dao.WardrobeItem.ListByUser(ctx, userId, category) +} + +func (s *wardrobeService) Update(ctx context.Context, userId, id int64, data g.Map) error { + item, err := dao.WardrobeItem.GetOne(ctx, id, userId) + if err != nil || item == nil { + return errors.New("服装不存在") + } + return dao.WardrobeItem.Update(ctx, id, data) +} + +func (s *wardrobeService) Delete(ctx context.Context, userId, id int64) error { + item, err := dao.WardrobeItem.GetOne(ctx, id, userId) + if err != nil || item == nil { + return errors.New("服装不存在") + } + if err := commonHttp.RemoveWorkspaceFile(item.PhotoUrl); err != nil { + return err + } + return dao.WardrobeItem.Delete(ctx, id) +}