
1. 在线教育管理系统到底做的是什么1.1 为什么很多在线教育项目“开发一时爽上线火葬场”试过真的把一个在线教育系统从零开始做成“完整版”的人都会认同一个观点真正难的从来不是“课程列表展示”这种首页功能而是背后的业务闭环。拿一个比较典型的场景来说学员在移动端看到了课程封面点击“立即购买”这时候后端要做什么先查当前课程是否上架、库存是否受限、下单人是否已经拥有该课程然后生成订单记录接着拉起支付等支付回调后还要给学员开通学习权限并且记录一条账户流水。整个过程只要一个环节没处理好后面就会出现“用户付了款但看不了课”或者“退款了一次但课程权限还在”这类问题。这套基于 SpringBoot Vue MyBatis MySQL 架构的企业级在线教育管理系统源码最大的价值不是某个页面多漂亮而是把这类业务闭环拆成了一个一个可以执行的模块。用户端、讲师端、管理端共用一套权限模型课程、订单、学习记录、问答、考试等数据落库清晰比较适合三类人一是刚学完 Java Web 想找一个完整项目练手的同学二是公司里需要快速搭建内部培训平台的技术人三是接外包项目时需要一套基础模板的团队。1.2 技术选型不是跟风四件套由来有原因SpringBoot 解决的是“配置地狱”。以前做 SSM 项目要写一堆 XML而 SpringBoot 用 starter 把常用组件自动装配好内嵌 Tomcat 也让打包部署从 war 包变成单个 jar。在线教育系统里要用到拦截器做登录校验、定时任务做课程统计、消息队列处理异步通知这些都是 Spring 家族的主场。Vue 在前后端分离项目里的地位有点像装修行业里“风格百搭”的解决方案。它既有响应式数据绑定又能用单文件组件把后台表格、课程卡片、视频播放器拆开复用。加上 vue-router、vuex/pinia 这套组合做后台管理的效率和可维护性都不算差。MyBatis 的选择更直接教育系统的报表统计和订单查询离不开复杂 SQLMyBatis 允许你掌握完整 SQL定位慢查询也方便。MySQL 则是对中小规模系统比较友好事务、索引、备份都有成熟方案在线教育这个量级的数据通常不会成为瓶颈。组件选它的理由常见替代SpringBoot自动装配、生态丰富、部署简单Spring Cloud微服务时Vue组件化开发、状态管理、生态成熟React、AngularMyBatisSQL 可控、适合复杂查询JPA、MyBatis-PlusMySQL稳定、运维成本低、文档多PostgreSQL、Oracle注意这里不是非要四件套不可但如果你打算拿这套源码做二次开发尽量保持与原作者一致的技术栈否则升级依赖时容易扯出一堆兼容性问题。2. 数据库设计与 MyBatis 细节决定项目上限2.1 核心表的拆分思路在线教育项目的数据库表设计不能照着“用户表 课程表”这种极简模式做。你需要先想清楚有哪些业务角色学生、讲师、运营、超级管理员。围绕这些角色最基础的表也要覆盖以下几个维度用户权限域sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu课程内容域edu_course、edu_chapter、edu_lesson、edu_course_attach交易域edu_order、edu_order_item、edu_refund_record学习过程域edu_learning_record、edu_homework、edu_exam_record互动域edu_question、edu_answer、edu_notice这里我特别想提醒课程与订单的关系。很多新手在设计订单表时习惯只保存一个 course_id 外键这是隐患。课程标题、价格、封面这些信息后续可能被讲师修改如果订单表里没有冗余快照等你要导出报表或处理退款时就会发现历史订单展示的数据已经对不上了。正确做法是在订单明细表里冗余课程名称、课程封面、课程原价、实付金额、优惠金额等字段查询时直接用这些快照而不是 join 课程表。2.2 公共字段与软删除设计不管哪张业务表都建议统一加上 create_time、update_time、deleted、version 这四个字段。create_time 和 update_time 在插入和更新时自动维护推荐用数据库的 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP不要完全依赖 Java 代码去 set因为数据库层兜底更可靠。deleted 是做逻辑删除的标记一般默认 0 表示正常1 表示已删除这样误删数据还有恢复余地注意所有查询默认加 deleted 0避免展示已删除数据。version 字段是给乐观锁用的。比如学员批量领取优惠券、管理员修改课程审核状态这些并发场景下 update 如果直接按旧数据更新会出现“丢失更新”。使用 UPDATE ... SET version version 1 WHERE id ? AND version ?当受影响行数为 0 时表示数据已被别人改过需要提示重试。这个设计对订单状态流转尤其重要。2.3 MySQL 连接与 MyBatis 配置实战application.yml 里最容易被忽略的是时区和编码。示例配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/edu_platform?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: edu_user password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.edu.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置会让数据库的 course_name 自动映射成 Java 的 courseName省掉大量 resultMap。生产环境不建议开 StdOutImpl 日志否则每个查询都会往控制台刷 SQL最好把 mybatis 的 log-impl 换成 Slf4jImpl再用日志框架控制输出。说一句连接池参数HikariCP 的 maximum-pool-size 不是越大越好。每个连接背后都是一个线程和一个 socket池子设成 50 不代表并发能力变强反而会让数据库的连接数被榨干。一般经验是 core 机器 4C8G 部署 MySQL 时Hikari 池给 20 以内比较合理。如果你发现系统经常报 Connection is not available先去查慢 SQL 而不是盲目加连接池。2.4 MyBatis 缓存和分页插件的正确用法MyBatis 的一级缓存是默认开启的作用范围是同一个 SqlSession。在 Spring 集成后每个 Mapper 方法默认在一个短生命周期 SqlSession 里执行一级缓存帮助有限但在同一个事务里连续查询同一条件时能少一次数据库交互。它的坑在于如果在一个长事务里做大量查询SqlSession 一直不关闭缓存里就可能堆积一堆对象导致内存压力变大。所以不要在 Service 层用一个超长事务包裹大量只读查询。二级缓存很多人一上来就开启我反而不推荐。MyBatis 默认的二级缓存是进程级缓存一旦系统部署多个实例每个实例缓存各自为政更新一个节点后其他节点还在读旧数据。更重要的是多表查询的缓存失效问题很麻烦查课程信息时缓存了课程表关联的分类名称但如果分类被修改相关的缓存并不是总能正确清除。真要做缓存加速建议绕过 MyBatis 二级缓存直接在 Service 层用 Redis 缓存课程详情、首页 Banner 这类高频读数据。这样缓存策略可控也不会出现脏数据。分页插件这里必须提一个典型错误。PageHelper 的PageHelper.startPage(pageNum, pageSize)方法必须在紧接着的下一条 SQL 查询之前调用而且中间不能穿插其他查询因为它基于 ThreadLocal 在当前线程上下一个 Mapper 方法生效。如果写在工具方法里或者在分页查询前执行了一次 count 查询分页就会失效甚至把别的查询也“分页”了。我的建议是尽量使用 MyBatis-Plus 的PaginationInnerInterceptor或者把分页逻辑集中在单独 Mapper 方法里不要让分页参数在 Service 层到处传。下面是分页常见问题速查现象原因处理查询返回全部数据startPage 和 SQL 之间插了查询把 startPage 放到紧挨目标 SQL 前分页总数不对count 语句被自定义 SQL 干扰手动指定 countSql或用 MyBatis-Plus 自动 count并发下分页参数串了ThreadLocal 未清理避免手动 usePage升级插件版本大深度分页慢limit 100000,20 全表扫描改为游标分页或 ids 分页3. Vue 前端实现与前后端联调3.1 准备 Vue 开发环境少踩版本坑拿到这套 SpringBoot Vue 的前后端分离源码第一件事不是急着跑npm run dev而是先确认三个前提Node 版本、npm 源、后端接口地址。Vue2 的项目对 Node 大版本要求宽松但 Vue3 Vite 建议 Node 18 以上另外建议把 npm 源切换成国内镜像否则 install 依赖能等得人想摔键盘。安装依赖后如果出现ERESOLVE unable to resolve dependency tree绝大多数是依赖版本冲突可以先删掉 node_modules 和 package-lock.json 再重新装。开发时浏览器装一个 Vue Devtools 插件非常值。它可以直接看到组件树、props、data、vuex 里的状态变化对排查“父组件改了数据但子组件界面没更新”这类问题效率比 console.log 高太多。项目里建议配置前端代理在 vue.config.js 里这样写module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } } }这样前端代码里请求/api/course/list开发环境会自动转发到后端 8081。很多人报“跨域”错其实不是后端没配 CORS而是前端没走代理直接用浏览器向 8081 发请求了。3.2 动态菜单与路由权限别把权限写死在路由表里在线教育后台一般分为系统管理、课程管理、订单管理、内容管理、数据统计等多个菜单。不同角色进入系统后看到的菜单不一样如果前端把路由全部写死在代码里就失去了权限控制的意义。比较常规的做法是后端根据当前登录用户的角色返回一个菜单树包含菜单名称、路由地址、组件路径和按钮权限标识前端拿到菜单树后用router.addRoute动态注册路由。这里有两个易错点。一个是刷新页面时路由会丢失因为 vuex 里的菜单是内存态所以需要把用户信息和菜单权限存到 localStorage并在刷新后重新拉取菜单再 addRoute。另一个是组件路径的写法通常用懒加载component: () import(/views/ item.component)但动态拼接 import 路径有时会被 webpack 编译报错稳妥做法是静态声明一个组件映射表再通过映射表取组件。按钮权限可以使用自定义指令v-permissioncourse:add配合后端接口鉴权双保险。3.3 路由传参query 和 params 选错容易丢数热词里反复出现“vue路由参数”这里说下实际项目里的两个经典场景。列表页跳转详情页很多人习惯this.$router.push({ path: /course/detail, query: { id: row.id } })query 参数拼在 url 后面刷新后不会丢适合课程 ID、订单号这种需要保留到地址栏的数据。但如果传的是查询条件对象比如筛选表单里的页码、分类、关键字全部放到 query 里会导致 URL 又长又丑code review 也可能被指出。params 是另一种传参方式this.$router.push({ name: CourseDetail, params: { id: row.id } })刷新页面时 params 里的数据会丢失除非配合路由配置里的 props 或者再通过查询接口重新取一次。在线教育系统里比较常见的做法是列表页只用 query 存 id详情页 mount 时读取this.$route.query.id如果为空就跳回列表页并提示“参数错误”。这种方式虽然粗暴但最不容易出幺蛾子。3.4 播放器模块与 m3u8 视频流的接入在线教育系统的核心功能之一是视频播放这里就回避不了 m3u8 格式。m3u8 是 HLS 协议的一个列表文件里面写了一段一段的 ts 切片地址播放器拿到 m3u8 之后按顺序加载切片好处是支持多码率、兼容性好尤其对移动端网络波动有天然容忍度。在 Vue 项目里接入 m3u8 播放最常用的组合是 video.js 加 videojs-contrib-hls或者直接用 hls.js。示例代码import Hls from hls.js if (Hls.isSupported()) { const hls new Hls() hls.loadSource(videoUrl) // 传入 .m3u8 地址 hls.attachMedia(videoElement) }这里提醒一个坑视频地址往往不是直接返回一个静态 m3u8而是经过防盗链签名的地址签名 URL 有时间限制。如果播放到一半 URL 过期切片请求会开始报 403。处理方式是尽量让后端返回一个较长有效期的地址或者根据业务场景在过期前重新获取播放地址并切换源。另一个坑是对跨域的支持HLS 里请求 ts 切片是浏览器发起的服务端必须允许跨域或走同域代理否则会间歇性黑屏。4. 认证、权限与核心业务安全设计4.1 登录鉴权链路的完整闭环在线教育平台如果连登录认证都做得松松垮垮后面的课程权限、订单数据都会出问题。目前中小团队最常见的方案还是 JWT 拦截器。用户登录后后端校验用户名密码生成一个 token 返回给前端前端把 token 存到 localStorage每次 axios 请求都在请求头带上Authorization: Bearer token。后端用一个拦截器解析 token如果合法就把用户信息放到 ThreadLocal业务代码里直接CurrentUser.get()获取当前登录用户。这个方案有几个细节要处理。第一token 不能设置得太长一般建议 2 到 4 小时但是用户在上课过程中可能长时间保持登录状态所以需要一个刷新机制后端提供一个 refreshToken 接口前端拦截 401 状态码自动刷新。第二用户退出登录后旧的 token 理论上有被重放的风险因此后端需要维护 token 黑名单或者用 Redis 存一份“有效 session”数据。第三管理员登录和学员登录建议走不同的登录入口不要共用一套登录逻辑否则后续做 ip 限制、异地登录提醒会很别扭。4.2 RBAC 权限模型落地的关键步骤RBAC基于角色的访问控制是后台系统权限最基本的一套模型在线教育系统里也不例外。实现时说穿了就是“用户-角色-菜单权限”三张核心表再加上两张关联表。后端在用户登录后根据用户 id 查角色再查角色关联的菜单权限。接口鉴权可以用 Spring Security 或 Apache Shiro也可以自己写拦截器实现。但要注意权限校验不能只在按钮层做后端接口必须同样校验否则前端绕过按钮直接调接口就完了。数据权限是另一层常见问题。比如讲师只能管理自己的课程运营只能看自己负责的班级数据。RBAC 模型如果不扩展数据权限光靠菜单权限是不够的。常用的两种做法是在业务表里加 owner_id 字段所有查询默认追加AND owner_id 当前用户或者通过 MyBatis 拦截器动态拼接数据权限 SQL。前者实现简单适合中小型系统后者灵活但容易误伤跨表查询。4.3 文件上传、XSS 与 SQL 注入三个老生常谈的坑在线教育系统必然会涉及考试附件、课程资料、头像图片上传尤其是上传 PDF 文件时很多人只校验了 Content-Type但文件头是能伪造的。比如用户把 exe 后缀改成 pdf 上传Content-Type 也可能被设置为 application/pdf但真正的文件头不是%PDF。所以服务端必须做双重校验先校验扩展名白名单再读取文件头部字节判断真实格式。上传后的文件也要放在 web 可访问目录之外通过专门的接口提供访问鉴权避免任意文件路径访问漏洞。XSS 攻击的典型场景是课程评价、问答评论。用户提交富文本内容里面可能藏着script标签。后端不能简单地把标签全过滤掉因为讲师可能会用富文本排版。这时可以用 Jsoup 的 Safelist 清洗富文本只保留允许的标签和属性。同时在前端也要注意不能直接用v-html渲染用户输入如果必须渲染得提前清洗。SQL 注入这块MyBatis 的#{}预编译能挡住绝大多数注入。真正危险的是${}它在 mybatis 里是做字符串拼接的比如排序字段、动态表名。如果业务上必须用${}一定要做白名单校验排序字段要么固定几个枚举值要么只允许字母数字和下划线。这是个老生常谈的坑但源码里被爆出${}直接拼接用户输入的案例依然不少。4.4 支付回调与订单状态幂等处理不能省交易类业务最怕“重复通知”。支付平台回调有可能因为网络原因发送多次如果每次回调都去给用户开通课程权限就会出现重复开课、重复加流水的问题。正确的做法是订单表里加一个 transaction_id 字段记录支付平台流水号并对这个字段建唯一索引支付回调处理逻辑第一步先查流水号是否存在存在就直接返回成功不存在才执行开课和更新订单状态的逻辑。伪代码public void handlePayNotify(PayNotifyDTO notifyDTO) { // 1. 校验签名 // 2. 根据 notifyDTO.getTransactionId() 查询是否存在 if (orderMapper.countByTransactionId(notifyDTO.getTransactionId()) 0) { return; } // 3. 更新订单为已支付 // 4. 给用户开通课程权限 // 5. 写入流水记录 }幂等设计还要考虑事务边界。最简单有效的办法是“先查后插”加唯一索引兜底或者利用数据库的分布式锁表。如果系统没有引入 MQ也可以直接在支付回调 Service 方法上加数据库悲观锁把相关订单行锁住但这样做性能一般只适合低并发场景。订单状态建议用枚举而不是裸字符串比如 0 待支付、1 已支付、2 已退款、3 已关闭避免代码里到处写魔法数字。5. 部署上线、Nginx 配置与问题排查5.1 数据库初始化与 MySQL 环境准备拿到源码后数据库部分通常有一个edu_platform.sql文件不要直接双击执行完就算完事。第一步先用 root 登录创建专用账号比如edu_user只授予该业务库的增删改查权限避免项目代码长期使用 root 连接。第二步确认字符集默认建议 utf8mb4因为 utf8mb4 能存 emoji 和生僻字否则学员昵称带个表情符号就保存失败。MySQL 8 安装时如果碰到 “caching_sha2_password” 认证插件导致客户端报错可以在创建用户时指定mysql_native_password或调整客户端驱动版本。初始化数据时建议把测试数据删掉。源码自带的演示账号、演示课程如果直接上线会影响真实业务。可以把初始化角色数据保留但用户、订单、学习记录这些演示数据必须清理干净否则线上运营数据会和测试数据混在一起。5.2 前后端打包发布后端打包执行mvn clean package -Dmaven.test.skiptrue会在 target 目录得到一个可执行 jar。部署方式可以简单用nohup java -jar edu-admin.jar 但生产环境更推荐用 systemd 管理这样服务崩了能自动拉起日志也能托管到 journald。JVM 参数上建议至少设置-Xms512m -Xmx1024m有精力的话再配置 GC 日志和堆转储路径。前端打包执行npm run build产出 dist 目录。Nginx 里最核心的是要处理 Vue history 路由刷新 404 的问题。如果前端路由用了 history 模式刷新/course/detail/123时 Nginx 会去找这个真实路径找不到就 404这时需要配置location / { root /var/www/edu; index index.html; try_files $uri $uri/ /index.html; }同时把/api反向代理到后端服务。如果 Nginx 和后端在同一台机器一般配置proxy_pass http://127.0.0.1:8081;。要注意proxy_set_header Host $host;和proxy_set_header X-Real-IP $remote_addr;不然后端拿到的客户端 IP 永远是 Nginx 的内网 IP登录日志、风控模块都会受影响。5.3 线上环境问题排查速查表现象可能原因排查 处理页面刷新 404Vue history 路由未配置 try_files在 Nginx location 中加入 try_files前端请求接口报跨域浏览器直连后端、代理没生效走 /api 代理或后端配置 CORS上传大文件失败Nginx client_max_body_size 太小 / 后端 multipart 限制两边都要调大建议 Nginx 先改接口突然变慢SQL 缺索引、慢查询、连接池耗尽开慢查询日志EXPLAIN 分析执行计划系统 UTC 时间比本地早/晚 8 小时数据库、JDBC、服务器时区不一致serverTimezone 统一为 Asia/Shanghai视频播放黑屏HLS 切片跨域、防盗链过期检查 ts 请求网络状态服务端允许跨域排查的时候不要一次改多个配置比如“接口慢”时同时调了连接池和索引最后根本不知道哪个生效。先开慢查询日志定位到具体 SQL再用 EXPLAIN 看是否走了索引最后再决定是加索引还是改 SQL 还是调连接池参数。6. 我的实战经验与扩展方向6.1 容易被忽略却能拖垮上线的三个细节第一是 Long 型主键传到前端后精度丢失。Java 后端常用雪花算法生成 19 位 Long 型 ID而 JavaScript 最大安全整数只有 2 的 53 次方减 1前端拿到这个 ID 再传回后端时末位几位已经变了导致详情查询查不到数据。解决办法是在前端 JSON 序列化时把 Long 转为字符串Jackson 中可以对相关字段加JsonSerialize(using ToStringSerializer.class)或者全局配置 Long 转 String。别问为什么订单号明明在数据库里却查不到多半就是这个问题。第二是 MyBatis 的 Update 执行慢。不是慢在 SQL 本身而是慢在“事务没提交导致行锁等待”。比如在一个 Service 方法里先更新课程状态然后去调远程接口远程接口超时 30 秒这 30 秒里事务一直不提交其他请求更新同一行数据时全被阻塞。正确的做法是不要把网络调用、文件上传这类耗时操作放在事务方法里必要的事务只需包住数据库操作。另一个原因是更新条件没走索引比如订单状态更新用status字段做 where而 status 区分度很低MySQL 可能不走索引还要回表这种情况要检查是不是该加联合索引。第三是文件上传目录给了执行权限。如果上传目录恰好被放在 Tomcat 的 webapps 下而且没有关闭 JSP 解析攻击者上传一个伪装成图片的 JSP 文件就能直接 getshell。在线教育项目里这个风险往往出现在试卷附件上传、讲师资料上传接口上。稳妥做法是把上传目录放到项目外部例如/var/data/edu/upload并且 Nginx 对上传目录只做静态文件服务不执行任何脚本。6.2 后续可以扩展的功能方向如果这套系统只是完成了基础版本的在线教育闭环后续要往“企业级”靠可以按优先级扩展引入 Redis做课程详情缓存、验证码缓存、Token 黑名单顺便把 MyBatis 二级缓存替代掉。引入 RabbitMQ支付回调异步处理、学习通知、报表生成都丢到 MQ减轻接口压力。引入 Flowable 工作流引擎处理课程审核、退款审批、机构入驻这种多级审批流程。引入 HanLP 分词对课程标题、问答内容做标签提取和搜索关键词联想。做学习行为分析比如学习时长、完课率、视频拖动区间给学员推荐课程。增加积分/优惠券体系把订单模块从单纯付费扩展成营销闭环。需要注意的是每加一个组件都会引入新的运维复杂度。比如上了 Redis 就要考虑缓存穿透、雪崩上了 MQ 就要处理消息可靠投递、重复消费、死信队列。所以扩展要逐步来先确保基础业务稳定再叠加这些增强能力。6.3 给打算拿这套源码做二次开发的人几句实在话我自己的体会是二次开发最忌讳一上来就改架构。先把它跑起来然后看一张核心表的关联关系比如“课程表”和“订单表”是怎么关联的再顺着一次完整的购买流程走一遍接口日志基本就能理解作者的设计意图。不要嫌现有代码写得不够好就推倒重来企业级系统的难点往往是细节状态机而不是代码风格。改代码前先理清业务边界改完记得跑一遍回归流程注册、登录、下单、支付回调、听课、做作业、后台审核这一圈能过心里就有底了。最后分享一个小习惯我每接触一个新的前后端分离项目都会先用日志把“用户请求-后端处理-数据库 SQL-前端展示”这条链路完整串一遍。网上很多问题排查不出来往往是因为在中间某一环丢失了上下文。这套系统里如果看到异常日志顺着请求 id 去查通常几分钟内就能定位到具体 Mapper 或组件方法。对在线教育这种业务状态多的系统这个习惯比任何调试技巧都管用。