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 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