feat(relation): 实现用户粉丝列表查询功能

- 新增查询用户粉丝列表接口
- 定义粉丝列表请求参数类 FindFansListReqVO- 定义粉丝信息响应类 FindFansUserRspVO
- 在 RelationController 中添加 /fans/list POST 接口
- 在 RelationService 中定义 findFansList 方法
- 在 RelationServiceImpl 中实现粉丝列表查询逻辑
- 支持 Redis 缓存查询与数据库分页查询
- 实现粉丝列表异步同步至 Redis 功能
- 添加 HTTP 客户端测试用例
This commit is contained in:
2025-10-15 17:40:36 +08:00
parent 5e4f9b1203
commit 9e3c35043e
7 changed files with 248 additions and 2 deletions

View File

@@ -1,6 +1,9 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="ConstantValue" enabled="true" level="WARNING" enabled_by_default="true">
<option name="REPORT_CONSTANT_REFERENCE_VALUES" value="false" />
</inspection_tool>
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true"> <inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages> <Languages>
<language minSize="56" name="Java" /> <language minSize="56" name="Java" />

View File

@@ -5,6 +5,8 @@ import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response; import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.dto.req.FindFollowingListReqVO; import com.hanserwei.hannote.user.dto.req.FindFollowingListReqVO;
import com.hanserwei.hannote.user.dto.resp.FindFollowingUserRspVO; import com.hanserwei.hannote.user.dto.resp.FindFollowingUserRspVO;
import com.hanserwei.hannote.user.relation.biz.model.dto.FindFansUserRspVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.FindFansListReqVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.FollowUserReqVO; 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.model.vo.UnfollowUserReqVO;
import com.hanserwei.hannote.user.relation.biz.service.RelationService; import com.hanserwei.hannote.user.relation.biz.service.RelationService;
@@ -41,4 +43,10 @@ public class RelationController {
public PageResponse<FindFollowingUserRspVO> findFollowingList(@Validated @RequestBody FindFollowingListReqVO findFollowingListReqVO) { public PageResponse<FindFollowingUserRspVO> findFollowingList(@Validated @RequestBody FindFollowingListReqVO findFollowingListReqVO) {
return relationService.findFollowingList(findFollowingListReqVO); return relationService.findFollowingList(findFollowingListReqVO);
} }
@PostMapping("/fans/list")
@ApiOperationLog(description = "查询用户粉丝列表")
public PageResponse<FindFansUserRspVO> findFansList(@Validated @RequestBody FindFansListReqVO findFansListReqVO) {
return relationService.findFansList(findFansListReqVO);
}
} }

View File

@@ -0,0 +1,39 @@
package com.hanserwei.hannote.user.relation.biz.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindFansUserRspVO {
/**
* 用户ID
*/
private Long userId;
/**
* 头像
*/
private String avatar;
/**
* 昵称
*/
private String nickname;
/**
* 粉丝总数
*/
private Long fansTotal;
/**
* 笔记总数
*/
private Long noteTotal;
}

View File

@@ -0,0 +1,20 @@
package com.hanserwei.hannote.user.relation.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 FindFansListReqVO {
@NotNull(message = "查询用户 ID 不能为空")
private Long userId;
@NotNull(message = "页码不能为空")
private Integer pageNo = 1; // 默认值为第一页
}

View File

@@ -4,6 +4,8 @@ import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response; import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.dto.req.FindFollowingListReqVO; import com.hanserwei.hannote.user.dto.req.FindFollowingListReqVO;
import com.hanserwei.hannote.user.dto.resp.FindFollowingUserRspVO; import com.hanserwei.hannote.user.dto.resp.FindFollowingUserRspVO;
import com.hanserwei.hannote.user.relation.biz.model.dto.FindFansUserRspVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.FindFansListReqVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.FollowUserReqVO; 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.model.vo.UnfollowUserReqVO;
@@ -30,4 +32,13 @@ public interface RelationService {
* @return 响应 * @return 响应
*/ */
PageResponse<FindFollowingUserRspVO> findFollowingList(FindFollowingListReqVO findFollowingListReqVO); PageResponse<FindFollowingUserRspVO> findFollowingList(FindFollowingListReqVO findFollowingListReqVO);
/**
* 查询粉丝列表
*
* @param findFansListReqVO 查询粉丝列表请求
* @return 响应
*/
PageResponse<FindFansUserRspVO> findFansList(FindFansListReqVO findFansListReqVO);
} }

View File

@@ -14,14 +14,18 @@ import com.hanserwei.hannote.user.dto.resp.FindFollowingUserRspVO;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO; 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.MQConstants;
import com.hanserwei.hannote.user.relation.biz.constant.RedisKeyConstants; import com.hanserwei.hannote.user.relation.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.user.relation.biz.domain.dataobject.FansDO;
import com.hanserwei.hannote.user.relation.biz.domain.dataobject.FollowingDO; import com.hanserwei.hannote.user.relation.biz.domain.dataobject.FollowingDO;
import com.hanserwei.hannote.user.relation.biz.enums.LuaResultEnum; import com.hanserwei.hannote.user.relation.biz.enums.LuaResultEnum;
import com.hanserwei.hannote.user.relation.biz.enums.ResponseCodeEnum; import com.hanserwei.hannote.user.relation.biz.enums.ResponseCodeEnum;
import com.hanserwei.hannote.user.relation.biz.model.dto.FindFansUserRspVO;
import com.hanserwei.hannote.user.relation.biz.model.dto.FollowUserMqDTO; import com.hanserwei.hannote.user.relation.biz.model.dto.FollowUserMqDTO;
import com.hanserwei.hannote.user.relation.biz.model.dto.UnfollowUserMqDTO; import com.hanserwei.hannote.user.relation.biz.model.dto.UnfollowUserMqDTO;
import com.hanserwei.hannote.user.relation.biz.model.vo.FindFansListReqVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.FollowUserReqVO; 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.model.vo.UnfollowUserReqVO;
import com.hanserwei.hannote.user.relation.biz.rpc.UserRpcService; import com.hanserwei.hannote.user.relation.biz.rpc.UserRpcService;
import com.hanserwei.hannote.user.relation.biz.service.FansDOService;
import com.hanserwei.hannote.user.relation.biz.service.FollowingDOService; import com.hanserwei.hannote.user.relation.biz.service.FollowingDOService;
import com.hanserwei.hannote.user.relation.biz.service.RelationService; import com.hanserwei.hannote.user.relation.biz.service.RelationService;
import com.hanserwei.hannote.user.relation.biz.util.DateUtils; import com.hanserwei.hannote.user.relation.biz.util.DateUtils;
@@ -59,6 +63,8 @@ public class RelationServiceImpl implements RelationService {
private RocketMQTemplate rocketMQTemplate; private RocketMQTemplate rocketMQTemplate;
@Resource(name = "relationTaskExecutor") @Resource(name = "relationTaskExecutor")
private ThreadPoolTaskExecutor taskExecutor; private ThreadPoolTaskExecutor taskExecutor;
@Resource
private FansDOService fansDOService;
@Override @Override
public Response<?> follow(FollowUserReqVO followUserReqVO) { public Response<?> follow(FollowUserReqVO followUserReqVO) {
@@ -321,7 +327,6 @@ public class RelationServiceImpl implements RelationService {
log.info("==> 批量查询用户信息用户ID: {}", userIds); log.info("==> 批量查询用户信息用户ID: {}", userIds);
// RPC: 批量查询用户信息 // RPC: 批量查询用户信息
//noinspection ConstantValue
findFollowingUserRspVOS = rpcUserServiceAndDTO2VO(userIds, findFollowingUserRspVOS); findFollowingUserRspVOS = rpcUserServiceAndDTO2VO(userIds, findFollowingUserRspVOS);
} }
} else { } else {
@@ -358,7 +363,6 @@ public class RelationServiceImpl implements RelationService {
List<Long> userIds = followingDOS.stream().map(FollowingDO::getFollowingUserId).toList(); List<Long> userIds = followingDOS.stream().map(FollowingDO::getFollowingUserId).toList();
// RPC: 调用用户服务,并将 DTO 转换为 VO // RPC: 调用用户服务,并将 DTO 转换为 VO
//noinspection ConstantValue
findFollowingUserRspVOS = rpcUserServiceAndDTO2VO(userIds, findFollowingUserRspVOS); findFollowingUserRspVOS = rpcUserServiceAndDTO2VO(userIds, findFollowingUserRspVOS);
// 异步将关注列表全量同步到 Redis // 异步将关注列表全量同步到 Redis
@@ -372,6 +376,157 @@ public class RelationServiceImpl implements RelationService {
total); total);
} }
@Override
public PageResponse<FindFansUserRspVO> findFansList(FindFansListReqVO findFansListReqVO) {
// 要查询的用户ID
Long userId = findFansListReqVO.getUserId();
// 页码
Integer pageNo = findFansListReqVO.getPageNo();
// 先从Redis中查询
String fansListRedisKey = RedisKeyConstants.buildUserFansKey(userId);
// 查询目标用户粉丝列表 ZSet 的总大小
Long total = redisTemplate.opsForZSet().zCard(fansListRedisKey);
// 构建回参
List<FindFansUserRspVO> findFansUserRspVOS = null;
// 每页展示10条数据
long limit = 10L;
if (total != null && total > 0) {
// 缓存有数据
// 计算一共多少页
long totalPage = PageResponse.getTotalPage(total, limit);
// 请求页码超过总页数
if (pageNo > totalPage) {
log.info("==> 查询粉丝列表请求页码超过总页数,返回空数据");
return PageResponse.success(null, pageNo, total);
}
// 准备从 Redis 中查询 ZSet 分页数据
// 每页 10 个元素,计算偏移量
long offset = PageResponse.getOffset(pageNo, limit);
// 使用 ZREVRANGEBYSCORE 命令按 score 降序获取元素,同时使用 LIMIT 子句实现分页
Set<Object> followingUserIdsSet = redisTemplate.opsForZSet()
.reverseRangeByScore(fansListRedisKey, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, offset, limit);
if (CollUtil.isNotEmpty(followingUserIdsSet)) {
// 提取所有用户 ID 到集合中
List<Long> userIds = followingUserIdsSet.stream().map(object -> Long.valueOf(object.toString())).toList();
// RPC: 批量查询用户信息
findFansUserRspVOS = rpcUserServiceAndCountServiceAndDTO2VO(userIds, findFansUserRspVOS);
}
} else {
// 若 Redis 中没有数据,则从数据库查询
// 先查询记录总量
total = fansDOService.count(new LambdaQueryWrapper<>(FansDO.class).eq(FansDO::getUserId, userId));
// 获取一共多少页
long totalPage = PageResponse.getTotalPage(total, limit);
// 请求的页码超出了总页数(只允许查询前 500 页)
if (pageNo > totalPage || pageNo > 500) {
log.info("==> 查询粉丝列表页码大于总页数或者请求的页码超出了总页数,返回空数据");
return PageResponse.success(null, pageNo, total);
}
// 偏移量
long offset = PageResponse.getOffset(pageNo, limit);
// 分页查询
Page<FansDO> page = fansDOService.page(new Page<>(offset / limit + 1, limit),
new LambdaQueryWrapper<>(FansDO.class)
.select(FansDO::getFansUserId)
.eq(FansDO::getUserId, userId)
.orderByDesc(FansDO::getCreateTime));
List<FansDO> fansDOS = page.getRecords();
log.info("==> 查询到粉丝列表:{}", JsonUtils.toJsonString(fansDOS));
// 若记录不为空
if (CollUtil.isNotEmpty(fansDOS)) {
// 提取所有用户 ID 到集合中
List<Long> userIds = fansDOS.stream().map(FansDO::getFansUserId).toList();
// RPC: 调用用户服务、计数服务,并将 DTO 转换为 VO
findFansUserRspVOS = rpcUserServiceAndCountServiceAndDTO2VO(userIds, findFansUserRspVOS);
// 异步将粉丝列表同步到 Redis最多5000条
taskExecutor.submit(() -> syncFansList2Redis(userId));
}
}
return PageResponse.success(findFansUserRspVOS, pageNo, total);
}
private void syncFansList2Redis(Long userId) {
// 同步粉丝列表到 Redis
// 查询粉丝列表最多5000条
Page<FansDO> page = fansDOService.page(new Page<>(1, 5000),
new LambdaQueryWrapper<>(FansDO.class)
.select(FansDO::getFansUserId, FansDO::getCreateTime)
.eq(FansDO::getUserId, userId)
.orderByDesc(FansDO::getCreateTime));
List<FansDO> fansDOS = page.getRecords();
if (CollUtil.isNotEmpty(fansDOS)) {
// 用户粉丝列表的Redis Key
String fansListRedisKey = RedisKeyConstants.buildUserFansKey(userId);
// 随机过期时间,保底一天+随机秒数
long expireSeconds = 86400 + RandomUtil.randomLong(0, 86400);
// 构建 Lua 参数
Object[] luaArgs = buildFansZSetLuaArgs(fansDOS, expireSeconds);
// 执行 Lua 脚本,批量同步关注关系数据到 Redis 中
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/follow_batch_add_and_expire.lua")));
script.setResultType(Long.class);
redisTemplate.execute(script, Collections.singletonList(fansListRedisKey), luaArgs);
}
}
/**
* 构建 Lua 脚本参数 :粉丝列表
*
* @param fansDOS 粉丝DO列表
* @param expireSeconds 过期时间
* @return 参数列表
*/
private Object[] buildFansZSetLuaArgs(List<FansDO> fansDOS, long expireSeconds) {
int argsLength = fansDOS.size() * 2 + 1; // 每个粉丝关系有 2 个参数score 和 value再加一个过期时间
Object[] luaArgs = new Object[argsLength];
int i = 0;
for (FansDO fansDO : fansDOS) {
luaArgs[i] = DateUtils.localDateTime2Timestamp(fansDO.getCreateTime()); // 粉丝的关注时间作为 score
luaArgs[i + 1] = fansDO.getFansUserId(); // 粉丝的用户 ID 作为 ZSet value
i += 2;
}
luaArgs[argsLength - 1] = expireSeconds; // 最后一个参数是 ZSet 的过期时间
return luaArgs;
}
private List<FindFansUserRspVO> rpcUserServiceAndCountServiceAndDTO2VO(List<Long> userIds, List<FindFansUserRspVO> findFansUserRspVOS) {
// RPC: 批量查询用户信息
List<FindUserByIdRspDTO> findUserByIdRspDTOS = userRpcService.findByIds(userIds);
// TODO RPC: 批量查询用户的计数数据(笔记总数、粉丝总数)
// 若不为空DTO 转 VO
if (CollUtil.isNotEmpty(findUserByIdRspDTOS)) {
findFansUserRspVOS = findUserByIdRspDTOS.stream()
.map(dto -> FindFansUserRspVO.builder()
.userId(dto.getId())
.avatar(dto.getAvatar())
.nickname(dto.getNickName())
.noteTotal(0L) // TODO: 这块的数据暂无,后续补充
.fansTotal(0L) // TODO: 这块的数据暂无,后续补充
.build())
.toList();
}
return findFansUserRspVOS;
}
/** /**
* 全量同步关注列表到 Redis * 全量同步关注列表到 Redis
* *

View File

@@ -170,6 +170,16 @@ POST http://localhost:8000/relation/relation/following/list
Content-Type: application/json Content-Type: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
{
"userId": 100,
"pageNo": 1
}
### 查询用户粉丝列表
POST http://localhost:8000/relation/relation/fans/list
Content-Type: application/json
Authorization: Bearer {{token}}
{ {
"userId": 100, "userId": 100,
"pageNo": 1 "pageNo": 1