first commit
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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()
|
||||
result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes)
|
||||
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 := s.outfitDao.OutfitCreate(ctx, outfit); err != nil {
|
||||
return nil, fail(errcode.OutfitCreateFailed, "穿搭落库失败")
|
||||
}
|
||||
|
||||
clothesIDs := result.GetClothesIDs()
|
||||
items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs))
|
||||
for _, cid := range clothesIDs {
|
||||
items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid})
|
||||
}
|
||||
if err := s.outfitItemDao.OutfitItemCreateBatch(ctx, items); err != nil {
|
||||
// 明细落库失败:删除已建穿搭并回退额度,避免产生无明细的空推荐。
|
||||
_ = s.outfitDao.OutfitDeleteByUUID(ctx, outfit.OutfitUUID)
|
||||
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)
|
||||
}
|
||||
recordDailyUsage(ctx, userID, QuotaTypeAI)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user