Add 'server/' from commit 'e64421295fff83acbb6d6ab3d3b27f3ef8368f00'
git-subtree-dir: server git-subtree-mainline:c4e617ada7git-subtree-split:e64421295f
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
# 数据库与运行时产物
|
||||
slogan.db
|
||||
*.db
|
||||
data/
|
||||
slogan-agent
|
||||
workspace/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
|
||||
# 渲染器依赖
|
||||
scripts/avatar-render/node_modules/
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
slogan-agent:
|
||||
build: .
|
||||
container_name: slogan-agent
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3007:3007"
|
||||
volumes:
|
||||
# SQLite 数据库(config.yml 已指向 data/ 子目录,容器内 /app/data 与宿主机 ./data 互通)
|
||||
- ./data:/app/data
|
||||
# 生成的图片 / GLB 等运行时产物
|
||||
- ./workspace:/app/workspace
|
||||
# 容器内运行前需在 config.yml 调整:
|
||||
# render.node_bin → "/usr/bin/node"(镜像内置 alpine node,非 macOS nvm 路径)
|
||||
# payment.notify_url → 公网可达地址(支付回调容器内 localhost 不可达)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,933 @@
|
||||
# slogan-agent MVP 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 实现 slogan-agent 服务端 MVP:登录 → 照片/衣橱/身形管理 → 化身构建(v1 模板匹配)→ 穿搭生成(规则评分 + Agent 规划 + 兜底)→ 效果图按需生成,全链路可运行。
|
||||
|
||||
**Architecture:** Go 单体 + GoFrame v2 + SQLite,严格遵循 video-factory 分层规范(controller → service → dao),包级单例,RouteRegister 反射注册路由,JWT 鉴权,OpenAI 兼容 LLM。规则评分零 LLM 成本,效果图按需生成 + 缓存。
|
||||
|
||||
**Tech Stack:** Go 1.22+ / GoFrame v2.10 / SQLite / JWT / bcrypt / OpenAI 兼容 API / 和风天气 API
|
||||
|
||||
**参考代码(必须阅读后复制模式):**
|
||||
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/`(http.go / auth.go / base_dao.go / cache.go / auth_middleware.go / util.go)
|
||||
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/agent/chat_model.go`(直接复用整个文件,改包名)
|
||||
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/`(controller/service/dao/model 全部模式)
|
||||
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/main.go`(入口模式)
|
||||
|
||||
**数据库:** `slogan.db`(config.yml 配置),表名前缀 `slogan_`。所有 init() 自动建表 + ALTER 迁移兼容。
|
||||
|
||||
**模块路径:** `slogan-agent`(go.mod module name),业务包 `styleagent`。
|
||||
|
||||
---
|
||||
|
||||
## 数据库表总览(Task 2-3 建全)
|
||||
|
||||
| 表 | 关键字段 |
|
||||
|----|---------|
|
||||
| `slogan_user` | id, role(default 'user'), username, phone, password, name, created_at, updated_at |
|
||||
| `slogan_user_photo` | id, user_id, type(1大头照 2全身正面 3全身侧面 4全身背面), url, status, created_at |
|
||||
| `slogan_wardrobe_item` | id, user_id, photo_url, category(上衣/下装/鞋/配饰), season(春/夏/秋/冬/四季), style_tags, color_info, status, created_at |
|
||||
| `slogan_body_measurement` | id, user_id, height, weight, skin_tone(1-5), fit_params(JSON), updated_at |
|
||||
| `slogan_avatar_model` | id, user_id, face_template_id, body_template_id, skin_tone_index, face_texture_url, glb_url, build_status(pending/processing/done/failed), error, params_snapshot(JSON), created_at |
|
||||
| `slogan_hairstyle_asset` | id, name, style_tag, glb_url, thumb_url, applicable_face, sort |
|
||||
| `slogan_outfit_generation_task` | id, user_id, start_date, end_date, location, weather_snapshot(JSON), status(pending/planning/scoring/rendering/done/failed), error, model_name, created_at |
|
||||
| `slogan_outfit_plan` | id, task_id, user_id, date_range, location, source(wardrobe/recommend), score, main_flag(0/1), hairstyle_id, hair_color, weather_ref(JSON), created_at |
|
||||
| `slogan_plan_outfit_item` | id, plan_id, slot(发型/上衣/下装/鞋/配饰), source, wardrobe_item_id, product_name, name, desc |
|
||||
| `slogan_plan_effect_image` | id, plan_id, angle(正面/侧面/背面), url, status, prompt_snapshot, created_at |
|
||||
| `slogan_plan_review` | id, plan_id, user_id, action(fav/unfav), note, created_at |
|
||||
| `slogan_scoring_rule` | id, dimension, rule_type, rules_json, enabled, version |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 项目骨架(go.mod / config / main.go / common 复制)
|
||||
|
||||
**Files:**
|
||||
- Create: `go.mod`
|
||||
- Create: `config.yml`
|
||||
- Create: `main.go`
|
||||
- Copy: `common/http.go`, `common/auth.go`, `common/base_dao.go`, `common/cache.go`, `common/util.go`, `common/auth_middleware.go`(从 video-factory 复制,改 package 注释即可,无需改逻辑)
|
||||
|
||||
- [ ] **Step 1: 创建 go.mod**
|
||||
|
||||
```bash
|
||||
cd slogan-agent
|
||||
go mod init slogan-agent
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 config.yml**
|
||||
|
||||
```yaml
|
||||
database:
|
||||
default:
|
||||
name: slogan.db
|
||||
type: sqlite
|
||||
debug: false
|
||||
cache:
|
||||
ttl: 60
|
||||
server:
|
||||
address: :3007
|
||||
name: slogan
|
||||
workerId: 1
|
||||
clientMaxBodySize: 209715200
|
||||
requestTimeout: 3000
|
||||
chat:
|
||||
timeout: 300
|
||||
max_retries: 3
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 复制 common 包**
|
||||
|
||||
```bash
|
||||
cp /Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/{http.go,auth.go,base_dao.go,cache.go,util.go,auth_middleware.go} common/
|
||||
```
|
||||
|
||||
注意:auth_middleware.go 和 cache.go / util.go 需检查是否有对 video-factory 特定包的 import,如有则调整。auth.go 中 jwtSecret 改为 slogan 自己的密钥。
|
||||
|
||||
- [ ] **Step 4: 创建 main.go**(模式同 video-factory main.go,路由表注册 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
|
||||
@@ -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 同模式)
|
||||
@@ -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
|
||||
@@ -0,0 +1,104 @@
|
||||
# slogan-agent 服务端
|
||||
|
||||
人形象设计应用(slogan)的服务端。用户上传个人照片与服装照片,指定日期地点后由大模型生成穿搭方案(含发型),支持 3D 化身与效果图查看。
|
||||
|
||||
技术栈:Go 1.22+ / GoFrame v2 / SQLite / JWT / OpenAI 兼容大模型 / 和风天气 + 高德地理编码。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
go build -o slogan-agent .
|
||||
./slogan-agent
|
||||
```
|
||||
|
||||
服务默认监听 `:3007`,首次启动自动建库建表(`slogan.db`)。
|
||||
|
||||
### 必要配置(config.yml)
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `llm.base_url / api_key / model_name` | 大模型(OpenAI 兼容,如通义/DeepSeek/Kimi),未配置时生成任务失败并返回明确错误 |
|
||||
| `weather.qweather_key` | 和风天气 v7 Key(免费版即可),用于 7 天预报 |
|
||||
| `geo.amap_key` | 高德地理编码 Key,地点 → adcode |
|
||||
| `imagegen.supplier` | 效果图供应商:`mock`(占位图,开发用)或 `wanx`(通义万相,需配 `wanx_api_key`) |
|
||||
|
||||
未配置天气/LLM Key 时接口返回明确错误提示,服务本身可正常启动。
|
||||
|
||||
## 接口总览
|
||||
|
||||
统一响应格式:`{"code":0,"message":"OK","data":...}`;`code != 0` 为业务错误。除公开接口外需 `Authorization: Bearer <token>`(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。
|
||||
@@ -0,0 +1,47 @@
|
||||
module slogan-agent
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
|
||||
github.com/gogf/gf/v2 v2.10.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
golang.org/x/crypto v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.0.9 // indirect
|
||||
github.com/olekukonko/tablewriter v1.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
+104
@@ -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=
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
commonHttp "slogan-agent/common"
|
||||
|
||||
"slogan-agent/styleagent/controller"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// ==================== API 路由(RouteRegister 反射注册,kebab-case 前缀) ====================
|
||||
commonHttp.RouteRegister([]interface{}{
|
||||
controller.User,
|
||||
controller.UserPhoto,
|
||||
controller.Wardrobe,
|
||||
controller.BodyMeasurement,
|
||||
controller.Avatar,
|
||||
controller.Hairstyle,
|
||||
controller.Outfit,
|
||||
controller.PartnerStore,
|
||||
controller.Member,
|
||||
controller.Ad,
|
||||
controller.Cps,
|
||||
})
|
||||
|
||||
// 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装)
|
||||
commonHttp.Httpserver.Group("/member/order", func(group *ghttp.RouterGroup) {
|
||||
group.POST("/notify", controller.MemberNotify)
|
||||
})
|
||||
|
||||
// ==================== Workspace 文件服务(鉴权保护) ====================
|
||||
commonHttp.Httpserver.BindHandler("/workspace/*", func(r *ghttp.Request) {
|
||||
relPath := strings.TrimPrefix(r.URL.Path, "/workspace/")
|
||||
if relPath == "" || strings.Contains(relPath, "..") {
|
||||
r.Response.WriteStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
filePath := filepath.Join("workspace", relPath)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
r.Response.WriteStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
r.Response.ServeFile(filePath)
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// 恢复未完成的生成任务(重启后标记失败,避免重复消耗 LLM 费用)
|
||||
service.OutfitService.StartWorker(ctx)
|
||||
|
||||
// CPS 联盟商品定时同步(未配置 key 时空转)
|
||||
service.CpsProductService.StartSyncLoop(ctx)
|
||||
|
||||
g.Log().Info(ctx, "slogan-agent started on :3007")
|
||||
|
||||
<-ctx.Done()
|
||||
g.Log().Info(ctx, "shutting down...")
|
||||
time.Sleep(1 * time.Second)
|
||||
g.Log().Info(ctx, "bye")
|
||||
}
|
||||
+713
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// 化身 GLB -> 36 帧旋转 PNG(绕 Y 轴 10° 步进),服务端预渲染。
|
||||
// 用法: node render.js --glb <path> --out <dir> [--frames 36] [--size 256x512]
|
||||
import { argv } from 'node:process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import createGL from 'gl';
|
||||
import { PNG } from 'pngjs';
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
|
||||
function parseArgs() {
|
||||
const a = {};
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
if (argv[i].startsWith('--')) {
|
||||
const key = argv[i].slice(2);
|
||||
const val = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : true;
|
||||
a[key] = val;
|
||||
}
|
||||
}
|
||||
if (!a.glb || !a.out) {
|
||||
console.error('用法: node render.js --glb <path> --out <dir> [--frames 36] [--size 256x512]');
|
||||
process.exit(1);
|
||||
}
|
||||
a.frames = a.frames === true ? 36 : parseInt(a.frames, 10) || 36;
|
||||
const [w, h] = (a.size === true ? '256x512' : String(a.size)).split('x').map(Number);
|
||||
a.width = w || 256;
|
||||
a.height = h || 512;
|
||||
return a;
|
||||
}
|
||||
|
||||
const args = parseArgs();
|
||||
const { width, height, frames } = args;
|
||||
|
||||
const gl = createGL(width, height, { preserveDrawingBuffer: true });
|
||||
if (!gl) {
|
||||
console.error('headless-gl 初始化失败(容器内需 mesa/libglvnd)');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const canvas = {
|
||||
width,
|
||||
height,
|
||||
style: {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
getContext: () => gl,
|
||||
};
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: false });
|
||||
renderer.setClearColor(0xffffff, 1);
|
||||
renderer.setSize(width, height, false);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 1.4);
|
||||
dirLight.position.set(3, 6, 4);
|
||||
scene.add(dirLight);
|
||||
scene.add(new THREE.DirectionalLight(0xffffff, 0.5).translateY(-4).translateX(-3));
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 100);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
|
||||
const loadGlb = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
// Buffer 需转成 ArrayBuffer 才能触发 GLB 头解析
|
||||
const bin = fs.readFileSync(args.glb);
|
||||
const ab = bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength);
|
||||
loader.parse(ab, '', (gltf) => resolve(gltf.scene), (err) => reject(err));
|
||||
});
|
||||
|
||||
loadGlb()
|
||||
.then((object) => {
|
||||
scene.add(object);
|
||||
render(object);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('GLB 解析失败:', err && err.message ? err.message : err);
|
||||
process.exit(3);
|
||||
});
|
||||
|
||||
function render(object) {
|
||||
// 包围盒 -> 相机半径与观测高度
|
||||
const box = new THREE.Box3().setFromObject(object);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const radius = Math.max(size.x, size.z) * 1.6 + 0.6;
|
||||
const lookY = center.y + size.y * 0.35;
|
||||
const cameraY = center.y + size.y * 0.35;
|
||||
|
||||
fs.mkdirSync(args.out, { recursive: true });
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
const rowSize = width * 4;
|
||||
const png = new PNG({ width, height });
|
||||
|
||||
for (let i = 0; i < frames; i++) {
|
||||
const angle = (i / frames) * Math.PI * 2;
|
||||
camera.position.set(Math.sin(angle) * radius, cameraY, Math.cos(angle) * radius);
|
||||
camera.lookAt(0, lookY, 0);
|
||||
renderer.render(scene, camera);
|
||||
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const buf = Buffer.from(pixels.buffer);
|
||||
for (let y = 0; y < height; y++) {
|
||||
buf.copy(png.data, y * rowSize, (height - 1 - y) * rowSize, (height - y) * rowSize);
|
||||
}
|
||||
const out = path.join(args.out, `frame_${String(i).padStart(3, '0')}.png`);
|
||||
fs.writeFileSync(out, PNG.sync.write(png));
|
||||
}
|
||||
|
||||
console.log(`rendered ${frames} frames -> ${args.out}`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcache"
|
||||
)
|
||||
|
||||
var modelCfgCache = gcache.New()
|
||||
|
||||
// GetModelConfig 从 config.yml 读取 LLM 配置(缓存 60s),未配置返回明确错误
|
||||
func GetModelConfig(ctx context.Context) (*ModelConfig, error) {
|
||||
cacheKey := "llm:model_config"
|
||||
v, err := modelCfgCache.Get(ctx, cacheKey)
|
||||
if err == nil && !v.IsNil() {
|
||||
if cfg, ok := v.Val().(*ModelConfig); ok {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
cfg := &ModelConfig{
|
||||
BaseURL: g.Cfg().MustGet(ctx, "llm.base_url", "").String(),
|
||||
APIKey: g.Cfg().MustGet(ctx, "llm.api_key", "").String(),
|
||||
ModelName: g.Cfg().MustGet(ctx, "llm.model_name", "").String(),
|
||||
MaxTokens: g.Cfg().MustGet(ctx, "llm.max_tokens", 4096).Int(),
|
||||
Temperature: g.Cfg().MustGet(ctx, "llm.temperature", 0.8).Float32(),
|
||||
Timeout: time.Duration(g.Cfg().MustGet(ctx, "chat.timeout", 300).Int()) * time.Second,
|
||||
MaxRetries: g.Cfg().MustGet(ctx, "chat.max_retries", 3).Int(),
|
||||
}
|
||||
if cfg.APIKey == "" || cfg.ModelName == "" || cfg.BaseURL == "" {
|
||||
return nil, fmt.Errorf("LLM 未配置:请在 config.yml 设置 llm.base_url / llm.api_key / llm.model_name")
|
||||
}
|
||||
_ = modelCfgCache.Set(ctx, cacheKey, cfg, 60*time.Second)
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TripoClient 3D 化身客户端(图像转 3D:上传图片 → 提交 multiview 任务 → 轮询 → 下载 GLB)
|
||||
type TripoClient struct {
|
||||
apiKey string
|
||||
base string
|
||||
version string
|
||||
pollInterval time.Duration
|
||||
pollTimeout time.Duration
|
||||
}
|
||||
|
||||
func NewTripoClient(ctx context.Context) *TripoClient {
|
||||
return &TripoClient{
|
||||
apiKey: g.Cfg().MustGet(ctx, "avatar.tripo_api_key", "").String(),
|
||||
base: g.Cfg().MustGet(ctx, "avatar.tripo_base", "https://api.tripo3d.ai/v2/openapi").String(),
|
||||
version: g.Cfg().MustGet(ctx, "avatar.tripo_model_version", "v2.5-20250123").String(),
|
||||
pollInterval: time.Duration(g.Cfg().MustGet(ctx, "avatar.poll_interval", 5).Int()) * time.Second,
|
||||
pollTimeout: time.Duration(g.Cfg().MustGet(ctx, "avatar.poll_timeout", 900).Int()) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled 是否已配置 API Key
|
||||
func (c *TripoClient) Enabled() bool { return c.apiKey != "" }
|
||||
|
||||
// UploadImage 上传单张图片,返回 file_token
|
||||
func (c *TripoClient) UploadImage(ctx context.Context, filePath string) (string, error) {
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开图片失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
fw, err := w.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(fw, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
w.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/upload/sts", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
|
||||
data, err := c.do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, key := range []string{"file_token", "image_token", "token"} {
|
||||
if v, ok := data[key].(string); ok && v != "" {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("Tripo 上传响应缺少 file_token: %s", mustJSONStr(data))
|
||||
}
|
||||
|
||||
// SubmitMultiview 提交多视角转 3D 任务(front 必填,left/back 可空),返回 task_id
|
||||
func (c *TripoClient) SubmitMultiview(ctx context.Context, front, left, back string) (string, error) {
|
||||
files := make([]map[string]string, 0, 3)
|
||||
for _, t := range []string{front, left, back} {
|
||||
if t != "" {
|
||||
files = append(files, map[string]string{"type": "image", "file_token": t})
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"type": "multiview_to_model",
|
||||
"model_version": c.version,
|
||||
"files": files,
|
||||
"texture": true,
|
||||
"pbr": true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/task", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
data, err := c.do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
taskID, _ := data["task_id"].(string)
|
||||
if taskID == "" {
|
||||
return "", fmt.Errorf("Tripo 提交任务响应缺少 task_id: %s", mustJSONStr(data))
|
||||
}
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// PollTask 轮询任务直到 success/failed,成功返回 GLB 下载地址
|
||||
func (c *TripoClient) PollTask(ctx context.Context, taskID string) (string, error) {
|
||||
deadline := time.Now().Add(c.pollTimeout)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
default:
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.base+"/task/"+taskID, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
data, err := c.do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
status, _ := data["status"].(string)
|
||||
if status == "" {
|
||||
return "", fmt.Errorf("Tripo 任务响应缺少 status: %s", mustJSONStr(data))
|
||||
}
|
||||
switch status {
|
||||
case "success":
|
||||
if output, ok := data["output"].(map[string]any); ok {
|
||||
if pbr, ok := output["pbr_model"].(map[string]any); ok {
|
||||
if url, ok := pbr["url"].(string); ok && url != "" {
|
||||
return url, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("Tripo 任务成功但无模型下载地址")
|
||||
case "failed", "cancelled", "expired":
|
||||
msg, _ := data["error"].(string)
|
||||
if msg == "" {
|
||||
msg = mustJSONStr(data)
|
||||
}
|
||||
return "", fmt.Errorf("Tripo 任务%s: %s", status, msg)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("Tripo 任务超时(%s)", taskID)
|
||||
}
|
||||
time.Sleep(c.pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadGlb 下载 GLB 到 destPath(下载地址约 5 分钟过期,任务成功后应立即调用)
|
||||
func (c *TripoClient) DownloadGlb(ctx context.Context, url, destPath string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载 GLB 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载 GLB 失败: http %d", resp.StatusCode)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// do 统一请求:非 2xx 或 code != 0 时返回业务错误
|
||||
func (c *TripoClient) do(req *http.Request) (map[string]any, error) {
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Tripo 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 Tripo 响应失败: %w", err)
|
||||
}
|
||||
var r struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, fmt.Errorf("Tripo 响应解析失败: %s", string(raw))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || r.Code != 0 {
|
||||
return nil, fmt.Errorf("Tripo 接口错误 code=%d msg=%s", r.Code, r.Message)
|
||||
}
|
||||
return r.Data, nil
|
||||
}
|
||||
|
||||
func mustJSONStr(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package agent
|
||||
|
||||
import "time"
|
||||
|
||||
// 效果图 URL 缓存(key: 方案内容 hash:角度,24h TTL,复用泛型 TTL 缓存)
|
||||
var effectCache = NewTTLCache(24 * time.Hour)
|
||||
|
||||
// CacheGet 读取缓存 URL
|
||||
func CacheGet(key string) (string, bool) {
|
||||
v, ok := effectCache.Get(key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, _ := v.(string)
|
||||
return s, s != ""
|
||||
}
|
||||
|
||||
// CacheSet 写入缓存 URL
|
||||
func CacheSet(key, url string) {
|
||||
effectCache.Set(key, url)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// ImageGenClient 图像生成客户端(真实调用,无 mock)
|
||||
type ImageGenClient interface {
|
||||
// Generate 生成单张图片,返回图片 URL(可传 BaseImageURL 做图生图,为空则文生图)
|
||||
Generate(ctx context.Context, req *GenerateReq) (string, error)
|
||||
}
|
||||
|
||||
// GenerateReq 生成请求
|
||||
type GenerateReq struct {
|
||||
BaseImageURL string // 用户全身照(本地 /workspace 路径或 http(s) URL,空为文生图)
|
||||
Prompt string // 方案描述
|
||||
Angle string // 正面/侧面/背面
|
||||
Seed int64
|
||||
}
|
||||
|
||||
// NewClient 创建真实图像生成客户端;未配置供应商或 Key 时返回错误(不再降级 mock)
|
||||
func NewClient(supplier string) (ImageGenClient, error) {
|
||||
if supplier == "wanx" {
|
||||
key := g.Cfg().MustGet(context.Background(), "imagegen.wanx_api_key", "").String()
|
||||
if key != "" {
|
||||
return &wanxClient{
|
||||
apiKey: key,
|
||||
model: g.Cfg().MustGet(context.Background(), "imagegen.wanx_model", "wan2.7-image-pro").String(),
|
||||
base: g.Cfg().MustGet(context.Background(), "imagegen.wanx_base", "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation").String(),
|
||||
taskBase: g.Cfg().MustGet(context.Background(), "imagegen.wanx_task_base", "https://dashscope.aliyuncs.com/api/v1/tasks").String(),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("imagegen 未配置:请在 config.yml 设置 imagegen.wanx_api_key")
|
||||
}
|
||||
return nil, fmt.Errorf("imagegen 供应商不支持:%s(当前仅支持 wanx)", supplier)
|
||||
}
|
||||
|
||||
// buildPrompt 组装图片生成提示词
|
||||
func buildPrompt(planDesc, hairstyle, hairColor, angle string) string {
|
||||
return fmt.Sprintf("时尚穿搭效果图,%s;发型:%s(发色 %s);角度:%s;人物写实、高清、全身、纯色背景",
|
||||
planDesc, hairstyle, hairColor, angle)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// wanxClient 通义万相图像生成(wan2.7-image-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("万相任务超时")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)。"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
const (
|
||||
renderFramesCount = 36
|
||||
renderFrameSize = "256x512"
|
||||
renderTimeout = 15 * time.Minute
|
||||
)
|
||||
|
||||
// RenderAvatarFrames 将化身 GLB 预渲染为绕 Y 轴旋转帧序列,返回帧目录访问 URL。
|
||||
// 帧目录已就绪直接复用;render.enabled=false 或渲染失败时返回 error,由调用方降级。
|
||||
func RenderAvatarFrames(ctx context.Context, glb string, outKey string) (framesURL string, err error) {
|
||||
if !g.Cfg().MustGet(ctx, "render.enabled", true).Bool() {
|
||||
return "", errors.New("3D 渲染服务未启用")
|
||||
}
|
||||
dir := filepath.Join("workspace", "avatar_frames", outKey)
|
||||
if framesReady(dir) {
|
||||
return "/workspace/avatar_frames/" + outKey, nil
|
||||
}
|
||||
if _, err := os.Stat(glb); err != nil {
|
||||
return "", fmt.Errorf("化身 GLB 不存在: %w", err)
|
||||
}
|
||||
if err := runRender(ctx, glb, dir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "/workspace/avatar_frames/" + outKey, nil
|
||||
}
|
||||
|
||||
func framesReady(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
count := 0
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "frame_") && strings.HasSuffix(e.Name(), ".png") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count >= renderFramesCount
|
||||
}
|
||||
|
||||
func nodeBin(ctx context.Context) string {
|
||||
bin := g.Cfg().MustGet(ctx, "render.node_bin", "node").String()
|
||||
if bin == "" {
|
||||
return "node"
|
||||
}
|
||||
// 配置路径不存在(如容器环境)→ 回退 PATH 中的 node
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
return "node"
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func runRender(ctx context.Context, glb, out string) error {
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return execNode(ctx, filepath.Join("scripts", "avatar-render", "render.js"),
|
||||
"--glb", glb, "--out", out,
|
||||
"--frames", fmt.Sprint(renderFramesCount), "--size", renderFrameSize)
|
||||
}
|
||||
|
||||
func execNode(ctx context.Context, script string, args ...string) error {
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, renderTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cmdCtx, nodeBin(ctx), append([]string{script}, args...)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("node 渲染失败: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// colorScore 色彩和谐(20 分制):色相环角度差评估
|
||||
// 同色系(≤30°) 20;邻近(≤60°) 15;对比(≤150°) 8;冲突 3
|
||||
func colorScore(o CandidateOutfit) int {
|
||||
var hues []int
|
||||
for _, it := range o.Items {
|
||||
h, ok := parseHue(it.ColorInfo)
|
||||
if ok {
|
||||
hues = append(hues, h)
|
||||
}
|
||||
}
|
||||
if len(hues) < 2 {
|
||||
return 12 // 无色彩信息给中性分
|
||||
}
|
||||
total := 0
|
||||
pairs := 0
|
||||
for i := 0; i < len(hues); i++ {
|
||||
for j := i + 1; j < len(hues); j++ {
|
||||
diff := hueDiff(hues[i], hues[j])
|
||||
switch {
|
||||
case diff <= 30:
|
||||
total += 20
|
||||
case diff <= 60:
|
||||
total += 15
|
||||
case diff <= 150:
|
||||
total += 8
|
||||
default:
|
||||
total += 3
|
||||
}
|
||||
pairs++
|
||||
}
|
||||
}
|
||||
return total / pairs
|
||||
}
|
||||
|
||||
// parseHue 解析 #RRGGBB 或中文色名 → 色相角(0-360)
|
||||
func parseHue(color string) (int, bool) {
|
||||
c := strings.TrimSpace(color)
|
||||
if strings.HasPrefix(c, "#") && len(c) == 7 {
|
||||
r, e1 := strconv.ParseInt(c[1:3], 16, 32)
|
||||
gg, e2 := strconv.ParseInt(c[3:5], 16, 32)
|
||||
b, e3 := strconv.ParseInt(c[5:7], 16, 32)
|
||||
if e1 == nil && e2 == nil && e3 == nil {
|
||||
return rgbToHue(float64(r), float64(gg), float64(b)), true
|
||||
}
|
||||
}
|
||||
named := map[string]int{
|
||||
"红": 0, "橙": 30, "黄": 60, "绿": 120, "青": 180, "蓝": 240, "紫": 280,
|
||||
"粉": 340, "黑": 360, "白": 360, "灰": 360, "棕": 25, "卡其": 45, "牛仔": 220,
|
||||
}
|
||||
for name, h := range named {
|
||||
if strings.Contains(c, name) {
|
||||
return h, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func rgbToHue(r, g, b float64) int {
|
||||
max, min := r, g
|
||||
if g > max {
|
||||
max = g
|
||||
}
|
||||
if b > max {
|
||||
max = b
|
||||
}
|
||||
if g < min {
|
||||
min = g
|
||||
}
|
||||
if b < min {
|
||||
min = b
|
||||
}
|
||||
if max == min {
|
||||
return 0
|
||||
}
|
||||
var h float64
|
||||
switch max {
|
||||
case r:
|
||||
h = 60 * (g - b) / (max - min)
|
||||
case g:
|
||||
h = 60*(b-r)/(max-min) + 120
|
||||
default:
|
||||
h = 60*(r-g)/(max-min) + 240
|
||||
}
|
||||
if h < 0 {
|
||||
h += 360
|
||||
}
|
||||
return int(h)
|
||||
}
|
||||
|
||||
func hueDiff(a, b int) int {
|
||||
d := a - b
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d > 180 {
|
||||
d = 360 - d
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package agent
|
||||
|
||||
// completenessScore 层次完整度(20 分制):上衣+5 下装+5 鞋+5 配饰+5
|
||||
func completenessScore(o CandidateOutfit) int {
|
||||
score := 0
|
||||
for _, it := range o.Items {
|
||||
switch it.Category {
|
||||
case "上衣":
|
||||
score += 5
|
||||
case "下装":
|
||||
score += 5
|
||||
case "鞋":
|
||||
score += 5
|
||||
case "配饰":
|
||||
score += 5
|
||||
}
|
||||
}
|
||||
if o.HasOuterwear {
|
||||
score += 2
|
||||
}
|
||||
if score > 20 {
|
||||
return 20
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
// styleScore 风格一致性(10 分制):命中用户偏好标签每项 +2
|
||||
func styleScore(o CandidateOutfit, ctx ScoreContext) int {
|
||||
if len(ctx.StyleTags) == 0 {
|
||||
return 5
|
||||
}
|
||||
score := 0
|
||||
for _, it := range o.Items {
|
||||
for _, tag := range ctx.StyleTags {
|
||||
if tag != "" && it.StyleTags != "" && containsTag(it.StyleTags, tag) {
|
||||
score += 2
|
||||
}
|
||||
}
|
||||
}
|
||||
if score > 10 {
|
||||
return 10
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func containsTag(tags, tag string) bool {
|
||||
for _, t := range splitTags(tags) {
|
||||
if t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitTags(s string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, c := range s {
|
||||
if c == ',' || c == ',' || c == ' ' {
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
cur = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
cur += string(c)
|
||||
}
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package agent
|
||||
|
||||
// Score 总分(100 分制)
|
||||
func Score(c *CandidateOutfit, ctx *ScoreContext) int {
|
||||
return weatherScore(*c, *ctx) + occasionScore(*c, *ctx) + colorScore(*c) +
|
||||
completenessScore(*c) + styleScore(*c, *ctx)
|
||||
}
|
||||
|
||||
// IsPass 是否达到阈值
|
||||
func IsPass(score, threshold int) bool {
|
||||
return score >= threshold
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package agent
|
||||
|
||||
import "strings"
|
||||
|
||||
// occasionScore 场合匹配(25 分制):基础 15 + 场合类别匹配项 +5
|
||||
var occasionCategory = map[string][]string{
|
||||
"通勤": {"西装", "衬衫", "休闲", "通勤"},
|
||||
"约会": {"裙装", "连衣裙", "优雅", "约会", "浪漫"},
|
||||
"聚会": {"潮流", "时尚", "个性", "派对"},
|
||||
"运动": {"运动", "休闲", "T恤", "卫衣"},
|
||||
}
|
||||
|
||||
func occasionScore(o CandidateOutfit, ctx ScoreContext) int {
|
||||
score := 15
|
||||
allowed := occasionCategory[ctx.Occasion]
|
||||
for _, it := range o.Items {
|
||||
tags := it.StyleTags
|
||||
for _, a := range allowed {
|
||||
if a != "" && strings.Contains(tags, a) {
|
||||
score += 5
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if score > 25 {
|
||||
return 25
|
||||
}
|
||||
return score
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package agent
|
||||
|
||||
// NewItemBaseScore 推荐新品(用户衣橱不具备)的单品基础分,
|
||||
// 无衣橱属性(颜色/风格)无法走规则评分,直接给固定分值并入方案总分
|
||||
const NewItemBaseScore = 15
|
||||
|
||||
// WardrobeItem 评分用服装条目(从衣橱 entity 转换)
|
||||
type WardrobeItem struct {
|
||||
Category string // 上衣/下装/鞋/配饰
|
||||
Season string // 春/夏/秋/冬/四季
|
||||
ColorInfo string // 如 #RRGGBB
|
||||
StyleTags string
|
||||
}
|
||||
|
||||
// CandidateOutfit 候选组合
|
||||
type CandidateOutfit struct {
|
||||
Items []WardrobeItem
|
||||
HasOuterwear bool
|
||||
}
|
||||
|
||||
// ScoreContext 评分上下文
|
||||
type ScoreContext struct {
|
||||
TempAvg int // 日期范围平均温度℃
|
||||
Season string // 春/夏/秋/冬
|
||||
Occasion string // 通勤/约会/聚会/运动
|
||||
Weekday string // workday/weekend/holiday
|
||||
StyleTags []string // 用户偏好标签
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package agent
|
||||
|
||||
// weatherScore 天气适宜度(25 分制)
|
||||
// 温度匹配每件服装季节 +5;<10℃ 无外套 -10;>30℃ 有外套 -8
|
||||
func weatherScore(o CandidateOutfit, ctx ScoreContext) int {
|
||||
score := 0
|
||||
for _, it := range o.Items {
|
||||
switch {
|
||||
case ctx.TempAvg >= 28 && it.Season == "夏":
|
||||
score += 5
|
||||
case ctx.TempAvg >= 18 && ctx.TempAvg < 28 && it.Season == "春":
|
||||
score += 5
|
||||
case ctx.TempAvg >= 18 && ctx.TempAvg < 28 && it.Season == "秋":
|
||||
score += 5
|
||||
case ctx.TempAvg < 18 && it.Season == "冬":
|
||||
score += 5
|
||||
case it.Season == "四季":
|
||||
score += 4
|
||||
default:
|
||||
score += 2
|
||||
}
|
||||
}
|
||||
if ctx.TempAvg < 10 && !o.HasOuterwear {
|
||||
score -= 10
|
||||
}
|
||||
if ctx.TempAvg > 30 && o.HasOuterwear {
|
||||
score -= 8
|
||||
}
|
||||
if score < 0 {
|
||||
return 0
|
||||
}
|
||||
if score > 25 {
|
||||
return 25
|
||||
}
|
||||
return score
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "冬"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package consts
|
||||
|
||||
// 数据库组:config.yml database.* 中的分组名,与 slogans 各域 SQLite 文件一一对应
|
||||
const (
|
||||
DBGroupPlan = "plan"
|
||||
DBGroupPay = "pay"
|
||||
DBGroupCps = "cps"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package consts
|
||||
|
||||
const (
|
||||
TableNameUser = "slogan_user"
|
||||
TableNameUserPhoto = "slogan_user_photo"
|
||||
TableNameWardrobeItem = "slogan_wardrobe_item"
|
||||
TableNameBodyMeasurement = "slogan_body_measurement"
|
||||
TableNameAvatarModel = "slogan_avatar_model"
|
||||
TableNameHairstyleAsset = "slogan_hairstyle_asset"
|
||||
TableNameOutfitGenTask = "slogan_outfit_generation_task"
|
||||
TableNameOutfitPlan = "slogan_outfit_plan"
|
||||
TableNamePlanOutfitItem = "slogan_plan_outfit_item"
|
||||
TableNamePlanEffectImage = "slogan_plan_effect_image"
|
||||
TableNamePlanReview = "slogan_plan_review"
|
||||
TableNameScoringRule = "slogan_scoring_rule"
|
||||
TableNamePartnerStore = "slogan_partner_store"
|
||||
TableNameMemberPlan = "slogan_member_plan"
|
||||
TableNamePaymentOrder = "slogan_payment_order"
|
||||
TableNameUserMember = "slogan_user_member"
|
||||
TableNamePayNotifyLog = "slogan_pay_notify_log"
|
||||
TableNameAdRewardLog = "slogan_ad_reward_log"
|
||||
TableNameCpsCategory = "slogan_cps_category"
|
||||
TableNameCpsProduct = "slogan_cps_product"
|
||||
TableNameCpsClickLog = "slogan_cps_click_log"
|
||||
TableNameSceneCategoryMap = "slogan_scene_category_map"
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
commonHttp "slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type ad struct{}
|
||||
|
||||
var Ad = new(ad)
|
||||
|
||||
// RewardClaim 领取广告激励(限频:effect_extra 每日 2 次 / vip_trial 每日 1 次)
|
||||
func (c *ad) RewardClaim(ctx context.Context, req *dto.AdRewardClaimReq) (res *dto.AdRewardClaimRes, err error) {
|
||||
result, err := service.AdService.Claim(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.AdType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AdRewardClaimRes{Reward: &dto.AdRewardInfo{
|
||||
AdType: result.AdType, RemainingToday: result.RemainingToday,
|
||||
}}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type avatar struct{}
|
||||
|
||||
var Avatar = new(avatar)
|
||||
|
||||
func (c *avatar) Build(ctx context.Context, req *dto.AvatarBuildReq) (res *dto.AvatarBuildRes, err error) {
|
||||
a, err := service.AvatarService.Build(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.AvatarBuildRes{AvatarId: a.Id, Status: a.BuildStatus}, nil
|
||||
}
|
||||
|
||||
func (c *avatar) Get(ctx context.Context, req *dto.AvatarGetReq) (res *dto.AvatarGetRes, err error) {
|
||||
a, err := service.AvatarService.Get(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil || a == nil {
|
||||
return &dto.AvatarGetRes{}, nil
|
||||
}
|
||||
return &dto.AvatarGetRes{
|
||||
FaceTemplateId: a.FaceTemplateId,
|
||||
BodyTemplateId: a.BodyTemplateId,
|
||||
SkinToneIndex: a.SkinToneIndex,
|
||||
GlbUrl: a.GlbUrl,
|
||||
FramesUrl: a.FramesUrl,
|
||||
BuildStatus: a.BuildStatus,
|
||||
Error: a.Error,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type body_measurement struct{}
|
||||
|
||||
var BodyMeasurement = new(body_measurement)
|
||||
|
||||
func (c *body_measurement) Save(ctx context.Context, req *dto.BodyMeasurementSaveReq) (res *struct{}, err error) {
|
||||
if err := service.BodyMeasurementService.Save(ctx, common.GetUserId(g.RequestFromCtx(ctx)), &entity.BodyMeasurement{
|
||||
Height: req.Height,
|
||||
Weight: req.Weight,
|
||||
SkinTone: req.SkinTone,
|
||||
Bust: req.Bust,
|
||||
Waist: req.Waist,
|
||||
Hip: req.Hip,
|
||||
Shoulder: req.Shoulder,
|
||||
FitParams: req.FitParams,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
|
||||
func (c *body_measurement) Get(ctx context.Context, req *dto.BodyMeasurementGetReq) (res *dto.BodyMeasurementGetRes, err error) {
|
||||
b, err := service.BodyMeasurementService.Get(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil || b == nil {
|
||||
return &dto.BodyMeasurementGetRes{}, nil
|
||||
}
|
||||
return &dto.BodyMeasurementGetRes{
|
||||
Height: b.Height,
|
||||
Weight: b.Weight,
|
||||
SkinTone: b.SkinTone,
|
||||
Bust: b.Bust,
|
||||
Waist: b.Waist,
|
||||
Hip: b.Hip,
|
||||
Shoulder: b.Shoulder,
|
||||
FitParams: b.FitParams,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
)
|
||||
|
||||
// CategoryList 联盟分类列表(客户端 chips)
|
||||
func (c *cps) CategoryList(ctx context.Context, req *dto.CpsCategoryListReq) (res *dto.CpsCategoryListRes, err error) {
|
||||
list, err := service.CpsCategoryService.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsCategoryListRes{List: list}, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// MyRecent 最近优惠(点击日志 → 商品)
|
||||
func (c *cps) MyRecent(ctx context.Context, req *dto.CpsMyRecentReq) (res *dto.CpsMyRecentRes, err error) {
|
||||
list, err := service.CpsClickLogService.MyRecent(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsMyRecentRes{List: list}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// cps 共享 struct:/cps/* 簇的 6 个 handler 分文件挂在同一个类型上(组前缀路由零变更)
|
||||
type cps struct{}
|
||||
|
||||
var Cps = new(cps)
|
||||
|
||||
// ProductList 选品池分页列表
|
||||
func (c *cps) ProductList(ctx context.Context, req *dto.CpsProductListReq) (res *dto.CpsProductListRes, err error) {
|
||||
list, hasMore, err := service.CpsProductService.ListByCategory(ctx, req.Source, req.CategoryCode, req.City, req.Page, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsProductListRes{List: list, HasMore: hasMore}, nil
|
||||
}
|
||||
|
||||
// ProductLink 商品转链(记录点击日志)
|
||||
func (c *cps) ProductLink(ctx context.Context, req *dto.CpsProductLinkReq) (res *dto.CpsProductLinkRes, err error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
link, err := service.CpsProductService.ClickLink(ctx, common.GetUserId(r), req.ProductId, req.Scene, req.PlanId, r.GetClientIp())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsProductLinkRes{Deeplink: link}, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
)
|
||||
|
||||
type hairstyle struct{}
|
||||
|
||||
var Hairstyle = new(hairstyle)
|
||||
|
||||
func (c *hairstyle) List(ctx context.Context, req *dto.HairstyleListReq) (res *dto.HairstyleListRes, err error) {
|
||||
list, err := service.HairstyleService.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.HairstyleListRes{List: list}, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
commonHttp "slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// member 为 /member/* 簇共享控制器类型(struct 名 kebab-case 决定路由前缀,
|
||||
// 多表拆分文件但保持同一类型,避免路径漂移)
|
||||
type member struct{}
|
||||
|
||||
var Member = new(member)
|
||||
|
||||
// PlanList 会员套餐列表
|
||||
func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res *dto.MemberPlanListRes, err error) {
|
||||
list, err := service.MemberPlanService.PlanList(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.MemberPlanListRes{List: list}, nil
|
||||
}
|
||||
|
||||
// Status 我的会员状态
|
||||
func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto.MemberStatusRes, err error) {
|
||||
st, err := service.MemberPlanService.Status(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.MemberStatusRes{
|
||||
IsVip: st.IsVip, ExpireAt: st.ExpireAt, PlanName: st.PlanName, Benefits: st.Benefits,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// outfit 为 /outfit/* 簇共享控制器类型(struct 名 kebab-case 决定路由前缀,
|
||||
// 多表拆分文件但保持同一类型,避免路径漂移)
|
||||
type outfit struct{}
|
||||
|
||||
var Outfit = new(outfit)
|
||||
|
||||
// Generate 生成穿搭方案(异步任务)
|
||||
func (c *outfit) Generate(ctx context.Context, req *dto.OutfitGenerateReq) (res *dto.OutfitGenerateRes, err error) {
|
||||
taskId, err := service.OutfitService.Generate(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.OutfitGenerateRes{TaskId: taskId}, nil
|
||||
}
|
||||
|
||||
// TaskStatus 查询生成任务状态
|
||||
func (c *outfit) TaskStatus(ctx context.Context, req *dto.OutfitTaskStatusReq) (res *dto.OutfitTaskStatusRes, err error) {
|
||||
status, msg, err := service.OutfitService.GetTaskStatus(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.TaskId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.OutfitTaskStatusRes{Status: status, Error: msg}, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// PlanList 方案列表
|
||||
func (c *outfit) PlanList(ctx context.Context, req *dto.OutfitPlanListReq) (res *dto.OutfitPlanListRes, err error) {
|
||||
list, err := service.OutfitPlanService.ListPlans(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.OutfitPlanListRes{List: list}, nil
|
||||
}
|
||||
|
||||
// PlanDetail 方案详情(items + images + hairstyle)
|
||||
func (c *outfit) PlanDetail(ctx context.Context, req *dto.OutfitPlanDetailReq) (res *dto.OutfitPlanDetailRes, err error) {
|
||||
return service.OutfitPlanService.GetPlanDetail(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId)
|
||||
}
|
||||
|
||||
// SelectMain 选定主方案(触发效果图生成)
|
||||
func (c *outfit) SelectMain(ctx context.Context, req *dto.OutfitSelectMainReq) (res *struct{}, err error) {
|
||||
if err = service.OutfitPlanService.SelectMain(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
)
|
||||
|
||||
type partner_store struct{}
|
||||
|
||||
var PartnerStore = new(partner_store)
|
||||
|
||||
// List 合作门店列表
|
||||
func (c *partner_store) List(ctx context.Context, req *dto.StoreListReq) (res *dto.StoreListRes, err error) {
|
||||
list, err := service.PartnerStoreService.List(ctx, req.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.StoreListRes{List: list}, nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package controller
|
||||
|
||||
// pay_notify_log 表控制器:无独立路由 handler(回调日志由 /member/order/notify 审计写入)
|
||||
@@ -0,0 +1,74 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
commonHttp "slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
// OrderCreate 下单 → 返回支付 URL
|
||||
func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq) (res *dto.MemberOrderCreateRes, err error) {
|
||||
order, payURL, err := service.PaymentOrderService.CreateMemberOrder(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.PlanId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.MemberOrderCreateRes{OrderNo: order.OrderNo, PayUrl: payURL}, nil
|
||||
}
|
||||
|
||||
// OrderStatus 订单状态(App 轮询)
|
||||
func (c *member) OrderStatus(ctx context.Context, req *dto.MemberOrderStatusReq) (res *dto.MemberOrderStatusRes, err error) {
|
||||
order, err := service.PaymentOrderService.OrderStatus(ctx, req.OrderNo)
|
||||
if err != nil || order == nil {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
paidAt := ""
|
||||
if order.PaidAt != nil {
|
||||
paidAt = order.PaidAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
return &dto.MemberOrderStatusRes{Status: order.Status, TradeNo: order.TradeNo, PaidAt: paidAt}, nil
|
||||
}
|
||||
|
||||
// MemberNotify 虎皮棋支付回调:验签 → 幂等开通 → 返回裸文本 "success"
|
||||
// 虎皮棋要求回调响应体为字面 "success",故不走统一 JSON 包装(main.go 手动绑定)
|
||||
func MemberNotify(r *ghttp.Request) {
|
||||
ctx := r.Context()
|
||||
body := r.GetBodyString()
|
||||
hash := r.Get("hash").String()
|
||||
orderNo := r.Get("trade_order_id").String()
|
||||
remoteIP := r.GetClientIp()
|
||||
|
||||
params := make(map[string]string)
|
||||
for k, v := range r.GetRequestMap() {
|
||||
params[k] = fmt.Sprint(v)
|
||||
}
|
||||
ok := service.PaymentOrderService.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String())
|
||||
|
||||
if !ok {
|
||||
if err := service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, "bad_sign"); err != nil {
|
||||
g.Log().Warningf(ctx, "写入支付回调日志失败(bad_sign): %v", err)
|
||||
}
|
||||
r.Response.Write("fail")
|
||||
r.ExitAll()
|
||||
return
|
||||
}
|
||||
|
||||
state, err := service.PaymentOrderService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body)
|
||||
if logErr := service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, state); logErr != nil {
|
||||
g.Log().Warningf(ctx, "写入支付回调日志失败: %v", logErr)
|
||||
}
|
||||
// duplicate(幂等重复回调)同样返回 success,避免支付渠道无限重试
|
||||
if err != nil || state == "no_order" {
|
||||
r.Response.Write("fail")
|
||||
r.ExitAll()
|
||||
return
|
||||
}
|
||||
r.Response.Write("success")
|
||||
r.ExitAll()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package controller
|
||||
|
||||
// plan_effect_image 表控制器:无独立路由 handler,逻辑归属 outfit/* 簇
|
||||
// (效果图由选定主方案后异步生成,经 /outfit/plan/detail 返回)
|
||||
@@ -0,0 +1,4 @@
|
||||
package controller
|
||||
|
||||
// plan_outfit_item 表控制器:无独立路由 handler,逻辑归属 outfit/* 簇
|
||||
// (方案条目数据由 /outfit/plan/detail 承载)
|
||||
@@ -0,0 +1,19 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// Review 方案反馈
|
||||
func (c *outfit) Review(ctx context.Context, req *dto.OutfitReviewReq) (res *struct{}, err error) {
|
||||
if err = service.PlanReviewService.Review(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId, req.Action, req.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// PlanRecommend 方案驱动推荐(发型/买同款/到店试穿/场合)
|
||||
func (c *cps) PlanRecommend(ctx context.Context, req *dto.CpsPlanRecommendReq) (res *dto.CpsPlanRecommendRes, err error) {
|
||||
userId := common.GetUserId(g.RequestFromCtx(ctx))
|
||||
plan, err := service.OutfitPlanService.GetPlan(ctx, userId, req.PlanId)
|
||||
if err != nil || plan == nil {
|
||||
return nil, errors.New("方案不存在")
|
||||
}
|
||||
list, err := service.SceneCategoryMapService.Recommend(ctx, plan, req.Scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsPlanRecommendRes{List: list}, nil
|
||||
}
|
||||
|
||||
// WardrobeUpgrade 衣橱升级款
|
||||
func (c *cps) WardrobeUpgrade(ctx context.Context, req *dto.CpsWardrobeUpgradeReq) (res *dto.CpsWardrobeUpgradeRes, err error) {
|
||||
list, err := service.SceneCategoryMapService.WardrobeUpgrade(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.ItemId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.CpsWardrobeUpgradeRes{List: list}, nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package controller
|
||||
|
||||
// scoring_rule 表控制器:无独立路由 handler,规则在服务端读取(评分阈值/效果图额度)
|
||||
@@ -0,0 +1,47 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/dao"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type user struct{}
|
||||
|
||||
var User = new(user)
|
||||
|
||||
func (c *user) Register(ctx context.Context, req *dto.RegisterReq) (res *struct{}, err error) {
|
||||
_, err = service.UserService.Register(ctx, req.Account, req.Password, req.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
|
||||
func (c *user) Login(ctx context.Context, req *dto.LoginReq) (res *dto.LoginRes, err error) {
|
||||
user, token, err := service.UserService.Login(ctx, req.Account, req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.LoginRes{
|
||||
Token: token,
|
||||
User: &dto.LoginUser{Id: user.Id, Role: user.Role, Name: user.Name},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *user) ChangePassword(ctx context.Context, req *dto.ChangePasswordReq) (res *struct{}, err error) {
|
||||
return nil, service.UserService.ChangePassword(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.OldPassword, req.NewPassword)
|
||||
}
|
||||
|
||||
func (c *user) Profile(ctx context.Context, req *dto.ProfileReq) (res *dto.ProfileRes, err error) {
|
||||
user, err := dao.User.GetOne(ctx, common.GetUserId(g.RequestFromCtx(ctx)))
|
||||
if err != nil || user == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.ProfileRes{Id: user.Id, Role: user.Role, Name: user.Name, Username: user.Username, Phone: user.Phone}, nil
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package controller
|
||||
|
||||
// user_member 表控制器:无独立路由 handler(会员状态经 /member/status 返回,
|
||||
// 开通由支付回调与广告激励写入)
|
||||
@@ -0,0 +1,38 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type user_photo struct{}
|
||||
|
||||
var UserPhoto = new(user_photo)
|
||||
|
||||
func (c *user_photo) Upload(ctx context.Context, req *dto.UserPhotoUploadReq) (res *dto.UserPhotoUploadRes, err error) {
|
||||
id, err := service.UserPhotoService.Upload(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Type, g.RequestFromCtx(ctx).GetUploadFile("file"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.UserPhotoUploadRes{Id: id}, nil
|
||||
}
|
||||
|
||||
func (c *user_photo) List(ctx context.Context, req *dto.UserPhotoListReq) (res *dto.UserPhotoListRes, err error) {
|
||||
list, err := service.UserPhotoService.List(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.UserPhotoListRes{List: list}, nil
|
||||
}
|
||||
|
||||
func (c *user_photo) Delete(ctx context.Context, req *dto.UserPhotoDeleteReq) (res *struct{}, err error) {
|
||||
if err := service.UserPhotoService.Delete(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/model/dto"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
"slogan-agent/styleagent/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type wardrobe struct{}
|
||||
|
||||
var Wardrobe = new(wardrobe)
|
||||
|
||||
func (c *wardrobe) Upload(ctx context.Context, req *dto.WardrobeUploadReq) (res *dto.WardrobeUploadRes, err error) {
|
||||
id, err := service.WardrobeService.Upload(ctx, common.GetUserId(g.RequestFromCtx(ctx)), entity.WardrobeItem{
|
||||
Category: req.Category,
|
||||
Season: req.Season,
|
||||
StyleTags: req.StyleTags,
|
||||
ColorInfo: req.ColorInfo,
|
||||
}, g.RequestFromCtx(ctx).GetUploadFile("file"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.WardrobeUploadRes{Id: id}, nil
|
||||
}
|
||||
|
||||
func (c *wardrobe) List(ctx context.Context, req *dto.WardrobeListReq) (res *dto.WardrobeListRes, err error) {
|
||||
list, err := service.WardrobeService.List(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.WardrobeListRes{List: list}, nil
|
||||
}
|
||||
|
||||
func (c *wardrobe) Update(ctx context.Context, req *dto.WardrobeUpdateReq) (res *struct{}, err error) {
|
||||
data := map[string]any{}
|
||||
if req.Category != "" {
|
||||
data["category"] = req.Category
|
||||
}
|
||||
if req.Season != "" {
|
||||
data["season"] = req.Season
|
||||
}
|
||||
if req.StyleTags != "" {
|
||||
data["style_tags"] = req.StyleTags
|
||||
}
|
||||
if err := service.WardrobeService.Update(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
|
||||
func (c *wardrobe) Delete(ctx context.Context, req *dto.WardrobeDeleteReq) (res *struct{}, err error) {
|
||||
if err := service.WardrobeService.Delete(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &struct{}{}, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"slogan-agent/styleagent/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var AdRewardLog = &adRewardLogDao{}
|
||||
|
||||
type adRewardLogDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
ad_type TEXT NOT NULL DEFAULT '',
|
||||
reward_key TEXT NOT NULL DEFAULT '',
|
||||
slot INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'ok',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create ad_reward_log table failed: %v", err)
|
||||
}
|
||||
// 唯一索引兜底并发:同一 (user, day, type) 最多 limit 个 slot(如 effect_extra 2 / vip_trial 1)
|
||||
if _, err := dbPay().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key, slot)`); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_ad_reward_unique failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// rewardKey 自然日去重粒度:"2026-07-31:effect_extra"
|
||||
func rewardKey(adType string) string {
|
||||
return fmt.Sprintf("%s:%s", time.Now().Format("2006-01-02"), adType)
|
||||
}
|
||||
|
||||
// InsertTx 事务版本:领取记录与会员赠送原子提交
|
||||
func (d *adRewardLogDao) InsertTx(ctx context.Context, tx gdb.TX, userId int64, adType string, limit int) (int64, error) {
|
||||
for slot := 1; slot <= limit; slot++ {
|
||||
r, err := tx.Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{
|
||||
"user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok",
|
||||
}).Insert()
|
||||
if err == nil {
|
||||
return r.LastInsertId()
|
||||
}
|
||||
}
|
||||
return 0, errors.New("ad reward quota exhausted")
|
||||
}
|
||||
|
||||
func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) {
|
||||
n, err := dbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx).
|
||||
Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count()
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
// Insert 领取记录:在 1..limit 的 slot 中找一个空闲位写入;全满(唯一索引冲突)返回错误 → 视为限频
|
||||
func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string, limit int) (int64, error) {
|
||||
for slot := 1; slot <= limit; slot++ {
|
||||
r, err := dbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{
|
||||
"user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok",
|
||||
}).Insert()
|
||||
if err == nil {
|
||||
return r.LastInsertId()
|
||||
}
|
||||
}
|
||||
return 0, errors.New("ad reward quota exhausted")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var AvatarModel = &avatarModelDao{}
|
||||
|
||||
type avatarModelDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAvatarModel+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
face_template_id INTEGER NOT NULL DEFAULT 0,
|
||||
body_template_id INTEGER NOT NULL DEFAULT 0,
|
||||
skin_tone_index INTEGER NOT NULL DEFAULT 0,
|
||||
face_texture_url TEXT NOT NULL DEFAULT '',
|
||||
glb_url TEXT NOT NULL DEFAULT '',
|
||||
build_status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
params_snapshot TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime')),
|
||||
updated_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create avatar_model table failed: %v", err)
|
||||
}
|
||||
// 旧库补列(CREATE TABLE IF NOT EXISTS 不给已存在表加列,忽略 duplicate column 错误)
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameAvatarModel+" ADD COLUMN frames_url TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate column") {
|
||||
g.Log().Warningf(ctx, "alter avatar_model add frames_url failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *avatarModelDao) Insert(ctx context.Context, data *entity.AvatarModel) (int64, error) {
|
||||
r, err := g.DB().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNameAvatarModel+" (user_id, face_template_id, body_template_id, skin_tone_index, face_texture_url, glb_url, build_status, error, params_snapshot, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
|
||||
data.UserId, data.FaceTemplateId, data.BodyTemplateId, data.SkinToneIndex,
|
||||
data.FaceTextureUrl, data.GlbUrl, data.BuildStatus, data.Error, data.ParamsSnapshot)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *avatarModelDao) GetByUser(ctx context.Context, userId int64) (*entity.AvatarModel, error) {
|
||||
var a entity.AvatarModel
|
||||
err := g.DB().Model(consts.TableNameAvatarModel).Ctx(ctx).
|
||||
Where("user_id", userId).OrderDesc("id").Scan(&a)
|
||||
if err != nil || a.Id == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *avatarModelDao) Update(ctx context.Context, id int64, data map[string]any) error {
|
||||
_, err := g.DB().Model(consts.TableNameAvatarModel).Ctx(ctx).Data(data).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var BodyMeasurement = &bodyMeasurementDao{}
|
||||
|
||||
type bodyMeasurementDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameBodyMeasurement+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
weight INTEGER NOT NULL DEFAULT 0,
|
||||
skin_tone INTEGER NOT NULL DEFAULT 3,
|
||||
fit_params TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create body_measurement table failed: %v", err)
|
||||
}
|
||||
// 旧库补列(CREATE TABLE IF NOT EXISTS 不给已存在表加列,忽略 duplicate column 错误)
|
||||
for _, col := range []string{
|
||||
"bust INTEGER NOT NULL DEFAULT 0",
|
||||
"waist INTEGER NOT NULL DEFAULT 0",
|
||||
"hip INTEGER NOT NULL DEFAULT 0",
|
||||
"shoulder INTEGER NOT NULL DEFAULT 0",
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameBodyMeasurement+" ADD COLUMN "+col); err != nil {
|
||||
if !strings.Contains(err.Error(), "duplicate column") {
|
||||
g.Log().Warningf(ctx, "alter body_measurement add column failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *bodyMeasurementDao) Save(ctx context.Context, data *entity.BodyMeasurement) error {
|
||||
r, err := g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx).
|
||||
Where("user_id", data.UserId).One()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r == nil {
|
||||
_, err = g.DB().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNameBodyMeasurement+" (user_id, height, weight, skin_tone, bust, waist, hip, shoulder, fit_params, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
|
||||
data.UserId, data.Height, data.Weight, data.SkinTone,
|
||||
data.Bust, data.Waist, data.Hip, data.Shoulder, data.FitParams)
|
||||
return err
|
||||
}
|
||||
_, err = g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx).Data(g.Map{
|
||||
"height": data.Height, "weight": data.Weight, "skin_tone": data.SkinTone,
|
||||
"bust": data.Bust, "waist": data.Waist, "hip": data.Hip, "shoulder": data.Shoulder,
|
||||
"fit_params": data.FitParams, "updated_at": "datetime('now','localtime')",
|
||||
}).Where("user_id", data.UserId).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *bodyMeasurementDao) GetByUser(ctx context.Context, userId int64) (*entity.BodyMeasurement, error) {
|
||||
var b entity.BodyMeasurement
|
||||
err := g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx).
|
||||
Where("user_id", userId).Scan(&b)
|
||||
if err != nil || b.Id == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var CpsCategory = &cpsCategoryDao{}
|
||||
|
||||
type cpsCategoryDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsCategory+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
parent_code TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
source_cat_id TEXT NOT NULL DEFAULT '',
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_category table failed: %v", err)
|
||||
}
|
||||
seedCpsCategories(ctx)
|
||||
}
|
||||
|
||||
func seedCpsCategories(ctx context.Context) {
|
||||
base := []struct{ code, name, source, sourceCatId string }{
|
||||
{"beauty", "丽人", consts.CpsSourceMeituanOta, ""},
|
||||
{"clothing", "服装", consts.CpsSourceMeituanOta, ""},
|
||||
{"food", "餐厅", consts.CpsSourceMeituanOta, ""},
|
||||
{"hotel", "酒店", consts.CpsSourceMeituanOta, ""},
|
||||
{"ticket", "票务", consts.CpsSourceMeituanOta, ""},
|
||||
{"digital", "数码", consts.CpsSourceJdEcom, ""},
|
||||
}
|
||||
for i, c := range base {
|
||||
if _, err := dbCps().Exec(ctx,
|
||||
"INSERT OR IGNORE INTO "+consts.TableNameCpsCategory+
|
||||
" (code, name, parent_code, source, source_cat_id, sort) VALUES (?, ?, '', ?, ?, ?)",
|
||||
c.code, c.name, c.source, c.sourceCatId, i); err != nil {
|
||||
g.Log().Warningf(ctx, "seed cps_category %s failed: %v", c.code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *cpsCategoryDao) List(ctx context.Context) ([]*entity.CpsCategory, error) {
|
||||
var list []*entity.CpsCategory
|
||||
err := dbCps().Model(consts.TableNameCpsCategory).Ctx(ctx).
|
||||
OrderAsc("sort").OrderAsc("id").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var CpsClickLog = &cpsClickLogDao{}
|
||||
|
||||
type cpsClickLogDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsClickLog+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
outer_id TEXT NOT NULL DEFAULT '',
|
||||
scene TEXT NOT NULL DEFAULT '',
|
||||
plan_id INTEGER NOT NULL DEFAULT 0,
|
||||
category_code TEXT NOT NULL DEFAULT '',
|
||||
deeplink TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_click_log table failed: %v", err)
|
||||
}
|
||||
_, err = dbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_click_user ON "+
|
||||
consts.TableNameCpsClickLog+"(user_id, created_at)")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create cps_click_log index failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *cpsClickLogDao) Insert(ctx context.Context, log *entity.CpsClickLog) (int64, error) {
|
||||
r, err := dbCps().Exec(ctx, "INSERT INTO "+consts.TableNameCpsClickLog+
|
||||
" (user_id, source, outer_id, scene, plan_id, category_code, deeplink, ip, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
|
||||
log.UserId, log.Source, log.OuterId, log.Scene, log.PlanId,
|
||||
log.CategoryCode, log.Deeplink, log.Ip)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *cpsClickLogDao) ListByUser(ctx context.Context, userId int64, limit int) ([]*entity.CpsClickLog, error) {
|
||||
var list []*entity.CpsClickLog
|
||||
err := dbCps().Model(consts.TableNameCpsClickLog).Ctx(ctx).
|
||||
Where("user_id", userId).OrderDesc("id").Limit(limit).Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// 数据库组归属:DAO 按业务域拆分到独立 SQLite 文件,经所属组访问
|
||||
func dbPlan() gdb.DB { return g.DB("plan") }
|
||||
func dbPay() gdb.DB { return g.DB("pay") }
|
||||
func dbCps() gdb.DB { return g.DB("cps") }
|
||||
@@ -0,0 +1,97 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var HairstyleAsset = &hairstyleAssetDao{}
|
||||
|
||||
type hairstyleAssetDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameHairstyleAsset+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
style_tag TEXT NOT NULL DEFAULT '',
|
||||
glb_url TEXT NOT NULL DEFAULT '',
|
||||
thumb_url TEXT NOT NULL DEFAULT '',
|
||||
applicable_face TEXT NOT NULL DEFAULT 'all',
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create hairstyle_asset table failed: %v", err)
|
||||
}
|
||||
seedHairstyles(ctx)
|
||||
}
|
||||
|
||||
func seedHairstyles(ctx context.Context) {
|
||||
r, err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Count()
|
||||
if err != nil || r > 0 {
|
||||
return
|
||||
}
|
||||
items := []struct {
|
||||
name, tag, face string
|
||||
sort int
|
||||
}{
|
||||
{"清爽短发", "清爽", "all", 1},
|
||||
{"中分微卷", "温婉", "all", 2},
|
||||
{"披肩长发", "优雅", "all", 3},
|
||||
{"自然直发", "简约", "all", 4},
|
||||
{"利落寸头", "干练", "all", 5},
|
||||
{"高马尾", "活力", "all", 6},
|
||||
{"丸子头", "可爱", "all", 7},
|
||||
{"波浪卷发", "浪漫", "all", 8},
|
||||
}
|
||||
// 批量 multi-row INSERT(种子数据一次性写入)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("INSERT INTO " + consts.TableNameHairstyleAsset +
|
||||
" (name, style_tag, glb_url, thumb_url, applicable_face, sort, created_at) VALUES ")
|
||||
args := make([]any, 0, len(items)*6)
|
||||
for i, it := range items {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("(?,?,?,?,?,?,datetime('now','localtime'))")
|
||||
args = append(args, it.name, it.tag, "/workspace/templates/hairstyle_"+itoa(i+1)+".glb",
|
||||
"/workspace/templates/hairstyle_thumb_"+itoa(i+1)+".png", it.face, it.sort)
|
||||
}
|
||||
if _, err := dbPlan().Exec(ctx, sb.String(), args...); err != nil {
|
||||
g.Log().Warningf(ctx, "seed hairstyle_asset failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [8]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
func (d *hairstyleAssetDao) ListAll(ctx context.Context) ([]*entity.HairstyleAsset, error) {
|
||||
var list []*entity.HairstyleAsset
|
||||
err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).OrderAsc("sort").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (d *hairstyleAssetDao) GetOne(ctx context.Context, id int64) (*entity.HairstyleAsset, error) {
|
||||
var h entity.HairstyleAsset
|
||||
err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Where("id", id).Scan(&h)
|
||||
if err != nil || h.Id == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var MemberPlan = &memberPlanDao{}
|
||||
|
||||
type memberPlanDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMemberPlan+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
price_fen INTEGER NOT NULL DEFAULT 0,
|
||||
duration_days INTEGER NOT NULL DEFAULT 30,
|
||||
features TEXT NOT NULL DEFAULT '[]',
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create member_plan table failed: %v", err)
|
||||
}
|
||||
seedMemberPlans(ctx)
|
||||
}
|
||||
|
||||
func seedMemberPlans(ctx context.Context) {
|
||||
r, err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).Count()
|
||||
if err != nil || r > 0 {
|
||||
return
|
||||
}
|
||||
plans := []struct {
|
||||
name string
|
||||
price int
|
||||
days int
|
||||
features string
|
||||
sort int
|
||||
}{
|
||||
{"月卡 ¥29.9", 2990, 30, `["effect_unlimited","cps_commission_x15"]`, 1},
|
||||
{"年卡 ¥199", 19900, 365, `["effect_unlimited","ai_priority","cps_commission_x15","store_discount"]`, 2},
|
||||
}
|
||||
for _, p := range plans {
|
||||
if _, err := dbPay().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNameMemberPlan+" (name, price_fen, duration_days, features, sort, status, created_at) VALUES (?, ?, ?, ?, ?, 1, datetime('now','localtime'))",
|
||||
p.name, p.price, p.days, p.features, p.sort); err != nil {
|
||||
g.Log().Warningf(ctx, "seed member_plan %s failed: %v", p.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *memberPlanDao) ListEnabled(ctx context.Context) ([]*entity.MemberPlan, error) {
|
||||
var list []*entity.MemberPlan
|
||||
err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).
|
||||
Where("status", 1).OrderAsc("sort").OrderAsc("id").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetOneTx 事务版本:支付回调事务内读取套餐配置
|
||||
func (d *memberPlanDao) GetOneTx(ctx context.Context, tx gdb.TX, id int64) (*entity.MemberPlan, error) {
|
||||
var p *entity.MemberPlan
|
||||
err := tx.Model(consts.TableNameMemberPlan).Ctx(ctx).
|
||||
Where("id", id).Where("status", 1).Scan(&p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (d *memberPlanDao) GetOne(ctx context.Context, id int64) (*entity.MemberPlan, error) {
|
||||
var p *entity.MemberPlan
|
||||
err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).
|
||||
Where("id", id).Where("status", 1).Scan(&p)
|
||||
return p, err
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var OutfitGenTask = &outfitGenTaskDao{}
|
||||
|
||||
type outfitGenTaskDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitGenTask+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
start_date TEXT NOT NULL DEFAULT '',
|
||||
end_date TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
weather_snapshot TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime')),
|
||||
updated_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create outfit_generation_task table failed: %v", err)
|
||||
}
|
||||
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_gen_task_user ON "+consts.TableNameOutfitGenTask+"(user_id, created_at)"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_slogan_gen_task_user failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *outfitGenTaskDao) Insert(ctx context.Context, data *entity.OutfitGenerationTask) (int64, error) {
|
||||
r, err := dbPlan().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNameOutfitGenTask+" (user_id, start_date, end_date, location, weather_snapshot, status, error, model_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
|
||||
data.UserId, data.StartDate, data.EndDate, data.Location, data.WeatherSnapshot, data.Status, data.Error, data.ModelName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *outfitGenTaskDao) GetOne(ctx context.Context, id, userId int64) (*entity.OutfitGenerationTask, error) {
|
||||
var t entity.OutfitGenerationTask
|
||||
err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).
|
||||
Where("id", id).Where("user_id", userId).Scan(&t)
|
||||
if err != nil || t.Id == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (d *outfitGenTaskDao) Update(ctx context.Context, id int64, data g.Map) error {
|
||||
_, err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).
|
||||
Data(data).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *outfitGenTaskDao) UpdateStatus(ctx context.Context, id int64, status, errMsg string) error {
|
||||
_, err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{
|
||||
"status": status, "error": errMsg, "updated_at": "datetime('now','localtime')",
|
||||
}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateStatusTx 事务版本:方案落库事务内同步任务状态
|
||||
func (d *outfitGenTaskDao) UpdateStatusTx(ctx context.Context, tx gdb.TX, id int64, status, errMsg string) error {
|
||||
_, err := tx.Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{
|
||||
"status": status, "error": errMsg, "updated_at": "datetime('now','localtime')",
|
||||
}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// ListUnfinished 返回未完成的任务(重启恢复用)
|
||||
func (d *outfitGenTaskDao) ListUnfinished(ctx context.Context) ([]*entity.OutfitGenerationTask, error) {
|
||||
var list []*entity.OutfitGenerationTask
|
||||
err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).
|
||||
Where("status NOT IN (?)", g.Slice{consts.TaskStatusDone, consts.TaskStatusFailed}).
|
||||
OrderAsc("id").Limit(50).Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var OutfitPlan = &outfitPlanDao{}
|
||||
|
||||
type outfitPlanDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitPlan+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
date_range TEXT NOT NULL DEFAULT '',
|
||||
location TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'wardrobe',
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
main_flag INTEGER NOT NULL DEFAULT 0,
|
||||
hairstyle_id INTEGER NOT NULL DEFAULT 0,
|
||||
hair_color TEXT NOT NULL DEFAULT '',
|
||||
weather_ref TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create outfit_plan table failed: %v", err)
|
||||
}
|
||||
// 容错迁移:CREATE TABLE IF NOT EXISTS 不给旧库加列,duplicate column 错误可忽略
|
||||
if _, err := dbPlan().Exec(ctx, "ALTER TABLE "+consts.TableNameOutfitPlan+
|
||||
" ADD COLUMN occasion TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
g.Log().Warningf(ctx, "migrate outfit_plan.occasion skipped: %v", err)
|
||||
}
|
||||
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_user ON "+consts.TableNameOutfitPlan+"(user_id, created_at)"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_slogan_plan_user failed: %v", err)
|
||||
}
|
||||
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_task ON "+consts.TableNameOutfitPlan+"(task_id)"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_slogan_plan_task failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) Insert(ctx context.Context, data *entity.OutfitPlan) (int64, error) {
|
||||
r, err := dbPlan().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, occasion, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
|
||||
data.TaskId, data.UserId, data.DateRange, data.Location, data.Title, data.Source,
|
||||
data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef, data.Occasion)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// ===== 事务版本(runGenerateTask / SelectMain 流程使用,保证方案+单品原子落库) =====
|
||||
|
||||
func (d *outfitPlanDao) InsertTx(ctx context.Context, tx gdb.TX, data *entity.OutfitPlan) (int64, error) {
|
||||
r, err := tx.Ctx(ctx).Exec(
|
||||
"INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, occasion, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
|
||||
data.TaskId, data.UserId, data.DateRange, data.Location, data.Title, data.Source,
|
||||
data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef, data.Occasion)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) ClearMainFlagTx(ctx context.Context, tx gdb.TX, taskId int64) error {
|
||||
_, err := tx.Model(consts.TableNameOutfitPlan).Ctx(ctx).
|
||||
Data(g.Map{"main_flag": 0}).Where("task_id", taskId).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) SetMainFlagTx(ctx context.Context, tx gdb.TX, id int64) error {
|
||||
_, err := tx.Model(consts.TableNameOutfitPlan).Ctx(ctx).
|
||||
Data(g.Map{"main_flag": 1}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) ListByUser(ctx context.Context, userId int64) ([]*entity.OutfitPlan, error) {
|
||||
var list []*entity.OutfitPlan
|
||||
err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
|
||||
Where("user_id", userId).OrderDesc("id").Limit(50).Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) ListByTask(ctx context.Context, taskId int64) ([]*entity.OutfitPlan, error) {
|
||||
var list []*entity.OutfitPlan
|
||||
err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
|
||||
Where("task_id", taskId).OrderAsc("id").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) GetOne(ctx context.Context, id, userId int64) (*entity.OutfitPlan, error) {
|
||||
var p entity.OutfitPlan
|
||||
err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
|
||||
Where("id", id).Where("user_id", userId).Scan(&p)
|
||||
if err != nil || p.Id == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) ClearMainFlag(ctx context.Context, taskId int64) error {
|
||||
_, err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
|
||||
Data(g.Map{"main_flag": 0}).Where("task_id", taskId).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *outfitPlanDao) SetMainFlag(ctx context.Context, id int64) error {
|
||||
_, err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx).
|
||||
Data(g.Map{"main_flag": 1}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var PartnerStore = &partnerStoreDao{}
|
||||
|
||||
type partnerStoreDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePartnerStore+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
type INTEGER NOT NULL DEFAULT 1,
|
||||
lat REAL NOT NULL DEFAULT 0,
|
||||
lng REAL NOT NULL DEFAULT 0,
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
commission_policy TEXT NOT NULL DEFAULT '',
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create partner_store table failed: %v", err)
|
||||
}
|
||||
seedStores(ctx)
|
||||
}
|
||||
|
||||
func seedStores(ctx context.Context) {
|
||||
r, err := g.DB().Model(consts.TableNamePartnerStore).Ctx(ctx).Count()
|
||||
if err != nil || r > 0 {
|
||||
return
|
||||
}
|
||||
items := []struct {
|
||||
name, addr, policy string
|
||||
typ int
|
||||
lat, lng float64
|
||||
}{
|
||||
{"焕新造型工作室", "北京市朝阳区望京SOHO T1-1102", "到店核销佣金 8%", 1, 39.9965, 116.4816},
|
||||
{"发型研究所(国贸店)", "北京市朝阳区建国门外大街1号", "到店核销佣金 10%", 1, 39.9087, 116.4575},
|
||||
{"潮服集合店", "北京市朝阳区三里屯太古里19号", "到店核销佣金 6%", 2, 39.9374, 116.4556},
|
||||
{"简约风服装馆", "北京市海淀区中关村大街27号", "到店核销佣金 6%", 2, 39.9822, 116.3171},
|
||||
}
|
||||
for _, it := range items {
|
||||
if _, err := g.DB().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNamePartnerStore+" (name, type, lat, lng, address, commission_policy, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, datetime('now','localtime'))",
|
||||
it.name, it.typ, it.lat, it.lng, it.addr, it.policy); err != nil {
|
||||
g.Log().Warningf(ctx, "seed partner_store %s failed: %v", it.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *partnerStoreDao) List(ctx context.Context, storeType int) ([]*entity.PartnerStore, error) {
|
||||
m := g.DB().Model(consts.TableNamePartnerStore).Ctx(ctx).Where("status", 1)
|
||||
if storeType > 0 {
|
||||
m = m.Where("type", storeType)
|
||||
}
|
||||
var list []*entity.PartnerStore
|
||||
err := m.OrderAsc("id").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var PayNotifyLog = &payNotifyLogDao{}
|
||||
|
||||
type payNotifyLogDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePayNotifyLog+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_no TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
sign TEXT NOT NULL DEFAULT '',
|
||||
remote_ip TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'ok',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create pay_notify_log table failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *payNotifyLogDao) Insert(ctx context.Context, log *entity.PayNotifyLog) error {
|
||||
_, err := dbPay().Model(consts.TableNamePayNotifyLog).Ctx(ctx).Data(g.Map{
|
||||
"order_no": log.OrderNo, "body": log.Body, "sign": log.Sign,
|
||||
"remote_ip": log.RemoteIp, "status": log.Status,
|
||||
}).Insert()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var PaymentOrder = &paymentOrderDao{}
|
||||
|
||||
type paymentOrderDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePaymentOrder+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_no TEXT NOT NULL UNIQUE,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
plan_id INTEGER NOT NULL DEFAULT 0,
|
||||
amount_fen INTEGER NOT NULL DEFAULT 0,
|
||||
channel TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
trade_no TEXT NOT NULL DEFAULT '',
|
||||
notify_raw TEXT NOT NULL DEFAULT '',
|
||||
paid_at DATETIME,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create payment_order table failed: %v", err)
|
||||
}
|
||||
if _, err := dbPay().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_payment_order_user failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *paymentOrderDao) Insert(ctx context.Context, order *entity.PaymentOrder) (int64, error) {
|
||||
r, err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).Data(g.Map{
|
||||
"order_no": order.OrderNo, "user_id": order.UserId, "plan_id": order.PlanId,
|
||||
"amount_fen": order.AmountFen, "channel": order.Channel, "status": order.Status,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *paymentOrderDao) GetByOrderNo(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) {
|
||||
var o *entity.PaymentOrder
|
||||
err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).
|
||||
Where("order_no", orderNo).Scan(&o)
|
||||
return o, err
|
||||
}
|
||||
|
||||
// MarkPaid 状态机 pending→paid(只更新 pending 行,返回是否成功,回调并发安全)
|
||||
func (d *paymentOrderDao) MarkPaid(ctx context.Context, orderNo, tradeNo, notifyRaw string) (bool, error) {
|
||||
r, err := dbPay().Exec(ctx,
|
||||
"UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?",
|
||||
consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := r.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ===== 事务版本(HandlePaidNotify 回调流程使用,保证订单状态与会员开通原子) =====
|
||||
|
||||
func (d *paymentOrderDao) GetByOrderNoTx(ctx context.Context, tx gdb.TX, orderNo string) (*entity.PaymentOrder, error) {
|
||||
var o *entity.PaymentOrder
|
||||
err := tx.Model(consts.TableNamePaymentOrder).Ctx(ctx).Where("order_no", orderNo).Scan(&o)
|
||||
return o, err
|
||||
}
|
||||
|
||||
func (d *paymentOrderDao) MarkPaidTx(ctx context.Context, tx gdb.TX, orderNo, tradeNo, notifyRaw string) (bool, error) {
|
||||
r, err := tx.Ctx(ctx).Exec(
|
||||
"UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?",
|
||||
consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := r.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (d *paymentOrderDao) GetByUser(ctx context.Context, userId int64) ([]*entity.PaymentOrder, error) {
|
||||
var list []*entity.PaymentOrder
|
||||
err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).
|
||||
Where("user_id", userId).OrderDesc("id").Limit(20).Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var PlanEffectImage = &planEffectImageDao{}
|
||||
|
||||
type planEffectImageDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanEffectImage+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INTEGER NOT NULL,
|
||||
angle TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
prompt_snapshot TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime')),
|
||||
updated_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create plan_effect_image table failed: %v", err)
|
||||
}
|
||||
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_effect_plan ON "+consts.TableNamePlanEffectImage+"(plan_id)"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_slogan_effect_plan failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *planEffectImageDao) Insert(ctx context.Context, data *entity.PlanEffectImage) (int64, error) {
|
||||
r, err := dbPlan().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNamePlanEffectImage+" (plan_id, angle, url, status, prompt_snapshot, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
|
||||
data.PlanId, data.Angle, data.Url, data.Status, data.PromptSnapshot)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// InsertBatch 批量插入(单条 multi-row SQL,缓存命中已生成的记录直接落库)
|
||||
func (d *planEffectImageDao) InsertBatch(ctx context.Context, list []*entity.PlanEffectImage) error {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("INSERT INTO " + consts.TableNamePlanEffectImage +
|
||||
" (plan_id, angle, url, status, prompt_snapshot, created_at, updated_at) VALUES ")
|
||||
args := make([]any, 0, len(list)*5)
|
||||
for i, it := range list {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("(?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime'))")
|
||||
args = append(args, it.PlanId, it.Angle, it.Url, it.Status, it.PromptSnapshot)
|
||||
}
|
||||
_, err := dbPlan().Exec(ctx, sb.String(), args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *planEffectImageDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanEffectImage, error) {
|
||||
var list []*entity.PlanEffectImage
|
||||
err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).
|
||||
Where("plan_id", planId).OrderAsc("id").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
// CountByUserToday 统计用户当日已生成的效果图数量(join outfit_plan 拿 user_id)
|
||||
func (d *planEffectImageDao) CountByUserToday(ctx context.Context, userId int64) (int, error) {
|
||||
n, err := dbPlan().Model(consts.TableNamePlanEffectImage+" p").
|
||||
InnerJoin(consts.TableNameOutfitPlan+" o", "p.plan_id = o.id").
|
||||
Ctx(ctx).
|
||||
Where("o.user_id", userId).
|
||||
Where("date(p.created_at) = date('now','localtime')").
|
||||
Where("p.status IN (?)", g.Slice{consts.EffectStatusDone, consts.EffectStatusRendering}).
|
||||
Count()
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *planEffectImageDao) UpdateStatus(ctx context.Context, id int64, status, url string) error {
|
||||
_, err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).Data(g.Map{
|
||||
"status": status, "url": url, "updated_at": "datetime('now','localtime')",
|
||||
}).Where("id", id).Update()
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *planEffectImageDao) DeleteByPlan(ctx context.Context, planId int64) error {
|
||||
_, err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).
|
||||
Unscoped().Where("plan_id", planId).Delete()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var PlanOutfitItem = &planOutfitItemDao{}
|
||||
|
||||
type planOutfitItemDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanOutfitItem+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INTEGER NOT NULL,
|
||||
slot TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'wardrobe',
|
||||
wardrobe_item_id INTEGER NOT NULL DEFAULT 0,
|
||||
product_name TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
desc TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create plan_outfit_item table failed: %v", err)
|
||||
}
|
||||
if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_item ON "+consts.TableNamePlanOutfitItem+"(plan_id)"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_slogan_plan_item failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *planOutfitItemDao) Insert(ctx context.Context, data *entity.PlanOutfitItem) (int64, error) {
|
||||
r, err := dbPlan().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNamePlanOutfitItem+" (plan_id, slot, source, wardrobe_item_id, product_name, name, desc, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
|
||||
data.PlanId, data.Slot, data.Source, data.WardrobeItemId, data.ProductName, data.Name, data.Desc)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
// InsertBatchTx 批量插入所有方案的单品(单条 multi-row SQL,事务内)
|
||||
func (d *planOutfitItemDao) InsertBatchTx(ctx context.Context, tx gdb.TX, items []*entity.PlanOutfitItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("INSERT INTO " + consts.TableNamePlanOutfitItem +
|
||||
" (plan_id, slot, source, wardrobe_item_id, product_name, name, desc, created_at) VALUES ")
|
||||
args := make([]any, 0, len(items)*7)
|
||||
for i, it := range items {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("(?,?,?,?,?,?,?,datetime('now','localtime'))")
|
||||
args = append(args, it.PlanId, it.Slot, it.Source, it.WardrobeItemId, it.ProductName, it.Name, it.Desc)
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Exec(sb.String(), args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *planOutfitItemDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanOutfitItem, error) {
|
||||
var list []*entity.PlanOutfitItem
|
||||
err := dbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx).
|
||||
Where("plan_id", planId).OrderAsc("id").Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (d *planOutfitItemDao) DeleteByPlan(ctx context.Context, planId int64) error {
|
||||
_, err := dbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx).
|
||||
Unscoped().Where("plan_id", planId).Delete()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var PlanReview = &planReviewDao{}
|
||||
|
||||
type planReviewDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanReview+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create plan_review table failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *planReviewDao) Insert(ctx context.Context, data *entity.PlanReview) (int64, error) {
|
||||
r, err := dbPlan().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNamePlanReview+" (plan_id, user_id, action, note, created_at) VALUES (?, ?, ?, ?, datetime('now','localtime'))",
|
||||
data.PlanId, data.UserId, data.Action, data.Note)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *planReviewDao) ListByUserAndPlan(ctx context.Context, userId, planId int64) ([]*entity.PlanReview, error) {
|
||||
var list []*entity.PlanReview
|
||||
err := dbPlan().Model(consts.TableNamePlanReview).Ctx(ctx).
|
||||
Where("user_id", userId).Where("plan_id", planId).OrderDesc("id").Limit(20).Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
var SceneCategoryMap = &sceneCategoryMapDao{}
|
||||
|
||||
type sceneCategoryMapDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSceneCategoryMap+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scene_type TEXT NOT NULL DEFAULT '',
|
||||
occasion TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
category_code TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create scene_category_map table failed: %v", err)
|
||||
}
|
||||
seedSceneCategoryMap(ctx)
|
||||
}
|
||||
|
||||
func seedSceneCategoryMap(ctx context.Context) {
|
||||
seeds := []*entity.SceneCategoryMap{
|
||||
{SceneType: consts.CpsSceneHaircut, Source: consts.CpsSourceMeituanOta, CategoryCode: "beauty"},
|
||||
{SceneType: consts.CpsSceneItemBuy, Source: consts.CpsSourceJdEcom, CategoryCode: "clothing"},
|
||||
{SceneType: consts.CpsSceneItemUpgrade, Source: consts.CpsSourceMeituanOta, CategoryCode: "clothing"},
|
||||
{SceneType: consts.CpsSceneOccasion, Occasion: "通勤", Source: consts.CpsSourceMeituanOta, CategoryCode: "clothing", Priority: 1},
|
||||
{SceneType: consts.CpsSceneOccasion, Occasion: "约会", Source: consts.CpsSourceMeituanOta, CategoryCode: "food", Priority: 1},
|
||||
{SceneType: consts.CpsSceneOccasion, Occasion: "旅行", Source: consts.CpsSourceMeituanOta, CategoryCode: "hotel", Priority: 1},
|
||||
{SceneType: consts.CpsSceneOccasion, Occasion: "运动", Source: consts.CpsSourceMeituanOta, CategoryCode: "ticket", Priority: 1},
|
||||
}
|
||||
for _, s := range seeds {
|
||||
if _, err := dbCps().Exec(ctx,
|
||||
"INSERT OR IGNORE INTO "+consts.TableNameSceneCategoryMap+
|
||||
" (scene_type, occasion, source, category_code, priority) VALUES (?, ?, ?, ?, ?)",
|
||||
s.SceneType, s.Occasion, s.Source, s.CategoryCode, s.Priority); err != nil {
|
||||
g.Log().Warningf(ctx, "seed scene_category_map %s/%s failed: %v", s.SceneType, s.Occasion, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QueryByScene 场景 → 映射列表(occasion 精确匹配优先,通用匹配兜底)
|
||||
func (d *sceneCategoryMapDao) QueryByScene(ctx context.Context, sceneType, occasion string) ([]*entity.SceneCategoryMap, error) {
|
||||
var list []*entity.SceneCategoryMap
|
||||
m := dbCps().Model(consts.TableNameSceneCategoryMap).Ctx(ctx).Where("scene_type", sceneType)
|
||||
if occasion != "" {
|
||||
m = m.Where("occasion", occasion).OrderAsc("priority")
|
||||
} else {
|
||||
m = m.Where("occasion", "")
|
||||
}
|
||||
err := m.Scan(&list)
|
||||
return list, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slogan-agent/common"
|
||||
"slogan-agent/styleagent/consts"
|
||||
"slogan-agent/styleagent/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcache"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var User = &userDao{}
|
||||
|
||||
type userDao struct{}
|
||||
|
||||
func init() {
|
||||
ctx := context.Background()
|
||||
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUser+` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT (datetime('now','localtime')),
|
||||
updated_at DATETIME DEFAULT (datetime('now','localtime'))
|
||||
)`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "create user table failed: %v", err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_username ON "+consts.TableNameUser+"(username) WHERE username != ''"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_slogan_user_username failed: %v", err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_phone ON "+consts.TableNameUser+"(phone) WHERE phone != ''"); err != nil {
|
||||
g.Log().Warningf(ctx, "create index idx_slogan_user_phone failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clearUserCache(ctx context.Context, id int64) {
|
||||
_, _ = gcache.Remove(ctx, "user_GetOne_"+gconv.String(id))
|
||||
_, _ = gcache.Remove(ctx, "user_GetByAccount_")
|
||||
}
|
||||
|
||||
func (d *userDao) Insert(ctx context.Context, data *entity.User) (int64, error) {
|
||||
r, err := g.DB().Exec(ctx,
|
||||
"INSERT INTO "+consts.TableNameUser+" (role, username, phone, password, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
|
||||
data.Role, data.Username, data.Phone, data.Password, data.Name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *userDao) GetOne(ctx context.Context, id int64) (*entity.User, error) {
|
||||
var u entity.User
|
||||
err := g.DB().Model(consts.TableNameUser).Ctx(ctx).
|
||||
Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "user_GetOne_" + gconv.String(id)}).
|
||||
Where("id", id).Scan(&u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (d *userDao) GetByAccount(ctx context.Context, account string) (*entity.User, error) {
|
||||
var u entity.User
|
||||
err := g.DB().Model(consts.TableNameUser).Ctx(ctx).
|
||||
Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "user_GetByAccount_" + account}).
|
||||
Where("username = ? OR phone = ?", account, account).Scan(&u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (d *userDao) Update(ctx context.Context, data *entity.User) error {
|
||||
_, err := g.DB().Model(consts.TableNameUser).Ctx(ctx).Data(data).Where("id", data.Id).Update()
|
||||
clearUserCache(ctx, data.Id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *userDao) UpdateFields(ctx context.Context, id int64, data g.Map) error {
|
||||
_, err := g.DB().Model(consts.TableNameUser).Ctx(ctx).Data(data).Where("id", id).Update()
|
||||
clearUserCache(ctx, id)
|
||||
return err
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user