Compare commits
11 Commits
8be6719be8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 94729e5170 | |||
| 6e0f226b42 | |||
| e0cf96edbf | |||
| d9a960e265 | |||
| 6985431236 | |||
| 85e0238857 | |||
| 93ca81a15b | |||
| f74397ed1e | |||
| f90e36f7d6 | |||
| a8d5c7f9b7 | |||
| 51cebf6215 |
1
.idea/dictionaries/project.xml
generated
1
.idea/dictionaries/project.xml
generated
@@ -9,6 +9,7 @@
|
|||||||
<w>mget</w>
|
<w>mget</w>
|
||||||
<w>nacos</w>
|
<w>nacos</w>
|
||||||
<w>operationlog</w>
|
<w>operationlog</w>
|
||||||
|
<w>rbitmap</w>
|
||||||
<w>rustfs</w>
|
<w>rustfs</w>
|
||||||
<w>zadd</w>
|
<w>zadd</w>
|
||||||
<w>zrevrangebyscore</w>
|
<w>zrevrangebyscore</w>
|
||||||
|
|||||||
@@ -17,4 +17,29 @@ public interface MQConstants {
|
|||||||
*/
|
*/
|
||||||
String TOPIC_COMMENT_HEAT_UPDATE = "CommentHeatUpdateTopic";
|
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";
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,11 @@ public class RedisKeyConstants {
|
|||||||
*/
|
*/
|
||||||
private static final String HAVE_FIRST_REPLY_COMMENT_KEY_PREFIX = "comment:havaFirstReplyCommentId:";
|
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
|
* Key 前缀:二级评论分页 ZSET
|
||||||
*/
|
*/
|
||||||
@@ -45,6 +50,16 @@ public class RedisKeyConstants {
|
|||||||
*/
|
*/
|
||||||
private static final String COMMENT_DETAIL_KEY_PREFIX = "comment:detail:";
|
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
|
* 构建子评论分页 ZSET 完整 KEY
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public class Comment2DBConsumer {
|
|||||||
// 每秒创建 1000 个令牌
|
// 每秒创建 1000 个令牌
|
||||||
private final RateLimiter rateLimiter = RateLimiter.create(1000);
|
private final RateLimiter rateLimiter = RateLimiter.create(1000);
|
||||||
|
|
||||||
@Bean
|
@Bean(name = "Comment2DBConsumer")
|
||||||
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
|
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
|
||||||
// Group组
|
// Group组
|
||||||
String group = "han_note_group_" + MQConstants.TOPIC_PUBLISH_COMMENT;
|
String group = "han_note_group_" + MQConstants.TOPIC_PUBLISH_COMMENT;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,4 +39,22 @@ public class CommentController {
|
|||||||
return commentService.findChildCommentPageList(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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -120,4 +120,30 @@ public interface CommentDOMapper extends BaseMapper<CommentDO> {
|
|||||||
*/
|
*/
|
||||||
List<CommentDO> selectChildCommentsByParentIdAndLimit(@Param("parentId") Long parentId,
|
List<CommentDO> selectChildCommentsByParentIdAndLimit(@Param("parentId") Long parentId,
|
||||||
@Param("limit") int limit);
|
@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);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,46 @@ package com.hanserwei.hannote.comment.biz.domain.mapper;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO;
|
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.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface CommentLikeDOMapper extends BaseMapper<CommentLikeDO> {
|
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);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.hanserwei.hannote.comment.biz.domain.mapper;
|
|||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.hanserwei.hannote.comment.biz.domain.dataobject.NoteCountDO;
|
import com.hanserwei.hannote.comment.biz.domain.dataobject.NoteCountDO;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
|
public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
|
||||||
@@ -14,4 +15,14 @@ public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
|
|||||||
* @return 笔记评论总数
|
* @return 笔记评论总数
|
||||||
*/
|
*/
|
||||||
Long selectCommentTotalByNoteId(Long noteId);
|
Long selectCommentTotalByNoteId(Long noteId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新评论总数
|
||||||
|
*
|
||||||
|
* @param noteId 笔记 ID
|
||||||
|
* @param count 评论总数
|
||||||
|
* @return 更新数量
|
||||||
|
*/
|
||||||
|
int updateCommentTotalByNoteId(@Param("noteId") Long noteId,
|
||||||
|
@Param("count") int count);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.hanserwei.hannote.comment.biz.enums;
|
|||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum CommentLevelEnum {
|
public enum CommentLevelEnum {
|
||||||
@@ -14,4 +16,18 @@ public enum CommentLevelEnum {
|
|||||||
|
|
||||||
private final Integer code;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.hanserwei.hannote.comment.biz.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum CommentLikeLuaResultEnum {
|
||||||
|
// 布隆过滤器不存在
|
||||||
|
NOT_EXIST(-1L),
|
||||||
|
// 评论已点赞
|
||||||
|
COMMENT_LIKED(1L),
|
||||||
|
// 评论点赞成功
|
||||||
|
COMMENT_LIKE_SUCCESS(0L),
|
||||||
|
;
|
||||||
|
|
||||||
|
private final Long code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据类型 code 获取对应的枚举
|
||||||
|
*
|
||||||
|
* @param code 类型 code
|
||||||
|
* @return 枚举
|
||||||
|
*/
|
||||||
|
public static CommentLikeLuaResultEnum valueOf(Long code) {
|
||||||
|
for (CommentLikeLuaResultEnum commentLikeLuaResultEnum : CommentLikeLuaResultEnum.values()) {
|
||||||
|
if (Objects.equals(code, commentLikeLuaResultEnum.getCode())) {
|
||||||
|
return commentLikeLuaResultEnum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
|
|||||||
// ----------- 业务异常状态码 -----------
|
// ----------- 业务异常状态码 -----------
|
||||||
COMMENT_NOT_FOUND("COMMENT-20001", "此评论不存在"),
|
COMMENT_NOT_FOUND("COMMENT-20001", "此评论不存在"),
|
||||||
PARENT_COMMENT_NOT_FOUND("COMMENT-20000", "此父评论不存在"),
|
PARENT_COMMENT_NOT_FOUND("COMMENT-20000", "此父评论不存在"),
|
||||||
|
COMMENT_ALREADY_LIKED("COMMENT-20002", "您已经点赞过该评论"),
|
||||||
|
COMMENT_NOT_LIKED("COMMENT-20003", "您未点赞该评论,无法取消点赞"),
|
||||||
|
COMMENT_CANT_OPERATE("COMMENT-20004", "您无法操作该评论"),
|
||||||
;
|
;
|
||||||
|
|
||||||
// 异常码
|
// 异常码
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.hanserwei.hannote.comment.biz.model.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class LikeUnlikeCommentMqDTO {
|
||||||
|
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private Long commentId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0: 取消点赞, 1:点赞
|
||||||
|
*/
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.hanserwei.hannote.comment.biz.model.vo;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@AllArgsConstructor
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class DeleteCommentReqVO {
|
||||||
|
|
||||||
|
@NotNull(message = "评论 ID 不能为空")
|
||||||
|
private Long commentId;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,14 +6,12 @@ import com.hanserwei.framework.common.constant.DateConstants;
|
|||||||
import com.hanserwei.framework.common.response.Response;
|
import com.hanserwei.framework.common.response.Response;
|
||||||
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
|
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
|
||||||
import com.hanserwei.hannote.kv.api.KeyValueFeignApi;
|
import com.hanserwei.hannote.kv.api.KeyValueFeignApi;
|
||||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
import com.hanserwei.hannote.kv.dto.req.*;
|
||||||
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.resp.FindCommentContentRspDTO;
|
import com.hanserwei.hannote.kv.dto.resp.FindCommentContentRspDTO;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@@ -81,4 +79,29 @@ public class KeyValueRpcService {
|
|||||||
return response.getData();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -30,4 +30,35 @@ public interface CommentService extends IService<CommentDO> {
|
|||||||
* @return 响应
|
* @return 响应
|
||||||
*/
|
*/
|
||||||
PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(FindChildCommentPageListReqVO findChildCommentPageListReqVO);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import com.hanserwei.framework.common.utils.JsonUtils;
|
|||||||
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
|
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
|
||||||
import com.hanserwei.hannote.comment.biz.constants.RedisKeyConstants;
|
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.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.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.domain.mapper.NoteCountDOMapper;
|
||||||
import com.hanserwei.hannote.comment.biz.enums.CommentLevelEnum;
|
import com.hanserwei.hannote.comment.biz.enums.*;
|
||||||
import com.hanserwei.hannote.comment.biz.enums.ResponseCodeEnum;
|
import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO;
|
||||||
import com.hanserwei.hannote.comment.biz.model.dto.PublishCommentMqDTO;
|
import com.hanserwei.hannote.comment.biz.model.dto.PublishCommentMqDTO;
|
||||||
import com.hanserwei.hannote.comment.biz.model.vo.*;
|
import com.hanserwei.hannote.comment.biz.model.vo.*;
|
||||||
import com.hanserwei.hannote.comment.biz.retry.SendMqRetryHelper;
|
import com.hanserwei.hannote.comment.biz.retry.SendMqRetryHelper;
|
||||||
@@ -37,11 +39,20 @@ import jakarta.annotation.Resource;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.logging.log4j.util.Strings;
|
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.jspecify.annotations.NonNull;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.data.redis.core.*;
|
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.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
import org.springframework.scripting.support.ResourceScriptSource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -65,9 +76,15 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
|
|||||||
@Resource
|
@Resource
|
||||||
private UserRpcService userRpcService;
|
private UserRpcService userRpcService;
|
||||||
@Resource
|
@Resource
|
||||||
|
private RocketMQTemplate rocketMQTemplate;
|
||||||
|
@Resource
|
||||||
private RedisTemplate<String, Object> redisTemplate;
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
@Resource(name = "taskExecutor")
|
@Resource(name = "taskExecutor")
|
||||||
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
|
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
|
||||||
|
@Resource
|
||||||
|
private CommentLikeDOMapper commentLikeDOMapper;
|
||||||
|
@Resource
|
||||||
|
private TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 评论详情本地缓存
|
* 评论详情本地缓存
|
||||||
@@ -420,6 +437,353 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
|
|||||||
return PageResponse.success(childCommentRspVOS, pageNo, count, pageSize);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置子评论计数数据
|
* 设置子评论计数数据
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -201,4 +201,26 @@
|
|||||||
order by create_time
|
order by create_time
|
||||||
limit #{limit}
|
limit #{limit}
|
||||||
</select>
|
</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>
|
</mapper>
|
||||||
@@ -13,4 +13,36 @@
|
|||||||
<!--@mbg.generated-->
|
<!--@mbg.generated-->
|
||||||
id, user_id, comment_id, create_time
|
id, user_id, comment_id, create_time
|
||||||
</sql>
|
</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>
|
</mapper>
|
||||||
@@ -20,4 +20,10 @@
|
|||||||
from t_note_count
|
from t_note_count
|
||||||
where note_id = #{noteId}
|
where note_id = #{noteId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<update id="updateCommentTotalByNoteId" parameterType="map">
|
||||||
|
update t_note_count
|
||||||
|
set comment_total = comment_total + #{count}
|
||||||
|
where note_id = #{noteId}
|
||||||
|
</update>
|
||||||
</mapper>
|
</mapper>
|
||||||
@@ -57,6 +57,16 @@ public interface MQConstants {
|
|||||||
*/
|
*/
|
||||||
String TOPIC_NOTE_OPERATE = "NoteOperateTopic";
|
String TOPIC_NOTE_OPERATE = "NoteOperateTopic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topic: 评论点赞数更新
|
||||||
|
*/
|
||||||
|
String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topic: 计数 - 评论点赞数落库
|
||||||
|
*/
|
||||||
|
String TOPIC_COUNT_COMMENT_LIKE_2_DB = "CountCommentLike2DBTTopic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tag 标签:笔记发布
|
* Tag 标签:笔记发布
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public class RedisKeyConstants {
|
|||||||
*/
|
*/
|
||||||
private static final String COUNT_NOTE_KEY_PREFIX = "count:note:";
|
private static final String COUNT_NOTE_KEY_PREFIX = "count:note:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash Field: 笔记评论总数
|
||||||
|
*/
|
||||||
|
public static final String FIELD_COMMENT_TOTAL = "commentTotal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hash Field: 笔记收藏总数
|
* Hash Field: 笔记收藏总数
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import com.github.phantomthief.collection.BufferTrigger;
|
|||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
import com.hanserwei.framework.common.utils.JsonUtils;
|
import com.hanserwei.framework.common.utils.JsonUtils;
|
||||||
import com.hanserwei.hannote.count.biz.constant.MQConstants;
|
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.domain.mapper.NoteCountDOMapper;
|
||||||
import com.hanserwei.hannote.count.biz.model.dto.CountPublishCommentMqDTO;
|
import com.hanserwei.hannote.count.biz.model.dto.CountPublishCommentMqDTO;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -27,6 +29,8 @@ public class CountNoteCommentConsumer implements RocketMQListener<String> {
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private NoteCountDOMapper noteCountDOMapper;
|
private NoteCountDOMapper noteCountDOMapper;
|
||||||
|
@Resource
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
|
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
|
||||||
.bufferSize(50000) // 缓存队列的最大容量
|
.bufferSize(50000) // 缓存队列的最大容量
|
||||||
@@ -67,6 +71,19 @@ public class CountNoteCommentConsumer implements RocketMQListener<String> {
|
|||||||
// 评论数
|
// 评论数
|
||||||
int count = CollUtil.size(entry.getValue());
|
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) {
|
if (count > 0) {
|
||||||
noteCountDOMapper.insertOrUpdateCommentTotalByNoteId(count, noteId);
|
noteCountDOMapper.insertOrUpdateCommentTotalByNoteId(count, noteId);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import java.util.stream.Collectors;
|
|||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RocketMQMessageListener(
|
@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
|
topic = MQConstants.TOPIC_LIKE_OR_UNLIKE
|
||||||
)
|
)
|
||||||
public class CountNoteLikeConsumer implements RocketMQListener<String> {
|
public class CountNoteLikeConsumer implements RocketMQListener<String> {
|
||||||
|
|||||||
@@ -16,4 +16,14 @@ public interface CommentDOMapper extends BaseMapper<CommentDO> {
|
|||||||
* @return 更新结果
|
* @return 更新结果
|
||||||
*/
|
*/
|
||||||
int updateChildCommentTotal(@Param("parentId") Long parentId, @Param("count") int count);
|
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);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -48,4 +48,11 @@
|
|||||||
where id = #{parentId}
|
where id = #{parentId}
|
||||||
and level = 1
|
and level = 1
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateLikeTotalByCommentId" parameterType="map">
|
||||||
|
update t_comment
|
||||||
|
set like_total = like_total + #{count},
|
||||||
|
update_time = now()
|
||||||
|
where id = #{commentId}
|
||||||
|
</update>
|
||||||
</mapper>
|
</mapper>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,4 +31,6 @@ public interface KeyValueFeignApi {
|
|||||||
@PostMapping(value = PREFIX + "/comment/content/batchFind")
|
@PostMapping(value = PREFIX + "/comment/content/batchFind")
|
||||||
Response<List<FindCommentContentRspDTO>> batchFindCommentContent(@RequestBody BatchFindCommentContentReqDTO batchFindCommentContentReqDTO);
|
Response<List<FindCommentContentRspDTO>> batchFindCommentContent(@RequestBody BatchFindCommentContentReqDTO batchFindCommentContentReqDTO);
|
||||||
|
|
||||||
|
@PostMapping(value = PREFIX + "/comment/content/delete")
|
||||||
|
Response<?> deleteCommentContent(@RequestBody DeleteCommentContentReqDTO deleteCommentContentReqDTO);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.hanserwei.framework.common.response.Response;
|
|||||||
import com.hanserwei.hannote.kv.biz.service.CommentContentService;
|
import com.hanserwei.hannote.kv.biz.service.CommentContentService;
|
||||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
||||||
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
|
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
|
||||||
|
import com.hanserwei.hannote.kv.dto.req.DeleteCommentContentReqDTO;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
@@ -33,4 +34,10 @@ public class CommentContentController {
|
|||||||
return commentContentService.batchFindCommentContent(batchFindCommentContentReqDTO);
|
return commentContentService.batchFindCommentContent(batchFindCommentContentReqDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/comment/content/delete")
|
||||||
|
@ApiOperationLog(description = "删除评论内容")
|
||||||
|
public Response<?> deleteCommentContent(@Validated @RequestBody DeleteCommentContentReqDTO deleteCommentContentReqDTO) {
|
||||||
|
return commentContentService.deleteCommentContent(deleteCommentContentReqDTO);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,13 @@ public interface CommentContentRepository extends CassandraRepository<CommentCon
|
|||||||
List<CommentContentDO> findByPrimaryKeyNoteIdAndPrimaryKeyYearMonthInAndPrimaryKeyContentIdIn(
|
List<CommentContentDO> findByPrimaryKeyNoteIdAndPrimaryKeyYearMonthInAndPrimaryKeyContentIdIn(
|
||||||
Long noteId, List<String> yearMonths, List<UUID> contentIds
|
Long noteId, List<String> yearMonths, List<UUID> contentIds
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除评论正文
|
||||||
|
*
|
||||||
|
* @param noteId 笔记ID
|
||||||
|
* @param yearMonth 年月
|
||||||
|
* @param contentId 评论 ID
|
||||||
|
*/
|
||||||
|
void deleteByPrimaryKeyNoteIdAndPrimaryKeyYearMonthAndPrimaryKeyContentId(Long noteId, String yearMonth, UUID contentId);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.hanserwei.hannote.kv.biz.service;
|
|||||||
import com.hanserwei.framework.common.response.Response;
|
import com.hanserwei.framework.common.response.Response;
|
||||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
||||||
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
|
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
|
||||||
|
import com.hanserwei.hannote.kv.dto.req.DeleteCommentContentReqDTO;
|
||||||
|
|
||||||
public interface CommentContentService {
|
public interface CommentContentService {
|
||||||
|
|
||||||
@@ -21,4 +22,13 @@ public interface CommentContentService {
|
|||||||
* @return 批量查询结果
|
* @return 批量查询结果
|
||||||
*/
|
*/
|
||||||
Response<?> batchFindCommentContent(BatchFindCommentContentReqDTO batchFindCommentContentReqDTO);
|
Response<?> batchFindCommentContent(BatchFindCommentContentReqDTO batchFindCommentContentReqDTO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除评论内容
|
||||||
|
*
|
||||||
|
* @param deleteCommentContentReqDTO 删除评论内容请求参数
|
||||||
|
* @return 删除结果
|
||||||
|
*/
|
||||||
|
Response<?> deleteCommentContent(DeleteCommentContentReqDTO deleteCommentContentReqDTO);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.dataobject.CommentContentPrimaryKey;
|
||||||
import com.hanserwei.hannote.kv.biz.domain.repository.CommentContentRepository;
|
import com.hanserwei.hannote.kv.biz.domain.repository.CommentContentRepository;
|
||||||
import com.hanserwei.hannote.kv.biz.service.CommentContentService;
|
import com.hanserwei.hannote.kv.biz.service.CommentContentService;
|
||||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
import com.hanserwei.hannote.kv.dto.req.*;
|
||||||
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.resp.FindCommentContentRspDTO;
|
import com.hanserwei.hannote.kv.dto.resp.FindCommentContentRspDTO;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
@@ -91,4 +88,16 @@ public class CommentContentServiceImpl implements CommentContentService {
|
|||||||
|
|
||||||
return Response.success(findCommentContentRspDTOS);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ public class RedisKeyConstants {
|
|||||||
*/
|
*/
|
||||||
public static final String NOTE_DETAIL_KEY = "note:detail:";
|
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) {
|
public static String buildUserNoteCollectZSetKey(Long userId) {
|
||||||
return USER_NOTE_COLLECT_ZSET_KEY + 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.NoteLikeDO;
|
||||||
import com.hanserwei.hannote.note.biz.domain.dataobject.TopicDO;
|
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.NoteDOMapper;
|
||||||
|
import com.hanserwei.hannote.note.biz.domain.mapper.NoteLikeDOMapper;
|
||||||
import com.hanserwei.hannote.note.biz.enums.*;
|
import com.hanserwei.hannote.note.biz.enums.*;
|
||||||
import com.hanserwei.hannote.note.biz.model.dto.CollectUnCollectNoteMqDTO;
|
import com.hanserwei.hannote.note.biz.model.dto.CollectUnCollectNoteMqDTO;
|
||||||
import com.hanserwei.hannote.note.biz.model.dto.LikeUnlikeNoteMqDTO;
|
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;
|
private RedisTemplate<String, String> redisTemplate;
|
||||||
@Resource
|
@Resource
|
||||||
private RocketMQTemplate rocketMQTemplate;
|
private RocketMQTemplate rocketMQTemplate;
|
||||||
|
@Resource
|
||||||
|
private NoteLikeDOMapper noteLikeDOMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 笔记详情本地缓存
|
* 笔记详情本地缓存
|
||||||
@@ -630,14 +633,17 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
|||||||
// 2. 判断目标笔记,是否已经点赞过
|
// 2. 判断目标笔记,是否已经点赞过
|
||||||
Long userId = LoginUserContextHolder.getUserId();
|
Long userId = LoginUserContextHolder.getUserId();
|
||||||
|
|
||||||
// 布隆过滤器Key
|
// Roaring Bitmap Key
|
||||||
String bloomUserNoteLikeListKey = RedisKeyConstants.buildBloomUserNoteLikeListKey(userId);
|
String rbitmapUserNoteLikeListKey = RedisKeyConstants.buildRBitmapUserNoteLikeListKey(userId);
|
||||||
|
|
||||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||||
// Lua 脚本路径
|
// 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);
|
script.setResultType(Long.class);
|
||||||
|
|
||||||
// 执行 Lua 脚本,拿到返回结果
|
// 执行 Lua 脚本,拿到返回结果
|
||||||
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId);
|
Long result = redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId);
|
||||||
|
|
||||||
NoteLikeLuaResultEnum noteLikeLuaResultEnum = NoteLikeLuaResultEnum.valueOf(result);
|
NoteLikeLuaResultEnum noteLikeLuaResultEnum = NoteLikeLuaResultEnum.valueOf(result);
|
||||||
|
|
||||||
@@ -659,38 +665,25 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
|||||||
|
|
||||||
// 目标笔记已经被点赞
|
// 目标笔记已经被点赞
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
// 异步初始化布隆过滤器
|
// 异步初始化 Roaring Bitmap
|
||||||
threadPoolTaskExecutor.submit(() -> batchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey));
|
threadPoolTaskExecutor.submit(() ->
|
||||||
|
batchAddNoteLike2RBitmapAndExpire(userId, expireSeconds, rbitmapUserNoteLikeListKey));
|
||||||
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
|
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 若笔记未被点赞,查询当前用户是否点赞其他用户,有则同步初始化布隆过滤器
|
// 若目标笔记未被点赞,查询当前用户是否有点赞其他笔记,有则同步初始化 Roaring Bitmap
|
||||||
batchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey);
|
batchAddNoteLike2RBitmapAndExpire(userId, expireSeconds, rbitmapUserNoteLikeListKey);
|
||||||
|
|
||||||
// 若数据库中也没有点赞记录,说明该用户还未点赞过任何笔记
|
// 添加当前点赞笔记 ID 到 Roaring Bitmap 中
|
||||||
// Lua 脚本路径
|
// 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);
|
script.setResultType(Long.class);
|
||||||
redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId, expireSeconds);
|
redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId, expireSeconds);
|
||||||
}
|
}
|
||||||
// 目标笔记已经被点赞
|
// 目标笔记已经被点赞
|
||||||
case NOTE_LIKED -> {
|
case NOTE_LIKED -> {
|
||||||
// 校验 ZSet 列表中是否包含被点赞的笔记ID
|
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
|
||||||
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 点赞列表
|
// 3. 更新用户 ZSET 点赞列表
|
||||||
@@ -768,6 +761,37 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
|||||||
return Response.success();
|
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
|
@Override
|
||||||
public Response<?> unlikeNote(UnlikeNoteReqVO unlikeNoteReqVO) {
|
public Response<?> unlikeNote(UnlikeNoteReqVO unlikeNoteReqVO) {
|
||||||
// 笔记ID
|
// 笔记ID
|
||||||
@@ -780,37 +804,38 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
|||||||
// 当前登录用户ID
|
// 当前登录用户ID
|
||||||
Long userId = LoginUserContextHolder.getUserId();
|
Long userId = LoginUserContextHolder.getUserId();
|
||||||
|
|
||||||
// 布隆过滤器Key
|
// Roaring Bitmap Key
|
||||||
String bloomUserNoteLikeListKey = RedisKeyConstants.buildBloomUserNoteLikeListKey(userId);
|
String rbitmapUserNoteLikeListKey = RedisKeyConstants.buildRBitmapUserNoteLikeListKey(userId);
|
||||||
|
|
||||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||||
// Lua 脚本路径
|
// 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);
|
script.setResultType(Long.class);
|
||||||
|
|
||||||
// 执行 Lua 脚本,拿到返回结果
|
// 执行 Lua 脚本,拿到返回结果
|
||||||
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId);
|
Long result = redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId);
|
||||||
|
|
||||||
NoteUnlikeLuaResultEnum noteUnlikeLuaResultEnum = NoteUnlikeLuaResultEnum.valueOf(result);
|
NoteUnlikeLuaResultEnum noteUnlikeLuaResultEnum = NoteUnlikeLuaResultEnum.valueOf(result);
|
||||||
log.info("==> 【笔记取消点赞】Lua 脚本返回结果: {}", noteUnlikeLuaResultEnum);
|
log.info("==> 【笔记取消点赞】Lua 脚本返回结果: {}", noteUnlikeLuaResultEnum);
|
||||||
switch (Objects.requireNonNull(noteUnlikeLuaResultEnum)) {
|
switch (Objects.requireNonNull(noteUnlikeLuaResultEnum)) {
|
||||||
// 布隆过滤器不存在
|
// 布隆过滤器不存在
|
||||||
case NOT_EXIST -> {//笔记不存在
|
case NOT_EXIST -> {//笔记不存在
|
||||||
//异步初始化布隆过滤器
|
// 异步初始化 Roaring Bitmap
|
||||||
threadPoolTaskExecutor.submit(() -> {
|
threadPoolTaskExecutor.submit(() -> {
|
||||||
// 保底1天+随机秒数
|
// 保底1天+随机秒数
|
||||||
long expireSeconds = 60 * 60 * 24 + RandomUtil.randomInt(60 * 60 * 24);
|
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::getUserId, userId)
|
||||||
.eq(NoteLikeDO::getNoteId, noteId)
|
.eq(NoteLikeDO::getNoteId, noteId));
|
||||||
.eq(NoteLikeDO::getStatus, LikeStatusEnum.LIKE.getCode()));
|
|
||||||
if (count == 0) {
|
// 未点赞,无法取消点赞操作,抛出业务异常
|
||||||
log.info("==> 【笔记取消点赞】用户未点赞该笔记");
|
log.info("1111111");
|
||||||
throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
|
if (count == 0) throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 布隆过滤器校验目标笔记未被点赞(判断绝对正确)
|
// 布隆过滤器校验目标笔记未被点赞(判断绝对正确)
|
||||||
case NOTE_NOT_LIKED -> 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
|
// 用户点赞列表ZsetKey
|
||||||
String userNoteLikeZSetKey = RedisKeyConstants.buildUserNoteLikeZSetKey(userId);
|
String userNoteLikeZSetKey = RedisKeyConstants.buildUserNoteLikeZSetKey(userId);
|
||||||
|
|
||||||
// TODO: 后续考虑换掉布隆过滤器。
|
|
||||||
|
|
||||||
Long removed = redisTemplate.opsForZSet().remove(userNoteLikeZSetKey, noteId);
|
Long removed = redisTemplate.opsForZSet().remove(userNoteLikeZSetKey, noteId);
|
||||||
|
|
||||||
if (Objects.nonNull(removed) && removed == 0) {
|
|
||||||
log.info("==> 【笔记取消点赞】用户未点赞该笔记");
|
|
||||||
throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
|
|
||||||
}
|
|
||||||
|
|
||||||
//4. 发送 MQ, 数据更新落库
|
//4. 发送 MQ, 数据更新落库
|
||||||
// 构建MQ消息体
|
// 构建MQ消息体
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -3,7 +3,7 @@ POST http://localhost:8000/auth/verification/code/send
|
|||||||
Content-Type: application/json
|
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
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"email": "2628273921@qq.com",
|
"email": "ssw010723@gmail.com",
|
||||||
"code": "825004",
|
"code": "116253",
|
||||||
"type": 1
|
"type": 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,16 +202,16 @@ Content-Type: application/json
|
|||||||
Authorization: Bearer {{thirdToken}}
|
Authorization: Bearer {{thirdToken}}
|
||||||
|
|
||||||
{
|
{
|
||||||
"id": 1981698494959714362
|
"id": 1985254482941837349
|
||||||
}
|
}
|
||||||
|
|
||||||
### 笔记取消点赞入口
|
### 笔记取消点赞入口
|
||||||
POST http://localhost:8000/note/note/unlike
|
POST http://localhost:8000/note/note/unlike
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer {{otherToken}}
|
Authorization: Bearer {{thirdToken}}
|
||||||
|
|
||||||
{
|
{
|
||||||
"id": 1977249693272375330
|
"id": 1985254482941837349
|
||||||
}
|
}
|
||||||
|
|
||||||
### 笔记收藏入口
|
### 笔记收藏入口
|
||||||
@@ -366,3 +366,40 @@ Authorization: Bearer {{token}}
|
|||||||
"parentCommentId": 4002,
|
"parentCommentId": 4002,
|
||||||
"pageNo": 1
|
"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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user