☰
基于Spring Boot的家教信息撮合与预约管理系统实战
2026/9/28 6:01:07 网站建设 项目流程

家教的场景,其实就是典型的“信息撮合 + 预约管理”业务。家长要按学科、按区域找老师,老师要管理自己的时间表和收入,平台要解决信任和结算问题。用 Spring Boot 来做这套东西,胜在生态成熟、上手快、部署省心,而且后续要加支付、加消息推送、加小程序端,都有现成的路子可走。这篇内容不聊虚的,直接从系统怎么拆、表怎么建、接口怎么设计、踩过哪些坑说起,给准备做毕业设计或者个人项目的朋友一份可以直接抄作业的参考。

1. 项目定位与核心需求拆解

1.1 家教管理系统到底在管什么

很多人一听“家教管理系统”,脑子里冒出来的第一反应是“课程表管理”,这其实是把系统的边界想窄了。真正跑起来的家教平台,核心要解决三类问题:第一,家长的找老师需求怎么高效匹配到合适的教员;第二,教员的授课安排、学生进度、课时结算怎么有序管理;第三,平台方怎么对双方的交易过程做信任背书。

所以这套系统我从一开始就定了三个核心角色:管理员、家长/学生端用户、家教老师端用户。管理员负责审核老师的资质、处理投诉、查看平台运营数据;家长可以发布家教需求、浏览老师列表、发起约课、课后评价;老师则可以维护个人简历、设置可授课时间、确认订单、记录每次课的上课内容和学生反馈。所有角色统一通过账号体系进入,但看到的功能菜单完全不同。

这里有一个关键设计取舍:到底要不要做“学生独立账号”。我见过很多设计把学生和家长混成一个实体,字段上既放家长手机号,又放学生年级、学校。实际用起来很别扭,因为一个家长可能有两个孩子,一个孩子也可能由父母双方共同管理课程。我最后采用的是“家长账号 + 学生档案”模型,家长登录系统后可以维护多个孩子档案,下单时选定具体哪个孩子,这样数据统计和后续推荐都能更精准。

1.2 为什么选 Spring Boot 作为主框架

这个项目的技术选型,我基本上是照着“学业 + 工程实践兼顾”的标准来定的。Spring Boot 2.7.x,搭配 MyBatis-Plus 作为持久层框架,MySQL 8.0 存业务数据,Redis 做验证码和热门老师榜单缓存,前端直接用 Vue + Element UI 做后台管理界面,移动端优先考虑 H5 适配。这套组合不是最新潮的,但绝对是最稳的。

Spring Boot 在这个项目里的价值,首当其冲的是自动装配和起步依赖。引入spring-boot-starter-web、spring-boot-starter-validation、spring-boot-starter-data-redis之后,项目基本不用写 XML 配置,一个application.yml就能把数据源、Redis、日志级别全部搞定。对于一个人要完成前后端所有模块的开发者来说,少折腾配置就是省下大把时间写业务逻辑。

还有一点容易被新手忽略:Spring Boot 的版本兼容性。我刚开始图新,用过一段 Spring Boot 3.x,结果发现 MyBatis-Plus 的某些旧版本没适配 jakarta 命名空间,牵扯出不少麻烦。后来果断退回 2.7.18 这个非常成熟的版本线,所有中间件稳稳兼容,开发期内再没出过环境层面的幺蛾子。做项目,稳定压倒一切,没必要追新版本给自己添堵。

1.3 系统的完整业务流程梳理

在动手建表之前,我先把核心业务场景走了一遍,画出几条主链路:

  • 找老师链路:家长浏览老师列表/按学科搜索 → 查看老师详情(资质、评价、可约时段) → 发起预约请求 → 老师接受/拒绝 → 生成正式订单。
  • 授课管理链路:老师确认订单后 → 每次上课前查看课表 → 课后填写课时记录(授课内容、学生表现、下次课建议) → 家长确认课时 → 课时费进入结算池。
  • 平台运管链路:管理员审核认证老师 → 处理举报/退款申请 → 查看各学科供需数据 → 调整首页推荐位。

每条链路的状态流转我都用常量字典管理起来了,绝不硬编码魔法值。比如订单状态我定义了六种:WAIT_CONFIRM(待确认)、CONFIRMED(已约课)、IN_PROGRESS(授课中)、WAIT_COMMENT(待评价)、FINISHED(已完成)、CANCELLED(已取消)。状态一多,就特别容易在业务代码里出现“改了一个地方漏了另一个地方”的bug,所以我把所有状态变更都收敛到 Service 层的方法里,Controller 层只负责接收参数和返回结果,不允许出现order.setStatus("FINISHED")这种裸写。

2. 数据库表结构设计与核心字段解析

2.1 核心数据表一览与表关系梳理

数据库设计是整个系统能稳定运行的基石。我总共设计了十张核心表,按业务域可以分成四组。

第一组是用户域:sys_user(统一登录账号表)、teacher_profile(家教详情表)、student_profile(学生档案表)、parent_student_rel(家长与学生关联表)。这里没有把家长单独建表,因为家长就是sys_user里角色为ROLE_PARENT的那部分用户,这样登录认证只需要查一张表,省去了联表查询。

第二组是业务域:course(课程/学科表)、appointment(预约单表)、order_info(正式订单表)。appointment和order_info其实可以合并,但考虑到预约只是意向,订单才涉及金额和结算,分开更符合实际业务形态。

第三组是评价域:evaluation(评价表),记录家长对老师的评分、评语和匿名标记。

第四组是系统域:operation_log(操作日志)、sys_dict_item(字典项表)。日志表主要用来记录敏感操作,比如管理员修改老师资质审核状态、用户退款申请等。

表之间的关系不算复杂:一个老师用户对应一份teacher_profile;一个家长用户可以维护多个student_profile;一个学生档案可以关联多张订单;一个老师可以关联多个评价。但当初设计时踩过一个坑——在order_info里同时存了student_id和teacher_id,后来发现要查“某学生上一任老师是谁”“某老师近三个月教了多少个不同学生”这类统计时,缺一张历史关联表。后来加了teaching_record表,每次课时结束后写入一条记录,带科目、时间、时长、课时费、学生与老师的快照信息,统计类需求瞬间轻松了。这个经验非常重要:业务快照表该加就加,别只图表少。

2.2 老师档案表的字段设计与资质审核逻辑

teacher_profile表是系统里信息密度最大的一张表,字段包括:真实姓名、性别、头像URL、所在大学、专业、学历、教龄、擅长的学段(小学/初中/高中)、可授课科目(存逗号分隔的课程ID),还有资质证明材料的文件路径、个人介绍、授课方式(线上/线下可多选)、每小时收费价格区间、试听是否免费、实名认证状态、审核状态和审核备注。

这里要重点说说审核状态字段的设计。我给它设计了三个值:PENDING(待审核)、APPROVED(已通过)、REJECTED(已驳回),而不是只用一个布尔值verified。原因很简单,管理员驳回时需要填写备注,且被驳回后老师修改资料要能再次提交,这个时候必须要区分“从未提交过”和“被驳回后重新提交”,否则状态机根本走不通。

资质审核的流程我是这样实现的:老师提交实名资料时,上传身份证照片、学生证或教师资格证照片到本地文件服务的uploads/qualification/目录,同时审核状态置为PENDING。管理员端通过后,系统自动把该老师的status从OFFLINE改为ACTIVE,老师才会出现在前端的“优选老师”列表中。这个“审核通过后才可见”的设计很关键,否则未审核老师也能被约课,平台信任体系就崩了。

2.3 课时计费与订单金额的计算方式

课时费是这个系统里最敏感的字段,我在设计时用了一个“三段式”金额模型:teacher_profile里存老师的公开报价(元/小时),预约单里存确认报价(老师在收到预约请求时可以微调,比如距离远的加收跑腿费),订单完成后实际结算按确认报价走。

这看起来多了一步,但非常实用。公开报价只是吸引家长浏览的展示价,真正到了上门授课场景,老师有权根据距离、教材难度、是否是毕业班等实际情况调整价格。当然,为了方便系统自动推荐,我在推荐算法里用的都是公开报价,同一学科、同等资历的老师,报价更低的排序靠前,这符合大多数家长的筛选心理。

金额字段我全部使用的DECIMAL(10,2),避免浮点数精度问题。在计算订单总额时,我没有在前端页面做任何金额运算,所有价格由后端根据teacher_profile和appointment中的字段计算后返回。这个原则必须坚持:钱的运算永远只在服务端做。

3. 前后端接口设计与核心功能实现

3.1 用户认证与登录授权的完整实现

登录认证这块,我用的是 Spring Security + JWT 的无状态方案。用户输入手机号和密码,后端校验成功后生成 JWT Token,前端把 Token 存在localStorage,每次请求在 Axios 拦截器里统一塞入Authorization: Bearer {token}请求头。

SecurityConfig里最关键的是放行策略配置。我允许匿名访问的路径有:/api/auth/login、/api/auth/register、/api/course/list、/api/teacher/search、静态资源路径和/api/common/**。其余路径必须携带有效 Token。注意,必须把 Spring Security 默认生成的登录页和 CSRF 防护关掉,否则前后端分离模式下会报 403。我的配置核心代码如下:

http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/auth/login", "/api/auth/register", "/api/course/list", "/api/teacher/**").permitAll() .antMatchers("/api/admin/**").hasRole("ADMIN") .antMatchers("/api/teacher/**").hasRole("TEACHER") .antMatchers("/api/parent/**").hasRole("PARENT") .anyRequest().authenticated();

JWT 生成时我把用户 ID、用户名、角色放了进去,密钥存放在application.yml,并用@Value注入。Token 有效期设为 24 小时,快过期时前端根据过期时间提前调用刷新接口。这里有个细节:敏感操作比如修改密码、申请退款,我会要求用户重新登录获取临时 Token,防止 Token 泄露后的风险扩散,这也是工程实践里很容易被忽略的安全习惯。

3.2 找老师搜索与推荐功能的实现思路

老师搜索是系统里高频使用的接口,最初我用的是LIKE '%关键词%'模糊匹配学科名称和老师姓名,数据量一大性能就明显下降。后来换成 MySQL 全文索引配合ngram解析器,搜索体验才有了质的提升。

建全文索引的 SQL 大致如下:

ALTER TABLE teacher_profile ADD FULLTEXT INDEX ft_search (subject, school, teacher_name) WITH PARSER ngram;

查询的时候用MATCH ... AGAINST代替LIKE,相关性高的老师排前面。不过一提到全文索引就有个坑:MySQL 全文索引对中文分词依赖ngram,而ngram默认最小分词长度是 2。也就是说用户搜索“数”这种单字学科字段会搜不到结果。我的解决办法是在前端做了输入联想,用户在搜索框打到第二个字才真正发起搜索请求,同时保留“按学段筛选”“按价格区间筛选”等结构化筛选条件作为兜底。

推荐模块的逻辑相对简单:首页的“优选老师”取评分最高的前 6 位,配合 Redis 缓存,Key 为hot_teacher_list,有效期 30 分钟。每次管理员调整推荐位或有人产生新评价时删除该缓存,让下一次请求重新加载。做推荐别一开始就上协同过滤,数据量不够,效果反而不如规则推荐。

3.3 预约与下单的并发控制和事务处理

预约接口是这个系统里最核心的写操作,涉及多张表的联动更新:插入appointment记录、更新老师appointment_count统计、给老师推送站内通知。为了防止同一个时间段被多个家长同时预约,我用了两种手段结合:数据库层面在关键表增加唯一索引(比如老师 ID + 开始时间),业务层面使用 synchronized 关键字做了 JVM 进程内的锁保护。

当然,单机锁在分布式环境下是不完整的,但考虑到毕设或个人项目的场景,单机部署完全够用。真正的关键是要在 Service 层把多表操作放进同一个@Transactional事务里,任何一步失败都整体回滚。这里我踩过一个印象深刻的坑:Spring 的事务默认只对RuntimeException回滚,如果我在代码里手动 catch 了异常但不重新抛出,事务是不会回滚的。比如这样写就是错误的:

try { appointmentService.create(appointment); teacherService.incrementCount(teacherId); } catch (Exception e) { log.error("预约失败", e); // 异常被吞掉了,事务不会回滚 }

正确的做法是不捕获或者捕获后throw new RuntimeException(e),让事务代理感知到异常。这个细节写进简历里,就是实打实的项目经验。

3.4 课时记录与课后评价的闭环设计

每次老师上完课,需要在系统里提交课时记录,内容包括:实际上课时长、授课内容描述、学生课堂表现、作业布置情况。家长端确认后,这笔课时才算有效,老师的待结算金额才会增加。

评价模块我设计了两个维度的打分:教学水平(1~5分)和沟通态度(1~5分),总分取平均。评价表还带一个is_anonymous字段,家长可以选择匿名评价,但系统后台仍然能查到真实用户 ID,方便出纠纷时追溯。

这里有一个体验优化的小细节:评价入口不是课后立即开放,而是订单状态变为WAIT_COMMENT后 7 天内开放。超过 7 天自动变成FINISHED,跳过评价环节。这样做既给了家长充足的思考时间,又避免订单永远卡在待评价状态导致结算流程无法走完。同时在订单确认页面我做了二次确认弹窗“请确认该课时已完成且无纠纷”,减少后续扯皮。

4. 安全防护与性能优化实录

4.1 XSS 攻击与 SQL 注入的防御方案

家教的评价区和老师自我介绍都是富文本输入区域,天然是 XSS 攻击的重灾区。最初我天真地以为用了 MyBatis 的#{}占位符就万事大吉,后来才意识到:参数化查询能防 SQL 注入,但完全管不了 XSS。一个恶意用户完全可以在评价内容里写入<script>/* 盗取管理员 Cookie */</script>,管理员后台一查看就中招。

我的防御方案分三层:第一层,前端在提交表单时使用v-model配合正则过滤尖括号;第二层,后端引入自定义的XssFilter,继承HttpServletRequestWrapper,对getParameter和getInputStream回来的内容做 HTML 标签清理;第三层,存储层对用户输入统一做HTMLUtils.htmlEscape转义,展示层用Thymeleaf时加上th:utext转义输出。

需要特别提醒的是,处理上传 PDF、Word 等文件内容时也一样要防 XSS。文本型 PDF 在解析提取内容后要过一遍白名单,文件本身存到磁盘时对文件名做重命名处理,不用用户原始文件名。形如../../这样的路径穿越字符必须过滤,否则恶意用户上传文件会导致任意目录写入。项目里我统一用 UUID 重命名文件,后缀做白名单校验(.jpg .png .pdf .docx),这才把上传链路堵严实。

4.2 MyBatis 分页插件的正确使用方式

系统里所有列表接口都涉及分页:老师列表、订单列表、评价列表、操作日志列表。我用的是Mybatis-Plus内置的分页插件,配置一个MybatisPlusInterceptorBean 并注册PaginationInnerInterceptor即可。

分页插件最大的坑在于:分页查询发生在 Service 层,但入参Page对象和出参IPage必须保持类型一致。如果 Service 方法返回的是List,哪怕 Controller 层传入Page对象,插件也不会执行 count 查询,分页直接失效。正确的写法是:

public IPage<OrderVO> listOrders(OrderQuery query) { Page<Order> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<>(); // 拼接查询条件 return orderMapper.selectPage(page, wrapper); }

还有一个容易忽略的性能问题:分页时不要 SELECT 所有字段。比如订单列表只需要展示订单号、科目、授课时间、金额、状态,就不要把teaching_record里的大段文本内容也查出来。我在查询 VU 对象(VO 就是给前端展示的视图对象)时,用 MyBatis 的<resultMap>明确映射需要返回的字段,避免大字段拖慢查询速度。

4.3 Redis 缓存与热门数据的异步刷新

Redis 在这个项目里主要缓存了三类数据:

  • 短信验证码:Key 为sms:verify:手机号,有效期 5 分钟,单条记录只允许用户每分钟请求一次。
  • 热门老师列表:缓存首页推荐位,上面提过,不再赘述。
  • 系统字典数据:如学段、学科、授课方式等枚举类信息,加载后缓存 1 小时,大幅减少数据库查询频率。

这里想分享一个异步刷新的优雅写法。不要在每次请求里if (cache == null) { load; },这会有缓存击穿的风险。应该用@Cacheable注解加sync = true属性,让同一个 Key 的并发请求只有一个能穿透到数据库,其他线程阻塞等待缓存填充:

@Cacheable(value = "hot_teacher", key = "'list'", sync = true) public List<TeacherVO> getHotTeachers() { return teacherMapper.selectHotTeachers(); }

数据变更后,直接在新增评价或者管理员调整推荐位的 Service 方法里调用cacheManager.getCache("hot_teacher").clearLocals()或者通过注解@CacheEvict删除缓存。这块逻辑在整个项目中很独立,但能带来的性能提升却非常明显。

5. 常见问题排查与实操避坑

5.1 启动失败与依赖冲突的典型场景

我复盘整个开发过程,遇到过四类典型的启动问题,这里给出定位路径。

第一类是Port already in use。Spring Boot 默认 8080 端口,Mac 和 Linux 系统排查方式比较简单,直接lsof -i:8080找到占用进程干掉;Windows 用netstat -ano | findstr 8080加taskkill /PID。为了防止本地多个项目打架,我在application.yml里让启动端口可配置,用--server.port=8081传参覆盖,开发时切换项目再也不用开杀进程。

第二类是数据库连接失败,报错通常是Access denied for user或Communications link failure。我初期经常栽在allowPublicKeyRetrieval=true这个参数上,MySQL 8.0 默认驱动版本与caching_sha2_password认证插件不兼容,解决方案是在 JDBC URL 上加上useSSL=false&allowPublicKeyRetrieval=true,这是本地开发环境的默认标配。

第三类是MyBatis 接口找不到,报错Invalid bound statement (not found)。排查顺序是:检查@Mapper注解或@MapperScan扫描包路径是否正确,再检查 XML 文件存放在resources/mapper/下时,mybatis-plus.mapper-locations是否配置为classpath:mapper/*.xml。最容易出问题的是 Maven 项目里 XML 文件放到了src/main/java目录下,导致打包后没有被编译到 classpath,解决办法是配置build/resources明确包含该目录。

第四类是Spring Security 导致接口 401。这个最常见的原因是用户登录接口被安全拦截器挡了,但permitAll配置没生效。我调试了很久才发现,antMatchers("/api/auth/**").permitAll()必须放在anyRequest().authenticated()之前,顺序错误会导致放行策略被覆盖。

5.2 逻辑越权与数据可见性的修复记录

家教系统里有个典型的安全漏洞:越权访问。比如用户 A 登录后,直接修改 URL 中的订单 ID,理论上就能查看或操作属于用户 B 的订单。我在初版代码里也犯过这个错误——Controller 层只做了“是否登录”的校验,没做“是否为资源所属人”的校验。

修复思路很简单,所有涉及资源查询的 Service 方法,必须把当前登录用户 ID 作为查询条件之一传进去。比如查询订单详情的 SQL:

SELECT * FROM order_info WHERE id = #{orderId} AND (parent_id = #{currentUserId} OR teacher_id = #{currentUserId})

管理员查询则走单独的方法,不走用户端接口。这里我给所有 Controller 方法加了一个统一的getCurrentUserId()工具方法,从 JWT 的 Claim 中解析。这样开发者写新接口时没有借口跳过资源归属校验,从架构层面堵住了越权漏洞。

5.3 文件上传大小限制与路径安全

老师提交资质证明需要上传图片和 PDF,最初一传超过 1MB 就报错。Spring Boot 默认单文件上传大小限制是 1MB,需要在配置里显式调整:

spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB

这里有个隐藏隐患:请求体大小限制大于单文件限制。如果同时上传多张图片,max-request-size控制的是单次请求总体积。我把单文件设成 10MB,请求总体积设成 20MB,最多允许同时上传两张证书,刚好满足需求。

文件存储路径方面,我在项目根目录下建了uploads/目录,通过配置项file.upload-dir指定,然后使用ResourceHandlerRegistry将/uploads/**映射到本地磁盘路径。生产环境下我会把uploads/目录挂载到云盘或对象存储,但因为本地开发用的不多,这里就不展开了。

5.4 事务失效与数据不一致的复盘

支付回调、结算入账这类涉及金钱的操作,必须保证幂等性。我的课时结算逻辑是:家长确认课时后,系统更新order_info状态 =FINISHED,同时往settlement_detail表插入一条待打款记录。如果家长重复点击确认按钮,就会出现订单已经是FINISHED,但结算记录插了两条的问题。

解决办法是给order_info关联的结算业务引入唯一键:UNIQUE KEY uk_order_settlement (order_id),插入重复数据时数据库直接拒绝。然后业务代码捕获DuplicateKeyException,返回“订单已结算”的提示,而不是报 500 错误。这种“数据库约束 + 应用层捕获”的双保险,是处理幂等最简单可靠的方式。

另外一个事务失效的坑:同类的自调用绕过代理。我在处理订单时写了this.handleOrder(order)方法,该方法上有@Transactional,结果事务完全不生效。原因是this调用没有经过 Spring 代理对象,注解直接被忽略了。改成注入自身代理或者拆成两个 Bean 后问题解决。这类问题不发生在业务量大的项目里根本暴露不出来,但一旦踩中就非常隐蔽。

6. 项目复盘与可扩展方向

做这个家教管理系统,我前后花了约三周时间,从需求梳理到数据库设计,再到前后端编码和自测上线,整个过程让我重新理解了什么叫“业务驱动开发”。刚开始我花了很多时间在纠结用什么最新技术栈,后来发现真正的工作量全在业务规则的梳理上。比如“一节课该在什么状态下算完成?”这个问题就牵扯出家长确认、老师提交记录、平台介入申诉等一串设计。如果一开始没想清楚,代码写到一半必然推倒重来。

在这个阶段,我的经验是:TDD不一定适合所有项目起步阶段,但接口文档先行一定值得坚持。哪怕只是用 YApi 或者直接写 Markdown,先把每个接口的路径、入参、出参、错误码列清楚,前端照着对接,后端照着实现,两个端并行推进效率最高。否则项目做到一半,前后端联调时才发现字段对不上,返工成本非常惨痛。

系统做出来之后,我留了三个扩展口子,后续有时间可以继续填坑:一是接入在线支付功能,把课时费从“待结算金额”变成真实的微信/支付宝支付流程;二是增加聊天模块,让家长和老师在站内直接沟通课程细节;三是把移动端从 H5 升级成原生小程序,体验更好。这个项目的核心数据模型和接口设计,在这三个方向下都不会推翻重来,这就是当初多做一步抽象设计带来的后期红利。

最后还要提一嘴维护期的感受:别小看操作日志和字典管理这两个看起来不太起眼的功能。真正用了才知道,没有操作日志,管理员误操作后连恢复的快照都没有;没有字典管理,每加一个学科都要改代码重新部署。把这些基础模块做扎实,整个系统才会显得“像正经做出来的东西”,而不是只能跑通演示的玩具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询