feat(user): 新增用户关注列表查询功能

- 新增查询用户关注列表接口,支持分页查询
- 新增批量查询用户信息接口,提升查询效率
- 优化 MQ 消费模式为顺序消费,确保关注/取关操作有序性
- 完善用户信息 DTO,新增简介字段
- 新增分页响应封装类,支持分页查询结果返回
- 优化 Redis 查询逻辑,支持从缓存中分页获取关注列表
- 新增 Lua 脚本结果类型设置,确保脚本执行结果正确解析
- 添加 HTTP 接口测试用例,覆盖关注列表及批量查询接口
- 实现缓存与数据库双写一致性,提高数据查询性能
This commit is contained in:
2025-10-14 22:29:13 +08:00
parent b70d9073d8
commit 1e350a4af5
16 changed files with 490 additions and 17 deletions

View File

@@ -16,6 +16,7 @@ import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.springframework.core.io.ClassPathResource;
@@ -32,7 +33,8 @@ import java.util.Objects;
@Component
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW,
topic = MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW
topic = MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW,
consumeMode = ConsumeMode.ORDERLY
)
@Slf4j
@RequiredArgsConstructor
@@ -165,6 +167,7 @@ public class FollowUnfollowConsumer implements RocketMQListener<Message> {
// Lua脚本
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/follow_check_and_update_fans_zset.lua")));
script.setResultType(Long.class);
// 时间戳
long timestamp = DateUtils.localDateTime2Timestamp(createTime);

View File

@@ -1,7 +1,10 @@
package com.hanserwei.hannote.user.relation.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.user.dto.req.FindFollowingListReqVO;
import com.hanserwei.hannote.user.dto.resp.FindFollowingUserRspVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.FollowUserReqVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.UnfollowUserReqVO;
import com.hanserwei.hannote.user.relation.biz.service.RelationService;
@@ -32,4 +35,10 @@ public class RelationController {
public Response<?> unfollow(@Validated @RequestBody UnfollowUserReqVO unfollowUserReqVO) {
return relationService.unfollow(unfollowUserReqVO);
}
@PostMapping("/following/list")
@ApiOperationLog(description = "查询用户关注列表")
public PageResponse<FindFollowingUserRspVO> findFollowingList(@Validated @RequestBody FindFollowingListReqVO findFollowingListReqVO) {
return relationService.findFollowingList(findFollowingListReqVO);
}
}

View File

@@ -1,12 +1,15 @@
package com.hanserwei.hannote.user.relation.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.FindUserByIdReqDTO;
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;
@Component
@@ -34,4 +37,22 @@ public class UserRpcService {
return response.getData();
}
/**
* 批量查询用户信息
*
* @param userIds 用户 ID集合
* @return 用户信息集合
*/
public List<FindUserByIdRspDTO> findByIds(List<Long> userIds) {
FindUsersByIdsReqDTO findUsersByIdsReqDTO = new FindUsersByIdsReqDTO();
findUsersByIdsReqDTO.setIds(userIds);
Response<List<FindUserByIdRspDTO>> response = userFeignApi.findByIds(findUsersByIdsReqDTO);
if (!response.isSuccess() || Objects.isNull(response.getData()) || CollUtil.isEmpty(response.getData())) {
return null;
}
return response.getData();
}
}

View File

@@ -1,6 +1,9 @@
package com.hanserwei.hannote.user.relation.biz.service;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.dto.req.FindFollowingListReqVO;
import com.hanserwei.hannote.user.dto.resp.FindFollowingUserRspVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.FollowUserReqVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.UnfollowUserReqVO;
@@ -20,4 +23,11 @@ public interface RelationService {
* @return 响应
*/
Response<?> unfollow(UnfollowUserReqVO unfollowUserReqVO);
/**
* 查询关注列表
* @param findFollowingListReqVO 查询关注列表请求
* @return 响应
*/
PageResponse<FindFollowingUserRspVO> findFollowingList(FindFollowingListReqVO findFollowingListReqVO);
}

View File

@@ -5,8 +5,11 @@ import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hanserwei.framework.biz.context.holder.LoginUserContextHolder;
import com.hanserwei.framework.common.exception.ApiException;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.user.dto.req.FindFollowingListReqVO;
import com.hanserwei.hannote.user.dto.resp.FindFollowingUserRspVO;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
import com.hanserwei.hannote.user.relation.biz.constant.MQConstants;
import com.hanserwei.hannote.user.relation.biz.constant.RedisKeyConstants;
@@ -38,6 +41,7 @@ import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service
@Slf4j
@@ -264,6 +268,81 @@ public class RelationServiceImpl implements RelationService {
return Response.success();
}
@Override
public PageResponse<FindFollowingUserRspVO> findFollowingList(FindFollowingListReqVO findFollowingListReqVO) {
// 要查询的用户ID
Long userId = findFollowingListReqVO.getUserId();
// 页码
Integer pageNo = findFollowingListReqVO.getPageNo();
// 先从Redis中查询
String followingRedisKey = RedisKeyConstants.buildUserFollowingKey(userId);
// 查询目标用户的关注列表ZSet的总大小
Long total = redisTemplate.opsForZSet().zCard(followingRedisKey);
log.info("==> 查询目标用户的关注列表ZSet的总大小{}", total);
// 构建回参
List<FindFollowingUserRspVO> findFollowingUserRspVOS = null;
if (total != null) {
//缓存有数据
//每页展示10条数据
long limit = 10L;
// 计算一共多少页
long totalPage = PageResponse.getTotalPage(total, limit);
// 请求页码超过总页数
if (pageNo > totalPage) {
log.info("==> 请求页码超过总页数,返回空数据");
return PageResponse.success(null, pageNo, total);
}
// 准备从ZSet中查询分页数据
// 每页展示10条数据计算偏移量
long offset = (pageNo - 1) * limit;
// 使用 ZREVRANGEBYSCORE 命令按 score 降序获取元素,同时使用 LIMIT 子句实现分页
// 注意:这里使用了 Double.POSITIVE_INFINITY 和 Double.NEGATIVE_INFINITY 作为分数范围
// 因为关注列表最多有 1000 个元素,这样可以确保获取到所有的元素
Set<Object> followingUserIdsSet = redisTemplate.opsForZSet()
.reverseRangeByScore(followingRedisKey,
Double.NEGATIVE_INFINITY,
Double.POSITIVE_INFINITY,
offset,
limit);
if (CollUtil.isNotEmpty(followingUserIdsSet)) {
//提取所有ID
List<Long> userIds = followingUserIdsSet.stream()
.map(object -> Long.parseLong(object.toString())).toList();
log.info("==> 批量查询用户信息用户ID: {}", userIds);
// RPC: 批量查询用户信息
List<FindUserByIdRspDTO> findUserByIdRspDTOS = userRpcService.findByIds(userIds);
log.info("==> 批量查询用户信息,结果: {}", findUserByIdRspDTOS);
// 若不为空则则DTO转换为VO
if (CollUtil.isNotEmpty(findUserByIdRspDTOS)) {
findFollowingUserRspVOS = findUserByIdRspDTOS.stream().map(findUserByIdRspDTO -> FindFollowingUserRspVO.builder()
.userId(findUserByIdRspDTO.getId())
.introduction(findUserByIdRspDTO.getIntroduction())
.nickname(findUserByIdRspDTO.getNickName())
.avatar(findUserByIdRspDTO.getAvatar())
.build()).toList();
}
}else {
// TODO: 若 Redis 中没有数据,则从数据库查询
// TODO: 异步将关注列表全量同步到 Redis
}
}
//noinspection DataFlowIssue
return PageResponse.success(findFollowingUserRspVOS,
pageNo,
total);
}
/**
* 校验 Lua 脚本结果,根据状态码抛出对应的业务异常
* @param result Lua 脚本返回结果

View File

@@ -3,6 +3,7 @@ package com.hanserwei.hannote.user.relation.biz;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.user.relation.biz.constant.MQConstants;
import com.hanserwei.hannote.user.relation.biz.model.dto.FollowUserMqDTO;
import com.hanserwei.hannote.user.relation.biz.model.dto.UnfollowUserMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.producer.SendCallback;
@@ -59,4 +60,69 @@ class MQTests {
}
}
/**
* 测试:发送对同一个用户关注、取关 MQ
*/
@Test
void testSendFollowUnfollowMQ() {
// 操作者用户ID
Long userId = 100L;
// 目标用户ID
Long targetUserId = 2100L;
for (long i = 0; i < 100; i++) {
if (i % 2 == 0) {
// 偶数发送关注 MQ
log.info("{} 是偶数", i);
// 发送 MQ
// 构建消息体 DTO
FollowUserMqDTO followUserMqDTO = FollowUserMqDTO.builder()
.userId(userId)
.followUserId(targetUserId)
.createTime(LocalDateTime.now())
.build();
// 构建消息对象,并将 DTO 转成 Json 字符串设置到消息体中
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(followUserMqDTO))
.build();
// 通过冒号连接, 可让 MQ 发送给主题 Topic 时,携带上标签 Tag
String destination = MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW + ":" + MQConstants.TAG_FOLLOW;
String hashKey = String.valueOf(userId);
// 发送 MQ 消息
SendResult sendResult = rocketMQTemplate.syncSendOrderly(destination, message, hashKey);
log.info("==> MQ 发送结果SendResult: {}", sendResult);
} else { // 取关发送取关 MQ
log.info("{} 是奇数", i);
// 发送 MQ
// 构建消息体 DTO
UnfollowUserMqDTO unfollowUserMqDTO = UnfollowUserMqDTO.builder()
.userId(userId)
.unfollowUserId(targetUserId)
.createTime(LocalDateTime.now())
.build();
// 构建消息对象,并将 DTO 转成 Json 字符串设置到消息体中
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(unfollowUserMqDTO))
.build();
// 通过冒号连接, 可让 MQ 发送给主题 Topic 时,携带上标签 Tag
String destination = MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW + ":" + MQConstants.TAG_UNFOLLOW;
String hashKey = String.valueOf(userId);
// 发送 MQ 消息
SendResult sendResult = rocketMQTemplate.syncSendOrderly(destination, message, hashKey);
log.info("==> MQ 发送结果SendResult: {}", sendResult);
}
}
}
}