
做教育类系统这些年我前后手写过不下三套线上培训平台从最早的SSHSpringStrutsHibernate到后面的SpringBoot单体再到今天要聊的这套SpringBootVue3MyBatisMySQL前后端分离版本。每次技术迭代都会踩一些新坑但沉淀下来的架构思路和业务模型是通用的。这套线上教育培训办公系统核心就是解决教培机构“教务管理教学服务机构办公”三合一的需求覆盖课程、选课、订单、作业、考试、公告、审批等一套完整的业务闭环加上后台运营和前台学习两端分离的设计很适合作为培训机构自研系统的基础也适合想系统掌握前后端分离全栈开发的人拿来当练手项目。前后端分离最大的好处在真实项目里会体会得特别深前端可以独立部署在CDN或Nginx上后端只暴露JSON接口扩展新端小程序、App的时候后端不用大改而且前后端团队可以完全并行开发。这套系统源码里前端用的Vue3组合式API那种写法Element Plus后端是SpringBoot标准分层持久层用MyBatis写SQL而不是完全自动化的JPA很多从零学全栈的人对这个组合更熟悉因为网上资料多、面试也常问。下面我从项目设计、数据库、核心实现到部署上线把这套系统的关键环节完整拆一遍全是实操层面的东西。1. 项目定位与技术选型拆解1.1 一套教育培训系统到底要做哪些事先想清楚业务模型再谈技术。线上教育培训办公系统表面上拆开是三块线上教育、培训管理、办公协同实际运营中它们互相穿插。前台学员端课程浏览、选课下单、在线学习视频课时、作业提交、考试答题、查看成绩和公告。教师工作端管理自己名下的课程、上传课时视频、发布作业和批改、创建考试和阅卷、发起请假/报销等办公审批。后台管理端学员和教师账号管理、课程上下架、订单与退款处理、数据统计、系统公告、审批流配置。这三点对应到代码里就是角色权限体系管理员、教师、学员三套菜单、课程内容模型课程-章节-课时的树形结构、交易模型订单-支付状态流转。如果你只是简简单单做个“视频点播站”那根本不需要这么重的系统但一旦涉及多角色、多状态、多渠道内容数据结构和接口设计就变得关键了。这个项目源码里的做法就是按照这三条业务线去拆微模块而不是把代码都堆在一个Controller里。1.2 为什么选SpringBootVue3MyBatisMySQL这套组合很多新人会问为什么不选Spring Cloud微服务为什么不选JPA而用MyBatis这不是技术越新越好而是看业务体和团队熟练度。一个培训机构的日常业务量单体架构的性能和并发绰绰有余微服务带来的分布式事务、服务治理成本反而是负担维护起来更痛苦。SpringBoot的目的是快速构建可独立运行的Java应用内嵌Tomcat省掉一堆XML配置基本做到“一个Jar跑起来”。前端选Vue3而不是Vue2核心是组合式APIComposition API带来的逻辑复用能力。后台管理系统里有大量“列表表单详情”的相似页面把搜索条件、分页、CRUD封装成组合式函数代码量能减少三分之一以上。再加上script setup语法糖写起来比Options API直观很多。Element Plus在后台管理类项目里依然是利用率最高的组件库组件全、坑少、社区答案多遇到问题一搜就有答案这对开发效率很重要。持久层选择MyBatis说白了就是“SQL在手心里不慌”。教育系统里有大量多表关联查询和复杂报表统计用MyBatis的XML写动态SQL性能优化空间一目了然不需要去看Hibernate帮你生成的离谱SQL。而且市场上面试题最多的也是MyBatis一级缓存、二级缓存、#{}和${}的区别这类你工作里用过就是真会面试也能答得出来。MySQL则是配合Java生态最稳的关系型数据库事务支持、并发控制、运维工具链都非常成熟用utf8mb4字符集存中文和表情符号都没问题。1.3 前后端分离的收益与代价前后端分离架构示意图前端Vue3静态资源通过HTTP请求访问后端SpringBoot提供JSON API后端连接MySQL数据库。前端和后端各自独立开发、独立部署通过接口文档比如Swagger/knife4j生成的OpenAPI文档约定数据格式。好处上面说了团队协作效率高、部署灵活、便于多端复用。但代价也很现实——跨域问题、鉴权方案、接口联调效率这三件事需要处理。这套系统里用JWTJSON Web Token解决登录鉴权前端登录后拿到token存到Pinia和localStorage每次请求在Axios拦截器里把token加到请求头后端通过拦截器统一校验。这个方案在前后端分离场景里比Session更贴合因为后端不存状态方便水平扩容。如果说有什么要避开的误区就是别把前后端分离做成“半个分离”后端返回的不应该是页面片段或重定向而应该统一返回JSON结构前端也别直接操作数据库或把业务逻辑全写在页面里。这套源码在接口设计上统一走Result返回对象凡是可能出错的环节都放到了全局异常处理里这个规范值得照着学。2. 数据库设计教育培训系统的地基2.1 核心业务表与字段规划数据库设计决定了业务的天花板。我见到很多半路出家的项目表结构里全是冗余字段、没有外键约束、状态字段用字符串乱写后期改需求改到怀疑人生。这个教育培训系统的核心表我按业务域给你拆成四组。用户与权限域用户表、角色表、用户角色关联表 用户表字段一般有id, username, password, real_name, phone, email, avatar_url, role_type, status, create_time。密码存的是BCrypt加密后的密文不要用MD5——MD5彩虹表随便就能查出来。权限这块用角色字段控制再细一点可以拆sys_permission表和角色权限关联表但中小型系统用角色区分菜单就已经够用了。课程内容域课程分类表、课程表、章节表、课时表 课程表是这个业务的主线。基础字段有id, title, cover_url, intro, detail_html, category_id, teacher_id, price, status, study_count, create_time。章节表挂course_id课时表挂chapter_id形成一个三层目录。实际开发中要注意的是课时表里除了video_url视频地址还得存video_duration时长、is_free是否试听和sort排序值学员端展示学习进度时直接查这些字段性能比现场计算好很多。学习交易域选课表、订单表、作业表、作业提交表、考试相关表 选课表解决“学员买了哪些课程”的问题核心字段id, user_id, course_id, order_id, status, create_time这个表同时承载学习进度字段progress、last_lesson_id。订单表不用多说订单号要唯一且适合做索引金额用decimal(10,2)别用浮点数算钱是绝对不能有精度误差的。 作业和考试这两块设计时最容易乱。作业表edu_homework存课程ID、标题、内容、截止时间作业提交表homework_submit存作业ID、学员ID、提交内容、附件地址、分数、评语。一道作业允许多次提交每次覆盖之前的或者保存历史版本这个看业务需求建议至少保留最近三次提交记录方便老师对比。办公协同域审批表、公告表 办公审批这块的通用做法是“审批类型审批状态申请内容”三件套。统一建一张oa_approval表字段为approval_type请假、报销、采购等、applicant_id、approver_id、reason、amount、status待审批/通过/驳回、apply_time。这样做的好处是新增审批类型时不用改表结构只要前端加一个表单模板就行。公告表则比较简单标题、内容、发布人、置顶状态、发布时间再加一个目标用户范围字段全部/按角色查询时按时效和范围过滤。2.2 多对多与一对多关系落表教育培训系统里最典型的两个关系一个是用户与课程的“多对多”一个是课程到课时的“一对多”。用户和课程的关系怎么落表答案是中间表user_course。学员表一个用户能选很多课课程表一门课也能被很多学员选所以不能直接在用户表加course_id列也不能在课程表加user_id列而是新建一张关联表存两者ID。查询“某学员的所有课程”时SQL就是SELECT c.* FROM course c INNER JOIN user_course uc ON c.id uc.course_id WHERE uc.user_id #{userId} AND uc.status 1;这种写法在MyBatis里非常常见关联表上别忘了给user_id和course_id建联合索引不然数据量上去之后查询会慢到让你怀疑人生。课程到课时的层级关系属于一对多最直接的做法是章节表和课时表都带外键字段course_id、chapter_id查询时一次性LEFT JOIN查出整个课程结构在Java内存里组装成树形DTO返回给前端。很多新手会把“树形结构”硬生生用递归查好几遍数据量小的时候感觉不出来但课时一多接口就肉眼可见地变卡。数据量大时这种内存组装的方式比多次查询快很多而且代码逻辑好维护。2.3 MySQL连接配置与索引优化细节连接配置这块application.yml里最需要注意几个点spring: datasource: url: jdbc:mysql://localhost:3306/edu_system?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5characterEncodingutf8mb4这个必须写不然中文可能乱码而且只有utf8mb4才能正确存储表情。serverTimezoneAsia/Shanghai不设置的话新版MySQL驱动会报时区错误或者日期时间偏差8小时。useSSLfalse本地开发不用SSL加密加了反而拖慢连接速度。HikariCP连接池SpringBoot 2.x默认连接池就是Hikari性能极好参数不用调太多但maximum-pool-size别设太大20左右足够。索引优化方面除了每个表的主键索引下面这几个索引是必加的user_course表的user_id和course_id联合索引业务上最常见的查询入口course表的status和category_id归档/上下架查询edu_order表的user_id和order_no订单查询homework_submit表的homework_id和user_id查谁交了谁没交注意索引不是越多越好每多一个索引插入更新数据时的开销就大一分。围绕“最常见的查询条件”来建索引其他少建。3. 后端SpringBootMyBatis的关键实现3.1 分层架构与统一响应封装后端代码结构按经典分层来组织controller、service、mapper三层的划分com.example.edu ├── controller // 接口层只做参数接收和结果返回 ├── service // 业务层事务边界在这里 │ └── impl ├── mapper // MyBatis数据访问层 ├── entity // 数据库实体 ├── dto // 传输对象接收前端参数、返回给前端的数据 ├── common // 统一返回、异常处理、工具类 ├── config // 配置类跨域、拦截器、文件上传等 └── security // 登录鉴权相关为什么不让Controller直接调Mapper因为业务逻辑事务、校验、组装需要一个稳定的中间层。比如下单这个操作它不只是往订单表插一条数据还要判断库存和课程状态、扣减库存如果按库存卖、生成订单号、给学员开通课程权限这一串操作必须在一个事务里而事务的边界必须放在Service层。Controller只负责收参数把参数整理成DTO传给Service。统一返回对象Result是前后端分离里必须从第一天就定下的规范Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }所有接口统一返回这个结构code为200表示成功失败时给出业务错误码。前端Axios拿到响应后先看code再决定要不要弹出错误提示不用在几十个接口里写重复的失败处理逻辑。这是很基础但特别重要的工程规范能省掉后面大量的沟通成本。3.2 接口设计与权限控制方案接口设计遵循RESTful风格但不用教条GET查询、POST新增、PUT修改、DELETE删除。实际项目里我更喜欢用动词化的自定义接口比如/course/publish因为“上架课程”这个动作用PUT语义表达不清楚RESTful是手段不是目的做人要灵活代码也一样。权限控制是这个系统的核心难点因为有三类角色管理员、教师、学员菜单和操作权限差别很大。我的做法是登录接口校验用户名密码成功后签发JWT TokenToken里带上用户ID和角色信息。后端用拦截器统一从请求头Authorization里解析Token解析失败直接返回401。Controller上加自定义注解RequireRole(TEACHER)方法执行前检查当前用户角色是否匹配不匹配就返回403。JWT编码本体其实很短核心代码逻辑就三步生成Token、校验Token、解析用户信息。用io.jsonwebtoken:jjwt库来实现签名算法用HS256密钥放在配置文件里生产环境必须用环境变量注入别硬编码在代码里。有一个很常见的坑前后端分离项目里如何配合Vue路由做前端页面级权限。后端的权限校验只是不能保证数据安全但用户会看到很高的操作所以前端也要配合。思路是登录后把该用户的角色和菜单列表存到Pinia动态生成路由再router.addRoute挂载。菜单配置用后端返回的JSON数组前端根据权限标识渲染左侧菜单这样不同角色进入系统看到的侧边栏完全不同。3.3 MyBatis动态SQL使用要点MyBatis在这个项目里扮演的角色是“写SQL的地方”。我推荐所有复杂查询用XML方式因为动态SQLif、where、foreach在XML里写起来最灵活而且SQL变更不用重新编译Java代码。一个典型的多条件筛选课程示例select idselectCourseList resultTypecom.example.edu.entity.Course SELECT c.*, cat.name AS category_name FROM course c LEFT JOIN course_category cat ON c.category_id cat.id where if testtitle ! null and title ! AND c.title LIKE CONCAT(%, #{title}, %) /if if testcategoryId ! null AND c.category_id #{categoryId} /if if teststatus ! null AND c.status #{status} /if /where ORDER BY c.create_time DESC /select这里几个重点where标签会自动去掉多余的ANDLIKE CONCAT(%, #{title}, %)写法能防SQL注入#{}预编译占位符是安全的${}直接拼接SQL能不用就不用。分页用PageHelper插件引入pagehelper-spring-boot-starter依赖即可Service层查询前调用PageHelper.startPage(pageNum, pageSize)查询后得到PageInfo对象里面就有total、pages等分页元数据直接封装到Result返回前端。分页插件用起来是真的省事但要注意必须在紧跟的Mapper查询之前调用startPage中间不能夹杂其他数据库操作否则分页会失效或者套到错误的SQL上。MyBatis的一级缓存默认是开启的同一个SqlSession内查询结果缓存二级缓存默认关闭。在教育培训这种数据一致性要求高的业务里二级缓存我建议保持关闭因为你很难精确控制缓存失效时机一旦学员看到过期的课程价格或状态信息就是线上事故。缓存带来的提升在这个量级的数据下真的微不足道不值得冒这个险。另外就是SQL里的小于号、大于号。XML里直接用会报错得用转义字符lt;表示小于gt;表示大于。或者把整个SQL包在![CDATA[ ... ]]里。这个坑基本每个用MyBatis的人都踩过记住了就不慌。4. 前端Vue3项目结构与核心模块4.1 基于Vite搭建Vue3工程用Vite创建项目现在已经是标配了npm create vuelatestVue官方脚手架按照交互提示选择Router、Pinia等选项开发服务器启动速度比Webpack快一个数量级热更新基本秒级。项目结构上为了适配后台管理系统我一般这么组织src ├── api // 按业务域拆分的请求模块user.js, course.js, order.js... ├── assets // 静态资源 ├── components // 公共组件Upload, Pagination, RichEditor... ├── composables // 组合式函数useTable, useForm ├── router // 路由配置 ├── stores // Pinia状态管理 ├── views // 页面组件按角色分目录 ├── utils // 请求封装、工具函数 └── App.vue对后台管理系统来说views目录按角色划分特别重要views/admin、views/teacher、views/student每个子目录放各自角色的页面。虽然这会带来少量重复代码比如课程列表教师和管理员看到的字段不同但比在同一个页面里塞满v-ifrole teacher这种写法清晰得多。代码重复可以用组合式函数去复用页面逻辑归属乱才是最难维护的。4.2 权限路由与状态管理设计权限路由是Vue3后台管理系统绕不开的核心。我的做法是路由表里只保留公共路由登录页、404页、首页。登录成功后调后端接口获取该用户的菜单权限列表包含路径、组件名、标题、图标。把这个列表动态转换成RouteRecordRaw数组用router.addRoute()逐个注册。左侧菜单直接渲染这份动态路由数据角色不同菜单自然不同。状态管理用的是Pinia。相比VuexPinia没有Mutation概念直接在store里写方法改状态TypeScript支持也好。用户模块的store大概长这样import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: null, menus: [] }), actions: { async login(loginForm) { const res await loginApi(loginForm) this.token res.data.token this.userInfo res.data.userInfo this.menus res.data.menus localStorage.setItem(token, this.token) }, logout() { this.token this.userInfo null this.menus [] localStorage.removeItem(token) } } })这里有个细节值得说Token存localStorage有一个安全风险是XSS攻击恶意脚本拿到localStorage的token更安全的做法是存内存里、刷新页面后再用refresh_token换新的但这样实现复杂度明显提高。中小型内部系统用localStorage加请求拦截器的方式已经够用前提是前端对输入框渲染做好XSS防护Vue默认会转义插值语法不熟练的时候别用v-html渲染用户输入的内容。4.3 Axios封装与接口对接前后端对接时如果每个页面都单独写fetch那项目很快变成一团乱麻。Axios封装是必做的一层// utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /stores/user const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动带token request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) // 响应拦截器统一处理业务错误 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )要做的事有三件baseURL设置成/api可以在开发环境用Vite代理转发生产环境用Nginx转发请求拦截器统一加token响应拦截器统一处理错误码和401跳登录。这三个点做好业务代码里就只要关心正常数据了。开发环境的代理配置vite.config.js里的server.proxy和后端联调时如果端口不一致会跨域所以Vite代理是这么配的server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里的/api/course/list会被转发到http://localhost:8080/api/course/list浏览器里不会出现跨域报错很好用。生产环境就把这层转发交给Nginx配置原理一模一样。5. 环境准备、本地运行与部署上线5.1 环境要求与项目初始化运行这套系统的环境要求软件版本建议备注JDK8或11SpringBoot 2.x对JDK8兼容最好JDK11也稳定Maven3.6管理后端依赖Node.js16运行Vite必需我用的是18很稳MySQL5.7或8.05.7经典稳定8.0性能更强Nginx1.20生产环境部署前端用初始化步骤建库在MySQL里CREATE DATABASE edu_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;字符集一定要指定不然中文字段会乱码。导入数据用源码包里的edu_system.sql文件source /path/to/edu_system.sql导入里面包含了表结构和初始数据默认管理员账号。改配置把application.yml里的数据库账号密码改成你自己的如果MySQL在远程服务器把localhost换成对应的IP。后端启动用IDEA打开后端工程等Maven下载完依赖后直接运行EduApplication主类控制台看到“Started EduApplication”就是启动成功了。也可以用命令行方式mvn clean package -DskipTests打包出target/edu-system.jar后java -jar运行。前端启动npm install安装依赖然后npm run dev启动开发服务器默认端口5173浏览器访问就行。第一次启动如果提示端口被占用改一下vite.config.js里的server.port就行。5.2 数据初始化与本地联调步骤本地联调最关键的环节是“前后端已经都启动起来了但数据不对”。我推荐一个固定排查顺序接口文档直接用Swagger后端集成了springfox或knife4j依赖访问http://localhost:8080/doc.html查看所有接口的请求和响应示例。先用Swagger调一次接口确认后端正常再从前端页面发起请求这样能快速定位问题到底是在前端传参还是后端逻辑。另一类高频问题是“前端能访问但登录失败”多半是数据库里初始账号的密码加密方式对不上BCrypt加密后密文对应的明文密码不匹配用预先写好的初始化SQL重置密码即可。我这套系统的初始管理员密码是admin123但你在生产环境必须第一时间改掉这是安全底线。5.3 生产环境部署配置生产环境和本地最大的区别是前端构建成静态文件交给Nginx托管后端构建成可执行Jar包独立运行。后端生产部署时建议用systemd管理服务写一个service文件让Jar包开机自启、崩溃自动重启Restartalways日志用journalctl统一查看。核心启动命令参考nohup java -Xms512m -Xmx1024m -jar edu-system.jar \ --spring.profiles.activeprod --server.port8080 app.log 21 生产配置文件里记得把数据库密码改为环境变量读取的方式password: ${DB_PASSWORD}避免密码明文写在配置文件里被提交到仓库。Nginx配置前端和反向代理后端是重点server { listen 80; server_name edu.example.com; # 前端静态文件 root /opt/edu-front/dist; index index.html; # 解决Vue3 history模式刷新404问题 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传文件访问路径课程封面、视频等静态资源 location /upload/ { alias /opt/edu-upload/; } }try_files $uri $uri/ /index.html;这行必须写否则Vue3用history模式路由时刷新页面就会404。把前端构建好的dist目录上传到服务器的/opt/edu-front/dist让Nginx托管/api开头请求交给后端Java服务处理上传的文件统一放在/opt/edu-upload目录由Nginx直接提供静态访问比经过Java层的文件流读取要高效。6. 常见问题与排查技巧实录6.1 启动与连接层问题问题一MySQL时区错误。启动后端报错信息里出现The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这是MySQL连接URL里没有加serverTimezone。解决方式就是在数据源URL末尾加上serverTimezoneAsia/Shanghai改完重启就好。问题二端口被占用。启动SpringBoot提示Port 8080 was already in use。先执行netstat -ano | findstr 8080Windows或lsof -i:8080Linux/Mac找到占用进程要么结束进程要么在配置文件里改端口。别硬换端口因为前端代理和后端接口都是按8080配的换端口要连带改好几个地方。问题三中文乱码。数据库表、连接URL、前端页面三处都可能是乱码源头。排查顺序是先看数据库表字符集SHOW CREATE TABLE course;再确认连接URL里有characterEncodingutf8最后看后端响应头里是否带Content-Type: application/json;charsetUTF-8。大概率是某一步漏了。6.2 打包上线问题问题一前端打包后接口路径404。本地联调时前端代码里写的是/api前缀通过Vite代理访问后端但打包后部署到Nginx时如果没有配置location /api反向代理所有接口都会404。按上面Nginx配置补上就行了。还有一个方案是构建时把baseURL改成完整后端域名但Nginx反代是更优雅的做法保留了前后端分离的部署结构。问题二history模式刷新404。这个很多人第一次部署都会遇到。Vue3默认的路由是history模式地址栏输入/course/1再刷新Nginx直接去找/course/1这个文件然后发现不存在返回404。解决方法就是try_files $uri $uri/ /index.html;把所有找不到的路径都回退到首页由前端路由接管。问题三上传文件过大被拦截。上传课程视频时提示MaxUploadSizeExceededException这是SpringBoot默认限制单文件1MB导致的。在配置文件里调大spring: servlet: multipart: max-file-size: 2048MB max-request-size: 2048MB同时Nginx代理层也要把client_max_body_size调大默认1MB根本不够上传视频用。这两处都配好后再大的视频也能传上去了。问题四跨域问题被CORS策略拦截。后端接口能通过Swagger访问但前端浏览器里请求报CORS policy错误。原因是浏览器跨域保护机制当生产环境请求的接口域名和页面域名不同就会出现。既然Nginx已经做了同域反向代理/api走同域这个问题在生产环境基本不会出现。如果开发环境遇到就检查Vite代理配置是否生效或者在后端加全局CORS配置类放行指定域名。注意生产环境不要用allowedOriginPatterns(*)放开所有域名等于把接口裸奔给所有人调。6.3 功能实现与性能调优问题问题一MyBatis中${}导致的SQL注入。项目中会遇到排序字段动态传入的需求用ORDER BY ${sortColumn}因为没有别的办法让字段名走预编译。但这里绝对不能接受用户直接传入字段名解决办法是后端维护一个允许排序字段的白名单前端传的字段名先在白名单里查一下不在就直接用默认值杜绝SQL注入的可能。#{}传值则完全没有注入风险这是MyBatis核心用法。问题二MyBatis缓存导致的数据不一致。如果在开发过程中开启了二级缓存cache/标签修改数据库后查询结果还是旧的就是因为缓存没失效。教育培训系统的数据要求强一致建议直接关闭二级缓存。至于一级缓存它生命周期极短同一个SqlSession内基本不会出问题不用管它。问题三课程列表查询慢。数据量大概到几十万条时课程列表接口就会变慢。优化三板斧索引检查缺什么补什么、SQL分析用EXPLAIN看执行计划确认有没有走索引、查询字段精简列表接口只查列表需要展示的字段详情内容用另一个接口查。用EXPLAIN SELECT ...看到typeALL就是全表扫描必须加索引。问题四事务失效。下单、开课这种多表操作如果事务没生效中间一步失败数据就会处于半完成状态。常见原因是Service类里自己调用自己类内部的方法this.method()Spring事务是基于代理的内部调用绕过了代理Transactional就失效了。解决方式是要么把需要事务的方法拆分到另一个Service类里要么通过注入自身代理来调用。另一个坑是Transactional只对RuntimeException回滚默认不回滚受检异常如果业务中用try-catch把异常吞了事务就彻底失效了。事务函数里的异常要么向上抛要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。这套系统实际开发下来我最大的感受是教育培训系统的复杂度不在技术本身而在业务状态的流转和角色权限的边界。技术选型只是入场券真正花时间的是把订单状态、课程上下架、审批流程这些业务规则理清楚用数据库表和接口设计去表达它们。SpringBootVue3MyBatisMySQL这套组合恰恰是能把事情说清楚又不会过度设计的选择。如果你准备拿这套源码二次开发我建议先别急着改功能把数据库表结构和几个核心接口的调用链看一眼比如用户登录、下单选课、提交作业这三条理解清楚数据是怎么流转的再动手加需求。代码里的权限拦截、统一返回封装这些规范都是可以直接沿用的成熟写法别轻易推翻。最后提醒一句上线前记得把默认密码改掉、数据库账号用独立的低权限账号、定期导出备份数据库。祝顺利。