拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot毕设实战:家教匹配预约系统的评分、状态机与并发设计

Spring Boot毕设实战:家教匹配预约系统的评分、状态机与并发设计

又是一年毕业设计季。说实话,每年看到大量“XX管理系统”的题目,我都替同学们着急——图书管理、仓库管理、会议室预约,这些项目的技术难度确实不高,但答辩的时候评委也很难找到值得深挖的点。我自己今年选定并做完的方向,是“家教智能匹配和预约管理系统”。这套系统同样是基于 Spring Boot 的管理类项目,但在常规 CRUD 之上多了两个真正有含金量的模块:一个是用加权评分把“找家教”从列表浏览变成智能匹配,另一个是用状态机加并发控制把“约课”变成可靠的核心业务流。今天这篇文章,就是把整套系统的设计思路、技术选型、数据库建模、核心算法和踩坑过程完整记录下来,给正在选毕设题目、或者想练手 Spring Boot 全栈开发的读者一个可以直接参考复现的副本。

项目跑通之后,大概的样子是:家长注册登录,填写孩子的年级、学科、预算和上课区域,系统返回按匹配度排序的老师列表;家长选好老师后,能看到老师下周哪些时段可约,提交预约后老师端实时收到新订单提醒,确认后订单进入待上课状态;上完课家长打分评价。管理员侧负责老师实名资质审核、科目字典维护和订单异常处理。业务闭环非常完整,后端技术栈是 Spring Boot + MySQL + Redis,关键场景接入了 WebSocket,属于典型的“可以写进简历、也可以写进论文”的工程量。无论你是想用它做毕设,还是想从中拆出几个模块当成项目经验,这篇文章都值得从头到尾读一遍。

1. 需求白皮书:三种角色、一条主线、以及我主动砍掉的功能

1.1 角色划分与核心业务链路

这个系统一开始我就定了三种角色:家长、家教老师和平台管理员。注意,这里我没有单独设计“学生”登录角色,因为现实中找家教的是家长,被辅导的学生并不需要操作 App,把学生信息当成家长下单时填写的表单数据更合理。这一点在答辩论里很常见,评委问“为什么没有学生端”时,能给出业务上的理由比硬着头皮做一个空壳账号更有说服力。

角色职责如下:

角色核心操作说明
家长注册登录、维护孩子信息、搜索匹配、发起预约、确认上课、评价老师整个下单流程的发起方
家教老师完善资料、维护可授课时段、接收订单、确认或拒绝预约供给方,核心是被约状态
管理员审核老师资质、维护科目字典、处理异常订单、查看统计数据保证平台内容可控

业务主线其实只有一条:家长搜索 → 系统匹配 → 家长选时段 → 提交预约 → 老师确认 → 上门/线上授课 → 评价。把这条链路想清楚,后面建表、写接口、设计状态机就全都有了基准线。

1.2 功能模块清单:该有的都有,不该有的先不做

毕设最怕的就是“功能抄了一大堆,没一个做完”。我最后落地的功能模块是这样划分的:

  • 用户认证模块:登录注册、JWT 签发与校验、密码加密存储、角色权限拦截。家长和老师共用一套账号体系,通过角色字段区分。
  • 家教资料模块:老师维护教龄、可授科目、大学或机构、时薪、简介;管理员审核后资料才对外可见。
  • 课程表模块:老师按“周几 + 时间段”维护开放时段,比如周一 19:00-20:00 可约;可禁用某个时段。
  • 智能匹配模块:家长输入筛选条件,系统按学科匹配、价格接近度、评分、教龄、距离、时段命中率综合打分排序。
  • 预约订单模块:创建订单、状态流转、时段冲突校验、订单快照保存。
  • 消息通知模块:预约成功、老师确认、订单取消等关键节点通过 WebSocket 实时推送,推送失败的补数据在登录时拉取。
  • 后台管理模块:老师资质审核、科目字典维护、订单列表和统计。

这些功能都是能在一个月内认真做完的体量。我见过不少同学把在线支付、直播上课、GPS 定位全部写进需求文档,结果开发到一半发现根本收不住。先把核心链路做扎实,比堆砌一堆半成品更有答辩价值。

1.3 主动砍掉的功能与答辩理由

我明确砍掉的有三块:在线支付、直播授课、复杂的地图定位。原因很实际:在线支付需要第三方商户资质和回调流程,个人开发调试成本高;直播授课涉及音视频服务和房间信令管理,工程量直接翻倍;地图定位如果用真实地图 SDK,光 key 申请和隐私合规说明就够写两页纸。

砍掉不是功能缺失,而是控制风险的取舍。答辩时如果被问“为什么不做支付”,我会这样回答:家教交易的信任核心是撮合和履约记录,支付作为后期商业化扩展点,在一期项目中不做深挖,但订单金额字段和快照字段已经预留,后续接入支付网关不需要改核心表。这个回答比“没时间做”要成熟得多。

2. 技术栈盘点:Spring Boot 3 还是 2.x,周边组件怎么选才不给自己挖坑

2.1 Spring Boot 版本怎么定:3.x、2.7 与 JDK 的组合拳

很多同学一上来就问“Spring Boot 用哪个版本”。我的答案很直接:新项目用 Spring Boot 3.x,配 JDK 17,别再用 2.6、2.5 那种老版本硬撑了。理由有三:一是 3.x 是当前主要维护分支,依赖漏洞修复及时;二是 Spring Boot 3 强制 Jakarta 命名空间,网上新出的教程、代码基本都按这个来,你搜问题更好搜;三是从 2.x 切到 3 其实只需要注意 javax 改成 jakarta、部分配置项改名,成本远没有想象中高。

如果学校答辩机只有 JDK 8,或者实验室环境老旧没法升级,那就选 Spring Boot 2.7.x + JDK 8,这是最稳妥的退路。但要注意 2.7 的社区维护已进入尾声,毕设演示没问题,真要上生产就得考虑迁移。我当时用 JDK 17 时踩过一个典型坑:高版本 JDK 配低版本 Lombok 会在编译期报“找不到 getter/setter”的诡异错误,解决方案是升级 Lombok 到 1.18.30 以上。这就是那种“代码看着没错但就是跑不起来”的坑,提前说一句能帮读者省一小时。

2.2 ORM 选择:MyBatis-Plus 的实感与分页插件陷阱

ORM 层我选了 MyBatis-Plus,而不是 Spring Data JPA。原因很务实:毕设项目的查询大多是单表或两表关联,MyBatis-Plus 的 LambdaQueryWrapper 写起来直观,复杂 SQL 又能自己手写,不会被 JPA 的懒加载和 N+1 问题绕晕。更重要的一点是,MyBatis-Plus 自带代码生成器,能把实体、Mapper、Service 一次性生成出来,对写论文时的“工作量描述”也有帮助。

但这里有个必须提醒的坑:MyBatis-Plus 的分页插件不能只引入依赖,必须显式配置PaginationInnerInterceptor,否则调用Page查询时会查出全表数据,第二页开始数据完全不对。我第一次跑分页接口时,明明传了页码和每页条数,返回的 total 却等于全表行数,排查了半天才意识到是拦截器没注册。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

2.3 Redis 的引入层次与不装 Redis 的替代方案

Redis 在这个项目里承担三件事:登录验证码缓存、预约时段分布式锁、幂等键存储。引入 Redis 的观感红利很明显,答辩时“项目用了 Redis 做缓存和锁”这句话本身就能撑住一些技术深度。但如果你本地实在装不上 Redis,也有临时替代方案:用ConcurrentHashMap + ScheduledExecutorService写一个简单的带过期时间的缓存工具,再配合数据库唯一索引兜底。这个方案能跑通演示,但一定要在论文或答辩中说明“生产环境会替换成 Redis”,体现你清楚两种方案的差异,而不是只能背出结论。

2.4 作为加分项的 WebSocket 与监控组件

预约这种业务,老师端最好能实时收到新订单,所以在消息模块接入了 WebSocket。具体的握手鉴权、离线补发逻辑在第 6 章展开,这里只说选型结论:不引入 STOMP 协议,用 Spring 原生TextWebSocketHandler就足够,协议越简单越不容易翻车。另一个加分项是 Spring Boot Actuator + Spring Boot Admin,把项目跑起来后能在管理端看到内存、线程、请求映射等监控信息。这个属于“锦上添花”,核心功能没跑通前不建议碰。

3. 数据库建模的胜负手:时段表、冗余字段与订单快照

3.1 核心表结构:用 DDL 说清依赖关系

这个项目数据库的重点不在于表多,而在于几张关键表怎么设计。我最先建的五张表是用户表、家教资料表、时间段表、老师课程表、预约订单表,后续再补评价表和消息表。核心结构大致如下:

CREATE TABLE `app_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL, `role` TINYINT NOT NULL COMMENT '1家长 2老师 3管理员', `status` TINYINT NOT NULL DEFAULT 1, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `tutor_profile` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `real_name` VARCHAR(30), `years_of_exp` INT, `subject_ids` VARCHAR(100) COMMENT '可授科目ID,逗号分隔', `hourly_price` DECIMAL(10,2), `distance_km` DECIMAL(5,2), `rating_score` DECIMAL(3,2), `intro` VARCHAR(500), `audit_status` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家教资料表'; CREATE TABLE `time_slot` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `start_time` VARCHAR(5) NOT NULL, `end_time` VARCHAR(5) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时间段字典表'; CREATE TABLE `tutor_schedule` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `tutor_id` BIGINT NOT NULL, `week_day` TINYINT NOT NULL COMMENT '1-7', `time_slot_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 1, UNIQUE KEY `uk_tutor_slot` (`tutor_id`,`week_day`,`time_slot_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老师可授课时段表'; CREATE TABLE `appointment_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `parent_id` BIGINT NOT NULL, `tutor_id` BIGINT NOT NULL, `subject_id` BIGINT NOT NULL, `appoint_date` DATE NOT NULL, `week_day` TINYINT NOT NULL COMMENT '冗余,方便查询', `time_slot_id` BIGINT NOT NULL, `status` TINYINT NOT NULL COMMENT '1待确认 2已确认 3已完成 4已取消 5已缺席', `price_snapshot` DECIMAL(10,2), `subject_name_snapshot` VARCHAR(30), `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_tutor_date_slot` (`tutor_id`,`appoint_date`,`time_slot_id`), KEY `idx_parent` (`parent_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约订单表';

科目表、评价表、消息表也很常规,字段基本是名称、外键、内容、状态和时间,就不在这里占篇幅了。注意项目里我把用户表命名为app_user,而不是user,因为USER在部分数据库方言里容易引起歧义,虽然 MySQL 里能跑,但少给自己埋雷没坏处。

3.2 科目冗余:匹配查询为什么会快这么多

家教可授科目的标准建模应该用中间表tutor_subject(tutor_id, subject_id),但在实际查询中,“按科目筛选老师”是最高频的操作。如果收藏了多科目中间表,匹配时就必须先用子查询查出一批 tutor_id,再回到主表分页,SQL 写起来绕,索引也不好利用。

我的做法是在tutor_profile上直接存一个subject_ids的逗号分隔字段。粗筛时用FIND_IN_SET(#{subjectId}, subject_ids)就能一把查出所有能教数学的家教老师。这种冗余设计牺牲了范式,但换来了查询简单和执行效率,在一张几千行数据的表上性能完全不是问题。事务一致性只要控制好一个点:老师修改可授科目时,tutor_profile和tutor_subject在同一个事务里更新即可。这里我要坦白说明,这是我在实际项目中验证过的选择,如果数据量到几十万级,还是建议改回中间表加反范式表的设计,但毕设和中小型平台完全够用。

3.3 预约时间的建模:周几 + 时段 + 唯一索引的组合

预约系统最核心的是“时间”怎么建模。我把时间拆成两层:time_slot是时间段字典,比如19:00-20:00、20:00-21:00;tutor_schedule记录老师每周哪些天的哪些时段可以约;appointment_order在创建时绑定具体的日期appoint_date和时段。这样设计有一个直观的好处:老师的可约时间是一周循环的,不需要生成未来每个日期的排课记录,存储量小,维护也简单。

随之而来的并发问题也要在设计阶段就堵住。我在appointment_order上建了唯一索引uk_tutor_date_slot (tutor_id, appoint_date, time_slot_id),这是最后一道防线。就算业务层锁被绕过、代码出现并发 bug,数据库层面也会拒绝同一老师同一日期同一时段的第二条订单。我后面会详细讲这道防线怎么和 Redis 锁配合,但建表这一步千万别省。

3.4 订单快照字段:防止老师改资料后旧订单跟着变

订单表里我特意加了price_snapshot和subject_name_snapshot,这是很多初学者容易忽略的细节。预约成立时,老师时薪是 200,那么这笔订单就按 200 算;三天后老师把时薪改成 250,你不能让历史订单也跟着涨价。把关键业务数据在订单上做快照,是交易系统的常规做法,这个点写进论文里非常加分——它证明了你的系统考虑了“业务数据随时间变化”的真实场景,而不只是做增删改查。

4. 智能匹配模块:一门可解释的评分课,而不是玄学 AI

4.1 粗筛 SQL:先把硬性条件过滤掉

家长搜索时输入的硬性条件有科目、年级、预算上限、上课区域。这些条件先走一次 SQL 粗筛,把明显不合适的老师干掉,只保留候选集,再到内存里打分排序。粗筛 SQL 长这样:

SELECT * FROM tutor_profile WHERE audit_status = 1 AND FIND_IN_SET(#{subjectId}, subject_ids) AND hourly_price <= #{budget} AND distance_km <= #{maxDistance}

注意这里没有直接过滤年级,因为年级我更习惯放在科目关联的标签里处理,比如“擅长高中数学”。只要把学科标签和年级标签拆得足够细,查询时的口径就简单了。粗筛阶段不要做太多 JOIN,保持单表查询,让 MySQL 能利用索引,候选集一般几十到几百条,内存排序完全没有压力。

4.2 打分模型:权重怎么定才说得通

粗筛之后是精排。我设计的打分模型总分 100 分,每个维度都有明确权重:

维度分值计算逻辑
学科匹配40目标学科命中则满分,不命中直接淘汰
价格接近度15时薪不超预算给满分;超预算按超支比例扣分
综合评分15rating_score / 5 * 15
教龄10教龄 1 年 2 分,封顶 10 分
距离105 公里内满分,每多 1 公里扣 2 分,最低 0 分
时段命中10期望上课时段在老师课程表中且启用则满分

为什么价格只有 15 分而不是更高?因为家长的第一需求是“合适的老师”,价格是筛选条件,不是排序条件。预算已经在粗筛里卡过了,精排里的价格分只是为了让候选老师里更接近预算的人排在前面。权重不需要客观正确,但必须逻辑自洽。答辩时评委很可能问“你为什么这么设计”,能说清楚每条权重背后的业务语义,就比“我用了一个 AI 模型”更容易赢得认同。

4.3 Java 实现:从 SQL 结果到排序列表

打分逻辑我用一个独立的MatchService实现,核心代码如下:

public List<TutorVO> match(MatchRequest req) { List<TutorProfile> candidates = tutorProfileMapper.selectByHardCondition(req); List<TutorVO> result = new ArrayList<>(); for (TutorProfile tutor : candidates) { double score = 0; // 学科匹配 40 score += matchSubject(req.getSubjectId(), tutor.getSubjectIds()) ? 40 : 0; // 价格接近度 15 score += priceScore(req.getBudget(), tutor.getHourlyPrice(), 15); // 综合评分 15 score += (tutor.getRatingScore() == null ? 0 : tutor.getRatingScore() / 5.0 * 15); // 教龄 10 score += Math.min(10, tutor.getYearsOfExp() * 2); // 距离 10 score += Math.max(0, 10 - tutor.getDistanceKm() * 2); // 时段命中 10 score += hasAvailableTime(tutor.getId(), req.getExpectWeekDay(), req.getExpectTimeSlotId()) ? 10 : 0; result.add(new TutorVO(tutor, score)); } result.sort(Comparator.comparingDouble(TutorVO::getScore).reversed()); return result; }

思路不复杂,但有几个容易出错的地方需要说明。学科匹配不要用字符串contains,因为subject_ids是逗号分隔的,contains("1")会误匹配到“11”,必须用FIND_IN_SET语义或先 split 再比对。评分字段要处理空值,很多初写代码的人没给默认值,排序时 NPE 直接崩掉。这两点我在测试阶段都实际踩过。

4.4 无结果时的降级推荐逻辑

最尴尬的情况是粗筛后候选集为空。我加了一条降级策略:先放宽距离 5 公里,再放宽预算上限 20%,然后重新查询。如果还是空,就直接返回“当前条件下暂无可约家教,请调整条件”的提示,而不是让用户面对一张空白列表。降级策略也要做成可解释的:返回列表时标记“已放宽距离范围”,让家长知道推荐结果不是瞎凑的。这一步业务逻辑很小,但对用户体验的提升非常明显,也适合作为论文中的功能亮点。

4.5 答辩防身:为什么这套模型是可解释的

这段特别写给准备答辩的同学。智能匹配这四个字很容易让评委兴奋,但如果你的回答是“我用算法算出来的”,大概率会被追问“什么算法?怎么收敛?准确率怎么评估?”与其被问倒,不如一开始就把定位说清楚:这不是机器学习模型,而是一套基于业务规则的加权评分系统,规则透明、可调试、可解释。评委想听的是你对业务的理解和逻辑推导能力,一个自己能讲明白的评分模型,远胜过一个自己也解释不清的“神经网络黑盒”。

5. 预约状态机与并发抢课:同样是校验,为什么差点翻车

5.1 状态机流转:一个订单从提交到完成有多少种命运

订单创建之后不是一直躺到结束,而是有明确的状态流转。我定义了五种状态:待确认、已确认、已完成、已取消、已缺席。默认流程是:家长提交订单 → 待确认 → 老师确认 → 已确认 → 到达上课日期并完成授课 → 已完成。中间有两个分支:家长在待确认前可以取消,老师在待确认时可以拒绝,拒绝视同取消。

当前状态触发动作下一状态
待确认家长取消已取消
待确认老师确认已确认
待确认老师拒绝已取消
已确认到达上课时间并完成服务已完成
已确认老师预约前取消(需要规则限制)已取消
已确认老师未到课已缺席

这个表看起来简单,但我强烈建议你在写代码前把它画出来,或者写成注释贴在实体类上。谁在什么条件下能改状态、不能从哪个状态跳到哪个状态,必须有一套硬校验,不能全靠 if 分支糊弄。我在实现时写了一个OrderStatusTransition校验工具,每次更新前先检查当前状态和目标状态是否在合法映射里,非法流转直接抛业务异常。这样代码里所有状态变更都走同一个入口,逻辑不会散落得到处都是。

5.2 并发抢课的防线:Redis 锁、唯一索引、事务三者配合

预约最危险的场景是:同一个老师同一个时段,两个家长几乎同时提交预约。业务层如果只做“先查是否空闲、再插入”两步,会存在时间差,两个请求都查到空闲,然后都插入成功。数据库唯一索引能挡住第二次插入,但会抛异常,用户会看到不友好的错误。所以我在业务层加了 Redis 锁,在锁之外又靠数据库做最终兜底。

伪代码如下:

String lockKey = "lock:appointment:" + tutorId + ":" + appointDate + ":" + timeSlotId; String token = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, token, Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { throw new BizException("这个时段刚刚被预约走了,换个时间试试吧"); } try { return appointmentService.doCreateOrder(orderDTO); } finally { if (token.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } }

两个细节必须注意:锁 key 里一定要带上日期和时段,否则锁粒度会变成整张老师表,影响并发能力;释放锁之前要比较 token,防止误删别人刚获取的锁。锁的释放时机也有讲究,我这里把锁放在事务方法外面,因为事务提交需要一定时间,如果你在事务内部方法一结束就释放锁,另一个请求可能读到尚未提交的数据。

5.3 一次事务失效的完整排查链路

这部分是我实际开发中花时间最长的一次排错,值得完整复现一遍排查思路。

现象:并发测试时我用两个线程同时提交同一时段预约,数据库唯一索引确实拦截了第二次,但控制台始终没有抛出预期中的业务异常,而是输出了一条Duplicate entry的 SQL 异常,前端直接返回 500。

我的第一反应是业务层校验没生效,于是检查了doCreateOrder()里的空闲校验逻辑,发现校验确实执行了,但两个线程都通过了校验。问题锁定在“先查后插”的竞态窗口,于是我给入口加上 Redis 锁,重新测试,发现错误依然存在,只是变成了偶发。

到这里我开始怀疑锁没生效。把日志打出来后,看到两个线程拿到的锁 key 完全不同——原来其中一个测试方法通过在 service 内部调用this.doCreateOrder(),而我又在doCreateOrder上面加了@Transactional,同类内部调用导致事务注解被跳过。代理对象根本没包住这个方法,事务自然不会开启,Redis 锁是在事务外层获取的,但实际执行插入时事务并没有真正开启,数据一致性和预期完全不符。

修复方案是把被调用的doCreateOrder拆到另一个独立的OrderWriteService中,由原来的入口通过注入的 service 调用。重新测试并发场景,Redis 锁能挡住第二请求,唯一索引作为最终兜底,整个流程稳定了。这次排查给我的教训是:@Transactional并不是“写上去就生效”,同类内部调用、private 方法、异常被 catch 吞掉都会让事务静默失效。这也是我最想提醒读者的一段经历,它比任何教科书都直观。

5.4 接口幂等:同一订单重复提交的兜底

状态机和并发锁解决的是“同一时段被抢”的问题,还有一类问题是“同一个用户手滑点了两次提交”。前端按钮防重复提交能挡住大部分,但网络超时后用户刷新重试、脚本重复调用,后端还是可能创建出重复订单。我的做法是:创建订单时必须传一个客户端生成的幂等键,比如订单请求的requestId,后端把requestId存到 Redis,key 存在就直接返回已有订单,不存在才继续创建。这样配合唯一索引,三层防线下来,预约模块基本是稳的。

6. WebSocket 实时提醒:握手鉴权、心跳与离线补发

6.1 场景判断:为什么新订单提醒值得用 WebSocket

这个系统的消息通知,核心场景是老师收到新订单。用轮询也能做,前端每 5 秒调一次“新订单数量”接口,演示效果勉强可以,但延迟高、请求毛刺多,也不够优雅。WebSocket 的价值在于服务端主动推送:家长下单成功一瞬间,老师端的页面不用刷新就能弹出“您有一条新订单”的提示,演示时的视觉冲击力非常强。很多网上教程只讲连接怎么建立,这次我把从鉴权到离线补发的完整链路都走了一遍。

6.2 依赖与 yml 配置:Spring Boot 3 下的正确姿势

网上很多人搜“spring boot 集成 web socket yml 配置”,这里有个反直觉的事实:原生的 Spring WebSocket 几乎不需要在 yml 里写配置项,核心全在配置类和 Handler 里。你只需要引入依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

Spring Boot 3 的包名是jakarta.servlet相关命名空间,这一点如果你之前看过 2.x 的教程,注意区分。yml 那边最多设置一下服务器超时时间,比如:

server: port: 8080 websocket: session: timeout: 600000

真正干活的是下面这个配置类:

@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new OrderNoticeHandler(), "/ws/notice") .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOriginPatterns("*"); } }

开发阶段setAllowedOriginPatterns("*")方便前端调试,生产环境建议限定具体域名,否则任何来源都能连上你的长连接服务,这属于安全细节,答辩的时候提一句会显得你考虑过交叉域问题。

6.3 握手鉴权:从 URL 和 Header 里取 token

WebSocket 连接建立时,浏览器没法像普通请求那样方便地自定义 Header,所以我采用的是 URL 带 token 的方式:前端连接ws://localhost:8080/ws/notice?token=xxx,服务端在HandshakeInterceptor里把 token 校验出来,再把 userId 放进握手属性。后续的WebSocketSession.getAttributes()就能拿到当前用户。

public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String token = UriComponentsBuilder.fromUri(request.getURI()) .build().getQueryParams().getFirst("token"); Long userId = JwtUtil.parseUserId(token); if (userId == null) { return false; } attributes.put("userId", userId); return true; } }

这一步如果校验不过,握手直接失败,前端应该捕获onerror并重新走登录流程。注意 WebSocket 握手失败不会像 HTTP 那样返回 JSON 错误体,前端排错时要看重连日志。

6.4 离线补发:推送失败后的未读消息兜底

WebSocket 最大的问题是连接不可靠。老师可能中途断网、关电脑、换设备,推送会直接失败。如果业务逻辑只依赖 WebSocket 推送,肯定丢消息。我的设计是双轨制:每次生成“新订单提醒”时,除了推送,还往消息表message插入一条未读记录,记录里有user_id、content、is_read、create_time。WebSocket 推送成功就顺带标记已读,推送失败则保留未读状态,用户下次登录时从接口拉取未读消息数量。

这个设计看似绕了一圈,但可靠性是完全不同的层次。我之前也试过只推不存,结果演示时自己电脑休眠再唤醒,消息丢了,场面一度很尴尬。消息表字段不复杂:主键、用户 ID、内容、是否已读、关联订单号、创建时间,就算你不接 WebSocket,纯靠这个表也能实现一套完整的站内信功能。

6.5 会话维护:心跳、过期回收与并发推送

WebSocket 连接不是建好就万事大吉。我用一个ConcurrentHashMap<Long, WebSocketSession>维护 userId 到 session 的映射,推送时直接取对应用户的 session。但 session 可能已经过期或半开,所以发送前必须检查session.isOpen()。另外要防止同一个用户在两个标签页打开系统,我的策略是后建立的连接覆盖老连接,把老 session 关闭,否则同一个用户会收到重复通知。

心跳这块,浏览器原生 WebSocket 不会自动发心跳,需要前端定时send("ping"),服务端收到后回一个pong。服务端还要定期清理长时间没通信的连接,我是在 Handler 里配置了空闲超时,超时关闭后从 Map 里移除。如果不做心跳,网络中间设备可能把空闲连接当成垃圾回收,用户侧表现为“连接还在,但消息永远收不到”。这个问题的典型表现是静默断连,特别坑,提前加心跳能省很多事。

7. 从社区版 IDEA 到服务器:启动、部署与演示准备工作

7.1 社区版 IDEA 跑 Spring Boot 的正确打开方式

用 IntelliJ IDEA 社区版跑 Spring Boot 项目,最大的问题是没有 Spring Initializr 项目向导。解决办法很简单:打开start.spring.io网页,选择 Maven、JDK 17、Spring Boot 3.x,勾上 Spring Web、Validation、MySQL Driver、Redis 等依赖,生成 zip 下载,然后在 IDEA 社区版里File -> Open导入这个 Maven 项目,等依赖下载完,直接运行带有@SpringBootApplication的主类即可。

社区版没有 Spring Boot 的“运行仪表盘”,启动配置需要手动添加:右上角 Add Configuration → Application → Main class 选择主类,Working directory 保持默认。第一次运行 Maven 下载依赖可能很慢,建议配置阿里云镜像仓库,这个在网上搜关键字能找到标准配置,能省大量等待时间。

7.2 多环境配置与密钥处理

我把配置拆成了三个文件:application.yml里只放公共配置和spring.profiles.active=dev;application-dev.yml放本地数据库账号密码;application-prod.yml放服务器环境变量引用。数据库密码千万不要明文写在提交的配置里,生产环境用${DB_PASSWORD}占位符,部署时通过系统环境变量注入。演示时如果评委想看代码,看到你用了环境变量而非硬编码,印象分会好很多。

7.3 用 Docker Compose 搭出一套演示环境

我的演示环境是 Docker Compose 启动 MySQL 8 和 Redis 7,应用本身用打包好的 jar 跑。Compose 文件只需要两个服务,大概长这样:

services: mysql: image: mysql:8 environment: MYSQL_ROOT_PASSWORD: root123456 ports: - "3306:3306" redis: image: redis:7 ports: - "6379:6379"

数据库初始化脚本放在sql/init.sql,用docker exec或 Navicat 导入。这种做法的好处是干净:服务器上不用手动装数据库,删掉容器就能恢复初始环境。但要注意云服务器的安全组一定要放行对应端口,不然本地连不上 3306 和 6379,这也是我实际部署时卡了半小时才发现的问题。

7.4 答辩演示要准备的脚本和测试数据

演示环节最尴尬的是现场数据不对或用不了。我建议准备三套测试账号:家长账号、老师账号(已审核通过)、管理员账号,账号密码直接打进演示文档里。演示路径固定为:登录家长 → 搜索“高中数学”→ 进入老师详情 → 选择下周某时段 → 提交预约 → 切换到老师账号看到新订单提醒 → 确认预约 → 回到家长账号查看订单状态变化。这条路径在答辩前至少完整走三遍,确保每一步都流畅。另外准备一些带真实感的老师测试数据,比如不同大学、不同教龄、不同时薪的十条记录,匹配结果才更有对比度。监控组件如果有接入,可在最后切到 Admin 页面展示一次活跃线程和内存曲线,点到即止,不要喧宾夺主。

8. 免费毕设源码拿回来该先做什么:改造复用的五个动作

8.1 第一步不是运行,是读启动文档和环境检查

标题里写了“免费毕设源码分享”,我知道很多同学拿到源码的第一反应是双击运行,但我强烈建议先做环境检查。先看 README 或数据库脚本注释里写的注意事项:Spring Boot 版本、JDK 版本、MySQL 版本、Redis 是否必需、前端是否需要单独构建。我就见过同学拿了一份要求 Redis 的源码,本地没装 Redis,启动时一直报连接失败,还以为是源码有问题。五件事按这个顺序检查:JDK 版本是否匹配、Maven 是否配置好镜像、MySQL 是否创建了对应库、Redis 是否可用、端口是否被占用。

8.2 先跑通主线业务,再改任何代码

环境没问题之后,不要急着看代码细节,先用测试账号把“注册→登录→搜索→匹配→预约→确认→完成”这条主线跑通。主线跑通意味着配置、脚本、依赖、权限都能对上,这时候再改代码,出问题你至少知道是改出来的,而不是原来就没法跑。如果连主线都跑不通,优先查看启动日志里的异常堆栈,大多数报错是数据库连接或依赖版本问题,不要一上来就怀疑源码本身。

8.3 隐蔽的本地化改造点

把一套公开的源码变成你自己的项目,要做的不只是改包名。我整理了几个隐蔽的改造点:数据库名必须改,比如tutor_system改成带你自己标识的名字,避免答辩时被看出完全照搬;Redis 密码和数据库密码不要沿用默认值;前端如果接的是固定 IP 的后端接口,要改成 localhost 或你自己的服务器地址;项目里残留的第三方 appKey、短信密钥、公众号 ID 等配置全部清空或替换。包名重命名在 IDEA 里可以右键 Refactor → Rename 完成,但要注意 Mapper XML 里的 namespace 和实体类包路径要一起改,否则启动时 MyBatis 找不到 statement。

8.4 低成本功能点:让论文和源码真正属于你

想让自己和网上源码真正区分开,最有效的办法是加一个原项目没有的小功能。我建议加“收藏家教”:建一张favorite_tutor表存家长 ID 和老师 ID,家长在老师详情页点红心收藏,个人中心展示收藏列表,入口加一个收藏按钮,后端加两三个接口,前端加一个列表页。这个功能本身不难,但它能证明你对源码做过理解、有增删改查之外的设计。如果时间更充裕,还可以用 EasyExcel 把订单列表导出成 Excel,这在答辩演示时也很容易被评委注意到。自己亲手加一个功能,比反复强调“我改了很多配置”更有说服力。

8.5 关于免费源码的一点真心话

这个项目前后我大概花了一个多月,返工最多的地方就是预约并发和 WebSocket 离线补发。如果让我重新做一遍,我会先把状态机和锁的设计画清楚再动代码,那张状态流转表和那次事务失效的排查经历,比任何网上的现成源码都有价值。免费源码的意义从来不是让你省下思考的时间,而是给你一个成熟骨架,让你在上面长出属于自己的东西。拿到一套能跑的项目只是开始,真正让你毕业答辩有底气、面试能聊出深度的,是你能讲清楚它为什么这样设计、哪里会出问题、出了问题怎么排查——这些,才是从一份源码里真正复用到的东西。

返回列表