Add 'server/' from commit 'e64421295fff83acbb6d6ab3d3b27f3ef8368f00'

git-subtree-dir: server
git-subtree-mainline: c4e617ada7
git-subtree-split: e64421295f
This commit is contained in:
2026-08-04 15:02:35 +08:00
176 changed files with 13532 additions and 0 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,933 @@
# slogan-agent MVP 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现 slogan-agent 服务端 MVP:登录 → 照片/衣橱/身形管理 → 化身构建(v1 模板匹配)→ 穿搭生成(规则评分 + Agent 规划 + 兜底)→ 效果图按需生成,全链路可运行。
**Architecture:** Go 单体 + GoFrame v2 + SQLite,严格遵循 video-factory 分层规范(controller → service → dao),包级单例,RouteRegister 反射注册路由,JWT 鉴权,OpenAI 兼容 LLM。规则评分零 LLM 成本,效果图按需生成 + 缓存。
**Tech Stack:** Go 1.22+ / GoFrame v2.10 / SQLite / JWT / bcrypt / OpenAI 兼容 API / 和风天气 API
**参考代码(必须阅读后复制模式):**
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/`http.go / auth.go / base_dao.go / cache.go / auth_middleware.go / util.go
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/agent/chat_model.go`(直接复用整个文件,改包名)
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/`controller/service/dao/model 全部模式)
- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/main.go`(入口模式)
**数据库:** `slogan.db`config.yml 配置),表名前缀 `slogan_`。所有 init() 自动建表 + ALTER 迁移兼容。
**模块路径:** `slogan-agent`go.mod module name),业务包 `styleagent`
---
## 数据库表总览(Task 2-3 建全)
| 表 | 关键字段 |
|----|---------|
| `slogan_user` | id, role(default 'user'), username, phone, password, name, created_at, updated_at |
| `slogan_user_photo` | id, user_id, type(1大头照 2全身正面 3全身侧面 4全身背面), url, status, created_at |
| `slogan_wardrobe_item` | id, user_id, photo_url, category(上衣/下装/鞋/配饰), season(春/夏/秋/冬/四季), style_tags, color_info, status, created_at |
| `slogan_body_measurement` | id, user_id, height, weight, skin_tone(1-5), fit_params(JSON), updated_at |
| `slogan_avatar_model` | id, user_id, face_template_id, body_template_id, skin_tone_index, face_texture_url, glb_url, build_status(pending/processing/done/failed), error, params_snapshot(JSON), created_at |
| `slogan_hairstyle_asset` | id, name, style_tag, glb_url, thumb_url, applicable_face, sort |
| `slogan_outfit_generation_task` | id, user_id, start_date, end_date, location, weather_snapshot(JSON), status(pending/planning/scoring/rendering/done/failed), error, model_name, created_at |
| `slogan_outfit_plan` | id, task_id, user_id, date_range, location, source(wardrobe/recommend), score, main_flag(0/1), hairstyle_id, hair_color, weather_ref(JSON), created_at |
| `slogan_plan_outfit_item` | id, plan_id, slot(发型/上衣/下装/鞋/配饰), source, wardrobe_item_id, product_name, name, desc |
| `slogan_plan_effect_image` | id, plan_id, angle(正面/侧面/背面), url, status, prompt_snapshot, created_at |
| `slogan_plan_review` | id, plan_id, user_id, action(fav/unfav), note, created_at |
| `slogan_scoring_rule` | id, dimension, rule_type, rules_json, enabled, version |
---
### Task 1: 项目骨架(go.mod / config / main.go / common 复制)
**Files:**
- Create: `go.mod`
- Create: `config.yml`
- Create: `main.go`
- Copy: `common/http.go`, `common/auth.go`, `common/base_dao.go`, `common/cache.go`, `common/util.go`, `common/auth_middleware.go`(从 video-factory 复制,改 package 注释即可,无需改逻辑)
- [ ] **Step 1: 创建 go.mod**
```bash
cd slogan-agent
go mod init slogan-agent
```
- [ ] **Step 2: 创建 config.yml**
```yaml
database:
default:
name: slogan.db
type: sqlite
debug: false
cache:
ttl: 60
server:
address: :3007
name: slogan
workerId: 1
clientMaxBodySize: 209715200
requestTimeout: 3000
chat:
timeout: 300
max_retries: 3
```
- [ ] **Step 3: 复制 common 包**
```bash
cp /Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/{http.go,auth.go,base_dao.go,cache.go,util.go,auth_middleware.go} common/
```
注意:auth_middleware.go 和 cache.go / util.go 需检查是否有对 video-factory 特定包的 import,如有则调整。auth.go 中 jwtSecret 改为 slogan 自己的密钥。
- [ ] **Step 4: 创建 main.go**(模式同 video-factory main.go,路由表注册 controllerworkspace 鉴权静态服务,端口 3007)
- [ ] **Step 5: 添加依赖并编译**
```bash
go mod tidy
go build ./...
```
Expected: 编译通过(common 包复制可能依赖 gtime/gcachetidy 解决)。
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: slogan-agent skeleton with common package"
```
---
### Task 2: consts 与全部 entity
**Files:**
- Create: `styleagent/consts/table_name.go`(全部表名常量)
- Create: `styleagent/consts/status.go`(任务状态/照片类型/方案来源常量)
- Create: `styleagent/model/entity/`12 个文件:user.go, user_photo.go, wardrobe_item.go, body_measurement.go, avatar_model.go, hairstyle_asset.go, outfit_generation_task.go, outfit_plan.go, plan_outfit_item.go, plan_effect_image.go, plan_review.go, scoring_rule.go
- [ ] **Step 1: consts/table_name.go**
```go
package consts
const (
TableNameUser = "slogan_user"
TableNameUserPhoto = "slogan_user_photo"
TableNameWardrobeItem = "slogan_wardrobe_item"
TableNameBodyMeasurement = "slogan_body_measurement"
TableNameAvatarModel = "slogan_avatar_model"
TableNameHairstyleAsset = "slogan_hairstyle_asset"
TableNameOutfitGenTask = "slogan_outfit_generation_task"
TableNameOutfitPlan = "slogan_outfit_plan"
TableNamePlanOutfitItem = "slogan_plan_outfit_item"
TableNamePlanEffectImage = "slogan_plan_effect_image"
TableNamePlanReview = "slogan_plan_review"
TableNameScoringRule = "slogan_scoring_rule"
)
```
- [ ] **Step 2: consts/status.go**
```go
package consts
// 照片类型
const (
PhotoTypeHeadshot = 1 // 大头照
PhotoTypeFullFront = 2 // 全身正面
PhotoTypeFullSide = 3 // 全身侧面
PhotoTypeFullBack = 4 // 全身背面
)
// 生成任务状态
const (
TaskStatusPending = "pending"
TaskStatusPlanning = "planning"
TaskStatusScoring = "scoring"
TaskStatusRendering = "rendering"
TaskStatusDone = "done"
TaskStatusFailed = "failed"
)
// 方案来源
const (
PlanSourceWardrobe = "wardrobe"
PlanSourceRecommend = "recommend"
)
// 化身构建状态
const (
AvatarBuildPending = "pending"
AvatarBuildProcessing = "processing"
AvatarBuildDone = "done"
AvatarBuildFailed = "failed"
)
// 评分阈值(可被 scoring_rule 配置覆盖)
const DefaultScoreThreshold = 75
```
- [ ] **Step 3: entity 文件**orm tag 模式同 video-factory entity/user.go;全部含 CreatedAt/UpdatedAt `*gtime.Time`;字段完全对齐 Task 表格总览)
- [ ] **Step 4: 编译检查** `go build ./...`
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: consts and entities"`
---
### Task 3: 全部 DAOinit 自动建表)
**Files:**
- Create: `styleagent/dao/user_dao.go`(完整示例,含建表 + CRUD + 缓存)
- Create: 其余 11 个 dao 文件(user_photo / wardrobe_item / body_measurement / avatar_model / hairstyle_asset / outfit_generation_task / outfit_plan / plan_outfit_item / plan_effect_image / plan_review / scoring_rule
- [ ] **Step 1: user_dao.go**(模式:video-factory dao/user_dao.go
```go
package dao
import (
"context"
"slogan-agent/common"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
)
var User = &userDao{}
type userDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUser+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL DEFAULT 'user',
username TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create user table failed: %v", err)
}
_, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_username ON "+consts.TableNameUser+"(username) WHERE username != ''")
_, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_phone ON "+consts.TableNameUser+"(phone) WHERE phone != ''")
}
// 方法:Insert / GetOne / GetByAccount / Update / UpdateFields(复制 video-factory user_dao 对应方法,表名换 consts.TableNameUser
```
- [ ] **Step 2: 其余 11 个 dao**:每个含 init() 建表 + 核心查询方法(按字段):ListByUseruser_photo/wardrobe_item 按 user_id 分页)、GetByUserAndType、GetByUseravatar/body 单行)、ListByPlanplan_outfit_item/plan_effect_image)、GetByTaskoutfit_plan 列表)、ListAllhairstyle_asset 按 sort)、GetEnabledscoring_rule)、UpdateStatustask 状态流转)
- [ ] **Step 3: 建表自检**(先写 dao 测试或直接启动临时 main 验证)
```bash
go build ./... && go run main.go 2>&1 | head -5
# 验证 slogan.db 生成且无建表错误日志
```
- [ ] **Step 4: Commit** `git add -A && git commit -m "feat: dao layer with auto table creation"`
---
### Task 4: 全部 DTO(请求/响应 + g.Meta 路由)
**Files:**
- Create: `styleagent/model/dto/user_dto.go`LoginReq/LoginRes/ProfileRes
- Create: `styleagent/model/dto/user_photo_dto.go`
- Create: `styleagent/model/dto/wardrobe_dto.go`
- Create: `styleagent/model/dto/body_measurement_dto.go`
- Create: `styleagent/model/dto/avatar_dto.go`
- Create: `styleagent/model/dto/hairstyle_dto.go`
- Create: `styleagent/model/dto/outfit_dto.go`
- [ ] **Step 1: 关键 dto 内容**
```go
// user_dto.go
type LoginReq struct {
g.Meta `path:"/login" method:"post" tags:"用户" summary:"登录"`
Account string `v:"required" json:"account"`
Password string `v:"required" json:"password"`
}
type LoginRes struct {
Token string `json:"token"`
User *LoginUser `json:"user"`
}
type LoginUser struct {
Id int64 `json:"id"`
Role string `json:"role"`
Name string `json:"name"`
}
// user_photo_dto.go
type UserPhotoUploadReq struct {
g.Meta `path:"/upload" method:"post" tags:"照片" summary:"上传照片"`
Type int `v:"required|in:1,2,3,4" json:"type"`
// 文件字段:GoFrame 自动绑定 upload 文件(r.GetUploadFile
}
type UserPhotoUploadRes struct { Id int64 `json:"id"` }
type UserPhotoListReq struct {
g.Meta `path:"/list" method:"get" tags:"照片" summary:"照片列表"`
Type int `json:"type"` // 可空
}
type UserPhotoListRes struct {
List []*entity.UserPhoto `json:"list"`
}
type UserPhotoDeleteReq struct {
g.Meta `path:"/delete" method:"post" tags:"照片" summary:"删除照片"`
Id int64 `v:"required" json:"id"`
}
// wardrobe_dto.go
type WardrobeUploadReq struct {
g.Meta `path:"/upload" method:"post" tags:"衣橱" summary:"上传服装"`
Category string `v:"required" json:"category"`
Season string `json:"season"`
StyleTags string `json:"style_tags"`
ColorInfo string `json:"color_info"`
// 文件字段同上
}
type WardrobeListReq struct {
g.Meta `path:"/list" method:"get" tags:"衣橱" summary:"衣橱列表"`
Category string `json:"category"`
}
type WardrobeListRes struct { List []*entity.WardrobeItem `json:"list"` }
type WardrobeUpdateReq struct {
g.Meta `path:"/update" method:"post" tags:"衣橱" summary:"更新服装"`
Id int64 `v:"required" json:"id"`
Category string `json:"category"`
Season string `json:"season"`
StyleTags string `json:"style_tags"`
}
type WardrobeDeleteReq struct {
g.Meta `path:"/delete" method:"post" tags:"衣橱" summary:"删除服装"`
Id int64 `v:"required" json:"id"`
}
// body_measurement_dto.go
type BodyMeasurementSaveReq struct {
g.Meta `path:"/save" method:"post" tags:"身形" summary:"保存身形参数"`
Height int `json:"height"`
Weight int `json:"weight"`
SkinTone int `v:"in:1,2,3,4,5" json:"skin_tone"`
FitParams string `json:"fit_params"`
}
type BodyMeasurementGetRes struct {
Height int `json:"height"`
Weight int `json:"weight"`
SkinTone int `json:"skin_tone"`
FitParams string `json:"fit_params"`
}
// avatar_dto.go
type AvatarBuildReq struct {
g.Meta `path:"/build" method:"post" tags:"化身" summary:"构建化身"`
}
type AvatarBuildRes struct { TaskId int64 `json:"task_id"` }
type AvatarGetRes struct {
FaceTemplateId int `json:"face_template_id"`
BodyTemplateId int `json:"body_template_id"`
SkinToneIndex int `json:"skin_tone_index"`
GlbUrl string `json:"glb_url"`
BuildStatus string `json:"build_status"`
}
// hairstyle_dto.go
type HairstyleListRes struct { List []*entity.HairstyleAsset `json:"list"` }
// outfit_dto.go
type OutfitGenerateReq struct {
g.Meta `path:"/generate" method:"post" tags:"穿搭" summary:"生成穿搭方案"`
StartDate string `v:"required|date" json:"start_date"`
EndDate string `v:"required|date" json:"end_date"`
Location string `v:"required" json:"location"`
}
type OutfitGenerateRes struct { TaskId int64 `json:"task_id"` }
type OutfitTaskStatusReq struct {
g.Meta `path:"/task/status" method:"get" tags:"穿搭" summary:"任务状态"`
TaskId int64 `v:"required" json:"task_id"`
}
type OutfitTaskStatusRes struct {
Status string `json:"status"`
Error string `json:"error"`
}
type OutfitPlanListReq struct {
g.Meta `path:"/plan/list" method:"get" tags:"穿搭" summary:"方案列表"`
}
type OutfitPlanListRes struct { List []*entity.OutfitPlan `json:"list"` }
type OutfitPlanDetailReq struct {
g.Meta `path:"/plan/detail" method:"get" tags:"穿搭" summary:"方案详情"`
PlanId int64 `v:"required" json:"plan_id"`
}
type OutfitPlanDetailRes struct {
Plan *entity.OutfitPlan `json:"plan"`
Items []*entity.PlanOutfitItem `json:"items"`
Images []*entity.PlanEffectImage `json:"images"`
Hairstyle *entity.HairstyleAsset `json:"hairstyle,omitempty"`
}
type OutfitSelectMainReq struct {
g.Meta `path:"/plan/select-main" method:"post" tags:"穿搭" summary:"选定主方案"`
PlanId int64 `v:"required" json:"plan_id"`
}
type OutfitReviewReq struct {
g.Meta `path:"/plan/review" method:"post" tags:"穿搭" summary:"方案反馈"`
PlanId int64 `v:"required" json:"plan_id"`
Action string `v:"required|in:fav,unfav" json:"action"`
Note string `json:"note"`
}
```
- [ ] **Step 2: 编译检查** `go build ./...`entity 引入路径)
- [ ] **Step 3: Commit** `git add -A && git commit -m "feat: dto layer with route metadata"`
---
### Task 5: 用户域(user controller + service
**Files:**
- Create: `styleagent/controller/user_controller.go`
- Create: `styleagent/service/user_service.go`
- Test: `styleagent/service/user_service_test.go`
- [ ] **Step 1: 写失败测试**
```go
// user_service_test.go
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoginSuccess(t *testing.T) {
ctx := context.Background()
// 注册新用户
userId, err := UserService.Register(ctx, "test_user_1", "password123")
assert.NoError(t, err)
assert.True(t, userId > 0)
_, token, err := UserService.Login(ctx, "test_user_1", "password123")
assert.NoError(t, err)
assert.NotEmpty(t, token)
}
func TestLoginWrongPassword(t *testing.T) {
ctx := context.Background()
_, _, err := UserService.Login(ctx, "test_user_1", "wrong")
assert.Error(t, err)
}
```
- [ ] **Step 2: 运行确认失败** `go test ./styleagent/service/ -run TestLogin -v`
Expected: FAIL(编译失败/未定义 UserService
- [ ] **Step 3: 实现 user_service.go**(复制 video-factory user_service.go 模式 + Register 方法,bcrypt 哈希密码,JWT 7 天;测试需要独立 DB —— 测试用 `test_slogan.db`,在 TestMain 中切换 g.DB 配置)
- [ ] **Step 4: 实现 user_controller.go**Login / ChangePassword / Profile 三个方法绑定 dto
- [ ] **Step 5: 运行确认通过** `go test ./styleagent/service/ -run TestLogin -v` → PASS
- [ ] **Step 6: Commit** `git add -A && git commit -m "feat: user domain login/register"`
---
### Task 6: 照片/衣橱/身形域(上传 + 列表 + 删除)
**Files:**
- Create: `styleagent/controller/user_photo_controller.go`, `wardrobe_controller.go`, `body_measurement_controller.go`
- Create: `styleagent/service/user_photo_service.go`, `wardrobe_service.go`, `body_measurement_service.go`
- Create: `styleagent/service/file_storage.go`(文件保存封装:`SaveUploadedFile(file, subDir)``workspace/user_{id}/photos/xxx.jpg`,返回相对路径)
- [ ] **Step 1: file_storage.go**(模式:video-factory character_service 的文件保存逻辑)
```go
package service
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// SaveUploadedFile 保存上传文件到 workspace/{subDir},返回 "workspace/{subDir}/{filename}"
func SaveUploadedFile(file *ghttp.UploadFile, subDir string) (string, error) {
dir := filepath.Join("workspace", subDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), file.Filename)
path := filepath.Join(dir, filename)
if err := file.Save(path); err != nil {
return "", err
}
return "/" + filepath.ToSlash(filepath.Join("workspace", subDir, filename)), nil
}
```
- [ ] **Step 2: 写失败测试**(user_photo:上传→列表→删除;wardrobe 同理;bodysave→get 往返)
- [ ] **Step 3: 实现三个 service**upload 校验(单张 ≤10MB、jpg/png/webp 扩展名校验)→ SaveUploadedFile → dao.Insertlist 按 user_iddelete 校验归属(id + user_id 双条件)后删除文件 + 记录
- [ ] **Step 4: 实现三个 controller**Upload 方法用 `r.GetUploadFile("file")` 获取文件(controller 直接拿 request 时用 `*ghttp.Request` 参数)
```go
func (c *userPhoto) Upload(ctx context.Context, req *dto.UserPhotoUploadReq, r *ghttp.Request) (res *dto.UserPhotoUploadRes, err error) {
file := r.GetUploadFile("file")
userId := common.GetUserId(ctx)
url, err := service.UserPhotoService.Upload(ctx, userId, req.Type, file)
if err != nil {
return nil, err
}
return &dto.UserPhotoUploadRes{Id: url.Id}, nil
}
```
注意:GetUserId(ctx) 从 auth 中间件注入的 ctx 读取(auth_middleware.go 已有实现,按 video-factory 方式调用)。
- [ ] **Step 5: 测试通过** `go test ./styleagent/service/ -v`
- [ ] **Step 6: Commit** `git add -A && git commit -m "feat: photo/wardrobe/body domains"`
---
### Task 7: 化身域(v1 模板匹配 + build 任务)
**Files:**
- Create: `styleagent/avatar/template_matcher.go`
- Create: `styleagent/avatar/glb_packer.go`
- Create: `styleagent/service/avatar_service.go`
- Create: `styleagent/controller/avatar_controller.go`
- Test: `styleagent/avatar/template_matcher_test.go`
- [ ] **Step 1: 写失败测试**template_matcher:给定模拟特征(肤色 1-5 + 身高 cm + 胖瘦 1-5)→ 返回 face_template_id/body_template_id 索引)
```go
package avatar
import "testing"
func TestMatchTemplates(t *testing.T) {
f := &FaceFeature{SkinTone: 3, HeightCm: 175, Build: 3}
faceId, bodyId, skinIdx := MatchTemplates(f)
if faceId < 1 || faceId > 20 || bodyId < 1 || bodyId > 6 || skinIdx < 1 || skinIdx > 5 {
t.Fatalf("out of range: face=%d body=%d skin=%d", faceId, bodyId, skinIdx)
}
}
```
- [ ] **Step 2: 确认失败** `go test ./styleagent/avatar/ -v`
- [ ] **Step 3: 实现 template_matcher.go**
```go
package avatar
// FaceFeature 从照片+用户填写提取的化身特征(v1 简化:照片仅做肤色采样,其余用户填写/默认)
type FaceFeature struct {
SkinTone int // 1-5
HeightCm int
Build int // 1-5 瘦~胖
}
// 预烘焙模板库索引(构建期产物,运行时只读常量)
const (
FaceTemplateCount = 20
BodyTemplateCount = 6
SkinToneLevels = 5
DefaultFaceTemplate = 5
DefaultBodyTemplate = 3
)
// MatchTemplates 特征 → 模板索引(v1 规则映射:肤色→皮肤档,身高+体型→身体模板,脸型由照片后续 AI 提取后替换)
func MatchTemplates(f *FaceFeature) (faceId, bodyId, skinIdx int) {
if f == nil {
return DefaultFaceTemplate, DefaultBodyTemplate, 3
}
skinIdx = f.SkinTone
if skinIdx < 1 { skinIdx = 1 }
if skinIdx > SkinToneLevels { skinIdx = SkinToneLevels }
// 身体模板:身高 150-190 → 6 档
bodyId = (f.HeightCm - 145) / 8
if bodyId < 1 { bodyId = 1 }
if bodyId > BodyTemplateCount { bodyId = BodyTemplateCount }
// v1 脸型固定默认模板(AI 人脸特征提取后替换,见 spec v2)
faceId = DefaultFaceTemplate
return
}
```
- [ ] **Step 4: glb_packer.go**v1:拼 URL —— `/workspace/templates/face_{id}.glb``body_{id}.glb`,组合 avatar GLB 记录;真实打包后续)
- [ ] **Step 5: avatar_service.go**Build(ctx, userId):检查照片齐备(大头照+至少1张全身)→ 读 body_measurement → MatchTemplates → 插入 avatar_modelbuild_status=pending)→ 异步 goroutine 执行 processing → donev1 同步简化:直接 done + glb_url 用 packer 生成的路径);Get(ctx, userId) 返回最新 avatar_model
- [ ] **Step 6: avatar_controller.go**Build/Get 绑定 dtoBuild 返回 task 语义(v1 直接返回 avatar 记录 id
- [ ] **Step 7: 测试通过** + `go build ./...` + **Commit** `git commit -m "feat: avatar domain with template matching"`
---
### Task 8: 发型资产列表(静态 seed)
**Files:**
- Create: `styleagent/controller/hairstyle_controller.go`
- Create: `styleagent/service/hairstyle_service.go`
- Modify: `styleagent/dao/hairstyle_asset_dao.go`init 时 seed 8 个默认发型)
- [ ] **Step 1: dao init seed**(插入 8 条:短发/中发/长发/卷发/寸头/马尾/丸子头/波浪卷,style_tag、glb_url=`/workspace/templates/hairstyle_{id}.glb`、sort
- [ ] **Step 2: 测试**List 返回按 sort 排序的 8 条(dao 测试)
- [ ] **Step 3: service + controller 绑定**`go build ./...`**Commit**
---
### Task 9: 规则引擎评分(5 维度,零 LLM)
**Files:**
- Create: `styleagent/scoring/rules.go`ScoreContext + CandidateOutfit
- Create: `styleagent/scoring/weather_rule.go`
- Create: `styleagent/scoring/occasion_rule.go`
- Create: `styleagent/scoring/color_rule.go`
- Create: `styleagent/scoring/completeness_rule.go`
- Create: `styleagent/scoring/style_rule.go`
- Create: `styleagent/scoring/engine.go`(总分聚合 + 阈值判定)
- Test: `styleagent/scoring/engine_test.go`
- [ ] **Step 1: 写失败测试**(关键边界:冬季温度带外套得分高于无外套;色调和谐组合得分高于冲突组合;缺鞋减分;总分 ≥ 阈值判定通过)
```go
package scoring
import "testing"
func TestWinterOuterwearBonus(t *testing.T) {
ctx := ScoreContext{
TempAvg: 5, // 冬季
Occasion: "通勤",
Weekday: "workday",
Wardrobe: []WardrobeItem{{Category: "上衣", ColorInfo: "#333333"}, {Category: "下装", ColorInfo: "#1a1a1a"}},
}
withJacket := CandidateOutfit{Items: ctx.Wardrobe, HasOuterwear: true}
noJacket := CandidateOutfit{Items: ctx.Wardrobe, HasOuterwear: false}
if weatherScore(withJacket) <= weatherScore(noJacket) {
t.Fatal("winter should favor outerwear")
}
}
```
- [ ] **Step 2: 确认失败** `go test ./styleagent/scoring/ -v`
- [ ] **Step 3: 实现 5 个规则文件**(均为纯函数,输入输出确定):
```go
// rules.go 公共类型
type WardrobeItem struct {
Category string // 上衣/下装/鞋/配饰
Season string // 春/夏/秋/冬/四季
ColorInfo string // 如 #RRGGBB 或 "黑/白/红"
StyleTags string
}
type CandidateOutfit struct {
Items []WardrobeItem
HasOuterwear bool
}
type ScoreContext struct {
TempAvg int // 平均温度℃
Season string
Occasion string // 通勤/约会/聚会/运动
Weekday string // workday/weekend/holiday
StyleTags []string // 用户偏好
}
// weather_rule.go 温度档位表
func weatherScore(o CandidateOutfit, ctx ScoreContext) int {
// 25 分制:温度匹配每件服装 season 加 5 分;<10℃ 无外套扣 10 分;>30℃ 有外套扣 8 分
}
// occasion_rule.go 场合规则表
func occasionScore(o CandidateOutfit, ctx ScoreContext) int {
// 25 分制:场合→类别规则(约会加分:正装/裙装;运动加分:运动服)基础分 15 + 匹配项各 5
}
// color_rule.go 色相环相似度
func colorScore(o CandidateOutfit) int {
// 20 分制:同色系 20;邻近色 15;对比色 8;随机冲突 3
}
// completeness_rule.go
func completenessScore(o CandidateOutfit) int {
// 20 分制:上衣+5 下装+5 鞋+5 配饰+5
}
// style_rule.go 用户偏好
func styleScore(o CandidateOutfit, ctx ScoreContext) int {
// 10 分制:命中用户 styleTags 每项 +2
}
// engine.go
func Score(c *CandidateOutfit, ctx *ScoreContext) int {
return weatherScore(*c, *ctx) + occasionScore(*c, *ctx) + colorScore(*c) +
completenessScore(*c) + styleScore(*c, *ctx)
}
func IsPass(score int, threshold int) bool { return score >= threshold }
```
- [ ] **Step 4: 测试通过**(含色相解析测试:`#ff0000``#ff6666` 同色系;`#ff0000``#00ff00` 对比色)
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: rule-based scoring engine"`
---
### Task 10: 天气适配(和风 + 高德 + 缓存)
**Files:**
- Create: `styleagent/weather/qweather.go`
- Create: `styleagent/weather/geo.go`
- Create: `styleagent/weather/cache.go`
- Create: `styleagent/service/weather_service.go`(供 outfit service 调用)
- Test: `styleagent/weather/cache_test.go`
- [ ] **Step 1: 写失败测试**cacheget→miss→set→hitTTL 过期)
- [ ] **Step 2: 实现 cache.go**(内存 map + mutexkey=`{city}:{date}`TTL 6h
- [ ] **Step 3: 实现 qweather.go**`GetDaily(ctx, cityCode, startDate, endDate) ([]DayWeather, error)`,和风 `v7/weather/7d` 接口,Key 从 `config.yml``weather.qweather_key` 读取(空则返回 error 提示配置缺失)
```go
type DayWeather struct {
Date string `json:"date"`
TempMax int `json:"temp_max"`
TempMin int `json:"temp_min"`
TextDay string `json:"text_day"`
}
// 返回该日期范围内平均温度(用于评分)+ 每日天气
func GetDaily(ctx context.Context, cityCode string, startDate, endDate string) (*WeatherResult, error)
```
- [ ] **Step 4: 实现 geo.go**`GetCityCode(ctx, location) (string, error)` —— 高德地理编码 API,Key 从配置读;失败时降级:直接以 location 为 cityCode 缓存并返回默认天气(config 开启 mock 时)
- [ ] **Step 5: weather_service.go**:封装 `GetWeather(ctx, location, startDate, endDate)` → 先查缓存 → 未命中调 API → 存缓存;测试用 mock API 响应(httptest server 或注入接口)
- [ ] **Step 6: 测试通过** + **Commit** `git commit -m "feat: weather adapter with cache"`
---
### Task 11: Agentchat_model 复用 + 方案规划/兜底)
**Files:**
- Copy: `styleagent/agent/chat_model.go`(从 video-factory 复制,改 import 路径)
- Copy: `styleagent/agent/types.go`ChatRequest/ChatMessage/ChatResponse/ToolCall
- Create: `styleagent/agent/outfit_agent.go`(规划 + 兜底两函数)
- Create: `styleagent/agent/output.go`JSON Schema 校验)
- Create: `styleagent/agent/agent_config.go`(从 model_config 表读取 LLM 配置,未配置时返回错误)
- Test: `styleagent/agent/output_test.go`
- [ ] **Step 1: 复制 chat_model.go + types.go**,改包路径,`go build ./...` 通过
- [ ] **Step 2: 写失败测试**output 解析:合法 JSON 解析为 PlanOutput;缺字段报错;非法 JSON 报错)
```go
// output.go 规划输出结构
type PlanOutput struct {
Plans []PlanCandidate `json:"plans"`
}
type PlanCandidate struct {
Title string `json:"title"`
Hairstyle string `json:"hairstyle"` // 发型名称(匹配资产库)
HairColor string `json:"hair_color"` // 如 #A0522D
Items []PlanItemOut `json:"items"`
}
type PlanItemOut struct {
Slot string `json:"slot"` // 上衣/下装/鞋/配饰
ItemId int64 `json:"item_id,omitempty"` // 衣橱条目(wardrobe 来源)
Name string `json:"name"`
Desc string `json:"desc"`
NewItem bool `json:"new_item"` // 是否为推荐新服装
}
```
- [ ] **Step 3: 确认失败** `go test ./styleagent/agent/ -v`
- [ ] **Step 4: 实现 output.go 校验**json.Unmarshal + 必填字段检查:plans 非空、每套 items 至少 1 件)
- [ ] **Step 5: 实现 outfit_agent.go**
```go
// PlanOutfits 规则预筛候选 → LLM 润色规划(1 次调用)
func PlanOutfits(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string, candidates []CandidateData) (*PlanOutput, error)
// CreateRecommendPlan 兜底创作(全低分时调用,1 次调用)
func CreateRecommendPlan(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string) (*PlanOutput, error)
```
system prompt 要点(写入 agent/prompt.go 常量):角色是穿搭顾问;输出严格 JSON;天气/场合约束注入;仅输出 JSON 无额外文字。
- [ ] **Step 6: agent_config.go**:从 `model_config` 表(复用 video-factory 结构:system 配置 + 可覆盖)读取 base_url/api_key/model_name,用 gcache 缓存 60s;未配置返回明确错误。
- [ ] **Step 7: 测试通过**output 校验单测;agent 调用用 httptest mock OpenAI 端点)+ **Commit**
---
### Task 12: 穿搭生成编排(outfit service 核心)
**Files:**
- Create: `styleagent/service/outfit_service.go`Generate 编排 + 评分 + 兜底 + 落库)
- Test: `styleagent/service/outfit_service_test.go`(核心逻辑 mockweather/agent 注入接口)
- [ ] **Step 1: 写失败测试**(核心流程:衣橱 3 件 → 规则预筛 3 套 → 评分 → 全低分时触发兜底 → 落库 plan + items;高分时直接落库)
- [ ] **Step 2: 确认失败**
- [ ] **Step 3: 实现 outfit_service.go**
```go
type outfitService struct{}
var OutfitService = new(outfitService)
// Generate 创建生成任务并同步执行核心流程(v1 同步;异步任务表见 Task 13)
func (s *outfitService) Generate(ctx context.Context, userId int64, req *dto.OutfitGenerateReq) (int64, error) {
// 1. 校验衣橱非空(<3 件返回 "衣橱服装不足,请先添加至少 3 件服装")
// 2. 天气获取(weather_service
// 3. 规则预筛:衣橱 × 季节温度 × 场合 → 3 套候选(组合算法:按 category 分组随机/轮询组合)
// 4. 创建任务记录(planning)→ Agent.PlanOutfits1 次 LLM
// 5. 规则评分每套 → 任务状态 scoring
// 6. 3 套全 < 阈值 → Agent.CreateRecommendPlan1 次 LLM)→ 新套装标 recommend
// 7. 落库 outfit_planhairstyle_id 匹配资产库)+ plan_outfit_item(来源标注)
// 8. 任务 → done;返回 task_id
}
```
- [ ] **Step 4: 预筛组合算法**outfit_combiner.go):按 category 将衣橱分组,按温度过滤 season,生成最多 3 个互不相同组合(确定性:按 id 排序轮询),每个组合带 HasOuterwear 标记
- [ ] **Step 5: 测试通过** + `go build ./...` + **Commit**
---
### Task 13: 穿搭 controller + 异步任务表
**Files:**
- Create: `styleagent/controller/outfit_controller.go`
- Modify: `styleagent/service/outfit_service.go`(异步化:Generate 只建任务返回 task_idworker goroutine 执行;GetTaskStatus / ListPlans / GetPlanDetail / SelectMain / Review
- [ ] **Step 1: 异步化改造**Generate 插入任务(pending)→ 启动 goroutine 执行核心流程(含任务状态流转 pending→planning→scoring→done/failed + error 记录);`startWorker(ctx)` 守护恢复未完成任务(main.go 启动时调用,模式同 video-factory StartVideoPoller
- [ ] **Step 2: controller 绑定 6 个 dto 方法**Generate/TaskStatus/PlanList/PlanDetail/SelectMain/Review
- [ ] **Step 3: GetPlanDetail**:查 plan + items + images + hairstyle 资产,组装 OutfitPlanDetailRes
- [ ] **Step 4: SelectMain**:置 main_flag(事务:同 task 其他 plan 清零)+ 触发效果图任务(Task 14 后接通)
- [ ] **Step 5: 编译 + 冒烟测试**TestMain 起 gtest server:登录 → 上传 → generate → 轮询 → detail**Commit**
---
### Task 14: 效果图生成(ImageGenClient 接口 + wanx + mock + 缓存)
**Files:**
- Create: `styleagent/imagegen/client.go`(接口 + Factory
- Create: `styleagent/imagegen/wanx_client.go`
- Create: `styleagent/imagegen/mock_client.go`
- Create: `styleagent/imagegen/cache.go`
- Create: `styleagent/service/effect_image_service.go`(异步任务执行:选主方案后生成 3 视角)
- Test: `styleagent/imagegen/cache_test.go` + `mock_client_test.go`
- [ ] **Step 1: 写失败测试**cacheplan 内容 hash → 命中/未命中;mock client:调用返回固定 URL
- [ ] **Step 2: 实现 client.go**
```go
type ImageGenClient interface {
// Generate 生成单张效果图,返回图片 URL
Generate(ctx context.Context, req *GenerateReq) (string, error)
}
type GenerateReq struct {
BaseImageURL string // 用户全身照
Prompt string // 方案描述
Angle string // 正面/侧面/背面
Seed int64
}
func NewClient(supplier string) ImageGenClient // wanx | mockconfig 无 key 时强制 mock
```
- [ ] **Step 3: mock_client.go**:返回 `/workspace/mock/effect_{angle}.png` 占位路径(不真实调用,开发联调用)
- [ ] **Step 4: wanx_client.go**:通义万相人像写真类 API`image-sync` 或异步轮询接口),Key/模型从 `imagegen_config` 表读;v1 实现为"调用 + 轮询结果"封装;错误降级 mock
- [ ] **Step 5: effect_image_service.go**SelectMain 后 goroutine:按 plan 内容 hash 查缓存 → 未命中调用 ImageGenClient 逐角度生成(3 张)→ 存 plan_effect_image + 任务 rendering→done;每日免费次数校验(user 维度,默认 3 次/天,scoring_rule 表配置)
- [ ] **Step 6: 测试通过** + **Commit**
---
### Task 14.5: 商业化基础(partner_store 列表 + seed
**Files:**
- Create: `styleagent/model/entity/partner_store.go`id, name, type(1形象设计 2服装门店), lat, lng, address, commission_policy, status, created_at
- Modify: `styleagent/consts/table_name.go`+`TableNamePartnerStore = "slogan_partner_store"`
- Create: `styleagent/dao/partner_store_dao.go`(建表 + init seed 4 条示例门店 + ListByType
- Create: `styleagent/model/dto/partner_store_dto.go``StoreListReq` path `/list` + `StoreListRes{List []*entity.PartnerStore}`
- Create: `styleagent/service/partner_store_service.go`
- Create: `styleagent/controller/partner_store_controller.go`
- Modify: `main.go`(注册 `controller.PartnerStore`
- [ ] **Step 1: entity + dao**(模式同 Task 3;seed:2 条形象设计 + 2 条服装门店,坐标覆盖城市)
- [ ] **Step 2: dto + service + controller**List 支持 `type` 筛选,0 返回全部)
- [ ] **Step 3: `go build ./...` + 冒烟**GET /partner-store/list 返回 seed 数据)+ **Commit** `git commit -m "feat: partner store domain"`
---
### Task 15: 集成冒烟 + Dockerfile
**Files:**
- Create: `Dockerfile`(复用 video-factory 多阶段构建模式)
- Create: `docs/项目文档.md`(服务端文档,模式同 video-factory 项目文档)
- Create: `docs/api.json` 导出(启动后 GoFrame OpenAPI
- [ ] **Step 1: Dockerfile**golang:1.22 builder + alpine runtime,复制 video-factory Dockerfile 改端口)
- [ ] **Step 2: 全链路冒烟**`go run main.go` → curl 全流程:
1. `POST /user/login`(注册后)→ token
2. `POST /user-photo/upload`-F file=@headshot.jpg -F type=1
3. `POST /wardrobe/upload` × 3
4. `POST /body-measurement/save`
5. `POST /avatar/build` → get
6. `POST /outfit/generate` → task status 轮询 → done
7. `GET /outfit/plan/list` → detail
8. `POST /outfit/plan/select-main` → effect imagesmock 路径)
9. `GET /hairstyle/list`
- [ ] **Step 3: 验证响应格式统一** `{"code":0,"message":"OK","data":...}`
- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete with dockerfile and docs"`
---
## Self-Review 备注(执行前已知项)
- 测试 DB`styleagent/service` 单测使用独立 sqlite 文件 `test_slogan.db`(TestMain 设置),避免污染开发库
- GetUserId(ctx):确认 auth_middleware.go 注入的 key(复制 video-factory 后保持一致)
- 上传文件字段名统一 `file`
- 天气/LLM/图像 Key 全部从 config.yml / 配置表读取,代码不入 Key
@@ -0,0 +1,293 @@
# 商业化四支柱设计(后端)· slogan-agent
> **目标:** 以「个人形象设计」为主题业务,落地四支柱收入:VIP 会员充值、穿山甲广告、线下门店引流(OTA 联盟)、线上商品(电商联盟 CPS)。
> **核心原则:** 商业化从「方案/单品」长出,不做泛化场景广场。所有推荐由方案已有字段驱动,**零新增 LLM 调用**。
## 1. 总体架构
```
Appslogan-app
│ 会员中心/方案详情商业化入口/衣橱升级款/广告位
slogan-agent 新增模块
├─ 会员模块 member_plan / payment_order / user_member / pay_notify_log
├─ 广告激励 ad_reward_log + 发放权益
├─ CPS 统一引擎 cps_category / cps_product / cps_click_log / scene_category_map
│ └─ 适配器:美团联盟(OTA 到店) / 京东联盟(电商) / 淘宝客(美妆配饰)
└─ 配置 config.ymlcps.payment.ad 配置段,Key 默认空 → 模块自动降级)
├─▶ 虎皮椒聚合支付(微信/支付宝收银台,iOS WebView
├─▶ 美团联盟 API(选品 + 转链,pid 归因)
├─▶ 京东联盟 API(选品 + 转链)
└─▶ 淘宝客 API(选品 + 淘口令)
```
**模块降级原则**:与现有 `llm/weather/geo` 配置段同模式 —— 支付/CPS 相关 key 未配置时,接口返回明确错误信息(如"支付未开通,请在 config.yml 配置"),App 端隐藏对应入口,不影响主功能闭环。
## 2. 支柱 A:VIP 会员与聚合支付
### 2.1 支付服务商:虎皮棋(xunhupay)
- 个人可开通、无营业执照门槛、微信+支付宝双通道、收银台 URL 模式(App WebView 打开)
- 下单:`POST /v1/payment`RSA 签名请求);回调:`POST notify_url`(验签后解析)
- **签名/验签细节以官方最新文档为准**,实现时封装在 `payment/gateway.go` 适配器内,与业务解耦
- 金额一律以「分」为单位存库,避免浮点误差
### 2.2 数据模型(dao init 自动建表,沿用 SQLite 规范)
```sql
CREATE TABLE IF NOT EXISTS member_plan (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
price_fen INTEGER NOT NULL DEFAULT 0, -- 金额(分)
duration_days INTEGER NOT NULL DEFAULT 30, -- 时长(天)
features TEXT NOT NULL DEFAULT '[]', -- 权益 JSON["effect_unlimited","ai_priority","cps_commission_x15","store_discount"]
sort INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS payment_order (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL UNIQUE, -- 业务订单号
user_id INTEGER NOT NULL DEFAULT 0,
plan_id INTEGER NOT NULL DEFAULT 0,
amount_fen INTEGER NOT NULL DEFAULT 0,
channel TEXT NOT NULL DEFAULT '', -- alipay | wechat
status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | closed
trade_no TEXT NOT NULL DEFAULT '', -- 第三方交易号
notify_raw TEXT NOT NULL DEFAULT '', -- 回调原文(审计)
paid_at DATETIME,
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_payment_order_user ON payment_order(user_id, created_at);
CREATE TABLE IF NOT EXISTS user_member (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
plan_id INTEGER NOT NULL DEFAULT 0,
expire_at DATETIME,
source TEXT NOT NULL DEFAULT 'vip_pay', -- vip_pay | ad_trial | gift
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS pay_notify_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
sign TEXT NOT NULL DEFAULT '',
remote_ip TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ok', -- ok | bad_sign | duplicate | no_order
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
```
### 2.3 接口(RouteRegister 2 参 handler`common.GetUserId(g.RequestFromCtx(ctx))` 取用户)
| 路径 | 方法 | 请求 | 响应 | 说明 |
|---|---|---|---|---|
| `/member/plan/list` | GET | - | `{list: [member_plan]}` | 上架套餐 |
| `/member/status` | GET | - | `{member: {...}, is_vip, expire_at}` | 我的会员状态 |
| `/member/order/create` | POST | `{plan_id}` | `{order_no, pay_url}` | 下单 → 虎皮棋收银台 URL |
| `/member/order/notify` | POST | 表单回调 | `"success"` | **publicPaths 放行**;验签 → 幂等 → 订单 paid → 开通/续期会员 |
| `/member/order/status` | GET | `{order_no}` | `{status}` | App 轮询 |
**支付时序**
```
App → POST /member/order/create → 后端生成订单 + 调虎皮棋下单 → 返回 pay_url
App → WebView 打开 pay_url(用户完成支付)
虎皮棋 → POST /member/order/notifyRSA 验签)
后端 → 幂等校验(order_no 状态机 pending→paid,重复回调忽略并记 pay_notify_log
后端 → 更新 user_member(续费:expire_at 在原有效期上叠加,min 逻辑;过期则从现在起算)
App → GET /member/order/status 轮询(间隔 2s,超时 60s)→ 展示开通成功
```
**幂等与安全**:回调必须验签(失败记 `bad_sign` 并返回非 success);`order_no` 唯一 + 状态机保证只开通一次;回调日志全量入库审计;退款 MVP 阶段客服手动处理(标记 order closed + 人工延退会员)。
## 3. 支柱 B:广告激励
### 3.1 数据模型
```sql
CREATE TABLE IF NOT EXISTS ad_reward_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
ad_type TEXT NOT NULL DEFAULT '', -- effect_extra(效果图+1) | vip_trial(体验会员1天)
reward_key TEXT NOT NULL DEFAULT '', -- "2026-07-31:effect_extra" 自然日去重粒度
status TEXT NOT NULL DEFAULT 'ok',
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON ad_reward_log(user_id, reward_key);
```
### 3.2 接口
| 路径 | 方法 | 请求 | 响应 | 说明 |
|---|---|---|---|---|
| `/ad/reward/claim` | POST | `{ad_type}` | `{reward: {...}}` | 发放权益(限频见下) |
**风控**(防刷,纯服务端计数,不信任客户端):
- `ad_type=effect_extra`:每日每用户限 **2 次**`reward_key` 唯一索引 + 计数),发放后效果图当日额外 +1 次
- `ad_type=vip_trial`:每日每用户限 **1 次**,发放 1 天体验会员(写 user_membersource=ad_trial,到期自动失效)
- 效果图限额判定逻辑改造:`EffectImageService.GenerateForPlan``CountByUserToday` 判断改为 `当日已用 ≤ 基础额度(3) + 额外次数(ad_reward_log 当日 count)`;额外次数次日归零(不落独立表,按日查询即可)
## 4. 支柱 C/D:统一 CPS 引擎
### 4.1 核心抽象
```go
// cps/provider.go —— 数据源适配器接口(包级单例:cps.Providers 注册表)
type Provider interface {
Source() string // meituan_ota | jd_ecom | tb_ecom
SyncProducts(ctx, city string, catCode string) ([]CpsProduct, error) // 定时选品池同步
Search(ctx, keyword string, catCode string, page int) ([]CpsProduct, error) // 实时搜索兜底
GetLink(ctx, outerId string) (string, error) // 转链(带 pid),结果按 outerId 缓存 24h
}
```
- 统一 `cps_product` 选品池:联盟商品定时同步入库,列表读库(不实时调联盟);搜索接口实时兜底
- 转链结果缓存(与 imagegen cache 同模式),点击时写 `cps_click_log`
- 未配置某联盟 key → 该 source 降级(列表为空 + App 隐藏入口)
### 4.2 数据模型
```sql
CREATE TABLE IF NOT EXISTS cps_category (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE, -- haircut / clothing / beauty / food / hotel / ticket / transport / digital ...
name TEXT NOT NULL DEFAULT '',
parent_code TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', -- meituan_ota / jd_ecom / tb_ecom
source_cat_id TEXT NOT NULL DEFAULT '', -- 联盟侧类目 ID
sort INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS cps_product (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT '',
outer_id TEXT NOT NULL DEFAULT '', -- 联盟商品 ID
category_code TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
cover_url TEXT NOT NULL DEFAULT '',
price_fen INTEGER NOT NULL DEFAULT 0,
shop_name TEXT NOT NULL DEFAULT '',
commission_rate INTEGER NOT NULL DEFAULT 0, -- 万分比
city TEXT NOT NULL DEFAULT '', -- OTA 到店类目按城市
scene_tags TEXT NOT NULL DEFAULT '[]', -- 场合标签 ["通勤","约会","旅行"]
raw TEXT NOT NULL DEFAULT '', -- 联盟原始数据 JSON
status INTEGER NOT NULL DEFAULT 1,
sync_at DATETIME,
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON cps_product(source, category_code, status);
CREATE TABLE IF NOT EXISTS cps_click_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
outer_id TEXT NOT NULL DEFAULT '',
scene TEXT NOT NULL DEFAULT '', -- plan_haircut / plan_item / plan_occasion / wardrobe_upgrade / member_benefit
plan_id INTEGER NOT NULL DEFAULT 0,
category_code TEXT NOT NULL DEFAULT '',
deeplink TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime'))
);
CREATE INDEX IF NOT EXISTS idx_cps_click_user ON cps_click_log(user_id, created_at);
CREATE TABLE IF NOT EXISTS scene_category_map ( -- 方案字段 → 联盟类目映射(零 LLM 推荐核心)
id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_type TEXT NOT NULL DEFAULT '', -- haircut / item_buy / item_upgrade / occasion
occasion TEXT NOT NULL DEFAULT '', -- 通勤/约会/旅行/运动/商务(occasion 场景)
source TEXT NOT NULL DEFAULT '',
category_code TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0
);
```
### 4.3 方案驱动推荐(核心:用已有方案字段,零新增 LLM 调用)
| 入口 | 方案字段 | 映射 | 推荐内容 |
|---|---|---|---|
| 发型卡「做同款发型」 | 发型名 + 城市 | scene_type=haircut → 丽人类目 | 理发/造型店联盟券(美团) |
| 穿衣清单「买同款」 | 单品 name/Desc | 京东联盟搜索关键词 | 电商同款卡片 |
| 穿衣清单「到店试穿」 | 单品风格 tags + 城市 | scene_type=item_upgrade → 服装类目 | 服装店联盟券(美团) |
| 场合卡「延伸优惠」 | occasion + 地点 | scene_type=occasion 映射表 | 约会→餐厅+丽人;旅行→酒店/车票/当地丽人 |
| 衣橱「找升级款」 | 旧款 category + style_tags | 京东搜索相似款 | 电商升级款 |
### 4.4 接口
| 路径 | 方法 | 请求 | 响应 | 说明 |
|---|---|---|---|---|
| `/cps/category/list` | GET | - | `{list: [cps_category]}` | 统一分类树 |
| `/cps/product/list` | GET | `{source, category_code, city, page}` | `{list, has_more}` | 选品池分页 |
| `/cps/product/link` | POST | `{product_id, scene, plan_id}` | `{deeplink}` | 转链(缓存 24h)+ 记点击日志 |
| `/cps/plan/recommend` | GET | `{plan_id, scene}` | `{list: [推荐项]}` | 方案驱动推荐(发型卡/单品/场合) |
| `/cps/wardrobe/upgrade` | GET | `{item_id}` | `{list}` | 衣橱旧款升级款 |
| `/cps/my/recent` | GET | - | `{list: [点击记录]}` | 我的优惠记录(含返现状态占位) |
**归因**:转链 URL 内嵌联盟 pid(下单时由适配器生成),联盟侧自动归因;`cps_click_log` 用于转化分析,结算数据以联盟后台为准。
## 5. 会员权益实现
- `effect_unlimited`:效果图限额判定跳过(`EffectImageService``IsVip(userId)` 查询)
- `ai_priority``outfit_service.Generate` 任务插入优先级字段(MVP 可用简单 FIFO + vip 优先标记,或仅权益展示占位)
- `cps_commission_x15`VIP 购买 CPS 佣金 ×1.5 —— 结算在联盟后台,**MVP 仅权益展示**(文案"返现加成 1.5x"),真实返现二期(需联盟侧对账)
- `store_discount`:品牌合作门店(partner_store)展示"会员价"标识,到店出示会员状态(App 会员码页),自营核销二期
## 6. 配置(config.yml 新增段,Key 默认空)
```yaml
payment:
xunhu_appid: ""
xunhu_appsecret: ""
notify_url: "http://<公网>/member/order/notify" # 回调需公网可达
channel: "alipay,wechat"
ad:
limit_effect_extra: 2 # 每日激励视频次数(效果图)
limit_vip_trial: 1
cps:
meituan_appkey: ""
meituan_pid: ""
meituan_shop_id: ""
jd_appkey: ""
jd_secret: ""
jd_pid: ""
tb_appkey: ""
tb_secret: ""
tb_pid: ""
sync_cron: "0 4 * * *" # 选品池定时同步
```
## 7. 合规与风控
- **支付**:金额单位分;回调幂等 + 验签;`pay_notify_log` 全量审计;退款人工处理(记录到订单)
- **iOS 合规**iOS 端 WebView 支付为国内惯例做法,需在 App Store 审核时注意(虚拟商品 IAP 政策风险,上线策略:iOS 端主推激励广告+门店引流,充值入口弱化或按要求接 IAP)
- **广告**:隐私政策披露第三方 SDK 收集信息;提供个性化广告关闭入口(穿山甲 SDK 提供)
- **CPS**:各联盟 API 需个人/企业账号申请(美团联盟、京东联盟、淘宝客均可个人申请);跳转链接遵守联盟推广规范(不得截流/改链接);禁用敏感类目(医疗、成人等)
- **激励防刷**`ad_reward_log` 唯一索引 + 自然日限频;异常用户(同设备多账号)风控日志记录
## 8. 分期实施与成本
| 分期 | 内容 | 后端工作量 | 依赖 |
|---|---|---|---|
| **P0** | 会员全链路(4 表 + 5 接口 + 虎皮棋适配器 + 回调验签)+ 广告激励(1 表 + 1 接口 + 效果图限额改造) | ~2 人日 | 虎皮棋账号 |
| **P1** | CPS 引擎(4 表 + 6 接口 + 美团适配器 + 方案驱动推荐)+ 转链缓存 + 点击日志 | ~2.5 人日 | 美团联盟账号 |
| **P2** | 京东/淘宝适配器 + 会员返现加成 + 收益看板 + 风控报表 | ~2 人日 | 京东/淘宝联盟账号 |
- **服务器成本**:零新增基础设施(SQLite 表均小体量,选品池定时同步 + 转链缓存)
- **模型成本**:零新增 LLM 调用(类目映射 + 关键词匹配)
- **维护成本**:联盟 API 变更由适配器隔离;第三方故障 → 接口降级返回错误,App 隐藏入口
## 9. 开发规范约束(沿用 video-factory 规范)
- Controller→Service→DAO 三层,包级单例(`var MemberService = new(memberService)`
- RouteRegister 反射路由,**handler 必须 2 参** `func(ctx context.Context, req *BizReq) (*BizRes, error)`struct 名 kebab-case`member_plan``/member/plan`
- 用户 ID 一律 `common.GetUserId(g.RequestFromCtx(ctx))`
- 每表一 DAO`dao/member_plan_dao.go` 等),`init()``CREATE TABLE IF NOT EXISTS` + seed
- 统一响应 `{"code":0,"message":"OK","data":...}``/member/order/notify` 加入 publicPaths
- 外部服务(支付/联盟)全部走包内适配器(`payment/``cps/`),业务层不直接感知
- 配置默认空 → 降级不 panic(与 llm/weather 同模式)
@@ -0,0 +1,301 @@
# slogan-agent 服务端设计方案
> 日期:2026-07-31
> 关联:slogan-app 设计方案(App 端)见 slogan-app 仓库对应文档
## 1. 项目概述
slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现。
本仓库为服务端(slogan-agent),提供:用户/照片/衣橱/身形管理、3D 化身构建、穿搭方案生成(规则评分 + Agent)、效果图生成、天气服务、商业化渠道(CPS 电商/门店导流/订阅)。
## 2. 开发规范约束(严格遵守 video-factory
本服务端**架构与代码规范严格遵守** `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory` 的既有规范:
| 规范点 | 约束 |
|--------|------|
| 技术栈 | Go 1.22+ / GoFrame v2 (github.com/gogf/gf/v2) / SQLiteGoFrame ORM 驱动) |
| 认证 | JWT (golang-jwt/jwt/v5)`/user/login` 公开,其余全部经 auth 中间件,7 天过期,bcrypt 密码 |
| 分层 | Controller → Service → DAO → SQLite;每一层独立包,包级变量单例(`var XxxService = new(xxxService)` |
| 路由 | `RouteRegister`common/http/http.go)反射注册,kebab-case 前缀,如 `/outfit/generate` |
| 响应 | 统一 JSON `{"code":0,"message":"OK","data":...}` |
| DAO | 每张表一个 DAO`init()` 自动建表 + ALTER TABLE 兼容迁移 |
| 模型 | `model/entity/`(表实体)+ `model/dto/`(请求响应,含 g.Meta 路由)+ `model/domain/` |
| Agent | 复用 video-factory ReAct 引擎模式:chat_model.goOpenAI 兼容 API,指数退避重试)+ react_agent.go + tools.go + context.go |
| 模型配置 | 系统配置 + 用户配置 → MergedModelConfig(复用 model_config / user_model_config 表模式) |
| 异步任务 | 生成任务表 + 后台轮询(复用 GenerationService.StartPoller 模式,15s 间隔) |
| 文件存储 | `workspace/` 目录 + JWT 鉴权静态文件服务(BindHandler 方式,防路径穿越) |
| 参数校验 | gvalidmain.go 注册自定义规则) |
| 部署 | 单体服务,Docker(复用 video-factory Dockerfile 模式),端口 3006 规则下自定 |
**新增加固规则**(本项目的领域约束):
- 所有涉及 LLM / 图像生成的调用必须经过"供应商适配层"chat_model / imagegen),禁止业务代码直连第三方 SDK
- 所有外部 API(人脸/天气/地理编码)必须封装为 service 层适配器,Key 存配置表不入代码
- 费用敏感:LLM/图像调用全部走任务表异步化 + 缓存,禁止同步阻塞式出图
## 3. 总体架构
```
Flutter App (slogan-app)
│ HTTPS + JWT
slogan-agent (Go 单体)
├── controller → service → dao → SQLite
├── avatar/ 3D 化身管线(预烘焙模板匹配 + 贴图合成 + GLB 输出)
├── scoring/ 规则引擎评分(零 LLM 成本)
├── agent/ 轻量 Agent(方案规划 / 兜底创作)
├── imagegen/ 效果图客户端(多供应商适配 + 缓存)
├── weather/ 天气适配(和风天气 + 缓存)
├── commercial/ CPS 商品 / 门店 / 导流 / 订阅
├── assets/avatar-templates/ 预烘焙模板库(构建期产物,运行时只读)
└── workspace/ 用户照片 / GLB / 效果图
```
## 4. 项目结构
```
slogan-agent/
├── main.go # 入口:RouteRegister + workspace 鉴权文件服务 + 后台轮询
├── common/ # 复用 video-factoryauth / cache / http / base_dao
├── styleagent/ # 业务模块(对应 shortdrama
│ ├── controller/ # user / user-photo / wardrobe / body-measurement /
│ │ # avatar / outfit / hairstyle / product-recommend /
│ │ # partner-store / store-lead / subscription
│ ├── service/ # 对应业务逻辑(每域一个)
│ ├── dao/ # 每表一个
│ ├── model/
│ │ ├── entity/ # 表实体
│ │ ├── dto/ # 请求/响应 + g.Meta 路由
│ │ └── domain/
│ │ ├── outfit_plan.go # 方案领域模型 + JSON 解析校验
│ │ └── avatar_profile.go # 化身参数配置
│ ├── avatar/ # 3D 化身管线
│ │ ├── template_matcher.go # 特征 → 模板匹配
│ │ ├── texture_composer.go # 面部照片贴图合成
│ │ ├── glb_packer.go # 头部/身体/发型 GLB 组合打包
│ │ └── template_builder/ # 构建期烘焙脚本(MakeHuman/MPFB+BlenderCI 运行,不入运行时)
│ ├── scoring/ # 规则引擎评分
│ │ ├── rules.go # 规则定义与配置加载
│ │ ├── weather_rule.go # 天气适宜度
│ │ ├── occasion_rule.go # 场合匹配
│ │ ├── color_rule.go # 色彩和谐
│ │ └── completeness_rule.go # 层次完整度
│ ├── agent/ # 轻量 Agent
│ │ ├── chat_model.go # OpenAI 兼容调用(含重试/限流,复用模式)
│ │ ├── outfit_agent.go # 方案规划 / 兜底创作
│ │ ├── tools.go # get_weather / list_wardrobe / score_outfit / create_plan
│ │ └── output.go # 输出 JSON Schema 校验
│ ├── imagegen/
│ │ ├── client.go # ImageGenClient 接口
│ │ ├── wanx_client.go # 通义万相
│ │ ├── jimeng_client.go # 即梦
│ │ └── cache.go # 按快照 hash 缓存
│ ├── weather/
│ │ ├── qweather.go # 和风天气适配
│ │ └── geo.go # 地点 → 城市编码(高德)
│ ├── commercial/
│ │ ├── cps.go # CPS 商品检索
│ │ ├── store.go # 合作门店 LBS
│ │ └── subscription.go # 订阅权益
│ └── consts/
│ ├── public/table_name.go # 表名常量
│ ├── public/content_type.go # 照片类型/方案来源/任务状态
│ └── status.go # 任务状态常量
├── assets/avatar-templates/ # 预烘焙模板(20 头部 GLB + 6 身体 GLB + 5 档皮肤贴图 + 发型 GLB)
└── workspace/ # 用户数据(照片/GLB/效果图)
```
## 5. 数据库设计(每表一个 DAO/Service/Controller
### 用户域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `user` | 复用 video-factory 用户模型(role 扩展:user) | 账号密码登录 v1,手机号绑定留扩展 |
| `user_photo` | id / user_id / type(1大头照 2全身正面 3全身侧面 4全身背面) / url / status | 3D 构建用原图 |
| `wardrobe_item` | id / user_id / photo_url / category(上衣/下装/鞋/配饰) / season / style_tags / color_info / status | 服装资产 |
| `body_measurement` | id / user_id / height / weight / skin_tone / fit_params(JSON) | 用户填写 + 照片估算合并 |
### 化身域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `avatar_model` | id / user_id / face_template_id / body_template_id / skin_tone_index / face_texture_url / glb_url / build_status / params_snapshot(JSON) | 3D 化身 |
| `hairstyle_asset` | id / name / style_tag / glb_url / thumb_url / applicable_face / sort | 发型资产库(静态维护) |
| `outfit_asset` | id / name / style_tag / season / glb_url / cc0_source | 服装简模资产库(少量 CC0) |
### 生成域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `outfit_generation_task` | id / user_id / start_date / end_date / location / weather_snapshot(JSON) / status(planning→scored→rendering→done/failed) / model_name / error | 生成任务(轮询) |
| `outfit_plan` | id / task_id / user_id / date_range / location / source(wardrobe/recommend) / score / main_flag / hairstyle_id / hair_color / weather_ref(JSON) | 穿搭方案 |
| `plan_outfit_item` | id / plan_id / slot(发型/上衣/下装/鞋/配饰) / source(wardrobe/recommend) / wardrobe_item_id(可空) / product_recommend_id(可空) / name / desc | 方案条目 |
| `plan_effect_image` | id / plan_id / angle(正面/侧面/背面) / url / status / prompt_snapshot | 2D 效果图 |
| `plan_review` | id / plan_id / user_id / action(fav/unfav) / note | 用户反馈 → 回流 Agent |
### 商业域
| 表 | 字段要点 | 说明 |
|----|---------|------|
| `product_recommend` | id / plan_id(可空,全局备选) / product_name / channel(淘宝/京东/抖音/拼多多) / cps_url / price / commission_rate / image_url / status | CPS 商品 |
| `partner_store` | id / name / type(1形象设计 2服装门店) / lat / lng / address / commission_policy(JSON) / status | 合作门店 |
| `store_lead` | id / user_id / plan_id / store_id / status(created→visited→settled/cancelled) / create_time | 导流订单 |
| `subscription` | id / user_id / plan_type(standard/pro) / start_time / end_time / status | 会员订阅 |
| `model_config` / `user_model_config` | 复用 video-factory 表结构 | 模型配置 |
| `imagegen_config` | id / supplier / api_key / model_name / price_tier / enabled | 图像生成供应商配置 |
| `scoring_rule` | id / dimension / rule_type / rules_json / enabled / version | 评分规则配置(第 7 节),内置默认值 + 可配置 |
## 6. 3D 化身管线(预烘焙模板 + 运行时匹配)
### 核心理念
所有"昂贵且不稳定"的环节在**构建期**完成;运行时只做轻量匹配与合成,服务器成本趋近于零。
### 构建期(CI 或发布流水线,一次性执行)
1. MakeHuman(CC0 资产,官方导出可商用)生成参数化角色基底
2. MPFB + Blender headless 脚本烘焙:
- 20 个头部 GLB(脸型差异,PBR 材质)
- 6 个身体 GLB(体型差异:身高×胖瘦组合)
- 5 档皮肤贴图(肤色深浅)
- 10-15 个发型 GLB(CC0/自建,含发色可调材质)
3. glTF-Transform 压缩优化,产物提交 `assets/avatar-templates/`
### 运行时(用户触发 build)
```
用户照片(大头照+全身) + 身形参数
→ ① 特征提取:国内人脸 API(腾讯/阿里,免费额度)→ 脸型/五官特征向量
→ ② 模板匹配:特征向量 → 最近脸型模板(余弦距离,阈值外降级到用户滑杆微调)
→ ③ 贴图合成:大头照人脸区域 → 面部贴图(对齐模板 UV,肤色按色阶匹配 5 档)
→ ④ 打包:组合 头部模板 + 身体模板 + 皮肤贴图 → avatar GLB(头部/身体/发型分离存储,App 端组合换装)
→ ⑤ 保存 avatar_model 记录(build 任务异步,状态机 pending→processing→done/failed
```
### v1 边界声明
- 化身定位"高相似度虚拟形象"(脸型/肤色/身形贴近),非照片级真人重建
- 发型为资产库切换,不做 AI 重建用户真实发型
- 用户可在 App 端用滑杆微调身形/肤色(参数化信息与照片估算合并),滑杆调整即时反映在 GLB 缩放参数上(运行时零渲染成本)
## 7. 规则引擎评分(零 LLM 成本)
每个候选方案多维度打分,总分 100:
| 维度 | 权重 | 规则来源 |
|------|------|---------|
| 天气适宜度 | 25 | 温度区间 × 服装厚度匹配表(如 <10°C 需外套;25-32°C 短袖) |
| 场合匹配 | 25 | 日期类型(工作日/周末/节假日)→ 场合(通勤/约会/聚会)→ 服装类别规则表 |
| 色彩和谐 | 20 | 色相环配色表(同类色/邻近色/对比色得分) |
| 层次完整度 | 20 | 上衣/下装/鞋/配饰齐全度 + 可穿性(衣橱库存覆盖) |
| 风格一致性 | 10 | 服装 style_tags 与用户画像(历史收藏偏好)匹配度 |
- 规则表配置存库(`scoring_rule` 可配置,后台可调,v1 内置默认值常量 + 配置表扩展)
- 阈值 75 分可配置
- 全部低于阈值 → 判定"无合格衣橱方案",触发 Agent 兜底创作
- 免费用户效果图次数:每日 N 次(默认 3 次,配置可调);pro 订阅不限
## 8. 穿搭生成流程(Agent + 评分 + 兜底)
```
POST /outfit/generate {start_date, end_date, location}
→ ① 天气获取(和风 API,按 城市+日期 缓存 6h;地点经高德地理编码)
→ ② 规则引擎预筛:衣橱 × 天气 × 场合 → 3 套候选组合(零 LLM)
→ ③ Agent 规划(1 次 LLM 调用):
│ 工具:get_weather / list_wardrobe / score_outfit(规则引擎) / create_plan
│ 输出:3 套方案结构化 JSON(每套含发型建议/发色/服装条目)
→ ④ 规则评分:≥75 → source=wardrobe3 套全 <75 → LLM 兜底创作(1 次调用):
│ 输入:用户画像 + 天气 + 场合 + 衣橱摘要
│ 输出:高分方案 JSON(含 1-3 件新服装推荐,带品类/风格/价格带)
│ 方案标记 source=recommend,新服装关联 CPS 商品检索
→ ⑤ 保存方案(outfit_plan + plan_outfit_item),任务状态 → done
→ ⑥ App 端 3D 即时呈现 3 套方案(无额外成本);用户选定主方案后:
→ ⑦ 效果图按需生成(见下节),缓存命中则免费
```
**成本控制**
- 每次生成 LLM 调用 ≤ 2 次(规划 + 兜底,兜底仅全低分时触发)
- 评分 100% 规则引擎
- 工具调用控制在 3-5 次内(ReAct 最大步数 8
## 9. 效果图生成(按需 + 缓存 + 多供应商)
- 供应商适配器:`ImageGenClient` 接口,实现 通义万相(人像写真类 API)/ 即梦,配置表切换
- 输入:用户全身照 + 方案条目描述 + 人像一致性参数 + 视角(正面/侧面/背面)
- 触发:用户选定主方案后自动生成 3 视角;其余方案需用户主动请求(免费次数内/订阅权益检查)
- 缓存:key = md5(user_id + wardrobe_snapshot + plan_content),命中直接返回已生成图
- 异步:任务表 + 轮询(复用 StartPoller 模式)
- 失败重试 1 次,仍失败则标记 failed 并降级提示(3D 方案仍可用)
## 10. 商业化模块
| 渠道 | 实现 |
|------|------|
| 服装电商 CPS | `product_recommend` 表;兜底方案新服装检索 CPS 商品(淘宝联盟/京东联盟/抖音电商),App 端展示跳转,按成交佣金分成 |
| 形象设计门店 | `partner_store` type=1;发型/造型方案 LBS 推荐附近合作店(理发/造型师),`store_lead` 导流 + 到店核销 |
| 服装门店渠道 | `partner_store` type=2;本地服装门店展示 + 方案一键到店 |
| 会员订阅 | `subscription`standard(免费基础)/ pro(无限生成/高清效果图/方案全量效果图解锁) |
## 11. API 路由表(所有请求 JWT 鉴权,除 /user/login
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/user/login` | 登录(公开) |
| POST | `/user/change-password` | 修改密码 |
| GET | `/user/profile` | 个人资料 |
| POST | `/user-photo/upload` | 上传照片(type:大头照/全身正面/侧面/背面) |
| GET | `/user-photo/list` | 照片列表 |
| POST | `/user-photo/delete` | 删除照片 |
| POST | `/wardrobe/upload` | 上传服装(分类/季节/风格标签) |
| GET | `/wardrobe/list` | 衣橱列表 |
| POST | `/wardrobe/update` | 更新服装信息 |
| POST | `/wardrobe/delete` | 删除服装 |
| POST | `/body-measurement/save` | 保存身形参数 |
| GET | `/body-measurement/get` | 获取身形参数 |
| POST | `/avatar/build` | 触发化身构建任务 |
| GET | `/avatar/get` | 化身信息(GLB 地址/状态) |
| POST | `/avatar/rebuild` | 重新构建化身 |
| GET | `/hairstyle/list` | 发型资产列表 |
| POST | `/outfit/generate` | 生成穿搭方案(日期范围+地点) |
| GET | `/outfit/task/status` | 生成任务状态轮询 |
| GET | `/outfit/plan/list` | 方案列表(历史) |
| GET | `/outfit/plan/detail` | 方案详情(3D 配置 + 条目 + 商品/门店) |
| POST | `/outfit/plan/select-main` | 选定主方案(触发效果图生成) |
| POST | `/outfit/plan/effect-image/generate` | 补生成某方案效果图(权益检查) |
| POST | `/outfit/plan/review` | 方案反馈(收藏/点赞/备注) |
| GET | `/product-recommend/list` | 方案关联 CPS 商品 |
| GET | `/partner-store/list` | 附近合作门店(lat/lng |
| POST | `/store-lead/create` | 创建导流订单 |
| POST | `/store-lead/confirm` | 到店核销 |
| POST | `/subscription/create` | 创建订阅 |
| GET | `/subscription/status` | 订阅状态 |
## 12. 错误处理与异步任务
- 任务状态机:`pending → processing → done / failed`,失败写 `error` 字段,App 轮询展示
- 外部 API(人脸/天气/LLM/图像)统一超时与指数退避重试;Key 失效/欠费返回明确错误码
- 图片上传限制:单张 ≤ 10MB,格式 jpg/png/webp,服务端校验 + 压缩(宽边 ≤ 2048)
- 路径安全:workspace 文件服务防 `..` 穿越(复用 video-factory BindHandler 实现)
## 13. 测试策略
- DAO/Service:表驱动单测(SQLite 内存库),覆盖评分规则各维度边界(温度档位/色彩组合/阈值判定)
- Agent:输出 JSON Schema 校验测试 + 工具 mockchat_model 接口化)
- 化身管线:模板匹配单元测试(特征向量 → 模板索引)+ 贴图合成冒烟
- Controller:路由注册冒烟 + 鉴权中间件测试
- 关键流程集成测试:generate → 评分 → 兜底 → 出图(全 mock 外部 API)
## 14. 成本估算与部署(初期 1 万次生成/月)
| 项目 | 月成本 | 说明 |
|------|--------|------|
| 服务器 | ~¥150 | 2C4G 轻量云,Docker 部署单体 |
| LLM | ~¥1000 | DeepSeek/Qwen~¥0.1/次(≤2 次调用 + 工具) |
| 图像生成 | ~¥7000 | 主方案 3 视角 ≈ ¥0.7/次;pro 订阅用户分摊成本 |
| 人脸 API / 天气 | 免费额度内 | 缓存 + 免费版 |
| **单次生成总成本** | **~¥0.8** | 其中图像生成占大头,已按最优策略控制 |
- 存储 v1 本地 workspace(可迁 OSS/COS,存储接口抽象预留)
- 规模化信号:存储 > 50GB 或单机 CPU 持续 >70% → 迁对象存储 + 拆分轮询 worker
+104
View File
@@ -0,0 +1,104 @@
# slogan-agent 服务端
人形象设计应用(slogan)的服务端。用户上传个人照片与服装照片,指定日期地点后由大模型生成穿搭方案(含发型),支持 3D 化身与效果图查看。
技术栈:Go 1.22+ / GoFrame v2 / SQLite / JWT / OpenAI 兼容大模型 / 和风天气 + 高德地理编码。
## 快速开始
```bash
go mod tidy
go build -o slogan-agent .
./slogan-agent
```
服务默认监听 `:3007`,首次启动自动建库建表(`slogan.db`)。
### 必要配置(config.yml
| 配置项 | 说明 |
|--------|------|
| `llm.base_url / api_key / model_name` | 大模型(OpenAI 兼容,如通义/DeepSeek/Kimi),未配置时生成任务失败并返回明确错误 |
| `weather.qweather_key` | 和风天气 v7 Key(免费版即可),用于 7 天预报 |
| `geo.amap_key` | 高德地理编码 Key,地点 → adcode |
| `imagegen.supplier` | 效果图供应商:`mock`(占位图,开发用)或 `wanx`(通义万相,需配 `wanx_api_key` |
未配置天气/LLM Key 时接口返回明确错误提示,服务本身可正常启动。
## 接口总览
统一响应格式:`{"code":0,"message":"OK","data":...}``code != 0` 为业务错误。除公开接口外需 `Authorization: Bearer <token>`JWT7 天有效)。
| 模块 | 路径 | 说明 | 公开 |
|------|------|------|------|
| 用户 | `POST /user/register` | 注册 | 是 |
| 用户 | `POST /user/login` | 登录,返回 token | 是 |
| 用户 | `POST /user/change-password` | 修改密码 | |
| 用户 | `GET /user/profile` | 个人信息 | |
| 照片 | `POST /user-photo/upload` | 上传照片(type: 1 大头 2 全身正面 3 侧面 4 背面) | |
| 照片 | `GET /user-photo/list` | 照片列表(type 可筛选) | |
| 照片 | `POST /user-photo/delete` | 删除照片 | |
| 衣橱 | `POST /wardrobe/upload` | 上传服装(category: 上衣/下装/鞋/配饰,season, style_tags, color_info | |
| 衣橱 | `GET /wardrobe/list` | 衣橱列表 | |
| 衣橱 | `POST /wardrobe/update` | 更新服装信息 | |
| 衣橱 | `POST /wardrobe/delete` | 删除服装 | |
| 身形 | `POST /body-measurement/save` | 保存身形(height/weight/skin_tone | |
| 身形 | `GET /body-measurement/get` | 查询身形 | |
| 化身 | `POST /avatar/build` | 构建 3D 化身(模板匹配,v1 同步) | |
| 化身 | `GET /avatar/get` | 化身信息(glb_url | |
| 发型 | `GET /hairstyle/list` | 发型资产库 | 是 |
| 穿搭 | `POST /outfit/generate` | 生成穿搭方案(异步任务,body: start_date/end_date/location | |
| 穿搭 | `GET /outfit/task/status` | 任务状态(pending→planning→scoring→done/failed | |
| 穿搭 | `GET /outfit/plan/list` | 方案列表 | |
| 穿搭 | `GET /outfit/plan/detail` | 方案详情(items + hairstyle + effect images | |
| 穿搭 | `POST /outfit/plan/select-main` | 选定主方案(触发 3 视角效果图生成) | |
| 穿搭 | `POST /outfit/plan/review` | 方案反馈(fav/unfav | |
| 门店 | `GET /partner-store/list` | 合作门店(type: 1 形象设计 2 服装门店,0 全部) | |
| 静态 | `GET /workspace/*` | 上传文件与模板资产(鉴权放行) | |
OpenAPI 文档:`http://127.0.0.1:3007/api.json`
## 生成流程(outfit/generate
```
pending → planning(天气获取 → 规则预筛 3 套候选 → LLM 规划 1 次调用)
→ scoring(规则引擎 5 维评分:天气 25/场合 25/色彩 20/完整度 20/风格 10,阈值 75
→ 全低分 → LLM 兜底创作(1 次调用,recommend 方案)
→ 落库 outfit_plan + plan_outfit_item
→ done
```
- 衣橱不足 3 件、日期倒挂、Key 未配置等均在任务结果中返回明确错误
- 服务重启时未完成任务标记 failed(避免重复消耗模型费用)
- 效果图按需生成:选主方案后异步生成 正面/侧面/背面 3 张,内容 hash 缓存 24h,每日限 3 次(可配 `scoring_rule``effect_limit` 维度)
## 数据模型
13 张表:`slogan_user``slogan_user_photo``slogan_wardrobe_item``slogan_body_measurement``slogan_avatar_model``slogan_hairstyle_asset`seed 8 发型)、`slogan_outfit_generation_task``slogan_outfit_plan``slogan_plan_outfit_item``slogan_plan_effect_image``slogan_plan_review``slogan_scoring_rule``slogan_partner_store`seed 4 门店)。
## 目录结构
```
main.go 入口:路由注册 + workspace 静态服务 + 任务恢复
common/ 统一响应/RouteRegister/JWT 鉴权/工具
styleagent/
controller/ Controller 层(反射路由,struct 名 → kebab-case URL
service/ 业务层(生成编排/化身/衣橱/效果图)
dao/ 每表一 DAOinit 自动建表 + seed
model/entity|dto/ 实体与请求响应结构
agent/ LLM 调用(OpenAI 兼容,重试/工具调用)+ 方案规划/兜底
scoring/ 规则评分引擎(零 LLM 成本)
weather/ 和风天气 + 高德地理编码 + 缓存
imagegen/ 效果图客户端(mock/wanx+ 缓存
avatar/ 3D 化身模板匹配
consts/ 常量
```
## 部署
```bash
docker build -t slogan-agent .
docker run -d -p 3007:3007 -v /data/slogan:/app/workspace -v /data/slogan/slogan.db:/app/slogan.db slogan-agent
```
生产部署前在 config.yml 填写 llm/weather/geo/imagegen 的真实 Key。