SpringBoot Vue MyBatis MySQL 这套组合在在线教育系统这个赛道里几乎快成为“标配模板”了。但你真去搜源码时会发现绝大多数号称“完整版”的项目打开以后要么只是一个课程列表配一个后台管理页面要么订单是假的、权限是写死的、视频播放直接丢一个 mp4 链接。真正能跑通“学生选课—下单支付—视频学习—进度记录—后台统计”这个完整闭环的并不多。这套源码的价值也正在这里。它不是给你一个“看起来像教育网站”的壳子而是把在线教育业务里最麻烦的几个环节——订单状态流转、视频资源托管、多角色权限控制、数据统计报表——都用企业级项目的标准方式做出来了。不管你是做毕业设计、给中小培训机构搭建自用平台还是想系统学习前后端分离项目的完整落地过程这份源码都值得花时间一行一行读透。我用这套代码跑了将近一个月中间踩了不少坑也看清楚了很多设计上的取舍。这篇文章就按照我实际的理解路径来拆解这套系统先讲为什么选这套技术栈再讲业务模块划分然后逐个核心功能点分析实现思路最后是数据库设计和部署阶段最容易被卡住的那些问题。1. 为什么是这套组合SpringBoot Vue MyBatis MySQL 的选型逻辑1.1 后端选 SpringBoot关键在于“边界感”先说 SpringBoot。可能有人会觉得“企业级”就应该上 Spring Cloud、上微服务、上 Docker、上 K8s不然不够高大上。但这是个典型的过度设计。在线教育系统这个体量核心用户量在几千到几万这个量级的时候单机 SpringBoot 完全扛得住反而上了微服务之后服务拆分的成本、运维成本、分布式事务的成本会直接把小团队拖垮。SpringBoot 在这个项目里的优势不是技术先进而是它的工程化边界非常清晰。自动装配帮你去掉了大量 XML 配置起步依赖让 Maven 的依赖管理变得可控内置 Tomcat 让部署变成“打包—扔服务器—启动”三步。更重要的是SpringBoot 对分层架构的约束很自然Controller 层接收参数、Service 层处理业务、Mapper 层操作数据、实体类承载字段。这套代码里的模块划分也是按这个思路来的登录鉴权、课程管理、订单中心、支付回调这些模块各自独立不会出现一个 Controller 里塞所有接口的情况。如果你以后要把这个项目升级成微服务架构SpringBoot 的应用层代码基本不用改只需要把模块按服务边界重新拆包加上注册中心和服务间调用框架就行。这就是“边界感”带来的红利。1.2 Vue 负责的不仅仅是页面而是整个交互状态Vue 在这套系统里承担的是标准的前后端分离职责。它其实不是“写页面”而是管理页面的状态用户登录之后 token 怎么存、购物车和订单状态如何同步、课程列表的筛选条件怎么在组件之间共享、后台管理里不同角色的菜单如何动态渲染。在线教育管理系统的前端界面说直白点就是大量的表格、表单、弹窗、树形控件和状态标签。这类场景 Vue 的组件化开发模型非常合适。每个页面拆成小组件数据通过 props 往下传、通过事件往上抛逻辑清楚也方便多个人协作开发。这套源码的前端部分有一个很值得学习的点Axios 请求封装。统一拦截器里做了三件事——请求头自动携带 token、收到 401 时自动跳转登录页、统一弹窗显示后端返回的错误信息。这看起来很基础但很多从零自学的开发者会忽略导致每个页面都重复写一遍错误处理逻辑。源码里用了一个统一的 request.js 就把所有 HTTP 请求管起来了这是非常工程化的做法。1.3 MyBatis 是“看得见 SQL”的持久层框架MyBatis 在这套系统里的地位其实被很多人低估了。它和 JPA 最大的区别在于JPA 是帮你自动生成 SQL而 MyBatis 是让你手写 SQL、框架负责执行。后者听起来麻烦但在业务复杂的时候反而更可控。在线教育系统里有两类典型的复杂查询第一类是课程列表页的多条件筛选关键字、分类、价格区间、上架状态会动态组合第二类是后台数据中心的各种统计报表比如课程销量趋势、用户增长曲线、订单转化率。这两类需求如果全部靠 JPA 的 Specification 或者 QueryDSL写出来的代码会非常拗口而且 SQL 是怎么生成的、性能如何你心里没底。MyBatis 把这些都变成了 XML 里可见的动态 SQL。什么时候加这个查询条件什么时候不加全部由if、where、foreach标签控制逻辑一目了然。而且手写 SQL 还有一个好处可以精确控制索引的使用、JOIN 的执行顺序、查询的字段列表。这些对性能的优化空间在数据量上来之后会体现得非常明显。1.4 MySQL 是这套系统最稳的“压舱石”MySQL 的选择很务实。在线教育系统的数据模型本质上还是关系型的用户、订单、课程、章节、学习记录都是强关联的结构化数据用关系型数据库最顺手。MySQL 在这个量级下的表现很稳定而且生态成熟出了问题网上一搜就有答案。这源码用的是 MySQL 8.0InnoDB 引擎字符集 utf8mb4。记住 utf8mb4 这一点非常关键因为课程标题和用户昵称里会经常出现 emoji 表情字符如果用 utf8那些字符存进去直接变问号。另外MySQL 8.0 默认的事务隔离级别是可重复读REPEATABLE READ在订单和支付的场景下这个隔离级别是合适的可以避免一部分并发问题。2. 这套源码到底覆盖了多少业务模块拆解与功能地图拿到这套源码之后第一步不是急着跑起来而是先看目录结构和数据库脚本搞清楚系统到底有多少张表、多少个模块。2.1 三条业务主线学生端、讲师端、管理端一套真正完整的在线教育系统绝不是只有“学生看课”和“管理员管课”两端。它最少应该有三条业务线这套源码正好把这三条都覆盖了学生端C端前台用户注册登录、课程浏览与搜索、课程详情查看、下单购买、视频播放、学习进度记录、课程评价。讲师端内容供给讲师可以上传课程、管理课程章节、查看自己课程的基本数据。讲师不是管理员权限范围只限于自己的课程。管理端平台运营用户管理、课程分类管理、课程上下架审核、讲师审核、订单管理、退款处理、数据统计大屏。这三条业务线在数据库中对应的数据范围是不一样的这也直接决定了权限设计的复杂度。如果一套系统只有两张表、三个接口那不需要权限但有了讲师端和管理端之后就必须有角色和权限体系了。2.2 核心功能模块的覆盖情况我用一张表把这套源码的模块覆盖情况和实现要点列一下方便你对照自己的需求做检查业务模块核心功能点实现要点用户中心注册、登录、个人信息JWT 无状态鉴权、密码 BCrypt 加密课程中心课程分类、列表、详情、搜索多条件动态 SQL 分页查询视频学习视频播放、断点续学m3u8 流媒体、播放进度定时上报订单交易下单、支付回调、退款订单状态机、支付签名校验、幂等性处理评价系统课程评分、评论列表一对一课程关联查询讲师管理课程上传、章节维护按讲师 ID 做数据权限隔离系统管理用户、角色、菜单、权限RBAC 模型、注解 AOP 权限校验数据统计销售趋势、课程排行聚合查询、日期分组你把这张表和你自己的需求对比一下就能发现这套源码可以作为各种在线教育业务的基座。UI 换一换、字段加一加、支付渠道换一下就是一个新的平台了。2.3 模块之间如何通信Service 层的职责边界这套代码在模块通信上做得很规范。比如在“学生购买课程”这个流程里调用链是订单 Controller → 订单 Service →校验课程状态→创建订单→调用支付接口→异步回调通知→回调 Service 更新订单和用户课程关系。每一步的职责都很清楚Service 不会跨模块去直接操作别的 Mapper。我遇到过很多项目Service 之间互相调用随意甚至 Controller 里直接操作 Mapper这样的代码三个月之后连作者自己都不愿意看。这套源码的做法是每个业务模块有自己的 Service 接口和实现类跨模块的调用只能走对方的 Service不直接访问对方的 Mapper。这个约束虽然是软性的但对于一个多模块项目来说是保证可维护性的底线。3. 学生端核心链路课程浏览、视频播放、学习进度到底怎么落地的学生端的体验决定了这个产品的生死。这一章我从学生视角出发把这条链路上几个最核心的技术点拿出来分析。3.1 课程列表的多条件筛选是怎么用 MyBatis 动态 SQL 实现的课程列表页是用户进入平台后最先看到的界面它要支持按分类、按价格区间、按排序方式销量、最新、评分来筛选课程。这类需求如果用硬拼接字符串的方式写 SQL很容易产生 SQL 注入问题如果用多个 if 判断去拼条件代码又会非常啰嗦。MyBatis 的 XML 里用where标签加if标签就能非常优雅地解决这个问题select idselectCoursePage parameterTypemap resultTypecourseVo SELECT c.id, c.title, c.cover, c.price, c.sales, c.score, t.name as teacherName FROM course c LEFT JOIN teacher t ON c.teacher_id t.id where if testcategoryId ! null and categoryId ! AND c.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND c.title LIKE CONCAT(%, #{keyword}, %) /if if testminPrice ! null AND c.price #{minPrice} /if if testmaxPrice ! null AND c.price lt; #{maxPrice} /if AND c.status 1 /where ORDER BY choose when testsortType salesc.sales DESC/when when testsortType newestc.create_time DESC/when otherwisec.id DESC/otherwise /choose LIMIT #{offset}, #{pageSize} /select这里有三个细节值得学习第一where标签会自动处理第一个条件前面的 AND不用自己写 WHERE 11。第二排序条件用了choose做白名单控制而不是直接把前端传的 sortType 拼进去这有效防止了 SQL 注入。第三分页用了手写 LIMIT 配合 offset如果数据量更大后面可以换成 PageHelper 这种分页插件但原理是一样的。3.2 视频播放为什么选用 m3u8而不是直接放 mp4这是在线教育系统里最容易被问到、也最值得展开的技术点。很多初学者的做法是把视频传到一个 OSS 上拿到一个 mp4 的 URL前端直接丢给video标签播放。短期的确能跑通但用到真实业务里会出现三个问题加载慢、容易下载盗走、拖动不流畅。所以这套源码采用的方式是视频先做转码切成 m3u8 索引文件加 ts 视频分片再放到 CDN 或 OSS 上。m3u8 本身是一个文本文件里面记录了一串 ts 分片的地址列表。播放器先加载 m3u8然后根据自己的带宽和拖动位置去逐段加载 ts 分片。这种方式下用户打开视频只要先加载前面几个小分片就能开始播不用等整个视频下载完。前端播放 m3u8 最常用的方案是// 基于 hls.js在支持 MSE 的浏览器上播放 m3u8 if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(videoUrl); hls.attachMedia(videoElement); hls.on(Hls.Events.MANIFEST_PARSED, () { videoElement.play(); }); }m3u8 还有一个好处可以做防盗链。给 ts 分片的 URL 加上时间戳签名过期就失效别人扒走一个 m3u8 文件也没法正常播放。源码里返回视频播放地址的接口就是动态生成带签名的 URL过期时间一般设 30 分钟课程观看需要登录鉴权这就基本堵住了盗链的路。在实际操作中这套源码把视频地址处理成了两层数据库存的是原始视频 key 或者原始 mp4 地址接口返回给前端的是经过签名和转码后的播放地址。这样的解耦设计方便你后面换云服务商或者自建转码服务不需要改表结构。3.3 学习进度和断点续学一个很“小”但很影响体验的功能断点续学是判断一套在线教育系统是不是“能用”的门槛功能。用户上次看到 12 分 35 秒今天打开课程应该从 12 分 35 秒继续而不是从零开始。这套源码里对应的是一张学习记录表字段大致是用户 ID、课程 ID、章节 ID、上次播放位置秒、观看总时长、最后更新时间。前端在播放视频时会每隔 15 秒调用一次上报接口把当前播放位置传回后端。这个上报接口是做了防抖的如果播放位置和上次上报的时间差太短就直接忽略如果用户在拖动进度条也会立即触发一次上报。后端在返回课程章节列表的时候会顺便带上每个章节的学习进度// 课程章节 VO 中增加进度字段 ChapterProgressVO vo new ChapterProgressVO(); vo.setChapterId(chapter.getId()); vo.setTitle(chapter.getTitle()); vo.setVideoUrl(signedUrl); vo.setDuration(chapter.getDuration()); vo.setProgress(progressMapper.selectLastPosition(userId, chapter.getId()));前端拿到进度之后如果是首播则从 0 开始否则从上次位置继续播放。这个功能在用户看来很不起眼但在“每天学一点”的网课场景里它决定了用户愿不愿意每天打开你的平台。4. 后台管理的权限体系从数据库表到注解拦截的完整链路后台管理系统的安全性直接决定了整个平台的数据安全。我见过太多后台管理系统只有一套“登录了就能进”的简单判断结果任何一个普通用户登录之后都能访问管理员接口。这套源码在权限上做了三个层面的事RBAC 表设计、后端接口鉴权、按钮级权限控制。4.1 RBAC 模型用户—角色—菜单的关系表设计这套权限体系遵循标准的 RBAC 模型数据库里用了五张表sys_user系统用户表存用户名、密码、状态。sys_role角色表存角色名称和角色编码如 ADMIN、TEACHER、STUDENT。sys_menu菜单/权限表可以是菜单项也可以是按钮权限点。sys_user_role用户和角色的关联表。sys_role_menu角色和菜单权限的关联表。为什么要拆两张关联表而不是直接在用户表里写一个 role 字段因为现实业务中一个用户往往有多个角色比如“运营A”可能同时是课程审核员和订单管理员而且角色和权限之间的关系也是多对多的。用关联表设计未来增减权限只需要修改关联关系不需要动业务表结构。4.2 后端接口鉴权拦截器 自定义注解 AOP这套源码的后端鉴权链路设计得很典型我在其他企业项目里也经常看到同样模式第一步用户登录成功之后后端用 userId 和角色信息生成一个 JWT token返回给前端。前端后续请求都在 header 里带Authorization: Bearer token。第二步后端注册一个拦截器HandlerInterceptor在请求进入 Controller 之前统一解析 token。token 有效、用户存在就把用户信息放入 ThreadLocaltoken 无效或过期直接返回 401。第三步也是这套源码最有借鉴价值的地方它定义了一个RequiresPermission自定义注解然后通过 AOP 切面来校验接口权限。比如删除订单接口上标了RequiresPermission(order:delete)切面会在方法执行前判断当前用户是否拥有order:delete这个权限点没有就抛出“无权限访问”异常。这种设计比传统的“只判断 isAdmin”要灵活得多你不用为每个接口单独写校验逻辑只需要在方法上加一行注解权限点名称对应数据库里 sys_menu 表的权限标识。以后要调整角色权限直接改数据库关联表就行代码不用动。4.3 数据权限讲师只能看到自己的课程怎么实现接口权限解决的是“能不能访问这个功能”数据权限解决的是“能访问哪些数据”。这套源码里讲师端的核心逻辑是讲师登录之后只能看到和维护自己创建的课程不能看到别人的课程。数据权限实现方式其实不复杂讲师登录时把 userId 存入上下文在查询课程列表的 SQL 中固定拼接一个条件——AND teacher_id #{currentUserId}。但这个拼接不能散落在各个方法里否则容易漏。源码里是把当前用户信息封装成了一个LoginUser对象通过 AOP 或 MyBatis 拦截器统一注入条件这样新增课程查询方法的时候自动就有了数据权限限制不容易犯错。这一点单独拿出来说是因为它是“企业级”和“课程设计作业”的重要区别。普通项目不考虑数据权限所有用户看到的是同一份数据企业项目必须考虑因为多个讲师共用一个后台数据串了就是事故。5. 数据库建模从订单表到课程表这些表为什么要这样设计表结构是这套源码的骨架。我在看数据库脚本时最关注的不是哪些表存在而是每个关键表里的字段是怎么设计的因为在字段之间藏着大量设计决策。5.1 商品表为什么课程表要把讲师独立出来课程表的核心字段大概是id、title、cover、price、original_price、teacher_id、category_id、status、sales、score、create_time。这里有两个隐藏的设计点第一个teacher_id是一个外键概念指向讲师用户 ID。讲师自身的信息名字、头像、简介是放在用户扩展表里的。把课程和讲师拆开是为了“一人多课”“一课一师”的关系建模更清晰也方便课程列表做表关联查询。第二个课程的状态字段。课程状态不是简单的 1 和 0这套源码里至少有三种状态草稿0、已上架1、已下架2。同时可能还有“待审核”状态。为什么需要这么细因为在真实运营里课程必须经过审核才能上架而且课程下架不等于删除用户之前购买的课程还得能继续看。如果只用 1 和 0 表示“上下架”就没法表达审核流。5.2 订单表为什么要把课程名称和价格快照冗余进去订单表是这套系统里值得反复研究的表。核心字段是id、order_no、user_id、course_id、course_title、course_cover、amount、pay_status、pay_type、pay_time、refund_status、create_time。这里的关键决策是冗余了course_title和course_cover。很多人会不理解课程名称和封面不是能通过 course_id 关联查出来吗为什么要多存一份原因很简单课程信息是可变的。如果一门课从 99 元涨价到 199 元或者课程名称改版了而你的订单表只存了 course_id那么所有历史订单查出来的都是现价现名订单历史就彻底对不上了。订单表冗余商品快照是电商系统的通用设计方法在线教育本质上也是电商课程就是商品。所以订单表必须保存下单那一刻的商品信息快照才能保证后续对账和售后处理时有据可依。5.3 金额字段为什么坚决不能用 float 和 double在线教育系统里涉及钱的字段规范是用decimal精度设为decimal(10,2)。原因很简单float 和 double 在计算机里是二进制浮点数存储 0.1 0.2 会得到 0.30000000000000004。付款金额如果出现这种误差支付接口直接校验不通过。这一点我在自己项目中踩过坑。之前有个同事把费用字段设成 double结果订单金额明明是 19.9传给支付平台的时候变成了 19.9000000001导致签名校验失败。后来全表排查把所有涉及金额的字段统一改成了 decimal。这套源码在这一点上是规范的不用返工。5.4 逻辑删除为什么业务数据不能物理删除看数据库脚本的时候可以注意到核心业务表基本都有一个deleted字段默认值为 0。这就是逻辑删除删除操作不是执行 DELETE 语句而是把deleted字段更新为 1。在线教育系统里用户购买记录、订单记录、课程评价这些数据都不能物理删除因为它们是未来可能产生纠纷的凭证。如果用户支付了一门课、后来课程被管理员删了订单记录必须还在这样退款和客服处理才有依据。所以逻辑删除是业务安全的底线查询时默认加上AND deleted 0条件即可。5.5 索引设计先把高频查询路径的索引建好这套数据库脚本里的索引设计也做得比较规范。高频的查询路径主要有三类对应的索引如下用户登录查询username上建唯一索引。订单查询order_no建唯一索引user_id建普通索引。课程列表category_id和status建联合索引。关于联合索引有一个常见误区不是查询条件越多索引越好。索引列的顺序也要注意区分度高的列优先。比如category_id可能只有十几个分类区分度不高status只有 0 和 1 两个值区分度更低。但把两者组合成联合索引对“分类 上架状态”这个最常见的筛选路径还是有效的因为索引覆盖了两列的查询条件。索引不是越多越好每增加一个索引都会拖慢写入速度所以只需要把最核心、最高频的查询路径覆盖住就行。6. 把源码跑起来的完整过程与踩坑记录这部分是实际操作中最容易劝退人的地方。环境问题、版本兼容问题、依赖问题任何一个卡住都会让新手陷入一万个为什么。我把自己跑这套源码的完整过程梳理一遍顺手把最容易踩的坑标出来。6.1 环境版本推荐先统一版本再开始拿到源码第一步先确认你的本机版本是否匹配。我推荐的环境组合是组件推荐版本说明JDK1.8 或 11SpringBoot 2.7.x 以下建议 JDK 8避免升级到 17 带来的依赖问题Maven3.6 或 3.8Maven 3.9 也能用但个别旧插件有问题MySQL8.0如果源码里的驱动还带 cj说明它是按 8.0 来写的Node.js16 或 18Vue 2 项目一般 14~18 都能跑Vue 3 建议 18npm8.x配合 Node 16/18 使用需要注意的一点不要看到 SpringBoot 有 3.x 就把版本昇上去。SpringBoot 3.0 基于 Jakarta EE很多旧版 mybatis-spring-boot-starter、PageHelper 的包名不兼容会导到启动直接报 ClassNotFound。源码用什么版本本地就用什么版本这是跑源码的黄金法则。6.2 MySQL 8.0 安装与连接最容易卡住的三个配置MySQL 8.0 的安装配置在热搜里常年上榜说明这确实是很多人的门槛。我总结三个必须处理的配置第一是密码加密规则。MySQL 8.0 默认的加密方式是caching_sha2_password但一些老版本的 JDBC 驱动和客户端不支持会报认证失败。可以用 SQL 把用户切换成mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码; FLUSH PRIVILEGES;第二是时区问题。连接串里必须加serverTimezoneAsia/Shanghai否则 JDBC 连接 MySQL 8.0 会报时区异常。spring: datasource: url: jdbc:mysql://localhost:3306/edu_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse第三是驱动类名。新版驱动类名是com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver。如果项目里没配SpringBoot 会自动识别但为了稳妥最好显式写出来。6.3 前端依赖安装与跨域问题前端项目一般拿到手先跑npm install。这里最常见的坑是 npm 源访问慢或者直接被墙解决办法是切换国内镜像源npm config set registry https://registry.npmmirror.com然后是跨域问题。开发阶段前端的 Vite 或 Vue CLI 需要配置开发代理devServer proxy让/api开头的请求转发到 SpringBoot 的 8080 端口否则浏览器会报跨域错误。注意这只解决开发阶段的问题。生产部署阶段跨域问题要交给 Nginx 反向代理解决。同一个域名下Nginx 根据路径前缀把请求分发到前端静态资源或后端服务server { listen 80; server_name edu.example.com; location / { root /opt/edu-front/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }6.4 MyBatis 相关的坑自动建表、缓存、动态 SQL 判空6.4.1 表不存在怎么自动创建网上有个热词叫“springboot mybatis 当表不存在自动建表”这其实是很多人的真实需求项目换了一台新服务器数据库是空的希望启动时自动建表。有三种常见方式用spring.sql.init配置加上schema.sql脚本SpringBoot 自带支持。集成 Flyway把建表脚本纳入数据库版本管理这种方式最规范。在项目启动类里写一个ApplicationRunner执行一段初始化 SQL手动且灵活。第三种方式在中小项目里很实用简单直接不用引入额外依赖。源码里的建表脚本是单独的.sql文件你手动执行一次或者配上启动自动执行都行。6.4.2 MyBatis 一级缓存导致的数据不一致MyBatis 的一级缓存默认开启作用域是 SqlSession。在 Spring 集成环境下每个数据库操作通常都会开启和关闭新的 SqlSession一级缓存基本没有跨方法影响。但如果你在同一个事务里多次执行同一条 SQL一级缓存也会生效导致第二次查询拿不到别的地方 update 之后的最新数据。这个坑比较隐蔽在订单支付回调里容易踩到先查订单状态发现是“待支付”然后另一个方法更新了订单状态回到同一个事务里再查订单发现还是“待支付”。实际原因是同一 SqlSession 的缓存。解决办法是在关键查询上强制清缓存Autowired private SqlSessionTemplate sqlSessionTemplate; sqlSessionTemplate.clearCache();大部分时候你不会遇到这个问题但一旦遇到要能定位到一级缓存这层。6.4.3 动态 SQL 的条件判空数字 0 被过滤的经典问题MyBatis 的if teststatus ! null and status ! 这行代码在 Java 中如果 status 是 Integer 类型且值为 0那么status ! 这个比较在处理时可能为 false导致 0 这个合法值被过滤掉SQL 里对应的条件就没了。这个坑在查询订单状态和课程状态时特别容易出现。状态 0 往往代表“草稿”或“待支付”如果被过滤查出来的结果就包含其他状态的数据。正确的判断方式是只判断 nullif teststatus ! null AND status #{status} /if所以遇到动态查询的条件字段是数字类型时不要去判断空字符串这是一个很实用的经验。6.5 事务与并发支付回调的幂等性和数据库唯一索引支付回调是订单模块里最重要的接口。用户付完钱支付平台会向你发送一个异步通知通知内容包含订单号、金额、支付状态。这个通知可能会发多次因为可能超时重发所以回调接口必须保证幂等处理一次和处理多次结果是一致的。实现幂等有三个层级的配合。第一层订单表对order_no建唯一索引防止重复插入第二层处理之前先查一次订单状态如果已经从“待支付”变成了“已支付”直接返回成功不再重复处理第三层回调处理逻辑要用Transactional包裹把“更新订单状态”和“开通用户课程权限”放在同一个事务里要么都成功要么都回滚。并发场景下支付回调可能同时被两个线程触发。先查订单状态这一步其实有竞态条件稳妥的方案是在 SQL 层面做原子更新UPDATE orders SET pay_status 1, pay_time NOW() WHERE order_no #{orderNo} AND pay_status 0影响行数为 1 说明这个更新是自己做的说明之前没人改过状态影响行数为 0 说明已经被处理过了直接返回成功即可。这套源码里对支付回调的处理基本就是这个思路很值得照抄。6.6 课程视频播放的兼容性Safari 的坑和其他浏览器最后说一个很实际的兼容性坑。m3u8 的播放方案在 Chrome、Edge 上可以用 hls.js 解决但 Safari 原生支持 HLS不需要也不应该走 hls.js。所以前端写播放逻辑时要做能力检测function canPlayHls() { const video document.createElement(video); return Boolean(video.canPlayType(application/vnd.apple.mpegurl)); } if (canPlayHls()) { // Safari 直接赋值 src 播放 videoElement.src videoUrl; } else { // 其他浏览器用 hls.js if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(videoUrl); hls.attachMedia(videoElement); } }这个分支判断不加就会出现在某些电脑上用 Chrome 能播、用 Safari 打不开视频的情况。做在线教育系统的视频兼容性这部分必须提前考虑。跑通这套源码之后我最大的体会是一个项目的工程价值不只体现在功能能不能用更体现在代码结构、数据库设计和异常处理这些“看不见的地方”。这套系统里订单快照冗余、逻辑删除、数据权限隔离、支付回调幂等这些设计哪一个单拆出来都能写一篇文章而它们都在这套源码里有机地整合在一起。建议你不要满足于把它跑起来而是把订单流程和权限体系这两块代码从头到尾读一遍这是目前网上那些零散的教程最给不了你的东西。