SpringBoot+Vue线上教育系统实战:m3u8播放与JWT鉴权 📅 发布时间:2026/9/14 10:25:27 👁 浏览次数: 简介这是一套面向计算机专业本科生的毕业设计级线上教育系统源码适用于Java全栈开发学习与课程设计实践。系统采用前后端分离架构前端基于Vue.js与Element UI实现响应式界面后端依托SpringBoot整合SpringMVC、MyBatis及MySQL完成业务逻辑与数据管理完整覆盖教师、学生、管理员三类角色的协同教学场景。资源压缩包大小为80.17MB含完整项目工程文件如pom.xml、Vue组件、Controller/Service/DAO层代码、SQL建表脚本等结构清晰、模块分明四大功能模块——公共登录注册、学生课程学习与作业提交、教师资料发布与进度跟踪、管理员用户与权限管理——均具备可运行的业务闭环。目前已有280人学习下载读者可直接导入IDE运行调试深入理解SSM/SpringBootVue技术栈在真实教育平台中的集成方式、权限控制设计及前后端联调要点。1. 为什么一个“线上教育系统”必须用 SpringBoot Vue 做前后端分离不是因为简历好看而是真实业务压出来的技术选择。当课程视频要支持 m3u8 流式播放、学生答题记录需毫秒级写入、教师后台要实时看到班级学习进度看板——单体架构的 Servlet 容器扛不住并发请求JSP 模板页改个按钮都要重启服务更别说跨域登录态管理、权限动态刷新、前端独立灰度发布这些刚需。SpringBoot 提供了开箱即用的 WebMvc、MyBatis-Plus、Redis 缓存、JWT 鉴权和 Actuator 监控能力Vue 则用 Composition API Pinia Vue Router 实现路由守卫拦截未登录跳转、用video.js或hls.js插件原生解析 m3u8、用v-show/v-if精准控制不同角色学生/教师/管理员的界面区块。这不是“学完能做毕设”的玩具组合而是当前国内教育 SaaS 产品线最主流、招聘 JD 中出现频次最高的技术栈——它解决的从来不是“能不能跑”而是“高并发下怎么稳、多角色下怎么权、视频流怎么顺、上线后怎么调”。2. 搭建最小可运行骨架SpringBoot 后端接口 Vue 前端页面联调2.1 初始化 SpringBoot 后端工程JDK 17 SpringBoot 3.2.xSpringBoot 3.x 要求 JDK 17且默认移除了javax.*包改用jakarta.*。若用 IDEA 创建项目务必在 start.spring.io 选Spring Boot 3.2.122024 年稳定 LTS 版勾选以下依赖Spring WebSpring Data JPA或 MyBatis-Plus本文用后者更适配教育系统复杂查询Spring Security处理登录鉴权Lombok减少 getter/setterValidation参数校验Redis存储 token 和课程缓存生成后在pom.xml中显式添加 MyBatis-Plus 3.5.5兼容 SpringBoot 3dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency提示不要用mybatis-spring-boot-starter它不支持 SpringBoot 3 的jakarta.persistence若遇到ClassNotFoundException: javax.servlet.http.HttpServletRequest说明引入了旧版依赖需全局搜索javax.*并替换为jakarta.*。2.2 配置 JWT 登录认证与跨域关键三步教育系统必须区分学生、教师、管理员三类用户且前端需在每次请求头带Authorization: Bearer token。SpringBoot 中需完成2.2.1 定义 JWT 工具类含 token 签发与解析Component public class JwtUtil { private static final String SECRET edusys_jwt_secret_2024; // 生产环境请从配置中心读取 private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; // 24小时 public String generateToken(Long userId, String username, String role) { Date now new Date(); Date expiry new Date(now.getTime() EXPIRE_TIME); return Jwts.builder() .subject(username) .claim(userId, userId) .claim(role, role) // 存角色用于前端路由守卫 .issuedAt(now) .expiration(expiry) .signWith(SignatureAlgorithm.HS512, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .verifyWith(Keys.hmacShaKeyFor(SECRET.getBytes())) .build() .parseSignedClaims(token.replace(Bearer , )) .getPayload(); } }2.2.2 自定义过滤器校验 token 并注入 SecurityContextComponent public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtUtil jwtUtil; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims jwtUtil.parseToken(token); String username claims.getSubject(); String role (String) claims.get(role); UserDetails userDetails userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken auth new UsernamePasswordAuthenticationToken(userDetails, null, Collections.singletonList(new SimpleGrantedAuthority(ROLE_ role))); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write(Invalid token); return; } } filterChain.doFilter(request, response); } }2.2.3 在 SecurityConfig 中注册过滤器并放行静态资源Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf - csrf.disable()) .sessionManagement(session - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authz - authz .requestMatchers(/api/auth/login, /api/public/**).permitAll() // 登录和公开接口 .requestMatchers(/api/admin/**).hasRole(ADMIN) .requestMatchers(/api/teacher/**).hasAnyRole(TEACHER, ADMIN) .requestMatchers(/api/student/**).hasAnyRole(STUDENT, TEACHER, ADMIN) .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }注意/api/public/**用于存放课程列表、公开课视频地址等无需登录即可访问的接口/api/student/**下的/student/course/progress接口返回当前用户学习进度必须校验 token 中的userId与数据库中 student_id 一致防止越权。2.3 Vue 前端初始化与 Axios 请求拦截器处理 token 自动携带使用 Vue CLI 5.0.8兼容 Vue 3.4创建项目npm create vuelatest # 选 TypeScript Router Pinia ESLint cd edu-front npm install axios1.6.7 npm install hls.js1.5.9 # 支持 m3u8 播放2.3.1 封装 axios 实例并注入 token// src/utils/request.ts import axios from axios const service axios.create({ baseURL: import.meta.env.VUE_APP_BASE_API || /api, // 开发时通过 vite.config.ts 代理到后端 timeout: 10000 }) // 请求拦截自动携带 token service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, error Promise.reject(error) ) // 响应拦截统一错误处理 service.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } ) export default service2.3.2 在 vite.config.ts 中配置开发代理解决跨域// vite.config.ts export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, // SpringBoot 默认端口 changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })提示生产环境 Nginx 需配置location /api { proxy_pass http://backend; }前端打包后VUE_APP_BASE_API设为空字符串由 Nginx 统一转发避免暴露后端 IP。3. 核心功能落地课程视频 m3u8 播放与学习进度同步3.1 后端提供标准化视频接口与进度保存接口教育系统视频不能直接暴露文件路径需经后端鉴权。SpringBoot 中定义RestController RequestMapping(/api/student) public class StudentCourseController { GetMapping(/course/{courseId}/video) public ResultMapString, Object getVideoInfo(PathVariable Long courseId, AuthenticationPrincipal UserDetails userDetails) { // 1. 校验学生是否购买该课程查 order 表 // 2. 查询课程视频信息m3u8 地址、封面、时长 // 3. 返回 { m3u8Url: https://cdn.edu.com/v1/1001/index.m3u8?tokenxxx, cover: ..., duration: 1245 } return Result.success(videoService.getVideoInfo(courseId, userDetails.getUsername())); } PostMapping(/course/{courseId}/progress) public ResultVoid saveProgress(PathVariable Long courseId, RequestBody Valid ProgressDTO dto, AuthenticationPrincipal UserDetails userDetails) { // dto: { currentTime: 185, isFinished: false } progressService.saveProgress(courseId, userDetails.getUsername(), dto); return Result.success(); } }注意m3u8Url必须带一次性签名 token如?t1717023456signabc由后端生成并校验防止盗链currentTime单位为秒精度到整数即可前端每 10 秒上报一次。3.2 Vue 前端实现 m3u8 播放器与进度上报3.2.1 使用 hls.js 加载 m3u8兼容 Safari!-- src/views/CoursePlayer.vue -- template div classplayer-container video refvideoRef classvideo-player controls/video div classprogress-bar span{{ formatTime(currentTime) }} / {{ formatTime(duration) }}/span /div /div /template script setup langts import Hls from hls.js import { ref, onMounted, onUnmounted } from vue import request from /utils/request const props defineProps{ courseId: number }() const videoRef refHTMLVideoElement | null(null) const currentTime ref(0) const duration ref(0) let hls: Hls | null null onMounted(async () { try { const res await request.get(/student/course/${props.courseId}/video) const { m3u8Url } res.data if (Hls.isSupported()) { hls new Hls() hls.loadSource(m3u8Url) hls.attachMedia(videoRef.value!) hls.on(Hls.Events.MANIFEST_PARSED, () { videoRef.value!.play() }) hls.on(Hls.Events.FRAG_LOADED, () { currentTime.value Math.floor(videoRef.value!.currentTime) }) } else if (videoRef.value?.canPlayType(application/vnd.apple.mpegurl)) { videoRef.value.src m3u8Url videoRef.value.addEventListener(loadedmetadata, () { videoRef.value!.play() }) } } catch (e) { console.error(加载视频失败, e) } }) // 每10秒上报进度 const timer setInterval(() { if (videoRef.value !videoRef.value.paused) { currentTime.value Math.floor(videoRef.value.currentTime) request.post(/student/course/${props.courseId}/progress, { currentTime: currentTime.value, isFinished: videoRef.value.ended }) } }, 10000) onUnmounted(() { clearInterval(timer) hls?.destroy() }) /script3.2.2 进度上报防抖与断点续播单纯定时上报不可靠需结合ended事件与timeupdate事件// 在 setup 中补充 const lastReportTime ref(0) const reportProgress debounce(async () { if (Date.now() - lastReportTime.value 5000) { // 5秒内只报一次 await request.post(/student/course/${props.courseId}/progress, { currentTime: currentTime.value, isFinished: videoRef.value?.ended || false }) lastReportTime.value Date.now() } }, 300) // 在 videoRef 上绑定 timeupdate if (videoRef.value) { videoRef.value.addEventListener(timeupdate, () { currentTime.value Math.floor(videoRef.value!.currentTime) reportProgress() }) videoRef.value.addEventListener(ended, () { reportProgress() }) }提示debounce函数需自行实现或引入lodash.debounceisFinished字段用于标记课程是否学完触发发放电子证书逻辑。3.3 数据库设计要点MySQL 8.0教育系统核心表需支撑视频、章节、进度、订单四维关系表名关键字段说明courseid, title, cover_url, price, status课程主表status0草稿/1上架/2下架chapterid, course_id, title, sort_order, video_m3u8_url章节表video_m3u8_url 存原始地址播放时由后端加签student_progressid, student_id, chapter_id, current_time, is_finished, updated_at学习进度表联合索引(student_id, chapter_id)加速查询orderid, student_id, course_id, amount, status订单表status1已支付关联后才允许调用/video接口-- 创建 student_progress 表带唯一约束防重复插入 CREATE TABLE student_progress ( id bigint NOT NULL AUTO_INCREMENT, student_id varchar(64) NOT NULL COMMENT 学生账号, chapter_id bigint NOT NULL, current_time int DEFAULT 0, is_finished tinyint(1) DEFAULT 0, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_student_chapter (student_id,chapter_id), KEY idx_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;4. 生产环境关键配置与常见坑点排查4.1 SpringBoot 生产配置application-prod.ymlserver: port: 8080 compression: enabled: true mime-types: text/html,text/xml,text/plain,application/json,application/javascript,text/css spring: datasource: url: jdbc:mysql://prod-db:3306/edu_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: edu_app password: ${DB_PASSWORD:changeme} # 从环境变量读取 redis: host: prod-redis port: 6379 password: ${REDIS_PASSWORD:} lettuce: pool: max-active: 20 max-idle: 10 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 生产关闭 global-config: db-config: id-type: assign_id # 使用雪花算法生成ID logging: level: com.edu: info org.springframework.web: warn注意server.compression开启 GZIP 可使 JSON 响应体体积减少 60%mybatis-plus.configuration.log-impl在生产必须注释掉否则日志刷爆磁盘。4.2 Vue 打包部署避坑指南Nginx 配置Vue Router 使用history模式时直接访问/course/1001会 404需 Nginx 配置 fallbacklocation / { try_files $uri $uri/ /index.html; } # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control public, immutable; } # API 转发 location /api/ { proxy_pass http://backend:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }4.2.1 解决 Vue 打包后布局异常常见于 flex 布局错乱若发现.flex-col在生产环境换行失效检查postcss.config.js是否遗漏autoprefixer// postcss.config.js module.exports { plugins: { autoprefixer: { overrideBrowserslist: [ 1%, last 2 versions, not dead, iOS 10, Android 6] } } }提示Vue CLI 5 默认启用 autoprefixer但若手动删过node_modules或升级过vue/cli-service需确认browserslist字段存在于package.json中。4.3 前后端分离下的 Token 处理进阶技巧4.3.1 刷新 token 机制避免用户频繁重登当 token 剩余 30 分钟过期时前端自动用 refresh_token 换新 token// 后端增加 RefreshTokenController PostMapping(/auth/refresh) public ResultString refreshToken(RequestBody RefreshTokenDTO dto) { if (redisTemplate.hasKey(refresh: dto.getRefreshToken())) { String newToken jwtUtil.generateToken(...); // 生成新 access token redisTemplate.delete(refresh: dto.getRefreshToken()); return Result.success(newToken); } return Result.fail(refresh token invalid); }// 前端响应拦截器增强 service.interceptors.response.use( response response, async error { const originalRequest error.config if (error.response?.status 401 !originalRequest._retry) { originalRequest._retry true const refreshToken localStorage.getItem(refreshToken) try { const res await axios.post(/api/auth/refresh, { refreshToken }) const newToken res.data.data localStorage.setItem(token, newToken) originalRequest.headers.Authorization Bearer ${newToken} return service(originalRequest) } catch (e) { localStorage.removeItem(token) localStorage.removeItem(refreshToken) router.push(/login) } } } )4.3.2 权限按钮级控制比路由守卫更细粒度在PermissionButton.vue组件中根据用户角色动态渲染template button v-ifhasPermission([ADMIN, TEACHER]) clickdeleteCourse 删除课程 /button /template script setup import { useUserStore } from /stores/user const userStore useUserStore() const hasPermission (roles: string[]) { return roles.includes(userStore.role) // userStore.role 来自登录后存入 pinia } /script注意userStore.role必须在登录成功后立即赋值且在 token 过期时清空避免角色信息残留导致越权操作。5. 验证系统是否真正“线上可用”五项必测指标一个能上线的线上教育系统不能只满足“页面能打开”必须通过以下硬性验证指标验证方式合格标准工具/命令接口平均响应时间对/api/student/course/1001/video发起 1000 次压测P95 ≤ 300msab -n 1000 -c 100 http://localhost:8080/api/student/course/1001/videom3u8 视频首帧加载时间Chrome DevTools Network Tab 查看index.m3u8和首个ts文件耗时≤ 1.2s4G 网络模拟Chrome DevTools → Network → Throttling → 4GJWT token 无法伪造使用 jwt.io 修改 payload 中role为ADMIN后请求/api/admin/user/list返回 403 Forbidden手动修改 token 后用 curl 测试学生A无法查看学生B进度登录学生A账号手动修改请求 URL 中courseId为学生B的课程ID返回 403 或空数据Postman 修改 Authorization Header 后发请求Vue 页面白屏率线上环境打开chrome://inspect查看 Console 是否有Uncaught ReferenceError0 错误Chrome DevTools → Console执行第一项压测时若发现响应时间超标优先检查MySQL 是否对student_progress(student_id, chapter_id)建立了联合索引缺失会导致全表扫描Redis 是否启用maxmemory-policy allkeys-lru防止内存溢出SpringBoot 是否配置了spring.mvc.throw-exception-if-no-handler-foundtrue避免 404 被 Spring Security 拦截导致额外开销。最后当所有指标达标你交付的就不再是一个“基于 SpringBoot Vue 的线上教育系统的设计与实现.zip”而是一个能承载每日 5 万学员并发学习、视频卡顿率低于 0.3%、教师后台操作延迟感知不到的生产级教育平台。本文还有配套的精品资源点击获取