
简介一份用于职业心理测评场景的完整系统源码面向需要搭建或二次开发测评平台的开发者、企业HR及教育机构。系统覆盖前端交互、后端逻辑与数据库管理支持Web端与Uniapp移动端多端访问包含用户认证、数据采集、结果生成与反馈展示等核心模块并提供用户管理、测评结果管理与数据统计功能可辅助个人职业规划、企业人才选拔和就业指导。压缩包为zip格式共2000个文件整体126.45MB其中以1815个Markdown文档为主用于说明业务逻辑与接口文档91个JavaScript文件对应前后端逻辑60个JSON用于数据配置与通信25个XML及少量txt、docx则涉及项目配置与测评量表资料。目前已有269人学习浏览。通过源码可掌握系统架构设计、量表处理与数据存储方式理解管理系统标签下的完整业务闭环对希望快速构建类似平台或做二次开发的技术人员是一份可运行、可参考的实战代码资料。1. 职业心理测评系统一套能直接跑起来的测评管理系统如果你正在做企业内部的人才评估项目或者想给团队搞一次职业倾向调查又不想依赖第三方测评平台这套前后端分离的职业心理测评系统源码会是个不错的起点。它内置了完整的量表管理、在线答题、自动计分、报告生成和用户管理能力管理员在后台维护题目员工打开网页就能完成测评并立即看到职业建议报告。技术栈是 Spring Boot Vue3 MySQL Redis代码结构清晰适合拿来二次开发。下面我从目录结构到核心计分链路逐步拆开讲尽量还原当时拆这套系统时的完整思路。2. 系统架构与技术选型前后端分离Spring Boot Vue 的落地姿势2.1 整体架构三个端和一条数据流这套系统拆开来看是三个部分用户端浏览器页面、后台管理页面、后端服务。用户端负责登录、选择量表、在线答题、查看报告管理端负责量表维护、题目管理、人员管理、报告汇总后端服务统一提供 API所有业务逻辑和数据处理都在这里完成。数据流比较简单用户在前端提交答案前端通过 HTTP 请求把数据发送到后端后端在校验登录状态后在事务里完成计分和报告生成最后结果落库并返回给前端展示。整个过程中 Redis 用来缓存验证码和答题过程中的临时状态MySQL 负责持久化前后端通过 JSON 通信鉴权用 JWT。从模块划分角度系统由认证模块、量表管理模块、答题模块、计分引擎、报告模块、用户管理模块组成。其中计分引擎是核心也是后期最容易定制的地方。第 4 章会专门拆这一段。整体架构上没有引入微服务也没有做复杂的消息队列因为测评业务本身是低频、短事务、数据量可控的场景单体应用足以支撑几千人的并发访问。2.2 技术选型为什么用这些而不是别的后端起的是一个标准的 Spring Boot 单体应用版本用的 2.x搭配 MyBatis-Plus 操作数据库。选 Spring Boot 是因为它起步快、配置集中对于这种管理类系统来说不需要引入微服务那套复杂的治理框架一个 jar 包就能跑部署成本低。MyBatis-Plus 比原生 MyBatis 省去了大量单表 CRUD 的 XML分页、条件构造器写起来顺手尤其是用户列表、报告列表这种典型的后台查询场景写起来效率很高。数据库选了 MySQL理由不用多说生态成熟、团队里的人基本都会。Redis 用于两个地方一是登录验证码的存储设置过期时间后自动失效二是保存答题过程中的“草稿状态”防止用户做一半刷新后全部丢失。这个设计在真实场景里非常有用特别是那些题目数量比较多的测评用户可能会分几次才能完成。前端用的是 Vue3 加 Element Plus组件库覆盖了表格、表单、弹窗这些后台管理的高频组件开发效率比手写原生 DOM 高很多。Vite 作为构建工具开发环境启动速度快热更新体验比 Webpack 时代舒服得多。有同学可能问为什么不用 Spring Security而手写 JWT 拦截器。这套系统没有复杂的权限层级就是个简单的管理员/普通用户角色判断用拦截器加注解反而更直观。而且二次开发时修改登录逻辑更容易看懂不需要去翻 Security 的过滤器链。2.3 目录结构源码拿到手先看哪几个文件夹我拉下代码后第一件事就是看目录。后端是标准 Maven 结构前端是用 Vite 初始化的 Vue 工程。大概长这样psy-assessment/ ├── backend/ │ ├── pom.xml │ └── src/main/java/com/example/psych/ │ ├── config/ # 跨域、拦截器、Redis配置 │ ├── controller/ # REST接口层 │ ├── service/ # 业务逻辑层 │ ├── mapper/ # MyBatis-Plus数据访问层 │ ├── entity/ # 数据库实体类 │ ├── dto/ # 入参和出参对象 │ ├── utils/ # JWT、密码加密等工具类 │ └── common/ # 通用返回结果、异常处理 ├── frontend/ │ ├── package.json │ └── src/ │ ├── api/ # 前端封装的后端接口调用 │ ├── views/ # 页面答题页、报告页、管理后台 │ ├── router/ # 前端路由 │ ├── store/ # Pinia状态管理 │ └── components/ # 复用组件 ├── sql/ │ └── init.sql # 建库建表及测试数据 ├── README.md └── docs/ # 接口文档和部署说明拿到源码建议按下面顺序读先读 SQL 看表结构然后打开 README 看启动步骤再把后端controller包整体过一遍了解有哪些接口。接下来看service包里的计分逻辑最后跑通前后端后再去研究前端页面。不要一开始就陷在某个工具类里那样容易被细节点带偏主线。2.4 前端路由与状态管理用户和管理员看到的是同一套壳前端路由用 Vue Router配置了两种布局一种是不带侧边栏的答题页面另一种是带左侧菜单的管理后台。浏览器端通过路由 meta 里写的roles字段判断页面允许哪些角色访问配合后端拦截器双重校验。没有登录就跳转到登录页登录后如果访问没有权限的路由会被重定向到 403 页面。状态管理用的 Pinia。store 里维护了两个关键对象一个是userInfo记录当前登录用户的基本信息和 token另一个是scaleState保存正在进行中的测评状态包括当前题号、已选答案和剩余时间。在答题过程中每做一题就调用一次更新接口避免整个页面刷新后数据丢失。这个做法比全量保存用户体验更好也为后面做断点续答打了基础。后端接口返回的格式统一封装成{ code: 200, data: ..., message: success }前端 axios 实例里做了响应拦截如果 code 不是 200 会弹出 ElMessage 提示。比如登录密码错误统一返回 code 401前端就自动跳到登录页。这个约定在前后端联调时非常省心双方只要认准这一个结构就行。3. 业务模型与数据库设计用户、量表、答题记录和报告3.1 核心表结构五张表怎么关联测评系统的业务数据不复杂核心就 5 张表表名说明关键字段sys_user用户表id, username, password, real_name, role, dept_idpsy_scale量表定义表id, scale_name, description, dimensions, statuspsy_question题目表id, scale_id, question_text, options, dimension_idpsy_answer_record答题记录表id, user_id, scale_id, question_id, answer_value, create_timepsy_report报告表id, user_id, scale_id, scores, result_text, generate_time关系上一个量表包含多道题目和多个维度题目通过scale_id关联量表通过dimension_id关联维度用户做一次测评会产生多条答题记录最后汇总成一份报告。报告表里scores字段用 JSON 存储各个维度的原始分和标准分这样后期切换计分规则不需要改表结构。需要说明的是这里用一张psy_dimension表会更规范但这个项目把维度定义直接放在了量表的dimensions字段里以 JSON 数组格式存储比如[{code:R,name:实际型,weight:1}]。对于一次性测评来说少连一张表更轻松查询时省去一次 join代价是修改维度时不能像操作普通表那样方便。后面二次开发要加量表时这个设计可以直接沿用。3.2 量表规则因子维度与计分权重怎么存职业心理测评的经典模型是霍兰德六边形本系统内置了这套模型作为默认量表。六个维度分别是实际型(R)、研究型(I)、艺术型(A)、社会型(S)、企业型(E)、常规型(C)。每道题有多个选项每个选项对应不同维度加不同分。例如题目“遇到难题时你更倾向于怎么做”选项 A“查阅资料独立研究”给 I 维度加 2 分选项 B“找人商量一起解决”给 S 维度加 2 分。这种映射关系保存在psy_question表的options字段中同样用 JSON 数组表示。比如[ { label: A, text: 查阅资料独立研究, scores: { I: 2 } }, { label: B, text: 找人商量一起解决, scores: { S: 2 } } ]也就是说题目的选项不直接存得分而是存“这个选项在每个维度上的加分数”这样一道题可以同时在多个维度上产生不同影响比传统的单选只对应一个维度的设计更接近真实测评量表的做法。计分时把同一维度所有题目的得分累加再除以题目数量乘以 100 得到标准分这样不同长度量表的分数可以相互比较。为什么不直接用数字字段存得分因为这个项目希望支持管理员在后台任意调整选项和维度如果字段是硬编码的那加一个维度就得改数据库表。用 JSON 可以在不改变表结构的情况下支持自定义量表是一个取舍后的结果。3.3 管理端功能量表启用、用户重置与报告审核除了基础的 CRUD管理端还有几个对业务很重要的功能。第一个是量表的启用与停用。量表有DRAFT、ENABLED、DISABLED三种状态只有处于ENABLED状态的量表才会出现在用户的量表列表里。这样管理员可以提前配置好题目预览确认无误后再开放避免用户打到一半发现题目还在改。第二个是重置用户测评状态。管理员在后台可以针对某个用户执行“重置该量表测评记录”逻辑上是删除对应量表下的答题记录和报告并把用户在该量表的可答题状态置为未完成。这个功能看起来很普通但其实很容易踩坑。因为如果只是把报告删掉不处理答题记录用户提交时就会走重复提交校验提示“该量表已提交过”。所以重置时必须同时清理答题记录和报告两个操作要在同一个事务里完成。第三个是报告审核。默认情况下报告是自动生成的用户提交完就能看到。但有些企业内部测评希望先由管理员或 HR 审核后再发出去系统里加了一个report_status字段默认是AUTO管理员可以把它改成REVIEWED。前端报告页根据这个字段展示“审核中”或“已确认”的状态。这部分逻辑不复杂但体现了系统和真实管理流程的贴合。3.4 接口设计前端调用的几个核心 API整个系统接口数量不多下面是前端会高频调用的几个接口方法说明/api/auth/loginPOST登录参数是用户名和密码返回 token 和用户信息/api/auth/logoutPOST退出登录清理 token/api/scale/listGET获取量表列表管理员可见全部用户可见已启用状态/api/scale/{scaleId}/questionsGET获取量表的题目和选项/api/record/submitPOST提交答题记录后端计算得分并生成报告/api/record/myselfGET获取当前用户的历史报告列表/api/report/{reportId}GET获取报告详情包含分数和职业建议/api/admin/scalePOST管理员新增或修改量表这些接口在前后端分离的开发模式下会先写一份 Swagger 文档前端根据文档生成对应的api目录文件。实际项目中接口定义最核心的一点是提交答题记录接口必须保证幂等否则用户重复点击提交按钮会生成多份报告。项目里对此做了处理第 4 章会看到具体实现。4. 核心链路拆解一次测评从登录到报告生成的全过程4.1 登录与鉴权JWT 拦截器里做了什么所有接口除了登录之外都要求请求头带上Authorization字段。后端增加了一个AuthInterceptor拦截器在进入 Controller 之前从请求头取出 token解析出用户 ID然后通过ThreadLocal暂存当前用户信息。调用业务接口时直接从上下文取出用户 ID避免每个 Controller 都从 token 里解析。先看工具类生成 token 的核心代码public String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 86400000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }这里secretKey从配置文件读取默认是写死的上线前一定要改成环境变量。过期时间设在 1 天考虑到测评系统用户可能中途离开很久token 过期会导致答题提交失败所以项目里还加了一个刷新 token 的接口前端每次请求如果发现状态码为 401会尝试用 refreshToken 换取新的 token整个过程对用户无感。拦截器逻辑如下public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new BusinessException(未登录或登录已过期); } String token authHeader.substring(7); Long userId JwtUtils.getUserId(token); UserHolder.set(userId); return true; }这段代码的核心是UserHolder.set。它是一个ThreadLocal包装类保证一次请求处理的全部过程中都能拿到当前登录用户 ID。注意拦截器里没有做权限判断权限判断是后续在 Controller 方法上通过RequireRole(admin)注解完成的。这样设计的好处是登录校验和角色校验可以分开管理不需要为每个角色写重复的拦截逻辑。4.2 测评提交事务里如何校验、计分、落库答题提交是系统里逻辑最集中的地方先是校验用户和量表状态然后逐条保存答题记录再调用计分引擎计算各维度分数最后生成报告并落库。整个过程加了一个Transactional事务注解任何一步出错都能回滚避免出现“题目记录保存了但报告没生成”的半截状态。核心的 Service 方法大概长这样Transactional public SubmitResult submitRecord(SubmitRequest request, Long userId) { Scale scale scaleMapper.selectById(request.getScaleId()); if (scale null || !ENABLED.equals(scale.getStatus())) { throw new BusinessException(量表不存在或未启用); } Long recordId answerRecordMapper.selectSubmitted(userId, request.getScaleId()); if (recordId ! null) { throw new BusinessException(该量表已提交过请勿重复提交); } for (AnswerItem item : request.getAnswers()) { PsyAnswerRecord record new PsyAnswerRecord(); record.setUserId(userId); record.setScaleId(request.getScaleId()); record.setQuestionId(item.getQuestionId()); record.setAnswerValue(item.getAnswerValue()); answerRecordMapper.insert(record); } MapString, Double scores scoreCalculator.calculate(request.getAnswers(), scale); Report report reportGenerator.generate(scale, scores, userId); reportMapper.insert(report); return buildResult(report, scores); }重点在于重复提交校验。selectSubmitted先查一次当前用户在里这个量表下是否有报告如果已有报告直接抛出业务异常。为什么不用数据库唯一索引因为同一用户可能被管理员重置后重新测评所以这里用软判断而不是硬约束。生产环境中如果担心并发导致重复插入还可以在psy_report表加一个(user_id, scale_id, is_valid)的唯一索引但需要考虑重置时更新is_valid而不是物理删除。这段代码里还有一个容易被忽略的点for循环逐条插入答题记录。如果题目数量很大批量插入会比循环插入效率高很多。这个项目把量表的题目控制在 60 道以内循环插入的耗时在几十毫秒级别所以没有特别优化。如果你要支持几百道题的问卷建议改成SqlSession批量插入或者用 MyBatis-Plus 的insertBatchSomeColumn方法。4.3 计分引擎分数到推荐语的映射逻辑算分逻辑独立封装在ScoreCalculator和ReportGenerator两个类中。前者负责计算分数后者负责根据分数生成报告。这样分开写将来要替换推荐语生成规则不必动计分代码。ScoreCalculator的计算思路是先初始化每个维度的总分遍历用户提交的答案从题目options里找到对应选项的scores字段把维度分数累加最后对累加分做标准化public MapString, Double calculate(ListAnswerItem answers, Scale scale) { MapString, Double scoreMap new HashMap(); MapString, Integer dimensionCount new HashMap(); for (AnswerItem item : answers) { PsyQuestion question questionMapper.selectById(item.getQuestionId()); JSONObject option question.findOption(item.getAnswerValue()); JSONObject scores option.getJSONObject(scores); for (String dim : scores.keySet()) { scoreMap.merge(dim, scores.getDouble(dim), Double::sum); dimensionCount.merge(dim, 1, Integer::sum); } } for (String dim : scoreMap.keySet()) { double avg scoreMap.get(dim) / dimensionCount.get(dim); scoreMap.put(dim, avg * 100); } return scoreMap; }标准化逻辑很简单把每个维度的均分缩放到百分制。因为不同题目在同一个维度上出现的次数不同直接用累加分会导致题目多的维度天然分数高影响公平性。换算成标准分后再排序就得到用户的职业倾向排名。ReportGenerator拿到分数后做两件事一是按降序排列维度取前两个作为主码和三码二是在内置的职业匹配表里查找对应的推荐岗位和相关建议。比如某用户“企业型”“社会型”得分最高报告里就会说“适合管理类、销售类或公共关系类岗位建议发挥组织和协调能力”。推荐表存在psy_report_rule表里二次开发时直接改表里的映射关系就能调整推荐结果。这里我解释一下为什么排序取前两个维度而不是只取最高分。从职业测评角度看单一维度最高的用户很少见大多数人是几个维度组合在一起。比如研究型实际型的人适合做技术工程师而社会型企业型的人适合做业务管理。如果只取最高维度推荐结果的区分度会很低。这个组合逻辑在report_rule表里是用 JSON 存储的比如{primary:S,secondary:E,suggestion:...}这样的结构方便后台维护。5. 部署运行与避坑手册从零跑通到稳定上线5.1 环境准备与配置文件修改在本地跑这个项目需要 JDK 1.8 以上、Maven 3.6 以上、MySQL 5.7 以上、Redis 5.0 以上以及 Node.js 14 以上。后端默认端口是 8080前端是 5173Vite 默认前后端通过 HTTP 通信需要开启跨域。拿到源码后第一件事是创建数据库mysql -u root -p sql/init.sqlinit.sql里包含建库、建表、插入初始管理员账号和演示量表数据。执行成功后去修改后端的application.yml里的数据库地址、账号密码以及 Redis 地址和secret密钥。关键配置项如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/psy_assessment?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 jwt: secret: your-secret-key-please-change-me这里最容易被忽略的是serverTimezone如果写错或者不写数据库时间会和本地差 8 个小时报告生成时间会显示成昨天的。还有数据库连接串最好加useSSLfalse本地开发环境不需要加密连接加了反而会在启动时报警告。5.2 启动后端和前端两条命令的事后端在backend目录下执行mvn spring-boot:run如果 Maven 下载依赖太慢用阿里云镜像或者在仓库设置里换源。启动成功后控制台会打印端口和几个初始化日志看到“Started Application”就说明起来了。前端在frontend目录下执行npm install npm run serve注意 Vite 默认启动在localhost:5173如果浏览器打开后接口报跨域错误去后端WebConfig里确认CorsMappings是否配置了允许该端口。项目里已经写了一个允许所有源头和所有方法的配置但如果你改了端口要把新的端口加进去。5.3 避坑手册我在这套系统上踩过的 5 个大坑第一个坑前端登录一直报“跨域请求被阻止”。现象是浏览器控制台红色错误请求发不出去。原因是我把后端的 CORS 配置放到了 Spring Security 过滤器链里但项目自己写的拦截器在 Security 之前先拦截了导致 OPTIONS 预检请求没通过。解决办法是把跨域处理放到一个独立类实现WebMvcConfigurer并显式允许所有预检请求头。第二个坑管理员重置密码后用户用旧 token 还能访问接口。现象是用户被重置密码后未过期的 token 依然可以调接口。原因是 JWT 是无状态的服务端不知道 token 是否失效。解决办法是在用户表加一个token_version字段登录时把当前值写进 token每次重置密码或封禁用户时自增该字段拦截器里比较版本号不一致就拒绝。第三个坑报告分数出现重复累加。现象是同一用户提交两次答题记录第二次提交没有报错但报告里分数比第一次高。原因是我在判断重复记录时只查了answer_record表没有查report表而答题记录允许同量表多次提交。解决办法是在ReportService里增加查询报告表是否已有有效记录同时给(user_id, scale_id)加唯一索引双保险。第四个坑Redis 连接不上登录接口直接 500。这个坑最典型。原因是我把验证码校验放在 Redis 里没有处理缓存不可用的降级逻辑。生产环境里 Redis 偶尔会抖动我们不能因为缓存挂了就让整个登录接口全挂。解决办法是给 Redis 操作加 try-catch获取不到验证码时放开校验同时监控告警。做测评系统的登录本来就不是高安全场景牺牲一点安全性换可用性是可接受的。第五个坑前端打包后刷新页面 404。现象是npm run build产物放到 Nginx 下用户刷新/admin路由变成 404。原因是 Vue Router 用了history模式Nginx 没有配置 try_files。解决办法是在 Nginx 配置里加上location / { try_files $uri $uri/ /index.html; }把请求回退到入口文件。这个坑在后端分离项目里几乎是必踩的。5.4 上线前的检查清单跑通本地只是第一步真要上生产环境我习惯按下面几项过一遍。先检查数据库连接串和 Redis 密码是否改成真实环境的init.sql里的初始管理员密码是不是改过了。再检查 JWT 的secret是否足够随机最好用环境变量注入。然后是定时备份 MySQL这个系统数据量虽然不大但报告表和答题记录会持续增长备份策略不能少。日志方面后端默认用 Logback建议把错误日志单独输出到logs/error.log方便排查线上问题。接口层面可以加一个简单的访问日志拦截器记录每次请求的 userId、接口路径和耗时。这个系统目前没有做限流如果担心被刷可以在网关层加个 IP 维度的时间窗口限制不需要很复杂能挡住明显异常就行。6. 二次开发指南如何加一个自定义测评量表6.1 新增量表管理后台直接配置还是写 SQL这个系统支持两种方式新增量表。第一种是在管理后台页面点“新增量表”填写名称、简介然后按题目和选项逐条添加。量表如果题目不多用后台表单最方便。第二种方式是直接往数据库里导入 SQL适合大批量数据初始化。我一般推荐第二种因为后台页面一次只能加一题而 SQL 可以把几十道题一次性插入。不过要注意保持options字段的 JSON 格式和原有数据一致写错一个引号前端解析题目时会报错。新量表的状态默认设置为DRAFT在管理后台预览确认没问题后再改为ENABLED这样可以避免正在编辑中的量表被用户误点进入答题。6.2 修改计分逻辑把固定权重改成动态权重当前计分引擎每种题型的所有题目权重一样。如果你希望“核心题”的得分影响力更大不需要改数据库只需要修改ScoreCalculator的累加逻辑增加一个权重读取。我的做法是在psy_scale表增加一个weights字段存储每个维度的权重因子。比如想提高社会型维度的权重设置{S: 1.5}计算时把该维度的得分乘以权重。改动位置只在calculate方法末尾加一行double finalScore avg * scale.getWeights().getOrDefault(dim, 1.0); scoreMap.put(dim, finalScore);这样改的好处是计分规则由数据驱动同一个量表可以开发多种评分版本后台切换权重即可不需要修改代码重新发版。6.3 验证改造是否正常我的测试套路每次改完计分逻辑我会先构造一组已知答案手工计算出预期分数然后调用接口提交对比返回的分数是否一致。比如一个只有 10 道题的演示量表每个维度的题目数和选项都固定我用手算把预期分数写在注释里再跑接口验证。差异如果超过 0.01 就说明有浮点精度问题。然后我会重置用户数据删除答题记录和报告再提交一次确认能重新测评。那条“请勿重复提交”提示看起来是限制其实是防呆设计有了它测试时能快速发现有没有脏数据。从那以后我每次改完计分或报告生成都会强制走一遍“新量表创建 → 模拟答题 → 查看报告 → 重置再测”的完整流程确保数据一致性和幂等性都通过才合并代码。希望帮到你。本文还有配套的精品资源点击获取