简介:基于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 截图放进文档,比贴十页代码都直观,老师一眼能看出你理解了前后端分离的调用链路。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐