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 的懒加载和 N1 问题绕晕。更重要的一点是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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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:00tutor_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. 智能匹配模块一门可解释的评分课而不是玄学 AI4.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 ListTutorVO match(MatchRequest req) { ListTutorProfile candidates tutorProfileMapper.selectByHardCondition(req); ListTutorVO 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存到 Rediskey 存在就直接返回已有订单不存在才继续创建。这样配合唯一索引三层防线下来预约模块基本是稳的。6. WebSocket 实时提醒握手鉴权、心跳与离线补发6.1 场景判断为什么新订单提醒值得用 WebSocket这个系统的消息通知核心场景是老师收到新订单。用轮询也能做前端每 5 秒调一次“新订单数量”接口演示效果勉强可以但延迟高、请求毛刺多也不够优雅。WebSocket 的价值在于服务端主动推送家长下单成功一瞬间老师端的页面不用刷新就能弹出“您有一条新订单”的提示演示时的视觉冲击力非常强。很多网上教程只讲连接怎么建立这次我把从鉴权到离线补发的完整链路都走了一遍。6.2 依赖与 yml 配置Spring Boot 3 下的正确姿势网上很多人搜“spring boot 集成 web socket yml 配置”这里有个反直觉的事实原生的 Spring WebSocket 几乎不需要在 yml 里写配置项核心全在配置类和 Handler 里。你只需要引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependencySpring 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 里取 tokenWebSocket 连接建立时浏览器没法像普通请求那样方便地自定义 Header所以我采用的是 URL 带 token 的方式前端连接ws://localhost:8080/ws/notice?tokenxxx服务端在HandshakeInterceptor里把 token 校验出来再把 userId 放进握手属性。后续的WebSocketSession.getAttributes()就能拿到当前用户。public class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, 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 连接不是建好就万事大吉。我用一个ConcurrentHashMapLong, 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.activedevapplication-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 离线补发。如果让我重新做一遍我会先把状态机和锁的设计画清楚再动代码那张状态流转表和那次事务失效的排查经历比任何网上的现成源码都有价值。免费源码的意义从来不是让你省下思考的时间而是给你一个成熟骨架让你在上面长出属于自己的东西。拿到一套能跑的项目只是开始真正让你毕业答辩有底气、面试能聊出深度的是你能讲清楚它为什么这样设计、哪里会出问题、出了问题怎么排查——这些才是从一份源码里真正复用到的东西。
返回列表