feat(note): 新增笔记详情查询功能

- 新增笔记详情请求VO类 FindNoteDetailReqVO
- 新增笔记详情响应VO类 FindNoteDetailRspVO
- KV服务Feign接口新增查询笔记内容方法
- KeyValueRpcService新增findNoteContent方法实现
- NoteController新增笔记详情查询接口
- NoteService接口及实现类新增findNoteDetail方法
- 新增RedisKeyConstants常量类用于构建笔记详情缓存KEY
- 新增ResponseCodeEnum枚举值用于笔记相关异常码
- 新增ThreadPoolConfig配置类定义异步线程池
- 新增UserRpcService用于调用用户服务查询用户信息
- 笔记详情接口支持多级缓存(本地缓存Caffeine+Redis)
- 笔记详情查询增加可见性校验逻辑
- pom.xml新增用户服务api依赖和Caffeine依赖
- UserFeignApi新增根据ID查询用户信息接口
This commit is contained in:
Hanserwei
2025-10-09 11:30:59 +08:00
parent 869889b87d
commit c75b1f6fe4
14 changed files with 391 additions and 6 deletions

View File

@@ -100,6 +100,18 @@
<groupId>com.hanserwei</groupId>
<artifactId>han-note-distributed-id-generator-api</artifactId>
</dependency>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-user-api</artifactId>
</dependency>
<!-- Caffeine 本地缓存 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>

View File

@@ -0,0 +1,37 @@
package com.hanserwei.hannote.note.biz.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
public class ThreadPoolConfig {
@Bean(name = "noteTaskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(50);
// 队列容量
executor.setQueueCapacity(200);
// 线程活跃时间(秒)
executor.setKeepAliveSeconds(30);
// 线程名前缀
executor.setThreadNamePrefix("NoteExecutor-");
// 拒绝策略:由调用线程处理(一般为主线程)
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
// 设置等待时间,如果超过这个时间还没有销毁就强制销毁,以确保应用最后能够被关闭,而不是被没有完成的任务阻塞
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,20 @@
package com.hanserwei.hannote.note.biz.constant;
public class RedisKeyConstants {
/**
* 笔记详情 KEY 前缀
*/
public static final String NOTE_DETAIL_KEY = "note:detail:";
/**
* 构建完整的笔记详情 KEY
* @param noteId 笔记ID
* @return 笔记详情 KEY
*/
public static String buildNoteDetailKey(Long noteId) {
return NOTE_DETAIL_KEY + noteId;
}
}

View File

@@ -2,6 +2,8 @@ package com.hanserwei.hannote.note.biz.controller;
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.note.biz.model.vo.FindNoteDetailReqVO;
import com.hanserwei.hannote.note.biz.model.vo.FindNoteDetailRspVO;
import com.hanserwei.hannote.note.biz.model.vo.PublishNoteReqVO;
import com.hanserwei.hannote.note.biz.service.NoteService;
import jakarta.annotation.Resource;
@@ -26,4 +28,10 @@ public class NoteController {
return noteService.publishNote(publishNoteReqVO);
}
@PostMapping(value = "/detail")
@ApiOperationLog(description = "笔记详情")
public Response<FindNoteDetailRspVO> findNoteDetail(@Validated @RequestBody FindNoteDetailReqVO findNoteDetailReqVO) {
return noteService.findNoteDetail(findNoteDetailReqVO);
}
}

View File

@@ -1,9 +1,6 @@
package com.hanserwei.hannote.note.biz.domain.dataobject;
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 com.baomidou.mybatisplus.annotation.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

View File

@@ -15,6 +15,9 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 业务异常状态码 -----------
NOTE_TYPE_ERROR("NOTE-20000", "未知的笔记类型"),
NOTE_PUBLISH_FAIL("NOTE-20001", "笔记发布失败"),
NOTE_NOT_FOUND("NOTE-20002", "笔记不存在"),
NOTE_PRIVATE("NOTE-20003", "作者已将该笔记设置为仅自己可见"),
;
;
// 异常码

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 FindNoteDetailReqVO {
@NotNull(message = "笔记 ID 不能为空")
private Long id;
}

View File

@@ -0,0 +1,49 @@
package com.hanserwei.hannote.note.biz.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindNoteDetailRspVO {
private Long id;
private Integer type;
private String title;
private String content;
private List<String> imgUris;
private Long topicId;
private String topicName;
private Long creatorId;
private String creatorName;
private String avatar;
private String videoUri;
/**
* 编辑时间
*/
private LocalDateTime updateTime;
/**
* 是否可见
*/
private Integer visible;
}

View File

@@ -4,6 +4,8 @@ import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.kv.api.KeyValueFeignApi;
import com.hanserwei.hannote.kv.dto.req.AddNoteContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.DeleteNoteContentReqDTO;
import com.hanserwei.hannote.kv.dto.req.FindNoteContentReqDTO;
import com.hanserwei.hannote.kv.dto.resp.FindNoteContentRspDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
@@ -47,4 +49,23 @@ public class KeyValueRpcService {
return Objects.nonNull(response) && response.isSuccess();
}
/**
* 查询笔记内容
*
* @param uuid 笔记UUID
* @return 笔记内容
*/
public String findNoteContent(String uuid) {
FindNoteContentReqDTO findNoteContentReqDTO = new FindNoteContentReqDTO();
findNoteContentReqDTO.setUuid(uuid);
Response<FindNoteContentRspDTO> response = keyValueFeignApi.findNoteContent(findNoteContentReqDTO);
if (Objects.isNull(response) || !response.isSuccess() || Objects.isNull(response.getData())) {
return null;
}
return response.getData().getContent();
}
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.hannote.note.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;
/**
* 查询用户信息
*
* @param userId 用户ID
* @return 用户信息
*/
public FindUserByIdRspDTO findById(Long userId) {
FindUserByIdReqDTO findUserByIdReqDTO = new FindUserByIdReqDTO();
findUserByIdReqDTO.setId(userId);
Response<FindUserByIdRspDTO> response = userFeignApi.findById(findUserByIdReqDTO);
if (Objects.isNull(response) || !response.isSuccess()) {
return null;
}
return response.getData();
}
}

View File

@@ -3,6 +3,8 @@ package com.hanserwei.hannote.note.biz.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteDO;
import com.hanserwei.hannote.note.biz.model.vo.FindNoteDetailReqVO;
import com.hanserwei.hannote.note.biz.model.vo.FindNoteDetailRspVO;
import com.hanserwei.hannote.note.biz.model.vo.PublishNoteReqVO;
public interface NoteService extends IService<NoteDO> {
@@ -14,4 +16,11 @@ public interface NoteService extends IService<NoteDO> {
*/
Response<?> publishNote(PublishNoteReqVO publishNoteReqVO);
/**
* 笔记详情
* @param findNoteDetailReqVO 笔记详情请求
* @return 笔记详情结果
*/
Response<FindNoteDetailRspVO> findNoteDetail(FindNoteDetailReqVO findNoteDetailReqVO);
}

View File

@@ -1,31 +1,45 @@
package com.hanserwei.hannote.note.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.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.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.note.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.note.biz.domain.dataobject.NoteDO;
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.model.vo.FindNoteDetailReqVO;
import com.hanserwei.hannote.note.biz.model.vo.FindNoteDetailRspVO;
import com.hanserwei.hannote.note.biz.model.vo.PublishNoteReqVO;
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.NoteService;
import com.hanserwei.hannote.note.biz.service.TopicDOService;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@@ -36,6 +50,22 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
private KeyValueRpcService keyValueRpcService;
@Resource
private TopicDOService topicDOService;
@Resource
private UserRpcService userRpcService;
@Resource(name = "noteTaskExecutor")
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
@Resource
private RedisTemplate<String, String> redisTemplate;
/**
* 笔记详情本地缓存
*/
@SuppressWarnings("NullableProblems")
private static final Cache<Long, String> LOCAL_CACHE = Caffeine.newBuilder()
.initialCapacity(10000) // 设置初始容量为 10000 个条目
.maximumSize(10000) // 设置缓存的最大容量为 10000 个条目
.expireAfterWrite(1, TimeUnit.HOURS) // 设置缓存条目在写入后 1 小时过期
.build();
@Override
public Response<?> publishNote(PublishNoteReqVO publishNoteReqVO) {
@@ -127,9 +157,141 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
log.error("保存笔记失败!", e);
// RPC调用KV服务删除笔记内容
if (StringUtils.isNotBlank(contentUuid)) {
keyValueRpcService.deleteNoteContent(contentUuid);
boolean res = keyValueRpcService.deleteNoteContent(contentUuid);
if (!res) {
log.error("删除笔记内容失败!");
}
}
}
return Response.success();
}
@Override
public Response<FindNoteDetailRspVO> findNoteDetail(FindNoteDetailReqVO findNoteDetailReqVO) {
// 查询笔记ID
Long noteId = findNoteDetailReqVO.getId();
// 当前登录用户
Long userId = LoginUserContextHolder.getUserId();
// 先从本地缓存中查询
String findNoteDetailRspVOStrLocalCache = LOCAL_CACHE.getIfPresent(noteId);
if (StringUtils.isNotBlank(findNoteDetailRspVOStrLocalCache)) {
FindNoteDetailRspVO findNoteDetailRspVO = JsonUtils.parseObject(findNoteDetailRspVOStrLocalCache, FindNoteDetailRspVO.class);
log.info("==> 笔记详情命中了本地缓存;{}", findNoteDetailRspVOStrLocalCache);
// 可见性校验
checkNoteVisibleFromVO(userId, findNoteDetailRspVO);
return Response.success(findNoteDetailRspVO);
}
// 从Redis缓存中获取
String noteDetailRedisKey = RedisKeyConstants.buildNoteDetailKey(noteId);
String noteDetailJson = redisTemplate.opsForValue().get(noteDetailRedisKey);
// 若缓存中有该笔记的数据,则直接返回
if (StringUtils.isNotBlank(noteDetailJson)) {
FindNoteDetailRspVO findNoteDetailRspVO = JsonUtils.parseObject(noteDetailJson, FindNoteDetailRspVO.class);
// 异步线程中将用户信息存入本地缓存
threadPoolTaskExecutor.submit(() -> {
// 写入本地缓存
LOCAL_CACHE.put(noteId,
Objects.isNull(findNoteDetailRspVO) ? "null" : JsonUtils.toJsonString(findNoteDetailRspVO));
});
// 可见性校验
checkNoteVisibleFromVO(userId, findNoteDetailRspVO);
return Response.success(findNoteDetailRspVO);
}
// 查询笔记
NoteDO noteDO = this.getOne(new LambdaQueryWrapper<>(NoteDO.class)
.eq(NoteDO::getId, noteId)
.eq(NoteDO::getStatus, 1));
// 若笔记不存在,则抛异常
if (Objects.isNull(noteDO)){
threadPoolTaskExecutor.execute(() -> {
// 防止缓存穿透,将空数据存入 Redis 缓存 (过期时间不宜设置过长)
// 保底1分钟 + 随机秒数
long expireSeconds = 60 + RandomUtil.randomInt(60);
redisTemplate.opsForValue().set(noteDetailRedisKey, "null", expireSeconds, TimeUnit.SECONDS);
});
throw new ApiException(ResponseCodeEnum.NOTE_NOT_FOUND);
}
// 可见性校验
Integer visible = noteDO.getVisible();
checkNoteVisible(visible, userId, noteDO.getCreatorId());
// RPC调用用户服务获取用户信息
Long creatorId = noteDO.getCreatorId();
FindUserByIdRspDTO findUserByIdRspDTO = userRpcService.findById(creatorId);
// RPC: 调用 K-V 存储服务获取内容
String content = null;
if (Objects.equals(noteDO.getIsContentEmpty(), Boolean.FALSE)) {
content = keyValueRpcService.findNoteContent(noteDO.getContentUuid());
}
// 笔记类型
Integer noteType = noteDO.getType();
// 图文笔记图片链接(字符串)
String imgUrisStr = noteDO.getImgUris();
// 图文笔记图片链接(集合)
List<String> imgUris = null;
// 如果查询的是图文笔记,需要将图片链接的逗号分隔开,转换成集合
if (Objects.equals(noteType, NoteTypeEnum.IMAGE_TEXT.getCode())
&& StringUtils.isNotBlank(imgUrisStr)) {
imgUris = List.of(imgUrisStr.split(","));
}
// 构建返参 VO 实体类
FindNoteDetailRspVO findNoteDetailRspVO = FindNoteDetailRspVO.builder()
.id(noteDO.getId())
.type(noteDO.getType())
.title(noteDO.getTitle())
.content(content)
.imgUris(imgUris)
.topicId(noteDO.getTopicId())
.topicName(noteDO.getTopicName())
.creatorId(noteDO.getCreatorId())
.creatorName(findUserByIdRspDTO.getNickName())
.avatar(findUserByIdRspDTO.getAvatar())
.videoUri(noteDO.getVideoUri())
.updateTime(noteDO.getUpdateTime())
.visible(noteDO.getVisible())
.build();
// 异步线程中将笔记详情存入 Redis
threadPoolTaskExecutor.submit(() -> {
String noteDetailJson1 = JsonUtils.toJsonString(findNoteDetailRspVO);
// 过期时间保底1天 + 随机秒数,将缓存过期时间打散,防止同一时间大量缓存失效,导致数据库压力太大)
long expireSeconds = 60*60*24 + RandomUtil.randomInt(60*60*24);
redisTemplate.opsForValue().set(noteDetailRedisKey, noteDetailJson1, expireSeconds, TimeUnit.SECONDS);
});
return Response.success(findNoteDetailRspVO);
}
/**
* 校验笔记的可见性
* @param visible 是否可见
* @param userId 当前用户 ID
* @param creatorId 笔记创建者
*/
private void checkNoteVisible(Integer visible, Long userId, Long creatorId) {
if (Objects.equals(visible, NoteVisibleEnum.PRIVATE.getCode())
&& !Objects.equals(userId, creatorId)) { // 仅自己可见, 并且访问用户为笔记创建者才能访问,非本人则抛出异常
throw new ApiException(ResponseCodeEnum.NOTE_PRIVATE);
}
}
/**
* 校验笔记的可见性(针对 VO 实体类)
* @param userId 当前用户 ID
* @param findNoteDetailRspVO 笔记详情VO类
*/
private void checkNoteVisibleFromVO(Long userId, FindNoteDetailRspVO findNoteDetailRspVO) {
if (Objects.nonNull(findNoteDetailRspVO)) {
Integer visible = findNoteDetailRspVO.getVisible();
checkNoteVisible(visible, userId, findNoteDetailRspVO.getCreatorId());
}
}
}