
简介一套基于Java、Spring Boot、Vue与MySQL的在线小说阅读平台毕业设计项目面向需要完成课程设计、期末大作业或毕业设计的计算机专业学生。平台采用前后端分离架构支持用户注册登录、小说检索浏览、阅读进度保存、阅读模式切换等功能已通过导师指导获得高分评价下载后无需修改即可运行。资源包共835个文件、约15.6MB包含131个Java后端文件、49个Vue前端组件、164个JS脚本、MySQL数据库脚本以及bat一键启动脚本覆盖数据库设计、接口开发、页面交互和系统部署等完整开发闭环。目前已有50人学习既可作为毕业设计答辩展示也可用于二次开发实践在现有基础上扩展评论、支付等商业功能极大降低从零搭建的试错成本。1. 在线小说阅读平台到底是什么一份源码 数据库能帮你撑起整个答辩闭环距离答辩还有三周导师在群里说「能把功能和数据库讲清楚的优先通过」而你手里正是这份基于 Java Spring Boot Vue MySQL 的在线小说阅读平台源码与数据库压缩包。它解决的远不止「选题难」注册登录、分类浏览、关键词搜索、分章节阅读、加入书架、发布评论这一条完整业务链路被拆成 Vue 页面、Spring Boot 接口和 MySQL 表三部分每一层都能单独拿出来讲。适合三类人想要一套能演示的全栈项目的毕业生想搞懂前后端分离与接口联调的初学者准备基于源码做二次开发、加排行榜或阅读历史这类亮点功能的同学。这套源码不是黑匣子——数据库脚本、接口定义、页面路由都在压缩包里下面按「先看架构、再建库、再写接口、再联调」的顺序把它彻底讲透。2. 技术选型与工程结构这套组合的版本怎么配、目录怎么拆、启动先做哪三步2.1 一次请求如何穿过三层Spring Boot、Vue、MySQL 的分工打开浏览器输入 localhost:8080最先响应的是 Vue 应用输入关键词点搜索axios 把请求发到/api/novel/list开发环境下vue.config.js的 proxy 把请求转发给 8081 端口Spring Boot 的 Controller 接住参数Service 处理业务Mapper 通过 MyBatis-Plus 把 SQL 发到 3306 端口的 MySQL结果按 JSON 原路返回Vue 响应式地把列表渲染出来。这条链路是最短路径也是答辩时最好画的一张架构图。为什么毕业设计扎堆选这套组合Spring Boot 内嵌 Tomcat装好 JDK 就能直接跑省去单独部署容器的步骤Vue 组件化之后首页、详情页、阅读器、书架各管一摊路由跳转清晰MySQL 免费而且可视化工具多Navicat 里导出 ER 图就是现成的答辩素材。持久层用 MyBatis-Plus 而不是原生 JDBC 或 JPA是因为单表 CRUD、分页和条件构造都封装好了能在有限时间内把业务闭环做完。如果你手里的源码用的是原生 MyBatis XML 或 Spring Data JPA不冲突后面的接口边界和表设计思路完全一样。2.2 版本匹配矩阵JDK、Node、MySQL 三个环必须对齐组件推荐版本踩坑点JDK1.8配 Spring Boot 2.7.x若源码是 Spring Boot 3.x 则必须 JDK 17很多机房机器没装Maven3.6 及以上版本过低拉依赖会报错优先配阿里云镜像Node.js14 或 16Vue 2 webpack 4 在 Node 18 频繁报 OpenSSL 错误MySQL5.7 或 8.08.0 必须换带cj的驱动类名和连接参数IDEIDEA 2021必须装 Lombok 插件否则后端编译期报红拿到压缩包先别急着解压跑花五分钟确认三件事打开backend/pom.xml里spring-boot-starter-parent的 version打开frontend/package.json看vue和vue-router的版本号再搜application.yml里driver-class-name那一行。三个信息一旦确定环境半小时能装完。springboot 配置集中在application.ymlmysql 安装配置教程网上很多这里只提醒一点别只看安装成功要用命令行mysql -u root -p能真正连进去才算完否则后端起来第一个报错就是连接拒绝。2.3 启动顺序与最小命令数据库、后端、前端三步走解压后的标准目录结构大致是这样名字可能略有出入但分层思路一致novel-platform/ ├─ sql/ │ └─ novel.sql ├─ backend/ │ ├─ src/main/java/com/example/novel/ │ │ ├─ controller/ │ │ ├─ service/ │ │ ├─ mapper/ │ │ └─ entity/ │ ├─ src/main/resources/ │ │ └─ application.yml │ └─ pom.xml └─ frontend/ ├─ src/ │ ├─ views/ │ ├─ router/ │ ├─ api/ │ ├─ App.vue │ └─ main.js ├─ package.json └─ vue.config.js启动顺序有讲究先导数据库再起后端最后起前端。数据库没就绪后端启动虽然不会崩但第一个查询就会把你绕回环境排查后端没起来前端页面虽然能打开但所有列表都是空的容易误判成前端问题。cd /path/to/novel-platform mysql -u root -p sql/novel.sql # 后端新终端 cd backend mvn spring-boot:run # 或打包运行mvn clean package -DskipTests java -jar target/*.jar # 前端新终端 cd frontend npm install npm run serve第一条命令把建库建表和数据一次性导入注意确认novel.sql里是否有CREATE DATABASE语句没有的话要提前建好库再导入。mvn spring-boot:run适合调试改代码热重启即可npm install首次可能要几分钟装完后npm run serve默认起在 8080。如果页面能打开但列表空白八成是后端端口或代理配置问题。后端唯一要改的文件就是application.ymlserver: port: 8081 spring: datasource: url: jdbc:mysql://localhost:3306/novel_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 改成你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted四个连接参数缺一不可serverTimezoneAsia/Shanghai不加MySQL 8 下日期字段直接报错useSSLfalse避免握手失败allowPublicKeyRetrievaltrue是 MySQL 8 的caching_sha2_password认证插件常见要求driver-class-name必须是带cj的类名老写法com.mysql.jdbc.Driver在 8.0 下已移除。注意密码不要用特殊字符过多的组合yml 里如果包含或:必须加引号否则启动时配置解析会翻车。3. 数据库与后端一起动手五张表的设计逻辑、建表 SQL 与 Spring Boot 三个核心接口3.1 为什么小说和章节必须拆两张表冗余、更新与查询的平衡如果不拆把章节正文塞进小说表每章一条记录会重复书名、作者、分类、简介这些元数据用户打开目录要拉回几十条重复字段改一次封面还要 UPDATE 几十行。拆开是标准做法novel表一本书一行chapter表一章一行用novel_id指回小说。更关键的是章节顺序不能用自增 id 控制而要加一个chapter_no业务序号否则作者在中间插入新章节时顺序全乱。书架表冗余last_chapter_id和update_time是我认为这套设计里最聪明的一笔。书架页要显示「最近读到第几章」如果每次打开书架都去chapter表查最后一条记录是典型的把简单问题复杂化。冗余一个最近章节 ID 后书架列表一条关联查询就能拿到阅读进度update_time用来按最近阅读时间排序这是标准的读多写少优化答辩时值得单独讲。密码列必须留 60 个字符因为 BCrypt 密文固定 60 位。很多初版源码用 MD5 的 32 位老师一问「密码怎么存的」就露怯。评论表加status字段是为了「下架不删除」属于逻辑删除的加分细节MyBatis-Plus 全局配置一行就能开启。3.2 核心建表 SQL五张表一次导入字段与索引这样说DROP TABLE IF EXISTS user; CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, username varchar(50) NOT NULL COMMENT 登录名, password varchar(60) NOT NULL COMMENT BCrypt密文, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, role tinyint(1) DEFAULT 0 COMMENT 0普通用户 1管理员, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; DROP TABLE IF EXISTS novel; CREATE TABLE novel ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 小说ID, title varchar(100) NOT NULL COMMENT 书名, author varchar(50) DEFAULT NULL COMMENT 作者, category_id bigint(20) DEFAULT NULL COMMENT 分类ID, cover varchar(255) DEFAULT NULL COMMENT 封面图URL, intro text COMMENT 简介, status tinyint(1) DEFAULT 1 COMMENT 1连载中 2已完结, click_count int(11) DEFAULT 0 COMMENT 浏览总数, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_title (title), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT小说表; DROP TABLE IF EXISTS chapter; CREATE TABLE chapter ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 章节ID, novel_id bigint(20) NOT NULL COMMENT 所属小说ID, chapter_no int(11) NOT NULL COMMENT 章节序号从1开始, title varchar(100) NOT NULL COMMENT 章节标题, content mediumtext NOT NULL COMMENT 章节正文, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_novel_no (novel_id, chapter_no), KEY idx_novel (novel_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT章节表; DROP TABLE IF EXISTS bookshelf; CREATE TABLE bookshelf ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, user_id bigint(20) NOT NULL COMMENT 用户ID, novel_id bigint(20) NOT NULL COMMENT 小说ID, last_chapter_id bigint(20) DEFAULT NULL COMMENT 最近读到章节ID, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 最近阅读时间, PRIMARY KEY (id), UNIQUE KEY uk_user_novel (user_id, novel_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT书架表; DROP TABLE IF EXISTS comment; CREATE TABLE comment ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 评论ID, novel_id bigint(20) NOT NULL COMMENT 小说ID, user_id bigint(20) NOT NULL COMMENT 用户ID, content varchar(500) NOT NULL COMMENT 评论内容, status tinyint(1) DEFAULT 1 COMMENT 1展示 0隐藏, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_novel (novel_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评论表;字段类型的选择依据要说得出理由id 全部用 BIGINT自增主键在 InnoDB 下是聚簇索引避免频繁页分裂正文用 MEDIUMTEXT 而不是 VARCHAR网文一章几千甚至上万字是常态状态类字段用 TINYINT够用且省空间时间统一 DATETIME配合DEFAULT CURRENT_TIMESTAMP让数据库管写入时间代码里不用手动 set。索引设计是另一个答辩点。user.username唯一索引兜底重复注册chapter的uk_novel_no唯一索引保证一本书内章节序号不重复也是按小说查目录的最优路径novel.title加普通索引用于关键词前缀查询comment.novel_id不加索引的话热门小说评论一多就是全表扫描。引擎必须选 InnoDB提供事务和行级锁MyISAM 虽然读快但写锁整表评论区并发一高就废。3.3 后端接口骨架Controller、Service、Mapper 三层代码怎么摆backend/src/main/java/com/example/novel/ ├── controller/ │ ├── UserController.java │ ├── NovelController.java │ ├── ChapterController.java │ └── BookshelfController.java ├── service/ │ ├── UserService.java │ ├── NovelService.java │ └── BookshelfService.java ├── mapper/ │ ├── UserMapper.java │ ├── NovelMapper.java │ └── ChapterMapper.java ├── entity/ │ ├── User.java │ └── Novel.java ├── config/ │ ├── WebConfig.java │ └── JwtConfig.java └── NovelApplication.java职责边界一句话说清Controller 只接收 HTTP 参数并返回统一 Result不写业务Service 放业务规则比如密码校验、章节序号增减Mapper 只做数据库交互。统一返回体Result通常长这样code表示成功失败、message放提示、data放业务数据。前端只看 code 就能决定是渲染还是弹错误提示这是前后端分离最基本的约定。3.4 登录鉴权、分页搜索、章节阅读三个接口把核心链路串起来登录注册接口是第一个要讲清楚的。用户表密码存的是 BCrypt 密文前端传明文到后端后端用BCryptPasswordEncoder.matches()做校验绝不能在数据库里存明文。RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; PostMapping(/login) public Result login(RequestBody LoginDTO dto) { User user userService.getByUsername(dto.getUsername()); if (user null || !userService.checkPassword(dto.getPassword(), user.getPassword())) { return Result.error(用户名或密码错误); } String token userService.createToken(user); return Result.success(token); } PostMapping(/register) public Result register(RequestBody RegisterDTO dto) { if (userService.getByUsername(dto.getUsername()) ! null) { return Result.error(用户名已存在); } userService.register(dto); return Result.success(); } }逻辑说明login先查用户再比对密文两个条件同时满足才发 tokenregister先查重再插入新用户。token 用 JWT 生成payload 里放userId和过期时间签名用 HS256一天后过期。生成的 token 返回给前端前端存在 localStorage后续请求放在Authorization头里。拦截器负责守卫接口。常见做法是放行登录、注册、小说列表、章节阅读拦截书架和评论这类需要身份的接口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/novel/**, /api/chapter/** ); } }参数说明addPathPatterns声明拦截范围excludePathPatterns声明白名单。这份配置的含义是游客可以浏览小说和章节但操作书架、发评论必须登录。这个「部分接口公开、部分接口鉴权」的设计比全站拦截更容易讲也更贴近真实产品。小说列表和搜索接口是阅读平台的门面分页参数必须清晰GetMapping(/list) public Result list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size, RequestParam(required false) String keyword, RequestParam(required false) Long categoryId) { PageNovel pageResult novelService.pageQuery(page, size, keyword, categoryId); return Result.success(pageResult); }Service 里的条件构造是 MyBatis-Plus 的核心用法public PageNovel pageQuery(int page, int size, String keyword, Long categoryId) { LambdaQueryWrapperNovel wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(keyword), Novel::getTitle, keyword) .eq(categoryId ! null, Novel::getCategoryId, categoryId) .orderByDesc(Novel::getClickCount); return novelMapper.selectPage(new Page(page, size), wrapper); }逻辑说明like只在 keyword 非空时拼接eq只在 categoryId 非空时拼接两个条件同时为空返回全量列表并按点击量倒序。orderByDesc(Novel::getClickCount)等价于热度排序这是小说站最常见的排序场景。Page的 page 从 1 开始前端传 1 就代表第一页size 默认 10偏离这个约定会出现「第一页是空的」这种玄学问题。章节阅读接口要处理一个细节每次阅读给小说的点击量加一。最简单的是直接在查询后执行UPDATE novel SET click_count click_count 1 WHERE id ?毕设规模完全扛得住但答辩时可以主动提一句真实场景会用异步批量更新或 Redis 计数来避免频繁写库这句话一出来老师就知道你考虑过扩展性。GetMapping(/chapter/{id}) public Result getChapter(PathVariable Long id) { Chapter chapter chapterMapper.selectById(id); if (chapter null) { return Result.error(章节不存在); } novelMapper.incrementClick(chapter.getNovelId()); return Result.success(chapter); }上一章下一章的跳转别用selectById做加减正确做法是按章节序号查GetMapping(/chapter/near) public Result near(RequestParam Long novelId, RequestParam Integer chapterNo, RequestParam Integer offset) { LambdaQueryWrapperChapter wrapper new LambdaQueryWrapper(); wrapper.eq(Chapter::getNovelId, novelId) .eq(Chapter::getChapterNo, chapterNo offset); return Result.success(chapterMapper.selectOne(wrapper)); }offset传 1 是下一章、-1 是上一章配合uk_novel_no唯一索引这条查询永远走索引。比 Chapter 表 id 加一减一安全得多因为中间可能有章节被删除导致序号不连续。4. Vue 前端路由与接口对接五个页面、一个 axios 封装和一条代理链路4.1 路由配置与登录守卫hash 模式为什么比 history 稳妥前端页面按功能拆成五个视图Home 小说列表、Detail 小说详情、Reader 章节阅读、Shelf 我的书架、Login 登录。vue 路由就在router/index.js里定义import Vue from vue import VueRouter from vue-router import Home from ../views/Home.vue import Detail from ../views/Detail.vue import Reader from ../views/Reader.vue import Shelf from ../views/Shelf.vue import Login from ../views/Login.vue Vue.use(VueRouter) const router new VueRouter({ mode: hash, routes: [ { path: /, component: Home }, { path: /novel/:id, component: Detail }, { path: /reader/:novelId/:chapterId, component: Reader }, { path: /shelf, component: Shelf, meta: { requiresAuth: true } }, { path: /login, component: Login } ] }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } }) export default router参数说明meta.requiresAuth是路由元信息标记需要登录才能进入的页面beforeEach全局前置守卫在跳转前检查 token没登录直接踢去登录页。路由模式选hash而不是history是因为 hash 模式的 URL 带#刷新不会 404history模式在部署到 Spring Boot 静态目录后直接刷新/novel/1会被后端当接口处理这是一个非常隐蔽的坑。4.2 axios 实例封装把 token 带上、把 401 拦住如果每个页面都直接调axios.gettoken 要么忘记加要么每个页面重写一遍非常容易出权限问题。标准做法是封装一个 axios 实例import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) location.href #/login } return Promise.reject(error) } ) export default request逻辑说明请求拦截器在每次发出前从 localStorage 取 token拼到Authorization头后端拦截器取出来就是「Bearer 开头的完整令牌」响应拦截器把返回值直接剥壳成response.data页面里不用再写res.data.data这种嵌套。401 统一处理清掉过期 token跳回登录页。这套封装写完书架页代码里只需要request.get(/bookshelf/list)职责全在拦截器里。4.3 开发环境代理与阅读器交互跨域不用慌翻页接口要带上一章标识前端 8080、后端 8081两个端口不同源浏览器会拦截跨域请求。最省事的办法不是在 Spring Boot 里配 CORS而是用开发服务器代理module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } }配置说明页面发的/api/novel/list请求被 devServer 转发到http://localhost:8081/api/novel/list浏览器看到的是同源请求跨域问题从根上消失。changeOrigin: true让转发请求的 Host 头变成目标地址个别后端框架校验 Host 时不会误判。阅读器是交互最复杂的页面。Reader.vue 接收novelId和chapterId两个路由参数页面加载后请求章节内容渲染。点击「上一章」「下一章」时不要用浏览器前进后退而是watch: { $route.params.chapterId: function () { this.fetchChapter() } }, methods: { prev() { this.$router.replace({ path: /reader/${this.novelId}/${Number(this.chapterId) - 1} }) }, next() { this.$router.replace({ path: /reader/${this.novelId}/${Number(this.chapterId) 1} }) } }这段代码把翻页变成了「改路由参数 → watch 触发重新请求 → 页面刷新」用户按浏览器返回键时还能按阅读顺序回退。如果直接在方法里请求并把结果塞进 data浏览器返回时会看到旧章节残留这个体验问题很多初版毕设都有。5. 运行期高频踩坑记录从启动失败到中文乱码的五个排查现场5.1 后端启动失败端口占用与 Mapper Bean 找不到现象一后端启动直接退出日志末尾出现Port 8081 was already in use。原因很简单之前调试的后端进程还挂着端口被占死。解决在命令行查占用进程并杀掉再重新启动。# Windows netstat -ano | findstr 8081 # Linux / macOS kill -9 $(lsof -t -i:8081)现象二启动报Consider defining a bean of type com.example.novel.mapper.UserMapper in your NovelApplication。原因是 Mapper 接口没被 Spring 扫描到。解决在主类加MapperScan(com.example.novel.mapper)或者在每个 Mapper 接口上加Mapper注解。这个报错 90% 是漏了扫描注解不是代码逻辑问题。5.2 前端 npm install 失败与白屏Node 版本这个老坑绕不过去现象npm install报Error: error:0308010C:digital envelope routines::unsupported或者构建时直接崩溃。原因是 Node 17 改了 OpenSSL 默认算法Vue 2 配套的 webpack 4 不兼容。最稳的解决是用 nvm 切到 Node 14 或 16而不是去搜--openssl-legacy-provider那种临时补救。现象页面打开是白屏控制台也没有明显报错。原因大概率是直接双击了dist/index.html用 file 协议打开Vue 路由和 axios 在这种模式下根本不工作。解决老老实实npm run serve起开发服务器这也是 vue 安装及环境配置里最容易被跳过的验证步骤。另一类白屏是路由模式问题如果源码里写了history部署后刷新二级页面就会 404。5.3 MySQL 8 连接失败驱动类、时区、公钥三个参数缺一不可现象后端启动时数据源初始化报ClassNotFoundException: com.mysql.jdbc.Driver或者Access denied for user rootlocalhost。前者的原因 MySQL 8 把驱动类改名成com.mysql.cj.jdbc.Driver老配置自然找不到后者是密码确实不对或者 root 账号用了新的caching_sha2_password认证插件。现象连接时报Public Key Retrieval is not allowed。原因就是 MySQL 8 默认认证插件要求在连接时取公钥驱动出于安全默认不放行。解决在 JDBC URL 末尾追加allowPublicKeyRetrievaltrueuseSSLfalse同时确认 URL 里有serverTimezoneAsia/Shanghai。这三个参数我在 2.3 节的application.yml里已经写全照抄即可。5.4 中文乱码连接串、建表字符集、页面编码三层排查现象前端显示书名、评论全是问号或者数据库里直接看就是乱码。原因可能是三层里的任何一层JDBC URL 少了characterEncodingutf8建表语句用了utf8而不是utf8mb4SQL 文件用记事本编辑后存成了 ANSI 编码导入时彻底坏了。解决的排查顺序是从底层往上。第一步看表SHOW CREATE TABLE novel;确认DEFAULT CHARSETutf8mb4。第二步查连接串确认application.yml的 url 里有useUnicodetruecharacterEncodingutf8。第三步看导入方式用 Navicat 导入 SQL 文件时连接属性里选 UTF-8命令行导入则保证终端编码一致。这三步走完乱码基本绝迹。中文乱码是这类中文毕设项目里最普遍也最掉价的 bug一旦出现老师印象分直接减半。6. 答辩前最后一步把 Vue 打包进 Spring Boot、用一条请求链路讲清设计临近答辩很多同学还在两个窗口间切换8080 是 Vue8081 是后端。老师演示时只开一个网址更舒服这时候可以把前端构建产物放进 Spring Boot 的 static 目录打成单端口应用cd frontend npm run build cp -r dist/* ../backend/src/main/resources/static/ cd ../backend mvn clean package -DskipTests java -jar target/novel-0.0.1-SNAPSHOT.jar访问http://localhost:8081就能同时拿到页面和接口。注意三点Vue 路由必须用 hash 模式否则在http://localhost:8081/novel/1刷新会 404登录拦截器要放行静态资源避免把 html、js 也拦掉Spring MVC 默认会处理 static 目录的 index.html但别让自定义 Controller 把根路径/或/**抢走。这一步做完整个项目就从一个开发态工程变成了一个能交付的成品。验证功能别只靠肉眼点页面。我建议打开 Postman按完整链路走一遍POST /api/user/login拿 tokenGET /api/novel/list?page1size10keyword仙验证分页和模糊搜索GET /api/chapter/{id}验证正文返回再带 token 调书架接口。每个接口的响应码、返回结构都过一遍答辩现场出问题的概率会低很多。讲设计时优先讲这三件事一是密码用 BCrypt 而不是 MD5原因是加盐抗彩虹表二是点击量用冗余字段而非COUNT(*)实时统计读多写少场景下的常规权衡三是 chapter 表用(novel_id, chapter_no)唯一索引控制分页和章节跳转这条索引让「上一章下一章」永远走最短路。把这三个点讲透配合 ER 图和 Postman 请求记录比逐行背源码强太多——这些本身也是 java 面试题里最常见的考察点。我当年第一次跑这类全栈毕设时卡在 Node 版本和 MySQL 参数上折腾到半夜后来发现三个配置文件里躺着所有后悔药版本对齐、连接参数、路由模式。先把这套跑通再动手改一两个字段你就能从「会用源码」变成「讲得明白」。希望帮到你。本文还有配套的精品资源点击获取