feat(comment): 一级评论:子评论总数更新与查询

- 新增批量查询评论计数的数据库接口及SQL实现
- 优化本地缓存中评论ID失效判断逻辑,修正变量命名
- 增加从Redis中获取评论计数数据的功能,并支持缺失时回源数据库
- 实现评论计数数据异步同步至Redis的逻辑,包括子评论总数和点赞数
- 在消费端增加更新Redis中评论子评论总数的逻辑
- 添加评论计数相关的Redis Key和Field常量定义
- 更新HTTP测试用例中的评论内容和回复ID,验证计数同步功能
This commit is contained in:
2025-11-08 21:01:15 +08:00
parent 6f22c2b50d
commit e3f9b6a5b5
7 changed files with 230 additions and 10 deletions

View File

@@ -7,6 +7,19 @@ public class RedisKeyConstants {
*/
private static final String HAVE_FIRST_REPLY_COMMENT_KEY_PREFIX = "comment:havaFirstReplyCommentId:";
/**
* Hash Field: 子评论总数
*/
public static final String FIELD_CHILD_COMMENT_TOTAL = "childCommentTotal";
/**
* Hash Field: 点赞总数
*/
public static final String FIELD_LIKE_TOTAL = "likeTotal";
/**
* 评论维度计数 Key 前缀
*/
private static final String COUNT_COMMENT_KEY_PREFIX = "count:comment:";
/**
* Hash Field 键:评论总数
*/
@@ -27,6 +40,15 @@ public class RedisKeyConstants {
*/
private static final String COMMENT_DETAIL_KEY_PREFIX = "comment:detail:";
/**
* 构建评论维度计数 Key
*
* @param commentId 评论 ID
* @return 评论维度计数 Key
*/
public static String buildCountCommentKey(Long commentId) {
return COUNT_COMMENT_KEY_PREFIX + commentId;
}
/**
* 构建完整 KEY

View File

@@ -102,4 +102,12 @@ public interface CommentDOMapper extends BaseMapper<CommentDO> {
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);
}

View File

@@ -21,6 +21,7 @@ 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 com.hanserwei.hannote.comment.biz.enums.ResponseCodeEnum;
import com.hanserwei.hannote.comment.biz.model.dto.PublishCommentMqDTO;
import com.hanserwei.hannote.comment.biz.model.vo.*;
@@ -37,9 +38,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.util.Strings;
import org.jspecify.annotations.NonNull;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.data.redis.core.*;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
@@ -212,7 +211,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
// 先查询本地缓存
// 新建一个集合用于存储本地缓存中不存在的评论ID
List<Long> localeCacheExpiredCommentIds = Lists.newArrayList();
List<Long> localCacheExpiredCommentIds = Lists.newArrayList();
// 构建本地缓存的key集合
List<Long> localCacheKeys = commentIdList.stream()
@@ -225,7 +224,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
Map<Long, String> missingData = Maps.newHashMap();
missingKeys.forEach(key -> {
// 记录缓存中不存在的ID
localeCacheExpiredCommentIds.add(key);
localCacheExpiredCommentIds.add(key);
// 不存在的评论详情对其Value设置为空字符串
missingData.put(key, Strings.EMPTY);
});
@@ -233,7 +232,7 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
});
// 如果localCacheExpiredCommentIds的大小不等于commentIdList的大小说明本地缓存中有数据
if (CollUtil.size(localeCacheExpiredCommentIds) != commentIdList.size()) {
if (CollUtil.size(localCacheExpiredCommentIds) != commentIdList.size()) {
// 将本地缓存中的评论详情Json转为实体类添加到VO返参集合中
for (String value : commentIdAndDetailJsonMap.values()) {
if (StringUtils.isBlank(value)) continue;
@@ -243,12 +242,16 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
}
// 如果localCacheExpiredCommentIds大小为0说明评论详情全在本地缓存中直接响应返参
if (CollUtil.size(localeCacheExpiredCommentIds) == 0) {
if (CollUtil.size(localCacheExpiredCommentIds) == 0) {
// 计数数据需要从 Redis 中查
if (CollUtil.isNotEmpty(commentRspVOS)) {
setCommentCountData(commentRspVOS, localCacheExpiredCommentIds);
}
return PageResponse.success(commentRspVOS, pageNo, count, pageSize);
}
// 构建 MGET 批量查询评论详情的 Key 集合
List<String> commentIdKeys = localeCacheExpiredCommentIds.stream()
List<String> commentIdKeys = localCacheExpiredCommentIds.stream()
.map(RedisKeyConstants::buildCommentDetailKey)
.toList();
@@ -635,4 +638,142 @@ public class CommentServiceImpl extends ServiceImpl<CommentDOMapper, CommentDO>
});
}
}
/**
* 设置评论 VO 的计数
*
* @param commentRspVOS 返参 VO 集合
* @param expiredCommentIds 缓存中已失效的评论 ID 集合
*/
private void setCommentCountData(List<FindCommentItemRspVO> commentRspVOS,
List<Long> expiredCommentIds) {
// 准备从评论 Hash 中查询计数 (子评论总数、被点赞数)
// 缓存中存在的评论 ID
List<Long> notExpiredCommentIds = Lists.newArrayList();
// 遍历从缓存中解析出的 VO 集合,提取一级、二级评论 ID
commentRspVOS.forEach(commentRspVO -> {
Long oneLevelCommentId = commentRspVO.getCommentId();
notExpiredCommentIds.add(oneLevelCommentId);
FindCommentItemRspVO firstCommentVO = commentRspVO.getFirstReplyComment();
if (Objects.nonNull(firstCommentVO)) {
notExpiredCommentIds.add(firstCommentVO.getCommentId());
}
});
// 已失效的 Hash 评论 ID
List<Long> expiredCountCommentIds = Lists.newArrayList();
// 构建需要查询的 Hash Key 集合
List<String> commentCountKeys = notExpiredCommentIds.stream()
.map(RedisKeyConstants::buildCountCommentKey).toList();
// 使用 RedisTemplate 执行管道批量操作
List<Object> results = redisTemplate.executePipelined(new SessionCallback<>() {
@Override
public Object execute(@NonNull RedisOperations operations) {
// 遍历需要查询的评论计数的 Hash 键集合
commentCountKeys.forEach(key ->
// 在管道中执行 Redis 的 hash.entries 操作
// 此操作会获取指定 Hash 键中所有的字段和值
operations.opsForHash().entries(key));
return null;
}
});
// 评论 ID - 计数数据字典
Map<Long, Map<Object, Object>> commentIdAndCountMap = Maps.newHashMap();
// 遍历未过期的评论 ID 集合
for (int i = 0; i < notExpiredCommentIds.size(); i++) {
// 当前评论 ID
Long currCommentId = Long.valueOf(notExpiredCommentIds.get(i).toString());
// 从缓存查询结果中,获取对应 Hash
Map<Object, Object> hash = (Map<Object, Object>) results.get(i);
// 若 Hash 结果为空,说明缓存中不存在,添加到 expiredCountCommentIds 中,保存一下
if (CollUtil.isEmpty(hash)) {
expiredCountCommentIds.add(currCommentId);
continue;
}
// 若存在,则将数据添加到 commentIdAndCountMap 中,方便后续读取
commentIdAndCountMap.put(currCommentId, hash);
}
// 若已过期的计数评论 ID 集合大于 0说明部分计数数据不在 Redis 缓存中
// 需要查询数据库,并将这部分的评论计数 Hash 同步到 Redis 中
if (CollUtil.size(expiredCountCommentIds) > 0) {
// 查询数据库
List<CommentDO> commentDOS = commentDOMapper.selectCommentCountByIds(expiredCountCommentIds);
commentDOS.forEach(commentDO -> {
Integer level = commentDO.getLevel();
Map<Object, Object> map = Maps.newHashMap();
map.put(RedisKeyConstants.FIELD_LIKE_TOTAL, commentDO.getLikeTotal());
// 只有一级评论需要统计子评论总数
if (Objects.equals(level, CommentLevelEnum.ONE.getCode())) {
map.put(RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, commentDO.getChildCommentTotal());
}
// 统一添加到 commentIdAndCountMap 字典中,方便后续查询
commentIdAndCountMap.put(commentDO.getId(), map);
});
// 异步同步到 Redis 中
threadPoolTaskExecutor.execute(() -> {
redisTemplate.executePipelined(new SessionCallback<>() {
@Override
public Object execute(RedisOperations operations) {
commentDOS.forEach(commentDO -> {
// 构建 Hash Key
String key = RedisKeyConstants.buildCountCommentKey(commentDO.getId());
// 评论级别
Integer level = commentDO.getLevel();
// 设置 Field 数据
Map<String, Long> fieldsMap = Objects.equals(level, CommentLevelEnum.ONE.getCode()) ?
Map.of(RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, commentDO.getChildCommentTotal(),
RedisKeyConstants.FIELD_LIKE_TOTAL, commentDO.getLikeTotal()) : Map.of(RedisKeyConstants.FIELD_LIKE_TOTAL, commentDO.getLikeTotal());
// 添加 Hash 数据
operations.opsForHash().putAll(key, fieldsMap);
// 设置随机过期时间 (5小时以内)
long expireTime = RandomUtil.randomInt(5 * 60 * 60);
operations.expire(key, expireTime, TimeUnit.SECONDS);
});
return null;
}
});
});
}
// 遍历 VO, 设置对应评论的二级评论数、点赞数
for (FindCommentItemRspVO commentRspVO : commentRspVOS) {
// 评论 ID
Long commentId = commentRspVO.getCommentId();
// 若当前这条评论是从数据库中查询出来的, 则无需设置二级评论数、点赞数,以数据库查询出来的为主
if (CollUtil.isNotEmpty(expiredCommentIds)
&& expiredCommentIds.contains(commentId)) {
continue;
}
// 设置一级评论的子评论总数、点赞数
Map<Object, Object> hash = commentIdAndCountMap.get(commentId);
if (CollUtil.isNotEmpty(hash)) {
Object childCommentTotalObj = hash.get(RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL);
Long childCommentTotal = Objects.isNull(childCommentTotalObj) ? 0 : Long.parseLong(childCommentTotalObj.toString());
Object likeTotalObj = hash.get(RedisKeyConstants.FIELD_LIKE_TOTAL);
Long likeTotal = Objects.isNull(likeTotalObj) ? 0 : Long.parseLong(likeTotalObj.toString());
commentRspVO.setChildCommentTotal(childCommentTotal);
commentRspVO.setLikeTotal(likeTotal);
// 最初回复的二级评论
FindCommentItemRspVO firstCommentVO = commentRspVO.getFirstReplyComment();
if (Objects.nonNull(firstCommentVO)) {
Long firstCommentId = firstCommentVO.getCommentId();
Map<Object, Object> firstCommentHash = commentIdAndCountMap.get(firstCommentId);
if (CollUtil.isNotEmpty(firstCommentHash)) {
Long firstCommentLikeTotal = Long.valueOf(firstCommentHash.get(RedisKeyConstants.FIELD_LIKE_TOTAL).toString());
firstCommentVO.setLikeTotal(firstCommentLikeTotal);
}
}
}
}
}
}

View File

@@ -180,4 +180,16 @@
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>
</mapper>