feat(relation): 实现用户关注功能及相关校验逻辑

- 新增 DateUtils 工具类,支持 LocalDateTime 转时间戳
- 编写三个 Lua 脚本:单条关注、批量关注及关注校验与添加
- 新增 RedisKeyConstants 常量类,用于构建关注列表 KEY
- 新增 LuaResultEnum 枚举,定义 Lua 脚本返回结果状态
- 实现关注接口的完整业务逻辑,包括 Redis 校验和数据库兜底
- 添加 HTTP 测试用例和环境变量配置
- 支持关注关系的过期策略,包含随机过期时间计算
- 增加对关注上限和重复关注的业务异常处理
- 实现从数据库同步关注数据到 Redis 的逻辑
- 使用 Lua 脚本保证操作的原子性和性能优化
This commit is contained in:
2025-10-12 19:55:20 +08:00
parent 7942a46592
commit 3c8dc9e4af
10 changed files with 262 additions and 3 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -15,6 +15,8 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 业务异常状态码 ----------- // ----------- 业务异常状态码 -----------
CANT_FOLLOW_YOUR_SELF("RELATION-20001", "无法关注自己"), CANT_FOLLOW_YOUR_SELF("RELATION-20001", "无法关注自己"),
FOLLOW_USER_NOT_EXISTED("RELATION-20002", "关注的用户不存在"), FOLLOW_USER_NOT_EXISTED("RELATION-20002", "关注的用户不存在"),
FOLLOWING_COUNT_LIMIT("RELATION-20003", "您关注的用户已达上限,请先取关部分用户"),
ALREADY_FOLLOWED("RELATION-20004", "您已经关注了该用户"),
; ;
// 异常码 // 异常码

View File

@@ -1,17 +1,32 @@
package com.hanserwei.hannote.user.relation.biz.service.impl; 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.biz.context.holder.LoginUserContextHolder;
import com.hanserwei.framework.common.exception.ApiException; import com.hanserwei.framework.common.exception.ApiException;
import com.hanserwei.framework.common.response.Response; import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO; 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.enums.ResponseCodeEnum;
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.rpc.UserRpcService; 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.service.RelationService;
import com.hanserwei.hannote.user.relation.biz.util.DateUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; 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 org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Objects; import java.util.Objects;
@Service @Service
@@ -20,6 +35,10 @@ public class RelationServiceImpl implements RelationService {
@Resource @Resource
private UserRpcService userRpcService; private UserRpcService userRpcService;
@Resource
private RedisTemplate<Object, Object> redisTemplate;
@Resource
private FollowingDOService followingDOService;
@Override @Override
public Response<?> follow(FollowUserReqVO followUserReqVO) { public Response<?> follow(FollowUserReqVO followUserReqVO) {
@@ -37,12 +56,109 @@ public class RelationServiceImpl implements RelationService {
throw new ApiException(ResponseCodeEnum.FOLLOW_USER_NOT_EXISTED); throw new ApiException(ResponseCodeEnum.FOLLOW_USER_NOT_EXISTED);
} }
// TODO: 校验关注数是否已经达到上限 // 校验当前用户的Zset关注列表是否已经存在
String followingRedisKey = RedisKeyConstants.buildUserFollowingKey(userId);
// TODO: 写入 Redis ZSET 关注列表 DefaultRedisScript<Long> 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<FollowingDO> 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<Long> 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<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 脚本follow_check_and_add.lua , 将最新地关注关系添加进去
result = redisTemplate.execute(script, Collections.singletonList(followingRedisKey), followUserId, timestamp);
checkLuaScriptResult(result);
}
}
// TODO: 发送 MQ // TODO: 发送 MQ
return Response.success(); 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<FollowingDO> 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;
}
} }

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -136,4 +136,13 @@ Authorization: Bearer {{token}}
{ {
"followUserId": -1 "followUserId": -1
}
### 正常关注用户
POST http://localhost:8000/relation/relation/follow
Content-Type: application/json
Authorization: Bearer {{token}}
{
"followUserId":{{otherUserId}}
} }

View File

@@ -1,7 +1,9 @@
{ {
"dev": { "dev": {
"token": "4bXpiBbjXEDFE4ZpqjCOHu1rP81qepl2ROOygrxRGb61K536ckLuyAwfyQHSMcyRdUzf8CxntLEMfbU2ynbYx9nJKlx4vpWZrHqv2mI4iMhnShQ4mPBi7OPPgZi22O2f", "token": "4bXpiBbjXEDFE4ZpqjCOHu1rP81qepl2ROOygrxRGb61K536ckLuyAwfyQHSMcyRdUzf8CxntLEMfbU2ynbYx9nJKlx4vpWZrHqv2mI4iMhnShQ4mPBi7OPPgZi22O2f",
"otherToken": "mqFNHrWkPcipIAvw7Gn4cigOWYP54sn8HYlQX3CXTxHf90DhjFiROhWVgPqLBi35xKXOOfHlXeEdaQrkXf1JXd8hbXBOdZqnrycW96BJwTbUS40EqIZifVgPun3ai0Ek",
"noteId": "1977249693272375330", "noteId": "1977249693272375330",
"userId": "100" "userId": "100",
"otherUserId": "2100"
} }
} }