Compare commits

..

5 Commits

Author SHA1 Message Date
362c32cbd6 feat(user-relation): 实现用户关注与取消关注功能
- 在 t_following 和 t_fans 表中添加联合唯一索引,确保关注关系的幂等性- 新增 RocketMQ 消费者 FollowUnfollowConsumer,处理关注和取消关注消息
- 实现关注逻辑,通过事务保证关注表和粉丝表数据一致性
- 修改 DeleteNoteLocalCacheConsumer 的 consumerGroup 名称,避免消费者组冲突,否则可能遇到消费者不消费的情况
2025-10-12 21:17:39 +08:00
5a7564d504 feat(relation): 实现用户关注功能并集成RocketMQ消息队列
- 新增关注用户MQ消息传输对象 FollowUserMqDTO
- 定义MQ常量类 MQConstants,包含关注/取关主题与标签
- 引入RocketMQ依赖及自动配置类 RocketMQConfig
- 在关注接口中构造并异步发送关注操作消息
- 使用JsonUtils将消息体序列化为JSON字符串
- 添加日志记录MQ发送状态及异常处理回调
2025-10-12 20:03:33 +08:00
3c8dc9e4af feat(relation): 实现用户关注功能及相关校验逻辑
- 新增 DateUtils 工具类,支持 LocalDateTime 转时间戳
- 编写三个 Lua 脚本:单条关注、批量关注及关注校验与添加
- 新增 RedisKeyConstants 常量类,用于构建关注列表 KEY
- 新增 LuaResultEnum 枚举,定义 Lua 脚本返回结果状态
- 实现关注接口的完整业务逻辑,包括 Redis 校验和数据库兜底
- 添加 HTTP 测试用例和环境变量配置
- 支持关注关系的过期策略,包含随机过期时间计算
- 增加对关注上限和重复关注的业务异常处理
- 实现从数据库同步关注数据到 Redis 的逻辑
- 使用 Lua 脚本保证操作的原子性和性能优化
2025-10-12 19:55:20 +08:00
7942a46592 feat(relation): 实现用户关注功能
- 新增关注用户接口,支持通过用户ID关注其他用户
- 添加参数校验,确保被关注用户ID不为空
- 实现关注用户时的业务逻辑,包括:
  -不能关注自己
  - 校验被关注用户是否存
  - 集成Feign客户端,调用用户服务查询用户信息
- 定义关注相关的异常码和错误信息
- 更新网关配置,路由/relation/**请求到用户关系服务- 添加HTTP客户端测试用例,用于验证关注功能
- 引入用户API依赖,支持远程调用用户服务
2025-10-12 15:02:15 +08:00
16ab8a13d2 feat(http): 添加用户信息更新和笔记管理接口测试用例
- 新增更新用户信息的 multipart/form-data 请求示例
- 添加发布图文笔记和视频笔记的 JSON 请求示例- 添加笔记详情查询和笔记修改的请求示例
- 在私有环境变量中增加 noteId 字段用于笔记相关接口测试
- 调整包名路径以符合项目结构规范
2025-10-12 14:13:52 +08:00
25 changed files with 741 additions and 6 deletions

View File

@@ -22,6 +22,12 @@ spring:
- Path=/note/** - Path=/note/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
- id: user-relation
uri: lb://han-note-user-relation
predicates:
- Path=/relation/**
filters:
- StripPrefix=1
data: data:
redis: redis:
database: 5 # Redis 数据库索引(默认为 0 database: 5 # Redis 数据库索引(默认为 0

View File

@@ -10,7 +10,7 @@ import org.springframework.stereotype.Component;
@Component @Component
@Slf4j @Slf4j
@RocketMQMessageListener( @RocketMQMessageListener(
consumerGroup = "han_note_group", consumerGroup = "han_note_group_" + MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE,
topic = MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE, topic = MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE,
messageModel = MessageModel.BROADCASTING messageModel = MessageModel.BROADCASTING
) )

View File

@@ -91,6 +91,17 @@
<artifactId>spring-boot-starter-data-redis</artifactId> <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-user-api</artifactId>
</dependency>
<!-- Rocket MQ -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>

View File

@@ -1,11 +1,13 @@
package com.hanserwei.hannote.user.relation.biz.domain; package com.hanserwei.hannote.user.relation.biz;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication @SpringBootApplication
@MapperScan("com.hanserwei.hannote.user.relation.biz.domain.mapper") @MapperScan("com.hanserwei.hannote.user.relation.biz.domain.mapper")
@EnableFeignClients(basePackages = "com.hanserwei.hannote")
public class HannoteUserRelationBizApplication { public class HannoteUserRelationBizApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(HannoteUserRelationBizApplication.class, args); SpringApplication.run(HannoteUserRelationBizApplication.class, args);

View File

@@ -0,0 +1,10 @@
package com.hanserwei.hannote.user.relation.biz.config;
import org.apache.rocketmq.spring.autoconfigure.RocketMQAutoConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@Import(RocketMQAutoConfiguration.class)
public class RocketMQConfig {
}

View File

@@ -0,0 +1,19 @@
package com.hanserwei.hannote.user.relation.biz.constant;
public interface MQConstants {
/**
* Topic: 关注、取关共用一个
*/
String TOPIC_FOLLOW_OR_UNFOLLOW = "FollowUnfollowTopic";
/**
* 关注标签
*/
String TAG_FOLLOW = "Follow";
/**
* 取关标签
*/
String TAG_UNFOLLOW = "Unfollow";
}

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,102 @@
package com.hanserwei.hannote.user.relation.biz.consumer;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.user.relation.biz.constant.MQConstants;
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.service.FansDOService;
import com.hanserwei.hannote.user.relation.biz.service.FollowingDOService;
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.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.Objects;
@Component
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW,
topic = MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW
)
@Slf4j
public class FollowUnfollowConsumer implements RocketMQListener<Message> {
private final TransactionTemplate transactionTemplate;
private final FollowingDOService followingDOService;
private final FansDOService fansDOService;
public FollowUnfollowConsumer(TransactionTemplate transactionTemplate, FollowingDOService followingDOService, FansDOService fansDOService) {
this.transactionTemplate = transactionTemplate;
this.followingDOService = followingDOService;
this.fansDOService = fansDOService;
}
@Override
public void onMessage(Message message) {
// 消息体
String bodyJsonStr = new String(message.getBody());
// 标签
String tags = message.getTags();
log.info("==> FollowUnfollowConsumer 消费了消息 {}, tags: {}", bodyJsonStr, tags);
// 根据MQ标签判断操作类型
if (Objects.equals(tags, MQConstants.TAG_FOLLOW)){
// 关注
handleFollowTagMessage(bodyJsonStr);
} else if (Objects.equals(tags, MQConstants.TAG_UNFOLLOW)) {
// 取关
// TODO: 待实现
}
}
/**
* 关注
* @param bodyJsonStr 消息体
*/
private void handleFollowTagMessage(String bodyJsonStr) {
// 解析消息体转换为DTO对象
FollowUserMqDTO followUserMqDTO = JsonUtils.parseObject(bodyJsonStr, FollowUserMqDTO.class);
// 判空
if (Objects.isNull(followUserMqDTO)) {
return;
}
// 幂等性:通过联合唯一索引保证
Long userId = followUserMqDTO.getUserId();
Long followUserId = followUserMqDTO.getFollowUserId();
LocalDateTime createTime = followUserMqDTO.getCreateTime();
// 编程式事物
boolean isSuccess = Boolean.TRUE.equals(transactionTemplate.execute(status -> {
try {
// 关注成功需往数据库添加两条记录
// 关注表:一条记录
boolean followRecordSaved = followingDOService.save(FollowingDO.builder()
.userId(userId)
.followingUserId(followUserId)
.createTime(createTime)
.build());
// 粉丝表:一条记录
if (followRecordSaved){
return fansDOService.save(FansDO.builder()
.userId(followUserId)
.fansUserId(userId)
.createTime(createTime)
.build());
}
}catch (Exception e){
status.setRollbackOnly();
log.error("## 添加关注关系失败, userId: {}, followUserId: {}, createTime: {}", userId, followUserId, createTime);
}
return false;
}));
log.info("## 数据库添加记录结果: {}", isSuccess);
// TODO: 更新 Redis 中被关注用户的 ZSet 粉丝列表
}
}

View File

@@ -0,0 +1,29 @@
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.service.RelationService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/relation")
@Slf4j
public class RelationController {
@Resource
private RelationService relationService;
@PostMapping("/follow")
@ApiOperationLog(description = "关注用户")
public Response<?> follow(@Validated @RequestBody FollowUserReqVO followUserReqVO) {
return relationService.follow(followUserReqVO);
}
}

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

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

View File

@@ -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 FollowUserMqDTO {
private Long userId;
private Long followUserId;
private LocalDateTime createTime;
}

View File

@@ -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 FollowUserReqVO {
@NotNull(message = "被关注用户 ID 不能为空")
private Long followUserId;
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.hannote.user.relation.biz.rpc;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.api.UserFeignApi;
import com.hanserwei.hannote.user.dto.req.FindUserByIdReqDTO;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
public class UserRpcService {
@Resource
private UserFeignApi userFeignApi;
/**
* 根据用户 ID 查询
*
* @param userId 用户 ID
* @return 用户信息
*/
public FindUserByIdRspDTO findById(Long userId) {
FindUserByIdReqDTO findUserByIdReqDTO = new FindUserByIdReqDTO();
findUserByIdReqDTO.setId(userId);
Response<FindUserByIdRspDTO> response = userFeignApi.findById(findUserByIdReqDTO);
if (!response.isSuccess() || Objects.isNull(response.getData())) {
return null;
}
return response.getData();
}
}

View File

@@ -0,0 +1,15 @@
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;
public interface RelationService {
/**
* 关注用户
*
* @param followUserReqVO 关注用户请求
* @return 响应
*/
Response<?> follow(FollowUserReqVO followUserReqVO);
}

View File

@@ -0,0 +1,203 @@
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.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
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.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.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.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
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
@Slf4j
public class RelationServiceImpl implements RelationService {
@Resource
private UserRpcService userRpcService;
@Resource
private RedisTemplate<Object, Object> redisTemplate;
@Resource
private FollowingDOService followingDOService;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Override
public Response<?> follow(FollowUserReqVO followUserReqVO) {
// 获取被关注用户 ID
Long followUserId = followUserReqVO.getFollowUserId();
// 获取当前登录用户 ID
Long userId = LoginUserContextHolder.getUserId();
if (Objects.equals(userId, followUserId)) {
throw new ApiException(ResponseCodeEnum.CANT_FOLLOW_YOUR_SELF);
}
// 校验关注的用户是否存在
FindUserByIdRspDTO findUserByIdRspDTO = userRpcService.findById(followUserId);
if (Objects.isNull(findUserByIdRspDTO)){
throw new ApiException(ResponseCodeEnum.FOLLOW_USER_NOT_EXISTED);
}
// 校验当前用户的Zset关注列表是否已经存在
String followingRedisKey = RedisKeyConstants.buildUserFollowingKey(userId);
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);
}
}
// 发送 MQ
// 构造消息体DTO
FollowUserMqDTO followUserMqDTO = FollowUserMqDTO.builder()
.userId(userId)
.followUserId(followUserId)
.createTime(now)
.build();
// 构造消息对象并把DTO转换为JSON字符串设置到消息体中
Message<String> message = MessageBuilder
.withPayload(JsonUtils.toJsonString(followUserMqDTO))
.build();
// 通过冒号连接, 可让 MQ 发送给主题 Topic 时,携带上标签 Tag
String destination = MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW + ":" + MQConstants.TAG_FOLLOW;
log.info("==> 开始发送关注操作 MQ, 消息体: {}", followUserMqDTO);
// 异步发送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 脚本返回结果
*/
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

BIN
http-client/file/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -3,7 +3,7 @@ POST http://localhost:8000/auth/verification/code/send
Content-Type: application/json Content-Type: application/json
{ {
"email": "ssw010723@gmail.com" "email": "2628273921@qq.com"
} }
### 登录/注册 ### 登录/注册
@@ -11,8 +11,8 @@ POST http://localhost:8000/auth/login
Content-Type: application/json Content-Type: application/json
{ {
"email": "ssw010723@gmail.com", "email": "2628273921@qq.com",
"code": "135466", "code": "825004",
"type": 1 "type": 1
} }
@@ -25,3 +25,124 @@ Authorization: Bearer {{token}}
"newPassword": "wwgb1314" "newPassword": "wwgb1314"
} }
### 更新用户信息
POST http://localhost:8000/user/user/update
Content-Type: multipart/form-data; boundary=WebAppBoundary
Authorization: Bearer {{token}}
--WebAppBoundary
Content-Disposition: form-data; name="avatar"; filename="avatar.png"
Content-Type: image/png
< ./file/img.png
--WebAppBoundary
Content-Disposition: form-data; name="nickname"
Hanserwei
--WebAppBoundary
Content-Disposition: form-data; name="hanNoteId"
hanserwei010723
--WebAppBoundary
Content-Disposition: form-data; name="sex"
1
--WebAppBoundary
Content-Disposition: form-data; name="birthday"
2001-07-23
--WebAppBoundary
Content-Disposition: form-data; name="introduction"
我在HttpClient里测试的
--WebAppBoundary--
Content-Disposition: form-data; name="backgroundImg"; filename="backgroundImg.png"
Content-Type: image/jpeg
< ./file/backgroundImg.jpg
### 发布图文笔记
POST http://localhost:8000/note/note/publish
Content-Type: application/json
Authorization: Bearer {{token}}
{
"type": 0,
"imgUris": [
"https://cdn.pixabay.com/photo/2025/10/05/15/06/autumn-9875155_1280.jpg"
],
"title": "图文笔记测试",
"content": "这个是图文笔记的测试",
"topicId": 1
}
### 发布视频笔记
POST http://localhost:8000/note/note/publish
Content-Type: application/json
Authorization: Bearer {{token}}
{
"type": 1,
"videoUri": "https://cdn.pixabay.com/photo/2025/10/05/15/06/autumn-9875155_1280.jpg",
"title": "视频笔记测试",
"content": "这个是视频笔记的测试",
"topicId": 2
}
### 笔记详情
POST http://localhost:8000/note/note/detail
Content-Type: application/json
Authorization: Bearer {{token}}
{
"id": {{noteId}}
}
### 修改笔记
POST http://localhost:8000/note/note/update
Content-Type: application/json
Authorization: Bearer {{token}}
{
"id": {{noteId}},
"type": 0,
"imgUris": [
"https://cdn.pixabay.com/photo/2025/10/05/15/06/autumn-9875155_1280.jpg"
],
"title": "笔记修改测试",
"content": "我把图文笔记的内容修改了",
"topicId": 1
}
### 关注自己
POST http://localhost:8000/relation/relation/follow
Content-Type: application/json
Authorization: Bearer {{token}}
{
"followUserId": {{userId}}
}
### 关注不存在的用户
POST http://localhost:8000/relation/relation/follow
Content-Type: application/json
Authorization: Bearer {{token}}
{
"followUserId": -1
}
### 正常关注用户
POST http://localhost:8000/relation/relation/follow
Content-Type: application/json
Authorization: Bearer {{token}}
{
"followUserId": {{otherUserId}}
}

View File

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

View File

@@ -175,4 +175,8 @@ CREATE TABLE `t_fans`
DEFAULT CHARSET = utf8mb4 DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='用户粉丝表'; COLLATE = utf8mb4_unicode_ci COMMENT ='用户粉丝表';
ALTER TABLE t_following ADD UNIQUE uk_user_id_following_user_id(user_id, following_user_id);
ALTER TABLE t_fans ADD UNIQUE uk_user_id_fans_user_id(user_id, fans_user_id);