- 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
338 lines
10 KiB
Go
338 lines
10 KiB
Go
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"
|
||
}
|