feat(note): 实现笔记收藏功能

- 新增笔记收藏接口及对应业务逻辑
- 添加布隆过滤器和ZSet校验笔记是否已收藏
- 实现异步初始化用户收藏笔记数据到Redis
- 新增多个Lua脚本支持批量操作和过期时间设置
- 更新NoteCollectionDO实体类字段类型和时间格式
- 添加收藏相关枚举类和请求VO类
- 扩展RedisKeyConstants常量类支持收藏功能键名构建
- 在网关API测试文件中增加笔记收藏入口配置
This commit is contained in:
2025-10-18 21:11:10 +08:00
parent 54c34706fb
commit 65b089de70
14 changed files with 370 additions and 3 deletions

View File

@@ -12,11 +12,21 @@ public class RedisKeyConstants {
*/
public static final String BLOOM_USER_NOTE_LIKE_LIST_KEY = "bloom:note:likes:";
/**
* 布隆过滤器:用户笔记收藏 前缀
*/
public static final String BLOOM_USER_NOTE_COLLECT_LIST_KEY = "bloom:note:collects:";
/**
* 用户笔记点赞列表 ZSet 前缀
*/
public static final String USER_NOTE_LIKE_ZSET_KEY = "user:note:likes:";
/**
* 用户笔记收藏列表 ZSet 前缀
*/
public static final String USER_NOTE_COLLECT_ZSET_KEY = "user:note:collects:";
/**
* 构建完整的笔记详情 KEY
@@ -37,6 +47,16 @@ public class RedisKeyConstants {
return BLOOM_USER_NOTE_LIKE_LIST_KEY + userId;
}
/**
* 构建完整的布隆过滤器:用户笔记收藏 KEY
*
* @param userId 用户ID
* @return 布隆过滤器:用户笔记收藏 KEY
*/
public static String buildBloomUserNoteCollectListKey(Long userId) {
return BLOOM_USER_NOTE_COLLECT_LIST_KEY + userId;
}
/**
* 构建完整的用户笔记点赞列表 ZSet KEY
*
@@ -47,4 +67,13 @@ public class RedisKeyConstants {
return USER_NOTE_LIKE_ZSET_KEY + userId;
}
/**
* 构建完整的用户笔记收藏列表 ZSet KEY
*
* @param userId 用户ID
* @return 用户笔记收藏列表 ZSet KEY
*/
public static String buildUserNoteCollectZSetKey(Long userId) {
return USER_NOTE_COLLECT_ZSET_KEY + userId;
}
}

View File

@@ -68,4 +68,10 @@ public class NoteController {
return noteService.unlikeNote(unlikeNoteReqVO);
}
@PostMapping(value = "/collect")
@ApiOperationLog(description = "收藏笔记")
public Response<?> collectNote(@Validated @RequestBody CollectNoteReqVO collectNoteReqVO) {
return noteService.collectNote(collectNoteReqVO);
}
}

View File

@@ -4,12 +4,13 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.util.Date;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 笔记收藏表
*/
@@ -41,11 +42,11 @@ public class NoteCollectionDO {
* 创建时间
*/
@TableField(value = "create_time")
private Date createTime;
private LocalDateTime createTime;
/**
* 收藏状态(0取消收藏 1收藏)
*/
@TableField(value = "`status`")
private Byte status;
private Integer status;
}

View File

@@ -0,0 +1,14 @@
package com.hanserwei.hannote.note.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum CollectStatusEnum {
COLLECT(1), // 收藏
UNCOLLECTED(0), // 取消收藏
;
private final Integer code;
}

View File

@@ -0,0 +1,35 @@
package com.hanserwei.hannote.note.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum NoteCollectLuaResultEnum {
// 布隆过滤器或者 ZSet 不存在
NOT_EXIST(-1L),
// 笔记已收藏
NOTE_COLLECTED(1L),
// 笔记收藏成功
NOTE_COLLECTED_SUCCESS(0L),
;
private final Long code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static NoteCollectLuaResultEnum valueOf(Long code) {
for (NoteCollectLuaResultEnum noteCollectLuaResultEnum : NoteCollectLuaResultEnum.values()) {
if (Objects.equals(code, noteCollectLuaResultEnum.getCode())) {
return noteCollectLuaResultEnum;
}
}
return null;
}
}

View File

@@ -23,6 +23,7 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
NOTE_CANT_OPERATE("NOTE-20007", "您无法操作该笔记"),
NOTE_ALREADY_LIKED("NOTE-20008", "您已经点赞过该笔记"),
NOTE_NOT_LIKED("NOTE-20009", "您未点赞该篇笔记,无法取消点赞"),
NOTE_ALREADY_COLLECTED("NOTE-20010", "您已经收藏过该笔记"),
;
// 异常码

View File

@@ -0,0 +1,18 @@
package com.hanserwei.hannote.note.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 CollectNoteReqVO {
@NotNull(message = "笔记 ID 不能为空")
private Long id;
}

View File

@@ -65,4 +65,12 @@ public interface NoteService extends IService<NoteDO> {
*/
Response<?> unlikeNote(UnlikeNoteReqVO unlikeNoteReqVO);
/**
* 收藏笔记
*
* @param collectNoteReqVO 收藏笔记请求
* @return 收藏笔记结果
*/
Response<?> collectNote(CollectNoteReqVO collectNoteReqVO);
}

View File

@@ -16,6 +16,7 @@ import com.hanserwei.framework.common.utils.DateUtils;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.note.biz.constant.MQConstants;
import com.hanserwei.hannote.note.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteCollectionDO;
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteDO;
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteLikeDO;
import com.hanserwei.hannote.note.biz.domain.dataobject.TopicDO;
@@ -26,6 +27,7 @@ import com.hanserwei.hannote.note.biz.model.vo.*;
import com.hanserwei.hannote.note.biz.rpc.DistributedIdGeneratorRpcService;
import com.hanserwei.hannote.note.biz.rpc.KeyValueRpcService;
import com.hanserwei.hannote.note.biz.rpc.UserRpcService;
import com.hanserwei.hannote.note.biz.service.NoteCollectionDOService;
import com.hanserwei.hannote.note.biz.service.NoteLikeDOService;
import com.hanserwei.hannote.note.biz.service.NoteService;
import com.hanserwei.hannote.note.biz.service.TopicDOService;
@@ -37,6 +39,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@@ -84,6 +87,8 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
.build();
@Resource
private NoteLikeDOService noteLikeDOService;
@Autowired
private NoteCollectionDOService noteCollectionDOService;
@Override
public Response<?> publishNote(PublishNoteReqVO publishNoteReqVO) {
@@ -794,6 +799,185 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
return Response.success();
}
@Override
public Response<?> collectNote(CollectNoteReqVO collectNoteReqVO) {
// 笔记ID
Long noteId = collectNoteReqVO.getId();
// 1. 校验被收藏的笔记是否存在
checkNoteIsExist(noteId);
// TODO: 2. 判断目标笔记,是否已经收藏过
// 当前登录用户ID
Long userId = LoginUserContextHolder.getUserId();
// 布隆过滤器Key
String bloomUserNoteCollectListKey = RedisKeyConstants.buildBloomUserNoteCollectListKey(userId);
// 构建 Redis Key
String userNoteCollectZSetKey = RedisKeyConstants.buildUserNoteCollectZSetKey(userId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_note_collect_check.lua")));
// 返回值类型
script.setResultType(Long.class);
// 执行 Lua 脚本,拿到返回结果
Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserNoteCollectListKey), noteId);
NoteCollectLuaResultEnum noteCollectLuaResultEnum = NoteCollectLuaResultEnum.valueOf(result);
log.info("==> 【笔记收藏】Lua 脚本返回结果: {}", noteCollectLuaResultEnum);
assert noteCollectLuaResultEnum != null;
switch (noteCollectLuaResultEnum) {
// 布隆过滤器不存在
case NOT_EXIST -> {
// 从数据库中校验笔记是否被收藏,并异步初始化布隆过滤器,设置过期时间
long count = noteCollectionDOService.count(new LambdaQueryWrapper<>(NoteCollectionDO.class)
.eq(NoteCollectionDO::getUserId, userId)
.eq(NoteCollectionDO::getNoteId, noteId)
.eq(NoteCollectionDO::getStatus, CollectStatusEnum.COLLECT.getCode()));
// 设置隋朝过期时间
long expireSeconds = 60 * 60 * 24 + RandomUtil.randomInt(60 * 60 * 24);
// 若目标笔记已经收藏
if (count > 0) {
// 异步初始化布隆过滤器
threadPoolTaskExecutor.submit(() -> {
batchAddNoteCollect2BloomAndExpire(userId, expireSeconds, bloomUserNoteCollectListKey);
});
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_COLLECTED);
}
// 若目标笔记未被收藏,查询当前用户是否有收藏其他笔记,有则同步初始化布隆过滤器
batchAddNoteCollect2BloomAndExpire(userId, expireSeconds, bloomUserNoteCollectListKey);
// 添加当前收藏笔记 ID 到布隆过滤器中
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_add_note_collect_and_expire.lua")));
// 返回值类型
script.setResultType(Long.class);
redisTemplate.execute(script, Collections.singletonList(bloomUserNoteCollectListKey), noteId, expireSeconds);
}
// 目标笔记已经被收藏 (可能存在误判,需要进一步确认)
case NOTE_COLLECTED -> {
// 校验ZSet列表中是否有被收藏的笔记ID
Double score = redisTemplate.opsForZSet().score(userNoteCollectZSetKey, noteId);
if (Objects.nonNull(score)) {
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_COLLECTED);
}
// 若score为空则说明该笔记未被收藏查数据库确认
long count = noteCollectionDOService.count(new LambdaQueryWrapper<>(NoteCollectionDO.class)
.eq(NoteCollectionDO::getUserId, userId)
.eq(NoteCollectionDO::getNoteId, noteId)
.eq(NoteCollectionDO::getStatus, CollectStatusEnum.COLLECT.getCode()));
if (count > 0) {
// 数据库里面有收藏记录,而 Redis 中 ZSet 已过期被删除的话,需要重新异步初始化 ZSet
asynInitUserNoteCollectsZSet(userId, userNoteCollectZSetKey);
throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_COLLECTED);
}
}
}
// TODO: 3. 更新用户 ZSET 收藏列表
// TODO: 4. 发送 MQ, 将收藏数据落库
return Response.success();
}
/**
* 异步初始化用户收藏笔记 ZSet
*
* @param userId 用户ID
* @param userNoteCollectZSetKey 用户收藏笔记 ZSet KEY
*/
private void asynInitUserNoteCollectsZSet(Long userId, String userNoteCollectZSetKey) {
threadPoolTaskExecutor.submit(() -> {
// 判断用户笔记收藏 ZSET 是否存在
boolean hasKey = redisTemplate.hasKey(userNoteCollectZSetKey);
// 不存在则初始化
if (!hasKey) {
// 查询当前用户最新收藏的 300 篇笔记
Page<NoteCollectionDO> page = noteCollectionDOService.page(new Page<>(1, 300), new LambdaQueryWrapper<>(NoteCollectionDO.class)
.select(NoteCollectionDO::getNoteId, NoteCollectionDO::getCreateTime)
.eq(NoteCollectionDO::getUserId, userId)
.eq(NoteCollectionDO::getStatus, CollectStatusEnum.COLLECT.getCode())
.orderByDesc(NoteCollectionDO::getCreateTime));
List<NoteCollectionDO> noteCollectionDOS = page.getRecords();
if (CollUtil.isNotEmpty(noteCollectionDOS)) {
// 保底1天+随机秒数
long expireSeconds = 60 * 60 * 24 + RandomUtil.randomInt(60 * 60 * 24);
// 构建 Lua 参数
Object[] luaArgs = buildNoteCollectZSetLuaArgs(noteCollectionDOS, expireSeconds);
DefaultRedisScript<Long> script2 = new DefaultRedisScript<>();
// Lua 脚本路径
script2.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/batch_add_note_collect_zset_and_expire.lua")));
// 返回值类型
script2.setResultType(Long.class);
redisTemplate.execute(script2, Collections.singletonList(userNoteCollectZSetKey), luaArgs);
}
}
});
}
/**
* 构建笔记收藏 ZSET Lua 脚本参数
*
* @param noteCollectionDOS 笔记收藏列表
* @param expireSeconds 过期时间
* @return Lua 脚本参数
*/
private Object[] buildNoteCollectZSetLuaArgs(List<NoteCollectionDO> noteCollectionDOS, long expireSeconds) {
int argsLength = noteCollectionDOS.size() * 2 + 1; // 每个笔记收藏关系有 2 个参数score 和 value最后再跟一个过期时间
Object[] luaArgs = new Object[argsLength];
int i = 0;
for (NoteCollectionDO noteCollectionDO : noteCollectionDOS) {
// 收藏时间作为 score
luaArgs[i] = DateUtils.localDateTime2Timestamp(noteCollectionDO.getCreateTime());
// 笔记ID 作为 ZSet value
luaArgs[i + 1] = noteCollectionDO.getNoteId();
i += 2;
}
luaArgs[argsLength - 1] = expireSeconds; // 最后一个参数是 ZSet 的过期时间
return luaArgs;
}
/**
* 批量添加用户收藏笔记到布隆过滤器中
*
* @param userId 用户ID
* @param expireSeconds 过期时间
* @param bloomUserNoteCollectListKey 布隆过滤器:用户收藏笔记
*/
private void batchAddNoteCollect2BloomAndExpire(Long userId, long expireSeconds, String bloomUserNoteCollectListKey) {
try {
// 异步全量同步一下,并设置过期时间
List<NoteLikeDO> noteCollectionDOS = noteLikeDOService.list(new LambdaQueryWrapper<>(NoteLikeDO.class)
.select(NoteLikeDO::getNoteId)
.eq(NoteLikeDO::getUserId, userId)
.eq(NoteLikeDO::getStatus, CollectStatusEnum.COLLECT.getCode()));
if (CollUtil.isNotEmpty(noteCollectionDOS)) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_batch_add_note_collect_and_expire.lua")));
// 返回值类型
script.setResultType(Long.class);
// 构造Lua参数
List<Object> luaParams = Lists.newArrayList();
// 将每个收藏的笔记 ID 传入
noteCollectionDOS.forEach(noteLikeDO -> luaParams.add(noteLikeDO.getNoteId()));
// 最后一个参数是过期时间
luaParams.add(expireSeconds);
redisTemplate.execute(script, Collections.singletonList(bloomUserNoteCollectListKey), luaParams.toArray());
}
} catch (Exception e) {
log.error("## 异步初始化【笔记收藏】布隆过滤器异常: ", e);
}
}
/**
* 异步初始化用户点赞笔记 ZSet
*

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,10 @@
-- 操作的 Key
local key = KEYS[1]
local noteId = ARGV[1] -- 笔记ID
local expireSeconds = ARGV[2] -- 过期时间(秒)
redis.call("BF.ADD", key, noteId)
-- 设置过期时间
redis.call("EXPIRE", key, expireSeconds)
return 0

View File

@@ -0,0 +1,12 @@
-- 操作的 Key
local key = KEYS[1]
for i = 1, #ARGV - 1 do
redis.call("BF.ADD", key, ARGV[i])
end
---- 最后一个参数为过期时间
local expireTime = ARGV[#ARGV]
-- 设置过期时间
redis.call("EXPIRE", key, expireTime)
return 0

View File

@@ -0,0 +1,20 @@
-- LUA 脚本:笔记收藏布隆过滤器
local key = KEYS[1] -- 操作的 Redis Key
local noteId = ARGV[1] -- 笔记ID
-- 使用 EXISTS 命令检查布隆过滤器是否存在
local exists = redis.call('EXISTS', key)
if exists == 0 then
return -1
end
-- 校验该篇笔记是否被收藏过(1 表示已经收藏0 表示未收藏)
local isCollected = redis.call('BF.EXISTS', key, noteId)
if isCollected == 1 then
return 1
end
-- 未被收藏,添加收藏数据
redis.call('BF.ADD', key, noteId)
return 0

View File

@@ -199,6 +199,15 @@ POST http://localhost:8000/note/note/unlike
Content-Type: application/json
Authorization: Bearer {{token}}
{
"id": 1977249693272375330
}
### 笔记收藏入口
POST http://localhost:8000/note/note/collect
Content-Type: application/json
Authorization: Bearer {{token}}
{
"id": 1977249693272375330
}