Compare commits

...

3 Commits

Author SHA1 Message Date
5e4f9b1203 fix(relation):修复批量查询用户信息时返回空数据的问题
- 在请求页码超过总页数时正确返回空数据
- 添加DataFlowIssue注解以忽略潜在的数据流问题警告
2025-10-14 23:32:01 +08:00
aca7c657fa feat(relation): 实现关注列表分页查询及异步同步到Redis
- 在 PageResponse 中新增 getOffset 方法用于计算分页偏移量
- 优化关注列表分页逻辑,支持从 Redis 和数据库双重查询
- 添加线程池配置,用于异步同步关注列表至 Redis
- 实现全量同步关注列表到 Redis 的方法,并设置随机过期时间
- 封装 RPC 调用用户服务并将 DTO 转换为 VO 的公共方法
-修复分页查询边界条件判断,避免无效查询
- 使用 Lua 脚本批量操作 Redis 提高同步效率和原子性
2025-10-14 23:31:25 +08:00
1e350a4af5 feat(user): 新增用户关注列表查询功能
- 新增查询用户关注列表接口,支持分页查询
- 新增批量查询用户信息接口,提升查询效率
- 优化 MQ 消费模式为顺序消费,确保关注/取关操作有序性
- 完善用户信息 DTO,新增简介字段
- 新增分页响应封装类,支持分页查询结果返回
- 优化 Redis 查询逻辑,支持从缓存中分页获取关注列表
- 新增 Lua 脚本结果类型设置,确保脚本执行结果正确解析
- 添加 HTTP 接口测试用例,覆盖关注列表及批量查询接口
- 实现缓存与数据库双写一致性,提高数据查询性能
2025-10-14 22:29:13 +08:00
17 changed files with 626 additions and 17 deletions

View File

@@ -0,0 +1,37 @@
package com.hanserwei.hannote.user.relation.biz.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class ThreadPoolConfig {
@Bean(name = "relationTaskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(50);
// 队列容量
executor.setQueueCapacity(200);
// 线程活跃时间(秒)
executor.setKeepAliveSeconds(30);
// 线程名前缀
executor.setThreadNamePrefix("UserExecutor-");
// 拒绝策略:由调用线程处理(一般为主线程)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
// 设置等待时间,如果超过这个时间还没有销毁就强制销毁,以确保应用最后能够被关闭,而不是被没有完成的任务阻塞
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}

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

@@ -3,10 +3,14 @@ package com.hanserwei.hannote.user.relation.biz.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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;
@@ -31,6 +35,7 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Service;
@@ -38,6 +43,7 @@ import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service
@Slf4j
@@ -51,6 +57,8 @@ public class RelationServiceImpl implements RelationService {
private FollowingDOService followingDOService;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource(name = "relationTaskExecutor")
private ThreadPoolTaskExecutor taskExecutor;
@Override
public Response<?> follow(FollowUserReqVO followUserReqVO) {
@@ -264,6 +272,162 @@ 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;
//每页展示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);
}
// 准备从ZSet中查询分页数据
// 每页展示10条数据计算偏移量
long offset = PageResponse.getOffset(pageNo, 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: 批量查询用户信息
//noinspection ConstantValue
findFollowingUserRspVOS = rpcUserServiceAndDTO2VO(userIds, findFollowingUserRspVOS);
}
} else {
// 若 Redis 中没有数据,则从数据库查询
// 先查询记录总量
long count = followingDOService.count(new LambdaQueryWrapper<>(FollowingDO.class)
.eq(FollowingDO::getUserId, userId));
// 计算一共多少页
long totalPage = PageResponse.getTotalPage(count, limit);
// 请求页码超过总页数
if (pageNo > totalPage) {
log.info("==> 批量查询用户信息,返回空数据");
//noinspection DataFlowIssue
return PageResponse.success(null, pageNo, total);
}
// 偏移量
long offset = PageResponse.getOffset(pageNo, limit);
// 分页查询
// 从数据库分页查询
Page<FollowingDO> page = followingDOService.page(new Page<>(offset / limit + 1, limit),
new LambdaQueryWrapper<FollowingDO>()
.eq(FollowingDO::getUserId, userId)
.orderByDesc(FollowingDO::getCreateTime));
List<FollowingDO> followingDOS = page.getRecords();
// 赋值真实地记录总数
total = count;
// 若记录不为空
if (CollUtil.isNotEmpty(followingDOS)) {
// 提取所有关注用户 ID 到集合中
List<Long> userIds = followingDOS.stream().map(FollowingDO::getFollowingUserId).toList();
// RPC: 调用用户服务,并将 DTO 转换为 VO
//noinspection ConstantValue
findFollowingUserRspVOS = rpcUserServiceAndDTO2VO(userIds, findFollowingUserRspVOS);
// 异步将关注列表全量同步到 Redis
taskExecutor.submit(() -> syncFollowingList2Redis(userId));
}
}
return PageResponse.success(findFollowingUserRspVOS,
pageNo,
total);
}
/**
* 全量同步关注列表到 Redis
*
* @param userId 用户ID
*/
private void syncFollowingList2Redis(Long userId) {
Page<FollowingDO> page = followingDOService.page(new Page<>(1, 1000),
new LambdaQueryWrapper<>(FollowingDO.class)
.select(FollowingDO::getFollowingUserId, FollowingDO::getCreateTime)
.eq(FollowingDO::getUserId, userId));
List<FollowingDO> followingDOS = page.getRecords();
log.info("==> 全量同步用户关注列表{}", JsonUtils.toJsonString(followingDOS));
if (CollUtil.isNotEmpty(followingDOS)) {
// 用户关注列表 Redis Key
String followingListRedisKey = RedisKeyConstants.buildUserFollowingKey(userId);
// 随机过期时间
// 保底1天+随机秒数
long expireSeconds = 60 * 60 * 24 + RandomUtil.randomInt(60 * 60 * 24);
// 构建 Lua 参数
Object[] luaArgs = buildLuaArgs(followingDOS, 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(followingListRedisKey), luaArgs);
log.info("==> 全量同步用户关注列表到 Redis用户ID: {}", userId);
}
}
/**
* RPC: 调用用户服务,并将 DTO 转换为 VO
*
* @param userIds 用户 ID 列表
* @param findFollowingUserRspVOS 跟随用户列表
* @return 跟随用户列表
*/
private List<FindFollowingUserRspVO> rpcUserServiceAndDTO2VO(List<Long> userIds, List<FindFollowingUserRspVO> findFollowingUserRspVOS) {
// RPC: 批量查询用户信息
List<FindUserByIdRspDTO> findUserByIdRspDTOS = userRpcService.findByIds(userIds);
// 若不为空DTO 转 VO
if (CollUtil.isNotEmpty(findUserByIdRspDTOS)) {
findFollowingUserRspVOS = findUserByIdRspDTOS.stream()
.map(dto -> FindFollowingUserRspVO.builder()
.userId(dto.getId())
.avatar(dto.getAvatar())
.nickname(dto.getNickName())
.introduction(dto.getIntroduction())
.build())
.toList();
}
return findFollowingUserRspVOS;
}
/**
* 校验 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);
}
}
}
}

View File

@@ -2,16 +2,15 @@ package com.hanserwei.hannote.user.api;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.constant.ApiConstants;
import com.hanserwei.hannote.user.dto.req.FindUserByEmailReqDTO;
import com.hanserwei.hannote.user.dto.req.FindUserByIdReqDTO;
import com.hanserwei.hannote.user.dto.req.RegisterUserReqDTO;
import com.hanserwei.hannote.user.dto.req.UpdateUserPasswordReqDTO;
import com.hanserwei.hannote.user.dto.req.*;
import com.hanserwei.hannote.user.dto.resp.FindUserByEmailRspDTO;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
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 UserFeignApi {
@@ -52,4 +51,13 @@ public interface UserFeignApi {
*/
@PostMapping(value = PREFIX + "/findById")
Response<FindUserByIdRspDTO> findById(@RequestBody FindUserByIdReqDTO findUserByIdReqDTO);
/**
* 批量查询用户信息
*
* @param findUsersByIdsReqDTO 批量查询信息请求
* @return 响应
*/
@PostMapping(value = PREFIX + "/findByIds")
Response<List<FindUserByIdRspDTO>> findByIds(@RequestBody FindUsersByIdsReqDTO findUsersByIdsReqDTO);
}

View File

@@ -0,0 +1,20 @@
package com.hanserwei.hannote.user.dto.req;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindFollowingListReqVO {
@NotNull(message = "查询用户 ID 不能为空")
private Long userId;
@NotNull(message = "页码不能为空")
private Integer pageNo = 1; // 默认值为第一页
}

View File

@@ -0,0 +1,22 @@
package com.hanserwei.hannote.user.dto.req;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindUsersByIdsReqDTO {
@NotNull(message = "用户 ID 集合不能为空")
@Size(min = 1, max = 10, message = "用户 ID 集合大小必须大于等于 1, 小于等于 10")
private List<Long> ids;
}

View File

@@ -0,0 +1,22 @@
package com.hanserwei.hannote.user.dto.resp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindFollowingUserRspVO {
private Long userId;
private String avatar;
private String nickname;
private String introduction;
}

View File

@@ -25,4 +25,9 @@ public class FindUserByIdRspDTO {
* 头像
*/
private String avatar;
/**
* 简介
*/
private String introduction;
}

View File

@@ -4,10 +4,7 @@ import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.biz.model.vo.UpdateUserInfoReqVO;
import com.hanserwei.hannote.user.biz.service.UserService;
import com.hanserwei.hannote.user.dto.req.FindUserByEmailReqDTO;
import com.hanserwei.hannote.user.dto.req.FindUserByIdReqDTO;
import com.hanserwei.hannote.user.dto.req.RegisterUserReqDTO;
import com.hanserwei.hannote.user.dto.req.UpdateUserPasswordReqDTO;
import com.hanserwei.hannote.user.dto.req.*;
import com.hanserwei.hannote.user.dto.resp.FindUserByEmailRspDTO;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
@@ -19,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/user")
@Slf4j
@@ -63,4 +62,10 @@ public class UserController {
return userService.findById(findUserByIdReqDTO);
}
@PostMapping("/findByIds")
@ApiOperationLog(description = "批量查询用户信息")
public Response<List<FindUserByIdRspDTO>> findByIds(@Validated @RequestBody FindUsersByIdsReqDTO findUsersByIdsReqDTO) {
return userService.findByIds(findUsersByIdsReqDTO);
}
}

View File

@@ -4,13 +4,12 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.biz.domain.dataobject.UserDO;
import com.hanserwei.hannote.user.biz.model.vo.UpdateUserInfoReqVO;
import com.hanserwei.hannote.user.dto.req.FindUserByEmailReqDTO;
import com.hanserwei.hannote.user.dto.req.FindUserByIdReqDTO;
import com.hanserwei.hannote.user.dto.req.RegisterUserReqDTO;
import com.hanserwei.hannote.user.dto.req.UpdateUserPasswordReqDTO;
import com.hanserwei.hannote.user.dto.req.*;
import com.hanserwei.hannote.user.dto.resp.FindUserByEmailRspDTO;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
import java.util.List;
public interface UserService extends IService<UserDO> {
/**
@@ -52,4 +51,12 @@ public interface UserService extends IService<UserDO> {
* @return 响应结果
*/
Response<FindUserByIdRspDTO> findById(FindUserByIdReqDTO findUserByIdReqDTO);
/**
* 批量根据用户 ID 查询用户信息
*
* @param findUsersByIdsReqDTO 批量查询用户信息请求参数
* @return 响应结果
*/
Response<List<FindUserByIdRspDTO>> findByIds(FindUsersByIdsReqDTO findUsersByIdsReqDTO);
}

View File

@@ -1,11 +1,14 @@
package com.hanserwei.hannote.user.biz.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import com.hanserwei.framework.biz.context.holder.LoginUserContextHolder;
import com.hanserwei.framework.common.enums.DeletedEnum;
import com.hanserwei.framework.common.enums.StatusEnum;
@@ -27,16 +30,17 @@ import com.hanserwei.hannote.user.biz.model.vo.UpdateUserInfoReqVO;
import com.hanserwei.hannote.user.biz.rpc.DistributedIdGeneratorRpcService;
import com.hanserwei.hannote.user.biz.rpc.OssRpcService;
import com.hanserwei.hannote.user.biz.service.UserService;
import com.hanserwei.hannote.user.dto.req.FindUserByEmailReqDTO;
import com.hanserwei.hannote.user.dto.req.FindUserByIdReqDTO;
import com.hanserwei.hannote.user.dto.req.RegisterUserReqDTO;
import com.hanserwei.hannote.user.dto.req.UpdateUserPasswordReqDTO;
import com.hanserwei.hannote.user.dto.req.*;
import com.hanserwei.hannote.user.dto.resp.FindUserByEmailRspDTO;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,8 +50,10 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Service
@Slf4j
@@ -296,6 +302,7 @@ public class UserServiceImpl extends ServiceImpl<UserDOMapper, UserDO> implement
.id(userDO.getId())
.nickName(userDO.getNickname())
.avatar(userDO.getAvatar())
.introduction(userDO.getIntroduction())
.build();
threadPoolTaskExecutor.submit(() -> {
// 过期时间保底1天+随机秒数,避免缓存雪崩
@@ -304,5 +311,119 @@ public class UserServiceImpl extends ServiceImpl<UserDOMapper, UserDO> implement
});
return Response.success(findUserByIdRspDTO);
}
@Override
public Response<List<FindUserByIdRspDTO>> findByIds(FindUsersByIdsReqDTO findUsersByIdsReqDTO) {
List<Long> userIds = findUsersByIdsReqDTO.getIds();
// 构建Redis的Key集合
List<String> userInfoKeys = userIds.stream()
.map(RedisKeyConstants::buildUserInfoKey)
.toList();
// 先从Redis中获取
List<Object> redisValues = redisTemplate.opsForValue().multiGet(userInfoKeys);
// 如果缓存中不为空,过滤掉空值
if (CollUtil.isNotEmpty(redisValues)){
redisValues = redisValues.stream()
.filter(Objects::nonNull)
.toList();
}
// 返参
List<FindUserByIdRspDTO> findUserByIdRspDTOS = Lists.newArrayList();
// 将过滤后的缓存集合,转换为 DTO 返参实体类
if (CollUtil.isNotEmpty(redisValues)) {
findUserByIdRspDTOS = redisValues.stream()
.map(value -> JsonUtils.parseObject(String.valueOf(value), FindUserByIdRspDTO.class))
.collect(Collectors.toList());
}
// 如果被查询的用户信息,都在 Redis 缓存中, 则直接返回
if (CollUtil.size(userIds) == CollUtil.size(findUserByIdRspDTOS)) {
return Response.success(findUserByIdRspDTOS);
}
// 还有另外两种情况:一种是缓存里没有用户信息数据,还有一种是缓存里数据不全,需要从数据库中补充
// 筛选出缓存里没有的用户数据,去查数据库
List<Long> userIdsNeedQuery = null;
if (CollUtil.isNotEmpty(findUserByIdRspDTOS)) {
// 将 findUserInfoByIdRspDTOS 集合转 Map
Map<Long, FindUserByIdRspDTO> map = findUserByIdRspDTOS.stream()
.collect(Collectors.toMap(FindUserByIdRspDTO::getId, p -> p));
// 筛选出需要查 DB 的用户 ID
userIdsNeedQuery = userIds.stream()
.filter(id -> Objects.isNull(map.get(id)))
.toList();
} else { // 缓存中一条用户信息都没查到,则提交的用户 ID 集合都需要查数据库
userIdsNeedQuery = userIds;
}
// 数据库中批量查询用户信息
List<UserDO> userDOs = this.list(new LambdaQueryWrapper<>(UserDO.class)
.eq(UserDO::getStatus, 0)
.eq(UserDO::getIsDeleted, 0)
.in(UserDO::getId, userIdsNeedQuery));
List<FindUserByIdRspDTO> findUserByIdRspDTOS2 = null;
// 若数据不为空,则转为 DTO 返回
if (CollUtil.isNotEmpty(userDOs)){
findUserByIdRspDTOS2 = userDOs.stream()
.map(userDO -> FindUserByIdRspDTO.builder()
.id(userDO.getId())
.nickName(userDO.getNickname())
.introduction(userDO.getIntroduction())
.avatar(userDO.getAvatar())
.build())
.toList();
// 批量更新 Redis
List<FindUserByIdRspDTO> finalFindUserByIdRspDTOS = findUserByIdRspDTOS2;
threadPoolTaskExecutor.submit(()->{
// DTO集合转Map
Map<Long, FindUserByIdRspDTO> map = finalFindUserByIdRspDTOS.stream()
.collect(Collectors.toMap(FindUserByIdRspDTO::getId, p -> p));
// 执行pipeline操作
//noinspection NullableProblems
redisTemplate.executePipelined(new SessionCallback<>() {
@SuppressWarnings("unchecked")
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
// 你可以直接获取 ValueOperations
ValueOperations<String, Object> valueOperations = operations.opsForValue();
for (UserDO userDO : userDOs) {
Long userId = userDO.getId();
// 用户缓存Key (K = String)
String userInfoRedisKey = RedisKeyConstants.buildUserInfoKey(userId);
// DTO转JSON
FindUserByIdRspDTO findUserByIdRspDTO = map.get(userId);
// 的值类型是 Object所以它可以接受 String。
String value = JsonUtils.toJsonString(findUserByIdRspDTO);
// 过期时间
long expireSeconds = 60 * 60 * 24 + RandomUtil.randomInt(60 * 60 * 24);
valueOperations.set(userInfoRedisKey, value, expireSeconds, TimeUnit.SECONDS);
}
return null;
}
});
});
}
// 合并数据
if (CollUtil.isNotEmpty(findUserByIdRspDTOS2)) {
findUserByIdRspDTOS.addAll(findUserByIdRspDTOS2);
}
return Response.success(findUserByIdRspDTOS);
}
}

View File

@@ -0,0 +1,70 @@
package com.hanserwei.framework.common.response;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 分页响应(未使用任何分页插件)
*
* @author hanserwei
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class PageResponse<T> extends Response<List<T>> {
private long pageNo; // 当前页码
private long totalCount; // 总数据量
private long pageSize; // 每页展示的数据量
private long totalPage; // 总页数
public static <T> PageResponse<T> success(List<T> data, long pageNo, long totalCount) {
PageResponse<T> pageResponse = new PageResponse<>();
pageResponse.setSuccess(true);
pageResponse.setData(data);
pageResponse.setPageNo(pageNo);
pageResponse.setTotalCount(totalCount);
// 每页展示的数据量
long pageSize = 10L;
pageResponse.setPageSize(pageSize);
// 计算总页数
long totalPage = (totalCount + pageSize - 1) / pageSize;
pageResponse.setTotalPage(totalPage);
return pageResponse;
}
public static <T> PageResponse<T> success(List<T> data, long pageNo, long totalCount, long pageSize) {
PageResponse<T> pageResponse = new PageResponse<>();
pageResponse.setSuccess(true);
pageResponse.setData(data);
pageResponse.setPageNo(pageNo);
pageResponse.setTotalCount(totalCount);
pageResponse.setPageSize(pageSize);
// 计算总页数
long totalPage = pageSize == 0 ? 0 : (totalCount + pageSize - 1) / pageSize;
pageResponse.setTotalPage(totalPage);
return pageResponse;
}
/**
* 获取总页数
* @return 总页数
*/
public static long getTotalPage(long totalCount, long pageSize) {
return pageSize == 0 ? 0 : (totalCount + pageSize - 1) / pageSize;
}
/**
* 计算分页查询的 offset
* @param pageNo 页码
* @param pageSize 每页展示的数据量
* @return offset
*/
public static long getOffset(long pageNo, long pageSize) {
// 如果页码小于 1默认返回第一页的 offset
if (pageNo < 1) {
pageNo = 1;
}
return (pageNo - 1) * pageSize;
}
}

View File

@@ -155,3 +155,22 @@ Authorization: Bearer {{token}}
{
"unfollowUserId": 2100
}
### 批量查询用户信息
POST http://localhost:8000/user/user/findByIds
Content-Type: application/json
Authorization: Bearer {{token}}
{
"ids": [100,2100,4100]
}
### 查询用户关注列表
POST http://localhost:8000/relation/relation/following/list
Content-Type: application/json
Authorization: Bearer {{token}}
{
"userId": 100,
"pageNo": 1
}