package service import ( "context" "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/upload" "gorm.io/gorm" ) // OutfitService 穿搭业务服务。 type OutfitService struct { outfitDao *mysqldao.OutfitDao outfitItemDao *mysqldao.OutfitItemDao clothesDao *mysqldao.ClothesDao auditLogDao *mysqldao.OutfitAuditLogDao userDao *mysqldao.UserDao likeDao *mysqldao.LikeDao collectDao *mysqldao.CollectDao } // NewOutfitService 创建穿搭服务。 func NewOutfitService() *OutfitService { return &OutfitService{ outfitDao: mysqldao.NewOutfitDao(), outfitItemDao: mysqldao.NewOutfitItemDao(), clothesDao: mysqldao.NewClothesDao(), auditLogDao: mysqldao.NewOutfitAuditLogDao(), userDao: mysqldao.NewUserDao(), likeDao: mysqldao.NewLikeDao(), collectDao: mysqldao.NewCollectDao(), } } // Create 创建穿搭。 func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.OutfitCreateRequest) (*dto.OutfitCreateResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitCreate") defer span.End() clothesIDs, items, cerr := s.resolveOwnedClothes(ctx, userID, request.Items) if cerr != nil { return nil, cerr } source := request.Source if source != OutfitSourceUser && source != OutfitSourceAI { source = OutfitSourceUser } outfit := &mysqlmodel.Outfit{ OutfitUUID: genUUID(), UserID: userID, OutfitName: request.OutfitName, OutfitDesc: request.OutfitDesc, Scene: request.Scene, Source: source, IsPublic: OutfitPrivate, AuditStatus: OutfitAuditPending, Status: OutfitStatusNormal, } if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(outfit).Error; err != nil { return err } dbItems := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs)) for _, cid := range clothesIDs { dbItems = append(dbItems, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid}) } if len(dbItems) > 0 { return tx.Create(&dbItems).Error } return nil }); err != nil { return nil, errcode.OutfitCreateFailed } response := buildOutfitCreateResponse(outfit, items) return &response, nil } // GetByUUID 获取穿搭详情。私有穿搭仅本人可见。 func (s *OutfitService) GetByUUID(ctx context.Context, userID int64, request *dto.OutfitGetByUUIDRequest) (*dto.OutfitGetByUUIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitGetByUUID") defer span.End() outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID) if err != nil { if isNotFound(err) { return nil, errcode.OutfitNotFound } return nil, errcode.DatabaseError } // 可见性:本人,或已公开且审核通过且正常。 if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { return nil, errcode.OutfitNoPermission } items, lerr := s.itemResponses(ctx, outfit.ID) if lerr != nil { return nil, errcode.DatabaseError } return &dto.OutfitGetByUUIDResponse{ OutfitCreateResponse: buildOutfitCreateResponse(outfit, items), }, nil } // UpdateByUUID 修改穿搭基础信息与明细。 func (s *OutfitService) UpdateByUUID(ctx context.Context, userID int64, request *dto.OutfitUpdateByUUIDRequest) (*dto.OutfitUpdateByUUIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitUpdateByUUID") defer span.End() outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID) if cerr != nil { return nil, cerr } clothesIDs, _, verr := s.resolveOwnedClothes(ctx, userID, request.Items) if verr != nil { return nil, verr } updates := map[string]any{} setIfNotEmpty(updates, "outfit_name", request.OutfitName) setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc) setIfNotEmpty(updates, "scene", request.Scene) rebuildErr := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if len(updates) > 0 { if err := tx.Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", request.OutfitUUID).Updates(updates).Error; err != nil { return err } } if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil { return err } items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs)) for _, cid := range clothesIDs { items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid}) } if len(items) == 0 { return nil } return tx.Create(&items).Error }) if rebuildErr != nil { return nil, errcode.OutfitUpdateFailed } return &dto.OutfitUpdateByUUIDResponse{}, nil } // List 分页查询我的穿搭。 func (s *OutfitService) List(ctx context.Context, userID int64, request *dto.OutfitListRequest) (*dto.OutfitListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) list, total, err := s.outfitDao.OutfitListByUserID(ctx, userID, request.IsPublic, request.Source, page, pageSize) if err != nil { return nil, errcode.DatabaseError } items, berr := s.buildOutfitListResponses(ctx, list) if berr != nil { return nil, errcode.DatabaseError } return &dto.OutfitListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // CommunityList 分页查询社区穿搭:其他用户「公开 + 审核通过 + 状态正常」的穿搭, // 聚合作者信息与点赞/收藏计数后一次性下发(批量查询,避免 N+1)。 func (s *OutfitService) CommunityList(ctx context.Context, userID int64, request *dto.OutfitCommunityListRequest) (*dto.OutfitCommunityListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitCommunityList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) list, total, err := s.outfitDao.OutfitListCommunity(ctx, userID, page, pageSize) if err != nil { return nil, errcode.DatabaseError } items, berr := s.buildCommunityItems(ctx, list) if berr != nil { return nil, errcode.DatabaseError } return &dto.OutfitCommunityListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // Delete 删除穿搭(软删除穿搭 + 清理明细)。 func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.OutfitDeleteRequest) (*dto.OutfitDeleteResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitDelete") defer span.End() outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID) if cerr != nil { return nil, cerr } if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil { return err } return tx.Where("outfit_uuid = ?", request.OutfitUUID).Delete(&mysqlmodel.Outfit{}).Error }); err != nil { return nil, errcode.OutfitDeleteFailed } return &dto.OutfitDeleteResponse{}, nil } // Public 设置穿搭公开状态。公开时自动审核通过并写审核日志、消耗社区分享额度。 func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.OutfitPublicRequest) (*dto.OutfitPublicResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.OutfitPublic") defer span.End() outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID) if cerr != nil { return nil, cerr } // 公开/取消公开流程加锁串行化:消除并发下「A 扣额失败回退公开态、B 幂等返回成功但最终未公开」 // 的语义冲突;锁失败返回请求过于频繁,Redis 异常时降级放行(与项目 fail-open 策略一致)。 lockKey := fmtLockKey(redismodel.KeyLockOutfitPublic, request.OutfitUUID) lockVal := genUUID() if ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 10*time.Second); lerr != nil { slog.WarnContext(ctx, "acquire outfit public lock failed, proceed without lock", "outfit_uuid", request.OutfitUUID, "error", lerr) } else if !ok { return nil, errcode.TooManyRequests } else { defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() } // 取消公开:直接置为不公开。 if request.IsPublic == OutfitPrivate { if outfit.IsPublic == OutfitPrivate { return &dto.OutfitPublicResponse{}, nil } if err := s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate); err != nil { return nil, errcode.OutfitUpdateFailed } return &dto.OutfitPublicResponse{}, nil } // 已公开则无需重复处理。 if outfit.IsPublic == OutfitPublic { return &dto.OutfitPublicResponse{}, nil } // 首次公开:先条件更新(私密 -> 公开+审核通过),成功才消耗额度,防并发重复扣减。 ok, perr := s.outfitDao.OutfitPublish(ctx, request.OutfitUUID) if perr != nil { return nil, errcode.OutfitUpdateFailed } if !ok { // 已被并发公开,幂等返回。 return &dto.OutfitPublicResponse{}, nil } if qerr := consumeDailyQuota(ctx, userID, QuotaTypeOutfitShare, 1, "穿搭社区分享消耗"); qerr != nil { // 扣额度失败,回退公开状态。 _ = s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate) return nil, qerr } // 写审核日志(自动通过,best-effort:失败仅告警不阻断公开)。 if err := s.auditLogDao.OutfitAuditLogCreate(ctx, &mysqlmodel.OutfitAuditLog{ TargetID: outfit.ID, AuditResult: AuditResultPass, AuditTime: nowUnix(), }); err != nil { slog.WarnContext(ctx, "write outfit audit log failed", "outfit_id", outfit.ID, "error", err) } // 记录当日社区分享计数(统计)。 return &dto.OutfitPublicResponse{}, nil } // ---- 内部辅助 ---- // getOwnedOutfit 查询穿搭并校验归属。 func (s *OutfitService) getOwnedOutfit(ctx context.Context, outfitUUID string, userID int64) (*mysqlmodel.Outfit, *errcode.Error) { outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID) if err != nil { if isNotFound(err) { return nil, errcode.OutfitNotFound } return nil, errcode.DatabaseError } if outfit.UserID != userID { return nil, errcode.OutfitNoPermission } return outfit, nil } // isOutfitPublicVisible 判断穿搭是否对他人公开可见(公开 + 审核通过 + 正常)。 func isOutfitPublicVisible(o *mysqlmodel.Outfit) bool { return o.IsPublic == OutfitPublic && o.AuditStatus == OutfitAuditPass && o.Status == OutfitStatusNormal } // resolveOwnedClothes 校验请求中的衣物均属于该用户,返回有序去重的 clothesID 与 uuid 列表。 func (s *OutfitService) resolveOwnedClothes(ctx context.Context, userID int64, items []dto.OutfitItemRequest) ([]int64, []dto.OutfitItemResponse, *errcode.Error) { uuids := make([]string, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, it := range items { if _, ok := seen[it.ClothesUUID]; ok { continue } seen[it.ClothesUUID] = struct{}{} uuids = append(uuids, it.ClothesUUID) } clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, uuids) if err != nil { return nil, nil, errcode.DatabaseError } byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList)) for _, c := range clothesList { byUUID[c.ClothesUUID] = c } ids := make([]int64, 0, len(uuids)) respItems := make([]dto.OutfitItemResponse, 0, len(uuids)) for _, u := range uuids { c, ok := byUUID[u] if !ok { return nil, nil, errcode.ClothesNotFound } if c.UserID != userID { return nil, nil, errcode.ClothesNoPermission } ids = append(ids, c.ID) respItems = append(respItems, buildOutfitItemResponse(&c)) } return ids, respItems, nil } // createItems 批量创建穿搭明细。 func (s *OutfitService) createItems(ctx context.Context, outfitID int64, clothesIDs []int64) error { items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs)) for _, cid := range clothesIDs { items = append(items, mysqlmodel.OutfitItem{OutfitID: outfitID, ClothesID: cid}) } return s.outfitItemDao.OutfitItemCreateBatch(ctx, items) } // itemResponses 获取某穿搭的衣物明细(含图片 URL)。 func (s *OutfitService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) { return loadOutfitItemResponses(ctx, s.outfitItemDao, s.clothesDao, outfitID) } // loadOutfitItemResponses 获取某穿搭的衣物明细(含图片 URL),供穿搭详情与穿搭分享复用。 func loadOutfitItemResponses(ctx context.Context, outfitItemDao *mysqldao.OutfitItemDao, clothesDao *mysqldao.ClothesDao, outfitID int64) ([]dto.OutfitItemResponse, error) { items, err := outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID) if err != nil { return nil, err } ids := make([]int64, 0, len(items)) for _, it := range items { ids = append(ids, it.ClothesID) } clothesList, err := clothesDao.ClothesGetByIDs(ctx, ids) if err != nil { return nil, err } clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList)) for i := range clothesList { clothesByID[clothesList[i].ID] = clothesList[i] } resps := make([]dto.OutfitItemResponse, 0, len(items)) for _, it := range items { if c, ok := clothesByID[it.ClothesID]; ok { resps = append(resps, buildOutfitItemResponse(&c)) } } return resps, nil } // buildOutfitListResponses 批量组装穿搭列表(一次性加载全部明细与衣物,避免 N+1)。 func (s *OutfitService) buildOutfitListResponses(ctx context.Context, list []mysqlmodel.Outfit) ([]dto.OutfitCreateResponse, error) { if len(list) == 0 { return []dto.OutfitCreateResponse{}, nil } outfitIDs := make([]int64, 0, len(list)) for _, o := range list { outfitIDs = append(outfitIDs, o.ID) } allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs) if err != nil { return nil, err } clothesIDSet := make(map[int64]struct{}, len(allItems)) itemsByOutfit := make(map[int64][]int64, len(list)) for _, it := range allItems { itemsByOutfit[it.OutfitID] = append(itemsByOutfit[it.OutfitID], it.ClothesID) clothesIDSet[it.ClothesID] = struct{}{} } clothesIDs := make([]int64, 0, len(clothesIDSet)) for id := range clothesIDSet { clothesIDs = append(clothesIDs, id) } clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs) if err != nil { return nil, err } clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList)) for _, c := range clothesList { clothesByID[c.ID] = c } resps := make([]dto.OutfitCreateResponse, 0, len(list)) for i := range list { o := &list[i] items := make([]dto.OutfitItemResponse, 0, len(itemsByOutfit[o.ID])) for _, cid := range itemsByOutfit[o.ID] { if c, ok := clothesByID[cid]; ok { items = append(items, buildOutfitItemResponse(&c)) } } resps = append(resps, buildOutfitCreateResponse(o, items)) } return resps, nil } // buildCommunityItems 批量组装社区穿搭列表项(明细/衣物/作者/互动计数均批量查询,避免 N+1)。 func (s *OutfitService) buildCommunityItems(ctx context.Context, list []mysqlmodel.Outfit) ([]dto.OutfitCommunityItem, error) { items := make([]dto.OutfitCommunityItem, 0, len(list)) if len(list) == 0 { return items, nil } outfitIDs := make([]int64, 0, len(list)) ownerIDs := make([]int64, 0, len(list)) for _, o := range list { outfitIDs = append(outfitIDs, o.ID) ownerIDs = append(ownerIDs, o.UserID) } // 1. 穿搭明细 + 衣物(批量)。 allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs) if err != nil { return nil, err } itemsByOutfit := make(map[int64][]int64, len(list)) clothesIDSet := make(map[int64]struct{}, len(allItems)) for _, it := range allItems { itemsByOutfit[it.OutfitID] = append(itemsByOutfit[it.OutfitID], it.ClothesID) clothesIDSet[it.ClothesID] = struct{}{} } clothesIDs := make([]int64, 0, len(clothesIDSet)) for id := range clothesIDSet { clothesIDs = append(clothesIDs, id) } clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesIDs)) if len(clothesIDs) > 0 { clothesList, cerr := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs) if cerr != nil { return nil, cerr } for i := range clothesList { clothesByID[clothesList[i].ID] = clothesList[i] } } // 2. 作者信息(批量;头像 COS key 统一转可访问 URL)。 ownerByID := make(map[int64]mysqlmodel.User, len(ownerIDs)) if owners, uerr := s.userDao.UserGetByIDs(ctx, ownerIDs); uerr == nil { for i := range owners { ownerByID[owners[i].ID] = owners[i] } } // 3. 点赞 / 收藏计数(批量)。 likeCounts, lerr := s.likeDao.LikeCountByTargets(ctx, LikeTypeOutfit, outfitIDs) if lerr != nil { return nil, lerr } collectCounts, cerr := s.collectDao.CollectCountByTargets(ctx, CollectTypeOutfit, outfitIDs) if cerr != nil { return nil, cerr } for i := range list { o := &list[i] oItems := make([]dto.OutfitItemResponse, 0, len(itemsByOutfit[o.ID])) for _, cid := range itemsByOutfit[o.ID] { if c, ok := clothesByID[cid]; ok { oItems = append(oItems, buildOutfitItemResponse(&c)) } } item := dto.OutfitCommunityItem{ OutfitUUID: o.OutfitUUID, OutfitName: o.OutfitName, OutfitDesc: o.OutfitDesc, Scene: o.Scene, Source: o.Source, CreatedAt: o.CreatedAt, Items: oItems, LikeCount: likeCounts[o.ID], CollectCount: collectCounts[o.ID], } if owner, ok := ownerByID[o.UserID]; ok { item.OwnerName = owner.Username item.OwnerAvatar = upload.AvatarURL(owner.Avatar) } items = append(items, item) } return items, nil } // buildOutfitItemResponse 组装穿搭明细(processed_image 转为可访问 URL)。 func buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse { return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: clothesImageURL(c.ProcessedImage)} } // clothesImageURL 将衣物抠图相对路径转为可访问 URL(空则返回空)。 func clothesImageURL(processed string) string { if processed == "" { return "" } return upload.GetCosUrl(processed) } // buildOutfitCreateResponse 组装穿搭响应。 // 带出 status,便于前端在列表中区分「待用户保存」的 AI 穿搭(status=3)与正式穿搭。 func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse) dto.OutfitCreateResponse { return dto.OutfitCreateResponse{ OutfitUUID: o.OutfitUUID, OutfitName: o.OutfitName, OutfitDesc: o.OutfitDesc, Scene: o.Scene, Source: o.Source, IsPublic: o.IsPublic, AuditStatus: o.AuditStatus, Status: o.Status, Items: items, } }