149 lines
5.5 KiB
Go
149 lines
5.5 KiB
Go
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
|
|
}
|