Compare commits

...

14 Commits

Author SHA1 Message Date
94729e5170 refactor(note):优化笔记点赞功能,使用 Roaring Bitmap 替代布隆过滤器
- 修改消费者组名称,统一命名规范
- 更新 HTTP 客户端测试用例中的授权令牌和笔记 ID
- 引入 NoteLikeDOMapper 并替换原有的 service 查询方式
- 将布隆过滤器相关逻辑全部替换为 Roaring Bitmap 实现
- 新增多个 Lua 脚本支持 Roaring Bitmap 的操作与初始化
- 添加 Roaring Bitmap 相关的 Redis Key 构建方法
- 删除旧有的布隆过滤器校验逻辑及冗余代码
- 更新 Redis Key 常量类,增加 Roaring Bitmap 相关定义
- 日志字典文件中新增 rbitmap 关键词
- 优化点赞和取消点赞流程,提升性能与准确性
2025-11-09 22:09:23 +08:00
6e0f226b42 feat(comment): 实现评论删除功能及相关缓存更新
- 新增删除一级评论及其子评论的逻辑
- 支持批量删除评论及递归查找回复评论
- 添加评论计数更新机制,包括 Redis 和数据库同步
- 实现评论热度值异步更新消息队列发送- 新增本地缓存删除消费者,支持广播模式清理缓存
- 扩展 CommentLevelEnum 枚举,增加 valueOf 方法用于类型转换- 在 NoteCountDOMapper 中新增更新评论总数的方法- 完善注释和日志记录,提升代码可读性和维护性
2025-11-09 15:30:04 +08:00
e0cf96edbf Revert "feat(comment): 新增评论删除及缓存清理功能"
This reverts commit 6985431236.
2025-11-09 15:19:48 +08:00
d9a960e265 Merge remote-tracking branch 'all/master'
# Conflicts:
#	han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/ResponseCodeEnum.java
#	han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/impl/CommentServiceImpl.java
#	http-client/gateApi.http
2025-11-09 15:18:17 +08:00
6985431236 feat(comment): 新增评论删除及缓存清理功能
- 新增删除一级评论及其子评论的接口与实现- 新增批量删除评论的功能支持
- 新增根据回复评论ID查询评论的方法
- 为CommentLevelEnum添加通过code获取枚举值的方法
- 实现评论本地缓存删除服务接口
- 新增删除评论后的MQ消费者处理逻辑
- 新增删除评论本地缓存的MQ广播消费逻辑
- 扩展NoteCountDOMapper以支持评论总数更新操作
2025-11-09 15:16:20 +08:00
85e0238857 feat(comment): 新增删除评论功能
- 新增删除评论接口,支持物理删除评论及关联内容
- 添加权限校验,仅允许评论创建者删除评论- 使用编程式事务保证删除操作的原子性- 删除评论后清理 Redis 缓存(ZSet 和 String 类型)
- 发送 MQ 消息异步更新计数、删除关联数据及本地缓存
- 新增 DeleteCommentReqVO 请求参数类校验评论 ID
- 补充 KeyValueRpcService 删除评论内容方法
- 新增相关 MQ Topic 常量及响应码枚举
- 更新 HTTP 接口测试用例
2025-11-09 14:12:24 +08:00
93ca81a15b feat(kv): 新增删除评论内容功能
- 在 CommentContentController 中新增删除评论内容的接口
- 定义 DeleteCommentContentReqDTO 用于接收删除请求参数
- 在 CommentContentRepository 中新增删除评论正文的方法
- 在 CommentContentService 及其实现类中新增删除评论内容的业务逻辑
- 在 KeyValueFeignApi 中新增删除评论内容的 Feign 接口
- 在 gateApi.http 中添加删除评论内容的测试用例
2025-11-09 14:01:14 +08:00
f74397ed1e feat(comment): 计数服务:评论点赞数更新,取消点赞接口
- 新增取消点赞接口 /comment/unlike
- 添加布隆过滤器校验评论是否已点赞
- 实现取消点赞时从布隆过滤器中移除记录
- 发送取消点赞消息到 RocketMQ 进行异步处理
- 新增取消点赞相关枚举和异常码
- 更新计数服务消费点赞/取消点赞消息逻辑
- 支持评论点赞数的增减与持久化更新
- 添加 HTTP 客户端测试用例
2025-11-09 13:53:07 +08:00
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
8be6719be8 feat(comment): 实现子评论分页查询与缓存优化
- 新增根据父评论 ID 和限制数量查询子评论的数据库方法
- 实现子评论分页缓存机制,使用 ZSET + String 结构提升查询性能
- 添加子评论详情批量同步到 Redis 的功能
-优化子评论计数数据的缓存读取与数据库同步逻辑
- 新增父评论不存在时的业务异常处理
- 完善子评论缓存失效后的数据库查询与数据回填机制
- 提取公共方法用于批量操作 Redis 数据,提升代码复用性
2025-11-08 21:58:29 +08:00
e3f9b6a5b5 feat(comment): 一级评论:子评论总数更新与查询
- 新增批量查询评论计数的数据库接口及SQL实现
- 优化本地缓存中评论ID失效判断逻辑,修正变量命名
- 增加从Redis中获取评论计数数据的功能,并支持缺失时回源数据库
- 实现评论计数数据异步同步至Redis的逻辑,包括子评论总数和点赞数
- 在消费端增加更新Redis中评论子评论总数的逻辑
- 添加评论计数相关的Redis Key和Field常量定义
- 更新HTTP测试用例中的评论内容和回复ID,验证计数同步功能
2025-11-08 21:01:15 +08:00
6f22c2b50d feat(comment): 实现二级评论分页查询功能
- 新增子评论分页查询接口 /comment/child/list- 添加查询一级评论下子评论总数的 Mapper 方法
- 实现二级评论分页数据查询的 Mapper 方法
- 补充对应的 XML 查询语句,支持按 parent_id 查询子评论
- 创建 FindChildCommentItemRspVO 和 FindChildCommentPageListReqVO VO 类
- 在 CommentServiceImpl 中实现子评论分页查询业务逻辑
- 支持批量查询子评论内容及用户信息并组装返回数据
- 添加 HTTP 客户端测试用例用于验证接口功能
2025-11-08 20:29:24 +08:00
58 changed files with 2659 additions and 99 deletions

View File

@@ -9,6 +9,7 @@
<w>mget</w>
<w>nacos</w>
<w>operationlog</w>
<w>rbitmap</w>
<w>rustfs</w>
<w>zadd</w>
<w>zrevrangebyscore</w>

View File

@@ -17,4 +17,29 @@ public interface MQConstants {
*/
String TOPIC_COMMENT_HEAT_UPDATE = "CommentHeatUpdateTopic";
/**
* Topic: 评论点赞、取消点赞共用一个 Topic
*/
String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic";
/**
* Topic: 删除本地缓存 —— 评论详情
*/
String TOPIC_DELETE_COMMENT_LOCAL_CACHE = "DeleteCommentDetailLocalCacheTopic";
/**
* Topic: 删除评论
*/
String TOPIC_DELETE_COMMENT = "DeleteCommentTopic";
/**
* Tag 标签:点赞
*/
String TAG_LIKE = "Like";
/**
* Tag 标签:取消点赞
*/
String TAG_UNLIKE = "UnLike";
}

View File

@@ -7,6 +7,29 @@ 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
*/
private static final String CHILD_COMMENT_LIST_KEY_PREFIX = "comment:childList:";
/**
* Hash Field: 子评论总数
*/
public static final String FIELD_CHILD_COMMENT_TOTAL = "childCommentTotal";
/**
* Hash Field: 点赞总数
*/
public static final String FIELD_LIKE_TOTAL = "likeTotal";
/**
* 评论维度计数 Key 前缀
*/
private static final String COUNT_COMMENT_KEY_PREFIX = "count:comment:";
/**
* Hash Field 键:评论总数
*/
@@ -27,6 +50,35 @@ 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
*
* @param commentId 一级评论 ID
* @return 子评论分页 ZSET 完整 KEY
*/
public static String buildChildCommentListKey(Long commentId) {
return CHILD_COMMENT_LIST_KEY_PREFIX + commentId;
}
/**
* 构建评论维度计数 Key
*
* @param commentId 评论 ID
* @return 评论维度计数 Key
*/
public static String buildCountCommentKey(Long commentId) {
return COUNT_COMMENT_KEY_PREFIX + commentId;
}
/**
* 构建完整 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,184 @@
package com.hanserwei.hannote.comment.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
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.constants.RedisKeyConstants;
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 jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@SuppressWarnings("UnstableApiUsage")
@Component
@Slf4j
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_DELETE_COMMENT, // Group
topic = MQConstants.TOPIC_DELETE_COMMENT // 消费的主题 Topic
)
public class DeleteCommentConsumer implements RocketMQListener<String> {
// 每秒创建 1000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(1000);
@Resource
private CommentDOMapper commentDOMapper;
@Resource
private NoteCountDOMapper noteCountDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Override
public void onMessage(String body) {
// 令牌桶流控
rateLimiter.acquire();
log.info("## 【删除评论 - 后续业务处理】消费者消费成功, body: {}", body);
CommentDO commentDO = JsonUtils.parseObject(body, CommentDO.class);
// 评论级别
Integer level = null;
if (commentDO != null) {
level = commentDO.getLevel();
}
CommentLevelEnum commentLevelEnum = CommentLevelEnum.valueOf(level);
if (commentLevelEnum != null) {
switch (commentLevelEnum) {
case ONE -> // 一级评论
handleOneLevelComment(commentDO);
case TWO -> // 二级评论
handleTwoLevelComment(commentDO);
}
}
}
/**
* 一级评论处理
*
* @param commentDO 评论
*/
private void handleOneLevelComment(CommentDO commentDO) {
Long commentId = commentDO.getId();
Long noteId = commentDO.getNoteId();
// 1. 关联评论删除(一级评论下所有子评论,都需要删除)
int count = commentDOMapper.deleteByParentId(commentId);
// 2. 计数更新(笔记下总评论数)
// 更新 Redis 缓存
String redisKey = RedisKeyConstants.buildNoteCommentTotalKey(noteId);
boolean hasKey = redisTemplate.hasKey(redisKey);
if (hasKey) {
// 笔记评论总数 -1
redisTemplate.opsForHash().increment(redisKey, RedisKeyConstants.FIELD_COMMENT_TOTAL, -(count + 1));
}
// 更新 t_note_count 计数表
noteCountDOMapper.updateCommentTotalByNoteId(noteId, -(count + 1));
}
/**
* 二级评论处理
*
* @param commentDO 评论
*/
private void handleTwoLevelComment(CommentDO commentDO) {
Long commentId = commentDO.getId();
// 1. 批量删除关联评论(递归查询回复评论,并批量删除)
List<Long> replyCommentIds = Lists.newArrayList();
recurrentGetReplyCommentId(replyCommentIds, commentId);
// 被删除的行数
int count = 0;
if (CollUtil.isNotEmpty(replyCommentIds)) {
count = commentDOMapper.deleteByIds(replyCommentIds);
}
// 2. 更新一级评论的计数
Long parentCommentId = commentDO.getParentId();
String redisKey = RedisKeyConstants.buildCountCommentKey(parentCommentId);
boolean hasKey = redisTemplate.hasKey(redisKey);
if (hasKey) {
redisTemplate.opsForHash().increment(redisKey, RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, -(count + 1));
}
// 3. 若是最早的发布的二级评论被删除,需要更新一级评论的 first_reply_comment_id
// 查询一级评论
CommentDO oneLevelCommentDO = commentDOMapper.selectById(parentCommentId);
Long firstReplyCommentId = oneLevelCommentDO.getFirstReplyCommentId();
// 若删除的是最早回复的二级评论
if (Objects.equals(firstReplyCommentId, commentId)) {
// 查询数据库,重新获取一级评论最早回复的评论
CommentDO earliestCommentDO = commentDOMapper.selectEarliestByParentId(parentCommentId);
// 最早回复的那条评论 ID。若查询结果为 null, 则最早回复的评论 ID 为 null
Long earliestCommentId = Objects.nonNull(earliestCommentDO) ? earliestCommentDO.getId() : null;
// 更新其一级评论的 first_reply_comment_id
commentDOMapper.updateFirstReplyCommentIdByPrimaryKey(earliestCommentId, parentCommentId);
}
// 4. 重新计算一级评论的热度值
Set<Long> commentIds = Sets.newHashSetWithExpectedSize(1);
commentIds.add(parentCommentId);
// 异步发送计数 MQ, 更新评论热度值
org.springframework.messaging.Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(commentIds))
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COMMENT_HEAT_UPDATE, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【评论热度值更新】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【评论热度值更新】MQ 发送异常: ", throwable);
}
});
}
/**
* 递归获取全部回复的评论 ID
*
* @param commentIds 评论 ID 列表
* @param commentId 评论 ID
*/
private void recurrentGetReplyCommentId(List<Long> commentIds, Long commentId) {
CommentDO replyCommentDO = commentDOMapper.selectByReplyCommentId(commentId);
if (Objects.isNull(replyCommentDO)) return;
commentIds.add(replyCommentDO.getId());
Long replyCommentId = replyCommentDO.getId();
// 递归调用
recurrentGetReplyCommentId(commentIds, replyCommentId);
}
}

View File

@@ -0,0 +1,29 @@
package com.hanserwei.hannote.comment.biz.consumer;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.service.CommentService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
@Component
@Slf4j
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_DELETE_COMMENT_LOCAL_CACHE, // Group
topic = MQConstants.TOPIC_DELETE_COMMENT_LOCAL_CACHE, // 消费的主题 Topic
messageModel = MessageModel.BROADCASTING) // 广播模式
public class DeleteCommentLocalCacheConsumer implements RocketMQListener<String> {
@Resource
private CommentService commentService;
@Override
public void onMessage(String body) {
Long commentId = Long.valueOf(body);
log.info("## 消费者消费成功, commentId: {}", commentId);
commentService.deleteCommentLocalCache(commentId);
}
}

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

@@ -3,9 +3,7 @@ package com.hanserwei.hannote.comment.biz.controller;
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.comment.biz.model.vo.FindCommentItemRspVO;
import com.hanserwei.hannote.comment.biz.model.vo.FindCommentPageListReqVO;
import com.hanserwei.hannote.comment.biz.model.vo.PublishCommentReqVO;
import com.hanserwei.hannote.comment.biz.model.vo.*;
import com.hanserwei.hannote.comment.biz.service.CommentService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -35,4 +33,28 @@ public class CommentController {
return commentService.findCommentPageList(findCommentPageListReqVO);
}
@PostMapping("/child/list")
@ApiOperationLog(description = "二级评论分页查询")
public PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(@Validated @RequestBody FindChildCommentPageListReqVO findChildCommentPageListReqVO) {
return commentService.findChildCommentPageList(findChildCommentPageListReqVO);
}
@PostMapping("/like")
@ApiOperationLog(description = "评论点赞")
public Response<?> likeComment(@Validated @RequestBody LikeCommentReqVO likeCommentReqVO) {
return commentService.likeComment(likeCommentReqVO);
}
@PostMapping("/unlike")
@ApiOperationLog(description = "评论取消点赞")
public Response<?> unlikeComment(@Validated @RequestBody UnLikeCommentReqVO unLikeCommentReqVO) {
return commentService.unlikeComment(unLikeCommentReqVO);
}
@PostMapping("/delete")
@ApiOperationLog(description = "删除评论")
public Response<?> deleteComment(@Validated @RequestBody DeleteCommentReqVO deleteCommentReqVO) {
return commentService.deleteComment(deleteCommentReqVO);
}
}

View File

@@ -82,4 +82,68 @@ public interface CommentDOMapper extends BaseMapper<CommentDO> {
* @return 热门评论
*/
List<CommentDO> selectHeatComments(Long noteId);
/**
* 查询一级评论下子评论总数
*
* @param commentId 一级评论 ID
* @return 一级评论下子评论总数
*/
Long selectChildCommentTotalById(Long commentId);
/**
* 查询二级评论分页数据
*
* @param parentId 一级评论 ID
* @param offset 偏移量
* @param pageSize 页大小
* @return 二级评论分页数据
*/
List<CommentDO> selectChildPageList(@Param("parentId") Long parentId,
@Param("offset") long offset,
@Param("pageSize") long pageSize);
/**
* 批量查询计数数据
*
* @param commentIds 评论 ID 列表
* @return 计数数据
*/
List<CommentDO> selectCommentCountByIds(@Param("commentIds") List<Long> commentIds);
/**
* 查询子评论
*
* @param parentId 一级评论 ID
* @param limit 子评论数量限制
* @return 子评论
*/
List<CommentDO> selectChildCommentsByParentIdAndLimit(@Param("parentId") Long parentId,
@Param("limit") int limit);
/**
* 删除一级评论下,所有二级评论
*
* @param commentId 一级评论 ID
* @return 删除数量
*/
int deleteByParentId(Long commentId);
/**
* 批量删除评论
*
* @param commentIds 评论 ID 列表
* @return 删除数量
*/
int deleteByIds(@Param("commentIds") List<Long> commentIds);
/**
* 根据 reply_comment_id 查询
*
* @param commentId 回复的评论 ID
* @return 评论
*/
CommentDO selectByReplyCommentId(Long commentId);
}

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

@@ -3,6 +3,7 @@ package com.hanserwei.hannote.comment.biz.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hanserwei.hannote.comment.biz.domain.dataobject.NoteCountDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
@@ -14,4 +15,14 @@ public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
* @return 笔记评论总数
*/
Long selectCommentTotalByNoteId(Long noteId);
/**
* 更新评论总数
*
* @param noteId 笔记 ID
* @param count 评论总数
* @return 更新数量
*/
int updateCommentTotalByNoteId(@Param("noteId") Long noteId,
@Param("count") int count);
}

View File

@@ -3,6 +3,8 @@ package com.hanserwei.hannote.comment.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum CommentLevelEnum {
@@ -14,4 +16,18 @@ public enum CommentLevelEnum {
private final Integer code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static CommentLevelEnum valueOf(Integer code) {
for (CommentLevelEnum commentLevelEnum : CommentLevelEnum.values()) {
if (Objects.equals(code, commentLevelEnum.getCode())) {
return commentLevelEnum;
}
}
return null;
}
}

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,35 @@
package com.hanserwei.hannote.comment.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum CommentUnlikeLuaResultEnum {
// 布隆过滤器不存在
NOT_EXIST(-1L),
// 评论已点赞
COMMENT_LIKED(1L),
// 评论未点赞
COMMENT_NOT_LIKED(0L),
;
private final Long code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static CommentUnlikeLuaResultEnum valueOf(Long code) {
for (CommentUnlikeLuaResultEnum commentUnlikeLuaResultEnum : CommentUnlikeLuaResultEnum.values()) {
if (Objects.equals(code, commentUnlikeLuaResultEnum.getCode())) {
return commentUnlikeLuaResultEnum;
}
}
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

@@ -14,6 +14,10 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 业务异常状态码 -----------
COMMENT_NOT_FOUND("COMMENT-20001", "此评论不存在"),
PARENT_COMMENT_NOT_FOUND("COMMENT-20000", "此父评论不存在"),
COMMENT_ALREADY_LIKED("COMMENT-20002", "您已经点赞过该评论"),
COMMENT_NOT_LIKED("COMMENT-20003", "您未点赞该评论,无法取消点赞"),
COMMENT_CANT_OPERATE("COMMENT-20004", "您无法操作该评论"),
;
// 异常码

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 DeleteCommentReqVO {
@NotNull(message = "评论 ID 不能为空")
private Long commentId;
}

View File

@@ -0,0 +1,63 @@
package com.hanserwei.hannote.comment.biz.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindChildCommentItemRspVO {
/**
* 评论 ID
*/
private Long commentId;
/**
* 发布者用户 ID
*/
private Long userId;
/**
* 头像
*/
private String avatar;
/**
* 昵称
*/
private String nickname;
/**
* 评论内容
*/
private String content;
/**
* 评论内容
*/
private String imageUrl;
/**
* 发布时间
*/
private String createTime;
/**
* 被点赞数
*/
private Long likeTotal;
/**
* 回复的用户昵称
*/
private String replyUserName;
/**
* 回复的用户 ID
*/
private Long replyUserId;
}

View File

@@ -0,0 +1,20 @@
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 FindChildCommentPageListReqVO {
@NotNull(message = "父评论 ID 不能为空")
private Long parentCommentId;
@NotNull(message = "页码不能为空")
private Integer pageNo = 1;
}

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

@@ -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 UnLikeCommentReqVO {
@NotNull(message = "评论 ID 不能为空")
private Long commentId;
}

View File

@@ -6,14 +6,12 @@ import com.hanserwei.framework.common.constant.DateConstants;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
import com.hanserwei.hannote.kv.api.KeyValueFeignApi;
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.CommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.FindCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.*;
import com.hanserwei.hannote.kv.dto.resp.FindCommentContentRspDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@@ -81,4 +79,29 @@ public class KeyValueRpcService {
return response.getData();
}
/**
* 删除评论内容
*
* @param noteId 笔记ID
* @param createTime 创建时间
* @param contentId 评论内容ID
* @return 是否成功
*/
public boolean deleteCommentContent(Long noteId, LocalDateTime createTime, String contentId) {
DeleteCommentContentReqDTO deleteCommentContentReqDTO = DeleteCommentContentReqDTO.builder()
.noteId(noteId)
.yearMonth(DateConstants.DATE_FORMAT_Y_M.format(createTime))
.contentId(contentId)
.build();
// 调用 KV 存储服务
Response<?> response = keyValueFeignApi.deleteCommentContent(deleteCommentContentReqDTO);
if (!response.isSuccess()) {
throw new RuntimeException("删除评论内容失败");
}
return true;
}
}

View File

@@ -4,9 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
import com.hanserwei.hannote.comment.biz.model.vo.FindCommentItemRspVO;
import com.hanserwei.hannote.comment.biz.model.vo.FindCommentPageListReqVO;
import com.hanserwei.hannote.comment.biz.model.vo.PublishCommentReqVO;
import com.hanserwei.hannote.comment.biz.model.vo.*;
public interface CommentService extends IService<CommentDO> {
/**
@@ -24,4 +22,43 @@ public interface CommentService extends IService<CommentDO> {
* @return 响应
*/
PageResponse<FindCommentItemRspVO> findCommentPageList(FindCommentPageListReqVO findCommentPageListReqVO);
/**
* 二级评论分页查询
*
* @param findChildCommentPageListReqVO 二级评论分页查询参数
* @return 响应
*/
PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(FindChildCommentPageListReqVO findChildCommentPageListReqVO);
/**
* 评论点赞
*
* @param likeCommentReqVO 评论点赞请求
* @return 响应
*/
Response<?> likeComment(LikeCommentReqVO likeCommentReqVO);
/**
* 取消评论点赞
*
* @param unLikeCommentReqVO 取消评论点赞请求
* @return 响应
*/
Response<?> unlikeComment(UnLikeCommentReqVO unLikeCommentReqVO);
/**
* 删除评论
*
* @param deleteCommentReqVO 删除评论请求
* @return 响应
*/
Response<?> deleteComment(DeleteCommentReqVO deleteCommentReqVO);
/**
* 删除本地评论缓存
*
* @param commentId 评论ID
*/
void deleteCommentLocalCache(Long commentId);
}

View File

@@ -8,6 +8,7 @@ import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.hanserwei.framework.biz.context.holder.LoginUserContextHolder;
import com.hanserwei.framework.common.constant.DateConstants;
import com.hanserwei.framework.common.exception.ApiException;
@@ -18,13 +19,14 @@ 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.ResponseCodeEnum;
import com.hanserwei.hannote.comment.biz.enums.*;
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.FindCommentItemRspVO;
import com.hanserwei.hannote.comment.biz.model.vo.FindCommentPageListReqVO;
import com.hanserwei.hannote.comment.biz.model.vo.PublishCommentReqVO;
import com.hanserwei.hannote.comment.biz.model.vo.*;
import com.hanserwei.hannote.comment.biz.retry.SendMqRetryHelper;
import com.hanserwei.hannote.comment.biz.rpc.DistributedIdGeneratorRpcService;
import com.hanserwei.hannote.comment.biz.rpc.KeyValueRpcService;
@@ -37,12 +39,20 @@ 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.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
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 org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.*;
@@ -66,9 +76,15 @@ 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;
@Resource
private TransactionTemplate transactionTemplate;
/**
* 评论详情本地缓存
@@ -213,7 +229,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
// 先查询本地缓存
// 新建一个集合用于存储本地缓存中不存在的评论ID
List<Long> localeCacheExpiredCommentIds = Lists.newArrayList();
List<Long> localCacheExpiredCommentIds = Lists.newArrayList();
// 构建本地缓存的key集合
List<Long> localCacheKeys = commentIdList.stream()
@@ -226,7 +242,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
Map<Long, String> missingData = Maps.newHashMap();
missingKeys.forEach(key -> {
// 记录缓存中不存在的ID
localeCacheExpiredCommentIds.add(key);
localCacheExpiredCommentIds.add(key);
// 不存在的评论详情对其Value设置为空字符串
missingData.put(key, Strings.EMPTY);
});
@@ -234,7 +250,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
});
// 如果localCacheExpiredCommentIds的大小不等于commentIdList的大小说明本地缓存中有数据
if (CollUtil.size(localeCacheExpiredCommentIds) != commentIdList.size()) {
if (CollUtil.size(localCacheExpiredCommentIds) != commentIdList.size()) {
// 将本地缓存中的评论详情Json转为实体类添加到VO返参集合中
for (String value : commentIdAndDetailJsonMap.values()) {
if (StringUtils.isBlank(value)) continue;
@@ -244,12 +260,16 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
}
// 如果localCacheExpiredCommentIds大小为0说明评论详情全在本地缓存中直接响应返参
if (CollUtil.size(localeCacheExpiredCommentIds) == 0) {
if (CollUtil.size(localCacheExpiredCommentIds) == 0) {
// 计数数据需要从 Redis 中查
if (CollUtil.isNotEmpty(commentRspVOS)) {
setCommentCountData(commentRspVOS, localCacheExpiredCommentIds);
}
return PageResponse.success(commentRspVOS, pageNo, count, pageSize);
}
// 构建 MGET 批量查询评论详情的 Key 集合
List<String> commentIdKeys = localeCacheExpiredCommentIds.stream()
List<String> commentIdKeys = localCacheExpiredCommentIds.stream()
.map(RedisKeyConstants::buildCommentDetailKey)
.toList();
@@ -300,6 +320,797 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
return PageResponse.success(commentRspVOS, pageNo, count, pageSize);
}
@Override
public PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(FindChildCommentPageListReqVO findChildCommentPageListReqVO) {
// 父评论 ID
Long parentCommentId = findChildCommentPageListReqVO.getParentCommentId();
// 当前页码
Integer pageNo = findChildCommentPageListReqVO.getPageNo();
// 每页展示的二级评论数 (小红书 APP 中是一次查询 6 条)
long pageSize = 6;
// 先从缓存中查询
String countCommentKey = RedisKeyConstants.buildCountCommentKey(parentCommentId);
// 子评论总数
Number redisCount = (Number) redisTemplate.opsForHash()
.get(countCommentKey, RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL);
long count = Objects.isNull(redisCount) ? 0L : redisCount.longValue();
// 若缓存不存在,走数据库查询
if (Objects.isNull(redisCount)) {
// 查询一级评论下子评论的总数 (直接查询 t_comment 表的 child_comment_total 字段,提升查询性能, 避免 count(*))
Long dbCount = commentDOMapper.selectChildCommentTotalById(parentCommentId);
// 若数据库中也不存在,则抛出业务异常
if (Objects.isNull(dbCount)) {
throw new ApiException(ResponseCodeEnum.PARENT_COMMENT_NOT_FOUND);
}
count = dbCount;
// 异步将子评论总数同步到 Redis 中
threadPoolTaskExecutor.execute(() -> syncCommentCount2Redis(countCommentKey, dbCount));
}
// 若子评论总数为 0直接返参
if (count == 0) {
return PageResponse.success(null, pageNo, 0);
}
// 分页返回参数VO
List<FindChildCommentItemRspVO> childCommentRspVOS = Lists.newArrayList();
// 计算分页查询的偏移量offset需要加1因为最早回复的二级评论已经被展示了
long offset = PageResponse.getOffset(pageNo, pageSize) + 1;
// 子评论分页缓存使用Zset+String实现
// 构建子评论ZSETKey
String childCommentZsetKey = RedisKeyConstants.buildChildCommentListKey(parentCommentId);
// 先判断ZSET是否存在
boolean hasKey = redisTemplate.hasKey(childCommentZsetKey);
// 若不存在
if (!hasKey) {
// 异步将子评论同步到 Redis 中(最多同步 6*10 条)
threadPoolTaskExecutor.execute(() -> syncChildComments2Redis(parentCommentId, childCommentZsetKey));
}
// 若子评论 ZSET 缓存存在, 并且查询的是前 10 页的子评论
if (hasKey && offset < 6 * 10) {
// 使用ZRevRange获取某个一级评论下的子评论按回复时间升序排序
Set<Object> childCommentIds = redisTemplate
.opsForZSet().rangeByScore(childCommentZsetKey, 0, Double.MAX_VALUE, offset, pageSize);
// 若结果不为空
if (CollUtil.isNotEmpty(childCommentIds)) {
// Set转List
List<Object> childCommentIdList = Lists.newArrayList(childCommentIds);
// 构建 MGET 批量查询子评论详情的 Key 集合
List<String> commentIdKeys = childCommentIds.stream()
.map(RedisKeyConstants::buildCommentDetailKey)
.toList();
// MGET 批量获取评论数据
List<Object> commentsJsonList = redisTemplate.opsForValue().multiGet(commentIdKeys);
// 可能存在部分评论不在缓存中,已经过期被删除,这些评论 ID 需要提取出来,等会查数据库
List<Long> expiredChildCommentIds = Lists.newArrayList();
if (commentsJsonList != null) {
for (int i = 0; i < commentsJsonList.size(); i++) {
String commentJson = (String) commentsJsonList.get(i);
Long commentId = Long.valueOf(childCommentIdList.get(i).toString());
if (Objects.nonNull(commentJson)) {
// 缓存中存在的评论 Json直接转换为 VO 添加到返参集合中
FindChildCommentItemRspVO childCommentRspVO = JsonUtils.parseObject(commentJson, FindChildCommentItemRspVO.class);
childCommentRspVOS.add(childCommentRspVO);
} else {
// 评论失效,添加到失效评论列表
expiredChildCommentIds.add(commentId);
}
}
}
// 对于缓存中存在的子评论, 需要再次查询 Hash, 获取其计数数据
if (CollUtil.isNotEmpty(childCommentRspVOS)) {
setChildCommentCountData(childCommentRspVOS, expiredChildCommentIds);
}
// 对于不存在的子评论,需要批量从数据库中查询,并添加到 commentRspVOS 中
if (CollUtil.isNotEmpty(expiredChildCommentIds)) {
List<CommentDO> commentDOS = commentDOMapper.selectByCommentIds(expiredChildCommentIds);
getChildCommentDataAndSync2Redis(commentDOS, childCommentRspVOS);
}
// 按评论 ID 升序排列(等同于按回复时间升序)
childCommentRspVOS = childCommentRspVOS.stream()
.sorted(Comparator.comparing(FindChildCommentItemRspVO::getCommentId))
.collect(Collectors.toList());
return PageResponse.success(childCommentRspVOS, pageNo, count, pageSize);
}
}
// 分页查询子评论
List<CommentDO> childCommentDOS = commentDOMapper.selectChildPageList(parentCommentId, offset, pageSize);
getChildCommentDataAndSync2Redis(childCommentDOS, childCommentRspVOS);
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();
}
@Override
public Response<?> unlikeComment(UnLikeCommentReqVO unLikeCommentReqVO) {
// 被取消点赞的评论 ID
Long commentId = unLikeCommentReqVO.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_unlike_check.lua")));
// 返回值类型
script.setResultType(Long.class);
// 执行 Lua 脚本,拿到返回结果
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserCommentLikeListKey), commentId);
CommentUnlikeLuaResultEnum commentUnlikeLuaResultEnum = CommentUnlikeLuaResultEnum.valueOf(result);
if (Objects.isNull(commentUnlikeLuaResultEnum)) {
throw new ApiException(ResponseCodeEnum.PARAM_NOT_VALID);
}
switch (commentUnlikeLuaResultEnum) {
// 布隆过滤器不存在
case NOT_EXIST -> {
// 异步初始化布隆过滤器
threadPoolTaskExecutor.submit(() -> {
// 保底1小时+随机秒数
long expireSeconds = 60 * 60 + RandomUtil.randomInt(60 * 60);
batchAddCommentLike2BloomAndExpire(userId, expireSeconds, bloomUserCommentLikeListKey);
});
// 从数据库中校验评论是否被点赞
int count = commentLikeDOMapper.selectCountByUserIdAndCommentId(userId, commentId);
// 未点赞,无法取消点赞操作,抛出业务异常
if (count == 0) throw new ApiException(ResponseCodeEnum.COMMENT_NOT_LIKED);
}
// 布隆过滤器校验目标评论未被点赞(判断绝对正确)
case COMMENT_NOT_LIKED -> throw new ApiException(ResponseCodeEnum.COMMENT_NOT_LIKED);
}
// 3. 发送顺序 MQ删除评论点赞记录
// 构建消息体 DTO
LikeUnlikeCommentMqDTO likeUnlikeCommentMqDTO = LikeUnlikeCommentMqDTO.builder()
.userId(userId)
.commentId(commentId)
.type(LikeUnlikeCommentTypeEnum.UNLIKE.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_UNLIKE;
// 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();
}
@Override
@SuppressWarnings("unchecked")
public Response<?> deleteComment(DeleteCommentReqVO deleteCommentReqVO) {
// 被删除的评论 ID
Long commentId = deleteCommentReqVO.getCommentId();
// 1. 校验评论是否存在
CommentDO commentDO = commentDOMapper.selectById(commentId);
if (Objects.isNull(commentDO)) {
throw new ApiException(ResponseCodeEnum.COMMENT_NOT_FOUND);
}
// 2. 校验是否有权限删除
Long currUserId = LoginUserContextHolder.getUserId();
if (!Objects.equals(currUserId, commentDO.getUserId())) {
throw new ApiException(ResponseCodeEnum.COMMENT_CANT_OPERATE);
}
// 3. 物理删除评论、评论内容
// 编程式事务,保证多个操作的原子性
transactionTemplate.execute(status -> {
try {
// 删除评论元数据
commentDOMapper.deleteById(commentId);
// 删除评论内容
keyValueRpcService.deleteCommentContent(commentDO.getNoteId(),
commentDO.getCreateTime(),
commentDO.getContentUuid());
return null;
} catch (Exception ex) {
status.setRollbackOnly(); // 标记事务为回滚
log.error("", ex);
throw ex;
}
});
// 4. 删除 Redis 缓存ZSet 和 String
Integer level = commentDO.getLevel();
Long noteId = commentDO.getNoteId();
Long parentCommentId = commentDO.getParentId();
// 根据评论级别,构建对应的 ZSet Key
String redisZSetKey = Objects.equals(level, 1) ?
RedisKeyConstants.buildCommentListKey(noteId) : RedisKeyConstants.buildChildCommentListKey(parentCommentId);
// 使用 RedisTemplate 执行管道操作
redisTemplate.executePipelined(new SessionCallback<>() {
@Override
public Object execute(@NonNull RedisOperations operations) {
// 删除 ZSet 中对应评论 ID
operations.opsForZSet().remove(redisZSetKey, commentId);
// 删除评论详情
operations.delete(RedisKeyConstants.buildCommentDetailKey(commentId));
return null;
}
});
// 5. 发布广播 MQ, 将本地缓存删除
rocketMQTemplate.asyncSend(MQConstants.TOPIC_DELETE_COMMENT_LOCAL_CACHE, commentId, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【删除评论详情本地缓存】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【删除评论详情本地缓存】MQ 发送异常: ", throwable);
}
});
// 6. 发送 MQ, 异步去更新计数、删除关联评论、热度值等
// 构建消息对象,并将 DO 转成 Json 字符串设置到消息体中
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(commentDO))
.build();
// 异步发送 MQ 消息,提升接口响应速度
rocketMQTemplate.asyncSend(MQConstants.TOPIC_DELETE_COMMENT, message, 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();
}
@Override
public void deleteCommentLocalCache(Long commentId) {
LOCAL_CACHE.invalidate(commentId);
}
/**
* 初始化评论点赞布隆过滤器
*
* @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);
}
}
}
}
/**
* 设置子评论计数数据
*
* @param commentRspVOS 子评论VO
* @param expiredCommentIds 失效的子评论ID
*/
private void setChildCommentCountData(List<FindChildCommentItemRspVO> commentRspVOS, List<Long> expiredCommentIds) {
// 准备从评论 Hash 中查询计数 (被点赞数)
// 缓存中存在的子评论 ID
List<Long> notExpiredCommentIds = Lists.newArrayList();
// 遍历从缓存中解析出的 VO 集合,提取二级评论 ID
commentRspVOS.forEach(commentRspVO -> {
Long childCommentId = commentRspVO.getCommentId();
notExpiredCommentIds.add(childCommentId);
});
// 从 Redis 中查询评论计数 Hash 数据
Map<Long, Map<Object, Object>> commentIdAndCountMap = getCommentCountDataAndSync2RedisHash(notExpiredCommentIds);
// 遍历 VO, 设置对应子评论的点赞数
for (FindChildCommentItemRspVO commentRspVO : commentRspVOS) {
// 评论 ID
Long commentId = commentRspVO.getCommentId();
// 若当前这条评论是从数据库中查询出来的, 则无需设置点赞数,以数据库查询出来的为主
if (CollUtil.isNotEmpty(expiredCommentIds)
&& expiredCommentIds.contains(commentId)) {
continue;
}
// 设置子评论的点赞数
Map<Object, Object> hash = commentIdAndCountMap.get(commentId);
if (CollUtil.isNotEmpty(hash)) {
Long likeTotal = Long.valueOf(hash.get(RedisKeyConstants.FIELD_LIKE_TOTAL).toString());
commentRspVO.setLikeTotal(likeTotal);
}
}
}
/**
* 获取评论计数数据,并同步到 Redis 中
*
* @param notExpiredCommentIds 缓存中存在的评论 ID
* @return 评论计数数据
*/
@SuppressWarnings("unchecked")
private Map<Long, Map<Object, Object>> getCommentCountDataAndSync2RedisHash(List<Long> notExpiredCommentIds) {
// 已失效的 Hash 评论 ID
List<Long> expiredCountCommentIds = Lists.newArrayList();
// 构建需要查询的 Hash Key 集合
List<String> commentCountKeys = notExpiredCommentIds.stream()
.map(RedisKeyConstants::buildCountCommentKey).toList();
// 使用 RedisTemplate 执行管道批量操作
List<Object> results = redisTemplate.executePipelined(new SessionCallback<>() {
@Override
public Object execute(@NonNull RedisOperations operations) {
// 遍历需要查询的评论计数的 Hash 键集合
commentCountKeys.forEach(key ->
// 在管道中执行 Redis 的 hash.entries 操作
// 此操作会获取指定 Hash 键中所有的字段和值
operations.opsForHash().entries(key));
return null;
}
});
// 评论 ID - 计数数据字典
Map<Long, Map<Object, Object>> commentIdAndCountMap = Maps.newHashMap();
// 遍历未过期的评论 ID 集合
for (int i = 0; i < notExpiredCommentIds.size(); i++) {
// 当前评论 ID
Long currCommentId = Long.valueOf(notExpiredCommentIds.get(i).toString());
// 从缓存查询结果中,获取对应 Hash
Map<Object, Object> hash = (Map<Object, Object>) results.get(i);
// 若 Hash 结果为空,说明缓存中不存在,添加到 expiredCountCommentIds 中,保存一下
if (CollUtil.isEmpty(hash)) {
expiredCountCommentIds.add(currCommentId);
continue;
}
// 若存在,则将数据添加到 commentIdAndCountMap 中,方便后续读取
commentIdAndCountMap.put(currCommentId, hash);
}
// 若已过期的计数评论 ID 集合大于 0说明部分计数数据不在 Redis 缓存中
// 需要查询数据库,并将这部分的评论计数 Hash 同步到 Redis 中
if (CollUtil.size(expiredCountCommentIds) > 0) {
// 查询数据库
List<CommentDO> commentDOS = commentDOMapper.selectCommentCountByIds(expiredCountCommentIds);
commentDOS.forEach(commentDO -> {
Integer level = commentDO.getLevel();
Map<Object, Object> map = Maps.newHashMap();
map.put(RedisKeyConstants.FIELD_LIKE_TOTAL, commentDO.getLikeTotal());
// 只有一级评论需要统计子评论总数
if (Objects.equals(level, CommentLevelEnum.ONE.getCode())) {
map.put(RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, commentDO.getChildCommentTotal());
}
// 统一添加到 commentIdAndCountMap 字典中,方便后续查询
commentIdAndCountMap.put(commentDO.getId(), map);
});
// 异步同步到 Redis 中
threadPoolTaskExecutor.execute(() -> redisTemplate.executePipelined(new SessionCallback<>() {
@Override
public Object execute(@NonNull RedisOperations operations) {
commentDOS.forEach(commentDO -> {
// 构建 Hash Key
String key = RedisKeyConstants.buildCountCommentKey(commentDO.getId());
// 评论级别
Integer level = commentDO.getLevel();
// 设置 Field 数据
Map<String, Long> fieldsMap = Objects.equals(level, CommentLevelEnum.ONE.getCode()) ?
Map.of(RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, commentDO.getChildCommentTotal(),
RedisKeyConstants.FIELD_LIKE_TOTAL, commentDO.getLikeTotal()) : Map.of(RedisKeyConstants.FIELD_LIKE_TOTAL, commentDO.getLikeTotal());
// 添加 Hash 数据
operations.opsForHash().putAll(key, fieldsMap);
// 设置随机过期时间 (5小时以内)
long expireTime = 60 * 60 + RandomUtil.randomInt(4 * 60 * 60);
operations.expire(key, expireTime, TimeUnit.SECONDS);
});
return null;
}
}));
}
return commentIdAndCountMap;
}
/**
* 获取子评论数据
*
* @param childCommentDOS 子评论DO
* @param childCommentRspVOS 子评论VO
*/
private void getChildCommentDataAndSync2Redis(List<CommentDO> childCommentDOS, List<FindChildCommentItemRspVO> childCommentRspVOS) {
// 调用KV服务需要的入参
List<FindCommentContentReqDTO> findCommentContentReqDTOS = Lists.newArrayList();
// 调用用户服务需要的入参
Set<Long> userIds = Sets.newHashSet();
// 归属的笔记ID
Long noteId = null;
// 循环提取RPC调用所需要的入参数据
for (CommentDO childCommentDO : childCommentDOS) {
noteId = childCommentDO.getNoteId();
// 构建调用KV服务批量查询评论内容的入参
Boolean isContentEmpty = childCommentDO.getIsContentEmpty();
if (!isContentEmpty) {
FindCommentContentReqDTO findCommentContentReqDTO = FindCommentContentReqDTO.builder()
.contentId(childCommentDO.getContentUuid())
.yearMonth(DateConstants.DATE_FORMAT_Y_M.format(childCommentDO.getCreateTime()))
.build();
findCommentContentReqDTOS.add(findCommentContentReqDTO);
}
// 构建调用用户服务批量查询用户信息入参
userIds.add(childCommentDO.getUserId());
Long parentId = childCommentDO.getParentId();
Long replyCommentId = childCommentDO.getReplyCommentId();
// 若当前评论的 replyCommentId 不等于 parentId则前端需要展示回复的哪个用户如 “回复 Hanserwei
if (!Objects.equals(parentId, replyCommentId)) {
userIds.add(childCommentDO.getReplyUserId());
}
}
// RPC 调用KV服务批量查询评论内容
List<FindCommentContentRspDTO> findCommentContentRspDTOS = keyValueRpcService.batchFindCommentContent(noteId, findCommentContentReqDTOS);
// DTO转Map方便后续拼接数据
Map<String, String> commentUuidAndContentMap = null;
if (CollUtil.isNotEmpty(findCommentContentRspDTOS)) {
commentUuidAndContentMap = findCommentContentRspDTOS.stream()
.collect(Collectors.toMap(FindCommentContentRspDTO::getContentId, FindCommentContentRspDTO::getContent));
}
// RPC 调用用户服务批量查询用户信息
List<FindUserByIdRspDTO> findUserByIdRspDTOS = userRpcService.findByIds(userIds.stream().toList());
// DTO转Map方便后续拼接数据
Map<Long, FindUserByIdRspDTO> userIdAndDTOMap = null;
if (CollUtil.isNotEmpty(findUserByIdRspDTOS)) {
userIdAndDTOMap = findUserByIdRspDTOS.stream()
.collect(Collectors.toMap(FindUserByIdRspDTO::getId, e -> e));
}
// DO 转 VO
for (CommentDO childCommentDO : childCommentDOS) {
// 构建 VO 实体类
Long userId = childCommentDO.getUserId();
FindChildCommentItemRspVO childCommentRspVO = FindChildCommentItemRspVO.builder()
.userId(userId)
.commentId(childCommentDO.getId())
.imageUrl(childCommentDO.getImageUrl())
.createTime(DateUtils.formatRelativeTime(childCommentDO.getCreateTime()))
.likeTotal(childCommentDO.getLikeTotal())
.build();
// 填充用户信息(包括评论发布者、回复的用户)
if (CollUtil.isNotEmpty(userIdAndDTOMap)) {
FindUserByIdRspDTO findUserByIdRspDTO = userIdAndDTOMap.get(userId);
// 评论发布者用户信息(头像、昵称)
if (Objects.nonNull(findUserByIdRspDTO)) {
childCommentRspVO.setAvatar(findUserByIdRspDTO.getAvatar());
childCommentRspVO.setNickname(findUserByIdRspDTO.getNickName());
}
// 评论回复的哪个
Long replyCommentId = childCommentDO.getReplyCommentId();
Long parentId = childCommentDO.getParentId();
if (Objects.nonNull(replyCommentId)
&& !Objects.equals(replyCommentId, parentId)) {
Long replyUserId = childCommentDO.getReplyUserId();
FindUserByIdRspDTO replyUser = userIdAndDTOMap.get(replyUserId);
childCommentRspVO.setReplyUserName(replyUser.getNickName());
childCommentRspVO.setReplyUserId(replyUser.getId());
}
}
// 评论内容
if (CollUtil.isNotEmpty(commentUuidAndContentMap)) {
String contentUuid = childCommentDO.getContentUuid();
if (StringUtils.isNotBlank(contentUuid)) {
childCommentRspVO.setContent(commentUuidAndContentMap.get(contentUuid));
}
}
childCommentRspVOS.add(childCommentRspVO);
}
// 异步将笔记详情,同步到 Redis 中
threadPoolTaskExecutor.execute(() -> {
// 准备批量写入的数据
Map<String, String> data = Maps.newHashMap();
childCommentRspVOS.forEach(commentRspVO -> {
// 评论 ID
Long commentId = commentRspVO.getCommentId();
// 构建 Key
String key = RedisKeyConstants.buildCommentDetailKey(commentId);
data.put(key, JsonUtils.toJsonString(commentRspVO));
});
batchAddCommentDetailJson2Redis(data);
});
}
/**
* 批量添加评论详情 Json 到 Redis 中
*
* @param data 批量写入的数据
*/
private void batchAddCommentDetailJson2Redis(Map<String, String> data) {
// 使用 Redis Pipeline 提升写入性能
redisTemplate.executePipelined((RedisCallback<?>) (connection) -> {
for (Map.Entry<String, String> entry : data.entrySet()) {
// 将 Java 对象序列化为 JSON 字符串
String jsonStr = JsonUtils.toJsonString(entry.getValue());
// 随机生成过期时间 (5小时以内)
int randomExpire = 60 * 60 + RandomUtil.randomInt(4 * 60 * 60);
// 批量写入并设置过期时间
connection.stringCommands().setEx(
Objects.requireNonNull(redisTemplate.getStringSerializer().serialize(entry.getKey())),
randomExpire,
Objects.requireNonNull(redisTemplate.getStringSerializer().serialize(jsonStr))
);
}
return null;
});
}
/**
* 同步子评论到 Redis 中
*
* @param parentCommentId 父评论ID
* @param childCommentZSetKey 子评论ZSet Key
*/
private void syncChildComments2Redis(Long parentCommentId, String childCommentZSetKey) {
List<CommentDO> childCommentDOS = commentDOMapper.selectChildCommentsByParentIdAndLimit(parentCommentId, 6 * 10);
if (CollUtil.isNotEmpty(childCommentDOS)) {
// 使用 Redis Pipeline 提升写入性能
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
// 遍历子评论数据并批量写入 ZSet
for (CommentDO childCommentDO : childCommentDOS) {
Long commentId = childCommentDO.getId();
// create_time 转时间戳
long commentTimestamp = DateUtils.localDateTime2Timestamp(childCommentDO.getCreateTime());
zSetOps.add(childCommentZSetKey, commentId, commentTimestamp);
}
// 设置随机过期时间保底1小时 + 随机时间),单位:秒
int randomExpiryTime = 60 * 60 + RandomUtil.randomInt(4 * 60 * 60); // 5小时以内
redisTemplate.expire(childCommentZSetKey, randomExpiryTime, TimeUnit.SECONDS);
return null; // 无返回值
});
}
}
/**
* 同步评论数量到 Redis 中
*
* @param countCommentKey 评论数量缓存的键值
* @param dbCount 数据库中的评论数量
*/
@SuppressWarnings("unchecked")
private void syncCommentCount2Redis(String countCommentKey, Long dbCount) {
redisTemplate.executePipelined(new SessionCallback<Void>() {
@Override
public <K, V> Void execute(@NonNull RedisOperations<K, V> operations) throws DataAccessException {
// 这里用强制类型转换指定类型
RedisOperations<String, Object> ops = (RedisOperations<String, Object>) operations;
ops.opsForHash()
.put(countCommentKey, RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, dbCount);
long expireTime = 60 * 60 + RandomUtil.randomInt(4 * 60 * 60);
ops.expire(countCommentKey, expireTime, TimeUnit.SECONDS);
return null;
}
});
}
/**
* 同步评论详情到本地缓存中
*
@@ -445,25 +1256,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
data.put(key, JsonUtils.toJsonString(commentRspVO));
});
// 使用 Redis Pipeline 提升写入性能
redisTemplate.executePipelined((RedisCallback<?>) (connection) -> {
for (Map.Entry<String, String> entry : data.entrySet()) {
// 将 Java 对象序列化为 JSON 字符串
String jsonStr = JsonUtils.toJsonString(entry.getValue());
// 随机生成过期时间 (5小时以内)
int randomExpire = RandomUtil.randomInt(5 * 60 * 60);
// 批量写入并设置过期时间
connection.stringCommands().setEx(
Objects.requireNonNull(redisTemplate.getStringSerializer().serialize(entry.getKey())),
randomExpire,
Objects.requireNonNull(redisTemplate.getStringSerializer().serialize(jsonStr))
);
}
return null;
});
batchAddCommentDetailJson2Redis(data);
});
}
@@ -509,4 +1302,64 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
});
}
}
/**
* 设置评论 VO 的计数
*
* @param commentRspVOS 返参 VO 集合
* @param expiredCommentIds 缓存中已失效的评论 ID 集合
*/
private void setCommentCountData(List<FindCommentItemRspVO> commentRspVOS,
List<Long> expiredCommentIds) {
// 准备从评论 Hash 中查询计数 (子评论总数、被点赞数)
// 缓存中存在的评论 ID
List<Long> notExpiredCommentIds = Lists.newArrayList();
// 遍历从缓存中解析出的 VO 集合,提取一级、二级评论 ID
commentRspVOS.forEach(commentRspVO -> {
Long oneLevelCommentId = commentRspVO.getCommentId();
notExpiredCommentIds.add(oneLevelCommentId);
FindCommentItemRspVO firstCommentVO = commentRspVO.getFirstReplyComment();
if (Objects.nonNull(firstCommentVO)) {
notExpiredCommentIds.add(firstCommentVO.getCommentId());
}
});
// 已失效的 Hash 评论 ID
Map<Long, Map<Object, Object>> commentIdAndCountMap = getCommentCountDataAndSync2RedisHash(notExpiredCommentIds);
// 遍历 VO, 设置对应评论的二级评论数、点赞数
for (FindCommentItemRspVO commentRspVO : commentRspVOS) {
// 评论 ID
Long commentId = commentRspVO.getCommentId();
// 若当前这条评论是从数据库中查询出来的, 则无需设置二级评论数、点赞数,以数据库查询出来的为主
if (CollUtil.isNotEmpty(expiredCommentIds)
&& expiredCommentIds.contains(commentId)) {
continue;
}
// 设置一级评论的子评论总数、点赞数
Map<Object, Object> hash = commentIdAndCountMap.get(commentId);
if (CollUtil.isNotEmpty(hash)) {
Object likeTotalObj = hash.get(RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL);
Long childCommentTotal = Objects.isNull(likeTotalObj) ? 0 : Long.parseLong(likeTotalObj.toString());
Long likeTotal = Long.valueOf(hash.get(RedisKeyConstants.FIELD_LIKE_TOTAL).toString());
commentRspVO.setChildCommentTotal(childCommentTotal);
commentRspVO.setLikeTotal(likeTotal);
// 最初回复的二级评论
FindCommentItemRspVO firstCommentVO = commentRspVO.getFirstReplyComment();
if (Objects.nonNull(firstCommentVO)) {
Long firstCommentId = firstCommentVO.getCommentId();
Map<Object, Object> firstCommentHash = commentIdAndCountMap.get(firstCommentId);
if (CollUtil.isNotEmpty(firstCommentHash)) {
Long firstCommentLikeTotal = Long.valueOf(firstCommentHash.get(RedisKeyConstants.FIELD_LIKE_TOTAL).toString());
firstCommentVO.setLikeTotal(firstCommentLikeTotal);
}
}
}
}
}
}

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

@@ -0,0 +1,11 @@
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 表示未点赞)
return redis.call('BF.EXISTS', key, commentId)

View File

@@ -154,4 +154,73 @@
order by heat desc
limit 500
</select>
<select id="selectChildCommentTotalById" resultType="long">
select child_comment_total
from t_comment
where id = #{commentId}
and level = 1
</select>
<select id="selectChildPageList" resultMap="BaseResultMap" parameterType="map">
select id,
user_id,
note_id,
content_uuid,
is_content_empty,
image_url,
like_total,
create_time,
reply_user_id,
parent_id,
reply_comment_id
from t_comment
where parent_id = #{parentId}
and level = 2
order by id
limit #{offset}, #{pageSize}
</select>
<select id="selectCommentCountByIds" resultMap="BaseResultMap" parameterType="list">
select id,
child_comment_total,
like_total,
level
from t_comment
where id in
<foreach collection="commentIds" open="(" separator="," close=")" item="commentId">
#{commentId}
</foreach>
</select>
<select id="selectChildCommentsByParentIdAndLimit" resultMap="BaseResultMap" parameterType="map">
select id, create_time
from t_comment
where parent_id = #{parentId}
and level = 2
order by create_time
limit #{limit}
</select>
<delete id="deleteByParentId" parameterType="long">
delete
from t_comment
where parent_id = #{commentId}
</delete>
<delete id="deleteByIds" parameterType="map">
delete
from t_comment
where id in
<foreach collection="commentIds" item="commentId" open="(" separator="," close=")">
#{commentId}
</foreach>
</delete>
<select id="selectByReplyCommentId" resultMap="BaseResultMap" parameterType="long">
select
<include refid="Base_Column_List"/>
from t_comment
where reply_comment_id = #{commentId}
</select>
</mapper>

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

@@ -20,4 +20,10 @@
from t_note_count
where note_id = #{noteId}
</select>
<update id="updateCommentTotalByNoteId" parameterType="map">
update t_note_count
set comment_total = comment_total + #{count}
where note_id = #{noteId}
</update>
</mapper>

View File

@@ -57,6 +57,16 @@ public interface MQConstants {
*/
String TOPIC_NOTE_OPERATE = "NoteOperateTopic";
/**
* Topic: 评论点赞数更新
*/
String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic";
/**
* Topic: 计数 - 评论点赞数落库
*/
String TOPIC_COUNT_COMMENT_LIKE_2_DB = "CountCommentLike2DBTTopic";
/**
* Tag 标签:笔记发布
*/

View File

@@ -31,11 +31,35 @@ 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: 笔记收藏总数
*/
public static final String FIELD_COLLECT_TOTAL = "collectTotal";
/**
* Hash Field: 子评论总数
*/
public static final String FIELD_CHILD_COMMENT_TOTAL = "childCommentTotal";
/**
* 评论维度计数 Key 前缀
*/
private static final String COUNT_COMMENT_KEY_PREFIX = "count:comment:";
/**
* 构建评论维度计数 Key
*
* @param commentId 评论ID
* @return 评论维度计数 Key
*/
public static String buildCountCommentKey(Long commentId) {
return COUNT_COMMENT_KEY_PREFIX + commentId;
}
/**
* 构建用户维度计数 Key
*

View File

@@ -0,0 +1,55 @@
package com.hanserwei.hannote.count.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.count.biz.model.dto.AggregationCountLikeUnlikeCommentMqDTO;
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.stereotype.Component;
import java.util.List;
@SuppressWarnings("ALL")
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_COMMENT_LIKE_2_DB, // Group 组
topic = MQConstants.TOPIC_COUNT_COMMENT_LIKE_2_DB // 主题 Topic
)
@Slf4j
public class CountCommentLike2DBConsumer implements RocketMQListener<String> {
// 每秒创建 5000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(5000);
@Resource
private CommentDOMapper commentDOMapper;
@Override
public void onMessage(String body) {
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
rateLimiter.acquire();
log.info("## 消费到了 MQ 【计数: 评论点赞数入库】, {}...", body);
List<AggregationCountLikeUnlikeCommentMqDTO> countList = null;
try {
countList = JsonUtils.parseList(body, AggregationCountLikeUnlikeCommentMqDTO.class);
} catch (Exception e) {
log.error("## 解析 JSON 字符串异常", e);
}
if (CollUtil.isNotEmpty(countList)) {
// 更新评论点赞数
countList.forEach(item -> {
Long commentId = item.getCommentId();
Integer count = item.getCount();
commentDOMapper.updateLikeTotalByCommentId(count, commentId);
});
}
}
}

View File

@@ -0,0 +1,138 @@
package com.hanserwei.hannote.count.biz.consumer;
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.enums.LikeUnlikeCommentTypeEnum;
import com.hanserwei.hannote.count.biz.model.dto.AggregationCountLikeUnlikeCommentMqDTO;
import com.hanserwei.hannote.count.biz.model.dto.CountLikeUnlikeCommentMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_count_" + MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE, // Group 组
topic = MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE // 主题 Topic
)
@Slf4j
public class CountCommentLikeConsumer implements RocketMQListener<String> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
.linger(Duration.ofSeconds(1)) // 多久聚合一次
.setConsumerEx(this::consumeMessage) // 设置消费者方法
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> bodys) {
log.info("==> 【评论点赞数】聚合消息, size: {}", bodys.size());
log.info("==> 【评论点赞数】聚合消息, {}", JsonUtils.toJsonString(bodys));
// List<String> 转 List<CountLikeUnlikeCommentMqDTO>
List<CountLikeUnlikeCommentMqDTO> countLikeUnlikeCommentMqDTOS = bodys.stream()
.map(body -> JsonUtils.parseObject(body, CountLikeUnlikeCommentMqDTO.class)).toList();
// 按评论 ID 进行分组
Map<Long, List<CountLikeUnlikeCommentMqDTO>> groupMap = countLikeUnlikeCommentMqDTOS.stream()
.collect(Collectors.groupingBy(CountLikeUnlikeCommentMqDTO::getCommentId));
// 按组汇总数据,统计出最终的计数
// 最终操作的计数对象
List<AggregationCountLikeUnlikeCommentMqDTO> countList = Lists.newArrayList();
for (Map.Entry<Long, List<CountLikeUnlikeCommentMqDTO>> entry : groupMap.entrySet()) {
// 评论 ID
Long commentId = entry.getKey();
List<CountLikeUnlikeCommentMqDTO> list = entry.getValue();
// 最终的计数值,默认为 0
int finalCount = 0;
for (CountLikeUnlikeCommentMqDTO countLikeUnlikeCommentMqDTO : list) {
// 获取操作类型
Integer type = countLikeUnlikeCommentMqDTO.getType();
// 根据操作类型,获取对应枚举
LikeUnlikeCommentTypeEnum likeUnlikeCommentTypeEnum = LikeUnlikeCommentTypeEnum.valueOf(type);
// 若枚举为空,跳到下一次循环
if (Objects.isNull(likeUnlikeCommentTypeEnum)) continue;
switch (likeUnlikeCommentTypeEnum) {
case LIKE -> finalCount += 1; // 如果为点赞操作,点赞数 +1
case UNLIKE -> finalCount -= 1; // 如果为取消点赞操作,点赞数 -1
}
}
// 将分组后统计出的最终计数,存入 countList 中
countList.add(AggregationCountLikeUnlikeCommentMqDTO.builder()
.commentId(commentId)
.count(finalCount)
.build());
}
log.info("## 【评论点赞数】聚合后的计数数据: {}", JsonUtils.toJsonString(countList));
// 更新 Redis
countList.forEach(item -> {
// 评论 ID
Long commentId = item.getCommentId();
// 聚合后的计数
Integer count = item.getCount();
// Redis 中评论计数 Hash Key
String countCommentRedisKey = RedisKeyConstants.buildCountCommentKey(commentId);
// 判断 Redis 中 Hash 是否存在
boolean isCountCommentExisted = redisTemplate.hasKey(countCommentRedisKey);
// 若存在才会更新
// (因为缓存设有过期时间,考虑到过期后,缓存会被删除,这里需要判断一下,存在才会去更新,而初始化工作放在查询计数来做)
if (isCountCommentExisted) {
// 对目标用户 Hash 中的点赞数字段进行计数操作
redisTemplate.opsForHash().increment(countCommentRedisKey, RedisKeyConstants.FIELD_LIKE_TOTAL, count);
}
});
// 发送 MQ, 评论点赞数据落库
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(countList))
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_COMMENT_LIKE_2_DB, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【计数服务评论点赞数写库】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【计数服务评论点赞数写库】MQ 发送异常: ", throwable);
}
});
}
}

View File

@@ -5,6 +5,7 @@ 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.CommentDOMapper;
import com.hanserwei.hannote.count.biz.enums.CommentLevelEnum;
import com.hanserwei.hannote.count.biz.model.dto.CountPublishCommentMqDTO;
@@ -15,6 +16,7 @@ import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
@@ -39,6 +41,9 @@ public class CountNoteChildCommentConsumer implements RocketMQListener<String> {
@Resource
private CommentDOMapper commentDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
@@ -82,6 +87,19 @@ public class CountNoteChildCommentConsumer implements RocketMQListener<String> {
// 评论数
int count = CollUtil.size(entry.getValue());
// 更新 Redis 缓存中的评论计数数据
// 构建 Key
String commentCountHashKey = RedisKeyConstants.buildCountCommentKey(parentId);
// 判断 Hash 是否存在
boolean hasKey = redisTemplate.hasKey(commentCountHashKey);
// 若 Hash 存在,则更新子评论总数
if (hasKey) {
// 累加
redisTemplate.opsForHash()
.increment(commentCountHashKey, RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, count);
}
// 更新一级评论的下级评论总数,进行累加操作
commentDOMapper.updateChildCommentTotal(parentId, count);
}

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

@@ -28,7 +28,7 @@ import java.util.stream.Collectors;
@Component
@Slf4j
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_LIKE_OR_UNLIKE,
consumerGroup = "han_note_count_group_" + MQConstants.TOPIC_LIKE_OR_UNLIKE,
topic = MQConstants.TOPIC_LIKE_OR_UNLIKE
)
public class CountNoteLikeConsumer implements RocketMQListener<String> {

View File

@@ -16,4 +16,14 @@ public interface CommentDOMapper extends BaseMapper<CommentDO> {
* @return 更新结果
*/
int updateChildCommentTotal(@Param("parentId") Long parentId, @Param("count") int count);
/**
* 更新评论点赞数
*
* @param count 计数
* @param commentId 评论 ID
* @return 更新结果
*/
int updateLikeTotalByCommentId(@Param("count") Integer count,
@Param("commentId") Long commentId);
}

View File

@@ -0,0 +1,27 @@
package com.hanserwei.hannote.count.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum LikeUnlikeCommentTypeEnum {
// 点赞
LIKE(1),
// 取消点赞
UNLIKE(0),
;
private final Integer code;
public static LikeUnlikeCommentTypeEnum valueOf(Integer code) {
for (LikeUnlikeCommentTypeEnum likeUnlikeCommentTypeEnum : LikeUnlikeCommentTypeEnum.values()) {
if (Objects.equals(code, likeUnlikeCommentTypeEnum.getCode())) {
return likeUnlikeCommentTypeEnum;
}
}
return null;
}
}

View File

@@ -0,0 +1,24 @@
package com.hanserwei.hannote.count.biz.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class AggregationCountLikeUnlikeCommentMqDTO {
/**
* 评论 ID
*/
private Long commentId;
/**
* 聚合后的计数
*/
private Integer count;
}

View File

@@ -0,0 +1,22 @@
package com.hanserwei.hannote.count.biz.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CountLikeUnlikeCommentMqDTO {
private Long userId;
private Long commentId;
/**
* 0: 取消点赞, 1点赞
*/
private Integer type;
}

View File

@@ -48,4 +48,11 @@
where id = #{parentId}
and level = 1
</update>
<update id="updateLikeTotalByCommentId" parameterType="map">
update t_comment
set like_total = like_total + #{count},
update_time = now()
where id = #{commentId}
</update>
</mapper>

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

@@ -31,4 +31,6 @@ public interface KeyValueFeignApi {
@PostMapping(value = PREFIX + "/comment/content/batchFind")
Response<List<FindCommentContentRspDTO>> batchFindCommentContent(@RequestBody BatchFindCommentContentReqDTO batchFindCommentContentReqDTO);
@PostMapping(value = PREFIX + "/comment/content/delete")
Response<?> deleteCommentContent(@RequestBody DeleteCommentContentReqDTO deleteCommentContentReqDTO);
}

View File

@@ -0,0 +1,25 @@
package com.hanserwei.hannote.kv.dto.req;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class DeleteCommentContentReqDTO {
@NotNull(message = "笔记 ID 不能为空")
private Long noteId;
@NotBlank(message = "发布年月不能为空")
private String yearMonth;
@NotBlank(message = "评论正文 ID 不能为空")
private String contentId;
}

View File

@@ -5,6 +5,7 @@ import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.kv.biz.service.CommentContentService;
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.DeleteCommentContentReqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
@@ -33,4 +34,10 @@ public class CommentContentController {
return commentContentService.batchFindCommentContent(batchFindCommentContentReqDTO);
}
@PostMapping(value = "/comment/content/delete")
@ApiOperationLog(description = "删除评论内容")
public Response<?> deleteCommentContent(@Validated @RequestBody DeleteCommentContentReqDTO deleteCommentContentReqDTO) {
return commentContentService.deleteCommentContent(deleteCommentContentReqDTO);
}
}

View File

@@ -20,4 +20,13 @@ public interface CommentContentRepository extends CassandraRepository<CommentCon
List<CommentContentDO> findByPrimaryKeyNoteIdAndPrimaryKeyYearMonthInAndPrimaryKeyContentIdIn(
Long noteId, List<String> yearMonths, List<UUID> contentIds
);
/**
* 删除评论正文
*
* @param noteId 笔记ID
* @param yearMonth 年月
* @param contentId 评论 ID
*/
void deleteByPrimaryKeyNoteIdAndPrimaryKeyYearMonthAndPrimaryKeyContentId(Long noteId, String yearMonth, UUID contentId);
}

View File

@@ -3,6 +3,7 @@ package com.hanserwei.hannote.kv.biz.service;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.DeleteCommentContentReqDTO;
public interface CommentContentService {
@@ -21,4 +22,13 @@ public interface CommentContentService {
* @return 批量查询结果
*/
Response<?> batchFindCommentContent(BatchFindCommentContentReqDTO batchFindCommentContentReqDTO);
/**
* 删除评论内容
*
* @param deleteCommentContentReqDTO 删除评论内容请求参数
* @return 删除结果
*/
Response<?> deleteCommentContent(DeleteCommentContentReqDTO deleteCommentContentReqDTO);
}

View File

@@ -7,10 +7,7 @@ import com.hanserwei.hannote.kv.biz.domain.dataobject.CommentContentDO;
import com.hanserwei.hannote.kv.biz.domain.dataobject.CommentContentPrimaryKey;
import com.hanserwei.hannote.kv.biz.domain.repository.CommentContentRepository;
import com.hanserwei.hannote.kv.biz.service.CommentContentService;
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.CommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.FindCommentContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.*;
import com.hanserwei.hannote.kv.dto.resp.FindCommentContentRspDTO;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.NotBlank;
@@ -91,4 +88,16 @@ public class CommentContentServiceImpl implements CommentContentService {
return Response.success(findCommentContentRspDTOS);
}
@Override
public Response<?> deleteCommentContent(DeleteCommentContentReqDTO deleteCommentContentReqDTO) {
Long noteId = deleteCommentContentReqDTO.getNoteId();
String yearMonth = deleteCommentContentReqDTO.getYearMonth();
String contentId = deleteCommentContentReqDTO.getContentId();
// 删除评论正文
commentContentRepository.deleteByPrimaryKeyNoteIdAndPrimaryKeyYearMonthAndPrimaryKeyContentId(noteId, yearMonth, UUID.fromString(contentId));
return Response.success();
}
}

View File

@@ -7,6 +7,11 @@ public class RedisKeyConstants {
*/
public static final String NOTE_DETAIL_KEY = "note:detail:";
/**
* Roaring Bitmap用户笔记点赞 前缀
*/
public static final String R_BITMAP_USER_NOTE_LIKE_LIST_KEY = "rbitmap:note:likes:";
/**
* 布隆过滤器:用户笔记点赞
*/
@@ -76,4 +81,14 @@ public class RedisKeyConstants {
public static String buildUserNoteCollectZSetKey(Long userId) {
return USER_NOTE_COLLECT_ZSET_KEY + userId;
}
/**
* 构建完整的 Roaring Bitmap用户笔记点赞 KEY
*
* @param userId 用户ID
* @return Roaring Bitmap用户笔记点赞 KEY
*/
public static String buildRBitmapUserNoteLikeListKey(Long userId) {
return R_BITMAP_USER_NOTE_LIKE_LIST_KEY + userId;
}
}

View File

@@ -21,6 +21,7 @@ import com.hanserwei.hannote.note.biz.domain.dataobject.NoteDO;
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteLikeDO;
import com.hanserwei.hannote.note.biz.domain.dataobject.TopicDO;
import com.hanserwei.hannote.note.biz.domain.mapper.NoteDOMapper;
import com.hanserwei.hannote.note.biz.domain.mapper.NoteLikeDOMapper;
import com.hanserwei.hannote.note.biz.enums.*;
import com.hanserwei.hannote.note.biz.model.dto.CollectUnCollectNoteMqDTO;
import com.hanserwei.hannote.note.biz.model.dto.LikeUnlikeNoteMqDTO;
@@ -76,6 +77,8 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
private RedisTemplate<String, String> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource
private NoteLikeDOMapper noteLikeDOMapper;
/**
* 笔记详情本地缓存
@@ -630,14 +633,17 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
// 2. 判断目标笔记,是否已经点赞过
Long userId = LoginUserContextHolder.getUserId();
// 布隆过滤器Key
String bloomUserNoteLikeListKey = RedisKeyConstants.buildBloomUserNoteLikeListKey(userId);
// Roaring Bitmap Key
String rbitmapUserNoteLikeListKey = RedisKeyConstants.buildRBitmapUserNoteLikeListKey(userId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_note_like_check.lua")));
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/rbitmap_note_like_check.lua")));
// 返回值类型
script.setResultType(Long.class);
// 执行 Lua 脚本,拿到返回结果
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId);
Long result = redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId);
NoteLikeLuaResultEnum noteLikeLuaResultEnum = NoteLikeLuaResultEnum.valueOf(result);
@@ -659,39 +665,26 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
// 目标笔记已经被点赞
if (count > 0) {
// 异步初始化布隆过滤器
threadPoolTaskExecutor.submit(() -> batchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey));
// 异步初始化 Roaring Bitmap
threadPoolTaskExecutor.submit(() ->
batchAddNoteLike2RBitmapAndExpire(userId, expireSeconds, rbitmapUserNoteLikeListKey));
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
}
// 若笔记未被点赞,查询当前用户是否点赞其他用户,有则同步初始化布隆过滤器
batchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey);
// 若目标笔记未被点赞,查询当前用户是否点赞其他笔记,有则同步初始化 Roaring Bitmap
batchAddNoteLike2RBitmapAndExpire(userId, expireSeconds, rbitmapUserNoteLikeListKey);
// 若数据库中也没有点赞记录,说明该用户还未点赞过任何笔记
// 添加当前点赞笔记 ID 到 Roaring Bitmap 中
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_add_note_like_and_expire.lua")));
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/rbitmap_add_note_like_and_expire.lua")));
// 返回值类型
script.setResultType(Long.class);
redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId, expireSeconds);
redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId, expireSeconds);
}
// 目标笔记已经被点赞
case NOTE_LIKED -> {
// 校验 ZSet 列表中是否包含被点赞的笔记ID
Double score = redisTemplate.opsForZSet().score(userNoteLikeZSetKey, noteId);
if (Objects.nonNull(score)) {
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
}
// 若 Score 为空,则表示 ZSet 点赞列表中不存在,查询数据库校验
long count = noteLikeDOService.count(new LambdaQueryWrapper<>(NoteLikeDO.class)
.eq(NoteLikeDO::getNoteId, noteId)
.eq(NoteLikeDO::getStatus, LikeStatusEnum.LIKE.getCode()));
if (count > 0) {
// 数据库里面有点赞记录,而 Redis 中 ZSet 不存在,需要重新异步初始化 ZSet
asynInitUserNoteLikesZSet(userId, userNoteLikeZSetKey);
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
}
}
}
// 3. 更新用户 ZSET 点赞列表
LocalDateTime now = LocalDateTime.now();
@@ -768,6 +761,37 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
return Response.success();
}
/**
* 初始化笔记点赞 Roaring Bitmap
*
* @param userId 用户 ID
* @param expireSeconds 过期时间
* @param rbitmapUserNoteLikeListKey RBitmap 列表 Key
*/
private void batchAddNoteLike2RBitmapAndExpire(Long userId, long expireSeconds, String rbitmapUserNoteLikeListKey) {
try {
// 异步全量同步一下,并设置过期时间
List<NoteLikeDO> noteLikeDOS = noteLikeDOMapper.selectList(new LambdaQueryWrapper<>(NoteLikeDO.class)
.eq(NoteLikeDO::getUserId, userId));
if (CollUtil.isNotEmpty(noteLikeDOS)) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/rbitmap_batch_add_note_like_and_expire.lua")));
// 返回值类型
script.setResultType(Long.class);
// 构建 Lua 参数
List<Object> luaArgs = Lists.newArrayList();
noteLikeDOS.forEach(noteLikeDO -> luaArgs.add(noteLikeDO.getNoteId())); // 将每个点赞的笔记 ID 传入
luaArgs.add(expireSeconds); // 最后一个参数是过期时间(秒)
redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), luaArgs.toArray());
}
} catch (Exception e) {
log.error("## 异步初始化【笔记点赞】Roaring Bitmap 异常: ", e);
}
}
@Override
public Response<?> unlikeNote(UnlikeNoteReqVO unlikeNoteReqVO) {
// 笔记ID
@@ -780,37 +804,38 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
// 当前登录用户ID
Long userId = LoginUserContextHolder.getUserId();
// 布隆过滤器Key
String bloomUserNoteLikeListKey = RedisKeyConstants.buildBloomUserNoteLikeListKey(userId);
// Roaring Bitmap Key
String rbitmapUserNoteLikeListKey = RedisKeyConstants.buildRBitmapUserNoteLikeListKey(userId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_note_unlike_check.lua")));
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/rbitmap_note_unlike_check.lua")));
// 返回值类型
script.setResultType(Long.class);
// 执行 Lua 脚本,拿到返回结果
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId);
Long result = redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId);
NoteUnlikeLuaResultEnum noteUnlikeLuaResultEnum = NoteUnlikeLuaResultEnum.valueOf(result);
log.info("==> 【笔记取消点赞】Lua 脚本返回结果: {}", noteUnlikeLuaResultEnum);
switch (Objects.requireNonNull(noteUnlikeLuaResultEnum)) {
// 布隆过滤器不存在
case NOT_EXIST -> {//笔记不存在
//异步初始化布隆过滤器
// 异步初始化 Roaring Bitmap
threadPoolTaskExecutor.submit(() -> {
// 保底1天+随机秒数
long expireSeconds = 60 * 60 * 24 + RandomUtil.randomInt(60 * 60 * 24);
batchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey);
batchAddNoteLike2RBitmapAndExpire(userId, expireSeconds, rbitmapUserNoteLikeListKey);
});
// 从数据库中校验笔记是否被点赞
long count = noteLikeDOService.count(new LambdaQueryWrapper<>(NoteLikeDO.class)
long count = noteLikeDOMapper.selectCount(new LambdaQueryWrapper<>(NoteLikeDO.class)
.eq(NoteLikeDO::getUserId, userId)
.eq(NoteLikeDO::getNoteId, noteId)
.eq(NoteLikeDO::getStatus, LikeStatusEnum.LIKE.getCode()));
if (count == 0) {
log.info("==> 【笔记取消点赞】用户未点赞该笔记");
throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
}
.eq(NoteLikeDO::getNoteId, noteId));
// 未点赞,无法取消点赞操作,抛出业务异常
log.info("1111111");
if (count == 0) throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
}
// 布隆过滤器校验目标笔记未被点赞(判断绝对正确)
case NOTE_NOT_LIKED -> throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
@@ -820,14 +845,9 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
// 用户点赞列表ZsetKey
String userNoteLikeZSetKey = RedisKeyConstants.buildUserNoteLikeZSetKey(userId);
// TODO: 后续考虑换掉布隆过滤器。
Long removed = redisTemplate.opsForZSet().remove(userNoteLikeZSetKey, noteId);
if (Objects.nonNull(removed) && removed == 0) {
log.info("==> 【笔记取消点赞】用户未点赞该笔记");
throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
}
//4. 发送 MQ, 数据更新落库
// 构建MQ消息体

View File

@@ -0,0 +1,10 @@
-- 操作的 Key
local key = KEYS[1]
local noteId = ARGV[1] -- 笔记ID
local expireSeconds = ARGV[2] -- 过期时间(秒)
redis.call("R64.SETBIT", key, noteId, 1)
-- 设置过期时间
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("R64.SETBIT", key, ARGV[i], 1)
end
-- 最后一个参数为过期时间
local expireTime = ARGV[#ARGV]
-- 设置过期时间
redis.call("EXPIRE", key, expireTime)
return 0

View File

@@ -0,0 +1,20 @@
-- LUA 脚本:点赞 Roaring Bitmap
local key = KEYS[1] -- 操作的 Redis Key
local noteId = ARGV[1] -- 笔记ID
-- 使用 EXISTS 命令检查 Roaring Bitmap 是否存在
local exists = redis.call('EXISTS', key)
if exists == 0 then
return -1
end
-- 校验该篇笔记是否被点赞过(1 表示已经点赞0 表示未点赞)
local isLiked = redis.call('R64.GETBIT', key, noteId)
if isLiked == 1 then
return 1
end
-- 未被点赞,添加点赞数据
redis.call('R64.SETBIT', key, noteId, 1)
return 0

View File

@@ -0,0 +1,17 @@
local key = KEYS[1] -- 操作的 Redis Key
local noteId = ARGV[1] -- 笔记ID
-- 使用 EXISTS 命令检查 Roaring Bitmap 是否存在
local exists = redis.call('EXISTS', key)
if exists == 0 then
return -1
end
-- 校验该篇笔记是否被点赞过(1 表示已经点赞0 表示未点赞)
local isLiked = redis.call('R64.GETBIT', key, noteId)
if isLiked == 0 then
return 0
end
-- 取消点赞,设置 Value 为 0
return redis.call('R64.SETBIT', key, noteId, 0)

View File

@@ -3,7 +3,7 @@ POST http://localhost:8000/auth/verification/code/send
Content-Type: application/json
{
"email": "2628273921@qq.com"
"email": "ssw010723@gmail.com"
}
### 登录/注册
@@ -11,8 +11,8 @@ POST http://localhost:8000/auth/login
Content-Type: application/json
{
"email": "2628273921@qq.com",
"code": "825004",
"email": "ssw010723@gmail.com",
"code": "116253",
"type": 1
}
@@ -202,16 +202,16 @@ Content-Type: application/json
Authorization: Bearer {{thirdToken}}
{
"id": 1981698494959714362
"id": 1985254482941837349
}
### 笔记取消点赞入口
POST http://localhost:8000/note/note/unlike
Content-Type: application/json
Authorization: Bearer {{otherToken}}
Authorization: Bearer {{thirdToken}}
{
"id": 1977249693272375330
"id": 1985254482941837349
}
### 笔记收藏入口
@@ -298,9 +298,9 @@ Authorization: Bearer {{token}}
{
"noteId": 1862481582414102549,
"content": "这是一条测试同步Redis更新热度的评论",
"content": "这是一条测试同步Redis更新计数的评论",
"imageUrl": "https://cdn.pixabay.com/photo/2025/10/05/15/06/autumn-9875155_1280.jpg",
"replyCommentId": 8001
"replyCommentId": 4002
}
### 批量添加评论
@@ -356,3 +356,50 @@ Content-Type: application/json
"noteId": 1862481582414102549,
"pageNo": 1
}
### 分页查询子评论
POST http://localhost:8000/comment/comment/child/list
Content-Type: application/json
Authorization: Bearer {{token}}
{
"parentCommentId": 4002,
"pageNo": 1
}
### 点赞评论
POST http://localhost:8000/comment/comment/like
Content-Type: application/json
Authorization: Bearer {{token}}
{
"commentId": 8001
}
### 取消点赞评论
POST http://localhost:8000/comment/comment/unlike
Content-Type: application/json
Authorization: Bearer {{token}}
{
"commentId": 8001
}
### 删除评论
POST http://localhost:8084/kv/comment/content/delete
Content-Type: application/json
{
"noteId": 1862481582414102549,
"yearMonth": "2025-11",
"contentId": "0fa4376f-a098-4fee-821b-f5b7e627a72c"
}
### 删除评论,同步删除一切相关缓存
POST http://localhost:8000/comment/comment/delete
Content-Type: application/json
Authorization: Bearer {{token}}
{
"commentId": 8001
}