Compare commits

...

86 Commits

Author SHA1 Message Date
94729e5170 refactor(note):优化笔记点赞功能,使用 Roaring Bitmap 替代布隆过滤器
- 修改消费者组名称,统一命名规范
- 更新 HTTP 客户端测试用例中的授权令牌和笔记 ID
- 引入 NoteLikeDOMapper 并替换原有的 service 查询方式
- 将布隆过滤器相关逻辑全部替换为 Roaring Bitmap 实现
- 新增多个 Lua 脚本支持 Roaring Bitmap 的操作与初始化
- 添加 Roaring Bitmap 相关的 Redis Key 构建方法
- 删除旧有的布隆过滤器校验逻辑及冗余代码
- 更新 Redis Key 常量类,增加 Roaring Bitmap 相关定义
- 日志字典文件中新增 rbitmap 关键词
- 优化点赞和取消点赞流程,提升性能与准确性
2025-11-09 22:09:23 +08:00
6e0f226b42 feat(comment): 实现评论删除功能及相关缓存更新
- 新增删除一级评论及其子评论的逻辑
- 支持批量删除评论及递归查找回复评论
- 添加评论计数更新机制,包括 Redis 和数据库同步
- 实现评论热度值异步更新消息队列发送- 新增本地缓存删除消费者,支持广播模式清理缓存
- 扩展 CommentLevelEnum 枚举,增加 valueOf 方法用于类型转换- 在 NoteCountDOMapper 中新增更新评论总数的方法- 完善注释和日志记录,提升代码可读性和维护性
2025-11-09 15:30:04 +08:00
e0cf96edbf Revert "feat(comment): 新增评论删除及缓存清理功能"
This reverts commit 6985431236.
2025-11-09 15:19:48 +08:00
d9a960e265 Merge remote-tracking branch 'all/master'
# Conflicts:
#	han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/enums/ResponseCodeEnum.java
#	han-note-comment/han-note-comment-biz/src/main/java/com/hanserwei/hannote/comment/biz/service/impl/CommentServiceImpl.java
#	http-client/gateApi.http
2025-11-09 15:18:17 +08:00
6985431236 feat(comment): 新增评论删除及缓存清理功能
- 新增删除一级评论及其子评论的接口与实现- 新增批量删除评论的功能支持
- 新增根据回复评论ID查询评论的方法
- 为CommentLevelEnum添加通过code获取枚举值的方法
- 实现评论本地缓存删除服务接口
- 新增删除评论后的MQ消费者处理逻辑
- 新增删除评论本地缓存的MQ广播消费逻辑
- 扩展NoteCountDOMapper以支持评论总数更新操作
2025-11-09 15:16:20 +08:00
85e0238857 feat(comment): 新增删除评论功能
- 新增删除评论接口,支持物理删除评论及关联内容
- 添加权限校验,仅允许评论创建者删除评论- 使用编程式事务保证删除操作的原子性- 删除评论后清理 Redis 缓存(ZSet 和 String 类型)
- 发送 MQ 消息异步更新计数、删除关联数据及本地缓存
- 新增 DeleteCommentReqVO 请求参数类校验评论 ID
- 补充 KeyValueRpcService 删除评论内容方法
- 新增相关 MQ Topic 常量及响应码枚举
- 更新 HTTP 接口测试用例
2025-11-09 14:12:24 +08:00
93ca81a15b feat(kv): 新增删除评论内容功能
- 在 CommentContentController 中新增删除评论内容的接口
- 定义 DeleteCommentContentReqDTO 用于接收删除请求参数
- 在 CommentContentRepository 中新增删除评论正文的方法
- 在 CommentContentService 及其实现类中新增删除评论内容的业务逻辑
- 在 KeyValueFeignApi 中新增删除评论内容的 Feign 接口
- 在 gateApi.http 中添加删除评论内容的测试用例
2025-11-09 14:01:14 +08:00
f74397ed1e feat(comment): 计数服务:评论点赞数更新,取消点赞接口
- 新增取消点赞接口 /comment/unlike
- 添加布隆过滤器校验评论是否已点赞
- 实现取消点赞时从布隆过滤器中移除记录
- 发送取消点赞消息到 RocketMQ 进行异步处理
- 新增取消点赞相关枚举和异常码
- 更新计数服务消费点赞/取消点赞消息逻辑
- 支持评论点赞数的增减与持久化更新
- 添加 HTTP 客户端测试用例
2025-11-09 13:53:07 +08:00
f90e36f7d6 feat(comment): 实现评论点赞与取消点赞功能,评论点赞、取消点赞批量写库
- 新增评论点赞布隆过滤器,提升点赞判断性能
- 实现评论点赞与取消点赞的批量操作消费者
- 添加评论点赞状态查询接口及异常处理
- 优化点赞操作合并逻辑,减少数据库访问频率
- 增加评论点赞相关 Lua 脚本支持过期时间设置
- 完善评论点赞 Mapper 层批量插入与删除方法
- 添加评论已点赞业务异常状态码
- 新增测试类用于验证评论点赞 MQ 消费逻辑
- 调整 MQ 消费者 Bean 名称避免冲突
- 更新 HTTP 测试文件中的评论 ID便于调试
2025-11-08 22:55:09 +08:00
a8d5c7f9b7 feat(comment): 实现评论点赞功能
- 新增评论点赞接口,支持用户对评论进行点赞操作
- 集成 Redis 布隆过滤器,用于快速校验评论是否已被点赞
- 编写 Lua 脚本实现布隆过滤器的检查与添加逻辑
- 定义点赞相关枚举类,包括点赞状态和操作类型- 新增点赞请求 VO 和 MQ 消息 DTO,规范数据传输结构
- 通过 RocketMQ 异步处理点赞记录落库,提升接口响应速度
- 添加 HTTP 客户端测试用例,便于接口调试和验证
- 补充 Redis Key 构建方法及常量定义,统一缓存键管理
2025-11-08 22:16:15 +08:00
51cebf6215 feat(count): 新增笔记评论数缓存更新逻辑
- 在 CountNoteCommentConsumer 中引入 RedisTemplate依赖
- 消费评论消息时,更新 Redis 中笔记评论总数
- 新增 RedisKeyConstants.FIELD_COMMENT_TOTAL 常量定义
- 实现基于 Hash 的评论数累加更新机制
- 优化评论数更新流程,支持批量处理与缓存同步
2025-11-08 22:06:26 +08:00
8be6719be8 feat(comment): 实现子评论分页查询与缓存优化
- 新增根据父评论 ID 和限制数量查询子评论的数据库方法
- 实现子评论分页缓存机制,使用 ZSET + String 结构提升查询性能
- 添加子评论详情批量同步到 Redis 的功能
-优化子评论计数数据的缓存读取与数据库同步逻辑
- 新增父评论不存在时的业务异常处理
- 完善子评论缓存失效后的数据库查询与数据回填机制
- 提取公共方法用于批量操作 Redis 数据,提升代码复用性
2025-11-08 21:58:29 +08:00
e3f9b6a5b5 feat(comment): 一级评论:子评论总数更新与查询
- 新增批量查询评论计数的数据库接口及SQL实现
- 优化本地缓存中评论ID失效判断逻辑,修正变量命名
- 增加从Redis中获取评论计数数据的功能,并支持缺失时回源数据库
- 实现评论计数数据异步同步至Redis的逻辑,包括子评论总数和点赞数
- 在消费端增加更新Redis中评论子评论总数的逻辑
- 添加评论计数相关的Redis Key和Field常量定义
- 更新HTTP测试用例中的评论内容和回复ID,验证计数同步功能
2025-11-08 21:01:15 +08:00
6f22c2b50d feat(comment): 实现二级评论分页查询功能
- 新增子评论分页查询接口 /comment/child/list- 添加查询一级评论下子评论总数的 Mapper 方法
- 实现二级评论分页数据查询的 Mapper 方法
- 补充对应的 XML 查询语句,支持按 parent_id 查询子评论
- 创建 FindChildCommentItemRspVO 和 FindChildCommentPageListReqVO VO 类
- 在 CommentServiceImpl 中实现子评论分页查询业务逻辑
- 支持批量查询子评论内容及用户信息并组装返回数据
- 添加 HTTP 客户端测试用例用于验证接口功能
2025-11-08 20:29:24 +08:00
bd775b805c feat(comment): 实现评论热度同步到 Redis ZSet
- 新增 Lua 脚本支持热点评论添加与更新
- 在评论消费端同步一级评论至 Redis 热点评论 ZSet
- 支持批量更新评论热度并维护 Redis 中的 Top 500 热点评论
- 修改 CommentDO 和 CommentHeatBO 模型,增加 noteId 字段以支持按笔记分组
- 调整 Mapper XML 查询字段,补充 note_id 字段用于构建 Redis Key
- 优化 Redis 脚本执行逻辑,确保线程安全及数据一致性
- 更新 HTTP 测试用例内容,验证 Redis 同步功能正确性
2025-11-08 15:48:18 +08:00
85e6bab079 feat(comment): 引入本地缓存优化评论查询性能
- 添加 Caffeine 依赖以支持本地缓存
- 实现评论详情的本地缓存机制,减少 Redis 查询压力
-优化分页查询逻辑,优先从本地缓存获取评论数据
- 异步同步评论详情至本地缓存,提升响应速度
- 简化 Redis 操作代码,提高可读性和维护性
2025-11-08 15:34:27 +08:00
6fbe8eed25 feat(comment): 实现评论热度排序及缓存优化
- 修改 CommentDO 中 heat 字段类型从 BigDecimal为 Double
- 新增 selectHeatComments 方法用于查询热门评论- 优化评论分页查询逻辑,引入 Redis 缓存提升性能
- 新增评论总数与热门评论的 Redis 缓存同步机制
- 实现评论详情的 Redis 批量缓存与过期策略
- 添加 COMMENT_NOT_FOUND 业务异常码
- 更新 RedisKeyConstants 增加相关键构建方法
- 调整 XML 映射文件以支持新的查询与字段类型- 引入 RedisTemplate 和线程池异步处理缓存操作
- 在 FindCommentItemRspVO 中新增 heat 字段返回热度值
2025-11-08 11:43:32 +08:00
fdee4dc2b4 feat(comment): 实现评论分页查询功能
- 新增评论分页查询接口与实现逻辑
- 支持查询一级评论及其最早回复的二级评论
- 支持从KV服务批量获取评论内容
- 支持从用户服务批量获取用户信息并组装- 新增评论热度字段用于排序
- 修改MyBatis代码生成配置至comment模块
- 调整评论表结构,优化字段定义
- 完善相关DTO、VO及Mapper文件
- 添加HTTP客户端测试用例
2025-11-08 11:07:50 +08:00
2b06ca0300 feat(kv): 新增批量查询评论内容功能
- 新增 BatchFindCommentContentReqDTO 用于批量查询请求参数校验
- 新增 FindCommentContentReqDTO 和 FindCommentContentRspDTO 用于查询参数与响应封装
- 在 CommentContentController 中添加 /comment/content/batchFind 接口
- 实现 CommentContentRepository 的批量查询方法
- 在 CommentContentServiceImpl 中完成批量查询逻辑,包括参数解析与数据转换
- 更新 gateApi.http 添加批量查询接口测试用例
2025-11-08 09:54:34 +08:00
29cf889dd7 feat(comment): 新增一级评论首条回复ID字段及更新机制
- 在 CommentDO 中新增 firstReplyCommentId 字段,用于记录一级评论下最早回复的评论 ID
- 在 CommentDOMapper 中新增 selectEarliestByParentId 和 updateFirstReplyCommentIdByPrimaryKey 方法,用于查询和更新一级评论的首条回复 ID
- 在 t_comment 表中新增 first_reply_comment_id 字段- 新增 OneLevelCommentFirstReplyCommentIdUpdateConsumer 消费者,用于异步更新一级评论的首条回复 ID- 新增 RedisKeyConstants 常量类,用于构建 Redis Key
- 新增 RedisTemplateConfig 配置类,用于配置 RedisTemplate
- 在 pom.xml 中新增 spring-boot-starter-data-redis 依赖
2025-11-07 21:49:47 +08:00
c454e1832c feat(comment): 新增评论热度计算与更新功能
- 在评论数据对象中新增 childCommentTotal 和 heat 字段
- 扩展 CommentDOMapper 支持批量更新评论热度值
- 新增 CommentHeatBO 类用于封装评论热度信息
- 实现基于点赞数和回复数的热度值计算工具类 HeatCalculator
- 添加 RocketMQ 消费者异步处理评论热度更新消息
- 引入 buffer-trigger依赖实现消息聚合发送
- 扩展 JsonUtils 工具类支持 Set 类型反序列化
- 新增 MQ 常量 TOPIC_COMMENT_HEAT_UPDATE用于热度更新主题
- 修改 SQL 脚本增加 heat 字段并设置默认值- 更新测试接口请求参数内容以适配新逻辑
2025-11-07 21:19:42 +08:00
9ec330216f feat(count): 实现评论计数功能支持二级评论统计
- 新增评论数据对象 CommentDO 及其 MyBatis 映射配置
- 新增评论级别枚举 CommentLevelEnum 区分一级与二级评论
- 新增 CountNoteChildCommentConsumer 消费 MQ 消息并更新子评论总数
- 修改 CountPublishCommentMqDTO 增加 level 和 parentId 字段以支持层级识别
- 调整 Comment2DBConsumer 中构造 CountPublishCommentMqDTO 的逻辑,使用 commentBO 提取完整信息
- 配置 MyBatis Code Helper 插件指向新的 han-note-count 模块路径
- 更新 gateApi.http 测试接口示例,添加 replyCommentId 参数用于模拟二级评论发布
2025-11-07 17:42:43 +08:00
63495b4938 feat(count): 实现评论发布后异步更新笔记评论数功能
- 新增 CountPublishCommentMqDTO 用于传输评论计数消息
- 在评论服务中添加异步发送评论计数消息逻辑
- 新建 CountNoteCommentConsumer 消费评论计数消息并批量更新笔记评论数
- 扩展 t_comment 表结构,新增 child_comment_total 字段
- 更新 MQ 常量配置,添加评论计数相关 Topic 定义
- 调整 LIKE/UNLIKE 和 COLLECT/UNCOLLECT 消费者中的注解使用(防止循环依赖)
- 修改 gateApi.http 中的测试用例内容以适配新功能
2025-11-07 17:13:01 +08:00
f49d0e6b76 refactor(mq): 重构笔记收藏与点赞的MQ消费者实现以提升性能和可靠性
- 使用DefaultMQPushConsumer替代RocketMQListener以支持批量消费
- 实现消息批量处理逻辑,减少数据库交互次数
- 添加内存级操作合并,避免重复操作写入数据库
- 配置流量削峰限流,控制数据库QPS在可接受范围
- 增加重试机制和手动ACK确保消息可靠消费
- 调整MQ主题订阅关系,统一消息流转逻辑
- 新增RocketMQ客户端依赖以支持底层API调
- 优化消费者启动和销毁流程,确保资源正确释放
- 修改Mapper支持批量插入或更新操作
- 调整计数服务消费主题,简化消息链路
2025-11-06 20:17:00 +08:00
a37e76c87c feat(comment): 实现评论异步消费与内容存储
- 新增评论内容批量存储接口与实现
- 实现MQ消息消费端处理评论发布逻辑
- 支持一级与二级评论的层级关系构建
- 添加评论内容与元数据分离存储机制
- 集成分布式ID生成服务用于评论ID生成
- 完善评论相关DTO、DO、BO模型类
- 添加Cassandra数据库操作支持
- 实现Feign接口调用与事务控制
2025-11-05 19:19:19 +08:00
c37b16ff42 feat(mq): 实现评论发布消息消费功能
- 新增 Comment2DBConsumer 消费者类,用于处理评论发布消息- 配置 RocketMQ 消费者,订阅 PublishCommentTopic 主题
- 实现消息监听逻辑,支持批量消费和手动确认机制
- 添加令牌桶限流控制,限制每秒处理消息数量
- 集成 Spring Boot 生命周期管理,确保消费者优雅关闭
- 新增 MQ 测试类 MQTests,验证消息发送与消费流程
- 引入 rocketmq-client 依赖以支持 RocketMQ 功能
2025-11-04 21:53:28 +08:00
eb19d52fcb feat(comment): 引入MQ消息重试机制
- 添加Spring Retry依赖及AOP支持
- 配置重试策略与指数退避机制
- 创建SendMqRetryHelper封装MQ发送与重试逻辑
- 替换原有RocketMQTemplate为带重试功能的实现
- 增加线程池配置以支持异步重试任务执行
- 更新测试用例中的评论内容用于验证重试机制
2025-11-04 21:38:28 +08:00
226c28885b feat(comment): 新增评论功能模块
- 新增评论发布接口,支持内容和图片评论
- 新增评论和评论点赞数据表结构及对应DO、Mapper
- 新增评论相关DTO、VO及校验规则
- 新增评论服务接口及实现,集成RocketMQ异步发送
- 新增全局异常处理器,统一处理参数校验和业务异常
- 配置网关路由,支持/comment/**路径转发至评论服务
- 新增RocketMQ配置类,启用MQ自动配置
- 添加评论发布HTTP测试用例
2025-11-04 19:45:11 +08:00
5eb3c7b58e feat(comment): 初始化评论服务模块
- 新增评论服务基础结构,包括 api 和 biz 模块
- 配置应用启动端口、Nacos 服务发现与配置中心
- 添加 MyBatis-Plus、MySQL、Druid 等依赖
- 创建评论表和评论点赞表 SQL 脚本
- 配置日志输出格式及异步写入策略
- 更新 IDEA 编码设置和数据源映射
- 在父级 pom 中注册评论服务模块
2025-11-04 19:18:21 +08:00
2b2cd2be70 refactor(search):重构搜索服务模块结构
- 将 han-note-search 模块拆分为 han-note-search-api 和 han-note-search-biz
- 调整包路径,统一添加 biz 子包以区分业务实现
- 更新相关类的导入路径以适配新的包结构
- 修改 Maven 模块配置,设置父模块打包方式为 pom- 添加新的 API 模块用于 RPC 接口定义
- 更新依赖配置,确保模块间正确引用
- 调整 IDEA 编译器配置以识别新模块
- 更新 HTTP 客户端测试数据和请求示例
- 添加 Feign 客户端支持以实现服务间通信
- 实现笔记文档重建功能并提供对外接口
- 增加数据对齐服务中远程调用搜索服务的能力
- 更新全局异常处理器和枚举类的包路径
- 调整应用启动类的 Mapper 扫描路径
- 更新 Elasticsearch 配置类和索引相关类路径
- 修改控制器和服务接口以支持新架构
- 更新测试类路径以匹配新的项目结构
2025-11-03 16:00:22 +08:00
218f4c6974 refactor(search):优化 Canal 数据同步逻辑
- 移除未使用的 IndexResponse 导入
- 简化 Elasticsearch 索引写入操作,去除不必要的响应处理- 保持异常处理逻辑不变,确保错误日志记录完整性
2025-11-03 14:53:09 +08:00
268a009c9b feat(search): 实现用户索引同步与删除功能
- 新增处理用户事件的逻辑,包括插入和更新操作
- 实现用户文档的删除方法- 添加批量同步用户索引和笔记索引的功能
- 扩展 selectEsNoteIndexData 方法支持按用户 ID 查询
- 新增 selectEsUserIndexData 方法用于查询用户索引数据
- 更新 XML 映射文件以支持新的查询条件和字段
2025-11-03 14:52:51 +08:00
678c8ab8eb feat(search): 实现 Canal 数据同步到 Elasticsearch 功能
- 添加 Elasticsearch 客户端依赖及配置
- 实现 Canal 数据监听与解析逻辑
- 新增笔记索引同步与删除处理
- 添加 MyBatis Mapper 扫描与数据源配置
- 定义笔记状态与可见性枚举类
- 配置 MyBatis XML 映射文件路径
2025-11-03 14:22:55 +08:00
39d2eb1063 feat(search): 集成 Canal 实现数据库变更监听与词典热更新
- 新增 Canal 客户端配置与连接管理
- 实现 Canal 数据订阅与消费调度任务
- 添加外部词典热更新接口与服务实现- 配置 Elasticsearch词典热更新支持
- 引入 Canal 相关依赖并统一版本管理- 启用 Spring 定时任务支持以驱动 Canal 消费- 增加项目词典以优化拼写检查准确性
2025-11-02 19:03:26 +08:00
96b4127873 feat(search): 集成 Canal 实现数据库变更监听与词典热更新
- 新增 Canal 客户端配置与连接管理
- 实现 Canal 数据订阅与消费调度任务
- 添加外部词典热更新接口与服务实现- 配置 Elasticsearch词典热更新支持
- 引入 Canal 相关依赖并统一版本管理- 启用 Spring 定时任务支持以驱动 Canal 消费- 增加项目词典以优化拼写检查准确性
2025-11-02 19:02:52 +08:00
7c62f1dcf9 feat(search): 增加笔记发布时间范围筛选功能
- 在 DateConstants 中新增 MM-dd 和 HH:mm 时间格式常量
- 在 DateUtils 中增加 localDateTime2String 和 formatRelativeTime 方法- 新增 NotePublishTimeRangeEnum 枚举类用于定义发布时间范围
- 在搜索服务中实现按发布时间范围筛选逻辑
- 修改 SearchNoteReqVO 添加 publishTimeRange 参数
- 修改 SearchNoteRspVO 将 updateTime 改为字符串类型并新增评论数和收藏数字段
- 更新搜索结果处理逻辑以支持新的时间格式化和数据展示
2025-11-02 14:40:42 +08:00
1335582827 fix(search):修复用户搜索服务中的空指针异常和高亮逻辑
- 修复了likeTotal字段为null时的空指针异常- 重构用户搜索逻辑,优化查询构建和响应处理
- 移除了过时的Guava Lists依赖,使用ArrayList替代
- 改进了高亮字段处理逻辑,确保正确提取高亮内容
- 更新异常处理类型从Exception到具体的IOException-优化代码结构,添加注释分段标识提高可读性- 调整粉丝总数格式化逻辑,增强空值处理能力
2025-11-02 14:13:10 +08:00
34c7092abc feat(search): 增强笔记搜索功能支持类型筛选和多种排序方式
- 新增笔记类型筛选功能,支持图文和视频类型过滤
- 添加多种排序方式:最新发布、最多点赞、最多评论、最多收藏
- 实现综合排序逻辑,基于 function_score 查询优化搜索结果
- 增加搜索结果高亮显示标题功能
-重构查询构建逻辑,支持动态构建 bool 查询和 function_score 查询
- 添加 NoteSortTypeEnum 枚举类管理排序类型
- 优化搜索响应处理,支持高亮字段解析和数据格式化
- 更新 SearchNoteReqVO 模型,添加 type 和 sort 查询参数
- 改进异常处理逻辑,使用 IOException 替代通用 Exception
- 移除旧的 stream 处理方式,采用循环遍历处理搜索结果
2025-11-01 22:34:58 +08:00
3d33a73462 feat(search): 实现笔记搜索功能
- 添加 Elasticsearch 配置,支持通过 ObjectMapper 注入
- 创建笔记索引字段常量类 NoteIndex
- 新增搜索控制器 NoteController,提供搜索接口
- 实现 NoteService 接口及具体业务逻辑 NoteServiceImpl
- 构建 function_score 查询,结合多字段匹配与评分函数
- 支持搜索结果高亮显示及分页处理
- 添加请求参数校验和响应数据格式化逻辑
- 更新 SearchUserRspVO 使用 JsonAlias 替代 JsonProperty
2025-11-01 14:53:28 +08:00
4b13e52a29 feat(search): 实现用户搜索昵称高亮与粉丝数格式化- 添加昵称高亮字段 highlightNickname 到 SearchUserRspVO
- 修改粉丝总数字段类型为 String,支持格式化显示
- 引入 NumberUtils 工具类,实现数字转“万”单位格式
- 配置 Elasticsearch 查询高亮规则,支持昵称关键词高亮
- 新增 mergeHitToRspVO 方法,合并原始数据与高亮结果
- 优化搜索请求构建逻辑,增强可读性与扩展性
2025-11-01 13:50:18 +08:00
4e00542371 feat(search): 实现用户搜索功能
- 添加 Elasticsearch 客户端配置
- 创建用户搜索接口和实现类
- 定义搜索请求和响应 VO 类
- 集成分页查询和关键字匹配逻辑
- 添加 Elasticsearch 测试用例
- 更新 pom.xml 依赖和版本管理
- 添加 HTTP 客户端测试脚本
2025-10-30 22:21:06 +08:00
3437c2bff4 feat(search): 初始化搜索服务模块- 添加搜索服务基础配置文件 application.yml 和 bootstrap.yml
- 配置 Nacos 服务发现与配置中心集成
- 引入 Elasticsearch 客户端依赖并配置连接参数
- 创建全局异常处理器 GlobalExceptionHandler
- 定义响应码枚举 ResponseCodeEnum
- 添加用户搜索请求/响应 VO 类
- 定义用户索引常量 UserIndex
- 创建 UserService 接口及实现类
2025-10-29 22:37:08 +08:00
c216ca4c63 chore(http-client): 添加 Elasticsearch 索引和文档操作示例
- 新增创建索引的 HTTP 请求示例
- 添加多个创建文档的 PUT 请求示例
- 包含不同类型内容的文档数据(旅游、美食、穿搭)
- 提供基于 multi_match 的搜索文档示例
- 所有请求均针对 note 索引进行操作
- 补充完整的请求体和时间字段内容
2025-10-27 23:04:14 +08:00
6cc5c06879 feat(data-align): 新增多种计数对齐任务及批量删除支持
- 新增粉丝数、笔记发布数、笔记点赞数、笔记收藏数、用户收藏数、用户点赞数等计数对齐任务
- 扩展 DeleteRecordMapper 支持多种计数变更表的批量删除操作
- 新增 SelectRecordMapper 查询方法支持各类计数表数据批量获取- 新增 UpdateRecordMapper 更新方法支持多维度计数表更新
- 完善 Redis 缓存更新逻辑,支持用户和笔记维度的计数缓存同步
- 添加对应的 XML 映射文件 SQL 实现,支持分片表结构动态拼接- 优化计数对齐任务处理流程,提升数据一致性保障能力
2025-10-25 23:21:37 +08:00
d1f756d5c8 refactor(data-align):优化数据对齐任务与MQ消费逻辑
fix(data-align,note):点赞同一用户发布的两篇不同笔记,无法保存变更记录。点赞笔记的SQL查询错误修复。

- 移除了事务模板,简化数据库操作流程
- 分离笔记ID与用户ID的布隆过滤器处理逻辑- 新增针对笔记作者的点赞/收藏数变更记录
- 重构Redis键命名规范,区分笔记与用户维度
- 优化MQ消息处理流程,增强异常捕获机制
- 更新HTTP客户端测试用例与环境配置
-修复NoteServiceImpl中点赞查询的用户ID条件缺失
- 调整分片计算方式,提升数据分布均匀性
2025-10-24 20:57:21 +08:00
ac65664dfe feat(data-align): 实现用户关注数对齐分片任务
- 新增 DeleteRecordMapper 接口及 XML 配置,支持批量删除临时表记录
- 新增 SelectRecordMapper 接口及 XML 配置,支持分批查询和统计关注数
- 新增 UpdateRecordMapper 接口及 XML 配置,用于更新用户关注总数
- 新增 FollowingCountShardingXxlJob 任务类,实现分片广播处理关注数对齐逻辑
-重命名 InsertRecordMapper为 InsertMapper 并同步更新相关引用
- 在 RedisKeyConstants 中新增构建用户计数 Key 的方法及相关常量
- 修改多个消费者类中的 Mapper 引用名称以匹配重命名后的接口
- 更新数据源映射文件,调整 Mapper XML 文件路径配置
2025-10-24 19:10:40 +08:00
17123657f4 feat(data-align): 实现用户关注、粉丝及笔记发布数的数据对齐功能
- 新增 LUA 脚本实现布隆过滤器校验日增量数据
- 修改表结构将 t_data_align_note_publish_count_temp 的 note_id 替换为 user_id
-为 CreateTableXxlJob 添加事务管理确保表创建一致性
- 新增 FollowUnfollowMqDTO 和 NoteOperateMqDTO 用于消息传递
- 扩展 InsertRecordMapper 支持插入关注、粉丝和笔记发布计数记录
- 在 RedisKeyConstants 中新增多个布隆过滤器相关常量和构建方法
- 新增两个 RocketMQ 消费者处理用户关注/取关和笔记发布/删除事件
- 更新 HTTP 测试文件中的请求参数以适配最新接口逻辑
2025-10-23 20:02:36 +08:00
5c4d8862a2 feat(data-align): 添加删除日增量临时表功能
- 新增 DeleteTableMapper 接口及 XML 映射文件
- 实现删除多种日增量表的 SQL 语句- 创建 DeleteTableXxlJob 定时任务
- 支持按日期和分片批量删除临时表- 集成 XXL-JOB 执行日志记录
- 自动清理最近一个月的历史数据表
2025-10-21 20:09:20 +08:00
a6f4d437d2 feat(data-align): 新增笔记收藏数据对齐功能
- 新增布隆过滤器Lua脚本,用于日增量笔记收藏数据去重
- 新增收藏/取消收藏MQ消息DTO定义- 新增笔记收藏数和用户获得收藏数的数据库插入接口及XML配置
- 新增笔记收藏数MQ Topic常量定义
- 优化笔记服务中布隆过滤器异步初始化逻辑-修复取消收藏时Lua脚本路径错误问题
- 增强取消收藏操作的幂等性校验
- 新增笔记收藏数据对齐消费者,实现增量数据落库和布隆过滤器更新
- 新增笔记收藏布隆过滤器相关常量和工具方法
2025-10-21 20:02:48 +08:00
c1c0590cce feat(data-align): 实现笔记点赞增量数据处理与布隆过滤器校验
- 新增 Redis 布隆过滤器 Lua 脚本,用于校验日增量变更数据- 创建 InsertRecordMapper 及 XML 映射文件,支持笔记和用户点赞数落库
- 定义 LikeUnlikeNoteMqDTO用于 MQ 消息传输
- 配置 RedisTemplate 支持 JSON 序列化
- 修改 TableConstants 中 buildTableNameSuffix 方法参数类型为 long
- 实现 TodayNoteLikeIncrementData2DBConsumer 消费者逻辑:
  - 使用布隆过滤器去重判断 - 数据库写入操作使用事务保证原子性
  - 写入成功后更新布隆过滤器
- 更新 IntelliJ IDEA 数据源映射与 SQL 检查配置
2025-10-21 19:45:43 +08:00
8a1681e590 feat(data-align): bug修复,目前取消点赞接口可以重复访问,已经修改,但后续考虑换掉布隆过滤器。
- 新增 MQ 常量定义文件 MQConstants.java
- 新增 RocketMQ 配置类 RocketMQConfig.java
- 新增笔记点赞增量数据消费类 TodayNoteLikeIncrementData2DBConsumer- 引入 Jackson 和 RocketMQ Starter 依赖
-优化笔记取消点赞逻辑,增强布隆过滤器处理
- 增强日志记录,包括布隆过滤器初始化和执行结果- 添加空值检查以提高代码健壮性
- 修复笔记服务中重复注释问题
2025-10-21 19:14:20 +08:00
f217b8133a feat(data-align): 新增数据对齐日增量表创建功能
- 新增 CreateTableMapper 接口定义多个创建临时表方法
- 新增 CreateTableMapper.xml 实现具体建表 SQL逻辑
- 修改 CreateTableXxlJob 定时任务,集成表创建逻辑
- 新增 TableConstants 工具类用于构建表名后缀
- 更新 MyBatis 配置文件路径映射及词典配置
- 支持按日期和分片自动创建七种数据对齐相关表结构
2025-10-20 21:43:15 +08:00
efd2e51d24 feat(job): 集成XXL-JOB分布式定时任务
- 添加XXL-JOB核心依赖
- 创建任务配置类XxlJobConfig
- 定义任务属性类XxlJobProperties
- 新增创建表任务示例CreateTableXxlJob
- 实现任务执行器初始化逻辑
- 配置任务日志路径及保留天数
- 注册任务组件到Spring容器
- 添加任务处理器注解及日志记录
2025-10-19 20:24:17 +08:00
023d0c0926 feat(data-align): 初始化数据对齐模块
- 添加 han-note-data-align 模块基础结构
- 配置 application.yml 和 bootstrap.yml 文件
- 设置 MyBatis Plus 和 Redis 配置
- 集成 Nacos 服务发现与配置中心
- 添加日志配置文件 logback-spring.xml
- 创建数据源和 MyBatis 相关 IDEA 配置文件- 更新 .gitignore 排除特定开发环境配置和日志文件
- 配置 IntelliJ IDEA 编译器和编码设置
2025-10-19 20:01:31 +08:00
7fc24e1e2a feat(count): 实现笔记发布与删除的计数更新功能
- 新增笔记操作 MQ 消费者 CountNotePublishConsumer
- 支持处理笔记发布和删除消息,更新 Redis 和数据库计数
- 新增笔记操作相关常量:TOPIC_NOTE_OPERATE、TAG_NOTE_PUBLISH、TAG_NOTE_DELETE
- 定义笔记操作 DTO:NoteOperateMqDTO,用于 MQ 消息传递
- 在笔记服务中发送笔记发布和删除的 MQ 消息
- 新增 Redis Hash 字段 noteTotal 用于存储笔记总数
- 新增数据库操作 insertOrUpdateNoteTotalByUserId 用于更新笔记总数
2025-10-19 17:58:57 +08:00
7b1df60c05 feat(count): 新增笔记收藏与点赞计数聚合功能,用户维度统计功能
- 新增 AggregationCountCollectedUncollectedNoteMqDTO 和 AggregationCountLikeUnlikeNoteMqDTO 聚合消息体
- 在 CollectUnCollectNoteMqDTO、CountCollectUnCollectNoteMqDTO 和 CountLikeUnlikeNoteMqDTO 中添加 noteCreatorId 字段
- 优化 CountNoteCollect2DBConsumer 和 CountNoteLike2DBConsumer 消费者逻辑,支持事务性更新用户及笔记计数
- 修改 CountNoteCollectConsumer 和 CountNoteLikeConsumer,使用聚合 DTO 替代 Map 结构处理计数逻辑
- 扩展 JsonUtils 工具类,新增 parseList 方法用于解析 JSON 到 List 对象
- 更新 NoteServiceImpl 中点赞和收藏相关方法,补充获取并传递 noteCreatorId 参数
- 在 UserCountDOMapper 及其 XML 映射文件中新增点赞数和收藏数的插入或更新操作接口
2025-10-19 17:18:20 +08:00
564eefa7bc feat(count): 实现笔记收藏计数功能
- 新增笔记收藏/取消收藏 MQ 消费者,处理收藏计数逻辑
- 新增笔记收藏数落库消费者,实现批量更新数据库
- 新增收藏类型枚举和 DTO 类,用于消息传递与解析
- 修改 MQ 消费组名称前缀统一为 han_note_group_
- 新增 Redis 收藏总数字段常量及更新逻辑
- 扩展 NoteCountDOMapper 支持收藏数插入或更新操作
- 在 XML 映射文件中新增对应 SQL 插入语句
- 完善 MQ 常量定义,增加收藏相关主题常量
2025-10-19 16:06:45 +08:00
c036fadbff feat(note): 实现笔记取消收藏功能
- 新增取消收藏笔记的 Controller 接口 /uncollect
- 实现取消收藏笔记的业务逻辑,包括布隆过滤器校验和数据库状态更新
- 添加 Lua 脚本用于 Redis 布隆过滤器检查笔记是否被收藏
- 新增取消收藏相关的枚举类 NoteUnCollectLuaResultEnum
- 扩展 RocketMQ 消息标签支持取消收藏操作
- 在 NoteCollectionDOMapper 中新增 update2UnCollectByUserIdAndNoteId 方法
- 新增响应码 NOTE_NOT_COLLECTED用于未收藏情况的错误提示
- 添加取消收藏请求参数 VO 类 UnCollectNoteReqVO
- 更新 HTTP 客户端测试脚本增加取消收藏接口调用示例
2025-10-19 15:40:27 +08:00
61cfbd6b81 feat(note): 实现笔记收藏与取消收藏功能
- 新增收藏/取消收藏 MQ 消费者 CollectUnCollectNoteConsumer
- 新增 MQ 消息 DTO 类 CollectUnCollectNoteMqDTO
- 新增收藏操作类型枚举 CollectUnCollectNoteTypeEnum
- 在 MQConstants 中新增收藏相关主题与标签常量
- 扩展 NoteCollectionDOMapper 支持插入或更新收藏记录
- 在 NoteCollectionDOMapper.xml 中实现 insertOrUpdate SQL 逻辑
- 在 NoteServiceImpl 中构建并发送收藏 MQ 消息
- 添加流量削峰限流与幂等性处理机制
2025-10-18 21:31:09 +08:00
1ac61d1b06 feat(note): 实现笔记收藏ZSET更新逻辑
- 移除TODO注释,完善收藏判断逻辑
- 新增Lua脚本实现ZSET收藏列表更新
- 添加ZSET列表不存在时的初始化逻辑
- 实现收藏列表超限移除最早收藏项
- 支持批量同步历史收藏数据到Redis
- 设置随机过期时间避免缓存雪崩
2025-10-18 21:19:12 +08:00
65b089de70 feat(note): 实现笔记收藏功能
- 新增笔记收藏接口及对应业务逻辑
- 添加布隆过滤器和ZSet校验笔记是否已收藏
- 实现异步初始化用户收藏笔记数据到Redis
- 新增多个Lua脚本支持批量操作和过期时间设置
- 更新NoteCollectionDO实体类字段类型和时间格式
- 添加收藏相关枚举类和请求VO类
- 扩展RedisKeyConstants常量类支持收藏功能键名构建
- 在网关API测试文件中增加笔记收藏入口配置
2025-10-18 21:11:10 +08:00
54c34706fb feat(count): 实现笔记点赞计数功能
- 新增笔记点赞计数 MQ DTO 类
- 实现笔记点赞计数消费者,支持流量削峰与批量聚合
- 实现笔记点赞计数落库消费者,带限流处理
- 新增笔记点赞类型枚举类
- 添加笔记点赞相关 MQ Topic 常量定义
- 扩展笔记计数 Mapper,支持点赞数更新
- 新增 Redis 笔记计数 Key 构建方法及字段常量
- 在笔记服务中发送点赞计数 MQ 消息
2025-10-18 16:16:18 +08:00
cfcd12be0d fix(note):修复笔记取消点赞逻辑错误
- 增加布隆过滤器不存在情况的处理逻辑
- 调整笔记未点赞状态的判断条件
-优化异步初始化布隆过滤器的执行流程- 修复状态判断与异常抛出的逻辑错误
2025-10-18 15:37:01 +08:00
90bd9a5a5d feat(note): 实现笔记取消点赞功能
- 新增 Lua 脚本用于布隆过滤器校验笔记是否被点赞
- 添加取消点赞接口 /note/note/unlike
- 实现取消点赞业务逻辑,包括 Redis ZSet 删除与 MQ 异步更新
- 新增取消点赞请求 VO 类 UnlikeNoteReqVO
- 新增 Lua 脚本执行结果枚举 NoteUnlikeLuaResultEnum
- 添加响应码 NOTE_NOT_LIKED 用于未点赞提示
- 更新 HTTP 客户端测试用例,增加取消点赞入口
- 消费者 LikeUnlikeNoteConsumer 支持处理取消点赞消息
- 补充相关服务层方法 unlikeNote 及其实现
2025-10-18 15:28:38 +08:00
bb44cd3d23 bugFix: (note)
- Bloom 布隆过滤器不存在时,未校验是否点赞其他笔记
2025-10-17 22:20:20 +08:00
7c92bd91f6 feat(note): 实现笔记点赞功能及Redis ZSet同步
- 新增笔记点赞MQ消费者LikeUnlikeNoteConsumer,支持点赞与取消点赞操作
- 添加LikeUnlikeNoteMqDTO数据传输对象和LikeUnlikeNoteTypeEnum枚举类
- 扩展NoteLikeDO实体类使用LocalDateTime并调整status字段类型为Integer
- 实现NoteLikeDOMapper的insertOrUpdate方法支持插入或更新点赞记录- 新增两个Lua脚本用于批量添加点赞记录及检查更新用户点赞ZSet-优化NoteServiceImpl中的点赞逻辑,增强幂等性校验和ZSet初始化机制
- 引入Redis ZSet维护用户最近100条点赞记录,并支持过期时间设置
- 调整MQ常量定义,增加点赞相关主题与标签配置
- 迁移DateUtils工具类至公共模块并修复相关引用路径
- 增加用户笔记点赞列表ZSet的Key构建方法及相关常量定义
2025-10-17 22:07:48 +08:00
648c621fbf feat(note): 实现笔记点赞功能
- 新增笔记点赞接口,支持用户对笔记进行点赞操作
- 集成 Redis 布隆过滤器,用于高效判断用户是否已点赞
- 添加 Lua 脚本处理点赞逻辑,包括布隆过滤器检查与更新
- 实现异步批量初始化布隆过滤器,提升性能与用户体验
- 完善点赞相关枚举、VO 类及 Redis Key 常量定义
- 在 HTTP 客户端中新增点赞接口测试用例
- 增加笔记存在性校验逻辑,确保操作目标有效
- 添加点赞状态枚举和响应码,优化错误提示信息
2025-10-16 22:47:18 +08:00
d59acad051 feat(count): 实现粉丝与关注计数服务
- 新增粉丝数与关注数的 MQ 消费逻辑
- 实现 Redis 计数更新与数据库落库操作
- 添加流量削峰限流机制提升系统稳定性
- 完善计数 DTO 与枚举类型定义
- 扩展 JsonUtils 工具类支持 Map 转换
- 更新 MQ 常量与 Redis Key 管理策略
-优化 MyBatis Mapper 支持计数插入或更新操作,Mybatis-plus操作起来属于硬编码,所以使用MyBatis的XML形式
2025-10-16 19:21:28 +08:00
c6ac7193c1 feat(count): 引入批量消费机制优化粉丝计数处理
- 添加 BufferTrigger依赖以支持消息聚合
- 实现消息批量消费逻辑,提升处理效率
- 配置批量大小为 1000,缓存队列最大容量为50000
- 设置聚合间隔为1 秒,均衡实时性与性能
- 新增测试用例验证大量消息发送与消费流程
- 日志记录聚合消息内容,便于调试与监控
2025-10-15 22:33:20 +08:00
31ab7c3d86 feat(count): 实现关注与粉丝数统计功能
- 新增关注数与粉丝数 MQ 消费者
- 在用户关系服务中新增关注/取关时发送 MQ 消息逻辑
- 新增关注/取关类型枚举类
- 新增用于统计的 MQ 常量定义
- 调整应用主类包路径以符合项目结构(致命,查半天)
- 移除配置文件中不再使用的 MQ 消费者限流配置项
2025-10-15 22:25:59 +08:00
e17ab857b9 refactor(note):优化NoteLikeDOServiceImpl的导入顺序- 调整了类导入顺序,将Spring注解与MyBatis相关依赖分开
- 移除了未使用的List导入
-重新组织了包导入顺序以提高可读性
-保持了@Service注解的位置不变- 确保所有必需的依赖仍然正确导入
2025-10-15 19:33:21 +08:00
ee99654e7c refactor(core):优化服务实现类代码结构
- 调整了多个服务实现类中的 import 语句顺序- 移除了未使用的注解和依赖注入相关导入
- 统一了 MyBatis 注解的导入方式-优化了数据源配置类中的 DataSource 导入位置
- 移除了过滤器中不必要的 Component 注解- 简化了部分重复的代码结构以提升可读性
2025-10-15 19:31:02 +08:00
3904e8510e feat(count): 新增笔记和用户计数相关数据结构和服务
- 新增笔记收藏表(NoteCollectionDO)及相关Mapper和服务实现
- 新增笔记计数表(NoteCountDO)及相关Mapper和服务实现
- 新增笔记点赞表(NoteLikeDO)及相关Mapper和服务实现
- 新增用户计数表(UserCountDO)及相关Mapper和服务实现
- 配置RedisTemplate以支持JSON格式序列化
- 引入RocketMQ依赖并配置自动装配
- 在count模块中添加Redis和RocketMQ相关配置类
2025-10-15 19:26:18 +08:00
893f52e5aa feat(count): 初始化计数服务模块
- 新增 han-note-count 模块及其子模块 han-note-count-api 和 han-note-count-biz
- 配置 application.yml 和 bootstrap.yml,支持 Nacos 注册与配置中心
- 添加 MyBatis-Plus、Redis、MySQL 等基础依赖
- 集成日志配置 logback-spring.xml,支持异步输出及多环境配置
- 设置 .gitignore 忽略本地开发配置文件
- 更新 IDEA 编码配置,指定新增模块的字符集为 UTF-8- 在主 pom.xml 中注册新模块 han-note-count
2025-10-15 19:10:48 +08:00
84d6914b1c feat(sql): 新增笔记点赞、收藏及计数相关表结构
- 创建笔记点赞表 t_note_like,记录用户对笔记的点赞状态
- 创建笔记收藏表 t_note_collection,记录用户对笔记的收藏状态
- 创建笔记计数表 t_note_count,统计笔记的点赞、收藏和评论数量
- 创建用户计数表 t_user_count,统计用户的粉丝、关注、笔记及获赞数据
2025-10-15 17:56:10 +08:00
9e3c35043e feat(relation): 实现用户粉丝列表查询功能
- 新增查询用户粉丝列表接口
- 定义粉丝列表请求参数类 FindFansListReqVO- 定义粉丝信息响应类 FindFansUserRspVO
- 在 RelationController 中添加 /fans/list POST 接口
- 在 RelationService 中定义 findFansList 方法
- 在 RelationServiceImpl 中实现粉丝列表查询逻辑
- 支持 Redis 缓存查询与数据库分页查询
- 实现粉丝列表异步同步至 Redis 功能
- 添加 HTTP 客户端测试用例
2025-10-15 17:40:36 +08:00
5e4f9b1203 fix(relation):修复批量查询用户信息时返回空数据的问题
- 在请求页码超过总页数时正确返回空数据
- 添加DataFlowIssue注解以忽略潜在的数据流问题警告
2025-10-14 23:32:01 +08:00
aca7c657fa feat(relation): 实现关注列表分页查询及异步同步到Redis
- 在 PageResponse 中新增 getOffset 方法用于计算分页偏移量
- 优化关注列表分页逻辑,支持从 Redis 和数据库双重查询
- 添加线程池配置,用于异步同步关注列表至 Redis
- 实现全量同步关注列表到 Redis 的方法,并设置随机过期时间
- 封装 RPC 调用用户服务并将 DTO 转换为 VO 的公共方法
-修复分页查询边界条件判断,避免无效查询
- 使用 Lua 脚本批量操作 Redis 提高同步效率和原子性
2025-10-14 23:31:25 +08:00
1e350a4af5 feat(user): 新增用户关注列表查询功能
- 新增查询用户关注列表接口,支持分页查询
- 新增批量查询用户信息接口,提升查询效率
- 优化 MQ 消费模式为顺序消费,确保关注/取关操作有序性
- 完善用户信息 DTO,新增简介字段
- 新增分页响应封装类,支持分页查询结果返回
- 优化 Redis 查询逻辑,支持从缓存中分页获取关注列表
- 新增 Lua 脚本结果类型设置,确保脚本执行结果正确解析
- 添加 HTTP 接口测试用例,覆盖关注列表及批量查询接口
- 实现缓存与数据库双写一致性,提高数据查询性能
2025-10-14 22:29:13 +08:00
b70d9073d8 feat(relation): 实现用户取关功能
- 新增取消关注接口及完整业务逻辑
- 添加 Lua 脚本支持 Redis 取关校验与删除操作
- 实现 MQ 异步处理取关事件
- 补充相关 DTO、VO 类以及枚举响应码
- 完善 Redis Key 构建工具方法
- 增加 HTTP 测试用例用于手动验证接口
- 优化关注流程中的 Redis ZSet 粉丝列表维护逻辑
- 添加粉丝数量限制控制,超出时自动移除最早关注者
2025-10-13 22:40:11 +08:00
f0afb23a73 feat(note): 新增笔记删除功能
- 新增 DeleteNoteReqVO 请求参数类,用于接收笔记删除请求
- 在 NoteController 中添加 /delete 接口,实现笔记删除功能
- 在 NoteService 和 NoteServiceImpl 中实现 deleteNote 方法
- 删除笔记时进行权限校验,仅允许笔记创建者删除
- 删除操作为逻辑删除,更新笔记状态为已删除
- 删除笔记后清除 Redis 缓存,并通过 MQ 广播通知各实例清除本地缓存
-优化更新和可见性接口的权限校验逻辑,避免重复代码
- 添加 MQ 测试类 MQTests,用于批量发送关注/取关消息
- 引入 Guava 的 RateLimiter 实现 MQ 消费端限流- 配置 Nacos 配置中心依赖及动态刷新配置
- 更新 .gitignore 文件,忽略日志文件目录
- 在 application.yml 中添加 MQ 消费者限流配置项
- 在 bootstrap.yml 中完善 Nacos 配置中心相关配置
- 为 FollowUnfollowConsumer 添加限流逻辑,防止消费端压力过大
2025-10-13 21:18:12 +08:00
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
339 changed files with 18995 additions and 77 deletions

80
.gitignore vendored
View File

@@ -1,13 +1,31 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.kotlin
# -----------------------------------------------------------------------------
# 构建产物和依赖
# -----------------------------------------------------------------------------
target/ # Maven 默认的编译输出目录
**/target/
!.mvn/wrapper/maven-wrapper.jar # 保留 Maven Wrapper jar 包
!**/src/main/**/target/ # 排除 src/main 下的 target 目录,但如果上面的 target/ 已经生效,这行可能冗余,但安全起见保留
!**/src/test/**/target/ # 排除 src/test 下的 target 目录
/build/ # Gradle 默认的编译输出目录
!**/src/main/**/build/ # 排除 src/main 下的 build 目录
!**/src/test/**/build/ # 排除 src/test 下的 build 目录
/dist/ # NetBeans / 通用分发目录
/nbbuild/
/nbdist/
/.nb-gradle/
# -----------------------------------------------------------------------------
# IDE 配置文件
# -----------------------------------------------------------------------------
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/ # IntelliJ IDEA 配置目录
# 保留某些重要的 IDEA 文件 (如果需要,但通常 .idea/ 排除足够)
!.idea/modules.xml
!.idea/jarRepositories.xml
!.idea/compiler.xml
# 排除掉不需要的版本控制的文件
.idea/libraries/
*.iws
*.iml
@@ -24,29 +42,31 @@ target/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
/.idea/
/han-note-auth/src/main/resources/application-dev.yml
/han-note-auth/src/main/resources/application-prod.yml
/han-note-auth/logs/
/logs/
/han-note-oss/han-note-oss-biz/src/main/resources/application-dev.yml
/han-note-user/han-note-user-biz/src/main/resources/application-dev.yml
/han-note-user/han-note-user-biz/logs/
/han-note-kv/han-note-kv-biz/src/main/resources/application-dev.yml
/han-note-kv/han-note-kv-biz/src/main/resources/application-prod.yml
/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
# -----------------------------------------------------------------------------
# OS/平台文件
# -----------------------------------------------------------------------------
.DS_Store # Mac OS X
# .idea/ (已在 IDEA 部分排除,但 Mac 用户有时会在项目根目录生成一个隐藏的 .idea 文件夹)
# -----------------------------------------------------------------------------
# 语言特定文件
# -----------------------------------------------------------------------------
.kotlin # Kotlin 缓存文件
# -----------------------------------------------------------------------------
# 自定义应用配置文件和日志 (重点优化部分)
# -----------------------------------------------------------------------------
# 排除所有模块的 logs/ 目录
*/logs/
# 统一排除所有模块的日志目录(如果上面的 *\/logs/ 不够全面)
logs/
# 排除所有 application-dev.yml 和 application-prod.yml
# 这种方式更简洁,排除所有环境的本地配置,统一管理
application-dev.yml
application-prod.yml

434
.idea/MyBatisCodeHelperDatasource.xml generated Normal file
View File

@@ -0,0 +1,434 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MyBatisCodeHelperDatasource">
<option name="projectProfile">
<ProjectProfile>
<option name="controllerTemplateString" value="&#10;#* @vtlvariable name=&quot;tableName&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;entityPackageName&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;entityClassName&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;servicePackageName&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;serviceInterfacePackage&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;serviceClassName&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;serviceInterfaceClassName&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;mapperPackageName&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;mapperClassName&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;controllerPackage&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;tableRemark&quot; type=&quot;java.lang.String&quot; *#&#10;#* @vtlvariable name=&quot;myDate&quot; type=&quot;java.util.Date&quot; *#&#10;#* @vtlvariable name=&quot;simpleDateFormat&quot; type=&quot;java.text.SimpleDateFormat&quot; *#&#10;package $!{controllerPackage};&#10;import $!{entityPackageName}.$!{entityClassName};&#10;###set($realServiceName = $!{serviceClassName}+'Impl')&#10;import $!{servicePackageName}.$!{serviceClassName};&#10;import org.springframework.web.bind.annotation.*;&#10;&#10;#set($serviceFirstLower = $!{serviceClassName.substring(0,1).toLowerCase()}+$!{serviceClassName.substring(1,$!{serviceClassName.length()})})&#10;import org.springframework.beans.factory.annotation.Autowired;&#10;&#10;/**&#10;* $!{tableRemark}($!{tableName})表控制层&#10;*&#10;* @author xxxxx&#10;*/&#10;@RestController&#10;@RequestMapping(&quot;/$!{tableName}&quot;)&#10;public class $!{entityClassName}Controller {&#10;/**&#10;* 服务对象&#10;*/&#10; @Autowired&#10; private $!{serviceClassName} $!{serviceFirstLower};&#10;&#10; /**&#10; * 通过主键查询单条数据&#10; *&#10; * @param id 主键&#10; * @return 单条数据&#10; */&#10; @GetMapping(&quot;selectOne&quot;)&#10; public $!{entityClassName} selectOne(Integer id) {&#10; return $!{serviceFirstLower}.selectByPrimaryKey(id);&#10; }&#10;&#10;}" />
<option name="customizedLombokAnnotation" value="true" />
<option name="customizedLombokValue" value="@lombok.Builder" />
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaMapperPackage" value="com.hanserwei.hannote.comment.biz.domain.mapper" />
<option name="javaMapperPath" value="$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/java" />
<option name="javaModelPackage" value="com.hanserwei.hannote.comment.biz.domain.dataobject" />
<option name="javaModelPath" value="$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/java" />
<option name="lastDatabaseCrudChooseModuleName" value="han-note-comment-biz" />
<option name="lombokAllArgConstructor" value="true" />
<option name="lombokDataAnnotation" value="true" />
<option name="lombokNoArgsConstructor" value="true" />
<option name="mapperAnnotaion" value="true" />
<option name="mapperFilesFolder" value="$PROJECT_DIR$/han-note-search/src/main/resources/mapperxml" />
<option name="mapperFilesFolderList">
<list>
<option value="$PROJECT_DIR$/han-note-auth/src/main/resources/mapperxml" />
<option value="$PROJECT_DIR$/han-note-data-align/src/main/resources/mapperxml" />
<option value="$PROJECT_DIR$/han-note-search/src/main/resources/mapperxml" />
</list>
</option>
<option name="moduleNameToPackageAndPathMap">
<map>
<entry key="han-note-auth">
<value>
<UserPackageAndPathInfoByModule>
<option name="javaMapperPackage" value="com.hanserwei.hannote.count.biz.domain.mapper" />
<option name="javaMapperPath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" />
<option name="javaModelPacakge" value="com.hanserwei.hannote.count.biz.domain.dataobject" />
<option name="javaModelPath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" />
<option name="javaServiceInterfacePackage" value="com.hanserwei.hannote.count.biz.service" />
<option name="javaServiceInterfacePath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" />
<option name="javaServicePackage" value="com.hanserwei.hannote.count.biz.service.impl" />
<option name="javaServicePath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" />
<option name="xmlPackage" value="mapperxml" />
<option name="xmlPath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/resources" />
</UserPackageAndPathInfoByModule>
</value>
</entry>
<entry key="han-note-comment-biz">
<value>
<UserPackageAndPathInfoByModule>
<option name="javaMapperPackage" value="com.hanserwei.hannote.comment.biz.domain.mapper" />
<option name="javaMapperPath" value="$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/java" />
<option name="javaModelPacakge" value="com.hanserwei.hannote.comment.biz.domain.dataobject" />
<option name="javaModelPath" value="$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/java" />
<option name="javaServiceInterfacePackage" value="com.hanserwei.hannote.comment.biz.service" />
<option name="javaServiceInterfacePath" value="$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/java" />
<option name="javaServicePackage" value="com.hanserwei.hannote.comment.biz.service.impl" />
<option name="javaServicePath" value="$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/java" />
<option name="xmlPackage" value="mapperxml" />
<option name="xmlPath" value="$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/resources" />
</UserPackageAndPathInfoByModule>
</value>
</entry>
<entry key="han-note-count-biz">
<value>
<UserPackageAndPathInfoByModule>
<option name="javaMapperPackage" value="com.hanserwei.hannote.count.biz.domain.mapper" />
<option name="javaMapperPath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" />
<option name="javaModelPacakge" value="com.hanserwei.hannote.count.biz.domain.dataobject" />
<option name="javaModelPath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" />
<option name="javaServiceInterfacePackage" value="com.hanserwei.hannote.count.biz.service" />
<option name="javaServiceInterfacePath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" />
<option name="javaServicePackage" value="com.hanserwei.hannote.count.biz.service.impl" />
<option name="javaServicePath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" />
<option name="xmlPackage" value="mapperxml" />
<option name="xmlPath" value="$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/resources" />
</UserPackageAndPathInfoByModule>
</value>
</entry>
<entry key="han-note-note-biz">
<value>
<UserPackageAndPathInfoByModule>
<option name="javaMapperPackage" value="com.hanserwei.hannote.note.biz.domain.mapper" />
<option name="javaMapperPath" value="$PROJECT_DIR$/han-note-note/han-note-note-biz/src/main/java" />
<option name="javaModelPacakge" value="com.hanserwei.hannote.note.biz.domain.dataobject" />
<option name="javaModelPath" value="$PROJECT_DIR$/han-note-note/han-note-note-biz/src/main/java" />
<option name="javaServiceInterfacePackage" value="com.hanserwei.hannote.note.biz.service" />
<option name="javaServiceInterfacePath" value="$PROJECT_DIR$/han-note-note/han-note-note-biz/src/main/java" />
<option name="javaServicePackage" value="com.hanserwei.hannote.note.biz.service.impl" />
<option name="javaServicePath" value="$PROJECT_DIR$/han-note-note/han-note-note-biz/src/main/java" />
<option name="xmlPackage" value="mapperxml" />
<option name="xmlPath" value="$PROJECT_DIR$/han-note-note/han-note-note-biz/src/main/resources" />
</UserPackageAndPathInfoByModule>
</value>
</entry>
</map>
</option>
<option name="mybatisPlusIdType" value="ASSIGN_ID" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="tableGenerateConfigs">
<map>
<entry key="han_note:t_channel">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="ChannelDO" />
<option name="moduleName" value="han-note-note-biz" />
<option name="mybatisplusIdType" value="ASSIGN_ID" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_channel_topic_rel">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="ChannelTopicRelDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_comment">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="CommentDO" />
<option name="moduleName" value="han-note-comment-biz" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_comment_like">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="CommentLikeDO" />
<option name="moduleName" value="han-note-comment-biz" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_fans">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="FansDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_following">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="FollowingDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_note">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="NoteDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_note_collection">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="NoteCollectionDO" />
<option name="moduleName" value="han-note-note-biz" />
<option name="mybatisplusIdType" value="ASSIGN_ID" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_note_count">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="NoteCountDO" />
<option name="moduleName" value="han-note-comment-biz" />
<option name="mybatisplusIdType" value="ASSIGN_ID" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_note_like">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="NoteLikeDO" />
<option name="moduleName" value="han-note-note-biz" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_permission">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="PermissionDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_role">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="RoleDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_role_permission_rel">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="RolePermissionDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_topic">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="TopicDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_user">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="UserDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="ASSIGN_ID" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_user_count">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="UserCountDO" />
<option name="moduleName" value="han-note-count-biz" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
<entry key="han_note:t_user_role_rel">
<value>
<TableGenerateConfig>
<option name="deleteByPrimayKeyEnabled" value="false" />
<option name="generatedKey" value="id" />
<option name="insertMethodEnabled" value="false" />
<option name="insertSelectiveMethodEnabled" value="false" />
<option name="javaModelName" value="UserRoleDO" />
<option name="moduleName" value="han-note-auth" />
<option name="mybatisplusIdType" value="AUTO" />
<option name="selectByPrimaryKeyEnabled" value="false" />
<option name="sequenceColumn" value="" />
<option name="sequenceId" value="" />
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="useActualColumnName" value="false" />
</TableGenerateConfig>
</value>
</entry>
</map>
</option>
<option name="updateByPrimaryKeySelectiveEnabled" value="false" />
<option name="updateByPrimaykeyEnabled" value="false" />
<option name="userMybatisPlus" value="true" />
<option name="xmlMapperPackage" value="mapperxml" />
<option name="xmlMapperPath" value="$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/resources" />
</ProjectProfile>
</option>
</component>
</project>

54
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
</profile>
<profile name="Annotation profile for han-note" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<processorPath useClasspath="false">
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.30/lombok-1.18.30.jar" />
</processorPath>
<module name="han-note-distributed-id-generator-api" />
<module name="han-note-data-align" />
<module name="han-note-note-biz" />
<module name="han-note-user-relation-api" />
<module name="han-note-user-api" />
<module name="han-note-user-biz" />
<module name="han-note-distributed-id-generator-biz" />
<module name="han-note-user-relation-biz" />
<module name="han-note-oss-biz" />
<module name="han-note-comment-biz" />
<module name="han-note-count-api" />
<module name="han-note-oss-api" />
<module name="han-note-count-biz" />
<module name="han-note-comment-api" />
<module name="hanserwei-spring-boot-starter-biz-operationlog" />
<module name="han-note-gateway" />
<module name="han-note-note-api" />
<module name="hanserwei-spring-boot-starter-jackson" />
<module name="han-note-kv-api" />
<module name="han-note-search-api" />
<module name="han-note-kv-biz" />
<module name="hanserwei-common" />
<module name="han-note-auth" />
<module name="han-note-search-biz" />
<module name="hanserwei-spring-boot-starter-biz-context" />
</profile>
</annotationProcessing>
<bytecodeTargetLevel>
<module name="hanserwei-spring-starter-biz-context" target="21" />
</bytecodeTargetLevel>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="han-note-auth" options="" />
</option>
</component>
</project>

57
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="han_note@127.0.0.1" uuid="f2474a4a-e4f1-4afa-bd43-7ae7738b47c5">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://127.0.0.1:3306/han_note?useUnicode=true&amp;characterEncoding=utf-8&amp;autoReconnect=true&amp;useSSL=false&amp;serverTimezone=Asia/Shanghai&amp;allowPublicKeyRetrieval=true</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="5@127.0.0.1" uuid="5b969fbe-0f66-42be-8d30-ff21036ab8a4">
<driver-ref>redis</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>jdbc.RedisDriver</jdbc-driver>
<jdbc-url>jdbc:redis://127.0.0.1:6379/5</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="hannote@127.0.0.1" uuid="19b59a4f-95b9-451e-9051-bb3d5ce210ee">
<driver-ref>cassandra</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.ing.data.cassandra.jdbc.CassandraDriver</jdbc-driver>
<jdbc-url>jdbc:cassandra://127.0.0.1:9042/hannote</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="leaf@127.0.0.1" uuid="c4c1f1dc-816f-4113-88d6-9ebd7677af82">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://127.0.0.1:3306/leaf?useUnicode=true&amp;characterEncoding=utf-8&amp;autoReconnect=true&amp;useSSL=false&amp;serverTimezone=Asia/Shanghai</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

13
.idea/data_source_mapping.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/19b59a4f-95b9-451e-9051-bb3d5ce210ee/console.sql" value="19b59a4f-95b9-451e-9051-bb3d5ce210ee" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/5b969fbe-0f66-42be-8d30-ff21036ab8a4/console.sql" value="5b969fbe-0f66-42be-8d30-ff21036ab8a4" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f2474a4a-e4f1-4afa-bd43-7ae7738b47c5/console.sql" value="f2474a4a-e4f1-4afa-bd43-7ae7738b47c5" />
<file url="file://$PROJECT_DIR$/han-note-data-align/src/main/resources/mapperxml/InsertMapper.xml" value="f2474a4a-e4f1-4afa-bd43-7ae7738b47c5" />
<file url="file://$PROJECT_DIR$/han-note-data-align/src/main/resources/mapperxml/SelectRecordMapper.xml" value="f2474a4a-e4f1-4afa-bd43-7ae7738b47c5" />
<file url="file://$PROJECT_DIR$/sql/createData.sql" value="f2474a4a-e4f1-4afa-bd43-7ae7738b47c5" />
<file url="file://$PROJECT_DIR$/sql/createTable.sql" value="f2474a4a-e4f1-4afa-bd43-7ae7738b47c5" />
<file url="file://$PROJECT_DIR$/sql/leafcreatetable.sql" value="c4c1f1dc-816f-4113-88d6-9ebd7677af82" />
</component>
</project>

19
.idea/dictionaries/project.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>asyn</w>
<w>entrys</w>
<w>hannote</w>
<w>hanserwei</w>
<w>jobhandler</w>
<w>mget</w>
<w>nacos</w>
<w>operationlog</w>
<w>rbitmap</w>
<w>rustfs</w>
<w>zadd</w>
<w>zrevrangebyscore</w>
<w>zset</w>
</words>
</dictionary>
</component>

20
.idea/encodings.xml generated
View File

@@ -3,6 +3,20 @@
<component name="Encoding" defaultCharsetForPropertiesFiles="UTF-8">
<file url="file://$PROJECT_DIR$/han-note-auth/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-auth/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-comment/han-note-comment-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-comment/han-note-comment-api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-comment/han-note-comment-biz/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-comment/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-comment/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-count/han-note-count-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-count/han-note-count-api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-count/han-note-count-biz/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-count/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-count/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-data-align/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-data-align/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-distributed-id-generator/han-note-distributed-id-generator-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-distributed-id-generator/han-note-distributed-id-generator-api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-distributed-id-generator/han-note-distributed-id-generator-biz/src/main/java" charset="UTF-8" />
@@ -29,6 +43,12 @@
<file url="file://$PROJECT_DIR$/han-note-oss/han-note-oss-biz/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-oss/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-oss/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-search/han-note-search-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-search/han-note-search-api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-search/han-note-search-biz/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-search/han-note-search-biz/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-search/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-search/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-user-relation/han-note-user-relation-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-user-relation/han-note-user-relation-api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/han-note-user-relation/han-note-user-relation-biz/src/main/java" charset="UTF-8" />

View File

@@ -1,6 +1,14 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ConstantValue" enabled="true" level="WARNING" enabled_by_default="true">
<option name="REPORT_CONSTANT_REFERENCE_VALUES" value="false" />
</inspection_tool>
<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>
@@ -15,6 +23,10 @@
</list>
</option>
</inspection_tool>
<inspection_tool class="SpringBootConfigYamlInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="SqlResolveInspection" enabled="true" level="ERROR" enabled_by_default="true">
<option name="suppressForPossibleStringLiterals" value="true" />
</inspection_tool>
</profile>
</component>

20
.idea/jarRepositories.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

9
.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/sql/createData.sql" dialect="MySQL" />
<file url="file://$PROJECT_DIR$/sql/createTable.sql" dialect="MySQL" />
<file url="file://$PROJECT_DIR$/sql/leafcreatetable.sql" dialect="MySQL" />
<file url="PROJECT" dialect="MySQL" />
</component>
</project>

6
.idea/swagger-settings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SwaggerSettings">
<option name="defaultPreviewType" value="REDOC" />
</component>
</project>

View File

@@ -0,0 +1,24 @@
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-comment</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>han-note-comment-api</artifactId>
<name>${project.artifactId}</name>
<description>RPC层, 供其他服务调用</description>
<dependencies>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-common</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,150 @@
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-comment</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>han-note-comment-biz</artifactId>
<name>${project.artifactId}</name>
<description>评论服务业务层</description>
<dependencies>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-common</artifactId>
</dependency>
<!-- 业务接口日志组件 -->
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-spring-boot-starter-biz-operationlog</artifactId>
</dependency>
<!-- 上下文组件 -->
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-spring-boot-starter-biz-context</artifactId>
</dependency>
<!-- Jackson 组件 -->
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- Druid 数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!-- 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- Rocket MQ -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
<!-- Spring Retry 重试框架 -->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<!-- AOP 切面Spring Retry 重试框架需要) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Rocket MQ 客户端 -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
</dependency>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-distributed-id-generator-api</artifactId>
</dependency>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-kv-api</artifactId>
</dependency>
<!-- 快手 Buffer Trigger -->
<dependency>
<groupId>com.github.phantomthief</groupId>
<artifactId>buffer-trigger</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</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>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,17 @@
package com.hanserwei.hannote.comment.biz;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.retry.annotation.EnableRetry;
@SpringBootApplication
@MapperScan("com.hanserwei.hannote.comment.biz.domain.mapper")
@EnableFeignClients("com.hanserwei.hannote")
@EnableRetry
public class HannoteCommentApplication {
public static void main(String[] args) {
SpringApplication.run(HannoteCommentApplication.class, args);
}
}

View File

@@ -0,0 +1,31 @@
package com.hanserwei.hannote.comment.biz.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置 RedisTemplate 的连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 使用 StringRedisSerializer 来序列化和反序列化 redis 的 key 值,确保 key 是可读的字符串
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值, 确保存储的是 JSON 格式
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashValueSerializer(serializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}

View File

@@ -0,0 +1,34 @@
package com.hanserwei.hannote.comment.biz.config;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
@Configuration
public class RetryConfig {
@Resource
private RetryProperties retryProperties;
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();
// 定义重试策略
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
retryPolicy.setMaxAttempts(retryProperties.getMaxAttempts());// 最大重试次数
// 定义间隔策略
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(retryProperties.getInitInterval()); // 初始间隔时间
backOffPolicy.setMultiplier(retryProperties.getMultiplier()); // 延迟倍数默认为2
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.setBackOffPolicy(backOffPolicy);
return retryTemplate;
}
}

View File

@@ -0,0 +1,29 @@
package com.hanserwei.hannote.comment.biz.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = RetryProperties.PREFIX)
@Component
@Data
public class RetryProperties {
public static final String PREFIX = "retry";
/**
* 最大重试次数
*/
private Integer maxAttempts = 3;
/**
* 初始间隔时间,单位 ms
*/
private Integer initInterval = 1000;
/**
* 乘积(每次乘以 2
*/
private Double multiplier = 2.0;
}

View File

@@ -0,0 +1,10 @@
package com.hanserwei.hannote.comment.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,37 @@
package com.hanserwei.hannote.comment.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 = "taskExecutor")
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,45 @@
package com.hanserwei.hannote.comment.biz.constants;
public interface MQConstants {
/**
* Topic: 评论发布
*/
String TOPIC_PUBLISH_COMMENT = "PublishCommentTopic";
/**
* Topic: 笔记评论总数计数
*/
String TOPIC_COUNT_NOTE_COMMENT = "CountNoteCommentTopic";
/**
* Topic: 评论热度值更新
*/
String TOPIC_COMMENT_HEAT_UPDATE = "CommentHeatUpdateTopic";
/**
* Topic: 评论点赞、取消点赞共用一个 Topic
*/
String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic";
/**
* Topic: 删除本地缓存 —— 评论详情
*/
String TOPIC_DELETE_COMMENT_LOCAL_CACHE = "DeleteCommentDetailLocalCacheTopic";
/**
* Topic: 删除评论
*/
String TOPIC_DELETE_COMMENT = "DeleteCommentTopic";
/**
* Tag 标签:点赞
*/
String TAG_LIKE = "Like";
/**
* Tag 标签:取消点赞
*/
String TAG_UNLIKE = "UnLike";
}

View File

@@ -0,0 +1,123 @@
package com.hanserwei.hannote.comment.biz.constants;
public class RedisKeyConstants {
/**
* Key 前缀:一级评论的 first_reply_comment_id 字段值是否更新标识
*/
private static final String HAVE_FIRST_REPLY_COMMENT_KEY_PREFIX = "comment:havaFirstReplyCommentId:";
/**
* Key 前缀:布隆过滤器 - 用户点赞的评论
*/
private static final String BLOOM_COMMENT_LIKES_KEY_PREFIX = "bloom:comment:likes:";
/**
* Key 前缀:二级评论分页 ZSET
*/
private static final String CHILD_COMMENT_LIST_KEY_PREFIX = "comment:childList:";
/**
* Hash Field: 子评论总数
*/
public static final String FIELD_CHILD_COMMENT_TOTAL = "childCommentTotal";
/**
* Hash Field: 点赞总数
*/
public static final String FIELD_LIKE_TOTAL = "likeTotal";
/**
* 评论维度计数 Key 前缀
*/
private static final String COUNT_COMMENT_KEY_PREFIX = "count:comment:";
/**
* Hash Field 键:评论总数
*/
public static final String FIELD_COMMENT_TOTAL = "commentTotal";
/**
* Key 前缀:笔记评论总数
*/
private static final String COUNT_COMMENT_TOTAL_KEY_PREFIX = "count:note:";
/**
* Key 前缀:评论分页 ZSET
*/
private static final String COMMENT_LIST_KEY_PREFIX = "comment:list:";
/**
* Key 前缀:评论详情 JSON
*/
private static final String COMMENT_DETAIL_KEY_PREFIX = "comment:detail:";
/**
* 构建 布隆过滤器 - 用户点赞的评论 完整 KEY
*
* @param userId 用户 ID
* @return 布隆过滤器 - 用户点赞的评论 完整 KEY
*/
public static String buildBloomCommentLikesKey(Long userId) {
return BLOOM_COMMENT_LIKES_KEY_PREFIX + userId;
}
/**
* 构建子评论分页 ZSET 完整 KEY
*
* @param commentId 一级评论 ID
* @return 子评论分页 ZSET 完整 KEY
*/
public static String buildChildCommentListKey(Long commentId) {
return CHILD_COMMENT_LIST_KEY_PREFIX + commentId;
}
/**
* 构建评论维度计数 Key
*
* @param commentId 评论 ID
* @return 评论维度计数 Key
*/
public static String buildCountCommentKey(Long commentId) {
return COUNT_COMMENT_KEY_PREFIX + commentId;
}
/**
* 构建完整 KEY
*
* @param commentId 一级评论 ID
* @return 完整 KEY
*/
public static String buildHaveFirstReplyCommentKey(Long commentId) {
return HAVE_FIRST_REPLY_COMMENT_KEY_PREFIX + commentId;
}
/**
* 构建笔记评论总数完整 KEY
*
* @param noteId 笔记 ID
* @return 笔记评论总数完整 KEY
*/
public static String buildNoteCommentTotalKey(Long noteId) {
return COUNT_COMMENT_TOTAL_KEY_PREFIX + noteId;
}
/**
* 构建评论分页 ZSET 完整 KEY
*
* @param noteId 笔记 ID
* @return 评论分页 ZSET 完整 KEY
*/
public static String buildCommentListKey(Long noteId) {
return COMMENT_LIST_KEY_PREFIX + noteId;
}
/**
* 构建评论详情完整 KEY
*
* @param commentId 评论 ID
* @return 评论详情完整 KEY
*/
public static String buildCommentDetailKey(Object commentId) {
return COMMENT_DETAIL_KEY_PREFIX + commentId;
}
}

View File

@@ -0,0 +1,295 @@
package com.hanserwei.hannote.comment.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.constants.RedisKeyConstants;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.comment.biz.enums.CommentLevelEnum;
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
import com.hanserwei.hannote.comment.biz.model.dto.CountPublishCommentMqDTO;
import com.hanserwei.hannote.comment.biz.model.dto.PublishCommentMqDTO;
import com.hanserwei.hannote.comment.biz.rpc.KeyValueRpcService;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.SendCallback;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
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.support.MessageBuilder;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import java.util.stream.Collectors;
@SuppressWarnings("UnstableApiUsage")
@Component
@Slf4j
public class Comment2DBConsumer {
@Value("${rocketmq.name-server}")
private String nameServer;
@Resource
private CommentDOMapper commentDOMapper;
@Resource
private TransactionTemplate transactionTemplate;
@Resource
private KeyValueRpcService keyValueRpcService;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private DefaultMQPushConsumer consumer;
// 每秒创建 1000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(1000);
@Bean(name = "Comment2DBConsumer")
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
// Group组
String group = "han_note_group_" + MQConstants.TOPIC_PUBLISH_COMMENT;
// 创建一个新的DefaultMQPushConsumer示例并指定消费者的消费组名
consumer = new DefaultMQPushConsumer(group);
// 设置NameServer地址
consumer.setNamesrvAddr(nameServer);
// 订阅指定的主题,并设置主题的订阅规则("*" 表示订阅所有标签的消息)
consumer.subscribe(MQConstants.TOPIC_PUBLISH_COMMENT, "*");
// 设置消费者消费消息的起始位置,如果队列中没有消息,则从最新的消息开始消费。
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
// 设置消息消费模式,这里使用集群模式 (CLUSTERING)
consumer.setMessageModel(MessageModel.CLUSTERING);
// 设置每批次消费的最大消息数量,这里设置为 30表示每次拉取时最多消费 30 条消息
consumer.setConsumeMessageBatchMaxSize(30);
// 消息体 Json 字符串转 DTO
List<PublishCommentMqDTO> publishCommentMqDTOS = Lists.newArrayList();
// 注册消息监听器
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> {
log.info("==> 本批次消息大小: {}", msgs.size());
try {
// 令牌桶流控
rateLimiter.acquire();
for (MessageExt msg : msgs) {
String message = new String(msg.getBody());
log.info("==> Consumer - Received message: {}", message);
publishCommentMqDTOS.add(JsonUtils.parseObject(message, PublishCommentMqDTO.class));
}
// 提取所有不为空的回复评论 ID
List<Long> replyCommentIds = publishCommentMqDTOS.stream()
.filter(Objects::nonNull)
.map(PublishCommentMqDTO::getReplyCommentId)
.toList();
// 批量查询相关回复评论记录
List<CommentDO> replyCommentDOS = null;
if (CollUtil.isNotEmpty(replyCommentIds)) {
// 批量查询数据库
replyCommentDOS = commentDOMapper.selectByCommentIds(replyCommentIds);
}
// DO 集合转 <评论 ID - 评论 DO> 字典, 以方便后续查找
Map<Long, CommentDO> commentIdAndCommentDOMap = Maps.newHashMap();
if (CollUtil.isNotEmpty(replyCommentDOS)) {
commentIdAndCommentDOMap = replyCommentDOS.stream()
.collect(Collectors.toMap(CommentDO::getId, commentDO -> commentDO));
}
// DTO 转 BO
List<CommentBO> commentBOS = Lists.newArrayList();
for (PublishCommentMqDTO publishCommentMqDTO : publishCommentMqDTOS) {
String imageUrl = publishCommentMqDTO.getImageUrl();
CommentBO commentBO = CommentBO.builder()
.id(publishCommentMqDTO.getCommentId())
.noteId(publishCommentMqDTO.getNoteId())
.userId(publishCommentMqDTO.getCreatorId())
.isContentEmpty(true) // 默认评论内容为空
.imageUrl(StringUtils.isBlank(imageUrl) ? "" : imageUrl)
.level(CommentLevelEnum.ONE.getCode()) // 默认为一级评论
.parentId(publishCommentMqDTO.getNoteId()) // 默认设置为所属笔记 ID
.createTime(publishCommentMqDTO.getCreateTime())
.updateTime(publishCommentMqDTO.getCreateTime())
.isTop(false)
.replyTotal(0L)
.likeTotal(0L)
.replyCommentId(0L)
.replyUserId(0L)
.build();
// 评论内容若不为空
String content = publishCommentMqDTO.getContent();
if (StringUtils.isNotBlank(content)) {
commentBO.setContentUuid(UUID.randomUUID().toString()); // 生成评论内容的 UUID 标识
commentBO.setIsContentEmpty(false);
commentBO.setContent(content);
}
// 设置评论级别、回复用户 ID (reply_user_id)、父评论 ID (parent_id)
Long replyCommentId = publishCommentMqDTO.getReplyCommentId();
if (Objects.nonNull(replyCommentId)) {
CommentDO replyCommentDO = commentIdAndCommentDOMap.get(replyCommentId);
if (Objects.nonNull(replyCommentDO)) {
// 若回复的评论 ID 不为空,说明是二级评论
commentBO.setLevel(CommentLevelEnum.TWO.getCode());
commentBO.setReplyCommentId(publishCommentMqDTO.getReplyCommentId());
// 父评论 ID
commentBO.setParentId(replyCommentDO.getId());
if (Objects.equals(replyCommentDO.getLevel(), CommentLevelEnum.TWO.getCode())) { // 如果回复的评论属于二级评论
commentBO.setParentId(replyCommentDO.getParentId());
}
// 回复的哪个用户
commentBO.setReplyUserId(replyCommentDO.getUserId());
}
}
commentBOS.add(commentBO);
}
log.info("## 清洗后的 CommentBOS: {}", JsonUtils.toJsonString(commentBOS));
// 编程式事务,保证整体操作的原子性
Integer insertedRows = transactionTemplate.execute(status -> {
try {
// 先批量存入评论元数据
int count = commentDOMapper.batchInsert(commentBOS);
// 过滤出评论内容不为空的 BO
List<CommentBO> commentContentNotEmptyBOS = commentBOS.stream()
.filter(commentBO -> Boolean.FALSE.equals(commentBO.getIsContentEmpty()))
.toList();
if (CollUtil.isNotEmpty(commentContentNotEmptyBOS)) {
// 批量存入评论内容
boolean result = keyValueRpcService.batchSaveCommentContent(commentContentNotEmptyBOS);
if (!result) {
throw new RuntimeException("批量保存评论内容失败");
}
}
return count;
} catch (Exception ex) {
status.setRollbackOnly(); // 标记事务为回滚
log.error("", ex);
throw ex;
}
});
// 如果批量插入的行数大于 0
if (Objects.nonNull(insertedRows) && insertedRows > 0) {
// 构建发送给计数服务的 DTO 集合
List<CountPublishCommentMqDTO> countPublishCommentMqDTOS = commentBOS.stream()
.map(commentBO -> CountPublishCommentMqDTO.builder()
.noteId(commentBO.getNoteId())
.commentId(commentBO.getId())
.level(commentBO.getLevel())
.parentId(commentBO.getParentId())
.build())
.toList();
// 异步发送计数 MQ
org.springframework.messaging.Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(countPublishCommentMqDTOS))
.build();
// 同步一级评论到 Redis 热点评论 ZSET 中
syncOneLevelComment2RedisZSet(commentBOS);
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_NOTE_COMMENT, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【计数: 评论发布】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【计数: 评论发布】MQ 发送异常: ", throwable);
}
});
}
// 手动 ACK告诉 RocketMQ 这批次消息消费成功
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (Exception e) {
log.error("", e);
// 手动 ACK告诉 RocketMQ 这批次消息处理失败,稍后再进行重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
});
// 启动消费者
consumer.start();
return consumer;
}
/**
* 同步一级评论到 Redis 热点评论 ZSET 中
*
* @param commentBOS 评论 BO 列表
*/
private void syncOneLevelComment2RedisZSet(List<CommentBO> commentBOS) {
// 过滤出一级评论,并按所属笔记进行分组,转换为一个 Map 字典
Map<Long, List<CommentBO>> commentIdAndBOListMap = commentBOS.stream()
.filter(commentBO -> Objects.equals(commentBO.getLevel(), CommentLevelEnum.ONE.getCode())) // 仅过滤一级评论
.collect(Collectors.groupingBy(CommentBO::getNoteId));
// 循环字典
commentIdAndBOListMap.forEach((noteId, commentBOList) -> {
// 构建 Redis 热点评论 ZSET Key
String key = RedisKeyConstants.buildCommentListKey(noteId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/add_hot_comments.lua")));
// 返回值类型
script.setResultType(Long.class);
// 构建执行 Lua 脚本所需的 ARGS 参数
List<Object> args = Lists.newArrayList();
commentBOList.forEach(commentBO -> {
args.add(commentBO.getId()); // Member: 评论ID
args.add(0); // Score: 热度值,初始值为 0
});
// 执行 Lua 脚本
redisTemplate.execute(script, Collections.singletonList(key), args.toArray());
});
}
@PreDestroy
public void destroy() {
if (Objects.nonNull(consumer)) {
try {
consumer.shutdown(); // 关闭消费者
} catch (Exception e) {
log.error("", e);
}
}
}
}

View File

@@ -0,0 +1,138 @@
package com.hanserwei.hannote.comment.biz.consumer;
import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.constants.RedisKeyConstants;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.comment.biz.model.bo.CommentHeatBO;
import com.hanserwei.hannote.comment.biz.utils.HeatCalculator;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_COMMENT_HEAT_UPDATE, // Group 组
topic = MQConstants.TOPIC_COMMENT_HEAT_UPDATE // 主题 Topic
)
@Slf4j
public class CommentHeatUpdateConsumer implements RocketMQListener<String> {
@Resource
private CommentDOMapper commentDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(300) // 一批次最多聚合 300 条
.linger(Duration.ofSeconds(2)) // 多久聚合一次2s 一次)
.setConsumerEx(this::consumeMessage) // 设置消费者方法
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> bodys) {
log.info("==> 【评论热度值计算】聚合消息, size: {}", bodys.size());
log.info("==> 【评论热度值计算】聚合消息, {}", JsonUtils.toJsonString(bodys));
// 将聚合后的消息体 Json 转 Set<Long>, 去重相同的评论 ID, 防止重复计算
Set<Long> commentIds = Sets.newHashSet();
bodys.forEach(body -> {
try {
Set<Long> list = JsonUtils.parseSet(body, Long.class);
commentIds.addAll(list);
} catch (Exception e) {
log.error("", e);
}
});
log.info("==> 去重后的评论 ID: {}", commentIds);
// 批量查询评论
List<CommentDO> commentDOS = commentDOMapper.selectByCommentIds(commentIds.stream().toList());
// 评论 ID
List<Long> ids = Lists.newArrayList();
// 热度值 BO
List<CommentHeatBO> commentBOS = Lists.newArrayList();
//重新计算每条评论的热度值
commentDOS.forEach(commentDO -> {
Long commentId = commentDO.getId();
// 被点赞数
Long likeTotal = commentDO.getLikeTotal();
// 被回复数
Long childCommentTotal = commentDO.getChildCommentTotal();
// 计算热度值
BigDecimal heatNum = HeatCalculator.calculateHeat(likeTotal, childCommentTotal);
ids.add(commentId);
commentBOS.add(CommentHeatBO.builder()
.id(commentId)
.heat(heatNum.doubleValue())
.noteId(commentDO.getNoteId())
.build());
});
// 批量更新评论热度值
commentDOMapper.batchUpdateHeatByCommentIds(ids, commentBOS);
// 更新 Redis 中热度评论 ZSET
updateRedisHotComments(commentBOS);
}
/**
* 更新 Redis 中热点评论 ZSET
*
* @param commentHeatBOList 热度值 BO 列表
*/
private void updateRedisHotComments(List<CommentHeatBO> commentHeatBOList) {
// 过滤出热度值大于 0 的,并按所属笔记 ID 分组若热度等于0则不进行更新
Map<Long, List<CommentHeatBO>> noteIdAndBOListMap = commentHeatBOList.stream()
.filter(commentHeatBO -> commentHeatBO.getHeat() > 0)
.collect(Collectors.groupingBy(CommentHeatBO::getNoteId));
// 循环
noteIdAndBOListMap.forEach((noteId, commentHeatBOS) -> {
// 构建热点评论 Redis Key
String key = RedisKeyConstants.buildCommentListKey(noteId);
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// Lua 脚本路径
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("/lua/update_hot_comments.lua")));
// 返回值类型
script.setResultType(Long.class);
// 构建执行 Lua 脚本所需的 ARGS 参数
List<Object> args = Lists.newArrayList();
commentHeatBOS.forEach(commentHeatBO -> {
args.add(commentHeatBO.getId()); // Member: 评论ID
args.add(commentHeatBO.getHeat()); // Score: 热度值
});
// 执行 Lua 脚本
redisTemplate.execute(script, Collections.singletonList(key), args.toArray());
});
}
}

View File

@@ -0,0 +1,184 @@
package com.hanserwei.hannote.comment.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.constants.RedisKeyConstants;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.comment.biz.domain.mapper.NoteCountDOMapper;
import com.hanserwei.hannote.comment.biz.enums.CommentLevelEnum;
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.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@SuppressWarnings("UnstableApiUsage")
@Component
@Slf4j
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_DELETE_COMMENT, // Group
topic = MQConstants.TOPIC_DELETE_COMMENT // 消费的主题 Topic
)
public class DeleteCommentConsumer implements RocketMQListener<String> {
// 每秒创建 1000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(1000);
@Resource
private CommentDOMapper commentDOMapper;
@Resource
private NoteCountDOMapper noteCountDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Override
public void onMessage(String body) {
// 令牌桶流控
rateLimiter.acquire();
log.info("## 【删除评论 - 后续业务处理】消费者消费成功, body: {}", body);
CommentDO commentDO = JsonUtils.parseObject(body, CommentDO.class);
// 评论级别
Integer level = null;
if (commentDO != null) {
level = commentDO.getLevel();
}
CommentLevelEnum commentLevelEnum = CommentLevelEnum.valueOf(level);
if (commentLevelEnum != null) {
switch (commentLevelEnum) {
case ONE -> // 一级评论
handleOneLevelComment(commentDO);
case TWO -> // 二级评论
handleTwoLevelComment(commentDO);
}
}
}
/**
* 一级评论处理
*
* @param commentDO 评论
*/
private void handleOneLevelComment(CommentDO commentDO) {
Long commentId = commentDO.getId();
Long noteId = commentDO.getNoteId();
// 1. 关联评论删除(一级评论下所有子评论,都需要删除)
int count = commentDOMapper.deleteByParentId(commentId);
// 2. 计数更新(笔记下总评论数)
// 更新 Redis 缓存
String redisKey = RedisKeyConstants.buildNoteCommentTotalKey(noteId);
boolean hasKey = redisTemplate.hasKey(redisKey);
if (hasKey) {
// 笔记评论总数 -1
redisTemplate.opsForHash().increment(redisKey, RedisKeyConstants.FIELD_COMMENT_TOTAL, -(count + 1));
}
// 更新 t_note_count 计数表
noteCountDOMapper.updateCommentTotalByNoteId(noteId, -(count + 1));
}
/**
* 二级评论处理
*
* @param commentDO 评论
*/
private void handleTwoLevelComment(CommentDO commentDO) {
Long commentId = commentDO.getId();
// 1. 批量删除关联评论(递归查询回复评论,并批量删除)
List<Long> replyCommentIds = Lists.newArrayList();
recurrentGetReplyCommentId(replyCommentIds, commentId);
// 被删除的行数
int count = 0;
if (CollUtil.isNotEmpty(replyCommentIds)) {
count = commentDOMapper.deleteByIds(replyCommentIds);
}
// 2. 更新一级评论的计数
Long parentCommentId = commentDO.getParentId();
String redisKey = RedisKeyConstants.buildCountCommentKey(parentCommentId);
boolean hasKey = redisTemplate.hasKey(redisKey);
if (hasKey) {
redisTemplate.opsForHash().increment(redisKey, RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, -(count + 1));
}
// 3. 若是最早的发布的二级评论被删除,需要更新一级评论的 first_reply_comment_id
// 查询一级评论
CommentDO oneLevelCommentDO = commentDOMapper.selectById(parentCommentId);
Long firstReplyCommentId = oneLevelCommentDO.getFirstReplyCommentId();
// 若删除的是最早回复的二级评论
if (Objects.equals(firstReplyCommentId, commentId)) {
// 查询数据库,重新获取一级评论最早回复的评论
CommentDO earliestCommentDO = commentDOMapper.selectEarliestByParentId(parentCommentId);
// 最早回复的那条评论 ID。若查询结果为 null, 则最早回复的评论 ID 为 null
Long earliestCommentId = Objects.nonNull(earliestCommentDO) ? earliestCommentDO.getId() : null;
// 更新其一级评论的 first_reply_comment_id
commentDOMapper.updateFirstReplyCommentIdByPrimaryKey(earliestCommentId, parentCommentId);
}
// 4. 重新计算一级评论的热度值
Set<Long> commentIds = Sets.newHashSetWithExpectedSize(1);
commentIds.add(parentCommentId);
// 异步发送计数 MQ, 更新评论热度值
org.springframework.messaging.Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(commentIds))
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COMMENT_HEAT_UPDATE, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【评论热度值更新】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【评论热度值更新】MQ 发送异常: ", throwable);
}
});
}
/**
* 递归获取全部回复的评论 ID
*
* @param commentIds 评论 ID 列表
* @param commentId 评论 ID
*/
private void recurrentGetReplyCommentId(List<Long> commentIds, Long commentId) {
CommentDO replyCommentDO = commentDOMapper.selectByReplyCommentId(commentId);
if (Objects.isNull(replyCommentDO)) return;
commentIds.add(replyCommentDO.getId());
Long replyCommentId = replyCommentDO.getId();
// 递归调用
recurrentGetReplyCommentId(commentIds, replyCommentId);
}
}

View File

@@ -0,0 +1,29 @@
package com.hanserwei.hannote.comment.biz.consumer;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.service.CommentService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.MessageModel;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
@Component
@Slf4j
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_DELETE_COMMENT_LOCAL_CACHE, // Group
topic = MQConstants.TOPIC_DELETE_COMMENT_LOCAL_CACHE, // 消费的主题 Topic
messageModel = MessageModel.BROADCASTING) // 广播模式
public class DeleteCommentLocalCacheConsumer implements RocketMQListener<String> {
@Resource
private CommentService commentService;
@Override
public void onMessage(String body) {
Long commentId = Long.valueOf(body);
log.info("## 消费者消费成功, commentId: {}", commentId);
commentService.deleteCommentLocalCache(commentId);
}
}

View File

@@ -0,0 +1,156 @@
package com.hanserwei.hannote.comment.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentLikeDOMapper;
import com.hanserwei.hannote.comment.biz.enums.LikeUnlikeCommentTypeEnum;
import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeOrderlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.remoting.protocol.heartbeat.MessageModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
@SuppressWarnings("UnstableApiUsage")
@Component
@Slf4j
public class LikeUnlikeComment2DBConsumer {
// 每秒创建 5000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(5000);
@Value("${rocketmq.name-server}")
private String nameServer;
@Resource
private CommentLikeDOMapper commentLikeDOMapper;
private DefaultMQPushConsumer consumer;
@Bean(name = "LikeUnlikeComment2DBConsumer")
public DefaultMQPushConsumer mqPushConsumer() throws MQClientException {
// Group 组
String group = "han_note_group_" + MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE;
// 创建一个新的 DefaultMQPushConsumer 实例,并指定消费者的消费组名
consumer = new DefaultMQPushConsumer(group);
// 设置 RocketMQ 的 NameServer 地址
consumer.setNamesrvAddr(nameServer);
// 订阅指定的主题,并设置主题的订阅规则("*" 表示订阅所有标签的消息)
consumer.subscribe(MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE, "*");
// 设置消费者消费消息的起始位置,如果队列中没有消息,则从最新的消息开始消费。
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
// 设置消息消费模式,这里使用集群模式 (CLUSTERING)
consumer.setMessageModel(MessageModel.CLUSTERING);
// 最大重试次数, 以防消息重试过多次仍然没有成功,避免消息卡在消费队列中。
consumer.setMaxReconsumeTimes(3);
// 设置每批次消费的最大消息数量,这里设置为 30表示每次拉取时最多消费 30 条消息。
consumer.setConsumeMessageBatchMaxSize(30);
// 注册消息监听器
consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> {
log.info("==> 【评论点赞、取消点赞】本批次消息大小: {}", msgs.size());
try {
// 令牌桶流控, 以控制数据库能够承受的 QPS
rateLimiter.acquire();
// 将批次 Json 消息体转换 DTO 集合
List<LikeUnlikeCommentMqDTO> likeUnlikeCommentMqDTOS = Lists.newArrayList();
msgs.forEach(msg -> {
String tag = msg.getTags(); // Tag 标签
String msgJson = new String(msg.getBody()); // 消息体 Json 字符串
log.info("==> 【评论点赞、取消点赞】Consumer - Tag: {}, Received message: {}", tag, msgJson);
// Json 转 DTO
likeUnlikeCommentMqDTOS.add(JsonUtils.parseObject(msgJson, LikeUnlikeCommentMqDTO.class));
});
// 按评论 ID 分组
Map<Long, List<LikeUnlikeCommentMqDTO>> commentIdAndListMap = likeUnlikeCommentMqDTOS.stream()
.collect(Collectors.groupingBy(LikeUnlikeCommentMqDTO::getCommentId));
List<LikeUnlikeCommentMqDTO> finalLikeUnlikeCommentMqDTOS = Lists.newArrayList();
commentIdAndListMap.forEach((commentId, ops) -> {
// 优化:若某个用户对某评论,多次操作,如点赞 -> 取消点赞 -> 点赞,需进行操作合并,只提取最后一次操作,进一步降低操作数据库的频率
Map<Long, LikeUnlikeCommentMqDTO> userLastOp = ops.stream()
.collect(Collectors.toMap(
LikeUnlikeCommentMqDTO::getUserId, // 以发布评论的用户 ID 作为 Map 的键
Function.identity(), // 直接使用 DTO 对象本身作为 Map 的值
// 合并策略:当出现重复键(同一用户多次操作)时,保留时间更晚的记录
(oldValue, newValue) ->
oldValue.getCreateTime().isAfter(newValue.getCreateTime()) ? oldValue : newValue
));
finalLikeUnlikeCommentMqDTOS.addAll(userLastOp.values());
});
// 批量操作数据库
executeBatchSQL(finalLikeUnlikeCommentMqDTOS);
// 手动 ACK告诉 RocketMQ 这批次消息消费成功
return ConsumeOrderlyStatus.SUCCESS;
} catch (Exception e) {
log.error("", e);
// 这样 RocketMQ 会暂停当前队列的消费一段时间,再重试
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
});
// 启动消费者
consumer.start();
return consumer;
}
private void executeBatchSQL(List<LikeUnlikeCommentMqDTO> values) {
// 过滤出点赞操作
List<LikeUnlikeCommentMqDTO> likes = values.stream()
.filter(op -> Objects.equals(op.getType(), LikeUnlikeCommentTypeEnum.LIKE.getCode()))
.toList();
// 过滤出取消点赞操作
List<LikeUnlikeCommentMqDTO> unlikes = values.stream()
.filter(op -> Objects.equals(op.getType(), LikeUnlikeCommentTypeEnum.UNLIKE.getCode()))
.toList();
// 取消点赞:批量删除
if (CollUtil.isNotEmpty(unlikes)) {
commentLikeDOMapper.batchDelete(unlikes);
}
// 点赞:批量新增
if (CollUtil.isNotEmpty(likes)) {
commentLikeDOMapper.batchInsert(likes);
}
}
@PreDestroy
public void destroy() {
if (Objects.nonNull(consumer)) {
try {
consumer.shutdown(); // 关闭消费者
} catch (Exception e) {
log.error("", e);
}
}
}
}

View File

@@ -0,0 +1,164 @@
package com.hanserwei.hannote.comment.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Lists;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.comment.biz.constants.MQConstants;
import com.hanserwei.hannote.comment.biz.constants.RedisKeyConstants;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.comment.biz.enums.CommentLevelEnum;
import com.hanserwei.hannote.comment.biz.model.dto.CountPublishCommentMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_first_reply_comment_id" + MQConstants.TOPIC_COUNT_NOTE_COMMENT, // Group 组
topic = MQConstants.TOPIC_COUNT_NOTE_COMMENT // 主题 Topic
)
@Slf4j
public class OneLevelCommentFirstReplyCommentIdUpdateConsumer implements RocketMQListener<String> {
@Resource
private CommentDOMapper commentDOMapper;
@Resource(name = "taskExecutor")
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
.linger(Duration.ofSeconds(1)) // 多久聚合一次1s 一次)
.setConsumerEx(this::consumeMessage) // 设置消费者方法
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> bodys) {
log.info("==> 【一级评论 first_reply_comment_id 更新】聚合消息, size: {}", bodys.size());
log.info("==> 【一级评论 first_reply_comment_id 更新】聚合消息, {}", JsonUtils.toJsonString(bodys));
// 将聚合后的消息体 Json 转 List<CountPublishCommentMqDTO>
List<CountPublishCommentMqDTO> publishCommentMqDTOS = Lists.newArrayList();
bodys.forEach(body -> {
try {
List<CountPublishCommentMqDTO> list = JsonUtils.parseList(body, CountPublishCommentMqDTO.class);
publishCommentMqDTOS.addAll(list);
} catch (Exception e) {
log.error("", e);
}
});
// 过滤出二级评论的 parent_id即一级评论 ID并去重需要更新对应一级评论的 first_reply_comment_id
List<Long> parentIds = publishCommentMqDTOS.stream()
.filter(publishCommentMqDTO -> Objects.equals(publishCommentMqDTO.getLevel(), CommentLevelEnum.TWO.getCode()))
.map(CountPublishCommentMqDTO::getParentId)
.distinct() // 去重
.toList();
if (CollUtil.isEmpty(parentIds)) return;
// 构建RedisKey
List<String> keys = parentIds.stream()
.map(RedisKeyConstants::buildHaveFirstReplyCommentKey)
.toList();
// 批量查询Redis
List<Object> values = redisTemplate.opsForValue().multiGet(keys);
// 提取Redis中不存在的评论ID
List<Long> missingCommentIds = Lists.newArrayList();
if (values != null) {
for (int i = 0; i < values.size(); i++) {
if (Objects.isNull(values.get(i))) {
missingCommentIds.add(parentIds.get(i));
}
}
}
// 存在的一级评论 ID说明表中对应记录的 first_reply_comment_id 已经有值
if (CollUtil.isNotEmpty(missingCommentIds)) {
// 不存在的,则需要进一步查询数据库来确定,是否要更新记录对应的 first_reply_comment_id 值
// 批量去数据库中查询
List<CommentDO> commentDOS = commentDOMapper.selectByCommentIds(missingCommentIds);
// 异步将 first_reply_comment_id 不为 0 的一级评论 ID, 同步到 redis 中
threadPoolTaskExecutor.submit(() -> {
List<Long> needSyncCommentIds = commentDOS.stream()
.filter(commentDO -> commentDO.getFirstReplyCommentId() != 0)
.map(CommentDO::getId)
.toList();
sync2Redis(needSyncCommentIds);
});
// 过滤出值为 0 的,都需要更新其 first_reply_comment_id
List<CommentDO> needUpdateCommentDOS = commentDOS.stream()
.filter(commentDO -> commentDO.getFirstReplyCommentId() == 0)
.toList();
needUpdateCommentDOS.forEach(needUpdateCommentDO -> {
// 一级评论 ID
Long needUpdateCommentId = needUpdateCommentDO.getId();
// 查询数据库,拿到一级评论最早回复的那条评论
CommentDO earliestCommentDO = commentDOMapper.selectEarliestByParentId(needUpdateCommentId);
if (Objects.nonNull(earliestCommentDO)) {
// 最早回复的那条评论 ID
Long earliestCommentId = earliestCommentDO.getId();
// 更新其一级评论的 first_reply_comment_id
commentDOMapper.updateFirstReplyCommentIdByPrimaryKey(earliestCommentId, needUpdateCommentId);
// 异步同步到 Redis
threadPoolTaskExecutor.submit(() -> sync2Redis(Lists.newArrayList(needUpdateCommentId)));
}
});
}
}
/**
* 异步将 first_reply_comment_id 不为 0 的一级评论 ID, 同步到 redis 中
*
* @param needSyncCommentIds 需要同步的评论 ID
*/
private void sync2Redis(List<Long> needSyncCommentIds) {
// 获取 ValueOperations
ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();
// 使用 RedisTemplate 的管道模式,允许在一个操作中批量发送多个命令,防止频繁操作 Redis
redisTemplate.executePipelined((RedisCallback<?>) (connection) -> {
needSyncCommentIds.forEach(needSyncCommentId -> {
// 构建 Redis Key
String key = RedisKeyConstants.buildHaveFirstReplyCommentKey(needSyncCommentId);
// 批量设置值并指定过期时间5小时以内
valueOperations.set(key, 1, RandomUtil.randomInt(5 * 60 * 60), TimeUnit.SECONDS);
});
return null;
});
}
}

View File

@@ -0,0 +1,60 @@
package com.hanserwei.hannote.comment.biz.controller;
import com.hanserwei.framework.biz.operationlog.aspect.ApiOperationLog;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.comment.biz.model.vo.*;
import com.hanserwei.hannote.comment.biz.service.CommentService;
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("/comment")
@Slf4j
public class CommentController {
@Resource
private CommentService commentService;
@PostMapping("/publish")
@ApiOperationLog(description = "发布评论")
public Response<?> publishComment(@Validated @RequestBody PublishCommentReqVO publishCommentReqVO) {
return commentService.publishComment(publishCommentReqVO);
}
@PostMapping("/list")
@ApiOperationLog(description = "评论分页查询")
public PageResponse<FindCommentItemRspVO> findCommentPageList(@Validated @RequestBody FindCommentPageListReqVO findCommentPageListReqVO) {
return commentService.findCommentPageList(findCommentPageListReqVO);
}
@PostMapping("/child/list")
@ApiOperationLog(description = "二级评论分页查询")
public PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(@Validated @RequestBody FindChildCommentPageListReqVO findChildCommentPageListReqVO) {
return commentService.findChildCommentPageList(findChildCommentPageListReqVO);
}
@PostMapping("/like")
@ApiOperationLog(description = "评论点赞")
public Response<?> likeComment(@Validated @RequestBody LikeCommentReqVO likeCommentReqVO) {
return commentService.likeComment(likeCommentReqVO);
}
@PostMapping("/unlike")
@ApiOperationLog(description = "评论取消点赞")
public Response<?> unlikeComment(@Validated @RequestBody UnLikeCommentReqVO unLikeCommentReqVO) {
return commentService.unlikeComment(unLikeCommentReqVO);
}
@PostMapping("/delete")
@ApiOperationLog(description = "删除评论")
public Response<?> deleteComment(@Validated @RequestBody DeleteCommentReqVO deleteCommentReqVO) {
return commentService.deleteComment(deleteCommentReqVO);
}
}

View File

@@ -0,0 +1,130 @@
package com.hanserwei.hannote.comment.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 lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 评论表
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "t_comment")
public class CommentDO {
/**
* id
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 关联的笔记ID
*/
@TableField(value = "note_id")
private Long noteId;
/**
* 发布者用户ID
*/
@TableField(value = "user_id")
private Long userId;
/**
* 评论内容UUID
*/
@TableField(value = "content_uuid")
private String contentUuid;
/**
* 内容是否为空(0不为空 1为空)
*/
@TableField(value = "is_content_empty")
private Boolean isContentEmpty;
/**
* 评论附加图片URL
*/
@TableField(value = "image_url")
private String imageUrl;
/**
* 级别(1一级评论 2二级评论)
*/
@TableField(value = "`level`")
private Integer level;
/**
* 评论被回复次数,仅一级评论需要
*/
@TableField(value = "reply_total")
private Long replyTotal;
/**
* 评论被点赞次数
*/
@TableField(value = "like_total")
private Long likeTotal;
/**
* 父ID (若是对笔记的评论则此字段存储笔记ID; 若是二级评论则此字段存储一级评论的ID)
*/
@TableField(value = "parent_id")
private Long parentId;
/**
* 回复哪个的评论 (0表示是对笔记的评论若是对他人评论的回复则存储回复评论的ID)
*/
@TableField(value = "reply_comment_id")
private Long replyCommentId;
/**
* 回复的哪个用户, 存储用户ID
*/
@TableField(value = "reply_user_id")
private Long replyUserId;
/**
* 是否置顶(0不置顶 1置顶)
*/
@TableField(value = "is_top")
private Boolean isTop;
/**
* 创建时间
*/
@TableField(value = "create_time")
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(value = "update_time")
private LocalDateTime updateTime;
/**
* 二级评论总数(只有一级评论才需要统计)
*/
@TableField(value = "child_comment_total")
private Long childCommentTotal;
/**
* 评论热度
*/
@TableField(value = "heat")
private Double heat;
/**
* 最早回复的评论ID (只有一级评论需要)
*/
@TableField(value = "first_reply_comment_id")
private Long firstReplyCommentId;
}

View File

@@ -0,0 +1,46 @@
package com.hanserwei.hannote.comment.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 lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 评论点赞表
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "t_comment_like")
public class CommentLikeDO {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
@TableField(value = "user_id")
private Long userId;
/**
* 评论ID
*/
@TableField(value = "comment_id")
private Long commentId;
/**
* 创建时间
*/
@TableField(value = "create_time")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,50 @@
package com.hanserwei.hannote.comment.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 lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 笔记计数表
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "t_note_count")
public class NoteCountDO {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
/**
* 笔记ID
*/
@TableField(value = "note_id")
private Long noteId;
/**
* 获得点赞总数
*/
@TableField(value = "like_total")
private Long likeTotal;
/**
* 获得收藏总数
*/
@TableField(value = "collect_total")
private Long collectTotal;
/**
* 被评论总数
*/
@TableField(value = "comment_total")
private Long commentTotal;
}

View File

@@ -0,0 +1,149 @@
package com.hanserwei.hannote.comment.biz.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
import com.hanserwei.hannote.comment.biz.model.bo.CommentHeatBO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CommentDOMapper extends BaseMapper<CommentDO> {
/**
* 根据评论 ID 批量查询
*
* @param commentIds 评论 ID 列表
* @return 评论列表
*/
List<CommentDO> selectByCommentIds(@Param("commentIds") List<Long> commentIds);
/**
* 批量插入评论
*
* @param comments 评论列表
* @return 插入数量
*/
int batchInsert(@Param("comments") List<CommentBO> comments);
/**
* 批量更新热度值
*
* @param commentIds 评论 ID 列表
* @param commentHeatBOS 热度值列表
* @return 更新数量
*/
int batchUpdateHeatByCommentIds(@Param("commentIds") List<Long> commentIds,
@Param("commentHeatBOS") List<CommentHeatBO> commentHeatBOS);
/**
* 查询一级评论下最早回复的评论
*
* @param parentId 一级评论 ID
* @return 一级评论下最早回复的评论
*/
CommentDO selectEarliestByParentId(Long parentId);
/**
* 更新一级评论的 first_reply_comment_id
*
* @param firstReplyCommentId 一级评论下最早回复的评论 ID
* @param id 一级评论 ID
* @return 更新数量
*/
int updateFirstReplyCommentIdByPrimaryKey(@Param("firstReplyCommentId") Long firstReplyCommentId,
@Param("id") Long id);
/**
* 查询评论分页数据
*
* @param noteId 笔记 ID
* @param offset 偏移量
* @param pageSize 页大小
* @return 评论分页数据
*/
List<CommentDO> selectPageList(@Param("noteId") Long noteId,
@Param("offset") long offset,
@Param("pageSize") long pageSize);
/**
* 批量查询二级评论
*
* @param commentIds 评论 ID 列表
* @return 二级评论
*/
List<CommentDO> selectTwoLevelCommentByIds(@Param("commentIds") List<Long> commentIds);
/**
* 查询热门评论
*
* @param noteId 笔记 ID
* @return 热门评论
*/
List<CommentDO> selectHeatComments(Long noteId);
/**
* 查询一级评论下子评论总数
*
* @param commentId 一级评论 ID
* @return 一级评论下子评论总数
*/
Long selectChildCommentTotalById(Long commentId);
/**
* 查询二级评论分页数据
*
* @param parentId 一级评论 ID
* @param offset 偏移量
* @param pageSize 页大小
* @return 二级评论分页数据
*/
List<CommentDO> selectChildPageList(@Param("parentId") Long parentId,
@Param("offset") long offset,
@Param("pageSize") long pageSize);
/**
* 批量查询计数数据
*
* @param commentIds 评论 ID 列表
* @return 计数数据
*/
List<CommentDO> selectCommentCountByIds(@Param("commentIds") List<Long> commentIds);
/**
* 查询子评论
*
* @param parentId 一级评论 ID
* @param limit 子评论数量限制
* @return 子评论
*/
List<CommentDO> selectChildCommentsByParentIdAndLimit(@Param("parentId") Long parentId,
@Param("limit") int limit);
/**
* 删除一级评论下,所有二级评论
*
* @param commentId 一级评论 ID
* @return 删除数量
*/
int deleteByParentId(Long commentId);
/**
* 批量删除评论
*
* @param commentIds 评论 ID 列表
* @return 删除数量
*/
int deleteByIds(@Param("commentIds") List<Long> commentIds);
/**
* 根据 reply_comment_id 查询
*
* @param commentId 回复的评论 ID
* @return 评论
*/
CommentDO selectByReplyCommentId(Long commentId);
}

View File

@@ -0,0 +1,47 @@
package com.hanserwei.hannote.comment.biz.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO;
import com.hanserwei.hannote.comment.biz.model.dto.LikeUnlikeCommentMqDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CommentLikeDOMapper extends BaseMapper<CommentLikeDO> {
/**
* 查询某个评论是否被点赞
*
* @param userId 用户 ID
* @param commentId 评论 ID
* @return 1 表示已点赞0 表示未点赞
*/
int selectCountByUserIdAndCommentId(@Param("userId") Long userId,
@Param("commentId") Long commentId);
/**
* 查询对应用户点赞的所有评论
*
* @param userId 用户 ID
* @return 评论点赞列表
*/
List<CommentLikeDO> selectByUserId(@Param("userId") Long userId);
/**
* 批量删除点赞记录
*
* @param unlikes 删除点赞记录
* @return 删除数量
*/
int batchDelete(@Param("unlikes") List<LikeUnlikeCommentMqDTO> unlikes);
/**
* 批量添加点赞记录
*
* @param likes 添加点赞记录
* @return 添加数量
*/
int batchInsert(@Param("likes") List<LikeUnlikeCommentMqDTO> likes);
}

View File

@@ -0,0 +1,28 @@
package com.hanserwei.hannote.comment.biz.domain.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hanserwei.hannote.comment.biz.domain.dataobject.NoteCountDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface NoteCountDOMapper extends BaseMapper<NoteCountDO> {
/**
* 查询笔记评论总数
*
* @param noteId 笔记ID
* @return 笔记评论总数
*/
Long selectCommentTotalByNoteId(Long noteId);
/**
* 更新评论总数
*
* @param noteId 笔记 ID
* @param count 评论总数
* @return 更新数量
*/
int updateCommentTotalByNoteId(@Param("noteId") Long noteId,
@Param("count") int count);
}

View File

@@ -0,0 +1,33 @@
package com.hanserwei.hannote.comment.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum CommentLevelEnum {
// 一级评论
ONE(1),
// 二级评论
TWO(2),
;
private final Integer code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static CommentLevelEnum valueOf(Integer code) {
for (CommentLevelEnum commentLevelEnum : CommentLevelEnum.values()) {
if (Objects.equals(code, commentLevelEnum.getCode())) {
return commentLevelEnum;
}
}
return null;
}
}

View File

@@ -0,0 +1,37 @@
package com.hanserwei.hannote.comment.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum CommentLikeLuaResultEnum {
// 布隆过滤器不存在
NOT_EXIST(-1L),
// 评论已点赞
COMMENT_LIKED(1L),
// 评论点赞成功
COMMENT_LIKE_SUCCESS(0L),
;
private final Long code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static CommentLikeLuaResultEnum valueOf(Long code) {
for (CommentLikeLuaResultEnum commentLikeLuaResultEnum : CommentLikeLuaResultEnum.values()) {
if (Objects.equals(code, commentLikeLuaResultEnum.getCode())) {
return commentLikeLuaResultEnum;
}
}
return null;
}
}

View File

@@ -0,0 +1,35 @@
package com.hanserwei.hannote.comment.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Objects;
@Getter
@AllArgsConstructor
public enum CommentUnlikeLuaResultEnum {
// 布隆过滤器不存在
NOT_EXIST(-1L),
// 评论已点赞
COMMENT_LIKED(1L),
// 评论未点赞
COMMENT_NOT_LIKED(0L),
;
private final Long code;
/**
* 根据类型 code 获取对应的枚举
*
* @param code 类型 code
* @return 枚举
*/
public static CommentUnlikeLuaResultEnum valueOf(Long code) {
for (CommentUnlikeLuaResultEnum commentUnlikeLuaResultEnum : CommentUnlikeLuaResultEnum.values()) {
if (Objects.equals(code, commentUnlikeLuaResultEnum.getCode())) {
return commentUnlikeLuaResultEnum;
}
}
return null;
}
}

View File

@@ -0,0 +1,17 @@
package com.hanserwei.hannote.comment.biz.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum LikeUnlikeCommentTypeEnum {
// 点赞
LIKE(1),
// 取消点赞
UNLIKE(0),
;
private final Integer code;
}

View File

@@ -0,0 +1,28 @@
package com.hanserwei.hannote.comment.biz.enums;
import com.hanserwei.framework.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {
// ----------- 通用异常状态码 -----------
SYSTEM_ERROR("COMMENT-10000", "出错啦,后台小哥正在努力修复中..."),
PARAM_NOT_VALID("COMMENT-10001", "参数错误"),
// ----------- 业务异常状态码 -----------
COMMENT_NOT_FOUND("COMMENT-20001", "此评论不存在"),
PARENT_COMMENT_NOT_FOUND("COMMENT-20000", "此父评论不存在"),
COMMENT_ALREADY_LIKED("COMMENT-20002", "您已经点赞过该评论"),
COMMENT_NOT_LIKED("COMMENT-20003", "您未点赞该评论,无法取消点赞"),
COMMENT_CANT_OPERATE("COMMENT-20004", "您无法操作该评论"),
;
// 异常码
private final String errorCode;
// 错误信息
private final String errorMsg;
}

View File

@@ -0,0 +1,103 @@
package com.hanserwei.hannote.comment.biz.exception;
import com.hanserwei.framework.common.exception.ApiException;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.comment.biz.enums.ResponseCodeEnum;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.Optional;
@SuppressWarnings("LoggingSimilarMessage")
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 捕获自定义业务异常
*
* @return Response.fail(e)
*/
@ExceptionHandler({ApiException.class})
@ResponseBody
public Response<Object> handleApiException(HttpServletRequest request, ApiException e) {
log.warn("{} request fail, errorCode: {}, errorMessage: {}", request.getRequestURI(), e.getErrorCode(), e.getErrorMsg());
return Response.fail(e);
}
/**
* 捕获参数校验异常
*
* @return Response.fail(errorCode, errorMessage)
*/
@ExceptionHandler({MethodArgumentNotValidException.class})
@ResponseBody
public Response<Object> handleMethodArgumentNotValidException(HttpServletRequest request, MethodArgumentNotValidException e) {
// 参数错误异常码
String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();
// 获取 BindingResult
BindingResult bindingResult = e.getBindingResult();
StringBuilder sb = new StringBuilder();
// 获取校验不通过的字段,并组合错误信息,格式为: email 邮箱格式不正确, 当前值: '123124qq.com';
Optional.of(bindingResult.getFieldErrors()).ifPresent(errors -> {
errors.forEach(error ->
sb.append(error.getField())
.append(" ")
.append(error.getDefaultMessage())
.append(", 当前值: '")
.append(error.getRejectedValue())
.append("'; ")
);
});
// 错误信息
String errorMessage = sb.toString();
log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);
return Response.fail(errorCode, errorMessage);
}
/**
* 捕获 guava 参数校验异常
*
* @return Response.fail(ResponseCodeEnum.PARAM_NOT_VALID)
*/
@ExceptionHandler({IllegalArgumentException.class})
@ResponseBody
public Response<Object> handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException e) {
// 参数错误异常码
String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();
// 错误信息
String errorMessage = e.getMessage();
log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);
return Response.fail(errorCode, errorMessage);
}
/**
* 其他类型异常
*
* @param request 请求
* @param e 异常
* @return Response.fail(ResponseCodeEnum.SYSTEM_ERROR)
*/
@ExceptionHandler({Exception.class})
@ResponseBody
public Response<Object> handleOtherException(HttpServletRequest request, Exception e) {
log.error("{} request error, ", request.getRequestURI(), e);
return Response.fail(ResponseCodeEnum.SYSTEM_ERROR);
}
}

View File

@@ -0,0 +1,46 @@
package com.hanserwei.hannote.comment.biz.model.bo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CommentBO {
private Long id;
private Long noteId;
private Long userId;
private String contentUuid;
private String content;
private Boolean isContentEmpty;
private String imageUrl;
private Integer level;
private Long replyTotal;
private Long likeTotal;
private Long parentId;
private Long replyCommentId;
private Long replyUserId;
private Boolean isTop;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,27 @@
package com.hanserwei.hannote.comment.biz.model.bo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CommentHeatBO {
/**
* 评论 ID
*/
private Long id;
/**
* 热度值
*/
private Double heat;
/**
* 笔记 ID
*/
private Long noteId;
}

View File

@@ -0,0 +1,34 @@
package com.hanserwei.hannote.comment.biz.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CountPublishCommentMqDTO {
/**
* 笔记 ID
*/
private Long noteId;
/**
* 评论 ID
*/
private Long commentId;
/**
* 评论级别
*/
private Integer level;
/**
* 父 ID
*/
private Long parentId;
}

View File

@@ -0,0 +1,26 @@
package com.hanserwei.hannote.comment.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 LikeUnlikeCommentMqDTO {
private Long userId;
private Long commentId;
/**
* 0: 取消点赞, 1点赞
*/
private Integer type;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,48 @@
package com.hanserwei.hannote.comment.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 PublishCommentMqDTO {
private Long noteId;
/**
* 评论内容
*/
private String content;
/**
* 评论图片链接
*/
private String imageUrl;
/**
* 回复的哪个评论(评论 ID
*/
private Long replyCommentId;
/**
* 发布时间
*/
private LocalDateTime createTime;
/**
* 发布者 ID
*/
private Long creatorId;
/**
* 评论 ID
*/
private Long commentId;
}

View File

@@ -0,0 +1,18 @@
package com.hanserwei.hannote.comment.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 DeleteCommentReqVO {
@NotNull(message = "评论 ID 不能为空")
private Long commentId;
}

View File

@@ -0,0 +1,63 @@
package com.hanserwei.hannote.comment.biz.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindChildCommentItemRspVO {
/**
* 评论 ID
*/
private Long commentId;
/**
* 发布者用户 ID
*/
private Long userId;
/**
* 头像
*/
private String avatar;
/**
* 昵称
*/
private String nickname;
/**
* 评论内容
*/
private String content;
/**
* 评论内容
*/
private String imageUrl;
/**
* 发布时间
*/
private String createTime;
/**
* 被点赞数
*/
private Long likeTotal;
/**
* 回复的用户昵称
*/
private String replyUserName;
/**
* 回复的用户 ID
*/
private Long replyUserId;
}

View File

@@ -0,0 +1,20 @@
package com.hanserwei.hannote.comment.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 FindChildCommentPageListReqVO {
@NotNull(message = "父评论 ID 不能为空")
private Long parentCommentId;
@NotNull(message = "页码不能为空")
private Integer pageNo = 1;
}

View File

@@ -0,0 +1,69 @@
package com.hanserwei.hannote.comment.biz.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FindCommentItemRspVO {
/**
* 评论 ID
*/
private Long commentId;
/**
* 发布者用户 ID
*/
private Long userId;
/**
* 头像
*/
private String avatar;
/**
* 昵称
*/
private String nickname;
/**
* 评论内容
*/
private String content;
/**
* 评论内容
*/
private String imageUrl;
/**
* 发布时间
*/
private String createTime;
/**
* 被点赞数
*/
private Long likeTotal;
/**
* 二级评论总数
*/
private Long childCommentTotal;
/**
* 最早回复的评论
*/
private FindCommentItemRspVO firstReplyComment;
/**
* 热度值
*/
private Double heat;
}

View File

@@ -0,0 +1,20 @@
package com.hanserwei.hannote.comment.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 FindCommentPageListReqVO {
@NotNull(message = "笔记 ID 不能为空")
private Long noteId;
@NotNull(message = "页码不能为空")
private Integer pageNo = 1;
}

View File

@@ -0,0 +1,18 @@
package com.hanserwei.hannote.comment.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 LikeCommentReqVO {
@NotNull(message = "评论 ID 不能为空")
private Long commentId;
}

View File

@@ -0,0 +1,33 @@
package com.hanserwei.hannote.comment.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 PublishCommentReqVO {
@NotNull(message = "笔记 ID 不能为空")
private Long noteId;
/**
* 评论内容
*/
private String content;
/**
* 评论图片链接
*/
private String imageUrl;
/**
* 回复的哪个评论(评论 ID
*/
private Long replyCommentId;
}

View File

@@ -0,0 +1,18 @@
package com.hanserwei.hannote.comment.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 UnLikeCommentReqVO {
@NotNull(message = "评论 ID 不能为空")
private Long commentId;
}

View File

@@ -0,0 +1,87 @@
package com.hanserwei.hannote.comment.biz.retry;
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.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.retry.RetryCallback;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Component
@Slf4j
public class SendMqRetryHelper {
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource
private RetryTemplate retryTemplate;
@Resource(name = "taskExecutor")
private ThreadPoolTaskExecutor threadPoolTaskExecutor;
/**
* 异步发送 MQ
*
* @param topic MQ topic
*/
public void asyncSend(String topic, String body) {
log.info("==> 开始异步发送 MQ, Topic: {}, Body: {}", topic, body);
// 构建消息对象,并将 DTO 转成 Json 字符串设置到消息体中
Message<String> message = MessageBuilder.withPayload(body)
.build();
// 异步发送 MQ 消息,提升接口响应速度
rocketMQTemplate.asyncSend(topic, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【评论发布】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【评论发布】MQ 发送异常: ", throwable);
handleRetry(topic, message);
}
});
}
/**
* 重试处理
*
* @param topic MQ topic
* @param message 消息对象
*/
private void handleRetry(String topic, Message<String> message) {
// 异步处理
threadPoolTaskExecutor.submit(() -> {
try {
// 通过 retryTemplate 执行重试
retryTemplate.execute((RetryCallback<Void, RuntimeException>) context -> {
log.info("==> 开始重试 MQ 发送, 当前重试次数: {}, 时间: {}", context.getRetryCount() + 1, LocalDateTime.now());
// 同步发送 MQ
rocketMQTemplate.syncSend(topic, message);
return null;
});
} catch (Exception e) {
// 多次重试失败,进入兜底方案
fallback(e, topic, message.getPayload());
}
});
}
/**
* 兜底方案: 将发送失败的 MQ 写入数据库,之后,通过定时任务扫表,将发送失败的 MQ 再次发送,最终发送成功后,将该记录物理删除
*/
private void fallback(Exception e, String topic, String bodyJson) {
log.error("==> 多次发送失败, 进入兜底方案, Topic: {}, bodyJson: {}", topic, bodyJson);
// TODO:
}
}

View File

@@ -0,0 +1,22 @@
package com.hanserwei.hannote.comment.biz.rpc;
import com.hanserwei.hannote.distributed.id.generator.api.DistributedIdGeneratorFeignApi;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
@Component
public class DistributedIdGeneratorRpcService {
@Resource
private DistributedIdGeneratorFeignApi distributedIdGeneratorFeignApi;
/**
* 生成评论 ID
*
* @return 评论 ID
*/
public String generateCommentId() {
return distributedIdGeneratorFeignApi.getSegmentId("leaf-segment-comment-id");
}
}

View File

@@ -0,0 +1,107 @@
package com.hanserwei.hannote.comment.biz.rpc;
import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.hanserwei.framework.common.constant.DateConstants;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.comment.biz.model.bo.CommentBO;
import com.hanserwei.hannote.kv.api.KeyValueFeignApi;
import com.hanserwei.hannote.kv.dto.req.*;
import com.hanserwei.hannote.kv.dto.resp.FindCommentContentRspDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
@Component
public class KeyValueRpcService {
@Resource
private KeyValueFeignApi keyValueFeignApi;
/**
* 批量存储评论内容
*
* @param commentBOS 评论 BO
* @return 批量保存结果
*/
public boolean batchSaveCommentContent(List<CommentBO> commentBOS) {
List<CommentContentReqDTO> comments = Lists.newArrayList();
// BO 转 DTO
commentBOS.forEach(commentBO -> {
CommentContentReqDTO commentContentReqDTO = CommentContentReqDTO.builder()
.noteId(commentBO.getNoteId())
.content(commentBO.getContent())
.contentId(commentBO.getContentUuid())
.yearMonth(commentBO.getCreateTime().format(DateConstants.DATE_FORMAT_Y_M))
.build();
comments.add(commentContentReqDTO);
});
// 构建接口入参实体类
BatchAddCommentContentReqDTO batchAddCommentContentReqDTO = BatchAddCommentContentReqDTO.builder()
.comments(comments)
.build();
// 调用 KV 存储服务
Response<?> response = keyValueFeignApi.batchAddCommentContent(batchAddCommentContentReqDTO);
// 若返参中 success 为 false, 则主动抛出异常,以便调用层回滚事务
if (!response.isSuccess()) {
throw new RuntimeException("批量保存评论内容失败");
}
return true;
}
/**
* 批量查询评论内容
*
* @param noteId 笔记ID
* @param findCommentContentReqDTOS 查询参数
* @return 批量查询结果
*/
public List<FindCommentContentRspDTO> batchFindCommentContent(Long noteId, List<FindCommentContentReqDTO> findCommentContentReqDTOS) {
BatchFindCommentContentReqDTO bathFindCommentContentReqDTO = BatchFindCommentContentReqDTO.builder()
.noteId(noteId)
.commentContentKeys(findCommentContentReqDTOS)
.build();
Response<List<FindCommentContentRspDTO>> response = keyValueFeignApi.batchFindCommentContent(bathFindCommentContentReqDTO);
if (!response.isSuccess() || Objects.isNull(response.getData()) || CollUtil.isEmpty(response.getData())) {
return null;
}
return response.getData();
}
/**
* 删除评论内容
*
* @param noteId 笔记ID
* @param createTime 创建时间
* @param contentId 评论内容ID
* @return 是否成功
*/
public boolean deleteCommentContent(Long noteId, LocalDateTime createTime, String contentId) {
DeleteCommentContentReqDTO deleteCommentContentReqDTO = DeleteCommentContentReqDTO.builder()
.noteId(noteId)
.yearMonth(DateConstants.DATE_FORMAT_Y_M.format(createTime))
.contentId(contentId)
.build();
// 调用 KV 存储服务
Response<?> response = keyValueFeignApi.deleteCommentContent(deleteCommentContentReqDTO);
if (!response.isSuccess()) {
throw new RuntimeException("删除评论内容失败");
}
return true;
}
}

View File

@@ -0,0 +1,45 @@
package com.hanserwei.hannote.comment.biz.rpc;
import cn.hutool.core.collection.CollUtil;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.user.api.UserFeignApi;
import com.hanserwei.hannote.user.dto.req.FindUsersByIdsReqDTO;
import com.hanserwei.hannote.user.dto.resp.FindUserByIdRspDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Component
public class UserRpcService {
@Resource
private UserFeignApi userFeignApi;
/**
* 批量查询用户信息
*
* @param userIds 用户 ID集合
* @return 用户信息集合
*/
public List<FindUserByIdRspDTO> findByIds(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return null;
}
FindUsersByIdsReqDTO findUsersByIdsReqDTO = new FindUsersByIdsReqDTO();
// 去重, 并设置用户 ID 集合
findUsersByIdsReqDTO.setIds(userIds.stream().distinct().collect(Collectors.toList()));
Response<List<FindUserByIdRspDTO>> response = userFeignApi.findByIds(findUsersByIdsReqDTO);
if (!response.isSuccess() || Objects.isNull(response.getData()) || CollUtil.isEmpty(response.getData())) {
return null;
}
return response.getData();
}
}

View File

@@ -0,0 +1,9 @@
package com.hanserwei.hannote.comment.biz.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO;
public interface CommentLikeService extends IService<CommentLikeDO> {
}

View File

@@ -0,0 +1,64 @@
package com.hanserwei.hannote.comment.biz.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.hanserwei.framework.common.response.PageResponse;
import com.hanserwei.framework.common.response.Response;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO;
import com.hanserwei.hannote.comment.biz.model.vo.*;
public interface CommentService extends IService<CommentDO> {
/**
* 发布评论
*
* @param publishCommentReqVO 发布评论请求
* @return 响应
*/
Response<?> publishComment(PublishCommentReqVO publishCommentReqVO);
/**
* 评论列表分页查询
*
* @param findCommentPageListReqVO 评论列表分页查询参数
* @return 响应
*/
PageResponse<FindCommentItemRspVO> findCommentPageList(FindCommentPageListReqVO findCommentPageListReqVO);
/**
* 二级评论分页查询
*
* @param findChildCommentPageListReqVO 二级评论分页查询参数
* @return 响应
*/
PageResponse<FindChildCommentItemRspVO> findChildCommentPageList(FindChildCommentPageListReqVO findChildCommentPageListReqVO);
/**
* 评论点赞
*
* @param likeCommentReqVO 评论点赞请求
* @return 响应
*/
Response<?> likeComment(LikeCommentReqVO likeCommentReqVO);
/**
* 取消评论点赞
*
* @param unLikeCommentReqVO 取消评论点赞请求
* @return 响应
*/
Response<?> unlikeComment(UnLikeCommentReqVO unLikeCommentReqVO);
/**
* 删除评论
*
* @param deleteCommentReqVO 删除评论请求
* @return 响应
*/
Response<?> deleteComment(DeleteCommentReqVO deleteCommentReqVO);
/**
* 删除本地评论缓存
*
* @param commentId 评论ID
*/
void deleteCommentLocalCache(Long commentId);
}

View File

@@ -0,0 +1,12 @@
package com.hanserwei.hannote.comment.biz.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO;
import com.hanserwei.hannote.comment.biz.domain.mapper.CommentLikeDOMapper;
import com.hanserwei.hannote.comment.biz.service.CommentLikeService;
import org.springframework.stereotype.Service;
@Service
public class CommentLikeServiceImpl extends ServiceImpl<CommentLikeDOMapper, CommentLikeDO> implements CommentLikeService {
}

View File

@@ -0,0 +1,40 @@
package com.hanserwei.hannote.comment.biz.utils;
import java.math.BigDecimal;
import java.math.RoundingMode;
public class HeatCalculator {
// 热度计算的权重配置
private static final double LIKE_WEIGHT = 0.7; // 点赞权重 70%
private static final double REPLY_WEIGHT = 0.3; // 回复权重 30%
public static BigDecimal calculateHeat(long likeCount, long replyCount) {
// 点赞数权重 70%,被回复数权重 30%
BigDecimal likeWeight = new BigDecimal(LIKE_WEIGHT);
BigDecimal replyWeight = new BigDecimal(REPLY_WEIGHT);
// 转换点赞数和回复数为 BigDecimal
BigDecimal likeCountBD = new BigDecimal(likeCount);
BigDecimal replyCountBD = new BigDecimal(replyCount);
// 计算热度 (点赞数*点赞权重 + 回复数*回复权重)
BigDecimal heat = likeCountBD.multiply(likeWeight).add(replyCountBD.multiply(replyWeight));
// 四舍五入保留两位小数
return heat.setScale(2, RoundingMode.HALF_UP);
}
public static void main(String[] args) {
int likeCount = 150; // 点赞数
int replyCount = 10; // 被回复数
// 计算热度
BigDecimal heat = calculateHeat(likeCount, replyCount);
// 输出热度值
System.out.println("Calculated Heat: " + heat);
}
}

View File

@@ -0,0 +1,21 @@
server:
port: 8093 # 项目启动的端口
spring:
profiles:
active: dev # 默认激活 dev 本地开发环境
servlet:
multipart:
max-file-size: 20MB # 单个文件最大大小
max-request-size: 100MB # 单次请求最大大小(包含多个文件)
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
global-config:
banner: false
mapper-locations: classpath*:/mapperxml/*.xml
retry:
max-attempts: 3 # 最大重试次数
init-interval: 1000 # 初始延迟时间,单位 ms
multiplier: 2 # 每次重试间隔加倍(每次乘以 2

View File

@@ -0,0 +1,19 @@
spring:
application:
name: han-note-comment # 应用名称
profiles:
active: dev # 默认激活 dev 本地开发环境
cloud:
nacos:
discovery:
enabled: true # 启用服务发现
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,58 @@
<configuration>
<!-- 引用 Spring Boot 的 logback 基础配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 应用名称 -->
<property scope="context" name="appName" value="comment"/>
<!-- 自定义日志输出路径,以及日志名称前缀 -->
<property name="LOG_FILE" value="./logs/${appName}.%d{yyyy-MM-dd}"/>
<!-- 每行日志输出的格式 -->
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/>
<!-- 文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 日志文件的命名格式 -->
<fileNamePattern>${LOG_FILE}-%i.log</fileNamePattern>
<!-- 保留 30 天的日志文件 -->
<maxHistory>30</maxHistory>
<!-- 单个日志文件最大大小 -->
<maxFileSize>10MB</maxFileSize>
<!-- 日志文件的总大小0 表示不限制 -->
<totalSizeCap>0</totalSizeCap>
<!-- 重启服务时,是否清除历史日志,不推荐清理 -->
<cleanHistoryOnStart>false</cleanHistoryOnStart>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 异步写入日志,提升性能 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<!-- 是否丢弃日志, 0 表示不丢弃。默认情况下,如果队列满 80%, 会丢弃 TRACE、DEBUG、INFO 级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 队列大小。默认值为 256 -->
<queueSize>256</queueSize>
<appender-ref ref="FILE"/>
</appender>
<!-- 本地 dev 开发环境 -->
<springProfile name="dev">
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/> <!-- 输出控制台日志 -->
<appender-ref ref="ASYNC_FILE"/> <!-- 打印日志到文件中。PS: 本地环境下,如果不想打印日志到文件,可注释掉此行 -->
</root>
</springProfile>
<!-- 其它环境 -->
<springProfile name="prod">
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<root level="INFO">
<appender-ref ref="ASYNC_FILE"/> <!-- 生产环境下,仅打印日志到文件中 -->
</root>
</springProfile>
</configuration>

View File

@@ -0,0 +1,33 @@
-- 操作的 Key
local zsetKey = KEYS[1]
-- 获取传入的成员和分数列表
local membersScores = ARGV
-- ZSet 最多缓存 500 条评论
local sizeLimit = 500
-- 检查 ZSet 是否存在
if redis.call('EXISTS', zsetKey) == 0 then
return -1 -- 若不存在,直接 return
end
-- 获取当前 ZSet 的大小
local currentSize = redis.call('ZCARD', zsetKey)
-- 遍历传入的成员和分数,添加到 ZSet 中
for i = 1, #membersScores, 2 do
-- 评论 ID
local member = membersScores[i]
-- 热度值
local score = membersScores[i + 1]
-- 检查当前 ZSet 的大小是否小于 500 条
if currentSize < sizeLimit then
-- 若是,则添加缓存
redis.call('ZADD', zsetKey, score, member)
currentSize = currentSize + 1 -- 更新 ZSet 大小
else
break -- 否则,则达到最大限制,停止添加
end
end
return 0

View File

@@ -0,0 +1,10 @@
-- 操作的 Key
local key = KEYS[1]
local commentId = ARGV[1] -- 评论ID
local expireSeconds = ARGV[2] -- 过期时间(秒)
redis.call("BF.ADD", key, commentId)
-- 设置过期时间
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 commentId = 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, commentId)
if isLiked == 1 then
return 1
end
-- 未被点赞,添加点赞数据
redis.call('BF.ADD', key, commentId)
return 0

View File

@@ -0,0 +1,11 @@
local key = KEYS[1] -- 操作的 Redis Key
local commentId = ARGV[1] -- 评论ID
-- 使用 EXISTS 命令检查布隆过滤器是否存在
local exists = redis.call('EXISTS', key)
if exists == 0 then
return -1
end
-- 校验该评论是否被点赞过(1 表示已经点赞0 表示未点赞)
return redis.call('BF.EXISTS', key, commentId)

View File

@@ -0,0 +1,40 @@
-- 入参说明:
-- KEYS[1]: ZSet 的键
-- ARGV: 每个评论的数据,格式为 member1, score1, member2, score2 ...
local zsetKey = KEYS[1]
local maxSize = 500 -- 最多缓存 500 条热点评论
local batchSize = #ARGV / 2 -- 有多少条评论
-- 确认 ZSet 是否存在
if redis.call("EXISTS", zsetKey) == 0 then
return -1 -- 如果 ZSet 不存在,直接返回
end
for i = 1, batchSize do
local member = ARGV[(i - 1) * 2 + 1] -- 获取当前评论 ID
local score = ARGV[(i - 1) * 2 + 2] -- 获取当前评论的热度
-- 获取 ZSet 的大小
local currentSize = redis.call("ZCARD", zsetKey)
if currentSize < maxSize then
-- 如果 ZSet 的大小小于 maxSize直接添加
redis.call("ZADD", zsetKey, score, member)
else
-- 若已缓存 500 条热点评论
-- 获取当前 ZSet 中热度值最小的评论
local minEntry = redis.call("ZRANGE", zsetKey, 0, 0, "WITHSCORES")
-- 热度最小评论的值
local minScore = minEntry[2]
if score > minScore then
-- 如果当前评论的热度大于最小热度,替换掉最小的;否则无视
redis.call("ZREM", zsetKey, minEntry[1])
redis.call("ZADD", zsetKey, score, member)
end
end
end
return 0

View File

@@ -0,0 +1,226 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hanserwei.hannote.comment.biz.domain.mapper.CommentDOMapper">
<resultMap id="BaseResultMap" type="com.hanserwei.hannote.comment.biz.domain.dataobject.CommentDO">
<!--@mbg.generated-->
<!--@Table t_comment-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="note_id" jdbcType="BIGINT" property="noteId" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="content_uuid" jdbcType="VARCHAR" property="contentUuid" />
<result column="is_content_empty" jdbcType="BIT" property="isContentEmpty" />
<result column="image_url" jdbcType="VARCHAR" property="imageUrl" />
<result column="level" jdbcType="TINYINT" property="level" />
<result column="reply_total" jdbcType="BIGINT" property="replyTotal" />
<result column="like_total" jdbcType="BIGINT" property="likeTotal" />
<result column="parent_id" jdbcType="BIGINT" property="parentId" />
<result column="reply_comment_id" jdbcType="BIGINT" property="replyCommentId" />
<result column="reply_user_id" jdbcType="BIGINT" property="replyUserId" />
<result column="is_top" jdbcType="TINYINT" property="isTop" />
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime" />
<result column="child_comment_total" jdbcType="BIGINT" property="childCommentTotal"/>
<result column="heat" jdbcType="DOUBLE" property="heat"/>
<result column="first_reply_comment_id" jdbcType="BIGINT" property="firstReplyCommentId"/>
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id,
note_id,
user_id,
content_uuid,
is_content_empty,
image_url,
`level`,
reply_total,
like_total,
parent_id,
reply_comment_id,
reply_user_id,
is_top,
create_time,
update_time,
child_comment_total,
heat,
first_reply_comment_id
</sql>
<select id="selectByCommentIds" resultMap="BaseResultMap" parameterType="list">
select id,
user_id,
content_uuid,
is_content_empty,
image_url,
like_total,
is_top,
create_time,
first_reply_comment_id,
child_comment_total,
level,
parent_id,
heat,
note_id
from t_comment
where id in
<foreach collection="commentIds" open="(" separator="," close=")" item="commentId">
#{commentId}
</foreach>
</select>
<insert id="batchInsert" parameterType="list">
insert IGNORE into t_comment (id, note_id, user_id,
content_uuid, is_content_empty, image_url,
`level`, reply_total, like_total,
parent_id, reply_comment_id, reply_user_id,
is_top, create_time, update_time)
values
<foreach collection="comments" item="comment" separator=",">
( #{comment.id}, #{comment.noteId}, #{comment.userId}, #{comment.contentUuid}, #{comment.isContentEmpty}
, #{comment.imageUrl}, #{comment.level}, #{comment.replyTotal}, #{comment.likeTotal}, #{comment.parentId}
, #{comment.replyCommentId}, #{comment.replyUserId}, #{comment.isTop}, #{comment.createTime}
, #{comment.updateTime})
</foreach>
</insert>
<update id="batchUpdateHeatByCommentIds" parameterType="map">
UPDATE t_comment
SET heat = CASE id
<foreach collection="commentHeatBOS" item="bo" separator="">
WHEN #{bo.id} THEN #{bo.heat}
</foreach>
ELSE heat END
WHERE id IN
<foreach close=")" collection="commentIds" item="commentId" open="(" separator=",">
#{commentId}
</foreach>
</update>
<select id="selectEarliestByParentId" parameterType="map" resultMap="BaseResultMap">
select id
from t_comment
where parent_id = #{parentId}
and level = 2
order by create_time
limit 1
</select>
<update id="updateFirstReplyCommentIdByPrimaryKey" parameterType="map">
update t_comment
set first_reply_comment_id = #{firstReplyCommentId}
where id = #{id}
</update>
<select id="selectPageList" resultMap="BaseResultMap" parameterType="map">
select id,
user_id,
content_uuid,
is_content_empty,
image_url,
like_total,
is_top,
create_time,
first_reply_comment_id,
child_comment_total,
heat
from t_comment
where note_id = #{noteId}
and level = 1
order by heat desc
limit #{offset}, #{pageSize}
</select>
<select id="selectTwoLevelCommentByIds" resultMap="BaseResultMap" parameterType="list">
select id,
user_id,
content_uuid,
is_content_empty,
image_url,
like_total,
create_time,
heat
from t_comment
where id in
<foreach collection="commentIds" open="(" separator="," close=")" item="commentId">
#{commentId}
</foreach>
</select>
<select id="selectHeatComments" resultMap="BaseResultMap">
select id, heat
from t_comment
where note_id = #{noteId}
and level = 1
order by heat desc
limit 500
</select>
<select id="selectChildCommentTotalById" resultType="long">
select child_comment_total
from t_comment
where id = #{commentId}
and level = 1
</select>
<select id="selectChildPageList" resultMap="BaseResultMap" parameterType="map">
select id,
user_id,
note_id,
content_uuid,
is_content_empty,
image_url,
like_total,
create_time,
reply_user_id,
parent_id,
reply_comment_id
from t_comment
where parent_id = #{parentId}
and level = 2
order by id
limit #{offset}, #{pageSize}
</select>
<select id="selectCommentCountByIds" resultMap="BaseResultMap" parameterType="list">
select id,
child_comment_total,
like_total,
level
from t_comment
where id in
<foreach collection="commentIds" open="(" separator="," close=")" item="commentId">
#{commentId}
</foreach>
</select>
<select id="selectChildCommentsByParentIdAndLimit" resultMap="BaseResultMap" parameterType="map">
select id, create_time
from t_comment
where parent_id = #{parentId}
and level = 2
order by create_time
limit #{limit}
</select>
<delete id="deleteByParentId" parameterType="long">
delete
from t_comment
where parent_id = #{commentId}
</delete>
<delete id="deleteByIds" parameterType="map">
delete
from t_comment
where id in
<foreach collection="commentIds" item="commentId" open="(" separator="," close=")">
#{commentId}
</foreach>
</delete>
<select id="selectByReplyCommentId" resultMap="BaseResultMap" parameterType="long">
select
<include refid="Base_Column_List"/>
from t_comment
where reply_comment_id = #{commentId}
</select>
</mapper>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hanserwei.hannote.comment.biz.domain.mapper.CommentLikeDOMapper">
<resultMap id="BaseResultMap" type="com.hanserwei.hannote.comment.biz.domain.dataobject.CommentLikeDO">
<!--@mbg.generated-->
<!--@Table t_comment_like-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="comment_id" jdbcType="BIGINT" property="commentId" />
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, comment_id, create_time
</sql>
<select id="selectCountByUserIdAndCommentId" resultType="int" parameterType="map">
select count(1)
from t_comment_like
where user_id = #{userId}
and comment_id = #{commentId}
limit 1
</select>
<select id="selectByUserId" resultMap="BaseResultMap" parameterType="map">
select comment_id
from t_comment_like
where user_id = #{userId}
</select>
<delete id="batchDelete" parameterType="map">
DELETE
FROM t_comment_like
WHERE (comment_id, user_id) IN
<foreach collection="unlikes" item="unlike" open="(" separator="," close=")">
(#{unlike.commentId}, #{unlike.userId})
</foreach>
</delete>
<insert id="batchInsert" parameterType="list">
INSERT INTO t_comment_like (comment_id, user_id, create_time)
VALUES
<foreach collection="likes" item="like" separator=",">
(#{like.commentId}, #{like.userId}, #{like.createTime})
</foreach>
ON DUPLICATE KEY UPDATE id=id
</insert>
</mapper>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hanserwei.hannote.comment.biz.domain.mapper.NoteCountDOMapper">
<resultMap id="BaseResultMap" type="com.hanserwei.hannote.comment.biz.domain.dataobject.NoteCountDO">
<!--@mbg.generated-->
<!--@Table t_note_count-->
<id column="id" jdbcType="BIGINT" property="id"/>
<result column="note_id" jdbcType="BIGINT" property="noteId"/>
<result column="like_total" jdbcType="BIGINT" property="likeTotal"/>
<result column="collect_total" jdbcType="BIGINT" property="collectTotal"/>
<result column="comment_total" jdbcType="BIGINT" property="commentTotal"/>
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, note_id, like_total, collect_total, comment_total
</sql>
<select id="selectCommentTotalByNoteId" resultType="long">
select comment_total
from t_note_count
where note_id = #{noteId}
</select>
<update id="updateCommentTotalByNoteId" parameterType="map">
update t_note_count
set comment_total = comment_total + #{count}
where note_id = #{noteId}
</update>
</mapper>

View File

@@ -0,0 +1,46 @@
package com.hanserwei.hannote.comment.biz;
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;
@SpringBootTest
@Slf4j
class MQTests {
@Resource
private RocketMQTemplate rocketMQTemplate;
/**
* 测试:模拟发送评论发布消息
*/
@Test
void testBatchSendMQ() {
for (long i = 0; i < 1620; i++) {
// 构建消息对象
Message<String> message = MessageBuilder.withPayload("消息体数据")
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend("PublishCommentTopic", message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【评论发布】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【评论发布】MQ 发送异常: ", throwable);
}
});
}
}
}

25
han-note-comment/pom.xml Normal file
View File

@@ -0,0 +1,25 @@
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.hanserwei</groupId>
<artifactId>han-note</artifactId>
<version>${revision}</version>
</parent>
<!-- 多模块项目需要配置打包方式为 pom -->
<packaging>pom</packaging>
<!-- 子模块管理 -->
<modules>
<module>han-note-comment-api</module>
<module>han-note-comment-biz</module>
</modules>
<artifactId>han-note-comment</artifactId>
<!-- 项目名称 -->
<name>${project.artifactId}</name>
<!-- 项目描述 -->
<description>评论服务</description>
</project>

View File

@@ -0,0 +1,25 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-count</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>han-note-count-api</artifactId>
<name>${project.artifactId}</name>
<description>RPC层, 供其他服务调用</description>
<dependencies>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-common</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,117 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 指定父项目 -->
<parent>
<groupId>com.hanserwei</groupId>
<artifactId>han-note-count</artifactId>
<version>${revision}</version>
</parent>
<!-- 打包方式 -->
<packaging>jar</packaging>
<artifactId>han-note-count-biz</artifactId>
<name>${project.artifactId}</name>
<description>计数服务业务模块</description>
<dependencies>
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-common</artifactId>
</dependency>
<!-- 业务接口日志组件 -->
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-spring-boot-starter-biz-operationlog</artifactId>
</dependency>
<!-- 上下文组件 -->
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-spring-boot-starter-biz-context</artifactId>
</dependency>
<!-- Jackson 组件 -->
<dependency>
<groupId>com.hanserwei</groupId>
<artifactId>hanserwei-spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!-- 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- Mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- Druid 数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
</dependency>
<!-- 提供 Redis 连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Rocket MQ -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
<!-- 快手 Buffer Trigger -->
<dependency>
<groupId>com.github.phantomthief</groupId>
<artifactId>buffer-trigger</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,13 @@
package com.hanserwei.hannote.count.biz;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.hanserwei.hannote.count.biz.domain.mapper")
public class HannoteCountBizApplication {
public static void main(String[] args) {
SpringApplication.run(HannoteCountBizApplication.class, args);
}
}

View File

@@ -0,0 +1,31 @@
package com.hanserwei.hannote.count.biz.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置 RedisTemplate 的连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 使用 StringRedisSerializer 来序列化和反序列化 redis 的 key 值,确保 key 是可读的字符串
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
// 使用 Jackson2JsonRedisSerializer 来序列化和反序列化 redis 的 value 值, 确保存储的是 JSON 格式
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashValueSerializer(serializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}

View File

@@ -0,0 +1,10 @@
package com.hanserwei.hannote.count.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,80 @@
package com.hanserwei.hannote.count.biz.constant;
public interface MQConstants {
/**
* Topic: 笔记评论总数计数
*/
String TOPIC_COUNT_NOTE_COMMENT = "CountNoteCommentTopic";
/**
* Topic: 评论热度值更新
*/
String TOPIC_COMMENT_HEAT_UPDATE = "CommentHeatUpdateTopic";
/**
* Topic: 计数 - 笔记点赞数
*/
String TOPIC_LIKE_OR_UNLIKE = "LikeUnlikeTopic";
/**
* Topic: 笔记收藏、取消收藏
*/
String TOPIC_COLLECT_OR_UN_COLLECT = "CollectUnCollectTopic";
/**
* Topic: 关注数计数
*/
String TOPIC_COUNT_FOLLOWING = "CountFollowingTopic";
/**
* Topic: 粉丝数计数
*/
String TOPIC_COUNT_FANS = "CountFansTopic";
/**
* Topic: 粉丝数计数入库
*/
String TOPIC_COUNT_FANS_2_DB = "CountFans2DBTopic";
/**
* Topic: 粉丝数计数入库
*/
String TOPIC_COUNT_FOLLOWING_2_DB = "CountFollowing2DBTopic";
/**
* Topic: 计数 - 笔记点赞数落库
*/
String TOPIC_COUNT_NOTE_LIKE_2_DB = "CountNoteLike2DBTTopic";
/**
* Topic: 计数 - 笔记收藏数落库
*/
String TOPIC_COUNT_NOTE_COLLECT_2_DB = "CountNoteCollect2DBTTopic";
/**
* Topic: 笔记操作(发布、删除)
*/
String TOPIC_NOTE_OPERATE = "NoteOperateTopic";
/**
* Topic: 评论点赞数更新
*/
String TOPIC_COMMENT_LIKE_OR_UNLIKE = "CommentLikeUnlikeTopic";
/**
* Topic: 计数 - 评论点赞数落库
*/
String TOPIC_COUNT_COMMENT_LIKE_2_DB = "CountCommentLike2DBTTopic";
/**
* Tag 标签:笔记发布
*/
String TAG_NOTE_PUBLISH = "publishNote";
/**
* Tag 标签:笔记删除
*/
String TAG_NOTE_DELETE = "deleteNote";
}

View File

@@ -0,0 +1,84 @@
package com.hanserwei.hannote.count.biz.constant;
public class RedisKeyConstants {
/**
* Hash Field: 粉丝总数
*/
public static final String FIELD_FANS_TOTAL = "fansTotal";
/**
* Hash Field: 关注总数
*/
public static final String FIELD_FOLLOWING_TOTAL = "followingTotal";
/**
* Hash Field: 笔记发布总数
*/
public static final String FIELD_NOTE_TOTAL = "noteTotal";
/**
* 用户维度计数 Key 前缀
*/
private static final String COUNT_USER_KEY_PREFIX = "count:user:";
/**
* Hash Field: 笔记点赞总数
*/
public static final String FIELD_LIKE_TOTAL = "likeTotal";
/**
* 笔记维度计数 Key 前缀
*/
private static final String COUNT_NOTE_KEY_PREFIX = "count:note:";
/**
* Hash Field: 笔记评论总数
*/
public static final String FIELD_COMMENT_TOTAL = "commentTotal";
/**
* Hash Field: 笔记收藏总数
*/
public static final String FIELD_COLLECT_TOTAL = "collectTotal";
/**
* Hash Field: 子评论总数
*/
public static final String FIELD_CHILD_COMMENT_TOTAL = "childCommentTotal";
/**
* 评论维度计数 Key 前缀
*/
private static final String COUNT_COMMENT_KEY_PREFIX = "count:comment:";
/**
* 构建评论维度计数 Key
*
* @param commentId 评论ID
* @return 评论维度计数 Key
*/
public static String buildCountCommentKey(Long commentId) {
return COUNT_COMMENT_KEY_PREFIX + commentId;
}
/**
* 构建用户维度计数 Key
*
* @param userId 用户ID
* @return 用户维度计数 Key
*/
public static String buildCountUserKey(Long userId) {
return COUNT_USER_KEY_PREFIX + userId;
}
/**
* 构建笔记维度计数 Key
*
* @param noteId 笔记ID
* @return 笔记维度计数 Key
*/
public static String buildCountNoteKey(Long noteId) {
return COUNT_NOTE_KEY_PREFIX + noteId;
}
}

View File

@@ -0,0 +1,55 @@
package com.hanserwei.hannote.count.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.count.biz.model.dto.AggregationCountLikeUnlikeCommentMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
import java.util.List;
@SuppressWarnings("ALL")
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_COMMENT_LIKE_2_DB, // Group 组
topic = MQConstants.TOPIC_COUNT_COMMENT_LIKE_2_DB // 主题 Topic
)
@Slf4j
public class CountCommentLike2DBConsumer implements RocketMQListener<String> {
// 每秒创建 5000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(5000);
@Resource
private CommentDOMapper commentDOMapper;
@Override
public void onMessage(String body) {
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
rateLimiter.acquire();
log.info("## 消费到了 MQ 【计数: 评论点赞数入库】, {}...", body);
List<AggregationCountLikeUnlikeCommentMqDTO> countList = null;
try {
countList = JsonUtils.parseList(body, AggregationCountLikeUnlikeCommentMqDTO.class);
} catch (Exception e) {
log.error("## 解析 JSON 字符串异常", e);
}
if (CollUtil.isNotEmpty(countList)) {
// 更新评论点赞数
countList.forEach(item -> {
Long commentId = item.getCommentId();
Integer count = item.getCount();
commentDOMapper.updateLikeTotalByCommentId(count, commentId);
});
}
}
}

View File

@@ -0,0 +1,138 @@
package com.hanserwei.hannote.count.biz.consumer;
import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Lists;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.enums.LikeUnlikeCommentTypeEnum;
import com.hanserwei.hannote.count.biz.model.dto.AggregationCountLikeUnlikeCommentMqDTO;
import com.hanserwei.hannote.count.biz.model.dto.CountLikeUnlikeCommentMqDTO;
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.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_count_" + MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE, // Group 组
topic = MQConstants.TOPIC_COMMENT_LIKE_OR_UNLIKE // 主题 Topic
)
@Slf4j
public class CountCommentLikeConsumer implements RocketMQListener<String> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
.linger(Duration.ofSeconds(1)) // 多久聚合一次
.setConsumerEx(this::consumeMessage) // 设置消费者方法
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> bodys) {
log.info("==> 【评论点赞数】聚合消息, size: {}", bodys.size());
log.info("==> 【评论点赞数】聚合消息, {}", JsonUtils.toJsonString(bodys));
// List<String> 转 List<CountLikeUnlikeCommentMqDTO>
List<CountLikeUnlikeCommentMqDTO> countLikeUnlikeCommentMqDTOS = bodys.stream()
.map(body -> JsonUtils.parseObject(body, CountLikeUnlikeCommentMqDTO.class)).toList();
// 按评论 ID 进行分组
Map<Long, List<CountLikeUnlikeCommentMqDTO>> groupMap = countLikeUnlikeCommentMqDTOS.stream()
.collect(Collectors.groupingBy(CountLikeUnlikeCommentMqDTO::getCommentId));
// 按组汇总数据,统计出最终的计数
// 最终操作的计数对象
List<AggregationCountLikeUnlikeCommentMqDTO> countList = Lists.newArrayList();
for (Map.Entry<Long, List<CountLikeUnlikeCommentMqDTO>> entry : groupMap.entrySet()) {
// 评论 ID
Long commentId = entry.getKey();
List<CountLikeUnlikeCommentMqDTO> list = entry.getValue();
// 最终的计数值,默认为 0
int finalCount = 0;
for (CountLikeUnlikeCommentMqDTO countLikeUnlikeCommentMqDTO : list) {
// 获取操作类型
Integer type = countLikeUnlikeCommentMqDTO.getType();
// 根据操作类型,获取对应枚举
LikeUnlikeCommentTypeEnum likeUnlikeCommentTypeEnum = LikeUnlikeCommentTypeEnum.valueOf(type);
// 若枚举为空,跳到下一次循环
if (Objects.isNull(likeUnlikeCommentTypeEnum)) continue;
switch (likeUnlikeCommentTypeEnum) {
case LIKE -> finalCount += 1; // 如果为点赞操作,点赞数 +1
case UNLIKE -> finalCount -= 1; // 如果为取消点赞操作,点赞数 -1
}
}
// 将分组后统计出的最终计数,存入 countList 中
countList.add(AggregationCountLikeUnlikeCommentMqDTO.builder()
.commentId(commentId)
.count(finalCount)
.build());
}
log.info("## 【评论点赞数】聚合后的计数数据: {}", JsonUtils.toJsonString(countList));
// 更新 Redis
countList.forEach(item -> {
// 评论 ID
Long commentId = item.getCommentId();
// 聚合后的计数
Integer count = item.getCount();
// Redis 中评论计数 Hash Key
String countCommentRedisKey = RedisKeyConstants.buildCountCommentKey(commentId);
// 判断 Redis 中 Hash 是否存在
boolean isCountCommentExisted = redisTemplate.hasKey(countCommentRedisKey);
// 若存在才会更新
// (因为缓存设有过期时间,考虑到过期后,缓存会被删除,这里需要判断一下,存在才会去更新,而初始化工作放在查询计数来做)
if (isCountCommentExisted) {
// 对目标用户 Hash 中的点赞数字段进行计数操作
redisTemplate.opsForHash().increment(countCommentRedisKey, RedisKeyConstants.FIELD_LIKE_TOTAL, count);
}
});
// 发送 MQ, 评论点赞数据落库
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(countList))
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_COMMENT_LIKE_2_DB, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【计数服务评论点赞数写库】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【计数服务评论点赞数写库】MQ 发送异常: ", throwable);
}
});
}
}

View File

@@ -0,0 +1,46 @@
package com.hanserwei.hannote.count.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.UserCountDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
import java.util.Map;
@SuppressWarnings("ALL")
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_FANS_2_DB, // Group 组
topic = MQConstants.TOPIC_COUNT_FANS_2_DB // 主题 Topic
)
@Slf4j
public class CountFans2DBConsumer implements RocketMQListener<String> {
@Resource
private UserCountDOMapper userCountDOMapper;
// 每秒创建 5000 个令牌
private RateLimiter rateLimiter = RateLimiter.create(5000);
@Override
public void onMessage(String body) {
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
rateLimiter.acquire();
log.info("## 消费到了 MQ 【计数: 粉丝数入库】, {}...", body);
Map<Long, Integer> countMap = null;
try {
countMap = JsonUtils.parseMap(body, Long.class, Integer.class);
} catch (Exception e) {
log.error("## 解析 JSON 字符串异常", e);
}
if (CollUtil.isNotEmpty(countMap)) {
// 判断数据库中,若目标用户的记录不存在,则插入;若记录已存在,则直接更新
countMap.forEach((k, v) -> userCountDOMapper.insertOrUpdateFansTotalByUserId(v, k));
}
}
}

View File

@@ -0,0 +1,127 @@
package com.hanserwei.hannote.count.biz.consumer;
import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Maps;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.enums.FollowUnfollowTypeEnum;
import com.hanserwei.hannote.count.biz.model.dto.CountFollowUnfollowMqDTO;
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.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Component
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_FANS,
topic = MQConstants.TOPIC_COUNT_FANS
)
@Slf4j
public class CountFansConsumer implements RocketMQListener<String> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
.linger(Duration.ofSeconds(1)) // 多久聚合一次
.setConsumerEx(this::consumeMessage)
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> body) {
log.info("==> 聚合消息, size: {}", body.size());
log.info("==> 聚合消息, {}", JsonUtils.toJsonString(body));
// List<String> body 转换成 List<CountFollowUnfollowMqDTO>
List<CountFollowUnfollowMqDTO> countFollowUnfollowMqDTOList = body.stream()
.map(e -> JsonUtils.parseObject(e, CountFollowUnfollowMqDTO.class))
.toList();
// 按目标用户进行分组
Map<Long, List<CountFollowUnfollowMqDTO>> groupMap = countFollowUnfollowMqDTOList.stream()
.collect(Collectors.groupingBy(CountFollowUnfollowMqDTO::getTargetUserId));
// 按组汇聚数据,统计出最终数据
Map<Long, Integer> countMap = Maps.newHashMap();
for (Map.Entry<Long, List<CountFollowUnfollowMqDTO>> entry : groupMap.entrySet()) {
List<CountFollowUnfollowMqDTO> list = entry.getValue();
// 最终数据
int finalCount = 0;
for (CountFollowUnfollowMqDTO countFollowUnfollowMqDTO : list) {
// 获取操作类型
Integer type = countFollowUnfollowMqDTO.getType();
// 根据操作类型,获取对应枚举
FollowUnfollowTypeEnum followUnfollowTypeEnum = FollowUnfollowTypeEnum.valueOf(type);
// 若枚举类型为空,则跳过
if (Objects.isNull(followUnfollowTypeEnum)) {
continue;
}
switch (followUnfollowTypeEnum) {
case FOLLOW -> finalCount++;
case UNFOLLOW -> finalCount--;
}
}
// 将分组后统计出的最终计数,存入 countMap 中
countMap.put(entry.getKey(), finalCount);
}
log.info("## 聚合后的计数数据: {}", JsonUtils.toJsonString(countMap));
// 更新 Redis
countMap.forEach((k, v) -> {
// Redis Key
String redisKey = RedisKeyConstants.buildCountUserKey(k);
// 判断 Redis 中 Hash 是否存在
boolean isExisted = redisTemplate.hasKey(redisKey);
// 若存在才会更新
// (因为缓存设有过期时间,考虑到过期后,缓存会被删除,这里需要判断一下,存在才会去更新,而初始化工作放在查询计数来做)
if (isExisted) {
// 对目标用户 Hash 中的粉丝数字段进行计数操作
redisTemplate.opsForHash().increment(redisKey, RedisKeyConstants.FIELD_FANS_TOTAL, v);
}
});
// 发送 MQ, 计数数据落库
// 构建MQ消息体
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(countMap))
.build();
// 异步发送消息提高接口响应速度
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_FANS_2_DB, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【计数服务粉丝数入库】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【计数服务粉丝数入库】MQ 发送异常: ", throwable);
}
});
}
}

View File

@@ -0,0 +1,54 @@
package com.hanserwei.hannote.count.biz.consumer;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.UserCountDOMapper;
import com.hanserwei.hannote.count.biz.enums.FollowUnfollowTypeEnum;
import com.hanserwei.hannote.count.biz.model.dto.CountFollowUnfollowMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
@SuppressWarnings("ALL")
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_FOLLOWING_2_DB, // Group 组
topic = MQConstants.TOPIC_COUNT_FOLLOWING_2_DB // 主题 Topic
)
@Slf4j
public class CountFollowing2DBConsumer implements RocketMQListener<String> {
@Resource
private UserCountDOMapper userCountDOMapper;
// 每秒创建 5000 个令牌
private RateLimiter rateLimiter = RateLimiter.create(5000);
@Override
public void onMessage(String body) {
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
rateLimiter.acquire();
log.info("## 消费到了 MQ 【计数: 关注数入库】, {}...", body);
if (StringUtils.isBlank(body)) return;
CountFollowUnfollowMqDTO countFollowUnfollowMqDTO = JsonUtils.parseObject(body, CountFollowUnfollowMqDTO.class);
// 操作类型:关注 or 取关
Integer type = countFollowUnfollowMqDTO.getType();
// 原用户ID
Long userId = countFollowUnfollowMqDTO.getUserId();
// 关注数:关注 +1 取关 -1
int count = Objects.equals(type, FollowUnfollowTypeEnum.FOLLOW.getCode()) ? 1 : -1;
// 判断数据库中,若原用户的记录不存在,则插入;若记录已存在,则直接更新
userCountDOMapper.insertOrUpdateFollowingTotalByUserId(count, userId);
}
}

View File

@@ -0,0 +1,82 @@
package com.hanserwei.hannote.count.biz.consumer;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.enums.FollowUnfollowTypeEnum;
import com.hanserwei.hannote.count.biz.model.dto.CountFollowUnfollowMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
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.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_FOLLOWING,
topic = MQConstants.TOPIC_COUNT_FOLLOWING
)
@Slf4j
public class CountFollowingConsumer implements RocketMQListener<String> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Override
public void onMessage(String body) {
log.info("## 消费了 MQ [计数:关注数]: {}", body);
if (StringUtils.isBlank(body)) {
return;
}
// 关注数和粉丝数计数场景不同,单个用户无法短时间内关注大量用户,所以无需聚合
// 直接对 Redis 中的 Hash 进行 +1 或 -1 操作即可
CountFollowUnfollowMqDTO countFollowUnfollowMqDTO = JsonUtils.parseObject(body, CountFollowUnfollowMqDTO.class);
// 操作类型:关注 or 取关
assert countFollowUnfollowMqDTO != null;
Integer type = countFollowUnfollowMqDTO.getType();
// 原用户ID
Long userId = countFollowUnfollowMqDTO.getUserId();
// 更新 Redis
String redisKey = RedisKeyConstants.buildCountUserKey(userId);
// 判断 Hash 是否存在
boolean isExisted = redisTemplate.hasKey(redisKey);
// 若存在
if (isExisted) {
// 关注数:关注 +1 取关 -1
long count = Objects.equals(type, FollowUnfollowTypeEnum.FOLLOW.getCode()) ? 1 : -1;
// 对 Hash 中的 followingTotal 字段进行加减操作
redisTemplate.opsForHash().increment(redisKey, RedisKeyConstants.FIELD_FOLLOWING_TOTAL, count);
}
// 发送 MQ, 关注数写库
// 构建消息对象
Message<String> message = MessageBuilder.withPayload(body)
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_FOLLOWING_2_DB, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【计数服务关注数入库】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【计数服务关注数入库】MQ 发送异常: ", throwable);
}
});
}
}

View File

@@ -0,0 +1,126 @@
package com.hanserwei.hannote.count.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Lists;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.CommentDOMapper;
import com.hanserwei.hannote.count.biz.enums.CommentLevelEnum;
import com.hanserwei.hannote.count.biz.model.dto.CountPublishCommentMqDTO;
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.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_child_comment_total" + MQConstants.TOPIC_COUNT_NOTE_COMMENT, // Group 组
topic = MQConstants.TOPIC_COUNT_NOTE_COMMENT // 主题 Topic
)
@Slf4j
public class CountNoteChildCommentConsumer implements RocketMQListener<String> {
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource
private CommentDOMapper commentDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
.linger(Duration.ofSeconds(1)) // 多久聚合一次1s 一次)
.setConsumerEx(this::consumeMessage) // 设置消费者方法
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> bodys) {
log.info("==> 【笔记二级评论数】聚合消息, size: {}", bodys.size());
log.info("==> 【笔记二级评论数】聚合消息, {}", JsonUtils.toJsonString(bodys));
// 将聚合后的消息体 Json 转 List<CountPublishCommentMqDTO>
List<CountPublishCommentMqDTO> countPublishCommentMqDTOList = Lists.newArrayList();
bodys.forEach(body -> {
try {
List<CountPublishCommentMqDTO> list = JsonUtils.parseList(body, CountPublishCommentMqDTO.class);
countPublishCommentMqDTOList.addAll(list);
} catch (Exception e) {
log.error("", e);
}
});
// 过滤出二级评论,并按 parent_id 分组
Map<Long, List<CountPublishCommentMqDTO>> groupMap = countPublishCommentMqDTOList.stream()
.filter(commentMqDTO -> Objects.equals(CommentLevelEnum.TWO.getCode(), commentMqDTO.getLevel()))
.collect(Collectors.groupingBy(CountPublishCommentMqDTO::getParentId)); // 按 parent_id 分组
// 若无二级评论,则直接 return
if (CollUtil.isEmpty(groupMap)) return;
// 循环分组字典
for (Map.Entry<Long, List<CountPublishCommentMqDTO>> entry : groupMap.entrySet()) {
// 一级评论 ID
Long parentId = entry.getKey();
// 评论数
int count = CollUtil.size(entry.getValue());
// 更新 Redis 缓存中的评论计数数据
// 构建 Key
String commentCountHashKey = RedisKeyConstants.buildCountCommentKey(parentId);
// 判断 Hash 是否存在
boolean hasKey = redisTemplate.hasKey(commentCountHashKey);
// 若 Hash 存在,则更新子评论总数
if (hasKey) {
// 累加
redisTemplate.opsForHash()
.increment(commentCountHashKey, RedisKeyConstants.FIELD_CHILD_COMMENT_TOTAL, count);
}
// 更新一级评论的下级评论总数,进行累加操作
commentDOMapper.updateChildCommentTotal(parentId, count);
}
// 获取字典中所用的评论ID
Set<Long> commentIds = groupMap.keySet();
// 异步发送MQ消息计数更新评论热度值
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(commentIds))
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COMMENT_HEAT_UPDATE, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【评论热度值更新】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【评论热度值更新】MQ 发送异常: ", throwable);
}
});
}
}

View File

@@ -0,0 +1,72 @@
package com.hanserwei.hannote.count.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.NoteCountDOMapper;
import com.hanserwei.hannote.count.biz.domain.mapper.UserCountDOMapper;
import com.hanserwei.hannote.count.biz.model.dto.AggregationCountCollectedUncollectedNoteMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
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.util.List;
@SuppressWarnings("UnstableApiUsage")
@Component
@Slf4j
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_NOTE_COLLECT_2_DB,
topic = MQConstants.TOPIC_COUNT_NOTE_COLLECT_2_DB
)
public class CountNoteCollect2DBConsumer implements RocketMQListener<String> {
// 每秒创建 5000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(5000);
@Resource
private NoteCountDOMapper noteCountDOMapper;
@Resource
private UserCountDOMapper userCountDOMapper;
@Resource
private TransactionTemplate transactionTemplate;
@Override
public void onMessage(String body) {
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
rateLimiter.acquire();
log.info("## 消费到了 MQ 【计数: 笔记收藏数入库】, {}...", body);
List<AggregationCountCollectedUncollectedNoteMqDTO> countList = null;
try {
countList = JsonUtils.parseList(body, AggregationCountCollectedUncollectedNoteMqDTO.class);
} catch (Exception e) {
log.error("## 解析 JSON 字符串异常");
}
if (CollUtil.isNotEmpty(countList)) {
countList.forEach(item -> {
Long creatorId = item.getCreatorId();
Long noteId = item.getNoteId();
Integer count = item.getCount();
// 编程式事务,保证两条语句的原子性
transactionTemplate.execute(status -> {
try {
noteCountDOMapper.insertOrUpdateCollectTotalByNoteId(count, noteId);
userCountDOMapper.insertOrUpdateCollectTotalByUserId(count, creatorId);
return true;
} catch (Exception ex) {
status.setRollbackOnly(); // 标记事务为回滚
log.error("", ex);
}
return false;
});
});
}
}
}

View File

@@ -0,0 +1,141 @@
package com.hanserwei.hannote.count.biz.consumer;
import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Lists;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.enums.CollectUnCollectNoteTypeEnum;
import com.hanserwei.hannote.count.biz.model.dto.AggregationCountCollectedUncollectedNoteMqDTO;
import com.hanserwei.hannote.count.biz.model.dto.CountCollectUnCollectNoteMqDTO;
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.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Component
@Slf4j
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COLLECT_OR_UN_COLLECT,
topic = MQConstants.TOPIC_COLLECT_OR_UN_COLLECT
)
public class CountNoteCollectConsumer implements RocketMQListener<String> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
.linger(Duration.ofSeconds(1)) // 多久聚合一次
.setConsumerEx(this::consumeMessage) // 设置消费者方法
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> bodies) {
log.info("==> 【笔记收藏数】聚合消息, size: {}", bodies.size());
log.info("==> 【笔记收藏数】聚合消息, {}", JsonUtils.toJsonString(bodies));
// List<String> -> List<CountCollectUnCollectNoteMqDTO>
List<CountCollectUnCollectNoteMqDTO> countCollectUnCollectNoteMqDTOS = bodies.stream()
.map(body -> JsonUtils.parseObject(body, CountCollectUnCollectNoteMqDTO.class))
.toList();
// 按笔记ID分组
Map<Long, List<CountCollectUnCollectNoteMqDTO>> groupMap = countCollectUnCollectNoteMqDTOS.stream()
.collect(Collectors.groupingBy(CountCollectUnCollectNoteMqDTO::getNoteId));
// 按组汇总数据,统计出最终的计数
List<AggregationCountCollectedUncollectedNoteMqDTO> countList = Lists.newArrayList();
for (Map.Entry<Long, List<CountCollectUnCollectNoteMqDTO>> entry : groupMap.entrySet()) {
// 笔记 ID
Long noteId = entry.getKey();
// 笔记发布者 ID
Long creatorId = null;
List<CountCollectUnCollectNoteMqDTO> list = entry.getValue();
// 默认计数为0
int finalCount = 0;
for (CountCollectUnCollectNoteMqDTO countCollectUnCollectNoteMqDTO : list) {
Integer type = countCollectUnCollectNoteMqDTO.getType();
creatorId = countCollectUnCollectNoteMqDTO.getNoteCreatorId();
// 获取枚举类
CollectUnCollectNoteTypeEnum collectUnCollectNoteTypeEnum = CollectUnCollectNoteTypeEnum.valueOf(type);
switch (Objects.requireNonNull(collectUnCollectNoteTypeEnum)) {
case COLLECT -> finalCount++;
case UN_COLLECT -> finalCount--;
}
}
// 将分组后统计出的最终计数,存入 countList 中
countList.add(AggregationCountCollectedUncollectedNoteMqDTO.builder()
.noteId(noteId)
.creatorId(creatorId)
.count(finalCount)
.build());
}
log.info("==> 【笔记收藏数】最终结果, {}", JsonUtils.toJsonString(countList));
// 更新 Redis
countList.forEach(item -> {
// 笔记发布者 ID
Long creatorId = item.getCreatorId();
// 笔记 ID
Long noteId = item.getNoteId();
// 聚合后的计数
Integer count = item.getCount();
// 笔记维度计数 Redis Key
String countNoteRedisKey = RedisKeyConstants.buildCountNoteKey(noteId);
// 判断Redis 中 Hash 是否存在
boolean isCountNoteExisted = redisTemplate.hasKey(countNoteRedisKey);
// 若存在才会更新
// (因为缓存设有过期时间,考虑到过期后,缓存会被删除,这里需要判断一下,存在才会去更新,而初始化工作放在查询计数来做)
if (isCountNoteExisted) {
// 对目标用户 Hash 中的点赞数字段进行计数操作
redisTemplate.opsForHash().increment(countNoteRedisKey, RedisKeyConstants.FIELD_COLLECT_TOTAL, count);
}
// 更新 Redis 用户维度收藏数
String countUserRedisKey = RedisKeyConstants.buildCountUserKey(creatorId);
Boolean isCountUserExisted = redisTemplate.hasKey(countUserRedisKey);
if (isCountUserExisted) {
// 对目标用户 Hash 中的收藏数字段进行计数操作
redisTemplate.opsForHash().increment(countUserRedisKey, RedisKeyConstants.FIELD_COLLECT_TOTAL, count);
}
});
// 发送 MQ, 笔记收藏数据落库
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(countList))
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_NOTE_COLLECT_2_DB, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【计数服务笔记收藏数入库】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【计数服务笔记收藏数入库】MQ 发送异常: ", throwable);
}
});
}
}

View File

@@ -0,0 +1,93 @@
package com.hanserwei.hannote.count.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Lists;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.NoteCountDOMapper;
import com.hanserwei.hannote.count.biz.model.dto.CountPublishCommentMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Component
@RocketMQMessageListener(consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_NOTE_COMMENT, // Group 组
topic = MQConstants.TOPIC_COUNT_NOTE_COMMENT // 主题 Topic
)
@Slf4j
public class CountNoteCommentConsumer implements RocketMQListener<String> {
@Resource
private NoteCountDOMapper noteCountDOMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
.linger(Duration.ofSeconds(1)) // 多久聚合一次1s 一次)
.setConsumerEx(this::consumeMessage) // 设置消费者方法
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> bodys) {
log.info("==> 【笔记评论数】聚合消息, size: {}", bodys.size());
log.info("==> 【笔记评论数】聚合消息, {}", JsonUtils.toJsonString(bodys));
// 将聚合后的消息体 Json 转 List<CountPublishCommentMqDTO>
List<CountPublishCommentMqDTO> countPublishCommentMqDTOList = Lists.newArrayList();
bodys.forEach(body -> {
try {
List<CountPublishCommentMqDTO> list = JsonUtils.parseList(body, CountPublishCommentMqDTO.class);
countPublishCommentMqDTOList.addAll(list);
} catch (Exception e) {
log.error("", e);
}
});
// 按笔记 ID 进行分组
Map<Long, List<CountPublishCommentMqDTO>> groupMap = countPublishCommentMqDTOList.stream()
.collect(Collectors.groupingBy(CountPublishCommentMqDTO::getNoteId));
// 循环分组字典
for (Map.Entry<Long, List<CountPublishCommentMqDTO>> entry : groupMap.entrySet()) {
// 笔记 ID
Long noteId = entry.getKey();
// 评论数
int count = CollUtil.size(entry.getValue());
// 更新 Redis 缓存中的笔记评论总数
// 构建 Key
String noteCountHashKey = RedisKeyConstants.buildCountNoteKey(noteId);
// 判断 Hash 是否存在
boolean hasKey = redisTemplate.hasKey(noteCountHashKey);
// 若 Hash 存在
if (hasKey) {
// 累加更新
redisTemplate.opsForHash()
.increment(noteCountHashKey, RedisKeyConstants.FIELD_COMMENT_TOTAL, count);
}
// 若评论数大于零,则执行更新操作:累加评论总数
if (count > 0) {
noteCountDOMapper.insertOrUpdateCommentTotalByNoteId(count, noteId);
}
}
}
}

View File

@@ -0,0 +1,73 @@
package com.hanserwei.hannote.count.biz.consumer;
import cn.hutool.core.collection.CollUtil;
import com.google.common.util.concurrent.RateLimiter;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.NoteCountDOMapper;
import com.hanserwei.hannote.count.biz.domain.mapper.UserCountDOMapper;
import com.hanserwei.hannote.count.biz.model.dto.AggregationCountLikeUnlikeNoteMqDTO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
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.util.List;
@Component
@Slf4j
@SuppressWarnings({"UnstableApiUsage"})
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_COUNT_NOTE_LIKE_2_DB,
topic = MQConstants.TOPIC_COUNT_NOTE_LIKE_2_DB
)
public class CountNoteLike2DBConsumer implements RocketMQListener<String> {
// 每秒创建 5000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(5000);
@Resource
private NoteCountDOMapper noteCountDOMapper;
@Resource
private UserCountDOMapper userCountDOMapper;
@Resource
private TransactionTemplate transactionTemplate;
@Override
public void onMessage(String body) {
// 流量削峰:通过获取令牌,如果没有令牌可用,将阻塞,直到获得
rateLimiter.acquire();
log.info("## 消费到了 MQ 【计数: 笔记点赞数入库】, {}...", body);
List<AggregationCountLikeUnlikeNoteMqDTO> countList = null;
try {
countList = JsonUtils.parseList(body, AggregationCountLikeUnlikeNoteMqDTO.class);
} catch (Exception e) {
log.error("## 解析 JSON 字符串异常", e);
}
if (CollUtil.isNotEmpty(countList)) {
// 判断数据库中 t_user_count 和 t_note_count 表,若笔记计数记录不存在,则插入;若记录已存在,则直接更新
countList.forEach(item -> {
Long creatorId = item.getCreatorId();
Long noteId = item.getNoteId();
Integer count = item.getCount();
// 编程式事务,保证两条语句的原子性
transactionTemplate.execute(status -> {
try {
noteCountDOMapper.insertOrUpdateLikeTotalByNoteId(count, noteId);
userCountDOMapper.insertOrUpdateLikeTotalByUserId(count, creatorId);
return true;
} catch (Exception ex) {
status.setRollbackOnly(); // 标记事务为回滚
log.error("", ex);
}
return false;
});
});
}
}
}

View File

@@ -0,0 +1,140 @@
package com.hanserwei.hannote.count.biz.consumer;
import com.github.phantomthief.collection.BufferTrigger;
import com.google.common.collect.Lists;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.enums.LikeUnlikeNoteTypeEnum;
import com.hanserwei.hannote.count.biz.model.dto.AggregationCountLikeUnlikeNoteMqDTO;
import com.hanserwei.hannote.count.biz.model.dto.CountLikeUnlikeNoteMqDTO;
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.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Component
@Slf4j
@RocketMQMessageListener(
consumerGroup = "han_note_count_group_" + MQConstants.TOPIC_LIKE_OR_UNLIKE,
topic = MQConstants.TOPIC_LIKE_OR_UNLIKE
)
public class CountNoteLikeConsumer implements RocketMQListener<String> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private RocketMQTemplate rocketMQTemplate;
private final BufferTrigger<String> bufferTrigger = BufferTrigger.<String>batchBlocking()
.bufferSize(50000) // 缓存队列的最大容量
.batchSize(1000) // 一批次最多聚合 1000 条
.linger(Duration.ofSeconds(1)) // 多久聚合一次
.setConsumerEx(this::consumeMessage) // 设置消费者方法
.build();
@Override
public void onMessage(String body) {
// 往 bufferTrigger 中添加元素
bufferTrigger.enqueue(body);
}
private void consumeMessage(List<String> bodies) {
log.info("==> 【笔记点赞数】聚合消息, size: {}", bodies.size());
log.info("==> 【笔记点赞数】聚合消息, {}", JsonUtils.toJsonString(bodies));
List<CountLikeUnlikeNoteMqDTO> countLikeUnlikeNoteMqDTOS = bodies.stream()
.map(body -> JsonUtils.parseObject(body, CountLikeUnlikeNoteMqDTO.class)).toList();
// 按笔记ID分组
Map<Long, List<CountLikeUnlikeNoteMqDTO>> groupMap = countLikeUnlikeNoteMqDTOS.stream()
.collect(Collectors.groupingBy(CountLikeUnlikeNoteMqDTO::getNoteId));
// 按组汇总统计处最终计数
List<AggregationCountLikeUnlikeNoteMqDTO> countList = Lists.newArrayList();
for (Map.Entry<Long, List<CountLikeUnlikeNoteMqDTO>> entry : groupMap.entrySet()) {
// 笔记 ID
Long noteId = entry.getKey();
// 笔记发布者 ID
Long creatorId = null;
List<CountLikeUnlikeNoteMqDTO> list = entry.getValue();
// 最终地计数值,默认为 0
int finalCount = 0;
for (CountLikeUnlikeNoteMqDTO countLikeUnlikeNoteMqDTO : list) {
// 设置笔记发布者用户 ID
creatorId = countLikeUnlikeNoteMqDTO.getNoteCreatorId();
Integer type = countLikeUnlikeNoteMqDTO.getType();
LikeUnlikeNoteTypeEnum likeUnlikeNoteTypeEnum = LikeUnlikeNoteTypeEnum.valueOf(type);
if (likeUnlikeNoteTypeEnum == null) {
continue;
}
switch (likeUnlikeNoteTypeEnum) {
case LIKE -> finalCount++;
case UNLIKE -> finalCount--;
}
}
// 将分组后统计出的最终计数,存入 countList 中
countList.add(AggregationCountLikeUnlikeNoteMqDTO.builder()
.noteId(noteId)
.creatorId(creatorId)
.count(finalCount)
.build());
}
log.info("## 【笔记点赞数】聚合后的计数数据: {}", JsonUtils.toJsonString(countList));
// 更新 Redis
countList.forEach(item -> {
// 笔记发布者 ID
Long creatorId = item.getCreatorId();
// 笔记 ID
Long noteId = item.getNoteId();
// 聚合后的计数
Integer count = item.getCount();
// 笔记维度计数 Redis Key
String countNoteRedisKey = RedisKeyConstants.buildCountNoteKey(noteId);
// 判断 Redis 中 Hash 是否存在
boolean isCountNoteExisted = redisTemplate.hasKey(countNoteRedisKey);
// 若存在才会更新
// (因为缓存设有过期时间,考虑到过期后,缓存会被删除,这里需要判断一下,存在才会去更新,而初始化工作放在查询计数来做)
if (isCountNoteExisted) {
// 对目标用户 Hash 中的点赞数字段进行计数操作
redisTemplate.opsForHash().increment(countNoteRedisKey, RedisKeyConstants.FIELD_LIKE_TOTAL, count);
}
// 更新 Redis 用户维度点赞数
String countUserRedisKey = RedisKeyConstants.buildCountUserKey(creatorId);
boolean isCountUserExisted = redisTemplate.hasKey(countUserRedisKey);
if (isCountUserExisted) {
redisTemplate.opsForHash().increment(countUserRedisKey, RedisKeyConstants.FIELD_LIKE_TOTAL, count);
}
});
// 发送 MQ, 笔记点赞数据落库
Message<String> message = MessageBuilder.withPayload(JsonUtils.toJsonString(countList))
.build();
// 异步发送 MQ 消息
rocketMQTemplate.asyncSend(MQConstants.TOPIC_COUNT_NOTE_LIKE_2_DB, message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
log.info("==> 【计数服务笔记点赞数入库】MQ 发送成功SendResult: {}", sendResult);
}
@Override
public void onException(Throwable throwable) {
log.error("==> 【计数服务笔记点赞数入库】MQ 发送异常: ", throwable);
}
});
}
}

View File

@@ -0,0 +1,79 @@
package com.hanserwei.hannote.count.biz.consumer;
import com.hanserwei.framework.common.utils.JsonUtils;
import com.hanserwei.hannote.count.biz.constant.MQConstants;
import com.hanserwei.hannote.count.biz.constant.RedisKeyConstants;
import com.hanserwei.hannote.count.biz.domain.mapper.UserCountDOMapper;
import com.hanserwei.hannote.count.biz.model.dto.NoteOperateMqDTO;
import jakarta.annotation.Resource;
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.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
@Slf4j
@RocketMQMessageListener(
consumerGroup = "han_note_group_" + MQConstants.TOPIC_NOTE_OPERATE,
topic = MQConstants.TOPIC_NOTE_OPERATE
)
public class CountNotePublishConsumer implements RocketMQListener<Message> {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private UserCountDOMapper userCountDOMapper;
@Override
public void onMessage(Message message) {
// 消息体
String bodyJsonStr = new String(message.getBody());
// 标签
String tags = message.getTags();
log.info("==> CountNotePublishConsumer 消费了消息 {}, tags: {}", bodyJsonStr, tags);
// 根据 MQ 标签,判断笔记操作类型
if (Objects.equals(tags, MQConstants.TAG_NOTE_PUBLISH)) { // 笔记发布
handleTagMessage(bodyJsonStr, 1);
} else if (Objects.equals(tags, MQConstants.TAG_NOTE_DELETE)) { // 笔记删除
handleTagMessage(bodyJsonStr, -1);
}
}
/**
* 处理笔记发布和笔记删除的 MQ 消息
*
* @param bodyJsonStr 笔记发布或删除的 MQ 消息体
* @param count 笔记发布或删除的计数
*/
private void handleTagMessage(String bodyJsonStr, long count) {
// 消息体 JSON 字符串转 DTO
NoteOperateMqDTO noteOperateMqDTO = JsonUtils.parseObject(bodyJsonStr, NoteOperateMqDTO.class);
if (Objects.isNull(noteOperateMqDTO)) return;
// 笔记发布者 ID
Long creatorId = noteOperateMqDTO.getCreatorId();
// 更新 Redis 中用户维度的计数 Hash
String countUserRedisKey = RedisKeyConstants.buildCountUserKey(creatorId);
// 判断 Redis 中 Hash 是否存在
boolean isCountUserExisted = redisTemplate.hasKey(countUserRedisKey);
// 若存在才会更新
// (因为缓存设有过期时间,考虑到过期后,缓存会被删除,这里需要判断一下,存在才会去更新,而初始化工作放在查询计数来做)
if (isCountUserExisted) {
// 对目标用户 Hash 中的笔记发布总数,进行加减操作
redisTemplate.opsForHash().increment(countUserRedisKey, RedisKeyConstants.FIELD_NOTE_TOTAL, count);
}
// 更新 t_user_count 表
userCountDOMapper.insertOrUpdateNoteTotalByUserId(count, creatorId);
}
}

View File

@@ -0,0 +1,118 @@
package com.hanserwei.hannote.count.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 lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 评论表
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "t_comment")
public class CommentDO {
/**
* id
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 关联的笔记ID
*/
@TableField(value = "note_id")
private Long noteId;
/**
* 发布者用户ID
*/
@TableField(value = "user_id")
private Long userId;
/**
* 评论内容UUID
*/
@TableField(value = "content_uuid")
private String contentUuid;
/**
* 内容是否为空(0不为空 1为空)
*/
@TableField(value = "is_content_empty")
private Boolean isContentEmpty;
/**
* 评论附加图片URL
*/
@TableField(value = "image_url")
private String imageUrl;
/**
* 级别(1一级评论 2二级评论)
*/
@TableField(value = "`level`")
private Integer level;
/**
* 评论被回复次数,仅一级评论需要
*/
@TableField(value = "reply_total")
private Long replyTotal;
/**
* 评论被点赞次数
*/
@TableField(value = "like_total")
private Long likeTotal;
/**
* 父ID (若是对笔记的评论则此字段存储笔记ID; 若是二级评论则此字段存储一级评论的ID)
*/
@TableField(value = "parent_id")
private Long parentId;
/**
* 回复哪个的评论 (0表示是对笔记的评论若是对他人评论的回复则存储回复评论的ID)
*/
@TableField(value = "reply_comment_id")
private Long replyCommentId;
/**
* 回复的哪个用户, 存储用户ID
*/
@TableField(value = "reply_user_id")
private Long replyUserId;
/**
* 是否置顶(0不置顶 1置顶)
*/
@TableField(value = "is_top")
private Boolean isTop;
/**
* 创建时间
*/
@TableField(value = "create_time")
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(value = "update_time")
private LocalDateTime updateTime;
/**
* 二级评论总数(只有一级评论才需要统计)
*/
@TableField(value = "child_comment_total")
private Long childCommentTotal;
}

View File

@@ -0,0 +1,50 @@
package com.hanserwei.hannote.count.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 lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 笔记计数表
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "t_note_count")
public class NoteCountDO {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
/**
* 笔记ID
*/
@TableField(value = "note_id")
private Long noteId;
/**
* 获得点赞总数
*/
@TableField(value = "like_total")
private Long likeTotal;
/**
* 获得收藏总数
*/
@TableField(value = "collect_total")
private Long collectTotal;
/**
* 被评论总数
*/
@TableField(value = "comment_total")
private Long commentTotal;
}

View File

@@ -0,0 +1,50 @@
package com.hanserwei.hannote.count.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 lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 笔记计数表
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "t_note_count")
public class TNoteCount {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
/**
* 笔记ID
*/
@TableField(value = "note_id")
private Long noteId;
/**
* 获得点赞总数
*/
@TableField(value = "like_total")
private Long likeTotal;
/**
* 获得收藏总数
*/
@TableField(value = "collect_total")
private Long collectTotal;
/**
* 被评论总数
*/
@TableField(value = "comment_total")
private Long commentTotal;
}

View File

@@ -0,0 +1,62 @@
package com.hanserwei.hannote.count.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 lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 用户计数表
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName(value = "t_user_count")
public class UserCountDO {
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
@TableField(value = "user_id")
private Long userId;
/**
* 粉丝总数
*/
@TableField(value = "fans_total")
private Long fansTotal;
/**
* 关注总数
*/
@TableField(value = "following_total")
private Long followingTotal;
/**
* 发布笔记总数
*/
@TableField(value = "note_total")
private Long noteTotal;
/**
* 获得点赞总数
*/
@TableField(value = "like_total")
private Long likeTotal;
/**
* 获得收藏总数
*/
@TableField(value = "collect_total")
private Long collectTotal;
}

Some files were not shown because too many files have changed in this diff Show More