又是一套被问烂了但永远有人需要的“在线课程管理系统”,后端SpringBoot、前端Vue、数据库MySQL,三件套整整齐齐。说实话,这类项目在GitHub和各大源码站上一抓一大把,但真正能直接跑起来、结构还清晰的,反而没几个。我手里正好维护着一套能用的版本,前后端分离,该有的功能都有,最关键的是——它在Windows和Mac上都能顺利启动,不需要你翻山越岭去改一堆配置。今天就把这套系统的设计思路、核心实现和部署过程中的那些坑,一次性讲清楚。
这套东西适合谁?一是拿来做毕业设计的在校生,二是想学SpringBoot+Vue全栈但一直停留在看教程阶段的新手,三是确实需要一个轻量教学管理后台的培训机构和中小型教育团队。它解决的核心问题就一个:让你用最少的时间,跑通一套完整的前后端分离项目,并且能看懂每个模块为什么这么写、每个表为什么这么建。
1. 系统整体设计与技术栈选型
1.1 为什么是SpringBoot+Vue+MySQL这套组合
先说说技术栈的选型逻辑。SpringBoot在Java后端领域的统治地位不用我废话,它最大的价值不是性能有多极致,而是把SSM那套繁琐的XML配置全部消灭了,一个注解搞定Bean管理,一个application.yml搞定数据源和端口配置,对中小型系统来说开发效率是真的高。
Vue火到现在,核心原因就一条:组件化开发。像课程列表、轮播图、个人中心这类的UI块,抽成组件后能在不同页面复用,维护成本直线下降。配合Vue Router做SPA页面切换,用户体验接近原生App,不需要每次点击都刷新整个页面。
MySQL作为关系型数据库,在数据一致性要求高的场景下仍然是首选。课程管理涉及用户、订单、选课关系等多张表的关联查询,用MySQL的事务机制能保证数据不会写到一半断电就丢了。
这套组合的分工很清晰:SpringBoot只负责提供RESTful API,不掺和页面渲染;Vue只负责页面交互,通过Axios发请求拿数据;MySQL在底层老老实实存数据。三层解耦,每一层都能单独替换,这也是为什么企业招聘对这三个技术栈的需求常年排在前列。
1.2 功能模块划分与角色权限设计
整套系统分了三个角色:学生、教师、管理员。每个角色看到的功能入口完全不同,这是通过后端的权限拦截器和前端的路由守卫双保险实现的。
- 学生端:注册登录、浏览课程列表、查看课程详情、选课、观看课程视频、查看已选课程、提交课程评价
- 教师端:创建课程、管理课程章节、上传课程视频、查看选课学生列表、回复课程评价
- 管理端:用户管理(禁用/启用账号)、课程审核(上架/下架)、分类管理、数据统计概览
权限这块用的是JWT(JSON Web Token)方案。用户登录成功后,后端生成一个Token返回给前端,前端存在localStorage里,每次请求都在Header里带上Authorization: Bearer <token>。后端通过拦截器解析Token获取用户角色,再判断当前请求是否有权限访问。
这里有个设计细节值得说:前端路由守卫只控制“看得到的页面”,真正拦住违权操作的是后端的接口拦截。比如学生手动调用教师的创建课程接口,后端一定要返回403,不能光靠前端藏按钮,这个安全意识从一开始就要养成。
1.3 项目目录结构与代码规范
拿到源码后,先看目录结构。后端遵循标准的Maven分模块结构:
course-backend/ ├── src/main/java/com/course/ │ ├── controller/ # 控制器层,接收请求 │ ├── service/ # 业务逻辑层 │ ├── mapper/ # MyBatis接口层 │ ├── entity/ # 实体类 │ ├── dto/ # 数据传输对象 │ ├── config/ # 配置类(WebMvc、跨域、JWT拦截器) │ ├── common/ # 统一返回值、异常处理 │ └── utils/ # 工具类(JWT工具、MD5加密) ├── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ └── application.yml └── pom.xml前端目录则遵循Vue CLI的标准结构:
course-frontend/ ├── public/ ├── src/ │ ├── api/ # 接口请求封装 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ ├── router/ # 路由配置 │ ├── store/ # Vuex状态管理 │ ├── views/ # 页面视图 │ ├── utils/ # 工具函数 │ ├── App.vue │ └── main.js └── package.json前后端分离项目的目录结构直接影响团队协作效率。我看到不少新手项目把Controller里塞满了业务代码,Service层形同虚设,导致后期想加个缓存、加个事务都无从下手。这套源码的层级划分比较干净,你照着分层规范去改代码,不会越改越乱。
2. 核心细节解析与实操要点
2.1 数据库表设计:五张核心表的关系梳理
这套系统的MySQL库一共8张表,最关键的是这五张:
| 表名 | 说明 | 关键字段 |
|---|---|---|
user | 用户表(学生/教师/管理员) | id, username, password, role, status |
course | 课程表 | id, title, cover, teacher_id, category_id, status |
chapter | 章节表 | id, course_id, title, sort |
video | 视频表 | id, chapter_id, url, duration |
user_course | 选课关系表 | id, user_id, course_id, create_time |
设计上有一个关键点:user_course是中间表,把用户和课程做成多对多关系。一个学生可以选多门课,一门课可以被多个学生选,中间表的存在就是用来化解这种复杂关系的。
user表的role字段用int类型(0管理员、1教师、2学生),不搞字符串,理由很简单:数字在索引和比对时效率更高,而且后续扩展角色不用改表结构。
course表的teacher_id关联user表的id,没有设置实际的外键约束,但逻辑上存在关联关系。这样做的原因是,机房环境里经常有人手动删数据,物理外键容易导致删除失败,线上系统的普遍做法是保留逻辑关联而不是物理约束,ORM层面控制好就行。
建表SQL文件在源码的sql/目录下,文件名带日期,比如course_db_20240101.sql。导入之前先确认字符集:
CREATE DATABASE IF NOT EXISTS course_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;utf8mb4是必须的,因为课程评价里可能有Emoji表情,老的utf8存不了四字节字符,一存就报错。这个坑我见过太多次了。
2.2 后端接口设计:RESTful风格与统一返回格式
先看后端接口的返回格式,这是前后端能顺利协作的第一步。源码里定义了一个Result类,所有接口都返回这个统一结构:
public class Result<T> { private Integer code; // 200成功,4xx业务错误,500系统错误 private String message; // 提示信息 private T data; // 数据体 public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }前端拿到返回体后:
if (res.data.code === 200) { // 正常渲染数据 } else { // 弹出错误提示 }这种统一返回格式的意义在于:前端拦截器可以集中处理错误码,不用每个接口单独判断。比如Token过期时后端返回401,前端Axios拦截器里统一跳转登录页,一次写好全项目通用。
接口路径严格遵循RESTful规范:
POST /api/user/login # 登录 GET /api/course/page?page=1&size=10 # 分页查询课程 POST /api/course # 创建课程(教师权限) PUT /api/course/{id} # 修改课程(教师/管理员) DELETE /api/course/{id} # 删除课程(管理员权限) POST /api/user/course # 学生选课 GET /api/user/course/my # 查看已选课程动词全部交给HTTP方法,路径只放资源名,这个习惯从搭框架第一天就要养成。有不少人喜欢写/api/getCourseByTeacherId这种,也不是不行,但长线维护下来,RESTful风格的优势会越来越明显——看到路径就能猜出语义,新成员接手时的沟通成本低很多。
2.3 关键实现拆解:选课业务与事务管理
选课这个操作,看似简单,实际上包含了事务和并发两个经典问题。源码中StudentCourseServiceImpl的选课方法值得细看:
@Override @Transactional(rollbackFor = Exception.class) public Result<String> selectCourse(Long courseId, Long userId) { // 1. 校验课程是否存在且已上架 Course course = courseMapper.selectById(courseId); if (course == null || course.getStatus() != 1) { return Result.error(400, "课程不存在或已下架"); } // 2. 校验是否已经选过 Integer count = userCourseMapper.checkSelected(userId, courseId); if (count > 0) { return Result.error(400, "请勿重复选课"); } // 3. 插入选课记录 UserCourse userCourse = new UserCourse(); userCourse.setUserId(userId); userCourse.setCourseId(courseId); userCourse.setCreateTime(new Date()); int rows = userCourseMapper.insert(userCourse); if (rows == 0) { return Result.error(500, "选课失败,请联系管理员"); } return Result.success("选课成功"); }有几个细节说明一下:
@Transactional(rollbackFor = Exception.class)把整个方法包进事务,只要中间任何一步抛异常,前面的插入操作都会回滚。如果不加这个注解,可能出现“用户课表多了记录但订单没生成”的数据不一致情况。- 条件判断全部前置,先校验后操作,减少无谓的数据库写入。
- 这里没有加Redis分布式锁,对于课容量不限制的在线学习平台来说,单机下的乐观锁和去重表设计已经够用。如果是抢课业务,就得考虑更严密的并发控制方案。
关于事务,我再多说一句:@Transactional只对运行时异常(RuntimeException)生效,如果代码里捕获了异常不抛出去,事务是不会回滚的,这是初学者最容易踩的坑之一。
3. 实操过程与核心环节实现
3.1 环境准备:JDK、Maven、Node.js、MySQL的版本搭配
在把项目跑起来之前,先把环境对齐。这套系统的版本是有讲究的,配错了轻则启动报错,重则依赖冲突出一堆看不懂的异常。
我建议的环境搭配是:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8(8u202及以上) | SpringBoot 2.x基于JDK8开发最稳定 |
| Maven | 3.6.3 | 与JDK8兼容性最好 |
| MySQL | 5.7或8.0 | 都支持,注意驱动版本就行 |
| Node.js | 16.x或18.x | 对应Vue CLI 4.x/5.x |
| Vue CLI | 4.5.13及以上 | 脚手架版本不要太旧 |
| IDEA | 2021.3+ | 建议使用专业版,社区版也够用 |
特别提醒一下MySQL 8.0的坑:8.0的默认密码插件是caching_sha2_password,而SpringBoot 2.x自带的MySQL驱动版本如果过低,连8.0数据库会报Public Key Retrieval is not allowed错误。解决办法有两个:
- 把驱动升级到
mysql-connector-java 8.0.x - 在连接串里加上
allowPublicKeyRetrieval=true&useSSL=false
源码里已经处理过这个问题,pom.xml中MySQL驱动的版本锁定在8.0.33:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>如果你用5.7的数据库,这个驱动也能兼容,不用改。
3.2 后端启动流程:0到1跑通SpringBoot
后端启动在IDEA里操作,顺便把application.yml里的关键配置也说清楚:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/course_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.course.entity configuration: map-underscore-to-camel-case: true jwt: secret: your_jwt_secret_key_here expire: 604800000注意三处:
serverTimezone=Asia/Shanghai必须加,否则MySQL 8.0会在日期处理上报时区错误。map-underscore-to-camel-case: true开启下划线转驼峰,这样数据库字段create_time能自动映射到实体类的createTime属性,不用每个字段都写@Result注解。- JWT密钥建议改成自己的随机字符串,别用源码里默认的,毕竟是从网上下的项目,默认密钥等于裸奔。
启动步骤就三步:
- 打开IDEA,
File -> Open选择course-backend目录 - 等待Maven自动下载依赖(第一次会比较慢,可以配置阿里云镜像加速)
- 找到
CourseApplication.java,右键Run即可
启动成功后控制台输出一行Started CourseApplication in 3.2 seconds(类似的信息),然后在浏览器访问http://localhost:8080/api/user/info,能看到JSON返回就说明后端已经活了。
3.3 前端启动流程:Node依赖安装与跨域调试
前端启动前先确认Node环境:
node -v npm -v然后进到course-frontend目录:
npm config set registry https://registry.npmmirror.com npm install用国内镜像源的原因不用多说,几十个依赖包从官方源下载,等得花儿都谢了。npm install完成后,执行:
npm run serve默认会启动在http://localhost:8081(如果8080被占会自动切换到8081)。启动完成后浏览器自动打开,能看到登录页面就算成功。
前后端分离的项目必然面临跨域问题。前端在8081端口,后端在8080端口,浏览器出于同源策略,会拦截前端发往8080的请求。
源码在SpringBoot后端做了跨域配置,Config包下有个CorsConfig.java:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这段配置的意思是:允许任意来源、任意方法的跨域请求。开发阶段这么配完全没问题,但上线前一定要收紧,只允许你自己的域名访问,否则等于给别人留了一把打开你API的钥匙。
3.4 用一个完整业务流程串联前后端:用户登录
我们通过“用户登录”这个最基础的流程,把前后端的协作逻辑完整过一遍,你就知道整套系统是怎么串起来的了。
第一步:前端发起请求
用户在登录页输入账号密码,点击登录按钮,Vue组件里调用登录接口:
// src/api/user.js import request from '@/utils/request'; export function login(data) { return request({ url: '/api/user/login', method: 'post', data }); }request.js里做了一件关键的事:从localStorage读取Token,附加到请求头:
// src/utils/request.js import axios from 'axios'; import { Message } from 'element-ui'; import router from '@/router'; const service = axios.create({ baseURL: '/api', // 配合vue.config.js中的代理 timeout: 10000 }); // 请求拦截器 service.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }); // 响应拦截器 service.interceptors.response.use( (response) => { const res = response.data; if (res.code === 200) { return res; } Message.error(res.message); return Promise.reject(new Error(res.message)); }, (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里的代理配置,开发环境下把请求转发到后端8080端口,从而绕开跨域限制:
// vue.config.js module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };第二步:后端校验逻辑
后端的登录接口在UserController里:
@PostMapping("/login") public Result<LoginDTO> login(@RequestBody LoginVO loginVO) { String username = loginVO.getUsername(); String password = loginVO.getPassword(); if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) { return Result.error(400, "用户名和密码不能为空"); } User user = userService.login(username, MD5Util.md5(password)); if (user == null) { return Result.error(400, "用户名或密码错误"); } if (user.getStatus() == 0) { return Result.error(403, "账号已被禁用,请联系管理员"); } // 生成Token String token = JwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole()); // 返回用户信息和Token LoginDTO loginDTO = new LoginDTO(); loginDTO.setToken(token); loginDTO.setUserinfo(user); return Result.success(loginDTO); }注意密码是MD5Util.md5(password),不是明文比对。虽然MD5在今天看来安全性偏弱,但对于毕设和中小系统已经够用,更稳妥的做法是加盐后再MD5,或者直接用BCrypt,这个看你的时间成本。
第三步:前端保存状态并跳转
登录成功后,前端把Token存到localStorage,同时通过Vuex保存用户信息,然后根据角色跳转到不同页面:
login(form).then(res => { localStorage.setItem('token', res.data.token); localStorage.setItem('userInfo', JSON.stringify(res.data.userinfo)); const role = res.data.userinfo.role; // 0-管理员,1-教师,2-学生 let redirectPath = '/student/home'; if (role === 0) redirectPath = '/admin/dashboard'; if (role === 1) redirectPath = '/teacher/home'; router.push(redirectPath); }).catch(() => { // 错误提示已由拦截器统一处理 });至此,一次完整的用户登录流程就串通了。其他业务模块的逻辑大同小异,套路都是前端发请求、后端做校验和数据处理、返回统一格式的JSON、前端渲染。
4. 常见问题与排查技巧实录
4.1 数据库连接失败问题
这是出现频率最高的问题,报错信息一般是:
Cannot create PoolableConnectionFactory (Access denied for user 'root'@'localhost')或者:
Communications link failure CommunicationsException: Communications link failure排查顺序从三方面走:
- 检查
application.yml里的用户名密码,确保和本地MySQL一致。MySQL的root密码经常被忘记,建议在MySQL 8.0里单独创建一个业务账号,而不是一直用root:CREATE USER 'course'@'localhost' IDENTIFIED BY 'course123'; GRANT ALL PRIVILEGES ON course_db.* TO 'course'@'localhost'; FLUSH PRIVILEGES; - 检查MySQL是否已启动。Windows下按
Win+R输入services.msc,找到MySQL80(或对应版本)服务,确认状态是“正在运行”。 - 检查端口。3306端口被占用时,连接也会失败。用
netstat -ano | findstr 3306查看谁占了3306,如果是其他程序占用,要么改MySQL的端口,要么改SpringBoot的连接串。
注意:如果MySQL控制台能登录但Java连不上,大概率是
serverTimezone参数缺失,加上就对了。
4.2 前端依赖安装报错
Node.js版本过高或过低都会导致一些依赖包安装失败。Vue CLI 5.x搭配Node 18.x基本没问题,但如果你用的是Node 21+,有概率碰到OpenSSL相关的ERR_OSSL_EVP_UNSUPPORTED错误。
解决方案是在package.json的scripts里加上:
"serve": "set NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve"Linux/Mac环境用:
"serve": "export NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve"这个问题的本质是Node 17+的OpenSSL默认策略变更,老版本Webpack用了不兼容的哈希算法。从根上解决的办法是锁定Node LTS版本(16.x或18.x),一劳永逸。
4.3 前端正常但接口404
前端页面能打开,但登录时报404,这是一个典型问题,多半是vue.config.js中的代理配置没有生效。
检查顺序:
- 确认前端是通过
npm run serve开发服务器启动的,而不是直接在浏览器里打开HTML文件。 - 确认
vue.config.js里proxy.target指向后端的地址端口,后端启动在8080,target就得是8080。 - 修改配置后必须重启前端服务,
Ctrl+C停掉再npm run serve,代理修改不会热更新。
还有一个常见原因:后端的Context Path设置。如果application.yml里配了:
server: servlet: context-path: /course那么所有接口路径都要加/course前缀,即/course/api/user/login,而前端代理还指向/api,就会404。这套源码默认没有配context-path,你如果自己加了记得同步改前端。
4.4 跨域问题处理不当
后端配了CorsConfig,前端也配了代理,但有时候仍然报跨域错误。这种时候要确认是不是走上了别的请求路径。
跨域错误的表现是浏览器控制台出现:
Access to XMLHttpRequest at 'http://localhost:8080/api/user/login' from origin 'http://localhost:8081' has been blocked by CORS policy最常见的原因是后端的allowedOriginPatterns("*")被拦截器截胡了。如果JWT拦截器在CorsConfig之前先处理了OPTIONS预检请求,就会导致跨域失败。
源码里已经处理了这个问题,在JwtInterceptor中放行了预检请求:
if ("OPTIONS".equals(request.getMethod())) { return true; }如果你自己改造时遇到类似问题,先加上这个放行逻辑再说。
4.5 表格问题速查
| 问题描述 | 报错关键字 | 解决方案 |
|---|---|---|
| 数据库连接失败 | Access denied | 核对账号密码,授权或重建用户 |
| 时区错误 | serverTimezone | 连接串加上serverTimezone=Asia/Shanghai |
| 端口被占用 | Port already in use | 修改后端server.port或前端devServer.port |
| 热门依赖下载失败 | NETWORK ERROR | 切换npm镜像源:npm config set registry https://registry.npmmirror.com |
| Maven依赖下载慢 | Could not transfer | pom.xml加阿里云镜像,或IDEA中配置Maven镜像 |
| 登录后接口403 | Forbidden | 检查Token是否过期,检查用户状态是否被禁用 |
| Vue页面白屏 | Cannot read properties of undefined | F12看Console报错,多半是API路径漏了或字段名不匹配 |
| 中文乱码 | 数据库插入后显示问号 | 建表库时指定utf8mb4字符集,连接串加characterEncoding=utf8mb4 |
| MySQL 8.0认证失败 | Authentication plugin | 给用户改成mysql_native_password:ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'; |
| 前端端口冲突 | Port 8081 is already in use | 修改vue.config.js的devServer.port |
4.6 从源码库里学到的三个实战技巧
这套系统虽然不算复杂,但有几个细节值得学习:
技巧一:分页参数封装
后端的分页接口都用了统一的PageVO对象:
public class PageVO<T> { private List<T> records; private Long total; private Integer current; private Integer size; }配合MyBatis的PageHelper插件,一行代码实现分页查询,前端接收这个结构后,配合Element UI的el-pagination组件刚好对得上。
技巧二:系统初始化数据
course_db.sql里预置了管理员账号(admin/admin123)、教师和学生账号,方便测试。但上线前务必删除初始测试账号,连密码带账号都改掉,这是安全底线。
技巧三:文件上传路径配置
视频上传功能里的文件保存路径在application.yml中配置:
file: upload-path: D:/course-upload/ access-path: /upload/**这类路径配置建议统一放在配置文件里管理,不要写死在代码中。换服务器时改一行配置就能迁移,不用全局搜索替换。
5. 后续扩展:从毕设项目到生产级系统的升级路线
如果停留在这个层面,它只是一个毕业设计。但如果你真想把这个系统用在真实教学场景,有几处要补强的地方:
第一个是密码加密升级。把MD5换成BCrypt算法,Spring Security已经内置了BCryptPasswordEncoder,改造起来成本不高,但安全性提升了一个量级。
第二个是接口限流。学生选课高峰期,大量并发打到后端,单机全靠数据库扛会用性能风险。建议在选课接口加Redis缓存+限流,用令牌桶或滑动窗口控制QPS。
第三个是视频点播优化。现在的实现是直接返回视频文件URL,大并发下带宽扛不住。可以对接阿里云OSS或腾讯云COS,利用CDN分发视频流量,这是真实场景下课程平台的标配方案。
第四个是日志巡检。给系统接入logback+ELK或至少用spring-boot-starter-log4j2把日志切分归档,不然出了问题连排查的入口都没有。
第五个是测试用例。补上JUnit单元测试和MockMvc接口测试,覆盖核心的登录、选课、课程管理业务流程。这套源码目前测试代码基本是空的,有精力的话建议补一补,面试时讲到这也是加分项。
这套系统从编码到调通再到部署,我前前后后跑了不下十遍,每一遍都能发现一些小问题。不是代码本身问题,而是环境的差异实在太大了——不同版本的JDK、Maven、MySQL之间互相掐架是常态。不过也正因为如此,调试过程中积累的这些经验,恰恰是你在面试和实际工作中最值钱的部分。希望这份拆解能帮你少走一些弯路,把时间花在真正需要打磨的业务逻辑和代码质量上。