Compare commits

...

35 Commits

Author SHA1 Message Date
xiongxiaoyang
f3f37721b1 Merge remote-tracking branch 'Gitee/develop_xxy' into develop_xxy 2025-12-27 13:08:56 +08:00
xiongxiaoyang
0f7d81de8d fix(novel-front): 修复作品评论区分页错误 2025-12-27 13:06:47 +08:00
xxy
6fc8e9634d Update README.md 2025-11-09 11:55:18 +08:00
xiongxiaoyang
313e73c63b v5.3.0 发布 2025-10-26 10:54:07 +08:00
xiongxiaoyang
3d1b952e1a perf(novel-crawl): 优化采集监控页异常提示体验
在爬虫采集监控页面,针对网络或服务异常情况,避免频繁弹窗提示,提升用户体验。
2025-10-25 19:19:45 +08:00
xiongxiaoyang
6b72d4856d feat(novel-admin): 新增小说下载功能
为满足部分用户将小说下载至手机阅读的需求,新增管理后台小说下载功能。

该功能未在前台开放,主要基于以下考量:
1. 防止用户流失,保障网站留存率及广告收入
2. 避免大量下载请求带来额外的服务器流量与带宽压力

通过后台受限访问的方式,在满足特定需求的同时,兼顾系统稳定性与商业可持续性。
2025-10-25 13:00:22 +08:00
xiongxiaoyang
1aa86bdaec chore(novel-crawl): 修改生产环境外部配置文件 2025-10-25 12:00:21 +08:00
xiongxiaoyang
bd12d2f2a5 fix(novel-admin): 修复小说推荐列表分页显示异常
推荐小说不存在时,分页会显示异常
2025-10-25 11:35:37 +08:00
xiongxiaoyang
938ae8571d feat(novel-crawl): 新增磁盘保护机制,支持邮件告警与自动熔断
- 实现定时检测磁盘使用率,避免爬虫耗尽磁盘资源
- 当磁盘使用率达到 85%、90%、95% 时,分别发送告警邮件
- 当使用率达到 95% 时,强制停止当前爬虫进程
2025-10-24 19:04:50 +08:00
xiongxiaoyang
0279a86e56 fix: 修复中国部分 IP 无省份信息导致地理位置显示为“0”的问题
部分中国 IP(如 46.248.24.0-46.248.25.255)缺少省份信息,返回值为“0”。
现优化为直接显示国家名称“中国”。
2025-10-14 10:17:47 +08:00
xiongxiaoyang
43213adba4 v5.2.6 发布 2025-10-04 21:42:35 +08:00
xiongxiaoyang
f49d0dd1c0 fix: 修复因缓存 key 未更新导致小说推荐无法刷新的问题 2025-10-04 19:53:51 +08:00
xiongxiaoyang
803607350e perf: 禁用爬虫的 Cookie 管理以绕过 Cookie 限制
有助于绕过部分通过 Cookie 来识别爬虫的反爬机制。
2025-10-01 13:36:18 +08:00
xiongxiaoyang
8448e86ac5 feat(windows): 增加生产环境 Windows 一键启动脚本
- 支持从任意路径执行,自动定位项目根目录
- 使用 %~dp0.. 技术确保 user.dir 正确
- 默认加载 prod 环境配置:-Dspring.profiles.active=prod
- 包含 pause 命令,便于查看启动错误
- 支持宝塔 Windows 面板项目执行命令:C:\novel-plus\novel-fornt\bin\novel-front.bat
- 提升 Windows 服务器上的运维效率和可维护性
2025-09-29 17:35:36 +08:00
xiongxiaoyang
1ad240c7f9 perf(linux): 支持从任意路径执行启动脚本
- 使用 readlink 定位脚本真实路径
- 启动时自动切换到项目目录
2025-09-29 17:30:12 +08:00
xiongxiaoyang
0564871093 v5.2.5 发布 2025-08-14 22:36:37 +08:00
xiongxiaoyang
d6faab8ca1 fix: 删除废弃接口
可能导致 XSS 漏洞
2025-08-14 22:06:12 +08:00
xiongxiaoyang
1ef64edcda v5.2.4 发布 2025-07-25 20:09:50 +08:00
xiongxiaoyang
c24c68ecaf perf: 优化缓存模块
提升可读性 & 减小内存占用
2025-07-25 17:03:46 +08:00
xiongxiaoyang
7e27456a65 build:打包时复制最新模版文件 2025-07-25 13:00:00 +08:00
xiongxiaoyang
d4e8fb1cc7 模版更新 2025-07-25 12:58:18 +08:00
xiongxiaoyang
d4e1126873 perf: UI优化 2025-07-22 11:55:27 +08:00
xiongxiaoyang
84a90bbc34 v5.2.3 发布 2025-07-19 18:22:25 +08:00
xiongxiaoyang
b2d8fd8c66 feat(AI): 更新默认AI对话模型
- 原默认AI对话模型 deepseek-ai/DeepSeek-R1-Distill-Llama-8B 已从硅基流动模型广场下线。
- 此次更新将项目中使用的默认AI对话模型更改为新的可用模型 deepseek-ai/DeepSeek-R1-0528-Qwen3-8B。
2025-07-19 17:27:45 +08:00
xiongxiaoyang
11d9d6f6e8 refactor: 重构排序参数处理代码 2025-07-19 17:27:21 +08:00
xiongxiaoyang
8c7b891af2 build(AI): Spring AI 升级到 1.0.0 2025-07-18 21:24:24 +08:00
xiongxiaoyang
bb1a87e337 chore(sql): 内置海外专用源 2025-07-18 20:41:00 +08:00
xiongxiaoyang
1cd8a49fd4 perf: 优化排序参数校验 2025-07-18 16:21:35 +08:00
xiongxiaoyang
773ce159f7 v5.2.2 发布 2025-07-17 21:14:24 +08:00
xiongxiaoyang
91e7d2712b refactor: 重构sort和order参数校验功能 2025-07-17 20:53:41 +08:00
xiongxiaoyang
3db8828384 fix: 修复sort和order参数的SQL注入漏洞 2025-07-17 19:03:58 +08:00
xiongxiaoyang
54bd194b98 feat(novel-crawl): 增加爬虫源采集章节数量监控功能
可以监测到爬虫源在当前环境下是否可用
2025-07-16 19:52:07 +08:00
xiongxiaoyang
3d41cf3ebb perf(novel-crawl): 优化爬虫源列表排序
按照更新时间倒序
2025-07-15 18:53:31 +08:00
xiongxiaoyang
720711414c v5.2.1 发布 2025-07-14 22:00:34 +08:00
xiongxiaoyang
522bb7c739 fix(novel-front): 修复评论回复中的XSS漏洞 2025-07-14 21:02:13 +08:00
96 changed files with 1603 additions and 379 deletions

View File

@@ -6,6 +6,8 @@
<a href='https://github.com/201206030/novel-plus'><img alt="Github forks" src="https://img.shields.io/github/forks/201206030/novel-plus?logo=github"></a>
<a href='https://gitee.com/novel_dev_team/novel-plus'><img alt="Gitee stars" src="https://gitee.com/novel_dev_team/novel-plus/badge/star.svg?theme=gitee"></a>
<a href='https://gitee.com/novel_dev_team/novel-plus'><img alt="Gitee forks" src="https://gitee.com/novel_dev_team/novel-plus/badge/fork.svg?theme=gitee"></a>
<a href='https://github.com/201206030/novel-plus/releases'><img alt="GitHub downloads" src="https://img.shields.io/github/downloads/201206030/novel-plus/total.svg"></a>
<a href='https://hub.docker.com/u/201206030'><img alt="Docker pull" src="https://img.shields.io/docker/pulls/201206030/novel-front"></a>
</p>
<p align="center">
@@ -81,7 +83,7 @@ novel-plus 5.x 已集成 Spring 官方最新发布的 Spring AI 框架,并推
我们将持续关注 AI 技术的发展并致力于将其与小说创作场景深度融合为用户带来更智能更便捷的创作工具
novel-plus 项目默认使用的是第三方大模型服务平台[硅基流动](https://cloud.siliconflow.cn/i/DOgMRH9S)提供的 API兼容 OpenAI 的相关接口,可直接通过 Spring AI 框架调用),采用的 AI 模型有对话模型`deepseek-ai/DeepSeek-R1-Distill-Llama-8B`DeepSeek-R1 的蒸馏版本,免费使用)和生图模型`Kwai-Kolors/Kolors`(快手 Kolors 团队开发的文本到图像生成模型,免费使用)。只需注册一个硅基流动账号,创建一个 API 密钥,并将其添加到 novel-plus 项目 novel-front 模块的 yaml 配置文件中,即可体验 novel-plus 项目的 AI 写作功能。
novel-plus 项目默认使用的是第三方大模型服务平台[硅基流动](https://cloud.siliconflow.cn/i/DOgMRH9S)提供的 API兼容 OpenAI 的相关接口,可直接通过 Spring AI 框架调用),采用的 AI 模型有对话模型`deepseek-ai/DeepSeek-R1-0528-Qwen3-8B`DeepSeek-R1 的蒸馏版本,免费使用)和生图模型`Kwai-Kolors/Kolors`(快手 Kolors 团队开发的文本到图像生成模型,免费使用)。只需注册一个硅基流动账号,创建一个 API 密钥,并将其添加到 novel-plus 项目 novel-front 模块的 yaml 配置文件中,即可体验 novel-plus 项目的 AI 写作功能。
```yaml
spring:
@@ -98,7 +100,7 @@ spring:
base-url: https://api.siliconflow.cn
chat:
options:
model: deepseek-ai/DeepSeek-R1-Distill-Llama-8B
model: deepseek-ai/DeepSeek-R1-0528-Qwen3-8B
```
novel-plus 项目默认使用的都是免费 AI 模型生成效果有限如果对生成内容有更高的要求建议选用付费的 AI 模型

View File

@@ -3174,4 +3174,43 @@ CREATE TABLE `book_comment_reply`
`create_user_id` bigint(20) DEFAULT NULL COMMENT '回复时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='小说评论回复表';
DEFAULT CHARSET = utf8mb4 COMMENT ='小说评论回复表';
INSERT INTO crawl_source (source_name, crawl_rule, source_status, create_time
, update_time)
VALUES ('飘天文学网海外专用', '{
"bookListUrl": "https://www.piaotia.com/booksort{catId}/{page}.html",
"catIdRule": {
"catId1": "1/0",
"catId2": "2/0",
"catId3": "3/0",
"catId4": "4/0",
"catId5": "6/0",
"catId6": "5/0"
},
"bookIdPatten": "href=\\"https://www.piaotia.com/bookinfo/(\\\\d+/\\\\d+).html\\"",
"pagePatten": "<em\\\\s+id=\\"pagestats\\">(\\\\d+)/\\\\d+</em>",
"totalPagePatten": "<em\\\\s+id=\\"pagestats\\">\\\\d+/(\\\\d+)</em>",
"bookDetailUrl": "https://www.piaotia.com/bookinfo/{bookId}.html",
"bookNamePatten": "<h1>([^/]+)</h1>",
"authorNamePatten": "<td\\\\s+width=\\"\\\\d+%\\">作&nbsp;&nbsp;&nbsp; 者:([^/]+)<",
"picUrlPatten": "<img\\\\s+src=\\"(https://www.piaotia.com/files/article/image/[^\\"]+)\\"",
"statusPatten": "<td>文章状态:([^/]+)</td>",
"bookStatusRule": {
"连载中": 0,
"已完成": 1
},
"descStart": " <span class=\\"hottext\\">内容简介:</span><br />",
"descEnd": "</td>",
"filterDesc": "",
"bookIndexUrl": "https://www.piaotia.com/html/{bookId}/index.html",
"indexIdPatten": "<li><a href=\\"(\\\\d+).html\\">[^/]+</a></li>",
"indexNamePatten": "<li><a href=\\"\\\\d+.html\\">([^/]+)</a></li>",
"bookContentUrl": "https://www.piaotia.com/html/{bookId}/{indexId}.html",
"contentStart": "<br>",
"contentEnd": "</div>",
"filterContent": "",
"charset": "gbk"
}', 0, '2025-07-13 18:57:39'
, '2025-07-13 18:57:39');

View File

@@ -5,7 +5,7 @@
<groupId>com.java2nb</groupId>
<artifactId>novel-admin</artifactId>
<version>5.2.0</version>
<version>5.3.0</version>
<packaging>jar</packaging>
<name>novel-admin</name>

View File

@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0.."
java -jar -Dspring.profiles.active=prod novel-admin.jar
pause

View File

@@ -5,9 +5,12 @@ JAR_NAME=$APP_NAME\.jar
PID=$APP_NAME\.pid
#使用说明,用来提示输入参数
SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
cd "$SCRIPT_DIR"/.. || exit 1
# 使用说明
usage() {
echo "Usage: ./novel-admin.sh [start|stop|restart|status]"
echo "Usage: $0 [start|stop|restart|status]"
exit 1
}

View File

@@ -0,0 +1,14 @@
package com.java2nb.common.annotation;
import java.lang.annotation.*;
/**
* 标记某个方法参数需要进行 Map 字段的清理和标准化处理。
*
* <p>通常用于 DAO 接口中 list 方法的 Map 参数,用于防止非法排序字段或排序顺序。</p>
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SanitizeMap {
}

View File

@@ -0,0 +1,69 @@
package com.java2nb.common.aspect;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.common.utils.SortWhitelistUtil;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
/**
* 拦截所有 Mapper 接口的 list* 方法,对带有 @SanitizeMap 注解的 Map 参数进行排序字段和顺序的规范化处理。
*
* <p>主要防止 SQL 注入或非法排序字段、非法排序顺序的问题。
* 例如对 sort 和 order 字段进行白名单过滤和标准化处理。</p>
*/
@Aspect
@Component
@RequiredArgsConstructor
public class MapSortValidationAspect {
/**
* 拦截所有 Mapper 接口的 list* 方法(如 list(), listByPage 等)。
* 对带有 @SanitizeMap 注解的 Map 参数进行处理。
*
* <p>执行逻辑:</p>
* <ol>
* <li>获取方法参数及注解信息</li>
* <li>遍历所有参数,检查是否带有 @SanitizeMap 注解</li>
* <li>如果参数是 Map 类型且有注解,则进行字段清理</li>
* </ol>
*
* @param joinPoint 切点信息
* @return 方法执行结果
*/
@SneakyThrows
@Around("execution(* com.java2nb.*.dao.*Dao.list*(..))")
public Object sanitizeMapParameters(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Annotation[][] parameterAnnotations = method.getParameterAnnotations();
for (int i = 0; i < parameterAnnotations.length; i++) {
boolean hasAnnotation = Arrays.stream(parameterAnnotations[i])
.anyMatch(a -> a.annotationType().equals(SanitizeMap.class));
if (hasAnnotation && args[i] instanceof Map map) {
if (map.get("sort") instanceof String sortStr) {
map.put("sort", SortWhitelistUtil.sanitizeColumn(sortStr));
}
if (map.get("order") instanceof String orderStr) {
map.put("order", SortWhitelistUtil.sanitizeOrder(orderStr));
}
}
}
return joinPoint.proceed(args);
}
}

View File

@@ -8,7 +8,7 @@ public interface CacheKey {
/**
* 首页小说设置
*/
String INDEX_BOOK_SETTINGS_KEY = "indexBookSettingsKey";
String INDEX_BOOK_SETTINGS_KEY = "indexBookSettingsKey:v2";
/**
* 首页新闻

View File

@@ -15,7 +15,7 @@ public class Constant {
//通知公告阅读状态-已读
public static int OA_NOTIFY_READ_YES = 1;
//部门根节点id
public static Long DEPT_ROOT_ID = 0l;
public static Long DEPT_ROOT_ID = 0L;
//缓存方式
public static String CACHE_TYPE_REDIS = "redis";
@@ -23,5 +23,6 @@ public class Constant {
public static final String UPLOAD_FILES_PREFIX = "/files/";
public static final String BOOK_IS_DOWNLOADING_KEY = "bookIsDownloading:";
}

View File

@@ -1,5 +1,6 @@
package com.java2nb.common.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.common.domain.DictDO;
import java.util.List;
@@ -19,7 +20,7 @@ public interface DictDao {
DictDO get(Long id);
List<DictDO> list(Map<String, Object> map);
List<DictDO> list(@SanitizeMap Map<String, Object> map);
int count(Map<String, Object> map);

View File

@@ -1,5 +1,6 @@
package com.java2nb.common.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.common.domain.FileDO;
import java.util.List;
@@ -18,7 +19,7 @@ public interface FileDao {
FileDO get(Long id);
List<FileDO> list(Map<String,Object> map);
List<FileDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -3,6 +3,7 @@ package com.java2nb.common.dao;
import java.util.List;
import java.util.Map;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.common.domain.GenColumnsDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -19,7 +20,7 @@ public interface GenColumnsDao {
GenColumnsDO get(Long id);
List<GenColumnsDO> list(Map<String,Object> map);
List<GenColumnsDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,6 @@
package com.java2nb.common.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.common.domain.LogDO;
import java.util.List;
@@ -18,7 +19,7 @@ public interface LogDao {
LogDO get(Long id);
List<LogDO> list(Map<String,Object> map);
List<LogDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -0,0 +1,43 @@
package com.java2nb.common.utils;
import lombok.experimental.UtilityClass;
import java.util.Set;
/**
* 排序字段和排序顺序的白名单工具类。
*/
@UtilityClass
public class SortWhitelistUtil {
// 白名单字段
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "name", "order_num","index_num");
// 白名单排序方式
private static final Set<String> ALLOWED_ORDERS = Set.of("asc", "desc");
/**
* 对排序字段进行白名单过滤和标准化。
*
* @param column 原始字段名
* @return 安全的字段名,若非法则返回 null
*/
public static String sanitizeColumn(String column) {
if (column == null) return null;
String lower = column.trim().toLowerCase();
return ALLOWED_COLUMNS.contains(lower) ? lower : null;
}
/**
* 对排序方式进行白名单过滤和标准化。
*
* @param order 原始排序方式
* @return 安全的排序方式("asc" 或 "desc"),若非法则返回 null
*/
public static String sanitizeOrder(String order) {
if (order == null) return null;
String lower = order.trim().toLowerCase();
return ALLOWED_ORDERS.contains(lower) ? lower : null;
}
}

View File

@@ -1,10 +1,24 @@
package com.java2nb.novel.controller;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.java2nb.common.config.Constant;
import com.java2nb.novel.domain.BookContentDO;
import com.java2nb.novel.domain.BookIndexDO;
import com.java2nb.novel.service.BookContentService;
import com.java2nb.novel.service.BookIndexService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -22,6 +36,9 @@ import com.java2nb.common.utils.PageBean;
import com.java2nb.common.utils.Query;
import com.java2nb.common.utils.R;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* 小说表
*
@@ -30,12 +47,34 @@ import com.java2nb.common.utils.R;
* @date 2020-12-01 03:49:46
*/
@Slf4j
@Controller
@RequestMapping("/novel/book")
public class BookController {
@Autowired
private BookService bookService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private BookIndexService bookIndexService;
@Autowired
private BookContentService bookContentService;
private final Pattern PATTERN_BR = Pattern.compile("<br\\s*/?>", Pattern.CASE_INSENSITIVE);
private final Pattern PATTERN_NBSP = Pattern.compile("&nbsp;", Pattern.CASE_INSENSITIVE);
private final Pattern PATTERN_ANCHOR_OPEN = Pattern.compile("<a[^>]*>", Pattern.CASE_INSENSITIVE);
private final Pattern PATTERN_ANCHOR_CLOSE = Pattern.compile("</a>", Pattern.CASE_INSENSITIVE);
private final Pattern PATTERN_DIV_OPEN = Pattern.compile("<div[^>]*>", Pattern.CASE_INSENSITIVE);
private final Pattern PATTERN_DIV_CLOSE = Pattern.compile("</div>", Pattern.CASE_INSENSITIVE);
private final Pattern PATTERN_EMPTY_PARAGRAPH_WITH_LINK = Pattern.compile("<p[^>]*>[^<]*<a[^>]*>[^<]*</a>\\s*</p>", Pattern.CASE_INSENSITIVE);
private final Pattern PATTERN_P_OPEN = Pattern.compile("<p[^>]*>", Pattern.CASE_INSENSITIVE);
private final Pattern PATTERN_P_CLOSE = Pattern.compile("</p>", Pattern.CASE_INSENSITIVE);
@GetMapping()
@RequiresPermissions("novel:book:book")
String Book() {
@@ -66,7 +105,7 @@ public class BookController {
@GetMapping("/edit/{id}")
@RequiresPermissions("novel:book:edit")
String edit(@PathVariable("id") Long id, Model model) {
BookDO book = bookService.get(id);
BookDO book = bookService.get(id);
model.addAttribute("book", book);
return "novel/book/edit";
}
@@ -75,7 +114,7 @@ public class BookController {
@GetMapping("/detail/{id}")
@RequiresPermissions("novel:book:detail")
String detail(@PathVariable("id") Long id, Model model) {
BookDO book = bookService.get(id);
BookDO book = bookService.get(id);
model.addAttribute("book", book);
return "novel/book/detail";
}
@@ -87,7 +126,7 @@ public class BookController {
@ResponseBody
@PostMapping("/save")
@RequiresPermissions("novel:book:add")
public R save( BookDO book) {
public R save(BookDO book) {
if (bookService.save(book) > 0) {
return R.ok();
}
@@ -101,8 +140,8 @@ public class BookController {
@ResponseBody
@RequestMapping("/update")
@RequiresPermissions("novel:book:edit")
public R update( BookDO book) {
bookService.update(book);
public R update(BookDO book) {
bookService.update(book);
return R.ok();
}
@@ -113,7 +152,7 @@ public class BookController {
@PostMapping("/remove")
@ResponseBody
@RequiresPermissions("novel:book:remove")
public R remove( Long id) {
public R remove(Long id) {
if (bookService.remove(id) > 0) {
return R.ok();
}
@@ -128,8 +167,83 @@ public class BookController {
@ResponseBody
@RequiresPermissions("novel:book:batchRemove")
public R remove(@RequestParam("ids[]") Long[] ids) {
bookService.batchRemove(ids);
bookService.batchRemove(ids);
return R.ok();
}
/**
* 小说下载
*/
@RequestMapping(value = "/download")
public void download(@RequestParam("bookId") Long bookId, @RequestParam("bookName") String bookName,
HttpServletResponse resp) {
try {
OutputStream out = resp.getOutputStream();
Boolean success = redisTemplate
.opsForValue()
.setIfAbsent(Constant.BOOK_IS_DOWNLOADING_KEY + bookId, "1", 10, TimeUnit.MINUTES);
if (Boolean.FALSE.equals(success)) {
resp.setContentType("text/html;charset=UTF-8");
out.write("该小说正在下载中,请稍后重试!".getBytes(StandardCharsets.UTF_8));
out.close();
return;
}
//设置响应头对文件进行url编码
bookName = URLEncoder.encode(bookName, StandardCharsets.UTF_8);
//解决手机端不能下载附件的问题
resp.setContentType("application/octet-stream");
resp.setHeader("Content-Disposition", "attachment;filename=" + bookName + ".txt");
Map<String, Object> params = new HashMap<>();
params.put("bookId", bookId);
params.put("sort", "index_num");
params.put("order", "asc");
params.put("limit", 9999);
params.put("offset", 0);
List<BookIndexDO> bookIndexBigList = bookIndexService.list(params);
if (!bookIndexBigList.isEmpty()) {
List<List<BookIndexDO>> bookIndexSmallList = bookIndexBigList.stream().collect(
Collectors.groupingBy(item -> bookIndexBigList.indexOf(item) / 100)).values().stream().toList();
for (List<BookIndexDO> bookIndexList : bookIndexSmallList) {
// 获取集合中所有的ID
List<Long> bookIndexIds = bookIndexList.stream().map(BookIndexDO::getId).toList();
List<BookContentDO> bookContentList = bookContentService.listByIndexIds(bookIndexIds);
Map<Long, String> bookContentMap = bookContentList.stream()
.collect(Collectors.toMap(BookContentDO::getIndexId, BookContentDO::getContent));
for (BookIndexDO bookIndex : bookIndexList) {
String indexName = bookIndex.getIndexName();
if (indexName != null) {
String content = bookContentMap.get(bookIndex.getId());
out.write(indexName.getBytes(StandardCharsets.UTF_8));
out.write("\r\n".getBytes(StandardCharsets.UTF_8));
content = PATTERN_BR.matcher(content).replaceAll("\r\n");
content = PATTERN_NBSP.matcher(content).replaceAll(" ");
content = PATTERN_ANCHOR_OPEN.matcher(content).replaceAll("");
content = PATTERN_ANCHOR_CLOSE.matcher(content).replaceAll("");
content = PATTERN_DIV_OPEN.matcher(content).replaceAll("");
content = PATTERN_DIV_CLOSE.matcher(content).replaceAll("");
content = PATTERN_EMPTY_PARAGRAPH_WITH_LINK.matcher(content).replaceAll("");
content = PATTERN_P_OPEN.matcher(content).replaceAll("");
content = PATTERN_P_CLOSE.matcher(content).replaceAll("\r\n");
out.write(content.getBytes(StandardCharsets.UTF_8));
out.write("\r\n".getBytes(StandardCharsets.UTF_8));
out.write("\r\n".getBytes(StandardCharsets.UTF_8));
out.flush();
}
}
}
}
out.close();
} catch (Exception e) {
log.error(e.getMessage(), e);
} finally {
redisTemplate.delete(Constant.BOOK_IS_DOWNLOADING_KEY + bookId);
}
}
}

View File

@@ -9,7 +9,7 @@ import com.java2nb.novel.service.FriendLinkService;
import io.swagger.annotations.ApiOperation;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.annotation.Validated;
@@ -31,7 +31,7 @@ public class FriendLinkController {
@Autowired
private FriendLinkService friendLinkService;
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
private StringRedisTemplate redisTemplate;
@GetMapping()
@RequiresPermissions("novel:friendLink:friendLink")

View File

@@ -9,7 +9,7 @@ import com.java2nb.novel.service.NewsService;
import io.swagger.annotations.ApiOperation;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@@ -32,7 +32,7 @@ public class NewsController {
@Autowired
private NewsService newsService;
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
private StringRedisTemplate redisTemplate;
@GetMapping()
@RequiresPermissions("novel:news:news")

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.AuthorCodeDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface AuthorCodeDao {
AuthorCodeDO get(Long id);
List<AuthorCodeDO> list(Map<String,Object> map);
List<AuthorCodeDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,6 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.AuthorDO;
import java.util.Date;
@@ -19,7 +20,7 @@ public interface AuthorDao {
AuthorDO get(Long id);
List<AuthorDO> list(Map<String,Object> map);
List<AuthorDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.BookCommentDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface BookCommentDao {
BookCommentDO get(Long id);
List<BookCommentDO> list(Map<String,Object> map);
List<BookCommentDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,7 +1,10 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.BookContentDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@@ -18,7 +21,7 @@ public interface BookContentDao {
BookContentDO get(Long id);
List<BookContentDO> list(Map<String, Object> map);
List<BookContentDO> list(@SanitizeMap Map<String, Object> map);
int count(Map<String, Object> map);
@@ -31,4 +34,6 @@ public interface BookContentDao {
int batchRemove(Long[] ids);
int removeByIndexIds(Long[] indexIds);
List<BookContentDO> listByIndexIds(@Param("indexIds") List<Long> indexIds);
}

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.BookDO;
import org.apache.ibatis.annotations.Mapper;
@@ -19,7 +21,7 @@ public interface BookDao {
BookDO get(Long id);
List<BookDO> list(Map<String, Object> map);
List<BookDO> list(@SanitizeMap Map<String, Object> map);
int count(Map<String, Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.BookIndexDO;
import org.apache.ibatis.annotations.Mapper;
@@ -18,7 +20,7 @@ public interface BookIndexDao {
BookIndexDO get(Long id);
List<BookIndexDO> list(Map<String, Object> map);
List<BookIndexDO> list(@SanitizeMap Map<String, Object> map);
int count(Map<String, Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.BookSettingDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface BookSettingDao {
BookSettingDO get(Long id);
List<BookSettingDO> list(Map<String,Object> map);
List<BookSettingDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.CategoryDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface CategoryDao {
CategoryDO get(Integer id);
List<CategoryDO> list(Map<String,Object> map);
List<CategoryDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.FriendLinkDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface FriendLinkDao {
FriendLinkDO get(Integer id);
List<FriendLinkDO> list(Map<String,Object> map);
List<FriendLinkDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.NewsDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface NewsDao {
NewsDO get(Long id);
List<NewsDO> list(Map<String,Object> map);
List<NewsDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.PayDO;
import java.util.Date;
@@ -19,7 +21,7 @@ public interface PayDao {
PayDO get(Long id);
List<PayDO> list(Map<String,Object> map);
List<PayDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.UserDO;
import org.apache.ibatis.annotations.Mapper;
@@ -17,7 +19,7 @@ public interface UserDao {
UserDO get(Long id);
List<UserDO> list(Map<String, Object> map);
List<UserDO> list(@SanitizeMap Map<String, Object> map);
int count(Map<String, Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.UserFeedbackDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface UserFeedbackDao {
UserFeedbackDO get(Long id);
List<UserFeedbackDO> list(Map<String,Object> map);
List<UserFeedbackDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.novel.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.novel.domain.WebsiteInfoDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface WebsiteInfoDao {
WebsiteInfoDO get(Long id);
List<WebsiteInfoDO> list(Map<String,Object> map);
List<WebsiteInfoDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -27,4 +27,6 @@ public interface BookContentService {
int remove(Long id);
int batchRemove(Long[] ids);
List<BookContentDO> listByIndexIds(List<Long> indexIds);
}

View File

@@ -1,5 +1,6 @@
package com.java2nb.novel.service.impl;
import org.apache.ibatis.annotations.Param;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -51,5 +52,10 @@ public class BookContentServiceImpl implements BookContentService {
public int batchRemove(Long[] ids){
return bookContentDao.batchRemove(ids);
}
@Override
public List<BookContentDO> listByIndexIds(List<Long> indexIds) {
return bookContentDao.listByIndexIds(indexIds);
}
}

View File

@@ -1,5 +1,7 @@
package com.java2nb.system.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.system.domain.DataPermDO;
import java.util.List;
@@ -19,7 +21,7 @@ public interface DataPermDao {
DataPermDO get(Long id);
List<DataPermDO> list(Map<String,Object> map);
List<DataPermDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.system.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.system.domain.DeptDO;
import java.util.List;
@@ -19,7 +21,7 @@ public interface DeptDao {
DeptDO get(Long deptId);
List<DeptDO> list(Map<String,Object> map);
List<DeptDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.system.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.system.domain.MenuDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface MenuDao {
MenuDO get(Long menuId);
List<MenuDO> list(Map<String,Object> map);
List<MenuDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.system.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.system.domain.RoleDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface RoleDao {
RoleDO get(Long roleId);
List<RoleDO> list(Map<String,Object> map);
List<RoleDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.system.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.system.domain.RoleDataPermDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface RoleDataPermDao {
RoleDataPermDO get(Long id);
List<RoleDataPermDO> list(Map<String,Object> map);
List<RoleDataPermDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,5 +1,7 @@
package com.java2nb.system.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.system.domain.RoleMenuDO;
import java.util.List;
@@ -18,7 +20,7 @@ public interface RoleMenuDao {
RoleMenuDO get(Long id);
List<RoleMenuDO> list(Map<String,Object> map);
List<RoleMenuDO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -1,15 +1,15 @@
package com.java2nb.system.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.system.domain.UserDO;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
*
* @author xiongxy
* @email 1179705413@qq.com
* @date 2019-10-03 09:45:11
@@ -17,23 +17,23 @@ import org.apache.ibatis.annotations.Param;
@Mapper
public interface SysUserDao {
UserDO get(Long userId);
List<UserDO> list(Map<String,Object> map);
int count(Map<String,Object> map);
int save(UserDO user);
int update(UserDO user);
int remove(Long userId);
int batchRemove(Long[] userIds);
Long[] listAllDept();
UserDO get(Long userId);
List<UserDO> listByPerm(Map<String, Object> map);
List<UserDO> list(@SanitizeMap Map<String, Object> map);
int countByPerm(Map<String,Object> map);
int count(Map<String, Object> map);
int save(UserDO user);
int update(UserDO user);
int remove(Long userId);
int batchRemove(Long[] userIds);
Long[] listAllDept();
List<UserDO> listByPerm(@SanitizeMap Map<String, Object> map);
int countByPerm(Map<String, Object> map);
}

View File

@@ -1,5 +1,7 @@
package com.java2nb.system.dao;
import com.java2nb.common.annotation.SanitizeMap;
import com.java2nb.system.domain.UserRoleDO;
import java.util.List;
@@ -19,7 +21,7 @@ public interface UserRoleDao {
UserRoleDO get(Long id);
List<UserRoleDO> list(Map<String, Object> map);
List<UserRoleDO> list(@SanitizeMap Map<String, Object> map);
int count(Map<String, Object> map);

View File

@@ -38,6 +38,17 @@
</where>
</select>
<select id="listByIndexIds" resultType="com.java2nb.novel.domain.BookContentDO">
select `id`,`index_id`,`content` from book_content
where index_id in (
<foreach item="item" index="index" collection="indexIds" separator=",">
#{item}
</foreach>
)
</select>
<insert id="save" parameterType="com.java2nb.novel.domain.BookContentDO">
insert into book_content
(`id`,

View File

@@ -43,7 +43,7 @@
</select>
<select id="count" resultType="int">
select count(*) from book_setting t1 inner join book t2 on t1.book_id = t2.id
select count(*) from book_setting t1
<where>
<if test="id != null and id != ''">and t1.id = #{id}</if>
<if test="bookId != null and bookId != ''">and t1.book_id = #{bookId}</if>

View File

@@ -143,10 +143,14 @@ function load() {
field: 'id',
align: 'center',
formatter: function (value, row, index) {
// 增加下载按钮
var d = '<a class="btn btn-primary btn-sm" href="#" mce_href="#" title="下载TXT" onclick="downloadBook(\''
+ row.id
+ '\',\'' + row.bookName + '\')"><i class="fa fa-cloud-download"></i></a><br><br> ';
var r = '<a class="btn btn-warning btn-sm ' + s_remove_h + '" href="#" title="删除" mce_href="#" onclick="remove(\''
+ row.id
+ '\')"><i class="fa fa-remove"></i></a> ';
return r;
return d + r;
}
}
@@ -249,4 +253,9 @@ function batchRemove() {
}, function () {
});
}
function downloadBook(bookId, bookName) {
window.open(prefix + '/download?bookId='+bookId+'&bookName='+bookName);
}

View File

@@ -18,7 +18,7 @@ public interface ${className}Dao {
${className}DO get(${pk.javaType} ${pk.attrname});
List<${className}DO> list(Map<String,Object> map);
List<${className}DO> list(@SanitizeMap Map<String,Object> map);
int count(Map<String,Object> map);

View File

@@ -44,7 +44,7 @@ public interface ${className}Mapper {
"limit #{offset}, #{limit}" +
"</if>"+
"</script>")
List<${className}DO> list(Map<String,Object> map);
List<${className}DO> list(@SanitizeMap Map<String,Object> map);
@Select("<script>" +
"select count(*) from ${tableName} " +

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>novel</artifactId>
<groupId>com.java2nb</groupId>
<version>5.2.0</version>
<version>5.3.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
@@ -22,6 +22,12 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Aop 相关 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>

View File

@@ -8,7 +8,7 @@ public interface CacheKey {
/**
* 首页小说设置
* */
String INDEX_BOOK_SETTINGS_KEY = "indexBookSettingsKey";
String INDEX_BOOK_SETTINGS_KEY = "indexBookSettingsKey:v2";
/**
* 首页新闻

View File

@@ -1,55 +1,61 @@
package com.java2nb.novel.core.cache;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.util.List;
/**
* @author 11797
*/
public interface CacheService {
/**
* 根据key获取缓存的String类型数据
*/
String get(String key);
/**
* 根据key获取缓存的String类型数据
*/
String get(String key);
/**
* 设置String类型的缓存
*/
void set(String key, String value);
/**
* 设置String类型的缓存
*/
void set(String key, String value);
/**
* 设置一个有过期时间的String类型的缓存,单位秒
*/
void set(String key, String value, long timeout);
/**
* 根据key获取缓存的Object类型数据
*/
Object getObject(String key);
/**
* 设置Object类型的缓存
*/
void setObject(String key, Object value);
/**
* 设置一个有过期时间的Object类型的缓存,单位秒
*/
/**
* 设置一个有过期时间的String类型的缓存,单位秒
*/
void set(String key, String value, long timeout);
/**
* 根据key获取缓存的Object类型数据
*/
<T> T getObject(String key, Class<T> clazz);
<T> List<T> getList(String key, Class<T> clazz);
/**
* 设置Object类型的缓存
*/
void setObject(String key, Object value);
/**
* 设置一个有过期时间的Object类型的缓存,单位秒
*/
void setObject(String key, Object value, long timeout);
/**
* 根据key删除缓存的数据
*/
void del(String key);
/**
* 根据key删除缓存的数据
*/
void del(String key);
/**
* 判断是否存在一个key
* */
boolean contains(String key);
/**
* 设置key过期时间
* */
void expire(String key, long timeout);
/**
* 判断是否存在一个key
*/
boolean contains(String key);
/**
* 设置key过期时间
*/
void expire(String key, long timeout);
}

View File

@@ -1,23 +1,35 @@
package com.java2nb.novel.core.cache.impl;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.java2nb.novel.core.cache.CacheService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @author xxy
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class RedisServiceImpl implements CacheService {
private final StringRedisTemplate stringRedisTemplate;
private final RedisTemplate<Object, Object> redisTemplate;
private ObjectMapper objectMapper;
@PostConstruct
public void init() {
objectMapper = new ObjectMapper();
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
@Override
@@ -37,34 +49,66 @@ public class RedisServiceImpl implements CacheService {
}
@Override
public Object getObject(String key) {
return redisTemplate.opsForValue().get(key);
public <T> T getObject(String key, Class<T> clazz) {
String result = get(key);
if (result != null) {
try {
return objectMapper.readValue(result, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return null;
}
@Override
public <T> List<T> getList(String key, Class<T> clazz) {
String result = get(key);
if (result != null) {
try {
return objectMapper.readValue(result,
objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return null;
}
@Override
public void setObject(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
if (value != null) {
try {
set(key, objectMapper.writeValueAsString(value));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
@Override
public void setObject(String key, Object value, long timeout) {
redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
if (value != null) {
try {
set(key, objectMapper.writeValueAsString(value), timeout);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
@Override
public void del(String key) {
redisTemplate.delete(key);
stringRedisTemplate.delete(key);
}
@Override
public boolean contains(String key) {
return redisTemplate.hasKey(key) || stringRedisTemplate.hasKey(key);
return stringRedisTemplate.hasKey(key);
}
@Override
public void expire(String key, long timeout) {
redisTemplate.expire(key, timeout, TimeUnit.SECONDS);
stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS);
}

View File

@@ -63,6 +63,8 @@ public class RestTemplates {
connectionManager.setDefaultMaxPerRoute(300);
HttpClientBuilder clientBuilder = HttpClients.custom();
// 禁用 Cookie 管理
clientBuilder.disableCookieManagement();
if (Objects.nonNull(httpProxyProperties) && Boolean.TRUE.equals(httpProxyProperties.getEnabled())) {
HttpHost proxy = new HttpHost(httpProxyProperties.getIp(), httpProxyProperties.getPort());
clientBuilder.setProxy(proxy);

View File

@@ -25,10 +25,10 @@ http:
# 是否开启 HTTP 代理true-开启false-不开启
enabled: false
# 代理 IP
ip: us.swiftproxy.net
ip: proxy.bestproxy.com
# 代理端口号
port: 7878
port: 2312
# 代理用户名
username: swiftproxy_u
username: bestproxy_u
# 代理密码
password: swiftproxy_p
password: bestproxy_p

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>novel</artifactId>
<groupId>com.java2nb</groupId>
<version>5.2.0</version>
<version>5.3.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
@@ -37,6 +37,10 @@
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
</dependencies>

View File

@@ -38,10 +38,38 @@ http:
# 是否开启 HTTP 代理true-开启false-不开启
enabled: false
# 代理 IP
ip: us.swiftproxy.net
ip: proxy.bestproxy.com
# 代理端口号
port: 7878
port: 2312
# 代理用户名
username: swiftproxy_u
username: bestproxy_u
# 代理密码
password: swiftproxy_p
password: bestproxy_p
---
#邮箱服务器
spring:
mail:
host: smtp.163.com
#发件人昵称
nickname: novel-plus
#邮箱账户
username: xxyopen@163.com
#邮箱第三方授权码
password: novel123456
# 磁盘监控配置
disk-monitor:
# 是否开启磁盘监控true-开启false-不启用
enabled: false
# 磁盘监控间隔时间分钟
interval-minutes: 5
# 磁盘使用率阈值
warning-threshold: 85.0
severe-threshold: 90.0
critical-threshold: 95.0
# 告警邮件接收人列表支持多个邮箱
recipients: xxyopen@foxmail.com,12345678@qq.com

View File

@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0.."
java -jar -Dspring.profiles.active=prod novel-crawl.jar
pause

View File

@@ -5,9 +5,12 @@ JAR_NAME=$APP_NAME\.jar
PID=$APP_NAME\.pid
#使用说明,用来提示输入参数
SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
cd "$SCRIPT_DIR"/.. || exit 1
# 使用说明
usage() {
echo "Usage: ./novel-crawl.sh [start|stop|restart|status]"
echo "Usage: $0 [start|stop|restart|status]"
exit 1
}

View File

@@ -72,17 +72,15 @@ public class CrawlController {
if(url.startsWith("https://")||url.startsWith("http://")){
String refreshCache="1";
if(!refreshCache.equals(isRefresh)) {
Object cache = cacheService.getObject(CacheKey.BOOK_TEST_PARSE + url);
if (cache == null) {
html = cacheService.get(CacheKey.BOOK_TEST_PARSE + url);
if (html == null) {
isRefresh="1";
}else {
html = (String) cache;
}
}
if(refreshCache.equals(isRefresh)){
html = HttpUtil.getByHttpClientWithChrome(url);
if (html != null) {
cacheService.setObject(CacheKey.BOOK_TEST_PARSE + url, html, 60 * 10);
cacheService.set(CacheKey.BOOK_TEST_PARSE + url, html, 60 * 10);
}else{
resultMap.put("msg","html is null");
return RestResult.ok(resultMap);

View File

@@ -0,0 +1,28 @@
package com.java2nb.novel.core.bean;
import lombok.Data;
/**
* @author xiongxiaoyang
* @date 2025/10/24
*/
@Data
public class DiskInfo {
private String path;
private double totalGB;
private double freeGB;
private double usage;
public DiskInfo(String path, long totalBytes, long freeBytes, double usage) {
this.path = path;
this.totalGB = bytesToGB(totalBytes);
this.freeGB = bytesToGB(freeBytes);
this.usage = usage;
}
private double bytesToGB(long bytes) {
return bytes / (1024.0 * 1024.0 * 1024.0);
}
}

View File

@@ -0,0 +1,25 @@
package com.java2nb.novel.core.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* @author xiongxiaoyang
* @date 2025/10/24
*/
@Component
@ConfigurationProperties(prefix = "disk-monitor")
@Data
public class DiskMonitorProperties {
private boolean enabled;
private int intervalMinutes;
private double warningThreshold;
private double severeThreshold;
private double criticalThreshold;
private List<String> recipients;
}

View File

@@ -12,6 +12,7 @@ import io.github.xxyopen.util.IdWorker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.text.ParseException;
@@ -34,6 +35,13 @@ public class CrawlParser {
private final CrawlHttpClient crawlHttpClient;
private final StringRedisTemplate stringRedisTemplate;
/**
* 爬虫源采集章节数量缓存key
*/
private static final String CRAWL_SOURCE_CHAPTER_COUNT_CACHE_KEY = "crawlSource:chapterCount:";
/**
* 爬虫任务进度
*/
@@ -53,6 +61,20 @@ public class CrawlParser {
crawlTaskProgress.remove(taskId);
}
/**
* 获取爬虫源采集的章节数量
*/
public Long getCrawlSourceChapterCount(Integer sourceId) {
return Optional.ofNullable(
stringRedisTemplate.opsForValue().get(CRAWL_SOURCE_CHAPTER_COUNT_CACHE_KEY + sourceId)).map(v -> {
try {
return Long.parseLong(v);
} catch (NumberFormatException e) {
return 0L;
}
}).orElse(0L);
}
public void parseBook(RuleBean ruleBean, String bookId, CrawlBookHandler handler)
throws InterruptedException {
Book book = new Book();
@@ -182,7 +204,7 @@ public class CrawlParser {
handler.handle(book);
}
public boolean parseBookIndexAndContent(String sourceBookId, Book book, RuleBean ruleBean,
public boolean parseBookIndexAndContent(String sourceBookId, Book book, RuleBean ruleBean, Integer sourceId,
Map<Integer, BookIndex> existBookIndexMap, CrawlBookChapterHandler handler, CrawlSingleTask task)
throws InterruptedException {
@@ -314,10 +336,12 @@ public class CrawlParser {
bookIndex.setUpdateTime(currentDate);
if (task != null) {
// 更新采集进度
// 更新单本任务采集进度
crawlTaskProgress.put(task.getId(), indexList.size());
}
// 更新爬虫源采集章节数量
stringRedisTemplate.opsForValue().increment(CRAWL_SOURCE_CHAPTER_COUNT_CACHE_KEY + sourceId);
}

View File

@@ -74,7 +74,7 @@ public class StarterListener implements ServletContextInitializer {
needUpdateBook.getId());
//解析章节目录
crawlParser.parseBookIndexAndContent(needUpdateBook.getCrawlBookId(), book,
ruleBean, existBookIndexMap,
ruleBean, needUpdateBook.getCrawlSourceId(), existBookIndexMap,
chapter -> bookService.updateBookAndIndexAndContent(book,
chapter.getBookIndexList(),
chapter.getBookContentList(), existBookIndexMap), null);

View File

@@ -0,0 +1,138 @@
package com.java2nb.novel.core.schedule;
import com.java2nb.novel.core.bean.DiskInfo;
import com.java2nb.novel.core.config.DiskMonitorProperties;
import com.java2nb.novel.service.EmailService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* @author xiongxiaoyang
* @date 2025/10/24
*/
@ConditionalOnProperty(prefix = "disk-monitor", name = "enabled", havingValue = "true")
@Service
@RequiredArgsConstructor
@Slf4j
public class DiskMonitorSchedule {
private final DiskMonitorProperties properties;
private final EmailService emailService;
private final AtomicBoolean criticalAlertSent = new AtomicBoolean(false);
@Scheduled(fixedDelayString = "#{1000 * 60 * ${disk-monitor.interval-minutes}}")
public void checkDiskUsage() {
log.info("🔍 开始检查磁盘使用情况...");
File[] roots = File.listRoots();
List<DiskInfo> diskInfos = new ArrayList<>();
boolean criticalDetected = false;
for (File root : roots) {
String path = root.getAbsolutePath().trim();
if (path.isEmpty()) continue;
long total = root.getTotalSpace();
if (total == 0) continue;
long free = root.getFreeSpace();
double usage = (double)(total - free) / total * 100;
diskInfos.add(new DiskInfo(path, total, free, usage));
if (usage >= properties.getCriticalThreshold()) {
criticalDetected = true;
}
}
if (criticalDetected) {
if (criticalAlertSent.compareAndSet(false, true)) {
sendAlertEmail("CRITICAL", diskInfos);
log.error("🚨 磁盘使用率 ≥ 95%10秒后终止进程...");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.exit(1);
}
} else {
criticalAlertSent.set(false);
double maxUsage = diskInfos.stream().mapToDouble(DiskInfo::getUsage).max().orElse(0);
if (maxUsage >= properties.getSevereThreshold() && maxUsage < properties.getCriticalThreshold()) {
sendAlertEmail("WARNING", diskInfos);
} else if (maxUsage >= properties.getWarningThreshold() && maxUsage < properties.getSevereThreshold()) {
sendAlertEmail("INFO", diskInfos);
}
}
}
private void sendAlertEmail(String level, List<DiskInfo> diskInfos) {
Map<String, Object> model = new HashMap<>();
model.put("diskInfos", diskInfos);
model.put("alertLevel", getAlertLevelText(level));
model.put("icon", getIcon(level));
model.put("actionText", getActionText(level));
model.put("levelColor", getLevelColor(level));
String subject = getSubject(level);
emailService.sendHtmlEmail(subject, "disk_alert", model, properties.getRecipients());
}
private String getAlertLevelText(String level) {
return switch (level) {
case "CRITICAL" -> "严重";
case "WARNING" -> "警告";
case "INFO" -> "提示";
default -> "通知";
};
}
private String getIcon(String level) {
return switch (level) {
case "CRITICAL" -> "🚨";
case "WARNING" -> "⚠️";
case "INFO" -> "💡";
default -> "📢";
};
}
private String getActionText(String level) {
return switch (level) {
case "CRITICAL" ->
"⚠️ 检测到任一磁盘使用率 ≥ 95%,为防止系统崩溃,系统已自动<strong style='color:#d32f2f'>关闭爬虫程序</strong>。";
case "WARNING" ->
"📌 建议:<strong style='color:#f57c00'>请立即暂停爬虫程序</strong>,防止磁盘写满导致服务中断。";
case "INFO" ->
"📌 提示:磁盘使用率已较高,请关注爬虫数据写入情况。";
default -> "";
};
}
private String getLevelColor(String level) {
return switch (level) {
case "CRITICAL" -> "#d32f2f";
case "WARNING" -> "#f57c00";
case "INFO" -> "#388e3c";
default -> "#1976d2";
};
}
private String getSubject(String level) {
return switch (level) {
case "CRITICAL" -> "🚨 严重告警:磁盘使用率超 95%,爬虫已关闭";
case "WARNING" -> "⚠️ 警告:磁盘使用率超 90%,请暂停爬虫";
case "INFO" -> "💡 提示:磁盘使用率超 85%";
default -> "磁盘使用率告警";
};
}
}

View File

@@ -0,0 +1,14 @@
package com.java2nb.novel.service;
import java.util.List;
import java.util.Map;
/**
* @author xiongxiaoyang
* @date 2025/10/24
*/
public interface EmailService {
void sendHtmlEmail(String subject, String templateName, Map<String, Object> templateModel, List<String> to);
}

View File

@@ -100,14 +100,19 @@ public class CrawlServiceImpl implements CrawlService {
PageHelper.startPage(page, pageSize);
SelectStatementProvider render = select(id, sourceName, sourceStatus, createTime, updateTime)
.from(crawlSource)
.orderBy(updateTime)
.orderBy(updateTime.descending())
.build()
.render(RenderingStrategies.MYBATIS3);
List<CrawlSource> crawlSources = crawlSourceMapper.selectMany(render);
crawlSources.forEach(crawlSource -> crawlSource.setSourceStatus(
Optional.ofNullable(crawlSourceStatusMap.get(crawlSource.getId())).orElse((byte) 0)));
PageBean<CrawlSource> pageBean = PageBuilder.build(crawlSources);
pageBean.setList(BeanUtil.copyList(crawlSources, CrawlSourceVO.class));
List<CrawlSourceVO> crawlSourceVOS = BeanUtil.copyList(crawlSources, CrawlSourceVO.class);
crawlSourceVOS.forEach(crawlSource -> {
crawlSource.setSourceStatus(
Optional.ofNullable(crawlSourceStatusMap.get(crawlSource.getId())).orElse((byte) 0));
crawlSource.setChapterCount(crawlParser.getCrawlSourceChapterCount(crawlSource.getId()));
}
);
pageBean.setList(crawlSourceVOS);
return pageBean;
}
@@ -386,7 +391,7 @@ public class CrawlServiceImpl implements CrawlService {
book.setCrawlLastTime(new Date());
book.setId(idWorker.nextId());
//解析章节目录
boolean parseIndexContentResult = crawlParser.parseBookIndexAndContent(bookId, book, ruleBean,
boolean parseIndexContentResult = crawlParser.parseBookIndexAndContent(bookId, book, ruleBean, sourceId,
new HashMap<>(0), chapter -> {
bookService.saveBookAndIndexAndContent(book, chapter.getBookIndexList(),
chapter.getBookContentList());

View File

@@ -0,0 +1,62 @@
package com.java2nb.novel.service.impl;
import com.java2nb.novel.service.EmailService;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* @author xiongxiaoyang
* @date 2025/10/24
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailServiceImpl implements EmailService {
private final JavaMailSender javaMailSender;
private final TemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String fromEmail;
@Value("${spring.mail.nickname}")
private String nickName;
@Override
public void sendHtmlEmail(String subject, String templateName, Map<String, Object> templateModel, List<String> to) {
try {
MimeMessage message = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(
message,
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
StandardCharsets.UTF_8.name());
Context context = new Context();
context.setVariables(templateModel);
String htmlBody = templateEngine.process(templateName, context);
helper.setFrom(new InternetAddress(fromEmail, nickName, "UTF-8"));
helper.setTo(to.toArray(new String[0]));
helper.setSubject(subject);
helper.setText(htmlBody, true);
javaMailSender.send(message);
} catch (Exception e) {
log.error("发送邮件失败");
log.error(e.getMessage(), e);
}
}
}

View File

@@ -20,7 +20,7 @@ public class CrawlSourceVO extends CrawlSource{
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm")
private Date updateTime;
private Long chapterCount;
@Override
public String toString() {

View File

@@ -28,6 +28,41 @@ crawl:
max: 500
---
#邮箱服务器
spring:
mail:
host: smtp.163.com
#发件人昵称
nickname: novel-plus
#邮箱账户
username: xxyopen@163.com
#邮箱第三方授权码
password: novel123456
#编码类型
default-encoding: UTF-8
port: 465
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: rue
socketFactory:
port: 465
class: javax.net.ssl.SSLSocketFactory
fallback: false
# 磁盘监控配置
disk-monitor:
enabled: false
interval-minutes: 5
warning-threshold: 85.0
severe-threshold: 90.0
critical-threshold: 95.0
# 告警邮件接收人列表支持多个邮箱
recipients: xxyopen@foxmail.com,12345678@qq.com

View File

@@ -118,9 +118,10 @@
<script language="javascript" type="text/javascript">
let curr = 1;
let limit = 10;
let isUpdate = false;
search();
setInterval(function(){
setInterval(function () {
search();
}, 10000);
@@ -185,6 +186,7 @@
if (!first) {
curr = obj.curr;
limit = obj.limit;
isUpdate = false;
search();
} else {
@@ -196,13 +198,21 @@
}
} else {
} else if (data.code == 1001) {
//未登录
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else if (!isUpdate) {
layer.alert(data.msg);
}
isUpdate = true;
},
error: function () {
layer.alert('网络异常');
if (!isUpdate) {
layer.alert('网络异常');
}
isUpdate = true;
}
})

View File

@@ -43,7 +43,7 @@
<th class="style">
序号
</th>
<th class="chapter">
<th class="name">
爬虫源
</th>
<th class="name">
@@ -52,6 +52,9 @@
<th class="name">
更新时间
</th>
<th class="goread">
采集数量
</th>
<th class="goread">
状态
</th>
@@ -111,11 +114,16 @@
<script src="/javascript/header.js" type="text/javascript"></script>
<script src="/javascript/user.js" type="text/javascript"></script>
<script language="javascript" type="text/javascript">
search(1, 10);
let curr = 1;
let limit = 10;
let isUpdate = false;
var pageCrawlSourceList = null;
search();
setInterval(function () {
search();
}, 10000);
function search(curr, limit) {
function search() {
$.ajax({
type: "get",
@@ -125,7 +133,6 @@
success: function (data) {
if (data.code == 200) {
var crawlSourceList = data.data.list;
pageCrawlSourceList = data.data.list;
if (crawlSourceList.length > 0) {
var crawlSourceListHtml = "";
for (var i = 0; i < crawlSourceList.length; i++) {
@@ -134,13 +141,15 @@
" <td class=\"style bookclass\">\n" +
" [" + (i + 1) + "]\n" +
" </td>\n" +
" <td class=\"chapter\">\n" +
" <td class=\"name\">\n" +
" " + crawlSource.sourceName + "</td>\n" +
" <td class=\"name\" valsc=\"291|2037554|1\">"
+ crawlSource.createTime + "</td>\n" +
" <td class=\"name\">\n" +
" " + crawlSource.updateTime + "\n" +
" </td>\n" +
" <td class=\"goread\">\n" +
" " + crawlSource.chapterCount + "章</td>\n" +
" <td class=\"goread\" id='sourceStatus" + crawlSource.id + "'>" + (crawlSource.sourceStatus == 0 ? '停止运行' : '正在运行') +
" </td>\n" +
@@ -169,7 +178,10 @@
//首次不执行
if (!first) {
search(obj.curr, obj.limit);
curr = obj.curr;
limit = obj.limit;
isUpdate = false;
search();
} else {
}
@@ -184,13 +196,17 @@
//未登录
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else {
} else if (!isUpdate) {
layer.alert(data.msg);
}
isUpdate = true;
},
error: function () {
layer.alert('网络异常');
if (!isUpdate) {
layer.alert('网络异常');
}
isUpdate = true;
}
})
@@ -216,11 +232,11 @@
if (status == 0) {
//开启
$("#sourceStatus" + sourceId).html("正在运行");
$("#opt" + sourceId).html("<a href='javascript:openOrStopCrawl(" + sourceId + "," + 1 + ")'>关闭</a>");
$("#opt" + sourceId).html("<a href='javascript:openOrStopCrawl(" + sourceId + "," + 1 + ")'>关闭 </a>" + "<a href='javascript:updateCrawlSource(" + sourceId + ")'>修改 </a>");
} else {
//关闭
$("#sourceStatus" + sourceId).html("停止运行");
$("#opt" + sourceId).html("<a href='javascript:openOrStopCrawl(" + sourceId + "," + 0 + ")'>开启</a>");
$("#opt" + sourceId).html("<a href='javascript:openOrStopCrawl(" + sourceId + "," + 0 + ")'>开启 </a>" + "<a href='javascript:updateCrawlSource(" + sourceId + ")'>修改 </a>");
}

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; color: #333; background: #f4f6f9; }
.container { max-width: 800px; margin: 20px auto; padding: 20px; background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header { padding: 15px; border-radius: 6px; text-align: center; color: white; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th { background: #1976d2; color: white; padding: 12px; text-align: center; }
td { padding: 10px; border: 1px solid #ddd; text-align: center; }
.action { margin: 20px 0; font-size: 16px; line-height: 1.6; }
.footer { margin-top: 30px; font-size: 12px; color: #777; text-align: center; }
</style>
</head>
<body>
<div class="container">
<div class="header" th:style="'background:' + ${levelColor}">
<h2 th:text="${icon} + ' ' + ${alertLevel} + ' 告警磁盘使用率过高'"></h2>
</div>
<p><strong>时间:</strong><span th:text="${#dates.format(#dates.createNow(), 'yyyy-MM-dd HH:mm:ss')}"></span></p>
<div class="action" th:utext="${actionText}"></div>
<h3>📊 磁盘使用详情</h3>
<table>
<tr style="background:#1976d2; color:white;">
<th>磁盘路径</th><th>总空间 (GB)</th><th>空闲空间 (GB)</th><th>使用率</th>
</tr>
<tr th:each="disk : ${diskInfos}" th:style="'background-color:#f9f9f9;'">
<td th:text="${disk.path}"></td>
<td th:text="${#numbers.formatDecimal(disk.totalGB, 2, 2)}"></td>
<td th:text="${#numbers.formatDecimal(disk.freeGB, 2, 2)}"></td>
<td th:text="${#numbers.formatDecimal(disk.usage, 2, 2)} + '%'"
th:style="'color:' + ${disk.usage >= 95 ? '#d32f2f' : (disk.usage >= 90 ? '#f57c00' : '#388e3c')} + '; font-weight:bold;'">
</td>
</tr>
</table>
<div class="footer">此邮件由磁盘监控系统自动发送,请及时处理。</div>
</div>
</body>
</html>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>novel</artifactId>
<groupId>com.java2nb</groupId>
<version>5.2.0</version>
<version>5.3.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
@@ -59,7 +59,7 @@
<!-- AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
@@ -96,6 +96,16 @@
<include name="application-website.yml"/>
</fileset>
</copy>
<copy todir="${basedir}/../templates/green/html" overwrite="true">
<fileset dir="${basedir}/src/main/resources/templates">
<include name="**/*.*"/>
</fileset>
</copy>
<copy todir="${basedir}/../templates/green/static" overwrite="true">
<fileset dir="${basedir}/src/main/resources/static">
<include name="**/*.*"/>
</fileset>
</copy>
<copy todir="${project.build.directory}/build/templates" overwrite="true">
<fileset dir="${basedir}/../templates">
<include name="**/*.*"/>

View File

@@ -41,13 +41,13 @@ http:
# 是否开启 HTTP 代理true-开启false-不开启
enabled: false
# 代理 IP
ip: us.swiftproxy.net
ip: proxy.bestproxy.com
# 代理端口号
port: 7878
port: 2312
# 代理用户名
username: swiftproxy_u
username: bestproxy_u
# 代理密码
password: swiftproxy_p
password: bestproxy_p
--- #--------------------- Spring AI 配置----------------------

View File

@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0.."
java -jar -Dspring.profiles.active=prod novel-front.jar
pause

View File

@@ -4,10 +4,12 @@ JAR_NAME=$APP_NAME\.jar
#PID 代表是PID文件
PID=$APP_NAME\.pid
SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
cd "$SCRIPT_DIR"/.. || exit 1
#使用说明,用来提示输入参数
# 使用说明
usage() {
echo "Usage: ./novel-front.sh [start|stop|restart|status]"
echo "Usage: $0 [start|stop|restart|status]"
exit 1
}

View File

@@ -110,20 +110,6 @@ public class AuthorController extends BaseController {
return RestResult.ok();
}
/**
* 更新章节名
*/
@PostMapping("updateIndexName")
public RestResult<Void> updateIndexName(Long indexId, String indexName, HttpServletRequest request) {
Author author = checkAuthor(request);
//更新章节名
bookService.updateIndexName(indexId, indexName, author.getId());
return RestResult.ok();
}
/**
* 发布章节内容

View File

@@ -15,6 +15,7 @@ import io.github.xxyopen.model.resp.RestResult;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@@ -42,7 +43,7 @@ public class BookController extends BaseController {
* 查询首页小说设置列表数据
*/
@GetMapping("listBookSetting")
public RestResult<Map<Byte, List<BookSettingVO>>> listBookSetting() {
public RestResult<Map<String, List<BookSettingVO>>> listBookSetting() {
return RestResult.ok(bookService.listBookSettingVO());
}
@@ -82,7 +83,7 @@ public class BookController extends BaseController {
* 分页搜索
*/
@GetMapping("searchByPage")
public RestResult<?> searchByPage(BookSpVO bookSP, @RequestParam(value = "curr", defaultValue = "1") int page,
public RestResult<?> searchByPage(@Validated BookSpVO bookSP, @RequestParam(value = "curr", defaultValue = "1") int page,
@RequestParam(value = "limit", defaultValue = "20") int pageSize) {
return RestResult.ok(bookService.searchByPage(bookSP, page, pageSize));
}

View File

@@ -82,7 +82,7 @@ public class PageController extends BaseController {
@RequestMapping(path = {"/", "/index", "/index.html"})
public String index(Model model) {
//加载小说首页小说基本信息线程
CompletableFuture<Map<Byte, List<BookSettingVO>>> bookCompletableFuture = CompletableFuture.supplyAsync(
CompletableFuture<Map<String, List<BookSettingVO>>> bookCompletableFuture = CompletableFuture.supplyAsync(
bookService::listBookSettingVO, threadPoolExecutor);
//加载首页新闻线程
CompletableFuture<List<News>> newsCompletableFuture = CompletableFuture.supplyAsync(newsService::listIndexNews,

View File

@@ -1,12 +1,7 @@
package com.java2nb.novel.mapper;
import com.java2nb.novel.entity.Book;
import com.java2nb.novel.vo.BookSpVO;
import com.java2nb.novel.vo.BookVO;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* @author Administrator
*/

View File

@@ -16,9 +16,10 @@ public interface BookService {
/**
* 查询首页小说设置列表数据
*
* @return
* */
Map<Byte, List<BookSettingVO>> listBookSettingVO();
*/
Map<String, List<BookSettingVO>> listBookSettingVO();
/**
* 查询首页点击榜单数据

View File

@@ -1,6 +1,5 @@
package com.java2nb.novel.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.pagehelper.PageHelper;
import com.java2nb.novel.core.cache.CacheKey;
import com.java2nb.novel.core.cache.CacheService;
@@ -25,7 +24,6 @@ import io.github.xxyopen.web.util.BeanUtil;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.client.utils.DateUtils;
import org.mybatis.dynamic.sql.SortSpecification;
import org.mybatis.dynamic.sql.render.RenderingStrategies;
@@ -48,7 +46,6 @@ import java.util.concurrent.ThreadPoolExecutor;
import java.util.stream.Collectors;
import static com.java2nb.novel.mapper.BookCategoryDynamicSqlSupport.bookCategory;
import static com.java2nb.novel.mapper.BookCategoryDynamicSqlSupport.sort;
import static com.java2nb.novel.mapper.BookCommentDynamicSqlSupport.bookComment;
import static com.java2nb.novel.mapper.BookContentDynamicSqlSupport.bookContent;
import static com.java2nb.novel.mapper.BookContentDynamicSqlSupport.content;
@@ -108,19 +105,19 @@ public class BookServiceImpl implements BookService {
@SneakyThrows
@Override
public Map<Byte, List<BookSettingVO>> listBookSettingVO() {
String result = cacheService.get(CacheKey.INDEX_BOOK_SETTINGS_KEY);
if (result == null || result.length() < Constants.OBJECT_JSON_CACHE_EXIST_LENGTH) {
List<BookSettingVO> list = bookSettingMapper.listVO();
if (list.size() == 0) {
public Map<String, List<BookSettingVO>> listBookSettingVO() {
List<BookSettingVO> list = cacheService.getList(CacheKey.INDEX_BOOK_SETTINGS_KEY, BookSettingVO.class);
if (list == null || list.isEmpty()) {
list = bookSettingMapper.listVO();
if (list.isEmpty()) {
//如果首页小说没有被设置,则初始化首页小说设置
list = initIndexBookSetting();
}
result = new ObjectMapper().writeValueAsString(
list.stream().collect(Collectors.groupingBy(BookSettingVO::getType)));
cacheService.set(CacheKey.INDEX_BOOK_SETTINGS_KEY, result, 3600 * 24);
cacheService.setObject(CacheKey.INDEX_BOOK_SETTINGS_KEY, list, 3600 * 24);
}
return new ObjectMapper().readValue(result, Map.class);
return list.stream().collect(
Collectors.groupingBy(book -> book.getType().toString())
);
}
@@ -170,11 +167,10 @@ public class BookServiceImpl implements BookService {
return new ArrayList<>(0);
}
@Override
public List<Book> listClickRank() {
List<Book> result = (List<Book>) cacheService.getObject(CacheKey.INDEX_CLICK_BANK_BOOK_KEY);
if (result == null || result.size() == 0) {
List<Book> result = cacheService.getList(CacheKey.INDEX_CLICK_BANK_BOOK_KEY, Book.class);
if (result == null || result.isEmpty()) {
result = listRank((byte) 0, 10);
cacheService.setObject(CacheKey.INDEX_CLICK_BANK_BOOK_KEY, result, 5000);
}
@@ -183,8 +179,8 @@ public class BookServiceImpl implements BookService {
@Override
public List<Book> listNewRank() {
List<Book> result = (List<Book>) cacheService.getObject(CacheKey.INDEX_NEW_BOOK_KEY);
if (result == null || result.size() == 0) {
List<Book> result = cacheService.getList(CacheKey.INDEX_NEW_BOOK_KEY, Book.class);
if (result == null || result.isEmpty()) {
result = listRank((byte) 1, 10);
cacheService.setObject(CacheKey.INDEX_NEW_BOOK_KEY, result, 3600);
}
@@ -193,8 +189,8 @@ public class BookServiceImpl implements BookService {
@Override
public List<BookVO> listUpdateRank() {
List<BookVO> result = (List<BookVO>) cacheService.getObject(CacheKey.INDEX_UPDATE_BOOK_KEY);
if (result == null || result.size() == 0) {
List<BookVO> result = cacheService.getList(CacheKey.INDEX_UPDATE_BOOK_KEY, BookVO.class);
if (result == null || result.isEmpty()) {
List<Book> bookPOList = listRank((byte) 2, 23);
result = BeanUtil.copyList(bookPOList, BookVO.class);
cacheService.setObject(CacheKey.INDEX_UPDATE_BOOK_KEY, result, 60 * 10);

View File

@@ -1,6 +1,5 @@
package com.java2nb.novel.service.impl;
import io.github.xxyopen.web.util.BeanUtil;
import com.java2nb.novel.service.FriendLinkService;
import com.java2nb.novel.core.cache.CacheKey;
import com.java2nb.novel.core.cache.CacheService;
@@ -31,16 +30,16 @@ public class FriendLinkServiceImpl implements FriendLinkService {
@Override
public List<FriendLink> listIndexLink() {
List<FriendLink> result = (List<FriendLink>) cacheService.getObject(CacheKey.INDEX_LINK_KEY);
if(result == null || result.size() == 0) {
SelectStatementProvider selectStatement = select(linkName,linkUrl)
.from(friendLink)
.where(isOpen,isEqualTo((byte)1))
.orderBy(sort)
.build()
.render(RenderingStrategies.MYBATIS3);
result = friendLinkMapper.selectMany(selectStatement);
cacheService.setObject(CacheKey.INDEX_LINK_KEY,result,60 * 60 * 24);
List<FriendLink> result = cacheService.getList(CacheKey.INDEX_LINK_KEY, FriendLink.class);
if (result == null || result.isEmpty()) {
SelectStatementProvider selectStatement = select(linkName, linkUrl)
.from(friendLink)
.where(isOpen, isEqualTo((byte) 1))
.orderBy(sort)
.build()
.render(RenderingStrategies.MYBATIS3);
result = friendLinkMapper.selectMany(selectStatement);
cacheService.setObject(CacheKey.INDEX_LINK_KEY, result, 60 * 60 * 24);
}
return result;
}

View File

@@ -26,6 +26,7 @@ public class IpLocationServiceImpl implements IpLocationService {
try {
// 示例返回:"中国|0|湖北省|武汉市|电信"
String region = searcher.search(ip);
log.info("IP{},区域:{}", ip, region);
String[] regions = region.split("\\|");
if (regions.length > 0) {
// 国家
@@ -38,9 +39,17 @@ public class IpLocationServiceImpl implements IpLocationService {
return getLocation(publicIp);
}
} else if ("中国".equals(country)) {
// 中国,则返回省份(第三个字段
String province = regions.length > 2 ? regions[2] : "未知地区";
// 去掉最后一个“省”字
// 若为中国,则尝试返回省份(regions[2]
if (regions.length <= 2) {
// 数据不足,直接返回国家
return country;
}
String province = regions[2];
if (!StringUtils.hasText(province) || "0".equals(province)) {
// 省份字段为空或未知,返回国家
return country;
}
// 去掉末尾的“省”字
return province.endsWith("") ? province.substring(0, province.length() - 1) : province;
} else {
// 非中国,返回国家名

View File

@@ -4,7 +4,7 @@ import com.java2nb.novel.service.LikeService;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.StaticScriptSource;
import org.springframework.stereotype.Service;
@@ -20,7 +20,7 @@ import java.util.Collections;
@Slf4j
public class LikeServiceImpl implements LikeService {
private final RedisTemplate<Object, Object> redisTemplate;
private final StringRedisTemplate redisTemplate;
private DefaultRedisScript<Long> toggleLikeScript;
@@ -33,19 +33,19 @@ public class LikeServiceImpl implements LikeService {
public void init() {
// Lua 脚本保证原子性操作
String script = """
local key = KEYS[1]
local userId = ARGV[1]
local isLiked = redis.call('SISMEMBER', key, userId)
if isLiked == 1 then
redis.call('SREM', key, userId)
else
redis.call('SADD', key, userId)
end
return redis.call('SCARD', key)
""";
local key = KEYS[1]
local userId = ARGV[1]
local isLiked = redis.call('SISMEMBER', key, userId)
if isLiked == 1 then
redis.call('SREM', key, userId)
else
redis.call('SADD', key, userId)
end
return redis.call('SCARD', key)
""";
toggleLikeScript = new DefaultRedisScript<>();
toggleLikeScript.setScriptSource(new StaticScriptSource(script));
@@ -89,6 +89,6 @@ public class LikeServiceImpl implements LikeService {
}
private Long executeToggle(String key, Long userId) {
return redisTemplate.execute(toggleLikeScript, Collections.singletonList(key), userId);
return redisTemplate.execute(toggleLikeScript, Collections.singletonList(key), String.valueOf(userId));
}
}

View File

@@ -36,16 +36,16 @@ public class NewsServiceImpl implements NewsService {
@Override
public List<News> listIndexNews() {
List<News> result = (List<News>) cacheService.getObject(CacheKey.INDEX_NEWS_KEY);
if(result == null || result.size() == 0) {
SelectStatementProvider selectStatement = select(id, catName, catId, title,createTime)
.from(news)
.orderBy(createTime.descending())
.limit(2)
.build()
.render(RenderingStrategies.MYBATIS3);
List<News> result = cacheService.getList(CacheKey.INDEX_NEWS_KEY, News.class);
if (result == null || result.isEmpty()) {
SelectStatementProvider selectStatement = select(id, catName, catId, title, createTime)
.from(news)
.orderBy(createTime.descending())
.limit(2)
.build()
.render(RenderingStrategies.MYBATIS3);
result = newsMapper.selectMany(selectStatement);
cacheService.setObject(CacheKey.INDEX_NEWS_KEY,result,60 * 60 * 12);
cacheService.setObject(CacheKey.INDEX_NEWS_KEY, result, 60 * 60 * 12);
}
return result;
}
@@ -53,25 +53,25 @@ public class NewsServiceImpl implements NewsService {
@Override
public News queryNewsInfo(Long newsId) {
SelectStatementProvider selectStatement = select(news.allColumns())
.from(news)
.where(id,isEqualTo(newsId))
.build()
.render(RenderingStrategies.MYBATIS3);
.from(news)
.where(id, isEqualTo(newsId))
.build()
.render(RenderingStrategies.MYBATIS3);
return newsMapper.selectMany(selectStatement).get(0);
}
@Override
public PageBean<News> listByPage(int page, int pageSize) {
PageHelper.startPage(page,pageSize);
SelectStatementProvider selectStatement = select(id, catName, catId, title,createTime)
.from(news)
.orderBy(createTime.descending())
.build()
.render(RenderingStrategies.MYBATIS3);
PageHelper.startPage(page, pageSize);
SelectStatementProvider selectStatement = select(id, catName, catId, title, createTime)
.from(news)
.orderBy(createTime.descending())
.build()
.render(RenderingStrategies.MYBATIS3);
List<News> news = newsMapper.selectMany(selectStatement);
PageBean<News> pageBean = PageBuilder.build(news);
pageBean.setList(BeanUtil.copyList(news,NewsVO.class));
pageBean.setList(BeanUtil.copyList(news, NewsVO.class));
return pageBean;
}

View File

@@ -1,5 +1,6 @@
package com.java2nb.novel.vo;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.util.Date;
@@ -29,9 +30,8 @@ public class BookSpVO {
private Long updatePeriod;
@Pattern(regexp = "^(last_index_update_time|word_count|visit_count)$")
private String sort;
}

View File

@@ -23,7 +23,7 @@ xss:
# 排除链接多个用逗号分隔
excludes: /system/notice/*
# 匹配链接 多个用逗号分隔
urlPatterns: /book/addBookComment,/user/addFeedBack,/author/addBook,/author/addBookContent,/author/updateBookContent,/author/register.html
urlPatterns: /book/addCommentReply,/book/addBookComment,/user/addFeedBack,/author/addBook,/author/addBookContent,/author/updateBookContent,/author/register.html
author:
@@ -59,6 +59,6 @@ spring:
base-url: https://api.siliconflow.cn
chat:
options:
model: deepseek-ai/DeepSeek-R1-Distill-Llama-8B
model: deepseek-ai/DeepSeek-R1-0528-Qwen3-8B

View File

@@ -166,7 +166,7 @@
//首次不执行
if (!first) {
searchComments(obj.curr, obj.limit);
loadCommentList(obj.curr, obj.limit);
} else {
}

View File

@@ -1,6 +1,4 @@
<!DOCTYPE html>
<html lang="en">
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
@@ -16,8 +14,6 @@
<div th:include="mobile/common/css :: css"></div>
</div>
<style type="text/css">
@@ -62,6 +58,15 @@
height: 180px;
}
.book_desc {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
white-space: pre-wrap;
}
</style>
</head>
<body>
@@ -187,19 +192,19 @@
book.bookDesc = book.bookDesc.replace(/<[^>]+>/g, "").replace(/\s+/g, "").replace(/&nbsp;/g, "");
}
bookListHtml += ("<div class=\"layui-row\" style=\"margin-bottom:10px;padding:10px;background: #f2f2f2\">\n" +
bookListHtml += ("<div class=\"layui-row\" style=\"margin-bottom:10px;padding:5px 0px;background: #f2f2f2\">\n" +
" <a href=\"/book/" + book.id + ".html\">\n" +
" <div class=\"layui-col-xs6 layui-col-sm3 layui-col-md2 layui-col-lg2\" style=\"text-align: center\">\n" +
" <img style='width: 130px;height: 180px' align=\"center\"\n" +
" <div class=\"layui-col-xs5 layui-col-sm3 layui-col-md2 layui-col-lg2\" style=\"text-align: center\">\n" +
" <img style='width: 130px;height: 190px' align=\"center\"\n" +
" src=\"" + book.picUrl + "\"/>\n" +
"\n" +
" </div>\n" +
" </a>\n" +
" <div style=\"padding: 10px\" class=\"layui-col-xs6 layui-col-sm8 layui-col-md8 layui-col-lg8\">\n" +
" <div style=\"padding: 0px 10px 0px 0px\" class=\"layui-col-xs7 layui-col-sm9 layui-col-md10 layui-col-lg10\">\n" +
" <a href=\"/book/" + book.id + ".html\">\n" +
" <div class=\"line-limit-length\" style=\";color: #000;font-size: 15px\">" + book.bookName + "</div>\n" +
" </a>\n" +
" <div style=\";color: #4c6978;float: right;\"><i style=\"color: red\"></i></div>\n" +
" <div style=\"height: 5px;color: #4c6978;\"><i style=\"color: red\"></i></div>\n" +
" <a href=\"/book/book_ranking.html?keyword=" + encodeURI(book.authorName) + "\">\n" +
" <div style=\";color: #a6a6a6;\" class=\"line-limit-length\">作者:" + book.authorName + "</div>\n" +
" </a>\n" +
@@ -207,7 +212,7 @@
" <div style=\"margin-top: 5px;color: #a6a6a6;\">状态:" + (book.bookStatus == 0 ? '连载' : '完结') + "</div>\n" +
" <div style=\"margin-top: 5px;color: #a6a6a6;\">更新:<i style='color: red'>" + book.lastIndexUpdateTime.substr(0, 11) + "</i>\n" +
" </div>\n" +
" <div style=\"margin-top: 5px;color: #a6a6a6;\">简介:" + (book.bookDesc ? (book.bookDesc.length > 15 ? (book.bookDesc.substr(0, 15) + "...") : book.bookDesc) : book.bookDesc) + "</div>\n" +
" <div class='book_desc' style=\"margin-top: 5px;color: #a6a6a6;\">简介:" + (book.bookDesc) + "</div>\n" +
"\n" +
"\n" +
" </div>\n" +

View File

@@ -26,6 +26,15 @@
}
}
.book_name {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: pre-wrap;
}
#footer {
position: absolute;
bottom: 0px;

View File

@@ -53,6 +53,44 @@
color: #3eaf7c;
}
.container {
display: flex;
overflow-x: auto; /* 允许内容水平滚动 */
white-space: nowrap; /* 禁止换行 */
scroll-snap-type: x mandatory; /* 在滚动时强制对齐 snap-points */
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.container::-webkit-scrollbar {
width: 0;
height: 0;
}
.container::-webkit-scrollbar-track,
.container::-webkit-scrollbar-thumb {
display: none;
}
.item {
flex: 0 0 calc(100% / 10); /* 每个元素宽度为容器的十分之一 */
scroll-snap-align: start;
box-sizing: border-box;
text-align: center;
border-radius: 5px;
margin-right: 5px;
}
.book_desc {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
white-space: pre-wrap;
}
</style>
@@ -107,60 +145,58 @@
精品推荐
</blockquote>
<div class="container" style="padding-bottom: 10px">
<div class="layui-container" style="padding: 0px">
<div class="item" style="position: relative;padding:1%;" th:if="${bookMap['4']}" th:each="book,iterStat:${bookMap['4']}">
<a th:href="'/book/'+${book.bookId}+'.html'">
<div class="layui-row" style="text-align: center" id="currentWeek" th:if="${bookMap['4']}">
<span th:each="book,iterStat : ${bookMap['4']}" th:if="${iterStat.index<3}">
<a th:href="'/book/'+${book.bookId}+'.html'">
<div style="padding: 1%" class="layui-col-xs4 layui-col-sm4 layui-col-md4 layui-col-lg4">
<img style=" width:100px; height:125px; max-width:100%; max-height:100%;"
th:src="${book.picUrl}">
<img style=" width:100px; height:125px; max-width:100%; max-height:100%;"
th:src="${book.picUrl}">
<br>
<span style="width: 100px" class="book_name" th:text="${book.bookName}"></span>
<br>
<span th:text="${#strings.length(book.bookName) > 5}? (${#strings.substring(book.bookName,0,5)}+'...'): ${book.bookName} "></span>
</a>
</div>
</a>
</span>
</div>
</div>
</div>
<div class="layui-colla-item">
<blockquote class="layui-elem-quote" style="text-align: left;font-size: 16px">
热门推荐
</blockquote>
<div class="layui-container">
<div class="layui-row" id="hotRecBooks" th:if="${bookMap['3']}">
<div th:each="book,iterStat : ${bookMap['3']}" th:if="${iterStat.index<6}" style="margin-bottom: 5px"
class="layui-col-xs12 layui-col-sm6 layui-col-md4 layui-col-lg4">
<a th:href="'/book/'+${book.bookId}+'.html'">
<div class="layui-col-xs5 layui-col-sm4 layui-col-md4 layui-col-lg4">
<img style=" width:100px; height:125px;" th:src="${book.picUrl}">
</div>
<div class="layui-col-xs5 layui-col-sm6 layui-col-md6 layui-col-lg6">
<ul>
<li style="padding-bottom: 2px" class="line-limit-length"
th:text="${book.bookName}"></li>
<li style="padding-bottom: 2px;color: #a6a6a6" th:text="'作者'+${book.authorName}"></li>
<li style="color: #a6a6a6;width: 180px;height:60px;overflow: hidden"
th:utext="${book.bookDesc}"></li>
</ul>
</div>
<div style="font-style: italic;color: red"
class="layui-col-xs2 layui-col-sm2 layui-col-md2 layui-col-lg2"></div>
</a>
</div>
</div>
</div>
</div>
</div>
<div style="clear: both"></div>
<div class="layui-colla-item">
<blockquote class="layui-elem-quote" style="text-align: left;font-size: 16px">
热门推荐
</blockquote>
<div class="layui-container">
<div class="layui-row" id="hotRecBooks" th:if="${bookMap['3']}">
<div th:each="book,iterStat : ${bookMap['3']}" th:if="${iterStat.index<6}" style="margin-bottom: 5px"
class="layui-col-xs12 layui-col-sm6 layui-col-md4 layui-col-lg4">
<a th:href="'/book/'+${book.bookId}+'.html'">
<div class="layui-col-xs4 layui-col-sm4 layui-col-md4 layui-col-lg4">
<img style=" width:100px; height:125px;" th:src="${book.picUrl}">
</div>
<div class="layui-col-xs8 layui-col-sm8 layui-col-md8 layui-col-lg8">
<ul>
<li style="padding-bottom: 5px" class="line-limit-length"
th:text="${book.bookName}"></li>
<li style="padding-bottom: 5px;color: #a6a6a6" th:text="'作者'+${book.authorName}"></li>
<li class='book_desc' style="color: #a6a6a6;padding-right:10px;"
th:utext="${book.bookDesc}"></li>
</ul>
</div>
<div style="font-style: italic;color: red"
class="layui-col-xs2 layui-col-sm2 layui-col-md2 layui-col-lg2"></div>
</a>
</div>
</div>
</div>
</div>
<div style="clear: both"></div>
<div style="height: 1px" class="layui-col-lg1"></div>
@@ -184,7 +220,6 @@
</div>
</div>
</div>
<div style="clear: both"></div>
<div th:replace="mobile/common/footer :: footer">
@@ -226,7 +261,7 @@
"\n" +
" <div style=\"clear: both\"></div>\n" +
" <div style=\"color: #a6a6a6;padding-left: 5px;padding-top: 5px\"\n" +
" class=\"layui-elip layui-col-md11 layui-col-sm11 layui-col-lg11\">简介:  " + updateRankBook.bookDesc + "" +
" class=\"layui-elip layui-col-md11 layui-col-sm11 layui-col-lg11\">简介:" + updateRankBook.bookDesc + "" +
" </div></a>\n" +
" </div>");

View File

@@ -5,7 +5,7 @@
<groupId>com.java2nb</groupId>
<artifactId>novel</artifactId>
<version>5.2.0</version>
<version>5.3.0</version>
<modules>
<module>novel-common</module>
<module>novel-front</module>
@@ -56,7 +56,7 @@
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0-M6</version>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>

View File

@@ -0,0 +1,216 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head th:replace="common/header :: common_head(~{::title},~{},~{::link},~{})">
<title th:text="'评论回复区'"></title>
<link href="/css/main.css" rel="stylesheet"/>
<link href="/css/book.css" rel="stylesheet"/>
</head>
<body>
<input type="hidden" id="commentId" th:value="${commentId}"/>
<div th:replace="common/top :: top('')">
</div>
<div class="main box_center cf mb50">
<div class="channelBookContent cf">
<!--left start-->
<div class="wrap_left fl">
<div class="wrap_bg">
<div class="pad20">
<div class="bookComment">
<div class="book_tit">
<div class="fl">
<h3>评论回复区</h3><span id="bookCommentTotal">(0条)</span>
</div>
<a class="fr" href="#txtComment">发表回复</a>
</div>
<blockquote class="layui-elem-quote" th:utext="${commentContent}">
</blockquote>
<div class="no_comment" id="noCommentPanel" style="display: none;">
<img src="/images/no_comment.png" alt=""/>
<span class="block">暂无回复</span>
</div>
<div class="commentBar" id="commentPanel">
</div>
<div class="pageBox cf mt15 mr10" id="commentPage">
</div>
<div class="reply_bar" id="reply_bar">
<div class="tit">
<span class="fl font16">发表回复</span>
<!--未登录状态下不可发表评论,显示以下链接-->
<span class="fr black9" style="display:none; ">请先 <a class="orange"
href="/user/login.html">登录</a><em
class="ml10 mr10">|</em><a class="orange"
href="/user/register.html">注册</a></span>
</div>
<textarea name="txtComment" rows="2" cols="20" id="txtComment" class="replay_text"
placeholder="我来说两句..."></textarea>
<div class="reply_btn">
<span class="fl black9"><em class="ml5" id="emCommentNum">0/1000</em> 字</span>
<span class="fr"><a class="btn_ora" href="javascript:void(0);"
onclick="javascript:BookDetail.SaveCommentReply(37,0,$('#txtComment').val());">发表</a></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!--left end-->
<!--right start-->
<!--right end-->
</div>
</div>
<div th:replace="common/footer :: footer">
</div>
<div th:replace="common/js :: js"></div>
<script src="/javascript/bookdetail.js" type="text/javascript"></script>
<script language="javascript" type="text/javascript">
$('#txtComment').on('input propertychange', function () {
var count = $(this).val().length;
$('#emCommentNum').html(count + "/1000");
if (count > 1000) {
$('#txtComment').val($('#txtComment').val().substring(0, 1000));
}
});
loadCommentList(1, 20);
function loadCommentList(curr, limit) {
$.ajax({
type: "get",
url: "/book/listCommentReplyByPage",
data: {'commentId': $("#commentId").val(), 'curr': curr, 'limit': limit},
dataType: "json",
success: function (data) {
if (data.code == 200) {
if (data.data.total == 0) {
$("#noCommentPanel").css("display", "block");
$("#commentPanel").css("display", "none");
return;
}
$("#noCommentPanel").css("display", "none");
$("#commentPanel").css("display", "block");
var commentList = data.data.list;
if (commentList.length > 0) {
$("#bookCommentTotal").html("(" + data.data.total + ")");
var commentListHtml = "";
for (var i = 0; i < commentList.length; i++) {
var comment = commentList[i];
commentListHtml += ("<div class=\"comment_list cf\">" +
"<div class=\"user_heads fl\" vals=\"389\">" +
"<img src=\"" + (comment.createUserPhoto ? comment.createUserPhoto : '/images/man.png') + "\" class=\"user_head\" alt=\"\">" +
"<span class=\"user_level1\" style=\"display: none;\">见习</span></div>" +
"<ul class=\"pl_bar fr\">\t\t\t<li class=\"name\">" + (comment.createUserName) + "<span style='padding-left: 10px' class=\"other\">" + (comment.location ? comment.location + "读者" : '') + "</span></li><li class=\"dec\">" +
comment.replyContent +
"</li><li class=\"other cf\">" +
"<span class=\"time fl\" style='padding-right: 10px'>" + (data.data.total - ((curr - 1) * limit + i)) + "楼</span>" +
"<span class=\"time fl\">" + comment.createTime + "</span>" +
"<span class=\"fr\"><a href=\"javascript:toggleCommentUnLike('"+comment.id+"')\" class=\"zan\" style=\"padding-left: 10px\">踩<i class=\"num\" id='unLikeCount"+comment.id+"'>("+comment.unLikesCount+")</i></a></span>" +
"<span class=\"fr\"><a href=\"javascript:toggleCommentLike('"+comment.id+"')\" class=\"zan\" style=\"padding-left: 10px\">赞<i class=\"num\" id='likeCount"+comment.id+"'>("+comment.likesCount+")</i></a></span>" +
"</li>\t\t</ul>\t</div>");
}
$("#commentPanel").html(commentListHtml);
layui.use('laypage', function () {
var laypage = layui.laypage;
//执行一个laypage实例
laypage.render({
elem: 'commentPage' //注意,这里的 test1 是 ID不用加 # 号
, count: data.data.total //数据总数,从服务端得到,
, curr: data.data.pageNum
, limit: data.data.pageSize
, jump: function (obj, first) {
//obj包含了当前分页的所有参数比如
console.log(obj.curr); //得到当前页,以便向服务端请求对应页的数据。
console.log(obj.limit); //得到每页显示的条数
//首次不执行
if (!first) {
loadCommentList(obj.curr, obj.limit);
} else {
}
}
});
});
}
} else {
layer.alert(data.msg);
}
},
error: function () {
layer.alert('网络异常');
}
})
}
function toggleCommentLike(replyId) {
$.ajax({
type: "post",
url: "/book/toggleReplyLike",
data: {'replyId': replyId},
dataType: "json",
success: function (data) {
if (data.code == 200) {
$("#likeCount"+replyId).text("("+data.data+")")
} else if (data.code == 1001) {
//未登录
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else {
layer.alert(data.msg);
}
},
error: function () {
layer.alert('网络异常');
}
})
}
function toggleCommentUnLike(replyId) {
$.ajax({
type: "post",
url: "/book/toggleReplyUnLike",
data: {'replyId': replyId},
dataType: "json",
success: function (data) {
if (data.code == 200) {
$("#unLikeCount"+replyId).text("("+data.data+")")
} else if (data.code == 1001) {
//未登录
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else {
layer.alert(data.msg);
}
},
error: function () {
layer.alert('网络异常');
}
})
}
</script>
</body>
</html>

View File

@@ -1,6 +1,4 @@
<!DOCTYPE html>
<html lang="en">
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
@@ -16,8 +14,6 @@
<div th:include="mobile/common/css :: css"></div>
</div>
<style type="text/css">
@@ -62,6 +58,15 @@
height: 180px;
}
.book_desc {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
white-space: pre-wrap;
}
</style>
</head>
<body>
@@ -187,19 +192,19 @@
book.bookDesc = book.bookDesc.replace(/<[^>]+>/g, "").replace(/\s+/g, "").replace(/&nbsp;/g, "");
}
bookListHtml += ("<div class=\"layui-row\" style=\"margin-bottom:10px;padding:10px;background: #f2f2f2\">\n" +
bookListHtml += ("<div class=\"layui-row\" style=\"margin-bottom:10px;padding:5px 0px;background: #f2f2f2\">\n" +
" <a href=\"/book/" + book.id + ".html\">\n" +
" <div class=\"layui-col-xs6 layui-col-sm3 layui-col-md2 layui-col-lg2\" style=\"text-align: center\">\n" +
" <img style='width: 130px;height: 180px' align=\"center\"\n" +
" <div class=\"layui-col-xs5 layui-col-sm3 layui-col-md2 layui-col-lg2\" style=\"text-align: center\">\n" +
" <img style='width: 130px;height: 190px' align=\"center\"\n" +
" src=\"" + book.picUrl + "\"/>\n" +
"\n" +
" </div>\n" +
" </a>\n" +
" <div style=\"padding: 10px\" class=\"layui-col-xs6 layui-col-sm8 layui-col-md8 layui-col-lg8\">\n" +
" <div style=\"padding: 0px 10px 0px 0px\" class=\"layui-col-xs7 layui-col-sm9 layui-col-md10 layui-col-lg10\">\n" +
" <a href=\"/book/" + book.id + ".html\">\n" +
" <div class=\"line-limit-length\" style=\";color: #000;font-size: 15px\">" + book.bookName + "</div>\n" +
" </a>\n" +
" <div style=\";color: #4c6978;float: right;\"><i style=\"color: red\"></i></div>\n" +
" <div style=\"height: 5px;color: #4c6978;\"><i style=\"color: red\"></i></div>\n" +
" <a href=\"/book/book_ranking.html?keyword=" + encodeURI(book.authorName) + "\">\n" +
" <div style=\";color: #a6a6a6;\" class=\"line-limit-length\">作者:" + book.authorName + "</div>\n" +
" </a>\n" +
@@ -207,7 +212,7 @@
" <div style=\"margin-top: 5px;color: #a6a6a6;\">状态:" + (book.bookStatus == 0 ? '连载' : '完结') + "</div>\n" +
" <div style=\"margin-top: 5px;color: #a6a6a6;\">更新:<i style='color: red'>" + book.lastIndexUpdateTime.substr(0, 11) + "</i>\n" +
" </div>\n" +
" <div style=\"margin-top: 5px;color: #a6a6a6;\">简介:" + (book.bookDesc ? (book.bookDesc.length > 15 ? (book.bookDesc.substr(0, 15) + "...") : book.bookDesc) : book.bookDesc) + "</div>\n" +
" <div class='book_desc' style=\"margin-top: 5px;color: #a6a6a6;\">简介:" + (book.bookDesc) + "</div>\n" +
"\n" +
"\n" +
" </div>\n" +

View File

@@ -26,6 +26,15 @@
}
}
.book_name {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: pre-wrap;
}
#footer {
position: absolute;
bottom: 0px;

View File

@@ -53,6 +53,44 @@
color: #3eaf7c;
}
.container {
display: flex;
overflow-x: auto; /* 允许内容水平滚动 */
white-space: nowrap; /* 禁止换行 */
scroll-snap-type: x mandatory; /* 在滚动时强制对齐 snap-points */
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.container::-webkit-scrollbar {
width: 0;
height: 0;
}
.container::-webkit-scrollbar-track,
.container::-webkit-scrollbar-thumb {
display: none;
}
.item {
flex: 0 0 calc(100% / 10); /* 每个元素宽度为容器的十分之一 */
scroll-snap-align: start;
box-sizing: border-box;
text-align: center;
border-radius: 5px;
margin-right: 5px;
}
.book_desc {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
white-space: pre-wrap;
}
</style>
@@ -107,60 +145,58 @@
精品推荐
</blockquote>
<div class="container" style="padding-bottom: 10px">
<div class="layui-container" style="padding: 0px">
<div class="item" style="position: relative;padding:1%;" th:if="${bookMap['4']}" th:each="book,iterStat:${bookMap['4']}">
<a th:href="'/book/'+${book.bookId}+'.html'">
<div class="layui-row" style="text-align: center" id="currentWeek" th:if="${bookMap['4']}">
<span th:each="book,iterStat : ${bookMap['4']}" th:if="${iterStat.index<3}">
<a th:href="'/book/'+${book.bookId}+'.html'">
<div style="padding: 1%" class="layui-col-xs4 layui-col-sm4 layui-col-md4 layui-col-lg4">
<img style=" width:100px; height:125px; max-width:100%; max-height:100%;"
th:src="${book.picUrl}">
<img style=" width:100px; height:125px; max-width:100%; max-height:100%;"
th:src="${book.picUrl}">
<br>
<span style="width: 100px" class="book_name" th:text="${book.bookName}"></span>
<br>
<span th:text="${#strings.length(book.bookName) > 5}? (${#strings.substring(book.bookName,0,5)}+'...'): ${book.bookName} "></span>
</a>
</div>
</a>
</span>
</div>
</div>
</div>
<div class="layui-colla-item">
<blockquote class="layui-elem-quote" style="text-align: left;font-size: 16px">
热门推荐
</blockquote>
<div class="layui-container">
<div class="layui-row" id="hotRecBooks" th:if="${bookMap['3']}">
<div th:each="book,iterStat : ${bookMap['3']}" th:if="${iterStat.index<6}" style="margin-bottom: 5px"
class="layui-col-xs12 layui-col-sm6 layui-col-md4 layui-col-lg4">
<a th:href="'/book/'+${book.bookId}+'.html'">
<div class="layui-col-xs5 layui-col-sm4 layui-col-md4 layui-col-lg4">
<img style=" width:100px; height:125px;" th:src="${book.picUrl}">
</div>
<div class="layui-col-xs5 layui-col-sm6 layui-col-md6 layui-col-lg6">
<ul>
<li style="padding-bottom: 2px" class="line-limit-length"
th:text="${book.bookName}"></li>
<li style="padding-bottom: 2px;color: #a6a6a6" th:text="'作者'+${book.authorName}"></li>
<li style="color: #a6a6a6;width: 180px;height:60px;overflow: hidden"
th:utext="${book.bookDesc}"></li>
</ul>
</div>
<div style="font-style: italic;color: red"
class="layui-col-xs2 layui-col-sm2 layui-col-md2 layui-col-lg2"></div>
</a>
</div>
</div>
</div>
</div>
</div>
<div style="clear: both"></div>
<div class="layui-colla-item">
<blockquote class="layui-elem-quote" style="text-align: left;font-size: 16px">
热门推荐
</blockquote>
<div class="layui-container">
<div class="layui-row" id="hotRecBooks" th:if="${bookMap['3']}">
<div th:each="book,iterStat : ${bookMap['3']}" th:if="${iterStat.index<6}" style="margin-bottom: 5px"
class="layui-col-xs12 layui-col-sm6 layui-col-md4 layui-col-lg4">
<a th:href="'/book/'+${book.bookId}+'.html'">
<div class="layui-col-xs4 layui-col-sm4 layui-col-md4 layui-col-lg4">
<img style=" width:100px; height:125px;" th:src="${book.picUrl}">
</div>
<div class="layui-col-xs8 layui-col-sm8 layui-col-md8 layui-col-lg8">
<ul>
<li style="padding-bottom: 5px" class="line-limit-length"
th:text="${book.bookName}"></li>
<li style="padding-bottom: 5px;color: #a6a6a6" th:text="'作者'+${book.authorName}"></li>
<li class='book_desc' style="color: #a6a6a6;padding-right:10px;"
th:utext="${book.bookDesc}"></li>
</ul>
</div>
<div style="font-style: italic;color: red"
class="layui-col-xs2 layui-col-sm2 layui-col-md2 layui-col-lg2"></div>
</a>
</div>
</div>
</div>
</div>
<div style="clear: both"></div>
<div style="height: 1px" class="layui-col-lg1"></div>
@@ -184,7 +220,6 @@
</div>
</div>
</div>
<div style="clear: both"></div>
<div th:replace="mobile/common/footer :: footer">
@@ -226,7 +261,7 @@
"\n" +
" <div style=\"clear: both\"></div>\n" +
" <div style=\"color: #a6a6a6;padding-left: 5px;padding-top: 5px\"\n" +
" class=\"layui-elip layui-col-md11 layui-col-sm11 layui-col-lg11\">简介:  " + updateRankBook.bookDesc + "" +
" class=\"layui-elip layui-col-md11 layui-col-sm11 layui-col-lg11\">简介:" + updateRankBook.bookDesc + "" +
" </div></a>\n" +
" </div>");

View File

@@ -133,6 +133,52 @@
})
},
SaveCommentReply: function (cmtBId, cmtCId, cmtDetail) {
if (!isLogin) {
layer.alert('请先登录');
return;
}
var cmtDetailTemp = cmtDetail.replace(/(^\s*)/g, "");
if (cmtDetailTemp == '') {
layer.alert('回复内容必须填写');
return;
}
if (cmtDetailTemp.length < 5) {
layer.alert('回复内容必须大于5个字');
return;
}
if (cmtDetail.length < 5) {
layer.alert('回复内容必须大于5个字');
return;
}
$.ajax({
type: "POST",
url: "/book/addCommentReply",
data: {'commentId': $("#commentId").val(), 'replyContent': cmtDetail},
dataType: "json",
success: function (data) {
if (data.code == 200) {
$('#txtComment').val("")
layer.alert('回复成功');
loadCommentList(1, 20);
} else if (data.code == 1001) {
//未登录
location.href = '/user/login.html?originUrl=' + encodeURIComponent(location.href);
} else {
layer.alert(data.msg);
}
},
error: function () {
layer.alert('网络异常');
}
})
},
GetFavoritesBook: function (BId) {
},