2026/10/6 5:11:50

SpringBoot心理健康测评与咨询平台:毕设项目状态机与并发控制实战

SpringBoot心理健康测评与咨询平台:毕设项目状态机与并发控制实战 做毕设的时候很多人第一反应是为什么选心理健康测评这种题目说实话我当初选这个题目也没有想太多只是觉得比起电商系统、内容管理系统那种纯粹在改数据库的毕设测评与咨询平台有一个很特殊的点——它背后有一套真实的业务规则在驱动。测评怎么计分、预警怎么触发、咨询预约怎么防止撞时间这些问题拆开之后你才会发现它不是一个简单CRUD而是一个带状态机、带并发控制、带权限边界的中型系统拿来练SpringBoot非常合适。而且从实际角度看现在高校里对这个方向的需求是真实存在的。学生压力大、辅导员人手不够线上测评加在线咨询天然匹配校园场景。所以无论你是选毕设题目还是想练一个能写进简历的Java项目这个方向都值得仔细走一遍。下面我把整个项目从需求拆解、技术选型、核心模块到踩坑记录完整讲一遍尽量把我认为真正有价值的东西都写出来。1. 一个测评咨询平台本质上是什么需求拆解与服务闭环1.1 用户画像与核心痛点想清楚给谁用系统设计才不会跑偏。这个平台在校园场景下主要有三类用户学生最核心的使用者。他们需要完成心理量表测评、查看自己的测评结果、根据结果预约咨询师、按时进入在线咨询并且能回看历史测评记录和咨询建议。咨询师负责发布可预约时段、查看学生测评报告、接听在线咨询、填写咨询记录、对高风险学生做持续跟踪。管理员/辅导员维护用户和量表数据查看全校测评总体状态重点盯预警学生名单输出简单的统计报表。三类用户的诉求差异很大。学生要操作轻、入口清晰咨询师要能看到测评结果又不能让学生看到自己的咨询手记管理员要的是宏观数据而不是具体某个学生的隐私内容。这些边界在需求阶段就定了后面做权限和字段过滤时就不会乱。1.2 服务闭环测评-预警-咨询-跟踪如果只做一个在线填量表功能那和问卷星没有区别。心理平台真正值钱的是闭环学生完成量表后系统自动判分根据阈值规则产生预警信号预警学生被推送给咨询师和辅导员学生在线预约时段完成咨询后咨询师填写记录后续系统可以提醒下次回访时间。这个闭环直接决定了表结构和模块边界。我的做法是划分成四个核心模块模块核心能力测评模块量表管理、题目与选项管理、测评记录、结果计算、报告生成预警模块阈值规则配置、预警记录、推送任务、辅导员确认处理咨询模块咨询师排班、预约、状态流转、咨询记录、回访提醒系统模块用户、角色、权限、菜单、字典、操作日志顺带说一句毕设答辩时老师最喜欢问的就是为什么你的系统不只是一个问卷系统这个闭环就是最好的回答角度。1.3 功能清单与角色矩阵我整理了一份功能权限清单开发时始终对照它做接口权限控制学生端多量表测评、个人测评记录、结果报告、预约咨询师、在线咨询、评价咨询师。咨询师端管理可预约时段、查看已预约列表、查看学生测评概览按需脱敏、填写咨询记录、处理预警任务。管理员端用户管理、角色管理、量表与题目管理、预警规则管理、咨询师排班总览、数据统计。功能清单看起来不复杂但真正写起来容易漏的是测评报告谁能看咨询记录谁能写谁能改这类边界问题。建议在数据库设计之前就把角色矩阵表列出来后面做到权限拦截时基本就是照着这个矩阵填注解。2. 技术选型和项目骨架SpringBoot 3 Vue3 MyBatis-Plus2.1 技术栈选择逻辑毕设项目最容易犯的错是堆技术。七八个中间件往项目里塞演示时出了故障根本不知道是哪个环节的问题。我的选择标准一直是能解决问题就行但每个选型都要能说出理由。Java 17 SpringBoot 3.2.xSpringBoot 3已经全面流行网上资料和新版本依赖都很成熟。如果选SpringBoot 2.x答辩时反而容易被质疑技术栈偏旧。Vue3 Element Plus Vite后台管理界面用Vue3是主流方案Element Plus组件全面表格、表单、弹窗都能快速搭建。Vite启动速度比Webpack体感快很多调试友好。MyBatis-Plus持久层选它主要因为开发效率高。单表CRUD几乎不用写XML条件构造器能覆盖大部分查询场景。而且它的TableLogic、分页插件、字段自动填充都是实际开发中高频使用的能力。MySQL 8.x RedisMySQL存核心业务数据Redis存会话令牌、验证码、短时高频数据。不要为了显得高级硬加一些用不到的组件。WebSocket用来做预约成功、预警提醒的实时消息推送这个在后端Skill里是加分项也对应了真实业务中咨询师需要快速知道新预约的需求。还有一个细节JDK一定要用17以上。SpringBoot 3.2最低要求就是JDK17如果本机装了Java 8新建项目时编译直接报错。我身边不止一个同学卡在这个地方排查半天发现是版本对不上。2.2 数据库设计核心表与字段说明数据库设计我建议按模块拆。测评、预约、用户权限三块业务相对独立表之间的外键关系越少越好靠索引和业务校验保证一致性。我列出几张核心表的关键设计思路-- 量表表 CREATE TABLE scale ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(50) NOT NULL, -- 量表编码如 SCL90 name VARCHAR(100) NOT NULL, -- 量表名称 description VARCHAR(500), question_count INT, dimension_json TEXT, -- 维度定义JSON status TINYINT DEFAULT 1, deleted TINYINT DEFAULT 0 ); -- 题目表 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scale_id BIGINT NOT NULL, content VARCHAR(500) NOT NULL, dimension_code VARCHAR(50), -- 归属维度 sort INT, deleted TINYINT DEFAULT 0 ); -- 测评记录表 CREATE TABLE assessment_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, scale_id BIGINT NOT NULL, total_score DECIMAL(8,2), dimension_result_json TEXT, -- 各维度得分结果JSON warning_level TINYINT, -- 0无预警 1关注 2预警 status TINYINT DEFAULT 0, -- 0进行中 1已完成 create_time DATETIME ); -- 预约表 CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, counselor_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, -- 关联咨询师排班 status TINYINT NOT NULL, -- 0待确认 1已确认 2咨询中 3已完成 4已取消 5爽约 remark VARCHAR(255), create_time DATETIME );这里重点说一下dimension_json字段。心理量表几乎都是多维度测评比如SCL-90包含躯体化、强迫症状、人际关系敏感等9个维度。如果把维度作为独立表再加一堆关联查询开发成本高而且题目一旦微调维度映射很难维护。用JSON字段把维度定义和每个维度的选项映射存下来在测评引擎里解析计算实际上更好扩展。2.3 工程结构按业务拆包会更舒服后端包结构我推荐按业务模块拆而不是按技术层拆com.university.psys ├── common # 通用工具、异常、常量、返回结果封装 ├── config # 配置类跨域、Redis、WebSocket、自动填充 ├── security # 登录认证、权限拦截、上下文用户信息 ├── module │ ├── user # 用户、角色、权限 │ ├── assessment # 量表、题目、测评记录、结果计算 │ ├── warning # 预警规则、预警记录 │ └── consult # 排班、预约、咨询记录这种结构的好处是一个模块内部的逻辑闭环controller、service、mapper挨在一起后期加减功能影响面很小。很多人习惯先建controller/service/mapper三个大包结果项目一大文件全挤在一起遍历文件翻半天而且按模块拆在答辩讲架构时也更清晰。3. 测评模块的核心实现量表、计分引擎与预警规则3.1 量表数据模型动态配置而不是写死第一次写测评功能时很容易把题目和选项写死在代码里后面挨个换量表就要改后端重新发版非常痛苦。正确做法是量表、题目、选项全部配置化后端只提供一个通用的测评引擎。我的题目选项表设计的是CREATE TABLE question_option ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_id BIGINT NOT NULL, option_label VARCHAR(20), -- A/B/C/D 或 1/2/3/4/5 option_text VARCHAR(100), score DECIMAL(8,2), -- 本选项得分 sort INT );注意score字段直接挂在选项上而不是在service里用if判断。因为不同量表同一选项的计分可能不同有的正向计分有的反向计分。虽然反向计分也可以在前端存一个人的注记但最稳妥的做法是配置里直接写明选项得分计算引擎只负责累加。管理员后台只需要维护量表、题目、选项三张表前端用三层的表单联动就能配置出一份新量表。这种数据驱动的思路在测评类系统里是绝对的核心也是我当时花时间最多、觉得收获最大的部分。3.2 计分引擎实现按维度汇总计分逻辑听起来简单实际上要处理多维度量表的归一化。以SCL-90为例它由9个因子构成每个因子下有6到13道题每道题按1到5分计因子得分的计算公式通常是该因子下所有题目原始分之和除以题目数。我在service里抽了一个ScoreCalculator组件public DimensionScoreResult calculate(AssessmentRecord record, ListAnswerDetail answers) { Scale scale scaleMapper.selectById(record.getScaleId()); ListQuestion questions questionMapper.selectByScaleId(scale.getId()); MapString, BigDecimal dimSumMap new HashMap(); MapString, Integer dimCountMap new HashMap(); for (AnswerDetail answer : answers) { Question question questionMapper.selectById(answer.getQuestionId()); OptionItem option optionMapper.selectById(answer.getOptionId()); if (question.getDimensionCode() null) { continue; } dimSumMap.merge(question.getDimensionCode(), option.getScore(), BigDecimal::add); dimCountMap.merge(question.getDimensionCode(), 1, Integer::sum); } MapString, BigDecimal dimAvgMap new HashMap(); for (Map.EntryString, BigDecimal entry : dimSumMap.entrySet()) { BigDecimal avg entry.getValue() .divide(BigDecimal.valueOf(dimCountMap.get(entry.getKey())), 2, RoundingMode.HALF_UP); dimAvgMap.put(entry.getKey(), avg); } // 同时算出总分和阳性项目数供预警规则使用 BigDecimal totalScore answers.stream() .map(a - optionMapper.selectById(a.getOptionId()).getScore()) .reduce(BigDecimal.ZERO, BigDecimal::add); return new DimensionScoreResult(dimAvgMap, totalScore); }这个组件有几个细节值得注意维度代码是配置在question表上的不要在后端代码里写if (dimension.equals(somatization))这种硬编码否则换量表就要改代码。平均值结果保留两位小数全部用BigDecimal计算避免浮点数精度问题。心理量表分数经常要判断是否超过1.5、2.0这样的阈值double计算容易出偏差。结果直接拼成JSON存到assessment_record.dimension_result_json字段里简化详情查询。3.3 结果解析与预警阈值规则动态化测评完成后的下一步是解析结果。真实校园场景中咨询师并不需要看每一道题的答案他们需要的是这个学生各维度得分、总分、阳性项目数、预警等级。预警等级我设计成三层等级判定条件示例处理动作正常所有维度得分均低于关注线正常显示报告不做提醒关注任一维度得分超过关注线但低于预警线生成提醒推送给辅导员预警任一维度得分超过预警线或总分达到设定值学生端提示建议咨询消息推送给咨询师阈值规则不能写死在代码里我把规则配置放了一张表CREATE TABLE warning_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scale_code VARCHAR(50) NOT NULL, dimension_code VARCHAR(50), threshold_value DECIMAL(8,2) NOT NULL, operator VARCHAR(10) DEFAULT gt, warning_level TINYINT NOT NULL, enabled TINYINT DEFAULT 1 );预警计算发生在测评提交之后放在同一个事务里先写测评记录再计算结果然后匹配规则生成warning_record最后向相关角色发送站内消息。消息发送如果失败不能影响主流程所以我用了try-catch包住并且把失败消息放Redis队列由定时任务补偿重推。这套设计让我后面遇到一个实际问题有的量表维度非常多规则有十几条如果每条都在Java代码里用if判断那整个方法会写得又长又乱。规则表加循环匹配的方式虽然简单但扩展性很好新加一条规则只是后台插入一行数据。4. 在线咨询预约状态机设计、并发控制与消息通知4.1 预约流程状态机不要让状态散落在代码里预约模块是整个系统里业务状态最复杂的一段。一个预约从创建到结束要经历待确认、已确认、咨询中、已完成、已取消、爽约等状态。如果每个接口都随手改status字段过两天就没人说得清楚当前状态到底有几种可能了。我画了状态流转图作为开发依据这里用文字描述学生选择咨询师空闲时段创建预约 -PENDING咨询师确认时段 -CONFIRMED咨询时系统进入咨询房间或咨询师开始咨询 -IN_PROGRESS咨询结束咨询师提交记录 -COMPLETED咨询师拒绝或学生在开始前取消 -CANCELLED到约定时间未出现定时任务自动流转 -NO_SHOW后端在写状态更新时我用了两段式校验Appointment appointment appointmentMapper.selectById(appointmentId); if (!appointment.getStatus().equals(AppointmentStatus.PENDING)) { throw new BizException(当前状态不允许该操作); } appointment.setStatus(AppointmentStatus.CONFIRMED); appointmentMapper.updateById(appointment);先校验当前状态再更新目标状态。这样代码里永远不会出现从已完成状态直接改成咨询中这种非法流转。虽然在事务内做更新也能一定程度避免脏数据但状态机的本质是先判断后落库这一层逻辑必须显式写出来。4.2 防止同一时间段重复预约并发控制怎么做在线预约最典型的坑是两个人同时抢同一个咨询时段。正常流程下学生选择时段时会查询counselor_schedule表中该时段是否被占用如果没被占用就插入一条预约记录。但两个请求同时通过查询空闲这一步就可能同时插入成功造成超卖。解决并发冲突我用了两套方案叠加第一层是数据库唯一约束。appointment表里加唯一索引ALTER TABLE appointment ADD UNIQUE INDEX uk_schedule_student (schedule_id, student_id, status);但这只能保证同一个学生不会重复预约同一时段不能防止不同学生抢同一时段。所以第二层必须在预约创建时锁住排班记录。我采用的是SELECT ... FOR UPDATE行锁方案Transactional public void createAppointment(Long scheduleId, Long studentId, Long counselorId) { CounselorSchedule schedule scheduleMapper.selectByIdForUpdate(scheduleId); if (schedule.getBooked()) { throw new BizException(该时段已被预约请选择其他时间); } Appointment appointment new Appointment(); appointment.setScheduleId(scheduleId); appointment.setStudentId(studentId); appointment.setCounselorId(counselorId); appointment.setStatus(AppointmentStatus.PENDING); appointmentMapper.insert(appointment); schedule.setBooked(true); scheduleMapper.updateById(schedule); }selectByIdForUpdate对应的SQL是SELECT ... FOR UPDATE事务提交前其他事务对这条排班记录的操作都会被阻塞。这样即使两个学生同时点预约后一个事务也会等待前一个提交然后拿到最新状态判断已被预约。这里有个坑要提醒FOR UPDATE必须在事务里生效而且被锁的记录必须有索引否则MySQL悲观锁会退化成锁表或锁范围。我给schedule_id加了主键索引实际测试下来并发场景没问题。如果不想用数据库悲观锁也可以给排班表加乐观锁字段version更新时WHERE version ?更新行数返回0就表示冲突。两种方式都可以毕设答辩时能说清楚区别和利弊就够了。4.3 消息通知从轮询到WebSocket实时推送预约创建成功后咨询师需要第一时间知道新预约信息。最朴素的做法是前端定时轮询接口每10秒查一次未读消息。轮询写起来简单但体验不好、对服务器压力也大。我最终选择了WebSocket做实时推送同时保留站内消息表做离线兜底。配置一个简单的WebSocket端点Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws/notice) .setAllowedOriginPatterns(*) .withSockJS(); } Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker(/topic, /user); registry.setUserDestinationPrefix(/user); } }预约创建成功后后端向咨询师发送一条定向通知template.convertAndSendToUser( counselorId.toString(), /queue/notice, new NoticeMessage(新预约, 你有一条新的咨询预约待确认) );前端使用SockJS STOMP协议订阅/user/{id}/queue/notice即可实时收到消息。这里要注意convertAndSendToUser依赖用户在认证成功后被注入Principal信息。如果你用的是自定义JWT拦截器而不是Spring Security的认证体系需要把用户标识主动绑定到DefaultHandshakeHandler中否则后端知道发给哪个用户前端也会因为主题不匹配无法收到消息。这个细节我折腾了不少时间。5. 权限边界与数据安全心理数据要加倍小心5.1 RBAC权限模型角色、菜单、接口三张网心理测评数据比普通业务数据敏感得多权限控制不能只靠前端隐藏按钮。我的做法是标准的RBAC模型表结构用sys_user、sys_role、sys_user_role、sys_menu、sys_role_menu五张表。后端通过角色算出用户拥有的菜单权限和接口权限集合然后在接口层做两种拦截菜单权限前端路由根据登录用户返回的菜单列表动态生成学生登录只看到测评和我的预约咨询师看到工作台和排班。接口权限后端为每个角色维护一个权限码集合在拦截器里校验当前请求路径和请求方法对应的权限码。我用的是Spring Boot拦截器加自定义注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String value(); }写接口时显式声明权限RequirePermission(consult:appointment:confirm) PostMapping(/confirm) public ResultVoid confirm(RequestBody ConfirmRequest request) { appointmentService.confirm(request.getId()); return Result.ok(); }拦截器在进入Controller前解析注解对比当前登录用户持有的权限码集合。这样即使有人绕过前端直接调接口在没有权限码的情况下也会被拒绝。5.2 登录认证与密码安全别在Session里裸奔密码存储我用了BCryptPasswordEncoder它自带随机的盐相同密码两次生成的哈希不一样有效防彩虹表攻击。很多学生项目会用MD5加盐但MD5运算速度太快反而容易暴力破解BCrypt的慢哈希设计就是为密码场景准备的。针对毕设场景登录方案我推荐两种简单方案JWT无状态令牌。用户登录成功后生成Token返回前端存起来后续请求放在Authorization头里。稳妥方案JWT Redis黑名单。登出时把Token加入黑名单密码修改后让旧Token失效。我实际选择的是JWT加Redis登录成功后把用户ID写进Token同时Redis缓存一份带过期时间的会话标记。每次请求过滤器先验签再查Redis确认会话有效最后把用户ID放进ThreadLocal里的上下文对象供Service层随时取当前操作人。日志里永远不记录明文密码接口返回时统一把密码字段置空。这些是常规操作但一定要形成习惯否则测评报告里糊着一串加密串也很丑。5.3 数据脱敏与访问审计该看到的才看到心理测评报告和咨询记录是高度私密数据。一个咨询师不应该看到全校所有学生的报告辅导员也只能在授权范围内查看预警名单。字段级脱敏需要具体落到位学生本人可以看自己的完整测评结果和推荐建议。咨询师只能看到预约给自己的学生测评结果。辅导员看到的是脱敏名单隐藏完整学号中间几位、姓名只保留姓氏加**。咨询师的咨询手记学生不可见但咨询建议部分可以生成只读副本反馈给学生。接口查询脚本里我封装了一个SensitiveFieldUtil在返回前做字段转换。但更稳妥的做法是SQL层直接不查出敏感字段而不是查出后再脱敏这样可以减少敏感数据在内存中的暴露窗口。同时我给查看测评报告的操作加了一行审计日志谁在什么时间看了哪个学生的测评报告。实战意义很明显——出了隐私事故能追溯答辩时也是一个非常有说服力的设计点。6. 实际开发中踩过的坑和解决方式6.1 SpringBoot 3版本适配javax变成了jakarta这是新一代SpringBoot项目最容易踩的坑。SpringBoot 3.x把Java EE API的包名从javax.*换成了jakarta.*。刚开始建项目时我照着老的教程写import javax.servlet.http.HttpServletRequest;结果编译直接报错。原因是SpringBoot 3.0以后内置Tomcat 10它遵循Jakarta EE规范旧包名已经不再支持。实际解决方案很简单所有javax.servlet、javax.annotation级别的引入全部改成jakarta.servlet、jakarta.annotation。但网上一搜一大把老教程都是javax复制代码时一定要留意不然就是编译期连环报错。另外一个隐藏坑是MyBatis-Plus版本。老版本的mybatis-plus-boot-starter只适配SpringBoot 2.x直接用到3.x启动会报Failed to introspect Class之类的问题。解决办法是用官方针对SpringBoot3的starterdependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency这个细节在项目初始化时就要查清楚否则等代码写了一堆才发现启动失败排查成本很高。6.2 逻辑删除和唯一索引的冲突想要删除记录却被唯一约束卡住做排班和预约时我用了MyBatis-Plus的逻辑删除给表加了deleted字段。然后在排班表上建了唯一索引想保证同一个咨询师在同一天同一时间只能有一个排班ALTER TABLE counselor_schedule ADD UNIQUE INDEX uk_counselor_date_time (counselor_id, work_date, time_slot);头几天运行正常直到有一天管理员删除一条排班重新配置时发现问题允许管理员软删除排班但删除后再插入同一时间段的排班会报唯一约束冲突。原因是逻辑删除只是把deleted字段置为1那条数据实际还在表里唯一索引仍然生效。这种问题有几种解法我选择了最简单的一种唯一索引改为包含deleted字段。比如把索引改成(counselor_id, work_date, time_slot, deleted)再利用deleted默认值为0只要软删除后把这个字段设成主键ID或者其他唯一值再插入同一时段新数据时索引就不冲突了。实际项目里这种逻辑删除唯一索引的组合几乎是必踩的坑。知道了这个思路以后遇到类似场景就不会再卡半天。6.3 跨域配置和JWT过滤器的顺序前后端分离时跨域是跑不掉的配置。我最初的写法是registry.addMapping(/**) .allowedOrigins(http://localhost:5173) .allowedMethods(*);结果发现前端发来的预检请求OPTIONS总是被JWT校验拦截器挡掉提示没有带Token。排查原因很简单我自定义的拦截器拦截了所有路径OPTIONS请求在进入到CORS处理之前就被拦截了。正确做法有两个步骤跨域配置允许所有来源和方法并让allowedHeaders包含Authorization。JWT拦截器里直接放行OPTIONS请求if (request.getMethod().equals(HttpMethod.OPTIONS.name())) { return true; }或者用Spring Security时确保.cors()配置在csrf().disable()之前生效。总之记住一条预检请求不会携带业务Token跨域与认证拦截器的顺序一定要处理好。6.4 定时任务处理预约状态预约模块里有两个需要定时兜底的场景预约超时未确认自动取消以及预约开始时间到了但学生未进入咨询自动标记爽约。我用的是Spring自带的Scheduled。一个容易忽略的点是Scheduled默认是单线程串行执行的。如果项目里有多个定时任务A任务执行时间过长会阻塞B任务。所以我为定时任务配了一个独立的线程池参数spring: task: scheduling: pool: size: 5同时定时任务的扫描逻辑里我限定只处理状态为PENDING且超过时效的记录防止反复处理已完成的数据。另外爽约的判断不只看预约时间还会参考咨询开始时间之后10分钟内是否产生过咨询记录避免把正在排队进入的学生误判成爽约。6.5 部署细节Vue打包放进SpringBoot毕设项目最后要演示最方便的方式是把Vue打包后的静态文件放进SpringBoot的src/main/resources/static目录打成单Jar演示。Vue初始化时把统一前缀路径配好比如// vite.config.js export default defineConfig({ base: /, server: { port: 5173, proxy: { /api: http://localhost:8080 } } })构建后dist目录里是静态文件复制到SpringBoot的static目录。本地联调用Vue代理转发API部署时则让后端加上/api统一前缀。唯一要注意的是刷新页面时静态路由会触发401或者404需要把404处理指向index.html。前端用history模式时更明显刷新子路由页面会请求后端路径如果不做转发演示时一刷新页面就白屏很尴尬。写在最后从毕设到真实落地还差的几步整个项目做下来我的体会是测评类系统比普通管理系统的难点不在增删改查而在业务规则如何落到代码上。计分引擎的维度映射、预约状态机的流转控制、并发抢占时段的处理这些才是真正能在答辩现场讲出深度、在面试时体现项目含金量的内容。还有一点想提醒后来的同学不要为了秀技术而疯狂加功能。先把测评-预警-咨询这条主链路走通再把权限、日志、消息推送这些“看不见但必须做”的能力补好系统就已经比大多数同学的作品完整了。如果时间充裕可以继续加数据统计报表、问题性人格预警、定期回访提醒这类扩展点但这些要建立在基础业务稳定的前提下否则演示翻车的概率很大。最后分享一个小经验开发过程中把每个模块的踩坑日志用Markdown记录下来特别是像jakarta包名、逻辑删除唯一索引这种有代表性的坑答辩时主动讲出来老师通常会认为这个项目确实是亲手实践过的比准备一堆理论话术管用得多。