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" ) // LikeService 点赞业务服务。 type LikeService struct { likeDao *mysqldao.LikeDao outfitDao *mysqldao.OutfitDao } // NewLikeService 创建点赞服务。 func NewLikeService() *LikeService { return &LikeService{ likeDao: mysqldao.NewLikeDao(), outfitDao: mysqldao.NewOutfitDao(), } } // LikeCreate 点赞或取消点赞社区穿搭。 func (s *LikeService) LikeCreate(ctx context.Context, userID int64, request *dto.LikeCreateRequest) (*dto.LikeResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.LikeLikeCreate") defer span.End() outfit, cerr := s.getPublicOutfit(ctx, userID, request.TargetUUID) if cerr != nil { return nil, cerr } targetID := outfit.ID existing, err := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID) notExist := false if err != nil { if isNotFound(err) { notExist = true } else { slog.ErrorContext(ctx, "like: query record failed", "target_id", targetID, "user_id", userID, "error", err) return nil, errcode.DatabaseError } } switch request.Action { case ActionDo: // 点赞 if cerr := s.likeDao.LikeUpsertOn(ctx, &mysqlmodel.Like{ LikeType: LikeTypeOutfit, TargetID: targetID, UserID: userID, Status: InteractStatusOn, }); cerr != nil { slog.ErrorContext(ctx, "like: upsert record failed", "target_id", targetID, "user_id", userID, "error", cerr) return nil, errcode.LikeFailed } if rerr := redisdao.LikeAdd(ctx, LikeTypeOutfit, targetID, userID); rerr != nil { slog.WarnContext(ctx, "like: redis add failed, counter may undercount", "target_id", targetID, "user_id", userID, "error", rerr) } // 仅在「首次点赞」时发通知(likes 表此前无记录)。 // 取消点赞只把 status 置为已取消、不删行,因此 notExist 可稳定表达 // 「此前从未赞过该穿搭」;取消后再点赞不再重复通知,避免频繁点赞/取消把拥有者刷屏。 if notExist && outfit.UserID != userID { sendNotification(ctx, outfit.UserID, userID, NotificationTypeLike, "收到新的点赞", "你的穿搭「"+outfit.OutfitName+"」收到了一个赞") } case ActionCancel: // 取消点赞 if !notExist && existing.Status == InteractStatusOn { if uerr := s.likeDao.LikeUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil { slog.ErrorContext(ctx, "like: update status failed", "target_id", targetID, "user_id", userID, "error", uerr) return nil, errcode.LikeFailed } if rerr := redisdao.LikeRemove(ctx, LikeTypeOutfit, targetID, userID); rerr != nil { slog.WarnContext(ctx, "like: redis remove failed, counter may overcount", "target_id", targetID, "user_id", userID, "error", rerr) } } } return &dto.LikeResponse{}, nil } // GetByTargetID 查询穿搭点赞状态与点赞数(Redis 优先)。 func (s *LikeService) GetByTargetID(ctx context.Context, userID int64, request *dto.LikeGetByTargetIDRequest) (*dto.LikeGetByTargetIDResponse, *errcode.Error) { ctx, span := global.Tracer.Start(ctx, "svc.LikeGetByTargetID") defer span.End() outfit, cerr := s.getPublicOutfit(ctx, userID, request.TargetUUID) if cerr != nil { return nil, cerr } targetID := outfit.ID // 是否已点赞:以 MySQL 为权威。 // Redis 集合带 TTL,过期后会被后续写入重建为「仅含新成员」,导致 SIsMember/SCard 长期低估, // 因此只在 DB 异常时用 Redis 兜底(降级可用性优先于精确性)。 isLiked := false if rec, derr := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID); derr == nil { isLiked = rec.Status == InteractStatusOn } else if !isNotFound(derr) { slog.WarnContext(ctx, "query like record failed, fallback to redis", "target_id", targetID, "user_id", userID, "error", derr) if v, rerr := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID); rerr == nil { isLiked = v } else { slog.DebugContext(ctx, "like: redis fallback read failed", "target_id", targetID, "user_id", userID, "error", rerr) } } // 点赞总数:以 MySQL 为权威(同上,Redis 仅在 DB 异常时兜底)。 count, derr := s.likeDao.LikeCountByTarget(ctx, LikeTypeOutfit, targetID) if derr != nil { slog.WarnContext(ctx, "count like failed, fallback to redis", "target_id", targetID, "error", derr) if rcnt, rerr := redisdao.LikeCount(ctx, LikeTypeOutfit, targetID); rerr == nil { count = rcnt } } return &dto.LikeGetByTargetIDResponse{ UserIsLiked: isLiked, LikeCount: count, }, nil } // getPublicOutfit 按 UUID 查询社区穿搭(须存在),非本人仅可操作公开可见的穿搭。 func (s *LikeService) getPublicOutfit(ctx context.Context, userID int64, outfitUUID string) (*mysqlmodel.Outfit, *errcode.Error) { outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID) if err != nil { if isNotFound(err) { return nil, errcode.TargetNotFound } slog.ErrorContext(ctx, "like: query outfit failed", "outfit_uuid", outfitUUID, "user_id", userID, "error", err) return nil, errcode.DatabaseError } if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { return nil, errcode.OutfitNoPermission } return outfit, nil }