网上在线学习平台Java源码:Spring Boot+MySQL部署实战与避坑指南

发布时间:2026/10/30 0:26:06
网上在线学习平台Java源码:Spring Boot+MySQL部署实战与避坑指南

简介:基于Java的网上在线学习平台设计实现项目,适合毕业设计、课程设计及初阶Java Web开发者参考,系统实现了学生注册与实名认证、教师作业发布与批改、学习资源管理、个人信息管理、资源上传下载等完整功能,覆盖学生、教师两类核心用户角色,可支撑在线教学场景的基本业务闭环。压缩包内共2000个文件,包括182个Java源码、149个JSP页面、311个JS脚本、240个CSS样式,以及大量PNG/JPG/GIF图片素材和SQL数据库脚本,总体积69.85MB,另附说明文档与部署文档,便于理解项目结构并快速启动。文件覆盖后端逻辑到前端展示的完整层次,其中SQL脚本可直接导入MySQL数据库,图片与样式资源为界面还原提供了充足素材。项目基于JDK1.8与MySQL5.7,兼容Eclipse和IntelliJ IDEA开发,演示版本可直观体验前后台完整操作。已有56人浏览学习,是掌握在线学习系统(LMS)从数据库设计到代码实现全过程的实用参考。

1. 网上在线学习平台的 Java 源码包:跑通它之前,先明白这几件事

昨天还有做毕设的同学问我:手里这套网上在线学习平台的 Java 源代码,带前后端页面、MySQL 脚本和说明文档,能不能直接照着跑。每年这时候类似的提问我都要回几轮,因为这类 zip 包表面看是一个完整项目,实际卡人的地方从来不在业务逻辑,而在把 MySQL 装对、把库表初始化对、把前后端联调通。所谓“完整前后端 + mysql + 说明文档 + LW”,拆开看就是一套 Spring Boot 后端,配一套 HTML/JS 页面,再用 MySQL 存课程、用户、学习记录这些数据,LW 和说明文档用来支撑答辩和二次开发。适合谁?正在做毕设的学生、刚入职想找完整案例练手的初级开发,以及小团队想快速搭一个教学系统做原型的人。这篇文章按我自己的落地顺序,从架构拆解讲到避坑,再讲到能带着改上线的部分。

2. 先把“完整前后端”拆清楚:Java 后端、页面与 MySQL 表结构怎么分工

很多同学拿到源码第一反应是找 Vue 或 React 工程,结果翻遍目录没找到package.json,就开始怀疑发错了包。这里要先纠正一个预期:这类教学项目的“完整前后端”,通常不是前后端分离,而是后端渲染或静态页面混部。理解这一点,后面所有配置和部署逻辑才顺。

2.1 不是前后端分离:先分清静态页面和后端接口的边界

常见做法是把页面文件放在src/main/resources/static目录下,Java 代码负责提供/api/**接口,前端页面通过 Ajax 或 Fetch 调这些接口拿数据。整个工程打成一个 jar 包,java -jar起来后,浏览器直接访问端口就能看到登录页和课程列表页,不需要额外启动前端服务。

这里有一个很实用的判断方法:解压后看目录里有没有src/main/resources/static/index.html或templates目录。有,就是混部模式;只有src/main/java和src/main/resources里的application.yml,没有任何页面文件,才是纯后端。混部模式的好处是部署成本低,适合毕设答辩和内部演示环境;坏处是如果以后要拆小程序或 App,需要把接口层单独抽出来重做一套。对学习平台这个场景,混部模式完全够用。

2.2 用户、课程、选课记录:MySQL 核心表的建表思路

数据库设计是这类项目的灵魂,比页面样式重要得多。我一般会先建一张用户表,一张课程表,一张学习记录表。课程表里核心字段是课程名称、封面图路径、讲师、价格、上下架状态;学习记录表要记录学员 ID、课程 ID、最后观看位置、总时长、更新时间。下面是一份可以直接用的初始化 SQL 片段:

CREATE DATABASE IF NOT EXISTS online_learning DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE online_learning; CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT 'MD5或BCrypt密文', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0-学员 1-教师 2-管理员', `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB; CREATE TABLE `study_progress` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `course_id` INT NOT NULL, `position_second` INT NOT NULL DEFAULT 0 COMMENT '观看进度,单位秒', `duration_second` INT NOT NULL DEFAULT 0 COMMENT '视频总时长', `updated_at` DATETIME NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_course` (`user_id`, `course_id`) ) ENGINE=InnoDB;

逻辑说明:用户表用唯一索引锁住用户名,防止重复注册;学习记录表把(user_id, course_id)做成联合唯一键,这个唯一键是后面做“断点续看”功能的基础,也是防止重复插入学习记录的兜底手段。字符集用 utf8mb4 而不是 utf8,是因为课程简介和教师名字里可能出现 emoji 或特殊符号,utf8 存 emoji 会直接报错。这里我刻意没建物理外键,只用普通索引做逻辑关联。教学项目禁止用物理外键,因为代答辩时往表里插测试数据,有外键约束只会多出一堆顺序上的麻烦;再说 MyBatis 里业务逻辑已经控制了关联关系,外键的唯一作用就是给自己添堵。AUTO_INCREMENT 从 1000 开始是我个人习惯,方便手工插入一批测试账号时一眼区分。

2.3 从登录到课程列表:一次请求在后端里走了哪几站

弄清楚请求链路,是读懂这套源码最快的路径。以课程列表为例,浏览器访问index.html,页面里的fetch('/api/course/list')会把请求发到 Spring Boot 的CourseController,Controller 调CourseMapper的方法,Mapper 再拼 SQL 查 MySQL,查询结果依次往回传,最后在页面渲染成课程卡片。代码一般是这个结构:

@RestController @RequestMapping("/api/course") public class CourseController { private final CourseMapper courseMapper; public CourseController(CourseMapper courseMapper) { this.courseMapper = courseMapper; } @GetMapping("/list") public Result list() { return Result.ok(courseMapper.listOnlineCourses()); } }
@Mapper public interface CourseMapper { @Select("SELECT * FROM course WHERE status = 1 ORDER BY created_at DESC") List<Course> listOnlineCourses(); }

这里有两个细节值得关注。第一,Controller 用构造器注入CourseMapper,而不是直接@Autowired字段注入,这是新版 Spring 更推荐的写法,好处是单元测试时容易换 mock 对象,也防止出现循环依赖。第二,@Select注解直接写在 Mapper 接口上,属于轻量写法;如果源码里用的是 XML mapper,不要慌,两者只是配置位置不同,SQL 逻辑完全可以对照着看。登录链路也类似,区别在于登录成功后后端会在 Session 里写入用户 ID,后面访问/api/**时由拦截器检查 Session 是否存在。教学项目用 Session 足够,生产环境再考虑换 JWT,这里不展开。

3. 在本地把 MySQL 和 Spring Boot 跑通:安装、初始化与启动命令

讲完结构,接下来是动手环节。这一章的目标只有一个:让项目在你自己电脑上跑起来。我会按“装 MySQL → 导入 SQL → 改配置 → 启动”四步走,每一步都不绕。

3.1 MySQL 5.7/8.0 的安装与最稳的启动方式

MySQL 版本选择是个现实问题。我看到网上大量安装教程还在讲 5.7.44,但新下的源码很多已经按 MySQL 8.0 的驱动写法来配了。我的建议很简单:源码 SQL 脚本里如果没写特别老的语法,直接用 MySQL 8.0 的 MSI 安装包,一路 Next 就能装好;如果你在 Linux 服务器上部署,用系统自带的包管理器装 8.0 也一样。Windows 装完后,服务名一般叫mysql或MySQL80,启动命令是:

# 启动 MySQL 服务(Windows) net start mysql # 如果服务名是 MySQL80 net start MySQL80 # 如果启动失败,先看错误日志 tail -n 50 "C:/ProgramData/MySQL/MySQL Server 8.0/Data/hostname.err"

参数说明:net start后面跟的服务名要和“服务管理器”里显示的名称完全一致,大小写不敏感,但空格和多出来的数字错了就会报“服务名无效”。MySQL 8.0 安装时如果没有自动初始化数据目录,会报“服务无法启动”,你需要在安装目录下手动执行mysqld --initialize-insecure,这条命令会生成一个 root 空密码的实例,等服务起来后再自己ALTER USER改密码。用 Docker 装 MySQL 是另一种常见做法,但新手经常在“容器起来了但连不上”这件事上翻车,原因多半是端口映射漏了-p 3306:3306,或者挂载目录权限不对导致容器反复重启。我的建议是本地调试直接用本机安装版,别在这个环节同时引入 Docker 和容器网络两层不确定性。

3.2 执行 SQL 脚本:导入数据最稳的三行命令

源码包里的 SQL 文件一般是online_learning.sql或db/init.sql,里面通常包含建库、建表和 INSERT 测试数据。导入前先确认 MySQL 服务已经起来,然后打开命令行执行:

# 进入 MySQL 客户端 mysql -uroot -p # 在客户端里执行(注意路径不要带中文) source D:/online_learning.sql; # 查看导入结果 USE online_learning; SHOW TABLES;

逻辑说明:source是 MySQL 客户端内置命令,和mysql < file.sql等效,但source的好处是执行过程中如果有语法错误,会继续往下跑,不会像重定向输入那样整体中断。导入完成后,重点检查三张表:course表有没有课程数据,user表有没有测试账号,study_progress表是否为空表。如果中文乱码,多半是客户端连接字符集不是 utf8mb4,在登录命令后面加--default-character-set=utf8mb4再重新导入。这一步别跳过测试数据,后面联调登录和课程列表都要靠它们撑场面。

3.3 改连接配置并启动项目:application.yml 里那三处必改项

Spring Boot 的数据库连接全在application.yml(或application.properties)里。你不需要读懂每一行,只需要盯住三个地方:URL、用户名、密码。改错任何一个,启动时都会报连接失败,但报错信息不一样,可以靠这个快速定位。

spring: datasource: url: jdbc:mysql://localhost:3306/online_learning?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver server: port: 8080

参数说明:useSSL=false是新手最容易漏的一项,MySQL 8.0 默认开 SSL 校验,不关掉会报SSL connection error,本地开发完全没有加密必要,直接关。serverTimezone=Asia/Shanghai不写的话,8.0 驱动会把数据库时区当成 UTC,查出来的时间比本地时间少 8 小时,课程创建时间全错位。driver-class-name在 8.0 驱动下必须写com.mysql.cj.jdbc.Driver,写老的com.mysql.jdbc.Driver会直接启动失败。改完后在项目根目录执行:

mvn spring-boot:run

看到Started字样就说明起来了,浏览器访问http://localhost:8080,能出现登录页就说明前后端资源加载正常。如果mvn命令不存在,先检查 JDK 版本,这类教学项目多数要求 JDK 8 或 11,用 17 以上跑老项目会遇到一堆依赖版本不兼容的怪问题。

4. 常见问题排查:从 MySQL 启动失败到排序错乱的 5 个案例

跑通只是开始,联调和测试阶段才会集中踩坑。下面这几条是我在帮人看这类项目时反复见到的,每一条都按“现象 → 原因 → 解决”的顺序写,可直接对着排查。

4.1 net start mysql 提示服务无法启动

现象:执行net start mysql时提示“服务无法启动”,但服务列表里确实有这个服务。打开 Windows 事件查看器,能看到 MySQL 相关的报错记录。

原因:最常见的有三种。第一种是 MySQL 数据目录没有初始化,安装过程被中断或手动拷贝过数据文件;第二种是my.ini配置文件里的basedir或datadir路径写错,MySQL 找不到目录自然起不来;第三种是端口被占用,常有旧版 MySQL 或 Navicat 自带的 MariaDB 抢占了 3306。

解决:先看错误日志,日志路径在my.ini里datadir指定的目录下,文件名类似主机名.err。日志里写Can't start server: Bind on TCP/IP port就是端口冲突,用netstat -ano | findstr 3306找到占用进程并结束它。日志里写Can't find error-message file则是路径配置问题,逐字符核对basedir和datadir。什么都没写就退出,保险做法是备份datadir下所有文件,然后删掉目录重跑mysqld --initialize-insecure,虽然会丢数据,但能让服务先起来。

4.2 MySQL 8.0 的 SSL 连接错误

现象:Spring Boot 启动时报错,关键字是SSL connection error或Public Key Retrieval is not allowed。

原因:MySQL 8.0 默认启用 caching_sha2_password 认证,且服务端默认要求 SSL。Java 驱动和它握手的时,如果连接串没关 SSL,就会卡在密钥交换环节。Public Key Retrieval is not allowed是 8.0 驱动在非 SSL 连接下尝试获取 RSA 公钥时的额外限制。

解决:连接串里加useSSL=false&allowPublicKeyRetrieval=true。这两个参数配合使用,前者关掉 SSL 加密,后者允许客户端获取公钥完成认证。改完后重启项目,如果还报错,检查驱动包版本是不是mysql-connector-java8.0.x,老的 5.1.x 驱动连 8.0 服务端会有兼容问题,需要升到 8.x。

4.3 课程按价格或时间排序结果不对:MySQL 排序与 OR 查询的坑

现象:课程列表页按“最新”排序,但新上架的课程排在老课程后面;或者在筛选条件里写WHERE teacher_id = 1 OR course_type = '必修'时,结果出现重复行,而且排序是乱的。

原因:第一类问题的根子是 MySQL 把created_at当成了字符串排序。VARCHAR 类型存的日期会按字典序排,"2024-09-02"和"2024-09-01"比较没问题,但一旦混入了"2024-9-02"这种格式,字典序就会出错。第二类问题出在 OR 查询本身,MySQL 的 OR 不会自动去重,teacher_id = 1 OR course_type = '必修'如果同一门课同时满足两个条件,就会出现两行。

解决:第一类用ORDER BY created_at DESC前提是字段类型必须为 DATETIME,如果库里已经存成 VARCHAR,先ALTER TABLE course MODIFY created_at DATETIME NOT NULL,再排序就稳了。第二类把 OR 改成 UNION,或者SELECT DISTINCT。曾经有同学问“MySQL 的 OR 能去重吗”,答案是不能,去重要靠 DISTINCT 或改写语句。这个坑在面试里也常被问,记住结论:OR 只负责条件,不管去重。

4.4 按钮重复提交导致选课记录重复插入

现象:用户快速点了两次“选课”按钮,study_progress表里同一个用户对同一门课出现两条记录;更严重的是订单表里出现重复订单,金额对不上。

原因:前端按钮没有防抖,后端接口也没有做幂等校验。用户手速快,两次 HTTP 请求都到了后端,后端每次都执行了 INSERT。

解决:前端先把按钮置灰,这是成本最低的防线:

function submitEnroll() { const btn = document.getElementById('enrollBtn'); btn.disabled = true; // 立即禁用,防止二次点击 fetch('/api/course/enroll', { method: 'POST' }) .then(res => res.json()) .then(data => { // 业务处理 }) .finally(() => { btn.disabled = false; // 请求结束后恢复,允许重新操作 }); }

后端再补一道拦截,查询study_progress表里(user_id, course_id)是否已存在,存在就返回“已选课”。我在 2.2 里特别强调的联合唯一键uk_user_course在这里就是最后一道保底,就算前端后端都没拦住,数据库也会把第二条 INSERT 直接拒绝,而不是产生脏数据。

4.5 导入 SQL 脚本中文乱码

现象:source导入后,前端页面课程名称显示成???或一堆乱码,但英文正常。

原因:SQL 文件本身是 UTF-8 编码,但 MySQL 客户端连接时用的默认字符集不是 UTF-8,导致数据写入表里时已经被转成了错误编码。这一步数据已经脏了,改连接参数只是让显示正常,救不回已写入的数据。

解决:导入前先确认 SQL 文件编码。用 Notepad++ 或 VS Code 打开文件,右下角显示 UTF-8 就对了,显示 GB2312 就先另存为 UTF-8。然后在导入命令里强制指定字符集:

mysql -uroot -p --default-character-set=utf8mb4 < online_learning.sql

如果是已经导入过的脏数据,最干净的做法是DROP DATABASE online_learning;再重新建库导入。在导入这种事上,不要指望 ALTER TABLE 改 charset 能把已损坏的数据恢复,那些???是老早之前就被丢弃的字节,改编码救不回来,只有重导一条路。

5. 从“能跑”到“能答辩/能上线”:学习进度、选课与 MySQL 调优

项目跑通之后,接下来就该考虑功能补全和性能问题了。这一章不讲炫技,只讲三个最常被问到的改造点。

5.1 给学员端加“我的课程表”:一次典型的后端改动

原项目里“课程列表”通常是公开的,点进去就能看视频。但一个像样的学习平台必须有“我的课程”概念,也就是学员选课后才能在“我的课程表”里看到这门课。这个改动需要动三处:数据库加选课表、Mapper 加关联查询、页面加一个 Tab。

选课表结构不用重新设计,直接复用study_progress表,因为选课的本质就是“建立用户和课程的关联”。查询某学员的课程表时,用 JOIN 把课程信息带出来:

SELECT c.id, c.title, c.cover_url, c.teacher_name FROM study_progress sp JOIN course c ON sp.course_id = c.id WHERE sp.user_id = #{userId} ORDER BY sp.updated_at DESC;

逻辑说明:这个 JOIN 查询的核心是sp.user_id做过滤条件,然后用sp.updated_at排序,实现“最近在学的课程排前面”。#{userId}是 MyBatis 的占位符写法,它会自动做参数转义,比拼字符串安全。加完这步后,前端“我的课程表”只需要调/api/course/myList接口,传当前登录用户 ID 即可。如果我拿到手源码的登录逻辑里有把用户 ID 存 Session,接口里就用session.getAttribute("userId")拿,不要相信前端传过来的用户 ID,这个习惯能从根上防越权访问别人课程表。

5.2 用事务和 upsert 保存学习进度:避免先查再改

学习进度保存是个高频写操作,如果写成“先 SELECT 查进度,再 UPDATE 更新”,高并发下会产生竞态条件,而且每次做两次 SQL 查询。更严重的是,两个请求同时查到旧值,后提交的那个会把先提交的覆盖掉,这也就是常说的“更新丢失”。解决这个问题,MySQL 有更直接的办法:INSERT ... ON DUPLICATE KEY UPDATE,也就是 upsert 语法。

INSERT INTO study_progress (user_id, course_id, position_second, duration_second, updated_at) VALUES (#{userId}, #{courseId}, #{positionSecond}, #{durationSecond}, NOW()) ON DUPLICATE KEY UPDATE position_second = VALUES(position_second), duration_second = VALUES(duration_second), updated_at = NOW();

参数说明:ON DUPLICATE KEY UPDATE触发条件是表里有唯一键冲突。我们在 2.2 里定义的uk_user_course (user_id, course_id)在这里起了决定性作用——同一用户同一课程只会保留一行,第二次上报走的是 UPDATE 分支而不是新增。VALUES()函数取值来自本次 INSERT 语句,这样就把“查一次再改一次”缩成了“一条语句搞定”,省了往返,也天然处理了并发冲突。这个方法同样适用选课、收藏、点赞这类场景,比逻辑代码里if (exists) update else insert简洁得多。至于 MySQL 事务,Spring Boot 里只要在 Service 方法上加@Transactional注解,多条写操作就会自动包进同一个事务里。需要注意的是,不要在这个场景里引入存储过程来做判断,业务逻辑放在服务层比放在数据库里更容易调试和迁移。

5.3 上线前必看的三个 MySQL 参数:索引、连接池、慢查询

本地跑通和部署上线是两回事。上线前我会固定做三件事:检查全表扫描、改连接池参数、开慢查询日志。

索引的问题最隐蔽。原项目如果比较小,课程表可能连索引都没建,数据量到几千条时查询还能忍,到几万条时课程列表接口会明显变慢。我一般直接看 Mapper 里 WHERE 和 ORDER BY 涉及的字段,为它们补上索引:

ALTER TABLE course ADD INDEX idx_status_created (status, created_at); ALTER TABLE study_progress ADD INDEX idx_user_course (user_id, course_id);

逻辑说明:第一条索引同时覆盖“查询上架课程”和“按创建时间排序”两个场景,因为索引最左前缀原则,status 和 created_at 的顺序不能反;第二条索引其实已经被uk_user_course覆盖了,这里写它只是为了强调:唯一索引也同时是普通索引,别建重复索引浪费磁盘。连接池参数里最值得调的是maximum-pool-size,教学项目默认 10,本地够用;部署到服务器后,按CPU核心数乘以2再加磁盘数这个经验公式估,四核机器我一般设 20 左右。再配合慢查询日志,把超过 1 秒的 SQL 捞出来看,基本能把 90% 的性能问题定位到位。

6. 进阶技巧:学习进度断点续看,前后端各要改什么

很多在线学习平台只做到了“学完打勾”,但用户真正的痛点是:上一节课看到 23 分钟,下次打开得从头拖进度条。断点续看不难,难在做得不烦人——既不能每秒钟都往数据库写一条记录,也不能完全靠用户手动点“保存进度”。我这里给的是我认为最稳的增量上报方案。

前端思路是:视频播放过程中,每 5 秒采集一次当前播放位置,把它保存在一个全局变量里,同时向后端上报一次进度。注意,上报内容不是“累计看了多久”,而是“这次请求时的播放位置”。后端拿到后用 5.2 的 upsert 语句更新study_progress表。用户再次进入课程时,前端调进度查询接口,拿到position_second值,赋给播放器的currentTime就完成了续看。

核心代码如下:

let reportTimer = null; let lastPosition = 0; video.addEventListener('timeupdate', () => { lastPosition = Math.floor(video.currentTime); // 只存不报,节流 }); video.addEventListener('play', () => { reportTimer = setInterval(() => { fetch('/api/progress/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ courseId: currentCourseId, positionSecond: lastPosition }) }).catch(() => {}); // 上报失败不打断播放 }, 5000); }); video.addEventListener('pause', () => { clearInterval(reportTimer); // 暂停即停,避免无效上报 fetch('/api/progress/save', { // 暂停瞬间再补一次 method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ courseId: currentCourseId, positionSecond: lastPosition }) }).catch(() => {}); });

这段代码里有几个细节是我自己踩过坑后加上的。timeupdate在播放时触发的频率很高,每秒钟能触发四到五次,不能在里面直接发网络请求,不然数据库会被写穿。用setInterval固定 5 秒上报一次,是播放器类项目常用的节流手段,既能让服务端拿到足够新的进度,又把写库频率控制在每分钟 12 次左右。catch(() => {})里故意把错误吞掉,是保证网络抖动时不会弹错误提示打断用户看视频,进度丢 5 秒的数据完全能接受。暂停时补报一次,是防止用户看完就关页面、最后一次进度还停留在 5 秒前。课程继续播放时,从study_progress表把position_second读出来,传给播放器设置currentTime,断点续看就闭环了。

我第一次做这个功能时,图省事用setInterval每秒上报,结果结课高峰时 MySQL 的负载直接翻了一倍,被运维同事狠批了一顿。后来才换成 5 秒节流加暂停补报的方案,写库量降了五倍,用户体验反而没差别。“宁可少报,不可报死”,这个教训我记到现在。希望帮到你。

本文还有配套的精品资源,点击获取