如果你在一个 Java SpringBoot + Vue3 + MyBatis 的家教管理系统源码里,只看到了 Controller、Service、Mapper 的堆叠,那基本等于只拿到了毛坯房。真正决定这个前后端分离项目能不能跑起来、能不能应付真实家教业务、能不能支撑二次开发的,是隐藏在代码背后的设计取舍:数据表怎么拆、权限怎么分、预约时段怎么锁、订单状态怎么流转、前端路由怎么守卫。这篇文章就从这些真正关键的地方入手,把一套基于 MySQL 的家教管理系统源码掰开揉碎讲一遍。
我前后经手过好几个类似的教学管理类项目,也替别人救过火。坦白说,很多号称完整的源码,跑通是个门槛,改起来是另一个门槛。所以这篇东西不只是解释“它怎么跑”,更偏向“它为什么这么设计”以及“你接手之后怎么改”。适合正在做课程设计、刚接触前后端分离项目、或者想在家教、培训、预约类业务上做二次开发的朋友参考。
1. 从“家教中介”到“供需匹配平台”:需求建模与业务边界
先说一个很多人忽略的问题:家教管理系统到底管什么?很多半成品源码把重点放在“增删改查”上,教师表能新增、课程表能新增、订单表能新增,但整个系统没有一条完整状态链。这种系统演示起来没问题,真拿去用,家长约了课不知道有没有被确认,老师改了时间家长也不知道,订单支付后状态不更新,整个就乱套了。
家教业务从本质上是一个双边交易平台,它的一端是教师供给,另一端是家长需求,中间还有平台方的审核与交易撮合。所以需求建模至少要拆出三个视角:
- 家长端:注册登录、浏览教师、按科目/价格/评分筛选、发起预约、支付订单、查看上课安排、提交课后评价。
- 教师端:入驻申请、资质资料维护、发布课程、设置可授课时段、管理预约订单、确认或取消课程、查看收益记录。
- 平台管理端:教师入驻审核、科目/价格体系管理、用户管理、订单监督、投诉处理。
如果只做前两端的 CRUD,那顶多算“信息展示系统”,不是“管理系统”。真正的家教管理系统必须把供求匹配和交易闭环打通,所以核心业务在源代码里应该集中在几件事上:教师资料的结构化存储、上课时段的可用性管理、预约动作的并发控制、订单状态的一致性流转、评价与信用数据的沉淀。
在设计数据库之前,先把这些问题想清楚,后面写表就不容易返工。我见过有项目把“星期几上课”直接拼成字符串塞进一个字段里,查询“周三上午有空”的老师时只能靠 LIKE 模糊匹配,数据量一上来就完蛋。这就是没有在需求建模阶段考虑清楚“时段”到底是一个属性,还是一组可被检索的记录。
2. 数据表设计中的四个关键决策
数据库设计是这套源码的地基。MySQL 在这类项目里承担的是主存储角色,所以表结构设计直接决定了后续的查询效率、扩展空间和踩坑数量。我把整个家教系统里最有代表性的四个设计决策单独拿出来说。
2.1 用户体系:主表拆开,角色模型独立
家教系统里最忌把“家长”和“教师”做成两张割裂的用户表。因为真实场景里,完全可能存在“既是家长又是家教老师”的账号,也完全有可能未来需要增加“运营人员”“客服”等角色。用两张表存两种人,最后一定会遇到数据同步问题。
这套源码采用的模式是:一张t_user用户主表 + 一张独立的角色表 + 一张用户角色关联表。用户主表只存登录账号、密码密文、昵称、头像、手机号、账号状态这些通用字段;教师独有的资质信息、授课科目、课时单价、教龄、简介这些,单独放t_teacher_profile扩展表里,与主表一对一关联。
这么做的好处非常直接:家长和教师共用登录认证逻辑,教师子信息可以按需扩展,后续增加新的用户子类型(比如督学、助教)不用动用户主表。实际写 SQL 时,查询教师列表就是user JOIN teacher_profile,查询家长信息就是user LEFT JOIN teacher_profile WHERE teacher_id IS NULL,语义清晰、索引友好。
2.2 课程与时段:模板实例法,而不是一张表硬扛
“课程”这个概念也要拆。一位老师发布的课程通常是一个长期项目,比如“高中数学一对一辅导”,每周可能上两次课,每次一小时。如果直接把“课程”和“某个时间段的具体一次课”写在同一条记录里,那发布一个持续三个月的课程就要生成几十条记录,改一次价格还要批量更新,非常痛苦。
合理的做法是拆成两层:
t_course:课程模板,记录课程名称、科目、老师ID、课程简介、课时单价、适用年级、状态。t_course_session:课程实例,也就是一个可被预约的具体时段,记录课程ID、开始时间、结束时间、上课方式(线下/线上)、状态。
发布课程时先生成模板,再根据老师设定的每周规律批量生成未来 N 周的时段记录。预约动作发生在 session 这一层,某个时段被锁定了,状态就从“可预约”变成“已锁单”,支付成功再变成“已确认”,上完课变成“已完成”。
这里有一个很关键的点:生成时段记录时,要一次性把时间归属的星期几、按周索引这些冗余字段也算好。这样前端“课程日历”和后端“本周可约列表”查询时,直接按时间范围过滤即可,不需要在内存里做复杂的日期推算。
2.3 预约冲突:用唯一索引兜底并发
家教系统里技术含量最高、最容易出 bug 的地方,不是课程 CRUD,而是同一个老师的同一个时段被两个家长同时抢到。真实业务里,家长看到“晚上七点有空”时,另一个家长可能也正在下单。如果代码里只靠if (session.status == 可预约)来判断,在高并发下就是一个典型的竞态条件。
源码这里做法是双保险。第一道用数据库唯一索引:给t_course_session的状态字段和预约锁定表建立一个组合约束,保证同一时间点、同一老师、同一时段只能有一条“有效预约单”。第二道在应用层用事务配合条件更新(UPDATE ... WHERE status = 可预约)来抢占,影响行数为 0 则说明被别人抢先一步。
我第一次接触这种场景时,总觉得“加个锁”很麻烦,后来实测发现,对于这种体量的项目,数据库唯一索引配合乐观条件更新,比分布式锁简单得多,也足够可靠。值得注意的是,唯一索引的字段组合必须仔细设计,不能想当然把“用户ID”放进去,要围绕着“业务冲突语义”来设计。
2.4 订单与评价解耦,状态字段不是万能筐
订单表和评价表建议拆开。有的项目图省事,把评价内容、评分直接做成订单表的字段,看起来少了一张表,实际上后患无穷。一个订单可能被反复评价、修改评价,或者管理员介入后需要记录审核意见,这些如果都堆在t_order一行里,字段越加越多,查询越来越慢,最后变成一张大宽表。
合理的拆分是:t_order存订单流水、金额、关联的课程时段ID、买家ID、卖家ID、订单状态、支付流水号、支付时间等交易核心字段;t_review独立存储评分、评价内容、匿名标记、回复内容、审核状态,通过订单ID或课程ID关联。这样订单表保持轻量,评价数据可以单独做列表、统计和审核流程。
另外,订单状态字段要设计成明确的枚举值,而不是随便写个字符串。比如订单状态只能是PENDING_PAY(待支付)、PAID(已支付)、CONFIRMED(已确认)、COMPLETED(已完成)、CANCELLED(已取消)、REFUNDING(退款中)。状态枚举值在前后端共用时,要定义成统一的字典,前端状态标签的文案要跟后端枚举严格对应,这个问题我在联调阶段被坑过,后面会专门说。
3. 权限模型:为什么不用 user_type 字段,而要引入 RBAC
很多人写管理系统,权限就是很简单的一个user_type字段:0 是管理员,1 是老师,2 是家长。代码里到处if (user.getType() == 0)。这个方案在小 demo 里确实够用,但放到家教管理系统里,很快就会遇到尴尬:运营后台来了一个“内容审核员”,他需要查看教师资料但不需要管订单;又来了一个“客服”,只能看订单、不能改价格。这时user_type字段就完全不够用了。
3.1 RBAC 的角色、菜单、权限三层结构
这套源码采用的是经典 RBAC 模型,拆成五张核心表:用户表、角色表、用户角色关联表、菜单/权限表、角色菜单关联表。用户与角色是多对多,角色与权限也是多对多。具体到业务上:管理员角色拥有全部菜单权限,教师角色拥有课程管理、订单管理、个人中心菜单,家长角色拥有选课、预约、订单、评价菜单。
Spring Security(或 Shiro)配合 JWT 做无状态认证时,登录成功后把用户角色集合放进 token 的 claims 里,前端根据角色渲染不同的菜单和按钮。这里要特别注意:token 里放角色只是为了前端展示拦截,真正的接口鉴权必须在后端做。因为 token 是可以被解密的,如果把角色当成安全凭证,权限校验就是纸糊的。
3.2 数据级权限:老师只能改自己的课
角色权限解决的是“能不能访问这个接口”,数据权限解决的是“能访问哪些数据”。家教系统里最典型的需求:A 老师不能修改 B 老师的课程,家长只能看到自己的订单。这个逻辑如果写在每个 Service 方法里,会非常啰嗦,而且容易漏。
我在实际项目里常用的做法是,在 SQL 层强制拼上归属条件:查询课程列表时,Service 层从当前登录用户上下文里取出 userId,执行查询时必定带上WHERE course.teacher_id = #{userId};更新操作前先做一次归属校验,确保资源属于当前用户。不要依赖前端把 userId 传过来,前端传的 ID 一律不可信,必须从认证上下文里取。
这里补充一个小细节:MyBatis 的resultMap如果配置了复杂关联查询,数据权限过滤的 SQL 片段最好抽出来做成公共 SQL 片段(<sql>标签),避免在几十个查询 XML 里重复粘贴同一段条件。否则后面改动过滤条件时,全局搜索替换会改到人崩溃。
4. 课程发布与预约主流程:状态机落地与事务边界
如果说数据库表结构是骨架,那业务状态机就是血管。一个家教系统能不能真实运转,看的是“课程发布 -> 用户浏览 -> 预约锁定 -> 支付确认 -> 上课完成 -> 评价”这条链路每个环节的状态流转是否严密。
4.1 预约锁单的状态流转
预约这个动作,我建议走一个“两阶段”模型:
- 家长提交预约时,系统先把对应
t_course_session从“可预约”改为“锁单中”,同时生成一条待支付订单。此时该时段不再向其他家长展示。 - 家长在限定时间(比如 15 分钟)内完成支付,订单状态更新为“已支付”,课程时段更新为“已确认”。
- 如果超时未支付,定时任务或延时队列把时段状态回滚成“可预约”,订单标记为“已取消”。
状态机可以简单用下表概括:
| 阶段 | 订单状态 | 课程时段状态 | 触发动作 |
|---|---|---|---|
| 提交预约 | 待支付 | 锁单中 | 应用层条件 UPDATE |
| 支付成功 | 已支付 | 已确认 | 支付回调 + 状态流转 |
| 超时未付 | 已取消 | 可预约 | 定时任务回滚 |
| 完成上课 | 已完成 | 已完成 | 教师/系统确认 |
| 退款取消 | 退款中/已退款 | 可预约/已释放 | 管理员或客服操作 |
这套状态流转最核心的原则是:任何一步状态变更都必须在事务里执行,而且只能流转到合法的下一个状态。绝对不能出现“已支付”直接跳到“已取消”这种非法路径。
4.2 事务边界与幂等:别把支付回调搞成重复下单
支付模块是家教系统里事务最复杂的地方。拿常见的支付回调为例:支付平台回调你的接口,告诉你“用户支付成功”。但回调可能因为网络原因重复发送多次,如果回调处理逻辑没有做幂等,就会出现同一笔订单被重复确认、甚至重复释放时段的问题。
正确的做法是:回调接口入口先根据“订单号 + 支付流水号”去查订单当前状态,如果订单已经是“已支付”,直接返回成功,不再执行后续的更新逻辑。只有订单处于“待支付”状态时,才继续走“更新订单状态 + 更新课程时段状态 + 记录支付流水”这三步。
这三步必须在同一个事务里。如果分开写,订单状态改了而课程时段状态没改,用户支付成功后课程还是锁单中,家长那边看到的状态就会错乱。我在源码评审时见过不少这种“事务边界划错”的问题,典型的症状就是:单测看着都过了,联调时多试几次并发回调,数据就花了。
4.3 锁单超时回滚的两种实现
实现超时释放,常见的方案有两种:
一是基于数据库定时任务:每几分钟扫描一次“锁单中”的订单,如果创建时间超过 15 分钟且仍未支付,把订单置为取消、课程时段恢复可预约。这个方案简单、不依赖额外组件,但精准度差,可能有几秒到几分钟的延迟。
二是基于延时消息队列:下单时发送一条延迟消息,到期后消费消息检查订单状态,未支付则执行回滚。这个方案精准,但需要引入消息中间件,对普通单体项目来说有点杀鸡用牛刀。
我的建议是:教学项目或中小型真实项目,用第一种就够了。定时任务的延迟完全在可接受范围内,关键是任务执行时要加好事务和状态判断,不能把“已支付”的订单又取消掉。
5. Vue3 前端的“数据驱动页面”实践:路由守卫与状态管理
后端再强壮,前端拉胯一样白搭。这套家教系统的前端基于 Vue3,搭配 Vite 构建,整体走的是组合式 API 写法。我从实际改造和联调的角度,挑三个最值得说的点展开。
5.1 登录状态保存与 Axios 封装
前端所有请求都需要携带 JWT token,所以 Axios 必须做统一封装。常规做法是:请求拦截器从 Pinia(或 localStorage)取出 token,塞进请求头的Authorization字段;响应拦截器统一处理后端返回结构,遇到 401 状态码时,清除本地登录态并跳转登录页。
这里有一个很容易被忽略的细节:token 过期和 token 缺失要区分处理。缺失说明用户根本没登录,直接跳登录页;过期说明登录状态失效,应该提示“登录已过期,请重新登录”,然后跳转。如果两个场景混在一起,用户会被莫名其妙的刷新跳转搞得很困惑。
5.2 动态菜单与按钮级权限控制
Vue3 前端通常会在登录后根据用户角色渲染菜单。一种是前端静态定义好全部菜单,根据角色过滤;另一种是登录后从后端接口获取“当前用户的菜单列表”,动态注册路由。
个人认为家教管理系统这种规模,静态定义 + 过滤就够了,无需动态路由。后端把用户角色返回给前端,前端在路由守卫里判断当前用户是否有权限访问目标路由,无权限则重定向到 403 页面。菜单渲染用模板协议:后端返回的菜单数据里带component字段的字符串值,前端通过import.meta.glob实现组件映射,或者在本地维护一个菜单字符串到组件的映射表。
按钮级权限用自定义指令v-permission控制。比如“取消订单”按钮只有家长和管理员能看到,“审核入驻”按钮只有管理员能看到。指令内部从 Pinia 的 user store 里取角色集合,没有权限就把元素移除掉。
5.3 预约表单的联动校验:前端的脏活累活
前端最容易写得乱的地方,其实是“预约上课”这种联动表单:选择科目、选择老师、选择上课时段、选择上课方式。尤其上课时段,要按周几展示,不同老师的可约时段不一样,选完老师后时段选择器需要动态刷新。
这里我强烈建议把“时段数据”在进入页面时一次性拉全,按老师的维度在内存里做索引映射,前端选择老师时直接本地过滤,而不是每次切换老师都发请求。减少无谓的联调请求只是表面好处,更重要的是交互流畅度——真实用户等不起每次切换都转圈圈。
时段冲突的校验不能只靠前端,后端接口在提交订单时还要再校验一次。前端校验是体验问题,后端校验才是安全与数据一致性问题。这一点我在联调阶段深有体会:某次前端漏了校验,两个家长同时提交同一个时段,后端如果没有兜底,订单就会重复。
6. 联调阶段那些真实的坑:跨域、时间格式、MyBatis 映射与事务失效
这一章写给所有准备把这份源码跑起来的人。SpringBoot 后端和 Vue3 前端分开跑,本地联调、部署上线,每一步都有经典坑。我挑几个我亲手踩过、也帮别人排查过的,按优先级排序。
6.1 跨域配置:开发与生产的两种姿势
本地开发时,前端跑在localhost:5173,后端跑在localhost:8080,端口不同必然跨域。后端要配置 CORS,允许前端域名访问,并处理预检请求(OPTIONS)。常见的配置方式是在 SpringBoot 里写一个WebMvcConfigurer,设置allowedOrigins、allowedMethods、allowedHeaders,并且允许携带凭证。
但注意,生产环境不应继续依赖 CORS 跨域,因为这样等于向所有前端域名开放接口(如果配置成*更危险)。生产上正确的做法是在 Nginx 接入层做同源转发:前端静态资源与 API 请求共用同一个域名,前端请求/api/xxx,Nginx 将其转发到后端服务。这样浏览器视角是同源,CORS 根本不会触发,也顺便解决了 HTTPS 证书和前端 history 路由刷新 404 的问题。
6.2 LocalDateTime 序列化:一不留神前端收到的是一串数组
SpringBoot 项目引入 Java 8 时间 API 后,如果没配置好序列化格式,后端返回的LocalDateTime默认会序列化成类似[2025, 1, 15, 14, 30, 0]的数组结构,前端拿到这种数据基本没法直接显示。解决方式是在application.yml里配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8前端也可以统一封装时间格式化工具函数,把后端返回的时间字符串在展示层做格式化。另外,前端传给后端的日期参数,如果走 JSON 格式,建议统一使用字符串"yyyy-MM-dd HH:mm:ss",配合后端的@JsonFormat注解,避免前端 Date 对象序列化时带了时区偏移,导致数据差 8 个小时。这个“差8小时”问题在真实项目里非常常见,根源就是时区默认值不一致。
6.3 MyBatis 映射与 SQL 细节
MyBatis 是这套源码的持久层框架,常见的坑集中在两个地方:
第一个是下划线与驼峰的映射。如果application.yml里没有开启map-underscore-to-camel-case: true,那么实体类的teacherName字段映射不到数据库的teacher_name列,查出来的数据全是 null。开启之后,MyBatis 会自动做映射,省去大量resultMap手写字段。但注意,一旦某个查询使用了自定义resultMap,映射规则以resultMap为准,不自动应用驼峰转换,写的时候要清楚。
第二个是模糊查询与批量插入的写法。模糊查询用LIKE CONCAT('%', #{keyword}, '%'),不要直接在 XML 里写LIKE '%${keyword}%',前者走预编译防注入,后者存在 SQL 注入风险。批量插入凌晨时段的几十条初始数据,用<foreach>标签拼批量 insert,效率比单条循环高得多。之前看到一个项目在循环里逐条插入,一个排课生成接口处理 20 条时段数据耗时接近一秒,改成批量后瞬间变 10ms 以内。
6.4 事务失效的典型场景
Spring 的事务是基于 AOP 代理实现的,有几个失效场景在业务代码里非常容易踩中:
- 同类内部方法调用:Service 的 A 方法调用了同一个类里的 B 方法,B 方法的
@Transactional不生效,因为调用方是 this 自身,没有经过代理对象。 - 异常被捕获:B 方法内有事务,但是异常被 try-catch 吞掉了,事务不会回滚。应该让事务方法把异常抛出去,或者在 catch 块里手动
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。 - 非 public 方法:
@Transactional加在 private 方法上不生效。
预约锁单、订单支付回调这类核心链路,事务方法必须写得干净利落:公开方法作为事务入口,内部尽量不做 try-catch 掩盖,底层异常抛到事务边界再统一处理。我之前排查过一个诡异问题:用户支付成功后订单偶尔会丢更新,最后定位到是回调处理里 try-catch 把关键异常吃了,数据库操作静默失败,状态没更新。后来把异常处理和事务边界重新整理,问题就消失了。
7. 部署上线与日常维护:从能跑到能用的最后一步
很多人本地跑通了就觉得完事了,其实从“开发环境能跑”到“生产环境能用”,中间还隔着不少环节。这一章讲部署和上线前必须要处理的事情。
7.1 构建产物与启动脚本
后端打包用 Maven:mvn clean package -DskipTests,生成 jar 包后放到服务器。启动时建议用java -jar xxx.jar --spring.profiles.active=prod,配置文件按环境拆成application-dev.yml、application-prod.yml,避免把数据库密码写在默认配置里。生产环境数据库连接建议加上useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4参数,否则分分钟遇到时区或字符编码问题。
前端构建用npm run build,产物在dist目录。部署时,dist里的静态文件放到 Nginx 的站点目录,并配置/api前缀的请求转发到后端端口:
server { listen 80; server_name your-domain.com; root /var/www/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }最后这个try_files就是解决 Vue Router history 模式刷新 404 的关键:所有不存在的文件路径都回退到index.html,交给前端路由处理。
7.2 数据库初始化与备份
MySQL 字符集建议直接建库时就指定utf8mb4,排序规则utf8mb4_unicode_ci。因为家教系统里教师的个人简介、课程详情、评价内容都可能输入 emoji 或特殊字符,utf8mb4才能完整支持。建库语句类似:
CREATE DATABASE tutor_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;上线前要跑一遍初始化 SQL,确认表结构完整、初始管理员账号已创建。日常运维至少要做两种备份:逻辑备份用mysqldump定期导出,物理备份有条件就做增量。我见过不止一次“服务器磁盘坏了,才发现数据库从没备份过”的事故,这种低级错误真的要命。
7.3 上线后务必检查的几件事
- 文件上传目录的权限:如果老师要上传头像、资质证书,这些文件不能放在
src/main/resources/static下随代码打包,要放在服务器独立目录,并配置静态资源映射。否则每次重新部署,上传的文件就被覆盖了。 - 日志切割与磁盘监控:SpringBoot 默认日志可能一直往一个文件里写,跑几个月就能把磁盘塞满,配置一个 Logback 按天切割的规则非常必要。
- 慢查询排查:MySQL 开启慢查询日志,过段时间看看有没有
LIKE '%xxx%'导致的全表扫描,如果有,就该加全文索引或者调整查询逻辑了。
实话实说,这些内容看起来琐碎,但没有一个是可以跳过的。我在帮朋友救火时,遇到过最多的生产事故就是这三类:图片不显示(目录权限)、数据库被写满(没看日志)、接口偶发超时(慢查询没处理)。每一类都很好排查,但在上线前提前处理就根本不会发生。
最后说点个人体会。一套家教管理系统的源码,跑通它只需要半天,但真正把它变成能支撑真实业务的产品,至少还要花几倍的时间去处理状态流转、权限边界、并发冲突、部署运维这些“看不见的工程量”。如果你拿到这份源码是想学习,建议别急着优化代码,先把订单状态机画一遍,再把权限表结构梳理一遍,再看前端路由与后端接口的对应关系,这样一轮下来,你对前后端分离项目的理解会有一个质的提升。如果你想拿它做二次开发,那重点研究两处:一是预约锁定与超时回滚的扩展性,二是支付回调的幂等处理,这两个模块是整个系统里最值钱的部分。