diff --git a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/constant/RedisKeyConstants.java b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/constant/RedisKeyConstants.java index 73458d9..c26f570 100644 --- a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/constant/RedisKeyConstants.java +++ b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/constant/RedisKeyConstants.java @@ -7,6 +7,11 @@ public class RedisKeyConstants { */ public static final String NOTE_DETAIL_KEY = "note:detail:"; + /** + * 布隆过滤器:用户笔记点赞 + */ + public static final String BLOOM_USER_NOTE_LIKE_LIST_KEY = "bloom:note:likes:"; + /** * 构建完整的笔记详情 KEY @@ -17,4 +22,14 @@ public class RedisKeyConstants { return NOTE_DETAIL_KEY + noteId; } + /** + * 构建完整的布隆过滤器:用户笔记点赞 KEY + * + * @param userId 用户ID + * @return 布隆过滤器:用户笔记点赞 KEY + */ + public static String buildBloomUserNoteLikeListKey(Long userId) { + return BLOOM_USER_NOTE_LIKE_LIST_KEY + userId; + } + } \ No newline at end of file diff --git a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/controller/NoteController.java b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/controller/NoteController.java index a59dd87..f40c950 100644 --- a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/controller/NoteController.java +++ b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/controller/NoteController.java @@ -56,4 +56,10 @@ public class NoteController { return noteService.topNote(topNoteReqVO); } + @PostMapping(value = "/like") + @ApiOperationLog(description = "点赞笔记") + public Response likeNote(@Validated @RequestBody LikeNoteReqVO likeNoteReqVO) { + return noteService.likeNote(likeNoteReqVO); + } + } diff --git a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/LikeStatusEnum.java b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/LikeStatusEnum.java new file mode 100644 index 0000000..fbf3179 --- /dev/null +++ b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/LikeStatusEnum.java @@ -0,0 +1,15 @@ +package com.hanserwei.hannote.note.biz.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum LikeStatusEnum { + LIKE(1), // 点赞 + DISLIKE(0), // 取消点赞 + ; + + private final Integer code; + +} diff --git a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/NoteLikeLuaResultEnum.java b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/NoteLikeLuaResultEnum.java new file mode 100644 index 0000000..d38846b --- /dev/null +++ b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/NoteLikeLuaResultEnum.java @@ -0,0 +1,33 @@ +package com.hanserwei.hannote.note.biz.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Objects; + +@Getter +@AllArgsConstructor +public enum NoteLikeLuaResultEnum { + // 布隆过滤器不存在 + BLOOM_NOT_EXIST(-1L), + // 笔记已点赞 + NOTE_LIKED(1L), + ; + + private final Long code; + + /** + * 根据类型 code 获取对应的枚举 + * + * @param code 类型 code + * @return 类型枚举 + */ + public static NoteLikeLuaResultEnum valueOf(Long code) { + for (NoteLikeLuaResultEnum noteLikeLuaResultEnum : NoteLikeLuaResultEnum.values()) { + if (Objects.equals(code, noteLikeLuaResultEnum.getCode())) { + return noteLikeLuaResultEnum; + } + } + return null; + } +} \ No newline at end of file diff --git a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/ResponseCodeEnum.java b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/ResponseCodeEnum.java index 0127d8e..b03f3c2 100644 --- a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/ResponseCodeEnum.java +++ b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/enums/ResponseCodeEnum.java @@ -21,6 +21,7 @@ public enum ResponseCodeEnum implements BaseExceptionInterface { TOPIC_NOT_FOUND("NOTE-20005", "话题不存在"), NOTE_CANT_VISIBLE_ONLY_ME("NOTE-20006", "此笔记无法修改为仅自己可见"), NOTE_CANT_OPERATE("NOTE-20007", "您无法操作该笔记"), + NOTE_ALREADY_LIKED("NOTE-20008", "您已经点赞过该笔记"), ; // 异常码 diff --git a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/model/vo/LikeNoteReqVO.java b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/model/vo/LikeNoteReqVO.java new file mode 100644 index 0000000..b700db2 --- /dev/null +++ b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/model/vo/LikeNoteReqVO.java @@ -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 LikeNoteReqVO { + + @NotNull(message = "笔记 ID 不能为空") + private Long id; + +} \ No newline at end of file diff --git a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/service/NoteService.java b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/service/NoteService.java index 5add426..e17e842 100644 --- a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/service/NoteService.java +++ b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/service/NoteService.java @@ -49,4 +49,12 @@ public interface NoteService extends IService { */ Response topNote(TopNoteReqVO topNoteReqVO); + /** + * 点赞笔记 + * + * @param likeNoteReqVO 点赞笔记请求 + * @return 点赞笔记结果 + */ + Response likeNote(LikeNoteReqVO likeNoteReqVO); + } \ No newline at end of file diff --git a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/service/impl/NoteServiceImpl.java b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/service/impl/NoteServiceImpl.java index 1cbfd22..bfa230a 100644 --- a/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/service/impl/NoteServiceImpl.java +++ b/han-note-note/han-note-note-biz/src/main/java/com/hanserwei/hannote/note/biz/service/impl/NoteServiceImpl.java @@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.google.common.base.Preconditions; +import com.google.common.collect.Lists; import com.hanserwei.framework.biz.context.holder.LoginUserContextHolder; import com.hanserwei.framework.common.exception.ApiException; import com.hanserwei.framework.common.response.Response; @@ -14,16 +15,15 @@ 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.NoteDO; +import com.hanserwei.hannote.note.biz.domain.dataobject.NoteLikeDO; import com.hanserwei.hannote.note.biz.domain.dataobject.TopicDO; import com.hanserwei.hannote.note.biz.domain.mapper.NoteDOMapper; -import com.hanserwei.hannote.note.biz.enums.NoteStatusEnum; -import com.hanserwei.hannote.note.biz.enums.NoteTypeEnum; -import com.hanserwei.hannote.note.biz.enums.NoteVisibleEnum; -import com.hanserwei.hannote.note.biz.enums.ResponseCodeEnum; +import com.hanserwei.hannote.note.biz.enums.*; 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.NoteLikeDOService; import com.hanserwei.hannote.note.biz.service.NoteService; import com.hanserwei.hannote.note.biz.service.TopicDOService; import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO; @@ -34,14 +34,18 @@ 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.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.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.UUID; @@ -75,6 +79,8 @@ public class NoteServiceImpl extends ServiceImpl implement .maximumSize(10000) // 设置缓存的最大容量为 10000 个条目 .expireAfterWrite(1, TimeUnit.HOURS) // 设置缓存条目在写入后 1 小时过期 .build(); + @Resource + private NoteLikeDOService noteLikeDOService; @Override public Response publishNote(PublishNoteReqVO publishNoteReqVO) { @@ -552,6 +558,130 @@ public class NoteServiceImpl extends ServiceImpl implement return Response.success(); } + @Override + public Response likeNote(LikeNoteReqVO likeNoteReqVO) { + Long noteId = likeNoteReqVO.getId(); + // 1. 校验被点赞的笔记是否存在 + checkNoteIsExist(noteId); + // 2. 判断目标笔记,是否已经点赞过 + Long userId = LoginUserContextHolder.getUserId(); + + // 布隆过滤器Key + String bloomUserNoteLikeListKey = RedisKeyConstants.buildBloomUserNoteLikeListKey(userId); + DefaultRedisScript script = new DefaultRedisScript<>(); + // Lua 脚本路径 + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_note_like_check.lua"))); + script.setResultType(Long.class); + // 执行 Lua 脚本,拿到返回结果 + Long result = redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId); + + NoteLikeLuaResultEnum noteLikeLuaResultEnum = NoteLikeLuaResultEnum.valueOf(result); + + assert noteLikeLuaResultEnum != null; + switch (noteLikeLuaResultEnum) { + // Redis 中布隆过滤器不存在 + case BLOOM_NOT_EXIST -> { + // TODO: 从数据库中校验笔记是否被点赞,并异步初始化布隆过滤器,设置过期时间 + long count = noteLikeDOService.count(new LambdaQueryWrapper<>(NoteLikeDO.class) + .eq(NoteLikeDO::getNoteId, noteId) + .eq(NoteLikeDO::getStatus, LikeStatusEnum.LIKE.getCode())); + + // 保底1天+随机秒数 + long expireSeconds = 60 * 60 * 24 + RandomUtil.randomInt(60 * 60 * 24); + + // 目标笔记已经被点赞 + if (count > 0) { + // 异步初始化布隆过滤器 + asynBatchAddNoteLike2BloomAndExpire(userId, expireSeconds, bloomUserNoteLikeListKey); + throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED); + } + + // 若数据库中也没有点赞记录,说明该用户还未点赞过任何笔记 + // Lua 脚本路径 + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_add_note_like_and_expire.lua"))); + // 返回值类型 + script.setResultType(Long.class); + redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), noteId, expireSeconds); + } + // 目标笔记已经被点赞 + case NOTE_LIKED -> throw new ApiException(ResponseCodeEnum.NOTE_ALREADY_LIKED); + } + // 3. 更新用户 ZSET 点赞列表 + + // 4. 发送 MQ, 将点赞数据落库 + + return Response.success(); + } + + /** + * 异步批量添加笔记点赞记录到布隆过滤器中,并设置过期时间 + * + * @param userId 用户 ID + * @param expireSeconds 过期时间(秒) + * @param bloomUserNoteLikeListKey 布隆过滤器 Key + */ + private void asynBatchAddNoteLike2BloomAndExpire(Long userId, long expireSeconds, String bloomUserNoteLikeListKey) { + threadPoolTaskExecutor.submit(() -> { + try { + List noteLikeDOS = noteLikeDOService.list(new LambdaQueryWrapper<>(NoteLikeDO.class) + .eq(NoteLikeDO::getUserId, userId)); + if (CollUtil.isNotEmpty(noteLikeDOS)) { + DefaultRedisScript script = new DefaultRedisScript<>(); + // Lua 脚本路径 + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/bloom_batch_add_note_like_and_expire.lua"))); + // 返回值类型 + script.setResultType(Long.class); + + // 构建 Lua 参数 + List luaArgs = Lists.newArrayList(); + noteLikeDOS.forEach(noteLikeDO -> luaArgs.add(noteLikeDO.getNoteId())); // 将每个点赞的笔记 ID 传入 + luaArgs.add(expireSeconds); // 最后一个参数是过期时间(秒) + redisTemplate.execute(script, Collections.singletonList(bloomUserNoteLikeListKey), luaArgs.toArray()); + } + } catch (Exception e) { + log.error("异步批量添加笔记点赞记录到布隆过滤器中,并设置过期时间失败...", e); + } + }); + } + + /** + * 校验笔记是否存在 + * + * @param noteId 笔记 ID + */ + private void checkNoteIsExist(Long noteId) { + // 先从本地缓存中检验 + String findNoteDetailRspVOStrLocalCache = LOCAL_CACHE.getIfPresent(noteId); + // 解析 JSON 为 FindNoteDetailRspVO + FindNoteDetailRspVO findNoteDetailRspVO = JsonUtils.parseObject(findNoteDetailRspVOStrLocalCache, FindNoteDetailRspVO.class); + + // 若缓存不存在 + if (Objects.isNull(findNoteDetailRspVO)) { + // 从 Redis 中获取 + String noteDetailRedisKey = RedisKeyConstants.buildNoteDetailKey(noteId); + String noteDetailJson = redisTemplate.opsForValue().get(noteDetailRedisKey); + + // 解析字符串为 FindNoteDetailRspVO + findNoteDetailRspVO = JsonUtils.parseObject(noteDetailJson, FindNoteDetailRspVO.class); + + // 若 Redis 中不存在,则从数据库中获取 + + if (Objects.isNull(findNoteDetailRspVO)) { + boolean isExist = this.exists(new LambdaQueryWrapper<>(NoteDO.class) + .eq(NoteDO::getId, noteId) + .eq(NoteDO::getStatus, NoteStatusEnum.NORMAL.getCode())); + if (!isExist) { + throw new ApiException(ResponseCodeEnum.NOTE_NOT_FOUND); + } + // 缓存 + threadPoolTaskExecutor.submit(() -> { + FindNoteDetailReqVO findNoteDetailReqVO = FindNoteDetailReqVO.builder().id(noteId).build(); + findNoteDetail(findNoteDetailReqVO); + }); + } + } + } + /** * 校验笔记的可见性 * diff --git a/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_add_note_like_and_expire.lua b/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_add_note_like_and_expire.lua new file mode 100644 index 0000000..d706bc4 --- /dev/null +++ b/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_add_note_like_and_expire.lua @@ -0,0 +1,9 @@ +-- 操作的 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 diff --git a/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_batch_add_note_like_and_expire.lua b/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_batch_add_note_like_and_expire.lua new file mode 100644 index 0000000..e996e4f --- /dev/null +++ b/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_batch_add_note_like_and_expire.lua @@ -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 diff --git a/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_note_like_check.lua b/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_note_like_check.lua new file mode 100644 index 0000000..cd52bff --- /dev/null +++ b/han-note-note/han-note-note-biz/src/main/resources/lua/bloom_note_like_check.lua @@ -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 isLiked = redis.call('BF.EXISTS', key, noteId) +if isLiked == 1 then + return 1 +end + +-- 未被点赞,添加点赞数据 +redis.call('BF.ADD', key, noteId) +return 0 diff --git a/http-client/gateApi.http b/http-client/gateApi.http index a9db3ff..2d83335 100644 --- a/http-client/gateApi.http +++ b/http-client/gateApi.http @@ -183,4 +183,13 @@ Authorization: Bearer {{token}} { "userId": 100, "pageNo": 1 +} + +### 笔记点赞入口 +POST http://localhost:8000/note/note/like +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "id": {{noteId}} } \ No newline at end of file