☰
基于SpringBoot的在线小说阅读平台源码:从书卷章结构到Redis缓存实战
2026/9/25 6:38:05 网站建设 项目流程

简介:这是一套基于SpringBoot的在线小说阅读平台完整源码,面向Java Web初学者、课程设计学生及需要搭建阅读类项目的开发者,帮助解决从零构建小说阅读系统的实际需求。项目采用Java语言与SpringBoot框架,前端使用Vue配合Ajax交互,后端依托Maven构建,数据库为MySQL 5.7,并通过MyBatisPlus完成数据持久化,开发环境兼容Eclipse、MyEclipse与IDEA,JDK版本为1.8。压缩包为zip格式,大小约18.83MB,内含项目源码、数据库脚本及配套文档等文件,目录结构清晰,便于按模块查阅与二次开发。资源围绕用户信息、图片素材、视频素材等核心模块展开,文档部分涵盖绪论、选题动因、背景与意义以及MySQL、Vue等相关技术介绍,可帮助读者理解系统整体设计思路与实现方式。目前已有62人学习下载,适合作为毕业设计、课程作业或自学练手项目,快速掌握SpringBoot与Vue前后端分离开发流程。

1. 从零搭一个在线小说阅读平台:为什么我劝你先别急着写书架

很多人第一次接触在线小说阅读平台,脑子里想的是「一个书架、一个阅读页、一个目录」,觉得三天就能撸完。真动手才发现,光是「章节正文怎么存」这一件事就能卡住一周:存数据库大字段,翻页慢得离谱;存静态文件,目录和正文又对不上号。基于 SpringBoot 的在线小说阅读平台源码,本质是一套把「书—卷—章」三级结构、阅读进度、分页缓存、正文渲染串起来的内容管理系统,它解决的不是「能不能显示文字」,而是「几十万字的长篇怎么让用户翻得顺、后台传得快、数据库扛得住」。这套东西适合两类人:一类是拿它做 Java 课程设计案例源码、想找一个能写进简历的完整项目;另一类是真的想上线一个阅读站,需要一套能改、能扩的骨架。下面我按自己踩过的顺序,把选型、建表、接口、缓存和排错讲清楚。

2. 技术选型与数据模型:先把「书—卷—章」三级结构定死

2.1 为什么是 SpringBoot + MyBatis 而不是 JPA

在线小说阅读平台的数据访问有个鲜明特点:读多写少,而且查询几乎全是「按书 ID 查章节列表」「按章节 ID 查正文」这种点查,偶尔来一个「按分类 + 更新时间倒序」的列表页。这种场景下,MyBatis 的手写 SQL 比 JPA 的自动生成更可控,尤其是分页和字段裁剪——列表页只需要章节标题和 ID,正文页才需要 content 大字段,用 JPA 很容易把整个实体捞出来,白白拖慢响应。

SpringBoot 这边我一般选 2.7.x 或 3.x 的稳定版,别追最新。热词里常出现「springboot 版本太高」,这不是玩笑:3.x 默认把 javax 换成 jakarta,很多老依赖直接编译不过。如果你是从网上抄的 Java 课程设计案例源码,先看它用的哪个大版本,再决定自己的 JDK 是 8 还是 17。我自己的习惯是 JDK 17 + SpringBoot 3.2 + MyBatis-Plus,MyBatis-Plus 自带分页插件,省得自己写 count 查询。

依赖清单大致是这几块:spring-boot-starter-web 提供接口层,mybatis-plus-boot-starter 管数据访问,spring-boot-starter-data-redis 做章节正文缓存,spring-boot-starter-validation 校验参数,再加上 mysql-connector-j 和 lombok。别小看 validation,后台传章节时如果标题为空、字数超限,没有校验就会写进脏数据,后面清洗起来是血泪经验。

2.2 三张核心表怎么设计才不返工

小说平台最忌讳把「书」和「章」塞进一张表。正确做法是拆成 book、chapter、reading_progress 三张主表,外加 category 分类表。book 表存书名、作者、封面、简介、分类 ID、状态(连载/完结)、总字数、最后更新时间;chapter 表存所属 book_id、卷号、章节序号、标题、正文、字数;reading_progress 存用户 ID、书 ID、最后读到的章节 ID 和偏移量。

这里有个关键决策:正文 content 用什么类型。MySQL 里 TEXT 最大 64KB,MEDIUMTEXT 是 16MB,LONGTEXT 是 4GB。单章小说一般 2000 到 5000 字,UTF-8 下也就十几 KB,TEXT 够用,但如果你要存带格式的富文本,直接上 MEDIUMTEXT 更稳。我见过有人用 VARCHAR(65535),结果遇到生僻字加 emoji 直接截断,翻车现场。

CREATE TABLE `book` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL COMMENT '书名', `author` VARCHAR(64) NOT NULL COMMENT '作者', `category_id` INT NOT NULL COMMENT '分类ID', `cover_url` VARCHAR(255) DEFAULT NULL, `intro` VARCHAR(512) DEFAULT NULL, `status` TINYINT DEFAULT 0 COMMENT '0连载 1完结', `word_count` INT DEFAULT 0, `last_chapter_id` BIGINT DEFAULT NULL COMMENT '最后章节,用于更新排序', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_update` (`category_id`, `update_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `chapter` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `book_id` BIGINT NOT NULL, `volume_no` INT DEFAULT 1 COMMENT '卷号', `chapter_no` INT NOT NULL COMMENT '章节序号', `title` VARCHAR(128) NOT NULL, `content` MEDIUMTEXT NOT NULL, `word_count` INT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_book_chapter` (`book_id`, `chapter_no`), KEY `idx_book` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

上面这段建表语句里,uk_book_chapter这个唯一索引是后悔药级别的设计。没有它,后台重复提交或者爬虫重跑就会插入重复章节,用户翻目录看到两个「第一章」,体验直接崩。idx_category_update则是给分类列表页准备的联合索引,让「按分类查、按更新时间倒序」走索引而不是全表扫。字符集一定用 utf8mb4,别用 utf8,后者在 MySQL 里其实是三字节的,存不了 emoji 和部分生僻字。

2.3 分页插件和字段裁剪的配合

列表页查询章节时,绝对不要把 content 带出来。MyBatis-Plus 里可以用select指定字段,或者干脆建一个 ChapterListVO 只映射 id、title、chapter_no。分页插件配置如下:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 分页插件,指定数据库类型为 MySQL interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

参数说明:DbType.MYSQL决定了分页 SQL 的方言,写错会导致 limit 语法不生效。分页查询时Page<ChapterListVO> page = new Page<>(pageNo, pageSize),pageSize 我一般限制在 50 以内,目录页一次拉太多前端也渲染不动。这里有个容易忽略的点:分页插件默认会先查 count,如果表数据量大,count 本身也慢,可以在不需要总数的场景下关掉searchCount。

3. 核心接口实现:从目录分页到正文缓存的完整链路

3.1 目录接口:一次返回章节列表而不是整本书

目录接口的设计直接决定阅读体验。常见做法是GET /api/book/{bookId}/chapters?pageNo=1&pageSize=100,返回章节 ID、标题、序号。前端拿到后渲染成目录,点击某章再单独请求正文。为什么不一次把整本书的章节都返回?一本 3000 章的书,光标题就有几百 KB,移动端流量和解析都吃不消。

@GetMapping("/book/{bookId}/chapters") public Result<Page<ChapterListVO>> listChapters( @PathVariable Long bookId, @RequestParam(defaultValue = "1") Integer pageNo, @RequestParam(defaultValue = "100") Integer pageSize) { // 限制单页最大条数,防止前端传个 10000 把数据库拖垮 pageSize = Math.min(pageSize, 200); Page<ChapterListVO> page = new Page<>(pageNo, pageSize); // 只查 id、chapter_no、title 三列,不碰 content LambdaQueryWrapper<Chapter> wrapper = new LambdaQueryWrapper<Chapter>() .select(Chapter::getId, Chapter::getChapterNo, Chapter::getTitle) .eq(Chapter::getBookId, bookId) .orderByAsc(Chapter::getChapterNo); return Result.ok(chapterService.page(page, wrapper) .convert(this::toListVO)); }

逻辑说明:select显式指定列,避免 MyBatis-Plus 默认查全字段。orderByAsc保证目录顺序稳定,别依赖数据库默认顺序。convert把实体转成 VO,隔离数据库字段和接口字段。参数上,pageSize 做了上限保护,这是防止恶意请求的基本操作。如果目录特别长,还可以考虑按卷分组返回,前端做折叠。

3.2 正文接口与 Redis 缓存策略

正文是读得最频繁的接口,同一章可能被成千上万人读。直接查 MySQL 不是不行,但热点章节会把数据库连接占满。我一般用 Redis 缓存章节正文,key 设计成novel:chapter:{chapterId},value 存正文文本,过期时间设 24 小时。

public String getChapterContent(Long chapterId) { String cacheKey = "novel:chapter:" + chapterId; // 先查缓存 String cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return cached; } // 缓存未命中,查数据库 Chapter chapter = chapterMapper.selectById(chapterId); if (chapter == null) { return null; } // 写入缓存,设置 24 小时过期,避免冷章节长期占用内存 redisTemplate.opsForValue().set(cacheKey, chapter.getContent(), 24, TimeUnit.HOURS); return chapter.getContent(); }

参数说明:过期时间不能设太长,否则作者修改章节后用户长时间看到旧内容;也不能太短,否则缓存形同虚设。24 小时是个折中。更严谨的做法是更新章节时主动删除缓存,也就是「先更新数据库,再删缓存」,而不是更新缓存,避免并发写导致脏数据。这里有个经典坑:如果缓存删除失败,旧数据会一直留着,所以删除操作最好加重试或者用消息队列补偿。

3.3 阅读进度:别用「最后阅读时间」当唯一依据

阅读进度表看起来简单,但设计不好会出玄学问题。有人只用 user_id + book_id 做主键,存一个 last_chapter_id,结果用户在多设备上读,进度互相覆盖。更稳的做法是加一个 update_time,取最新的一条;或者干脆用 Redis 的 Hash 存progress:{userId},field 是 bookId,value 是章节 ID,天然支持多书并行。

public void saveProgress(Long userId, Long bookId, Long chapterId) { String key = "progress:" + userId; // Hash 结构,一本书一个 field,互不干扰 redisTemplate.opsForHash().put(key, String.valueOf(bookId), String.valueOf(chapterId)); // 异步落库,避免每次翻页都写 MySQL progressMapper.upsert(userId, bookId, chapterId); }

逻辑说明:Redis 负责高频读写,MySQL 负责持久化。upsert用INSERT ... ON DUPLICATE KEY UPDATE实现,前提是 user_id + book_id 建了唯一索引。参数上,如果用户量不大,也可以直接写 MySQL,但翻页频繁时数据库压力明显。

4. 后台管理与正文入库:批量导入和 XSS 过滤的坑

4.1 批量导入章节的两种方式

后台录入章节,单章手动填只适合调试,真正上线要支持 TXT 批量导入。常见做法是解析 TXT 的章节标题行(比如「第一章 xxx」),按正则切分后批量插入。这里要注意:不同来源的 TXT 章节标题格式五花八门,有的是「第1章」,有的是「第一章」,还有的带空格。正则要写得宽容一点。

// 匹配 第X章 / 第X节,X 可以是数字或中文数字 private static final Pattern CHAPTER_PATTERN = Pattern.compile("^\\s*第[0-9一二三四五六七八九十百千]+[章节]\\s*.*$"); public List<Chapter> parseTxt(Long bookId, List<String> lines) { List<Chapter> chapters = new ArrayList<>(); StringBuilder content = new StringBuilder(); String title = null; int chapterNo = 0; for (String line : lines) { if (CHAPTER_PATTERN.matcher(line).matches()) { // 遇到新章节标题,先把上一章存起来 if (title != null) { chapters.add(buildChapter(bookId, ++chapterNo, title, content.toString())); } title = line.trim(); content.setLength(0); } else { content.append(line).append("\n"); } } // 别忘了最后一章 if (title != null) { chapters.add(buildChapter(bookId, ++chapterNo, title, content.toString())); } return chapters; }

逻辑说明:用 StringBuilder 累积正文,遇到标题行就结算上一章。参数上,chapterNo自增保证序号连续。这里最容易翻车的是最后一章:循环结束后如果忘了把缓冲区里的内容存进去,最后一章就丢了,而且不报错,属于典型的黑匣子问题。批量插入时用saveBatch,每批 500 条,太多会撑爆 SQL 长度限制。

4.2 正文 XSS 过滤:上传 PDF 和富文本都要防

热词里提到「springboot 项目全局过滤器处理上传 pdf 文件时 xss 攻击」,这在小说平台同样适用。如果后台允许作者粘贴带 HTML 的正文,或者上传的 TXT 里混入了<script>,直接存库再渲染到前端就是 XSS 漏洞。常见做法是用 Jsoup 做白名单过滤,只保留<p>、<br>这类标签。

public String cleanContent(String raw) { if (raw == null) { return ""; } // 白名单:只允许段落和换行,其余标签全部转义 Safelist safelist = Safelist.none().addTags("p", "br"); return Jsoup.clean(raw, safelist); }

参数说明:Safelist.none()表示默认不允许任何标签,再按需添加。如果正文是纯文本,其实可以直接把<>转义,更简单。注意过滤要在入库前做,而不是渲染时做,否则数据库里存的就是脏数据,导出或二次加工时还会出问题。

5. 避坑与排查:那些让我加班到凌晨的常见问题

5.1 现象:目录页加载越来越慢,最后超时

原因:章节表数据量涨到几十万后,ORDER BY chapter_no没有走索引,MySQL 做了 filesort。解决:给(book_id, chapter_no)建联合索引,并且查询时确保 where 条件用到了 book_id。如果已经建了索引还是慢,用EXPLAIN看执行计划,确认 type 不是 ALL。

5.2 现象:正文接口偶发返回 null,但数据库里明明有数据

原因:缓存穿透或者缓存和数据库不一致。如果缓存里存了空值(比如章节被删),后续请求会一直命中空缓存。解决:缓存空值时也设一个短过期时间,比如 5 分钟;更新章节时先更新数据库再删缓存,删失败就重试。

5.3 现象:批量导入后章节顺序错乱

原因:TXT 里章节标题格式不统一,正则没匹配到,导致多章内容被合并成一章,或者章节序号跳号。解决:导入前先抽样检查标题行,把正则调宽;导入后校验chapter_no是否连续,不连续就报警。

5.4 现象:用户反馈「读到一半进度丢了」

原因:阅读进度只存在 Redis,Redis 重启或内存淘汰后数据丢失。解决:Redis 只做缓存,每次翻页异步写 MySQL;或者用 Redis 的 AOF 持久化,但根本方案还是落库。

5.5 现象:后台传章节报「Data too long for column 'content'」

原因:正文超过了字段类型上限,比如用了 TEXT 却传了 20 万字。解决:把 content 改成 MEDIUMTEXT 或 LONGTEXT,同时在应用层做字数校验,超过阈值就拒绝并提示。

6. 进阶技巧:用 HanLP 做章节关键词和阅读推荐

平台跑起来之后,真正拉开差距的是「让用户找到下一本想读的书」。热词里出现「hanlp 分词在 springboot」,这正好能用在小说场景:对每本书的简介和章节标题做分词,提取关键词,再基于关键词做相似推荐。比如用户读完一本玄幻,系统根据「修炼」「宗门」「丹药」这些词召回同类书。

// 引入 hanlp-portable 依赖后 public List<String> extractKeywords(String text, int topN) { // 提取关键词,返回带权重的词条 List<Term> terms = HanLP.extractKeyword(text, topN); return terms.stream().map(Term::word).collect(Collectors.toList()); }

参数说明:topN控制返回关键词数量,简介短就取 5 到 10 个。分词结果可以存到 book 表的一个 keyword 字段,或者单独建倒排表。推荐时用关键词做交集匹配,简单有效。注意 HanLP 的词典加载有内存开销,建议在应用启动时初始化一次,别每次请求都 new。

验证推荐效果的方法很土但管用:找 20 本书,人工标注哪些是同类,然后看关键词召回的准确率。如果准确率低于 60%,说明关键词提取有问题,可能是简介太短或者停用词没过滤。我一般会加一个停用词表,把「的」「了」「是」这类词去掉。

最后说个我自己的习惯:每次改完缓存逻辑,我都会手动把 Redis 里对应的 key 删掉再测一遍,确认「缓存未命中 → 查库 → 回写」这条链路是通的。因为缓存这东西,平时看着没事,一旦出问题就是用户直接看到旧内容或者空白页,排查起来特别费劲。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询