
简介面向JavaWeb学习者与毕业设计者这是一份基于SpringBootVue的学生心理咨询评估系统完整项目源码涵盖学生信息管理、心理评估流程、图片与视频素材维护等模块能够帮助读者快速搭建前后端分离的评估管理平台。技术实现采用SpringBoot、MyBatisPlus、MySQL、Maven与VueJDK1.8环境即可运行工程结构清晰适合二次开发与课程设计参考。包内共355个文件以Java后端逻辑、Vue前端页面、SVG图标、XML配置及JS静态资源为主压缩包整体约8.26MB同时附有系统实现说明文档与常用运行脚本便于快速启动并对照学习。目前已有65人学习查看整体规模适中适合需要从零理解心理咨询评估系统设计思路、积累前后端分离项目经验的开发者参考借鉴。项目工程划分了后端服务与前端页面能直观看到接口调用、数据持久化与组件渲染的完整链路便于深入理解项目整体运作。1. 学生心理咨询评估系统从量表提交到预警结果Java 后端要拆清哪几层学生心理咨询评估系统平时看起来不太忙一周几十条记录。但开学集中测评时心理中心往往只给 23 天采集窗口一天内可能涌入几千上万份量表。比普通问卷系统更难的是结果要按维度聚合并做预警分级之后还要支持咨询师复核、学期对比同一套量表改版后历史评估记录不能跟着变化。用 Java 生态落地这类系统最常见的组合是 Spring Boot MyBatis-Plus MySQL核心链路拆成领域建模、评估引擎、管理 API、生产加固四层。这篇内容适合正在做心理测评、健康档案或类似表单评估系统的后端工程师照着可用的方案搭骨架也能把系统里容易漏的边界参数补上。2. 学生心理咨询评估系统的领域建模量表、题项与评估记录先讲一个容易犯的错项目初期图省事只用一张 assessment_record 表把 scale_id、answers_json、score_json 全部塞进去。单次提交功能确实能跑但后面做“按因子筛选”“按学期对比历史”“追溯旧版本计分”时会发现 JSON 字段无法在 SQL 里高效筛选只能写扫表后反序列化的垃圾代码。所以领域建模第一件事就是拆表同时保证评估记录里留着完整的提交现场。2.1 量表、题项、评估记录三张核心表的设计职责按稳定职责拆三层scale 保存量表元数据scale_item 保存题目assessment_record 保存一次评估的答案与结果。为什么不把题目直接放进 scale 的 JSON 里题目数量多而且选项范围、反向计分标记、所属维度都是后续要参与查询和批量更新的数据拆成行存才能走索引也方便版本换代时做 diff。表职责典型变更频率scale量表编码、名称、版本号、维度定义 JSON每学期或修订计分规则时scale_item题干、题号、所属维度、反向计分标记量表改版时assessment_record学生答案快照、维度得分快照、预警级别、审核状态每次评估新增一条之后只读下面是建表 SQL 的核心部分按 MySQL 8.0 设计中文字符集用 utf8mb4CREATE TABLE scale ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL COMMENT 量表编码如 SCL90/SDS/SAS, name VARCHAR(64) NOT NULL COMMENT 量表名称, version_no INT NOT NULL DEFAULT 1 COMMENT 版本号每改版加 1, dimension_json TEXT NOT NULL COMMENT 维度定义、题项归属和计分阈值, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, updated_at DATETIME NOT NULL, UNIQUE KEY uk_scale_code_version (code, version_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT量表定义表; CREATE TABLE scale_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scale_id BIGINT NOT NULL, item_no INT NOT NULL COMMENT 题号从 1 开始, content VARCHAR(255) NOT NULL COMMENT 题干, dimension_code VARCHAR(32) NOT NULL COMMENT 所属因子或维度编码, reverse_score TINYINT NOT NULL DEFAULT 0 COMMENT 1反向计分, UNIQUE KEY uk_scale_item_no (scale_id, item_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT量表题目表; CREATE TABLE assessment_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, scale_id BIGINT NOT NULL COMMENT 评估时使用的量表 id, scale_version INT NOT NULL COMMENT 评估时量表版本号, detail_json TEXT NOT NULL COMMENT 逐题答案快照如 {1:3,2:5}, score_json TEXT NOT NULL COMMENT 维度得分快照如 {depression:3.2}, result_code VARCHAR(16) NOT NULL COMMENT LOW/MEDIUM/HIGH 预警级别, status VARCHAR(16) NOT NULL DEFAULT SUBMITTED, created_at DATETIME NOT NULL, reviewed_by BIGINT NULL COMMENT 复核咨询师 id, reviewed_at DATETIME NULL, KEY idx_student_created (student_id, created_at), KEY idx_scale_status (scale_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评估记录表;我故意没把选项定义拆成一张 option 表绝大多数心理量表的五档选项是固定的没有/轻度/中度/偏重/严重对应 15 分不需要每道题重复存一遍 option 列表。如果将来某个量表选项特殊在 scale_item 上加 option_json 字段就够全局拆选项表只会让查询和前端渲染都更啰嗦。2.2 Java 实体与 MyBatis-Plus 映射服务端用 Spring Boot MyBatis-Plus 时实体类直接对应表名。Scale 里 dimension_json 在 Java 侧先按 String 接收等进入评估引擎时再统一解析成领域对象不要在 Controller 层到处拆 JSON。Data TableName(scale) public class Scale { TableId(type IdType.AUTO) private Long id; private String code; // 量表编码如 SCL90 private String name; private Integer versionNo; // 每次发布新计分规则版本号递增 private String dimensionJson; // 维度、题项归属、阈值引擎里一次解析 private Integer status; // 1启用0停用 TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; }Data TableName(assessment_record) public class AssessmentRecord { TableId(type IdType.AUTO) private Long id; private Long studentId; private Long scaleId; private Integer scaleVersion; // 评估时锁定的版本快照 private String detailJson; // 原始答案明细用于历史重算 private String scoreJson; // 维度得分结果快照 private String resultCode; private String status; private LocalDateTime createdAt; private Long reviewedBy; private LocalDateTime reviewedAt; }时间字段统一用 LocalDateTime不要用 java.util.Date能省掉一批 MyBatis 类型转换相关的 TypeHandler。2.3 版本快照与只读历史记录scale 表的唯一键是 (code, version_no)每次发布新版本就新增一行旧行继续留档。提交评估时拿当前 status1 的版本把 scaleVersion、detailJson、scoreJson 一并写入评估记录。这样半年后咨询师回看界面还原的是当时的题面和答案完全不受新版本影响。这里有个非常容易被忽略的坑上线第二版量表之后历史记录的详情页如果直接用 scale_code 关联最新版量表所有题项内容和维度定义都会错位预警结果的解释也会对不上。我一般会在详情接口里强制要求按 record.scale_version 反查当时版本而不是走默认的最新版。历史评估记录原则上不 UPDATE真要调整计分结果必须通过迁移任务生成新版本记录保留审计链路。下一章开始写真正的计算逻辑这是评估系统与普通问卷系统拉开差距的地方。3. 评估核心引擎Java 代码把逐题答案算成维度得分与预警级别算分逻辑如果散落在 Controller 里后续无论是报表模块还是批量导入工具都得抄一份一模一样的代码改一处漏三处。我会把计分收敛成一个 ScoringEngine 领域服务Web 请求、Excel 批量导入、历史数据重算全部走同一个入口。3.1 提交流程里的请求参数与语义校验前端提交的数据是“题号到分数”的映射。注意加个 termKey 字段它表示“学期 批次”因为心理中心会在一个学期里发起多轮评估幂等键只按 studentId scaleCode 判断会把第二轮的提交误判成重复。Data public class AssessmentRequest { NotBlank(message scaleCode 不能为空) private String scaleCode; NotBlank(message termKey 不能为空) private String termKey; Valid NotEmpty(message answers 不能为空) private ListScaleAnswer answers; } Data public class ScaleAnswer { NotNull(message 题号不能为空) private Integer itemNo; Min(value 1, message 得分最小为 1) Max(value 5, message 得分最大为 5) private Integer score; }参数校验用 javax.validation 的三组注解就够了scaleCode 和 termKey 做 NotBlank分数用 Min Max 控制边界。这里没做“答案覆盖全部必答题”的校验是因为是否允许漏答属于业务规则放到引擎里判断更合适。3.2 维度定义 JSON 与测评算分代码一份量表的维度定义示例如下维度归属和阈值都放在 dimension_json 里{ dimensions: [ {code: depression, name: 抑郁, itemIds: [2,4,5,7,10], threshold: {medium: 2.5, high: 3.5}}, {code: anxiety, name: 焦虑, itemIds: [1,3,6,8,9], threshold: {medium: 2.5, high: 3.5}} ], scoring: AVG, reverseItemIds: [9] }scoring 取 AVG 时维度分 该维度下所有题项得分的平均值取 SUM 时则直接累加适合总分型量表。reverseItemIds 里放需要反向计分的题号例如某个题选项 5 的语义是“完全没有”定义 1 分代表“始终如此”那这里的分值就要按 6 - score 换算。引擎核心代码Service public class ScoringEngine { public AssessmentResult evaluate(Scale scale, AssessmentRequest req) { ScaleDefinition def ScaleDefinition.fromJson(scale.getDimensionJson()); MapInteger, Integer answerMap new HashMap(); for (ScaleAnswer answer : req.getAnswers()) { if (!def.containItem(answer.getItemNo())) { throw new ScoringException(题号不属于该量表: answer.getItemNo()); } answerMap.put(answer.getItemNo(), answer.getScore()); } MapString, Double dimScores new LinkedHashMap(); for (ScaleDimension dim : def.getDimensions()) { double total 0; for (int itemId : dim.getItemIds()) { Integer value answerMap.get(itemId); if (value null) { throw new ScoringException(缺少必答题 itemNo itemId); } // 反向计分原分值 1-5 换算为 6 - value if (def.getReverseItemIds().contains(itemId)) { value 6 - value; } total value; } // 维度分为该维度全部题项的均值保留两位小数 dimScores.put(dim.getCode(), round(total / dim.getItemIds().size(), 2)); } WarningLevel level judgeWarningLevel(dimScores, def); return new AssessmentResult(dimScores, level); } private WarningLevel judgeWarningLevel(MapString, Double dimScores, ScaleDefinition def) { int highCount 0; int mediumOver 0; for (ScaleDimension dim : def.getDimensions()) { double score dimScores.getOrDefault(dim.getCode(), 0.0); if (dim.getThreshold().getHigh() 0 score dim.getThreshold().getHigh()) { highCount; } else if (dim.getThreshold().getMedium() 0 score dim.getThreshold().getMedium()) { mediumOver; } } if (highCount 1) return WarningLevel.HIGH; if (mediumOver 2) return WarningLevel.MEDIUM; if (mediumOver 1) return WarningLevel.MEDIUM_LOW; return WarningLevel.LOW; } private double round(double value, int digits) { return BigDecimal.valueOf(value).setScale(digits, RoundingMode.HALF_UP).doubleValue(); } }参数含义我一般怎么定scoringAVG 均值或 SUM 累加发布后不在同一版本内改reverseItemIds反向计分题号列表改版时核对历史数据是否要重算threshold.medium / high中风险与高风险阈值由心理中心审核心理中心只读配置不改代码这段代码的关键点有三处。第一是反向计分先换算再做聚合后端的展示端不需要知道原题的语义。第二是维度分保留两位小数预警判断用舍入后的值避免浮点误差导致恰好等于阈值时判级抖动。第三是判级规则没有简单取最大值而是考虑了“多个维度同时超中界”的叠加一个维度超过中界只能算偏低风险但如果两个维度都超过中界整体预警等级要升一档。对代码调用时的异常要映射成业务异常码不要把 ScoringException 直接抛到前端。常见做法是在 ControllerAdvice 里统一捕获返回 400 errorCode前端按 errorCode 提示“存在漏答题”或“量表已停用”。3.3 引擎与事务边界的配合评估提交不单是算分还要把结果写库。常规做法是提交服务里加 Transactional捕获业务异常后标记事务回滚。时序上应先算分再写库规则变更时便于单测断言。这里再提醒一句明细快照 detailJson 建议存请求原始答案不能只存维度得分。否则后期调整计分算法、重新计算历史数据时只有汇总分没有明细重算无从谈起。提示detailJson 存的必须是原始答案不能是换算后的分值。否则反向计分规则一旦调整历史记录无法按新规则重新计算。4. 学生心理咨询评估管理系统的 Spring Boot API 与权限设计评估的管理端围绕三个角色展开学生、咨询师、系统管理员。学生只能看自己的评估记录和结果咨询师能看被授权分组内的学生记录并出具复核结论管理员做量表发布和全校统计。操作接口角色提交评估POST /api/v1/assessment/submit学生查询我的历史评估GET /api/v1/assessment/my?termKey学生查询待复核列表GET /api/v1/review/list?statusPENDING咨询师提交复核结论PUT /api/v1/review/{recordId}/conclusion咨询师查询量表列表GET /api/v1/scale/list管理员发布量表版本POST /api/v1/scale/publish管理员全校统计GET /api/v1/stats/school?termKey管理员4.1 JWT 的角色声明与咨询师数据权限JWT payload 我一般只放 userId、role、exp 三个字段太多自定义声明会让旧 token 在声明调整后失效排查起来很难受。真正的数据权限不能靠 payload 完成必须查库。咨询师能看到哪些学生通过“咨询分组”这个稳定的关联关系控制而不是直接基于班级。学生在咨询周期内转班分组关系不受影响才合理。SELECT r.* FROM assessment_record r JOIN assess_consult_group_student gs ON gs.student_id r.student_id JOIN assess_consult_group g ON g.id gs.group_id AND g.counselor_id #{counselorId} AND g.status 1 WHERE r.status SUBMITTED AND r.result_code IN (MEDIUM, HIGH) ORDER BY r.created_at DESC LIMIT #{limit}查询逻辑里把权限范围直接 join 进 SQL不要先查出所有学生 ID 再用 IN 分批查。集中评估期间一个咨询师名下可能是几百个学生IN 拆批会多出好几个 DB 往返。4.2 提交阶段的幂等控制Redis SETNX 与数据库兜底集中评估期间学生连点两次、前端断线重试都是正常的后端必须把“同一轮评估只能生成一条记录”兜住。最稳妥的组合是 Redis SetNX 做前置防重数据库唯一索引做最终兜底。Transactional(rollbackFor Exception.class) public AssessmentRecord submit(Long studentId, AssessmentRequest req) { String idempotentKey assess: studentId : req.getScaleCode() : req.getTermKey(); // SETNX 原子判空写入二十四小时内同一轮只允许第一笔成功 Boolean first redisTemplate.opsForValue() .setIfAbsent(idempotentKey, 1, Duration.ofHours(24)); if (Boolean.FALSE.equals(first)) { throw new DuplicateSubmitException(本轮评估已提交); } Scale scale scaleMapper.selectActiveScale(req.getScaleCode()); if (scale null) { throw new ScaleNotFoundException(量表不存在或已停用); } AssessmentResult result scoringEngine.evaluate(scale, req); AssessmentRecord record new AssessmentRecord(); record.setStudentId(studentId); record.setScaleId(scale.getId()); record.setScaleVersion(scale.getVersionNo()); record.setDetailJson(JsonUtil.toJson(req.getAnswers())); record.setScoreJson(JsonUtil.toJson(result.getDimensionScores())); record.setResultCode(result.getWarningLevel().name()); record.setStatus(SUBMITTED); record.setCreatedAt(LocalDateTime.now()); recordMapper.insert(record); return record; }为什么要 24 小时心理中心的单轮评估窗口通常在一周内24 小时足够覆盖“同一天重复点击”和“网络重试”两种典型场景超过 24 小时的重复提交大概率是学生换了另一轮批次不应再拦截。Redis 不可用时这段逻辑会退化成依赖数据库唯一索引所以 assessment_record 表需要加一个唯一键 (student_id, scale_id, term_key)不能只靠应用层判断。ALTER TABLE assessment_record ADD COLUMN term_key VARCHAR(32) NOT NULL DEFAULT , ADD UNIQUE KEY uk_student_scale_term (student_id, scale_id, term_key);这个唯一索引在正常流程下不会触发只有在 Redis 数据被清理或网络抖动导致 SetNX 漏判时才会起作用所以它的存在价值是兜底而不是替代防重逻辑。4.3 结果查询的脱敏与最小字段返回学生、咨询师、管理员看到的字段不同不要在 entity 上直接序列化应该按场景组装 VO。姓名通过自定义 JsonSerializer 做掩码只有管理员能看到完整姓名。具体实现public class NameMaskSerializer extends JsonSerializerString { Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value null || value.length() 1) { gen.writeString(***); } else if (value.length() 2) { gen.writeString(value.charAt(0) *); } else { gen.writeString(value.charAt(0) ** value.charAt(value.length() - 1)); } } }掩码规则按场景配置学生端看到“张*”咨询师端可以看到“张*三”管理员端拿到完整字段。核心是不要只把 resultCode 暴露成“编码”配一个 result_desc 帮前端展示预警说明少写一段前端翻译映射。5. 上线前必须验证的三个点并发峰值、索引命中与计分回归这里不讲大而全的压测方案只讲三个最能暴露问题的点。5.1 集中评估日的数据库连接池与事务时长应用接入层 HikariCP 的 maximum-pool-size 按“CPU 核数 × 2 1”起步4 核机器配置 98 核配置 17不要盲目调到 200。提交事务里涉及一次 Redis 写、一次量表查询、一次 JSON 解析、一次 DB 插入正常串行耗时应该控制在 30ms 以内如果超过 100ms先看是不是把“解析完整 JSON 正则校验”这类 CPU 密集操作放进了事务里。5.2 高频筛选维度与索引配置常见管理端筛选语句是“按学期、按量表、按预警级别、按咨询师”。除了主键索引和 (student_id, created_at)这几个场景必须覆盖场景推荐索引同批次列表(term_key, scale_id, status)咨询师待复核(status, result_code, created_at)学生历史(student_id, created_at)核查方式很简单把管理端常用的三条慢 SQL 拿出来执行 EXPLAIN看到 type 是 ref 或 range 就够用不需要对每条查询都建联合索引。5.3 用 golden sample 校验计分与预警回归我通常在仓库里放一份固定样例测试输入同样的答案断言各维度分和预警级别完全一致。这样改版时只要跑一遍就能把“算法改坏了但报告页面没报错”的风险降到最低。Test void evaluate_multiMedium_returnsMedium() { Scale scale new Scale(); scale.setCode(TEST); scale.setVersionNo(1); scale.setDimensionJson({\dimensions\:[ {\code\:\depression\,\itemIds\:[1,2,3],\threshold\:{\medium\:2.5,\high\:3.5}}, {\code\:\anxiety\,\itemIds\:[4,5,6],\threshold\:{\medium\:2.5,\high\:3.5}} ],\scoring\:\AVG\,\reverseItemIds\:[]}); AssessmentRequest req new AssessmentRequest(); req.setScaleCode(TEST); req.setTermKey(2024-01); req.setAnswers(List.of( new ScaleAnswer(1, 3), new ScaleAnswer(2, 3), new ScaleAnswer(3, 3), new ScaleAnswer(4, 3), new ScaleAnswer(5, 3), new ScaleAnswer(6, 3) )); AssessmentResult result scoringEngine.evaluate(scale, req); assertThat(result.getDimensionScores().get(depression)).isEqualTo(3.0); assertThat(result.getDimensionScores().get(anxiety)).isEqualTo(3.0); assertThat(result.getWarningLevel()).isEqualTo(WarningLevel.MEDIUM); }注意这里维度分 3.0 同时命中两个因子的 medium2.5 阈值却都不触达 high所以预警结果 MEDIUM。压测时还要用同一条 golden sample 跑批量导入接口确认入参解析、算分、落库三个环节产出的 score_json 一致。把这段测试加进 CI发版前不用人工在浏览器里点三十遍量表就能确信计分链路是稳的。本文还有配套的精品资源点击获取