diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/constant/RedisKeyConstants.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/constant/RedisKeyConstants.java index d73e8a7..ceef33e 100644 --- a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/constant/RedisKeyConstants.java +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/constant/RedisKeyConstants.java @@ -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; + } } \ No newline at end of file diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/consumer/FollowUnfollowConsumer.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/consumer/FollowUnfollowConsumer.java index 211e961..c19a0f7 100644 --- a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/consumer/FollowUnfollowConsumer.java +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/consumer/FollowUnfollowConsumer.java @@ -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 { @Resource private RateLimiter rateLimiter; + @Resource + private RedisTemplate redisTemplate; @Override public void onMessage(Message message) { @@ -46,17 +57,67 @@ public class FollowUnfollowConsumer implements RocketMQListener { 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 { .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 { })); log.info("## 数据库添加记录结果: {}", isSuccess); - // TODO: 更新 Redis 中被关注用户的 ZSet 粉丝列表 + if (isSuccess) { + // Lua脚本 + DefaultRedisScript 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); + } } } diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/controller/RelationController.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/controller/RelationController.java index 4fc0062..43ba2b0 100644 --- a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/controller/RelationController.java +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/controller/RelationController.java @@ -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); + } } \ No newline at end of file diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/LuaResultEnum.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/LuaResultEnum.java index 3ae63e7..998dd3d 100644 --- a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/LuaResultEnum.java +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/LuaResultEnum.java @@ -16,6 +16,8 @@ public enum LuaResultEnum { ALREADY_FOLLOWED(-3L), // 关注成功 FOLLOW_SUCCESS(0L), + // 未关注该用户 + NOT_FOLLOWED(-4L), ; private final Long code; diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/ResponseCodeEnum.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/ResponseCodeEnum.java index 4827eb3..cee14a1 100644 --- a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/ResponseCodeEnum.java +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/ResponseCodeEnum.java @@ -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", "你未关注对方,无法取关"), ; // 异常码 diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/model/dto/UnfollowUserMqDTO.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/model/dto/UnfollowUserMqDTO.java new file mode 100644 index 0000000..d92bffc --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/model/dto/UnfollowUserMqDTO.java @@ -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; +} \ No newline at end of file diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/model/vo/UnfollowUserReqVO.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/model/vo/UnfollowUserReqVO.java new file mode 100644 index 0000000..38046db --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/model/vo/UnfollowUserReqVO.java @@ -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; +} \ No newline at end of file diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/service/RelationService.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/service/RelationService.java index 435e37d..fa9de15 100644 --- a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/service/RelationService.java +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/service/RelationService.java @@ -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); } diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/service/impl/RelationServiceImpl.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/service/impl/RelationServiceImpl.java index 87fd3d8..72e081e 100644 --- a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/service/impl/RelationServiceImpl.java +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/service/impl/RelationServiceImpl.java @@ -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 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 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 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 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 脚本返回结果 diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_check_and_update_fans_zset.lua b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_check_and_update_fans_zset.lua new file mode 100644 index 0000000..1aa01ed --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_check_and_update_fans_zset.lua @@ -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 diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/unfollow_check_and_delete.lua b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/unfollow_check_and_delete.lua new file mode 100644 index 0000000..d4bd14e --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/unfollow_check_and_delete.lua @@ -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 diff --git a/http-client/gateApi.http b/http-client/gateApi.http index d6c98c7..45ccf0b 100644 --- a/http-client/gateApi.http +++ b/http-client/gateApi.http @@ -145,4 +145,13 @@ Authorization: Bearer {{token}} { "followUserId": {{otherUserId}} +} + +### 取消关注 +POST http://localhost:8000/relation/relation/unfollow +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "unfollowUserId": 2100 } \ No newline at end of file