Files
slogan/styleagent/service/outfit_generation_task_service.go
T
admin 775995f3e8 refactor: 五层对齐 22 表 + 工具并入 agent/ + 支付适配器内联(路由零变更)
- service/dto/controller 各拆 18 文件,一表一文件;跨表编排留在 outfit_generation_task_service
- controller 共享 struct(outfit/member)保 /outfit/*、/member/* 前缀,路由零漂移(28 路径 diff 为空)
- 修复 4 个缺失 g.Meta handler:/user/profile、/avatar/get、/body-measurement/get、/hairstyle/list 从 ALL 收敛为 GET
- 工具包并入 agent/ 单包(weather/imagegen/avatar/scoring),NewCache 泛化 NewTTLCache
- 虎皮椒支付适配器内联 service/payment_order_service.go,member 服务拆 4 表文件
- 冒烟 21 路径全绿 + 客户端 dart analyze 零 error
2026-07-31 15:15:04 +08:00

338 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"slogan-agent/styleagent/agent"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/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 := ScoringRuleService.Threshold(ctx)
plans := out.Plans
ctxScore := agent.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 agent.ScoreContext) int {
var out agent.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, agent.WardrobeItem{
Category: w.Category, Season: w.Season, ColorInfo: w.ColorInfo, StyleTags: w.StyleTags,
})
}
}
return agent.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
}
// ==================== 天气(原 weather_service 内联) ====================
var weatherCache = agent.NewTTLCache(6 * time.Hour)
// GetWeather 地点 + 日期范围 → 天气结果(高德地理编码 + 和风 7 天预报,缓存 6 小时)
func GetWeather(ctx context.Context, location, startDate, endDate string) (*agent.WeatherResult, error) {
cityCode, err := agent.GetCityCode(ctx, location)
if err != nil {
return nil, err
}
cacheKey := fmt.Sprintf("%s:%s:%s", cityCode, startDate, endDate)
if v, ok := weatherCache.Get(cacheKey); ok {
if result, ok := v.(*agent.WeatherResult); ok {
return result, nil
}
}
result, err := agent.GetDaily(ctx, cityCode, startDate, endDate)
if err != nil {
return nil, err
}
weatherCache.Set(cacheKey, result)
return result, nil
}
func weatherSummaryText(w *agent.WeatherResult) string {
return fmt.Sprintf("%s%s),平均 %d℃,%d 天", w.CityCode, w.Season, w.AvgTemp, len(w.Days))
}
// ==================== 预筛组合(原 outfit_combiner 内联) ====================
// candidateSet 一套预筛组合
type candidateSet struct {
Items []*entity.WardrobeItem
HasOuterwear bool
}
// combineCandidates 预筛组合算法:
// 按 category 分组 → 按季节过滤 → 确定性轮询组合,最多 3 套互不相同(含外套标记)
func combineCandidates(items []*entity.WardrobeItem, season string, maxSets int) []candidateSet {
groups := map[string][]*entity.WardrobeItem{}
for _, it := range items {
if season != "" && it.Season != "" && it.Season != "四季" && it.Season != season {
continue
}
groups[it.Category] = append(groups[it.Category], it)
}
if len(groups) == 0 || maxSets <= 0 {
return nil
}
var sets []candidateSet
cats := []string{"上衣", "下装", "鞋", "配饰"}
for i := 0; i < maxSets; i++ {
set := candidateSet{}
hasOuterwear := false
for _, cat := range cats {
g := groups[cat]
if len(g) == 0 {
continue
}
it := g[i%len(g)]
set.Items = append(set.Items, it)
if isOuterwear(it) {
hasOuterwear = true
}
}
if len(set.Items) == 0 {
break
}
set.HasOuterwear = hasOuterwear
sets = append(sets, set)
}
return sets
}
func isOuterwear(it *entity.WardrobeItem) bool {
return it.Category == "上衣" && (it.StyleTags == "" || it.StyleTags == "外套")
}
// toScoringOutfit 转评分用候选
func toScoringOutfit(set candidateSet) agent.CandidateOutfit {
o := agent.CandidateOutfit{HasOuterwear: set.HasOuterwear}
for _, it := range set.Items {
o.Items = append(o.Items, agent.WardrobeItem{
Category: it.Category,
Season: it.Season,
ColorInfo: it.ColorInfo,
StyleTags: it.StyleTags,
})
}
return o
}
// ==================== 内部辅助 ====================
func bodyDescText(ctx context.Context, userId int64) string {
bm, err := dao.BodyMeasurement.GetByUser(ctx, userId)
if err != nil || bm == nil {
return "身高 170cm,体重 60kg(默认)"
}
return fmt.Sprintf("身高 %dcm,体重 %dkg,肤色 %d 档", bm.Height, bm.Weight, bm.SkinTone)
}
func weekdayOf(date string) string {
d, err := time.Parse("2006-01-02", date)
if err != nil {
return "workday"
}
wd := d.Weekday()
if wd == time.Saturday || wd == time.Sunday {
return "weekend"
}
return "workday"
}