feat(relation): 实现用户取关功能

- 新增取消关注接口及完整业务逻辑
- 添加 Lua 脚本支持 Redis 取关校验与删除操作
- 实现 MQ 异步处理取关事件
- 补充相关 DTO、VO 类以及枚举响应码
- 完善 Redis Key 构建工具方法
- 增加 HTTP 测试用例用于手动验证接口
- 优化关注流程中的 Redis ZSet 粉丝列表维护逻辑
- 添加粉丝数量限制控制,超出时自动移除最早关注者
This commit is contained in:
2025-10-13 22:40:11 +08:00
parent f0afb23a73
commit b70d9073d8
12 changed files with 300 additions and 5 deletions

View File

@@ -7,6 +7,11 @@ public class RedisKeyConstants {
*/
private static final String USER_FOLLOWING_KEY_PREFIX = "following:";
/**
* 粉丝列表 KEY 前缀
*/
private static final String USER_FANS_KEY_PREFIX = "fans:";
/**
* 构建关注列表完整的 KEY
* @param userId 用户 ID
@@ -16,4 +21,12 @@ public class RedisKeyConstants {
return USER_FOLLOWING_KEY_PREFIX + userId;
}
/**
* 构建粉丝列表完整的 KEY
* @param userId 用户 ID
* @return 粉丝列表 KEY
*/
public static String buildUserFansKey(Long userId) {
return USER_FANS_KEY_PREFIX + userId;
}
}

View File

@@ -1,23 +1,32 @@
package com.hanserwei.hannote.user.relation.biz.consumer;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
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.domain.dataobject.FansDO;
import com.hanserwei.hannote.user.relation.biz.domain.dataobject.FollowingDO;
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.service.FansDOService;
import com.hanserwei.hannote.user.relation.biz.service.FollowingDOService;
import com.hanserwei.hannote.user.relation.biz.util.DateUtils;
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.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Objects;
@Component
@@ -34,6 +43,8 @@ public class FollowUnfollowConsumer implements RocketMQListener<Message> {
@Resource
private RateLimiter rateLimiter;
@Resource
private RedisTemplate<Object, Object> redisTemplate;
@Override
public void onMessage(Message message) {
@@ -46,17 +57,67 @@ public class FollowUnfollowConsumer implements RocketMQListener<Message> {
log.info("==> FollowUnfollowConsumer 消费了消息 {}, tags: {}", bodyJsonStr, tags);
// 根据MQ标签判断操作类型
if (Objects.equals(tags, MQConstants.TAG_FOLLOW)){
if (Objects.equals(tags, MQConstants.TAG_FOLLOW)) {
// 关注
handleFollowTagMessage(bodyJsonStr);
} else if (Objects.equals(tags, MQConstants.TAG_UNFOLLOW)) {
// 取关
// TODO: 待实现
handleUnfollowTagMessage(bodyJsonStr);
}
}
/**
* 取关
*
* @param bodyJsonStr 消息体
*/
private void handleUnfollowTagMessage(String bodyJsonStr) {
// 消息体json串转换为DTO对象
UnfollowUserMqDTO unfollowUserMqDTO = JsonUtils.parseObject(bodyJsonStr, UnfollowUserMqDTO.class);
// 判空
if (Objects.isNull(unfollowUserMqDTO)) {
return;
}
Long userId = unfollowUserMqDTO.getUserId();
Long unfollowUserId = unfollowUserMqDTO.getUnfollowUserId();
LocalDateTime createTime = unfollowUserMqDTO.getCreateTime();
// 编程式事务提交
boolean isSuccess = Boolean.TRUE.equals(transactionTemplate.execute(status -> {
try {
// 数据库操作,两个数据库操作
// 关注表:一条记录
boolean isRemoved = followingDOService.remove(new LambdaQueryWrapper<>(FollowingDO.class)
.eq(FollowingDO::getUserId, userId)
.eq(FollowingDO::getFollowingUserId, unfollowUserId));
if (isRemoved) {
// 粉丝表:一条记录
return fansDOService.remove(new LambdaQueryWrapper<>(FansDO.class)
.eq(FansDO::getUserId, unfollowUserId)
.eq(FansDO::getFansUserId, userId));
}
return true;
}catch (Exception e){
status.setRollbackOnly();
log.error("## 取关失败, userId: {}, unfollowUserId: {}, createTime: {}", userId, unfollowUserId, createTime);
}
return false;
}));
// 若数据库删除成功,更新 Redis将自己从被取关用户的 ZSet 粉丝列表删除
if (isSuccess) {
// 被取关用户的粉丝列表 Redis Key
String fansRedisKey = RedisKeyConstants.buildUserFansKey(unfollowUserId);
// 删除指定粉丝
redisTemplate.opsForZSet().remove(fansRedisKey, userId);
}
}
/**
* 关注
*
* @param bodyJsonStr 消息体
*/
private void handleFollowTagMessage(String bodyJsonStr) {
@@ -85,14 +146,14 @@ public class FollowUnfollowConsumer implements RocketMQListener<Message> {
.createTime(createTime)
.build());
// 粉丝表:一条记录
if (followRecordSaved){
if (followRecordSaved) {
return fansDOService.save(FansDO.builder()
.userId(followUserId)
.fansUserId(userId)
.createTime(createTime)
.build());
}
}catch (Exception e){
} catch (Exception e) {
status.setRollbackOnly();
log.error("## 添加关注关系失败, userId: {}, followUserId: {}, createTime: {}", userId, followUserId, createTime);
}
@@ -100,6 +161,19 @@ public class FollowUnfollowConsumer implements RocketMQListener<Message> {
}));
log.info("## 数据库添加记录结果: {}", isSuccess);
// TODO: 更新 Redis 中被关注用户的 ZSet 粉丝列表
if (isSuccess) {
// Lua脚本
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/follow_check_and_update_fans_zset.lua")));
// 时间戳
long timestamp = DateUtils.localDateTime2Timestamp(createTime);
// 构建关注有户的粉丝列表的KEY
String fansZSetKey = RedisKeyConstants.buildUserFansKey(followUserId);
// 执行Lua脚本
redisTemplate.execute(script, Collections.singletonList(fansZSetKey), userId, timestamp);
}
}
}

View File

@@ -3,6 +3,7 @@ package com.hanserwei.hannote.user.relation.biz.controller;
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.framework.common.response.Response;
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;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -26,4 +27,9 @@ public class RelationController {
return relationService.follow(followUserReqVO);
}
@PostMapping("/unfollow")
@ApiOperationLog(description = "取关用户")
public Response<?> unfollow(@Validated @RequestBody UnfollowUserReqVO unfollowUserReqVO) {
return relationService.unfollow(unfollowUserReqVO);
}
}

View File

@@ -16,6 +16,8 @@ public enum LuaResultEnum {
ALREADY_FOLLOWED(-3L),
// 关注成功
FOLLOW_SUCCESS(0L),
// 未关注该用户
NOT_FOLLOWED(-4L),
;
private final Long code;

View File

@@ -17,6 +17,8 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
FOLLOW_USER_NOT_EXISTED("RELATION-20002", "关注的用户不存在"),
FOLLOWING_COUNT_LIMIT("RELATION-20003", "您关注的用户已达上限,请先取关部分用户"),
ALREADY_FOLLOWED("RELATION-20004", "您已经关注了该用户"),
CANT_UNFOLLOW_YOUR_SELF("RELATION-20005", "无法取关自己"),
NOT_FOLLOWED("RELATION-20006", "你未关注对方,无法取关"),
;
// 异常码

View File

@@ -0,0 +1,21 @@
package com.hanserwei.hannote.user.relation.biz.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UnfollowUserMqDTO {
private Long userId;
private Long unfollowUserId;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,17 @@
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 UnfollowUserReqVO {
@NotNull(message = "被取关用户 ID 不能为空")
private Long unfollowUserId;
}

View File

@@ -2,6 +2,7 @@ package com.hanserwei.hannote.user.relation.biz.service;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.relation.biz.model.vo.FollowUserReqVO;
import com.hanserwei.hannote.user.relation.biz.model.vo.UnfollowUserReqVO;
public interface RelationService {
@@ -12,4 +13,11 @@ public interface RelationService {
* @return 响应
*/
Response<?> follow(FollowUserReqVO followUserReqVO);
/**
* 取关用户
* @param unfollowUserReqVO 取关用户请求
* @return 响应
*/
Response<?> unfollow(UnfollowUserReqVO unfollowUserReqVO);
}

View File

@@ -14,7 +14,9 @@ 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.ResponseCodeEnum;
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.vo.FollowUserReqVO;
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.service.FollowingDOService;
import com.hanserwei.hannote.user.relation.biz.service.RelationService;
@@ -162,6 +164,106 @@ public class RelationServiceImpl implements RelationService {
return Response.success();
}
@Override
public Response<?> unfollow(UnfollowUserReqVO unfollowUserReqVO) {
// 被取关用户 ID
Long unfollowUserId = unfollowUserReqVO.getUnfollowUserId();
// 当前登录用户id
Long userId = LoginUserContextHolder.getUserId();
// 无法取关自己
if (Objects.equals(userId, unfollowUserId)){
throw new ApiException(ResponseCodeEnum.CANT_UNFOLLOW_YOUR_SELF);
}
// 校验被取关用户是否存在
FindUserByIdRspDTO findUserByIdRspDTO = userRpcService.findById(unfollowUserId);
if (Objects.isNull(findUserByIdRspDTO)){
throw new ApiException(ResponseCodeEnum.FOLLOW_USER_NOT_EXISTED);
}
// 当前用户的关注列表 Redis Key
String followingRedisKey = RedisKeyConstants.buildUserFollowingKey(userId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/unfollow_check_and_delete.lua")));
// 返回值类型
script.setResultType(Long.class);
// 执行 Lua 脚本,拿到返回结果
Long result = redisTemplate.execute(script, Collections.singletonList(followingRedisKey), unfollowUserId);
// 校验 Lua 脚本执行结果
// 取关的用户不在关注列表中
if (Objects.equals(result, LuaResultEnum.NOT_FOLLOWED.getCode())) {
throw new ApiException(ResponseCodeEnum.NOT_FOLLOWED);
}
if (Objects.equals(result, LuaResultEnum.ZSET_NOT_EXIST.getCode())) { // ZSET 关注列表不存在
// 从数据库查询当前用户的关注关系记录
List<FollowingDO> followingDOS = followingDOService.list(new LambdaQueryWrapper<>(FollowingDO.class).eq(FollowingDO::getUserId, userId));
// 随机过期时间
// 保底1天+随机秒数
long expireSeconds = 60*60*24 + RandomUtil.randomInt(60*60*24);
// 若记录为空,则表示还未关注任何人,提示还未关注对方
if (CollUtil.isEmpty(followingDOS)) {
throw new ApiException(ResponseCodeEnum.NOT_FOLLOWED);
} else { // 若记录不为空,则将关注关系数据全量同步到 Redis 中,并设置过期时间;
// 构建 Lua 参数
Object[] luaArgs = buildLuaArgs(followingDOS, expireSeconds);
// 执行 Lua 脚本,批量同步关注关系数据到 Redis 中
DefaultRedisScript<Long> script3 = new DefaultRedisScript<>();
script3.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/follow_batch_add_and_expire.lua")));
script3.setResultType(Long.class);
redisTemplate.execute(script3, Collections.singletonList(followingRedisKey), luaArgs);
// 再次调用上面的 Lua 脚本unfollow_check_and_delete.lua , 将取关的用户删除
result = redisTemplate.execute(script, Collections.singletonList(followingRedisKey), unfollowUserId);
// 再次校验结果
if (Objects.equals(result, LuaResultEnum.NOT_FOLLOWED.getCode())) {
throw new ApiException(ResponseCodeEnum.NOT_FOLLOWED);
}
}
}
// 发送MQ
// 构建消息体DTO
UnfollowUserMqDTO unfollowUserMqDTO = UnfollowUserMqDTO.builder()
.userId(userId)
.unfollowUserId(unfollowUserId)
.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;
log.info("==> 开始发送取关操作 MQ, 消息体: {}", unfollowUserMqDTO);
// 异步发送MQ消息提升接口响应速度
rocketMQTemplate.asyncSend(destination, 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();
}
/**
* 校验 Lua 脚本结果,根据状态码抛出对应的业务异常
* @param result Lua 脚本返回结果

View File

@@ -0,0 +1,21 @@
local key = KEYS[1] -- 操作的 Redis Key
local fansUserId = ARGV[1] -- 粉丝ID
local timestamp = ARGV[2] -- 时间戳
-- 使用 EXISTS 命令检查 ZSET 粉丝列表是否存在
local exists = redis.call('EXISTS', key)
if exists == 0 then
return -1
end
-- 获取粉丝列表大小
local size = redis.call('ZCARD', key)
-- 若超过 5000 个粉丝,则移除最早关注的粉丝
if size >= 5000 then
redis.call('ZPOPMIN', key)
end
-- 添加新的粉丝关系
redis.call('ZADD', key, timestamp, fansUserId)
return 0

View File

@@ -0,0 +1,20 @@
-- LUA 脚本:校验并移除关注关系
local key = KEYS[1] -- 操作的 Redis Key
local unfollowUserId = ARGV[1] -- 关注的用户ID
-- 使用 EXISTS 命令检查 ZSET 是否存在
local exists = redis.call('EXISTS', key)
if exists == 0 then
return -1
end
-- 校验目标用户是否被关注
local score = redis.call('ZSCORE', key, unfollowUserId)
if score == false or score == nil then
return -4
end
-- ZREM 删除关注关系
redis.call('ZREM', key, unfollowUserId)
return 0