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", "无法关注自己"),
|
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", "您已经关注了该用户"),
|
||||||
;
|
;
|
||||||
|
|
||||||
// 异常码
|
// 异常码
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
"followUserId": -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
### 正常关注用户
|
||||||
|
POST http://localhost:8000/relation/relation/follow
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"followUserId":{{otherUserId}}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"dev": {
|
"dev": {
|
||||||
"token": "4bXpiBbjXEDFE4ZpqjCOHu1rP81qepl2ROOygrxRGb61K536ckLuyAwfyQHSMcyRdUzf8CxntLEMfbU2ynbYx9nJKlx4vpWZrHqv2mI4iMhnShQ4mPBi7OPPgZi22O2f",
|
"token": "4bXpiBbjXEDFE4ZpqjCOHu1rP81qepl2ROOygrxRGb61K536ckLuyAwfyQHSMcyRdUzf8CxntLEMfbU2ynbYx9nJKlx4vpWZrHqv2mI4iMhnShQ4mPBi7OPPgZi22O2f",
|
||||||
|
"otherToken": "mqFNHrWkPcipIAvw7Gn4cigOWYP54sn8HYlQX3CXTxHf90DhjFiROhWVgPqLBi35xKXOOfHlXeEdaQrkXf1JXd8hbXBOdZqnrycW96BJwTbUS40EqIZifVgPun3ai0Ek",
|
||||||
"noteId": "1977249693272375330",
|
"noteId": "1977249693272375330",
|
||||||
"userId": "100"
|
"userId": "100",
|
||||||
|
"otherUserId": "2100"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user