Compare commits

..

6 Commits

Author SHA1 Message Date
e0cf96edbf Revert "feat(comment): 新增评论删除及缓存清理功能"
This reverts commit 6985431236.
2025-11-09 15:19:48 +08:00
d9a960e265 Merge remote-tracking branch 'all/master'
# Conflicts:
#	han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/ResponseCodeEnum.java
#	han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/impl/CommentServiceImpl.java
#	http-client/gateApi.http
2025-11-09 15:18:17 +08:00
6985431236 feat(comment): 新增评论删除及缓存清理功能
- 新增删除一级评论及其子评论的接口与实现- 新增批量删除评论的功能支持
- 新增根据回复评论ID查询评论的方法
- 为CommentLevelEnum添加通过code获取枚举值的方法
- 实现评论本地缓存删除服务接口
- 新增删除评论后的MQ消费者处理逻辑
- 新增删除评论本地缓存的MQ广播消费逻辑
- 扩展NoteCountDOMapper以支持评论总数更新操作
2025-11-09 15:16:20 +08:00
85e0238857 feat(comment): 新增删除评论功能
- 新增删除评论接口,支持物理删除评论及关联内容
- 添加权限校验,仅允许评论创建者删除评论- 使用编程式事务保证删除操作的原子性- 删除评论后清理 Redis 缓存(ZSet 和 String 类型)
- 发送 MQ 消息异步更新计数、删除关联数据及本地缓存
- 新增 DeleteCommentReqVO 请求参数类校验评论 ID
- 补充 KeyValueRpcService 删除评论内容方法
- 新增相关 MQ Topic 常量及响应码枚举
- 更新 HTTP 接口测试用例
2025-11-09 14:12:24 +08:00
93ca81a15b feat(kv): 新增删除评论内容功能
- 在 CommentContentController 中新增删除评论内容的接口
- 定义 DeleteCommentContentReqDTO 用于接收删除请求参数
- 在 CommentContentRepository 中新增删除评论正文的方法
- 在 CommentContentService 及其实现类中新增删除评论内容的业务逻辑
- 在 KeyValueFeignApi 中新增删除评论内容的 Feign 接口
- 在 gateApi.http 中添加删除评论内容的测试用例
2025-11-09 14:01:14 +08:00
f74397ed1e feat(comment): 计数服务:评论点赞数更新,取消点赞接口
- 新增取消点赞接口 /comment/unlike
- 添加布隆过滤器校验评论是否已点赞
- 实现取消点赞时从布隆过滤器中移除记录
- 发送取消点赞消息到 RocketMQ 进行异步处理
- 新增取消点赞相关枚举和异常码
- 更新计数服务消费点赞/取消点赞消息逻辑
- 支持评论点赞数的增减与持久化更新
- 添加 HTTP 客户端测试用例
2025-11-09 13:53:07 +08:00
25 changed files with 730 additions and 17 deletions

View File

@@ -22,9 +22,24 @@ public interface MQConstants {
*/ */
String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic"; String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic";
/**
* Topic: 删除本地缓存 —— 评论详情
*/
String TOPIC_DELETE_COMMENT_LOCAL_CACHE = "DeleteCommentDetailLocalCacheTopic";
/**
* Topic: 删除评论
*/
String TOPIC_DELETE_COMMENT = "DeleteCommentTopic";
/** /**
* Tag 标签:点赞 * Tag 标签:点赞
*/ */
String TAG_LIKE = "Like"; String TAG_LIKE = "Like";
/**
* Tag 标签:取消点赞
*/
String TAG_UNLIKE = "UnLike";
} }

View File

@@ -45,4 +45,16 @@ public class CommentController {
return commentService.likeComment(likeCommentReqVO); return commentService.likeComment(likeCommentReqVO);
} }
@PostMapping("/unlike")
@ApiOperationLog(description = "评论取消点赞")
public Response<?> unlikeComment(@Validated @RequestBody UnLikeCommentReqVO unLikeCommentReqVO) {
return commentService.unlikeComment(unLikeCommentReqVO);
}
@PostMapping("/delete")
@ApiOperationLog(description = "删除评论")
public Response<?> deleteComment(@Validated @RequestBody DeleteCommentReqVO deleteCommentReqVO) {
return commentService.deleteComment(deleteCommentReqVO);
}
} }

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
package com.hanserwei.hannote.comment.biz.model.vo;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class DeleteCommentReqVO {
@NotNull(message = "评论 ID 不能为空")
private Long commentId;
}

View File

@@ -0,0 +1,18 @@
package com.hanserwei.hannote.comment.biz.model.vo;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UnLikeCommentReqVO {
@NotNull(message = "评论 ID 不能为空")
private Long commentId;
}

View File

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

View File

@@ -38,4 +38,20 @@ public interface CommentService extends IService<CommentDO> {
* @return 响应 * @return 响应
*/ */
Response<?> likeComment(LikeCommentReqVO likeCommentReqVO); Response<?> likeComment(LikeCommentReqVO likeCommentReqVO);
/**
* 取消评论点赞
*
* @param unLikeCommentReqVO 取消评论点赞请求
* @return 响应
*/
Response<?> unlikeComment(UnLikeCommentReqVO unLikeCommentReqVO);
/**
* 删除评论
*
* @param deleteCommentReqVO 删除评论请求
* @return 响应
*/
Response<?> deleteComment(DeleteCommentReqVO deleteCommentReqVO);
} }

View File

@@ -23,10 +23,7 @@ import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper; import com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentLikeDOMapper; import com.hanserwei.hannote.comment.biz.domain.mapper.CommentLikeDOMapper;
import com.hanserwei.hannote.comment.biz.domain.mapper.NoteCountDOMapper; import com.hanserwei.hannote.comment.biz.domain.mapper.NoteCountDOMapper;
import com.hanserwei.hannote.comment.biz.enums.CommentLevelEnum; import com.hanserwei.hannote.comment.biz.enums.*;
import com.hanserwei.hannote.comment.biz.enums.CommentLikeLuaResultEnum;
import com.hanserwei.hannote.comment.biz.enums.LikeUnlikeCommentTypeEnum;
import com.hanserwei.hannote.comment.biz.enums.ResponseCodeEnum;
import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO; import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO;
import com.hanserwei.hannote.comment.biz.model.dto.PublishCommentMqDTO; import com.hanserwei.hannote.comment.biz.model.dto.PublishCommentMqDTO;
import com.hanserwei.hannote.comment.biz.model.vo.*; import com.hanserwei.hannote.comment.biz.model.vo.*;
@@ -55,6 +52,7 @@ import org.springframework.messaging.support.MessageBuilder;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
@@ -85,6 +83,8 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
private ThreadPoolTaskExecutor threadPoolTaskExecutor; private ThreadPoolTaskExecutor threadPoolTaskExecutor;
@Resource @Resource
private CommentLikeDOMapper commentLikeDOMapper; private CommentLikeDOMapper commentLikeDOMapper;
@Resource
private TransactionTemplate transactionTemplate;
/** /**
* 评论详情本地缓存 * 评论详情本地缓存
@@ -539,6 +539,185 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
return Response.success(); return Response.success();
} }
@Override
public Response<?> unlikeComment(UnLikeCommentReqVO unLikeCommentReqVO) {
// 被取消点赞的评论 ID
Long commentId = unLikeCommentReqVO.getCommentId();
// 1. 校验评论是否存在
checkCommentIsExist(commentId);
// 2. 校验评论是否被点赞过
// 当前登录用户ID
Long userId = LoginUserContextHolder.getUserId();
// 布隆过滤器 Key
String bloomUserCommentLikeListKey = RedisKeyConstants.buildBloomCommentLikesKey(userId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_comment_unlike_check.lua")));
// 返回值类型
script.setResultType(Long.class);
// 执行 Lua 脚本,拿到返回结果
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserCommentLikeListKey), commentId);
CommentUnlikeLuaResultEnum commentUnlikeLuaResultEnum = CommentUnlikeLuaResultEnum.valueOf(result);
if (Objects.isNull(commentUnlikeLuaResultEnum)) {
throw new ApiException(ResponseCodeEnum.PARAM_NOT_VALID);
}
switch (commentUnlikeLuaResultEnum) {
// 布隆过滤器不存在
case NOT_EXIST -> {
// 异步初始化布隆过滤器
threadPoolTaskExecutor.submit(() -> {
// 保底1小时+随机秒数
long expireSeconds = 60 * 60 + RandomUtil.randomInt(60 * 60);
batchAddCommentLike2BloomAndExpire(userId, expireSeconds, bloomUserCommentLikeListKey);
});
// 从数据库中校验评论是否被点赞
int count = commentLikeDOMapper.selectCountByUserIdAndCommentId(userId, commentId);
// 未点赞,无法取消点赞操作,抛出业务异常
if (count == 0) throw new ApiException(ResponseCodeEnum.COMMENT_NOT_LIKED);
}
// 布隆过滤器校验目标评论未被点赞(判断绝对正确)
case COMMENT_NOT_LIKED -> throw new ApiException(ResponseCodeEnum.COMMENT_NOT_LIKED);
}
// 3. 发送顺序 MQ删除评论点赞记录
// 构建消息体 DTO
LikeUnlikeCommentMqDTO likeUnlikeCommentMqDTO = LikeUnlikeCommentMqDTO.builder()
.userId(userId)
.commentId(commentId)
.type(LikeUnlikeCommentTypeEnum.UNLIKE.getCode()) // 取消点赞评论
.createTime(LocalDateTime.now())
.build();
// 构建消息对象,并将 DTO 转成 Json 字符串设置到消息体中
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(likeUnlikeCommentMqDTO))
.build();
// 通过冒号连接, 可让 MQ 发送给主题 Topic 时,携带上标签 Tag
String destination = MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE + ":" + MQConstants.TAG_UNLIKE;
// MQ 分区键
String hashKey = String.valueOf(userId);
// 异步发送 MQ 顺序消息,提升接口响应速度
rocketMQTemplate.asyncSendOrderly(destination, message, hashKey, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【评论取消点赞】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【评论取消点赞】MQ 发送异常: ", throwable);
}
});
return Response.success();
}
@Override
@SuppressWarnings("unchecked")
public Response<?> deleteComment(DeleteCommentReqVO deleteCommentReqVO) {
// 被删除的评论 ID
Long commentId = deleteCommentReqVO.getCommentId();
// 1. 校验评论是否存在
CommentDO commentDO = commentDOMapper.selectById(commentId);
if (Objects.isNull(commentDO)) {
throw new ApiException(ResponseCodeEnum.COMMENT_NOT_FOUND);
}
// 2. 校验是否有权限删除
Long currUserId = LoginUserContextHolder.getUserId();
if (!Objects.equals(currUserId, commentDO.getUserId())) {
throw new ApiException(ResponseCodeEnum.COMMENT_CANT_OPERATE);
}
// 3. 物理删除评论、评论内容
// 编程式事务,保证多个操作的原子性
transactionTemplate.execute(status -> {
try {
// 删除评论元数据
commentDOMapper.deleteById(commentId);
// 删除评论内容
keyValueRpcService.deleteCommentContent(commentDO.getNoteId(),
commentDO.getCreateTime(),
commentDO.getContentUuid());
return null;
} catch (Exception ex) {
status.setRollbackOnly(); // 标记事务为回滚
log.error("", ex);
throw ex;
}
});
// 4. 删除 Redis 缓存ZSet 和 String
Integer level = commentDO.getLevel();
Long noteId = commentDO.getNoteId();
Long parentCommentId = commentDO.getParentId();
// 根据评论级别,构建对应的 ZSet Key
String redisZSetKey = Objects.equals(level, 1) ?
RedisKeyConstants.buildCommentListKey(noteId) : RedisKeyConstants.buildChildCommentListKey(parentCommentId);
// 使用 RedisTemplate 执行管道操作
redisTemplate.executePipelined(new SessionCallback<>() {
@Override
public Object execute(@NonNull RedisOperations operations) {
// 删除 ZSet 中对应评论 ID
operations.opsForZSet().remove(redisZSetKey, commentId);
// 删除评论详情
operations.delete(RedisKeyConstants.buildCommentDetailKey(commentId));
return null;
}
});
// 5. 发布广播 MQ, 将本地缓存删除
rocketMQTemplate.asyncSend(MQConstants.TOPIC_DELETE_COMMENT_LOCAL_CACHE, commentId, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【删除评论详情本地缓存】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【删除评论详情本地缓存】MQ 发送异常: ", throwable);
}
});
// 6. 发送 MQ, 异步去更新计数、删除关联评论、热度值等
// 构建消息对象,并将 DO 转成 Json 字符串设置到消息体中
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(commentDO))
.build();
// 异步发送 MQ 消息,提升接口响应速度
rocketMQTemplate.asyncSend(MQConstants.TOPIC_DELETE_COMMENT, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【评论删除】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【评论删除】MQ 发送异常: ", throwable);
}
});
return Response.success();
}
/** /**
* 初始化评论点赞布隆过滤器 * 初始化评论点赞布隆过滤器
* *

View File

@@ -0,0 +1,11 @@
local key = KEYS[1] -- 操作的 Redis Key
local commentId = ARGV[1] -- 评论ID
-- 使用 EXISTS 命令检查布隆过滤器是否存在
local exists = redis.call('EXISTS', key)
if exists == 0 then
return -1
end
-- 校验该评论是否被点赞过(1 表示已经点赞0 表示未点赞)
return redis.call('BF.EXISTS', key, commentId)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ POST http://localhost:8000/auth/verification/code/send
Content-Type: application/json Content-Type: application/json
{ {
"email": "2628273921@qq.com" "email": "ssw010723@gmail.com"
} }
### 登录/注册 ### 登录/注册
@@ -11,8 +11,8 @@ POST http://localhost:8000/auth/login
Content-Type: application/json Content-Type: application/json
{ {
"email": "2628273921@qq.com", "email": "ssw010723@gmail.com",
"code": "825004", "code": "116253",
"type": 1 "type": 1
} }
@@ -368,9 +368,38 @@ Authorization: Bearer {{token}}
} }
### 点赞评论 ### 点赞评论
POST http://localhost:8093/comment/like 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 Content-Type: application/json
{ {
"commentId": 6001 "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
} }