简介:采用 Java、Spring Boot、Vue 和 MySQL 技术栈开发的在线小说阅读平台,完整源码与数据库文件,面向需要毕业设计、课程设计或期末大作业参考的学生及开发者。项目功能完善,包含用户注册登录、小说检索浏览、阅读进度保存、章节更新提醒、阅读模式切换等,前后端分离,便于独立开发与二次扩展。资源共 835 个文件,涵盖 Java 后端源码、Vue 组件、JS/CSS 前端脚本、HTML 页面、SVG 图标、GIF/JPG/PNG 图片素材,以及 SQL 数据库脚本、Maven 配置与启动脚本,压缩包约 15.6MB。数据库采用 MySQL 8.0,并提供 Navicat 管理支持。已有 50 人浏览学习。项目经严格调试可运行,免改即用,可作为高分毕设的直接参考,也是学习 Spring Boot 与 Vue 全栈开发的实践案例;系统界面美观、管理便捷,可快速搭建私有阅读平台或用于课程设计演示。
1. 在线小说阅读平台:为什么这个毕设题目性价比高到少见
写毕设最怕的不是不会写代码,而是选了一个听起来高级、做起来到处是坑,最后连演示都跑不顺的题目。基于 Java + SpringBoot + Vue + MySQL 的在线小说阅读平台,名字听着普通,但它其实把登录鉴权、分页检索、阅读进度、评论互动、后台管理这一整套真实业务的前后端分离闭环全占齐了。对大多数计算机专业学生来说,这是一条可以按步骤跑通的路线;对想冲高分毕设的人来说,它又有足够的纵深可以展开。
这类项目交付时一般是一个 zip:一套 SpringBoot 后端源码、一套 Vue 前端代码,外加一份 .sql 建库脚本。数据库不是现成的,需要你自己导入;源码也要按本机环境去改配置。这篇文章就围绕这个标题背后的完整落地链路来写——从设计层面为什么这么选型,到后端接口怎么搭、前端页面怎么接、最后数据库和部署有哪些坑要躲,直接按“能复现”的标准来。
2. 技术选型与数据模型:先把答辩要讲的道理立住
2.1 为什么是 SpringBoot + Vue + MySQL:四件套背后的选型逻辑
很多同学选型是看网上哪个教程多就抄哪个,但答辩时老师一定会问一句:为什么用这套,不用别的?这句话答不好,前面代码写得再顺也会被扣分。
SpringBoot 解决的核心痛点是“配置地狱”。过去用 SSM 搭一个能跑的项目,要配一堆 XML、还要装外部 Tomcat;SpringBoot 内嵌容器加自动装配,java -jar就能起服务,这对毕设场景太合适了——写代码的时间不会浪费在环境上。Vue 这边,组件化和响应式让小说列表、书架、阅读器这类交互密集的页面写起来比原生 JS 顺手得多,而且 Vue 在国内的资料密度远超 React,遇到问题时能搜到的答案更多。MySQL 胜在免费、普及率高,而且使用 InnoDB 后事务和行级锁的知识点都是答辩老师喜欢追问的方向,你可以在数据一致性的回答上拿到加分。
对比一下其他方案就明白:如果做单体 JSP 页面,现在的主流公司很少这么写,老师会觉得你的技术栈停留在五年前;如果换成 Django 或 Flask,虽然开发更快,但和 Java 后端岗位的匹配度又下降了。SpringBoot + Vue + MySQL 这套组合刚好把“前后端分离”“工程化”“关系型数据库设计”三块常见面试点全部覆盖了,这也是它长期作为毕设热门的原因。这里不要随便上微服务、消息队列这类中间件,项目规模撑不住,反而暴露你对复杂度把控的陌生感。
2.2 数据库设计:六七张表把“书、章、用户、书架、评论”拆干净
在线小说阅读平台的数据模型并不复杂,但拆分是否合理直接影响后端的代码量。我习惯把核心表拆成六到七张:小说表、分类表、章节表、用户表、书架表、评论表,再加一张管理员表或直接用用户表里的角色字段区分。
核心的两张表建表脚本我通常这样写:
CREATE TABLE tb_book ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL COMMENT '书名', author VARCHAR(50) DEFAULT '' COMMENT '作者', category_id INT DEFAULT 0 COMMENT '分类ID', intro TEXT COMMENT '简介', cover_url VARCHAR(255) DEFAULT '' COMMENT '封面图URL', views INT DEFAULT 0 COMMENT '点击量', status TINYINT DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小说表'; CREATE TABLE tb_chapter ( id BIGINT AUTO_INCREMENT PRIMARY KEY, book_id BIGINT NOT NULL COMMENT '所属小说ID', chapter_no INT NOT NULL COMMENT '章节序号', title VARCHAR(100) NOT NULL COMMENT '章节标题', content MEDIUMTEXT COMMENT '章节正文', word_count INT DEFAULT 0 COMMENT '字数', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_book_no (book_id, chapter_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='章节表';这里有几个参数值得展开。tb_chapter必须单独拆出来,因为章节正文是典型的大字段,如果和小说的书名、简介放在同一张表,列表页查询时会把所有正文字段都带上,查询性能明显下降。book_id和chapter_no的联合唯一索引保证了一本书下不可能出现两个相同编号的章节,这是数据完整性的底线。正文类型用MEDIUMTEXT,单章两三万字毫无压力,如果书名和简介混在一起用TEXT,一页小说列表要拖几百行只为了显示 20 个字的书名,那才是设计失误。
书架表我通常会加一个last_chapter_id字段来记录用户读到哪一章:
CREATE TABLE tb_bookshelf ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, last_chapter_id BIGINT DEFAULT 0 COMMENT '最近阅读章节', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='书架表';这个表的唯一键往往会被忽略,等实现“加入书架”时才发现用户对同一本书可以插入多条记录,接口返回的数据重复。提前把uk_user_book建好,后端做插入时直接ON DUPLICATE KEY UPDATE更新进度,又能拿到当前阅读位置,代码能少写不少。
字符集我统一用utf8mb4而不是utf8,因为utf8在 MySQL 里最多存三字节,遇到 emoji 或生僻字就会报错或变问号。这个坑在后面避坑章节还会再出现一次,但建库这一层就定好,后面省心很多。
2.3 后端代码分层:Controller、Service、Mapper 各自该管什么
很多毕设代码最大的问题是 Controller 里写 SQL、Service 里拼 HTML,看起来也能跑,但老师让你讲架构时你自己都说不清楚。前后端分离项目里,我推荐按最传统的三层结构拆包:
src/main/java/com/example/novel/ ├── controller/ 接口入口,只做参数接收和结果返回 ├── service/ 业务逻辑,事务边界在这里 ├── mapper/ 数据库访问 ├── entity/ 实体类 ├── config/ JWT、跨域等配置 └── common/ 统一返回结果 R、异常处理持久层我一般用 MyBatis-Plus,而不是原生 MyBatis。原因很现实:毕设里大量接口是简单的单表 CRUD,MyBatis-Plus 的BaseMapper内置了增删改查和分页,不需要为每张表手写 XML 映射。手写 SQL 的精力应该留给多表关联和统计类查询,比如首页热门榜单、分类下的书籍列表,剩下那些“按 ID 查一条、插入一条”的工作交给内置方法就好。
统一返回结果类R也建议从一开始就定好。前后端约定一个 JSON 结构,比如{ code: 200, data: {...}, message: "ok" },前端的 axios 响应拦截器只需要判断一次code,不用每个接口单独处理异常。这个约定后端的每个 Controller 都必须遵守,否则前端拦截器形同虚设。实体类字段名和数据库列名保持小驼峰对应,MyBatis-Plus 默认开启驼峰映射,能省掉一大半人工字段映射的代码。
3. 后端落地:SpringBoot 工程从初始化到核心接口能跑
3.1 pom.xml 和 application.yml:先把依赖和环境定死
搭建后端的第一步是选 SpringBoot 版本。常见做法是 JDK 8 环境配 Spring Boot 2.7.x,JDK 17 环境配 Spring Boot 3.x。如果你的电脑里已经装好 JDK 8,就不要硬上 3.x,否则启动直接报UnsupportedClassVersionError。反过来如果你装的是 JDK 17,用 2.7 也能跑,但为了省事,我建议按照本机 JDK 版本选择对应的 Boot 大版本。
依赖文件里最核心的几项:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <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.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> </dependencies>spring-boot-starter-web是后端服务的骨架,内嵌 Tomcat,Controller 注解、JSON 序列化全在这里。MyBatis-Plus 的 starter 会自动装配数据源和 SqlSessionFactory,省去手写 MyBatis 配置。MySQL 驱动版本这里要注意,如果你的 MySQL 是 5.7,用 8.0.x 的驱动没问题;反过来如果 MySQL 是 8.0 却用了 5.1 的老驱动,连接时经常会报认证协议错误。JWT 相关我用 jjwt 的 API 包,功能全且坑少,比手写 Base64 拼 token 安全得多。
然后是核心配置文件。我习惯把数据库连接参数单独写在application.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/novel_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: autoURL 里的四个参数一个都不能省。useUnicode和characterEncoding控制中文写入不乱码,serverTimezone指定为Asia/Shanghai,否则 MySQL 8.x 默认 UTC 时区,插入时间会比本地晚八个小时,日志里还经常报Server returns invalid timezone。useSSL=false是本地开发避坑,防止连接时 SSL 握手警告刷屏。allowPublicKeyRetrieval=true是 MySQL 8.x 使用 caching_sha2_password 认证时可能需要的参数,不加可能遇到Public Key Retrieval is not allowed。
mybatis-plus.log-impl设为 StdOutImpl 后,控制台能看到每条 SQL 和参数,调试阶段非常有用。但正式演示可以把这项注释掉,避免几百条日志把启动信息刷到看不见。
3.2 登录注册与 JWT 鉴权:第一个绕不开的核心功能
在线小说阅读平台的登录注册不能做成装饰品,因为书架、评论这些功能都要知道当前用户是谁。这里我用 JWT 做无状态鉴权,流程是:用户登录成功后,后端生成一个 token 返回给前端,前端存到 localStorage,之后每次请求在 Header 里带上。后端通过拦截器校验 token 并解析出用户 ID,放入请求上下文。
注册与登录的接口可以放在同一个AuthController中:
@RestController @RequestMapping("/api/auth") public class AuthController { @Resource private UserService userService; @PostMapping("/register") public R<String> register(@RequestBody RegisterDTO dto) { if (!dto.getPassword().equals(dto.getConfirmPassword())) { return R.error("两次密码不一致"); } userService.register(dto); return R.ok("注册成功"); } @PostMapping("/login") public R<String> login(@RequestBody LoginDTO dto) { String token = userService.login(dto.getUsername(), dto.getPassword()); return R.ok(token); } }RegisterDTO和LoginDTO这种对象建议单独建类,不要直接用Map接收参数。用 DTO 的好处是可以在类上用@NotNull、@Size这类校验注解,参数校验代码不用堆在 Controller 里。密码的存储必须加密,常见的做法是使用 BCrypt 算法,Spring Security 里自带BCryptPasswordEncoder,不想引入 Spring Security 也可以直接引入spring-security-crypto这个轻量包单独使用。
登录校验的核心逻辑在 Service 层:
@Override public String login(String username, String rawPassword) { LambdaQueryWrapper<TbUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(TbUser::getUsername, username); TbUser user = this.getOne(wrapper); if (user == null) { throw new BizException("用户名不存在"); } if (!BCrypt.checkpw(rawPassword, user.getPassword())) { throw new BizException("密码错误"); } String token = JwtUtil.createToken(user.getId(), user.getUsername()); return token; }这里BCrypt.checkpw是校验明文密码和数据库密文是否匹配,JwtUtil.createToken生成 token。token 的有效期建议设成 7 天,太短会让用户频繁重新登录,太长又不安全。JWT 的密钥不要写在代码里明文,可以放到application.yml配置中,答辩时也可以说是从配置中心注入的,显得工程化程度更高。
然后在 SpringBoot 里注册一个拦截器:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } try { Long userId = JwtUtil.parseToken(token); request.setAttribute("userId", userId); return true; } catch (Exception e) { response.setStatus(401); return false; } } }注意preHandle里第一行判断handler是否为HandlerMethod。这是因为拦截器不只拦截 Controller 方法,某些静态资源请求也会经过这里,不做类型判断会把静态资源也拦死。解析成功后把userId放进 request 属性里,后面的 Controller 通过@RequestAttribute Long userId直接取,不要在拦截器里再去查一次数据库,省一次 IO。
3.3 分页查询与章节详情:让前端有数据可用
小说列表接口是首页的命脉。常见做法是支持关键词搜索、按分类筛选、按点击量排序,同时做分页。MyBatis-Plus 的分页需要配置一个拦截器插件,否则Page对象不会自动生成 count 查询和 LIMIT 语句:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }有了这个配置,Service 里就可以直接写:
@Override public Page<TbBook> listBooks(int pageNum, int pageSize, String keyword) { LambdaQueryWrapper<TbBook> wrapper = new LambdaQueryWrapper<>(); if (keyword != null && !keyword.isEmpty()) { wrapper.like(TbBook::getTitle, keyword); } wrapper.eq(TbBook::getStatus, 1); wrapper.orderByDesc(TbBook::getViews); Page<TbBook> page = this.page(new Page<>(pageNum, pageSize), wrapper); return page; }Page的构造函数有两个参数:当前页码和每页条数。注意页码从 1 开始,前端传 0 或负数时这里会直接报错。可以在 Controller 入口做一次参数校验,pageNum小于 1 时统一改成 1,pageSize超过 50 时封顶为 50,防止请求参数把数据库压到。wrapper.like在 keyword 为空时不会拼进 SQL,所以不用纠结要不要判空。
章节内容接口有一个容易忽略的设计细节:读者跳章阅读时,前端先拿到的是章节目录,再按章节号请求正文。这个接口要在返回值里同时给出上一章和下一章的编号,这样阅读器底部才能直接渲染“上一章/下一章”按钮,省得前端再查一次目录。
@Override public ChapterVO getChapterDetail(Long bookId, Integer chapterNo) { LambdaQueryWrapper<TbChapter> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(TbChapter::getBookId, bookId) .eq(TbChapter::getChapterNo, chapterNo); TbChapter chapter = this.getOne(wrapper); if (chapter == null) { throw new BizException("章节不存在"); } ChapterVO vo = new ChapterVO(); vo.setChapter(chapter); vo.setPrevNo(chapterNo - 1); vo.setNextNo(chapterNo + 1); return vo; }这里随手设置的prevNo和nextNo会在最新章节出现尴尬:用户读到最后一章时,nextNo传过去一个不存在的编号。前端拿到后如果直接点击会请求报错。更稳妥的做法是,在 Service 里先查这本书的最大章节号,如果当前chapterNo已经是最大值,就把nextNo置为 0,前端判断为 0 时禁用按钮。这个细节做到位,交互上看不出毛病,代码评审时还能讲一句“我做了边界处理”。
4. 前端落地:Vue 工程从初始化到页面联调
4.1 Vue + Vite 初始化与 axios 封装:把请求基础打好
前端我一般用 Vue 3 加 Vite。Vite 的开发服务器启动快,热更新也比 Vue CLI 时代舒服很多。初始化命令常用npm create vite@latest novel-front -- --template vue,生成后安装项目依赖,再装两个必要库:vue-router和axios。如果后台管理用现成组件库,我习惯加上element-plus,表格、表单、上传组件直接拿来用,省掉手写组件的大量时间。
开发环境有个必须处理的点:前端跑在 5173 端口,后端跑在 8080 端口,浏览器直接访问后端接口会跨域。常见做法是配 Vite 代理,让前端把/api开头的请求转发到后端:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })changeOrigin: true表示让后端拿到的请求头中 Host 是目标地址,如果不配这一项,后端某些框架做重定向时会出现路径错乱。通过 Vite 代理转发后,前端代码里请求就写/api/auth/login这种相对路径,不用把http://localhost:8080写死在代码里,以后换服务器地址只需要改这一处代理配置。
axios 的封装建议单独放一个文件,因为整个项目的请求都要从这里过,拦截器可以统一管 token 和错误状态:
import axios from 'axios' const http = axios.create({ baseURL: '/api', timeout: 10000 }) http.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) http.interceptors.response.use( response => { const res = response.data if (res.code === 401) { localStorage.removeItem('token') window.location.href = '/login' } return res }, error => { return Promise.reject(error) } ) export default http请求拦截器里如果本地存在 token,就自动加到 Header 中;响应拦截器里统一判断 401,token 失效时直接跳回登录页,业务页面里就不用每个请求都重复写这套逻辑。token 存储用localStorage还是sessionStorage各有取舍,毕设项目用任何一个都可以,关键是整个项目要统一,不要一会存 sessionStorage 一会又读 localStorage,这种低级错误排查起来非常浪费时间。
4.2 书架与阅读器:两个核心页面的组件拆分
书架页的功能是从接口拉取当前用户的书架列表,展示小说封面、书名、最近阅读章节和更新时间。阅读器页面则负责展示正文内容和切换章节,这两个页面的组件拆分方式直接影响后期的维护成本。
书架列表页核心逻辑不复杂,关键在卡片组件和列表组件的通信。如果一个页面里既写数据请求又写卡片 DOM 又写分页逻辑,代码会膨胀到几百行。我习惯拆成三个组件:书架页面负责请求数据,小说卡片是一个纯展示组件,空状态单独一个组件。这样接口字段改动时只动书架页面,样式改动只动卡片组件。
阅读器页面最大的坑是正文排版。从后端返回的章节内容是一大段带换行符的文本,如果直接用<div>{{ content }}</div>渲染,浏览器会把换行符当成空格处理,整章内容挤成一坨。常见做法有两种:第一种在后端导入章节时就把段落拆好,每段用<p>包起来,前端用v-html渲染;第二种是前端取到原文后用正则把\n替换成<br>再渲染。我推荐第一种,后端控制数据结构,前端不拼 HTML,职责更清晰。
阅读器的上一章下一章逻辑可以写成这样:
const loadChapter = async (bookId, chapterNo) => { const res = await http.get(`/book/${bookId}/chapter`, { params: { chapterNo } }) if (res.code === 200) { chapter.value = res.data.chapter prevNo.value = res.data.prevNo nextNo.value = res.data.nextNo } } const nextChapter = () => { if (nextNo.value === 0) return loadChapter(bookId.value, nextNo.value) }拿到prevNo和nextNo后,判断 0 值来禁用按钮。同时每读完一章,应该调用一次更新阅读进度的接口,把lastChapterId写回书架表。这个动作一定要做成异步且不能堵塞阅读操作,用户切章节时不需要等进度保存完成再渲染正文。
阅读器的滚动位置还有一个优化点:从书架点击“继续阅读”时,应该直接滚到上次阅读的位置,而不是重新从页首开始。常见做法是后端在书架列表接口里返回lastChapterId,前端拿到后请求对应章节,并在页面渲染完成后尝试滚动到滚动条高度。这个逻辑如果一开始不做,后面加需求时改动面会更大。
4.3 管理后台:用 Element Plus 搭出上传解析功能
管理后台通常需要支持导入小说内容,最常见的是管理员上传一个 TXT 文本文件,后端读取后按章节切分存入数据库。这块业务逻辑全在后端,前端只需要一个文件选择按钮和一个确认按钮。
SpringBoot 接收上传文件的接口这样写:
@PostMapping("/api/admin/book/import") public R<String> importBook(@RequestParam("file") MultipartFile file, @RequestParam("bookId") Long bookId) { bookService.importTxt(file, bookId); return R.ok("导入完成"); }Service 的处理逻辑是按章节标题拆分文本。一个 TXT 小说通常是类似“第一章 xxx”“第二章 xxx”这样的格式,解析时用正则匹配标题行,然后按目标段落切分正文:
public void importTxt(MultipartFile file, Long bookId) { try { BufferedReader reader = new BufferedReader( new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8) ); StringBuilder sb = new StringBuilder(); List<ChapterItem> chapters = new ArrayList<>(); String line; while ((line = reader.readLine()) != null) { if (line.matches("^第[0-9一二三四五六七八九十百千]+章.*$")) { if (sb.length() > 0) { chapters.add(createChapter(bookId, chapters.size() + 1, title, sb.toString())); } title = line; sb.setLength(0); } else { sb.append(line).append('\n'); } } // 批量插入章节 this.saveBatch(chapters); } catch (IOException e) { throw new BizException("文件读取失败"); } }核心正则^第[0-9一二三四五六七八九十百千]+章.*$能同时匹配“第1章”和“第一章”,但匹配失败时文本不会被切分,整本小说会变成一章。所以解析完要做好验证:如果切出来的章节数量小于 10,就提示用户检查 TXT 的章节标题格式。批量插入用saveBatch,这个方法是 MyBatis-Plus 提供的,内部按批提交,比循环单条插入快上一个数量级。插入完再更新一次小说的总字数统计,展示给用户看。
这一段的业务足够复杂,也适合作为答辩时“你做过什么复杂逻辑”的答案。
5. 避坑:从启动到演示最容易翻车的 5 个点
5.1 数据库连接失败:时区、驱动版本和字符集三座大山
现象:SpringBoot 启动时控制台报Communications link failure或者Server returns invalid timezone,页面完全起不来。
原因:本地 MySQL 时区默认不是中国时区,而连接串里没有指定serverTimezone;或者 MySQL 服务用的认证插件与驱动版本不匹配。
解决:连接串上把所有参数写全:useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false。如果 MySQL 是 8.0,务必确认mysql-connector-java用了 8.x 版本;如果是 5.7,用 8.x 的驱动也可以,但反过来用 5.1 驱动连 8.0 时几乎必踩认证协议问题。这里的血泪经验是:连接串参数不要靠搜索引擎从网上复一段就粘贴,必须对照自己本机的 MySQL 版本和字符集修改。
5.2 跨域问题:前端拿到了后端数据却被浏览器拦截
现象:前端页面控制台报CORS policy: No 'Access-Control-Allow-Origin',网络请求状态码是 200,但浏览器把响应拦截了。
原因:前端开发服务器在 5173 端口,后端在 8080 端口,浏览器同源策略限制跨端口的 AJAX 请求。开发阶段如果用了 Vite 代理就不会出现这个问题,但如果前端直接用http://localhost:8080访问,就一定要后端配置跨域。
解决:在 SpringBoot 里配置一个全局跨域,常见做法是写一个实现WebMvcConfigurer的配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里要提醒的是:生产或演示环境能不能直接用*允许所有源?如果项目是公开部署,这么写有安全风险。毕设场景下可以接受,但答辩老师问起来,正确的回答是“开发环境放开,生产环境改成白名单”。如果前端已经配了 Vite 代理,后端这个跨域配置甚至可以不加,两种方案共存也不会互相冲突。
5.3 循环查询拖垮接口:N+1 问题的定位
现象:小说列表接口响应越来越慢,点击首页后要等两三秒;打开 MyBatis 日志,发现查询一本书的时候,日志里刷出来几十条 SQL 全是SELECT * FROM tb_book WHERE id=?。
原因:这是典型的 N+1 查询。Service 层先查了章节列表,拿到每个章节的bookId后,在 Java 代码里循环去查询每本书的信息,查了 N 次。日志里每本书一次 select,几十本书就是几十次。
解决:改用批量查询,先一次查出全部章节,再去重收集bookId,组装成一个List后调一次selectBatchIds把书全部查回来,在 Java 内存里做关联:
List<TbChapter> chapters = chapterService.list(...); Set<Long> bookIds = chapters.stream().map(TbChapter::getBookId).collect(Collectors.toSet()); Map<Long, TbBook> bookMap = bookService.listByIds(bookIds) .stream().collect(Collectors.toMap(TbBook::getId, b -> b));做完这步,SQL 数量从“1 + N”降到“1 + 1”,接口时间能快一个数量级。排查这类问题时,优先看 MyBatis 日志总条数,发现 select 数量异常膨胀就先找循环位置,这是最快的定位路径。
5.4 阅读器排版和乱码:前端显示和数据库读取双重踩坑
现象:章节内容在阅读器里全部挤成一行,看不到段落;或者在后台导入的 TXT 小说,数据库里存的中文全变成了问号。
原因:问题是两个。排版问题是因为 Web 页面渲染时把文本中的换行符当作空白处理了;乱码问题是因为导入时没有指定 UTF-8 编码,BufferedReader按平台默认编码读了一个 GBK 编码的 TXT 文件。
解决:排版问题用后端切分段落的方式,导入 TXT 时按章节切分后,每个自然段用<p>标签包裹再存入数据库,前端直接用v-html渲染,段落结构完整保留。乱码问题的解决是读文件时强制指定编码:
new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))如果你的 TXT 文件本身是 GBK 编码,指定 UTF-8 读出来照样乱。怎么办?常见做法是给上传接口加一个charset参数,让管理员选文件编码;更稳妥的做法是先尝试用 UTF-8 读取,如果解析出的内容中包含大量 Unicode 替换符,就改用 GBK 重新读一遍。这个“自动探测编码”的逻辑可以作为加分项写进答辩文档里。
5.5 端口冲突与 IDE 缓存:那些“昨天还能跑”的玄学错误
现象:后端启动报Port 8080 was already in use,或者修改代码后重新启动,页面上的功能还是旧逻辑,怎么刷新都没变化。
原因:另一个 Java 进程占用了 8080 端口,常见是之前启动的后端服务没有关闭;而另一种情况是 IDE 的构建缓存没清理,代码没重新编译就启动了。
解决:端口占用时,在命令行用netstat -ano | findstr 8080找到占用进程,干掉对应 PID;或者直接把后端端口改成 8081,一劳永逸。IDE 缓存层面,养成一个习惯:每次大改完代码,先执行mvn clean再启动,把 target 目录清干净。IntelliJ IDEA 里还可以在File -> Invalidate Caches里清一次缓存。这类问题之所以被叫“玄学”,本质上是你没有按“先清缓存、再查日志、后看端口”这个固定顺序排查,乱翻一通就归因到玄学上了。
6. 答辩前的一小时自检:把“注册到阅读”的链路完整跑一遍
等到代码全部写完,还有一个环节比写代码更影响最终成绩,就是答辩现场演示。我见过太多项目平时能跑,一上台就翻车的案例,原因无他:演示环境没有提前做一次完整链路自检。
我给自己定的习惯是,答辩前一天不写新功能,只做一件事——用一条完整用户路径把所有接口过一遍。路径从注册开始:清空数据库中测试用户数据,打开前端页面,注册一个新账号,登录拿到 token,浏览首页推荐列表,搜索一个关键词,点击进入小说详情页,打开第一章,翻到第五章,加入书架,退出登录,换管理员账号登入,上传一本书的 TXT,确认章节解析成功,最后在书架里验证这个新用户的书架记录。
这条链路走完,我会顺手用一条curl命令验证后端某个核心接口,确保脱离前端也能通:
curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'返回 JSON 里包含 token 字段,说明后端服务和数据库连接都正常。接下来再验证数据库里确实写出了数据:登录 MySQL 查tb_user表里新增的记录,查tb_bookshelf里的书架记录。这一步做到位,演示时就算前端临时出问题,你也可以切到数据库管理工具向老师证明数据已经落库,系统是完整的,不需要慌张。
另外有个小技巧:演示前把 MySQL 的 binlog 或数据备份出一份,需要恢复时直接重导建库脚本。我经历过一次演示现场误删了演示账号,因为没有备份,整个流程从注册开始重来,特别狼狈。现在我的习惯是演示前先导出一份干净数据库,万一中途数据被搞乱,直接导入备份,一分钟回到初始状态。
这个项目如果能稳稳跑通“注册→登录→看小说→加书架→后台传书”这条链路,它作为一个毕业设计的完整度已经站在前列。剩下的高阶功能——比如用 Redis 做热门榜单缓存、用 ElasticSearch 做正文搜索——都属于锦上添花,先把链路跑通,再谈加分项。
希望这次从选型到落地再到排障的完整梳理,能帮你把这个在线小说阅读平台做成一个真正能讲、能跑、能拿高分的毕设项目。
本文还有配套的精品资源,点击获取