feat(relation): 实现用户关注功能及相关校验逻辑
- 新增 DateUtils 工具类,支持 LocalDateTime 转时间戳 - 编写三个 Lua 脚本:单条关注、批量关注及关注校验与添加 - 新增 RedisKeyConstants 常量类,用于构建关注列表 KEY - 新增 LuaResultEnum 枚举,定义 Lua 脚本返回结果状态 - 实现关注接口的完整业务逻辑,包括 Redis 校验和数据库兜底 - 添加 HTTP 测试用例和环境变量配置 - 支持关注关系的过期策略,包含随机过期时间计算 - 增加对关注上限和重复关注的业务异常处理 - 实现从数据库同步关注数据到 Redis 的逻辑 - 使用 Lua 脚本保证操作的原子性和性能优化
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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", "您已经关注了该用户"),
|
||||
;
|
||||
|
||||
// 异常码
|
||||
|
||||
@@ -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<Object, Object> 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<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
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -137,3 +137,12 @@ Authorization: Bearer {{token}}
|
||||
{
|
||||
"followUserId": -1
|
||||
}
|
||||
|
||||
### 正常关注用户
|
||||
POST http://localhost:8000/relation/relation/follow
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"followUserId":{{otherUserId}}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"dev": {
|
||||
"token": "4bXpiBbjXEDFE4ZpqjCOHu1rP81qepl2ROOygrxRGb61K536ckLuyAwfyQHSMcyRdUzf8CxntLEMfbU2ynbYx9nJKlx4vpWZrHqv2mI4iMhnShQ4mPBi7OPPgZi22O2f",
|
||||
"otherToken": "mqFNHrWkPcipIAvw7Gn4cigOWYP54sn8HYlQX3CXTxHf90DhjFiROhWVgPqLBi35xKXOOfHlXeEdaQrkXf1JXd8hbXBOdZqnrycW96BJwTbUS40EqIZifVgPun3ai0Ek",
|
||||
"noteId": "1977249693272375330",
|
||||
"userId": "100"
|
||||
"userId": "100",
|
||||
"otherUserId": "2100"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user