
简介一套基于SpringbootVueMybatis的小说阅读管理系统完整源码面向Java Web初学者、毕业设计或课程设计学生以及需要掌握前后端分离项目的开发者。压缩包共480个文件、约5.1MB包含82个Java后端源码、44个HTML页面、39个CSS样式、112个JS逻辑文件另附SQL脚本、yml/xml配置文件、UEditor富文本编辑器素材、图标图片与动图整体目录按后端服务、前端页面、静态资源、数据库脚本划分方便导入开发环境直接运行与二次修改。系统实现用户注册登录、小说上传编辑、分类搜索、在线章节阅读、书签管理等核心功能采用Spring Security完成权限控制Mybatis负责数据库CRUD与动态SQLVue.js配合Vuex和Vue Router处理前端状态与路由并通过RESTful API与后端进行数据交互。资源中还包括Mybatis的Mapper接口与XML映射文件Vue组件及路由配置完整可作为模块化开发的参照实现。已有663人学习下载既能作为全栈实战教学案例也可帮助理解SpringBoot自动配置、Mybatis映射、Vue组件化开发和项目部署全流程。1. 小说阅读管理系统SpringBootVueMybatis 这套组合到底怎么落地做小说阅读站最让人头疼的从来不是写个接口而是用户书架、章节分页、阅读进度这堆业务逻辑叠在一起时后端改一处前端崩三处的连锁反应。我拆过的这个基于 SpringBoot Vue Mybatis 的小说阅读管理系统恰好把这块短板补齐了后端用 SpringBoot 管业务和权限Mybatis 负责所有 SQL 的精细控制前端用 Vue 做单页应用书架、书评、阅读记录、后台管理全都有现成实现。它适合两类人一是还在用 JSP 或者纯 Servlet 写小说站、想迁移到前后端分离架构的开发者二是正准备做毕设、需要一个能跑通全流程参考项目的学生。这个资源最大的价值不是能跑而是它能让你看清楚一个完整的小说业务系统从前端路由到后端事务再到数据库表关联是怎么串起来的。接下来我会从项目结构、表设计、后端核心模块、前端页面到部署排错一条线拆完。2. 项目架构与数据库设计先用 5 张表把小说业务模型立住2.1 前后端分离的项目结构为什么这么分层拿到项目第一件事不是看代码而是看目录。这套系统用的是标准的前后端分离结构后端是 Maven 多模块或者说单模块但包结构清晰的那种controller 层只做参数接收和结果封装service 层做业务逻辑mapper 层就是 Mybatis 的接口加 XML。前端是 Vue CLI 生成的标准工程src 下面按 api、router、views、components 分目录。我一般会先打开后端项目的pom.xml确认依赖版本。这个项目用的是 SpringBoot 2.x 系列Mybatis 用的是mybatis-spring-boot-starter而不是原生 mybatis 包这个区别很重要——用 starter 的话你不用手动配置 SqlSessionFactorySpringBoot 的自动配置会帮你搞定。Vue 端则是 vue2 配合 vue-router 和 axios没有引额外的 UI 框架样式基本是手写的这对想看清逻辑的人来说反而是好事少了一层封装干扰。novel-reading-system/ ├── backend/ │ ├── src/main/java/com/novel/ │ │ ├── controller/ # 前端接口入口 │ │ ├── service/ # 业务逻辑层 │ │ ├── mapper/ # Mybatis Mapper 接口 │ │ └── entity/ # 数据库实体类 │ ├── src/main/resources/ │ │ ├── mapper/ # Mybatis XML 映射文件 │ │ └── application.yml # 数据源与端口配置 │ └── pom.xml └── frontend/ ├── src/ │ ├── api/ # axios 请求封装 │ ├── views/ # 页面组件 │ ├── router/ # 前端路由表 │ └── App.vue └── package.json目录结构本身不算复杂但注意 mapper 层那个 XML 目录是后端的重点。实际开发中很多人把 SQL 写在注解里这个项目用的是 XML 方式。XML 方式的好处是复杂 SQL 联合查询时格式更容易维护而且resultMap映射对比注解更直观。你后面改表结构的时候只需要动 XML 文件里面对应的 SQL 片段不需要重新编译 Java 代码。2.2 数据库表设计小说、章节、用户、书架、阅读记录这套系统的表设计在我看过的小说项目里算是紧凑的。核心是 5 张表小说表、章节表、用户表、书架表和阅读记录表。小说表存书名、作者、分类、简介、封面图 URL、状态连载/完结章节表存小说 ID、章节序号、标题、正文内容用户表除了基础账号字段还加了角色字段区分普通用户和管理员书架表做的是联合唯一索引阅读记录表存用户最后阅读的章节 ID 和阅读时间。看数据库脚本的时候重点看几个外键关系。章节表靠novel_id关联小说表这个字段必须建索引否则小说详情页每次查章节列表都是全表扫描。书架表是用户和小说多对多的中间表用联合唯一索引防止重复收藏。阅读记录表考虑的是性能问题读写都比较频繁所以只存最近一条记录而不是历史记录避免数据无限膨胀。CREATE TABLE t_book ( id int(11) NOT NULL AUTO_INCREMENT, book_name varchar(100) NOT NULL COMMENT 书名, author varchar(50) DEFAULT NULL COMMENT 作者, category varchar(20) DEFAULT NULL COMMENT 分类, intro text COMMENT 简介, cover_url varchar(255) DEFAULT NULL COMMENT 封面图地址, status tinyint(1) DEFAULT 0 COMMENT 0连载 1完结, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_chapter ( id int(11) NOT NULL AUTO_INCREMENT, book_id int(11) NOT NULL, chapter_no int(11) NOT NULL COMMENT 章节序号, title varchar(200) NOT NULL, content longtext NOT NULL COMMENT 正文, PRIMARY KEY (id), KEY idx_book_id (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;utf8mb4这个字符集选择是有讲究的。小说正文里会出现特殊符号比如 emoji 或者生僻字utf8存不了四字节字符直接报错。另外longtext存小说正文没问题但如果你的章节特别长比如每章两万字以上longtext的读取和传输都要注意后面我会说怎么优化。2.3 初始化流程从建库到跑通第一个接口项目导入 IDE 之后第一步不是直接启动而是把初始化数据跑一遍。资源包里通常会带一个sql文件夹里面是建库脚本和测试数据。先用 Navicat 或者命令行执行建库脚本导入测试数据再改application.yml的数据源配置。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/novel_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.novel.entity configuration: map-underscore-to-camel-case: true注意serverTimezone这个参数MySQL 8 以后不指定时区会报错。map-underscore-to-camel-case建议设成true这样数据库的book_name字段可以自动映射到 Java 实体的bookName属性省得每个字段都写 resultMap。不过我要提醒一句这个配置只对简单字段映射有效联合查询里如果多个表有同名字段你还是得手动写 resultMap。验证后端是否起来不用急着调页面。直接用浏览器访问http://localhost:8080/api/book/list如果返回 JSON 数据就说明 Mapper 层和数据库连接没问题。这一步很多人忽略非要去启动前端再联调结果前端报网络错误分不清是后端的问题还是跨域的问题浪费大量时间。3. 后端模块实战Mybatis XML 映射与分页查询的完整实现3.1 小说列表接口从 Controller 到 Mapper 的调用链看后端代码时我习惯沿着一条调用链读下去从 Controller 开始到 Service再到 Mapper 接口和 XML。小说列表这个接口是最典型的例子它做了两件事一是按分类筛选二是按关键词模糊搜索。Controller 层用RestController返回统一的结果封装对象这里项目里用了Result类里面是 code、msg、data 三个字段。前端拿到 code 为 200 才取 data这种封装方式前后端协调比较省心。RestController RequestMapping(/api/book) public class BookController { Autowired private BookService bookService; GetMapping(/list) public Result getBookList(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer limit, String category, String keyword) { PageBeanBook pageBean bookService.queryBookList(page, limit, category, keyword); return Result.success(pageBean); } }RequestParam的defaultValue是必须写的不然前端没传页码时后端直接空指针。keyword 和 category 是可选的前端传空字符串时Mybatis 的动态 SQL 会自动忽略这两个条件这个逻辑写在 XML 里面。3.2 Mybatis 动态 SQL 与分页手写 LIMIT 还是用 PageHelper翻页是这套系统的核心功能小说列表和章节列表都要用。项目里用的是手写 LIMIT 加 PageBean 的方式没有引入 PageHelper 插件。手写分页的优点是 SQL 逻辑完全可控缺点是每个接口都要算 offset。第一次看代码的人可能会问为什么不用 PageHelper我的判断是这个项目本身查询逻辑不复杂手写 LIMIT 反而更直观能一眼看到 SQL 是怎么执行的。如果你后续打算加复杂的多表联查再考虑引入 PageHelper 不迟。public PageBeanBook queryBookList(Integer page, Integer limit, String category, String keyword) { // 计算偏移量page从1开始 int offset (page - 1) * limit; ListBook books bookMapper.selectBookList(offset, limit, category, keyword); // 单独查总数 int total bookMapper.countBookList(category, keyword); PageBeanBook pageBean new PageBean(); pageBean.setList(books); pageBean.setTotal(total); pageBean.setPage(page); pageBean.setLimit(limit); return pageBean; }这里的逻辑要理解透彻分页查询永远是两条 SQL一条查数据一条查总数。如果只查数据不查总数前端就没法计算总页数。很多初学者只写第一条导致分页组件永远只有一页。查总数用的 SQL 和查列表的 SQL 条件必须保持一致否则翻到后面页码会出现空数据。select idselectBookList resultTypeBook SELECT id, book_name, author, category, intro, cover_url, status FROM t_book where if testcategory ! null and category ! AND category #{category} /if if testkeyword ! null and keyword ! AND (book_name LIKE CONCAT(%, #{keyword}, %) OR author LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY id DESC LIMIT #{offset}, #{limit} /selectwhere标签会自动去掉第一个AND这个细节很关键。如果你用if包裹条件但外层没有where当所有条件都不满足时 SQL 会变成SELECT * FROM t_book WHERE ORDER BY直接语法错误。CONCAT拼接%是为了模糊查询LIMIT #{offset}, #{limit}是 MySQL 的分页写法offset 从 0 开始。3.3 阅读进度模块事务与唯一约束阅读进度是小说系统里最容易被忽略但实际使用频率最高的功能。用户读到哪一章、进度条停在哪每次请求都要更新记录。这个项目用INSERT ... ON DUPLICATE KEY UPDATE的做法处理靠的是表里的联合唯一索引。这条 SQL 写得很聪明不需要先查询再判断更新还是插入数据库层面一次搞定。INSERT INTO t_read_record (user_id, book_id, chapter_id, read_time) VALUES (#{userId}, #{bookId}, #{chapterId}, NOW()) ON DUPLICATE KEY UPDATE chapter_id VALUES(chapter_id), read_time NOW()这里的user_id book_id是联合唯一索引如果该用户已经读过这本书那么重复插入时就会触犯唯一约束自动变成更新章节 ID 和阅读时间。省掉了 Java 层的判断也避免了并发场景下先查后改带来的数据不一致。这种写法在很多业务场景都适用比如购物车更新商品数量、用户点赞状态同步。4. 前端 Vue 实现路由配置、axios 封装与阅读器页面4.1 Vue 路由与页面结构前端怎么组织小说站前端路由决定了用户从首页到阅读页怎么走。这套系统用的是 vue-router采用 hash 模式而不是 history 模式。hash 模式的好处是部署到 nginx 不需要额外配置 rewrite 规则刷新页面不会 404。页面结构主要分三块首页的书城列表、书籍详情页、阅读器页。/router/index.js路由表里有几个关键点。阅读器页面的路由是带动态参数的/reader/:bookId/:chapterId这样设计的好处是用户在阅读器内点击上一章下一章时只需要改变 URL 的 chapterId 参数配合watch监听路由变化重新请求数据。书籍详情页跳转到阅读器时通过编程式导航传递 bookId 和 chapterId 两个参数。const routes [ { path: /, component: BookStore }, { path: /book/:id, component: BookDetail }, { path: /reader/:bookId/:chapterId, component: Reader }, { path: /login, component: Login }, { path: /admin, component: AdminLayout } ]这里的/admin路由需要做权限控制项目里用的方式是全局前置守卫从 localStorage 读用户角色如果不是管理员就重定向到首页。这种前端权限控制只能防君子不防小人真正的接口校验还是要靠后端。4.2 axios 封装与跨域问题baseURL 和拦截器怎么配axios 封装是前后端联调的关键环节。项目里在src/api目录下建了一个 request.js统一设置 baseURL、请求拦截器和响应拦截器。baseURL 用的是/api而不是完整的http://localhost:8080/api。这样做配合 Vue CLI 的 devServer proxy 转发能把跨域问题挡在开发环境之外。import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器在头部携带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) // 响应拦截器统一处理错误码 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { alert(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res.data }, error { alert(网络异常请检查后端是否启动) return Promise.reject(error) } ) export default request拦截器的用处不只是加 token。响应拦截器里把res.data直接返回前端页面里拿到的就是业务数据不用每次解壳response.data.data。这一层封装很多项目会做但这个项目的处理比较干净——错误提示统一弹 alert没有引入 ElMessage 之类的组件反而让代码更易懂。开发环境的跨域配置在vue.config.js里。注意后端接口路径是/api/book/listdevServer 的 proxy 把/api前缀下的所有请求转发到http://localhost:8080。module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这个配置只影响开发环境。上线部署时前端的/api请求同样需要通过 nginx 转发到后端端口配置逻辑和这个保持一致。4.3 阅读器页面实现正文加载与章节切换阅读器页面是小说项目里前端代码最密集的地方。核心功能有三个加载当前章节内容、切换上一章下一章、记录阅读进度。页面在mounted生命周期里获取路由参数调用章节接口加载数据。章节内容返回的是纯文本前端展示时要注意排版一般会加上white-space: pre-wrap的样式保留段落格式。切换章节的逻辑是易错点。如果使用window.location.href跳转整个页面会重新加载用户体验很差。正确做法是用 vue-router 的push方法改变路由参数然后用watch监听$route.params.chapterId参数变了就重新请求。项目里就是这么实现的所以翻页速度很快几乎无感知刷新。watch: { $route.params.chapterId: function(newVal, oldVal) { if (newVal ! oldVal) { this.loadChapter() } } }阅读进度的上报调用/api/record/update接口传入 userId、bookId、chapterId。两个策略值得借鉴一是记录在阅读器关闭时上报即beforeDestroy生命周期二是翻页时同步上报。相比每次加载章节都上报这种策略可以降低接口压力。5. 常见问题排查部署与联调中真正会踩的五个坑5.1 前端页面能打开但接口请求全部报 404现象是浏览器 Console 里看到/api/book/list返回 404但后端日志没有任何报错。原因通常有两种。第一种是 nginx 只配置了静态文件服务没有把/api路径转发到后端端口。第二种是开发环境下vue.config.js的 proxy 配置没有生效修改后没有重启 devServer。排查时用浏览器直接访问http://localhost:8080/api/book/list如果返回数据说明后端没问题问题在转发层。我的习惯是先确认后端接口通不通再查前端代理配置。5.2 Mybatis 报错Invalid bound statement (not found)这个错误在 Mybatis 项目里出现频率极高。表面意思是 Mapper 接口的某个方法找不到对应的 SQL 语句实际上有三个层次要排查。第一层是 XML 文件的 namespace 必须和 Mapper 接口的完整类名一致这个很多人会忽略第二层是 XML 文件的mapper标签里的接口方法名要和 Mapper 接口里定义的方法名一致第三层是application.yml的mapper-locations配置指向的路径要和实际 XML 文件的位置一致。注意classpath:mapper/*.xml只匹配 mapper 目录下的直接文件如果你把 XML 放在子目录里这个 pattern 会失效。5.3 数据库插入中文乱码或报错表现是小说正文某些特殊字符插入时报Incorrect string value错误或者读出数据显示为问号。原因百分之九十是字符集不统一。数据库连接串里没有指定characterEncodingutf8连接使用的是 MySQL 默认的 latin1 字符集。另一个原因是建库时没有指定 utf8mb4只有表用了 utf8mb4。解决方法是建库语句写成CREATE DATABASE novel_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;同时连接 URL 加上两个参数useUnicodetruecharacterEncodingutf8。还有一层比较少见的坑MySQL Connector 版本 8 以上URL 里的characterEncoding必须是utf8还是utf8mb4实测写utf8也能正确工作因为 MySQL 会做映射但直接写utf8mb4更安全。5.4 阅读记录更新失败报唯一键冲突用ON DUPLICATE KEY UPDATE时遇到唯一键冲突的情况报错的概率极低但如果表结构改过比如你在t_read_record表上加了其他字段并建立了新的唯一索引那么 ON DUPLICATE 的判定逻辑会改变。当多个唯一索引同时满足冲突条件而且更新的字段和唯一索引有交叉时MySQL 的行为是更新一行而不是插入可能造成语义错误。解决办法是每次修改表结构后检查唯一索引是否仍然只有user_id book_id这两个字段的组合。我在实际项目中就遇过同事加了一个chapter_id的唯一索引导致阅读记录死活不更新查了半小时才发现是索引多了一个。5.5 前端打包后放 SpringBoot 的 static 目录刷新页面 404后端把 Vue 打包产物放在src/main/resources/static下SpringBoot 直接访问首页没问题点路由跳转也没问题但手动刷新/book/1这个页面就 404 了。原因是 vue-router 默认用的 hash 模式不存在这个问题但如果你改成了 history 模式刷新时请求/book/1SpringBoot 试图在 controller 里找这个映射找不到返回 404。两个解法要么前端路由改回 hash 模式最简单要么后端加一个 forward 配置把所有非 API 路径的请求转发到 index.html。实际项目中没有特别需求就别用 history 模式hash 模式丑是丑了点胜在省事。6. 联调验证与性能优化用真实数据把系统压出问题来项目跑到这里功能层面的代码基本看完了。但一个小说系统要真正上线还要经历数据量和并发两层考验。我习惯的做法是往数据库里灌一批模拟数据至少 20 本小说、每本 100 章然后从前端走一遍完整流程。这套流程能暴露很多看一眼代码发现不了的问题。验证流程分三步走。第一步用 Navicat 的 SQL 功能批量生成章节数据观察章节列表接口在数据量上万条时的响应时间。如果超过 500ms就要检查chapter_no字段是否建索引book_id的索引有没有生效用EXPLAIN SELECT * FROM t_chapter WHERE book_id 1 ORDER BY chapter_no查看执行计划如果 type 不是ref而是ALL说明全表扫描了。第二步测试用户书架接口模拟用户收藏 30 本书之后的书架加载速度。书架表真正的性能瓶颈在关联查询——取出书籍信息后又逐条查封面图和最新章节可以改用一条 join 语法把三张表的数据一次取出来。SELECT b.id, b.book_name, b.cover_url, c.title AS latest_chapter FROM t_bookshelf s LEFT JOIN t_book b ON s.book_id b.id LEFT JOIN t_chapter c ON c.id ( SELECT id FROM t_chapter WHERE book_id b.id ORDER BY chapter_no DESC LIMIT 1 ) WHERE s.user_id #{userId}这条 SQL 用子查询拿最新章节标题不需要在 Java 层循环查询性能提升非常明显。第三步是验证并发场景用 JMeter 模拟 50 个用户同时翻页观察后端日志。如果出现数据库连接池耗尽SpringBoot 默认的 HikariCP 最大连接数是 10并发高时容易排队可以在application.yml里把maximum-pool-size调到 20但要结合实际数据库的max_connections上限来配不是越大越好。接口上的优化点还有一个章节正文的传输。一次请求返回完整的小说章节内容JSON 数据量可能达到几十 KB网络慢时明显卡顿。常见的做法是后端把章节内容拆成段落前端翻页时一次只加载一个段落的数据或者后端对内容做 gzip 压缩。SpringBoot 的server.compression.enabledtrue这个配置在 application.yml 里加上就能生效不需要改任何业务代码。协议压缩对纯文本效果最好小说正文这种场景收益很高实测能省 70% 的流量。回到那个书架接口的循环查询问题我之前接手过一个跑了两年的小说项目生产环境用户反馈书架加载要 3 秒查日志发现每加载一本书就发一条 SQL30 本书 30 次查询。改成像上面那条 join SQL 后响应时间降到 300ms 以内。从那以后我每次做联调验收都强制自己用EXPLAIN检查 mapper 里的每一条查询语句宁可多花十分钟看执行计划也不等上线后被用户教做人。这个习惯救了我很多次希望也能帮到你。本文还有配套的精品资源点击获取