十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot在线小说阅读平台源码解析:从数据库设计到事务一致性

Spring Boot在线小说阅读平台源码解析:从数据库设计到事务一致性 简介基于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.6Spring 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 章节表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两张表的结构边界值得细看。章节表的唯一索引uk_book_no锁定了“一本书下章节号不重复”的业务约束这个约束比在 Service 层用 if 判断更可靠。MEDIUMTEXT支撑单章最大 16MB 正文对网文场景完全够用。书籍表的status字段用 TINYINT 而不是 VARCHAR 枚举是刻意留出扩展余地——将来加“番外篇”“作品相关”状态时不需要改表结构。注意如果源码里的建表语句没有DEFAULT CHARSETutf8mb4手动补上。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;第一条查询如果看到typeALL说明需要联合索引。第二条查询关注 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.4.3.4/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.2/version /dependencyMaven 依赖里最容易忽略的是 PageHelper 与 Spring Boot 2.3 的兼容性。PageHelper 1.4.2 支持 MyBatis 3.5.7但如果源码里 MyBatis Plus 版本过高拦截器会冲突。常见做法是二选一要么用 MyBatis Plus 自带的PageT分页推荐要么用 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 ResultChapterVO 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 idselectPrevChapterId resultTypejava.lang.Long SELECT id FROM tb_chapter WHERE book_id #{bookId} AND chapter_no lt; #{chapterNo} ORDER BY chapter_no DESC LIMIT 1 /select用lt;转义是 XML 文件里的固定操作不少新手在这里会直接写报错。SQL 利用(book_id, chapter_no)的唯一索引天然有序不需要子查询性能比先查全部章节再在代码里循环要高一个数量级。3.3 拦截器统一处理登录态与用户上下文Session 会话管理如果用最原始的HttpSession.getAttributeService 层每接一个请求都要写重复代码。规范做法是拦截器中校验把用户 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;charsetUTF-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 PageResultBookshelfVO pageShelf(Long userId, int pageNum, int pageSize) { PageBookshelfVO page new Page(pageNum, pageSize); ListBookshelfVO records baseMapper.selectShelfPage(page, userId); return new PageResult(records, page.getTotal(), pageNum, pageSize); }select idselectShelfPage resultTypecom.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 /selectLEFT 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 ResultVoid 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 LambdaQueryWrapperBookshelf() .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.activedev target/novel-platform-1.0-SNAPSHOT.jar钱包和账号配置写在application-dev.yml里spring: datasource: url: jdbc:mysql://localhost:3306/novel_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root123 redis: host: localhost port: 6379 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl上面这段代码里的log-impl在测试环境开可以看到 SQL但上线务必关掉不然信息会流到日志文件里造成信息不必要地外流。serverTimezoneAsia/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字段上建索引。框架能自动帮你管好配置但解决不了索引缺失和 N1 查询——这两个问题要到上线前压测才会暴露耗时成本和排查成本都远高于提前准备。本文还有配套的精品资源点击获取
返回列表