前后端分离的在线教育项目,我平时接触不少,但能让人放心拿去二次开发的完整源码其实不多。最近花了两个周末把一套自己维护的课程管理系统重新梳理了一遍,技术栈就是标题里那套:SpringBoot+Vue+MyBatis+MySQL。从数据库设计到权限控制,从视频点播到订单流程,该有的模块基本都齐了。这篇博客就当一次完整的源码复盘,把架构思路、核心实现、还有我在开发过程中踩过的坑一并记录下来,想用这套源码起步做毕业设计或者企业内部培训平台的朋友,可以直接照着落地。
整个系统设计为三类角色:学员、讲师、管理员。学员端主要完成选课、学习、记录进度、考试;讲师端负责课程创建、章节维护、题库管理;管理员则管用户、管分类、管订单。围绕这三大角色,系统拆解出课程模块、用户模块、订单模块、学习记录模块、考试模块、数据统计模块。下面先把模块划分和设计取舍讲清楚,然后逐层拆开讲后端和前端的关键实现,最后把部署细节和排坑经验一次性说完。
1. 课程系统的核心需求与模块划分
1.1 企业在线课程管理到底在管什么
很多刚入门的朋友对"课程管理系统"的认知停留在"上传视频、展示课程列表"这个层面,实际做起来才发现完全不是那么回事。一个能真正用于企业培训或在线教育的系统,核心要解决的是这样几件事:课程内容的结构化组织、学员学习行为的过程记录、教学效果的评估反馈、以及围绕课程产生的商业或管理流程(比如订单、权限)。课程本身需要有多级分类,可以挂载章、节,视频、文档、测验都要能附在课程结构中。学员学习完一章后系统要记录完成状态,这样下次登录才能续学。讲师要能看到自己名下课程的学习数据,管理员要能控制整个平台的运行状态。这套关系捋清楚之后,模块划分自然就清晰了。
1.2 三类用户角色的功能边界
用户模块我用了一张sys_user表承载所有登录账号,用角色字段区分身份。学员端的功能集中在课程浏览、课程详情、学习页、个人中心;讲师端有课程管理、章节管理、学生列表、数据统计;管理员端则是用户管理、课程审核、分类管理、订单管理。前端通过动态路由控制页面权限,后端通过拦截器校验接口权限,双端都做了控制,避免有人绕开前端直接调接口。这里我特意没有引入太重的权限框架,像 Shiro、Spring Security 都没用,而是自己用拦截器配合 JWT 做的方案。原因在后面讲认证的时候详说。
角色权限的划分不能只停留在"能不能进某个页面"这个级别,更细的粒度要落到数据层面。比如讲师 A 登录后只能看到自己创建的课程,管理员能看到全部。这个需求听起来简单,实际设计时容易忽略。我在后端的查询逻辑里没有把所有课程都查出来再在前端过滤,而是在 SQL 层就根据当前用户角色拼接了条件。管理员查全表,讲师查询条件自动带上teacher_id,这样既保证数据隔离,也避免了一张大表全量查出来再筛选的性能浪费。
1.3 课程模块的树形结构设计
课程和章节我拆成了两张表:course和course_section。课程表记录标题、封面、分类、价格、状态、讲师 ID,章节表通过course_id关联主表,再用parent_id自关联实现章和节的层级。这样的设计好处是,课程详情页要展示左侧章节目录时,只需要查一次course_section,在内存中组装成树形结构返回前端,数据库查询压力很小。
刚开始我也考虑过直接用level字段区分章和节,一张表维护,但后来发现parent_id方案更灵活。如果以后要支持"节下面再加小节"这种更深的层次,树形结构不需要改表。组装树的时候要注意排序,我在表里加了sort字段,同一层级下按照sort升序排列。前端 Element Plus 的 el-tree 组件直接吃树形数据,组装逻辑放在后端 Java 代码里完成,前端拿到直接渲染,整体链路挺顺的。
1.4 订单模块中的课程与交易
订单功能我这边做成的是"课程下单 + 模拟支付 + 订单状态流转"这套逻辑。orders表记录订单号、课程 ID、用户 ID、金额、状态、创建时间。用户提交订单时先生成待支付状态,调用支付接口后改为已支付,同时往user_course表里插入一条购买关系记录。这个购买关系表很关键,它是学员"我的课程"列表的数据来源,也是判断用户有没有权限学习某门课程的依据。
我在学习权限校验这块踩过不少坑。最开始设计时,前端按钮是通过v-if判断课程是否免费来控制是否展示"立即学习",后来测试发现人只要知道课程详情接口的 URL,就能直接绕过前端把学习页面的数据调出来。后来我在后端视频播放和课程详情接口里统一加了校验逻辑:先查当前用户是否拥有该课程(免费课程默认拥有),没有购买记录直接返回 403。权限判断这事,前端只是提升体验,真正拦截必须放在后端接口层。
2. 技术选型背后的思考:为什么还是这套组合
2.1 SpringBoot 在项目中的实际定位
SpringBoot 在这套系统里承担的是应用框架的职责,内嵌 Tomcat、自动配置、Starter 机制能把大量样板配置消掉。我选 SpringBoot 一个很直接的原因是版本生态稳定,网上资料多,团队协作时新成员上手成本低。我使用的是 2.7.x 版本,没有直接冲 3.x,主要原因在于 3.x 对 JDK 版本有硬性要求(至少 JDK 17),而当前很多企业内部服务器还停留在 JDK 8 环境。从部署兼容性考虑,2.7.x 配 JDK 8 是最稳的组合。这个选择建议做企业项目的朋友重点关注,技术选型不全是为了用新,而是要保证交付环境能跑起来。
2.2 Vue 在前端项目中到底解决了什么问题
前端部分我选择的 Vue 2.7 + Element UI 的组合。可能有人会问为什么不用 Vue 3,原因很简单:Vue 2.7 是 Vue 2 的最后一个版本,它对 Composition API 有了部分支持,同时保留了 Options API 的习惯,Element UI 组件库又非常成熟,项目启动快、踩坑少。对于课程管理这类中后台系统,重要的不是框架本身多新,而是开发效率、组件生态、资料丰富度。Vue 的响应式数据绑定和组件化开发模式非常适合课程列表、视频学习页、管理后台这类交互复杂的场景。
前端的目录结构我按视图层、路由层、状态层来组织。view 目录下分student、teacher、admin三个子目录,对应三种角色页面;router 里配置动态路由表,用户登录后根据角色信息只注册对应权限的路由;Vuex 里维护用户信息、token、购物车等全局状态。Element UI 按需引入,避免打包体积过大。Axios 封装了统一的请求实例,请求头自动携带 token,响应拦截器统一处理错误码。
2.3 MyBatis 在数据访问层的优势
选 MyBatis 而不是 JPA 的原因很实际:这套系统里有大量的多表关联查询、动态条件筛选、统计报表 SQL,MyBatis 允许我直接编写和控制 SQL 语句,性能瓶颈出现时可以精确定位到某一条 SQL。JPA 虽然开发效率高,但一旦遇到复杂查询,要么拼 JPQL,要么落到原生 SQL,反而绕了一圈。MyBatis 的 XML 映射文件提供了很高的灵活性,<if>、<where>、<foreach>这几个动态标签用得熟练之后,多条件组合查询写起来非常顺手。
MyBatis 的缓存机制我也单独配置过。本地缓存默认开启,二级缓存我是在订单表和课程表这种读多写少的查询上开的。需要注意的是,如果一张表涉及频繁的增删改操作,开二级缓存反而容易出问题,容易读到脏数据。我在course表的查询上开了二级缓存,但learning_record表学习进度更新的场景里关闭了缓存,每次都查数据库。这类微调表面上不起眼,实际对生产环境的影响很大。
2.4 MySQL 选型与基本配置要点
数据库这块用的是 MySQL 8.0,存储引擎 InnoDB,字符集utf8mb4。现在很多教程还在用utf8,我建议直接换成utf8mb4,因为它完整支持 emoji 表情和生僻字。之前遇到过一个用户昵称带特殊符号导致插入失败的案例,排查到最后就是字符集的问题。数据库连接池用的 Druid,配置了初始连接数、最大活跃数、连接超时时间等参数。事务隔离级别保持默认的REPEATABLE_READ,在订单创建的场景下配合悲观锁(SELECT ... FOR UPDATE)防止超卖。
3. 数据库表设计与 MyBatis 落地细节
3.1 核心表的物理结构
建表时我遵循了几个原则:主键统一用bigint自增,业务唯一标识(比如订单号、课程编号)单独用varchar存,不让主键承担业务含义。每张业务表都带上create_time、update_time两个时间字段,后面做数据统计和问题排查都用得上。逻辑删除字段deleted设为tinyint,默认 0,删除时走 UPDATE 而不是 DELETE。
课程表course的字段设计比较有代表性:除了基本信息,我放了status字段(0 草稿、1 上架、2 下架),cover_url存封面地址,price用decimal(10,2)存价格,teacher_id关联讲师用户 ID。为什么不用字符串存价格?因为涉及到排序和统计,数值类型才能用SUM、AVG这些聚合函数直接算。分类字段category_id关联分类表,分类表用自关联结构支持二级分类。
章节表course_section的字段包括course_id、parent_id、title、video_url、sort、duration。这里duration存视频时长,单位是秒,前端展示时再格式化,避免在前端做复杂的换算。
用户表sys_user里除了账号密码,我额外设计了avatar、phone、status三个字段。status用于拉黑用户,管理员后台可以将某个用户禁登录,拦截器里每次请求都会校验这个状态字段。密码存储用的是 BCrypt 加密,Spring 的BCryptPasswordEncoder可以直接用,不要用 MD5 存密码,这一点虽然老生常谈,但我在很多毕设源码里还是看到 MD5 裸存的情况,安全问题不要省。
3.2 复杂查询中的动态 SQL 应用
课程列表页是典型的多条件筛选场景:按关键字模糊搜索、按分类筛选、按价格区间筛选、按上架状态筛选。这些条件组合起来非常多,用 MyBatis 的动态 SQL 可以优雅解决。我在 XML 里写的查询大致如下:
<select id="selectCourseList" resultType="com.example.entity.Course"> SELECT * FROM course <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY create_time DESC </select>这段 SQL 的精妙之处在于<where>标签会自动去掉第一个条件下多余的AND,避免出现WHERE AND title这种语法错误。>和<是 XML 中的转义写法,直接写>会被 XML 解析器认为是标签结束符,这个细节很容易忽略。
学习记录表learning_record的查询还需要按用户、按课程、按章节做联合条件。我在 XML 里用<resultMap>做了自定义映射,解决数据库字段名下划线(course_id)和 Java 属性驼峰命名(courseId)之间的转换问题。虽然 MyBatis 有全局的map-underscore-to-camel-case配置可以自动转换,但使用 resultMap 时如果多个表联查,字段冲突的场景还是要手动指定映射列,更可控。
3.3 批量插入与事务控制
课程章节批量保存的功能,我用到了 MyBatis 的<foreach>标签:
<insert id="batchInsertSections"> INSERT INTO course_section (course_id, parent_id, title, video_url, sort) VALUES <foreach collection="list" item="item" separator=","> (#{item.courseId}, #{item.parentId}, #{item.title}, #{item.videoUrl}, #{item.sort}) </foreach> </insert>批量插入时要注意 MySQL 对单条 SQL 语句长度有限制,默认max_allowed_packet是 64MB,一般业务数据量根本到不了上限,但如果一次性导入上千个章节,还是建议分批(每批 500 条左右)。批量操作的方法需要加@Transactional事务注解,一旦中间某条插入失败,整个批次会回滚,不会出现部分数据写入的脏状态。
关于事务还有一点值得提:我在这套系统里把事务边界控制在 Service 层,不在 Controller 加事务注解。因为 Controller 做的是参数接收和响应封装,真正涉及多表操作的业务逻辑都在 Service 层。如果你在 Controller 层直接调用多个 Mapper 方法且不加事务,中途某个步骤出错时,前面的写操作不会回滚,数据一致性就崩了。
3.4 统计报表的 SQL 实践
管理后台的数据统计模块需要展示课程总数、学员总数、订单总金额、每日新增用户数等指标。多表联查的聚合 SQL 在这种场景里非常关键。比如统计每门课程的选课人数:
SELECT c.id, c.title, COUNT(uc.id) AS student_count FROM course c LEFT JOIN user_course uc ON c.id = uc.course_id WHERE c.deleted = 0 GROUP BY c.id, c.title ORDER BY student_count DESC这里用LEFT JOIN而不是INNER JOIN,目的是让没被选过的课程也出现在结果里,选课人数显示为 0。这种报表数据我用一个独立的StatisticMapper去查,避免和业务 CRUD 混在一起。如果数据量大,后续考虑单独建统计表,定时任务去刷新汇总数据,但现阶段直接查也没问题。
4. 后端 SpringBoot 核心模块实现
4.1 基于 JWT 的认证与拦截器权限控制
认证方案我最终选择了 JWT + 自定义拦截器,而不是引入 Spring Security 全家桶。原因有两方面:一是这个系统的角色模型比较固定,只有三种角色,用注解或拦截器判断角色编码即可,Spring Security 的过滤器链在这里显得有些重;二是 JWT 无状态,后端不需要维护 session,对部署多个实例做负载均衡的场景很友好。
整体流程是这样:用户提交账号密码,后端校验通过后生成一个 token,里面包含用户 ID、用户名、角色编码,用 HMAC-SHA256 算法签名,有效期设置 24 小时。登录接口返回 token,前端把它存到 localStorage,每次请求在 Axios 拦截器里把 token 放到请求头Authorization字段。后端拦截器解析 token,把用户信息放入 ThreadLocal,供后续 Controller 使用。自定义注解@RequireRole("teacher")加在需要特定角色访问的 Controller 方法上,拦截器用反射获取注解并校验角色。
拦截器实现的代码核心是继承了HandlerInterceptor接口的preHandle方法,但有几个细节必须注意。白名单路径要放行登录接口、注册接口、课程列表查询接口、课程详情接口、视频播放接口,因为用户未登录也要能浏览课程。除了登录相关的接口,静态资源路径也要放行,否则前端打包后的 index.html 都访问不了。还有跨域问题,如果前端项目和后端项目分离部署,必须在 SpringBoot 里配置跨域过滤器,开发环境我用@CrossOrigin局部注解解决,更省事。
4.2 视频上传与播放方案(m3u8 切片)
在线课程系统的重头戏在视频模块。课程视频的存储和播放,我采用了本地文件存储 + m3u8 切片播放的方案。这里有必要多说几句,很多视频播放卡顿问题都是格式选择没做对。原视频文件不适合直接通过 HTTP 拉流播放,尤其 mp4 格式,需要先加载完整的 moov 元数据才能真正开始播放,用户等待时间很长。
我的做法是:讲师上传视频后,后端调用 FFmpeg 命令把视频转成 m3u8 格式,生成多个.ts切片文件和一个.m3u8索引文件。播放时前端用 video.js 加载 m3u8 地址,浏览器通过 HLS 协议逐个拉取切片,实现边下边播,拖动进度条时也能快速定位,体验远好于直接播 mp4。同时我开发了一个定时任务,扫描视频目录,清理超过 30 天没有被访问的临时文件。
FFmpeg 转码的命令行大致是这样的:
ffmpeg -i input.mp4 -c:v libx264 -c:a aac -hls_time 10 -hls_list_size 0 -hls_segment_filename "%03d.ts" output.m3u8-hls_time 10表示每个切片 10 秒左右,切片太短会导致请求频繁,太长则拖动进度时加载数据量大。转出来的 m3u8 文件内部记录的切片路径是相对路径,所以整个目录要一起部署到静态资源服务器。我在后端配置了虚拟路径映射,把本地磁盘上的视频目录映射为/video/**访问路径。
4.3 订单创建与支付状态流转
订单模块我采用的是"提前生成订单号、下单后校验库存、模拟支付回调更新状态"的方案。生成订单号时,我用SimpleDateFormat格式化当前时间到毫秒,再加用户 ID 拼接出唯一订单号,比如2025012115300012340001。虽然理论上存在并发冲突的概率,但在课程购买场景下量级不大,这个策略足够用。如果做的是高并发的秒杀系统,要考虑用 Redis 生成自增序号。
订单状态我用status字段管理,0 表示待支付,1 表示已支付,2 表示已取消。创建订单和支付成功这两个关键操作,都在独立的OrderService方法中完成。支付成功接口我模拟了支付宝/微信的回调机制:用户点击支付,后端不直接改状态,而是生成支付记录,然后模拟回调接口被调用,回调里执行订单状态修改和课程授权发放。这样设计的好处是,以后接真实第三方支付时,只需替换回调处理逻辑,整体流程不用改。
这里有个特别容易出问题的点:回调接口的幂等性。如果支付平台回调多次,后端重复执行授权逻辑会导致user_course表插入多条重复记录。我在回调处理中加了判断逻辑:先根据订单号查询订单状态,如果已经是已支付,直接 return,不再重复操作。
4.4 文件上传的路径处理与静态资源映射
课程封面上传、讲师头像上传、视频上传,这些功能都涉及文件操作。SpringBoot 默认的单文件大小限制是 1MB,多文件限制是 10MB,视频上传必须调大这两个参数。我在application.yml里配置:
spring: servlet: multipart: max-file-size: 1024MB max-request-size: 2048MB但光调大配置还不行,上传后的文件保存路径也需要注意。我在配置类里实现了WebMvcConfigurer,重写addResourceHandlers方法,把/upload/**路径映射到本地磁盘的E:/edu_files/目录。这样前端访问http://ip:8080/upload/course.mp4就能直接读取到磁盘上的文件。
路径写死这种方式在本地开发没问题,部署到 Linux 服务器时要改成绝对路径并确认目录可写。我自己的服务器是把文件目录设在/data/edu-files/,启动脚本里用环境变量注入。Windows 开发环境路径用的是反斜杠,Linux 用正斜杠,代码中统一用File.separator拼接,避免跨平台出问题。
5. 前端 Vue 实现的关键页面与交互
5.1 项目初始化与目录规范
前端项目我用 Vue CLI 4 脚手架初始化,Node 版本要求 12 以上。整个 src 目录结构大概这样:
src/ ├── api/ # 接口请求封装 │ ├── course.js │ ├── user.js │ ├── order.js │ └── statistics.js ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── CourseCard.vue │ ├── SectionTree.vue │ └── Pagination.vue ├── router/ # 路由配置 ├── store/ # Vuex 状态管理 ├── utils/ # 工具函数 │ ├── request.js # Axios 封装 │ └── auth.js # token 处理 ├── views/ # 页面视图 │ ├── admin/ │ ├── teacher/ │ └── student/ └── App.vue每新增一个功能模块,在 api 目录下建一个 JS 文件,统一 export 该模块的接口方法。比如课程相关的请求都放在course.js里,组件只负责调用方法,不直接写axios.get('/api/course')这种散落在各处的请求。这样维护起来非常清爽,后端接口地址变了只需改一个文件。
5.2 动态路由与权限控制的实现
动态路由是很多前端项目中实现权限控制的通用方案。老师在后台登录后,前端拿到该用户的角色信息,通过router.addRoutes动态挂载对应角色的路由表。学生登录挂载学生端路由,讲师登录挂载讲师端路由。
我在permission.js路由守卫文件里处理了完整的登录跳转逻辑:
router.beforeEach((to, from, next) => { const token = getToken() if (token) { if (to.path === '/login') { next({ path: '/' }) } else { // 判断路由表是否已经加载 if (store.getters.roles.length === 0) { store.dispatch('GetUserInfo').then(roles => { // 根据角色生成可访问路由表 const accessRoutes = generateRoutes(roles) router.addRoutes(accessRoutes) next({ ...to, replace: true }) }) } else { next() } } } else { if (whiteList.indexOf(to.path) !== -1) { next() } else { next(`/login?redirect=${to.path}`) } } })这段代码解决了一个很常见的体验问题:用户刷新页面时 Vuex 中的状态会丢失,路由表需要重新加载。每次刷新后,路由守卫会检查 Vuex 里是否有用户角色信息,没有就重新调接口获取并挂载动态路由。如果不做这一步,刷新后在课程管理页面会白屏,因为路由已经不复存在了,这个坑很多初学者踩过。
5.3 视频播放器集成与学习进度记录
视频学习页是学生核心场景,我这里用的 video.js 播放器,支持 HLS 播放。安装依赖后,在组件中引入播放器样式和 JS:
import videojs from 'video.js' import 'video.js/dist/video-js.css'组件挂载后初始化播放器,监听timeupdate事件,每隔一定时间向后端上报当前播放位置。上报接口设计得很轻,只传courseId、sectionId、currentTime三个参数。后端收到后先查询有没有记录,有则更新,无则插入。这里我用了一个lastReportTime变量控制上报频率,每 15 秒上报一次,避免频繁请求把后端压垮。
播放器进度恢复功能也很重要,用户在 12 分 30 秒退出,下次进来应该从 12 分 30 秒继续播放。实现方式是用video.currentTime(startTime)方法设置播放器的初始位置。如果播放的是 m3u8 流,需要监听loadedmetadata事件后再跳转,因为此时才能确定媒体时长。
HLS 流播放还有一个坑:如果 m3u8 切片文件中的分片没有正确编码,video.js 可能无法播放。排查时要先用 VLC 播放器直接打开 m3u8 链接,如果能正常播,说明是前端播放器集成的问题;如果 VLC 也播不了,基本可以断定是 FFmpeg 转码出的切片本身有问题,需要检查转码参数。
5.4 管理后台的表格、表单与数据联动
讲师端和管理员端大量使用 Element UI 的 el-table 组件。我封装了一个公共的分页组件,统一处理页码变化、页容量变化事件,表格数据请求都走分页参数 + 查询条件这同一套规范。el-table 的列配置里我习惯带prop字段,而不是直接做slot-scope,这样表格列的排序功能可以直接复用 Table 组件的sortable属性,服务端配合 ORDER BY 字段排序。
课程审核功能是管理后台里最能体现"表单联动"的部分。管理员查看待审核课程详情时,需要看到讲师提交的完整课程信息:封面图、简介、价格、课程目录树。这里我复用了学生端的课程详情组件,只是外层包了一层审核操作栏(通过isAuditprop 控制)。这样的复用方式大大减少了代码量,也保持了课程展示在不同场景下的一致性。
管理后台的数据统计页我用 ECharts 做了图形化展示:折线图展示近 7 天新增用户数趋势,饼图展示课程分类占比,柱状图展示课程销量排行。数据通过统计接口一次性返回,前端交给 ECharts 渲染。做报表接口时要注意,返回的字段结构应该直接对应图表需要的格式,不要让前端再做复杂的数组转换,否则接口设计就失败了。
6. 源码部署、运行与排坑实录
6.1 本地开发环境搭建
想把这套源码跑起来,本地环境要满足几个硬性条件。后端的 JDK 版本必须是 8 或 11,Maven 版本 3.6 以上,IDE 推荐 IntelliJ IDEA(社区版就够用)。MySQL 版本最好 8.0,5.7 也能跑,但要注意utf8mb4的配置方式略有不同。前端 Node.js 版本 12 以上,npm 或 yarn 都可以,推荐 npm,因为 lock 文件统一。
拿到源码后第一件事是导入数据库脚本,我提供了一份完整的edu.sql文件,里面包含了建库、建表和初始化数据。数据库连接配置在application.yml里,主要修改这几处:
spring: datasource: url: jdbc:mysql://localhost:3306/edu_system?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: your_password特别注意 MySQL 8.0 的驱动名称是com.mysql.cj.jdbc.Driver,老版本是com.mysql.jdbc.Driver,如果驱动写错,启动时会报ClassNotFoundException。serverTimezone参数如果缺失,连接时经常报时区错误,统一指定Asia/Shanghai最省心。
6.2 前后端联调环境的跨域配置
前端开发服务器默认跑在8080端口,后端接口在8080端口(如果后端改成 8081,那正好端口错开)。本地前后端分离开发时,跨域问题不可避免。我在 Vue 的vue.config.js里配置了代理:
module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这样做的好处是,前端代码里所有请求地址都写成/api/course/list这种相对路径,开发环境由 webpack-dev-server 代理转发到后端,部署环境通过 Nginx 将/api前缀转发到后端服务,接口地址不需要做任何改动。如果不用代理而是直接在前端写http://localhost:8080,部署时改域名或端口就要动一大堆代码,非常痛苦。
6.3 我遇到过的三个棘手问题及解决过程
第一个问题:视频上传时后端报错The temporary upload location ... is not valid。这个错误本质是 Tomcat 默认的临时目录被系统清理掉了,我查了很久资料才确认。解决方案是在配置类里显式指定MultipartConfigElement的临时目录:
@Bean public MultipartConfigElement multipartConfigElement() { MultipartConfigFactory factory = new MultipartConfigFactory(); factory.setLocation("/data/tmp"); return factory.createMultipartConfig(); }第二个问题:前端 el-table 表格数据量大时渲染卡顿。课程章节多的课程可能会有上千条记录,Element UI 在渲染大量行时有性能瓶颈。最后我通过懒加载树结构解决,展开章节时才请求对应的小节数据,而不是一次性把所有节点加载进来。配合后端在course_section查询接口中支持parent_id参数,按需加载,性能提升明显。
第三个问题:m3u8 播放时视频拖动有延迟。排查发现是因为切片时长设置太长,我原本设置的是 30 秒一个切片,用户拖到 20 分钟处,需要等对应的切片加载完成才能播放。后来把-hls_time参数改为 5 秒,切片数量变多但加载粒度更细,拖动的响应速度明显改善。切片文件数量增加后,要注意磁盘容量的规划,1 小时的视频大约生成 600 兆的切片文件。
6.4 生产环境部署的几点补充建议
如果这套系统要部署到真实服务器,我建议用 Nginx 托管前端静态文件,后端 SpringBoot 打成 jar 包用nohup启动。Nginx 配置里需要有三块内容:静态文件服务、API 反向代理、Gzip 压缩。视频切片文件如果体积大,建议在 Nginx 中配置单独的缓存策略,设置expires 7d,因为视频切片是典型的"一次生成、多次访问"资源,浏览器缓存能显著降低服务端带宽压力。
数据库备份也不能忽略,课程系统的数据虽然不是高频变化,但订单数据和学习记录具有价值,我在服务器上配了 crontab 任务,每天凌晨 2 点执行mysqldump全量备份,保留最近 7 天的备份文件。真出问题时,恢复成本很低。
这套系统的设计和编码过程,我最大的感受是前后端职责边界要清晰,数据权限要后端兜底,视频播放这类体验敏感的功能要舍得花时间调优。如果你打算基于这套源码二次开发,建议从课程模块入手熟悉整体结构,再逐步深入订单和权限部分,整个项目跑通之后,你对 SpringBoot + Vue 全栈开发的理解会上一个明显的台阶。