feat: slogan-agent MVP 服务端完整实现

- 用户域:注册/登录(JWT)/修改密码/个人资料
- 照片/衣橱/身形/化身:上传存储 + 3D 化身模板匹配
- 穿搭生成:天气(高德+和风+缓存) → 规则预筛 → LLM 规划(1次调用)
  → 规则评分(5维100分制) → 全低分触发 LLM 兜底创作 → 异步任务状态机
- 效果图:选主方案后异步生成 3 视角(mock/wanx 供应商 + 内容 hash 缓存 + 每日限额)
- 商业化:合作门店列表(seed 4 家)
- 冒烟:全链路端到端验证通过(mock LLM/天气),23 个 API 端点

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:15:11 +08:00
co-authored by Claude Opus 4.7
commit ef22f7672a
87 changed files with 6164 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# 数据库与运行时产物
slogan.db
*.db
slogan-agent
workspace/
# IDE
.idea/
*.iml
.vscode/
# 系统
.DS_Store
+24
View File
@@ -0,0 +1,24 @@
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 && go mod tidy
RUN go build -ldflags="-s -w" -o main ./main.go
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
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 .
RUN mkdir -p /app/workspace
EXPOSE 3007
ENTRYPOINT ["./main"]
+34
View File
@@ -0,0 +1,34 @@
package common
import (
"errors"
"github.com/golang-jwt/jwt/v5"
)
const jwtSecret = "slogan-agent-jwt-secret-2026"
type JwtClaims struct {
UserId int64 `json:"user_id"`
Role string `json:"role"`
AgentId int64 `json:"agent_id,omitempty"`
jwt.RegisteredClaims
}
func GetJwtSecret() string {
return jwtSecret
}
func ParseToken(tokenStr string) (*JwtClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &JwtClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*JwtClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
+90
View File
@@ -0,0 +1,90 @@
package common
import (
"net/http"
"strings"
"github.com/gogf/gf/v2/net/ghttp"
)
var publicPaths = []string{
"/user/login",
"/user/register",
"/hairstyle/list",
"/api.json",
}
func Auth(r *ghttp.Request) {
path := r.URL.Path
// 公开路径(精确匹配)
for _, p := range publicPaths {
if path == p {
r.Middleware.Next()
return
}
}
// workspace 文件通过前缀匹配放行(浏览器图片请求不带 Authorization
if strings.HasPrefix(path, "/workspace/") {
r.Middleware.Next()
return
}
auth := r.Header.Get("Authorization")
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
r.Response.WriteJson(ghttp.DefaultHandlerResponse{
Code: http.StatusUnauthorized,
Message: "未登录或登录已过期",
})
r.Exit()
return
}
claims, err := ParseToken(auth[7:])
if err != nil {
r.Response.WriteJson(ghttp.DefaultHandlerResponse{
Code: http.StatusUnauthorized,
Message: "登录已过期,请重新登录",
})
r.Exit()
return
}
r.SetCtxVar("userId", claims.UserId)
r.SetCtxVar("role", claims.Role)
r.SetCtxVar("agentId", claims.AgentId)
r.Middleware.Next()
}
func GetUserId(r *ghttp.Request) int64 {
v := r.GetCtxVar("userId")
if v == nil {
return 0
}
return v.Int64()
}
func GetRole(r *ghttp.Request) string {
v := r.GetCtxVar("role")
if v == nil {
return ""
}
return v.String()
}
func GetAgentId(r *ghttp.Request) int64 {
v := r.GetCtxVar("agentId")
if v == nil {
return 0
}
return v.Int64()
}
func CheckAdmin(r *ghttp.Request) bool {
return GetRole(r) == "admin"
}
func CheckAgent(r *ghttp.Request) bool {
return GetRole(r) == "agent"
}
+55
View File
@@ -0,0 +1,55 @@
package common
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
func prepareInsertData(data any) map[string]any {
m := gconv.Map(data, gconv.MapOption{Tags: []string{"orm"}})
delete(m, "id")
m["created_at"] = gtime.Now().Format("Y-m-d H:i:s")
m["updated_at"] = gtime.Now().Format("Y-m-d H:i:s")
delete(m, "deleted_at")
return m
}
func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, err error) {
m := prepareInsertData(data)
r, err := g.DB().Model(table).Ctx(ctx).Data(m).Insert()
if err != nil {
return 0, err
}
if r == nil {
return 0, nil
}
return r.LastInsertId()
}
func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err error) {
r, err := g.DB().Model(table).Ctx(ctx).
Cache(gdb.CacheOption{Duration: CacheTTL(), Name: table + "_GetOneByPk_" + gconv.String(pk)}).
Where("id", pk).One()
if err != nil {
return nil, err
}
if r == nil {
return nil, nil
}
err = r.Struct(&res)
return
}
func UpdateByPk(ctx context.Context, table string, pk int64, data any) error {
_, err := g.DB().Model(table).Ctx(ctx).Data(data).Where("id", pk).Update()
return err
}
func DeleteByPk(ctx context.Context, table string, pk int64) error {
_, err := g.DB().Model(table).Ctx(ctx).Unscoped().Where("id", pk).Delete()
return err
}
+22
View File
@@ -0,0 +1,22 @@
package common
import (
"context"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
)
var (
cacheTTL time.Duration
cacheTTLOnce sync.Once
)
// CacheTTL returns the database query cache TTL from config
func CacheTTL() time.Duration {
cacheTTLOnce.Do(func() {
cacheTTL = time.Duration(g.Cfg().MustGet(context.Background(), "database.cache.ttl", 60).Int()) * time.Second
})
return cacheTTL
}
+50
View File
@@ -0,0 +1,50 @@
package common
import (
"fmt"
"reflect"
"regexp"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
var Httpserver = g.Server()
func init() {
err := gtime.SetTimeZone("Asia/Shanghai")
if err != nil {
panic("设置时区失败")
}
Httpserver.SetOpenApiPath("/api.json")
// 全局 panic 恢复(最先注册,作为最外层包裹)
Httpserver.BindMiddlewareDefault(ghttp.MiddlewareHandlerResponse)
// CORS - allow all origins
Httpserver.BindMiddlewareDefault(func(r *ghttp.Request) {
r.Response.CORS(r.Response.DefaultCORSOptions())
r.Middleware.Next()
})
// JWT 鉴权
Httpserver.BindMiddlewareDefault(Auth)
}
// RouteRegister 根据控制器结构体名称自动注册路由
func RouteRegister(controllers []interface{}) {
re := regexp.MustCompile("[A-Z]")
for _, t := range controllers {
sName := reflect.ValueOf(t).Elem().Type().Name()
convertedStr := re.ReplaceAllStringFunc(sName, func(s string) string {
return fmt.Sprintf("-%s", strings.ToLower(s))
})
convertedStr = strings.ReplaceAll(convertedStr, "_", "-")
if len(convertedStr) > 0 && convertedStr[0] == '-' {
convertedStr = convertedStr[1:]
}
Httpserver.Group("/"+convertedStr, func(group *ghttp.RouterGroup) {
group.Bind(t)
})
}
go Httpserver.Run()
}
+249
View File
@@ -0,0 +1,249 @@
package common
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
)
// ImageFileToBase64 reads an image file and returns a data:image/...;base64 string.
func ImageFileToBase64(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
ext := strings.ToLower(pathExt(path))
mime := "image/png"
switch ext {
case ".jpg", ".jpeg":
mime = "image/jpeg"
case ".gif":
mime = "image/gif"
case ".webp":
mime = "image/webp"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
// pathExt extracts the extension from a path.
func pathExt(path string) string {
for i := len(path) - 1; i >= 0 && path[i] != '/'; i-- {
if path[i] == '.' {
return path[i:]
}
}
return ""
}
// BuildSchemaRequest validates input values against a JSON schema definition,
// fills in default values for missing optional fields,
// and returns the result matching the schema's nested structure.
//
// The schema format follows test.json convention:
//
// {
// "section": {
// "field_name": {
// "type": "string|integer|number|boolean|array|object",
// "required": true|false,
// "default": value,
// "enum": [...],
// "min": number,
// "max": number,
// "max_chars": number,
// "min_items": number,
// "max_items": number
// }
// }
// }
//
// input is a flat map like {"prompt": "hello", "duration": 5}.
// Fields not present in input but with a "default" in the schema are filled automatically.
// Nodes without "type" are treated as grouping sections and recursed into.
// When validate is false, required/type/range/enum checks are skipped (only structure + defaults).
func BuildSchemaRequest(schema map[string]any, input map[string]any, validate bool) (map[string]any, error) {
result := make(map[string]any)
for key, val := range schema {
fieldDef, ok := val.(map[string]any)
if !ok {
result[key] = val
continue
}
if _, hasType := fieldDef["type"]; hasType {
processed, err := processField(key, fieldDef, input, validate)
if err != nil {
return nil, err
}
if processed != nil {
result[key] = processed
}
continue
}
nested, err := BuildSchemaRequest(fieldDef, input, validate)
if err != nil {
return nil, err
}
if len(nested) > 0 {
result[key] = nested
}
}
return result, nil
}
func processField(name string, def map[string]any, input map[string]any, validate bool) (any, error) {
fieldType, _ := def["type"].(string)
required, _ := def["required"].(bool)
rawVal, exists := input[name]
if !exists {
if validate && required {
return nil, fmt.Errorf("%s", def["description"])
}
if dflt, ok := def["default"]; ok {
return convertDefault(dflt, fieldType), nil
}
return nil, nil
}
if !validate {
return rawVal, nil
}
switch fieldType {
case "string":
s, ok := rawVal.(string)
if !ok {
return nil, fmt.Errorf("'%s' must be a string", name)
}
if maxChars, ok := def["max_chars"].(float64); ok && len([]rune(s)) > int(maxChars) {
return nil, fmt.Errorf("'%s' exceeds max length of %d", name, int(maxChars))
}
if enum, ok := def["enum"].([]any); ok && len(enum) > 0 {
if !containsValue(enum, s) {
return nil, fmt.Errorf("'%s' must be one of %v", name, enum)
}
}
return s, nil
case "integer":
v, err := toInt(rawVal)
if err != nil {
return nil, fmt.Errorf("'%s' must be an integer", name)
}
if minVal, ok := def["min"].(float64); ok && v < int(minVal) {
return nil, fmt.Errorf("'%s' must be >= %d", name, int(minVal))
}
if maxVal, ok := def["max"].(float64); ok && v > int(maxVal) {
return nil, fmt.Errorf("'%s' must be <= %d", name, int(maxVal))
}
return v, nil
case "number":
v, ok := rawVal.(float64)
if !ok {
if iv, err := toInt(rawVal); err == nil {
v = float64(iv)
} else {
return nil, fmt.Errorf("'%s' must be a number", name)
}
}
if minVal, ok := def["min"].(float64); ok && v < minVal {
return nil, fmt.Errorf("'%s' must be >= %v", name, minVal)
}
if maxVal, ok := def["max"].(float64); ok && v > maxVal {
return nil, fmt.Errorf("'%s' must be <= %v", name, maxVal)
}
return v, nil
case "boolean":
_, ok := rawVal.(bool)
if !ok {
return nil, fmt.Errorf("'%s' must be a boolean", name)
}
return rawVal, nil
case "array":
arr, ok := rawVal.([]any)
if !ok {
return nil, fmt.Errorf("'%s' must be an array", name)
}
if minItems, ok := def["min_items"].(float64); ok && len(arr) < int(minItems) {
return nil, fmt.Errorf("'%s' must have at least %d items", name, int(minItems))
}
if maxItems, ok := def["max_items"].(float64); ok && len(arr) > int(maxItems) {
return nil, fmt.Errorf("'%s' must have at most %d items", name, int(maxItems))
}
if itemsDef, ok := def["items"].(map[string]any); ok {
items, err := processArrayItems(arr, itemsDef, validate)
if err != nil {
return nil, fmt.Errorf("'%s': %w", name, err)
}
return items, nil
}
return arr, nil
}
return rawVal, nil
}
func processArrayItems(arr []any, itemsDef map[string]any, validate bool) ([]any, error) {
itemType, _ := itemsDef["type"].(string)
if itemType != "object" {
return arr, nil
}
props, _ := itemsDef["properties"].(map[string]any)
if props == nil {
return arr, nil
}
result := make([]any, len(arr))
for i, item := range arr {
itemMap, ok := item.(map[string]any)
if !ok {
result[i] = item
continue
}
processed, err := BuildSchemaRequest(props, itemMap, validate)
if err != nil {
return nil, fmt.Errorf("item[%d]: %w", i, err)
}
result[i] = processed
}
return result, nil
}
func toInt(v any) (int, error) {
switch val := v.(type) {
case float64:
return int(val), nil
case int:
return val, nil
case int64:
return int(val), nil
case json.Number:
n, err := val.Int64()
return int(n), err
default:
return 0, fmt.Errorf("cannot convert %T to int", v)
}
}
func convertDefault(dflt any, fieldType string) any {
if fieldType == "integer" {
if f, ok := dflt.(float64); ok {
return int(f)
}
}
return dflt
}
func containsValue(arr []any, val any) bool {
for _, v := range arr {
if v == val {
return true
}
}
return false
}
+40
View File
@@ -0,0 +1,40 @@
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
# 和风天气 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: "mock" # mock | wanx
wanx_api_key: ""
wanx_model: "wanx-v2"
# 大模型配置(OpenAI 兼容,如通义/DeepSeek/Kimi
llm:
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
api_key: ""
model_name: "qwen-plus"
max_tokens: 4096
temperature: 0.8
+1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,933 @@
# slogan-agent MVP 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现 slogan-agent 服务端 MVP:登录 → 照片/衣橱/身形管理 → 化身构建(v1 模板匹配)→ 穿搭生成(规则评分 + Agent 规划 + 兜底)→ 效果图按需生成,全链路可运行。
**Architecture:** Go 单体 + GoFrame v2 + SQLite,严格遵循 video-factory 分层规范(controller → service → dao),包级单例,RouteRegister 反射注册路由,JWT 鉴权,OpenAI 兼容 LLM。规则评分零 LLM 成本,效果图按需生成 + 缓存。
**Tech Stack:** Go 1.22+ / GoFrame v2.10 / SQLite / JWT / bcrypt / OpenAI 兼容 API / 和风天气 API
**参考代码(必须阅读后复制模式):**
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/`http.go / auth.go / base_dao.go / cache.go / auth_middleware.go / util.go
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/agent/chat_model.go`(直接复用整个文件,改包名)
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/`controller/service/dao/model 全部模式)
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/main.go`(入口模式)
**数据库:** `slogan.db`config.yml 配置),表名前缀 `slogan_`。所有 init() 自动建表 + ALTER 迁移兼容。
**模块路径:** `slogan-agent`go.mod module name),业务包 `styleagent`
---
## 数据库表总览(Task 2-3 建全)
| 表 | 关键字段 |
|----|---------|
| `slogan_user` | id, role(default 'user'), username, phone, password, name, created_at, updated_at |
| `slogan_user_photo` | id, user_id, type(1大头照 2全身正面 3全身侧面 4全身背面), url, status, created_at |
| `slogan_wardrobe_item` | id, user_id, photo_url, category(上衣/下装/鞋/配饰), season(春/夏/秋/冬/四季), style_tags, color_info, status, created_at |
| `slogan_body_measurement` | id, user_id, height, weight, skin_tone(1-5), fit_params(JSON), updated_at |
| `slogan_avatar_model` | id, user_id, face_template_id, body_template_id, skin_tone_index, face_texture_url, glb_url, build_status(pending/processing/done/failed), error, params_snapshot(JSON), created_at |
| `slogan_hairstyle_asset` | id, name, style_tag, glb_url, thumb_url, applicable_face, sort |
| `slogan_outfit_generation_task` | id, user_id, start_date, end_date, location, weather_snapshot(JSON), status(pending/planning/scoring/rendering/done/failed), error, model_name, created_at |
| `slogan_outfit_plan` | id, task_id, user_id, date_range, location, source(wardrobe/recommend), score, main_flag(0/1), hairstyle_id, hair_color, weather_ref(JSON), created_at |
| `slogan_plan_outfit_item` | id, plan_id, slot(发型/上衣/下装/鞋/配饰), source, wardrobe_item_id, product_name, name, desc |
| `slogan_plan_effect_image` | id, plan_id, angle(正面/侧面/背面), url, status, prompt_snapshot, created_at |
| `slogan_plan_review` | id, plan_id, user_id, action(fav/unfav), note, created_at |
| `slogan_scoring_rule` | id, dimension, rule_type, rules_json, enabled, version |
---
### Task 1: 项目骨架(go.mod / config / main.go / common 复制)
**Files:**
- Create: `go.mod`
- Create: `config.yml`
- Create: `main.go`
- Copy: `common/http.go`, `common/auth.go`, `common/base_dao.go`, `common/cache.go`, `common/util.go`, `common/auth_middleware.go`(从 video-factory 复制,改 package 注释即可,无需改逻辑)
- [ ] **Step 1: 创建 go.mod**
```bash
cd slogan-agent
go mod init slogan-agent
```
- [ ] **Step 2: 创建 config.yml**
```yaml
database:
default:
name: slogan.db
type: sqlite
debug: false
cache:
ttl: 60
server:
address: :3007
name: slogan
workerId: 1
clientMaxBodySize: 209715200
requestTimeout: 3000
chat:
timeout: 300
max_retries: 3
```
- [ ] **Step 3: 复制 common 包**
```bash
cp /Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/{http.go,auth.go,base_dao.go,cache.go,util.go,auth_middleware.go} common/
```
注意:auth_middleware.go 和 cache.go / util.go 需检查是否有对 video-factory 特定包的 import,如有则调整。auth.go 中 jwtSecret 改为 slogan 自己的密钥。
- [ ] **Step 4: 创建 main.go**(模式同 video-factory main.go,路由表注册 controllerworkspace 鉴权静态服务,端口 3007)
- [ ] **Step 5: 添加依赖并编译**
```bash
go mod tidy
go build ./...
```
Expected: 编译通过(common 包复制可能依赖 gtime/gcachetidy 解决)。
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: slogan-agent skeleton with common package"
```
---
### Task 2: consts 与全部 entity
**Files:**
- Create: `styleagent/consts/table_name.go`(全部表名常量)
- Create: `styleagent/consts/status.go`(任务状态/照片类型/方案来源常量)
- Create: `styleagent/model/entity/`12 个文件:user.go, user_photo.go, wardrobe_item.go, body_measurement.go, avatar_model.go, hairstyle_asset.go, outfit_generation_task.go, outfit_plan.go, plan_outfit_item.go, plan_effect_image.go, plan_review.go, scoring_rule.go
- [ ] **Step 1: consts/table_name.go**
```go
package consts
const (
TableNameUser = "slogan_user"
TableNameUserPhoto = "slogan_user_photo"
TableNameWardrobeItem = "slogan_wardrobe_item"
TableNameBodyMeasurement = "slogan_body_measurement"
TableNameAvatarModel = "slogan_avatar_model"
TableNameHairstyleAsset = "slogan_hairstyle_asset"
TableNameOutfitGenTask = "slogan_outfit_generation_task"
TableNameOutfitPlan = "slogan_outfit_plan"
TableNamePlanOutfitItem = "slogan_plan_outfit_item"
TableNamePlanEffectImage = "slogan_plan_effect_image"
TableNamePlanReview = "slogan_plan_review"
TableNameScoringRule = "slogan_scoring_rule"
)
```
- [ ] **Step 2: consts/status.go**
```go
package consts
// 照片类型
const (
PhotoTypeHeadshot = 1 // 大头照
PhotoTypeFullFront = 2 // 全身正面
PhotoTypeFullSide = 3 // 全身侧面
PhotoTypeFullBack = 4 // 全身背面
)
// 生成任务状态
const (
TaskStatusPending = "pending"
TaskStatusPlanning = "planning"
TaskStatusScoring = "scoring"
TaskStatusRendering = "rendering"
TaskStatusDone = "done"
TaskStatusFailed = "failed"
)
// 方案来源
const (
PlanSourceWardrobe = "wardrobe"
PlanSourceRecommend = "recommend"
)
// 化身构建状态
const (
AvatarBuildPending = "pending"
AvatarBuildProcessing = "processing"
AvatarBuildDone = "done"
AvatarBuildFailed = "failed"
)
// 评分阈值(可被 scoring_rule 配置覆盖)
const DefaultScoreThreshold = 75
```
- [ ] **Step 3: entity 文件**orm tag 模式同 video-factory entity/user.go;全部含 CreatedAt/UpdatedAt `*gtime.Time`;字段完全对齐 Task 表格总览)
- [ ] **Step 4: 编译检查** `go build ./...`
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: consts and entities"`
---
### Task 3: 全部 DAOinit 自动建表)
**Files:**
- Create: `styleagent/dao/user_dao.go`(完整示例,含建表 + CRUD + 缓存)
- Create: 其余 11 个 dao 文件(user_photo / wardrobe_item / body_measurement / avatar_model / hairstyle_asset / outfit_generation_task / outfit_plan / plan_outfit_item / plan_effect_image / plan_review / scoring_rule
- [ ] **Step 1: user_dao.go**(模式:video-factory dao/user_dao.go
```go
package dao
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
)
var User = &userDao{}
type userDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUser+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL DEFAULT 'user',
username TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create user table failed: %v", err)
}
_, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_username ON "+consts.TableNameUser+"(username) WHERE username != ''")
_, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_phone ON "+consts.TableNameUser+"(phone) WHERE phone != ''")
}
// 方法:Insert / GetOne / GetByAccount / Update / UpdateFields(复制 video-factory user_dao 对应方法,表名换 consts.TableNameUser
```
- [ ] **Step 2: 其余 11 个 dao**:每个含 init() 建表 + 核心查询方法(按字段):ListByUseruser_photo/wardrobe_item 按 user_id 分页)、GetByUserAndType、GetByUseravatar/body 单行)、ListByPlanplan_outfit_item/plan_effect_image)、GetByTaskoutfit_plan 列表)、ListAllhairstyle_asset 按 sort)、GetEnabledscoring_rule)、UpdateStatustask 状态流转)
- [ ] **Step 3: 建表自检**(先写 dao 测试或直接启动临时 main 验证)
```bash
go build ./... && go run main.go 2>&1 | head -5
# 验证 slogan.db 生成且无建表错误日志
```
- [ ] **Step 4: Commit** `git add -A && git commit -m "feat: dao layer with auto table creation"`
---
### Task 4: 全部 DTO(请求/响应 + g.Meta 路由)
**Files:**
- Create: `styleagent/model/dto/user_dto.go`LoginReq/LoginRes/ProfileRes
- Create: `styleagent/model/dto/user_photo_dto.go`
- Create: `styleagent/model/dto/wardrobe_dto.go`
- Create: `styleagent/model/dto/body_measurement_dto.go`
- Create: `styleagent/model/dto/avatar_dto.go`
- Create: `styleagent/model/dto/hairstyle_dto.go`
- Create: `styleagent/model/dto/outfit_dto.go`
- [ ] **Step 1: 关键 dto 内容**
```go
// user_dto.go
type LoginReq struct {
g.Meta `path:"/login" method:"post" tags:"用户" summary:"登录"`
Account string `v:"required" json:"account"`
Password string `v:"required" json:"password"`
}
type LoginRes struct {
Token string `json:"token"`
User *LoginUser `json:"user"`
}
type LoginUser struct {
Id int64 `json:"id"`
Role string `json:"role"`
Name string `json:"name"`
}
// user_photo_dto.go
type UserPhotoUploadReq struct {
g.Meta `path:"/upload" method:"post" tags:"照片" summary:"上传照片"`
Type int `v:"required|in:1,2,3,4" json:"type"`
// 文件字段:GoFrame 自动绑定 upload 文件(r.GetUploadFile
}
type UserPhotoUploadRes struct { Id int64 `json:"id"` }
type UserPhotoListReq struct {
g.Meta `path:"/list" method:"get" tags:"照片" summary:"照片列表"`
Type int `json:"type"` // 可空
}
type UserPhotoListRes struct {
List []*entity.UserPhoto `json:"list"`
}
type UserPhotoDeleteReq struct {
g.Meta `path:"/delete" method:"post" tags:"照片" summary:"删除照片"`
Id int64 `v:"required" json:"id"`
}
// wardrobe_dto.go
type WardrobeUploadReq struct {
g.Meta `path:"/upload" method:"post" tags:"衣橱" summary:"上传服装"`
Category string `v:"required" json:"category"`
Season string `json:"season"`
StyleTags string `json:"style_tags"`
ColorInfo string `json:"color_info"`
// 文件字段同上
}
type WardrobeListReq struct {
g.Meta `path:"/list" method:"get" tags:"衣橱" summary:"衣橱列表"`
Category string `json:"category"`
}
type WardrobeListRes struct { List []*entity.WardrobeItem `json:"list"` }
type WardrobeUpdateReq struct {
g.Meta `path:"/update" method:"post" tags:"衣橱" summary:"更新服装"`
Id int64 `v:"required" json:"id"`
Category string `json:"category"`
Season string `json:"season"`
StyleTags string `json:"style_tags"`
}
type WardrobeDeleteReq struct {
g.Meta `path:"/delete" method:"post" tags:"衣橱" summary:"删除服装"`
Id int64 `v:"required" json:"id"`
}
// body_measurement_dto.go
type BodyMeasurementSaveReq struct {
g.Meta `path:"/save" method:"post" tags:"身形" summary:"保存身形参数"`
Height int `json:"height"`
Weight int `json:"weight"`
SkinTone int `v:"in:1,2,3,4,5" json:"skin_tone"`
FitParams string `json:"fit_params"`
}
type BodyMeasurementGetRes struct {
Height int `json:"height"`
Weight int `json:"weight"`
SkinTone int `json:"skin_tone"`
FitParams string `json:"fit_params"`
}
// avatar_dto.go
type AvatarBuildReq struct {
g.Meta `path:"/build" method:"post" tags:"化身" summary:"构建化身"`
}
type AvatarBuildRes struct { TaskId int64 `json:"task_id"` }
type AvatarGetRes struct {
FaceTemplateId int `json:"face_template_id"`
BodyTemplateId int `json:"body_template_id"`
SkinToneIndex int `json:"skin_tone_index"`
GlbUrl string `json:"glb_url"`
BuildStatus string `json:"build_status"`
}
// hairstyle_dto.go
type HairstyleListRes struct { List []*entity.HairstyleAsset `json:"list"` }
// outfit_dto.go
type OutfitGenerateReq struct {
g.Meta `path:"/generate" method:"post" tags:"穿搭" summary:"生成穿搭方案"`
StartDate string `v:"required|date" json:"start_date"`
EndDate string `v:"required|date" json:"end_date"`
Location string `v:"required" json:"location"`
}
type OutfitGenerateRes struct { TaskId int64 `json:"task_id"` }
type OutfitTaskStatusReq struct {
g.Meta `path:"/task/status" method:"get" tags:"穿搭" summary:"任务状态"`
TaskId int64 `v:"required" json:"task_id"`
}
type OutfitTaskStatusRes struct {
Status string `json:"status"`
Error string `json:"error"`
}
type OutfitPlanListReq struct {
g.Meta `path:"/plan/list" method:"get" tags:"穿搭" summary:"方案列表"`
}
type OutfitPlanListRes struct { List []*entity.OutfitPlan `json:"list"` }
type OutfitPlanDetailReq struct {
g.Meta `path:"/plan/detail" method:"get" tags:"穿搭" summary:"方案详情"`
PlanId int64 `v:"required" json:"plan_id"`
}
type OutfitPlanDetailRes struct {
Plan *entity.OutfitPlan `json:"plan"`
Items []*entity.PlanOutfitItem `json:"items"`
Images []*entity.PlanEffectImage `json:"images"`
Hairstyle *entity.HairstyleAsset `json:"hairstyle,omitempty"`
}
type OutfitSelectMainReq struct {
g.Meta `path:"/plan/select-main" method:"post" tags:"穿搭" summary:"选定主方案"`
PlanId int64 `v:"required" json:"plan_id"`
}
type OutfitReviewReq struct {
g.Meta `path:"/plan/review" method:"post" tags:"穿搭" summary:"方案反馈"`
PlanId int64 `v:"required" json:"plan_id"`
Action string `v:"required|in:fav,unfav" json:"action"`
Note string `json:"note"`
}
```
- [ ] **Step 2: 编译检查** `go build ./...`entity 引入路径)
- [ ] **Step 3: Commit** `git add -A && git commit -m "feat: dto layer with route metadata"`
---
### Task 5: 用户域(user controller + service
**Files:**
- Create: `styleagent/controller/user_controller.go`
- Create: `styleagent/service/user_service.go`
- Test: `styleagent/service/user_service_test.go`
- [ ] **Step 1: 写失败测试**
```go
// user_service_test.go
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoginSuccess(t *testing.T) {
ctx := context.Background()
// 注册新用户
userId, err := UserService.Register(ctx, "test_user_1", "password123")
assert.NoError(t, err)
assert.True(t, userId > 0)
_, token, err := UserService.Login(ctx, "test_user_1", "password123")
assert.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestLoginWrongPassword(t *testing.T) {
ctx := context.Background()
_, _, err := UserService.Login(ctx, "test_user_1", "wrong")
assert.Error(t, err)
}
```
- [ ] **Step 2: 运行确认失败** `go test ./styleagent/service/ -run TestLogin -v`
Expected: FAIL(编译失败/未定义 UserService
- [ ] **Step 3: 实现 user_service.go**(复制 video-factory user_service.go 模式 + Register 方法,bcrypt 哈希密码,JWT 7 天;测试需要独立 DB —— 测试用 `test_slogan.db`,在 TestMain 中切换 g.DB 配置)
- [ ] **Step 4: 实现 user_controller.go**Login / ChangePassword / Profile 三个方法绑定 dto
- [ ] **Step 5: 运行确认通过** `go test ./styleagent/service/ -run TestLogin -v` → PASS
- [ ] **Step 6: Commit** `git add -A && git commit -m "feat: user domain login/register"`
---
### Task 6: 照片/衣橱/身形域(上传 + 列表 + 删除)
**Files:**
- Create: `styleagent/controller/user_photo_controller.go`, `wardrobe_controller.go`, `body_measurement_controller.go`
- Create: `styleagent/service/user_photo_service.go`, `wardrobe_service.go`, `body_measurement_service.go`
- Create: `styleagent/service/file_storage.go`(文件保存封装:`SaveUploadedFile(file, subDir)``workspace/user_{id}/photos/xxx.jpg`,返回相对路径)
- [ ] **Step 1: file_storage.go**(模式:video-factory character_service 的文件保存逻辑)
```go
package service
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// SaveUploadedFile 保存上传文件到 workspace/{subDir},返回 "workspace/{subDir}/{filename}"
func SaveUploadedFile(file *ghttp.UploadFile, subDir string) (string, error) {
dir := filepath.Join("workspace", subDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), file.Filename)
path := filepath.Join(dir, filename)
if err := file.Save(path); err != nil {
return "", err
}
return "/" + filepath.ToSlash(filepath.Join("workspace", subDir, filename)), nil
}
```
- [ ] **Step 2: 写失败测试**(user_photo:上传→列表→删除;wardrobe 同理;bodysave→get 往返)
- [ ] **Step 3: 实现三个 service**upload 校验(单张 ≤10MB、jpg/png/webp 扩展名校验)→ SaveUploadedFile → dao.Insertlist 按 user_iddelete 校验归属(id + user_id 双条件)后删除文件 + 记录
- [ ] **Step 4: 实现三个 controller**Upload 方法用 `r.GetUploadFile("file")` 获取文件(controller 直接拿 request 时用 `*ghttp.Request` 参数)
```go
func (c *userPhoto) Upload(ctx context.Context, req *dto.UserPhotoUploadReq, r *ghttp.Request) (res *dto.UserPhotoUploadRes, err error) {
file := r.GetUploadFile("file")
userId := common.GetUserId(ctx)
url, err := service.UserPhotoService.Upload(ctx, userId, req.Type, file)
if err != nil {
return nil, err
}
return &dto.UserPhotoUploadRes{Id: url.Id}, nil
}
```
注意:GetUserId(ctx) 从 auth 中间件注入的 ctx 读取(auth_middleware.go 已有实现,按 video-factory 方式调用)。
- [ ] **Step 5: 测试通过** `go test ./styleagent/service/ -v`
- [ ] **Step 6: Commit** `git add -A && git commit -m "feat: photo/wardrobe/body domains"`
---
### Task 7: 化身域(v1 模板匹配 + build 任务)
**Files:**
- Create: `styleagent/avatar/template_matcher.go`
- Create: `styleagent/avatar/glb_packer.go`
- Create: `styleagent/service/avatar_service.go`
- Create: `styleagent/controller/avatar_controller.go`
- Test: `styleagent/avatar/template_matcher_test.go`
- [ ] **Step 1: 写失败测试**template_matcher:给定模拟特征(肤色 1-5 + 身高 cm + 胖瘦 1-5)→ 返回 face_template_id/body_template_id 索引)
```go
package avatar
import "testing"
func TestMatchTemplates(t *testing.T) {
f := &FaceFeature{SkinTone: 3, HeightCm: 175, Build: 3}
faceId, bodyId, skinIdx := MatchTemplates(f)
if faceId < 1 || faceId > 20 || bodyId < 1 || bodyId > 6 || skinIdx < 1 || skinIdx > 5 {
t.Fatalf("out of range: face=%d body=%d skin=%d", faceId, bodyId, skinIdx)
}
}
```
- [ ] **Step 2: 确认失败** `go test ./styleagent/avatar/ -v`
- [ ] **Step 3: 实现 template_matcher.go**
```go
package avatar
// FaceFeature 从照片+用户填写提取的化身特征(v1 简化:照片仅做肤色采样,其余用户填写/默认)
type FaceFeature struct {
SkinTone int // 1-5
HeightCm int
Build int // 1-5 瘦~胖
}
// 预烘焙模板库索引(构建期产物,运行时只读常量)
const (
FaceTemplateCount = 20
BodyTemplateCount = 6
SkinToneLevels = 5
DefaultFaceTemplate = 5
DefaultBodyTemplate = 3
)
// MatchTemplates 特征 → 模板索引(v1 规则映射:肤色→皮肤档,身高+体型→身体模板,脸型由照片后续 AI 提取后替换)
func MatchTemplates(f *FaceFeature) (faceId, bodyId, skinIdx int) {
if f == nil {
return DefaultFaceTemplate, DefaultBodyTemplate, 3
}
skinIdx = f.SkinTone
if skinIdx < 1 { skinIdx = 1 }
if skinIdx > SkinToneLevels { skinIdx = SkinToneLevels }
// 身体模板:身高 150-190 → 6 档
bodyId = (f.HeightCm - 145) / 8
if bodyId < 1 { bodyId = 1 }
if bodyId > BodyTemplateCount { bodyId = BodyTemplateCount }
// v1 脸型固定默认模板(AI 人脸特征提取后替换,见 spec v2)
faceId = DefaultFaceTemplate
return
}
```
- [ ] **Step 4: glb_packer.go**v1:拼 URL —— `/workspace/templates/face_{id}.glb``body_{id}.glb`,组合 avatar GLB 记录;真实打包后续)
- [ ] **Step 5: avatar_service.go**Build(ctx, userId):检查照片齐备(大头照+至少1张全身)→ 读 body_measurement → MatchTemplates → 插入 avatar_modelbuild_status=pending)→ 异步 goroutine 执行 processing → donev1 同步简化:直接 done + glb_url 用 packer 生成的路径);Get(ctx, userId) 返回最新 avatar_model
- [ ] **Step 6: avatar_controller.go**Build/Get 绑定 dtoBuild 返回 task 语义(v1 直接返回 avatar 记录 id
- [ ] **Step 7: 测试通过** + `go build ./...` + **Commit** `git commit -m "feat: avatar domain with template matching"`
---
### Task 8: 发型资产列表(静态 seed)
**Files:**
- Create: `styleagent/controller/hairstyle_controller.go`
- Create: `styleagent/service/hairstyle_service.go`
- Modify: `styleagent/dao/hairstyle_asset_dao.go`init 时 seed 8 个默认发型)
- [ ] **Step 1: dao init seed**(插入 8 条:短发/中发/长发/卷发/寸头/马尾/丸子头/波浪卷,style_tag、glb_url=`/workspace/templates/hairstyle_{id}.glb`、sort
- [ ] **Step 2: 测试**List 返回按 sort 排序的 8 条(dao 测试)
- [ ] **Step 3: service + controller 绑定**`go build ./...`**Commit**
---
### Task 9: 规则引擎评分(5 维度,零 LLM)
**Files:**
- Create: `styleagent/scoring/rules.go`ScoreContext + CandidateOutfit
- Create: `styleagent/scoring/weather_rule.go`
- Create: `styleagent/scoring/occasion_rule.go`
- Create: `styleagent/scoring/color_rule.go`
- Create: `styleagent/scoring/completeness_rule.go`
- Create: `styleagent/scoring/style_rule.go`
- Create: `styleagent/scoring/engine.go`(总分聚合 + 阈值判定)
- Test: `styleagent/scoring/engine_test.go`
- [ ] **Step 1: 写失败测试**(关键边界:冬季温度带外套得分高于无外套;色调和谐组合得分高于冲突组合;缺鞋减分;总分 ≥ 阈值判定通过)
```go
package scoring
import "testing"
func TestWinterOuterwearBonus(t *testing.T) {
ctx := ScoreContext{
TempAvg: 5, // 冬季
Occasion: "通勤",
Weekday: "workday",
Wardrobe: []WardrobeItem{{Category: "上衣", ColorInfo: "#333333"}, {Category: "下装", ColorInfo: "#1a1a1a"}},
}
withJacket := CandidateOutfit{Items: ctx.Wardrobe, HasOuterwear: true}
noJacket := CandidateOutfit{Items: ctx.Wardrobe, HasOuterwear: false}
if weatherScore(withJacket) <= weatherScore(noJacket) {
t.Fatal("winter should favor outerwear")
}
}
```
- [ ] **Step 2: 确认失败** `go test ./styleagent/scoring/ -v`
- [ ] **Step 3: 实现 5 个规则文件**(均为纯函数,输入输出确定):
```go
// rules.go 公共类型
type WardrobeItem struct {
Category string // 上衣/下装/鞋/配饰
Season string // 春/夏/秋/冬/四季
ColorInfo string // 如 #RRGGBB 或 "黑/白/红"
StyleTags string
}
type CandidateOutfit struct {
Items []WardrobeItem
HasOuterwear bool
}
type ScoreContext struct {
TempAvg int // 平均温度℃
Season string
Occasion string // 通勤/约会/聚会/运动
Weekday string // workday/weekend/holiday
StyleTags []string // 用户偏好
}
// weather_rule.go 温度档位表
func weatherScore(o CandidateOutfit, ctx ScoreContext) int {
// 25 分制:温度匹配每件服装 season 加 5 分;<10℃ 无外套扣 10 分;>30℃ 有外套扣 8 分
}
// occasion_rule.go 场合规则表
func occasionScore(o CandidateOutfit, ctx ScoreContext) int {
// 25 分制:场合→类别规则(约会加分:正装/裙装;运动加分:运动服)基础分 15 + 匹配项各 5
}
// color_rule.go 色相环相似度
func colorScore(o CandidateOutfit) int {
// 20 分制:同色系 20;邻近色 15;对比色 8;随机冲突 3
}
// completeness_rule.go
func completenessScore(o CandidateOutfit) int {
// 20 分制:上衣+5 下装+5 鞋+5 配饰+5
}
// style_rule.go 用户偏好
func styleScore(o CandidateOutfit, ctx ScoreContext) int {
// 10 分制:命中用户 styleTags 每项 +2
}
// engine.go
func Score(c *CandidateOutfit, ctx *ScoreContext) int {
return weatherScore(*c, *ctx) + occasionScore(*c, *ctx) + colorScore(*c) +
completenessScore(*c) + styleScore(*c, *ctx)
}
func IsPass(score int, threshold int) bool { return score >= threshold }
```
- [ ] **Step 4: 测试通过**(含色相解析测试:`#ff0000``#ff6666` 同色系;`#ff0000``#00ff00` 对比色)
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: rule-based scoring engine"`
---
### Task 10: 天气适配(和风 + 高德 + 缓存)
**Files:**
- Create: `styleagent/weather/qweather.go`
- Create: `styleagent/weather/geo.go`
- Create: `styleagent/weather/cache.go`
- Create: `styleagent/service/weather_service.go`(供 outfit service 调用)
- Test: `styleagent/weather/cache_test.go`
- [ ] **Step 1: 写失败测试**cacheget→miss→set→hitTTL 过期)
- [ ] **Step 2: 实现 cache.go**(内存 map + mutexkey=`{city}:{date}`TTL 6h
- [ ] **Step 3: 实现 qweather.go**`GetDaily(ctx, cityCode, startDate, endDate) ([]DayWeather, error)`,和风 `v7/weather/7d` 接口,Key 从 `config.yml``weather.qweather_key` 读取(空则返回 error 提示配置缺失)
```go
type DayWeather struct {
Date string `json:"date"`
TempMax int `json:"temp_max"`
TempMin int `json:"temp_min"`
TextDay string `json:"text_day"`
}
// 返回该日期范围内平均温度(用于评分)+ 每日天气
func GetDaily(ctx context.Context, cityCode string, startDate, endDate string) (*WeatherResult, error)
```
- [ ] **Step 4: 实现 geo.go**`GetCityCode(ctx, location) (string, error)` —— 高德地理编码 API,Key 从配置读;失败时降级:直接以 location 为 cityCode 缓存并返回默认天气(config 开启 mock 时)
- [ ] **Step 5: weather_service.go**:封装 `GetWeather(ctx, location, startDate, endDate)` → 先查缓存 → 未命中调 API → 存缓存;测试用 mock API 响应(httptest server 或注入接口)
- [ ] **Step 6: 测试通过** + **Commit** `git commit -m "feat: weather adapter with cache"`
---
### Task 11: Agentchat_model 复用 + 方案规划/兜底)
**Files:**
- Copy: `styleagent/agent/chat_model.go`(从 video-factory 复制,改 import 路径)
- Copy: `styleagent/agent/types.go`ChatRequest/ChatMessage/ChatResponse/ToolCall
- Create: `styleagent/agent/outfit_agent.go`(规划 + 兜底两函数)
- Create: `styleagent/agent/output.go`JSON Schema 校验)
- Create: `styleagent/agent/agent_config.go`(从 model_config 表读取 LLM 配置,未配置时返回错误)
- Test: `styleagent/agent/output_test.go`
- [ ] **Step 1: 复制 chat_model.go + types.go**,改包路径,`go build ./...` 通过
- [ ] **Step 2: 写失败测试**output 解析:合法 JSON 解析为 PlanOutput;缺字段报错;非法 JSON 报错)
```go
// output.go 规划输出结构
type PlanOutput struct {
Plans []PlanCandidate `json:"plans"`
}
type PlanCandidate struct {
Title string `json:"title"`
Hairstyle string `json:"hairstyle"` // 发型名称(匹配资产库)
HairColor string `json:"hair_color"` // 如 #A0522D
Items []PlanItemOut `json:"items"`
}
type PlanItemOut struct {
Slot string `json:"slot"` // 上衣/下装/鞋/配饰
ItemId int64 `json:"item_id,omitempty"` // 衣橱条目(wardrobe 来源)
Name string `json:"name"`
Desc string `json:"desc"`
NewItem bool `json:"new_item"` // 是否为推荐新服装
}
```
- [ ] **Step 3: 确认失败** `go test ./styleagent/agent/ -v`
- [ ] **Step 4: 实现 output.go 校验**json.Unmarshal + 必填字段检查:plans 非空、每套 items 至少 1 件)
- [ ] **Step 5: 实现 outfit_agent.go**
```go
// PlanOutfits 规则预筛候选 → LLM 润色规划(1 次调用)
func PlanOutfits(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string, candidates []CandidateData) (*PlanOutput, error)
// CreateRecommendPlan 兜底创作(全低分时调用,1 次调用)
func CreateRecommendPlan(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string) (*PlanOutput, error)
```
system prompt 要点(写入 agent/prompt.go 常量):角色是穿搭顾问;输出严格 JSON;天气/场合约束注入;仅输出 JSON 无额外文字。
- [ ] **Step 6: agent_config.go**:从 `model_config` 表(复用 video-factory 结构:system 配置 + 可覆盖)读取 base_url/api_key/model_name,用 gcache 缓存 60s;未配置返回明确错误。
- [ ] **Step 7: 测试通过**output 校验单测;agent 调用用 httptest mock OpenAI 端点)+ **Commit**
---
### Task 12: 穿搭生成编排(outfit service 核心)
**Files:**
- Create: `styleagent/service/outfit_service.go`Generate 编排 + 评分 + 兜底 + 落库)
- Test: `styleagent/service/outfit_service_test.go`(核心逻辑 mockweather/agent 注入接口)
- [ ] **Step 1: 写失败测试**(核心流程:衣橱 3 件 → 规则预筛 3 套 → 评分 → 全低分时触发兜底 → 落库 plan + items;高分时直接落库)
- [ ] **Step 2: 确认失败**
- [ ] **Step 3: 实现 outfit_service.go**
```go
type outfitService struct{}
var OutfitService = new(outfitService)
// Generate 创建生成任务并同步执行核心流程(v1 同步;异步任务表见 Task 13)
func (s *outfitService) Generate(ctx context.Context, userId int64, req *dto.OutfitGenerateReq) (int64, error) {
// 1. 校验衣橱非空(<3 件返回 "衣橱服装不足,请先添加至少 3 件服装")
// 2. 天气获取(weather_service
// 3. 规则预筛:衣橱 × 季节温度 × 场合 → 3 套候选(组合算法:按 category 分组随机/轮询组合)
// 4. 创建任务记录(planning)→ Agent.PlanOutfits1 次 LLM
// 5. 规则评分每套 → 任务状态 scoring
// 6. 3 套全 < 阈值 → Agent.CreateRecommendPlan1 次 LLM)→ 新套装标 recommend
// 7. 落库 outfit_planhairstyle_id 匹配资产库)+ plan_outfit_item(来源标注)
// 8. 任务 → done;返回 task_id
}
```
- [ ] **Step 4: 预筛组合算法**outfit_combiner.go):按 category 将衣橱分组,按温度过滤 season,生成最多 3 个互不相同组合(确定性:按 id 排序轮询),每个组合带 HasOuterwear 标记
- [ ] **Step 5: 测试通过** + `go build ./...` + **Commit**
---
### Task 13: 穿搭 controller + 异步任务表
**Files:**
- Create: `styleagent/controller/outfit_controller.go`
- Modify: `styleagent/service/outfit_service.go`(异步化:Generate 只建任务返回 task_idworker goroutine 执行;GetTaskStatus / ListPlans / GetPlanDetail / SelectMain / Review
- [ ] **Step 1: 异步化改造**Generate 插入任务(pending)→ 启动 goroutine 执行核心流程(含任务状态流转 pending→planning→scoring→done/failed + error 记录);`startWorker(ctx)` 守护恢复未完成任务(main.go 启动时调用,模式同 video-factory StartVideoPoller
- [ ] **Step 2: controller 绑定 6 个 dto 方法**Generate/TaskStatus/PlanList/PlanDetail/SelectMain/Review
- [ ] **Step 3: GetPlanDetail**:查 plan + items + images + hairstyle 资产,组装 OutfitPlanDetailRes
- [ ] **Step 4: SelectMain**:置 main_flag(事务:同 task 其他 plan 清零)+ 触发效果图任务(Task 14 后接通)
- [ ] **Step 5: 编译 + 冒烟测试**TestMain 起 gtest server:登录 → 上传 → generate → 轮询 → detail**Commit**
---
### Task 14: 效果图生成(ImageGenClient 接口 + wanx + mock + 缓存)
**Files:**
- Create: `styleagent/imagegen/client.go`(接口 + Factory
- Create: `styleagent/imagegen/wanx_client.go`
- Create: `styleagent/imagegen/mock_client.go`
- Create: `styleagent/imagegen/cache.go`
- Create: `styleagent/service/effect_image_service.go`(异步任务执行:选主方案后生成 3 视角)
- Test: `styleagent/imagegen/cache_test.go` + `mock_client_test.go`
- [ ] **Step 1: 写失败测试**cacheplan 内容 hash → 命中/未命中;mock client:调用返回固定 URL
- [ ] **Step 2: 实现 client.go**
```go
type ImageGenClient interface {
// Generate 生成单张效果图,返回图片 URL
Generate(ctx context.Context, req *GenerateReq) (string, error)
}
type GenerateReq struct {
BaseImageURL string // 用户全身照
Prompt string // 方案描述
Angle string // 正面/侧面/背面
Seed int64
}
func NewClient(supplier string) ImageGenClient // wanx | mockconfig 无 key 时强制 mock
```
- [ ] **Step 3: mock_client.go**:返回 `/workspace/mock/effect_{angle}.png` 占位路径(不真实调用,开发联调用)
- [ ] **Step 4: wanx_client.go**:通义万相人像写真类 API`image-sync` 或异步轮询接口),Key/模型从 `imagegen_config` 表读;v1 实现为"调用 + 轮询结果"封装;错误降级 mock
- [ ] **Step 5: effect_image_service.go**SelectMain 后 goroutine:按 plan 内容 hash 查缓存 → 未命中调用 ImageGenClient 逐角度生成(3 张)→ 存 plan_effect_image + 任务 rendering→done;每日免费次数校验(user 维度,默认 3 次/天,scoring_rule 表配置)
- [ ] **Step 6: 测试通过** + **Commit**
---
### Task 14.5: 商业化基础(partner_store 列表 + seed
**Files:**
- Create: `styleagent/model/entity/partner_store.go`id, name, type(1形象设计 2服装门店), lat, lng, address, commission_policy, status, created_at
- Modify: `styleagent/consts/table_name.go`+`TableNamePartnerStore = "slogan_partner_store"`
- Create: `styleagent/dao/partner_store_dao.go`(建表 + init seed 4 条示例门店 + ListByType
- Create: `styleagent/model/dto/partner_store_dto.go``StoreListReq` path `/list` + `StoreListRes{List []*entity.PartnerStore}`
- Create: `styleagent/service/partner_store_service.go`
- Create: `styleagent/controller/partner_store_controller.go`
- Modify: `main.go`(注册 `controller.PartnerStore`
- [ ] **Step 1: entity + dao**(模式同 Task 3;seed:2 条形象设计 + 2 条服装门店,坐标覆盖城市)
- [ ] **Step 2: dto + service + controller**List 支持 `type` 筛选,0 返回全部)
- [ ] **Step 3: `go build ./...` + 冒烟**GET /partner-store/list 返回 seed 数据)+ **Commit** `git commit -m "feat: partner store domain"`
---
### Task 15: 集成冒烟 + Dockerfile
**Files:**
- Create: `Dockerfile`(复用 video-factory 多阶段构建模式)
- Create: `docs/项目文档.md`(服务端文档,模式同 video-factory 项目文档)
- Create: `docs/api.json` 导出(启动后 GoFrame OpenAPI
- [ ] **Step 1: Dockerfile**golang:1.22 builder + alpine runtime,复制 video-factory Dockerfile 改端口)
- [ ] **Step 2: 全链路冒烟**`go run main.go` → curl 全流程:
1. `POST /user/login`(注册后)→ token
2. `POST /user-photo/upload`-F file=@headshot.jpg -F type=1
3. `POST /wardrobe/upload` × 3
4. `POST /body-measurement/save`
5. `POST /avatar/build` → get
6. `POST /outfit/generate` → task status 轮询 → done
7. `GET /outfit/plan/list` → detail
8. `POST /outfit/plan/select-main` → effect imagesmock 路径)
9. `GET /hairstyle/list`
- [ ] **Step 3: 验证响应格式统一** `{"code":0,"message":"OK","data":...}`
- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete with dockerfile and docs"`
---
## Self-Review 备注(执行前已知项)
- 测试 DB`styleagent/service` 单测使用独立 sqlite 文件 `test_slogan.db`(TestMain 设置),避免污染开发库
- GetUserId(ctx):确认 auth_middleware.go 注入的 key(复制 video-factory 后保持一致)
- 上传文件字段名统一 `file`
- 天气/LLM/图像 Key 全部从 config.yml / 配置表读取,代码不入 Key
@@ -0,0 +1,301 @@
# slogan-agent 服务端设计方案
> 日期:2026-07-31
> 关联:slogan-app 设计方案(App 端)见 slogan-app 仓库对应文档
## 1. 项目概述
slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现。
本仓库为服务端(slogan-agent),提供:用户/照片/衣橱/身形管理、3D 化身构建、穿搭方案生成(规则评分 + Agent)、效果图生成、天气服务、商业化渠道(CPS 电商/门店导流/订阅)。
## 2. 开发规范约束(严格遵守 video-factory
本服务端**架构与代码规范严格遵守** `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory` 的既有规范:
| 规范点 | 约束 |
|--------|------|
| 技术栈 | Go 1.22+ / GoFrame v2 (github.com/gogf/gf/v2) / SQLiteGoFrame ORM 驱动) |
| 认证 | JWT (golang-jwt/jwt/v5)`/user/login` 公开,其余全部经 auth 中间件,7 天过期,bcrypt 密码 |
| 分层 | Controller → Service → DAO → SQLite;每一层独立包,包级变量单例(`var XxxService = new(xxxService)` |
| 路由 | `RouteRegister`common/http/http.go)反射注册,kebab-case 前缀,如 `/outfit/generate` |
| 响应 | 统一 JSON `{"code":0,"message":"OK","data":...}` |
| DAO | 每张表一个 DAO`init()` 自动建表 + ALTER TABLE 兼容迁移 |
| 模型 | `model/entity/`(表实体)+ `model/dto/`(请求响应,含 g.Meta 路由)+ `model/domain/` |
| Agent | 复用 video-factory ReAct 引擎模式:chat_model.goOpenAI 兼容 API,指数退避重试)+ react_agent.go + tools.go + context.go |
| 模型配置 | 系统配置 + 用户配置 → MergedModelConfig(复用 model_config / user_model_config 表模式) |
| 异步任务 | 生成任务表 + 后台轮询(复用 GenerationService.StartPoller 模式,15s 间隔) |
| 文件存储 | `workspace/` 目录 + JWT 鉴权静态文件服务(BindHandler 方式,防路径穿越) |
| 参数校验 | gvalidmain.go 注册自定义规则) |
| 部署 | 单体服务,Docker(复用 video-factory Dockerfile 模式),端口 3006 规则下自定 |
**新增加固规则**(本项目的领域约束):
- 所有涉及 LLM / 图像生成的调用必须经过"供应商适配层"chat_model / imagegen),禁止业务代码直连第三方 SDK
- 所有外部 API(人脸/天气/地理编码)必须封装为 service 层适配器,Key 存配置表不入代码
- 费用敏感:LLM/图像调用全部走任务表异步化 + 缓存,禁止同步阻塞式出图
## 3. 总体架构
```
Flutter App (slogan-app)
│ HTTPS + JWT
slogan-agent (Go 单体)
├── controller → service → dao → SQLite
├── avatar/ 3D 化身管线(预烘焙模板匹配 + 贴图合成 + GLB 输出)
├── scoring/ 规则引擎评分(零 LLM 成本)
├── agent/ 轻量 Agent(方案规划 / 兜底创作)
├── imagegen/ 效果图客户端(多供应商适配 + 缓存)
├── weather/ 天气适配(和风天气 + 缓存)
├── commercial/ CPS 商品 / 门店 / 导流 / 订阅
├── assets/avatar-templates/ 预烘焙模板库(构建期产物,运行时只读)
└── workspace/ 用户照片 / GLB / 效果图
```
## 4. 项目结构
```
slogan-agent/
├── main.go # 入口:RouteRegister + workspace 鉴权文件服务 + 后台轮询
├── common/ # 复用 video-factoryauth / cache / http / base_dao
├── styleagent/ # 业务模块(对应 shortdrama
│ ├── controller/ # user / user-photo / wardrobe / body-measurement /
│ │ # avatar / outfit / hairstyle / product-recommend /
│ │ # partner-store / store-lead / subscription
│ ├── service/ # 对应业务逻辑(每域一个)
│ ├── dao/ # 每表一个
│ ├── model/
│ │ ├── entity/ # 表实体
│ │ ├── dto/ # 请求/响应 + g.Meta 路由
│ │ └── domain/
│ │ ├── outfit_plan.go # 方案领域模型 + JSON 解析校验
│ │ └── avatar_profile.go # 化身参数配置
│ ├── avatar/ # 3D 化身管线
│ │ ├── template_matcher.go # 特征 → 模板匹配
│ │ ├── texture_composer.go # 面部照片贴图合成
│ │ ├── glb_packer.go # 头部/身体/发型 GLB 组合打包
│ │ └── template_builder/ # 构建期烘焙脚本(MakeHuman/MPFB+BlenderCI 运行,不入运行时)
│ ├── scoring/ # 规则引擎评分
│ │ ├── rules.go # 规则定义与配置加载
│ │ ├── weather_rule.go # 天气适宜度
│ │ ├── occasion_rule.go # 场合匹配
│ │ ├── color_rule.go # 色彩和谐
│ │ └── completeness_rule.go # 层次完整度
│ ├── agent/ # 轻量 Agent
│ │ ├── chat_model.go # OpenAI 兼容调用(含重试/限流,复用模式)
│ │ ├── outfit_agent.go # 方案规划 / 兜底创作
│ │ ├── tools.go # get_weather / list_wardrobe / score_outfit / create_plan
│ │ └── output.go # 输出 JSON Schema 校验
│ ├── imagegen/
│ │ ├── client.go # ImageGenClient 接口
│ │ ├── wanx_client.go # 通义万相
│ │ ├── jimeng_client.go # 即梦
│ │ └── cache.go # 按快照 hash 缓存
│ ├── weather/
│ │ ├── qweather.go # 和风天气适配
│ │ └── geo.go # 地点 → 城市编码(高德)
│ ├── commercial/
│ │ ├── cps.go # CPS 商品检索
│ │ ├── store.go # 合作门店 LBS
│ │ └── subscription.go # 订阅权益
│ └── consts/
│ ├── public/table_name.go # 表名常量
│ ├── public/content_type.go # 照片类型/方案来源/任务状态
│ └── status.go # 任务状态常量
├── assets/avatar-templates/ # 预烘焙模板(20 头部 GLB + 6 身体 GLB + 5 档皮肤贴图 + 发型 GLB)
└── workspace/ # 用户数据(照片/GLB/效果图)
```
## 5. 数据库设计(每表一个 DAO/Service/Controller
### 用户域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `user` | 复用 video-factory 用户模型(role 扩展:user) | 账号密码登录 v1,手机号绑定留扩展 |
| `user_photo` | id / user_id / type(1大头照 2全身正面 3全身侧面 4全身背面) / url / status | 3D 构建用原图 |
| `wardrobe_item` | id / user_id / photo_url / category(上衣/下装/鞋/配饰) / season / style_tags / color_info / status | 服装资产 |
| `body_measurement` | id / user_id / height / weight / skin_tone / fit_params(JSON) | 用户填写 + 照片估算合并 |
### 化身域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `avatar_model` | id / user_id / face_template_id / body_template_id / skin_tone_index / face_texture_url / glb_url / build_status / params_snapshot(JSON) | 3D 化身 |
| `hairstyle_asset` | id / name / style_tag / glb_url / thumb_url / applicable_face / sort | 发型资产库(静态维护) |
| `outfit_asset` | id / name / style_tag / season / glb_url / cc0_source | 服装简模资产库(少量 CC0) |
### 生成域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `outfit_generation_task` | id / user_id / start_date / end_date / location / weather_snapshot(JSON) / status(planning→scored→rendering→done/failed) / model_name / error | 生成任务(轮询) |
| `outfit_plan` | id / task_id / user_id / date_range / location / source(wardrobe/recommend) / score / main_flag / hairstyle_id / hair_color / weather_ref(JSON) | 穿搭方案 |
| `plan_outfit_item` | id / plan_id / slot(发型/上衣/下装/鞋/配饰) / source(wardrobe/recommend) / wardrobe_item_id(可空) / product_recommend_id(可空) / name / desc | 方案条目 |
| `plan_effect_image` | id / plan_id / angle(正面/侧面/背面) / url / status / prompt_snapshot | 2D 效果图 |
| `plan_review` | id / plan_id / user_id / action(fav/unfav) / note | 用户反馈 → 回流 Agent |
### 商业域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `product_recommend` | id / plan_id(可空,全局备选) / product_name / channel(淘宝/京东/抖音/拼多多) / cps_url / price / commission_rate / image_url / status | CPS 商品 |
| `partner_store` | id / name / type(1形象设计 2服装门店) / lat / lng / address / commission_policy(JSON) / status | 合作门店 |
| `store_lead` | id / user_id / plan_id / store_id / status(created→visited→settled/cancelled) / create_time | 导流订单 |
| `subscription` | id / user_id / plan_type(standard/pro) / start_time / end_time / status | 会员订阅 |
| `model_config` / `user_model_config` | 复用 video-factory 表结构 | 模型配置 |
| `imagegen_config` | id / supplier / api_key / model_name / price_tier / enabled | 图像生成供应商配置 |
| `scoring_rule` | id / dimension / rule_type / rules_json / enabled / version | 评分规则配置(第 7 节),内置默认值 + 可配置 |
## 6. 3D 化身管线(预烘焙模板 + 运行时匹配)
### 核心理念
所有"昂贵且不稳定"的环节在**构建期**完成;运行时只做轻量匹配与合成,服务器成本趋近于零。
### 构建期(CI 或发布流水线,一次性执行)
1. MakeHuman(CC0 资产,官方导出可商用)生成参数化角色基底
2. MPFB + Blender headless 脚本烘焙:
- 20 个头部 GLB(脸型差异,PBR 材质)
- 6 个身体 GLB(体型差异:身高×胖瘦组合)
- 5 档皮肤贴图(肤色深浅)
- 10-15 个发型 GLB(CC0/自建,含发色可调材质)
3. glTF-Transform 压缩优化,产物提交 `assets/avatar-templates/`
### 运行时(用户触发 build)
```
用户照片(大头照+全身) + 身形参数
→ ① 特征提取:国内人脸 API(腾讯/阿里,免费额度)→ 脸型/五官特征向量
→ ② 模板匹配:特征向量 → 最近脸型模板(余弦距离,阈值外降级到用户滑杆微调)
→ ③ 贴图合成:大头照人脸区域 → 面部贴图(对齐模板 UV,肤色按色阶匹配 5 档)
→ ④ 打包:组合 头部模板 + 身体模板 + 皮肤贴图 → avatar GLB(头部/身体/发型分离存储,App 端组合换装)
→ ⑤ 保存 avatar_model 记录(build 任务异步,状态机 pending→processing→done/failed
```
### v1 边界声明
- 化身定位"高相似度虚拟形象"(脸型/肤色/身形贴近),非照片级真人重建
- 发型为资产库切换,不做 AI 重建用户真实发型
- 用户可在 App 端用滑杆微调身形/肤色(参数化信息与照片估算合并),滑杆调整即时反映在 GLB 缩放参数上(运行时零渲染成本)
## 7. 规则引擎评分(零 LLM 成本)
每个候选方案多维度打分,总分 100:
| 维度 | 权重 | 规则来源 |
|------|------|---------|
| 天气适宜度 | 25 | 温度区间 × 服装厚度匹配表(如 <10°C 需外套;25-32°C 短袖) |
| 场合匹配 | 25 | 日期类型(工作日/周末/节假日)→ 场合(通勤/约会/聚会)→ 服装类别规则表 |
| 色彩和谐 | 20 | 色相环配色表(同类色/邻近色/对比色得分) |
| 层次完整度 | 20 | 上衣/下装/鞋/配饰齐全度 + 可穿性(衣橱库存覆盖) |
| 风格一致性 | 10 | 服装 style_tags 与用户画像(历史收藏偏好)匹配度 |
- 规则表配置存库(`scoring_rule` 可配置,后台可调,v1 内置默认值常量 + 配置表扩展)
- 阈值 75 分可配置
- 全部低于阈值 → 判定"无合格衣橱方案",触发 Agent 兜底创作
- 免费用户效果图次数:每日 N 次(默认 3 次,配置可调);pro 订阅不限
## 8. 穿搭生成流程(Agent + 评分 + 兜底)
```
POST /outfit/generate {start_date, end_date, location}
→ ① 天气获取(和风 API,按 城市+日期 缓存 6h;地点经高德地理编码)
→ ② 规则引擎预筛:衣橱 × 天气 × 场合 → 3 套候选组合(零 LLM)
→ ③ Agent 规划(1 次 LLM 调用):
│ 工具:get_weather / list_wardrobe / score_outfit(规则引擎) / create_plan
│ 输出:3 套方案结构化 JSON(每套含发型建议/发色/服装条目)
→ ④ 规则评分:≥75 → source=wardrobe3 套全 <75 → LLM 兜底创作(1 次调用):
│ 输入:用户画像 + 天气 + 场合 + 衣橱摘要
│ 输出:高分方案 JSON(含 1-3 件新服装推荐,带品类/风格/价格带)
│ 方案标记 source=recommend,新服装关联 CPS 商品检索
→ ⑤ 保存方案(outfit_plan + plan_outfit_item),任务状态 → done
→ ⑥ App 端 3D 即时呈现 3 套方案(无额外成本);用户选定主方案后:
→ ⑦ 效果图按需生成(见下节),缓存命中则免费
```
**成本控制**
- 每次生成 LLM 调用 ≤ 2 次(规划 + 兜底,兜底仅全低分时触发)
- 评分 100% 规则引擎
- 工具调用控制在 3-5 次内(ReAct 最大步数 8
## 9. 效果图生成(按需 + 缓存 + 多供应商)
- 供应商适配器:`ImageGenClient` 接口,实现 通义万相(人像写真类 API)/ 即梦,配置表切换
- 输入:用户全身照 + 方案条目描述 + 人像一致性参数 + 视角(正面/侧面/背面)
- 触发:用户选定主方案后自动生成 3 视角;其余方案需用户主动请求(免费次数内/订阅权益检查)
- 缓存:key = md5(user_id + wardrobe_snapshot + plan_content),命中直接返回已生成图
- 异步:任务表 + 轮询(复用 StartPoller 模式)
- 失败重试 1 次,仍失败则标记 failed 并降级提示(3D 方案仍可用)
## 10. 商业化模块
| 渠道 | 实现 |
|------|------|
| 服装电商 CPS | `product_recommend` 表;兜底方案新服装检索 CPS 商品(淘宝联盟/京东联盟/抖音电商),App 端展示跳转,按成交佣金分成 |
| 形象设计门店 | `partner_store` type=1;发型/造型方案 LBS 推荐附近合作店(理发/造型师),`store_lead` 导流 + 到店核销 |
| 服装门店渠道 | `partner_store` type=2;本地服装门店展示 + 方案一键到店 |
| 会员订阅 | `subscription`standard(免费基础)/ pro(无限生成/高清效果图/方案全量效果图解锁) |
## 11. API 路由表(所有请求 JWT 鉴权,除 /user/login
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/user/login` | 登录(公开) |
| POST | `/user/change-password` | 修改密码 |
| GET | `/user/profile` | 个人资料 |
| POST | `/user-photo/upload` | 上传照片(type:大头照/全身正面/侧面/背面) |
| GET | `/user-photo/list` | 照片列表 |
| POST | `/user-photo/delete` | 删除照片 |
| POST | `/wardrobe/upload` | 上传服装(分类/季节/风格标签) |
| GET | `/wardrobe/list` | 衣橱列表 |
| POST | `/wardrobe/update` | 更新服装信息 |
| POST | `/wardrobe/delete` | 删除服装 |
| POST | `/body-measurement/save` | 保存身形参数 |
| GET | `/body-measurement/get` | 获取身形参数 |
| POST | `/avatar/build` | 触发化身构建任务 |
| GET | `/avatar/get` | 化身信息(GLB 地址/状态) |
| POST | `/avatar/rebuild` | 重新构建化身 |
| GET | `/hairstyle/list` | 发型资产列表 |
| POST | `/outfit/generate` | 生成穿搭方案(日期范围+地点) |
| GET | `/outfit/task/status` | 生成任务状态轮询 |
| GET | `/outfit/plan/list` | 方案列表(历史) |
| GET | `/outfit/plan/detail` | 方案详情(3D 配置 + 条目 + 商品/门店) |
| POST | `/outfit/plan/select-main` | 选定主方案(触发效果图生成) |
| POST | `/outfit/plan/effect-image/generate` | 补生成某方案效果图(权益检查) |
| POST | `/outfit/plan/review` | 方案反馈(收藏/点赞/备注) |
| GET | `/product-recommend/list` | 方案关联 CPS 商品 |
| GET | `/partner-store/list` | 附近合作门店(lat/lng |
| POST | `/store-lead/create` | 创建导流订单 |
| POST | `/store-lead/confirm` | 到店核销 |
| POST | `/subscription/create` | 创建订阅 |
| GET | `/subscription/status` | 订阅状态 |
## 12. 错误处理与异步任务
- 任务状态机:`pending → processing → done / failed`,失败写 `error` 字段,App 轮询展示
- 外部 API(人脸/天气/LLM/图像)统一超时与指数退避重试;Key 失效/欠费返回明确错误码
- 图片上传限制:单张 ≤ 10MB,格式 jpg/png/webp,服务端校验 + 压缩(宽边 ≤ 2048)
- 路径安全:workspace 文件服务防 `..` 穿越(复用 video-factory BindHandler 实现)
## 13. 测试策略
- DAO/Service:表驱动单测(SQLite 内存库),覆盖评分规则各维度边界(温度档位/色彩组合/阈值判定)
- Agent:输出 JSON Schema 校验测试 + 工具 mockchat_model 接口化)
- 化身管线:模板匹配单元测试(特征向量 → 模板索引)+ 贴图合成冒烟
- Controller:路由注册冒烟 + 鉴权中间件测试
- 关键流程集成测试:generate → 评分 → 兜底 → 出图(全 mock 外部 API)
## 14. 成本估算与部署(初期 1 万次生成/月)
| 项目 | 月成本 | 说明 |
|------|--------|------|
| 服务器 | ~¥150 | 2C4G 轻量云,Docker 部署单体 |
| LLM | ~¥1000 | DeepSeek/Qwen~¥0.1/次(≤2 次调用 + 工具) |
| 图像生成 | ~¥7000 | 主方案 3 视角 ≈ ¥0.7/次;pro 订阅用户分摊成本 |
| 人脸 API / 天气 | 免费额度内 | 缓存 + 免费版 |
| **单次生成总成本** | **~¥0.8** | 其中图像生成占大头,已按最优策略控制 |
- 存储 v1 本地 workspace(可迁 OSS/COS,存储接口抽象预留)
- 规模化信号:存储 > 50GB 或单机 CPU 持续 >70% → 迁对象存储 + 拆分轮询 worker
+104
View File
@@ -0,0 +1,104 @@
# slogan-agent 服务端
人形象设计应用(slogan)的服务端。用户上传个人照片与服装照片,指定日期地点后由大模型生成穿搭方案(含发型),支持 3D 化身与效果图查看。
技术栈:Go 1.22+ / GoFrame v2 / SQLite / JWT / OpenAI 兼容大模型 / 和风天气 + 高德地理编码。
## 快速开始
```bash
go mod tidy
go build -o slogan-agent .
./slogan-agent
```
服务默认监听 `:3007`,首次启动自动建库建表(`slogan.db`)。
### 必要配置(config.yml
| 配置项 | 说明 |
|--------|------|
| `llm.base_url / api_key / model_name` | 大模型(OpenAI 兼容,如通义/DeepSeek/Kimi),未配置时生成任务失败并返回明确错误 |
| `weather.qweather_key` | 和风天气 v7 Key(免费版即可),用于 7 天预报 |
| `geo.amap_key` | 高德地理编码 Key,地点 → adcode |
| `imagegen.supplier` | 效果图供应商:`mock`(占位图,开发用)或 `wanx`(通义万相,需配 `wanx_api_key` |
未配置天气/LLM Key 时接口返回明确错误提示,服务本身可正常启动。
## 接口总览
统一响应格式:`{"code":0,"message":"OK","data":...}``code != 0` 为业务错误。除公开接口外需 `Authorization: Bearer <token>`JWT7 天有效)。
| 模块 | 路径 | 说明 | 公开 |
|------|------|------|------|
| 用户 | `POST /user/register` | 注册 | 是 |
| 用户 | `POST /user/login` | 登录,返回 token | 是 |
| 用户 | `POST /user/change-password` | 修改密码 | |
| 用户 | `GET /user/profile` | 个人信息 | |
| 照片 | `POST /user-photo/upload` | 上传照片(type: 1 大头 2 全身正面 3 侧面 4 背面) | |
| 照片 | `GET /user-photo/list` | 照片列表(type 可筛选) | |
| 照片 | `POST /user-photo/delete` | 删除照片 | |
| 衣橱 | `POST /wardrobe/upload` | 上传服装(category: 上衣/下装/鞋/配饰,season, style_tags, color_info | |
| 衣橱 | `GET /wardrobe/list` | 衣橱列表 | |
| 衣橱 | `POST /wardrobe/update` | 更新服装信息 | |
| 衣橱 | `POST /wardrobe/delete` | 删除服装 | |
| 身形 | `POST /body-measurement/save` | 保存身形(height/weight/skin_tone | |
| 身形 | `GET /body-measurement/get` | 查询身形 | |
| 化身 | `POST /avatar/build` | 构建 3D 化身(模板匹配,v1 同步) | |
| 化身 | `GET /avatar/get` | 化身信息(glb_url | |
| 发型 | `GET /hairstyle/list` | 发型资产库 | 是 |
| 穿搭 | `POST /outfit/generate` | 生成穿搭方案(异步任务,body: start_date/end_date/location | |
| 穿搭 | `GET /outfit/task/status` | 任务状态(pending→planning→scoring→done/failed | |
| 穿搭 | `GET /outfit/plan/list` | 方案列表 | |
| 穿搭 | `GET /outfit/plan/detail` | 方案详情(items + hairstyle + effect images | |
| 穿搭 | `POST /outfit/plan/select-main` | 选定主方案(触发 3 视角效果图生成) | |
| 穿搭 | `POST /outfit/plan/review` | 方案反馈(fav/unfav | |
| 门店 | `GET /partner-store/list` | 合作门店(type: 1 形象设计 2 服装门店,0 全部) | |
| 静态 | `GET /workspace/*` | 上传文件与模板资产(鉴权放行) | |
OpenAPI 文档:`http://127.0.0.1:3007/api.json`
## 生成流程(outfit/generate
```
pending → planning(天气获取 → 规则预筛 3 套候选 → LLM 规划 1 次调用)
→ scoring(规则引擎 5 维评分:天气 25/场合 25/色彩 20/完整度 20/风格 10,阈值 75
→ 全低分 → LLM 兜底创作(1 次调用,recommend 方案)
→ 落库 outfit_plan + plan_outfit_item
→ done
```
- 衣橱不足 3 件、日期倒挂、Key 未配置等均在任务结果中返回明确错误
- 服务重启时未完成任务标记 failed(避免重复消耗模型费用)
- 效果图按需生成:选主方案后异步生成 正面/侧面/背面 3 张,内容 hash 缓存 24h,每日限 3 次(可配 `scoring_rule``effect_limit` 维度)
## 数据模型
13 张表:`slogan_user``slogan_user_photo``slogan_wardrobe_item``slogan_body_measurement``slogan_avatar_model``slogan_hairstyle_asset`seed 8 发型)、`slogan_outfit_generation_task``slogan_outfit_plan``slogan_plan_outfit_item``slogan_plan_effect_image``slogan_plan_review``slogan_scoring_rule``slogan_partner_store`seed 4 门店)。
## 目录结构
```
main.go 入口:路由注册 + workspace 静态服务 + 任务恢复
common/ 统一响应/RouteRegister/JWT 鉴权/工具
styleagent/
controller/ Controller 层(反射路由,struct 名 → kebab-case URL
service/ 业务层(生成编排/化身/衣橱/效果图)
dao/ 每表一 DAOinit 自动建表 + seed
model/entity|dto/ 实体与请求响应结构
agent/ LLM 调用(OpenAI 兼容,重试/工具调用)+ 方案规划/兜底
scoring/ 规则评分引擎(零 LLM 成本)
weather/ 和风天气 + 高德地理编码 + 缓存
imagegen/ 效果图客户端(mock/wanx+ 缓存
avatar/ 3D 化身模板匹配
consts/ 常量
```
## 部署
```bash
docker build -t slogan-agent .
docker run -d -p 3007:3007 -v /data/slogan:/app/workspace -v /data/slogan/slogan.db:/app/slogan.db slogan-agent
```
生产部署前在 config.yml 填写 llm/weather/geo/imagegen 的真实 Key。
+47
View File
@@ -0,0 +1,47 @@
module slogan-agent
go 1.26.1
require (
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.38.0
)
require (
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grokify/html-strip-tags-go v0.1.0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+104
View File
@@ -0,0 +1,104 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw=
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g=
github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8=
github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4=
github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+64
View File
@@ -0,0 +1,64 @@
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,
})
// ==================== 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)
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")
}
+37
View File
@@ -0,0 +1,37 @@
package agent
import (
"context"
"fmt"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
)
var modelCfgCache = gcache.New()
// GetModelConfig 从 config.yml 读取 LLM 配置(缓存 60s),未配置返回明确错误
func GetModelConfig(ctx context.Context) (*ModelConfig, error) {
cacheKey := "llm:model_config"
v, err := modelCfgCache.Get(ctx, cacheKey)
if err == nil && !v.IsNil() {
if cfg, ok := v.Val().(*ModelConfig); ok {
return cfg, nil
}
}
cfg := &ModelConfig{
BaseURL: g.Cfg().MustGet(ctx, "llm.base_url", "").String(),
APIKey: g.Cfg().MustGet(ctx, "llm.api_key", "").String(),
ModelName: g.Cfg().MustGet(ctx, "llm.model_name", "").String(),
MaxTokens: g.Cfg().MustGet(ctx, "llm.max_tokens", 4096).Int(),
Temperature: g.Cfg().MustGet(ctx, "llm.temperature", 0.8).Float32(),
Timeout: time.Duration(g.Cfg().MustGet(ctx, "chat.timeout", 300).Int()) * time.Second,
MaxRetries: g.Cfg().MustGet(ctx, "chat.max_retries", 3).Int(),
}
if cfg.APIKey == "" || cfg.ModelName == "" || cfg.BaseURL == "" {
return nil, fmt.Errorf("LLM 未配置:请在 config.yml 设置 llm.base_url / llm.api_key / llm.model_name")
}
_ = modelCfgCache.Set(ctx, cacheKey, cfg, 60*time.Second)
return cfg, nil
}
+313
View File
@@ -0,0 +1,313 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// ModelConfig 模型配置
type ModelConfig struct {
ModelName string // 对话模型名
APIKey string // API密钥
BaseURL string // API地址
MaxTokens int // 最大Token数
Temperature float32 // 温度参数
Timeout time.Duration // HTTP请求超时(0表示默认)
MaxRetries int // 最大重试次数(0表示默认3次)
}
// CallChatModel 调用大模型聊天接口(OpenAI 兼容格式)
func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*ChatResponse, error) {
if cfg == nil {
return nil, fmt.Errorf("model config cannot be empty")
}
if cfg.APIKey == "" {
return nil, fmt.Errorf("APIKey not configured")
}
if cfg.ModelName == "" {
return nil, fmt.Errorf("model name not configured")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("API address not configured")
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 300 * time.Second
}
body, err := buildReqBody(cfg.ModelName, req)
if err != nil {
return nil, err
}
url := trimSlashes(cfg.BaseURL)
var lastErr error
maxRetries := cfg.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
g.Log().Debugf(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=%d body_size=%d", cfg.ModelName, timeout, maxRetries, len(body))
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(1<<(attempt-1)) * time.Second
g.Log().Infof(ctx, "ChatAPI 重试第%d次(等待%v)", attempt, wait)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
result, doErr := doChatRequest(ctx, url, cfg.APIKey, body, timeout)
if doErr == nil {
g.Log().Debugf(ctx, "ChatAPI 调用成功 url=%s tool_calls=%d content_len=%d",
url, len(result.ToolCalls), len(result.Content))
return result, nil
}
lastErr = doErr
g.Log().Warningf(ctx, "ChatAPI request failed (attempt=%d/%d): %v", attempt+1, maxRetries+1, doErr)
// 只有限流或服务端错误才重试
errStr := lastErr.Error()
if !strings.Contains(errStr, "limit_requests") &&
!strings.Contains(errStr, "limit_tokens") &&
!strings.Contains(errStr, "500") &&
!strings.Contains(errStr, "502") &&
!strings.Contains(errStr, "503") {
break
}
}
g.Log().Errorf(ctx, "ChatAPI failed after %d retries: %v", maxRetries+1, lastErr)
return nil, lastErr
}
func doChatRequest(ctx context.Context, url, apiKey string, body []byte, timeout time.Duration) (*ChatResponse, error) {
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
start := time.Now()
client := &http.Client{Timeout: timeout}
resp, err := client.Do(httpReq)
elapsed := time.Since(start)
if err != nil {
return nil, fmt.Errorf("request failed (elapsed %v): %w", elapsed, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response failed (status=%d): %w", resp.StatusCode, err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API response error status=%d body=%s", resp.StatusCode, string(respBody))
}
g.Log().Debugf(ctx, "ChatAPI 响应完成 status=%d body_len=%d elapsed=%v",
resp.StatusCode, len(respBody), elapsed)
return parseRespBody(ctx, respBody)
}
// ==================== 内部实现 ====================
type apiReqBody struct {
Model string `json:"model"`
Messages []apiMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float32 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []apiToolDef `json:"tools,omitempty"`
}
// apiMessage 用于JSON序列化的消息体(适配OpenAI format
type apiMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []apiToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
type apiToolDef struct {
Type string `json:"type"`
Function apiToolFunction `json:"function"`
}
type apiToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
type apiRespBody struct {
Choices []apiChoice `json:"choices"`
Error *struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"error,omitempty"`
}
type apiChoice struct {
Index int `json:"index"`
Message apiRespMsg `json:"message"`
FinishReason string `json:"finish_reason"`
}
// apiRespMsg 响应消息体(arguments 使用 json.RawMessage 兼容对象和字符串)
type apiRespMsg struct {
Content string `json:"content"`
ToolCalls []apiRespToolCall `json:"tool_calls,omitempty"`
}
type apiRespToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function apiRespFuncCall `json:"function"`
}
type apiRespFuncCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type apiToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function apiReqFuncCall `json:"function"`
}
// apiReqFuncCall 请求中的 function callarguments 为 json.RawMessage 避免二次编码)
type apiReqFuncCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
func buildReqBody(model string, req *ChatRequest) ([]byte, error) {
body := apiReqBody{
Model: model,
Messages: toAPIMessages(req.Messages),
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
Stream: req.Stream,
}
if len(req.Tools) > 0 {
body.Tools = make([]apiToolDef, 0, len(req.Tools))
for _, t := range req.Tools {
body.Tools = append(body.Tools, apiToolDef{
Type: "function",
Function: apiToolFunction{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
}
return json.Marshal(body)
}
func toAPIMessages(msgs []*ChatMessage) []apiMessage {
out := make([]apiMessage, 0, len(msgs))
for _, m := range msgs {
om := apiMessage{
Role: m.Role,
Content: m.Content,
ToolCallID: m.ToolCallID,
Name: m.Name,
}
if len(m.ToolCalls) > 0 {
om.ToolCalls = make([]apiToolCall, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
args := tc.Arguments
if args == "" || !json.Valid([]byte(args)) {
args = "{}"
}
om.ToolCalls = append(om.ToolCalls, apiToolCall{
ID: tc.ID,
Type: "function",
Function: apiReqFuncCall{
Name: tc.Name,
Arguments: json.RawMessage(args),
},
})
}
}
out = append(out, om)
}
return out
}
func parseRespBody(ctx context.Context, data []byte) (*ChatResponse, error) {
var resp apiRespBody
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("parse response failed: %s", string(data))
}
if resp.Error != nil {
return nil, fmt.Errorf("API error(code=%s): %s", resp.Error.Code, resp.Error.Message)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("empty response")
}
msg := resp.Choices[0].Message
cr := &ChatResponse{Content: msg.Content}
// 检测 finish_reason 是否为 length(被 max_tokens 截断)
if resp.Choices[0].FinishReason == "length" {
g.Log().Warningf(ctx, "ChatAPI response truncated (finish_reason=length), content_len=%d, consider increasing max_tokens", len(msg.Content))
}
if len(msg.ToolCalls) > 0 {
cr.ToolCalls = make([]*ToolCall, 0, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
args := resolveArguments(tc.Function.Arguments)
cr.ToolCalls = append(cr.ToolCalls, &ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: args,
})
}
}
return cr, nil
}
// resolveArguments 将 json.RawMessage 的参数转为字符串
// API 可能返回 "arguments": "{\"key\":\"val\"}"(字符串)或 "arguments": {"key":"val"}(对象)
func resolveArguments(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
// 如果是 JSON 字符串(以 " 开头),直接提取字符串值
if raw[0] == '"' {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
}
// 否则是 JSON 对象,重新序列化回字符串
return string(raw)
}
func trimSlashes(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
+56
View File
@@ -0,0 +1,56 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
// CandidateData 预筛候选服装(供 LLM 选择组合)
type CandidateData struct {
SetId int64 `json:"set_id"` // 所属预筛组合编号
ItemId int64 `json:"item_id"` // 衣橱条目 id
Category string `json:"category"`
Name string `json:"name"`
Color string `json:"color"`
Season string `json:"season"`
Style string `json:"style"`
}
// PlanOutfits 规则预筛候选 → LLM 润色规划(1 次调用)
func PlanOutfits(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string, candidates []CandidateData) (*PlanOutput, error) {
candJSON, err := json.Marshal(candidates)
if err != nil {
return nil, fmt.Errorf("候选序列化失败: %w", err)
}
msg := userInput + "\n候选服装(JSON,set_id 表示第几套预选,请在同一套内选择):" + string(candJSON)
return callPlan(ctx, cfg, sysPrompt, msg)
}
// CreateRecommendPlan 兜底创作(全低分时调用,1 次调用)
func CreateRecommendPlan(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string) (*PlanOutput, error) {
return callPlan(ctx, cfg, sysPrompt, userInput)
}
func callPlan(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string) (*PlanOutput, error) {
req := &ChatRequest{
Messages: []*ChatMessage{
{Role: RoleSystem, Content: sysPrompt},
{Role: RoleUser, Content: userInput},
},
MaxTokens: cfg.MaxTokens,
Temperature: cfg.Temperature,
}
resp, err := CallChatModel(ctx, cfg, req)
if err != nil {
return nil, err
}
out, err := ParsePlanOutput(resp.Content)
if err != nil {
g.Log().Warningf(ctx, "LLM 方案输出解析失败: %v\n原始输出: %s", err, resp.Content)
return nil, err
}
return out, nil
}
+71
View File
@@ -0,0 +1,71 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
)
// PlanOutput 大模型输出的穿搭方案集合
type PlanOutput struct {
Plans []PlanCandidate `json:"plans"`
}
// PlanCandidate 一套穿搭方案
type PlanCandidate struct {
Title string `json:"title"`
Hairstyle string `json:"hairstyle"` // 发型名称(匹配资产库)
HairColor string `json:"hair_color"` // 如 #A0522D
Items []PlanItemOut `json:"items"`
}
// PlanItemOut 方案内一件单品
type PlanItemOut struct {
Slot string `json:"slot"` // 上衣/下装/鞋/配饰
ItemId int64 `json:"item_id,omitempty"` // 衣橱条目(wardrobe 来源)
Name string `json:"name"` // 单品名
Desc string `json:"desc"` // 搭配说明
NewItem bool `json:"new_item"` // 是否为推荐新服装
}
// ParsePlanOutput 解析并校验 LLM 输出(去除 markdown 代码围栏后 json.Unmarshal
func ParsePlanOutput(raw string) (*PlanOutput, error) {
text := strings.TrimSpace(raw)
// 容忍 ```json ... ``` 代码围栏
if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```")
if idx := strings.Index(text, "\n"); idx >= 0 {
text = text[idx+1:]
}
text = strings.TrimSuffix(strings.TrimSpace(text), "```")
}
var out PlanOutput
if err := json.Unmarshal([]byte(text), &out); err != nil {
return nil, fmt.Errorf("方案 JSON 解析失败: %w", err)
}
if len(out.Plans) == 0 {
return nil, fmt.Errorf("方案输出为空(plans 缺失)")
}
for i, p := range out.Plans {
if strings.TrimSpace(p.Title) == "" {
return nil, fmt.Errorf("方案 %d 缺少 title", i+1)
}
if len(p.Items) == 0 {
return nil, fmt.Errorf("方案 %d 缺少 items", i+1)
}
for _, it := range p.Items {
if !isValidSlot(it.Slot) {
return nil, fmt.Errorf("方案 %d 含非法 slot: %s", i+1, it.Slot)
}
}
}
return &out, nil
}
func isValidSlot(slot string) bool {
switch slot {
case "上衣", "下装", "鞋", "配饰":
return true
}
return false
}
+44
View File
@@ -0,0 +1,44 @@
package agent
import "testing"
func TestParsePlanOutput_Valid(t *testing.T) {
raw := `{"plans":[{"title":"通勤清爽","hairstyle":"清爽短发","hair_color":"#2B2B2B","items":[{"slot":"上衣","item_id":1,"name":"白衬衫","desc":"正式","new_item":false},{"slot":"鞋","item_id":3,"name":"小白鞋","desc":"百搭","new_item":false}]}]}`
out, err := ParsePlanOutput(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Plans) != 1 || out.Plans[0].Title != "通勤清爽" {
t.Fatalf("wrong parse result: %+v", out.Plans)
}
}
func TestParsePlanOutput_CodeFence(t *testing.T) {
raw := "```json\n{\"plans\":[{\"title\":\"周末约会\",\"hairstyle\":\"波浪卷发\",\"hair_color\":\"#8B4513\",\"items\":[{\"slot\":\"下装\",\"item_id\":0,\"name\":\"A字裙\",\"desc\":\"飘逸\",\"new_item\":true}]}]}\n```"
out, err := ParsePlanOutput(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Plans) != 1 || !out.Plans[0].Items[0].NewItem {
t.Fatalf("wrong parse result: %+v", out.Plans)
}
}
func TestParsePlanOutput_MissingPlans(t *testing.T) {
if _, err := ParsePlanOutput(`{"plans":[]}`); err == nil {
t.Fatal("expected error for empty plans")
}
}
func TestParsePlanOutput_InvalidJSON(t *testing.T) {
if _, err := ParsePlanOutput(`not json`); err == nil {
t.Fatal("expected error for invalid json")
}
}
func TestParsePlanOutput_InvalidSlot(t *testing.T) {
raw := `{"plans":[{"title":"x","hairstyle":"y","hair_color":"#fff","items":[{"slot":"帽子","item_id":1,"name":"a","desc":"b","new_item":false}]}]}`
if _, err := ParsePlanOutput(raw); err == nil {
t.Fatal("expected error for invalid slot")
}
}
+39
View File
@@ -0,0 +1,39 @@
package agent
// SystemPromptPlan 穿搭规划系统提示词
func SystemPromptPlan() string {
return planSystemPrompt
}
const planSystemPrompt = `你是一位资深穿搭顾问与形象设计师,为用户的服装搭配与发型设计提供方案。
你的任务:根据用户输入(天气、场合、候选服装、身形)输出穿搭方案。
输出要求:
1. 只输出一个 JSON 对象,不要输出任何解释文字、前后缀或 markdown 代码围栏。
2. JSON 结构:
{"plans":[{"title":"方案名称","hairstyle":"发型名称","hair_color":"#十六进制色值","items":[{"slot":"上衣|下装|鞋|配饰","item_id":0,"name":"单品名称","desc":"搭配理由(不超过20字)","new_item":false}]}]}
3. slot 只能是:上衣/下装/鞋/配饰,每套方案 3-5 件单品。
4. 候选服装通过 item_id 引用:引用已有服装时 item_id 必须等于候选中的 id 且 new_item=false;确需新推荐的服装 item_id=0 且 new_item=true(最多 1 件)。
5. hairstyle 从候选发型列表中选一个最匹配的名称;hair_color 给出与该发色对应的十六进制颜色。
6. 充分考虑天气冷暖、场合正式程度与色彩协调。`
// BuildPlanUserInput 组装规划用 user 消息
func BuildPlanUserInput(weatherDesc, occasion string, candidates string, hairstyles string, bodyDesc string) string {
return "天气与日期:" + weatherDesc +
"\n场合:" + occasion +
"\n用户身形:" + bodyDesc +
"\n候选服装(JSON):" + candidates +
"\n可用发型(名称,风格):" + hairstyles +
"\n请输出 3 套方案。"
}
// BuildFallbackUserInput 组装兜底创作用 user 消息(全低分时)
func BuildFallbackUserInput(weatherDesc, occasion string, wardrobe string, hairstyles string, bodyDesc string) string {
return "天气与日期:" + weatherDesc +
"\n场合:" + occasion +
"\n用户身形:" + bodyDesc +
"\n用户已有服装(JSON,可选用):" + wardrobe +
"\n可用发型(名称,风格):" + hairstyles +
"\n已有服装搭配效果不佳,请重新设计 3 套高分方案(可新推荐服装,每套最多 2 件 new_item)。"
}
+55
View File
@@ -0,0 +1,55 @@
package agent
import "context"
// ==================== 工具 ====================
// ToolInfo 工具定义(包含执行函数)
type ToolInfo struct {
Name string
Description string
Parameters map[string]any
Func func(ctx context.Context, args map[string]any) (string, error)
}
// ToolCall 模型请求的工具调用
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
}
// ==================== 聊天消息 ====================
// ChatMessage 对话消息
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []*ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}
// ChatRequest 聊天请求
type ChatRequest struct {
Messages []*ChatMessage
MaxTokens int
Temperature float32
Stream bool
Tools []*ToolInfo
}
// ChatResponse 聊天响应
type ChatResponse struct {
Content string
ToolCalls []*ToolCall
}
// ==================== 角色常量 ====================
const (
RoleSystem = "system"
RoleUser = "user"
RoleAssistant = "assistant"
RoleTool = "tool"
)
+9
View File
@@ -0,0 +1,9 @@
package avatar
import "fmt"
// PackGlbUrl v1 打包:组合模板 URL(头部/身体/发型分层,App 端组合渲染)
func PackGlbUrl(faceTemplateId, bodyTemplateId, skinToneIndex int) string {
return fmt.Sprintf("/workspace/templates/avatar_f%d_b%d_s%d.glb",
faceTemplateId, bodyTemplateId, skinToneIndex)
}
+42
View File
@@ -0,0 +1,42 @@
package avatar
// FaceFeature 从照片+用户填写提取的化身特征(v1:肤色/身高/体重来自身形参数,照片贴图后续增强)
type FaceFeature struct {
SkinTone int // 1-5
HeightCm int
WeightKg int
}
// 预烘焙模板库索引(构建期产物,运行时只读常量)
const (
FaceTemplateCount = 20
BodyTemplateCount = 6
SkinToneLevels = 5
DefaultFaceTemplate = 5
DefaultBodyTemplate = 3
)
// MatchTemplates 特征 → 模板索引
// 身体模板:身高 145-190cm 映射 6 档;肤色 1-5 直接映射皮肤贴图档
func MatchTemplates(f *FaceFeature) (faceId, bodyId, skinIdx int) {
if f == nil {
return DefaultFaceTemplate, DefaultBodyTemplate, 3
}
skinIdx = f.SkinTone
if skinIdx < 1 {
skinIdx = 1
}
if skinIdx > SkinToneLevels {
skinIdx = SkinToneLevels
}
bodyId = (f.HeightCm-145)/8 + 1
if bodyId < 1 {
bodyId = 1
}
if bodyId > BodyTemplateCount {
bodyId = BodyTemplateCount
}
// v1 脸型固定默认模板(AI 人脸特征提取后替换,见 spec v2)
faceId = DefaultFaceTemplate
return
}
+56
View File
@@ -0,0 +1,56 @@
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
+17
View File
@@ -0,0 +1,17 @@
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"
)
@@ -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 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 *struct{}) (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,
BuildStatus: a.BuildStatus,
Error: a.Error,
}, nil
}
@@ -0,0 +1,41 @@
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,
FitParams: req.FitParams,
}); err != nil {
return nil, err
}
return &struct{}{}, nil
}
func (c *body_measurement) Get(ctx context.Context, req *struct{}) (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,
FitParams: b.FitParams,
}, 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 *struct{}) (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,63 @@
package controller
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
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
}
// PlanList 方案列表
func (c *outfit) PlanList(ctx context.Context, req *dto.OutfitPlanListReq) (res *dto.OutfitPlanListRes, err error) {
list, err := service.OutfitService.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.OutfitService.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.OutfitService.SelectMain(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId); err != nil {
return nil, err
}
return &struct{}{}, nil
}
// Review 方案反馈
func (c *outfit) Review(ctx context.Context, req *dto.OutfitReviewReq) (res *struct{}, err error) {
if err = service.OutfitService.Review(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId, req.Action, req.Note); 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
}
+47
View File
@@ -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 *struct{}) (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,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
}
+60
View File
@@ -0,0 +1,60 @@
package dao
import (
"context"
"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)
}
}
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
}
+58
View File
@@ -0,0 +1,58 @@
package dao
import (
"context"
"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)
}
}
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, fit_params, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'))",
data.UserId, data.Height, data.Weight, data.SkinTone, 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,
"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
}
+84
View File
@@ -0,0 +1,84 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var HairstyleAsset = &hairstyleAssetDao{}
type hairstyleAssetDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().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) {
var cnt int
r, err := g.DB().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Count()
if err != nil || r > 0 {
return
}
_ = cnt
items := []struct{ name, tag, face string; sort int }{
{"清爽短发", "清爽", "all", 1},
{"中分微卷", "温婉", "all", 2},
{"披肩长发", "优雅", "all", 3},
{"自然直发", "简约", "all", 4},
{"利落寸头", "干练", "all", 5},
{"高马尾", "活力", "all", 6},
{"丸子头", "可爱", "all", 7},
{"波浪卷发", "浪漫", "all", 8},
}
for i, it := range items {
_, _ = g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameHairstyleAsset+" (name, style_tag, glb_url, thumb_url, applicable_face, sort, created_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
it.name, it.tag, "/workspace/templates/hairstyle_"+itoa(i+1)+".glb", "/workspace/templates/hairstyle_thumb_"+itoa(i+1)+".png", it.face, it.sort)
}
}
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 := g.DB().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 := g.DB().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,76 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var OutfitGenTask = &outfitGenTaskDao{}
type outfitGenTaskDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().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)
}
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_gen_task_user ON "+consts.TableNameOutfitGenTask+"(user_id, created_at)")
}
func (d *outfitGenTaskDao) Insert(ctx context.Context, data *entity.OutfitGenerationTask) (int64, error) {
r, err := g.DB().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 := g.DB().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 := g.DB().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 := g.DB().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 := g.DB().Model(consts.TableNameOutfitGenTask).Ctx(ctx).
Where("status NOT IN (?)", g.Slice{consts.TaskStatusDone, consts.TaskStatusFailed}).
OrderAsc("id").Limit(50).Scan(&list)
return list, err
}
+84
View File
@@ -0,0 +1,84 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var OutfitPlan = &outfitPlanDao{}
type outfitPlanDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().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)
}
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_user ON "+consts.TableNameOutfitPlan+"(user_id, created_at)")
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_task ON "+consts.TableNameOutfitPlan+"(task_id)")
}
func (d *outfitPlanDao) Insert(ctx context.Context, data *entity.OutfitPlan) (int64, error) {
r, err := g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, 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)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *outfitPlanDao) ListByUser(ctx context.Context, userId int64) ([]*entity.OutfitPlan, error) {
var list []*entity.OutfitPlan
err := g.DB().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 := g.DB().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 := g.DB().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 := g.DB().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 := g.DB().Model(consts.TableNameOutfitPlan).Ctx(ctx).
Data(g.Map{"main_flag": 1}).Where("id", id).Update()
return err
}
+64
View File
@@ -0,0 +1,64 @@
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 {
_, _ = 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)
}
}
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
}
+73
View File
@@ -0,0 +1,73 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var PlanEffectImage = &planEffectImageDao{}
type planEffectImageDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().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)
}
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_effect_plan ON "+consts.TableNamePlanEffectImage+"(plan_id)")
}
func (d *planEffectImageDao) Insert(ctx context.Context, data *entity.PlanEffectImage) (int64, error) {
r, err := g.DB().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()
}
func (d *planEffectImageDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanEffectImage, error) {
var list []*entity.PlanEffectImage
err := g.DB().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 := g.DB().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 := g.DB().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 := g.DB().Model(consts.TableNamePlanEffectImage).Ctx(ctx).
Unscoped().Where("plan_id", planId).Delete()
return err
}
+55
View File
@@ -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 PlanOutfitItem = &planOutfitItemDao{}
type planOutfitItemDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().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)
}
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_item ON "+consts.TableNamePlanOutfitItem+"(plan_id)")
}
func (d *planOutfitItemDao) Insert(ctx context.Context, data *entity.PlanOutfitItem) (int64, error) {
r, err := g.DB().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()
}
func (d *planOutfitItemDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanOutfitItem, error) {
var list []*entity.PlanOutfitItem
err := g.DB().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 := g.DB().Model(consts.TableNamePlanOutfitItem).Ctx(ctx).
Unscoped().Where("plan_id", planId).Delete()
return err
}
+45
View File
@@ -0,0 +1,45 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var PlanReview = &planReviewDao{}
type planReviewDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().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 := g.DB().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 := g.DB().Model(consts.TableNamePlanReview).Ctx(ctx).
Where("user_id", userId).Where("plan_id", planId).OrderDesc("id").Limit(20).Scan(&list)
return list, err
}
+36
View File
@@ -0,0 +1,36 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var ScoringRule = &scoringRuleDao{}
type scoringRuleDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameScoringRule+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dimension TEXT NOT NULL DEFAULT '',
rule_type TEXT NOT NULL DEFAULT '',
rules_json TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create scoring_rule table failed: %v", err)
}
}
func (d *scoringRuleDao) ListEnabled(ctx context.Context) ([]*entity.ScoringRule, error) {
var list []*entity.ScoringRule
err := g.DB().Model(consts.TableNameScoringRule).Ctx(ctx).
Where("enabled", 1).OrderAsc("id").Scan(&list)
return list, err
}
+95
View File
@@ -0,0 +1,95 @@
package dao
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
)
var User = &userDao{}
type userDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUser+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL DEFAULT 'user',
username TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create user table failed: %v", err)
}
if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_username ON "+consts.TableNameUser+"(username) WHERE username != ''"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_user_username failed: %v", err)
}
if _, err := g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_phone ON "+consts.TableNameUser+"(phone) WHERE phone != ''"); err != nil {
g.Log().Warningf(ctx, "create index idx_slogan_user_phone failed: %v", err)
}
}
func clearUserCache(ctx context.Context, id int64) {
_, _ = gcache.Remove(ctx, "user_GetOne_"+gconv.String(id))
_, _ = gcache.Remove(ctx, "user_GetByAccount_")
}
func (d *userDao) Insert(ctx context.Context, data *entity.User) (int64, error) {
r, err := g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameUser+" (role, username, phone, password, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))",
data.Role, data.Username, data.Phone, data.Password, data.Name)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *userDao) GetOne(ctx context.Context, id int64) (*entity.User, error) {
var u entity.User
err := g.DB().Model(consts.TableNameUser).Ctx(ctx).
Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "user_GetOne_" + gconv.String(id)}).
Where("id", id).Scan(&u)
if err != nil {
return nil, err
}
if u.Id == 0 {
return nil, nil
}
return &u, nil
}
func (d *userDao) GetByAccount(ctx context.Context, account string) (*entity.User, error) {
var u entity.User
err := g.DB().Model(consts.TableNameUser).Ctx(ctx).
Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "user_GetByAccount_" + account}).
Where("username = ? OR phone = ?", account, account).Scan(&u)
if err != nil {
return nil, err
}
if u.Id == 0 {
return nil, nil
}
return &u, nil
}
func (d *userDao) Update(ctx context.Context, data *entity.User) error {
_, err := g.DB().Model(consts.TableNameUser).Ctx(ctx).Data(data).Where("id", data.Id).Update()
clearUserCache(ctx, data.Id)
return err
}
func (d *userDao) UpdateFields(ctx context.Context, id int64, data g.Map) error {
_, err := g.DB().Model(consts.TableNameUser).Ctx(ctx).Data(data).Where("id", id).Update()
clearUserCache(ctx, id)
return err
}
+64
View File
@@ -0,0 +1,64 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var UserPhoto = &userPhotoDao{}
type userPhotoDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserPhoto+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type INTEGER NOT NULL,
url TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create user_photo table failed: %v", err)
}
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_user_photo_user ON "+consts.TableNameUserPhoto+"(user_id, type)")
}
func (d *userPhotoDao) Insert(ctx context.Context, data *entity.UserPhoto) (int64, error) {
r, err := g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameUserPhoto+" (user_id, type, url, status, created_at) VALUES (?, ?, ?, ?, datetime('now','localtime'))",
data.UserId, data.Type, data.Url, data.Status)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *userPhotoDao) ListByUser(ctx context.Context, userId int64, photoType int) ([]*entity.UserPhoto, error) {
m := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx).Where("user_id", userId).Where("status", 1)
if photoType > 0 {
m = m.Where("type", photoType)
}
var list []*entity.UserPhoto
err := m.OrderAsc("type").OrderAsc("id").Scan(&list)
return list, err
}
func (d *userPhotoDao) GetOne(ctx context.Context, id, userId int64) (*entity.UserPhoto, error) {
var p entity.UserPhoto
err := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx).
Where("id", id).Where("user_id", userId).Scan(&p)
if err != nil || p.Id == 0 {
return nil, err
}
return &p, nil
}
func (d *userPhotoDao) Delete(ctx context.Context, id int64) error {
_, err := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx).Unscoped().Where("id", id).Delete()
return err
}
+79
View File
@@ -0,0 +1,79 @@
package dao
import (
"context"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
var WardrobeItem = &wardrobeItemDao{}
type wardrobeItemDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameWardrobeItem+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
photo_url TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '',
season TEXT NOT NULL DEFAULT '四季',
style_tags TEXT NOT NULL DEFAULT '',
color_info TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create wardrobe_item table failed: %v", err)
}
_, _ = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_wardrobe_user ON "+consts.TableNameWardrobeItem+"(user_id, category)")
}
func (d *wardrobeItemDao) Insert(ctx context.Context, data *entity.WardrobeItem) (int64, error) {
r, err := g.DB().Exec(ctx,
"INSERT INTO "+consts.TableNameWardrobeItem+" (user_id, photo_url, category, season, style_tags, color_info, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))",
data.UserId, data.PhotoUrl, data.Category, data.Season, data.StyleTags, data.ColorInfo, data.Status)
if err != nil {
return 0, err
}
return r.LastInsertId()
}
func (d *wardrobeItemDao) ListByUser(ctx context.Context, userId int64, category string) ([]*entity.WardrobeItem, error) {
m := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Where("user_id", userId).Where("status", 1)
if category != "" {
m = m.Where("category", category)
}
var list []*entity.WardrobeItem
err := m.OrderAsc("id").Scan(&list)
return list, err
}
func (d *wardrobeItemDao) ListAllByUser(ctx context.Context, userId int64) ([]*entity.WardrobeItem, error) {
var list []*entity.WardrobeItem
err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).
Where("user_id", userId).Where("status", 1).OrderAsc("id").Scan(&list)
return list, err
}
func (d *wardrobeItemDao) GetOne(ctx context.Context, id, userId int64) (*entity.WardrobeItem, error) {
var w entity.WardrobeItem
err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).
Where("id", id).Where("user_id", userId).Scan(&w)
if err != nil || w.Id == 0 {
return nil, err
}
return &w, nil
}
func (d *wardrobeItemDao) Update(ctx context.Context, id int64, data map[string]any) error {
_, err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Data(data).Where("id", id).Update()
return err
}
func (d *wardrobeItemDao) Delete(ctx context.Context, id int64) error {
_, err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Unscoped().Where("id", id).Delete()
return err
}
+49
View File
@@ -0,0 +1,49 @@
package imagegen
import (
"sync"
"time"
)
// cache 效果图 URL 缓存(key: 方案内容 hash:角度,24h TTL
type cache struct {
mu sync.Mutex
items map[string]cacheEntry
}
type cacheEntry struct {
url string
expiresAt time.Time
}
var effectCache = &cache{items: make(map[string]cacheEntry)}
// CacheGet 读取缓存 URL
func CacheGet(key string) (string, bool) {
return cacheGet(key)
}
// CacheSet 写入缓存 URL
func CacheSet(key, url string) {
cacheSet(key, url)
}
func cacheGet(key string) (string, bool) {
effectCache.mu.Lock()
defer effectCache.mu.Unlock()
e, ok := effectCache.items[key]
if !ok {
return "", false
}
if time.Now().After(e.expiresAt) {
delete(effectCache.items, key)
return "", false
}
return e.url, true
}
func cacheSet(key, url string) {
effectCache.mu.Lock()
defer effectCache.mu.Unlock()
effectCache.items[key] = cacheEntry{url: url, expiresAt: time.Now().Add(24 * time.Hour)}
}
+43
View File
@@ -0,0 +1,43 @@
package imagegen
import (
"context"
"fmt"
"github.com/gogf/gf/v2/frame/g"
)
// ImageGenClient 效果图生成客户端
type ImageGenClient interface {
// Generate 生成单张效果图,返回图片 URL
Generate(ctx context.Context, req *GenerateReq) (string, error)
}
// GenerateReq 生成请求
type GenerateReq struct {
BaseImageURL string // 用户全身照
Prompt string // 方案描述
Angle string // 正面/侧面/背面
Seed int64
}
// NewClient 按供应商创建客户端(config 未配置 Key 时强制 mock
func NewClient(supplier string) ImageGenClient {
if supplier == "wanx" {
key := g.Cfg().MustGet(context.Background(), "imagegen.wanx_api_key", "").String()
if key != "" {
return &wanxClient{
apiKey: key,
model: g.Cfg().MustGet(context.Background(), "imagegen.wanx_model", "wanx-v2").String(),
base: "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis",
}
}
}
return &mockClient{}
}
// buildPrompt 组装方案描述 prompt
func buildPrompt(planDesc, hairstyle, hairColor, angle string) string {
return fmt.Sprintf("时尚穿搭效果图,%s;发型:%s(发色 %s);角度:%s;人物写实、高清、全身、纯色背景",
planDesc, hairstyle, hairColor, angle)
}
+13
View File
@@ -0,0 +1,13 @@
package imagegen
import (
"context"
"fmt"
)
type mockClient struct{}
// Generate mock 客户端:返回占位图路径(开发联调用,不真实调用)
func (c *mockClient) Generate(ctx context.Context, req *GenerateReq) (string, error) {
return fmt.Sprintf("/workspace/mock/effect_%s.png", req.Angle), nil
}
+136
View File
@@ -0,0 +1,136 @@
package imagegen
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// wanxClient 通义万相人像写真(image-synthesis 异步接口 + 轮询)
type wanxClient struct {
apiKey string
model string
base string
}
type wanxSubmitReq struct {
Model string `json:"model"`
Input wanxInput `json:"input"`
Parameters map[string]any `json:"parameters,omitempty"`
}
type wanxInput struct {
Prompt string `json:"prompt"`
BaseImageURL string `json:"base_image_url,omitempty"`
BaseImagePath string `json:"base_image_path,omitempty"`
}
type wanxResp struct {
Output struct {
TaskID string `json:"task_id"`
TaskStatus string `json:"task_status"`
Results []struct {
URL string `json:"url"`
} `json:"results"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
type wanxTaskResp struct {
Output struct {
TaskStatus string `json:"task_status"`
Results []struct {
URL string `json:"url"`
} `json:"results"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
// Generate 提交任务并轮询直到完成,失败返回错误(由上层降级 mock)
func (c *wanxClient) Generate(ctx context.Context, req *GenerateReq) (string, error) {
body, err := json.Marshal(wanxSubmitReq{
Model: c.model,
Input: wanxInput{Prompt: buildPrompt(req.Prompt, "", "", req.Angle), BaseImageURL: req.BaseImageURL},
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
}
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, _ := io.ReadAll(resp.Body)
var r wanxResp
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) {
taskURL := c.base + "?task_id=" + taskID
client := &http.Client{Timeout: 30 * time.Second}
for i := 0; i < 60; i++ {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(5 * time.Second):
}
req, err := http.NewRequestWithContext(ctx, "GET", taskURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := client.Do(req)
if err != nil {
return "", err
}
data, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var r wanxTaskResp
if err := json.Unmarshal(data, &r); err != nil {
return "", fmt.Errorf("万相任务查询解析失败: %s", string(data))
}
switch r.Output.TaskStatus {
case "SUCCEEDED":
if len(r.Output.Results) > 0 && r.Output.Results[0].URL != "" {
return r.Output.Results[0].URL, nil
}
return "", fmt.Errorf("万相任务成功但无结果")
case "FAILED":
return "", fmt.Errorf("万相任务失败: %s", r.Message)
}
}
return "", fmt.Errorf("万相任务超时")
}
+201
View File
@@ -0,0 +1,201 @@
package dto
import (
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
type LoginReq struct {
g.Meta `path:"/login" method:"post" tags:"用户" summary:"登录"`
Account string `v:"required" json:"account"`
Password string `v:"required" json:"password"`
}
type LoginRes struct {
Token string `json:"token"`
User *LoginUser `json:"user"`
}
type LoginUser struct {
Id int64 `json:"id"`
Role string `json:"role"`
Name string `json:"name"`
}
type ChangePasswordReq struct {
g.Meta `path:"/change-password" method:"post" tags:"用户" summary:"修改密码"`
OldPassword string `v:"required" json:"old_password"`
NewPassword string `v:"required|min-length:6" json:"new_password"`
}
type RegisterReq struct {
g.Meta `path:"/register" method:"post" tags:"用户" summary:"注册"`
Account string `v:"required" json:"account"`
Password string `v:"required|min-length:6" json:"password"`
Name string `json:"name"`
}
type ProfileRes struct {
Id int64 `json:"id"`
Role string `json:"role"`
Name string `json:"name"`
Username string `json:"username"`
Phone string `json:"phone"`
}
type UserPhotoUploadReq struct {
g.Meta `path:"/upload" method:"post" tags:"照片" summary:"上传照片"`
Type int `v:"required|in:1,2,3,4" json:"type"`
}
type UserPhotoUploadRes struct {
Id int64 `json:"id"`
}
type UserPhotoListReq struct {
g.Meta `path:"/list" method:"get" tags:"照片" summary:"照片列表"`
Type int `json:"type"`
}
type UserPhotoListRes struct {
List []*entity.UserPhoto `json:"list"`
}
type UserPhotoDeleteReq struct {
g.Meta `path:"/delete" method:"post" tags:"照片" summary:"删除照片"`
Id int64 `v:"required" json:"id"`
}
type WardrobeUploadReq struct {
g.Meta `path:"/upload" method:"post" tags:"衣橱" summary:"上传服装"`
Category string `v:"required|in:上衣,下装,鞋,配饰" json:"category"`
Season string `json:"season"`
StyleTags string `json:"style_tags"`
ColorInfo string `json:"color_info"`
}
type WardrobeUploadRes struct {
Id int64 `json:"id"`
}
type WardrobeListReq struct {
g.Meta `path:"/list" method:"get" tags:"衣橱" summary:"衣橱列表"`
Category string `json:"category"`
}
type WardrobeListRes struct {
List []*entity.WardrobeItem `json:"list"`
}
type WardrobeUpdateReq struct {
g.Meta `path:"/update" method:"post" tags:"衣橱" summary:"更新服装"`
Id int64 `v:"required" json:"id"`
Category string `json:"category"`
Season string `json:"season"`
StyleTags string `json:"style_tags"`
}
type WardrobeDeleteReq struct {
g.Meta `path:"/delete" method:"post" tags:"衣橱" summary:"删除服装"`
Id int64 `v:"required" json:"id"`
}
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"`
}
type AvatarBuildReq struct {
g.Meta `path:"/build" method:"post" tags:"化身" summary:"构建化身"`
}
type AvatarBuildRes struct {
AvatarId int64 `json:"avatar_id"`
Status string `json:"status"`
}
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"`
Error string `json:"error"`
}
type HairstyleListRes struct {
List []*entity.HairstyleAsset `json:"list"`
}
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"`
}
type StoreListReq struct {
g.Meta `path:"/list" method:"get" tags:"门店" summary:"合作门店列表"`
Type int `json:"type"`
}
type StoreListRes struct {
List []*entity.PartnerStore `json:"list"`
}
+18
View File
@@ -0,0 +1,18 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type AvatarModel struct {
Id int64 `orm:"id" json:"id"`
UserId int64 `orm:"user_id" json:"user_id"`
FaceTemplateId int `orm:"face_template_id" json:"face_template_id"`
BodyTemplateId int `orm:"body_template_id" json:"body_template_id"`
SkinToneIndex int `orm:"skin_tone_index" json:"skin_tone_index"`
FaceTextureUrl string `orm:"face_texture_url" json:"face_texture_url"`
GlbUrl string `orm:"glb_url" json:"glb_url"`
BuildStatus string `orm:"build_status" json:"build_status"`
Error string `orm:"error" json:"error"`
ParamsSnapshot string `orm:"params_snapshot" json:"params_snapshot"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
}
@@ -0,0 +1,13 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type BodyMeasurement struct {
Id int64 `orm:"id" json:"id"`
UserId int64 `orm:"user_id" json:"user_id"`
Height int `orm:"height" json:"height"`
Weight int `orm:"weight" json:"weight"`
SkinTone int `orm:"skin_tone" json:"skin_tone"`
FitParams string `orm:"fit_params" json:"fit_params"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
}
@@ -0,0 +1,14 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type HairstyleAsset struct {
Id int64 `orm:"id" json:"id"`
Name string `orm:"name" json:"name"`
StyleTag string `orm:"style_tag" json:"style_tag"`
GlbUrl string `orm:"glb_url" json:"glb_url"`
ThumbUrl string `orm:"thumb_url" json:"thumb_url"`
ApplicableFace string `orm:"applicable_face" json:"applicable_face"`
Sort int `orm:"sort" json:"sort"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
@@ -0,0 +1,17 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type OutfitGenerationTask struct {
Id int64 `orm:"id" json:"id"`
UserId int64 `orm:"user_id" json:"user_id"`
StartDate string `orm:"start_date" json:"start_date"`
EndDate string `orm:"end_date" json:"end_date"`
Location string `orm:"location" json:"location"`
WeatherSnapshot string `orm:"weather_snapshot" json:"weather_snapshot"`
Status string `orm:"status" json:"status"`
Error string `orm:"error" json:"error"`
ModelName string `orm:"model_name" json:"model_name"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
}
+19
View File
@@ -0,0 +1,19 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type OutfitPlan struct {
Id int64 `orm:"id" json:"id"`
TaskId int64 `orm:"task_id" json:"task_id"`
UserId int64 `orm:"user_id" json:"user_id"`
DateRange string `orm:"date_range" json:"date_range"`
Location string `orm:"location" json:"location"`
Title string `orm:"title" json:"title"`
Source string `orm:"source" json:"source"`
Score int `orm:"score" json:"score"`
MainFlag int `orm:"main_flag" json:"main_flag"`
HairstyleId int64 `orm:"hairstyle_id" json:"hairstyle_id"`
HairColor string `orm:"hair_color" json:"hair_color"`
WeatherRef string `orm:"weather_ref" json:"weather_ref"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
+15
View File
@@ -0,0 +1,15 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type PartnerStore struct {
Id int64 `orm:"id" json:"id"`
Name string `orm:"name" json:"name"`
Type int `orm:"type" json:"type"`
Lat float64 `orm:"lat" json:"lat"`
Lng float64 `orm:"lng" json:"lng"`
Address string `orm:"address" json:"address"`
CommissionPolicy string `orm:"commission_policy" json:"commission_policy"`
Status int `orm:"status" json:"status"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
@@ -0,0 +1,14 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type PlanEffectImage struct {
Id int64 `orm:"id" json:"id"`
PlanId int64 `orm:"plan_id" json:"plan_id"`
Angle string `orm:"angle" json:"angle"`
Url string `orm:"url" json:"url"`
Status string `orm:"status" json:"status"`
PromptSnapshot string `orm:"prompt_snapshot" json:"prompt_snapshot"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
}
@@ -0,0 +1,15 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type PlanOutfitItem struct {
Id int64 `orm:"id" json:"id"`
PlanId int64 `orm:"plan_id" json:"plan_id"`
Slot string `orm:"slot" json:"slot"`
Source string `orm:"source" json:"source"`
WardrobeItemId int64 `orm:"wardrobe_item_id" json:"wardrobe_item_id"`
ProductName string `orm:"product_name" json:"product_name"`
Name string `orm:"name" json:"name"`
Desc string `orm:"desc" json:"desc"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
+12
View File
@@ -0,0 +1,12 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type PlanReview struct {
Id int64 `orm:"id" json:"id"`
PlanId int64 `orm:"plan_id" json:"plan_id"`
UserId int64 `orm:"user_id" json:"user_id"`
Action string `orm:"action" json:"action"`
Note string `orm:"note" json:"note"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
+13
View File
@@ -0,0 +1,13 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type ScoringRule struct {
Id int64 `orm:"id" json:"id"`
Dimension string `orm:"dimension" json:"dimension"`
RuleType string `orm:"rule_type" json:"rule_type"`
RulesJson string `orm:"rules_json" json:"rules_json"`
Enabled int `orm:"enabled" json:"enabled"`
Version int `orm:"version" json:"version"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
+14
View File
@@ -0,0 +1,14 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type User struct {
Id int64 `orm:"id" json:"id"`
Role string `orm:"role" json:"role"`
Username string `orm:"username" json:"username"`
Phone string `orm:"phone" json:"phone"`
Password string `orm:"password" json:"-"`
Name string `orm:"name" json:"name"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"`
}
+12
View File
@@ -0,0 +1,12 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type UserPhoto struct {
Id int64 `orm:"id" json:"id"`
UserId int64 `orm:"user_id" json:"user_id"`
Type int `orm:"type" json:"type"`
Url string `orm:"url" json:"url"`
Status int `orm:"status" json:"status"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
+15
View File
@@ -0,0 +1,15 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
type WardrobeItem struct {
Id int64 `orm:"id" json:"id"`
UserId int64 `orm:"user_id" json:"user_id"`
PhotoUrl string `orm:"photo_url" json:"photo_url"`
Category string `orm:"category" json:"category"`
Season string `orm:"season" json:"season"`
StyleTags string `orm:"style_tags" json:"style_tags"`
ColorInfo string `orm:"color_info" json:"color_info"`
Status int `orm:"status" json:"status"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
+106
View File
@@ -0,0 +1,106 @@
package scoring
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
}
+72
View File
@@ -0,0 +1,72 @@
package scoring
// completenessScore 层次完整度(20 分制):上衣+5 下装+5 鞋+5 配饰+5
func completenessScore(o CandidateOutfit) int {
score := 0
for _, it := range o.Items {
switch it.Category {
case "上衣":
score += 5
case "下装":
score += 5
case "鞋":
score += 5
case "配饰":
score += 5
}
}
if o.HasOuterwear {
score += 2
}
if score > 20 {
return 20
}
return score
}
// styleScore 风格一致性(10 分制):命中用户偏好标签每项 +2
func styleScore(o CandidateOutfit, ctx ScoreContext) int {
if len(ctx.StyleTags) == 0 {
return 5
}
score := 0
for _, it := range o.Items {
for _, tag := range ctx.StyleTags {
if tag != "" && it.StyleTags != "" && containsTag(it.StyleTags, tag) {
score += 2
}
}
}
if score > 10 {
return 10
}
return score
}
func containsTag(tags, tag string) bool {
for _, t := range splitTags(tags) {
if t == tag {
return true
}
}
return false
}
func splitTags(s string) []string {
var out []string
cur := ""
for _, c := range s {
if c == ',' || c == '' || c == ' ' {
if cur != "" {
out = append(out, cur)
cur = ""
}
continue
}
cur += string(c)
}
if cur != "" {
out = append(out, cur)
}
return out
}
+12
View File
@@ -0,0 +1,12 @@
package scoring
// 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
}
+29
View File
@@ -0,0 +1,29 @@
package scoring
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
}
+24
View File
@@ -0,0 +1,24 @@
package scoring
// 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 // 用户偏好标签
}
+36
View File
@@ -0,0 +1,36 @@
package scoring
// 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
}
+97
View File
@@ -0,0 +1,97 @@
package service
import (
"context"
"encoding/json"
"errors"
"slogan-agent/styleagent/avatar"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
)
type avatarService struct{}
var AvatarService = new(avatarService)
// Build 构建化身(v1 同步简化:模板匹配 + 记录,异步管线后续接入)
func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.AvatarModel, error) {
photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0)
if err != nil {
return nil, err
}
var hasHead, hasFull bool
for _, p := range photos {
if p.Type == consts.PhotoTypeHeadshot {
hasHead = true
}
if p.Type >= consts.PhotoTypeFullFront {
hasFull = true
}
}
if !hasHead {
return nil, errors.New("请先上传大头照")
}
if !hasFull {
return nil, errors.New("请先上传全身照")
}
bm, err := dao.BodyMeasurement.GetByUser(ctx, userId)
if err != nil {
return nil, err
}
feature := &avatar.FaceFeature{SkinTone: 3, HeightCm: 170, WeightKg: 60}
if bm != nil {
feature = &avatar.FaceFeature{SkinTone: bm.SkinTone, HeightCm: bm.Height, WeightKg: bm.Weight}
}
faceId, bodyId, skinIdx := avatar.MatchTemplates(feature)
// 已有化身则重建(更新模板索引),否则新建
existing, _ := dao.AvatarModel.GetByUser(ctx, userId)
if existing != nil {
err := dao.AvatarModel.Update(ctx, existing.Id, map[string]any{
"face_template_id": faceId, "body_template_id": bodyId,
"skin_tone_index": skinIdx, "glb_url": avatar.PackGlbUrl(faceId, bodyId, skinIdx),
"build_status": consts.AvatarBuildDone, "error": "",
"params_snapshot": mustJSON(map[string]any{
"height_cm": feature.HeightCm, "weight_kg": feature.WeightKg, "skin_tone": skinIdx,
}),
"updated_at": "datetime('now','localtime')",
})
if err != nil {
return nil, err
}
existing.FaceTemplateId = faceId
existing.BodyTemplateId = bodyId
existing.SkinToneIndex = skinIdx
existing.GlbUrl = avatar.PackGlbUrl(faceId, bodyId, skinIdx)
existing.BuildStatus = consts.AvatarBuildDone
return existing, nil
}
_, err = dao.AvatarModel.Insert(ctx, &entity.AvatarModel{
UserId: userId,
FaceTemplateId: faceId,
BodyTemplateId: bodyId,
SkinToneIndex: skinIdx,
GlbUrl: avatar.PackGlbUrl(faceId, bodyId, skinIdx),
BuildStatus: consts.AvatarBuildDone,
ParamsSnapshot: mustJSON(map[string]any{
"height_cm": feature.HeightCm, "weight_kg": feature.WeightKg, "skin_tone": skinIdx,
}),
})
if err != nil {
return nil, err
}
return dao.AvatarModel.GetByUser(ctx, userId)
}
func (s *avatarService) Get(ctx context.Context, userId int64) (*entity.AvatarModel, error) {
return dao.AvatarModel.GetByUser(ctx, userId)
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
@@ -0,0 +1,30 @@
package service
import (
"context"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
)
type bodyMeasurementService struct{}
var BodyMeasurementService = new(bodyMeasurementService)
func (s *bodyMeasurementService) Save(ctx context.Context, userId int64, req *entity.BodyMeasurement) error {
if req.Height == 0 {
req.Height = 170
}
if req.Weight == 0 {
req.Weight = 60
}
if req.SkinTone == 0 {
req.SkinTone = 3
}
req.UserId = userId
return dao.BodyMeasurement.Save(ctx, req)
}
func (s *bodyMeasurementService) Get(ctx context.Context, userId int64) (*entity.BodyMeasurement, error) {
return dao.BodyMeasurement.GetByUser(ctx, userId)
}
+130
View File
@@ -0,0 +1,130 @@
package service
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/imagegen"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
)
type effectImageService struct{}
var EffectImageService = new(effectImageService)
var effectAngles = []string{"正面", "侧面", "背面"}
// GenerateForPlan 选定主方案后异步生成 3 视角效果图
func (s *effectImageService) GenerateForPlan(ctx context.Context, planId, userId int64) {
go s.run(ctx, planId, userId)
}
func (s *effectImageService) run(ctx context.Context, planId, userId int64) {
plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId)
if err != nil || plan == nil {
g.Log().Errorf(ctx, "效果图任务: 方案不存在 planId=%d", planId)
return
}
// 每日免费次数校验
limit := dailyEffectLimit(ctx)
if limit > 0 {
used, _ := dao.PlanEffectImage.CountByUserToday(ctx, userId)
if used >= limit {
g.Log().Warningf(ctx, "效果图任务: 用户 %d 当日次数已用尽(%d/%d", userId, used, limit)
return
}
}
_ = dao.OutfitGenTask.UpdateStatus(ctx, plan.TaskId, consts.TaskStatusRendering, "")
defer dao.OutfitGenTask.UpdateStatus(ctx, plan.TaskId, consts.TaskStatusDone, "")
items, _ := dao.PlanOutfitItem.ListByPlan(ctx, planId)
planDesc := planTitleDesc(plan.Title, items)
// 用户全身正面照作 base image
baseImageURL := ""
if photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0); err == nil {
for _, p := range photos {
if p.Type == consts.PhotoTypeFullFront {
baseImageURL = p.Url
break
}
}
}
client := imagegen.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "mock").String())
for i, angle := range effectAngles {
cacheKey := effectCacheKey(plan, angle)
if url, ok := imagegen.CacheGet(cacheKey); ok {
_, _ = dao.PlanEffectImage.Insert(ctx, &entity.PlanEffectImage{
PlanId: planId, Angle: angle, Url: url, Status: consts.EffectStatusDone,
PromptSnapshot: planDesc,
})
continue
}
recId, err := dao.PlanEffectImage.Insert(ctx, &entity.PlanEffectImage{
PlanId: planId, Angle: angle, Status: consts.EffectStatusRendering,
PromptSnapshot: planDesc,
})
if err != nil {
continue
}
url, err := client.Generate(ctx, &imagegen.GenerateReq{
BaseImageURL: baseImageURL, Prompt: planDesc, Angle: angle, Seed: plan.Id*100 + int64(i),
})
if err != nil {
g.Log().Warningf(ctx, "效果图生成失败 plan=%d angle=%s: %v", planId, angle, err)
_ = dao.PlanEffectImage.UpdateStatus(ctx, recId, consts.EffectStatusFailed, "")
continue
}
imagegen.CacheSet(cacheKey, url)
_ = dao.PlanEffectImage.UpdateStatus(ctx, recId, consts.EffectStatusDone, url)
}
g.Log().Infof(ctx, "方案 %d 效果图生成完成", planId)
}
func planTitleDesc(title string, items []*entity.PlanOutfitItem) string {
var sb strings.Builder
sb.WriteString("方案:")
sb.WriteString(title)
sb.WriteString("")
for _, it := range items {
sb.WriteString(it.Slot)
sb.WriteString("")
sb.WriteString(it.Name)
sb.WriteString("")
}
return strings.TrimSuffix(sb.String(), "")
}
func effectCacheKey(plan *entity.OutfitPlan, angle string) string {
sum := md5.Sum([]byte(fmt.Sprintf("%d:%s:%s", plan.Id, plan.Title, angle)))
return "plan:" + hex.EncodeToString(sum[:])
}
func dailyEffectLimit(ctx context.Context) int {
limit := consts.DefaultDailyEffectLimit
rules, err := dao.ScoringRule.ListEnabled(ctx)
if err != nil {
return limit
}
for _, r := range rules {
if r.Dimension == "effect_limit" {
var v struct {
Daily int `json:"daily"`
}
if json.Unmarshal([]byte(r.RulesJson), &v) == nil && v.Daily > 0 {
return v.Daily
}
}
}
return limit
}
+51
View File
@@ -0,0 +1,51 @@
package service
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)
}
+16
View File
@@ -0,0 +1,16 @@
package service
import (
"context"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
)
type hairstyleService struct{}
var HairstyleService = new(hairstyleService)
func (s *hairstyleService) List(ctx context.Context) ([]*entity.HairstyleAsset, error) {
return dao.HairstyleAsset.ListAll(ctx)
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"slogan-agent/styleagent/model/entity"
"slogan-agent/styleagent/scoring"
)
// candidateSet 一套预筛组合
type candidateSet struct {
Items []*entity.WardrobeItem
HasOuterwear bool
}
// combineCandidates 预筛组合算法:
// 按 category 分组 → 按季节过滤 → 确定性轮询组合,最多 3 套互不相同(含外套标记)
func combineCandidates(items []*entity.WardrobeItem, season string, maxSets int) []candidateSet {
groups := map[string][]*entity.WardrobeItem{}
for _, it := range items {
if season != "" && it.Season != "" && it.Season != "四季" && it.Season != season {
continue
}
groups[it.Category] = append(groups[it.Category], it)
}
if len(groups) == 0 || maxSets <= 0 {
return nil
}
var sets []candidateSet
cats := []string{"上衣", "下装", "鞋", "配饰"}
for i := 0; i < maxSets; i++ {
set := candidateSet{}
hasOuterwear := false
for _, cat := range cats {
g := groups[cat]
if len(g) == 0 {
continue
}
it := g[i%len(g)]
set.Items = append(set.Items, it)
if isOuterwear(it) {
hasOuterwear = true
}
}
if len(set.Items) == 0 {
break
}
set.HasOuterwear = hasOuterwear
sets = append(sets, set)
}
return sets
}
func isOuterwear(it *entity.WardrobeItem) bool {
return it.Category == "上衣" && (it.StyleTags == "" || it.StyleTags == "外套")
}
// toScoringOutfit 转评分用候选
func toScoringOutfit(set candidateSet) scoring.CandidateOutfit {
o := scoring.CandidateOutfit{HasOuterwear: set.HasOuterwear}
for _, it := range set.Items {
o.Items = append(o.Items, scoring.WardrobeItem{
Category: it.Category,
Season: it.Season,
ColorInfo: it.ColorInfo,
StyleTags: it.StyleTags,
})
}
return o
}
+355
View File
@@ -0,0 +1,355 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"slogan-agent/styleagent/agent"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/model/entity"
"slogan-agent/styleagent/scoring"
"slogan-agent/styleagent/weather"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
type outfitService struct{}
var OutfitService = new(outfitService)
// Generate 创建生成任务(pending)并异步执行核心流程
func (s *outfitService) Generate(ctx context.Context, userId int64, req *dto.OutfitGenerateReq) (int64, error) {
if req.StartDate > req.EndDate {
return 0, errors.New("开始日期不能晚于结束日期")
}
items, err := dao.WardrobeItem.ListAllByUser(ctx, userId)
if err != nil {
return 0, err
}
if len(items) < 3 {
return 0, errors.New("衣橱服装不足,请先添加至少 3 件服装")
}
taskId, err := dao.OutfitGenTask.Insert(ctx, &entity.OutfitGenerationTask{
UserId: userId, StartDate: req.StartDate, EndDate: req.EndDate,
Location: req.Location, Status: consts.TaskStatusPending,
})
if err != nil {
return 0, err
}
// 异步执行:传入独立 ctx(请求结束不中断任务)
go runGenerateTask(gctx.New(), taskId, userId)
return taskId, nil
}
// StartWorker 服务启动时恢复未完成任务(标记失败,避免重启后重复消耗 LLM 费用)
func (s *outfitService) StartWorker(ctx context.Context) {
tasks, err := dao.OutfitGenTask.ListUnfinished(ctx)
if err != nil {
g.Log().Warningf(ctx, "恢复未完成任务失败: %v", err)
return
}
for _, t := range tasks {
_ = dao.OutfitGenTask.UpdateStatus(ctx, t.Id, consts.TaskStatusFailed, "服务重启,任务中断,请重新生成")
g.Log().Infof(ctx, "任务 %d 已标记 failed(服务重启)", t.Id)
}
}
// runGenerateTask 任务核心流程:planning → scoring → done/failed
func runGenerateTask(ctx context.Context, taskId, userId int64) {
setTask := func(status, msg string) {
_ = dao.OutfitGenTask.UpdateStatus(ctx, taskId, status, msg)
}
fail := func(err error) {
setTask(consts.TaskStatusFailed, err.Error())
g.Log().Errorf(ctx, "生成任务 %d 失败: %v", taskId, err)
}
task, err := dao.OutfitGenTask.GetOne(ctx, taskId, userId)
if err != nil {
fail(fmt.Errorf("读取任务失败: %w", err))
return
}
if task == nil {
return
}
// 1. 天气(评分依赖,失败则任务失败)
setTask(consts.TaskStatusPlanning, "")
weatherResult, err := GetWeather(ctx, task.Location, task.StartDate, task.EndDate)
if err != nil {
fail(err)
return
}
weatherJSON, _ := json.Marshal(weatherResult)
_ = dao.OutfitGenTask.Update(ctx, taskId, g.Map{"weather_snapshot": string(weatherJSON), "model_name": g.Cfg().MustGet(ctx, "llm.model_name", "").String()})
// 2. LLM 配置
cfg, err := agent.GetModelConfig(ctx)
if err != nil {
fail(err)
return
}
// 3. 预筛 3 套候选
items, err := dao.WardrobeItem.ListAllByUser(ctx, userId)
if err != nil {
fail(err)
return
}
sets := combineCandidates(items, weatherResult.Season, 3)
if len(sets) == 0 {
fail(errors.New("没有符合当前季节的服装,请补充衣橱"))
return
}
// 4. LLM 规划(1 次调用)
hairstyles := hairstyleListText(ctx)
bodyDesc := bodyDescText(ctx, userId)
occasion := "通勤"
candidates := make([]agent.CandidateData, 0, len(sets)*3)
for si, set := range sets {
for _, it := range set.Items {
candidates = append(candidates, agent.CandidateData{
SetId: int64(si + 1), ItemId: it.Id, Category: it.Category,
Name: it.Category, Color: it.ColorInfo, Season: it.Season, Style: it.StyleTags,
})
}
}
userInput := agent.BuildPlanUserInput(weatherSummaryText(weatherResult), occasion, "", hairstyles, bodyDesc)
out, err := agent.PlanOutfits(ctx, cfg, agent.SystemPromptPlan(), userInput, candidates)
if err != nil {
fail(err)
return
}
// 5. 规则评分
setTask(consts.TaskStatusScoring, "")
threshold := scoringThreshold(ctx)
plans := out.Plans
ctxScore := scoring.ScoreContext{
TempAvg: weatherResult.AvgTemp, Season: weatherResult.Season,
Occasion: occasion, Weekday: weekdayOf(task.StartDate),
}
scores := make([]int, len(plans))
allLow := true
for i, p := range plans {
score := scorePlan(p, items, ctxScore)
scores[i] = score
if score >= threshold {
allLow = false
}
}
// 6. 全低分 → LLM 兜底创作(1 次调用)
if allLow {
g.Log().Infof(ctx, "任务 %d 预筛方案全低分,触发兜底创作", taskId)
wardrobeJSON, _ := json.Marshal(items)
fallbackInput := agent.BuildFallbackUserInput(weatherSummaryText(weatherResult), occasion, string(wardrobeJSON), hairstyles, bodyDesc)
fallback, err := agent.CreateRecommendPlan(ctx, cfg, agent.SystemPromptPlan(), fallbackInput)
if err != nil {
fail(err)
return
}
plans = fallback.Plans
scores = make([]int, len(plans))
for i, p := range plans {
scores[i] = scorePlan(p, items, ctxScore)
}
}
// 7. 落库 plan + items
hairstylesAll, _ := dao.HairstyleAsset.ListAll(ctx)
dateRange := task.StartDate + " ~ " + task.EndDate
weatherRef := weatherSummaryText(weatherResult)
for i, p := range plans {
planId, err := dao.OutfitPlan.Insert(ctx, &entity.OutfitPlan{
TaskId: taskId, UserId: userId, DateRange: dateRange, Location: task.Location,
Title: p.Title, Source: planSource(p), Score: scores[i],
HairstyleId: matchHairstyle(p.Hairstyle, hairstylesAll), HairColor: p.HairColor,
WeatherRef: weatherRef,
})
if err != nil {
fail(err)
return
}
for _, it := range p.Items {
source := consts.PlanSourceWardrobe
if it.NewItem || it.ItemId == 0 {
source = consts.PlanSourceRecommend
}
_, err := dao.PlanOutfitItem.Insert(ctx, &entity.PlanOutfitItem{
PlanId: planId, Slot: it.Slot, Source: source,
WardrobeItemId: it.ItemId, ProductName: "", Name: it.Name, Desc: it.Desc,
})
if err != nil {
fail(err)
return
}
}
}
setTask(consts.TaskStatusDone, "")
g.Log().Infof(ctx, "任务 %d 完成,共 %d 套方案", taskId, len(plans))
}
func scorePlan(p agent.PlanCandidate, items []*entity.WardrobeItem, ctxScore scoring.ScoreContext) int {
var out scoring.CandidateOutfit
byId := map[int64]*entity.WardrobeItem{}
for _, it := range items {
byId[it.Id] = it
}
for _, it := range p.Items {
if w := byId[it.ItemId]; w != nil {
out.Items = append(out.Items, scoring.WardrobeItem{
Category: w.Category, Season: w.Season, ColorInfo: w.ColorInfo, StyleTags: w.StyleTags,
})
}
}
return scoring.Score(&out, &ctxScore)
}
// ==================== 查询/操作 ====================
func (s *outfitService) GetTaskStatus(ctx context.Context, userId, taskId int64) (string, string, error) {
t, err := dao.OutfitGenTask.GetOne(ctx, taskId, userId)
if err != nil || t == nil {
return "", "", errors.New("任务不存在")
}
return t.Status, t.Error, nil
}
func (s *outfitService) ListPlans(ctx context.Context, userId int64) ([]*entity.OutfitPlan, error) {
return dao.OutfitPlan.ListByUser(ctx, userId)
}
func (s *outfitService) GetPlanDetail(ctx context.Context, userId, planId int64) (*dto.OutfitPlanDetailRes, error) {
plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId)
if err != nil || plan == nil {
return nil, errors.New("方案不存在")
}
res := &dto.OutfitPlanDetailRes{Plan: plan}
res.Items, err = dao.PlanOutfitItem.ListByPlan(ctx, planId)
if err != nil {
return nil, err
}
res.Images, err = dao.PlanEffectImage.ListByPlan(ctx, planId)
if err != nil {
return nil, err
}
if plan.HairstyleId > 0 {
res.Hairstyle, _ = dao.HairstyleAsset.GetOne(ctx, plan.HairstyleId)
}
return res, nil
}
// SelectMain 选定主方案(同任务其他方案清零)+ 异步生成效果图
func (s *outfitService) SelectMain(ctx context.Context, userId, planId int64) error {
plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId)
if err != nil || plan == nil {
return errors.New("方案不存在")
}
if err := dao.OutfitPlan.ClearMainFlag(ctx, plan.TaskId); err != nil {
return err
}
if err := dao.OutfitPlan.SetMainFlag(ctx, planId); err != nil {
return err
}
// 异步生成 3 视角效果图
EffectImageService.GenerateForPlan(gctx.New(), planId, userId)
return nil
}
func (s *outfitService) Review(ctx context.Context, userId, planId int64, action, note string) error {
plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId)
if err != nil || plan == nil {
return errors.New("方案不存在")
}
_, err = dao.PlanReview.Insert(ctx, &entity.PlanReview{PlanId: planId, UserId: userId, Action: action, Note: note})
return err
}
// ==================== 内部辅助 ====================
func planSource(p agent.PlanCandidate) string {
for _, it := range p.Items {
if it.NewItem || it.ItemId == 0 {
return consts.PlanSourceRecommend
}
}
return consts.PlanSourceWardrobe
}
func matchHairstyle(name string, all []*entity.HairstyleAsset) int64 {
if name == "" {
return 0
}
for _, h := range all {
if h.Name == name {
return h.Id
}
}
return 0
}
func hairstyleListText(ctx context.Context) string {
all, err := dao.HairstyleAsset.ListAll(ctx)
if err != nil {
return ""
}
text := ""
for i, h := range all {
if i > 0 {
text += ";"
}
text += h.Name + "," + h.StyleTag
}
return text
}
func bodyDescText(ctx context.Context, userId int64) string {
bm, err := dao.BodyMeasurement.GetByUser(ctx, userId)
if err != nil || bm == nil {
return "身高 170cm,体重 60kg(默认)"
}
return fmt.Sprintf("身高 %dcm,体重 %dkg,肤色 %d 档", bm.Height, bm.Weight, bm.SkinTone)
}
func weatherSummaryText(w *weather.WeatherResult) string {
return fmt.Sprintf("%s%s),平均 %d℃,%d 天", w.CityCode, w.Season, w.AvgTemp, len(w.Days))
}
func weekdayOf(date string) string {
d, err := time.Parse("2006-01-02", date)
if err != nil {
return "workday"
}
wd := d.Weekday()
if wd == time.Saturday || wd == time.Sunday {
return "weekend"
}
return "workday"
}
func scoringThreshold(ctx context.Context) int {
threshold := consts.DefaultScoreThreshold
rules, err := dao.ScoringRule.ListEnabled(ctx)
if err != nil {
return threshold
}
for _, r := range rules {
if r.Dimension == "threshold" {
var v struct {
Pass int `json:"pass"`
}
if json.Unmarshal([]byte(r.RulesJson), &v) == nil && v.Pass > 0 {
return v.Pass
}
}
}
return threshold
}
@@ -0,0 +1,17 @@
package service
import (
"context"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
)
type partnerStoreService struct{}
var PartnerStoreService = new(partnerStoreService)
// List 合作门店列表(type 为 0 返回全部)
func (s *partnerStoreService) List(ctx context.Context, storeType int) ([]*entity.PartnerStore, error) {
return dao.PartnerStore.List(ctx, storeType)
}
+44
View File
@@ -0,0 +1,44 @@
package service
import (
"context"
"errors"
"fmt"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/net/ghttp"
)
type userPhotoService struct{}
var UserPhotoService = new(userPhotoService)
func (s *userPhotoService) Upload(ctx context.Context, userId int64, photoType int, file *ghttp.UploadFile) (int64, error) {
url, err := SaveUploadedFile(file, fmt.Sprintf("user_%d/photos", userId))
if err != nil {
return 0, err
}
return dao.UserPhoto.Insert(ctx, &entity.UserPhoto{
UserId: userId,
Type: photoType,
Url: url,
Status: 1,
})
}
func (s *userPhotoService) List(ctx context.Context, userId int64, photoType int) ([]*entity.UserPhoto, error) {
return dao.UserPhoto.ListByUser(ctx, userId, photoType)
}
func (s *userPhotoService) Delete(ctx context.Context, userId, id int64) error {
p, err := dao.UserPhoto.GetOne(ctx, id, userId)
if err != nil || p == nil {
return errors.New("照片不存在")
}
if err := RemoveWorkspaceFile(p.Url); err != nil {
return err
}
return dao.UserPhoto.Delete(ctx, id)
}
+83
View File
@@ -0,0 +1,83 @@
package service
import (
"context"
"errors"
"time"
"slogan-agent/common"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type userService struct{}
var UserService = new(userService)
func (s *userService) Register(ctx context.Context, account, password, name string) (int64, error) {
if account == "" || password == "" {
return 0, errors.New("账号和密码不能为空")
}
existing, _ := dao.User.GetByAccount(ctx, account)
if existing != nil {
return 0, errors.New("账号已存在")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return 0, err
}
if name == "" {
name = account
}
return dao.User.Insert(ctx, &entity.User{
Role: "user",
Username: account,
Password: string(hash),
Name: name,
})
}
func (s *userService) Login(ctx context.Context, account, password string) (*entity.User, string, error) {
if account == "" {
return nil, "", errors.New("请输入账号")
}
user, err := dao.User.GetByAccount(ctx, account)
if err != nil || user == nil {
return nil, "", errors.New("账号不存在")
}
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
return nil, "", errors.New("密码错误")
}
now := time.Now()
claims := common.JwtClaims{
UserId: user.Id,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(common.GetJwtSecret()))
if err != nil {
return nil, "", err
}
return user, token, nil
}
func (s *userService) ChangePassword(ctx context.Context, userId int64, oldPwd, newPwd string) error {
user, err := dao.User.GetOne(ctx, userId)
if err != nil || user == nil {
return errors.New("用户不存在")
}
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPwd)) != nil {
return errors.New("原密码错误")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost)
if err != nil {
return err
}
return dao.User.UpdateFields(ctx, userId, map[string]any{"password": string(hash)})
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"context"
"errors"
"fmt"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type wardrobeService struct{}
var WardrobeService = new(wardrobeService)
func (s *wardrobeService) Upload(ctx context.Context, userId int64, req entity.WardrobeItem, file *ghttp.UploadFile) (int64, error) {
url, err := SaveUploadedFile(file, fmt.Sprintf("user_%d/wardrobe", userId))
if err != nil {
return 0, err
}
req.UserId = userId
req.PhotoUrl = url
req.Status = 1
if req.Season == "" {
req.Season = "四季"
}
return dao.WardrobeItem.Insert(ctx, &req)
}
func (s *wardrobeService) List(ctx context.Context, userId int64, category string) ([]*entity.WardrobeItem, error) {
return dao.WardrobeItem.ListByUser(ctx, userId, category)
}
func (s *wardrobeService) Update(ctx context.Context, userId, id int64, data g.Map) error {
item, err := dao.WardrobeItem.GetOne(ctx, id, userId)
if err != nil || item == nil {
return errors.New("服装不存在")
}
return dao.WardrobeItem.Update(ctx, id, data)
}
func (s *wardrobeService) Delete(ctx context.Context, userId, id int64) error {
item, err := dao.WardrobeItem.GetOne(ctx, id, userId)
if err != nil || item == nil {
return errors.New("服装不存在")
}
if err := RemoveWorkspaceFile(item.PhotoUrl); err != nil {
return err
}
return dao.WardrobeItem.Delete(ctx, id)
}
+29
View File
@@ -0,0 +1,29 @@
package service
import (
"context"
"fmt"
"time"
"slogan-agent/styleagent/weather"
)
var weatherCache = weather.NewCache(6 * time.Hour)
// GetWeather 地点 + 日期范围 → 天气结果(高德地理编码 + 和风 7 天预报,缓存 6 小时)
func GetWeather(ctx context.Context, location, startDate, endDate string) (*weather.WeatherResult, error) {
cityCode, err := weather.GetCityCode(ctx, location)
if err != nil {
return nil, err
}
cacheKey := fmt.Sprintf("%s:%s:%s", cityCode, startDate, endDate)
if result, ok := weatherCache.Get(cacheKey); ok {
return result, nil
}
result, err := weather.GetDaily(ctx, cityCode, startDate, endDate)
if err != nil {
return nil, err
}
weatherCache.Set(cacheKey, result)
return result, nil
}
+41
View File
@@ -0,0 +1,41 @@
package weather
import (
"sync"
"time"
)
type cacheEntry struct {
data *WeatherResult
expiresAt time.Time
}
type Cache struct {
mu sync.Mutex
ttl time.Duration
items map[string]cacheEntry
}
func NewCache(ttl time.Duration) *Cache {
return &Cache{ttl: ttl, items: make(map[string]cacheEntry)}
}
func (c *Cache) Get(key string) (*WeatherResult, 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 *WeatherResult) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = cacheEntry{data: data, expiresAt: time.Now().Add(c.ttl)}
}
+54
View File
@@ -0,0 +1,54 @@
package weather
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
}
+110
View File
@@ -0,0 +1,110 @@
package weather
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, _ := strconv.Atoi(d.TempMax)
minV, _ := strconv.Atoi(d.TempMin)
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 "冬"
}
}