From 3c8dc9e4afb44081f1fd3929a09a5550057557bd Mon Sep 17 00:00:00 2001 From: Hanserwei <2628273921@qq.com> Date: Sun, 12 Oct 2025 19:55:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(relation):=20=E5=AE=9E=E7=8E=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=85=B3=E6=B3=A8=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E6=A0=A1=E9=AA=8C=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DateUtils 工具类,支持 LocalDateTime 转时间戳 - 编写三个 Lua 脚本:单条关注、批量关注及关注校验与添加 - 新增 RedisKeyConstants 常量类,用于构建关注列表 KEY - 新增 LuaResultEnum 枚举,定义 Lua 脚本返回结果状态 - 实现关注接口的完整业务逻辑,包括 Redis 校验和数据库兜底 - 添加 HTTP 测试用例和环境变量配置 - 支持关注关系的过期策略,包含随机过期时间计算 - 增加对关注上限和重复关注的业务异常处理 - 实现从数据库同步关注数据到 Redis 的逻辑 - 使用 Lua 脚本保证操作的原子性和性能优化 --- .../biz/constant/RedisKeyConstants.java | 19 +++ .../relation/biz/enums/LuaResultEnum.java | 37 ++++++ .../relation/biz/enums/ResponseCodeEnum.java | 2 + .../biz/service/impl/RelationServiceImpl.java | 120 +++++++++++++++++- .../user/relation/biz/util/DateUtils.java | 17 +++ .../resources/lua/follow_add_and_expire.lua | 11 ++ .../lua/follow_batch_add_and_expire.lua | 20 +++ .../resources/lua/follow_check_and_add.lua | 26 ++++ http-client/gateApi.http | 9 ++ http-client/http-client.private.env.json | 4 +- 10 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/constant/RedisKeyConstants.java create mode 100644 han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/LuaResultEnum.java create mode 100644 han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/util/DateUtils.java create mode 100644 han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_add_and_expire.lua create mode 100644 han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_batch_add_and_expire.lua create mode 100644 han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_check_and_add.lua 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 new file mode 100644 index 0000000..d73e8a7 --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/constant/RedisKeyConstants.java @@ -0,0 +1,19 @@ +package com.hanserwei.hannote.user.relation.biz.constant; + +public class RedisKeyConstants { + + /** + * 关注列表 KEY 前缀 + */ + private static final String USER_FOLLOWING_KEY_PREFIX = "following:"; + + /** + * 构建关注列表完整的 KEY + * @param userId 用户 ID + * @return 关注列表 KEY + */ + public static String buildUserFollowingKey(Long userId) { + return USER_FOLLOWING_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/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 new file mode 100644 index 0000000..3ae63e7 --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/enums/LuaResultEnum.java @@ -0,0 +1,37 @@ +package com.hanserwei.hannote.user.relation.biz.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +@Getter +@AllArgsConstructor +public enum LuaResultEnum { + // ZSET 不存在 + ZSET_NOT_EXIST(-1L), + // 关注已达到上限 + FOLLOW_LIMIT(-2L), + // 已经关注了该用户 + ALREADY_FOLLOWED(-3L), + // 关注成功 + FOLLOW_SUCCESS(0L), + ; + + private final Long code; + + /** + * 根据类型 code 获取对应的枚举 + * + * @param code 类型 code + * @return 枚举 + */ + public static LuaResultEnum valueOf(Long code) { + for (LuaResultEnum luaResultEnum : LuaResultEnum.values()) { + if (Objects.equals(code, luaResultEnum.getCode())) { + return luaResultEnum; + } + } + return null; + } +} \ 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/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 9887019..4827eb3 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 @@ -15,6 +15,8 @@ public enum ResponseCodeEnum implements BaseExceptionInterface { // ----------- 业务异常状态码 ----------- CANT_FOLLOW_YOUR_SELF("RELATION-20001", "无法关注自己"), FOLLOW_USER_NOT_EXISTED("RELATION-20002", "关注的用户不存在"), + FOLLOWING_COUNT_LIMIT("RELATION-20003", "您关注的用户已达上限,请先取关部分用户"), + ALREADY_FOLLOWED("RELATION-20004", "您已经关注了该用户"), ; // 异常码 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 e901f12..30f2c34 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 @@ -1,17 +1,32 @@ 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.hanserwei.framework.biz.context.holder.LoginUserContextHolder; import com.hanserwei.framework.common.exception.ApiException; import com.hanserwei.framework.common.response.Response; import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO; +import com.hanserwei.hannote.user.relation.biz.constant.RedisKeyConstants; +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.vo.FollowUserReqVO; 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; +import com.hanserwei.hannote.user.relation.biz.util.DateUtils; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; +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.Service; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; import java.util.Objects; @Service @@ -20,6 +35,10 @@ public class RelationServiceImpl implements RelationService { @Resource private UserRpcService userRpcService; + @Resource + private RedisTemplate redisTemplate; + @Resource + private FollowingDOService followingDOService; @Override public Response follow(FollowUserReqVO followUserReqVO) { @@ -37,12 +56,109 @@ public class RelationServiceImpl implements RelationService { throw new ApiException(ResponseCodeEnum.FOLLOW_USER_NOT_EXISTED); } - // TODO: 校验关注数是否已经达到上限 + // 校验当前用户的Zset关注列表是否已经存在 + String followingRedisKey = RedisKeyConstants.buildUserFollowingKey(userId); - // TODO: 写入 Redis ZSET 关注列表 + DefaultRedisScript script = new DefaultRedisScript<>(); + + // Lua脚本路径 + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/follow_check_and_add.lua"))); + // 返回值类型 + script.setResultType(Long.class); + + // 当前时间 + LocalDateTime now = LocalDateTime.now(); + + // 转为时间戳 + long timestamp = DateUtils.localDateTime2Timestamp(now); + + // 执行Lua脚本拿到结果 + Long result = redisTemplate.execute(script, Collections.singletonList(followingRedisKey), followUserId, timestamp); + + // 校验 Lua 脚本执行结果 + checkLuaScriptResult(result); + + // ZSET不存在 + if (Objects.equals(result, LuaResultEnum.ZSET_NOT_EXIST.getCode())){ + // 从数据库查询当前用户的关注关系记录 + List followingDOS = followingDOService.list(new LambdaQueryWrapper<>(FollowingDO.class) + .select(FollowingDO::getUserId) + .select(FollowingDO::getFollowingUserId) + .select(FollowingDO::getCreateTime) + .eq(FollowingDO::getUserId, userId)); + + // 随机过期时间 + // 保底1天+随机秒数 + long expireSeconds = 60*60*24 + RandomUtil.randomInt(60*60*24); + + // 如果记录为空,直接ZADD关系数据,并设置过期时间 + if (CollUtil.isEmpty(followingDOS)){ + DefaultRedisScript script2 = new DefaultRedisScript<>(); + script2.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/follow_add_and_expire.lua"))); + script2.setResultType(Long.class); + + // TODO: 可以根据用户类型,设置不同的过期时间,若当前用户为大V, 则可以过期时间设置的长些或者不设置过期时间;如不是,则设置的短些 + // 如何判断呢?可以从计数服务获取用户的粉丝数,目前计数服务还没创建,则暂时采用统一的过期策略 + redisTemplate.execute(script2, Collections.singletonList(followingRedisKey), followUserId, timestamp, expireSeconds); + }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 脚本:follow_check_and_add.lua , 将最新地关注关系添加进去 + result = redisTemplate.execute(script, Collections.singletonList(followingRedisKey), followUserId, timestamp); + checkLuaScriptResult(result); + } + + } // TODO: 发送 MQ return Response.success(); } + + /** + * 校验 Lua 脚本结果,根据状态码抛出对应的业务异常 + * @param result Lua 脚本返回结果 + */ + private static void checkLuaScriptResult(Long result) { + LuaResultEnum luaResultEnum = LuaResultEnum.valueOf(result); + + if (Objects.isNull(luaResultEnum)) throw new RuntimeException("Lua 返回结果错误"); + // 校验 Lua 脚本执行结果 + switch (luaResultEnum) { + // 关注数已达到上限 + case FOLLOW_LIMIT -> throw new ApiException(ResponseCodeEnum.FOLLOWING_COUNT_LIMIT); + // 已经关注了该用户 + case ALREADY_FOLLOWED -> throw new ApiException(ResponseCodeEnum.ALREADY_FOLLOWED); + } + } + + /** + * 构建 Lua 脚本参数 + * + * @param followingDOS 关注列表 + * @param expireSeconds 过期时间 + * @return Lua 脚本参数 + */ + private static Object[] buildLuaArgs(List followingDOS, long expireSeconds) { + int argsLength = followingDOS.size() * 2 + 1; // 每个关注关系有 2 个参数(score 和 value),再加一个过期时间 + Object[] luaArgs = new Object[argsLength]; + + int i = 0; + for (FollowingDO following : followingDOS) { + luaArgs[i] = DateUtils.localDateTime2Timestamp(following.getCreateTime()); // 关注时间作为 score + luaArgs[i + 1] = following.getFollowingUserId(); // 关注的用户 ID 作为 ZSet value + i += 2; + } + + luaArgs[argsLength - 1] = expireSeconds; // 最后一个参数是 ZSet 的过期时间 + return luaArgs; + } } diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/util/DateUtils.java b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/util/DateUtils.java new file mode 100644 index 0000000..73f8241 --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/java/com/hanserwei/hannote/user/relation/biz/util/DateUtils.java @@ -0,0 +1,17 @@ +package com.hanserwei.hannote.user.relation.biz.util; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +public class DateUtils { + + /** + * LocalDateTime 转时间戳 + * + * @param localDateTime LocalDateTime + * @return 时间戳 + */ + public static long localDateTime2Timestamp(LocalDateTime localDateTime) { + return localDateTime.toInstant(ZoneOffset.UTC).toEpochMilli(); + } +} diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_add_and_expire.lua b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_add_and_expire.lua new file mode 100644 index 0000000..912414f --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_add_and_expire.lua @@ -0,0 +1,11 @@ +-- 如果从数据库查询到的记录为空,则执行该脚本 +local key = KEYS[1] -- 操作的 Redis Key +local followUserId = ARGV[1] -- 关注的用户ID +local timestamp = ARGV[2] -- 时间戳 +local expireSeconds = ARGV[3] -- 过期时间(秒) + +-- ZADD 添加关注关系 +redis.call('ZADD', key, timestamp, followUserId) +-- 设置过期时间 +redis.call('EXPIRE', key, expireSeconds) +return 0 diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_batch_add_and_expire.lua b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_batch_add_and_expire.lua new file mode 100644 index 0000000..e6f989c --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_batch_add_and_expire.lua @@ -0,0 +1,20 @@ +-- 操作的 Key +local key = KEYS[1] + +-- 准备批量添加数据的参数 +local zaddArgs = {} + +-- 遍历 ARGV 参数,将分数和值按顺序插入到 zaddArgs 变量中 +for i = 1, #ARGV - 1, 2 do + table.insert(zaddArgs, ARGV[i]) -- 分数(关注时间) + table.insert(zaddArgs, ARGV[i+1]) -- 值(关注的用户ID) +end + +-- 调用 ZADD 批量插入数据 +redis.call('ZADD', key, unpack(zaddArgs)) + +-- 设置 ZSet 的过期时间 +local expireTime = ARGV[#ARGV] -- 最后一个参数为过期时间 +redis.call('EXPIRE', key, expireTime) + +return 0 diff --git a/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_check_and_add.lua b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_check_and_add.lua new file mode 100644 index 0000000..afb410e --- /dev/null +++ b/han-note-user-relation/han-note-user-relation-biz/src/main/resources/lua/follow_check_and_add.lua @@ -0,0 +1,26 @@ +-- LUA 脚本:校验并添加关注关系 + +local key = KEYS[1] -- 操作的 Redis Key +local followUserId = ARGV[1] -- 关注的用户ID +local timestamp = ARGV[2] -- 时间戳 + +-- 使用 EXISTS 命令检查 ZSET 是否存在 +local exists = redis.call('EXISTS', key) +if exists == 0 then + return -1 +end + +-- 校验关注人数是否上限(是否达到 1000) +local size = redis.call('ZCARD', key) +if size >= 1000 then + return -2 +end + +-- 校验目标用户是否已经关注 +if redis.call('ZSCORE', key, followUserId) then + return -3 +end + +-- ZADD 添加关注关系 +redis.call('ZADD', key, timestamp, followUserId) +return 0 diff --git a/http-client/gateApi.http b/http-client/gateApi.http index fa2ce0b..10b35a1 100644 --- a/http-client/gateApi.http +++ b/http-client/gateApi.http @@ -136,4 +136,13 @@ Authorization: Bearer {{token}} { "followUserId": -1 +} + +### 正常关注用户 +POST http://localhost:8000/relation/relation/follow +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "followUserId":{{otherUserId}} } \ No newline at end of file diff --git a/http-client/http-client.private.env.json b/http-client/http-client.private.env.json index 1a94659..0514e9c 100644 --- a/http-client/http-client.private.env.json +++ b/http-client/http-client.private.env.json @@ -1,7 +1,9 @@ { "dev": { "token": "4bXpiBbjXEDFE4ZpqjCOHu1rP81qepl2ROOygrxRGb61K536ckLuyAwfyQHSMcyRdUzf8CxntLEMfbU2ynbYx9nJKlx4vpWZrHqv2mI4iMhnShQ4mPBi7OPPgZi22O2f", + "otherToken": "mqFNHrWkPcipIAvw7Gn4cigOWYP54sn8HYlQX3CXTxHf90DhjFiROhWVgPqLBi35xKXOOfHlXeEdaQrkXf1JXd8hbXBOdZqnrycW96BJwTbUS40EqIZifVgPun3ai0Ek", "noteId": "1977249693272375330", - "userId": "100" + "userId": "100", + "otherUserId": "2100" } } \ No newline at end of file