feat(note): 新增笔记删除功能

- 新增 DeleteNoteReqVO 请求参数类,用于接收笔记删除请求
- 在 NoteController 中添加 /delete 接口,实现笔记删除功能
- 在 NoteService 和 NoteServiceImpl 中实现 deleteNote 方法
- 删除笔记时进行权限校验,仅允许笔记创建者删除
- 删除操作为逻辑删除,更新笔记状态为已删除
- 删除笔记后清除 Redis 缓存,并通过 MQ 广播通知各实例清除本地缓存
-优化更新和可见性接口的权限校验逻辑,避免重复代码
- 添加 MQ 测试类 MQTests,用于批量发送关注/取关消息
- 引入 Guava 的 RateLimiter 实现 MQ 消费端限流- 配置 Nacos 配置中心依赖及动态刷新配置
- 更新 .gitignore 文件,忽略日志文件目录
- 在 application.yml 中添加 MQ 消费者限流配置项
- 在 bootstrap.yml 中完善 Nacos 配置中心相关配置
- 为 FollowUnfollowConsumer 添加限流逻辑,防止消费端压力过大
This commit is contained in:
2025-10-13 21:18:12 +08:00
parent 362c32cbd6
commit f0afb23a73
12 changed files with 215 additions and 6 deletions

1
.gitignore vendored
View File

@@ -50,3 +50,4 @@ build/
/han-note-kv/han-note-kv-biz/logs/
/han-note-note/han-note-note-biz/src/main/resources/application-dev.yml
/han-note-user-relation/han-note-user-relation-biz/src/main/resources/application-dev.yml
/han-note-user-relation/han-note-user-relation-biz/logs/

View File

@@ -1,6 +1,11 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<Languages>
<language minSize="56" name="Java" />
</Languages>
</inspection_tool>
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="customHeaders">
<set>

View File

@@ -38,6 +38,12 @@ public class NoteController {
return noteService.updateNote(updateNoteReqVO);
}
@PostMapping(value = "/delete")
@ApiOperationLog(description = "笔记删除")
public Response<?> deleteNote(@Validated @RequestBody DeleteNoteReqVO deleteNoteReqVO) {
return noteService.deleteNote(deleteNoteReqVO);
}
@PostMapping(value = "/visible/onlyme")
@ApiOperationLog(description = "笔记仅对自己可见")
public Response<?> visibleOnlyMe(@Validated @RequestBody UpdateNoteVisibleOnlyMeReqVO updateNoteVisibleOnlyMeReqVO) {

View File

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

View File

@@ -28,6 +28,13 @@ public interface NoteService extends IService<NoteDO> {
*/
Response<?> updateNote(UpdateNoteReqVO updateNoteReqVO);
/**
* 笔记删除
* @param deleteNoteReqVO 笔记删除请求
* @return 笔记删除结果
*/
Response<?> deleteNote(DeleteNoteReqVO deleteNoteReqVO);
/**
* 笔记仅对自己可见
* @param updateNoteVisibleOnlyMeReqVO 笔记仅对自己可见请求

View File

@@ -334,6 +334,20 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
}
}
// 当前登录用户 ID
Long currUserId = LoginUserContextHolder.getUserId();
NoteDO selectNoteDO = this.getById(noteId);
// 笔记不存在
if (Objects.isNull(selectNoteDO)) {
throw new ApiException(ResponseCodeEnum.NOTE_NOT_FOUND);
}
// 判断权限:非笔记发布者不允许更新笔记
if (!Objects.equals(currUserId, selectNoteDO.getCreatorId())) {
throw new ApiException(ResponseCodeEnum.NOTE_CANT_OPERATE);
}
// 话题
Long topicId = updateNoteReqVO.getTopicId();
String topicName = null;
@@ -428,11 +442,68 @@ public class NoteServiceImpl extends ServiceImpl<NoteDOMapper, NoteDO> implement
return Response.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Response<?> deleteNote(DeleteNoteReqVO deleteNoteReqVO) {
// 笔记 ID
Long noteId = deleteNoteReqVO.getId();
NoteDO selectNoteDO = this.getById(noteId);
// 判断笔记是否存在
if (Objects.isNull(selectNoteDO)) {
throw new ApiException(ResponseCodeEnum.NOTE_NOT_FOUND);
}
// 判断权限:非笔记发布者不允许删除笔记
Long currUserId = LoginUserContextHolder.getUserId();
if (!Objects.equals(currUserId, selectNoteDO.getCreatorId())) {
throw new ApiException(ResponseCodeEnum.NOTE_CANT_OPERATE);
}
// 逻辑删除
NoteDO noteDO = NoteDO.builder()
.id(noteId)
.status(NoteStatusEnum.DELETED.getCode())
.updateTime(LocalDateTime.now())
.build();
boolean updateSuccess = this.updateById(noteDO);
// 若失败,则表示该笔记不存在
if (!updateSuccess) {
throw new ApiException(ResponseCodeEnum.NOTE_NOT_FOUND);
}
// 删除缓存
String noteDetailRedisKey = RedisKeyConstants.buildNoteDetailKey(noteId);
redisTemplate.delete(noteDetailRedisKey);
// 同步发送广播模式 MQ将所有实例中的本地缓存都删除掉
rocketMQTemplate.syncSend(MQConstants.TOPIC_DELETE_NOTE_LOCAL_CACHE, noteId);
log.info("====> MQ删除笔记删除本地缓存发送成功...");
return Response.success();
}
@Override
public Response<?> visibleOnlyMe(UpdateNoteVisibleOnlyMeReqVO updateNoteVisibleOnlyMeReqVO) {
// 笔记 ID
Long noteId = updateNoteVisibleOnlyMeReqVO.getId();
NoteDO selectNoteDO = this.getById(noteId);
// 判断笔记是否存在
if (Objects.isNull(selectNoteDO)) {
throw new ApiException(ResponseCodeEnum.NOTE_NOT_FOUND);
}
// 判断权限:非笔记发布者不允许修改笔记权限
Long currUserId = LoginUserContextHolder.getUserId();
if (!Objects.equals(currUserId, selectNoteDO.getCreatorId())) {
throw new ApiException(ResponseCodeEnum.NOTE_CANT_OPERATE);
}
// 构建更新的实体类
NoteDO noteDO = NoteDO.builder()
.id(noteId)

View File

@@ -102,6 +102,12 @@
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
<!-- NaCos 配置中心 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
</dependencies>
<build>
<plugins>

View File

@@ -0,0 +1,21 @@
package com.hanserwei.hannote.user.relation.biz.config;
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@RefreshScope
public class FollowUnfollowMqConsumerRateLimitConfig {
@Value("${mq-consumer.follow-unfollow.rate-limit}")
private double rateLimit;
@Bean
@RefreshScope
public RateLimiter rateLimiter() {
return RateLimiter.create(rateLimit);
}
}

View File

@@ -1,5 +1,6 @@
package com.hanserwei.hannote.user.relation.biz.consumer;
import com.google.common.util.concurrent.RateLimiter;
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;
@@ -7,6 +8,8 @@ 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 jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
@@ -23,19 +26,19 @@ import java.util.Objects;
topic = MQConstants.TOPIC_FOLLOW_OR_UNFOLLOW
)
@Slf4j
@RequiredArgsConstructor
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;
}
@Resource
private RateLimiter rateLimiter;
@Override
public void onMessage(Message message) {
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
rateLimiter.acquire();
// 消息体
String bodyJsonStr = new String(message.getBody());
// 标签

View File

@@ -29,3 +29,6 @@ mybatis-plus:
global-config:
banner: false
mapper-locations: classpath*:/mapperxml/*.xml
mq-consumer: # MQ 消费者
follow-unfollow: # 关注、取关
rate-limit: 5000 # 每秒限流阈值

View File

@@ -10,3 +10,10 @@ spring:
group: DEFAULT_GROUP # 所属组
namespace: han-note # 命名空间
server-addr: 127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
config:
server-addr: http://127.0.0.1:8848 # 指定 Nacos 配置中心的服务器地址
prefix: ${spring.application.name} # 配置 Data Id 前缀,这里使用应用名称作为前缀
group: DEFAULT_GROUP # 所属组
namespace: han-note # 命名空间
file-extension: yaml # 配置文件格式
refresh-enabled: true # 是否开启动态刷新

View File

@@ -0,0 +1,62 @@
package com.hanserwei.hannote.user.relation.biz;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.user.relation.biz.constant.MQConstants;
import com.hanserwei.hannote.user.relation.biz.model.dto.FollowUserMqDTO;
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.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import java.time.LocalDateTime;
@SpringBootTest
@Slf4j
class MQTests {
@Resource
private RocketMQTemplate rocketMQTemplate;
/**
* 测试:发送一万条 MQ
*/
@Test
void testBatchSendMQ() {
for (long i = 0; i < 10000; i++) {
// 构建消息体 DTO
FollowUserMqDTO followUserMqDTO = FollowUserMqDTO.builder()
.userId(i)
.followUserId(i)
.createTime(LocalDateTime.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);
}
});
}
}
}