feat(comment): 实现评论点赞功能
- 新增评论点赞接口,支持用户对评论进行点赞操作 - 集成 Redis 布隆过滤器,用于快速校验评论是否已被点赞 - 编写 Lua 脚本实现布隆过滤器的检查与添加逻辑 - 定义点赞相关枚举类,包括点赞状态和操作类型- 新增点赞请求 VO 和 MQ 消息 DTO,规范数据传输结构 - 通过 RocketMQ 异步处理点赞记录落库,提升接口响应速度 - 添加 HTTP 客户端测试用例,便于接口调试和验证 - 补充 Redis Key 构建方法及常量定义,统一缓存键管理
This commit is contained in:
@@ -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";
|
||||
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -30,4 +30,12 @@ public interface CommentService extends IService<CommentDO> {
|
||||
* @return 响应
|
||||
*/
|
||||
PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(FindChildCommentPageListReqVO findChildCommentPageListReqVO);
|
||||
|
||||
/**
|
||||
* 评论点赞
|
||||
*
|
||||
* @param likeCommentReqVO 评论点赞请求
|
||||
* @return 响应
|
||||
*/
|
||||
Response<?> likeComment(LikeCommentReqVO likeCommentReqVO);
|
||||
}
|
||||
|
||||
@@ -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<CommentDOMapper, CommentDO>
|
||||
@Resource
|
||||
private UserRpcService userRpcService;
|
||||
@Resource
|
||||
private RocketMQTemplate rocketMQTemplate;
|
||||
@Resource
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
@Resource(name = "taskExecutor")
|
||||
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
|
||||
@@ -420,6 +433,109 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
|
||||
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<Long> 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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置子评论计数数据
|
||||
*
|
||||
|
||||
@@ -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
|
||||
@@ -366,3 +366,11 @@ Authorization: Bearer {{token}}
|
||||
"parentCommentId": 4002,
|
||||
"pageNo": 1
|
||||
}
|
||||
|
||||
### 点赞评论
|
||||
POST http://localhost:8093/comment/like
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"commentId": 4002
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user