package service import ( "context" "log/slog" "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/pkg/errcode" ) // CollectService 收藏业务服务。 type CollectService struct { collectDao *mysqldao.CollectDao outfitDao *mysqldao.OutfitDao outfitItemDao *mysqldao.OutfitItemDao clothesDao *mysqldao.ClothesDao } // NewCollectService 创建收藏服务。 func NewCollectService() *CollectService { return &CollectService{ collectDao: mysqldao.NewCollectDao(), outfitDao: mysqldao.NewOutfitDao(), outfitItemDao: mysqldao.NewOutfitItemDao(), clothesDao: mysqldao.NewClothesDao(), } } // Collect 收藏或取消收藏社区穿搭。 func (s *CollectService) Collect(ctx context.Context, userID int64, request *dto.CollectRequest) (*dto.CollectResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.CollectCollect") defer span.End() outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID) if err != nil { if isNotFound(err) { return nil, errcode.TargetNotFound } return nil, errcode.DatabaseError } // 非本人仅可收藏公开可见的穿搭。 if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { return nil, errcode.OutfitNoPermission } targetID := outfit.ID existing, err := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID) notExist := false if err != nil { if isNotFound(err) { notExist = true } else { return nil, errcode.DatabaseError } } switch request.Action { case ActionDo: // 收藏 if cerr := s.collectDao.CollectUpsertOn(ctx, &mysqlmodel.Collect{ CollectType: CollectTypeOutfit, TargetID: targetID, UserID: userID, Status: InteractStatusOn, }); cerr != nil { return nil, errcode.CollectFailed } _ = redisdao.CollectAdd(ctx, CollectTypeOutfit, targetID, userID) case ActionCancel: // 取消收藏 if !notExist && existing.Status == InteractStatusOn { if uerr := s.collectDao.CollectUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil { return nil, errcode.CollectFailed } _ = redisdao.CollectRemove(ctx, CollectTypeOutfit, targetID, userID) } } return &dto.CollectResponse{}, nil } // List 分页查询我收藏的穿搭。 func (s *CollectService) List(ctx context.Context, userID int64, request *dto.CollectListRequest) (*dto.CollectListResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.CollectList") defer span.End() page, pageSize := normalizePage(request.Page, request.PageSize) pageIDs, total, err := s.collectDao.CollectListTargetIDsByUserPage(ctx, CollectTypeOutfit, userID, page, pageSize) if err != nil { return nil, errcode.DatabaseError } items := make([]dto.OutfitGetByUUIDResponse, 0, len(pageIDs)) for _, oid := range pageIDs { outfit, gerr := s.outfitDao.OutfitGetByID(ctx, oid) if gerr != nil { // 穿搭可能已删除,跳过。 continue } // 越权防护(纵深防御):DAO 已按可见性联表过滤,此处再挡一次查询与读取之间 // 发生的「转私密 / 被封禁 / 审核驳回」变更,避免下发他人非公开穿搭内容。 if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { continue } outfitItems, uerr := s.itemResponses(ctx, outfit.ID) if uerr != nil { continue } items = append(items, dto.OutfitGetByUUIDResponse{ OutfitCreateResponse: buildOutfitCreateResponse(outfit, outfitItems), }) } return &dto.CollectListResponse{ PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, List: items, }, nil } // GetByTarget 查询穿搭收藏状态与收藏数(Redis 优先,回落 MySQL)。 func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request *dto.CollectGetByTargetRequest) (*dto.CollectGetByTargetResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.CollectGetByTarget") defer span.End() outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID) if err != nil { if isNotFound(err) { return nil, errcode.TargetNotFound } return nil, errcode.DatabaseError } // 非本人仅可查询公开可见穿搭的收藏状态。 if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { return nil, errcode.OutfitNoPermission } targetID := outfit.ID // 是否已收藏:以 MySQL 为权威。 // Redis 集合带 TTL,过期后会被后续写入重建为「仅含新成员」,导致 SIsMember/SCard 长期低估, // 因此只在 DB 异常时用 Redis 兜底(降级可用性优先于精确性)。 isCollected := false if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil { isCollected = rec.Status == InteractStatusOn } else if !isNotFound(derr) { slog.WarnContext(ctx, "query collect record failed, fallback to redis", "target_id", targetID, "user_id", userID, "error", derr) if v, rerr := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID); rerr == nil { isCollected = v } } // 收藏总数:以 MySQL 为权威(同上,Redis 仅在 DB 异常时兜底)。 count, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID) if derr != nil { slog.WarnContext(ctx, "count collect failed, fallback to redis", "target_id", targetID, "error", derr) if rcnt, rerr := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID); rerr == nil { count = rcnt } } return &dto.CollectGetByTargetResponse{ UserIsCollected: isCollected, CollectCount: count, }, nil } // itemResponses 获取某穿搭的衣物明细(含图片 URL)。 func (s *CollectService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) { its, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID) if err != nil { return nil, err } ids := make([]int64, 0, len(its)) for _, it := range its { ids = append(ids, it.ClothesID) } clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids) 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.OutfitItemResponse, 0, len(its)) for _, it := range its { if c, ok := clothesByID[it.ClothesID]; ok { resps = append(resps, buildOutfitItemResponse(&c)) } } return resps, nil }