Compare commits

...

3 Commits

Author SHA1 Message Date
f90e36f7d6 feat(comment): 实现评论点赞与取消点赞功能,评论点赞、取消点赞批量写库
- 新增评论点赞布隆过滤器,提升点赞判断性能
- 实现评论点赞与取消点赞的批量操作消费者
- 添加评论点赞状态查询接口及异常处理
- 优化点赞操作合并逻辑,减少数据库访问频率
- 增加评论点赞相关 Lua 脚本支持过期时间设置
- 完善评论点赞 Mapper 层批量插入与删除方法
- 添加评论已点赞业务异常状态码
- 新增测试类用于验证评论点赞 MQ 消费逻辑
- 调整 MQ 消费者 Bean 名称避免冲突
- 更新 HTTP 测试文件中的评论 ID便于调试
2025-11-08 22:55:09 +08:00
a8d5c7f9b7 feat(comment): 实现评论点赞功能
- 新增评论点赞接口,支持用户对评论进行点赞操作
- 集成 Redis 布隆过滤器,用于快速校验评论是否已被点赞
- 编写 Lua 脚本实现布隆过滤器的检查与添加逻辑
- 定义点赞相关枚举类,包括点赞状态和操作类型- 新增点赞请求 VO 和 MQ 消息 DTO,规范数据传输结构
- 通过 RocketMQ 异步处理点赞记录落库,提升接口响应速度
- 添加 HTTP 客户端测试用例,便于接口调试和验证
- 补充 Redis Key 构建方法及常量定义,统一缓存键管理
2025-11-08 22:16:15 +08:00
51cebf6215 feat(count): 新增笔记评论数缓存更新逻辑
- 在 CountNoteCommentConsumer 中引入 RedisTemplate依赖
- 消费评论消息时,更新 Redis 中笔记评论总数
- 新增 RedisKeyConstants.FIELD_COMMENT_TOTAL 常量定义
- 实现基于 Hash 的评论数累加更新机制
- 优化评论数更新流程,支持批量处理与缓存同步
2025-11-08 22:06:26 +08:00
21 changed files with 695 additions and 1 deletions

View File

@@ -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";
}

View File

@@ -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
*

View File

@@ -65,7 +65,7 @@ public class Comment2DBConsumer {
// 每秒创建 1000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(1000);
@Bean
@Bean(name = "Comment2DBConsumer")
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
// Group组
String group = "han_note_group_" + MQConstants.TOPIC_PUBLISH_COMMENT;

View File

@@ -0,0 +1,156 @@
package com.hanserwei.hannote.comment.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentLikeDOMapper;
import com.hanserwei.hannote.comment.biz.enums.LikeUnlikeCommentTypeEnum;
import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
@SuppressWarnings("UnstableApiUsage")
@Component
@Slf4j
public class LikeUnlikeComment2DBConsumer {
// 每秒创建 5000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(5000);
@Value("${rocketmq.name-server}")
private String nameServer;
@Resource
private CommentLikeDOMapper commentLikeDOMapper;
private DefaultMQPushConsumer consumer;
@Bean(name = "LikeUnlikeComment2DBConsumer")
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
// Group 组
String group = "han_note_group_" + MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE;
// 创建一个新的 DefaultMQPushConsumer 实例,并指定消费者的消费组名
consumer = new DefaultMQPushConsumer(group);
// 设置 RocketMQ 的 NameServer 地址
consumer.setNamesrvAddr(nameServer);
// 订阅指定的主题,并设置主题的订阅规则("*" 表示订阅所有标签的消息)
consumer.subscribe(MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE, "*");
// 设置消费者消费消息的起始位置,如果队列中没有消息,则从最新的消息开始消费。
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
// 设置消息消费模式,这里使用集群模式 (CLUSTERING)
consumer.setMessageModel(MessageModel.CLUSTERING);
// 最大重试次数, 以防消息重试过多次仍然没有成功,避免消息卡在消费队列中。
consumer.setMaxReconsumeTimes(3);
// 设置每批次消费的最大消息数量,这里设置为 30表示每次拉取时最多消费 30 条消息。
consumer.setConsumeMessageBatchMaxSize(30);
// 注册消息监听器
consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> {
log.info("==> 【评论点赞、取消点赞】本批次消息大小: {}", msgs.size());
try {
// 令牌桶流控, 以控制数据库能够承受的 QPS
rateLimiter.acquire();
// 将批次 Json 消息体转换 DTO 集合
List<LikeUnlikeCommentMqDTO> likeUnlikeCommentMqDTOS = Lists.newArrayList();
msgs.forEach(msg -> {
String tag = msg.getTags(); // Tag 标签
String msgJson = new String(msg.getBody()); // 消息体 Json 字符串
log.info("==> 【评论点赞、取消点赞】Consumer - Tag: {}, Received message: {}", tag, msgJson);
// Json 转 DTO
likeUnlikeCommentMqDTOS.add(JsonUtils.parseObject(msgJson, LikeUnlikeCommentMqDTO.class));
});
// 按评论 ID 分组
Map<Long, List<LikeUnlikeCommentMqDTO>> commentIdAndListMap = likeUnlikeCommentMqDTOS.stream()
.collect(Collectors.groupingBy(LikeUnlikeCommentMqDTO::getCommentId));
List<LikeUnlikeCommentMqDTO> finalLikeUnlikeCommentMqDTOS = Lists.newArrayList();
commentIdAndListMap.forEach((commentId, ops) -> {
// 优化:若某个用户对某评论,多次操作,如点赞 -> 取消点赞 -> 点赞,需进行操作合并,只提取最后一次操作,进一步降低操作数据库的频率
Map<Long, LikeUnlikeCommentMqDTO> userLastOp = ops.stream()
.collect(Collectors.toMap(
LikeUnlikeCommentMqDTO::getUserId, // 以发布评论的用户 ID 作为 Map 的键
Function.identity(), // 直接使用 DTO 对象本身作为 Map 的值
// 合并策略:当出现重复键(同一用户多次操作)时,保留时间更晚的记录
(oldValue, newValue) ->
oldValue.getCreateTime().isAfter(newValue.getCreateTime()) ? oldValue : newValue
));
finalLikeUnlikeCommentMqDTOS.addAll(userLastOp.values());
});
// 批量操作数据库
executeBatchSQL(finalLikeUnlikeCommentMqDTOS);
// 手动 ACK告诉 RocketMQ 这批次消息消费成功
return ConsumeOrderlyStatus.SUCCESS;
} catch (Exception e) {
log.error("", e);
// 这样 RocketMQ 会暂停当前队列的消费一段时间,再重试
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
});
// 启动消费者
consumer.start();
return consumer;
}
private void executeBatchSQL(List<LikeUnlikeCommentMqDTO> values) {
// 过滤出点赞操作
List<LikeUnlikeCommentMqDTO> likes = values.stream()
.filter(op -> Objects.equals(op.getType(), LikeUnlikeCommentTypeEnum.LIKE.getCode()))
.toList();
// 过滤出取消点赞操作
List<LikeUnlikeCommentMqDTO> unlikes = values.stream()
.filter(op -> Objects.equals(op.getType(), LikeUnlikeCommentTypeEnum.UNLIKE.getCode()))
.toList();
// 取消点赞:批量删除
if (CollUtil.isNotEmpty(unlikes)) {
commentLikeDOMapper.batchDelete(unlikes);
}
// 点赞:批量新增
if (CollUtil.isNotEmpty(likes)) {
commentLikeDOMapper.batchInsert(likes);
}
}
@PreDestroy
public void destroy() {
if (Objects.nonNull(consumer)) {
try {
consumer.shutdown(); // 关闭消费者
} catch (Exception e) {
log.error("", e);
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -2,8 +2,46 @@ package com.hanserwei.hannote.comment.biz.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO;
import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CommentLikeDOMapper extends BaseMapper<CommentLikeDO> {
/**
* 查询某个评论是否被点赞
*
* @param userId 用户 ID
* @param commentId 评论 ID
* @return 1 表示已点赞0 表示未点赞
*/
int selectCountByUserIdAndCommentId(@Param("userId") Long userId,
@Param("commentId") Long commentId);
/**
* 查询对应用户点赞的所有评论
*
* @param userId 用户 ID
* @return 评论点赞列表
*/
List<CommentLikeDO> selectByUserId(@Param("userId") Long userId);
/**
* 批量删除点赞记录
*
* @param unlikes 删除点赞记录
* @return 删除数量
*/
int batchDelete(@Param("unlikes") List<LikeUnlikeCommentMqDTO> unlikes);
/**
* 批量添加点赞记录
*
* @param likes 添加点赞记录
* @return 添加数量
*/
int batchInsert(@Param("likes") List<LikeUnlikeCommentMqDTO> likes);
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -15,6 +15,7 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 业务异常状态码 -----------
COMMENT_NOT_FOUND("COMMENT-20001", "此评论不存在"),
PARENT_COMMENT_NOT_FOUND("COMMENT-20000", "此父评论不存在"),
COMMENT_ALREADY_LIKED("COMMENT-20002", "您已经点赞过该评论"),
;
// 异常码

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -30,4 +30,12 @@ public interface CommentService extends IService<CommentDO> {
* @return 响应
*/
PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(FindChildCommentPageListReqVO findChildCommentPageListReqVO);
/**
* 评论点赞
*
* @param likeCommentReqVO 评论点赞请求
* @return 响应
*/
Response<?> likeComment(LikeCommentReqVO likeCommentReqVO);
}

View File

@@ -19,10 +19,15 @@ import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.constants.RedisKeyConstants;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentLikeDOMapper;
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 +42,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,9 +78,13 @@ 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;
@Resource
private CommentLikeDOMapper commentLikeDOMapper;
/**
* 评论详情本地缓存
@@ -420,6 +437,169 @@ 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 -> {
// 从数据库中校验评论是否被点赞,并异步初始化布隆过滤器,设置过期时间
int count = commentLikeDOMapper.selectCountByUserIdAndCommentId(userId, commentId);
// 保底1小小时+随机秒数
long expireSeconds = 60 * 60 + RandomUtil.randomInt(60 * 60);
// 目标评论已经被点赞
if (count > 0) {
// 异步初始化布隆过滤器
// 异步初始化布隆过滤器
threadPoolTaskExecutor.submit(() ->
batchAddCommentLike2BloomAndExpire(userId, expireSeconds, bloomUserCommentLikeListKey));
throw new ApiException(ResponseCodeEnum.COMMENT_ALREADY_LIKED);
}
// 若目标评论未被点赞,查询当前用户是否有点赞其他评论,有则同步初始化布隆过滤器
batchAddCommentLike2BloomAndExpire(userId, expireSeconds, bloomUserCommentLikeListKey);
// 添加当前点赞评论 ID 到布隆过滤器中
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_add_comment_like_and_expire.lua")));
// 返回值类型
script.setResultType(Long.class);
redisTemplate.execute(script, Collections.singletonList(bloomUserCommentLikeListKey), commentId, expireSeconds);
}
// 目标评论已经被点赞 (可能存在误判,需要进一步确认)
case COMMENT_LIKED -> {
// 查询数据库校验是否点赞
int count = commentLikeDOMapper.selectCountByUserIdAndCommentId(userId, commentId);
if (count > 0) {
throw new ApiException(ResponseCodeEnum.COMMENT_ALREADY_LIKED);
}
}
}
// 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 userId 用户ID
* @param expireSeconds 过期时间
* @param bloomUserCommentLikeListKey 布隆过滤器 Key
*/
private void batchAddCommentLike2BloomAndExpire(Long userId, long expireSeconds, String bloomUserCommentLikeListKey) {
try {
// 查询该用户点赞的所有评论
List<CommentLikeDO> commentLikeDOS = commentLikeDOMapper.selectByUserId(userId);
// 若不为空,批量添加到布隆过滤器中
if (CollUtil.isNotEmpty(commentLikeDOS)) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_batch_add_comment_like_and_expire.lua")));
// 返回值类型
script.setResultType(Long.class);
// 构建 Lua 参数
List<Object> luaArgs = Lists.newArrayList();
commentLikeDOS.forEach(commentLikeDO ->
luaArgs.add(commentLikeDO.getCommentId())); // 将每个点赞的评论 ID 传入
luaArgs.add(expireSeconds); // 最后一个参数是过期时间(秒)
redisTemplate.execute(script, Collections.singletonList(bloomUserCommentLikeListKey), luaArgs.toArray());
}
} catch (Exception e) {
log.error("## 异步初始化【评论点赞】布隆过滤器异常: ", e);
}
}
/**
* 校验被点赞的评论是否存在
*
* @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);
}
}
}
}
/**
* 设置子评论计数数据
*

View File

@@ -0,0 +1,10 @@
-- 操作的 Key
local key = KEYS[1]
local commentId = ARGV[1] -- 评论ID
local expireSeconds = ARGV[2] -- 过期时间(秒)
redis.call("BF.ADD", key, commentId)
-- 设置过期时间
redis.call("EXPIRE", key, expireSeconds)
return 0

View File

@@ -0,0 +1,12 @@
-- 操作的 Key
local key = KEYS[1]
for i = 1, #ARGV - 1 do
redis.call("BF.ADD", key, ARGV[i])
end
---- 最后一个参数为过期时间
local expireTime = ARGV[#ARGV]
-- 设置过期时间
redis.call("EXPIRE", key, expireTime)
return 0

View File

@@ -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

View File

@@ -13,4 +13,36 @@
<!--@mbg.generated-->
id, user_id, comment_id, create_time
</sql>
<select id="selectCountByUserIdAndCommentId" resultType="int" parameterType="map">
select count(1)
from t_comment_like
where user_id = #{userId}
and comment_id = #{commentId}
limit 1
</select>
<select id="selectByUserId" resultMap="BaseResultMap" parameterType="map">
select comment_id
from t_comment_like
where user_id = #{userId}
</select>
<delete id="batchDelete" parameterType="map">
DELETE
FROM t_comment_like
WHERE (comment_id, user_id) IN
<foreach collection="unlikes" item="unlike" open="(" separator="," close=")">
(#{unlike.commentId}, #{unlike.userId})
</foreach>
</delete>
<insert id="batchInsert" parameterType="list">
INSERT INTO t_comment_like (comment_id, user_id, create_time)
VALUES
<foreach collection="likes" item="like" separator=",">
(#{like.commentId}, #{like.userId}, #{like.createTime})
</foreach>
ON DUPLICATE KEY UPDATE id=id
</insert>
</mapper>

View File

@@ -31,6 +31,11 @@ public class RedisKeyConstants {
*/
private static final String COUNT_NOTE_KEY_PREFIX = "count:note:";
/**
* Hash Field: 笔记评论总数
*/
public static final String FIELD_COMMENT_TOTAL = "commentTotal";
/**
* Hash Field: 笔记收藏总数
*/

View File

@@ -5,12 +5,14 @@ import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Lists;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.NoteCountDOMapper;
import com.hanserwei.hannote.count.biz.model.dto.CountPublishCommentMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
@@ -27,6 +29,8 @@ public class CountNoteCommentConsumer implements RocketMQListener<String> {
@Resource
private NoteCountDOMapper noteCountDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
@@ -67,6 +71,19 @@ public class CountNoteCommentConsumer implements RocketMQListener<String> {
// 评论数
int count = CollUtil.size(entry.getValue());
// 更新 Redis 缓存中的笔记评论总数
// 构建 Key
String noteCountHashKey = RedisKeyConstants.buildCountNoteKey(noteId);
// 判断 Hash 是否存在
boolean hasKey = redisTemplate.hasKey(noteCountHashKey);
// 若 Hash 存在
if (hasKey) {
// 累加更新
redisTemplate.opsForHash()
.increment(noteCountHashKey, RedisKeyConstants.FIELD_COMMENT_TOTAL, count);
}
// 若评论数大于零,则执行更新操作:累加评论总数
if (count > 0) {
noteCountDOMapper.insertOrUpdateCommentTotalByNoteId(count, noteId);

View File

@@ -0,0 +1,78 @@
package com.hanserwei.hannote.count.biz;
import com.hanserwei.framework.common.utils.JsonUtils;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import java.time.LocalDateTime;
@SpringBootTest
public class TestCommentLikeUnLikeConsumer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
/**
* 测试:模拟发送评论点赞、取消点赞消息
*/
@Test
void testBatchSendLikeUnlikeCommentMQ() {
Long userId = 2001L;
Long commentId = 4001L;
for (long i = 0; i < 32; i++) {
// 构建消息体 DTO
LikeUnlikeCommentMqDTO likeUnlikeCommentMqDTO = LikeUnlikeCommentMqDTO.builder()
.userId(userId)
.commentId(commentId)
.createTime(LocalDateTime.now())
.build();
// 通过冒号连接, 可让 MQ 发送给主题 Topic 时,携带上标签 Tag
String destination = "CommentLikeUnlikeTopic:";
if (i % 2 == 0) { // 偶数
likeUnlikeCommentMqDTO.setType(0); // 取消点赞
destination = destination + "Unlike";
} else { // 奇数
likeUnlikeCommentMqDTO.setType(1); // 点赞
destination = destination + "Like";
}
// MQ 分区键
String hashKey = String.valueOf(userId);
// 构建消息对象,并将 DTO 转成 Json 字符串设置到消息体中
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(likeUnlikeCommentMqDTO))
.build();
// 同步发送 MQ 消息
rocketMQTemplate.syncSendOrderly(destination, message, hashKey);
}
}
@Getter
@Setter
@Builder
static class LikeUnlikeCommentMqDTO {
private Long userId;
private Long commentId;
/**
* 0: 取消点赞, 1点赞
*/
private Integer type;
private LocalDateTime createTime;
}
}

View File

@@ -366,3 +366,11 @@ Authorization: Bearer {{token}}
"parentCommentId": 4002,
"pageNo": 1
}
### 点赞评论
POST http://localhost:8093/comment/like
Content-Type: application/json
{
"commentId": 6001
}