ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot+Vue+MyBatis+MySQL前后端分离英语学习网站实战解析

2026/9/17 3:30:38 拓冰建站 浏览量
SpringBoot+Vue+MyBatis+MySQL前后端分离英语学习网站实战解析 最近抽空整理了一套前后端分离的英语知识应用网站管理系统技术栈是 SpringBoot Vue MyBatis MySQL。这套系统的定位其实很清晰前端是 Vue 2 单页应用后端是 SpringBoot 提供的 RESTful API数据库用 MySQL 存储用户、单词、课程、笔记、刷题记录这些业务数据持久层用 MyBatis 写 SQL、做映射。整个项目从开题、数据库设计到前后端联调、打包部署我完整跑了一遍踩了不少坑也积累了一些实操心得。这篇文章就把整个项目的设计思路、核心实现、关键细节和常见问题全部拆开来讲适合正在做毕业设计、课程设计或者想快速上手前后端分离项目的初中级开发者参考。不管你拿到的是一套现成源码还是打算从零手写这篇文章都能帮你快速理清 SpringBoot Vue MyBatis MySQL 这套组合背后真正值得关注的点。1. 项目概览与设计思路1.1 系统定位与核心功能英语知识应用网站目标用户分为普通学习者和后台管理员两类。普通用户要做的事很明确注册登录、浏览单词库、查看课程内容、学习视频、做练习题、记录学习笔记最好再有一个单词打卡功能来保留学习记录。管理员则负责维护整个网站内容包括单词数据、课程分类、视频资源、试卷题目以及查看用户数据。所以我从一开始就把系统切成了两个端用户端前台面向学习的完整闭环包括每日单词推荐、分类词汇浏览、视频学习、在线测试、学习笔记、个人中心。管理端后台维护系统内容包括仪表盘统计、单词管理、课程管理、视频管理、题目管理、用户管理、公告与轮播图配置。这个切分方式在刚拿到需求的时候就要先想清楚。很多同学一上来就写代码结果做到后面发现前台后台职责混在一起接口也越来越乱。其实这套业务逻辑并不复杂只要我们先把功能边界划清楚后面每个模块都是往这张表里去填东西填起来就非常快。1.2 为什么选 SpringBoot Vue 这套组合有人问我2025年了为什么不选微服务、不选 TypeScript不选更“新”的技术栈我的回答是选型看需求不是看热度。这个英语知识应用网站属于典型的中小型管理系统单机部署完全够用核心诉求是开发效率高、上手门槛低、维护成本小。SpringBoot 自动配置把大部分繁琐的 Bean 装配和部署配置都抹平了Vue 的组件化开发加上 Element UI 这类组件库后台管理页面基本上半天就能搭出基础骨架。MyBatis 在这套系统里承担持久层的工作。有人会质疑为什么不直接用 MyBatis-Plus如果是从零做新项目用 Plus 确实能省很多 CRUD 代码但很多教学源码和毕设项目都以原生 MyBatis 为主因为它的 SQL 映射机制更透明你可以清楚地看到每条 SQL 的执行过程。而且一旦需要写复杂 SQL比如单词搜索的多条件组合查询手写 XML 反而更可控。当然我这套项目里也保留了手写 mapper 和 XML 分离的结构方便你后面改成 Plus。MySQL 则是数据存储层的稳妥选择。它的稳定性和生态没得说中小型网站的数据承载能力绰绰有余。整套组合下来一个开发经验不多的同学只要有 Java 基础、懂一点 SQL 和 JavaScript就能在一个星期内把核心功能跑通这也是这套技术栈能持续受到课程设计和毕业设计欢迎的根本原因。1.3 从需求到模块划分的思考过程我拿到需求后第一步不是建表也不是创建 SpringBoot 项目而是先画功能脑图和接口清单。我列了一个表格把每一个页面和它对应的后端接口、数据库表一一对应起来如下模块前端页面后端接口数据表用户认证登录页、注册页POST /api/user/loginPOST /api/user/registersys_user单词学习单词列表、单词详情GET /api/word/pageGET /api/word/{id}word_info课程学习课程列表、课程详情GET /api/course/pageGET /api/course/{id}course_info视频播放视频播放页GET /api/video/infovideo_info在线测试测试列表、答题页GET /api/exam/paperPOST /api/exam/submitexam_paperexam_record学习笔记笔记列表、编辑笔记GET /api/note/listPOST /api/note/savestudy_note后台管理仪表盘、管理页面/api/admin/... 系列接口对应业务表这个表一旦画出来开发的时候就有一个明确的“地图”不会做着做着就迷路。也建议第一次做这类型项目的读者把这步功课做足后面会节省大量返工时间。2. 技术与环境准备2.1 核心依赖与版本选择先说版本。SpringBoot 的版本选择一直是个老生常谈的话题SpringBoot 3.x 要求 JDK 17如果你的电脑只有 JDK 8那就不能直接用 3.x否则启动直接报错。很多刚接触源码的同学看到“springboot版本太高”这个问题往往就是本地 JDK 版本不匹配导致的。我的建议是课程设计和毕设项目老老实实使用 SpringBoot 2.7.x JDK 1.8 的组合稳定、资料多、网上踩坑记录也全。我项目里的关键依赖版本参考如下SpringBoot 2.7.18MyBatis Spring Boot Starter 2.3.1MySQL Connector/J 8.0.33JJWT 0.9.1用于生成和校验 TokenHutool 5.8.x工具包做验证码、日期处理很方便Vue 2.6.14 Vue Router 3 Vuex 3 AxiosElement UI 2.15.xNginx 1.24前端部署这套组合虽然不能说最新但是非常稳。生产环境不需要追求纸面参数能够稳定跑起来、好排查问题的组合才是最合适的。2.2 MySQL 安装与初始化MySQL 的安装可能是很多初学者遇到的第一道坎。不管是 Windows 还是 Mac装 MySQL 时最容易踩的坑有两个一是安装完以后 root 密码设置问题二是字符集设置问题。安装完成后我建议立刻做两件事第一把 root 密码记在一个固定位置别等第二天忘了才到处找“mysql安装教程”第二在配置文件 my.iniWindows或 my.cnfMac/Linux里强制指定 UTF-8 字符集避免后面插入中文数据变成乱码。[mysqld] character-set-serverutf8mb4 collation-serverutf8mb4_general_ci [client] default-character-setutf8mb4配置完以后重启 MySQL 服务然后用命令行或者 Navicat 执行项目里的 init.sql 脚本。这个脚本会把所有表结构和初始数据都建好。我特别提一句不要用 root 用户直接操作表而是新建一个专用账号比如CREATE DATABASE english_learning DEFAULT CHARSET utf8mb4; CREATE USER english_applocalhost IDENTIFIED BY your_password; GRANT ALL PRIVILEGES ON english_learning.* TO english_applocalhost; FLUSH PRIVILEGES;这样做的好处是以后生产环境即使出了问题也可以用最小权限原则去限制风险不至于一个 root 账号打天下。2.3 前端环境搭建注意点前端是 Vue 工程Node.js 版本建议使用 16.x 或 18.x LTS 版本。如果你装了 Node 20 以上再跑老项目有时候会出现 node-sass 编译失败问题。所以我在项目里统一用 sass 替代 node-sass避免了一大堆原生模块编译的麻烦。创建 Vue 项目我推荐使用 Vue CLI版本选 5.xnpm install -g vue/cli vue create english-web cd english-web npm install element-ui axios vue-router vuex项目创建好后马上验证一次npm run serve能把默认页面跑起来再开始改代码。很多同学上来就装了一堆依赖然后报一堆版本冲突错误最后连问题出在哪都不知道。依赖管理这种事必须是先小步验证再逐步叠加。还有一个细节Vue 项目里用到播放器的话可以安装video.js和videojs-contrib-hls如果播放的是 m3u8 视频流这两个库能直接帮你搞定 HLS 协议。关于 m3u8 的接入后面我会专门讲解。3. 数据库设计与核心表结构3.1 用户与权限表设计用户表是系统的地基。我设计用户表时把字段分成三类基本信息、账号安全、扩展信息。CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 密码BCrypt加密, nickname VARCHAR(50) COMMENT 昵称, email VARCHAR(100) COMMENT 邮箱, avatar VARCHAR(255) COMMENT 头像URL, role TINYINT DEFAULT 1 COMMENT 角色0管理员1普通用户, status TINYINT DEFAULT 1 COMMENT 状态1启用0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表;这里有两个核心设计点第一密码字段长度至少要 60因为 BCrypt 加密后的字符串很长40 不够用第二角色字段我用的是单字段数字标识没做多表关联的角色权限模型。有人会觉得这不够“高级”但对于这个系统场景用户只需要区分管理员和普通用户单字段最简单、最直观。如果你后续要加老师、超级管理员等角色扩展方式也简单等级别多一点了再拆角色表也不迟。3.2 单词表、课程表与视频资源表英语知识网站内容才是核心所以单词表的设计要考虑三个维度单词本身的属性、分类标签、学习状态。CREATE TABLE word_info ( id INT PRIMARY KEY AUTO_INCREMENT, word VARCHAR(100) NOT NULL COMMENT 单词, phonetic_uk VARCHAR(100) COMMENT 英式音标, phonetic_us VARCHAR(100) COMMENT 美式音标, part_of_speech VARCHAR(50) COMMENT 词性, translation VARCHAR(500) COMMENT 中文释义, example_sentence TEXT COMMENT 例句, example_translation VARCHAR(500) COMMENT 例句翻译, category_id INT COMMENT 分类ID, difficulty TINYINT DEFAULT 1 COMMENT 难度1/2/3/4/5, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT单词信息表;课程表和视频表之间有一个层级关系一个课程course_info下包含多个章节course_chapter每个章节关联一个视频资源video_info。这里需要处理好一对多关系。我在实践里发现很多同学直接把视频字段粗暴地塞进课程表这样做会导致后面想做“章节切换”功能时非常痛苦。正确的做法是拆表用外键关联CREATE TABLE course_info ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, cover_url VARCHAR(255), description TEXT, teacher VARCHAR(50), category_id INT, status TINYINT DEFAULT 1 COMMENT 发布状态 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE video_info ( id INT PRIMARY KEY AUTO_INCREMENT, course_id INT NOT NULL COMMENT 所属课程ID, chapter_name VARCHAR(200) COMMENT 章节名称, video_url VARCHAR(500) COMMENT 视频地址支持m3u8/mp4, duration INT DEFAULT 0 COMMENT 时长秒, sort_order INT DEFAULT 0 COMMENT 排序 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这样设计以后前端课程详情页展示“课程信息 章节列表 视频播放器”就不需要任何多余查询一个课程ID就能把全部章节查出来。3.3 笔记与答题记录表笔记表和答题记录表属于用户产生内容的表设计思路是“以用户为中心”。笔记表要记录用户在哪一段学习内容下写了什么CREATE TABLE study_note ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, word_id INT COMMENT 关联单词可选, course_id INT COMMENT 关联课程可选, content TEXT NOT NULL COMMENT 笔记内容, is_public TINYINT DEFAULT 0 COMMENT 是否公开, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;答题记录表则要兼顾“过程”和“结果”。我设计了两张表一张存每一次答题任务exam_record一张存任务下每道题的具体作答明细exam_record_detail这样才能统计出“某道题的错题率”和“某个用户的薄弱知识点”。虽然这是一个很小的业务点但按规范化设计去拆表是这次实践里让我印象最深的一件事因为最初图省事只建了一张表后面想加统计功能时才回去补了一张表多花了大半天。4. 后端核心实现与 MyBatis 实践4.1 SpringBoot 工程结构与统一返回格式后端工程我采用的是常见的分层结构com.example.english ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── vo ├── config ├── utils └── common一开始就要把通用响应体写好否则后面每个接口都要写重复的 JSON 包装非常痛苦。我定义了一个 Result 类Data public class ResultT implements Serializable { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(success); result.setData(data); return result; } public static T ResultT error(Integer code, String msg) { ResultT result new Result(); result.setCode(code); result.setMsg(msg); return result; } }所有 Controller 返回值都统一走这个 Result。前端 Axios 拦截器里也做统一判断Code 不是 200 就直接弹错误提示。这套规则定下来以后前后端联调的时候沟通成本会低很多。4.2 MyBatis 的 XML 映射与动态 SQLMyBatis 最核心的应用场景就是写动态 SQL。拿单词条件分页查询举例前端可能传入关键词、分类、难度这三个条件如果我用三个不同的接口来做代码会冗余得不像样如果用 MyBatis 的动态 SQL一个方法就能搞定。Mapper 接口ListWordVO selectWordPage(Param(keyword) String keyword, Param(categoryId) Integer categoryId, Param(difficulty) Integer difficulty, Param(offset) int offset, Param(limit) int limit); long countWordPage(Param(keyword) String keyword, Param(categoryId) Integer categoryId, Param(difficulty) Integer difficulty);XML 映射select idselectWordPage resultTypecom.example.english.vo.WordVO SELECT w.*, c.name AS category_name FROM word_info w LEFT JOIN category_info c ON w.category_id c.id where if testkeyword ! null and keyword ! AND (w.word LIKE CONCAT(%, #{keyword}, %) OR w.translation LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND w.category_id #{categoryId} /if if testdifficulty ! null AND w.difficulty #{difficulty} /if /where ORDER BY w.id DESC LIMIT #{offset}, #{limit} /select这里有两个地方要特别提醒一是#{}和${}的区别一定不要用${}直接拼接用户输入否则会引发 SQL 注入二是 LEFT JOIN 关联分类表的时候为什么不用子查询因为关联一次就能拿到分类名称避免 N1 查询问题。MyBatis 这种“把 SQL 控制权交回开发者”的思想对于复杂查询来说是巨大优势多条件查询你根本不需要去拼接字符串干干净净写在 XML 里就好。4.3 MyBatis 缓存与常见踩坑MyBatis 有一级缓存和二级缓存官方文档里说得挺清楚但实际开发时它们的表现经常让人摸不着头脑。一级缓存是 SqlSession 级别的默认开启。在同一个 SqlSession 里执行两次相同 SQL第二次会直接命中缓存。但注意Spring 整合 MyBatis 以后每次 mapper 方法调用默认都会创建一个新的 SqlSession所以一级缓存基本起不到作用你也不用刻意依赖它。二级缓存是 mapper namespace 级别的需要手动开启。这里我强烈建议这个项目不要开启二级缓存。原因很简单当你的业务里有用到连表查询时缓存数据的更新时机不好控制经常出现数据改了但缓存没刷新的问题。对于这种访问量级别的学习类网站数据库性能完全够用完全没有必要为了“缓存”而缓存。你真正要做的优化是给高频查询加 Redis而不是依赖 MyBatis 自带的二级缓存。另外很多初学者看到 MyBatis 的缓存配置容易跟 Redis 混淆。Redis 是独立缓存中间件MyBatis 的缓存只是框架内部的一个机制两者解决的问题不一样别搞混。4.4 登录认证与 Token 设计系统采用 JWT 做登录认证。登录成功后后端用 userId 和 role 生成一个 Token前端把它存在 localStorage每次请求时放到请求头里。JWT 生成工具类public class JwtUtils { private static final String SECRET english-app-secret-key; private static final long EXPIRE 7 * 24 * 60 * 60 * 1000L; public static String createToken(Integer userId, Integer role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }有了 Token 之后需要写一个 HandlerInterceptor 来做登录校验。MyBatis 拦截器是一个经常被提及的扩展点但 SpringMVC 里的拦截器同样重要。这里我写了一个 JwtInterceptor对所有/api/**请求生效但放行/api/login、/api/register和静态资源public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } if (token null || token.isEmpty()) { response.setStatus(401); return false; } try { Claims claims JwtUtils.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }这个拦截器解决了一个特别常见的问题前端拿着过期 Token 去请求用户信息后端直接返回 401 而不是还在继续处理业务。登录状态失效的判断必须要在进入 Controller 之前完成否则会报一堆空指针异常排查起来特别费劲。5. 前端核心实现与 Vue 实践5.1 Vue 工程目录与路由设计前端目录结构我按模块拆分而不是按页面拆分src ├── api │ ├── user.js │ ├── word.js │ ├── course.js │ ├── exam.js │ └── admin.js ├── assets ├── components ├── router │ └── index.js ├── store │ └── modules ├── views │ ├── user │ ├── course │ ├── exam │ └── admin ├── utils │ ├── request.js │ └── auth.js └── App.vue路由设计上我用路由懒加载来减少首屏加载体积const routes [ { path: /, component: () import(/layout/UserLayout.vue), children: [ { path: , name: Home, component: () import(/views/home/Home.vue) }, { path: words, name: WordList, component: () import(/views/user/WordList.vue) }, { path: course/:id, name: CourseDetail, component: () import(/views/user/CourseDetail.vue) }, { path: exam/:paperId, name: Exam, component: () import(/views/user/ExamPaper.vue) } ] }, { path: /admin, component: () import(/layout/AdminLayout.vue), meta: { requiresAdmin: true }, children: [ { path: , name: Dashboard, component: () import(/views/admin/Dashboard.vue) }, { path: word-manage, name: WordManage, component: () import(/views/admin/WordManage.vue) } ] } ];路由懒加载的好处是首屏只加载当前页面需要的 JS 文件。对于前后端分离的项目这一步虽然看起来只是换个写法但对首屏性能和线上体验有实质提升。再搭配上meta.requiresAdmin标记配合路由守卫就能实现页面级权限控制。5.2 Axios 封装与请求拦截前端请求封装是我每次做项目都坚持最先完成的事情。一个干净的 request.js 能省掉 80% 的重复错误处理代码import axios from axios; import { Message } from element-ui; import router from /router; const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }, error Promise.reject(error)); service.interceptors.response.use(response { const res response.data; if (res.code ! 200) { Message.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg)); } return res; }, error { if (error.response error.response.status 401) { Message.error(登录已过期请重新登录); localStorage.removeItem(token); router.push(/login); } else { Message.error(网络异常请稍后重试); } return Promise.reject(error); }); export default service;这里有一个非常实用的处理把baseURL设置为/api开发的时候在vue.config.js里配置代理把/api转发到后端的localhost:8080生产部署的时候Nginx 同样把/api反向代理到 Java 服务。这样做的好处是前端代码里不需要写死任何一个后端地址以后换服务器只要改 Nginx 配置就行。5.3 权限控制与前端路由守卫权限控制是管理系统里最容易被忽视的一环。很多同学只在后端做了拦截前端每个接口只要在页面上能点击就行但这样就会出现“普通用户也能看到管理后台入口”这种尴尬情况。我用 Vue Router 的全局前置守卫实现了一套轻量但有效的权限控制router.beforeEach((to, from, next) { document.title to.meta.title ? to.meta.title - 英语学习平台 : 英语学习平台; const token localStorage.getItem(token); if (to.path /login || to.path /register) { next(); return; } if (!token) { next(/login); return; } if (to.meta.requiresAdmin) { const role parseInt(localStorage.getItem(role)); if (role ! 0) { Message.error(无权限访问); next(/); return; } } next(); });这个判断逻辑很直白没有 Token 就回登录页访问管理员页面但没有管理员角色就回首页。但这只是前端层面真正可信的判断还是要靠后端接口拦截。我提供的源码里后端 JwtInterceptor 会解析 Token 里的 role 字段接口层面的权限控制才是最终防线。5.4 m3u8 视频播放接入与踩坑这个项目里有一个学习视频模块视频源我测试时就用了 m3u8 格式。m3u8 是 HLS 协议下的播放列表文件本质是文本文件里面指向多个 .ts 视频切片浏览器不能直接打开播放需要使用 HLS 支持的播放器。前端我选用 video.jsnpm install video.js7 npm install videojs-contrib-hls页面里的使用方式如下import videojs from video.js; import video.js/dist/video-js.css; mounted() { const url https://example.com/path/to/playlist.m3u8; const player videojs(this.$refs.videoPlayer, { controls: true, autoplay: false, preload: auto, sources: [{ src: url, type: application/x-mpegURL }] }); this.player player; }踩坑记录主要有两个。第一m3u8 地址必须支持跨域访问如果视频服务器和网站域名不一致视频服务器上要配置 CORS 头否则播放器会报Failed to load错误这个问题排查起来最隐蔽。第二是 m3u8 里的切片地址有可能是相对路径播放器会基于 m3u8 的 URL 去解析如果你的视频源是第三方的最好不要在本地拼接完整地址直接传原始 m3u8 URL 给播放器更靠谱。这里我建议做课程项目时视频模块先用一个可公开访问的 m3u8 测试源跑通流程。如果放一个不存在的地址页面会一直 loading你根本无法判断是哪个环节出了问题。6. 核心功能实操从请求到响应的完整链路6.1 单词打卡功能的前后端实现单词打卡是这个系统里用户黏性最高的功能我们完整走一遍这个功能的实现链路。数据库表设计上我建了一张 user_word_progress 表记录用户每天打卡的单词CREATE TABLE user_word_progress ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, word_id INT NOT NULL, study_date DATE NOT NULL, status TINYINT DEFAULT 1 COMMENT 1已学习2已掌握, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_word_date (user_id, word_id, study_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;唯一索引的设计很关键它保证了同一个用户在同一天不会重复记录同一个单词数据库层面的约束比代码里的判断可靠得多。后端打卡接口PostMapping(/api/word/checkin) public Result? checkIn(RequestBody CheckInDTO dto, HttpServletRequest request) { Integer userId (Integer) request.getAttribute(userId); wordService.checkIn(userId, dto.getWordId()); return Result.success(null); }Service 层先判断用户今天是否已经打过卡如果没打过就插入记录并累加今天的打卡数。这里我用了一个 Redis 的可选项不过初始版本里直接用 MySQL 查询判断就够用了。前端打卡按钮的交互做成“点击后立即变为已打卡状态”再通过接口确认防止用户重复点击时提交重复数据。前端的按钮 loading 状态一定要记得加不然网速慢的时候用户多点几下就会触发后端重复插入虽然有唯一索引兜底但这种体验层面的细节能看出一个开发者的基本功。6.2 在线测试模块的实现在线测试模块的核心逻辑是试卷组装和自动判分。试卷表用来描述考试的基本信息试卷题目关联表 exam_paper_question 记录一道题在试卷里的分值题目表 exam_question 存具体题干、选项和答案。用户提交试卷后后端遍历每道题把用户答案和正确答案比对累加得分同时记录到 exam_record_detail 里public int submitPaper(Integer userId, Integer paperId, ListUserAnswerDTO answers) { ExamPaper paper examPaperMapper.selectById(paperId); ListExamQuestion questions examQuestionMapper.selectByPaperId(paperId); int score 0; for (UserAnswerDTO answer : answers) { ExamQuestion question questions.stream() .filter(q - q.getId().equals(answer.getQuestionId())) .findFirst().orElse(null); if (question null) continue; boolean correct question.getAnswer().equals(answer.getUserAnswer()); if (correct) { score answer.getScore(); } examRecordDetailMapper.insert(userId, paperId, answer.getQuestionId(), answer.getUserAnswer(), correct); } examRecordMapper.insert(userId, paperId, score, new Date()); return score; }但自动判分只适用于单选题、判断题填空题和听力题这种主观题型就无法用简单的字符串匹配来判断了。如果你要在系统里加入听力题那还要配合音频资源和人工复核机制。这也是我后续扩展的一个方向。6.3 后台管理模块的表格 CRUD 套路后台管理的页面无论是单词管理还是用户管理整体套路都一样用 Element UI 的 el-table 展示数据配合 el-pagination 做分页顶部放搜索条件右边放操作按钮。这类页面的实现效率几乎完全取决于你能不能把这套 CRUD 流程抽象出来。我的做法是先封装一个 PageResult 后端返回结构Data public class PageResultT { private long total; private ListT records; }然后所有的分页查询接口都返回这个结构。前端 el-table 的分页组件直接绑定 total 和 records翻页时重新请求一次即可。这套模式一旦建立后台管理的每个页面基本就是复制粘贴加改字段一个下午能做完一半管理页面。SpringBoot 的自动配置保证了我只需要在 application.yml 里配好数据源和 MyBatis 的 mapper-locations剩下的就是写业务代码。这里建议把敏感信息放到环境变量里而不是直接写死在配置文件中哪怕只是课程项目也要养成这个习惯。7. 部署上线与性能优化7.1 前端打包与 Nginx 部署前端开发调试完以后第一件事是执行打包npm run build打包会生成 dist 目录。这里有一个常见的坑“vue 打包后布局异常”或者“静态资源 404”。问题通常出在静态资源路径配错。如果你的项目部署在域名根路径下publicPath设置为/即可如果部署在子目录下就必须设置成子目录路径否则 JS、CSS 文件全部加载不到。我的部署方案是直接部署到 Nginx 根目录所以配置很简单server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /usr/share/nginx/html/english-web; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;这一行非常关键它是 Vue Router 的 history 模式正确工作的前提。如果不写用户直接访问/course/1这样的页面Nginx 会去找一个不存在的文件结果就是 404。7.2 后端打包与 jar 部署后端打包用 Maven 就行mvn clean package -DskipTests打包完成后target 目录下会生成一个可运行的 jar 包。启动命令是nohup java -jar english-server.jar --spring.profiles.activeprod server.log 21 --spring.profiles.activeprod这个参数很重要它让 SpringBoot 加载 application-prod.yml 中的生产环境配置。不同环境用不同配置文件这是项目规范里最基本的一条红线。如果是线上服务器内存不太大也可以给 JVM 设置初始堆大小比如-Xms256m -Xmx512m避免占用过多内存。7.3 缓存与查询性能优化这套系统目前的量级根本不需要做复杂的性能优化但有一些低成本的优化点值得做热门科目和轮播图这类不经常变化的数据用 Redis 缓存设置 5 分钟过期时间能有效减少数据库压力。列表查询统一使用分页禁止全表查询。MyBatis 中如果没写 LIMIT 语句可能会出现一次性查 10 万条记录的情况这是开发阶段最常见的内存溢出原因。多表查询时使用 LEFT JOIN并且给关联字段加索引。比如word_info.category_id加索引以后按分类查单词会快很多。关于 “mybatis 拦截器”如果你以后想做一个统一的分页插件或者给所有 insert/update 自动填充 create_time、update_time那个才是 MyBatis 拦截器的真正用武之地。MyBatis 的 Interceptor 可以拦截 Executor、StatementHandler 等核心对象实现原理并不复杂但可玩性很高建议有一定基础以后去读一下 MyBatis 的源码把Plugin和Interceptor的关系理清楚会对整个框架有更深的理解。8. 常见问题与排查技巧实录这套项目开发过程中我整理了一份避坑手册很多问题我在第一次做的时候都遇到过这里做一张速查表问题现象根本原因解决方案SpringBoot 启动报错提示找不到主类IDEA 未正确编译工程或者项目没有设置为 Maven 项目执行 mvn clean compile右键 pom.xml 选择 Add as Maven Project前端 npm run serve 报 node-sass 错误Node 版本与 node-sass 不兼容卸载 node-sass改用 sass 解决中文数据插入 MySQL 变成问号数据库字符集不是 utf8mb4修改 my.ini 中的 character-set-server重启 MySQL请求后端接口返回 401但本地调试没问题前端请求没有携带 Token或者 Token 过期检查 Axios 拦截器中的请求头设置重新登录获取 Token多条件查询查不到数据MyBatis 动态 SQL 中if标签条件判断错误开启 MyBatis 日志打印 SQL检查传入参数Vue 打包后页面空白静态资源请求路径错误检查 publicPath 配置这个值必须是绝对路径或以 ./ 开头路由回退时页面样式错乱浏览器缓存导致的旧版本资源清理浏览器缓存或对静态资源加版本号 hashNginx 代理后接口返回 502后端服务未启动或 proxy_pass 地址写错检查netstat -tlnp确认 8080 端口是否在监听m3u8 视频无法播放报跨域错误视频源服务器未配置 CORS 头在视频源服务端添加 Access-Control-Allow-Origin这些问题的排查思路说到底就是一条主线先定位是前端问题还是后端问题。如果页面能打开但数据不对打开浏览器 F12 看 Network 面板看接口请求状态码如果是接口请求都发不出去那基本是前端路由、代理或者请求封装的问题。MyBatis 还有一个非常有用的调试技巧在 application.yml 里配置日志输出打印 SQL 语句mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.english.entity logging: level: com.example.english.mapper: debug这样每一次 SQL 执行都会在日志中显示完整的参数和结果排查 SQL 写错的问题简直不要太高效。这个配置一定记下来是日常开发中使用频率最高的一个调试手段。再补充一个关于“springboot 配置”的注意点application.yml里的spring.datasource配置MySQL 8 的驱动类名是com.mysql.cj.jdbc.DriverMySQL 5 用的是com.mysql.jdbc.Driver。如果你本地装的是 MySQL 8但在网上抄了一段老配置启动时就会报 Driver 类找不到这个错误很骗人乍看像依赖没引入其实是驱动类名和版本不匹配。在实际导源码的过程中很多同学遇到的是“源码在自己的电脑上跑不起来”。这种情况百分之八十是环境问题而不是代码问题。拿到一套源码我建议按这个顺序检查环境变量JDK 版本、Maven 版本、MySQL 版本、Node 版本然后才是数据库初始化。环境版本匹配了项目基本能起来剩下的问题基本都是业务配置问题比环境问题好排查得多。9. 从源码到二次开发的扩展建议如果你拿到的是一套完整源码在搞懂核心流程之后不要急着改代码先按“用户-管理员-业务层”三条线把代码读一遍用户这条线看登录注册和用户中心管理员这条线看后台 CRUD业务层这条线看单词、课程、考试这几个核心功能的接口调用链。读完以后再做二次开发能少走很多弯路。这个英语知识应用网站可以扩展的方向也不少。比如在单词模块加入遗忘曲线复习机制那就要新增一张 review_record 表来记录每次复习的时间并且在前端做一个今日复习计划在线测试模块可以扩展听力题和填空题那就要把 exam_record_detail 表再细化增加听力音频 URL 字段和人工批改状态字段学习社区和论坛模块则要重新设计帖子表和评论表的表结构这种带用户互动的内容系统数据库设计会更复杂一些但你的项目完成度也会高出不少。我个人在实际操作中的体会是做管理系统类项目技术上真正难的点并不多大多数时间都花在了“需求边界划分、表结构设计、前后端联调”这三件看起来不是代码的事上。把这三件事抓好再用 SpringBoot Vue 这套组合去落地新手也能做出一套让人眼前一亮的东西。最后再分享一个小技巧拿到任何一套源码先在本地完整跑通并理解核心流程再动手去改功能这个顺序千万不能反。