
简介这套基于SpringBoot与Vue技术栈的大学生心理测评与分析系统采用前后端分离模式项目内含可运行源码、SQL脚本和配套文档。面向毕业设计、课程设计或工程实训的Java学习者既适合小白快速了解SpringBoot后端接口与Vue前端页面的交互方式也适合进阶者把它作为心理测评、量表管理、结果统计等业务场景的二次开发底座。压缩包共797个文件包含211个Java后端逻辑代码、149个Vue组件、159个SVG图标以及大量JPG、JS、CSS等前端资源包体约35.25MB。后台访问路径与前台独立部署目录按功能模块划分便于按需查阅。截至目前已有1807人学习浏览。配套的SQL初始化文件、启动脚本以及文档说明能帮助使用者快速构建运行环境从系统登录到测评结果生成形成完整认知是一套具备学习价值与工程参考意义的开源风格项目。1. 测评系统不是 CRUD先想清楚心理模型的落地口径大学生心理测评这类系统表面上是一套「建量表、发问卷、看结果、出报告」的管理后台真正难的不是 Spring Boot 的基本增删改查而是心理学量表在工程上的落地口径。同一个 SCL-90 症状自评量表题目、选项、维度、计分规则怎么存直接决定后面所有统计分析和风险预警能不能做。很多毕设或实训项目做到一半发现分数对不上、维度算不出来、报告模板没法套根源都在建表阶段没有把「测评范式」抽象成数据结构。这套系统通常分三个角色来理解学生端做测评、填写量表教师/辅导员端查看结果、筛选高风险人群、导出报告管理员端维护量表和学生信息。技术栈上以 springboot 为骨架数据层用 MyBatis-Plus前端用 Vue 或者纯模板引擎都常见区别在于你是做前后端分离的毕设还是做单体快速交付。下文按我自己实践过的一套方案展开量表题目支持动态配置作答以「一次测评 一张答案快照」存储计分规则放到 service 层而不是数据库存储过程理由很简单——规则变化频繁放代码里能用 git 追踪放数据库里改一次就要写一次迁移脚本。2. 量表建模与数据表设计先分清题目、维度、选项的关系2.1 固定量表与动态题库的差异决定你要不要设计题表心理测评系统最常见的建模误区是「把题目写死在代码里」。如果你只需要支撑一个固定的 SCL-90写死确实很快但后续换量表、增加维度、调整选项分值都要改代码重新发版这在真实使用场景里不可接受。常见做法是设计question题目表、option选项表、dimension维度表用question_dimension关联表建立多对多关系这样一套代码能同时支撑多个量表。题目表(question)一个量表下的所有题目 选项表(option)每道题的选项分值存这里 维度表(dimension)如焦虑、抑郁、敌对等 量表维度关联表(scale_dimension)量表与维度的映射及计分方向这个结构的核心收益在于计分不是写死在某道题上的而是「某一量表」对「某一维度」定义了一套「包含哪些题、每题反向计分与否、维度得分如何聚合」的规则。新来一个测评量表管理员在后台录入题目和维度就能跑通全流程不需要动 service 层代码。2.2 建表 SQL答题记录用快照而非动态关联学生的作答结果不建议实时关联题目表计算因为题目可能在测评中途被管理员调整历史作答分数就会跟着变。我一般把一次提交的所有答案以 JSON 字符串存快照列同时冗余一份总分和各维度得分查询时直接读结果表不需要回表算分。CREATE TABLE assessment_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT 学生ID, scale_id BIGINT NOT NULL COMMENT 量表ID, answer_snapshot JSON NOT NULL COMMENT 答案快照 {questionId: optionId}, total_score DECIMAL(8,2) DEFAULT NULL COMMENT 量表总分, status TINYINT DEFAULT 1 COMMENT 1已完成 0未完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME DEFAULT NULL, INDEX idx_student_scale (student_id, scale_id), INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT测评作答记录表; CREATE TABLE dimension_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL COMMENT 作答记录ID, dimension_code VARCHAR(50) NOT NULL COMMENT 维度编码, raw_score DECIMAL(8,2) NOT NULL COMMENT 原始分, standard_score DECIMAL(8,2) DEFAULT NULL COMMENT 标准分, level VARCHAR(20) DEFAULT NULL COMMENT 正常/轻/中/重, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_record (record_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT测评维度得分结果表;代码说明answer_snapshot用 MySQL 5.7 的 JSON 类型既保留了完整的答题痕迹又不需要为每道题的答案单独建行——一次 90 题的量表单行记录就够dimension_result表把计分结果独立出来方便后续做筛选、排序、群体分析。要注意的是JSON类型字段在 MyBatis-Plus 里默认不能直接映射为ListMap需要自定义 TypeHandler 或直接用String接收再做 JSON 解析。2.3 MyBatis-Plus 配置与分页查询避免循环查库spring: datasource: url: jdbc:mysql://localhost:3306/psy_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这段配置里真正影响开发效率的是map-underscore-to-camel-case: true——数据库的create_time自动映射为实体的createTime少写一堆TableField注解逻辑删除配置让「删除学生」变成软删保留测评历史数据的完整性。用 MyBatis-Plus 的LambdaQueryWrapper做条件查询时尽量把student_id IN (...)这种批量过滤放在一条 SQL 里不要在外面 for 循环里面查单条——尤其是辅导员拉取几百个学生的测评结果时循环查库会造成严重的 N1 性能问题。3. 计分规则、风险预警与核心业务接口实现3.1 反向计分与维度得分规则写在 Service 层而非数据库量表的计分规则一般是这样的每个维度包含若干道题每题 1~5 分部分题目反向计分选「1」得「5」维度原始分是这些题目的分数之和再除以题目数得到均分最后对照常模转换成标准分。反向计分的题必须在配置里显式标记不能靠题目序号硬编码。Service public class ScoringService { Resource private QuestionMapper questionMapper; Resource private DimensionMapper dimensionMapper; Transactional(rollbackFor Exception.class) public DimensionResult calculate(Long recordId, Long scaleId, MapLong, Integer answerMap) { ListQuestion questions questionMapper.selectList( new LambdaQueryWrapperQuestion() .eq(Question::getScaleId, scaleId) .eq(Question::getEnabled, 1)); MapLong, Dimension dimMap dimensionMapper.selectList( new LambdaQueryWrapperDimension() .eq(Dimension::getScaleId, scaleId)) .stream().collect(Collectors.toMap(Dimension::getId, d - d)); MapLong, Integer dimRawScoreMap new HashMap(); MapLong, Integer dimQuestionCountMap new HashMap(); for (Question q : questions) { Integer selectedOption answerMap.get(q.getId()); if (selectedOption null) continue; // 分值 选项的分值若反向计分则 (选项数 1) - 原分值 int rawValue selectedOption; if (Boolean.TRUE.equals(q.getReverse())) { rawValue q.getOptionMax() 1 - rawValue; } dimRawScoreMap.merge(q.getDimensionId(), rawValue, Integer::sum); dimQuestionCountMap.merge(q.getDimensionId(), 1, Integer::sum); } DimensionResult result new DimensionResult(); result.setRecordId(recordId); // 维度得分 维度原始分总和 / 维度题目数 dimRawScoreMap.forEach((dimId, rawScore) - { int count dimQuestionCountMap.getOrDefault(dimId, 1); result.setRawScore(rawScore * 1.0 / count); }); return result; } }逻辑说明这段代码把计分拆成了三步——「取题目配置」→「按答案映射原始分」→「按维度聚合」。q.getReverse()为真时用选项最大值 1 - 原分值实现反向计分SCL-90 是 5 级计分选「1」反向为「5」维度得分用均值而不是总分这是 SCL-90 的计分习惯换成其他量表时只需调整聚合方式。Transactional保证计分和结果落库在同一次事务中中途异常不会产生只有记录没有得分的脏数据。3.2 风险预警按常模阈值划分风险等级拿到维度分数后对照常模表不同性别/年级常模不同计算标准分再按标准分划级。比如 SCL-90 总均分大于等于 2 且小于 3 为轻度症状大于等于 3 为中度以上——这个阈值在不同学校的实践中略有差异做成配置项而不是写死常量。public RiskLevel evaluateRisk(DimensionResult dimResult, ScaleNorm norm, String gender) { double standardScore dimResult.getRawScore() * norm.getStdDev()[gender] norm.getMean()[gender]; if (standardScore norm.getHighRiskThreshold()) { return RiskLevel.HIGH; } else if (standardScore norm.getMediumRiskThreshold()) { return RiskLevel.MEDIUM; } return RiskLevel.LOW; }参数说明norm对象从scale_norm表加载字段包括mean_male、mean_female、std_dev_male、std_dev_female、medium_threshold、high_threshold。标准分的计算不是拿原始分直接比而是先做线性转换否则不同维度之间有的维度 3 道题、有的 10 道题的分值不可比。预警结果落到risk_alert表同时标记handled_status辅导员处理过后把状态置为已跟进这个标记能帮管理者快速筛出「需要看但还没人看」的记录。3.3 定时任务做群体数据汇总不要高估测评频率单个学生做一次测评实时算分没问题但学校每学期做一次全员普查一次导入几千人数据并逐人计算维度得分时用Scheduled做异步批处理比同步计算更稳。Spring Boot 的定时任务最简单的方式是在启动类加EnableScheduling然后在任务类上用Scheduled(cron 0 0 2 * * ?)每天凌晨两点执行。Component public class AssessmentSummaryTask { Resource private AssessmentRecordMapper recordMapper; Scheduled(cron 0 0 2 * * ?) public void generateDailySummary() { ListAssessmentRecord pendingRecords recordMapper.selectList( new LambdaQueryWrapperAssessmentRecord() .eq(AssessmentRecord::getStatus, 1) .isNull(AssessmentRecord::getSummaryGenerated)); // 分批处理每批 500 条避免一次性加载过多 ListListAssessmentRecord batches Lists.partition(pendingRecords, 500); for (ListAssessmentRecord batch : batches) { batch.forEach(record - scoringService.calculateAndSave(record.getId(), record.getScaleId())); } } }定时任务的 cron 表达式五个字段依次是秒、分、时、日、月、周0 0 2 * * ?表示每天凌晨 2 点触发。写在凌晨是为了避开学生使用高峰期避免批量计算占用数据库连接池影响在线答题体验。Lists.partition来自 Guava也可以用org.apache.commons.collections4.ListUtils.partition核心作用是控制每批处理的数据量防止一次性把几千条记录加载到堆内存造成 OOM。4. 查询报表、图表可视化把测评数据变成老师看得懂的信息4.1 按班级/年级聚类展示的聚合查询测评数据的最终消费者是辅导员和心理健康中心的老师他们关心的是「这个班整体心理状态怎么样」「哪些宿舍存在群体性风险信号」。所以查询接口不能只返回明细列表要能在 service 层做一次内存聚合或者 SQL 聚合。GetMapping(/analysis/class) public ResultListClassDimensionVO classAnalysis(RequestParam Long classId) { ListDimensionResult results dimensionResultMapper.selectList( new LambdaQueryWrapperDimensionResult() .inSql(DimensionResult::getRecordId, SELECT id FROM assessment_record WHERE class_id classId)); // 按维度编码分组计算均值和标准差 MapString, DoubleSummaryStatistics stats results.stream() .collect(Collectors.groupingBy(DimensionResult::getDimensionCode, Collectors.summarizingDouble(DimensionResult::getRawScore))); // 组装返回 VO return Result.success(stats); }代码说明inSql是 MyBatis-Plus 提供的子查询封装把「按班级找作答记录 ID」和「按记录 ID 找维度得分」合在一条 SQL 里但要注意inSql里有字符串拼接classId必须做参数校验防止 SQL 注入。如果班级数据量不大几十个人的测评结果也可以先查出recordId列表再用id IN (...)查询得分内存里做groupingBy效率差别不大但代码可读性更好。4.2 导出 ExcelEasyExcel 处理大数据量导出不掉坑导出的需求在学校场景几乎是必然的期末要交报告、个案要留档、筛查结果要发给各学院。用 Apache POI 手写 Excel 对几千行数据会内存溢出阿里开源的 EasyExcel 是把数据写入一批就刷一次盘内存占用曲线的上限明显低得多。public void exportAssessmentReport(Long scaleId, OutputStream out) { ListAssessmentRecord records assessmentRecordMapper.selectList( new LambdaQueryWrapperAssessmentRecord() .eq(AssessmentRecord::getScaleId, scaleId) .orderByDesc(AssessmentRecord::getFinishTime)); ListAssessmentExportRow rows records.stream().map(record - { AssessmentExportRow row new AssessmentExportRow(); row.setStudentNo(record.getStudentNo()); row.setStudentName(record.getStudentName()); row.setTotalScore(record.getTotalScore()); row.setLevel(record.getLevel()); row.setFinishTime(record.getFinishTime()); return row; }).collect(Collectors.toList()); // 写入 Excel每 3000 行刷一次 EasyExcel.write(out, AssessmentExportRow.class) .excelType(ExcelTypeEnum.XLSX) .inMemory(false) .sheet(测评结果) .doWrite(rows); }参数说明inMemory(false)是控制内存占用的关键开关默认 false 表示用磁盘缓存临时数据不会把所有行都堆在内存里如果导出经常失败优先检查OutputStream是否忘记关闭以及响应头是否设置了正确的Content-Disposition。另外 EasyExcel 默认按字段注解生成列头用ExcelProperty(学号)标注即可省去手动设置表头样式的麻烦。4.3 前端可视化ECharts 雷达图展示多维度对比后端只负责提供结构化数据图表交给前端。ECharts 的雷达图天然适合展示多个维度的得分对比——同一个学生在焦虑、抑郁、敌对、偏执等维度上的得分用雷达图呈现异常维度一眼就能看出来。// 前端用 axios 拿数据后渲染雷达图 const chart echarts.init(document.getElementById(chart)); const dims response.data.map(d ({ name: d.dimensionName, max: 5 })); const scores response.data.map(d d.rawScore); chart.setOption({ radar: { indicator: dims }, series: [{ type: radar, data: [{ value: scores, name: 当前状态 }] }] });前端拉到的是维度得分数组indicator定义了每个维度的名称和最大值量表最高分data里放具体得分。这里的注意事项是后端要返回维度名称而不仅是编码否则前端渲染出来的图表没法看。维度名称建议在dimension_result查询时 join 一下维度表或者在后端 VO 里冗余一个dimensionName字段避免前端单独发请求去查字典。5. 系统上线前要重点验证的边界与安全细节5.1 并发场景同一学生重复提交测评正常的测评逻辑应该是一个学生在一个测评周期内只能提交一次但用户在答题页面停留很久、提交时又连点两下按钮就容易产生两条作答记录。后端除了前端按钮置灰还要在接口层做防重校验。PostMapping(/submit) public Result submit(RequestBody AssessmentSubmitVO vo) { Integer count assessmentRecordMapper.selectCount( new LambdaQueryWrapperAssessmentRecord() .eq(AssessmentRecord::getStudentId, vo.getStudentId()) .eq(AssessmentRecord::getScaleId, vo.getScaleId()) .eq(AssessmentRecord::getStatus, 1)); if (count 0) { return Result.error(该量表本期已提交不能重复作答); } // 继续落库 }selectCount加上status 1的条件确保只有已完成状态的记录才算有效提交如果学生被允许在管理员撤回后重新作答再把旧记录状态置为 0 即可。数据库层面可以再加一个student_id scale_id status的唯一索引做双保险但注意唯一索引在「已撤回重新作答」的场景下要配合状态位使用不能直接建在(student_id, scale_id)上。5.2 路径穿越与导出文件读取的安全边界系统涉及报告导出文件下载接口容易出现路径穿越问题。如果导出文件名直接用前端传来的fileName攻击者可以传../../etc/passwd诱导后端读取系统文件。后端要固定文件存储路径对传入文件名做白名单校验只允许字母数字和下划线。String safeFileName fileName.replaceAll([^a-zA-Z0-9_\\-], ); File file new File(System.getProperty(user.dir) /export/, safeFileName);另外 Spring Boot 项目如果对外暴露了 actuator 或 heapdump 端点攻击者可能通过/actuator/heapdump下载 JVM 内存快照里面可能包含数据库连接串、密钥等敏感信息。生产环境务必用management.endpoints.web.exposure.include只暴露健康检查端点其他一律关闭。这个点很多参考项目不会注意但上线前尤其要检查。5.3 测评数据脱敏与访问控制不能等出事了再补心理测评数据比普通业务数据敏感得多——学生的姓名、学号、测评得分组合起来就是个人健康信息一旦泄露后果严重。接口层面建议用 Spring Security 或 Sa-Token 做基于角色的权限控制辅导员只能查看自己管理班级的数据心理咨询中心可以看全量数据。数据脱敏方面导出 Excel 时默认不要带完整学号只显示后四位前端列表页学生的姓名做中间打码点击详情后再展示完整信息。这些操作虽然不是核心功能但评审和实际部署时都是高频被问到的点。少一次事故比多做十个功能都值得。本文还有配套的精品资源点击获取