资讯详情

SpringBoot+Vue3在线教育平台:从框架选型到支付回调全解析

📅 2026/9/16 22:31:09 | 华诺云谱 👁 阅读
SpringBoot+Vue3在线教育平台:从框架选型到支付回调全解析
做在线教育平台系统这几年我最大的感受是SpringBootVue3MyBatisMySQL这套组合看着是烂大街的搭配但真要把课程展示、视频学习、下单支付、讲师后台这些模块全部串起来踩坑的地方一点也不少。市面上流传的在线教育平台系统源码很多不少同学拿到手第一件事就是本地跑不起来或者好不容易启动成功却发现随便点两下就报错——究其原因大多不是代码本身有问题而是没搞懂这个项目背后的技术选型逻辑和业务边界。这篇文章就围绕一套完整可运行的前后端分离在线教育平台来拆解为什么用SpringBoot3配Vue3而不是其他组合、数据库表结构怎么设计才能支撑课程和订单的联动、JWT登录和支付回调的完整链路怎么走、讲师端视频上传断点续传怎么落地以及面试官最喜欢追问的MyBatis缓存和自动装配底层逻辑。无论你是正在做毕业设计、准备转Java全栈还是刚入职接手教育类项目的开发维护这篇文章都能给你一套可以直接抄作业的参考。1. 为什么在线教育平台选SpringBootVue3MyBatis而不是JPA或者微服务1.1 业务复杂度决定了框架选型在线教育平台的核心业务无非是课程展示、用户学习、订单支付、讲师管理这几大块听起来简单但实际落地时会发现一个特点表关联复杂、统计查询多、业务规则频繁调整。比如课程列表要按分类、难度、价格、销量动态筛选订单报表要按讲师、按时间段聚合学员学习进度要和课时表、视频表做多表联查——这种场景下MyBatis比JPA更合适。JPA的强项是单表CRUD和领域驱动设计但在教育平台这种查询需求多变、SQL经常要手工调优的项目里JPA的自动SQL生成反而成为瓶颈。你很难控制它生成的JOIN语句一旦数据量上来一个N1查询就能把接口拖垮。MyBatis则完全不同SQL完全自己掌控动态SQL的if、foreach标签写复杂筛选条件非常顺手配合resultMap映射继承关系也比JPA的注解更直观。而且国内大多数Java团队都熟悉MyBatis招人、维护成本都低。前端用Vue3也不用多说。管理后台、讲师工作台、学员端H5和PC站这几个端都有大量的表单交互和状态共享Vue3的组合式APIComposition API比Vue2的选项式API更适合这种场景。加上TypeScript的普及Vue3SFC单文件组件的开发效率确实是当前前端框架里的第一梯队。1.2 单体应用足够支撑早期业务别一上来就拆微服务很多同学一看到在线教育平台就觉得应该上Spring Cloud Alibaba那套微服务这是个常见的误区。对于日活几千到几万的平台单体应用配合MySQL主从、Redis缓存和CDN在很长一段时间内都是够用的。微服务带来的服务拆分、分布式事务、链路追踪成本对于一个小团队或者个人开发者来说是实打实的负担。这套源码选择的是模块化单体结构——代码里按照system、course、order、user等业务域分包而不是拆成多个独立应用。好处很明显部署简单一个jar包搞定联调方便本地不需要启动Nacos、Gateway那一堆基础设施后期如果真要拆分由于包结构是按业务域划分的拆分的成本也可控。这个设计思路值得借鉴尤其是毕业设计或者中小型商业项目千万不要为了技术炫技牺牲交付效率。1.3 MySQL选型8.0版本带来的具体收益数据库选型上项目使用的是MySQL 8.0。相比5.78.0有几个对开发者很实用的改进默认字符集是utf8mb4直接支持emoji存储不用每次建库都手动指定窗口函数让排名、同比环比这类统计SQL简洁很多JSON类型的功能增强对于课程详情页这种部分字段结构多变的场景很实用。不过8.0也带来一个需要注意的问题驱动类名变成了com.mysql.cj.jdbc.DriverURL中必须加上serverTimezoneAsia/Shanghai否则启动时会报时区错误。另外8.0默认的认证插件是caching_sha2_password如果本地使用老版本Navicat连接可能需要在创建用户时指定mysql_native_password这个我在2.2节会提一句。注意如果你用的是SpringBoot 3.x必须搭配MySQL驱动8.0.31以上版本否则可能存在与高版本JDK的兼容问题。2. 从零初始化项目SpringBoot3.2Vue3MyBatis的版本搭配与常见坑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带过来的连锁反应是旧版的pagehelper、druid等第三方框架如果不升级版本直接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.xVue3选择3.4.xMyBatis-Spring-Boot-Starter选择3.0.3这个组合经过大量生产验证踩坑资料也最多遇到问题更容易搜索到解决方案。2.2 初始化阶段必踩的三个环境坑第一个是Node版本。Vue3Vite项目要求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.0pom.xml中依赖坐标最好显式指定版本dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency注意groupId是com.mysql不再是mysql。同时application.yml中要配置spring: datasource: url: jdbc:mysql://localhost:3306/edu_platform?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.DriverallowPublicKeyRetrievaltrue这个参数在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-casetrue这个配置一定要开。MySQL中的字段习惯用下划线命名比如course_name、video_urlJava实体类用驼峰courseName、videoUrl开启这个选项后MyBatis会自动映射不需要每个字段都写Results注解。如果项目用了PageHelper分页插件在SpringBoot3下必须用pagehelper-spring-boot-starter的1.4.7以上版本并且配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: truereasonable: true的作用是当页码小于1时自动返回第一页大于总页数时自动返回最后一页这个参数在写课程列表分页时很友好前端不用额外处理越界页码。另外建议打印SQL日志排查问题时能看到实际执行的语句和参数logging: level: com.example.edu.mapper: debug这里要说明mapper包的日志级别必须设为debugMyBatis才会打印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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表;章节表course_chapter和课时表course_lesson章节表course_id外键、title、sort课时表chapter_id外键、title、video_url、video_duration、is_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程订单表;这里要重点解释两个设计决策。第一为什么订单表要冗余course_title和course_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学习进度表;进度表的唯一索引uk_user_lesson非常关键它保证了一个用户对同一课时只有一条进度记录。前端播放器定时上报观看时长后台更新该记录续播功能直接读取last_position即可。4. 从JWT登录到课程下单核心接口链路拆解4.1 JWT认证无状态登录的完整流程在线教育平台的登录使用JWTJSON Web Token是目前的主流做法。流程是这样的用户提交手机号和密码后端校验通过后生成一个JWT令牌返回前端前端存储到localStorage中之后每个请求在Authorization头发送Bearer ${token}后端通过过滤器校验令牌的合法性从而识别用户身份。JWT令牌包含三部分Header算法信息、Payload自定义信息如userId、userType、exp过期时间、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 课程列表与详情接口一次查询怎么撑起筛选、排序、分页课程列表接口是访问量最大的接口之一设计目标是一个接口搞定所有筛选。前端传递categoryId、keyword、level、sortType、pageNum、pageSize等参数后端用MyBatis动态SQL拼接条件。select idselectCoursePage resultTypecom.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 testcategoryId ! null and categoryId ! 0 AND c.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (c.title LIKE CONCAT(%, #{keyword}, %) OR c.description LIKE CONCAT(%, #{keyword}, %)) /if if testlevel ! null AND c.level #{level} /if AND c.status 1 /where choose when testsortType 1 ORDER BY c.buy_count DESC /when when testsortType 2 ORDER BY c.create_time DESC /when when testsortType 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更清晰。课程详情接口的逻辑相对复杂需要返回课程基本信息、讲师信息、章节和课时列表并且要标记当前登录用户对每个课时是否有权限观看。我的做法是分三步查询课程基本信息含关联的讲师昵称查询章节列表再循环查询每个章节下的课时列表如果用户已登录查询该用户是否购买了此课程或者课时是否is_free1决定返回的videoUrl是否可播放这里要注意课时视频URL不能直接返回真实的CDN地址而应该返回一个带签名的临时播放地址防止付费视频被免费下载。后面5.3节会细说。4.3 下单与支付回调状态校验、重复下单与幂等处理下单接口是典型的写操作高频场景核心步骤校验课程状态是否为上架状态查询当前用户是否已经购买过user_course表已购买则直接返回已拥有校验价格生成订单号规则时间戳随机数或用雪花算法插入订单表状态为待支付返回订单号和支付参数防止重复下单是这里的重点。如果用户在前端连续点了两次立即购买后端可能生成两条待支付订单。虽然重复下单不致命支付后要做去重但对账时会很混乱。解决方案有两个层面一是前端按钮防抖点击后立即置灰二是后端在订单表中加uk_user_course唯一索引保证同一个用户对同一门课只有一条有效订单ALTER TABLE course_order ADD UNIQUE KEY uk_user_course (user_id, course_id);但这里有个细节用户取消订单后可能需要重新购买唯一索引就冲突了。更严谨的做法是在应用层加分布式锁如Redis的SETNX锁的key设计为order:create:{userId}:{courseId}拿到锁才允许创建订单。支付回调接口是对外暴露的接口安全性要求最高。回调处理逻辑验证签名支付宝/微信官方SDK都已封装验签通过后根据order_no查询订单如果订单状态已经是已支付直接返回成功幂等否则更新订单状态为已支付同时往user_course表插入记录正式开课返回成功应答这里有一个经典的坑步骤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(); }于是课时播放接口的完整逻辑变成前端请求播放课时传lessonId后端判断用户是否有权限已购买或试看课时有权限则查询课程的videoUrl即OSS的objectName生成5分钟有效的签名URL返回给前端播放器播放签名URL过期后自动重新获取这样即使有人把播放地址发出去5分钟后就失效了而且只能播放自己的课程——因为签名URL绑定了bucket权限。这里还有一个小技巧签名URL携带的临时凭证只能访问指定的objectName不能访问整个bucket否则只能用完整bucket权限可以接受但生产环境建议用最小权限权限范围避免签名被滥用。5.4 视频转码与封面图处理的异步任务因为涉及用户健康或敏感信息必须过滤我这里只讲视频转码的技术方案。用户上传的原始视频可能是4K、1080p大文件直接让所有用户都拉原始分辨率的视频不仅浪费带宽中低端手机上播放还可能卡顿。经验做法是上传完成后后端接收到回调将转码任务扔进消息队列或简单的异步线程池对视频做多码率转码生成标清/高清/超清多路输出。在单体架构中最简单的异步方案是Spring的Async注解配合一个自定义线程池。转码工作一般交给云服务阿里云媒体处理、FFmpeg流程是后端收到前端上报的上传完成消息调用云转码服务传入源视频路径和输出路径模板转码完成后云服务回调通知后端后端更新课时表的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;如果看到type是const或ref说明索引生效如果看到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 (PageResultCourseVO) cacheData; } // 查数据库 PageResultCourseVO result courseMapper.selectCoursePage(...); redisTemplate.opsForValue().set(cacheKey, result, 60, TimeUnit.SECONDS);缓存时间设置为60秒既保证数据不至于过分陈旧又能显著降低数据库压力。第三步热点课程详情页使用Caffeine本地缓存Redis二级缓存。Caffeine作为一级缓存速度极快Redis作为二级缓存供多实例共享。这个组合是单体应用性价比最高的缓存方案比一上来就上RedisClusterRedis集群更符合实际场景。6.2 MyBatis缓存的正确理解一级缓存与二级缓存的坑MyBatis缓存是Java面试的高频考点在项目中也经常被误用这里单独梳理一遍。一级缓存是SqlSession级别的缓存默认开启。同一个SqlSession中执行两次完全相同的查询第二次直接从缓存返回不会再查数据库。但在Spring集成的环境下SqlSession每次执行SQL后可能被关闭所以一级缓存的作用范围基本限定在同一个方法中——如果你在一个Transactional方法里查询两次相同数据第一次和第二次之间如果有插入、更新、删除操作一级缓存会被清空这个现象在排查为什么我的缓存不生效时经常遇到。二级缓存是Mapper级别的缓存多个SqlSession共享。配置方式是在Mapper XML中添加mapper namespacecom.example.edu.mapper.CourseMapper cache evictionLRU flushInterval60000 size512 readOnlytrue/ /mapper然而我个人的建议是在SpringBootMyBatis项目中除非场景非常明确否则关闭二级缓存。原因是二级缓存一旦开启所有查询结果都会缓存如果某个Mapper对应的表数据高频更新比如订单表脏数据的概率就极高。而且分布式的场景下每个节点的缓存各自为政数据一致性很难保证。项目中的Redis已经承担了绝大部分缓存需求完全没必要再叠加MyBatis的二级缓存。另外在MyBatis XML动态SQL中有一个数字字符比较的常见坑。如果你写if testlevel 1而Java类型是String那么level 1比较的是字符串引用不是数值结果是false。这时候应该用if testlevel 1或者if testlevel ! null and level 1.toString()热搜词里有mybatis 单个数字字符比较说的就是这个问题。这是MyBatis表达式的一个经典陷阱尤其在char、String类型和Integer类型混用的时候最容易出错。我的建议是Mapper层的参数类型尽量统一用包装类型比较时先判空再比较避免自动拆箱带来的NPE空指针异常。6.3 安全加固SQL注入、密码加密与敏感信息脱敏上线前安全这块不能省。密码存储绝不能使用MD5已能被破解推荐BCrypt加盐加密。在SpringBoot中直接使用BCryptPasswordEncoderBean 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 1long_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执行次数、耗时、并发等统计信息。通过对比executeCount和maxTimes很容易发现哪些SQL执行频繁且耗时高然后针对性地优化索引或调整SQL。需要特别说明Druid监控页面在生产环境必须配置强密码或者直接关闭暴露监控页面等于把数据库表结构、慢SQL裸奔给攻击者看。7. 给新手的几条实操建议和后端学习的延伸方向如果你照着这份源码学习或者准备搭建自己的在线教育平台我强烈建议按照下面的顺序走一遍而不是上来就埋头看代码第一步把数据库脚本导入MySQL然后用Navicat或DataGrip把所有表结构看一遍重点是订单表、课程表、用户表之间的关联。为什么先看表结构因为一切业务逻辑最终都是围绕表数据在转理解了表结构代码里的Service层逻辑基本能猜个八九不离十。第二步把项目启动起来用Postman接口调试工具逐个调用登录、课程列表、课程详情、下单这几个核心接口看返回的JSON结构。对照前端页面的展示效果理解前后端数据交互的格式约定。第三步找一个完整的功能链路去追踪源码比如用户登录→查看课程详情→免费试看→下单→支付回调→开始学习把这个链路涉及的所有Controller→Service→Mapper代码读一遍。这是最快理解整个项目的方法。第四步自己尝试加一个小功能比如课程收藏或者讲师关注。从设计表结构开始到后端接口开发再到前端页面实现全流程走一遍。只有亲手改过代码你才能真正理解这套源码的设计思路。关于后续的学习方向在线教育平台做完之后你有几条进阶路线一是把Redis用得更深入——缓存穿透、击穿、雪崩的解决方案分布式锁在防重复下单中的应用二是引入消息队列如RabbitMQ处理异步任务比如发送下单成功通知、视频转码状态回调三是学习Elasticsearch把课程搜索从MySQL的LIKE查询升级为全文检索支持更复杂的搜索需求。我在实际部署这类项目的过程中还有一个感触很深的教训线上环境一定要做数据库的定期备份并且要验证备份文件能恢复而不仅仅是用定时任务把备份文件扔到磁盘上就完事。有一次生产环境出现了误删数据恢复时才发现前一个月的备份文件损坏那种半夜处理事故的经历经历过一次就不想再有第二次。如果你正在运营或计划运营一个在线教育平台这个建议一定要记住。另外如果你打算用这套项目去面试不要只停留在我会用框架的层面。面试官最常问的点是JWT的认证流程怎么设计MyBatis的一二级缓存有什么区别订单状态机如何避免重复支付Redis在项目中用在哪里这些在这篇文章里都有覆盖你应该在自己的项目经历描述中把这些技术点的为什么讲清楚——为什么用JWT而不用Session为什么列表要缓存为什么订单要做幂等——这才是面试官最看重的思考深度。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。