Compare commits
24 Commits
a37e76c87c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 94729e5170 | |||
| 6e0f226b42 | |||
| e0cf96edbf | |||
| d9a960e265 | |||
| 6985431236 | |||
| 85e0238857 | |||
| 93ca81a15b | |||
| f74397ed1e | |||
| f90e36f7d6 | |||
| a8d5c7f9b7 | |||
| 51cebf6215 | |||
| 8be6719be8 | |||
| e3f9b6a5b5 | |||
| 6f22c2b50d | |||
| bd775b805c | |||
| 85e6bab079 | |||
| 6fbe8eed25 | |||
| fdee4dc2b4 | |||
| 2b06ca0300 | |||
| 29cf889dd7 | |||
| c454e1832c | |||
| 9ec330216f | |||
| 63495b4938 | |||
| f49d0e6b76 |
6
.idea/MyBatisCodeHelperDatasource.xml
generated
6
.idea/MyBatisCodeHelperDatasource.xml
generated
@@ -7,8 +7,6 @@
|
||||
<option name="customizedLombokAnnotation" value="true" />
|
||||
<option name="customizedLombokValue" value="@lombok.Builder" />
|
||||
<option name="deleteByPrimayKeyEnabled" value="false" />
|
||||
<option name="generateService" value="true" />
|
||||
<option name="generateServiceInterface" value="true" />
|
||||
<option name="insertMethodEnabled" value="false" />
|
||||
<option name="insertSelectiveMethodEnabled" value="false" />
|
||||
<option name="javaMapperPackage" value="com.hanserwei.hannote.comment.biz.domain.mapper" />
|
||||
@@ -96,7 +94,7 @@
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
<option name="mybatisPlusIdType" value="AUTO" />
|
||||
<option name="mybatisPlusIdType" value="ASSIGN_ID" />
|
||||
<option name="selectByPrimaryKeyEnabled" value="false" />
|
||||
<option name="tableGenerateConfigs">
|
||||
<map>
|
||||
@@ -260,7 +258,7 @@
|
||||
<option name="insertMethodEnabled" value="false" />
|
||||
<option name="insertSelectiveMethodEnabled" value="false" />
|
||||
<option name="javaModelName" value="NoteCountDO" />
|
||||
<option name="moduleName" value="han-note-count-biz" />
|
||||
<option name="moduleName" value="han-note-comment-biz" />
|
||||
<option name="mybatisplusIdType" value="ASSIGN_ID" />
|
||||
<option name="selectByPrimaryKeyEnabled" value="false" />
|
||||
<option name="sequenceColumn" value="" />
|
||||
|
||||
2
.idea/dictionaries/project.xml
generated
2
.idea/dictionaries/project.xml
generated
@@ -6,8 +6,10 @@
|
||||
<w>hannote</w>
|
||||
<w>hanserwei</w>
|
||||
<w>jobhandler</w>
|
||||
<w>mget</w>
|
||||
<w>nacos</w>
|
||||
<w>operationlog</w>
|
||||
<w>rbitmap</w>
|
||||
<w>rustfs</w>
|
||||
<w>zadd</w>
|
||||
<w>zrevrangebyscore</w>
|
||||
|
||||
@@ -113,6 +113,29 @@
|
||||
<artifactId>han-note-kv-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 快手 Buffer Trigger -->
|
||||
<dependency>
|
||||
<groupId>com.github.phantomthief</groupId>
|
||||
<artifactId>buffer-trigger</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.hanserwei</groupId>
|
||||
<artifactId>han-note-user-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine 本地缓存 -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hanserwei.hannote.comment.biz.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
@Configuration
|
||||
public class RedisTemplateConfig {
|
||||
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
|
||||
// 设置 RedisTemplate 的连接工厂
|
||||
redisTemplate.setConnectionFactory(connectionFactory);
|
||||
|
||||
// 使用 StringRedisSerializer 来序列化和反序列化 redis 的 key 值,确保 key 是可读的字符串
|
||||
redisTemplate.setKeySerializer(new StringRedisSerializer());
|
||||
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值, 确保存储的是 JSON 格式
|
||||
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
|
||||
redisTemplate.setValueSerializer(serializer);
|
||||
redisTemplate.setHashValueSerializer(serializer);
|
||||
|
||||
redisTemplate.afterPropertiesSet();
|
||||
return redisTemplate;
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,39 @@ public interface MQConstants {
|
||||
*/
|
||||
String TOPIC_PUBLISH_COMMENT = "PublishCommentTopic";
|
||||
|
||||
/**
|
||||
* Topic: 笔记评论总数计数
|
||||
*/
|
||||
String TOPIC_COUNT_NOTE_COMMENT = "CountNoteCommentTopic";
|
||||
|
||||
/**
|
||||
* Topic: 评论热度值更新
|
||||
*/
|
||||
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";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.hanserwei.hannote.comment.biz.constants;
|
||||
|
||||
public class RedisKeyConstants {
|
||||
|
||||
/**
|
||||
* Key 前缀:一级评论的 first_reply_comment_id 字段值是否更新标识
|
||||
*/
|
||||
private static final String HAVE_FIRST_REPLY_COMMENT_KEY_PREFIX = "comment:havaFirstReplyCommentId:";
|
||||
|
||||
/**
|
||||
* Key 前缀:布隆过滤器 - 用户点赞的评论
|
||||
*/
|
||||
private static final String BLOOM_COMMENT_LIKES_KEY_PREFIX = "bloom:comment:likes:";
|
||||
|
||||
/**
|
||||
* Key 前缀:二级评论分页 ZSET
|
||||
*/
|
||||
private static final String CHILD_COMMENT_LIST_KEY_PREFIX = "comment:childList:";
|
||||
|
||||
/**
|
||||
* Hash Field: 子评论总数
|
||||
*/
|
||||
public static final String FIELD_CHILD_COMMENT_TOTAL = "childCommentTotal";
|
||||
/**
|
||||
* Hash Field: 点赞总数
|
||||
*/
|
||||
public static final String FIELD_LIKE_TOTAL = "likeTotal";
|
||||
/**
|
||||
* 评论维度计数 Key 前缀
|
||||
*/
|
||||
private static final String COUNT_COMMENT_KEY_PREFIX = "count:comment:";
|
||||
|
||||
/**
|
||||
* Hash Field 键:评论总数
|
||||
*/
|
||||
public static final String FIELD_COMMENT_TOTAL = "commentTotal";
|
||||
|
||||
/**
|
||||
* Key 前缀:笔记评论总数
|
||||
*/
|
||||
private static final String COUNT_COMMENT_TOTAL_KEY_PREFIX = "count:note:";
|
||||
|
||||
/**
|
||||
* Key 前缀:评论分页 ZSET
|
||||
*/
|
||||
private static final String COMMENT_LIST_KEY_PREFIX = "comment:list:";
|
||||
|
||||
/**
|
||||
* Key 前缀:评论详情 JSON
|
||||
*/
|
||||
private static final String COMMENT_DETAIL_KEY_PREFIX = "comment:detail:";
|
||||
|
||||
/**
|
||||
* 构建 布隆过滤器 - 用户点赞的评论 完整 KEY
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @return 布隆过滤器 - 用户点赞的评论 完整 KEY
|
||||
*/
|
||||
public static String buildBloomCommentLikesKey(Long userId) {
|
||||
return BLOOM_COMMENT_LIKES_KEY_PREFIX + userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建子评论分页 ZSET 完整 KEY
|
||||
*
|
||||
* @param commentId 一级评论 ID
|
||||
* @return 子评论分页 ZSET 完整 KEY
|
||||
*/
|
||||
public static String buildChildCommentListKey(Long commentId) {
|
||||
return CHILD_COMMENT_LIST_KEY_PREFIX + commentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建评论维度计数 Key
|
||||
*
|
||||
* @param commentId 评论 ID
|
||||
* @return 评论维度计数 Key
|
||||
*/
|
||||
public static String buildCountCommentKey(Long commentId) {
|
||||
return COUNT_COMMENT_KEY_PREFIX + commentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整 KEY
|
||||
*
|
||||
* @param commentId 一级评论 ID
|
||||
* @return 完整 KEY
|
||||
*/
|
||||
public static String buildHaveFirstReplyCommentKey(Long commentId) {
|
||||
return HAVE_FIRST_REPLY_COMMENT_KEY_PREFIX + commentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建笔记评论总数完整 KEY
|
||||
*
|
||||
* @param noteId 笔记 ID
|
||||
* @return 笔记评论总数完整 KEY
|
||||
*/
|
||||
public static String buildNoteCommentTotalKey(Long noteId) {
|
||||
return COUNT_COMMENT_TOTAL_KEY_PREFIX + noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建评论分页 ZSET 完整 KEY
|
||||
*
|
||||
* @param noteId 笔记 ID
|
||||
* @return 评论分页 ZSET 完整 KEY
|
||||
*/
|
||||
public static String buildCommentListKey(Long noteId) {
|
||||
return COMMENT_LIST_KEY_PREFIX + noteId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建评论详情完整 KEY
|
||||
*
|
||||
* @param commentId 评论 ID
|
||||
* @return 评论详情完整 KEY
|
||||
*/
|
||||
public static String buildCommentDetailKey(Object commentId) {
|
||||
return COMMENT_DETAIL_KEY_PREFIX + commentId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import com.google.common.collect.Maps;
|
||||
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.enums.CommentLevelEnum;
|
||||
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
|
||||
import com.hanserwei.hannote.comment.biz.model.dto.CountPublishCommentMqDTO;
|
||||
import com.hanserwei.hannote.comment.biz.model.dto.PublishCommentMqDTO;
|
||||
import com.hanserwei.hannote.comment.biz.rpc.KeyValueRpcService;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
@@ -20,18 +22,23 @@ import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
|
||||
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
|
||||
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
|
||||
import org.apache.rocketmq.client.exception.MQClientException;
|
||||
import org.apache.rocketmq.client.producer.SendCallback;
|
||||
import org.apache.rocketmq.client.producer.SendResult;
|
||||
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
|
||||
import org.apache.rocketmq.common.message.MessageExt;
|
||||
import org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
import org.springframework.scripting.support.ResourceScriptSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
@@ -48,13 +55,17 @@ public class Comment2DBConsumer {
|
||||
private TransactionTemplate transactionTemplate;
|
||||
@Resource
|
||||
private KeyValueRpcService keyValueRpcService;
|
||||
@Resource
|
||||
private RocketMQTemplate rocketMQTemplate;
|
||||
@Resource
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private DefaultMQPushConsumer consumer;
|
||||
|
||||
// 每秒创建 1000 个令牌
|
||||
private final RateLimiter rateLimiter = RateLimiter.create(1000);
|
||||
|
||||
@Bean
|
||||
@Bean(name = "Comment2DBConsumer")
|
||||
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
|
||||
// Group组
|
||||
String group = "han_note_group_" + MQConstants.TOPIC_PUBLISH_COMMENT;
|
||||
@@ -165,10 +176,10 @@ public class Comment2DBConsumer {
|
||||
|
||||
log.info("## 清洗后的 CommentBOS: {}", JsonUtils.toJsonString(commentBOS));
|
||||
// 编程式事务,保证整体操作的原子性
|
||||
transactionTemplate.execute(status -> {
|
||||
Integer insertedRows = transactionTemplate.execute(status -> {
|
||||
try {
|
||||
// 先批量存入评论元数据
|
||||
commentDOMapper.batchInsert(commentBOS);
|
||||
int count = commentDOMapper.batchInsert(commentBOS);
|
||||
|
||||
// 过滤出评论内容不为空的 BO
|
||||
List<CommentBO> commentContentNotEmptyBOS = commentBOS.stream()
|
||||
@@ -176,10 +187,13 @@ public class Comment2DBConsumer {
|
||||
.toList();
|
||||
if (CollUtil.isNotEmpty(commentContentNotEmptyBOS)) {
|
||||
// 批量存入评论内容
|
||||
keyValueRpcService.batchSaveCommentContent(commentContentNotEmptyBOS);
|
||||
boolean result = keyValueRpcService.batchSaveCommentContent(commentContentNotEmptyBOS);
|
||||
if (!result) {
|
||||
throw new RuntimeException("批量保存评论内容失败");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return count;
|
||||
} catch (Exception ex) {
|
||||
status.setRollbackOnly(); // 标记事务为回滚
|
||||
log.error("", ex);
|
||||
@@ -187,6 +201,39 @@ public class Comment2DBConsumer {
|
||||
}
|
||||
});
|
||||
|
||||
// 如果批量插入的行数大于 0
|
||||
if (Objects.nonNull(insertedRows) && insertedRows > 0) {
|
||||
// 构建发送给计数服务的 DTO 集合
|
||||
List<CountPublishCommentMqDTO> countPublishCommentMqDTOS = commentBOS.stream()
|
||||
.map(commentBO -> CountPublishCommentMqDTO.builder()
|
||||
.noteId(commentBO.getNoteId())
|
||||
.commentId(commentBO.getId())
|
||||
.level(commentBO.getLevel())
|
||||
.parentId(commentBO.getParentId())
|
||||
.build())
|
||||
.toList();
|
||||
|
||||
// 异步发送计数 MQ
|
||||
org.springframework.messaging.Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(countPublishCommentMqDTOS))
|
||||
.build();
|
||||
|
||||
// 同步一级评论到 Redis 热点评论 ZSET 中
|
||||
syncOneLevelComment2RedisZSet(commentBOS);
|
||||
|
||||
// 异步发送 MQ 消息
|
||||
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_NOTE_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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 手动 ACK,告诉 RocketMQ 这批次消息消费成功
|
||||
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
|
||||
} catch (Exception e) {
|
||||
@@ -201,6 +248,40 @@ public class Comment2DBConsumer {
|
||||
return consumer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步一级评论到 Redis 热点评论 ZSET 中
|
||||
*
|
||||
* @param commentBOS 评论 BO 列表
|
||||
*/
|
||||
private void syncOneLevelComment2RedisZSet(List<CommentBO> commentBOS) {
|
||||
// 过滤出一级评论,并按所属笔记进行分组,转换为一个 Map 字典
|
||||
Map<Long, List<CommentBO>> commentIdAndBOListMap = commentBOS.stream()
|
||||
.filter(commentBO -> Objects.equals(commentBO.getLevel(), CommentLevelEnum.ONE.getCode())) // 仅过滤一级评论
|
||||
.collect(Collectors.groupingBy(CommentBO::getNoteId));
|
||||
|
||||
// 循环字典
|
||||
commentIdAndBOListMap.forEach((noteId, commentBOList) -> {
|
||||
// 构建 Redis 热点评论 ZSET Key
|
||||
String key = RedisKeyConstants.buildCommentListKey(noteId);
|
||||
|
||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||
// Lua 脚本路径
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/add_hot_comments.lua")));
|
||||
// 返回值类型
|
||||
script.setResultType(Long.class);
|
||||
|
||||
// 构建执行 Lua 脚本所需的 ARGS 参数
|
||||
List<Object> args = Lists.newArrayList();
|
||||
commentBOList.forEach(commentBO -> {
|
||||
args.add(commentBO.getId()); // Member: 评论ID
|
||||
args.add(0); // Score: 热度值,初始值为 0
|
||||
});
|
||||
|
||||
// 执行 Lua 脚本
|
||||
redisTemplate.execute(script, Collections.singletonList(key), args.toArray());
|
||||
});
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
if (Objects.nonNull(consumer)) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.hanserwei.hannote.comment.biz.consumer;
|
||||
|
||||
import com.github.phantomthief.collection.BufferTrigger;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.google.common.collect.Sets;
|
||||
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.model.bo.CommentHeatBO;
|
||||
import com.hanserwei.hannote.comment.biz.utils.HeatCalculator;
|
||||
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.core.io.ClassPathResource;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
import org.springframework.scripting.support.ResourceScriptSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_COMMENT_HEAT_UPDATE, // Group 组
|
||||
topic = MQConstants.TOPIC_COMMENT_HEAT_UPDATE // 主题 Topic
|
||||
)
|
||||
@Slf4j
|
||||
public class CommentHeatUpdateConsumer implements RocketMQListener<String> {
|
||||
|
||||
@Resource
|
||||
private CommentDOMapper commentDOMapper;
|
||||
@Resource
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
|
||||
.bufferSize(50000) // 缓存队列的最大容量
|
||||
.batchSize(300) // 一批次最多聚合 300 条
|
||||
.linger(Duration.ofSeconds(2)) // 多久聚合一次(2s 一次)
|
||||
.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));
|
||||
|
||||
// 将聚合后的消息体 Json 转 Set<Long>, 去重相同的评论 ID, 防止重复计算
|
||||
Set<Long> commentIds = Sets.newHashSet();
|
||||
bodys.forEach(body -> {
|
||||
try {
|
||||
Set<Long> list = JsonUtils.parseSet(body, Long.class);
|
||||
commentIds.addAll(list);
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("==> 去重后的评论 ID: {}", commentIds);
|
||||
|
||||
// 批量查询评论
|
||||
List<CommentDO> commentDOS = commentDOMapper.selectByCommentIds(commentIds.stream().toList());
|
||||
|
||||
// 评论 ID
|
||||
List<Long> ids = Lists.newArrayList();
|
||||
// 热度值 BO
|
||||
List<CommentHeatBO> commentBOS = Lists.newArrayList();
|
||||
|
||||
//重新计算每条评论的热度值
|
||||
commentDOS.forEach(commentDO -> {
|
||||
Long commentId = commentDO.getId();
|
||||
// 被点赞数
|
||||
Long likeTotal = commentDO.getLikeTotal();
|
||||
// 被回复数
|
||||
Long childCommentTotal = commentDO.getChildCommentTotal();
|
||||
|
||||
// 计算热度值
|
||||
BigDecimal heatNum = HeatCalculator.calculateHeat(likeTotal, childCommentTotal);
|
||||
ids.add(commentId);
|
||||
commentBOS.add(CommentHeatBO.builder()
|
||||
.id(commentId)
|
||||
.heat(heatNum.doubleValue())
|
||||
.noteId(commentDO.getNoteId())
|
||||
.build());
|
||||
});
|
||||
// 批量更新评论热度值
|
||||
commentDOMapper.batchUpdateHeatByCommentIds(ids, commentBOS);
|
||||
|
||||
// 更新 Redis 中热度评论 ZSET
|
||||
updateRedisHotComments(commentBOS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Redis 中热点评论 ZSET
|
||||
*
|
||||
* @param commentHeatBOList 热度值 BO 列表
|
||||
*/
|
||||
private void updateRedisHotComments(List<CommentHeatBO> commentHeatBOList) {
|
||||
// 过滤出热度值大于 0 的,并按所属笔记 ID 分组(若热度等于0,则不进行更新)
|
||||
Map<Long, List<CommentHeatBO>> noteIdAndBOListMap = commentHeatBOList.stream()
|
||||
.filter(commentHeatBO -> commentHeatBO.getHeat() > 0)
|
||||
.collect(Collectors.groupingBy(CommentHeatBO::getNoteId));
|
||||
|
||||
// 循环
|
||||
noteIdAndBOListMap.forEach((noteId, commentHeatBOS) -> {
|
||||
// 构建热点评论 Redis Key
|
||||
String key = RedisKeyConstants.buildCommentListKey(noteId);
|
||||
|
||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||
// Lua 脚本路径
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/update_hot_comments.lua")));
|
||||
// 返回值类型
|
||||
script.setResultType(Long.class);
|
||||
|
||||
// 构建执行 Lua 脚本所需的 ARGS 参数
|
||||
List<Object> args = Lists.newArrayList();
|
||||
commentHeatBOS.forEach(commentHeatBO -> {
|
||||
args.add(commentHeatBO.getId()); // Member: 评论ID
|
||||
args.add(commentHeatBO.getHeat()); // Score: 热度值
|
||||
});
|
||||
|
||||
// 执行 Lua 脚本
|
||||
redisTemplate.execute(script, Collections.singletonList(key), args.toArray());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.hanserwei.hannote.comment.biz.consumer;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import com.github.phantomthief.collection.BufferTrigger;
|
||||
import com.google.common.collect.Lists;
|
||||
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.enums.CommentLevelEnum;
|
||||
import com.hanserwei.hannote.comment.biz.model.dto.CountPublishCommentMqDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Component
|
||||
@RocketMQMessageListener(consumerGroup = "han_note_group_first_reply_comment_id" + MQConstants.TOPIC_COUNT_NOTE_COMMENT, // Group 组
|
||||
topic = MQConstants.TOPIC_COUNT_NOTE_COMMENT // 主题 Topic
|
||||
)
|
||||
@Slf4j
|
||||
public class OneLevelCommentFirstReplyCommentIdUpdateConsumer implements RocketMQListener<String> {
|
||||
|
||||
@Resource
|
||||
private CommentDOMapper commentDOMapper;
|
||||
@Resource(name = "taskExecutor")
|
||||
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
|
||||
|
||||
@Resource
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
|
||||
.bufferSize(50000) // 缓存队列的最大容量
|
||||
.batchSize(1000) // 一批次最多聚合 1000 条
|
||||
.linger(Duration.ofSeconds(1)) // 多久聚合一次(1s 一次)
|
||||
.setConsumerEx(this::consumeMessage) // 设置消费者方法
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public void onMessage(String body) {
|
||||
// 往 bufferTrigger 中添加元素
|
||||
bufferTrigger.enqueue(body);
|
||||
}
|
||||
|
||||
private void consumeMessage(List<String> bodys) {
|
||||
log.info("==> 【一级评论 first_reply_comment_id 更新】聚合消息, size: {}", bodys.size());
|
||||
log.info("==> 【一级评论 first_reply_comment_id 更新】聚合消息, {}", JsonUtils.toJsonString(bodys));
|
||||
|
||||
// 将聚合后的消息体 Json 转 List<CountPublishCommentMqDTO>
|
||||
List<CountPublishCommentMqDTO> publishCommentMqDTOS = Lists.newArrayList();
|
||||
bodys.forEach(body -> {
|
||||
try {
|
||||
List<CountPublishCommentMqDTO> list = JsonUtils.parseList(body, CountPublishCommentMqDTO.class);
|
||||
publishCommentMqDTOS.addAll(list);
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 过滤出二级评论的 parent_id(即一级评论 ID),并去重,需要更新对应一级评论的 first_reply_comment_id
|
||||
List<Long> parentIds = publishCommentMqDTOS.stream()
|
||||
.filter(publishCommentMqDTO -> Objects.equals(publishCommentMqDTO.getLevel(), CommentLevelEnum.TWO.getCode()))
|
||||
.map(CountPublishCommentMqDTO::getParentId)
|
||||
.distinct() // 去重
|
||||
.toList();
|
||||
|
||||
if (CollUtil.isEmpty(parentIds)) return;
|
||||
|
||||
// 构建RedisKey
|
||||
List<String> keys = parentIds.stream()
|
||||
.map(RedisKeyConstants::buildHaveFirstReplyCommentKey)
|
||||
.toList();
|
||||
// 批量查询Redis
|
||||
List<Object> values = redisTemplate.opsForValue().multiGet(keys);
|
||||
|
||||
// 提取Redis中不存在的评论ID
|
||||
List<Long> missingCommentIds = Lists.newArrayList();
|
||||
|
||||
if (values != null) {
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
if (Objects.isNull(values.get(i))) {
|
||||
missingCommentIds.add(parentIds.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 存在的一级评论 ID,说明表中对应记录的 first_reply_comment_id 已经有值
|
||||
if (CollUtil.isNotEmpty(missingCommentIds)) {
|
||||
// 不存在的,则需要进一步查询数据库来确定,是否要更新记录对应的 first_reply_comment_id 值
|
||||
// 批量去数据库中查询
|
||||
List<CommentDO> commentDOS = commentDOMapper.selectByCommentIds(missingCommentIds);
|
||||
|
||||
// 异步将 first_reply_comment_id 不为 0 的一级评论 ID, 同步到 redis 中
|
||||
threadPoolTaskExecutor.submit(() -> {
|
||||
List<Long> needSyncCommentIds = commentDOS.stream()
|
||||
.filter(commentDO -> commentDO.getFirstReplyCommentId() != 0)
|
||||
.map(CommentDO::getId)
|
||||
.toList();
|
||||
|
||||
sync2Redis(needSyncCommentIds);
|
||||
});
|
||||
|
||||
// 过滤出值为 0 的,都需要更新其 first_reply_comment_id
|
||||
List<CommentDO> needUpdateCommentDOS = commentDOS.stream()
|
||||
.filter(commentDO -> commentDO.getFirstReplyCommentId() == 0)
|
||||
.toList();
|
||||
|
||||
needUpdateCommentDOS.forEach(needUpdateCommentDO -> {
|
||||
// 一级评论 ID
|
||||
Long needUpdateCommentId = needUpdateCommentDO.getId();
|
||||
|
||||
// 查询数据库,拿到一级评论最早回复的那条评论
|
||||
CommentDO earliestCommentDO = commentDOMapper.selectEarliestByParentId(needUpdateCommentId);
|
||||
|
||||
if (Objects.nonNull(earliestCommentDO)) {
|
||||
// 最早回复的那条评论 ID
|
||||
Long earliestCommentId = earliestCommentDO.getId();
|
||||
|
||||
// 更新其一级评论的 first_reply_comment_id
|
||||
commentDOMapper.updateFirstReplyCommentIdByPrimaryKey(earliestCommentId, needUpdateCommentId);
|
||||
|
||||
// 异步同步到 Redis
|
||||
threadPoolTaskExecutor.submit(() -> sync2Redis(Lists.newArrayList(needUpdateCommentId)));
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步将 first_reply_comment_id 不为 0 的一级评论 ID, 同步到 redis 中
|
||||
*
|
||||
* @param needSyncCommentIds 需要同步的评论 ID
|
||||
*/
|
||||
private void sync2Redis(List<Long> needSyncCommentIds) {
|
||||
// 获取 ValueOperations
|
||||
ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();
|
||||
|
||||
// 使用 RedisTemplate 的管道模式,允许在一个操作中批量发送多个命令,防止频繁操作 Redis
|
||||
redisTemplate.executePipelined((RedisCallback<?>) (connection) -> {
|
||||
needSyncCommentIds.forEach(needSyncCommentId -> {
|
||||
// 构建 Redis Key
|
||||
String key = RedisKeyConstants.buildHaveFirstReplyCommentKey(needSyncCommentId);
|
||||
|
||||
// 批量设置值并指定过期时间(5小时以内)
|
||||
valueOperations.set(key, 1, RandomUtil.randomInt(5 * 60 * 60), TimeUnit.SECONDS);
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.hanserwei.hannote.comment.biz.controller;
|
||||
|
||||
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
|
||||
import com.hanserwei.framework.common.response.PageResponse;
|
||||
import com.hanserwei.framework.common.response.Response;
|
||||
import com.hanserwei.hannote.comment.biz.model.vo.PublishCommentReqVO;
|
||||
import com.hanserwei.hannote.comment.biz.model.vo.*;
|
||||
import com.hanserwei.hannote.comment.biz.service.CommentService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -26,4 +27,34 @@ public class CommentController {
|
||||
return commentService.publishComment(publishCommentReqVO);
|
||||
}
|
||||
|
||||
@PostMapping("/list")
|
||||
@ApiOperationLog(description = "评论分页查询")
|
||||
public PageResponse<FindCommentItemRspVO> findCommentPageList(@Validated @RequestBody FindCommentPageListReqVO findCommentPageListReqVO) {
|
||||
return commentService.findCommentPageList(findCommentPageListReqVO);
|
||||
}
|
||||
|
||||
@PostMapping("/child/list")
|
||||
@ApiOperationLog(description = "二级评论分页查询")
|
||||
public PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(@Validated @RequestBody FindChildCommentPageListReqVO findChildCommentPageListReqVO) {
|
||||
return commentService.findChildCommentPageList(findChildCommentPageListReqVO);
|
||||
}
|
||||
|
||||
@PostMapping("/like")
|
||||
@ApiOperationLog(description = "评论点赞")
|
||||
public Response<?> likeComment(@Validated @RequestBody LikeCommentReqVO likeCommentReqVO) {
|
||||
return commentService.likeComment(likeCommentReqVO);
|
||||
}
|
||||
|
||||
@PostMapping("/unlike")
|
||||
@ApiOperationLog(description = "评论取消点赞")
|
||||
public Response<?> unlikeComment(@Validated @RequestBody UnLikeCommentReqVO unLikeCommentReqVO) {
|
||||
return commentService.unlikeComment(unLikeCommentReqVO);
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
@ApiOperationLog(description = "删除评论")
|
||||
public Response<?> deleteComment(@Validated @RequestBody DeleteCommentReqVO deleteCommentReqVO) {
|
||||
return commentService.deleteComment(deleteCommentReqVO);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -109,4 +109,22 @@ public class CommentDO {
|
||||
*/
|
||||
@TableField(value = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/**
|
||||
* 二级评论总数(只有一级评论才需要统计)
|
||||
*/
|
||||
@TableField(value = "child_comment_total")
|
||||
private Long childCommentTotal;
|
||||
|
||||
/**
|
||||
* 评论热度
|
||||
*/
|
||||
@TableField(value = "heat")
|
||||
private Double heat;
|
||||
|
||||
/**
|
||||
* 最早回复的评论ID (只有一级评论需要)
|
||||
*/
|
||||
@TableField(value = "first_reply_comment_id")
|
||||
private Long firstReplyCommentId;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.hanserwei.hannote.comment.biz.domain.dataobject;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 笔记计数表
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@TableName(value = "t_note_count")
|
||||
public class NoteCountDO {
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 笔记ID
|
||||
*/
|
||||
@TableField(value = "note_id")
|
||||
private Long noteId;
|
||||
|
||||
/**
|
||||
* 获得点赞总数
|
||||
*/
|
||||
@TableField(value = "like_total")
|
||||
private Long likeTotal;
|
||||
|
||||
/**
|
||||
* 获得收藏总数
|
||||
*/
|
||||
@TableField(value = "collect_total")
|
||||
private Long collectTotal;
|
||||
|
||||
/**
|
||||
* 被评论总数
|
||||
*/
|
||||
@TableField(value = "comment_total")
|
||||
private Long commentTotal;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.hanserwei.hannote.comment.biz.domain.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
|
||||
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
|
||||
import com.hanserwei.hannote.comment.biz.model.bo.CommentHeatBO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@@ -10,7 +11,6 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CommentDOMapper extends BaseMapper<CommentDO> {
|
||||
|
||||
/**
|
||||
* 根据评论 ID 批量查询
|
||||
*
|
||||
@@ -26,4 +26,124 @@ public interface CommentDOMapper extends BaseMapper<CommentDO> {
|
||||
* @return 插入数量
|
||||
*/
|
||||
int batchInsert(@Param("comments") List<CommentBO> comments);
|
||||
|
||||
/**
|
||||
* 批量更新热度值
|
||||
*
|
||||
* @param commentIds 评论 ID 列表
|
||||
* @param commentHeatBOS 热度值列表
|
||||
* @return 更新数量
|
||||
*/
|
||||
int batchUpdateHeatByCommentIds(@Param("commentIds") List<Long> commentIds,
|
||||
@Param("commentHeatBOS") List<CommentHeatBO> commentHeatBOS);
|
||||
|
||||
/**
|
||||
* 查询一级评论下最早回复的评论
|
||||
*
|
||||
* @param parentId 一级评论 ID
|
||||
* @return 一级评论下最早回复的评论
|
||||
*/
|
||||
CommentDO selectEarliestByParentId(Long parentId);
|
||||
|
||||
/**
|
||||
* 更新一级评论的 first_reply_comment_id
|
||||
*
|
||||
* @param firstReplyCommentId 一级评论下最早回复的评论 ID
|
||||
* @param id 一级评论 ID
|
||||
* @return 更新数量
|
||||
*/
|
||||
int updateFirstReplyCommentIdByPrimaryKey(@Param("firstReplyCommentId") Long firstReplyCommentId,
|
||||
@Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 查询评论分页数据
|
||||
*
|
||||
* @param noteId 笔记 ID
|
||||
* @param offset 偏移量
|
||||
* @param pageSize 页大小
|
||||
* @return 评论分页数据
|
||||
*/
|
||||
List<CommentDO> selectPageList(@Param("noteId") Long noteId,
|
||||
@Param("offset") long offset,
|
||||
@Param("pageSize") long pageSize);
|
||||
|
||||
/**
|
||||
* 批量查询二级评论
|
||||
*
|
||||
* @param commentIds 评论 ID 列表
|
||||
* @return 二级评论
|
||||
*/
|
||||
List<CommentDO> selectTwoLevelCommentByIds(@Param("commentIds") List<Long> commentIds);
|
||||
|
||||
/**
|
||||
* 查询热门评论
|
||||
*
|
||||
* @param noteId 笔记 ID
|
||||
* @return 热门评论
|
||||
*/
|
||||
List<CommentDO> selectHeatComments(Long noteId);
|
||||
|
||||
/**
|
||||
* 查询一级评论下子评论总数
|
||||
*
|
||||
* @param commentId 一级评论 ID
|
||||
* @return 一级评论下子评论总数
|
||||
*/
|
||||
Long selectChildCommentTotalById(Long commentId);
|
||||
|
||||
/**
|
||||
* 查询二级评论分页数据
|
||||
*
|
||||
* @param parentId 一级评论 ID
|
||||
* @param offset 偏移量
|
||||
* @param pageSize 页大小
|
||||
* @return 二级评论分页数据
|
||||
*/
|
||||
List<CommentDO> selectChildPageList(@Param("parentId") Long parentId,
|
||||
@Param("offset") long offset,
|
||||
@Param("pageSize") long pageSize);
|
||||
|
||||
/**
|
||||
* 批量查询计数数据
|
||||
*
|
||||
* @param commentIds 评论 ID 列表
|
||||
* @return 计数数据
|
||||
*/
|
||||
List<CommentDO> selectCommentCountByIds(@Param("commentIds") List<Long> commentIds);
|
||||
|
||||
/**
|
||||
* 查询子评论
|
||||
*
|
||||
* @param parentId 一级评论 ID
|
||||
* @param limit 子评论数量限制
|
||||
* @return 子评论
|
||||
*/
|
||||
List<CommentDO> selectChildCommentsByParentIdAndLimit(@Param("parentId") Long parentId,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* 删除一级评论下,所有二级评论
|
||||
*
|
||||
* @param commentId 一级评论 ID
|
||||
* @return 删除数量
|
||||
*/
|
||||
int deleteByParentId(Long commentId);
|
||||
|
||||
/**
|
||||
* 批量删除评论
|
||||
*
|
||||
* @param commentIds 评论 ID 列表
|
||||
* @return 删除数量
|
||||
*/
|
||||
int deleteByIds(@Param("commentIds") List<Long> commentIds);
|
||||
|
||||
|
||||
/**
|
||||
* 根据 reply_comment_id 查询
|
||||
*
|
||||
* @param commentId 回复的评论 ID
|
||||
* @return 评论
|
||||
*/
|
||||
CommentDO selectByReplyCommentId(Long commentId);
|
||||
|
||||
}
|
||||
@@ -2,8 +2,46 @@ package com.hanserwei.hannote.comment.biz.domain.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO;
|
||||
import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CommentLikeDOMapper extends BaseMapper<CommentLikeDO> {
|
||||
|
||||
/**
|
||||
* 查询某个评论是否被点赞
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @param commentId 评论 ID
|
||||
* @return 1 表示已点赞,0 表示未点赞
|
||||
*/
|
||||
int selectCountByUserIdAndCommentId(@Param("userId") Long userId,
|
||||
@Param("commentId") Long commentId);
|
||||
|
||||
/**
|
||||
* 查询对应用户点赞的所有评论
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @return 评论点赞列表
|
||||
*/
|
||||
List<CommentLikeDO> selectByUserId(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 批量删除点赞记录
|
||||
*
|
||||
* @param unlikes 删除点赞记录
|
||||
* @return 删除数量
|
||||
*/
|
||||
int batchDelete(@Param("unlikes") List<LikeUnlikeCommentMqDTO> unlikes);
|
||||
|
||||
/**
|
||||
* 批量添加点赞记录
|
||||
*
|
||||
* @param likes 添加点赞记录
|
||||
* @return 添加数量
|
||||
*/
|
||||
int batchInsert(@Param("likes") List<LikeUnlikeCommentMqDTO> likes);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.hanserwei.hannote.comment.biz.domain.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hanserwei.hannote.comment.biz.domain.dataobject.NoteCountDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
|
||||
|
||||
/**
|
||||
* 查询笔记评论总数
|
||||
*
|
||||
* @param noteId 笔记ID
|
||||
* @return 笔记评论总数
|
||||
*/
|
||||
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.Getter;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum CommentLevelEnum {
|
||||
@@ -14,4 +16,18 @@ public enum CommentLevelEnum {
|
||||
|
||||
private final Integer code;
|
||||
|
||||
/**
|
||||
* 根据类型 code 获取对应的枚举
|
||||
*
|
||||
* @param code 类型 code
|
||||
* @return 枚举
|
||||
*/
|
||||
public static CommentLevelEnum valueOf(Integer code) {
|
||||
for (CommentLevelEnum commentLevelEnum : CommentLevelEnum.values()) {
|
||||
if (Objects.equals(code, commentLevelEnum.getCode())) {
|
||||
return commentLevelEnum;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -13,6 +13,11 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
|
||||
PARAM_NOT_VALID("COMMENT-10001", "参数错误"),
|
||||
|
||||
// ----------- 业务异常状态码 -----------
|
||||
COMMENT_NOT_FOUND("COMMENT-20001", "此评论不存在"),
|
||||
PARENT_COMMENT_NOT_FOUND("COMMENT-20000", "此父评论不存在"),
|
||||
COMMENT_ALREADY_LIKED("COMMENT-20002", "您已经点赞过该评论"),
|
||||
COMMENT_NOT_LIKED("COMMENT-20003", "您未点赞该评论,无法取消点赞"),
|
||||
COMMENT_CANT_OPERATE("COMMENT-20004", "您无法操作该评论"),
|
||||
;
|
||||
|
||||
// 异常码
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.hanserwei.hannote.comment.biz.model.bo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class CommentHeatBO {
|
||||
/**
|
||||
* 评论 ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 热度值
|
||||
*/
|
||||
private Double heat;
|
||||
|
||||
/**
|
||||
* 笔记 ID
|
||||
*/
|
||||
private Long noteId;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hanserwei.hannote.comment.biz.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class CountPublishCommentMqDTO {
|
||||
|
||||
/**
|
||||
* 笔记 ID
|
||||
*/
|
||||
private Long noteId;
|
||||
|
||||
/**
|
||||
* 评论 ID
|
||||
*/
|
||||
private Long commentId;
|
||||
|
||||
/**
|
||||
* 评论级别
|
||||
*/
|
||||
private Integer level;
|
||||
|
||||
/**
|
||||
* 父 ID
|
||||
*/
|
||||
private Long parentId;
|
||||
|
||||
}
|
||||
@@ -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,63 @@
|
||||
package com.hanserwei.hannote.comment.biz.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindChildCommentItemRspVO {
|
||||
|
||||
/**
|
||||
* 评论 ID
|
||||
*/
|
||||
private Long commentId;
|
||||
|
||||
/**
|
||||
* 发布者用户 ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 头像
|
||||
*/
|
||||
private String avatar;
|
||||
|
||||
/**
|
||||
* 昵称
|
||||
*/
|
||||
private String nickname;
|
||||
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
private String imageUrl;
|
||||
|
||||
/**
|
||||
* 发布时间
|
||||
*/
|
||||
private String createTime;
|
||||
|
||||
/**
|
||||
* 被点赞数
|
||||
*/
|
||||
private Long likeTotal;
|
||||
|
||||
/**
|
||||
* 回复的用户昵称
|
||||
*/
|
||||
private String replyUserName;
|
||||
|
||||
/**
|
||||
* 回复的用户 ID
|
||||
*/
|
||||
private Long replyUserId;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.hanserwei.hannote.comment.biz.model.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindChildCommentPageListReqVO {
|
||||
|
||||
@NotNull(message = "父评论 ID 不能为空")
|
||||
private Long parentCommentId;
|
||||
|
||||
@NotNull(message = "页码不能为空")
|
||||
private Integer pageNo = 1;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.hanserwei.hannote.comment.biz.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindCommentItemRspVO {
|
||||
|
||||
/**
|
||||
* 评论 ID
|
||||
*/
|
||||
private Long commentId;
|
||||
|
||||
/**
|
||||
* 发布者用户 ID
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 头像
|
||||
*/
|
||||
private String avatar;
|
||||
|
||||
/**
|
||||
* 昵称
|
||||
*/
|
||||
private String nickname;
|
||||
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
private String imageUrl;
|
||||
|
||||
/**
|
||||
* 发布时间
|
||||
*/
|
||||
private String createTime;
|
||||
|
||||
/**
|
||||
* 被点赞数
|
||||
*/
|
||||
private Long likeTotal;
|
||||
|
||||
/**
|
||||
* 二级评论总数
|
||||
*/
|
||||
private Long childCommentTotal;
|
||||
|
||||
/**
|
||||
* 最早回复的评论
|
||||
*/
|
||||
private FindCommentItemRspVO firstReplyComment;
|
||||
|
||||
/**
|
||||
* 热度值
|
||||
*/
|
||||
private Double heat;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.hanserwei.hannote.comment.biz.model.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindCommentPageListReqVO {
|
||||
|
||||
@NotNull(message = "笔记 ID 不能为空")
|
||||
private Long noteId;
|
||||
|
||||
@NotNull(message = "页码不能为空")
|
||||
private Integer pageNo = 1;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
package com.hanserwei.hannote.comment.biz.rpc;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.hanserwei.framework.common.constant.DateConstants;
|
||||
import com.hanserwei.framework.common.response.Response;
|
||||
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
|
||||
import com.hanserwei.hannote.kv.api.KeyValueFeignApi;
|
||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.CommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.*;
|
||||
import com.hanserwei.hannote.kv.dto.resp.FindCommentContentRspDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
public class KeyValueRpcService {
|
||||
@@ -54,4 +57,51 @@ public class KeyValueRpcService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询评论内容
|
||||
*
|
||||
* @param noteId 笔记ID
|
||||
* @param findCommentContentReqDTOS 查询参数
|
||||
* @return 批量查询结果
|
||||
*/
|
||||
public List<FindCommentContentRspDTO> batchFindCommentContent(Long noteId, List<FindCommentContentReqDTO> findCommentContentReqDTOS) {
|
||||
BatchFindCommentContentReqDTO bathFindCommentContentReqDTO = BatchFindCommentContentReqDTO.builder()
|
||||
.noteId(noteId)
|
||||
.commentContentKeys(findCommentContentReqDTOS)
|
||||
.build();
|
||||
|
||||
Response<List<FindCommentContentRspDTO>> response = keyValueFeignApi.batchFindCommentContent(bathFindCommentContentReqDTO);
|
||||
|
||||
if (!response.isSuccess() || Objects.isNull(response.getData()) || CollUtil.isEmpty(response.getData())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.hanserwei.hannote.comment.biz.rpc;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.hanserwei.framework.common.response.Response;
|
||||
import com.hanserwei.hannote.user.api.UserFeignApi;
|
||||
import com.hanserwei.hannote.user.dto.req.FindUsersByIdsReqDTO;
|
||||
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class UserRpcService {
|
||||
|
||||
@Resource
|
||||
private UserFeignApi userFeignApi;
|
||||
|
||||
/**
|
||||
* 批量查询用户信息
|
||||
*
|
||||
* @param userIds 用户 ID集合
|
||||
* @return 用户信息集合
|
||||
*/
|
||||
public List<FindUserByIdRspDTO> findByIds(List<Long> userIds) {
|
||||
if (CollUtil.isEmpty(userIds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
FindUsersByIdsReqDTO findUsersByIdsReqDTO = new FindUsersByIdsReqDTO();
|
||||
// 去重, 并设置用户 ID 集合
|
||||
findUsersByIdsReqDTO.setIds(userIds.stream().distinct().collect(Collectors.toList()));
|
||||
|
||||
Response<List<FindUserByIdRspDTO>> response = userFeignApi.findByIds(findUsersByIdsReqDTO);
|
||||
|
||||
if (!response.isSuccess() || Objects.isNull(response.getData()) || CollUtil.isEmpty(response.getData())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.getData();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.hanserwei.hannote.comment.biz.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.hanserwei.framework.common.response.PageResponse;
|
||||
import com.hanserwei.framework.common.response.Response;
|
||||
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
|
||||
import com.hanserwei.hannote.comment.biz.model.vo.PublishCommentReqVO;
|
||||
import com.hanserwei.hannote.comment.biz.model.vo.*;
|
||||
|
||||
public interface CommentService extends IService<CommentDO> {
|
||||
/**
|
||||
@@ -13,4 +14,51 @@ public interface CommentService extends IService<CommentDO> {
|
||||
* @return 响应
|
||||
*/
|
||||
Response<?> publishComment(PublishCommentReqVO publishCommentReqVO);
|
||||
|
||||
/**
|
||||
* 评论列表分页查询
|
||||
*
|
||||
* @param findCommentPageListReqVO 评论列表分页查询参数
|
||||
* @return 响应
|
||||
*/
|
||||
PageResponse<FindCommentItemRspVO> findCommentPageList(FindCommentPageListReqVO findCommentPageListReqVO);
|
||||
|
||||
/**
|
||||
* 二级评论分页查询
|
||||
*
|
||||
* @param findChildCommentPageListReqVO 二级评论分页查询参数
|
||||
* @return 响应
|
||||
*/
|
||||
PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(FindChildCommentPageListReqVO findChildCommentPageListReqVO);
|
||||
|
||||
/**
|
||||
* 评论点赞
|
||||
*
|
||||
* @param likeCommentReqVO 评论点赞请求
|
||||
* @return 响应
|
||||
*/
|
||||
Response<?> likeComment(LikeCommentReqVO likeCommentReqVO);
|
||||
|
||||
/**
|
||||
* 取消评论点赞
|
||||
*
|
||||
* @param unLikeCommentReqVO 取消评论点赞请求
|
||||
* @return 响应
|
||||
*/
|
||||
Response<?> unlikeComment(UnLikeCommentReqVO unLikeCommentReqVO);
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*
|
||||
* @param deleteCommentReqVO 删除评论请求
|
||||
* @return 响应
|
||||
*/
|
||||
Response<?> deleteComment(DeleteCommentReqVO deleteCommentReqVO);
|
||||
|
||||
/**
|
||||
* 删除本地评论缓存
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
*/
|
||||
void deleteCommentLocalCache(Long commentId);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
package com.hanserwei.hannote.comment.biz.utils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
public class HeatCalculator {
|
||||
|
||||
// 热度计算的权重配置
|
||||
private static final double LIKE_WEIGHT = 0.7; // 点赞权重 70%
|
||||
private static final double REPLY_WEIGHT = 0.3; // 回复权重 30%
|
||||
|
||||
public static BigDecimal calculateHeat(long likeCount, long replyCount) {
|
||||
// 点赞数权重 70%,被回复数权重 30%
|
||||
BigDecimal likeWeight = new BigDecimal(LIKE_WEIGHT);
|
||||
BigDecimal replyWeight = new BigDecimal(REPLY_WEIGHT);
|
||||
|
||||
// 转换点赞数和回复数为 BigDecimal
|
||||
BigDecimal likeCountBD = new BigDecimal(likeCount);
|
||||
BigDecimal replyCountBD = new BigDecimal(replyCount);
|
||||
|
||||
// 计算热度 (点赞数*点赞权重 + 回复数*回复权重)
|
||||
BigDecimal heat = likeCountBD.multiply(likeWeight).add(replyCountBD.multiply(replyWeight));
|
||||
|
||||
// 四舍五入保留两位小数
|
||||
return heat.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
int likeCount = 150; // 点赞数
|
||||
int replyCount = 10; // 被回复数
|
||||
|
||||
// 计算热度
|
||||
BigDecimal heat = calculateHeat(likeCount, replyCount);
|
||||
|
||||
// 输出热度值
|
||||
System.out.println("Calculated Heat: " + heat);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 操作的 Key
|
||||
local zsetKey = KEYS[1]
|
||||
-- 获取传入的成员和分数列表
|
||||
local membersScores = ARGV
|
||||
-- ZSet 最多缓存 500 条评论
|
||||
local sizeLimit = 500
|
||||
|
||||
-- 检查 ZSet 是否存在
|
||||
if redis.call('EXISTS', zsetKey) == 0 then
|
||||
return -1 -- 若不存在,直接 return
|
||||
end
|
||||
|
||||
-- 获取当前 ZSet 的大小
|
||||
local currentSize = redis.call('ZCARD', zsetKey)
|
||||
|
||||
-- 遍历传入的成员和分数,添加到 ZSet 中
|
||||
for i = 1, #membersScores, 2 do
|
||||
-- 评论 ID
|
||||
local member = membersScores[i]
|
||||
-- 热度值
|
||||
local score = membersScores[i + 1]
|
||||
|
||||
-- 检查当前 ZSet 的大小是否小于 500 条
|
||||
if currentSize < sizeLimit then
|
||||
-- 若是,则添加缓存
|
||||
redis.call('ZADD', zsetKey, score, member)
|
||||
currentSize = currentSize + 1 -- 更新 ZSet 大小
|
||||
else
|
||||
break -- 否则,则达到最大限制,停止添加
|
||||
end
|
||||
end
|
||||
|
||||
return 0
|
||||
@@ -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)
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 入参说明:
|
||||
-- KEYS[1]: ZSet 的键
|
||||
-- ARGV: 每个评论的数据,格式为 member1, score1, member2, score2 ...
|
||||
|
||||
local zsetKey = KEYS[1]
|
||||
local maxSize = 500 -- 最多缓存 500 条热点评论
|
||||
local batchSize = #ARGV / 2 -- 有多少条评论
|
||||
|
||||
-- 确认 ZSet 是否存在
|
||||
if redis.call("EXISTS", zsetKey) == 0 then
|
||||
return -1 -- 如果 ZSet 不存在,直接返回
|
||||
end
|
||||
|
||||
for i = 1, batchSize do
|
||||
local member = ARGV[(i - 1) * 2 + 1] -- 获取当前评论 ID
|
||||
local score = ARGV[(i - 1) * 2 + 2] -- 获取当前评论的热度
|
||||
|
||||
-- 获取 ZSet 的大小
|
||||
local currentSize = redis.call("ZCARD", zsetKey)
|
||||
|
||||
if currentSize < maxSize then
|
||||
-- 如果 ZSet 的大小小于 maxSize,直接添加
|
||||
redis.call("ZADD", zsetKey, score, member)
|
||||
else
|
||||
-- 若已缓存 500 条热点评论
|
||||
-- 获取当前 ZSet 中热度值最小的评论
|
||||
local minEntry = redis.call("ZRANGE", zsetKey, 0, 0, "WITHSCORES")
|
||||
-- 热度最小评论的值
|
||||
local minScore = minEntry[2]
|
||||
|
||||
if score > minScore then
|
||||
-- 如果当前评论的热度大于最小热度,替换掉最小的;否则无视
|
||||
redis.call("ZREM", zsetKey, minEntry[1])
|
||||
redis.call("ZADD", zsetKey, score, member)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return 0
|
||||
|
||||
@@ -19,18 +19,47 @@
|
||||
<result column="is_top" jdbcType="TINYINT" property="isTop" />
|
||||
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
|
||||
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime" />
|
||||
<result column="child_comment_total" jdbcType="BIGINT" property="childCommentTotal"/>
|
||||
<result column="heat" jdbcType="DOUBLE" property="heat"/>
|
||||
<result column="first_reply_comment_id" jdbcType="BIGINT" property="firstReplyCommentId"/>
|
||||
</resultMap>
|
||||
<sql id="Base_Column_List">
|
||||
<!--@mbg.generated-->
|
||||
id, note_id, user_id, content_uuid, is_content_empty, image_url, `level`, reply_total,
|
||||
like_total, parent_id, reply_comment_id, reply_user_id, is_top, create_time, update_time
|
||||
id,
|
||||
note_id,
|
||||
user_id,
|
||||
content_uuid,
|
||||
is_content_empty,
|
||||
image_url,
|
||||
`level`,
|
||||
reply_total,
|
||||
like_total,
|
||||
parent_id,
|
||||
reply_comment_id,
|
||||
reply_user_id,
|
||||
is_top,
|
||||
create_time,
|
||||
update_time,
|
||||
child_comment_total,
|
||||
heat,
|
||||
first_reply_comment_id
|
||||
</sql>
|
||||
|
||||
<select id="selectByCommentIds" resultMap="BaseResultMap" parameterType="list">
|
||||
select id,
|
||||
user_id,
|
||||
content_uuid,
|
||||
is_content_empty,
|
||||
image_url,
|
||||
like_total,
|
||||
is_top,
|
||||
create_time,
|
||||
first_reply_comment_id,
|
||||
child_comment_total,
|
||||
level,
|
||||
parent_id,
|
||||
user_id
|
||||
heat,
|
||||
note_id
|
||||
from t_comment
|
||||
where id in
|
||||
<foreach collection="commentIds" open="(" separator="," close=")" item="commentId">
|
||||
@@ -38,6 +67,7 @@
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
|
||||
<insert id="batchInsert" parameterType="list">
|
||||
insert IGNORE into t_comment (id, note_id, user_id,
|
||||
content_uuid, is_content_empty, image_url,
|
||||
@@ -52,4 +82,145 @@
|
||||
, #{comment.updateTime})
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
<update id="batchUpdateHeatByCommentIds" parameterType="map">
|
||||
UPDATE t_comment
|
||||
SET heat = CASE id
|
||||
<foreach collection="commentHeatBOS" item="bo" separator="">
|
||||
WHEN #{bo.id} THEN #{bo.heat}
|
||||
</foreach>
|
||||
ELSE heat END
|
||||
WHERE id IN
|
||||
<foreach close=")" collection="commentIds" item="commentId" open="(" separator=",">
|
||||
#{commentId}
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
<select id="selectEarliestByParentId" parameterType="map" resultMap="BaseResultMap">
|
||||
select id
|
||||
from t_comment
|
||||
where parent_id = #{parentId}
|
||||
and level = 2
|
||||
order by create_time
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<update id="updateFirstReplyCommentIdByPrimaryKey" parameterType="map">
|
||||
update t_comment
|
||||
set first_reply_comment_id = #{firstReplyCommentId}
|
||||
where id = #{id}
|
||||
</update>
|
||||
|
||||
<select id="selectPageList" resultMap="BaseResultMap" parameterType="map">
|
||||
select id,
|
||||
user_id,
|
||||
content_uuid,
|
||||
is_content_empty,
|
||||
image_url,
|
||||
like_total,
|
||||
is_top,
|
||||
create_time,
|
||||
first_reply_comment_id,
|
||||
child_comment_total,
|
||||
heat
|
||||
from t_comment
|
||||
where note_id = #{noteId}
|
||||
and level = 1
|
||||
order by heat desc
|
||||
limit #{offset}, #{pageSize}
|
||||
</select>
|
||||
|
||||
<select id="selectTwoLevelCommentByIds" resultMap="BaseResultMap" parameterType="list">
|
||||
select id,
|
||||
user_id,
|
||||
content_uuid,
|
||||
is_content_empty,
|
||||
image_url,
|
||||
like_total,
|
||||
create_time,
|
||||
heat
|
||||
from t_comment
|
||||
where id in
|
||||
<foreach collection="commentIds" open="(" separator="," close=")" item="commentId">
|
||||
#{commentId}
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
<select id="selectHeatComments" resultMap="BaseResultMap">
|
||||
select id, heat
|
||||
from t_comment
|
||||
where note_id = #{noteId}
|
||||
and level = 1
|
||||
order by heat desc
|
||||
limit 500
|
||||
</select>
|
||||
|
||||
<select id="selectChildCommentTotalById" resultType="long">
|
||||
select child_comment_total
|
||||
from t_comment
|
||||
where id = #{commentId}
|
||||
and level = 1
|
||||
</select>
|
||||
|
||||
<select id="selectChildPageList" resultMap="BaseResultMap" parameterType="map">
|
||||
select id,
|
||||
user_id,
|
||||
note_id,
|
||||
content_uuid,
|
||||
is_content_empty,
|
||||
image_url,
|
||||
like_total,
|
||||
create_time,
|
||||
reply_user_id,
|
||||
parent_id,
|
||||
reply_comment_id
|
||||
from t_comment
|
||||
where parent_id = #{parentId}
|
||||
and level = 2
|
||||
order by id
|
||||
limit #{offset}, #{pageSize}
|
||||
</select>
|
||||
|
||||
<select id="selectCommentCountByIds" resultMap="BaseResultMap" parameterType="list">
|
||||
select id,
|
||||
child_comment_total,
|
||||
like_total,
|
||||
level
|
||||
from t_comment
|
||||
where id in
|
||||
<foreach collection="commentIds" open="(" separator="," close=")" item="commentId">
|
||||
#{commentId}
|
||||
</foreach>
|
||||
</select>
|
||||
|
||||
<select id="selectChildCommentsByParentIdAndLimit" resultMap="BaseResultMap" parameterType="map">
|
||||
select id, create_time
|
||||
from t_comment
|
||||
where parent_id = #{parentId}
|
||||
and level = 2
|
||||
order by create_time
|
||||
limit #{limit}
|
||||
</select>
|
||||
|
||||
<delete id="deleteByParentId" parameterType="long">
|
||||
delete
|
||||
from t_comment
|
||||
where parent_id = #{commentId}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteByIds" parameterType="map">
|
||||
delete
|
||||
from t_comment
|
||||
where id in
|
||||
<foreach collection="commentIds" item="commentId" open="(" separator="," close=")">
|
||||
#{commentId}
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
<select id="selectByReplyCommentId" resultMap="BaseResultMap" parameterType="long">
|
||||
select
|
||||
<include refid="Base_Column_List"/>
|
||||
from t_comment
|
||||
where reply_comment_id = #{commentId}
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -13,4 +13,36 @@
|
||||
<!--@mbg.generated-->
|
||||
id, user_id, comment_id, create_time
|
||||
</sql>
|
||||
|
||||
<select id="selectCountByUserIdAndCommentId" resultType="int" parameterType="map">
|
||||
select count(1)
|
||||
from t_comment_like
|
||||
where user_id = #{userId}
|
||||
and comment_id = #{commentId}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectByUserId" resultMap="BaseResultMap" parameterType="map">
|
||||
select comment_id
|
||||
from t_comment_like
|
||||
where user_id = #{userId}
|
||||
</select>
|
||||
|
||||
<delete id="batchDelete" parameterType="map">
|
||||
DELETE
|
||||
FROM t_comment_like
|
||||
WHERE (comment_id, user_id) IN
|
||||
<foreach collection="unlikes" item="unlike" open="(" separator="," close=")">
|
||||
(#{unlike.commentId}, #{unlike.userId})
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
<insert id="batchInsert" parameterType="list">
|
||||
INSERT INTO t_comment_like (comment_id, user_id, create_time)
|
||||
VALUES
|
||||
<foreach collection="likes" item="like" separator=",">
|
||||
(#{like.commentId}, #{like.userId}, #{like.createTime})
|
||||
</foreach>
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
</insert>
|
||||
</mapper>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.hanserwei.hannote.comment.biz.domain.mapper.NoteCountDOMapper">
|
||||
<resultMap id="BaseResultMap" type="com.hanserwei.hannote.comment.biz.domain.dataobject.NoteCountDO">
|
||||
<!--@mbg.generated-->
|
||||
<!--@Table t_note_count-->
|
||||
<id column="id" jdbcType="BIGINT" property="id"/>
|
||||
<result column="note_id" jdbcType="BIGINT" property="noteId"/>
|
||||
<result column="like_total" jdbcType="BIGINT" property="likeTotal"/>
|
||||
<result column="collect_total" jdbcType="BIGINT" property="collectTotal"/>
|
||||
<result column="comment_total" jdbcType="BIGINT" property="commentTotal"/>
|
||||
</resultMap>
|
||||
<sql id="Base_Column_List">
|
||||
<!--@mbg.generated-->
|
||||
id, note_id, like_total, collect_total, comment_total
|
||||
</sql>
|
||||
|
||||
<select id="selectCommentTotalByNoteId" resultType="long">
|
||||
select comment_total
|
||||
from t_note_count
|
||||
where note_id = #{noteId}
|
||||
</select>
|
||||
|
||||
<update id="updateCommentTotalByNoteId" parameterType="map">
|
||||
update t_note_count
|
||||
set comment_total = comment_total + #{count}
|
||||
where note_id = #{noteId}
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -2,6 +2,26 @@ package com.hanserwei.hannote.count.biz.constant;
|
||||
|
||||
public interface MQConstants {
|
||||
|
||||
/**
|
||||
* Topic: 笔记评论总数计数
|
||||
*/
|
||||
String TOPIC_COUNT_NOTE_COMMENT = "CountNoteCommentTopic";
|
||||
|
||||
/**
|
||||
* Topic: 评论热度值更新
|
||||
*/
|
||||
String TOPIC_COMMENT_HEAT_UPDATE = "CommentHeatUpdateTopic";
|
||||
|
||||
/**
|
||||
* Topic: 计数 - 笔记点赞数
|
||||
*/
|
||||
String TOPIC_LIKE_OR_UNLIKE = "LikeUnlikeTopic";
|
||||
|
||||
/**
|
||||
* Topic: 笔记收藏、取消收藏
|
||||
*/
|
||||
String TOPIC_COLLECT_OR_UN_COLLECT = "CollectUnCollectTopic";
|
||||
|
||||
/**
|
||||
* Topic: 关注数计数
|
||||
*/
|
||||
@@ -22,21 +42,11 @@ public interface MQConstants {
|
||||
*/
|
||||
String TOPIC_COUNT_FOLLOWING_2_DB = "CountFollowing2DBTopic";
|
||||
|
||||
/**
|
||||
* Topic: 计数 - 笔记点赞数
|
||||
*/
|
||||
String TOPIC_COUNT_NOTE_LIKE = "CountNoteLikeTopic";
|
||||
|
||||
/**
|
||||
* Topic: 计数 - 笔记点赞数落库
|
||||
*/
|
||||
String TOPIC_COUNT_NOTE_LIKE_2_DB = "CountNoteLike2DBTTopic";
|
||||
|
||||
/**
|
||||
* Topic: 计数 - 笔记收藏数
|
||||
*/
|
||||
String TOPIC_COUNT_NOTE_COLLECT = "CountNoteCollectTopic";
|
||||
|
||||
/**
|
||||
* Topic: 计数 - 笔记收藏数落库
|
||||
*/
|
||||
@@ -47,6 +57,16 @@ public interface MQConstants {
|
||||
*/
|
||||
String TOPIC_NOTE_OPERATE = "NoteOperateTopic";
|
||||
|
||||
/**
|
||||
* Topic: 评论点赞数更新
|
||||
*/
|
||||
String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic";
|
||||
|
||||
/**
|
||||
* Topic: 计数 - 评论点赞数落库
|
||||
*/
|
||||
String TOPIC_COUNT_COMMENT_LIKE_2_DB = "CountCommentLike2DBTTopic";
|
||||
|
||||
/**
|
||||
* Tag 标签:笔记发布
|
||||
*/
|
||||
|
||||
@@ -31,11 +31,35 @@ public class RedisKeyConstants {
|
||||
*/
|
||||
private static final String COUNT_NOTE_KEY_PREFIX = "count:note:";
|
||||
|
||||
/**
|
||||
* Hash Field: 笔记评论总数
|
||||
*/
|
||||
public static final String FIELD_COMMENT_TOTAL = "commentTotal";
|
||||
|
||||
/**
|
||||
* Hash Field: 笔记收藏总数
|
||||
*/
|
||||
public static final String FIELD_COLLECT_TOTAL = "collectTotal";
|
||||
|
||||
/**
|
||||
* Hash Field: 子评论总数
|
||||
*/
|
||||
public static final String FIELD_CHILD_COMMENT_TOTAL = "childCommentTotal";
|
||||
/**
|
||||
* 评论维度计数 Key 前缀
|
||||
*/
|
||||
private static final String COUNT_COMMENT_KEY_PREFIX = "count:comment:";
|
||||
|
||||
/**
|
||||
* 构建评论维度计数 Key
|
||||
*
|
||||
* @param commentId 评论ID
|
||||
* @return 评论维度计数 Key
|
||||
*/
|
||||
public static String buildCountCommentKey(Long commentId) {
|
||||
return COUNT_COMMENT_KEY_PREFIX + commentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建用户维度计数 Key
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.hanserwei.hannote.count.biz.consumer;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.github.phantomthief.collection.BufferTrigger;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.hanserwei.framework.common.utils.JsonUtils;
|
||||
import com.hanserwei.hannote.count.biz.constant.MQConstants;
|
||||
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
|
||||
import com.hanserwei.hannote.count.biz.domain.mapper.CommentDOMapper;
|
||||
import com.hanserwei.hannote.count.biz.enums.CommentLevelEnum;
|
||||
import com.hanserwei.hannote.count.biz.model.dto.CountPublishCommentMqDTO;
|
||||
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.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
@RocketMQMessageListener(consumerGroup = "han_note_group_child_comment_total" + MQConstants.TOPIC_COUNT_NOTE_COMMENT, // Group 组
|
||||
topic = MQConstants.TOPIC_COUNT_NOTE_COMMENT // 主题 Topic
|
||||
)
|
||||
@Slf4j
|
||||
public class CountNoteChildCommentConsumer implements RocketMQListener<String> {
|
||||
|
||||
@Resource
|
||||
private RocketMQTemplate rocketMQTemplate;
|
||||
|
||||
@Resource
|
||||
private CommentDOMapper commentDOMapper;
|
||||
|
||||
@Resource
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
|
||||
.bufferSize(50000) // 缓存队列的最大容量
|
||||
.batchSize(1000) // 一批次最多聚合 1000 条
|
||||
.linger(Duration.ofSeconds(1)) // 多久聚合一次(1s 一次)
|
||||
.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));
|
||||
|
||||
// 将聚合后的消息体 Json 转 List<CountPublishCommentMqDTO>
|
||||
List<CountPublishCommentMqDTO> countPublishCommentMqDTOList = Lists.newArrayList();
|
||||
bodys.forEach(body -> {
|
||||
try {
|
||||
List<CountPublishCommentMqDTO> list = JsonUtils.parseList(body, CountPublishCommentMqDTO.class);
|
||||
countPublishCommentMqDTOList.addAll(list);
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 过滤出二级评论,并按 parent_id 分组
|
||||
Map<Long, List<CountPublishCommentMqDTO>> groupMap = countPublishCommentMqDTOList.stream()
|
||||
.filter(commentMqDTO -> Objects.equals(CommentLevelEnum.TWO.getCode(), commentMqDTO.getLevel()))
|
||||
.collect(Collectors.groupingBy(CountPublishCommentMqDTO::getParentId)); // 按 parent_id 分组
|
||||
|
||||
// 若无二级评论,则直接 return
|
||||
if (CollUtil.isEmpty(groupMap)) return;
|
||||
|
||||
// 循环分组字典
|
||||
for (Map.Entry<Long, List<CountPublishCommentMqDTO>> entry : groupMap.entrySet()) {
|
||||
// 一级评论 ID
|
||||
Long parentId = entry.getKey();
|
||||
// 评论数
|
||||
int count = CollUtil.size(entry.getValue());
|
||||
|
||||
// 更新 Redis 缓存中的评论计数数据
|
||||
// 构建 Key
|
||||
String commentCountHashKey = RedisKeyConstants.buildCountCommentKey(parentId);
|
||||
// 判断 Hash 是否存在
|
||||
boolean hasKey = redisTemplate.hasKey(commentCountHashKey);
|
||||
|
||||
// 若 Hash 存在,则更新子评论总数
|
||||
if (hasKey) {
|
||||
// 累加
|
||||
redisTemplate.opsForHash()
|
||||
.increment(commentCountHashKey, RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, count);
|
||||
}
|
||||
|
||||
// 更新一级评论的下级评论总数,进行累加操作
|
||||
commentDOMapper.updateChildCommentTotal(parentId, count);
|
||||
}
|
||||
|
||||
// 获取字典中所用的评论ID
|
||||
Set<Long> commentIds = groupMap.keySet();
|
||||
|
||||
// 异步发送MQ消息计数,更新评论热度值
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,8 @@ import java.util.stream.Collectors;
|
||||
@Component
|
||||
@Slf4j
|
||||
@RocketMQMessageListener(
|
||||
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_NOTE_COLLECT,
|
||||
topic = MQConstants.TOPIC_COUNT_NOTE_COLLECT
|
||||
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COLLECT_OR_UN_COLLECT,
|
||||
topic = MQConstants.TOPIC_COLLECT_OR_UN_COLLECT
|
||||
)
|
||||
public class CountNoteCollectConsumer implements RocketMQListener<String> {
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.hanserwei.hannote.count.biz.consumer;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.github.phantomthief.collection.BufferTrigger;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.hanserwei.framework.common.utils.JsonUtils;
|
||||
import com.hanserwei.hannote.count.biz.constant.MQConstants;
|
||||
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
|
||||
import com.hanserwei.hannote.count.biz.domain.mapper.NoteCountDOMapper;
|
||||
import com.hanserwei.hannote.count.biz.model.dto.CountPublishCommentMqDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_NOTE_COMMENT, // Group 组
|
||||
topic = MQConstants.TOPIC_COUNT_NOTE_COMMENT // 主题 Topic
|
||||
)
|
||||
@Slf4j
|
||||
public class CountNoteCommentConsumer implements RocketMQListener<String> {
|
||||
|
||||
@Resource
|
||||
private NoteCountDOMapper noteCountDOMapper;
|
||||
@Resource
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
|
||||
.bufferSize(50000) // 缓存队列的最大容量
|
||||
.batchSize(1000) // 一批次最多聚合 1000 条
|
||||
.linger(Duration.ofSeconds(1)) // 多久聚合一次(1s 一次)
|
||||
.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));
|
||||
|
||||
// 将聚合后的消息体 Json 转 List<CountPublishCommentMqDTO>
|
||||
List<CountPublishCommentMqDTO> countPublishCommentMqDTOList = Lists.newArrayList();
|
||||
bodys.forEach(body -> {
|
||||
try {
|
||||
List<CountPublishCommentMqDTO> list = JsonUtils.parseList(body, CountPublishCommentMqDTO.class);
|
||||
countPublishCommentMqDTOList.addAll(list);
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 按笔记 ID 进行分组
|
||||
Map<Long, List<CountPublishCommentMqDTO>> groupMap = countPublishCommentMqDTOList.stream()
|
||||
.collect(Collectors.groupingBy(CountPublishCommentMqDTO::getNoteId));
|
||||
|
||||
// 循环分组字典
|
||||
for (Map.Entry<Long, List<CountPublishCommentMqDTO>> entry : groupMap.entrySet()) {
|
||||
// 笔记 ID
|
||||
Long noteId = entry.getKey();
|
||||
// 评论数
|
||||
int count = CollUtil.size(entry.getValue());
|
||||
|
||||
// 更新 Redis 缓存中的笔记评论总数
|
||||
// 构建 Key
|
||||
String noteCountHashKey = RedisKeyConstants.buildCountNoteKey(noteId);
|
||||
// 判断 Hash 是否存在
|
||||
boolean hasKey = redisTemplate.hasKey(noteCountHashKey);
|
||||
|
||||
// 若 Hash 存在
|
||||
if (hasKey) {
|
||||
// 累加更新
|
||||
redisTemplate.opsForHash()
|
||||
.increment(noteCountHashKey, RedisKeyConstants.FIELD_COMMENT_TOTAL, count);
|
||||
}
|
||||
|
||||
// 若评论数大于零,则执行更新操作:累加评论总数
|
||||
if (count > 0) {
|
||||
noteCountDOMapper.insertOrUpdateCommentTotalByNoteId(count, noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ import java.util.stream.Collectors;
|
||||
@Component
|
||||
@Slf4j
|
||||
@RocketMQMessageListener(
|
||||
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_NOTE_LIKE,
|
||||
topic = MQConstants.TOPIC_COUNT_NOTE_LIKE
|
||||
consumerGroup = "han_note_count_group_" + MQConstants.TOPIC_LIKE_OR_UNLIKE,
|
||||
topic = MQConstants.TOPIC_LIKE_OR_UNLIKE
|
||||
)
|
||||
public class CountNoteLikeConsumer implements RocketMQListener<String> {
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.hanserwei.hannote.count.biz.domain.dataobject;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 评论表
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@TableName(value = "t_comment")
|
||||
public class CommentDO {
|
||||
/**
|
||||
* id
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 关联的笔记ID
|
||||
*/
|
||||
@TableField(value = "note_id")
|
||||
private Long noteId;
|
||||
|
||||
/**
|
||||
* 发布者用户ID
|
||||
*/
|
||||
@TableField(value = "user_id")
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 评论内容UUID
|
||||
*/
|
||||
@TableField(value = "content_uuid")
|
||||
private String contentUuid;
|
||||
|
||||
/**
|
||||
* 内容是否为空(0:不为空 1:为空)
|
||||
*/
|
||||
@TableField(value = "is_content_empty")
|
||||
private Boolean isContentEmpty;
|
||||
|
||||
/**
|
||||
* 评论附加图片URL
|
||||
*/
|
||||
@TableField(value = "image_url")
|
||||
private String imageUrl;
|
||||
|
||||
/**
|
||||
* 级别(1:一级评论 2:二级评论)
|
||||
*/
|
||||
@TableField(value = "`level`")
|
||||
private Integer level;
|
||||
|
||||
/**
|
||||
* 评论被回复次数,仅一级评论需要
|
||||
*/
|
||||
@TableField(value = "reply_total")
|
||||
private Long replyTotal;
|
||||
|
||||
/**
|
||||
* 评论被点赞次数
|
||||
*/
|
||||
@TableField(value = "like_total")
|
||||
private Long likeTotal;
|
||||
|
||||
/**
|
||||
* 父ID (若是对笔记的评论,则此字段存储笔记ID; 若是二级评论,则此字段存储一级评论的ID)
|
||||
*/
|
||||
@TableField(value = "parent_id")
|
||||
private Long parentId;
|
||||
|
||||
/**
|
||||
* 回复哪个的评论 (0表示是对笔记的评论,若是对他人评论的回复,则存储回复评论的ID)
|
||||
*/
|
||||
@TableField(value = "reply_comment_id")
|
||||
private Long replyCommentId;
|
||||
|
||||
/**
|
||||
* 回复的哪个用户, 存储用户ID
|
||||
*/
|
||||
@TableField(value = "reply_user_id")
|
||||
private Long replyUserId;
|
||||
|
||||
/**
|
||||
* 是否置顶(0:不置顶 1:置顶)
|
||||
*/
|
||||
@TableField(value = "is_top")
|
||||
private Boolean isTop;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField(value = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(value = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/**
|
||||
* 二级评论总数(只有一级评论才需要统计)
|
||||
*/
|
||||
@TableField(value = "child_comment_total")
|
||||
private Long childCommentTotal;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.hanserwei.hannote.count.biz.domain.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hanserwei.hannote.count.biz.domain.dataobject.CommentDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface CommentDOMapper extends BaseMapper<CommentDO> {
|
||||
|
||||
/**
|
||||
* 更新一级评论的子评论总数
|
||||
*
|
||||
* @param parentId 一级评论 ID
|
||||
* @param count 子评论数
|
||||
* @return 更新结果
|
||||
*/
|
||||
int updateChildCommentTotal(@Param("parentId") Long parentId, @Param("count") int count);
|
||||
|
||||
/**
|
||||
* 更新评论点赞数
|
||||
*
|
||||
* @param count 计数
|
||||
* @param commentId 评论 ID
|
||||
* @return 更新结果
|
||||
*/
|
||||
int updateLikeTotalByCommentId(@Param("count") Integer count,
|
||||
@Param("commentId") Long commentId);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
|
||||
|
||||
/**
|
||||
* 添加笔记计数记录或更新笔记点赞数
|
||||
*
|
||||
@@ -25,4 +24,13 @@ public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
|
||||
* @return 影响行数
|
||||
*/
|
||||
int insertOrUpdateCollectTotalByNoteId(@Param("count") Integer count, @Param("noteId") Long noteId);
|
||||
|
||||
/**
|
||||
* 添加记录或更新笔记评论数
|
||||
*
|
||||
* @param count 评论数
|
||||
* @param noteId 笔记ID
|
||||
* @return 影响行数
|
||||
*/
|
||||
int insertOrUpdateCommentTotalByNoteId(@Param("count") int count, @Param("noteId") Long noteId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.hanserwei.hannote.count.biz.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum CommentLevelEnum {
|
||||
// 一级评论
|
||||
ONE(1),
|
||||
// 二级评论
|
||||
TWO(2),
|
||||
;
|
||||
|
||||
private final Integer code;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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 CountPublishCommentMqDTO {
|
||||
|
||||
/**
|
||||
* 笔记 ID
|
||||
*/
|
||||
private Long noteId;
|
||||
|
||||
/**
|
||||
* 评论 ID
|
||||
*/
|
||||
private Long commentId;
|
||||
|
||||
/**
|
||||
* 评论级别
|
||||
*/
|
||||
private Integer level;
|
||||
|
||||
/**
|
||||
* 父 ID
|
||||
*/
|
||||
private Long parentId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.hanserwei.hannote.count.biz.domain.mapper.CommentDOMapper">
|
||||
<resultMap id="BaseResultMap" type="com.hanserwei.hannote.count.biz.domain.dataobject.CommentDO">
|
||||
<!--@mbg.generated-->
|
||||
<!--@Table t_comment-->
|
||||
<id column="id" jdbcType="BIGINT" property="id"/>
|
||||
<result column="note_id" jdbcType="BIGINT" property="noteId"/>
|
||||
<result column="user_id" jdbcType="BIGINT" property="userId"/>
|
||||
<result column="content_uuid" jdbcType="VARCHAR" property="contentUuid"/>
|
||||
<result column="is_content_empty" jdbcType="BIT" property="isContentEmpty"/>
|
||||
<result column="image_url" jdbcType="VARCHAR" property="imageUrl"/>
|
||||
<result column="level" jdbcType="TINYINT" property="level"/>
|
||||
<result column="reply_total" jdbcType="BIGINT" property="replyTotal"/>
|
||||
<result column="like_total" jdbcType="BIGINT" property="likeTotal"/>
|
||||
<result column="parent_id" jdbcType="BIGINT" property="parentId"/>
|
||||
<result column="reply_comment_id" jdbcType="BIGINT" property="replyCommentId"/>
|
||||
<result column="reply_user_id" jdbcType="BIGINT" property="replyUserId"/>
|
||||
<result column="is_top" jdbcType="TINYINT" property="isTop"/>
|
||||
<result column="create_time" jdbcType="TIMESTAMP" property="createTime"/>
|
||||
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime"/>
|
||||
<result column="child_comment_total" jdbcType="BIGINT" property="childCommentTotal"/>
|
||||
</resultMap>
|
||||
<sql id="Base_Column_List">
|
||||
<!--@mbg.generated-->
|
||||
id,
|
||||
note_id,
|
||||
user_id,
|
||||
content_uuid,
|
||||
is_content_empty,
|
||||
image_url,
|
||||
`level`,
|
||||
reply_total,
|
||||
like_total,
|
||||
parent_id,
|
||||
reply_comment_id,
|
||||
reply_user_id,
|
||||
is_top,
|
||||
create_time,
|
||||
update_time,
|
||||
child_comment_total
|
||||
</sql>
|
||||
|
||||
<update id="updateChildCommentTotal" parameterType="map">
|
||||
update t_comment
|
||||
set child_comment_total = child_comment_total + #{count},
|
||||
update_time = now()
|
||||
where id = #{parentId}
|
||||
and level = 1
|
||||
</update>
|
||||
|
||||
<update id="updateLikeTotalByCommentId" parameterType="map">
|
||||
update t_comment
|
||||
set like_total = like_total + #{count},
|
||||
update_time = now()
|
||||
where id = #{commentId}
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -26,4 +26,10 @@
|
||||
VALUES (#{noteId}, #{count})
|
||||
ON DUPLICATE KEY UPDATE collect_total = collect_total + (#{count});
|
||||
</insert>
|
||||
|
||||
<insert id="insertOrUpdateCommentTotalByNoteId" parameterType="map">
|
||||
INSERT INTO t_note_count (note_id, comment_total)
|
||||
VALUES (#{noteId}, #{count})
|
||||
ON DUPLICATE KEY UPDATE comment_total = comment_total + (#{count});
|
||||
</insert>
|
||||
</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;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@ package com.hanserwei.hannote.kv.api;
|
||||
|
||||
import com.hanserwei.framework.common.response.Response;
|
||||
import com.hanserwei.hannote.kv.constant.ApiConstants;
|
||||
import com.hanserwei.hannote.kv.dto.req.AddNoteContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.DeleteNoteContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.FindNoteContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.*;
|
||||
import com.hanserwei.hannote.kv.dto.resp.FindCommentContentRspDTO;
|
||||
import com.hanserwei.hannote.kv.dto.resp.FindNoteContentRspDTO;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = ApiConstants.SERVICE_NAME)
|
||||
public interface KeyValueFeignApi {
|
||||
|
||||
@@ -28,4 +28,9 @@ public interface KeyValueFeignApi {
|
||||
@PostMapping(value = PREFIX + "/comment/content/batchAdd")
|
||||
Response<?> batchAddCommentContent(@RequestBody BatchAddCommentContentReqDTO batchAddCommentContentReqDTO);
|
||||
|
||||
@PostMapping(value = PREFIX + "/comment/content/batchFind")
|
||||
Response<List<FindCommentContentRspDTO>> batchFindCommentContent(@RequestBody BatchFindCommentContentReqDTO batchFindCommentContentReqDTO);
|
||||
|
||||
@PostMapping(value = PREFIX + "/comment/content/delete")
|
||||
Response<?> deleteCommentContent(@RequestBody DeleteCommentContentReqDTO deleteCommentContentReqDTO);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.hanserwei.hannote.kv.dto.req;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class BatchFindCommentContentReqDTO {
|
||||
|
||||
/**
|
||||
* 笔记 ID
|
||||
*/
|
||||
@NotNull(message = "评论 ID 不能为空")
|
||||
private Long noteId;
|
||||
|
||||
@NotEmpty(message = "评论内容 Key 集合")
|
||||
@Valid // 指定集合中的 DTO 也需要进行参数校验
|
||||
private List<FindCommentContentReqDTO> commentContentKeys;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.hanserwei.hannote.kv.dto.req;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindCommentContentReqDTO {
|
||||
|
||||
@NotBlank(message = "发布年月不能为空")
|
||||
private String yearMonth;
|
||||
|
||||
@NotBlank(message = "评论正文 ID 不能为空")
|
||||
private String contentId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.hanserwei.hannote.kv.dto.resp;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class FindCommentContentRspDTO {
|
||||
|
||||
/**
|
||||
* 评论内容 UUID
|
||||
*/
|
||||
private String contentId;
|
||||
|
||||
/**
|
||||
* 评论内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
|
||||
import com.hanserwei.framework.common.response.Response;
|
||||
import com.hanserwei.hannote.kv.biz.service.CommentContentService;
|
||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.DeleteCommentContentReqDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -26,4 +28,16 @@ public class CommentContentController {
|
||||
return commentContentService.batchAddCommentContent(batchAddCommentContentReqDTO);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/comment/content/batchFind")
|
||||
@ApiOperationLog(description = "批量查询评论内容")
|
||||
public Response<?> batchFindCommentContent(@Validated @RequestBody BatchFindCommentContentReqDTO batchFindCommentContentReqDTO) {
|
||||
return commentContentService.batchFindCommentContent(batchFindCommentContentReqDTO);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/comment/content/delete")
|
||||
@ApiOperationLog(description = "删除评论内容")
|
||||
public Response<?> deleteCommentContent(@Validated @RequestBody DeleteCommentContentReqDTO deleteCommentContentReqDTO) {
|
||||
return commentContentService.deleteCommentContent(deleteCommentContentReqDTO);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.hanserwei.hannote.kv.biz.domain.repository;
|
||||
|
||||
import com.hanserwei.hannote.kv.biz.domain.dataobject.CommentContentDO;
|
||||
import com.hanserwei.hannote.kv.biz.domain.dataobject.CommentContentPrimaryKey;
|
||||
import org.springframework.data.cassandra.repository.CassandraRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface CommentContentRepository extends CassandraRepository<CommentContentDO, CommentContentPrimaryKey> {
|
||||
|
||||
/**
|
||||
* 批量查询评论内容
|
||||
*
|
||||
* @param noteId 笔记ID
|
||||
* @param yearMonths 年月
|
||||
* @param contentIds 评论 ID列表
|
||||
* @return 评论内容
|
||||
*/
|
||||
List<CommentContentDO> findByPrimaryKeyNoteIdAndPrimaryKeyYearMonthInAndPrimaryKeyContentIdIn(
|
||||
Long noteId, List<String> yearMonths, List<UUID> contentIds
|
||||
);
|
||||
|
||||
/**
|
||||
* 删除评论正文
|
||||
*
|
||||
* @param noteId 笔记ID
|
||||
* @param yearMonth 年月
|
||||
* @param contentId 评论 ID
|
||||
*/
|
||||
void deleteByPrimaryKeyNoteIdAndPrimaryKeyYearMonthAndPrimaryKeyContentId(Long noteId, String yearMonth, UUID contentId);
|
||||
}
|
||||
@@ -2,8 +2,33 @@ package com.hanserwei.hannote.kv.biz.service;
|
||||
|
||||
import com.hanserwei.framework.common.response.Response;
|
||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.BatchFindCommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.DeleteCommentContentReqDTO;
|
||||
|
||||
public interface CommentContentService {
|
||||
|
||||
/**
|
||||
* 批量添加评论内容
|
||||
*
|
||||
* @param batchAddCommentContentReqDTO 批量添加评论内容请求参数
|
||||
* @return 批量添加结果
|
||||
*/
|
||||
Response<?> batchAddCommentContent(BatchAddCommentContentReqDTO batchAddCommentContentReqDTO);
|
||||
|
||||
/**
|
||||
* 批量查询评论内容
|
||||
*
|
||||
* @param batchFindCommentContentReqDTO 批量查询评论内容请求参数
|
||||
* @return 批量查询结果
|
||||
*/
|
||||
Response<?> batchFindCommentContent(BatchFindCommentContentReqDTO batchFindCommentContentReqDTO);
|
||||
|
||||
/**
|
||||
* 删除评论内容
|
||||
*
|
||||
* @param deleteCommentContentReqDTO 删除评论内容请求参数
|
||||
* @return 删除结果
|
||||
*/
|
||||
Response<?> deleteCommentContent(DeleteCommentContentReqDTO deleteCommentContentReqDTO);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.hanserwei.hannote.kv.biz.service.impl;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.google.common.collect.Lists;
|
||||
import com.hanserwei.framework.common.response.Response;
|
||||
import com.hanserwei.hannote.kv.biz.domain.dataobject.CommentContentDO;
|
||||
import com.hanserwei.hannote.kv.biz.domain.dataobject.CommentContentPrimaryKey;
|
||||
import com.hanserwei.hannote.kv.biz.domain.repository.CommentContentRepository;
|
||||
import com.hanserwei.hannote.kv.biz.service.CommentContentService;
|
||||
import com.hanserwei.hannote.kv.dto.req.BatchAddCommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.CommentContentReqDTO;
|
||||
import com.hanserwei.hannote.kv.dto.req.*;
|
||||
import com.hanserwei.hannote.kv.dto.resp.FindCommentContentRspDTO;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.data.cassandra.core.CassandraTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -18,6 +22,8 @@ public class CommentContentServiceImpl implements CommentContentService {
|
||||
|
||||
@Resource
|
||||
private CassandraTemplate cassandraTemplate;
|
||||
@Resource
|
||||
private CommentContentRepository commentContentRepository;
|
||||
|
||||
@Override
|
||||
public Response<?> batchAddCommentContent(BatchAddCommentContentReqDTO batchAddCommentContentReqDTO) {
|
||||
@@ -47,4 +53,51 @@ public class CommentContentServiceImpl implements CommentContentService {
|
||||
.execute();
|
||||
return Response.success();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response<?> batchFindCommentContent(BatchFindCommentContentReqDTO batchFindCommentContentReqDTO) {
|
||||
// 归属的笔记ID
|
||||
Long noteId = batchFindCommentContentReqDTO.getNoteId();
|
||||
// 查询评论的发布年月、内容 UUID
|
||||
List<FindCommentContentReqDTO> commentContentKeys = batchFindCommentContentReqDTO.getCommentContentKeys();
|
||||
|
||||
// 过滤出年月
|
||||
List<@NotBlank(message = "发布年月不能为空") String> yearMonths = commentContentKeys.stream()
|
||||
.map(FindCommentContentReqDTO::getYearMonth)
|
||||
.distinct()
|
||||
.toList();
|
||||
// 过滤出内容 UUID
|
||||
List<UUID> contentIds = commentContentKeys.stream()
|
||||
.map(r -> UUID.fromString(r.getContentId()))
|
||||
.distinct()
|
||||
.toList();
|
||||
// 批量查询 Cassandra
|
||||
List<CommentContentDO> commentContentDOS = commentContentRepository
|
||||
.findByPrimaryKeyNoteIdAndPrimaryKeyYearMonthInAndPrimaryKeyContentIdIn(noteId, yearMonths, contentIds);
|
||||
|
||||
// DO 转 DTO
|
||||
List<FindCommentContentRspDTO> findCommentContentRspDTOS = Lists.newArrayList();
|
||||
if (CollUtil.isNotEmpty(commentContentDOS)) {
|
||||
findCommentContentRspDTOS = commentContentDOS.stream()
|
||||
.map(commentContentDO -> FindCommentContentRspDTO.builder()
|
||||
.contentId(String.valueOf(commentContentDO.getPrimaryKey().getContentId()))
|
||||
.content(commentContentDO.getContent())
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,13 @@
|
||||
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Rocket MQ 客户端 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.rocketmq</groupId>
|
||||
<artifactId>rocketmq-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
|
||||
@@ -1,172 +1,154 @@
|
||||
package com.hanserwei.hannote.note.biz.comsumer;
|
||||
|
||||
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.note.biz.constant.MQConstants;
|
||||
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteCollectionDO;
|
||||
import com.hanserwei.hannote.note.biz.domain.mapper.NoteCollectionDOMapper;
|
||||
import com.hanserwei.hannote.note.biz.model.dto.CollectUnCollectNoteMqDTO;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
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.common.message.Message;
|
||||
import org.apache.rocketmq.spring.annotation.ConsumeMode;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@SuppressWarnings({"UnstableApiUsage", "DuplicatedCode"})
|
||||
@SuppressWarnings({"UnstableApiUsage"})
|
||||
@Component
|
||||
@Slf4j
|
||||
@RocketMQMessageListener(
|
||||
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COLLECT_OR_UN_COLLECT,
|
||||
topic = MQConstants.TOPIC_COLLECT_OR_UN_COLLECT,
|
||||
consumeMode = ConsumeMode.ORDERLY
|
||||
)
|
||||
public class CollectUnCollectNoteConsumer implements RocketMQListener<Message> {
|
||||
public class CollectUnCollectNoteConsumer {
|
||||
|
||||
// 每秒创建 5000 个令牌
|
||||
// 每秒创建5000个令牌
|
||||
private final RateLimiter rateLimiter = RateLimiter.create(5000);
|
||||
@Value("${rocketmq.name-server}")
|
||||
private String nameServer;
|
||||
|
||||
private DefaultMQPushConsumer consumer;
|
||||
@Resource
|
||||
private NoteCollectionDOMapper noteCollectionDOMapper;
|
||||
@Resource
|
||||
private RocketMQTemplate rocketMQTemplate;
|
||||
|
||||
@Override
|
||||
public void onMessage(Message message) {
|
||||
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
|
||||
@Bean(name = "CollectUnCollectNoteConsumer")
|
||||
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
|
||||
// Group组
|
||||
String group = "han_note_group_" + MQConstants.TOPIC_COLLECT_OR_UN_COLLECT;
|
||||
|
||||
// 创建一个新的 DefaultMQPushConsumer 实例,并指定消费者的消费组名
|
||||
consumer = new DefaultMQPushConsumer(group);
|
||||
|
||||
// 设置 RocketMQ 的 NameServer 地址
|
||||
consumer.setNamesrvAddr(nameServer);
|
||||
|
||||
// 订阅指定的主题,并设置主题的订阅规则("*" 表示订阅所有标签的消息)
|
||||
consumer.subscribe(MQConstants.TOPIC_COLLECT_OR_UN_COLLECT, "*");
|
||||
|
||||
// 设置消费者消费消息的起始位置,如果队列中没有消息,则从最新的消息开始消费。
|
||||
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
|
||||
|
||||
// 设置消息消费模式,这里使用集群模式 (CLUSTERING)
|
||||
consumer.setMessageModel(MessageModel.CLUSTERING);
|
||||
|
||||
// 最大重试次数, 以防消息重试过多次仍然没有成功,避免消息卡在消费队列中。
|
||||
consumer.setMaxReconsumeTimes(3);
|
||||
// 设置每批次消费的最大消息数量,这里设置为 30,表示每次拉取时最多消费 30 条消息。
|
||||
consumer.setConsumeMessageBatchMaxSize(30);
|
||||
// 设置拉取间隔, 单位毫秒
|
||||
consumer.setPullInterval(1000);
|
||||
|
||||
// 注册消息监听器
|
||||
consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> {
|
||||
log.info("==> 【笔记收藏、取消收藏】本批次消息大小: {}", msgs.size());
|
||||
try {
|
||||
// 令牌桶流控, 以控制数据库能够承受的 QPS
|
||||
rateLimiter.acquire();
|
||||
|
||||
// 幂等性: 通过联合唯一索引保证
|
||||
|
||||
// 消息体
|
||||
String bodyJsonStr = new String(message.getBody());
|
||||
// 标签
|
||||
String tags = message.getTags();
|
||||
// 消息体 Json 字符串转 DTO
|
||||
List<CollectUnCollectNoteMqDTO> collectUnCollectNoteMqDTOS = Lists.newArrayList();
|
||||
msgs.forEach(msg -> {
|
||||
String msgJson = new String(msg.getBody());
|
||||
log.info("==> Consumer - Received message: {}", msgJson);
|
||||
collectUnCollectNoteMqDTOS.add(JsonUtils.parseObject(msgJson, CollectUnCollectNoteMqDTO.class));
|
||||
});
|
||||
|
||||
log.info("==> CollectUnCollectNoteConsumer 消费了消息 {}, tags: {}", bodyJsonStr, tags);
|
||||
// 1.内存级操作合并
|
||||
//按用户ID分组
|
||||
Map<Long, List<CollectUnCollectNoteMqDTO>> groupMap = collectUnCollectNoteMqDTOS.stream()
|
||||
.collect(Collectors.groupingBy(CollectUnCollectNoteMqDTO::getUserId));
|
||||
//对每个用户按照用户ID分组并且过滤合并
|
||||
// 对每个用户的操作按 noteId 二次分组,并过滤合并
|
||||
List<CollectUnCollectNoteMqDTO> finalOperations = groupMap.values().stream()
|
||||
.flatMap(userOperations -> {
|
||||
// 按 noteId 分组
|
||||
Map<Long, List<CollectUnCollectNoteMqDTO>> noteGroupMap = userOperations.stream()
|
||||
.collect(Collectors.groupingBy(CollectUnCollectNoteMqDTO::getNoteId));
|
||||
|
||||
// 根据 MQ 标签,判断操作类型
|
||||
if (Objects.equals(tags, MQConstants.TAG_COLLECT)) { // 收藏笔记
|
||||
handleCollectNoteTagMessage(bodyJsonStr);
|
||||
} else if (Objects.equals(tags, MQConstants.TAG_UN_COLLECT)) { // 取消收藏笔记
|
||||
handleUnCollectNoteTagMessage(bodyJsonStr);
|
||||
}
|
||||
// 处理每个 noteId 的分组
|
||||
// 取最后一次操作(消息是有序的)
|
||||
return noteGroupMap.values().stream()
|
||||
.filter(operations -> {
|
||||
int size = operations.size();
|
||||
// 根据奇偶性判断是否需要处理
|
||||
// 偶数次操作:最终状态抵消,无需写入
|
||||
// 奇数次操作:保留最后一次操作
|
||||
return size % 2 != 0;
|
||||
})
|
||||
.map(List::getLast);
|
||||
})
|
||||
.toList();
|
||||
|
||||
// 2. 批量写入数据库
|
||||
if (CollUtil.isNotEmpty(finalOperations)) {
|
||||
// DTO 转 DO
|
||||
List<NoteCollectionDO> noteCollectionDOS = finalOperations.stream()
|
||||
.map(finalOperation -> NoteCollectionDO.builder()
|
||||
.userId(finalOperation.getUserId())
|
||||
.noteId(finalOperation.getNoteId())
|
||||
.createTime(finalOperation.getCreateTime())
|
||||
.status(finalOperation.getType())
|
||||
.build())
|
||||
.toList();
|
||||
|
||||
// 批量写入
|
||||
noteCollectionDOMapper.batchInsertOrUpdate(noteCollectionDOS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理取消收藏笔记的 MQ 消息
|
||||
*
|
||||
* @param bodyJsonStr 消息体
|
||||
*/
|
||||
private void handleUnCollectNoteTagMessage(String bodyJsonStr) {
|
||||
// 消息体 JSON 字符串转 DTO
|
||||
CollectUnCollectNoteMqDTO unCollectNoteMqDTO = JsonUtils.parseObject(bodyJsonStr, CollectUnCollectNoteMqDTO.class);
|
||||
|
||||
if (Objects.isNull(unCollectNoteMqDTO)) return;
|
||||
|
||||
// 用户ID
|
||||
Long userId = unCollectNoteMqDTO.getUserId();
|
||||
// 收藏的笔记ID
|
||||
Long noteId = unCollectNoteMqDTO.getNoteId();
|
||||
// 操作类型
|
||||
Integer type = unCollectNoteMqDTO.getType();
|
||||
// 收藏时间
|
||||
LocalDateTime createTime = unCollectNoteMqDTO.getCreateTime();
|
||||
|
||||
// 构建 DO 对象
|
||||
NoteCollectionDO noteCollectionDO = NoteCollectionDO.builder()
|
||||
.userId(userId)
|
||||
.noteId(noteId)
|
||||
.createTime(createTime)
|
||||
.status(type)
|
||||
.build();
|
||||
|
||||
// 取消收藏:记录更新
|
||||
int count = noteCollectionDOMapper.update2UnCollectByUserIdAndNoteId(noteCollectionDO);
|
||||
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新数据库成功后,发送计数 MQ
|
||||
org.springframework.messaging.Message<String> message = MessageBuilder.withPayload(bodyJsonStr)
|
||||
.build();
|
||||
|
||||
// 异步发送 MQ 消息
|
||||
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_NOTE_COLLECT, message, new SendCallback() {
|
||||
@Override
|
||||
public void onSuccess(SendResult sendResult) {
|
||||
log.info("==> 【计数: 笔记取消收藏】MQ 发送成功,SendResult: {}", sendResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onException(Throwable throwable) {
|
||||
log.error("==> 【计数: 笔记取消收藏】MQ 发送异常: ", throwable);
|
||||
// 手动 ACK,告诉 RocketMQ 这批次消息消费成功
|
||||
return ConsumeOrderlyStatus.SUCCESS;
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
// 这样 RocketMQ 会暂停当前队列的消费一段时间,再重试
|
||||
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
|
||||
}
|
||||
});
|
||||
|
||||
// 启动消费者
|
||||
consumer.start();
|
||||
return consumer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理收藏笔记的 MQ 消息
|
||||
*
|
||||
* @param bodyJsonStr 消息体
|
||||
*/
|
||||
private void handleCollectNoteTagMessage(String bodyJsonStr) {
|
||||
// 消息体 JSON 字符串转 DTO
|
||||
CollectUnCollectNoteMqDTO collectUnCollectNoteMqDTO = JsonUtils.parseObject(bodyJsonStr, CollectUnCollectNoteMqDTO.class);
|
||||
|
||||
if (Objects.isNull(collectUnCollectNoteMqDTO)) return;
|
||||
|
||||
// 用户ID
|
||||
Long userId = collectUnCollectNoteMqDTO.getUserId();
|
||||
// 收藏的笔记ID
|
||||
Long noteId = collectUnCollectNoteMqDTO.getNoteId();
|
||||
// 操作类型
|
||||
Integer type = collectUnCollectNoteMqDTO.getType();
|
||||
// 收藏时间
|
||||
LocalDateTime createTime = collectUnCollectNoteMqDTO.getCreateTime();
|
||||
|
||||
// 构建 DO 对象
|
||||
NoteCollectionDO noteCollectionDO = NoteCollectionDO.builder()
|
||||
.userId(userId)
|
||||
.noteId(noteId)
|
||||
.createTime(createTime)
|
||||
.status(type)
|
||||
.build();
|
||||
|
||||
// 添加或更新笔记收藏记录
|
||||
boolean isSuccess = noteCollectionDOMapper.insertOrUpdate(noteCollectionDO);
|
||||
|
||||
if (!isSuccess) {
|
||||
return;
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
if (Objects.nonNull(consumer)) {
|
||||
try {
|
||||
consumer.shutdown(); // 关闭消费者
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
}
|
||||
|
||||
// 发送计数 MQ
|
||||
|
||||
// 更新数据库成功后,发送计数 MQ
|
||||
org.springframework.messaging.Message<String> message = MessageBuilder.withPayload(bodyJsonStr)
|
||||
.build();
|
||||
|
||||
// 异步发送 MQ 消息
|
||||
// 异步发送 MQ 消息
|
||||
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_NOTE_COLLECT, message, new SendCallback() {
|
||||
@Override
|
||||
public void onSuccess(SendResult sendResult) {
|
||||
log.info("==> 【计数: 笔记收藏】MQ 发送成功,SendResult: {}", sendResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onException(Throwable throwable) {
|
||||
log.error("==> 【计数: 笔记收藏】MQ 发送异常: ", throwable);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,179 +1,154 @@
|
||||
package com.hanserwei.hannote.note.biz.comsumer;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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.note.biz.constant.MQConstants;
|
||||
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteLikeDO;
|
||||
import com.hanserwei.hannote.note.biz.domain.mapper.NoteLikeDOMapper;
|
||||
import com.hanserwei.hannote.note.biz.enums.LikeUnlikeNoteTypeEnum;
|
||||
import com.hanserwei.hannote.note.biz.model.dto.LikeUnlikeNoteMqDTO;
|
||||
import com.hanserwei.hannote.note.biz.service.NoteLikeDOService;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
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.common.message.Message;
|
||||
import org.apache.rocketmq.spring.annotation.ConsumeMode;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@SuppressWarnings({"UnstableApiUsage"})
|
||||
@Component
|
||||
@RocketMQMessageListener(
|
||||
consumerGroup = "han_note_group_" + MQConstants.TOPIC_LIKE_OR_UNLIKE,
|
||||
topic = MQConstants.TOPIC_LIKE_OR_UNLIKE,
|
||||
consumeMode = ConsumeMode.ORDERLY// 顺序消费
|
||||
)
|
||||
@Slf4j
|
||||
public class LikeUnlikeNoteConsumer implements RocketMQListener<Message> {
|
||||
public class LikeUnlikeNoteConsumer {
|
||||
|
||||
// 每秒创建 5000 个令牌
|
||||
// 每秒创建5000个令牌
|
||||
private final RateLimiter rateLimiter = RateLimiter.create(5000);
|
||||
@Value("${rocketmq.name-server}")
|
||||
private String nameServer;
|
||||
|
||||
private DefaultMQPushConsumer consumer;
|
||||
@Resource
|
||||
private NoteLikeDOMapper noteLikeDOMapper;
|
||||
@Resource
|
||||
private NoteLikeDOService noteLikeDOService;
|
||||
@Resource
|
||||
private RocketMQTemplate rocketMQTemplate;
|
||||
|
||||
@Override
|
||||
public void onMessage(Message message) {
|
||||
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
|
||||
@Bean(name = "LikeUnLikeNoteConsumer")
|
||||
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
|
||||
// Group组
|
||||
String group = "han_note_group_" + MQConstants.TOPIC_LIKE_OR_UNLIKE;
|
||||
|
||||
// 创建一个新的 DefaultMQPushConsumer 实例,并指定消费者的消费组名
|
||||
consumer = new DefaultMQPushConsumer(group);
|
||||
|
||||
// 设置 RocketMQ 的 NameServer 地址
|
||||
consumer.setNamesrvAddr(nameServer);
|
||||
|
||||
// 订阅指定的主题,并设置主题的订阅规则("*" 表示订阅所有标签的消息)
|
||||
consumer.subscribe(MQConstants.TOPIC_LIKE_OR_UNLIKE, "*");
|
||||
|
||||
// 设置消费者消费消息的起始位置,如果队列中没有消息,则从最新的消息开始消费。
|
||||
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
|
||||
|
||||
// 设置消息消费模式,这里使用集群模式 (CLUSTERING)
|
||||
consumer.setMessageModel(MessageModel.CLUSTERING);
|
||||
|
||||
// 最大重试次数, 以防消息重试过多次仍然没有成功,避免消息卡在消费队列中。
|
||||
consumer.setMaxReconsumeTimes(3);
|
||||
// 设置每批次消费的最大消息数量,这里设置为 30,表示每次拉取时最多消费 30 条消息。
|
||||
consumer.setConsumeMessageBatchMaxSize(30);
|
||||
// 设置拉取间隔, 单位毫秒
|
||||
consumer.setPullInterval(1000);
|
||||
|
||||
// 注册消息监听器
|
||||
consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> {
|
||||
log.info("==> 【笔记点赞、取消点赞】本批次消息大小: {}", msgs.size());
|
||||
try {
|
||||
// 令牌桶流控, 以控制数据库能够承受的 QPS
|
||||
rateLimiter.acquire();
|
||||
|
||||
// 幂等性,通过联合索引保证
|
||||
// 幂等性: 通过联合唯一索引保证
|
||||
|
||||
// 消息体
|
||||
String bodyJsonStr = new String(message.getBody());
|
||||
// 标签
|
||||
String tags = message.getTags();
|
||||
|
||||
log.info("==> LikeUnlikeNoteConsumer 消费了消息 {}, tags: {}", bodyJsonStr, tags);
|
||||
|
||||
// 根据 MQ 标签,判断操作类型
|
||||
if (Objects.equals(tags, MQConstants.TAG_LIKE)) { // 点赞笔记
|
||||
handleLikeNoteTagMessage(bodyJsonStr);
|
||||
} else if (Objects.equals(tags, MQConstants.TAG_UNLIKE)) { // 取消点赞笔记
|
||||
handleUnlikeNoteTagMessage(bodyJsonStr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理取消点赞笔记的 MQ 消息
|
||||
*
|
||||
* @param bodyJsonStr 消息体
|
||||
*/
|
||||
private void handleUnlikeNoteTagMessage(String bodyJsonStr) {
|
||||
// 消息体 JSON 字符串转 DTO
|
||||
LikeUnlikeNoteMqDTO unlikeNoteMqDTO = JsonUtils.parseObject(bodyJsonStr, LikeUnlikeNoteMqDTO.class);
|
||||
if (Objects.isNull(unlikeNoteMqDTO)) {
|
||||
return;
|
||||
}
|
||||
// 用户ID
|
||||
Long userId = unlikeNoteMqDTO.getUserId();
|
||||
// 点赞的笔记ID
|
||||
Long noteId = unlikeNoteMqDTO.getNoteId();
|
||||
// 操作类型
|
||||
Integer type = unlikeNoteMqDTO.getType();
|
||||
// 取消点赞时间
|
||||
LocalDateTime createTime = unlikeNoteMqDTO.getCreateTime();
|
||||
|
||||
// 设置要更新的字段值
|
||||
NoteLikeDO updateEntity = NoteLikeDO.builder()
|
||||
.createTime(createTime) // 更新时间
|
||||
.status(type) // 设置新的状态值 (例如 0 表示取消点赞)
|
||||
.build();
|
||||
|
||||
// 设置更新条件:where user_id = [userId] and note_id = [noteId] and status = 1
|
||||
LambdaQueryWrapper<NoteLikeDO> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(NoteLikeDO::getUserId, userId)
|
||||
.eq(NoteLikeDO::getNoteId, noteId)
|
||||
.eq(NoteLikeDO::getStatus, LikeUnlikeNoteTypeEnum.LIKE.getCode()); // 确保只更新当前为“已点赞”的记录
|
||||
|
||||
// 执行更新
|
||||
boolean update = noteLikeDOService.update(updateEntity, wrapper);
|
||||
log.info("==> 【取消点赞笔记】更新数据库成功,update: {}", update);
|
||||
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
// 更新数据库成功后,发送计数 MQ
|
||||
org.springframework.messaging.Message<String> message = MessageBuilder.withPayload(bodyJsonStr)
|
||||
.build();
|
||||
|
||||
// 异步发送 MQ 消息
|
||||
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_NOTE_LIKE, message, new SendCallback() {
|
||||
@Override
|
||||
public void onSuccess(SendResult sendResult) {
|
||||
log.info("==> 【计数: 笔记取消点赞】MQ 发送成功,SendResult: {}", sendResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onException(Throwable throwable) {
|
||||
log.error("==> 【计数: 笔记取消点赞】MQ 发送异常: ", throwable);
|
||||
}
|
||||
// 消息体 Json 字符串转 DTO
|
||||
List<LikeUnlikeNoteMqDTO> likeUnlikeNoteMqDTOS = Lists.newArrayList();
|
||||
msgs.forEach(msg -> {
|
||||
String msgJson = new String(msg.getBody());
|
||||
log.info("==> Consumer - Received message: {}", msgJson);
|
||||
likeUnlikeNoteMqDTOS.add(JsonUtils.parseObject(msgJson, LikeUnlikeNoteMqDTO.class));
|
||||
});
|
||||
|
||||
// 1.内存级操作合并
|
||||
//按用户ID分组
|
||||
Map<Long, List<LikeUnlikeNoteMqDTO>> groupMap = likeUnlikeNoteMqDTOS.stream()
|
||||
.collect(Collectors.groupingBy(LikeUnlikeNoteMqDTO::getUserId));
|
||||
//对每个用户按照用户ID分组并且过滤合并
|
||||
// 对每个用户的操作按 noteId 二次分组,并过滤合并
|
||||
List<LikeUnlikeNoteMqDTO> finalOperations = groupMap.values().stream()
|
||||
.flatMap(userOperations -> {
|
||||
// 按 noteId 分组
|
||||
Map<Long, List<LikeUnlikeNoteMqDTO>> noteGroupMap = userOperations.stream()
|
||||
.collect(Collectors.groupingBy(LikeUnlikeNoteMqDTO::getNoteId));
|
||||
|
||||
// 处理每个 noteId 的分组
|
||||
// 取最后一次操作(消息是有序的)
|
||||
return noteGroupMap.values().stream()
|
||||
.filter(operations -> {
|
||||
int size = operations.size();
|
||||
// 根据奇偶性判断是否需要处理
|
||||
// 偶数次操作:最终状态抵消,无需写入
|
||||
// 奇数次操作:保留最后一次操作
|
||||
return size % 2 != 0;
|
||||
})
|
||||
.map(List::getLast);
|
||||
})
|
||||
.toList();
|
||||
|
||||
// 2. 批量写入数据库
|
||||
if (CollUtil.isNotEmpty(finalOperations)) {
|
||||
// DTO 转 DO
|
||||
List<NoteLikeDO> noteLikeDOS = finalOperations.stream()
|
||||
.map(finalOperation -> NoteLikeDO.builder()
|
||||
.userId(finalOperation.getUserId())
|
||||
.noteId(finalOperation.getNoteId())
|
||||
.createTime(finalOperation.getCreateTime())
|
||||
.status(finalOperation.getType())
|
||||
.build())
|
||||
.toList();
|
||||
|
||||
// 批量写入
|
||||
noteLikeDOMapper.batchInsertOrUpdate(noteLikeDOS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理点赞笔记的 MQ 消息
|
||||
*
|
||||
* @param bodyJsonStr 消息体
|
||||
*/
|
||||
private void handleLikeNoteTagMessage(String bodyJsonStr) {
|
||||
// 消息体 JSON 字符串转 DTO
|
||||
LikeUnlikeNoteMqDTO likeNoteMqDTO = JsonUtils.parseObject(bodyJsonStr, LikeUnlikeNoteMqDTO.class);
|
||||
|
||||
if (Objects.isNull(likeNoteMqDTO)) return;
|
||||
|
||||
// 用户ID
|
||||
Long userId = likeNoteMqDTO.getUserId();
|
||||
// 点赞的笔记ID
|
||||
Long noteId = likeNoteMqDTO.getNoteId();
|
||||
// 操作类型
|
||||
Integer type = likeNoteMqDTO.getType();
|
||||
// 点赞时间
|
||||
LocalDateTime createTime = likeNoteMqDTO.getCreateTime();
|
||||
|
||||
// 构建 DO 对象
|
||||
NoteLikeDO noteLikeDO = NoteLikeDO.builder()
|
||||
.userId(userId)
|
||||
.noteId(noteId)
|
||||
.createTime(createTime)
|
||||
.status(type)
|
||||
.build();
|
||||
|
||||
// 添加或更新笔记点赞记录
|
||||
boolean count = noteLikeDOMapper.insertOrUpdate(noteLikeDO);
|
||||
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送计数 MQ
|
||||
// 更新数据库成功后,发送计数 MQ
|
||||
org.springframework.messaging.Message<String> message = MessageBuilder.withPayload(bodyJsonStr)
|
||||
.build();
|
||||
|
||||
// 异步发送 MQ 消息
|
||||
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_NOTE_LIKE, message, new SendCallback() {
|
||||
@Override
|
||||
public void onSuccess(SendResult sendResult) {
|
||||
log.info("==> 【计数: 笔记点赞】MQ 发送成功,SendResult: {}", sendResult);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onException(Throwable throwable) {
|
||||
log.error("==> 【计数: 笔记点赞】MQ 发送异常: ", throwable);
|
||||
// 手动 ACK,告诉 RocketMQ 这批次消息消费成功
|
||||
return ConsumeOrderlyStatus.SUCCESS;
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
// 这样 RocketMQ 会暂停当前队列的消费一段时间,再重试
|
||||
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
|
||||
}
|
||||
});
|
||||
|
||||
// 启动消费者
|
||||
consumer.start();
|
||||
return consumer;
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
if (Objects.nonNull(consumer)) {
|
||||
try {
|
||||
consumer.shutdown(); // 关闭消费者
|
||||
} catch (Exception e) {
|
||||
log.error("", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,21 +17,11 @@ public interface MQConstants {
|
||||
*/
|
||||
String TOPIC_LIKE_OR_UNLIKE = "LikeUnlikeTopic";
|
||||
|
||||
/**
|
||||
* Topic: 计数 - 笔记点赞数
|
||||
*/
|
||||
String TOPIC_COUNT_NOTE_LIKE = "CountNoteLikeTopic";
|
||||
|
||||
/**
|
||||
* Topic: 收藏、取消收藏共用一个
|
||||
*/
|
||||
String TOPIC_COLLECT_OR_UN_COLLECT = "CollectUnCollectTopic";
|
||||
|
||||
/**
|
||||
* Topic: 计数 - 笔记收藏数
|
||||
*/
|
||||
String TOPIC_COUNT_NOTE_COLLECT = "CountNoteCollectTopic";
|
||||
|
||||
/**
|
||||
* Topic: 笔记操作(发布、删除)
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,11 @@ public class RedisKeyConstants {
|
||||
*/
|
||||
public static final String NOTE_DETAIL_KEY = "note:detail:";
|
||||
|
||||
/**
|
||||
* Roaring Bitmap:用户笔记点赞 前缀
|
||||
*/
|
||||
public static final String R_BITMAP_USER_NOTE_LIKE_LIST_KEY = "rbitmap:note:likes:";
|
||||
|
||||
/**
|
||||
* 布隆过滤器:用户笔记点赞
|
||||
*/
|
||||
@@ -76,4 +81,14 @@ public class RedisKeyConstants {
|
||||
public static String buildUserNoteCollectZSetKey(Long userId) {
|
||||
return USER_NOTE_COLLECT_ZSET_KEY + userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建完整的 Roaring Bitmap:用户笔记点赞 KEY
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return Roaring Bitmap:用户笔记点赞 KEY
|
||||
*/
|
||||
public static String buildRBitmapUserNoteLikeListKey(Long userId) {
|
||||
return R_BITMAP_USER_NOTE_LIKE_LIST_KEY + userId;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package com.hanserwei.hannote.note.biz.domain.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteCollectionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface NoteCollectionDOMapper extends BaseMapper<NoteCollectionDO> {
|
||||
@@ -21,4 +24,12 @@ public interface NoteCollectionDOMapper extends BaseMapper<NoteCollectionDO> {
|
||||
* @return 影响行数
|
||||
*/
|
||||
int update2UnCollectByUserIdAndNoteId(NoteCollectionDO noteCollectionDO);
|
||||
|
||||
/**
|
||||
* 批量新增笔记收藏记录
|
||||
*
|
||||
* @param noteCollectionDOS 笔记收藏记录
|
||||
* @return 影响行数
|
||||
*/
|
||||
int batchInsertOrUpdate(@Param("noteCollectionDOS") List<NoteCollectionDO> noteCollectionDOS);
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package com.hanserwei.hannote.note.biz.domain.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteLikeDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface NoteLikeDOMapper extends BaseMapper<NoteLikeDO> {
|
||||
@@ -13,4 +16,12 @@ public interface NoteLikeDOMapper extends BaseMapper<NoteLikeDO> {
|
||||
* @return 影响行数
|
||||
*/
|
||||
boolean insertOrUpdate(NoteLikeDO noteLikeDO);
|
||||
|
||||
/**
|
||||
* 批量插入或更新
|
||||
*
|
||||
* @param noteLikeDOS 批量笔记点赞记录
|
||||
* @return 影响行数
|
||||
*/
|
||||
int batchInsertOrUpdate(@Param("noteLikeDOS") List<NoteLikeDO> noteLikeDOS);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import com.hanserwei.hannote.note.biz.domain.dataobject.NoteDO;
|
||||
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteLikeDO;
|
||||
import com.hanserwei.hannote.note.biz.domain.dataobject.TopicDO;
|
||||
import com.hanserwei.hannote.note.biz.domain.mapper.NoteDOMapper;
|
||||
import com.hanserwei.hannote.note.biz.domain.mapper.NoteLikeDOMapper;
|
||||
import com.hanserwei.hannote.note.biz.enums.*;
|
||||
import com.hanserwei.hannote.note.biz.model.dto.CollectUnCollectNoteMqDTO;
|
||||
import com.hanserwei.hannote.note.biz.model.dto.LikeUnlikeNoteMqDTO;
|
||||
@@ -76,6 +77,8 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
@Resource
|
||||
private RocketMQTemplate rocketMQTemplate;
|
||||
@Resource
|
||||
private NoteLikeDOMapper noteLikeDOMapper;
|
||||
|
||||
/**
|
||||
* 笔记详情本地缓存
|
||||
@@ -630,14 +633,17 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
||||
// 2. 判断目标笔记,是否已经点赞过
|
||||
Long userId = LoginUserContextHolder.getUserId();
|
||||
|
||||
// 布隆过滤器Key
|
||||
String bloomUserNoteLikeListKey = RedisKeyConstants.buildBloomUserNoteLikeListKey(userId);
|
||||
// Roaring Bitmap Key
|
||||
String rbitmapUserNoteLikeListKey = RedisKeyConstants.buildRBitmapUserNoteLikeListKey(userId);
|
||||
|
||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||
// Lua 脚本路径
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_note_like_check.lua")));
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/rbitmap_note_like_check.lua")));
|
||||
// 返回值类型
|
||||
script.setResultType(Long.class);
|
||||
|
||||
// 执行 Lua 脚本,拿到返回结果
|
||||
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId);
|
||||
Long result = redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId);
|
||||
|
||||
NoteLikeLuaResultEnum noteLikeLuaResultEnum = NoteLikeLuaResultEnum.valueOf(result);
|
||||
|
||||
@@ -659,39 +665,26 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
||||
|
||||
// 目标笔记已经被点赞
|
||||
if (count > 0) {
|
||||
// 异步初始化布隆过滤器
|
||||
threadPoolTaskExecutor.submit(() -> batchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey));
|
||||
// 异步初始化 Roaring Bitmap
|
||||
threadPoolTaskExecutor.submit(() ->
|
||||
batchAddNoteLike2RBitmapAndExpire(userId, expireSeconds, rbitmapUserNoteLikeListKey));
|
||||
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
|
||||
}
|
||||
|
||||
// 若笔记未被点赞,查询当前用户是否点赞其他用户,有则同步初始化布隆过滤器
|
||||
batchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey);
|
||||
// 若目标笔记未被点赞,查询当前用户是否有点赞其他笔记,有则同步初始化 Roaring Bitmap
|
||||
batchAddNoteLike2RBitmapAndExpire(userId, expireSeconds, rbitmapUserNoteLikeListKey);
|
||||
|
||||
// 若数据库中也没有点赞记录,说明该用户还未点赞过任何笔记
|
||||
// 添加当前点赞笔记 ID 到 Roaring Bitmap 中
|
||||
// Lua 脚本路径
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_add_note_like_and_expire.lua")));
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/rbitmap_add_note_like_and_expire.lua")));
|
||||
// 返回值类型
|
||||
script.setResultType(Long.class);
|
||||
redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId, expireSeconds);
|
||||
redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId, expireSeconds);
|
||||
}
|
||||
// 目标笔记已经被点赞
|
||||
case NOTE_LIKED -> {
|
||||
// 校验 ZSet 列表中是否包含被点赞的笔记ID
|
||||
Double score = redisTemplate.opsForZSet().score(userNoteLikeZSetKey, noteId);
|
||||
|
||||
if (Objects.nonNull(score)) {
|
||||
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
|
||||
}
|
||||
// 若 Score 为空,则表示 ZSet 点赞列表中不存在,查询数据库校验
|
||||
long count = noteLikeDOService.count(new LambdaQueryWrapper<>(NoteLikeDO.class)
|
||||
.eq(NoteLikeDO::getNoteId, noteId)
|
||||
.eq(NoteLikeDO::getStatus, LikeStatusEnum.LIKE.getCode()));
|
||||
if (count > 0) {
|
||||
// 数据库里面有点赞记录,而 Redis 中 ZSet 不存在,需要重新异步初始化 ZSet
|
||||
asynInitUserNoteLikesZSet(userId, userNoteLikeZSetKey);
|
||||
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. 更新用户 ZSET 点赞列表
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -768,6 +761,37 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
||||
return Response.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化笔记点赞 Roaring Bitmap
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @param expireSeconds 过期时间
|
||||
* @param rbitmapUserNoteLikeListKey RBitmap 列表 Key
|
||||
*/
|
||||
private void batchAddNoteLike2RBitmapAndExpire(Long userId, long expireSeconds, String rbitmapUserNoteLikeListKey) {
|
||||
try {
|
||||
// 异步全量同步一下,并设置过期时间
|
||||
List<NoteLikeDO> noteLikeDOS = noteLikeDOMapper.selectList(new LambdaQueryWrapper<>(NoteLikeDO.class)
|
||||
.eq(NoteLikeDO::getUserId, userId));
|
||||
|
||||
if (CollUtil.isNotEmpty(noteLikeDOS)) {
|
||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||
// Lua 脚本路径
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/rbitmap_batch_add_note_like_and_expire.lua")));
|
||||
// 返回值类型
|
||||
script.setResultType(Long.class);
|
||||
|
||||
// 构建 Lua 参数
|
||||
List<Object> luaArgs = Lists.newArrayList();
|
||||
noteLikeDOS.forEach(noteLikeDO -> luaArgs.add(noteLikeDO.getNoteId())); // 将每个点赞的笔记 ID 传入
|
||||
luaArgs.add(expireSeconds); // 最后一个参数是过期时间(秒)
|
||||
redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), luaArgs.toArray());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("## 异步初始化【笔记点赞】Roaring Bitmap 异常: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response<?> unlikeNote(UnlikeNoteReqVO unlikeNoteReqVO) {
|
||||
// 笔记ID
|
||||
@@ -780,37 +804,38 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
||||
// 当前登录用户ID
|
||||
Long userId = LoginUserContextHolder.getUserId();
|
||||
|
||||
// 布隆过滤器Key
|
||||
String bloomUserNoteLikeListKey = RedisKeyConstants.buildBloomUserNoteLikeListKey(userId);
|
||||
// Roaring Bitmap Key
|
||||
String rbitmapUserNoteLikeListKey = RedisKeyConstants.buildRBitmapUserNoteLikeListKey(userId);
|
||||
|
||||
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
|
||||
// Lua 脚本路径
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_note_unlike_check.lua")));
|
||||
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/rbitmap_note_unlike_check.lua")));
|
||||
// 返回值类型
|
||||
script.setResultType(Long.class);
|
||||
|
||||
// 执行 Lua 脚本,拿到返回结果
|
||||
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId);
|
||||
Long result = redisTemplate.execute(script, Collections.singletonList(rbitmapUserNoteLikeListKey), noteId);
|
||||
|
||||
NoteUnlikeLuaResultEnum noteUnlikeLuaResultEnum = NoteUnlikeLuaResultEnum.valueOf(result);
|
||||
log.info("==> 【笔记取消点赞】Lua 脚本返回结果: {}", noteUnlikeLuaResultEnum);
|
||||
switch (Objects.requireNonNull(noteUnlikeLuaResultEnum)) {
|
||||
// 布隆过滤器不存在
|
||||
case NOT_EXIST -> {//笔记不存在
|
||||
//异步初始化布隆过滤器
|
||||
// 异步初始化 Roaring Bitmap
|
||||
threadPoolTaskExecutor.submit(() -> {
|
||||
// 保底1天+随机秒数
|
||||
long expireSeconds = 60 * 60 * 24 + RandomUtil.randomInt(60 * 60 * 24);
|
||||
batchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey);
|
||||
batchAddNoteLike2RBitmapAndExpire(userId, expireSeconds, rbitmapUserNoteLikeListKey);
|
||||
});
|
||||
|
||||
// 从数据库中校验笔记是否被点赞
|
||||
long count = noteLikeDOService.count(new LambdaQueryWrapper<>(NoteLikeDO.class)
|
||||
long count = noteLikeDOMapper.selectCount(new LambdaQueryWrapper<>(NoteLikeDO.class)
|
||||
.eq(NoteLikeDO::getUserId, userId)
|
||||
.eq(NoteLikeDO::getNoteId, noteId)
|
||||
.eq(NoteLikeDO::getStatus, LikeStatusEnum.LIKE.getCode()));
|
||||
if (count == 0) {
|
||||
log.info("==> 【笔记取消点赞】用户未点赞该笔记");
|
||||
throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
|
||||
}
|
||||
.eq(NoteLikeDO::getNoteId, noteId));
|
||||
|
||||
// 未点赞,无法取消点赞操作,抛出业务异常
|
||||
log.info("1111111");
|
||||
if (count == 0) throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
|
||||
}
|
||||
// 布隆过滤器校验目标笔记未被点赞(判断绝对正确)
|
||||
case NOTE_NOT_LIKED -> throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
|
||||
@@ -820,14 +845,9 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
|
||||
// 用户点赞列表ZsetKey
|
||||
String userNoteLikeZSetKey = RedisKeyConstants.buildUserNoteLikeZSetKey(userId);
|
||||
|
||||
// TODO: 后续考虑换掉布隆过滤器。
|
||||
|
||||
Long removed = redisTemplate.opsForZSet().remove(userNoteLikeZSetKey, noteId);
|
||||
|
||||
if (Objects.nonNull(removed) && removed == 0) {
|
||||
log.info("==> 【笔记取消点赞】用户未点赞该笔记");
|
||||
throw new ApiException(ResponseCodeEnum.NOTE_NOT_LIKED);
|
||||
}
|
||||
|
||||
//4. 发送 MQ, 数据更新落库
|
||||
// 构建MQ消息体
|
||||
|
||||
@@ -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)
|
||||
@@ -31,4 +31,13 @@
|
||||
and note_id = #{noteId}
|
||||
and status = 1
|
||||
</update>
|
||||
|
||||
<insert id="batchInsertOrUpdate" parameterType="list">
|
||||
INSERT INTO t_note_collection (user_id, note_id, status, create_time)
|
||||
VALUES
|
||||
<foreach item="item" collection="noteCollectionDOS" separator=",">
|
||||
(#{item.userId}, #{item.noteId}, #{item.status}, #{item.createTime})
|
||||
</foreach>
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status)
|
||||
</insert>
|
||||
</mapper>
|
||||
@@ -21,4 +21,13 @@
|
||||
ON DUPLICATE KEY UPDATE
|
||||
create_time = #{createTime}, status = #{status};
|
||||
</insert>
|
||||
|
||||
<insert id="batchInsertOrUpdate" parameterType="list">
|
||||
INSERT INTO t_note_like (user_id, note_id, status, create_time)
|
||||
VALUES
|
||||
<foreach item="item" collection="noteLikeDOS" separator=",">
|
||||
(#{item.userId}, #{item.noteId}, #{item.status}, #{item.createTime})
|
||||
</foreach>
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status)
|
||||
</insert>
|
||||
</mapper>
|
||||
@@ -108,6 +108,12 @@
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.rocketmq</groupId>
|
||||
<artifactId>rocketmq-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class JsonUtils {
|
||||
private static ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
@@ -95,4 +96,23 @@ public class JsonUtils {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串解析为指定类型的 Set 对象
|
||||
*
|
||||
* @param jsonStr JSON 字符串
|
||||
* @param clazz 目标对象类型
|
||||
* @param <T> 目标对象类型
|
||||
* @return Set 集合
|
||||
* @throws Exception 抛出异常
|
||||
*/
|
||||
public static <T> Set<T> parseSet(String jsonStr, Class<T> clazz) throws Exception {
|
||||
// 使用 TypeReference 指定 Set<T> 的泛型类型
|
||||
return OBJECT_MAPPER.readValue(jsonStr, new TypeReference<>() {
|
||||
@Override
|
||||
public CollectionType getType() {
|
||||
return OBJECT_MAPPER.getTypeFactory().constructCollectionType(Set.class, clazz);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ POST http://localhost:8000/auth/verification/code/send
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "2628273921@qq.com"
|
||||
"email": "ssw010723@gmail.com"
|
||||
}
|
||||
|
||||
### 登录/注册
|
||||
@@ -11,8 +11,8 @@ POST http://localhost:8000/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "2628273921@qq.com",
|
||||
"code": "825004",
|
||||
"email": "ssw010723@gmail.com",
|
||||
"code": "116253",
|
||||
"type": 1
|
||||
}
|
||||
|
||||
@@ -202,16 +202,16 @@ Content-Type: application/json
|
||||
Authorization: Bearer {{thirdToken}}
|
||||
|
||||
{
|
||||
"id": 1981698494959714362
|
||||
"id": 1985254482941837349
|
||||
}
|
||||
|
||||
### 笔记取消点赞入口
|
||||
POST http://localhost:8000/note/note/unlike
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{otherToken}}
|
||||
Authorization: Bearer {{thirdToken}}
|
||||
|
||||
{
|
||||
"id": 1977249693272375330
|
||||
"id": 1985254482941837349
|
||||
}
|
||||
|
||||
### 笔记收藏入口
|
||||
@@ -298,9 +298,9 @@ Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"noteId": 1862481582414102549,
|
||||
"content": "这是一条回复测试评论",
|
||||
"content": "这是一条测试同步Redis更新计数的评论",
|
||||
"imageUrl": "https://cdn.pixabay.com/photo/2025/10/05/15/06/autumn-9875155_1280.jpg",
|
||||
"replyCommentId": 2001
|
||||
"replyCommentId": 4002
|
||||
}
|
||||
|
||||
### 批量添加评论
|
||||
@@ -329,3 +329,77 @@ Content-Type: application/json
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
### 批量查询评论内容
|
||||
POST http://localhost:8084/kv/comment/content/batchFind
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"noteId": 1862481582414102549,
|
||||
"commentContentKeys": [
|
||||
{
|
||||
"yearMonth": "2025-11",
|
||||
"contentId": "a2537301-dc80-4fd6-ab2f-a9a908baebba"
|
||||
},
|
||||
{
|
||||
"yearMonth": "2025-11",
|
||||
"contentId": "a66b06ce-bf45-4a21-a5f2-31e5fb5fb5eb"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
### 分页查询评论
|
||||
POST http://localhost:8093/comment/list
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"noteId": 1862481582414102549,
|
||||
"pageNo": 1
|
||||
}
|
||||
|
||||
### 分页查询子评论
|
||||
POST http://localhost:8000/comment/comment/child/list
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"parentCommentId": 4002,
|
||||
"pageNo": 1
|
||||
}
|
||||
|
||||
### 点赞评论
|
||||
POST http://localhost:8000/comment/comment/like
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"commentId": 8001
|
||||
}
|
||||
|
||||
### 取消点赞评论
|
||||
POST http://localhost:8000/comment/comment/unlike
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"commentId": 8001
|
||||
}
|
||||
|
||||
### 删除评论
|
||||
POST http://localhost:8084/kv/comment/content/delete
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"noteId": 1862481582414102549,
|
||||
"yearMonth": "2025-11",
|
||||
"contentId": "0fa4376f-a098-4fee-821b-f5b7e627a72c"
|
||||
}
|
||||
|
||||
### 删除评论,同步删除一切相关缓存
|
||||
POST http://localhost:8000/comment/comment/delete
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"commentId": 8001
|
||||
}
|
||||
|
||||
@@ -280,4 +280,17 @@ CREATE TABLE `t_comment_like`
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='评论点赞表';
|
||||
|
||||
-- 表:t_comment表冗余字段
|
||||
alter table t_comment
|
||||
add column `child_comment_total` bigint(20) unsigned DEFAULT '0' COMMENT '二级评论总数(只有一级评论才需要统计)';
|
||||
|
||||
ALTER TABLE t_comment
|
||||
ADD COLUMN heat DECIMAL(10, 2) DEFAULT 0 COMMENT '评论热度';
|
||||
|
||||
alter table t_comment
|
||||
add column first_reply_comment_id bigint(20) unsigned default 0 COMMENT '最早回复的评论ID (只有一级评论需要)';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user