SpringBoot+Vue3在线教育平台:从框架选型到支付回调全解析
2026/9/16 22:31:02 网站建设 项目流程

做在线教育平台系统这几年,我最大的感受是:SpringBoot+Vue3+MyBatis+MySQL这套组合,看着是"烂大街"的搭配,但真要把课程展示、视频学习、下单支付、讲师后台这些模块全部串起来,踩坑的地方一点也不少。市面上流传的在线教育平台系统源码很多,不少同学拿到手第一件事就是本地跑不起来,或者好不容易启动成功,却发现随便点两下就报错——究其原因,大多不是代码本身有问题,而是没搞懂这个项目背后的技术选型逻辑和业务边界。这篇文章就围绕一套完整可运行的前后端分离在线教育平台来拆解:为什么用SpringBoot3配Vue3而不是其他组合、数据库表结构怎么设计才能支撑课程和订单的联动、JWT登录和支付回调的完整链路怎么走、讲师端视频上传断点续传怎么落地,以及面试官最喜欢追问的MyBatis缓存和自动装配底层逻辑。无论你是正在做毕业设计、准备转Java全栈,还是刚入职接手教育类项目的开发维护,这篇文章都能给你一套可以直接抄作业的参考。

1. 为什么在线教育平台选SpringBoot+Vue3+MyBatis,而不是JPA或者微服务

1.1 业务复杂度决定了框架选型

在线教育平台的核心业务无非是课程展示、用户学习、订单支付、讲师管理这几大块,听起来简单,但实际落地时会发现一个特点:表关联复杂、统计查询多、业务规则频繁调整。比如课程列表要按分类、难度、价格、销量动态筛选,订单报表要按讲师、按时间段聚合,学员学习进度要和课时表、视频表做多表联查——这种场景下,MyBatis比JPA更合适。

JPA的强项是单表CRUD和领域驱动设计,但在教育平台这种"查询需求多变、SQL经常要手工调优"的项目里,JPA的自动SQL生成反而成为瓶颈。你很难控制它生成的JOIN语句,一旦数据量上来,一个N+1查询就能把接口拖垮。MyBatis则完全不同,SQL完全自己掌控,动态SQL的<if><foreach>标签写复杂筛选条件非常顺手,配合<resultMap>映射继承关系也比JPA的注解更直观。而且国内大多数Java团队都熟悉MyBatis,招人、维护成本都低。

前端用Vue3也不用多说。管理后台、讲师工作台、学员端H5和PC站,这几个端都有大量的表单交互和状态共享,Vue3的组合式API(Composition API)比Vue2的选项式API更适合这种场景。加上TypeScript的普及,Vue3+SFC(单文件组件)的开发效率确实是当前前端框架里的第一梯队。

1.2 单体应用足够支撑早期业务,别一上来就拆微服务

很多同学一看到"在线教育平台"就觉得应该上Spring Cloud Alibaba那套微服务,这是个常见的误区。对于日活几千到几万的平台,单体应用配合MySQL主从、Redis缓存和CDN,在很长一段时间内都是够用的。微服务带来的服务拆分、分布式事务、链路追踪成本,对于一个小团队或者个人开发者来说,是实打实的负担。

这套源码选择的是"模块化单体"结构——代码里按照systemcourseorderuser等业务域分包,而不是拆成多个独立应用。好处很明显:部署简单(一个jar包搞定),联调方便(本地不需要启动Nacos、Gateway那一堆基础设施),后期如果真要拆分,由于包结构是按业务域划分的,拆分的成本也可控。这个设计思路值得借鉴,尤其是毕业设计或者中小型商业项目,千万不要为了技术炫技牺牲交付效率。

1.3 MySQL选型:8.0版本带来的具体收益

数据库选型上,项目使用的是MySQL 8.0。相比5.7,8.0有几个对开发者很实用的改进:默认字符集是utf8mb4,直接支持emoji存储,不用每次建库都手动指定;窗口函数让排名、同比环比这类统计SQL简洁很多;JSON类型的功能增强,对于课程详情页这种"部分字段结构多变"的场景很实用。

不过8.0也带来一个需要注意的问题:驱动类名变成了com.mysql.cj.jdbc.Driver,URL中必须加上serverTimezone=Asia/Shanghai,否则启动时会报时区错误。另外8.0默认的认证插件是caching_sha2_password,如果本地使用老版本Navicat连接,可能需要在创建用户时指定mysql_native_password,这个我在2.2节会提一句。

注意:如果你用的是SpringBoot 3.x,必须搭配MySQL驱动8.0.31以上版本,否则可能存在与高版本JDK的兼容问题。

2. 从零初始化项目:SpringBoot3.2+Vue3+MyBatis的版本搭配与常见坑

2.1 SpringBoot版本视角的"版本太高"问题解析

热搜词里有"springboot版本太高"这个词条,我猜大概率是新手用IDEA创建项目时选了最新的SpringBoot 3.3或3.4,然后发现大量的依赖导入报错。这里需要明确一个概念:SpringBoot 3.x从3.0开始强制要求JDK17及以上,如果你的本机装的是JDK8,即使IDEA能创建项目,编译也不可能通过——这是第一个"版本太高"的根源。

第二个坑是javax改成jakarta。SpringBoot 3.x中,所有以javax.servlet开头的包名全部迁移到了jakarta.servlet,带过来的连锁反应是:旧版的pagehelperdruid等第三方框架如果不升级版本,直接ClassNotFoundException。很多老项目的代码从SpringBoot 2.7迁移到3.x,大量报错都集中在这个包名变更上。

第三个坑是SpringSecurity的配置方式从WebSecurityConfigurerAdapter变成了基于SecurityFilterChain的Bean声明。2.x时代常见的写法是继承适配器,3.x中这个抽象类已经被移除,需要改成:

@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/course/list", "/api/course/detail/**").permitAll() .anyRequest().authenticated()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); }

对于本地学习来说,我的建议是:不要盲目追求最新版本。SpringBoot选择3.2.x,Vue3选择3.4.x,MyBatis-Spring-Boot-Starter选择3.0.3,这个组合经过大量生产验证,踩坑资料也最多,遇到问题更容易搜索到解决方案。

2.2 初始化阶段必踩的三个环境坑

第一个是Node版本。Vue3+Vite项目要求Node 18以上,我第一次用Node 16跑项目时,npm install直接报Error:0308010C:digital envelope routines::unsupported。这是Node版本与Vite的OpenSSL哈希算法不兼容导致的,根本解法是升级Node到18+,而不是网上说的NODE_OPTIONS=--openssl-legacy-provider那个治标不治本的方案。

第二个是MySQL连接参数。如果使用MySQL 8.0,pom.xml中依赖坐标最好显式指定版本:

<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency>

注意groupId是com.mysql,不再是mysql。同时application.yml中要配置:

spring: datasource: url: jdbc:mysql://localhost:3306/edu_platform?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver

allowPublicKeyRetrieval=true这个参数在MySQL 8.0的非SSL连接下经常被忽略,不加的话可能报Public Key Retrieval is not allowed。另外,不要用com.mysql.jdbc.Driver这个老驱动类名,在新版本中已经不存在了。

第三个是Vue3的跨域问题。前后端分离项目,前端跑在localhost:5173,后端跑在localhost:8080,浏览器会拦截跨域请求。源码在Vue的vite.config.js中配置了代理:

server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

后端也配置了CORS(跨域资源共享)过滤器放开/api/**。这里要提醒的是:如果上线部署,推荐用Nginx统一做反向代理,前端代理和后端CORS只留一个,否则会出现"本地好好的,部署到服务器就各种403"的诡异问题。

2.3 MyBatis在SpringBoot3下的关键配置

mybatis.configuration.map-underscore-to-camel-case=true这个配置一定要开。MySQL中的字段习惯用下划线命名,比如course_namevideo_url,Java实体类用驼峰courseNamevideoUrl,开启这个选项后MyBatis会自动映射,不需要每个字段都写@Results注解。

如果项目用了PageHelper分页插件,在SpringBoot3下必须用pagehelper-spring-boot-starter的1.4.7以上版本,并且配置:

pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true

reasonable: true的作用是当页码小于1时自动返回第一页,大于总页数时自动返回最后一页,这个参数在写课程列表分页时很友好,前端不用额外处理越界页码。

另外,建议打印SQL日志,排查问题时能看到实际执行的语句和参数:

logging: level: com.example.edu.mapper: debug

这里要说明:mapper包的日志级别必须设为debug,MyBatis才会打印SQL。如果只设全局root: debug会带出海量框架日志,影响排查效率。

3. 课程、订单、用户三大核心域的数据库模型设计

3.1 用户体系:一张表还是三张表

在线教育平台涉及三类角色:学员、讲师、管理员。常规设计会考虑拆成学员表、讲师表、管理员表,但实际操作下来,我更推荐单表加角色字段的方案——用户表存公共信息,角色相关的专属信息用扩展表存储。

用户表核心字段:

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `password` varchar(255) NOT NULL COMMENT '密码', `nickname` varchar(50) NOT NULL COMMENT '昵称', `avatar` varchar(500) DEFAULT NULL COMMENT '头像URL', `type` tinyint NOT NULL DEFAULT '0' COMMENT '用户类型:0-学员 1-讲师 2-管理员', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-正常 0-禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

讲师审核通过后,往讲师扩展表插一条记录,关联user_id,存讲师的简介、头衔、擅长方向、提现账户等信息。这种设计的核心优势是:登录认证只查一张表,简单高效;学员和讲师的专属信息天然解耦,不会因为讲师资料字段过多而拖慢学员表查询。

3.2 课程域:分类表、课程表、章节表、课时表的级联关系

课程模块是教育的核心,设计上采用经典的三级模型:分类表→课程表→章节表→课时表。

分类表是树形结构,支持一级分类(Java、Python、前端)、二级分类(SpringBoot、Vue3)。在MySQL中表示树形结构最通用的做法是parent_id自关联:

CREATE TABLE `course_category` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `parent_id` int NOT NULL DEFAULT '0', `sort` int DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程分类表';

课程表是信息聚合中心,包含标题、封面、简介、价格、原价、讲师ID、分类ID、难度等级、总课时、总时长、销量、评分等。关键字段:

CREATE TABLE `course` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '课程标题', `cover` varchar(500) DEFAULT NULL COMMENT '封面图URL', `category_id` int NOT NULL COMMENT '分类ID', `teacher_id` bigint NOT NULL COMMENT '讲师ID', `price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '售价', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价', `level` tinyint DEFAULT '1' COMMENT '难度:1-初级 2-中级 3-高级', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-草稿 1-上架 2-下架', `buy_count` int NOT NULL DEFAULT '0' COMMENT '销量', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`), KEY `idx_teacher` (`teacher_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';

章节表course_chapter和课时表course_lesson

  • 章节表:course_id(外键)、titlesort
  • 课时表:chapter_id(外键)、titlevideo_urlvideo_durationis_free(是否试看)、sort

这里有一个很多新手容易忽略的点:课时表要加is_free字段。免费试看是教育平台拉新的重要手段,一个课时是否免费这个属性需要在课程详情接口中被计算出来——免费课时不需要登录就能播放,付费课时则要在校验订单后返回播放地址。把逻辑落到表设计上,而不是靠代码写死,是系统可维护性的关键。

3.3 订单与支付:状态机与幂等性设计

订单表是教育平台中最容易出问题的表,设计时必须考虑状态流转和幂等:

CREATE TABLE `course_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '订单号', `user_id` bigint NOT NULL COMMENT '用户ID', `course_id` bigint NOT NULL COMMENT '课程ID', `course_title` varchar(200) DEFAULT NULL COMMENT '课程标题(冗余)', `course_cover` varchar(500) DEFAULT NULL COMMENT '课程封面(冗余)', `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额', `pay_type` tinyint DEFAULT NULL COMMENT '支付方式:1-微信 2-支付宝', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0-待支付 1-已支付 2-已取消 3-已退款', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`), KEY `idx_course` (`course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程订单表';

这里要重点解释两个设计决策。

第一,为什么订单表要冗余course_titlecourse_cover?因为订单是历史快照,课程标题和封面以后可能会被讲师修改,但用户的购买凭证应该保留购买时刻的信息。如果直接关联课程表,讲师改了标题,用户的订单记录就变了,这对财务对账和用户体验都是不可接受的。

第二,为什么订单号要唯一索引?因为支付回调可能重复推送,如果不做幂等,同一次支付可能给用户开课两次。处理方式是在代码中先查order_no是否存在,存在则直接返回成功。这是支付回调处理的铁律。

学习进度表单独设计:

CREATE TABLE `user_course_progress` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `lesson_id` bigint NOT NULL, `course_id` bigint NOT NULL, `watch_duration` int DEFAULT '0' COMMENT '观看时长(秒)', `total_duration` int DEFAULT '0' COMMENT '课时总时长(秒)', `last_position` int DEFAULT '0' COMMENT '最后播放位置', `is_finished` tinyint DEFAULT '0' COMMENT '是否学完', `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_lesson` (`user_id`, `lesson_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习进度表';

进度表的唯一索引uk_user_lesson非常关键,它保证了一个用户对同一课时只有一条进度记录。前端播放器定时上报观看时长,后台更新该记录,续播功能直接读取last_position即可。

4. 从JWT登录到课程下单:核心接口链路拆解

4.1 JWT认证:无状态登录的完整流程

在线教育平台的登录使用JWT(JSON Web Token)是目前的主流做法。流程是这样的:用户提交手机号和密码,后端校验通过后生成一个JWT令牌返回前端,前端存储到localStorage中,之后每个请求在Authorization头发送Bearer ${token},后端通过过滤器校验令牌的合法性,从而识别用户身份。

JWT令牌包含三部分:Header(算法信息)、Payload(自定义信息,如userIduserTypeexp过期时间)、Signature(签名)。签名使用密钥对前两部分加密,防止内容被篡改。

生产环境的密钥管理要注意,application.yml中的jwt.secret不要用明文,至少要用环境变量或配置中心管理,硬编码在代码中属于重大安全隐患。

JWT在SpringBoot3中的过滤器实现,我一般写成这样:

@Component public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Resource private JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); try { Claims claims = jwtUtil.parseToken(token); Long userId = Long.valueOf(claims.get("userId").toString()); Integer userType = (Integer) claims.get("userType"); // 构建当前登录用户上下文,方便业务层获取 LoginUser loginUser = new LoginUser(userId, userType); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(loginUser, null, null)); } catch (Exception e) { // 令牌无效,不设置认证信息,后续接口会被拦截 } } filterChain.doFilter(request, response); } }

需要提醒的是:JWT令牌是自包含的,服务端不保存状态,这是优点也是缺点。缺点是无法主动让某个令牌失效——如果用户要退出登录,前端只要删除本地token即可,但要"踢人下线"就需要引入Redis黑名单机制。在这套源码中,退出登录的实现是前端删除token,如果做后台管理系统的严格权限控制,需要进一步考虑Redis方案。

4.2 课程列表与详情接口:一次查询怎么撑起筛选、排序、分页

课程列表接口是访问量最大的接口之一,设计目标是"一个接口搞定所有筛选"。前端传递categoryIdkeywordlevelsortTypepageNumpageSize等参数,后端用MyBatis动态SQL拼接条件。

<select id="selectCoursePage" resultType="com.example.edu.entity.Course"> SELECT c.*, u.nickname AS teacher_name FROM course c LEFT JOIN user u ON c.teacher_id = u.id <where> <if test="categoryId != null and categoryId != 0"> AND c.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (c.title LIKE CONCAT('%', #{keyword}, '%') OR c.description LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="level != null"> AND c.level = #{level} </if> AND c.status = 1 </where> <choose> <when test="sortType == 1"> ORDER BY c.buy_count DESC </when> <when test="sortType == 2"> ORDER BY c.create_time DESC </when> <when test="sortType == 3"> ORDER BY c.price ASC </when> <otherwise> ORDER BY c.id DESC </otherwise> </choose> </select>

这段SQL里有几个小技巧。LIKE CONCAT('%', #{keyword}, '%')比直接写LIKE '%${keyword}%'安全,因为#{}是预编译占位符,${}是字符串拼接——后者可能导致SQL注入。<choose>标签处理多条件排序,比连续写多个<if>更清晰。

课程详情接口的逻辑相对复杂:需要返回课程基本信息、讲师信息、章节和课时列表,并且要标记当前登录用户对每个课时是否有权限观看。我的做法是分三步:

  1. 查询课程基本信息(含关联的讲师昵称)
  2. 查询章节列表,再循环查询每个章节下的课时列表
  3. 如果用户已登录,查询该用户是否购买了此课程,或者课时是否is_free=1,决定返回的videoUrl是否可播放

这里要注意,课时视频URL不能直接返回真实的CDN地址,而应该返回一个带签名的临时播放地址,防止付费视频被免费下载。后面5.3节会细说。

4.3 下单与支付回调:状态校验、重复下单与幂等处理

下单接口是典型的写操作高频场景,核心步骤:

  1. 校验课程状态是否为上架状态
  2. 查询当前用户是否已经购买过(user_course表),已购买则直接返回"已拥有"
  3. 校验价格,生成订单号(规则:时间戳+随机数,或用雪花算法)
  4. 插入订单表,状态为待支付
  5. 返回订单号和支付参数

防止重复下单是这里的重点。如果用户在前端连续点了两次"立即购买",后端可能生成两条待支付订单。虽然重复下单不致命(支付后要做去重),但对账时会很混乱。解决方案有两个层面:一是前端按钮防抖,点击后立即置灰;二是后端在订单表中加uk_user_course唯一索引,保证同一个用户对同一门课只有一条有效订单:

ALTER TABLE course_order ADD UNIQUE KEY uk_user_course (user_id, course_id);

但这里有个细节:用户取消订单后,可能需要重新购买,唯一索引就冲突了。更严谨的做法是在应用层加分布式锁(如Redis的SETNX),锁的key设计为order:create:{userId}:{courseId},拿到锁才允许创建订单。

支付回调接口是对外暴露的接口,安全性要求最高。回调处理逻辑:

  1. 验证签名(支付宝/微信官方SDK都已封装)
  2. 验签通过后,根据order_no查询订单
  3. 如果订单状态已经是"已支付",直接返回成功(幂等)
  4. 否则更新订单状态为已支付,同时往user_course表插入记录(正式开课)
  5. 返回成功应答

这里有一个经典的坑:步骤2和步骤3之间如果发生并发,两个线程都查到订单是"待支付",都可能执行后续逻辑——更新订单和插入用户课程。解决方式是在SQL层面做条件更新:

UPDATE course_order SET status = 1, pay_time = NOW() WHERE order_no = #{orderNo} AND status = 0;

如果影响行数为1,说明本次是第一个处理回调的线程,可以放心执行开课逻辑;如果影响行数为0,说明订单已经被处理过,直接返回成功。这是典型的乐观锁思想,简单高效,推荐学习。

5. 讲师端内容管理:视频上传、断点续传与安全分发

5.1 讲师工作台的整体功能边界

讲师工作台是平台内容的生产入口,核心功能包括:课程管理(创建课程、编辑基本信息)、课时管理(上传视频、设置试看)、学员数据查询、收益统计等。

从上手角度讲,讲师端的前端路由最好单独规划,与学员端彻底分开。在Vue3中通过路由懒加载实现:

const router = createRouter({ history: createWebHistory(), routes: [ { path: '/teacher', component: () => import('@/layout/TeacherLayout.vue'), children: [ { path: 'course/list', component: () => import('@/views/teacher/CourseList.vue') }, { path: 'course/edit', component: () => import('@/views/teacher/CourseEdit.vue') }, { path: 'lesson/edit/:courseId', component: () => import('@/views/teacher/LessonEdit.vue') }, { path: 'statistics', component: () => import('@/views/teacher/Statistics.vue') } ] } ] })

路由守卫中要校验用户类型必须是讲师或管理员,否则重定向到登录页:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token) { next('/login') return } // 从token解析出的userType,如果访问讲师菜单但type不是1或2,拒绝 const userInfo = parseToken(token) if (to.path.startsWith('/teacher') && userInfo.userType === 0) { next('/') return } next() })

5.2 视频直传与断点续传的方案对比

视频上传是讲师端最重的功能。很多新手直接用multipart/form-data把整个视频POST到后端,再由后端转发到OSS(对象存储,如阿里云OSS、腾讯云COS)。这种方式对几十MB的小视频没问题,但教育平台的视频动辄1GB以上,一旦网络中断就得重新传,用户体验极差。

推荐方案是:前端直传视频到OSS,断点续传由OSS SDK完成,后端只负责提供上传凭证和接收回调更新数据。以阿里云OSS为例,前端使用ali-ossSDK,调用后端接口获取STS临时凭证(访问密钥),然后进行分片上传:

import OSS from 'ali-oss' const upload = async (file, courseId, lessonId) => { // 1. 向后端获取STS临时凭证 const { credentials } = await api.getOssSts() const client = new OSS({ region: credentials.region, accessKeyId: credentials.accessKeyId, accessKeySecret: credentials.accessKeySecret, stsToken: credentials.securityToken, bucket: credentials.bucket }) // 2. 分片上传,partSize设置5MB一个分片 const result = await client.multipartUpload( `video/${courseId}/${lessonId}/${Date.now()}_${file.name}`, file, { partSize: 5 * 1024 * 1024 } ) // 3. 上传完成后,把视频URL和时长信息提交给后端 await api.updateLessonVideo(lessonId, { videoUrl: result.name }) }

STS临时凭证的有效期一般设置15分钟到30分钟,过期后需要重新获取。由于分片上传是客户端发起的,上传过程中后端不感知,所以视频信息(时长、大小)需要在分片完成后由前端调用接口上报,后端可在上报时触发异步转码任务。

如果你没有云服务条件,本地开发可以用MinIO代替OSS,接口设计思路完全一致,只是SDK不同。MinIO支持Docker一键部署,非常方便。

5.3 视频防盗链与地址安全

视频上传之后,面临的核心问题是防盗链。如果把视频URL直接返回给前端,任何人复制链接都能下载,付费课程就完全没有保护了。业界通用的做法是"私有Bucket+签名URL"。

OSS私有Bucket意味着文件默认不可访问,只有生成带签名参数的临时URL才能在有效期内访问。签名URL生成逻辑:

public String generateSignedUrl(String objectName, int expireMinutes) { // 以阿里云OSS为例 Date expiration = new Date(System.currentTimeMillis() + expireMinutes * 60 * 1000); URL url = ossClient.generatePresignedUrl(bucketName, objectName, expiration); return url.toString(); }

于是,课时播放接口的完整逻辑变成:

  1. 前端请求播放课时,传lessonId
  2. 后端判断用户是否有权限(已购买或试看课时)
  3. 有权限则查询课程的videoUrl(即OSS的objectName),生成5分钟有效的签名URL,返回给前端
  4. 播放器播放签名URL,过期后自动重新获取

这样即使有人把播放地址发出去,5分钟后就失效了,而且只能播放自己的课程——因为签名URL绑定了bucket权限。

这里还有一个小技巧:签名URL携带的临时凭证只能访问指定的objectName,不能访问整个bucket,否则只能用完整bucket权限可以接受,但生产环境建议用最小权限权限范围,避免签名被滥用。

5.4 视频转码与封面图处理的异步任务

因为涉及用户健康或敏感信息必须过滤,我这里只讲视频转码的技术方案。用户上传的原始视频可能是4K、1080p大文件,直接让所有用户都拉原始分辨率的视频,不仅浪费带宽,中低端手机上播放还可能卡顿。经验做法是:上传完成后,后端接收到回调,将转码任务扔进消息队列或简单的异步线程池,对视频做多码率转码,生成标清/高清/超清多路输出。

在单体架构中,最简单的异步方案是Spring的@Async注解,配合一个自定义线程池。转码工作一般交给云服务(阿里云媒体处理、FFmpeg),流程是:

  1. 后端收到前端上报的上传完成消息
  2. 调用云转码服务,传入源视频路径和输出路径模板
  3. 转码完成后,云服务回调通知后端
  4. 后端更新课时表的video_url为转码后的播放列表地址

在转码未完成前,课程状态建议保持为"编辑中",前端播放器提示"视频处理中,请稍后再试"。这块逻辑虽然不属于SpringBoot的核心代码,但一个完整的在线教育平台一定绕不开,我建议至少用FFmpeg本地跑一遍转码流程,理解其中涉及的编码参数(码率、分辨率、关键帧间隔),对后续排查播放问题非常有帮助。

6. 上线前必须关注的性能、缓存与安全加固

6.1 列表页性能优化:索引、分页与缓存策略

课程列表页是平台的门面,性能直接影响用户第一印象。这里说的不是单条SQL的微调,而是一整套缓存策略。

第一步,确保查询走了正确的索引。之前表设计中的联合索引idx_category_status(category_id, status)就是为了覆盖"按分类查上架课程"的场景。用EXPLAIN验证:

EXPLAIN SELECT * FROM course WHERE category_id = 1 AND status = 1 ORDER BY buy_count DESC;

如果看到typeconstref,说明索引生效;如果看到ALL,说明全表扫描,需要调整索引。

第二步,引入Redis缓存列表数据。课程列表属于"读多写少"的数据,非常适合缓存。缓存策略采用"列表页整体缓存+失效时间短"的方式:

// 缓存Key设计:course:list:{categoryId}:{pageNum}:{pageSize}:{sortType} String cacheKey = "course:list:" + categoryId + ":" + pageNum + ":" + pageSize; Object cacheData = redisTemplate.opsForValue().get(cacheKey); if (cacheData != null) { return (PageResult<CourseVO>) cacheData; } // 查数据库 PageResult<CourseVO> result = courseMapper.selectCoursePage(...); redisTemplate.opsForValue().set(cacheKey, result, 60, TimeUnit.SECONDS);

缓存时间设置为60秒,既保证数据不至于过分陈旧,又能显著降低数据库压力。

第三步,热点课程详情页使用Caffeine本地缓存+Redis二级缓存。Caffeine作为一级缓存速度极快,Redis作为二级缓存供多实例共享。这个组合是单体应用性价比最高的缓存方案,比一上来就上RedisCluster(Redis集群)更符合实际场景。

6.2 MyBatis缓存的正确理解:一级缓存与二级缓存的坑

MyBatis缓存是Java面试的高频考点,在项目中也经常被误用,这里单独梳理一遍。

一级缓存是SqlSession级别的缓存,默认开启。同一个SqlSession中执行两次完全相同的查询,第二次直接从缓存返回,不会再查数据库。但在Spring集成的环境下,SqlSession每次执行SQL后可能被关闭,所以一级缓存的作用范围基本限定在同一个方法中——如果你在一个@Transactional方法里查询两次相同数据,第一次和第二次之间如果有插入、更新、删除操作,一级缓存会被清空,这个现象在排查"为什么我的缓存不生效"时经常遇到。

二级缓存是Mapper级别的缓存,多个SqlSession共享。配置方式是在Mapper XML中添加:

<mapper namespace="com.example.edu.mapper.CourseMapper"> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/> </mapper>

然而我个人的建议是:在SpringBoot+MyBatis项目中,除非场景非常明确,否则关闭二级缓存。原因是二级缓存一旦开启,所有查询结果都会缓存,如果某个Mapper对应的表数据高频更新(比如订单表),脏数据的概率就极高。而且分布式的场景下,每个节点的缓存各自为政,数据一致性很难保证。项目中的Redis已经承担了绝大部分缓存需求,完全没必要再叠加MyBatis的二级缓存。

另外,在MyBatis XML动态SQL中有一个数字字符比较的常见坑。如果你写:

<if test="level == 1">

而Java类型是String,那么level == 1比较的是字符串引用,不是数值,结果是false。这时候应该用:

<if test='level == "1"'>

或者:

<if test="level != null and level == '1'.toString()">

热搜词里有"mybatis 单个数字字符比较",说的就是这个问题。这是MyBatis表达式的一个经典陷阱,尤其在charString类型和Integer类型混用的时候最容易出错。我的建议是:Mapper层的参数类型尽量统一用包装类型,比较时先判空再比较,避免自动拆箱带来的NPE(空指针异常)。

6.3 安全加固:SQL注入、密码加密与敏感信息脱敏

上线前安全这块不能省。密码存储绝不能使用MD5(已能被破解),推荐BCrypt加盐加密。在SpringBoot中直接使用BCryptPasswordEncoder

@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 注册时加密 String encoded = passwordEncoder.encode(rawPassword); // 登录时校验 boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);

JWT密钥、OSS密钥、数据库密码等配置,在生产环境一律通过环境变量或配置中心注入,不要提交到Git仓库。

MySQL连接信息要注意最小权限原则:给应用创建一个专用账号,只授予业务库的SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX权限,不要用root账号连接数据库。

接口层面,所有管理员操作(如课程上下架、讲师审核)必须校验请求者的用户类型,不能只在前端隐藏按钮——因为接口是可以直接调用的。后端在每个需要权限的接口上,建议用自定义注解+拦截器的方式统一校验权限,而不是在业务代码里手写判断。

6.4 慢SQL排查:从Druid监控到慢查询日志

项目落地后,慢SQL是性能问题的第一嫌疑。建议在application.yml中配置MySQL慢查询日志:

# my.cnf slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1

long_query_time = 1表示超过1秒的SQL都会被记录。线上环境建议设置为0.5秒,开发环境可以设置为2秒,避免日志刷屏。

如果项目集成了Druid连接池,可以直接启用Druid的监控页面,在application.yml配置:

spring: datasource: druid: stat-view-servlet: enabled: true url-pattern: /druid/* login-username: admin login-password: admin123

访问http://localhost:8080/druid可以看到SQL执行次数、耗时、并发等统计信息。通过对比executeCountmaxTimes,很容易发现哪些SQL执行频繁且耗时高,然后针对性地优化索引或调整SQL。

需要特别说明:Druid监控页面在生产环境必须配置强密码,或者直接关闭,暴露监控页面等于把数据库表结构、慢SQL裸奔给攻击者看。

7. 给新手的几条实操建议和后端学习的延伸方向

如果你照着这份源码学习,或者准备搭建自己的在线教育平台,我强烈建议按照下面的顺序走一遍,而不是上来就埋头看代码:

第一步,把数据库脚本导入MySQL,然后用Navicat或DataGrip把所有表结构看一遍,重点是订单表、课程表、用户表之间的关联。为什么先看表结构?因为一切业务逻辑最终都是围绕表数据在转,理解了表结构,代码里的Service层逻辑基本能猜个八九不离十。

第二步,把项目启动起来,用Postman(接口调试工具)逐个调用登录、课程列表、课程详情、下单这几个核心接口,看返回的JSON结构。对照前端页面的展示效果,理解前后端数据交互的格式约定。

第三步,找一个完整的功能链路去追踪源码,比如"用户登录→查看课程详情→免费试看→下单→支付回调→开始学习",把这个链路涉及的所有Controller→Service→Mapper代码读一遍。这是最快理解整个项目的方法。

第四步,自己尝试加一个小功能,比如"课程收藏"或者"讲师关注"。从设计表结构开始,到后端接口开发,再到前端页面实现,全流程走一遍。只有亲手改过代码,你才能真正理解这套源码的设计思路。

关于后续的学习方向,在线教育平台做完之后,你有几条进阶路线:一是把Redis用得更深入——缓存穿透、击穿、雪崩的解决方案,分布式锁在防重复下单中的应用;二是引入消息队列(如RabbitMQ)处理异步任务,比如发送下单成功通知、视频转码状态回调;三是学习Elasticsearch,把课程搜索从MySQL的LIKE查询升级为全文检索,支持更复杂的搜索需求。

我在实际部署这类项目的过程中还有一个感触很深的教训:线上环境一定要做数据库的定期备份,并且要验证备份文件能恢复,而不仅仅是用定时任务把备份文件扔到磁盘上就完事。有一次生产环境出现了误删数据,恢复时才发现前一个月的备份文件损坏,那种半夜处理事故的经历,经历过一次就不想再有第二次。如果你正在运营或计划运营一个在线教育平台,这个建议一定要记住。

另外,如果你打算用这套项目去面试,不要只停留在"我会用框架"的层面。面试官最常问的点是:JWT的认证流程怎么设计?MyBatis的一二级缓存有什么区别?订单状态机如何避免重复支付?Redis在项目中用在哪里?这些在这篇文章里都有覆盖,你应该在自己的项目经历描述中,把这些技术点的"为什么"讲清楚——为什么用JWT而不用Session,为什么列表要缓存,为什么订单要做幂等——这才是面试官最看重的思考深度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询