做在线教育平台管理系统这个项目是我把SpringBootVue这套前后端分离架构完整跑通的一次实战。整套代码覆盖了在线教育后台最核心的几条业务链课程管理、讲师信息维护、学员学习记录、后台账号权限、订单报名数据。技术栈很固定SpringBoot做后端服务MyBatis负责持久层SQLMySQL存业务数据前端用Vue写管理界面。这篇文章不打算写那种面面俱到的官方文档而是从实际落地角度把架构拆分、数据库设计、核心接口实现、踩坑记录这几块讲透。适合正在做毕业设计、准备面试项目、或者想从零把一套在线教育后台跑起来的开发者参考。1. 项目到底解决了什么问题1.1 在线教育系统的核心需求拆解很多人第一次拿到在线教育平台这种需求第一反应是“不就是课程列表加个播放器吗”。真做起来就发现后台管理系统的复杂度远高于前台展示页。在线教育平台的管理后台至少需要覆盖这几个角色和场景管理员要能够维护课程上下架、审核讲师入驻、查看平台运营数据。讲师需要上传课程章节、管理题库、布置作业、查看自己负责班级的学习情况。学员则需要查看已购课程、记录学习进度、完成章节测试。如果再往复杂了说还有优惠券、直播课、班级分组、多校区权限但最核心的底座永远是课程和用户两条线。我做的这个系统核心定位是后台管理端加简单的学员端查询能力。把“课程维护—讲师管理—学员管理—学习记录”这条主链路打通。为什么要先做后台而不是前台因为在线教育平台的真实瓶颈几乎都出在内容维护和权限控制上。前台页面再花哨后台如果连课程上下架、讲师角色权限都理不清楚业务根本跑不起来。需求梳理阶段我习惯把功能拆成“必须要有”和“有了更好”两类。必须有的用户登录注册、角色权限、课程分类、课程与章节管理、讲师资料维护、学员学习进度记录。可以后补的直播互动、课程评论、积分系统、数据报表大屏。这套系统在初版把“必须有”的做扎实后续扩展时再往上面加模块结构不会乱。1.2 为什么选SpringBootVueMyBatis这套组合说句实在话在线教育管理系统这种CRUD密集型业务用很多技术栈都能做。但选SpringBootVueMyBatisMySQL不是跟风而是这套组合在“开发效率、维护成本、上手难度”三个维度上非常均衡。SpringBoot解决的是后端基础设施的繁琐问题。以前用Spring MVC搭项目要写一堆XML配置现在SpringBoot通过自动配置和Starter机制把大部分常见场景都预设好了。你只需要引入依赖写少量配置就能把Web服务跑起来。对于以业务逻辑为主的系统这种“开箱即用”的体验非常舒服。Vue在前端的优势是组件化开发。后台管理界面有大量重复的表格、表单、弹窗场景用组件封装之后写一个新页面就是组合几个组件的事。Vue的响应式数据绑定也让表单交互和列表筛选省了很多精力不需要手动操作DOM。MyBatis经常被拿来和JPA对比。我的个人观点是在线教育系统里面高级查询和统计SQL特别多比如“查询某讲师名下所有课程的学员完成率”用MyBatis写SQL可以精确控制语句排查问题也直观。JPA虽然写简单CRUD很爽但一旦遇到复杂动态SQL调试成本会明显上升。MyBatis的灵活性和可控性在这个场景里更值得选。MySQL就更不用多说了成熟的社区生态、丰富的文档、对事务的支持足够支撑在线教育平台这个体量的数据。真正要关心的是表结构设计和索引优化这个放到后面数据库部分详细讲。2. 系统整体架构与模块设计2.1 前后端分离的整体思路这套系统的架构不复杂就是标准的前后端分离Vue前端通过HTTP请求访问SpringBoot后端的RESTful接口后端操作MySQL数据库。前后端分别开发、分别部署前端打包成静态文件丢到Nginx后端打成Jar包运行在服务器上。项目根目录下一般会分成两个部分frontend目录Vue项目包含管理后台的页面、路由、状态管理、API请求封装。backend目录SpringBoot项目包含Controller、Service、Mapper、实体类、配置类。这种结构最大的好处是团队协作时可以并行开发。前端根据接口文档定义Mock数据后端把接口实现好之后联调即可。即便一个人开发分开写也能让思维更清晰不会在改前端样式的时候被后端代码干扰。后端内部我做了一层严格的分层Controller只负责参数接收和结果返回Service负责业务逻辑Mapper负责数据库操作。举个例子课程上下架这个操作Controller接收课程IDService层做状态校验和数据更新Mapper层执行更新SQL。这样做的好处是业务逻辑可以复用后面再加一个“批量上下架”的接口只需要在Service层加方法不用重写SQL。接口设计遵循RESTful风格资源用名词表示动作用HTTP方法表达请求方法路径作用POST/api/auth/login登录GET/api/courses课程列表POST/api/courses新增课程PUT/api/courses/{id}更新课程DELETE/api/courses/{id}删除课程GET/api/courses/{id}/chapters课程章节列表GET/api/users/{id}/learning-records用户学习记录统一返回结构也是前后端分离项目必须处理的。我习惯用Result对象包装所有接口返回值包含code、message、data三个字段。code为200表示成功非200表示失败。这样前端拦截器可以统一处理错误提示和登录失效不用每个接口单独判断。2.2 数据库怎么设计才扛得住真实业务数据库设计是我在这个项目里花时间最多的地方也是最能体现功力的部分。在线教育系统的核心表我梳理出来大概有这几张用户表、角色表、用户角色关联表、课程分类表、课程表、章节表、学习记录表、订单表。用户表要区分管理员、讲师、学员但我不建议在用户表里直接用type字段走天下。更合理的做法是做好角色关联一个用户可能既是讲师也是某个课程的学员。我用的方案是独立的用户表加用户角色关联表用户表存基础账号信息角色表存角色定义中间表把两者关联起来。这样后续要扩展角色权限不需要改表结构。课程表是业务的中心字段上除了课程名称、封面图、简介、价格这些基础信息还有几个关键字段需要特别设计status课程状态草稿、已上架、已下架。用数字表示避免字符串在不同地方写法不一致。teacher_id归属讲师的用户ID。这里建议存普通索引不要建外键约束在线教育系统查询频繁外键约束会影响插入和删除性能。category_id课程分类ID。分类一般是树形结构为了简化处理第一版可以只做一级分类后续再扩展父子关系。publish_time上架时间用于列表排序和运营数据分析。章节表要挂在课程下面包含章节标题、排序号、视频地址、是否免费试看。排序号非常重要章节本来就是有序的不能依赖创建时间排序必须显式维护sort字段。免费试看字段在课程详情页和试看逻辑中都会用到。学习记录表是我特别想强调的。真实场景中学员学习课程会产生大量记录这张表需要同时关联用户、章节、课程再用一个watch_time记录看到第几秒用status标记是否完成。每次前端上报学习进度都会更新这张表所以它是全系统写入频率最高的表之一要建联合索引(user_id, chapter_id)避免查询时全表扫描。订单表在初版可以做成简单的报名记录表包含用户ID、课程ID、支付金额、订单状态。纯后台管理场景下重点是把“用户买了哪些课程”这个关系查清楚方便做学习记录的权限校验。支付流程的复杂度可以放到第二阶段再考虑。所有业务表我都保留了三个通用字段create_time、update_time、deleted。逻辑删除用0和1表示而不是物理删除数据。在线教育后台误删操作的场景不少比如课程下架时如果物理删了运营想恢复就麻烦了。逻辑删除让数据始终可追溯查询时统一加WHERE deleted 0条件即可。2.3 模块划分与核心业务流程整个系统的模块划分我按业务域来拆不按技术层拆。每个模块是一个独立的包结构包含自己的Controller、Service、Mapper。主要有这几个模块认证授权模块登录、JWT生成与校验、权限注解。用户管理模块用户CRUD、角色分配、讲师审核。课程管理模块课程、章节、分类的维护。学习模块学习记录上报、课程进度查询。统计模块简单的数据统计接口比如课程报名人数、学员完成率。核心业务流程里最值得理清楚的一条是“学员学习课程”。学员登录后获取自己的课程列表进入课程详情查看章节列表。前端在视频播放时每隔一段时间上报学习进度后端收到请求后更新学习记录表。如果某章节已经达到完成阈值就把状态标记为已完成。课程总体进度在列表页通过汇总该课程下已完成章节数除以总章节数来计算。这个流程看起来简单但里面涉及并发和幂等的问题如果前端一秒上报一次后端怎么做去重如果用户断网重连学习记录会不会重复插入我后面在踩坑部分会详细说解决办法。这里先记住一个原则学习记录表不能无脑每次插入新数据而是先查再更新最好用唯一索引兜底。3. 核心功能实操从0到1跑起来3.1 环境准备与项目初始化先把环境准备好。后端需要JDK 8或11、Maven 3.6以上版本、MySQL 5.7或8.0。前端需要Node.js 14以上npm 6以上。如果机器上装的是新版本比如JDK 17也没问题只要对应SpringBoot版本支持。我这边用的SpringBoot 2.7.x兼容JDK 8到17稳一点。后端项目初始化推荐直接在IDEA里新建Spring Initializr项目选好JDK版本和依赖。需要引入的依赖有spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok、spring-boot-starter-validation、jjwt。application.yml配置是最开始就要写对的server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/edu_platform?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.edu.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置很容易被忽略但它非常关键。数据库字段是create_time这种下划线风格Java实体类是createTime这种驼峰风格开启这个配置后MyBatis会自动映射。否则你就要在每条SQL里写别名非常痛苦。前端项目初始化用Vue CLI或Vite都可以。我的建议是直接用Vue CLI创建生态稳定模板工程对后台管理系统非常友好。创建命令npm install -g vue/cli vue create edu-frontend创建时选手动配置勾选Vue Router、Vuex和Axios。进入项目后先安装Element UI作为后台管理界面的组件库npm install element-ui npm install axiosElement UI的表格、表单、弹窗、分页组件都非常成熟做管理后台几乎不用自己造轮子。3.2 后端核心模块JWT认证与拦截管理后台的安全控制是第一优先级。这套系统用的是JWT方案用户登录成功后后端生成一个带过期时间的Token返回给前端前端每次请求都把这个Token放在HTTP请求头里后端拦截器校验Token的合法性并解析出用户信息。先定义一个简单的JWT工具类Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String generateToken(Integer userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }登录接口的Service层逻辑根据用户名查到用户校验密码是否一致校验通过就生成Token返回。密码存储不能是明文至少要用MD5加盐或者BCrypt加密。我在项目里用的是BCryptSpring Security的加密工具类可以直接调比自写MD5稳妥得多。拦截器是JWT校验的核心。我写了一个AuthInterceptor在请求进入Controller之前拦截public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json); response.getWriter().write({\code\:401,\message\:\登录已过期\}); return false; } } }这里有两个容易踩的坑第一OPTIONS预检请求必须放行否则前端跨域请求会被拦截器挡掉。第二拦截器里校验失败时要直接返回JSON而不是让Spring再走异常处理流程避免前端拿到一堆HTML错误页面。权限控制方面我用了自定义注解RequireRole标注在Controller方法上比如讲师管理接口只允许ROLE_ADMIN访问。拦截器解析Token后拿到用户ID再查一次数据库得到角色信息校验是否符合注解要求。这个方法虽然每请求多查一次数据库但胜在实现简单清晰对于后端管理系统的并发量来说完全够用。3.3 前端页面对接与接口联调前端的核心是把请求封装好后续页面调用时不用重复处理Token和错误提示。我在src目录下建了一个api文件夹统一封装axios实例import axios from axios import { Message } from element-ui import router from ../router const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { router.push(/login) } Message.error(error.message) return Promise.reject(error) } ) export default serviceVue页面开发时最常用的是表格加弹窗表单的组合。以课程管理页面为例页面加载时调用GET /api/courses获取列表点击新增按钮弹出表单对话框提交时调用POST /api/courses。这种模式几乎覆盖了后台管理系统80%的页面。跨域问题是前后端分离联调时最常见的问题。开发环境下Vue项目默认跑在8080端口后端跑在8080端口这两者之间就是跨域请求。我用的解决办法是在后端加CORS配置 java Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .maxAge(3600); } }生产环境会更好处理前端打包成静态文件后由Nginx提供服务然后用反向代理把/api路径转发到后端服务。这样浏览器看到的始终是同源请求不需要后端处理跨域。开发环境用CORS配置生产环境用Nginx反向代理这是比较标准的做法。3.4 学习进度上报接口的实现细节学习进度接口是整个系统里最贴近真实使用场景的一个功能值得单独拿出来讲。前端在视频播放时每隔15秒调一次上报接口传递userId、chapterId、watchTime、duration。后端收到请求后先查询是否已经有这条学习记录没有就插入有就更新watchTime。学习记录完成状态的判定不能死板。我的做法是当watchTime大于等于duration的90%或者当前端主动标记manualFinish为true时把status改为完成。为什么是90%而不是100%因为用户很可能跳过结尾演职人员表或者视频最后几秒网络卡顿强制要求100%反而会让用户觉得系统很死板。事务在这里要注意先查再插再更新整体操作要加Transactional防止插入记录和更新状态之间出现异常导致数据不一致。具体代码逻辑大概是Transactional public void reportLearningProgress(ProgressRequest request) { LearningRecord record learningRecordMapper.selectByUserAndChapter( request.getUserId(), request.getChapterId()); if (record null) { record new LearningRecord(); record.setUserId(request.getUserId()); record.setChapterId(request.getChapterId()); record.setWatchTime(request.getWatchTime()); record.setStatus(0); learningRecordMapper.insert(record); } else { record.setWatchTime(request.getWatchTime()); if (request.getWatchTime() request.getDuration() * 0.9) { record.setStatus(1); } learningRecordMapper.update(record); } }并发上报时如果我第一次请求和第二次请求几乎同时到达可能会插入两条记录。更稳妥的做法是在数据库层面给(user_id, chapter_id)加上联合唯一索引让数据库帮你兜底重复插入直接报错代码里再捕获异常做更新处理。算法层面的幂等控制永远没有数据库约束可靠这个是我实际测试出来的教训。4. 真实开发中踩过的坑与排查技巧4.1 事务失效和数据一致性问题在线教育系统里事务用得多但很多人写事务时都会忽略几个让人头疼的场景。最典型的在同一个类里A方法调用B方法B方法上面标了Transactional但是事务不生效。原因很简单Spring的事务是通过AOP代理实现的类内部的方法调用走的是this不会经过代理对象事务注解就被跳过了。解决办法是把需要事务的方法放到另一个Service类里或者自己从容器中获取代理对象再调用。另一个常见问题是事务内做远程调用。比如学习进度上报时如果先是更新学习记录再调用短信服务通知用户短信服务响应很慢事务会一直持有数据库连接不释放最终导致连接池耗尽。我的处理原则是事务里只做数据库操作远程调用放到事务提交之后通过事件或异步方式处理。数据一致性还体现在课程状态上。课程删除时如果直接把课程表里的记录删除那么学员的学习记录表里就有大量悬空数据课程列表查询也会出问题。这就是为什么我在前面强调逻辑删除字段。课程下架的时候只更新deleted1学习记录表里的数据还能正常关联到课程历史快照信息。4.2 接口性能瓶颈与优化这套系统在数据量小的时候跑起来很顺畅一旦数据库里课程、用户、学习记录都到了几十万条性能问题就会暴露。最让我印象深刻的场景是课程列表页每次查询课程列表都要统计报名人数和章节数如果使用写死的SQL把所有数据都拆出来N1查询问题马上就来了。N1查询的意思是先用一条SQL查出课程列表再对每个课程循环查询学员数量。课程越多查询次数越多接口响应时间直线上升。解决办法是用联表查询或子查询一次拿到所有需要的数据。MyBatis里写一个聚合SQL就能解决比如SELECT c.id, c.title, (SELECT COUNT(*) FROM orders o WHERE o.course_id c.id) AS student_count FROM course c WHERE c.deleted 0分页查询的深度分页问题也要注意。当使用LIMIT 100000, 20这种查询时MySQL还是要扫描前100000条记录效率很低。优化思路有两种一是使用WHERE id lastId的方式做游标分页适合移动端无限滚动场景二是给排序字段和筛选条件建立联合索引让数据库尽量走索引覆盖。后台表格用普通分页就好前端一览式的数据列表建议用游标分页。学习记录表写入频繁建议按用户维度做缓存。用户打开课程列表时不需要实时统计每个课程的学习进度可以把进度数据缓存到Redis里设置5分钟过期。用户每次上报进度时更新一次Redis页面展示时读缓存只有管理员看统计报表时才查数据库。这样可以显著降低数据库压力。4.3 部署上线时的细节处理本地开发跑通了部署又是半个坑。SpringBoot后端打包时要特别注意配置文件。开发环境连的是本地数据库服务器上用的是云数据库不能把本地数据库地址直接打包进去。我建议把application.yml拆成三个环境application-dev.yml、application-test.yml、application-prod.yml。打包时通过spring.profiles.active指定环境部署命令类似java -jar edu-backend.jar --spring.profiles.activeprod前端部署要处理的是路由模式。Vue Router如果用了history模式Nginx需要配置try_files把请求都重定向到index.html。否则前端页面一刷新Nginx就会报404这个问题太经典了。Nginx配置大概长这样location / { root /usr/share/nginx/html; 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; }生产环境还有一个容易忽略的点MySQL的时区问题。如果后端配置了serverTimezoneAsia/Shanghai但MySQL服务器用的是UTC查出来直接更新到数据库你会发现时间晚了8小时。最好的办法是把MySQL的全局时区也设置为东八区同时在后端配置里保持一致双重确认。4.4 前端联调中的常见问题速查我在联调阶段遇到过不少前端问题整理成一张表现象原因解决办法请求返回401但登录页没跳拦截器里处理的是response error但HTTP状态码可能被axios拦了检查response拦截器的error分支表格数据不显示后端返回的字段和前端表头字段不一致统一使用驼峰命名开启MyBatis驼峰映射表单提交后列表不刷新提交成功后没有重新调用列表接口在成功回调里重新查询当前页数据上传图片失败跨域请求被Nginx或CORS配置拦截检查Nginx的client_max_body_sizeToken过期后页面卡死401后没有清理本地存储和跳转拦截器里清空localStorage后跳登录页联调是前后端项目里最容易扯皮也最容易拖时间的环节。我的习惯是先在前端Mock数据把页面写完所有字段类型和接口路径都按真实约定来后端接口出了之后把Mock地址切到真实地址冲突就会少很多。5. 从能用做到好用扩展与经验沉淀5.1 演示项目到线上项目差在哪很多开发者做的在线教育系统功能看着都有但离真正上线还有一段距离。最大的差距在容错和可观测性。演示项目里接口挂了只会刷新重来线上系统要求的是接口异常时有日志、有告警、有兜底数据。我在后期给这套系统加了全局异常处理类统一捕获业务异常和未知异常记录日志并返回友好提示。加上操作日志模块后台管理员每次上下架课程、修改用户信息都会记录操作者和时间出了问题能追溯到人。数据层面线上系统还要求备份策略。MySQL备份不只是mysqldump命令而是要有全量备份加增量日志。我建议至少每天全量备份一次同时开启binlog。这样即使误操作删了数据也能恢复到指定的时间点对在线教育这种运营人员直接操作系统的情况非常关键。5.2 个人调试效率技巧几个能明显提升开发效率的小习惯。后端接口测试与其频繁起一个前端页面去点不如直接用接口测试工具把所有接口集合保存下来。后端改完接口跑一遍集合就能知道有没有破坏已有功能。前端调试时多用浏览器的Network面板看接口耗时和响应体比反复加console快得多。SQL排查我习惯在MyBatis的XML里写注释把业务背景和参数含义写明。半年后自己回头维护代码或者让另一个同事接手这些注释的价值远大于代码层的注释。遇到过太多次写的SQL自己都看不懂的情况。联调时前端报错第一反应不是去怀疑后端逻辑而是先打开浏览器控制台把Full Request URL复制出来直接在接口工具里重放一遍。如果接口工具返回正常那就是前端传参问题如果接口工具也报错再往后端查。这个排除法能省下大量沟通成本。5.3 后续可以怎么扩展这套系统留了几个明确的扩展点。课程播放页面可以接入真正的前端视频播放器配合学习记录接口做续播功能。权限方面可以把方法级别的注解权限升级成细粒度按钮权限前端根据用户权限动态渲染按钮。统计报表可以引入定时任务每天早上汇总前一天的学员活跃、课程报名数据生成运营日报。如果后续还需要支持多人同时观看直播课那就要考虑引入消息推送和WebRTC相关技术这部分和当前管理后台是相对独立的模块。架构上保持模块清晰技术升级不会影响已有功能。回头再看这个项目的价值不只是把SpringBootVue这套技术栈跑通而是通过在线教育这个真实业务场景把权限、事务、性能、部署这些后端开发的硬骨头都过了一遍。我在实际调试过程中最大的体会是很多问题都是到数据量上去之后才暴露出来的单纯写单测或者跑小样本数据根本发现不了。所以做这类管理系统一定在一开始就按真实数据量去设计表结构和索引不要等线上卡死才回头改模型。最后分享一个小技巧课程表和学习记录表这种高频访问的表主键不要用UUID字符串用自增整型主键就好。SQL里做JOIN时整型主键的索引效率和存储空间都远优于字符串。一套字段设计要经得起未来几年的数据增长这才是后端开发的核心功底。