ARTICLE DETAIL

资讯详情

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

SpringBoot3+Vue3构建大学生心理健康评测系统:从数据库设计到避坑指南

SpringBoot3+Vue3构建大学生心理健康评测系统:从数据库设计到避坑指南 简介面向毕业设计及课程设计场景的JavaSpringBoot3Vue.js3前后端分离项目实现了大学生心理健康评测系统的管理后台与用户前台涵盖量表管理、评测记录、结果统计等常用模块管理员可配置测评题目并查看学生评测趋势学生可在线作答并获取测评报告适合作为2025届计算机类专业毕设选题参考。资源包共4个文件整体约63.97MB含完整源码ZIP、MySQL8数据库SQL脚本、需求文档DOCX及操作录屏MP4从环境搭建到界面演示均有覆盖。已有142人学习/下载适合需要快速理解项目架构、复用真实方案或完成课题复现的开发者。通过源码可学习SpringBoot3与Vue3的整合方式、RESTful接口设计与前后端联调方法录屏直观展示页面流转与操作路径需求文档便于撰写开题报告与毕业设计说明书SQL脚本可直接导入MySQL运行验证快速搭建可演示环境。1. 大学生心理健康评测系统SpringBoot3 Vue3 到底解决了一个什么问题大学生心理健康评测系统说白了就是一套「量表投放 → 在线作答 → 自动评分 → 报告解读」的完整闭环。和普通问卷系统最大的区别是每道题背后都挂着心理健康维度提交答卷后不能只存答案还要按常模规则算出总因子分和阳性项目数最后生成一段学生能看懂、老师能拿去干预的报告。用 JAVA 生态做后端正好把 SpringBoot3 的事务、自动配置和 Starter 规范吃透用 Vue.js3 做前端组合式 API 管理动态题目状态比旧写法舒服得多。这套组合对 2025 毕业设计性价比很高业务完整、技术足够新、演示直观适合想把前后端、数据库、权限和常用算法整体过一遍的人。2. 数据模型设计把 SCL-90 量表拆成用户、题目、答卷与报告四类表这个系统的业务不复杂但建模有一个点必须先想清楚分数不是前端算出来的而是后端根据每道题的维度归属关系算出来的量表也不止一张很可能后面还会加 PHQ-9、UPI大学生人格问卷这类工具。所以数据模型我会按「一份可配置的量表」来设计而不是做一个写死的问卷。2.1 角色与权限模型学生、辅导员、管理员三种身份的数据边界权限模型的粒度直接影响开发量。最省心、也最能跟评委解释清楚的做法是在用户表里加一个 role 字段而不是急着上完整 RBAC。心理健康评测场景只有三类人学生答题看自己的报告辅导员看班级整体情况管理员维护量表和学生账号。独立建五张权限表的收益极低还容易把表关系讲乱。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50) DEFAULT NULL, student_no VARCHAR(20) DEFAULT NULL COMMENT 学号辅导员按班筛选用, class_name VARCHAR(50) DEFAULT NULL COMMENT 班级例如 计科2201, role VARCHAR(20) NOT NULL DEFAULT STUDENT, -- STUDENT/COUNSELOR/ADMIN status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0禁用, deleted TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_class_name (class_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;我建议保留 deleted 字段。毕设演示时经常要反复造数据如果真删用户答卷表的历史记录会变得很难看用逻辑删除是 MyBatis-Plus 的常规做法被问到「删除用户怎么处理」时也能给出合理回答。role 区分身份后接口层判断就简单了学生查询必须带 userId辅导员查询必须带 class_name 范围管理员不受限制。数据隔离规则写在 Service 层前端只负责把对应按钮暴露出来。2.2 量表与题目设计题目、选项、维度映射怎么落库量表是最容易设计错的地方。有人会把题目直接塞满一张大表评分逻辑写死在代码里之后换量表就要改代码。更稳的做法是把量表做成配置数据量表表记录基本信息和维度配置题目表记录题干和维度归属选项表记录每道题的选项分值。CREATE TABLE mental_scale ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, code VARCHAR(50) NOT NULL UNIQUE COMMENT 量表编码如 SCL90, dimension_json JSON NOT NULL COMMENT 维度配置[{code:som,name:躯体化},...], total_question INT NOT NULL DEFAULT 0, version VARCHAR(20) NOT NULL DEFAULT 1.0 COMMENT 常模版本, enabled TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE mental_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scale_id BIGINT NOT NULL, content TEXT NOT NULL, dimension_code VARCHAR(50) NOT NULL COMMENT 对应 dimension_json 里的 code, sort_no INT NOT NULL DEFAULT 0, is_reverse TINYINT NOT NULL DEFAULT 0 COMMENT 1反向计分, KEY idx_scale_sort (scale_id, sort_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE mental_question_option ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_id BIGINT NOT NULL, option_label VARCHAR(10) NOT NULL COMMENT A/B/C/D/E, option_text VARCHAR(100) NOT NULL, option_score INT NOT NULL DEFAULT 0 COMMENT 该选项的原始分正向题直接落这里, KEY idx_qid (question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;dimension_json 用 JSON 是为了降低新增量表的成本新增量表时只需补一段 JSON再往 mental_question 里按 dimension_code 关联题目即可。这里有个反直觉的点反向计分题不要靠「在选项分值里写反向值」解决。比如 5 点量表某题是正向的选 B 得 2 分同一道题如果是反向的选 B 应该得 4 分。如果直接改选项分值题目和选项的对应关系在代码里会很难追。把反向换算集中在评分阶段反而是最简单的做法。2.3 答卷与报告原始分、标准分、因子分分别存哪里答卷这里用一主一从两张表answer_header 存一次作答的整体信息answer_detail 存每道题的作答明细。这样既方便分页查询历史答卷也方便做重复提交的幂等判断。CREATE TABLE answer_header ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scale_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0作答中 1已完成, total_score INT DEFAULT NULL, total_level VARCHAR(20) DEFAULT NULL COMMENT 正常/轻度/中度/重度, started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at DATETIME DEFAULT NULL, UNIQUE KEY uk_user_scale (user_id, scale_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE answer_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, answer_id BIGINT NOT NULL, question_id BIGINT NOT NULL, option_id BIGINT NOT NULL, option_score INT NOT NULL COMMENT 提交时从选项表冗余出来的分值, KEY idx_answer_id (answer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;分数冗余进 answer_detail 是我比较坚持的一点。它让评分阶段不再需要连表查选项也让「某道题当时选了什么、得了几分」成为不可变的作答记录。如果学生改答案重新提交我们看的是新记录而不是被改掉的旧数据这在论文里叫「作答留痕」。报告表单独拆出来放因子分和结论文案。它和 answer_header 一对一也可以合并但拆开后前端报告页和后端导出 PDF 都更好写CREATE TABLE mental_report ( id BIGINT PRIMARY KEY AUTO_INCREMENT, answer_id BIGINT NOT NULL UNIQUE, user_id BIGINT NOT NULL, factor_scores JSON NOT NULL COMMENT {som:1.25,dep:2.31,...}, summary TEXT NOT NULL COMMENT 自动生成的文字结论, advice TEXT COMMENT 建议文案不写入具体诊断, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;写到这里你会发现整个系统核心只有五张业务表再加一张账号表就够了。这个体量对毕业设计非常合适讲得清楚扩展得动又不会把时间耗在过度设计上。3. 后端实现用 SpringBoot3 把「提交答卷 自动评分 权限隔离」一条线打通数据模型定好之后后端要做的其实很聚焦接收一张在线测评卷算好分再安全地返回结果。SpringBoot3 的优势在于起步依赖克制、自动配置省心真正需要花心思的是事务边界、评分时机和权限判断。3.1 工程初始化Java17、SpringBoot3 依赖选型与分包规范SpringBoot3 官方要求 JDK17 及以上第一步先确认本机java -version至少是 17。很多同学习惯装了 1.8 环境直接启动就会看到UnsupportedClassVersionError。如果电脑里有多个 JDK记得在 IDEA 的项目结构和 Maven 设置里都指向同一个 17避免旧版 JDK 编译阶段就出问题。pom.xml 里按这个骨架选依赖就够了parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent properties java.version17/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies特别提醒 MyBatis-Plus 的坐标SpringBoot3 下要用mybatis-plus-spring-boot3-starter而不是网上更常见的mybatis-plus-boot-starter。后者会把 SpringBoot2 时代的自动配置类带进来一启动就报Invalid value type for attribute factoryBeanObjectType这个坑后面专门展开。代码分包我习惯用五层controller、service、mapper、entity、dto/vo。entity 对应表结构dto 接前端参数vo 返回给前端展示。这个结构对答辩来说最好讲每层的职责一句话说清不至于被问到「这个字段为什么放这层」时卡壳。application.yml 里再把数据源和 MyBatis-Plus 驼峰映射配上spring: datasource: url: jdbc:mysql://localhost:3306/mental_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deletedmap-underscore-to-camel-case保证student_no自动映射到studentNologic-delete-field则让删除统一变成 update避免手写deleted1。3.2 核心接口一张答卷怎么接收、验重并入库答题提交的接口就一个POST /api/answer/submit。完整流程分四步按 userId 和 scaleId 查有没有已完成答卷有就拒绝没有就插入或复用 header删掉旧明细写入新明细最后调用评分服务。RestController RequestMapping(/api/answer) public class AnswerController { Resource private AnswerService answerService; PostMapping(/submit) public ResultLong submit(RequestBody Valid AnswerSubmitDTO dto) { Long userId UserContext.getUserId(); Long answerId answerService.submit(dto, userId); return Result.ok(answerId); } }Service 里最需要讲清楚的是幂等判断我不希望演示时连续点两次提交按钮结果产生两条答卷记录Transactional(rollbackFor Exception.class) public Long submit(AnswerSubmitDTO dto, Long userId) { AnswerHeader header answerMapper.selectOne( new LambdaQueryWrapperAnswerHeader() .eq(AnswerHeader::getUserId, userId) .eq(AnswerHeader::getScaleId, dto.getScaleId())); if (header ! null Integer.valueOf(1).equals(header.getStatus())) { throw new BusinessException(该量表已完成作答请勿重复提交); } if (header null) { header new AnswerHeader(); header.setScaleId(dto.getScaleId()); header.setUserId(userId); header.setStatus(0); answerMapper.insert(header); } // 覆盖式提交先清掉自动保存留下的明细再写入最终答案 answerDetailMapper.delete( new LambdaQueryWrapperAnswerDetail() .eq(AnswerDetail::getAnswerId, header.getId())); for (QuestionAnswerDTO item : dto.getAnswers()) { AnswerDetail detail new AnswerDetail(); detail.setAnswerId(header.getId()); detail.setQuestionId(item.getQuestionId()); detail.setOptionId(item.getOptionId()); detail.setOptionScore(optionService.getScore(item.getQuestionId(), item.getOptionId())); answerDetailMapper.insert(detail); } reportService.calculateAndSave(header.getId(), userId); header.setStatus(1); header.setFinishedAt(LocalDateTime.now()); answerMapper.updateById(header); return header.getId(); }这里我特意强调option_score从后端重新查不直接信任前端传值。这不是怕作弊而是防前端把「选项序号」和「选项分值」混在一起造成脏数据。另一个细节是Transactional(rollbackFor Exception.class)评分抛异常时整个提交一起回滚不会出现「答了但没分」的记录。3.3 评分规则实现维度分、总分与严重程度等级怎么算评分阶段最怕写成一堆 if/else 堆在 Controller 里。我通常抽一个ReportService.calculateAndSave()只负责两件事从明细表把每个维度的原始分算出来再按量表规则生成结论。SCL-90 的常用规则是每道题 1~5 分维度因子分 该维度所有题总分 ÷ 该维度题数总分 ≥ 160 或阳性项目数得分 ≥ 2 的题≥ 43 时提示阳性。public void calculateAndSave(Long answerId, Long userId) { ListAnswerDetail details answerDetailMapper.selectList( new LambdaQueryWrapperAnswerDetail() .eq(AnswerDetail::getAnswerId, answerId)); MapLong, MentalQuestion questionMap questionMapper.selectList(null).stream() .collect(Collectors.toMap(MentalQuestion::getId, q - q)); MapString, Integer rawSum new LinkedHashMap(); MapString, Integer countMap new LinkedHashMap(); for (AnswerDetail detail : details) { MentalQuestion q questionMap.get(detail.getQuestionId()); if (q null) { continue; } int score detail.getOptionScore(); if (q.getIsReverse() 1) { score 6 - score; // 5 点量表反向计分 } rawSum.merge(q.getDimensionCode(), score, Integer::sum); countMap.merge(q.getDimensionCode(), 1, Integer::sum); } MapString, BigDecimal factorScores new LinkedHashMap(); rawSum.forEach((dim, total) - { BigDecimal value BigDecimal.valueOf(total) .divide(BigDecimal.valueOf(countMap.get(dim)), 2, RoundingMode.HALF_UP); factorScores.put(dim, value); }); String summary buildSummary(factorScores, scale); MentalReport report new MentalReport(); report.setAnswerId(answerId); report.setUserId(userId); report.setFactorScores(JSON.toJSONString(factorScores)); report.setSummary(summary); reportMapper.insert(report); }这段代码里有一个容易踩的坑dimension_code在题目表里是字符串比如som、dep如果量表维度配置里也叫同名 code两边必须完全一致。我见过有人维度表里叫somatization题目表里叫som评分结果永远对不上。所以建表时就用同一份 code 列表维护评分前在日志里打印一次维度映射能省大量排查时间。3.4 权限控制JWT 拦截器与数据隔离的基本做法权限这块用 JWT 拦截器不引入 Spring Security 全家桶。拦截器只做一件事从 Authorization 头解析出 userId放进 ThreadLocal。学生查报告时 Service 层校验report.userId 当前登录 userId这一步能挡掉最简单的横向越权。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } try { Long userId JwtUtil.parseUserId(token.substring(7)); UserContext.setUserId(userId); return true; } catch (Exception e) { response.setStatus(401); return false; } } }注册拦截器时记得放行/api/auth/login、/api/scale/list这些公开接口。辅导员查看班级聚合报表时Service 把 scope 限定到class_name 辅导员的班级SQL 里不带 user_id也就看不到个人明细。这里有个答辩加分点ThreadLocal 里存 userId 而不是整个用户对象可避免 token 里冗余信息被篡改后影响查询。4. 前端实现用 Vue3 组合式 API 做一个带自动保存的在线测评页前端在毕设里的定位不是花哨而是能顺利走完测评流程。Vue3 组合式 API 特别适合动态问卷题目数量、选项个数、当前进度都是运行时状态如果全挂 data 里代码会很散用组合式函数把状态和操作收拢逻辑集中很多。4.1 用 useExam 组合式函数管理题目、当前答案和进度我习惯把答题页逻辑抽成一个useExamcomposable对外暴露 loading、题目列表、当前题号、答案对象和进度百分比页面只做渲染和交互不碰存储细节// src/composables/useExam.js import { ref, computed, watch } from vue import { getQuestions } from /api/scale export function useExam(scaleId) { const loading ref(false) const questions ref([]) const currentIndex ref(0) const answers ref({}) // { questionId: optionId } const progress computed(() { if (!questions.value.length) return 0 return Math.round( (Object.keys(answers.value).length / questions.value.length) * 100 ) }) async function loadQuestions() { loading.value true const { data } await getQuestions(scaleId) questions.value data loading.value false } function setAnswer(questionId, optionId) { answers.value[questionId] optionId } // 答案只要有变化就写入 localStorage作为自动保存 watch(answers, (val) { localStorage.setItem(exam:${scaleId}, JSON.stringify(val)) }, { deep: true }) function restore() { const raw localStorage.getItem(exam:${scaleId}) if (raw) { try { answers.value JSON.parse(raw) } catch (e) { /* 忽略坏数据 */ } } } return { loading, questions, currentIndex, answers, progress, setAnswer, restore } }watch的deep: true是配合对象键级更新的关键不写时只有替换整个 answers 对象才会触发保存。restore在页面刷新后从 localStorage 把答案捞回来这是「刷新不丢答卷」的主要保障。注意 key 里带上 scaleId避免不同量表互相覆盖。4.2 答题页组装题目切换、单选高亮与提交前校验答题页组件只做渲染所有逻辑都从 useExam 取script setup import { ref, computed, onMounted } from vue import { useExam } from /composables/useExam const props defineProps({ scaleId: { type: Number, required: true } }) const { loading, questions, currentIndex, answers, progress, setAnswer, restore } useExam(props.scaleId) const current computed(() questions.value[currentIndex.value] || {}) const selectedOption computed(() { const q current.value return q.id ? answers.value[q.id] : null }) function choose(optionId) { const q current.value if (q.id) setAnswer(q.id, optionId) } function next() { if (currentIndex.value questions.value.length - 1) currentIndex.value } function prev() { if (currentIndex.value 0) currentIndex.value-- } async function submit() { if (Object.keys(answers.value).length ! questions.value.length) { const firstMiss questions.value.findIndex(q !answers.value[q.id]) if (firstMiss ! -1) currentIndex.value firstMiss return } // 调提交接口成功后跳转报告页 } /script模板里的单选按钮我会直接给选项加:class{ active: selectedOption option.id }比原生的 radio 更好控制视觉上也容易看出选中态。这里有个用户能感知的细节点「下一题」之前先把当前题答案写入 answers进度条才会及时变化如果答案状态只在组件内部而没有同步到 useExam刷新后又会回到旧状态体验很割裂。4.3 报告页图表ECharts 雷达图展示九个因子分报告页的核心是让评委一眼看懂「这个系统确实算了分」。雷达图最合适一个维度一条轴异常维度一眼可见。ECharts 按需引入的写法// src/views/report/useRadar.js import * as echarts from echarts/core import { RadarChart } from echarts/charts import { TooltipComponent, LegendComponent } from echarts/components import { CanvasRenderer } from echarts/renderers echarts.use([RadarChart, TooltipComponent, LegendComponent, CanvasRenderer]) export function renderRadar(el, factorScores) { const indicator Object.keys(factorScores).map(name ({ name, max: 5 })) const chart echarts.init(el) chart.setOption({ tooltip: {}, radar: { indicator, // 九个维度 radius: 70%, splitNumber: 5, axisName: { color: #333, fontSize: 12 } }, series: [{ type: radar, data: [{ value: Object.values(factorScores), name: 因子分, areaStyle: { opacity: 0.15 } }] }] }) return chart }SCL-90 的维度分最高是 5radar 的max设 5 即可。报告页顶部除了雷达图还要放一行「本报告仅作为心理健康测评辅助参考不构成医学诊断结论如有困扰请前往学校心理咨询中心」。这句话既是产品规范也能让评委看到你考虑到了伦理和合规是个不大不小的加分项。4.4 路由守卫与接口拦截token 失效后别让页面白屏前端还有一个容易被忽略的工程点登录态失效。后端 401 返回后前端如果不处理用户会看到接口报错、页面空白。常规做法是在 axios 拦截器统一处理遇到 401 清掉本地 token 并跳回登录页路由守卫负责在页面跳转前拦住未登录状态。// src/api/http.js import axios from axios const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) config.headers.Authorization Bearer ${token} return config }) service.interceptors.response.use( res res.data, err { if (err.response?.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(err) } )window.location.href比router.push更保险因为刷新后所有状态重新初始化不会残留半登录状态。演示场景下这个处理意味着token 过期时系统会干净利落地回到登录页而不是在报告页弹出一串报错。5. 避坑手册从 javax 报错到重复提交SpringBoot3 量表的 5 个典型翻车点这一章写的是血泪经验。不少同学代码看起来没毛病一运行就报错最后发现全是选型或命名细节的坑。下面五条按出现频率从高到低排每一条都能在本地十分钟内复现也能在十分钟内修完。5.1 现象SpringBoot3 启动直接报ClassNotFoundException: javax.servlet.Filter原因是网上大量博客还在用 SpringBoot2 或更老的工程结构代码里写的是import javax.servlet.*。SpringBoot3 基于 Jakarta EE 9命名空间换成了jakarta.*原来的 javax 包在官方依赖里已经不存在了。解决方法是全局替换javax.servlet改成jakarta.servletjavax.validation改成jakarta.validation。如果你用 IDEA按CtrlShiftR全项目替换再跑一次编译看还有没有红色 imports。注意不止过滤器受影响validation 注解的包路径也是以 jakarta 开头。5.2 现象启动时报Invalid value type for attribute factoryBeanObjectType: java.lang.String这个报错几乎都来自 MyBatis-Plus 的 starter 版本不对。SpringBoot3 项目如果引入mybatis-plus-boot-starter它会加载一套针对 SpringBoot2 的自动配置实例化 SqlSessionFactory 时类型对不上于是抛这个抽象错误。解决方式是把依赖坐标改成mybatis-plus-spring-boot3-starter版本提到 3.5.5 以上。还有一种很玄学的情况坐标明明改对了还是报错多半是 Maven 本地仓库残留旧 starter 的 class执行mvn clean后到本地仓库把旧版本目录删掉再重新加载一次。5.3 现象分数算出来明显不对比如某维度分超过量表上限这种问题最容易出现在反向计分题上。SDS、SAS 这类量表有不少反向题比如「我觉得我比别人过得都好」选「很少」反而是加分项。如果题目表标了 is_reverse但提交时没有做6 - score转换总分就会偏高或偏低。我曾排查过一个案例学生在选项表里直接把反向题分值写成反向值结果正向题和反向题混在同一套计分逻辑里越往后越乱。解决方法是评分阶段对 is_reverse1 的题统一做转换选项表永远存正向分值同时写一个单元测试固定输入十道题断言预期总分。开发阶段手工核对后这类问题基本不会再溜到演示现场。5.4 现象同一份答卷提交两次报告被覆盖或出现两条记录如果 answer_header 没有唯一索引或判断条件只查了「是否存在」而没查status1用户快速双击提交时两个请求都通过了检查。解决方法是两层防护数据库层加UNIQUE KEY uk_user_scale (user_id, scale_id)应用层在提交后立即把 header.status 置为 1再提交就抛业务异常。前端也别闲着提交按钮在请求发出后置为 loading 并禁用。后端、数据库、前端三层各守一道怎么点都不会翻车。5.5 现象本地联调时接口通演示机上却 401 或跨域报错这属于环境坑根源多半是三个地方后端 CORS 没配、axios 的 baseURL 写死成 localhost、JWT 过期时间设太短。演示机的浏览器地址是http://10.x.x.x:5173后端是http://10.x.x.x:8080不配 CORS 的话浏览器会拦截响应。一个干净的解法是后端写GlobalCorsConfig放行前端地址JWT 有效期在演示前一天临时调到 24 小时后台上再改回短时效。还有个常被忽略的点跨域预检请求 OPTIONS 必须放行否则前端一进页面就报错。调试时先看 Network 面板如果 preflight 返回 403基本就是 CORS 没配全。6. 收尾用模拟答卷验证评分正确性把答辩演示做得更像实战6.1 用模拟答卷验证评分正确性开发完评分逻辑后别急着截图先写脚本回测一轮。常见做法是用 Python 在后端接口上造 20 份随机答卷把返回的因子分和本地手工按维度计算的结果比对。import random import requests BASE http://localhost:8080/api # optionId 取 1~5前提是演示数据里每道题都有这 5 个选项 def mock_answer(question_count): return [{questionId: i 1, optionId: random.randint(1, 5)} for i in range(question_count)] for i in range(20): payload {scaleId: 1, answers: mock_answer(90)} r requests.post(f{BASE}/answer/submit, jsonpayload, headers{Authorization: Bearer demo_token}) answer_id r.json()[data] report requests.get(f{BASE}/report/{answer_id}, headers{Authorization: Bearer demo_token}).json() # 比对 report.factor_scores 与本地 hand_calc() 结果这里 Python 只是外部校验真正的回归测试还是建议写在 JUnit 里。答辩时不可能现场跑 Python 脚本一个Test方法更直接固定十条题目、固定预期总分跑一次 assert 就能证明评分逻辑没被改坏。6.2 一个让演示加分的小功能导出 PDF 报告如果还有富余时间给报告页加一个「导出 PDF」按钮。用 OpenPDF 把 summary、因子分表格和雷达图导成一个 A4 文件功能不大但演示时很能撑场面。导出接口放在GET /api/report/{answerId}/export文件流直接返回给前端报告页用window.open打开即可。这个功能一般不会占太多工时却会让评委觉得系统是真的在给学校用。6.3 我的演示前习惯每次答辩前我都会过一遍这条链路登录 → 选量表 → 答两题刷新页面验证自动保存 → 提交 → 看雷达图 → 换辅导员账号看班级汇总。有一次我演示时没注意 token 过期时间讲到大屏时突然 401 跳回登录页全场等我重新登录了半分钟。从那次之后我养成了一个习惯演示前把所有测试账号的 token 有效期临时调长并在本地先跑一遍冒烟脚本讲的时候只讲业务流程和边界设计不念参数。希望帮到你。本文还有配套的精品资源点击获取
返回列表