package service import ( "context" "encoding/json" "fmt" "log/slog" "time" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub" "gorm.io/gorm" ) // AIRecommendService AI 穿搭推荐业务服务。 type AIRecommendService struct { outfitDao *mysqldao.OutfitDao outfitItemDao *mysqldao.OutfitItemDao clothesDao *mysqldao.ClothesDao userDao *mysqldao.UserDao horoscopeDao *mysqldao.HoroscopeDao flowLogDao *mysqldao.AIRecommendFlowLogDao } // NewAIRecommendService 创建 AI 推荐服务。 func NewAIRecommendService() *AIRecommendService { return &AIRecommendService{ outfitDao: mysqldao.NewOutfitDao(), outfitItemDao: mysqldao.NewOutfitItemDao(), clothesDao: mysqldao.NewClothesDao(), userDao: mysqldao.NewUserDao(), horoscopeDao: mysqldao.NewHoroscopeDao(), flowLogDao: mysqldao.NewAIRecommendFlowLogDao(), } } // Create 发起 AI 穿搭推荐:防并发、扣 AI 额度、基于衣橱与运势生成推荐,落库为待保存穿搭。 func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendCreate") defer span.End() // 1. 防并发锁。 lockKey := fmt.Sprintf(redismodel.KeyLockAIRecommend, userID) lockVal := genUUID() ok, err := redisdao.TryLock(ctx, lockKey, lockVal, 180*time.Second) if err != nil { // Redis 异常:返回服务错误而非误报「请求过于频繁」。 return nil, errcode.CacheError } if !ok { return nil, errcode.TooManyRequests } defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() // 2. 扣减 AI 每日额度(权威扣减)。 if qerr := consumeDailyQuota(ctx, userID, QuotaTypeAI, 1, "AI 推荐消耗"); qerr != nil { if qerr == errcode.QuotaInsufficient { return nil, errcode.AIRecommendQuotaLimit } return nil, qerr } // 失败时统一回退额度的兜底闭包。 fail := func(bizErr *errcode.Error, reason string) *errcode.Error { refundDailyQuota(ctx, userID, QuotaTypeAI, 1) s.writeFlowLog(ctx, userID, "", AIFlowStatusFailed, reason) return bizErr } // 3. 加载用户可用衣物(已成功处理)。 clothes, cerr := s.processedClothes(ctx, userID) if cerr != nil { return nil, fail(errcode.DatabaseError, "查询衣橱失败") } if len(clothes) == 0 { return nil, fail(errcode.AIRecommendFailed.WithDetails("衣橱暂无可用衣物"), "衣橱为空") } // 4. 计算星座与今日运势(best-effort)。 zodiac, fortune := s.userFortune(ctx, userID) // 5. 调用大模型生成推荐。 requestTime := nowUnix() client := tokenhub.NewClient() // 用户自定义场景(scene_type=2)透传给模型;为空时由模型自行判断。 result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes, request.Scene) if gerr != nil { return nil, fail(errcode.AIRecommendFailed, "大模型调用失败") } result.ValidateClothesIDs(clothes) if len(result.Items) == 0 { return nil, fail(errcode.AIRecommendFailed.WithDetails("未生成有效搭配"), "无有效单品") } // 6. 落库为待用户保存的 AI 穿搭。 scene := result.Scene if scene == "" { scene = request.Scene } outfit := &mysqlmodel.Outfit{ OutfitUUID: genUUID(), UserID: userID, OutfitName: result.OutfitName, OutfitDesc: result.Description, Scene: scene, Source: OutfitSourceAI, IsPublic: OutfitPrivate, AuditStatus: OutfitAuditPending, Status: OutfitStatusUnsaved, } if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(outfit).Error; err != nil { return err } clothesIDs := result.GetClothesIDs() items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs)) for _, cid := range clothesIDs { items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid}) } return tx.Create(&items).Error }); err != nil { return nil, fail(errcode.OutfitCreateFailed, "穿搭及明细落库失败") } // 7. 写成功流水 + 当日计数。 resultJSON, _ := json.Marshal(result) if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{ RequestID: genUUID(), UserID: userID, ModelVersion: global.TokenhubSetting.Model, ResultContent: string(resultJSON), Status: AIFlowStatusSuccess, RequestTime: requestTime, ResponseTime: nowUnix(), }); err != nil { slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err) } // 8. 组装响应(衣物 UUID 来自校正后的 items)。 outfitItems := make([]dto.OutfitItem, 0, len(result.Items)) for _, it := range result.Items { outfitItems = append(outfitItems, dto.OutfitItem{ClothesUUID: it.ClothesUUID}) } return &dto.AIRecommendCreateResponse{ OutfitUUID: outfit.OutfitUUID, OutfitName: outfit.OutfitName, OutfitDesc: outfit.OutfitDesc, Scene: outfit.Scene, Source: outfit.Source, Status: outfit.Status, OutfitItem: outfitItems, }, nil } // Save 将 AI 推荐生成的待保存穿搭保存到「我的搭配」(置为正常状态)。 func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dto.AIRecommendSaveRequest) *errcode.Error { ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendSave") defer span.End() outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID) if err != nil { if isNotFound(err) { return errcode.OutfitNotFound } return errcode.DatabaseError } if outfit.UserID != userID { return errcode.OutfitNoPermission } // 仅待保存状态可保存;已正常则幂等返回。 if outfit.Status == OutfitStatusNormal { return nil } if outfit.Status != OutfitStatusUnsaved { return errcode.OutfitUpdateFailed } if uerr := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, map[string]any{ "status": OutfitStatusNormal, }); uerr != nil { return errcode.OutfitUpdateFailed } return nil } // ---- 内部辅助 ---- // processedClothes 获取用户已成功处理、可用于推荐的衣物。 func (s *AIRecommendService) processedClothes(ctx context.Context, userID int64) ([]mysqlmodel.Clothes, error) { var result []mysqlmodel.Clothes page, pageSize := 1, 200 for { list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, "", "", page, pageSize) if err != nil { return nil, err } for _, c := range list { if c.ImageProcessStatus == ImageProcessSuccess { result = append(result, c) } } if len(list) == 0 || int64(page*pageSize) >= total { break } page++ } return result, nil } // userFortune 计算用户星座并读取今日运势(缺失时返回仅含星座的空运势)。 func (s *AIRecommendService) userFortune(ctx context.Context, userID int64) (string, *tokenhub.FortuneItem) { zodiac := "" if user, err := s.userDao.UserGetByID(ctx, userID); err == nil && user.BirthDay != 0 { zodiac = zodiacByUnix(user.BirthDay) } fortune := &tokenhub.FortuneItem{Zodiac: zodiac} if zodiac != "" { if h, err := s.horoscopeDao.HoroscopeGetByZodiacAndDate(ctx, zodiac, todayStatDate()); err == nil && h != nil { fortune = &tokenhub.FortuneItem{ Zodiac: h.Zodiac, Overall: h.OverallScore, Love: h.LoveScore, Work: h.CareerScore, Money: h.WealthScore, LuckyColor: h.LuckyColor, LuckyNumber: h.LuckyNumber, Description: h.DailyText, } } } return zodiac, fortune } // writeFlowLog 写 AI 推荐流水(best-effort,失败仅告警不阻断主流程)。 func (s *AIRecommendService) writeFlowLog(ctx context.Context, userID int64, resultContent string, status int8, failReason string) { if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{ RequestID: genUUID(), UserID: userID, ModelVersion: global.TokenhubSetting.Model, ResultContent: resultContent, Status: status, FailReason: failReason, RequestTime: nowUnix(), ResponseTime: nowUnix(), }); err != nil { slog.WarnContext(ctx, "write ai recommend flow log failed", "user_id", userID, "status", status, "error", err) } }