32 Commits
3.1.x ... 3.2.x

Author SHA1 Message Date
d5e45e74c9 chore: 增加 ShardingSphere-JDBC 的模式配置 2022-06-04 07:54:46 +08:00
08237e11e9 docs: update README.md 2022-06-03 22:07:30 +08:00
78b366716a docs: update README.md 2022-06-03 21:49:30 +08:00
5d0ac13ae6 feat: 集成 ShardingSphere-JDBC 最新版,分表存储小说内容(默认关闭)
默认关闭分库分表,通过配置属性 shardingsphere.enabled 来开启
2022-06-03 20:57:23 +08:00
bf60ba30c9 chore: 增加数据库分表脚本 2022-06-03 18:52:35 +08:00
5e995cd63e docs: update README.md 2022-06-03 09:22:07 +08:00
21f30df237 build: 修改版本号 2022-06-02 12:16:34 +08:00
520faff51f docs: 注释 2022-06-02 12:15:21 +08:00
320b985ce6 docs: update README.md 2022-06-02 06:58:18 +08:00
c4fabe2ca1 docs: update README.md 2022-06-01 14:11:58 +08:00
7f0d6c842a chore: 依赖版本管理 2022-06-01 13:59:41 +08:00
c628104a30 feat: 集成 Sentinel 实现接口防刷和限流 2022-06-01 13:40:17 +08:00
9894814fe4 docs: update README.md 2022-05-31 21:31:42 +08:00
d7a7580c4e feat: 增加 XXL-JOB 开关 2022-05-31 21:17:04 +08:00
9eb967402a docs: update README.md 2022-05-31 14:16:26 +08:00
220068cd3a feat: 集成分布式任务调度 XXL-JOB, 优化 Elasticsearch 数据同步任务 2022-05-31 14:03:43 +08:00
4d71aa33b1 feat: 章节列表返回 isVip 2022-05-30 06:48:41 +08:00
b6a07d3a0c fix: 排行榜过滤无章节小说 2022-05-30 06:40:30 +08:00
121ec01fa2 fix: 作家小说发布校验作品名 2022-05-29 20:47:45 +08:00
ed882abbd1 fix: 同类推荐过滤无章节小说 2022-05-29 20:36:31 +08:00
785646b4c4 fix: 章节发布校验 2022-05-29 19:48:47 +08:00
ad907063d9 feat: 小说信息增加更新时间返回 2022-05-29 18:03:30 +08:00
f33c66c5d2 feat: 增加作家状态查询接口 2022-05-29 15:23:37 +08:00
066dd0f13e feat: 增加小说发布章节列表查询接口 2022-05-29 12:58:15 +08:00
fa47081398 feat: 增肌小说发布列表查询接口 2022-05-29 12:31:10 +08:00
1151ed3f9f docs: update README.md 2022-05-29 09:53:12 +08:00
d2cadda291 docs: update README.md 2022-05-29 09:17:36 +08:00
8a0105cfa4 style: 换行 2022-05-29 05:52:49 +08:00
3019093dc3 style: 换行/命名 2022-05-28 18:51:47 +08:00
cdd99834a6 docs: update README.md 2022-05-28 07:23:08 +08:00
80e7264afa feat: 增加 Token 解析拦截器 2022-05-27 17:34:10 +08:00
e537240c73 refactor: 重构小说搜索接口 2022-05-27 16:04:26 +08:00
37 changed files with 869 additions and 169 deletions

View File

@ -11,9 +11,9 @@ novel 是一套基于时下**最新** Java 技术栈 Spring Boot 3 + Vue 3 开
## 项目地址
- 后端项目(更新中):[GitHub](https://github.com/201206030/novel) [码云](https://gitee.com/novel_dev_team/novel)
- 后端微服务版本项目(待更新):[GitHub](https://github.com/201206030/novel-cloud) [码云](https://gitee.com/novel_dev_team/novel-cloud)
- 前端项目(更新中):[GitHub](https://github.com/201206030/novel-front-web) [码云](https://gitee.com/novel_dev_team/novel-front-web)
- 线上应用版:[GitHub](https://github.com/201206030/novel-plus) [码云](https://gitee.com/novel_dev_team/novel-plus) [演示地址](http://47.106.243.172:8888/)
- 微服务版:[GitHub](https://github.com/201206030/novel-cloud) [码云](https://gitee.com/novel_dev_team/novel-cloud)
## 开发环境
@ -21,25 +21,31 @@ novel 是一套基于时下**最新** Java 技术栈 Spring Boot 3 + Vue 3 开
- Redis 7.0
- Elasticsearch 8.2.0(可选)
- RabbitMQ 3.10.2(可选)
- XXL-JOB 2.3.1(可选)
- JDK 17
- Maven 3.8
- IntelliJ IDEA 2021.3(可选)
- Node 16.14
**注Elasticsearch、RabbitMQ 和 XXL-JOB 默认关闭,可通过 application.yml 配置文件中相应的`enable`配置属性开启。**
## 后端技术选型
| 技术 | 版本 | 说明 | 官网 | 学习 |
|:----------------|:--------------:|---------------------| --------------------------------------- | :-------------------------------------------------: |
|:-------------------------------|:--------------:|---------------------| --------------------------------------- |:----------------------------------------------------------------------------------------:|
| Spring Boot | 3.0.0-SNAPSHOT | 容器 + MVC 框架 | https://spring.io/projects/spring-boot | [进入](https://youdoc.github.io/course/novel/11.html) |
| Mybatis | 3.5.9 | ORM 框架 | http://www.mybatis.org | [进入](https://mybatis.org/mybatis-3/zh/index.html) |
| MyBatis-Plus | 3.5.1 | Mybatis 增强工具 | https://baomidou.com/ | [进入](https://baomidou.com/pages/24112f/) |
| MyBatis | 3.5.9 | ORM 框架 | http://www.mybatis.org | [进入](https://mybatis.org/mybatis-3/zh/index.html) |
| MyBatis-Plus | 3.5.1 | MyBatis 增强工具 | https://baomidou.com/ | [进入](https://baomidou.com/pages/24112f/) |
| JJWT | 0.11.5 | JWT 登录支持 | https://github.com/jwtk/jjwt | - |
| Lombok | 1.18.24 | 简化对象封装工具 | https://github.com/projectlombok/lombok | [进入](https://projectlombok.org/features/all) |
| Caffeine | 3.1.0 | 本地缓存支持 | https://github.com/ben-manes/caffeine | [进入](https://github.com/ben-manes/caffeine/wiki/Home-zh-CN) |
| Redis | 7.0 | 分布式缓存支持 | https://redis.io | [进入](https://redis.io/docs) |
| MySQL | 8.0 | 数据库服务 | https://www.mysql.com | [进入](https://docs.oracle.com/en-us/iaas/mysql-database/doc/getting-started.html) |
| ShardingSphere-JDBC | 5.1.1 | 数据库分库分表支持 | https://shardingsphere.apache.org | [进入](https://shardingsphere.apache.org/document/5.1.1/cn/overview) |
| Elasticsearch | 8.2.0 | 搜索引擎服务 | https://www.elastic.co | [进入](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) |
| RabbitMQ | 3.10.2 | 开源消息中间件 | https://www.rabbitmq.com | [进入](https://www.rabbitmq.com/tutorials/tutorial-one-java.html) |
| XXL-JOB | 2.3.1 | 分布式任务调度平台 | https://www.xuxueli.com/xxl-job | [进入](https://www.xuxueli.com/xxl-job) |
| Sentinel | 1.8.4 | 流量控制组件 | https://github.com/alibaba/Sentinel | [进入](https://github.com/alibaba/Sentinel/wiki/%E4%B8%BB%E9%A1%B5) |
| Undertow | 2.2.17.Final | Java 开发的高性能 Web 服务器 | https://undertow.io | [进入](https://undertow.io/documentation.html) |
| Docker | - | 应用容器引擎 | https://www.docker.com/ | - |
| Jenkins | - | 自动化部署工具 | https://github.com/jenkinsci/jenkins | - |
@ -247,11 +253,15 @@ git clone https://gitee.com/novel_dev_team/novel-front-web.git
## 公众号
关注公众号接收项目最新动态,获取`Spring Boot 3`学习笔记!
- 关注公众号接收`项目`和`文档`的更新动态
加微信群交流,公众号后台回复「**微信群**」即可
- 加微信群学习交流,公众号后台回复「**微信群**」即可
![微信公众号](https://youdoc.github.io/img/qrcode_for_gh.jpg)
- 回复「**资料**」获取`Java 学习面试资料`
- 回复「**笔记**」获取`Spring Boot 3 学习笔记`
![xxyopen](https://youdoc.gitee.io/img/qrcode_for_gh.jpg)
## 赞赏支持

171
doc/sql/20220603.sql Normal file
View File

@ -0,0 +1,171 @@
DROP PROCEDURE IF EXISTS createBookChapterTable;
-- 创建小说章节表的存储过程
CREATE PROCEDURE createBookChapterTable()
BEGIN
-- 定义变量
DECLARE i int DEFAULT 0;
DECLARE tableName char(13) DEFAULT NULL;
while i < 10 do
set tableName = concat('book_chapter',i);
set @stmt = concat('create table ',tableName,'(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`book_id` bigint(20) unsigned NOT NULL COMMENT \'ID\',
`chapter_num` smallint(5) unsigned NOT NULL COMMENT \'\',
`chapter_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT \'\',
`word_count` int(10) unsigned NOT NULL COMMENT \'\',
`is_vip` tinyint(3) unsigned NOT NULL DEFAULT \'0\' COMMENT \';1- 0-\',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_bookId_chapterNum` (`book_id`,`chapter_num`) USING BTREE,
UNIQUE KEY `pk_id` (`id`) USING BTREE,
KEY `idx_bookId` (`book_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT=\'\'');
prepare stmt from @stmt;
execute stmt;
deallocate prepare stmt;
set i = i + 1;
end while;
END;
call createBookChapterTable();
DROP PROCEDURE IF EXISTS createBookContentTable;
-- 创建小说内容表的存储过程
CREATE PROCEDURE createBookContentTable()
BEGIN
-- 定义变量
DECLARE i int DEFAULT 0;
DECLARE tableName char(13) DEFAULT NULL;
while i < 10 do
set tableName = concat('book_content',i);
set @stmt = concat('create table ',tableName,'(
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT \'\',
`chapter_id` bigint(20) unsigned NOT NULL COMMENT \'ID\',
`content` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT \'\',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_chapterId` (`chapter_id`) USING BTREE,
UNIQUE KEY `pk_id` (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT=\'\'');
prepare stmt from @stmt;
execute stmt;
deallocate prepare stmt;
set i = i + 1;
end while;
END;
call createBookContentTable();
DROP PROCEDURE IF EXISTS copyBookChapterData;
-- 迁移小说章节数据的存储过程
CREATE PROCEDURE copyBookChapterData()
BEGIN
-- 定义变量
DECLARE s int DEFAULT 0;
DECLARE chapterId bigint;
DECLARE bookId bigint;
DECLARE chapterNum smallint;
DECLARE chapterName varchar(100);
DECLARE wordCount int DEFAULT 0;
DECLARE isVip tinyint(64) DEFAULT 0;
DECLARE createTime datetime DEFAULT NULL;
DECLARE updateTime datetime DEFAULT NULL;
DECLARE tableNumber int DEFAULT 0;
DECLARE tableName char(13) DEFAULT NULL;
-- 定义游标
DECLARE report CURSOR FOR select id,book_id,chapter_num, chapter_name, word_count, is_vip,create_time,update_time from book_chapter;
-- 声明当游标遍历完后将标志变量置成某个值
DECLARE CONTINUE HANDLER FOR NOT FOUND SET s=1;
-- 打开游标
open report;
-- 将游标中的值赋值给变量注意变量名不要和返回的列名同名变量顺序要和sql结果列的顺序一致
fetch report into chapterId,bookId,chapterNum, chapterName, wordCount,isVip,createTime,updateTime;
-- 循环遍历
while s<>1 do
-- 执行业务逻辑
set tableNumber = bookId % 10;
set tableName = concat('book_chapter',tableNumber);
set @stmt = concat('insert into ',tableName,'(`id`, `book_id`, `chapter_num`, `chapter_name`, `word_count`, `is_vip`, `create_time`, `update_time`) VALUES (',chapterId,', ',bookId,', ',chapterNum,', \'',chapterName,'\', ',wordCount,', ',isVip,', \'',createTime,'\', \'',updateTime,'\')');
prepare stmt from @stmt;
execute stmt;
deallocate prepare stmt;
fetch report into chapterId,bookId,chapterNum, chapterName, wordCount,isVip,createTime,updateTime;
end while;
-- 关闭游标
close report;
END;
call copyBookChapterData();
DROP PROCEDURE IF EXISTS copyBookContentData;
-- 迁移小说内容数据的存储过程
CREATE PROCEDURE copyBookContentData()
BEGIN
-- 定义变量
DECLARE s int DEFAULT 0;
DECLARE contentId bigint;
DECLARE chapterId bigint;
DECLARE bookContent mediumtext;
DECLARE createTime datetime DEFAULT NULL;
DECLARE updateTime datetime DEFAULT NULL;
DECLARE tableNumber int DEFAULT 0;
DECLARE tableName char(13) DEFAULT NULL;
-- 定义游标
DECLARE report CURSOR FOR select id,chapter_id,content,create_time,update_time from book_content;
-- 声明当游标遍历完后将标志变量置成某个值
DECLARE CONTINUE HANDLER FOR NOT FOUND SET s=1;
-- 打开游标
open report;
-- 将游标中的值赋值给变量注意变量名不要和返回的列名同名变量顺序要和sql结果列的顺序一致
fetch report into contentId,chapterId,bookContent,createTime,updateTime;
-- 循环遍历
while s<>1 do
-- 执行业务逻辑
set tableNumber = chapterId % 10;
set tableName = concat('book_content',tableNumber);
set bookContent = REPLACE(bookContent,'\'',"\\'");
set @stmt = concat('insert into ',tableName,'(`id`, `chapter_id`, `content`) VALUES (',contentId,', ',chapterId,',\'',bookContent,'\')');
prepare stmt from @stmt;
execute stmt;
deallocate prepare stmt;
fetch report into contentId,chapterId,bookContent,createTime,updateTime;
end while;
-- 关闭游标
close report;
END;
call copyBookContentData();

33
pom.xml
View File

@ -10,7 +10,7 @@
</parent>
<groupId>io.github.xxyopen</groupId>
<artifactId>novel</artifactId>
<version>3.1.0</version>
<version>3.2.0</version>
<name>novel</name>
<description>Spring Boot 3 + Vue 3 构建的前后端分离小说系统</description>
<properties>
@ -19,6 +19,9 @@
<spring.version>6.0.0-SNAPSHOT</spring.version>
<jjwt.version>0.11.5</jjwt.version>
<elasticsearch.version>8.2.0</elasticsearch.version>
<xxl-job.version>2.3.1</xxl-job.version>
<sentinel.version>1.8.4</sentinel.version>
<shardingsphere-jdbc.version>5.1.1</shardingsphere-jdbc.version>
</properties>
<dependencies>
<dependency>
@ -110,12 +113,38 @@
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- MQ 相关-->
<!-- MQ 相关 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- XXL-JOB 相关 -->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>${xxl-job.version}</version>
</dependency>
<!-- sentinel 相关 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
<version>${sentinel.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-parameter-flow-control</artifactId>
<version>${sentinel.version}</version>
</dependency>
<!-- ShardingSphere-JDBC -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>${shardingsphere-jdbc.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>

View File

@ -1,22 +1,24 @@
package io.github.xxyopen.novel.controller.author;
import io.github.xxyopen.novel.core.auth.UserHolder;
import io.github.xxyopen.novel.core.common.req.PageReqDto;
import io.github.xxyopen.novel.core.common.resp.PageRespDto;
import io.github.xxyopen.novel.core.common.resp.RestResp;
import io.github.xxyopen.novel.core.constant.ApiRouterConsts;
import io.github.xxyopen.novel.dto.req.AuthorRegisterReqDto;
import io.github.xxyopen.novel.dto.req.BookAddReqDto;
import io.github.xxyopen.novel.dto.req.ChapterAddReqDto;
import io.github.xxyopen.novel.dto.resp.BookChapterRespDto;
import io.github.xxyopen.novel.dto.resp.BookInfoRespDto;
import io.github.xxyopen.novel.service.AuthorService;
import io.github.xxyopen.novel.service.BookService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
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;
import org.springframework.web.bind.annotation.*;
/**
* 作家后台-作家模块 API 控制器
*
* @author xiongxiaoyang
* @date 2022/5/23
*/
@ -38,6 +40,14 @@ public class AuthorController {
return authorService.register(dto);
}
/**
* 查询作家状态接口
*/
@GetMapping("status")
public RestResp<Integer> getStatus() {
return authorService.getStatus(UserHolder.getUserId());
}
/**
* 小说发布接口
*/
@ -46,12 +56,29 @@ public class AuthorController {
return bookService.saveBook(dto);
}
/**
* 小说发布列表查询接口
*/
@GetMapping("books")
public RestResp<PageRespDto<BookInfoRespDto>> listBooks(PageReqDto dto) {
return bookService.listAuthorBooks(dto);
}
/**
* 小说章节发布接口
*/
@PostMapping("book/chapter")
public RestResp<Void> publishBookChapter(@Valid @RequestBody ChapterAddReqDto dto) {
@PostMapping("book/chapter/{bookId}")
public RestResp<Void> publishBookChapter(@PathVariable("bookId") Long bookId, @Valid @RequestBody ChapterAddReqDto dto) {
dto.setBookId(bookId);
return bookService.saveBookChapter(dto);
}
/**
* 小说章节发布列表查询接口
*/
@GetMapping("book/chapters/{bookId}")
public RestResp<PageRespDto<BookChapterRespDto>> listBookChapters(@PathVariable("bookId") Long bookId, PageReqDto dto) {
return bookService.listBookChapters(bookId, dto);
}
}

View File

@ -1,12 +1,9 @@
package io.github.xxyopen.novel.controller.front;
import io.github.xxyopen.novel.core.common.resp.PageRespDto;
import io.github.xxyopen.novel.core.constant.ApiRouterConsts;
import io.github.xxyopen.novel.core.common.resp.RestResp;
import io.github.xxyopen.novel.dto.req.BookSearchReqDto;
import io.github.xxyopen.novel.core.constant.ApiRouterConsts;
import io.github.xxyopen.novel.dto.resp.*;
import io.github.xxyopen.novel.service.BookService;
import io.github.xxyopen.novel.service.SearchService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@ -26,8 +23,6 @@ public class BookController {
private final BookService bookService;
private final SearchService searchService;
/**
* 小说分类列表查询接口
*/
@ -36,14 +31,6 @@ public class BookController {
return bookService.listCategory(workDirection);
}
/**
* 小说搜索接口
*/
@GetMapping("search_list")
public RestResp<PageRespDto<BookInfoRespDto>> searchBooks(BookSearchReqDto condition) {
return searchService.searchBooks(condition);
}
/**
* 小说信息查询接口
*/

View File

@ -0,0 +1,35 @@
package io.github.xxyopen.novel.controller.front;
import io.github.xxyopen.novel.core.common.resp.PageRespDto;
import io.github.xxyopen.novel.core.common.resp.RestResp;
import io.github.xxyopen.novel.core.constant.ApiRouterConsts;
import io.github.xxyopen.novel.dto.req.BookSearchReqDto;
import io.github.xxyopen.novel.dto.resp.BookInfoRespDto;
import io.github.xxyopen.novel.service.SearchService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 前台门户-搜索模块 API 控制器
*
* @author xiongxiaoyang
* @date 2022/5/27
*/
@RestController
@RequestMapping(ApiRouterConsts.API_FRONT_SEARCH_URL_PREFIX)
@RequiredArgsConstructor
public class SearchController {
private final SearchService searchService;
/**
* 小说搜索接口
*/
@GetMapping("books")
public RestResp<PageRespDto<BookInfoRespDto>> searchBooks(BookSearchReqDto condition) {
return searchService.searchBooks(condition);
}
}

View File

@ -32,7 +32,10 @@ public class AuthorAuthStrategy implements AuthStrategy {
/**
* 不需要进行作家权限认证的 URI
* */
private static final List<String> EXCLUDE_URI = List.of(ApiRouterConsts.API_AUTHOR_URL_PREFIX + "/register");
private static final List<String> EXCLUDE_URI = List.of(
ApiRouterConsts.API_AUTHOR_URL_PREFIX + "/register",
ApiRouterConsts.API_AUTHOR_URL_PREFIX +"/status"
);
@Override
public void auth(String token, String requestUri) throws BusinessException {

View File

@ -82,6 +82,16 @@ public enum ErrorCodeEnum {
* */
USER_UN_AUTH("A0301","访问未授权"),
/**
* 用户请求服务异常
* */
USER_REQ_EXCEPTION("A0500","用户请求服务异常"),
/**
* 请求超出限制
* */
USER_REQ_MANY("A0501","请求超出限制"),
/**
* 用户评论异常
* */
@ -92,6 +102,17 @@ public enum ErrorCodeEnum {
* */
USER_COMMENTED("A2001","用户已发表评论"),
/**
* 作家发布异常
* */
AUTHOR_PUBLISH("A3000","作家发布异常"),
/**
* 小说名已存在
* */
AUTHOR_BOOK_NAME_EXIST("A3001","小说名已存在"),
/**
* 用户上传文件异常
* */
@ -126,11 +147,11 @@ public enum ErrorCodeEnum {
/**
* 错误码
* */
private String code;
private final String code;
/**
* 中文描述
* */
private String message;
private final String message;
}

View File

@ -4,6 +4,8 @@ import io.github.xxyopen.novel.core.constant.ApiRouterConsts;
import io.github.xxyopen.novel.core.constant.SystemConfigConsts;
import io.github.xxyopen.novel.core.interceptor.AuthInterceptor;
import io.github.xxyopen.novel.core.interceptor.FileInterceptor;
import io.github.xxyopen.novel.core.interceptor.FlowLimitInterceptor;
import io.github.xxyopen.novel.core.interceptor.TokenParseInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
@ -21,28 +23,46 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final FlowLimitInterceptor flowLimitInterceptor;
private final AuthInterceptor authInterceptor;
private final FileInterceptor fileInterceptor;
private final TokenParseInterceptor tokenParseInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 流量限制拦截器
registry.addInterceptor(flowLimitInterceptor)
.addPathPatterns("/**")
.order(0);
// 文件访问拦截
registry.addInterceptor(fileInterceptor)
.addPathPatterns(SystemConfigConsts.IMAGE_UPLOAD_DIRECTORY + "**");
.addPathPatterns(SystemConfigConsts.IMAGE_UPLOAD_DIRECTORY + "**")
.order(1);
// 权限认证拦截
registry.addInterceptor(authInterceptor)
// 拦截会员中心相关请求接口
.addPathPatterns(ApiRouterConsts.API_FRONT_USER_URL_PREFIX + "/**"
.addPathPatterns(ApiRouterConsts.API_FRONT_USER_URL_PREFIX + "/**",
// 拦截作家后台相关请求接口
, ApiRouterConsts.API_AUTHOR_URL_PREFIX + "/**"
ApiRouterConsts.API_AUTHOR_URL_PREFIX + "/**",
// 拦截平台后台相关请求接口
, ApiRouterConsts.API_ADMIN_URL_PREFIX + "/**")
ApiRouterConsts.API_ADMIN_URL_PREFIX + "/**")
// 放行登录注册相关请求接口
.excludePathPatterns(ApiRouterConsts.API_FRONT_USER_URL_PREFIX + "/register"
, ApiRouterConsts.API_FRONT_USER_URL_PREFIX + "/login"
,ApiRouterConsts.API_ADMIN_URL_PREFIX + "/login");
.excludePathPatterns(ApiRouterConsts.API_FRONT_USER_URL_PREFIX + "/register",
ApiRouterConsts.API_FRONT_USER_URL_PREFIX + "/login",
ApiRouterConsts.API_ADMIN_URL_PREFIX + "/login")
.order(2);
// Token 解析拦截器
registry.addInterceptor(tokenParseInterceptor)
// 拦截小说内容查询接口,需要解析 token 以判断该用户是否有权阅读该章节(付费章节是否已购买)
.addPathPatterns(ApiRouterConsts.API_FRONT_BOOK_URL_PREFIX + "/content/*")
.order(3);
}
}

View File

@ -0,0 +1,46 @@
package io.github.xxyopen.novel.core.config;
import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* XXL-JOB 配置类
*
* @author xiongxiaoyang
* @date 2022/5/31
*/
@Configuration
@ConditionalOnProperty(prefix = "xxl.job", name = "enable", havingValue = "true")
@Slf4j
public class XxlJobConfig {
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;
@Value("${xxl.job.accessToken}")
private String accessToken;
@Value("${xxl.job.executor.appname}")
private String appname;
@Value("${xxl.job.executor.logpath}")
private String logPath;
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
log.info(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setLogPath(logPath);
return xxlJobSpringExecutor;
}
}

View File

@ -57,6 +57,11 @@ public class ApiRouterConsts {
* */
public static final String RESOURCE_URL_PREFIX = "/resource";
/**
* 搜索模块请求路径前缀
* */
public static final String SEARCH_URL_PREFIX = "/search";
/**
* 前台门户首页API请求路径前缀
*/
@ -82,4 +87,9 @@ public class ApiRouterConsts {
*/
public static final String API_FRONT_RESOURCE_URL_PREFIX = API_FRONT_URL_PREFIX + RESOURCE_URL_PREFIX;
/**
* 前台门户搜索相关API请求路径前缀
* */
public static final String API_FRONT_SEARCH_URL_PREFIX = API_FRONT_URL_PREFIX + SEARCH_URL_PREFIX;
}

View File

@ -89,8 +89,14 @@ public class DatabaseConsts {
public static final String COLUMN_CATEGORY_ID = "category_id";
public static final String COLUMN_BOOK_NAME = "book_name";
public static final String AUTHOR_ID = "author_id";
public static final String COLUMN_VISIT_COUNT = "visit_count";
public static final String COLUMN_WORD_COUNT = "word_count";
public static final String COLUMN_LAST_CHAPTER_UPDATE_TIME = "last_chapter_update_time";
}

View File

@ -0,0 +1,98 @@
package io.github.xxyopen.novel.core.interceptor;
import com.alibaba.csp.sentinel.Entry;
import com.alibaba.csp.sentinel.EntryType;
import com.alibaba.csp.sentinel.SphU;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.param.ParamFlowRuleManager;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.xxyopen.novel.core.common.constant.ErrorCodeEnum;
import io.github.xxyopen.novel.core.common.resp.RestResp;
import io.github.xxyopen.novel.core.common.util.IpUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 流量限制 拦截器
* 实现接口防刷和限流
*
* @author xiongxiaoyang
* @date 2022/6/1
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class FlowLimitInterceptor implements HandlerInterceptor {
private final ObjectMapper objectMapper;
/**
* novel 项目所有的资源
*/
private static final String NOVEL_RESOURCE = "novelResource";
static {
// 接口限流规则:所有的请求,限制每秒最多只能通过 2000 个,超出限制匀速排队
List<FlowRule> rules = new ArrayList<>();
FlowRule rule1 = new FlowRule();
rule1.setResource(NOVEL_RESOURCE);
rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
// Set limit QPS to 2000.
rule1.setCount(2000);
rule1.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER);
rules.add(rule1);
FlowRuleManager.loadRules(rules);
// 接口防刷规则 1所有的请求限制每个 IP 每秒最多只能通过 50 个,超出限制直接拒绝
ParamFlowRule rule2 = new ParamFlowRule(NOVEL_RESOURCE)
.setParamIdx(0)
.setCount(50);
// 接口防刷规则 2所有的请求限制每个 IP 每分钟最多只能通过 1000 个,超出限制直接拒绝
ParamFlowRule rule3 = new ParamFlowRule(NOVEL_RESOURCE)
.setParamIdx(0)
.setCount(1000)
.setDurationInSec(60);
ParamFlowRuleManager.loadRules(Arrays.asList(rule2, rule3));
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String ip = IpUtils.getRealIp(request);
Entry entry = null;
try {
// 若需要配置例外项,则传入的参数只支持基本类型。
// EntryType 代表流量类型,其中系统规则只对 IN 类型的埋点生效
// count 大多数情况都填 1代表统计为一次调用。
entry = SphU.entry(NOVEL_RESOURCE, EntryType.IN, 1, ip);
// Your logic here.
return HandlerInterceptor.super.preHandle(request, response, handler);
} catch (BlockException ex) {
// Handle request rejection.
log.info("IP:{}被限流了!", ip);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(objectMapper.writeValueAsString(RestResp.fail(ErrorCodeEnum.USER_REQ_MANY)));
} finally {
// 注意exit 的时候也一定要带上对应的参数,否则可能会有统计错误。
if (entry != null) {
entry.exit(1, ip);
}
}
return false;
}
}

View File

@ -0,0 +1,43 @@
package io.github.xxyopen.novel.core.interceptor;
import io.github.xxyopen.novel.core.auth.UserHolder;
import io.github.xxyopen.novel.core.constant.SystemConfigConsts;
import io.github.xxyopen.novel.core.util.JwtUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
/**
* Token 解析拦截器
*
* @author xiongxiaoyang
* @date 2022/5/27
*/
@Component
@RequiredArgsConstructor
public class TokenParseInterceptor implements HandlerInterceptor {
private final JwtUtils jwtUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取登录 JWT
String token = request.getHeader(SystemConfigConsts.HTTP_AUTH_HEADER_NAME);
if (StringUtils.hasText(token)) {
// 解析 token 并保存
UserHolder.setUserId(jwtUtils.parseToken(token, SystemConfigConsts.NOVEL_FRONT_KEY));
}
return HandlerInterceptor.super.preHandle(request, response, handler);
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 清理当前线程保存的用户数据
UserHolder.clear();
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
}

View File

@ -21,7 +21,7 @@ import org.springframework.stereotype.Component;
* @date 2022/5/25
*/
@Component
@ConditionalOnProperty(prefix = "spring.elasticsearch", name = "enable", havingValue = "true")
@ConditionalOnProperty(prefix = "spring", name = {"elasticsearch.enable","amqp.enable"}, havingValue = "true")
@RequiredArgsConstructor
@Slf4j
public class RabbitQueueListener {

View File

@ -6,6 +6,8 @@ import co.elastic.clients.elasticsearch.core.BulkRequest;
import co.elastic.clients.elasticsearch.core.BulkResponse;
import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.handler.annotation.XxlJob;
import io.github.xxyopen.novel.core.constant.DatabaseConsts;
import io.github.xxyopen.novel.core.constant.EsConsts;
import io.github.xxyopen.novel.dao.entity.BookInfo;
@ -15,7 +17,6 @@ import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
@ -40,17 +41,19 @@ public class BookToEsTask {
* 每月凌晨做一次全量数据同步
*/
@SneakyThrows
@Scheduled(cron = "0 0 0 1 * ?")
public void saveToEs() {
@XxlJob("saveToEsJobHandler")
public ReturnT<String> saveToEs() {
try {
QueryWrapper<BookInfo> queryWrapper = new QueryWrapper<>();
List<BookInfo> bookInfos;
long maxId = 0;
for(;;) {
for (; ; ) {
queryWrapper.clear();
queryWrapper
.orderByAsc(DatabaseConsts.CommonColumnEnum.ID.getName())
.gt(DatabaseConsts.CommonColumnEnum.ID.getName(), maxId)
.gt(DatabaseConsts.BookTable.COLUMN_WORD_COUNT, 0)
.last(DatabaseConsts.SqlEnum.LIMIT_30.getSql());
bookInfos = bookInfoMapper.selectList(queryWrapper);
if (bookInfos.isEmpty()) {
@ -80,9 +83,12 @@ public class BookToEsTask {
}
}
}
}
return ReturnT.SUCCESS;
} catch (Exception e) {
log.error(e.getMessage(), e);
return ReturnT.FAIL;
}
}
}

View File

@ -17,7 +17,6 @@ public class ChapterAddReqDto {
/**
* 小说ID
*/
@NotNull
private Long bookId;
/**

View File

@ -52,4 +52,9 @@ public class BookChapterRespDto implements Serializable {
@JsonFormat(pattern = "yyyy/MM/dd HH:dd")
private LocalDateTime chapterUpdateTime;
/**
* 是否收费;1-收费 0-免费
*/
private Integer isVip;
}

View File

@ -1,7 +1,10 @@
package io.github.xxyopen.novel.dto.resp;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.*;
import java.time.LocalDateTime;
/**
* 小说信息 响应DTO
*
@ -89,5 +92,11 @@ public class BookInfoRespDto {
*/
private String lastChapterName;
/**
* 最新章节更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm")
private LocalDateTime updateTime;
}

View File

@ -28,8 +28,8 @@ public class AuthorInfoCacheManager {
/**
* 查询作家信息,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER
, value = CacheConsts.AUTHOR_INFO_CACHE_NAME, unless = "#result == null")
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER,
value = CacheConsts.AUTHOR_INFO_CACHE_NAME, unless = "#result == null")
public AuthorInfoDto getAuthor(Long userId) {
QueryWrapper<AuthorInfo> queryWrapper = new QueryWrapper<>();
queryWrapper
@ -45,9 +45,9 @@ public class AuthorInfoCacheManager {
.status(authorInfo.getStatus()).build();
}
@CacheEvict(cacheManager = CacheConsts.REDIS_CACHE_MANAGER
, value = CacheConsts.AUTHOR_INFO_CACHE_NAME)
public void evictAuthorCache(){
@CacheEvict(cacheManager = CacheConsts.REDIS_CACHE_MANAGER,
value = CacheConsts.AUTHOR_INFO_CACHE_NAME)
public void evictAuthorCache() {
// 调用此方法自动清除作家信息的缓存
}

View File

@ -27,8 +27,8 @@ public class BookCategoryCacheManager {
/**
* 根据作品方向查询小说分类列表,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.BOOK_CATEGORY_LIST_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.BOOK_CATEGORY_LIST_CACHE_NAME)
public List<BookCategoryRespDto> listCategory(Integer workDirection) {
QueryWrapper<BookCategory> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(DatabaseConsts.BookCategoryTable.COLUMN_WORK_DIRECTION, workDirection);

View File

@ -23,8 +23,8 @@ public class BookChapterCacheManager {
/**
* 查询小说章节信息,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.BOOK_CHAPTER_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.BOOK_CHAPTER_CACHE_NAME)
public BookChapterRespDto getChapter(Long chapterId) {
BookChapter bookChapter = bookChapterMapper.selectById(chapterId);
return BookChapterRespDto.builder()

View File

@ -24,8 +24,8 @@ public class BookContentCacheManager {
/**
* 查询小说内容,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER
, value = CacheConsts.BOOK_CONTENT_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER,
value = CacheConsts.BOOK_CONTENT_CACHE_NAME)
public String getBookContent(Long chapterId) {
QueryWrapper<BookContent> contentQueryWrapper = new QueryWrapper<>();
contentQueryWrapper.eq(DatabaseConsts.BookContentTable.COLUMN_CHAPTER_ID, chapterId)

View File

@ -9,6 +9,7 @@ import io.github.xxyopen.novel.dao.mapper.BookChapterMapper;
import io.github.xxyopen.novel.dao.mapper.BookInfoMapper;
import io.github.xxyopen.novel.dto.resp.BookInfoRespDto;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;
@ -32,17 +33,17 @@ public class BookInfoCacheManager {
/**
* 从缓存中查询小说信息(先判断缓存中是否已存在,存在则直接从缓存中取,否则执行方法体中的逻辑后缓存结果)
*/
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.BOOK_INFO_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.BOOK_INFO_CACHE_NAME)
public BookInfoRespDto getBookInfo(Long id) {
return cachePutBookInfo(id);
}
/**
* 缓存小说信息(不管缓存中是否存在都执行方法体中的逻辑,然后缓存起来)
* */
@CachePut(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.BOOK_INFO_CACHE_NAME)
*/
@CachePut(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.BOOK_INFO_CACHE_NAME)
public BookInfoRespDto cachePutBookInfo(Long id) {
// 查询基础信息
BookInfo bookInfo = bookInfoMapper.selectById(id);
@ -72,16 +73,21 @@ public class BookInfoCacheManager {
.build();
}
@CacheEvict(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.BOOK_INFO_CACHE_NAME)
public void evictBookInfoCache(Long ignoredId) {
// 调用此方法自动清除小说信息的缓存
}
/**
* 查询每个类别下最新更新的 500 个小说ID列表并放入缓存中 1 个小时
*/
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.LAST_UPDATE_BOOK_ID_LIST_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.LAST_UPDATE_BOOK_ID_LIST_CACHE_NAME)
public List<Long> getLastUpdateIdList(Long categoryId) {
QueryWrapper<BookInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(DatabaseConsts.BookTable.COLUMN_CATEGORY_ID, categoryId)
.gt(DatabaseConsts.BookTable.COLUMN_WORD_COUNT,0)
.orderByDesc(DatabaseConsts.BookTable.COLUMN_LAST_CHAPTER_UPDATE_TIME)
.last(DatabaseConsts.SqlEnum.LIMIT_500.getSql());
return bookInfoMapper.selectList(queryWrapper).stream().map(BookInfo::getId).toList();

View File

@ -27,40 +27,44 @@ public class BookRankCacheManager {
/**
* 查询小说点击榜列表,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER
, value = CacheConsts.BOOK_VISIT_RANK_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER,
value = CacheConsts.BOOK_VISIT_RANK_CACHE_NAME)
public List<BookRankRespDto> listVisitRankBooks() {
QueryWrapper<BookInfo> bookInfoQueryWrapper = new QueryWrapper<>();
bookInfoQueryWrapper.orderByDesc(DatabaseConsts.BookTable.COLUMN_VISIT_COUNT);
return getBookRankRespDtos(bookInfoQueryWrapper);
return listRankBooks(bookInfoQueryWrapper);
}
/**
* 查询小说新书榜列表,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.BOOK_NEWEST_RANK_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.BOOK_NEWEST_RANK_CACHE_NAME)
public List<BookRankRespDto> listNewestRankBooks() {
QueryWrapper<BookInfo> bookInfoQueryWrapper = new QueryWrapper<>();
bookInfoQueryWrapper
.gt(DatabaseConsts.BookTable.COLUMN_WORD_COUNT,0)
.orderByDesc(DatabaseConsts.CommonColumnEnum.CREATE_TIME.getName());
return getBookRankRespDtos(bookInfoQueryWrapper);
return listRankBooks(bookInfoQueryWrapper);
}
/**
* 查询小说更新榜列表,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.BOOK_UPDATE_RANK_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.BOOK_UPDATE_RANK_CACHE_NAME)
public List<BookRankRespDto> listUpdateRankBooks() {
QueryWrapper<BookInfo> bookInfoQueryWrapper = new QueryWrapper<>();
bookInfoQueryWrapper
.gt(DatabaseConsts.BookTable.COLUMN_WORD_COUNT,0)
.orderByDesc(DatabaseConsts.CommonColumnEnum.UPDATE_TIME.getName());
return getBookRankRespDtos(bookInfoQueryWrapper);
return listRankBooks(bookInfoQueryWrapper);
}
private List<BookRankRespDto> getBookRankRespDtos(QueryWrapper<BookInfo> bookInfoQueryWrapper) {
bookInfoQueryWrapper.last(DatabaseConsts.SqlEnum.LIMIT_30.getSql());
private List<BookRankRespDto> listRankBooks(QueryWrapper<BookInfo> bookInfoQueryWrapper) {
bookInfoQueryWrapper
.gt(DatabaseConsts.BookTable.COLUMN_WORD_COUNT,0)
.last(DatabaseConsts.SqlEnum.LIMIT_30.getSql());
return bookInfoMapper.selectList(bookInfoQueryWrapper).stream().map(v -> {
BookRankRespDto respDto = new BookRankRespDto();
respDto.setId(v.getId());

View File

@ -27,8 +27,8 @@ public class FriendLinkCacheManager {
/**
* 友情链接列表查询,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER
, value = CacheConsts.HOME_FRIEND_LINK_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER,
value = CacheConsts.HOME_FRIEND_LINK_CACHE_NAME)
public List<HomeFriendLinkRespDto> listFriendLinks() {
// 从友情链接表中查询出友情链接列表
QueryWrapper<HomeFriendLink> queryWrapper = new QueryWrapper<>();

View File

@ -36,8 +36,8 @@ public class HomeBookCacheManager {
/**
* 查询首页小说推荐,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.HOME_BOOK_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.HOME_BOOK_CACHE_NAME)
public List<HomeBookRespDto> listHomeBooks() {
// 从首页小说推荐表中查询出需要推荐的小说
QueryWrapper<HomeBook> queryWrapper = new QueryWrapper<>();

View File

@ -27,8 +27,8 @@ public class NewsCacheManager {
/**
* 最新新闻列表查询,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER
, value = CacheConsts.LATEST_NEWS_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.CAFFEINE_CACHE_MANAGER,
value = CacheConsts.LATEST_NEWS_CACHE_NAME)
public List<NewsInfoRespDto> listLatestNews() {
// 从新闻信息表中查询出最新发布的两条新闻
QueryWrapper<NewsInfo> queryWrapper = new QueryWrapper<>();

View File

@ -25,11 +25,11 @@ public class UserInfoCacheManager {
/**
* 查询用户信息,并放入缓存中
*/
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER
, value = CacheConsts.USER_INFO_CACHE_NAME)
@Cacheable(cacheManager = CacheConsts.REDIS_CACHE_MANAGER,
value = CacheConsts.USER_INFO_CACHE_NAME)
public UserInfoDto getUser(Long userId) {
UserInfo userInfo = userInfoMapper.selectById(userId);
if(Objects.isNull(userInfo)){
if (Objects.isNull(userInfo)) {
return null;
}
return UserInfoDto.builder()

View File

@ -25,27 +25,25 @@ public class VerifyCodeManager {
private final StringRedisTemplate stringRedisTemplate;
/**
* 生成图验证码,并放入缓存
* 生成图验证码,并放入 Redis
*/
public String genImgVerifyCode(String sessionId) throws IOException {
String verifyCode = ImgVerifyCodeUtils.getRandomVerifyCode(4);
String img = ImgVerifyCodeUtils.genVerifyCodeImg(verifyCode);
stringRedisTemplate.opsForValue().set(CacheConsts.IMG_VERIFY_CODE_CACHE_KEY + sessionId
, verifyCode, Duration.ofMinutes(5));
stringRedisTemplate.opsForValue().set(CacheConsts.IMG_VERIFY_CODE_CACHE_KEY + sessionId,
verifyCode, Duration.ofMinutes(5));
return img;
}
/**
* 校验图验证码
* 校验图验证码
*/
public boolean imgVerifyCodeOk(String sessionId, String verifyCode) {
return Objects.equals(
stringRedisTemplate.opsForValue().get(CacheConsts.IMG_VERIFY_CODE_CACHE_KEY + sessionId)
, verifyCode);
return Objects.equals(stringRedisTemplate.opsForValue().get(CacheConsts.IMG_VERIFY_CODE_CACHE_KEY + sessionId), verifyCode);
}
/**
* 删除验证码
* 从 Redis 中删除验证码
*/
public void removeImgVerifyCode(String sessionId) {
stringRedisTemplate.delete(CacheConsts.IMG_VERIFY_CODE_CACHE_KEY + sessionId);

View File

@ -18,4 +18,11 @@ public interface AuthorService {
* @return void
*/
RestResp<Void> register(AuthorRegisterReqDto dto);
/**
* 查询作家状态
* @param userId 用户ID
* @return 作家状态
* */
RestResp<Integer> getStatus(Long userId);
}

View File

@ -1,5 +1,7 @@
package io.github.xxyopen.novel.service;
import io.github.xxyopen.novel.core.common.req.PageReqDto;
import io.github.xxyopen.novel.core.common.resp.PageRespDto;
import io.github.xxyopen.novel.core.common.resp.RestResp;
import io.github.xxyopen.novel.dto.req.BookAddReqDto;
import io.github.xxyopen.novel.dto.req.ChapterAddReqDto;
@ -128,32 +130,53 @@ public interface BookService {
/**
* 删除评论
*
* @param userId 评论用户ID
* @param commentId 评论ID
* @return void
* */
*/
RestResp<Void> deleteComment(Long userId, Long commentId);
/**
* 修改评论
*
* @param userId 用户ID
* @param id 评论ID
* @param content 修改后的评论内容
* @return void
* */
*/
RestResp<Void> updateComment(Long userId, Long id, String content);
/**
* 小说信息保存
*
* @param dto 小说信息
* @return void
* */
*/
RestResp<Void> saveBook(BookAddReqDto dto);
/**
* 小说章节信息保存
*
* @param dto 章节信息
* @return void
* */
*/
RestResp<Void> saveBookChapter(ChapterAddReqDto dto);
/**
* 查询作家发布小说列表
*
* @param dto 分页请求参数
* @return 小说分页列表数据
*/
RestResp<PageRespDto<BookInfoRespDto>> listAuthorBooks(PageReqDto dto);
/**
* 查询小说发布章节列表
*
* @param bookId 小说ID
* @param dto 分页请求参数
* @return 章节分页列表数据
*/
RestResp<PageRespDto<BookChapterRespDto>> listBookChapters(Long bookId, PageReqDto dto);
}

View File

@ -54,4 +54,10 @@ public class AuthorServiceImpl implements AuthorService {
return RestResp.ok();
}
@Override
public RestResp<Integer> getStatus(Long userId) {
AuthorInfoDto author = authorInfoCacheManager.getAuthor(userId);
return Objects.isNull(author) ? RestResp.ok(null) : RestResp.ok(author.getStatus());
}
}

View File

@ -1,8 +1,12 @@
package io.github.xxyopen.novel.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.github.xxyopen.novel.core.auth.UserHolder;
import io.github.xxyopen.novel.core.common.constant.ErrorCodeEnum;
import io.github.xxyopen.novel.core.common.req.PageReqDto;
import io.github.xxyopen.novel.core.common.resp.PageRespDto;
import io.github.xxyopen.novel.core.common.resp.RestResp;
import io.github.xxyopen.novel.core.constant.DatabaseConsts;
import io.github.xxyopen.novel.dao.entity.*;
@ -187,6 +191,7 @@ public class BookServiceImpl implements BookService {
return RestResp.ok(bookChapterMapper.selectList(queryWrapper).stream().map(v -> BookChapterRespDto.builder()
.id(v.getId())
.chapterName(v.getChapterName())
.isVip(v.getIsVip())
.build()).toList());
}
@ -199,9 +204,9 @@ public class BookServiceImpl implements BookService {
public RestResp<Void> saveComment(UserCommentReqDto dto) {
// 校验用户是否已发表评论
QueryWrapper<BookComment> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(DatabaseConsts.BookCommentTable.COLUMN_USER_ID,dto.getUserId())
.eq(DatabaseConsts.BookCommentTable.COLUMN_BOOK_ID,dto.getBookId());
if(bookCommentMapper.selectCount(queryWrapper) > 0){
queryWrapper.eq(DatabaseConsts.BookCommentTable.COLUMN_USER_ID, dto.getUserId())
.eq(DatabaseConsts.BookCommentTable.COLUMN_BOOK_ID, dto.getBookId());
if (bookCommentMapper.selectCount(queryWrapper) > 0) {
// 用户已发表评论
return RestResp.fail(ErrorCodeEnum.USER_COMMENTED);
}
@ -254,7 +259,7 @@ public class BookServiceImpl implements BookService {
public RestResp<Void> deleteComment(Long userId, Long commentId) {
QueryWrapper<BookComment> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(DatabaseConsts.CommonColumnEnum.ID.getName(), commentId)
.eq(DatabaseConsts.BookCommentTable.COLUMN_USER_ID,userId);
.eq(DatabaseConsts.BookCommentTable.COLUMN_USER_ID, userId);
bookCommentMapper.delete(queryWrapper);
return RestResp.ok();
}
@ -263,15 +268,21 @@ public class BookServiceImpl implements BookService {
public RestResp<Void> updateComment(Long userId, Long id, String content) {
QueryWrapper<BookComment> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(DatabaseConsts.CommonColumnEnum.ID.getName(), id)
.eq(DatabaseConsts.BookCommentTable.COLUMN_USER_ID,userId);
.eq(DatabaseConsts.BookCommentTable.COLUMN_USER_ID, userId);
BookComment bookComment = new BookComment();
bookComment.setCommentContent(content);
bookCommentMapper.update(bookComment,queryWrapper);
bookCommentMapper.update(bookComment, queryWrapper);
return RestResp.ok();
}
@Override
public RestResp<Void> saveBook(BookAddReqDto dto) {
// 校验小说名是否已存在
QueryWrapper<BookInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(DatabaseConsts.BookTable.COLUMN_BOOK_NAME, dto.getBookName());
if (bookInfoMapper.selectCount(queryWrapper) > 0) {
return RestResp.fail(ErrorCodeEnum.AUTHOR_BOOK_NAME_EXIST);
}
BookInfo bookInfo = new BookInfo();
// 设置作家信息
AuthorInfoDto author = authorInfoCacheManager.getAuthor(UserHolder.getUserId());
@ -296,15 +307,20 @@ public class BookServiceImpl implements BookService {
@Transactional(rollbackFor = Exception.class)
@Override
public RestResp<Void> saveBookChapter(ChapterAddReqDto dto) {
// 校验该作品是否属于当前作家
BookInfo bookInfo = bookInfoMapper.selectById(dto.getBookId());
if (!Objects.equals(bookInfo.getAuthorId(), UserHolder.getAuthorId())) {
return RestResp.fail(ErrorCodeEnum.USER_UN_AUTH);
}
// 1) 保存章节相关信息到小说章节表
// a) 查询最新章节号
int chapterNum = 0;
QueryWrapper<BookChapter> chapterQueryWrapper = new QueryWrapper<>();
chapterQueryWrapper.eq(DatabaseConsts.BookChapterTable.COLUMN_BOOK_ID,dto.getBookId())
chapterQueryWrapper.eq(DatabaseConsts.BookChapterTable.COLUMN_BOOK_ID, dto.getBookId())
.orderByDesc(DatabaseConsts.BookChapterTable.COLUMN_CHAPTER_NUM)
.last(DatabaseConsts.SqlEnum.LIMIT_1.getSql());
BookChapter bookChapter = bookChapterMapper.selectOne(chapterQueryWrapper);
if(Objects.nonNull(bookChapter)){
if (Objects.nonNull(bookChapter)) {
chapterNum = bookChapter.getChapterNum() + 1;
}
// b) 设置章节相关信息并保存
@ -328,7 +344,6 @@ public class BookServiceImpl implements BookService {
// 3) 更新小说表最新章节信息和小说总字数信息
// a) 更新小说表关于最新章节的信息
BookInfoRespDto bookInfo = bookInfoCacheManager.getBookInfo(dto.getBookId());
BookInfo newBookInfo = new BookInfo();
newBookInfo.setId(dto.getBookId());
newBookInfo.setLastChapterId(newBookChapter.getId());
@ -337,15 +352,55 @@ public class BookServiceImpl implements BookService {
newBookInfo.setWordCount(bookInfo.getWordCount() + newBookChapter.getWordCount());
newBookChapter.setUpdateTime(LocalDateTime.now());
bookInfoMapper.updateById(newBookInfo);
// b) 刷新小说信息缓存
bookInfoCacheManager.cachePutBookInfo(dto.getBookId());
// b) 清除小说信息缓存
bookInfoCacheManager.evictBookInfoCache(dto.getBookId());
// c) 发送小说信息更新的 MQ 消息
amqpMsgManager.sendBookChangeMsg(dto.getBookId());
return RestResp.ok();
}
@Override
public RestResp<PageRespDto<BookInfoRespDto>> listAuthorBooks(PageReqDto dto) {
IPage<BookInfo> page = new Page<>();
page.setCurrent(dto.getPageNum());
page.setSize(dto.getPageSize());
QueryWrapper<BookInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(DatabaseConsts.BookTable.AUTHOR_ID, UserHolder.getAuthorId())
.orderByDesc(DatabaseConsts.CommonColumnEnum.CREATE_TIME.getName());
IPage<BookInfo> bookInfoPage = bookInfoMapper.selectPage(page, queryWrapper);
return RestResp.ok(PageRespDto.of(dto.getPageNum(), dto.getPageSize(), page.getTotal(),
bookInfoPage.getRecords().stream().map(v -> BookInfoRespDto.builder()
.id(v.getId())
.bookName(v.getBookName())
.picUrl(v.getPicUrl())
.categoryName(v.getCategoryName())
.wordCount(v.getWordCount())
.visitCount(v.getVisitCount())
.updateTime(v.getUpdateTime())
.build()).toList()));
}
@Override
public RestResp<PageRespDto<BookChapterRespDto>> listBookChapters(Long bookId, PageReqDto dto) {
IPage<BookChapter> page = new Page<>();
page.setCurrent(dto.getPageNum());
page.setSize(dto.getPageSize());
QueryWrapper<BookChapter> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(DatabaseConsts.BookChapterTable.COLUMN_BOOK_ID, bookId)
.orderByDesc(DatabaseConsts.BookChapterTable.COLUMN_CHAPTER_NUM);
IPage<BookChapter> bookChapterPage = bookChapterMapper.selectPage(page, queryWrapper);
return RestResp.ok(PageRespDto.of(dto.getPageNum(), dto.getPageSize(), page.getTotal(),
bookChapterPage.getRecords().stream().map(v -> BookChapterRespDto.builder()
.id(v.getId())
.chapterName(v.getChapterName())
.chapterUpdateTime(v.getUpdateTime())
.isVip(v.getIsVip())
.build()).toList()));
}
@Override
public RestResp<BookContentAboutRespDto> getBookContentAbout(Long chapterId) {
log.debug("userId:{}", UserHolder.getUserId());
// 查询章节信息
BookChapterRespDto bookChapter = bookChapterCacheManager.getChapter(chapterId);

View File

@ -35,8 +35,8 @@ public class DbSearchServiceImpl implements SearchService {
page.setCurrent(condition.getPageNum());
page.setSize(condition.getPageSize());
List<BookInfo> bookInfos = bookInfoMapper.searchBooks(page, condition);
return RestResp.ok(PageRespDto.of(condition.getPageNum(), condition.getPageSize(), page.getTotal()
, bookInfos.stream().map(v -> BookInfoRespDto.builder()
return RestResp.ok(PageRespDto.of(condition.getPageNum(), condition.getPageSize(), page.getTotal(),
bookInfos.stream().map(v -> BookInfoRespDto.builder()
.id(v.getId())
.bookName(v.getBookName())
.categoryId(v.getCategoryId())

View File

@ -54,9 +54,8 @@ public class EsSearchServiceImpl implements SearchService {
buildSearchCondition(condition, searchBuilder);
// 排序
if (!StringUtils.isBlank(condition.getSort())) {
searchBuilder.sort(o ->
o.field(f -> f.field(StringUtils
.underlineToCamel(condition.getSort().split(" ")[0]))
searchBuilder.sort(o -> o.field(f -> f
.field(StringUtils.underlineToCamel(condition.getSort().split(" ")[0]))
.order(SortOrder.Desc))
);
}
@ -64,10 +63,10 @@ public class EsSearchServiceImpl implements SearchService {
searchBuilder.from((condition.getPageNum() - 1) * condition.getPageSize())
.size(condition.getPageSize());
// 设置高亮显示
searchBuilder.highlight(h -> h.fields(EsConsts.BookIndex.FIELD_BOOK_NAME
, t -> t.preTags("<em style='color:red'>").postTags("</em>"))
.fields(EsConsts.BookIndex.FIELD_AUTHOR_NAME
, t -> t.preTags("<em style='color:red'>").postTags("</em>")));
searchBuilder.highlight(h -> h.fields(EsConsts.BookIndex.FIELD_BOOK_NAME,
t -> t.preTags("<em style='color:red'>").postTags("</em>"))
.fields(EsConsts.BookIndex.FIELD_AUTHOR_NAME,
t -> t.preTags("<em style='color:red'>").postTags("</em>")));
return searchBuilder;
},
@ -110,12 +109,18 @@ public class EsSearchServiceImpl implements SearchService {
BoolQuery boolQuery = BoolQuery.of(b -> {
// 只查有字数的小说
b.must(RangeQuery.of(m -> m
.field(EsConsts.BookIndex.FIELD_WORD_COUNT)
.gt(JsonData.of(0))
)._toQuery());
if (!StringUtils.isBlank(condition.getKeyword())) {
// 关键词匹配
b.must((q -> q.multiMatch(t -> t
.fields(EsConsts.BookIndex.FIELD_BOOK_NAME + "^2"
, EsConsts.BookIndex.FIELD_AUTHOR_NAME + "^1.8"
, EsConsts.BookIndex.FIELD_BOOK_DESC + "^0.1")
.fields(EsConsts.BookIndex.FIELD_BOOK_NAME + "^2",
EsConsts.BookIndex.FIELD_AUTHOR_NAME + "^1.8",
EsConsts.BookIndex.FIELD_BOOK_DESC + "^0.1")
.query(condition.getKeyword())
)
));

View File

@ -20,6 +20,60 @@ spring:
url: jdbc:mysql://localhost:3306/novel_test?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: test123456
# ShardingSphere-JDBC 配置
# 配置是 ShardingSphere-JDBC 中唯一与应用开发者交互的模块,
# 通过它可以快速清晰的理解 ShardingSphere-JDBC 所提供的功能。
shardingsphere:
# 是否开启分库分表
enabled: false
props:
# 是否在日志中打印 SQL
sql-show: true
# 模式配置
mode:
# 单机模式
type: Standalone
repository:
# 文件持久化
type: File
props:
# 元数据存储路径
path: .shardingsphere
# 使用本地配置覆盖持久化配置
overwrite: true
# 数据源配置
datasource:
names: ds_0
ds_0:
type: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://localhost:3306/novel_test?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: test123456
# 规则配置
rules:
# 数据分片
sharding:
tables:
# book_content 表
book_content:
# 数据节点
actual-data-nodes: ds_$->{0}.book_content$->{0..9}
# 分表策略
table-strategy:
standard:
# 分片列名称
sharding-column: chapter_id
# 分片算法名称
sharding-algorithm-name: bookContentSharding
sharding-algorithms:
bookContentSharding:
# 行表达式分片算法,使用 Groovy 的表达式,提供对 SQL 语句中的 = 和 IN 的分片操作支持
type: INLINE
props:
# 分片算法的行表达式
algorithm-expression: book_content$->{chapter_id % 10}
config:
activate:
on-profile: dev
@ -58,6 +112,23 @@ spring:
# 第一次和第二次重试之间的持续时间
initial-interval: "3s"
# XXL-JOB 配置
xxl:
job:
# 是否开启 XXL-JOBtrue-开启 false-不开启
enable: false
admin:
### 调度中心部署根地址 [选填]:如调度中心集群部署存在多个地址则用逗号分隔。执行器将会使用该地址进行"执行器心跳注册"和"任务结果回调";为空则关闭自动注册;
addresses: http://127.0.0.1:8080/xxl-job-admin
executor:
### 执行器AppName [选填]:执行器心跳注册分组依据;为空则关闭自动注册
appname: xxl-job-executor-novel
### 执行器运行日志文件存储磁盘路径 [选填] :需要对该路径拥有读写权限;为空则使用默认路径;
logpath: logs/xxl-job/jobhandler
### xxl-job, access token
accessToken: 123
---
spring:
config: