简介:基于SpringBoot构建的在线小说阅读平台毕业设计资源,整合了完整可运行的项目源码、数据库SQL脚本和配套论文,面向需要完成毕设、课程设计或大作业的Java学习者,也很适合作为SpringBoot入门实践的参考项目。资源包整体大小约40.31MB,压缩包内主要包含后端Java源码、前端页面资源、数据库建表及初始化数据SQL文件,以及用于梳理系统设计的毕业论文文档;源码按功能模块组织,能够直观看到小说列表、分类检索、用户书架、阅读进度等核心功能的实现方式。目前已有69人学习浏览,代码经过严格测试,可直接导入开发环境运行,无需额外复杂配置。学习过程中,既可以对照论文理解系统整体架构,也可以围绕SpringBoot整合MyBatis、Thymeleaf等主流技术进行重点分析,并在此项目基础上增加评论、推荐等个性化功能。项目涉及的小说管理、用户登录与权限控制等模块,也覆盖了常见的工程化开发思路与表结构设计技巧,对准备毕业设计或快速上手SpringBootWeb开发的读者都有实际帮助。
1. 在线小说阅读平台在 Spring Boot 技术栈里到底在练什么
接手这类“在线小说阅读平台”项目源码时,最容易被 README 里密密麻麻的功能列表带偏。实际上剥掉“小说”这个业务外壳,它训练的是 Web 开发里最经典的一组能力组合:资源型数据的分层建模(书籍与章节是一对多关系)、读多写少场景下的缓存与分页设计、以及用户私有状态(书架、阅读进度)的事务一致性。Spring Boot 在这条链路里负责把 Spring MVC、MyBatis、事务管理和 Session 会话粘合起来,让开发者把注意力集中在业务边界而不是配置琐事上。
这套源码我会先用数据库 SQL 反推业务范围,再用代码验证它是否真的可用,最后补上批量导入、阅读时长统计这类实际项目里一定会出现的硬需求。对 5 年以上后端经验的人,重点看第二章的索引设计和第四章的“阅读进度 + 书架”双写一致性;对刚入门的人,跟着第三章的 Controller-Service-Mapper 三层结构可以完整走通一个业务接口。基础假设是 JDK 8 + MySQL 5.7 + Maven 3.6,Spring Boot 版本锁定 2.3.x 或 2.7.x 均可,过高版本(3.x)会牵涉 jakarta 命名空间改动,不建议从这个项目开始尝鲜。
2. 用数据库 SQL 反推小说平台的数据模型与索引设计
拿到源码包后不要急着启动,先打开sql目录下的脚本。小说阅读平台的核心表不会超过 10 张,但表关系比普通 CRUD 多一层“书籍-章节-阅读进度”的嵌套聚合。这一步读懂了,后面所有业务代码都能在表结构里找到落点。
2.1 核心表结构与字段语义
-- 书籍表 CREATE TABLE tb_book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT '书名', author VARCHAR(50) NOT NULL DEFAULT '', category_id INT NOT NULL COMMENT '分类ID', intro VARCHAR(500) DEFAULT '' COMMENT '简介', cover_url VARCHAR(255) DEFAULT '' COMMENT '封面路径', status TINYINT DEFAULT 1 COMMENT '1连载 2完结', word_count INT DEFAULT 0 COMMENT '总字数', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 章节表 CREATE TABLE tb_chapter ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL COMMENT '所属书籍ID', chapter_no INT NOT NULL COMMENT '章节序号', title VARCHAR(200) NOT NULL, content MEDIUMTEXT NOT NULL COMMENT '章节正文', word_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_book_no (book_id, chapter_no), KEY idx_book_id (book_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;两张表的结构边界值得细看。章节表的唯一索引uk_book_no锁定了“一本书下章节号不重复”的业务约束,这个约束比在 Service 层用 if 判断更可靠。MEDIUMTEXT支撑单章最大 16MB 正文,对网文场景完全够用。书籍表的status字段用 TINYINT 而不是 VARCHAR 枚举,是刻意留出扩展余地——将来加“番外篇”“作品相关”状态时不需要改表结构。
注意:如果源码里的建表语句没有
DEFAULT CHARSET=utf8mb4,手动补上。utf8 在 MySQL 中只支持最多 3 字节,遇到 emoji 或生僻字会直接报错。
2.2 书架、阅读进度与用户表的关联设计
用户私有状态是这类型平台区别于普通内容管理系统的核心。书架表和阅读进度表如果合在一张表里,会出现“收藏了但没读过”和“读过但移出书架”两条状态互相打架的问题。标准做法是拆开。
CREATE TABLE tb_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL COMMENT 'BCrypt密文', nickname VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE tb_bookshelf ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, last_read_chapter_id BIGINT DEFAULT NULL COMMENT '最后阅读章节', last_read_time DATETIME DEFAULT NULL, UNIQUE KEY uk_user_book (user_id, book_id), KEY idx_user_id (user_id) ); CREATE TABLE tb_read_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, chapter_id BIGINT NOT NULL, read_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, read_time) );tb_bookshelf的书架项里直接冗余了last_read_chapter_id,这是刻意为之。翻页查询书架列表时,只需要 JOIN 一次 chapter 表就能展示“当前读到第几章”,而不是对每本书单独查一次阅读进度表。tb_read_history是流水表,只做增量写入,用于“最近阅读”列表。两张表通过user_id + book_id天然关联,但各自的写入时机完全不同——书架是用户主动加书操作,历史记录是打开章节时被动写入。
2.3 索引设计与查询计划的验证思路
索引设计上有一个高频踩坑点:分页查询书籍列表时,如果category_id的区分度低(比如分类只有 8 个),数据库优化器可能放弃索引走全表扫描。解析的高频理解是先看 SQL 再决定要不要FORCE INDEX。以下两条验证命令在拿到源码后建议立刻执行:
EXPLAIN SELECT * FROM tb_book WHERE category_id = 3 ORDER BY update_time DESC LIMIT 12; EXPLAIN SELECT b.id, b.title, c.chapter_no, c.title AS chapter_title FROM tb_bookshelf s JOIN tb_book b ON s.book_id = b.id JOIN tb_chapter c ON s.last_read_chapter_id = c.id WHERE s.user_id = 10086;第一条查询如果看到type=ALL,说明需要联合索引。第二条查询关注 JOIN 的驱动顺序,MySQL 默认走user_id索引先筛书架,再用主键回表取书籍和章节信息,这个顺序是对的。真正需要优化的场景通常在书籍列表页——category_id + update_time的复合索引能消除 filesort,代价是每次更新书籍信息时索引维护成本上升。对小说平台这种“读多写少”的场景,收益远大于成本。
2.4 一键初始化的 SQL 脚本组织方式
源码包里的 SQL 文件如果是单一大文件,建议按模块拆开组织,避免后期维护混乱:
-- V1__schema.sql CREATE DATABASE IF NOT EXISTS novel_db DEFAULT CHARSET utf8mb4; USE novel_db; -- V2__init_data.sql INSERT INTO tb_category (id, name) VALUES (1, '玄幻'), (2, '都市'), (3, '仙侠'), (4, '历史'); -- V3__sample_books.sql -- 批量插入示例书籍,使用存储过程生成十万级测试数据 DELIMITER $$ CREATE PROCEDURE batch_insert_books(IN total INT) BEGIN DECLARE i INT DEFAULT 1; WHILE i <= total DO INSERT INTO tb_book (title, author, category_id, intro, word_count) VALUES (CONCAT('测试书籍', i), '作者A', (i MOD 4) + 1, '简介内容', 50000); SET i = i + 1; END WHILE; END$$ DELIMITER ; CALL batch_insert_books(5000);如果数据库 SQL 文件里有类似的存储过程或者事务包裹,导入时用source命令而不是复制粘贴到可视化工具里执行。MySQL 客户端对多语句和自定义分隔符的支持更稳定。导入完成后用第 2.3 节的 EXPLAIN 验证索引是否真正生效,不要只看表结构里的 KEY 定义就认为万无一失。
3. Spring Boot 后端三层结构与小说接口的最小实现
项目源码的 controller/service/mapper 三层结构在 Spring Boot 项目里几乎是事实标准。本章先讲结构骨架,再带一个“获取章节详情”接口的完整写法,最后落到拦截器与登录态校验——这部分源码最容易出现逻辑漏洞。
3.1 项目目录结构与依赖选型
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.3.4</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.2</version> </dependency>Maven 依赖里最容易忽略的是 PageHelper 与 Spring Boot 2.3 的兼容性。PageHelper 1.4.2 支持 MyBatis 3.5.7,但如果源码里 MyBatis Plus 版本过高,拦截器会冲突。常见做法是二选一:要么用 MyBatis Plus 自带的Page<T>分页(推荐),要么用 PageHelper 但把 MyBatis Plus 的mapper-locations配好,避免扫描到同一个 XML 文件。目录结构按职责切分:
com.novel.platform ├── controller // 接收请求,参数校验 ├── service // 业务逻辑,事务边界 ├── mapper // 数据访问,MyBatis-Plus ├── entity // 数据库实体映射 ├── config // WebMvcConfig、拦截器注册 └── common // 统一返回体、异常处理3.2 获取章节详情的完整链路
读取章节是平台最核心的接口,并发量最大。实现上要覆盖三个点:参数校验、阅读历史异步写入、章节内容返回。
@RestController @RequestMapping("/api/chapter") public class ChapterController { @Resource private ChapterService chapterService; @Resource private ReadHistoryService readHistoryService; @GetMapping("/{chapterId}") public Result<ChapterVO> getChapter(@PathVariable Long chapterId, @RequestAttribute("userId") Long userId) { Chapter chapter = chapterService.getById(chapterId); if (chapter == null) { return Result.error("章节不存在"); } ChapterVO vo = new ChapterVO(); vo.setId(chapter.getId()); vo.setBookId(chapter.getBookId()); vo.setTitle(chapter.getTitle()); vo.setContent(chapter.getContent()); vo.setPrevId(chapterService.getPrevChapterId(chapter.getBookId(), chapter.getChapterNo())); vo.setNextId(chapterService.getNextChapterId(chapter.getBookId(), chapter.getChapterNo())); readHistoryService.record(userId, chapter.getBookId(), chapterId); return Result.success(vo); } }代码里的record方法在后续 4.2 会展开,这里要说明@PathVariable拿到的章节 ID 必须由 Service 层再次校验归属关系。有些源码会直接在 Controller 里用 DAO 查库,上线后就暴露越权问题——传入其他书籍的 chapterId 也能读到正文,这是典型的水平越权漏洞。getPrevChapterId和getNextChapterId的实现建议使用 SQL 查询而不是内存中排序:
public Long getPrevChapterId(Long bookId, Integer chapterNo) { return baseMapper.selectPrevChapterId(bookId, chapterNo); }<select id="selectPrevChapterId" resultType="java.lang.Long"> SELECT id FROM tb_chapter WHERE book_id = #{bookId} AND chapter_no < #{chapterNo} ORDER BY chapter_no DESC LIMIT 1 </select>用<转义是 XML 文件里的固定操作,不少新手在这里会直接写<报错。SQL 利用(book_id, chapter_no)的唯一索引天然有序,不需要子查询,性能比先查全部章节再在代码里循环要高一个数量级。
3.3 拦截器统一处理登录态与用户上下文
Session 会话管理如果用最原始的HttpSession.getAttribute,Service 层每接一个请求都要写重复代码。规范做法是拦截器中校验,把用户 ID 塞进 Request Attribute,供后续业务直接取用。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object userId = session.getAttribute("userId"); if (userId == null) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}"); return false; } request.setAttribute("userId", userId); return true; } }注册拦截器时要注意排除静态资源和登录接口本身:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/user/login", "/api/user/register", "/api/book/list", "/api/book/detail/**"); } }拦截器方案相比注解@RequiresLogin的好处是一处配置全局生效,坏处是排除路径列表会越来越长。更进阶的做法是用 Spring AOP 配合自定义注解做细粒度控制,但那是大型微服务的玩法,单体平台上拦截器已经足够稳健。源码里常见的坑有两个:路径模式写成/api/*导致二级路径不拦截、排除路径里忘了放/api/book/**造成游客连书籍列表都打不开。
4. 阅读器翻页、书架状态与事务一致性的技术取舍
阅读器和书架是小说平台区别于其他 CRUD 系统的分水岭。这一章不从零教你写前端翻页,而是聚焦后端如何设计“进度持久化”和“书架双状态”的边界。做对了,线上不会丢读者进度;做错了,用户会一直反馈“上次读到的地方忘了”。
4.1 分页查询与查询参数规范
书架列表是典型的关联分页查询:要显示书籍封面、书名、最后阅读章节标题、最后阅读时间。注意最后阅读时间在tb_bookshelf表中,不能依赖 chapter 表的创建时间。
public PageResult<BookshelfVO> pageShelf(Long userId, int pageNum, int pageSize) { Page<BookshelfVO> page = new Page<>(pageNum, pageSize); List<BookshelfVO> records = baseMapper.selectShelfPage(page, userId); return new PageResult<>(records, page.getTotal(), pageNum, pageSize); }<select id="selectShelfPage" resultType="com.novel.platform.entity.BookshelfVO"> SELECT s.id AS shelf_id, b.id AS book_id, b.title AS book_title, b.cover_url, c.id AS last_chapter_id, c.title AS last_chapter_title, s.last_read_time FROM tb_bookshelf s JOIN tb_book b ON s.book_id = b.id LEFT JOIN tb_chapter c ON s.last_read_chapter_id = c.id WHERE s.user_id = #{userId} ORDER BY s.last_read_time DESC </select>LEFT JOIN加在章上是因为 DB 允许last_read_chapter_id为 NULL——用户收藏了但从未阅读时,书架项要正常展示。如果这里用INNER JOIN,未读的书籍会从书架上凭空消失。排序用last_read_time而非create_time,业务意图是“最近读过的放前面”,不是“最新收藏的放前面”。这两个细节是源码评审时的高频关注点。
4.2 阅读进度更新与书架状态写入的一致性
读章节时同时要更新书架里的最后阅读位置,这两步操作涉及两张表。如果中间一步失败,用户要么看到书架没更新,要么看到重复的历史记录。方案有两条路,根据源码现状选其一。
第一条路是强事务,适合单体应用:
@Service public class ReadHistoryServiceImpl implements ReadHistoryService { @Transactional(rollbackFor = Exception.class) public void record(Long userId, Long bookId, Long chapterId) { Bookshelf shelf = bookshelfMapper.selectByUserAndBook(userId, bookId); if (shelf != null) { shelf.setLastReadChapterId(chapterId); shelf.setLastReadTime(new Date()); bookshelfMapper.updateById(shelf); } ReadHistory history = new ReadHistory(); history.setUserId(userId); history.setBookId(bookId); history.setChapterId(chapterId); readHistoryMapper.insert(history); } }事务注解必须标注在 public 方法上,且要到 Service 接口实现类而非 Controller 里。rollbackFor = Exception.class必须显式声明,否则运行时框架默认只对 RuntimeException 回滚——检查异常会提交一个半成品状态。
第二条路是删掉历史记录插入,只更新书架。如果图书平台的“阅读历史”没做独立页面展示需求,多一次 DB 写入纯属浪费。标题里说的“在线小说阅读平台”,历史记录表通常是锦上添花的功能,在事务一致性要求上可以降级为“可丢失”。但书架进度不能丢,否则用户会流失。
另外注意一个容易被忽略的参数:last_read_chapter_id更新后,书架列表的排序权重被last_read_time承载,这个字段必须由后端生成。如果让前端传时间过来更新,用户改客户端时钟就能把任意书籍顶到书架最前位,属于越权写入。
4.3 阅读器章节切换的预加载边界
刚看小说的读者会快速连续翻页,每次请求章节都会触发历史表插入。在高并发下这一小段代码会成为写入热点。为了避免性能问题,业界常见做法是前端预加载后面三章,后端不修改接口逻辑,只减少请求次数。但预加载的前提是后端“获取章节”接口不带副作用——即它不能同时更新历史记录,因为用户可能只是滑过去没真正阅读。
把历史表更新拆成独立接口/api/reader/report,由前端在离开当前章或停留超过设定时长后调用一次。后端收到上报只更新书架进度和读写历史表,不返回业务数据:
@PostMapping("/api/reader/report") public Result<Void> reportReading(@RequestBody ReadingReportDTO dto, @RequestAttribute("userId") Long userId) { readHistoryService.record(userId, dto.getBookId(), dto.getChapterId()); return Result.success(); }// 前端逻辑示意:离开页面时上报进度 window.addEventListener('beforeunload', () => { if (navigator.sendBeacon) { navigator.sendBeacon('/api/reader/report', JSON.stringify({ bookId: currentBookId, chapterId: currentChapterId })); } });sendBeacon在页面卸载后仍能发出异步请求,且不会被浏览器挂起。这一层设计在源码里如果缺失,大概率是直接用同步 XHR,会在切页时多出几百毫秒的网络阻塞。如果你拿到的项目源码没有上报接口,可以自己补上。
4.4 书架删除与移出的软删除策略
tb_bookshelf表删除操作要注意外键依赖。删除书架项后,阅读历史表里的记录不应该被删——用户下次搜索仍能看到足迹。源码里如果用了物理外键FOREIGN KEY,删除书籍时会受约束阻塞,出现删除失败、事务回滚。实际项目里 MySQL 分布式场景极少用物理外键,统一用逻辑外键(普通字段 + 应用层判断)。删除操作的推荐写法是:
public void removeFromShelf(Long userId, Long bookId) { int removed = bookshelfMapper.delete( new LambdaQueryWrapper<Bookshelf>() .eq(Bookshelf::getUserId, userId) .eq(Bookshelf::getBookId, bookId)); if (removed == 0) { throw new BusinessException("书架项不存在"); } }LambdaQueryWrapper要确认项目引入的是 MyBatis Plus 3.x。如果是 2.x 版本,Lambda 方法引用写法有差异,编译时不会直接报错但运行抛异常,排查成本不低。删除书架时也不要顺带批量删除阅读历史,除非需求明确要求“清空足迹”。保留了历史,才能在首页做“继续阅读”推荐时召回用户之前浏览过的小说。
5. 从源码包到可运行系统,导入部署与验收清单
源码包拿到手,第一步是确认三样东西齐不齐:SQL 文件是否是完整建库脚本(不含DROP DATABASE强删语句)、application.yml 里的 MySQL 账号密码、后端源码是否带 Maven wrapper。这三样缺任何一样,都要手动补环境配置,不要硬跑。
5.1 导入与启动参数
mysql -u root -p -e "source /path/to/novel_db.sql" mvn clean package -DskipTests java -jar -Dspring.profiles.active=dev target/novel-platform-1.0-SNAPSHOT.jar钱包和账号配置写在application-dev.yml里:
spring: datasource: url: jdbc:mysql://localhost:3306/novel_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 redis: host: localhost port: 6379 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl上面这段代码里的log-impl在测试环境开可以看到 SQL,但上线务必关掉,不然信息会流到日志文件里,造成信息不必要地外流。serverTimezone=Asia/Shanghai不配,MySQL 8.x 驱动会直接报时区异常。Redis 在小说平台源码里可能扮演 Session 共享缓存和热点章节缓存双重角色,如果启动时报连接不上 Redis,先把依赖里spring-boot-starter-data-redis注掉,等主流程跑通再逐项加回。
5.2 核心接口的验收命令
启动后不要先点前端页面,直接用 curl 验证后端关键链路。
curl -X POST http://localhost:8080/api/user/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}' curl "http://localhost:8080/api/book/detail/1" curl "http://localhost:8080/api/chapter/1" \ -H "Cookie: JSESSIONID=你的会话ID"拿到登录返回的 Cookie 后再调章节接口,如果返回 401,检查拦截器排除路径的配置。章节接口能通之后,连续调用两次同一章节,确认书架表和阅读历史表的数据写入符合预期:
SELECT * FROM tb_read_history WHERE user_id = 10086 ORDER BY read_time DESC LIMIT 5; UPDATE tb_book SET word_count = word_count + 100 WHERE id = 1;最后这个更新语句用于验证事务边界——阅读历史写入失败时,事务是否回滚了书架进度。把readHistoryMapper.insert(history)临时改成制造一个主键冲突异常,然后调用章节接口,再查书架最后进度是否为旧值。是旧值说明事务生效;是新值说明事务没apply,这条代码有隐患。
5.3 一套低成本验证覆盖策略
给项目补测试不必追求覆盖率数字,聚焦三类接口即可:读类接口的性能基线、写类接口的幂等性、状态类接口的数据一致性。开关打开log-impl: StdOutImpl后,用 grep 观察慢 SQL 或者超过 200ms 的查询,配合EXPLAIN检查是否走了正确索引。小说平台表数据量过十万级后,tb_chapter.content的MEDIUMTEXT字段直接SELECT *会放大 IO,逼着联表查询时必须显式指定字段列表,这习惯在写新接口时就要建立。
用批量导入脚本灌入十万级数据,再连续翻页访问,看分页查询的耗时曲线是否一马平川。如果第 10000 页耗时异常,检查有没有在ORDER BY字段上建索引。框架能自动帮你管好配置,但解决不了索引缺失和 N+1 查询——这两个问题要到上线前压测才会暴露,耗时成本和排查成本都远高于提前准备。
本文还有配套的精品资源,点击获取