SpringBoot+Vue在线教育平台前后端分离实战:接口、token与视频点播
简介:基于SpringBoot与Vue构建的前后端分离在线教育平台项目源码,属于毕业设计完整实现,面向计算机相关专业毕业生及需要项目实战练习的学习者,也可直接用于课程设计或期末大作业。项目经导师指导并以98.5分通过评审,代码结构清晰,整体采用Controller-Service-Mapper分层,涵盖课程管理、教师管理、会员管理、数据统计等核心业务模块;前端Vue负责交互,后端SpringBoot提供接口,配合RedisUtils通用工具类与SQL数据库初始化脚本,可帮助读者快速理解企业级前后端分离开发流程,也便于直接参考或二次开发。压缩包共195个文件,以165个Java源码为主,辅以17个XML配置、YAML配置文件、SQL数据库脚本、PNG图片及MD说明文档;其中SQL脚本对应MySQL数据库初始化表结构与示例数据,MD文档记录项目说明及运行指引;整体大小2.13MB,便于下载与本地运行。目前已有192人学习,适合正在准备毕业设计或希望以完整项目提升实战能力的学生参考。
1. 在线教育平台用 SpringBoot+Vue 做前后端分离,最容易翻车的不是接口设计,而是联调环节
课程表和订单表画了十几张,Service 写了一堆,等到 Vue 里按钮点下去,跨域报错一片红,token 一刷新就丢,视频点播黑屏——这是在线教育平台毕业设计最常见的翻车现场。这个标题表面是“一套管理系统”,实际解决的是三件事:SpringBoot 把接口、事务和权限收住,Vue 只负责渲染和用户操作,数据库和文档让答辩时有东西可讲。适合两类人:一类要交毕业设计或课程设计,另一类是第一次做前后端分离、想弄清 token 链路和 m3u8 视频怎么串起来的新人。这套方案的核心不是代码量,而是每一层职责清晰:后端不掺前端逻辑,前端不直接碰数据库,出问题用一条请求就能定位到 Controller、数据库还是浏览器。
2. 前后端分离架构下,在线教育平台先拆业务域再写代码
2.1 四个业务域:用户、课程、订单、学习记录
在线教育平台不是把视频挂在页面上这么简单。梳理业务时会发现,所有功能都围绕四个域转:用户域管注册登录和身份,课程域管课程信息和分节视频,订单域管购买行为,学习记录域管播放进度和断点续看。把表设计先行收拢到这四块,后面的 Controller 和 Vue 页面才能对应上,不然就是边写边加表,最后库结构乱到文档没法写。
用一张表把这四个域对应关系列出来,也是后端建表时最常用的结构:
| 表名 | 所属域 | 作用 | 关键说明 |
|---|---|---|---|
| sys_user | 用户 | 学员与教师账号 | 密码不存明文,存散列值 |
| course | 课程 | 课程主信息 | teacher_id 可关联 sys_user |
| course_section | 课程 | 课程分节与视频地址 | video_url 存相对路径,不存完整 URL |
order | 订单 | 学员购买记录 | 关联 user 和 course,保留金额快照 |
| learn_record | 学习记录 | 播放进度 | 记录上次播放秒数,做断点续看 |
为什么订单表要单独抽出来,而不是在 course 表里加一个“购买人数”字段?因为购买行为有状态变化,从待支付到已支付,还要记录成交金额、下单时间。把可变状态放在订单表里,课程表只保留上架下架标记,统计收入时直接查订单表即可。至于 learn_record,它让“上次看到 05:23”这种需求有地方落,不是可有可无的表。
2.2 REST 接口规范:统一返回结构才能让 Vue 少写判断
前后端分离之后,前端每个页面都要处理成功和失败两种结果。如果每个接口返回格式都不一样,Vue 的 axios 封装就得写一堆分支判断。常见做法是后端统一返回一个 Result 结构,包含 code、message、data 三个字段,前端只看 code 决定走正常流程还是报错提示。
public class Result<T> {
private Integer code; // 业务状态码:200 成功,401 未登录,500 服务异常
private String message; // 给前端展示的提示信息
private T data; // 业务数据,失败时为 null
public static <T> Result<T> ok(T data) {
Result<T> r = new Result<>();
r.code = 200;
r.message = "ok";
r.data = data;
return r;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> r = new Result<>();
r.code = code;
r.message = message;
return r;
}
}
这里的参数和字段要对应说明:code 是业务码,不用 HTTP 状态码替代;HTTP 状态码表示请求通没通,业务码表示逻辑成没成。前端的 axios 响应拦截器只认 code,后端接口只要包一层 Result,前端就不用每个接口单独处理异常。
接口路径按资源来命名,是这套项目最省事的规范。比如课程用 /course ,订单用 /order ,登录用 /auth/login 。下面这个清单是平台最小闭环需要的一组接口:
| 方法 | 路径 | 作用 | 是否需要登录 |
|---|---|---|---|
| POST | /api/auth/register | 学员注册 | 否 |
| POST | /api/auth/login | 登录并返回 token | 否 |
| GET | /api/course | 课程分页列表 | 否 |
| GET | /api/course/{id}/sections | 课程视频章节 | 是 |
| POST | /api/order | 创建订单 | 是 |
| POST | /api/learn-record | 上报播放进度 | 是 |
前两个接口不鉴权,其余接口都要验证身份。这时候就引出前后端分离项目里躲不开的 token 问题。
2.3 token 链路:后端拦截器校验,前端请求头携带
在线教育平台如果用 session 维护登录态,前后端分离后会遇到一个麻烦:Vue 部署的地址和后端 jar 包地址往往不是同一个域名,浏览器对 session 的 Cookie 处理会变得很别扭。常见做法是用 JWT 生成 token,后端不存会话状态,前端每次请求在请求头里带上 token,后端解密验证。
@Component
public class LoginInterceptor 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 ")) {
response.setStatus(401);
return false; // 请求在这里被拦下,不会进 Controller
}
Long userId = JwtUtil.parseToken(token.replace("Bearer ", ""));
request.setAttribute("userId", userId); // 后续业务直接从 request 里取
return true;
}
}
这段代码的逻辑是:从请求头拿到 Authorization 字段,格式必须是 Bearer 加 token 字符串;解析成功就把 userId 塞进 request,Controller 里直接取用;解析失败返回 401。注册和登录接口要排除在拦截器之外,用 excludePathPatterns("/api/auth/**") 配置即可。
注意:拦截器只校验身份,不决定权限。在线教育平台里学员和管理员要做区分,可以在 Controller 方法上写角色判断,或者用一个简单的权限注解。毕业设计不必引入完整权限框架,把“是否登录”和“是不是管理员”分开判断,就已经能应付大部分功能了。
3. SpringBoot 后端落地:课程分页、下单事务与数据库初始化
3.1 依赖选择:SpringBoot 3.x 的 starter 变化要先确认
创建 SpringBoot 项目时的依赖勾选,直接影响后面能不能跑起来。除了 Spring Web 和 MySQL 驱动,这个项目还需要 MyBatis-Plus 做 ORM 和分页,以及 jjwt 做 token 解析。pom 里的关键依赖如下:
<!-- 如果 SpringBoot 是 3.x,必须用 boot3 的 starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
注意:SpringBoot 3.x 对应的是 mybatis-plus-spring-boot3-starter,如果引成老的 mybatis-plus-boot-starter,启动时会因为包路径冲突直接报错。选依赖前先确认自己初始化项目的 SpringBoot 版本。
MyBatis-Plus 在毕业设计里的优势是不用写 XML,单表查询用 LambdaQueryWrapper 就能拼条件,分页再配一个 PaginationInnerInterceptor 即可。MySQL 驱动 8.x 的驱动类是 com.mysql.cj.jdbc.Driver ,连接串里要带上 useSSL=false 和 serverTimezone=Asia/Shanghai ,这两个参数不配,本地跑起来就会遇到时区和 SSL 相关报错。
3.2 课程分页接口:Controller 到 Mapper 的最小链路
课程列表是首页最常见的接口,要求是分页返回,前端还要拿到总数渲染分页组件。Controller 层直接返回 IPage<Course> ,MyBatis-Plus 会把分页结果序列化成 records 和 total 两个字段。
@RestController
@RequestMapping("/course")
public class CourseController {
@Resource
private CourseService courseService;
// 分页查询:/course?page=1&size=10
@GetMapping
public Result<IPage<Course>> page(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
IPage<Course> result = courseService.page(
new Page<>(page, size),
new LambdaQueryWrapper<Course>().orderByDesc(Course::getCreatedAt));
return Result.ok(result);
}
}
defaultValue 保证前端不传参数时也能正常返回,这是接口健壮性的基本要求。排序用 orderByDesc(Course::getCreatedAt) ,新课程排在前面。返回 IPage 而不是 List,是因为前端分页组件需要 total 来算总页数,如果只返回当前页的数据,分页组件就不知道总共几页。
这里有一个常见的误用:直接在 Service 层返回 List,然后前端拿到数组再去猜总页数,这是把分页逻辑丢了。正确做法是后端保证“查一页就给一页的总数”,前端只管渲染。
3.3 创建订单:一个事务把订单和学习记录一起写入
下单流程比课程列表复杂:要校验课程存在、校验是否重复购买、写入订单,还要初始化一条学习记录。这几步要么全成功,要么全失败,必须用一个事务包住。
@Transactional(rollbackFor = Exception.class)
public Long createOrder(Long userId, Long courseId) {
Course course = courseMapper.selectById(courseId);
if (course == null) {
throw new BizException(404, "课程不存在");
}
Long count = orderMapper.selectCount(new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.eq(Order::getCourseId, courseId));
if (count > 0) {
throw new BizException(400, "请勿重复购买");
}
Order order = new Order();
order.setUserId(userId);
order.setCourseId(courseId);
order.setAmount(course.getPrice());
order.setStatus(1);
orderMapper.insert(order);
LearnRecord learnRecord = new LearnRecord();
learnRecord.setUserId(userId);
learnRecord.setCourseId(courseId);
learnRecord.setPositionSec(0);
learnRecordMapper.insert(learnRecord);
return order.getId();
}
@Transactional(rollbackFor = Exception.class) 的作用是:方法内任何位置抛出运行时异常,已经执行的 insert 全部回滚。这里显式声明 rollbackFor 是因为 Spring 默认只对 RuntimeException 回滚,如果抛的是受检异常,事务可能不生效,写明白最稳妥。
幂等校验是这个接口的关键:先查用户是否已购买过该课程,如果查过了还继续插入订单,用户就能重复购买同一门课。真实业务里还要考虑并发下单,毕业设计阶段用唯一索引加这个查询就够用,但把“重复购买”这个分支写出来,答辩时能解释清楚为什么这么设计。
3.4 建表脚本的“文档化”写法
数据库是标题里单独点出来的交付物,建表脚本要能放进文档说明里直接当附录。推荐每个字段都带 COMMENT,外键关系用索引表达而不是强外键约束,这样数据维护方便,文档也能看懂。
CREATE TABLE course (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '课程id',
title VARCHAR(128) NOT NULL COMMENT '课程标题',
price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '价格,0表示免费',
cover_url VARCHAR(255) COMMENT '课程封面地址',
status TINYINT DEFAULT 0 COMMENT '0上架 1下架',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE=InnoDB COMMENT='课程主表';
CREATE TABLE `order` (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '订单id',
user_id BIGINT NOT NULL COMMENT '下单用户',
course_id BIGINT NOT NULL COMMENT '课程id',
amount DECIMAL(10,2) NOT NULL COMMENT '成交金额',
status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
KEY idx_user (user_id)
) ENGINE=InnoDB COMMENT='订单表';
这里有两个细节要注意:一是 order 是 MySQL 的保留字,建表时必须用反引号包住,MyBatis-Plus 实体类上也要写 @TableName(" order ") ;二是金额用 DECIMAL(10,2) 不用 float,浮点数的精度误差在涉及钱的场景不能接受。字段注释写全,文档说明里直接导入这段 SQL 就能建库,答辩时老师查表结构也能看懂字段含义。
4. Vue3 前端接入:token 请求处理与 m3u8 视频点播
4.1 Vite 创建项目:先把开发代理配好,跨域就少一半
前端工程推荐用 Vite 而不是 Vue CLI,新建模板和安装依赖的命令如下:
npm create vite@latest edu-web -- --template vue
cd edu-web
npm install
npm install axios hls.js
安装依赖后,先改 vite.config.js 里的 dev 代理,这是解决开发环境跨域最直接的方式:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugin: [vue()],
server: {
port: 5173,
proxy: {
// 前端请求 /api/course,会转发到后端 8080
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
代理配置的逻辑是:前端页面跑在 5173,后端接口在 8080,浏览器直接访问 8080 会跨域;通过 proxy 把 /api 开头的请求转发给 8080,浏览器角度请求的是同源地址,自然没有跨域问题。 changeOrigin: true 让后端收到的 Host 头保持为 target 地址,避免部分服务器配置里校验 Host 导致 403。
这里要注意:如果后端接口路径本身没有 /api 前缀,前端 baseURL 写成 /api 就会全部 404。保持前后端路径一致,最省事的方案是后端统一加 /api 前缀,前端代理不做路径重写。
4.2 axios 封装:请求拦截器带 token,响应码 401 跳登录页
token 的生命周期管理是 Vue 侧的核心。登录成功后把 token 存进 localStorage,每次请求自动塞进请求头,收到 401 就清掉本地登录态并跳回登录页。一个完整的请求封装如下:
import axios from 'axios'
import router from '@/router'
const request = axios.create({ baseURL: '/api', timeout: 10000 })
// 请求拦截器:每个请求自动带上 token
request.interceptors.request.use(config => {
const token = localStorage.getItem('edu_token')
if (token) config.headers.Authorization = 'Bearer ' + token
return config
})
// 响应拦截器:统一处理业务码和 HTTP 状态码
request.interceptors.response.use(
response => {
const res = response.data
if (res.code === 401) {
localStorage.removeItem('edu_token')
router.push('/login')
return Promise.reject(new Error('登录已过期'))
}
return res
},
error => {
if (error.response?.status === 401) {
localStorage.removeItem('edu_token')
router.push('/login')
}
return Promise.reject(error)
}
)
两个拦截器的职责不同:请求拦截器在请求发出前注入 Authorization 头,令牌格式和后端拦截器校验的 Bearer 前缀保持一致;响应拦截器处理两部分,一是后端业务码返回 401,二是 HTTP 状态码 401,这两种情况都要跳转登录页。token 要存在 localStorage 而不是 Vuex,Vuex 刷新页面内存就清空了,localStorage 才能持久化。
4.3 m3u8 播放的选型:hls.js 和 video.js 怎么选
在线教育平台的视频通常不用 mp4 直出,因为 mp4 拖动定位要先加载索引,长视频体验差。常见做法是用 ffmpeg 把视频转成 HLS 切片,生成 m3u8 文件加 ts 分片。前端选型有两种主流方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| video.js | 自带播放器 UI,控制条、进度、倍速都齐全 | 样式需要覆盖才能融入自定义页面,组件包较大 |
| hls.js | 只负责 HLS 解码,体积小,和 Vue 组件结合干净 | 没有 UI,需要靠原生 video 标签的 controls |
毕业设计的视频点播页面,我更推荐 hls.js,因为播放器外观用原生 video 的 controls 就能展示,不需要引入整套播放器皮肤。hls.js 配合 Vue 组件的最小实现如下:
<template>
<video ref="videoRef" controls class="player"></video>
</template>
<script setup>
import Hls from 'hls.js'
import { onMounted, ref, watch } from 'vue'
const props = defineProps({ src: String })
const videoRef = ref(null)
const playM3u8 = (url) => {
const video = videoRef.value
if (Hls.isSupported()) {
// 大部分浏览器走这里:用 hls.js 解码并挂载到 video 元素
const hls = new Hls()
hls.loadSource(url)
hls.attachMedia(video)
hls.on(Hls.Events.ERROR, (event, data) => {
// 网络错误时重新加载,fatal 表示出错后无法继续
if (data.fatal && data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls.startLoad()
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari 原生支持 HLS,直接赋给 video.src
video.src = url
}
}
watch(() => props.src, (v) => v && playM3u8(v))
onMounted(() => props.src && playM3u8(props.src))
</script>
<style scoped>
.player { width: 100%; border-radius: 8px; }
</style>
这段逻辑分三条路:支持 MSE 的浏览器用 hls.js 解码,Safari 走原生 HLS,两者都不支持就什么都不做。错误处理里 startLoad() 是网络抖动恢复的常用手段,卡顿后至少有一次自动恢复机会。
播放 m3u8 时要额外确认 ts 分片的访问权限。m3u8 是文本文件,内部记录了每个 ts 切片的相对路径,如果视频文件和前端静态资源不在一台服务器上,分片一般都要开 CORS 才能被浏览器请求。后端存储视频时把 m3u8 和 ts 放在同一个目录,访问地址保持同一域名,能避开大部分黑屏问题。
5. 联调验证:用最小命令把接口、代理、播放器一次测通
5.1 四个高频故障与定位顺序
前后端分离项目出问题,大部分集中在四个症状上。按下面的顺序排查,比乱改代码有效得多:
| 症状 | 第一嫌疑 | 验证方法 |
|---|---|---|
| 按钮点击后请求 404 | 代理路径和后端路径不一致 | 打开 Network 看请求 URL 是否带 /api |
| 登录后刷新就退出 | token 放错存储位置 | 看 localStorage 里有没有 edu_token |
| 视频黑屏不播 | m3u8 分片 404 或跨域 | 打开 Network 看 ts 文件的状态码 |
| 接口返回 401 不跳转 | 响应拦截器没处理业务码 | 看响应体里 res.code 的实际值 |
页面按钮没反应时,不要先怀疑 Vue 代码,先按 F12 打开 Network 面板看请求有没有发出。请求都没发出,问题在 axios 封装或事件绑定;请求发出了但 404,问题在代理配置;请求 200 但页面数据不显示,问题在响应拦截器或渲染逻辑。
5.2 用 curl 固定验证后端,再回来调前端
排查 token 相关问题时,先用 curl 打一条需要登录的接口,确认后端和 token 本身没问题,再回头查前端。这是最节省时间的联调方式:
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/course?page=1&size=10"
$TOKEN 是登录接口拿到的 token 串,替换成实际值。这个命令能明确区分问题边界:curl 返回正常,后端没问题,前端去查请求头有没有带上 token;curl 返回 401,后端校验逻辑或 token 生成有问题,前端不用白费功夫。
答辩时把 Network 面板里这条请求的 Request Headers 和 Response 截图放进文档,比贴十页代码都直观,老师一眼能看出你理解了前后端分离的调用链路。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)