Files
zt-closet-backend/internal/service/like.go
T
zhengchiliu c2519c4b6d feat(clothes): 支持通过社区公开穿搭只读查看衣物
新增 outfit_uuid 查询参数,非属主可通过社区公开穿搭查看衣物详情。
同时调整点赞通知逻辑,仅在首次点赞时发送通知。
2026-09-16 02:28:59 +08:00

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
}