diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/constants/MQConstants.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/constants/MQConstants.java index 85628d4..1e07ffc 100644 --- a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/constants/MQConstants.java +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/constants/MQConstants.java @@ -17,4 +17,14 @@ public interface MQConstants { */ String TOPIC_COMMENT_HEAT_UPDATE = "CommentHeatUpdateTopic"; + /** + * Topic: 评论点赞、取消点赞共用一个 Topic + */ + String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic"; + + /** + * Tag 标签:点赞 + */ + String TAG_LIKE = "Like"; + } \ No newline at end of file diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/constants/RedisKeyConstants.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/constants/RedisKeyConstants.java index c9a2de0..4e1835a 100644 --- a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/constants/RedisKeyConstants.java +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/constants/RedisKeyConstants.java @@ -7,6 +7,11 @@ public class RedisKeyConstants { */ private static final String HAVE_FIRST_REPLY_COMMENT_KEY_PREFIX = "comment:havaFirstReplyCommentId:"; + /** + * Key 前缀:布隆过滤器 - 用户点赞的评论 + */ + private static final String BLOOM_COMMENT_LIKES_KEY_PREFIX = "bloom:comment:likes:"; + /** * Key 前缀:二级评论分页 ZSET */ @@ -45,6 +50,16 @@ public class RedisKeyConstants { */ private static final String COMMENT_DETAIL_KEY_PREFIX = "comment:detail:"; + /** + * 构建 布隆过滤器 - 用户点赞的评论 完整 KEY + * + * @param userId 用户 ID + * @return 布隆过滤器 - 用户点赞的评论 完整 KEY + */ + public static String buildBloomCommentLikesKey(Long userId) { + return BLOOM_COMMENT_LIKES_KEY_PREFIX + userId; + } + /** * 构建子评论分页 ZSET 完整 KEY * diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/controller/CommentController.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/controller/CommentController.java index c60dbdb..8b9800d 100644 --- a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/controller/CommentController.java +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/controller/CommentController.java @@ -39,4 +39,10 @@ public class CommentController { return commentService.findChildCommentPageList(findChildCommentPageListReqVO); } + @PostMapping("/like") + @ApiOperationLog(description = "评论点赞") + public Response likeComment(@Validated @RequestBody LikeCommentReqVO likeCommentReqVO) { + return commentService.likeComment(likeCommentReqVO); + } + } \ No newline at end of file diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/CommentLikeLuaResultEnum.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/CommentLikeLuaResultEnum.java new file mode 100644 index 0000000..57bac48 --- /dev/null +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/CommentLikeLuaResultEnum.java @@ -0,0 +1,37 @@ +package com.hanserwei.hannote.comment.biz.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + + +@Getter +@AllArgsConstructor +public enum CommentLikeLuaResultEnum { + // 布隆过滤器不存在 + NOT_EXIST(-1L), + // 评论已点赞 + COMMENT_LIKED(1L), + // 评论点赞成功 + COMMENT_LIKE_SUCCESS(0L), + ; + + private final Long code; + + /** + * 根据类型 code 获取对应的枚举 + * + * @param code 类型 code + * @return 枚举 + */ + public static CommentLikeLuaResultEnum valueOf(Long code) { + for (CommentLikeLuaResultEnum commentLikeLuaResultEnum : CommentLikeLuaResultEnum.values()) { + if (Objects.equals(code, commentLikeLuaResultEnum.getCode())) { + return commentLikeLuaResultEnum; + } + } + return null; + } +} + diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/LikeUnlikeCommentTypeEnum.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/LikeUnlikeCommentTypeEnum.java new file mode 100644 index 0000000..8e7587b --- /dev/null +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/LikeUnlikeCommentTypeEnum.java @@ -0,0 +1,17 @@ +package com.hanserwei.hannote.comment.biz.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum LikeUnlikeCommentTypeEnum { + // 点赞 + LIKE(1), + // 取消点赞 + UNLIKE(0), + ; + + private final Integer code; + +} \ No newline at end of file diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/model/dto/LikeUnlikeCommentMqDTO.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/model/dto/LikeUnlikeCommentMqDTO.java new file mode 100644 index 0000000..bff20df --- /dev/null +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/model/dto/LikeUnlikeCommentMqDTO.java @@ -0,0 +1,26 @@ +package com.hanserwei.hannote.comment.biz.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class LikeUnlikeCommentMqDTO { + + private Long userId; + + private Long commentId; + + /** + * 0: 取消点赞, 1:点赞 + */ + private Integer type; + + private LocalDateTime createTime; +} \ No newline at end of file diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/model/vo/LikeCommentReqVO.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/model/vo/LikeCommentReqVO.java new file mode 100644 index 0000000..56f86a1 --- /dev/null +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/model/vo/LikeCommentReqVO.java @@ -0,0 +1,18 @@ +package com.hanserwei.hannote.comment.biz.model.vo; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class LikeCommentReqVO { + + @NotNull(message = "评论 ID 不能为空") + private Long commentId; + +} \ No newline at end of file diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/CommentService.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/CommentService.java index b6baf24..a81d93a 100644 --- a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/CommentService.java +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/CommentService.java @@ -30,4 +30,12 @@ public interface CommentService extends IService { * @return 响应 */ PageResponse findChildCommentPageList(FindChildCommentPageListReqVO findChildCommentPageListReqVO); + + /** + * 评论点赞 + * + * @param likeCommentReqVO 评论点赞请求 + * @return 响应 + */ + Response likeComment(LikeCommentReqVO likeCommentReqVO); } diff --git a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/impl/CommentServiceImpl.java b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/impl/CommentServiceImpl.java index 39cf8db..c97ce8d 100644 --- a/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/impl/CommentServiceImpl.java +++ b/han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/impl/CommentServiceImpl.java @@ -22,7 +22,10 @@ import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO; import com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper; import com.hanserwei.hannote.comment.biz.domain.mapper.NoteCountDOMapper; import com.hanserwei.hannote.comment.biz.enums.CommentLevelEnum; +import com.hanserwei.hannote.comment.biz.enums.CommentLikeLuaResultEnum; +import com.hanserwei.hannote.comment.biz.enums.LikeUnlikeCommentTypeEnum; import com.hanserwei.hannote.comment.biz.enums.ResponseCodeEnum; +import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO; import com.hanserwei.hannote.comment.biz.model.dto.PublishCommentMqDTO; import com.hanserwei.hannote.comment.biz.model.vo.*; import com.hanserwei.hannote.comment.biz.retry.SendMqRetryHelper; @@ -37,10 +40,18 @@ import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.util.Strings; +import org.apache.rocketmq.client.producer.SendCallback; +import org.apache.rocketmq.client.producer.SendResult; +import org.apache.rocketmq.spring.core.RocketMQTemplate; import org.jspecify.annotations.NonNull; +import org.springframework.core.io.ClassPathResource; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.*; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -65,6 +76,8 @@ public class CommentServiceImpl extends ServiceImpl @Resource private UserRpcService userRpcService; @Resource + private RocketMQTemplate rocketMQTemplate; + @Resource private RedisTemplate redisTemplate; @Resource(name = "taskExecutor") private ThreadPoolTaskExecutor threadPoolTaskExecutor; @@ -420,6 +433,109 @@ public class CommentServiceImpl extends ServiceImpl return PageResponse.success(childCommentRspVOS, pageNo, count, pageSize); } + @Override + public Response likeComment(LikeCommentReqVO likeCommentReqVO) { + // 被点赞的评论ID + Long commentId = likeCommentReqVO.getCommentId(); + // 1. 校验被点赞的评论是否存在 + checkCommentIsExist(commentId); + + // 2. 判断目标评论,是否已经被点赞 + // 当前登录用户ID + Long userId = LoginUserContextHolder.getUserId(); + // 布隆过滤器 Key + String bloomUserCommentLikeListKey = RedisKeyConstants.buildBloomCommentLikesKey(userId); + + DefaultRedisScript script = new DefaultRedisScript<>(); + // Lua 脚本路径 + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_comment_like_check.lua"))); + // 返回值类型 + script.setResultType(Long.class); + + // 执行 Lua 脚本,拿到返回结果 + Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserCommentLikeListKey), commentId); + + CommentLikeLuaResultEnum commentLikeLuaResultEnum = CommentLikeLuaResultEnum.valueOf(result); + + if (Objects.isNull(commentLikeLuaResultEnum)) { + throw new ApiException(ResponseCodeEnum.PARAM_NOT_VALID); + } + + switch (commentLikeLuaResultEnum) { + // Redis 中布隆过滤器不存在 + case NOT_EXIST -> { + // TODO: + } + // 目标评论已经被点赞 (可能存在误判,需要进一步确认) + case COMMENT_LIKED -> { + // TODO: + } + } + + // 3. 发送 MQ, 异步将评论点赞记录落库 + // 构建消息体 DTO + LikeUnlikeCommentMqDTO likeUnlikeCommentMqDTO = LikeUnlikeCommentMqDTO.builder() + .userId(userId) + .commentId(commentId) + .type(LikeUnlikeCommentTypeEnum.LIKE.getCode()) // 点赞评论 + .createTime(LocalDateTime.now()) + .build(); + + // 构建消息对象,并将 DTO 转成 Json 字符串设置到消息体中 + Message message = MessageBuilder.withPayload(JsonUtils.toJsonString(likeUnlikeCommentMqDTO)) + .build(); + + // 通过冒号连接, 可让 MQ 发送给主题 Topic 时,携带上标签 Tag + String destination = MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE + ":" + MQConstants.TAG_LIKE; + + // MQ 分区键 + String hashKey = String.valueOf(userId); + + // 异步发送 MQ 消息,提升接口响应速度 + rocketMQTemplate.asyncSendOrderly(destination, message, hashKey, new SendCallback() { + @Override + public void onSuccess(SendResult sendResult) { + log.info("==> 【评论点赞】MQ 发送成功,SendResult: {}", sendResult); + } + + @Override + public void onException(Throwable throwable) { + log.error("==> 【评论点赞】MQ 发送异常: ", throwable); + } + }); + + return Response.success(); + } + + /** + * 校验被点赞的评论是否存在 + * + * @param commentId 评论ID + */ + private void checkCommentIsExist(Long commentId) { + // 先从本地缓存校验 + String localCacheJson = LOCAL_CACHE.getIfPresent(commentId); + + // 若本地缓存中,该评论不存在 + if (StringUtils.isBlank(localCacheJson)) { + // 再从 Redis 中校验 + String commentDetailRedisKey = RedisKeyConstants.buildCommentDetailKey(commentId); + + boolean hasKey = redisTemplate.hasKey(commentDetailRedisKey); + + // 若 Redis 中也不存在 + if (!hasKey) { + // 从数据库中校验 + CommentDO commentDO = commentDOMapper.selectById(commentId); + + // 若数据库中,该评论也不存在,抛出业务异常 + if (Objects.isNull(commentDO)) { + throw new ApiException(ResponseCodeEnum.COMMENT_NOT_FOUND); + } + } + } + } + /** * 设置子评论计数数据 * diff --git a/han-note-comment/han-note-comment-biz/src/main/resources/lua/bloom_comment_like_check.lua b/han-note-comment/han-note-comment-biz/src/main/resources/lua/bloom_comment_like_check.lua new file mode 100644 index 0000000..8f031ac --- /dev/null +++ b/han-note-comment/han-note-comment-biz/src/main/resources/lua/bloom_comment_like_check.lua @@ -0,0 +1,20 @@ +-- LUA 脚本:评论点赞布隆过滤器 + +local key = KEYS[1] -- 操作的 Redis Key +local commentId = ARGV[1] -- 笔记ID + +-- 使用 EXISTS 命令检查布隆过滤器是否存在 +local exists = redis.call('EXISTS', key) +if exists == 0 then + return -1 +end + +-- 校验该评论是否被点赞过(1 表示已经点赞,0 表示未点赞) +local isLiked = redis.call('BF.EXISTS', key, commentId) +if isLiked == 1 then + return 1 +end + +-- 未被点赞,添加点赞数据 +redis.call('BF.ADD', key, commentId) +return 0 diff --git a/http-client/gateApi.http b/http-client/gateApi.http index 606d0ec..70811d6 100644 --- a/http-client/gateApi.http +++ b/http-client/gateApi.http @@ -366,3 +366,11 @@ Authorization: Bearer {{token}} "parentCommentId": 4002, "pageNo": 1 } + +### 点赞评论 +POST http://localhost:8093/comment/like +Content-Type: application/json + +{ + "commentId": 4002 +}