资讯详情

SpringBoot+Vue3在线考试系统:从数据库设计到自动评分实战

📅 2026/9/24 20:39:04 | 华诺云谱 👁 阅读
SpringBoot+Vue3在线考试系统:从数据库设计到自动评分实战
1. 项目背景与整体设计思路1.1 这个考试系统到底在解决什么问题先说个现象我身边不少学校、培训机构甚至企业内部培训部门到现在还在用纸质试卷或者简单的问卷表单来做在线考试。纸质考试的问题不用多讲——出卷、印刷、监考、批改、统计分数一套流程下来少说一周遇到多选题答案设置错了或者某个学生分数加错了返工成本高到让人头大。用问卷表单做的所谓“在线考试”又缺失了最关键的考试约束能力没倒计时、没切屏监控、没法按题型自动算分交卷以后的成绩分析更是几乎为零。这个用Java SpringBoot Vue3 MyBatis MySQL做的在线考试与学习交流平台本质上是把“考、练、学、聊”四件事放到一个前后端分离的系统里。考试端解决的是出卷、组卷、在线答题、自动评分、成绩统计学习端解决的是题库练习、错题回顾、资料下载交流端则是一个简单的社区模块让用户能发帖提问、评论回答形成学习闭环。我在实际做类似项目的时候最深的一个体会是考试系统最核心的难点不是代码怎么写而是你对业务规则的理解够不够细。比如考试中途刷新页面要不要算交卷倒计时到了但用户还在答题已填的答案要不要自动提交多选题少选给不给分这些规则直接在需求阶段定清楚后面代码一次就能写对。这个项目适合谁来参考如果你是刚学完 SpringBoot 和 Vue3、想找一个能写进简历的完整项目这套系统的功能复杂度刚好合适——既有登录鉴权、角色权限、CRUD 这类常规操作又有考试计时、自动评分这种带状态流转的业务逻辑还能帮你把 MyBatis 动态 SQL 和联表查询练熟。你要是已经在公司里做业务系统也可以参考它的模块拆分思路和表结构设计尤其是考试答卷那几张表的关联关系稍微改造一下就能用到实际的测评系统里。1.2 前后端分离的选型逻辑这套系统在架构上走了前后端分离后端只提供 RESTful API前端用 Vue3 做单页应用两者通过 JSON 通信。这个决策在当前的技术环境下几乎是必然选择不只是因为“大家都在这么做”。从团队协作角度看前后端分离之后前端同学和后端同学可以并行开发只要提前把接口文档定义清楚两边各自写各自的代码最后联调时再对齐。就算你自己一个人做整个项目这种拆分也有好处——你在后端写接口的时候不需要考虑页面长什么样写完用 Swagger 或者 Postman 测一下返回数据对不对就行写前端的时候也不用在 Java 代码里拼接 HTML所有精力都放在组件交互上。更重要的是Vue3 的项目本身是静态资源可以单独部署到 Nginx和后端完全隔离后面哪怕要给系统加负载均衡、加 CDN都不会碰到后端代码。为什么用 Vue3 而不是 Vue2一方面 Vue3 现在是绝对主流Composition API在组织复杂业务逻辑时比 Vue2 的Options API要顺手得多另一方面 Vue3 的响应式系统重写以后性能和内存占用都有明显提升配合Vite开发时的热更新速度用过的都知道几毫秒的反馈开发体验完全不一样。这套系统里我用 Vue3 的Composition API把考试倒计时、答题状态管理、自动保存这些逻辑各自拆成了独立的组合式函数代码看着清爽后期维护也省心。1.3 后端技术栈的搭配思路后端的技术栈是 SpringBoot MyBatis MySQL这三样组合在中小型项目中非常经典。SpringBoot 负责提供 HTTP 接口、参数校验、统一异常处理、定时任务这些“框架级”能力。它最核心的价值是自动配置我只需要在pom.xml里引入spring-boot-starter-webSpringBoot 就会自动配好内嵌的 Tomcat、JSON 序列化和基础的 MVC 组件我不需要手动写一堆 XML 配置文件。这对提升开发效率帮助非常大尤其在做这种业务逻辑密集的系统时核心时间应该花在业务实现上而不是跟框架配置较劲。MyBatis 的定位是持久层框架我比较喜欢它的原因有两点。第一SQL 是明确的、手写的复杂查询的优化空间完全掌握在自己手里不像JPA那样在简单场景很爽、一到复杂报表就抓瞎第二MyBatis 的动态 SQL 能力在考试系统的“多条件组合查询”场景里简直就是救命稻草比如后台管理系统里按试卷名称、考试状态、创建时间范围查试卷列表条件可能是不确定的用where和if标签组合一下一个方法就能覆盖所有查询组合。MySQL 在这里就是最后的数据落点存储用户、试题、试卷、考试记录、帖子等所有业务数据。它的 InnoDB 存储引擎支持事务和外键约束考试系统中交卷时同时更新答卷状态和成绩这两步操作就需要包在同一个事务里确保要么都成功要么都失败不能出现答卷状态改了成绩没算出来的中间状态。说实话这套技术栈放到今天的市场上不算新潮但正因为它的社区生态成熟、资料多、踩坑记录丰富所以特别适合做这种“要稳定、要能跑、要能演示”的完整项目。你要是把它换成 Spring Cloud 那套微服务体系反而把复杂度提上去了对这类业务场景没有必要。2. 数据库表设计与核心业务建模2.1 核心数据表的结构说明做考试系统第一件事不是写代码而是把表结构想清楚。我见过太多人上来就建一张exam表把考试所有信息都塞进去后面做扩展时痛苦不堪。合理的设计应该是垂直拆分 水平拆分结合每个业务实体有自己的表实体之间的关系用外键或中间表来维护。这套考试系统的核心表设计我建议从以下几个维度来规划用户侧维度包括sys_user用户表、sys_role角色表、sys_user_role用户角色关联表。用户表存基本信息比如用户名、密码加密存储、昵称、头像、状态角色表区分管理员、教师、学生三类角色。为什么用户和角色要分开表加关联表因为一个用户可能有多个角色比如某个教师同时也是系统管理员如果只用一个字段role存枚举值后面想扩展多角色会很痛苦。内容维度包括question试题表、exam_paper试卷表、exam_question试卷试题关联表。试题表是题库的底表核心字段有question_type题型单选、多选、判断、简答、content题干、options选项 JSON用于存储 A/B/C/D 选项内容、answer标准答案、score分值、analysis答案解析。这里选项为什么用 JSON 而不是单独建一张选项表我个人的经验是单选、多选题的选项通常就是 2~5 个字符串用 JSON 存储最简单直接查询时一次性取出不需要额外关联而且 JSON 字段可以通过 MySQL 的JSON_EXTRACT函数在特殊场景下做查询。但要是你的系统里选项本身有大量属性比如图片、音频那还是拆一张表更合适。exam_paper试卷表存试卷的基本信息如果卷名称、总分、及格分、考试时长、创建人、状态草稿/发布/结束。exam_question关联表则是试卷和试题的多对多桥梁里面会额外带上该题在试卷中的序号和分值这是因为同一道题可能出现在多张试卷里分值可能不一样必须把“本题在这张试卷里占多少分”放到关联关系中单独存。考试过程维度包括exam_record考试记录表、answer_record答题明细表。exam_record体现的是“某次考试会话”的信息用户 ID、试卷 ID、开始时间、结束时间、总得分、状态答题中/已交卷/考试超时、交卷方式手动/自动。answer_record则是这个会话下每一道题的作答情况考试记录 ID、试题 ID、用户答案、是否正确、得分。这两张表一个定单头、一个定明细和电商订单那种order order_item的结构是同一个思路。2.2 关键表结构的实战建表语句直接把核心几张表的结构贴一部分出来你建表的时候可以做个参照。用户表CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码(BCrypt加密), nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, email varchar(100) DEFAULT NULL COMMENT 邮箱, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态 1正常 0禁用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT系统用户表;试题表CREATE TABLE question ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, question_type tinyint(4) NOT NULL COMMENT 题型 1单选 2多选 3判断 4简答, content text NOT NULL COMMENT 题干内容, options text COMMENT 选项JSON,格式如{A:选项内容,B:选项内容}, answer varchar(1000) NOT NULL COMMENT 标准答案,多选用逗号分隔, score decimal(5,1) NOT NULL DEFAULT 2.0 COMMENT 默认分值, analysis text COMMENT 答案解析, difficulty tinyint(4) DEFAULT 2 COMMENT 难度 1简单 2中等 3困难, creator bigint(20) DEFAULT NULL COMMENT 创建人ID, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_type_difficulty (question_type,difficulty) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT试题表;考试记录表CREATE TABLE exam_record ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, user_id bigint(20) NOT NULL COMMENT 用户ID, paper_id bigint(20) NOT NULL COMMENT 试卷ID, total_score decimal(6,1) DEFAULT NULL COMMENT 总分, start_time datetime DEFAULT NULL COMMENT 开始考试时间, end_time datetime DEFAULT NULL COMMENT 交卷时间, duration_seconds int(11) DEFAULT NULL COMMENT 实际用时(秒), status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态 0答题中 1已交卷 2超时交卷, submit_type tinyint(4) DEFAULT NULL COMMENT 交卷方式 1手动 2自动, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_paper_id (paper_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT考试记录表;我特别想强调几个建表细节。第一所有时间字段统一用datetime并设置默认值CURRENT_TIMESTAMP创建时间不需要代码里手动塞数据库自动维护更新时间用ON UPDATE CURRENT_TIMESTAMP只要这一行数据被修改MySQL 会自动更新时间戳省去业务代码的重复操作。第二字符集统一用utf8mb4因为utf8mb4支持完整的 Unicode 字符包括 emoji 表情和生僻字考试系统中用户昵称、题干描述里完全可能出现这些符号。第三所有金额或分数类型用decimal而不是float/double因为浮点数在算总分的时候存在精度丢失问题几分之差在考试排行里就是名次的差别决不能用浮点数做精确计算。三张核心表的关系是这样的用户考一张试卷产生一条exam_record这条记录下面是每道题一条answer_record通过exam_record_id关联。列表查询时顺着exam_record - exam_paper - sys_user往下关联就能把一次考试的所有信息串起来。3. SpringBoot 后端核心实现与 MyBatis 实战3.1 SpringBoot 项目的基础配置先看pom.xml里需要引什么依赖。对于一个考试系统核心依赖无非这几个spring-boot-starter-webWeb 能力、spring-boot-starter-validation参数校验、mybatis-spring-boot-starterMyBatis 集成、mysql-connector-jMySQL 驱动、lombok简化实体代码、spring-boot-starter-security或者jjwt来做登录鉴权、spring-boot-starter-test做单元测试。这里有个配置层面的注意事项MyBatis 的mybatis-spring-boot-starter版本要和 SpringBoot 的大版本匹配。SpringBoot 3.x 对应 MyBatis starter 3.xSpringBoot 2.x 对应 MyBatis starter 2.x版本对应不上会导致SqlSessionFactory初始化失败。我实际项目里踩过这个坑SpringBoot 3.2 配了个老版本的 MyBatis starter启动直接报Failed to configure a DataSource查了半天才发现是版本冲突。建议用mybatis-spring-boot-starter官方最新的 3.0.x配合 SpringBoot 3.2 以上版本兼容性没问题。application.yml的配置要点spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/exam_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.exam.system.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl server: port: 8080map-underscore-to-camel-case这个配置必须开它会把数据库的create_time自动映射到 Java 实体里的createTime字段这能省掉大量写resultMap的时间。开发阶段我习惯把log-impl设为StdOutImpl这样控制台能直接打印出 MyBatis 执行的 SQL 语句和参数排查问题时比看抓包工具更直接。等上了生产环境再把它关掉。3.2 MyBatis 多表查询与动态 SQL 详解考试系统里最复杂的查询场景之一是“查询考试记录列表”前端要展示的信息包括考试名称、考试人、得分、状态、考试时长这些数据分散在三张表里。对应到 MyBatis 就是一个多表关联查询。看这段 Mapper XMLselect idselectExamRecordPage resultTypecom.exam.system.entity.vo.ExamRecordVO SELECT er.id, er.total_score, er.start_time, er.end_time, er.duration_seconds, er.status, er.submit_type, ep.name AS paper_name, ep.total_score AS paper_total_score, su.username, su.nickname FROM exam_record er LEFT JOIN exam_paper ep ON er.paper_id ep.id LEFT JOIN sys_user su ON er.user_id su.id where if testuserId ! null AND er.user_id #{userId} /if if teststatus ! null AND er.status #{status} /if if testpaperName ! null and paperName ! AND ep.name LIKE CONCAT(%, #{paperName}, %) /if /where ORDER BY er.create_time DESC /select这段 SQL 用LEFT JOIN把考试记录和试卷、用户串起来一次性查出列表页展示的全部字段避免业务代码里逐条发查询。如果有前端按用户名模糊搜索的需求把su.username LIKE CONCAT(%, #{username}, %)加到where里就行。这就是 MyBatis 动态 SQL 最典型的应用场景——状态不确定、条件不确定的列表查询用whereif的组合能在一个方法里解决所有可能性并且会自动处理掉第一个条件前的AND关键字不用手动拼字符串。再讲一个细节实际列表往往还需要分页。如果手动用LIMIT控制每次还得先查 COUNT 再查数据写起来麻烦。更规范的做法是集成一个分页插件。3.3 MyBatis 分页插件的用法分页插件这款工具在 Java 面试里几乎是必问内容实际项目里也是高频使用。它是一款基于 MyBatis 拦截器机制实现的物理分页插件原理是通过拦截Executor的query方法在 SQL 执行前自动生成COUNT查询和带有LIMIT的分页 SQL。用法上我总结为三步第一步引入依赖。在pom.xml中加入dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version2.1.0/version /dependency第二步在application.yml中配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true第三步代码里调用PageHelper.startPage(pageNum, pageSize); ListExamRecordVO list examRecordMapper.selectExamRecordPage(param); PageInfoExamRecordVO pageInfo new PageInfo(list);这里有个非常重要的实践细节PageHelper.startPage(pageNum, pageSize)必须在 Mapper 查询方法被调用之前执行而且只对紧接着的下一条 SQL 查询生效。我见过有人把它放在循环体内部或者在startPage之后还执行了其他数据库操作导致分页条件作用到了错误的 SQL 上查出来的数据莫名其妙少了几条。正确姿势是页面参数解析完成后先调startPage紧接着立刻调select方法然后用返回值构造PageInfo。PageInfo里会封装好总记录数、总页数、当前页数据这些分页页面上需要的所有信息直接返回给前端用。reasonable: true这个配置也值得说一下。它表示“合理化”分页如果pageNum小于 1自动查询第一页如果pageNum大于最大页数自动查询最后一页。这在大多数列表页场景中是符合预期行为的能避免前端传来一个异常页码时后端报错。3.4 MyBatis 缓存机制在考试系统中的取舍MyBatis 的缓存分为一级缓存和二级缓存。一级缓存是SqlSession级别的默认开启二级缓存是namespace级别的需要手动配置。在考试系统这个场景下我对缓存的态度是保守的。一级缓存还好因为考试系统的读操作通常是一次会话一个 SqlSession缓存的生命周期很短不会带来数据不一致的问题。但二级缓存我不会轻易开因为考试系统的数据时效性要求很高——比如管理员刚改了某道题的答案如果二级缓存里还存着旧答案考生在做题时就会拿到错误数据。加上 MyBatis 的二级缓存粒度是namespace也就是 Mapper一旦涉及多表联查缓存失效策略会变得非常复杂例如exam_record表和answer_record表做了关联查询结果缓存到ExamRecordMapper的 namespace 里但answer_record表的数据更新了这个缓存不会被自动清空。如果你确实想用缓存提升性能我建议用更可控的方案在业务服务层用 SpringCache 或 Redis 对热点数据做缓存自己明确指定缓存的 key 和过期时间。比如考试排行前十名、热门试卷列表这类不常变化的查询可以缓存 5 分钟而考试答卷详情这种数据频率很高但一致性要求也刚的场景可以做短时间缓存或者干脆不缓存。二次开箱的行为不仅仅是一个“开不开启”的选择题更是一个对业务流程的数据一致性做判断的过程最终选型的标准永远是你能否完全说清楚每条数据在主流程中的流转路径。4. Vue3 前端实现与前后端交互实战4.1 Vue3 项目搭建与目录结构前端用 Vue3 搭配 Vite 来搭建开发服务器端口配置为 5173通过 Vite 的代理转发/api前缀的请求到后端的 8080 端口。这是前后端联调时最常用的方式能规避跨域问题也不需要后端额外做 CORS 配置。我在 Vite 配置文件vite.config.js里会做这么一段代理export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })目录结构上我习惯把代码按模块拆分而不是按类型堆叠。src/api下面按业务模块放接口定义文件exam.js里放考试相关接口question.js放题库接口user.js放用户相关src/views下面对应页面组件src/components放公共组件src/store放 Pinia 状态管理src/composables放组合式函数。这类分层适合像考试系统这种功能边界相对清晰的业务两个人同时开发时不容易冲突后加功能的人也能快速定位代码位置。4.2 登录鉴权与路由守卫考试系统的权限模型是三套角色管理员、教师、学生。管理员管理用户和系统配置教师负责题库和试卷管理学生参加考试、查看成绩、参与社区讨论。不同角色能访问的页面不同前端要在路由层面做权限控制。我用的方案是登录成功后后端返回 JWT token 和用户信息包含角色列表。前端把 token 存到localStorage在 Axios 请求拦截器里自动加上Authorization: Bearer token头。路由守卫在跳转前做两件事一是判断是否登录有没有 token没有就去登录页二是拿到当前用户角色和路由元信息里的roles做匹配没有权限就跳到 403 页面。这个方案需要注意的一点是前端的路由守卫只是用户体验层面的拦截真正的权限校验必须在后端接口层面做。前端可以靠隐藏菜单来避免普通学生点进管理员页面但如果有人直接通过 API 调接口比如把teacherId参数改成别人的 ID后端没有权限校验的话数据就泄露了。所以我在后端用 SpringMVC 的拦截器统一处理 JWT 鉴权并且在不同接口上加上PreAuthorize注解或用 AOP 做角色校验保证即使绕过前端数据也不会被越权访问。4.3 在线考试页面的核心交互实现在线考试页面是整个系统里交互逻辑最复杂的部分涉及到几个核心问题倒计时、答题状态的本地维护、自动保存、交卷策略。先说倒计时。从后端查询试卷时接口会返回考试时长比如 60 分钟。前端拿到开始考试的时间戳后用setInterval每秒计算剩余时间。这里有一个很容易踩的坑不要依赖 setTimeout 的计时精度因为浏览器在后台标签页会休眠定时器导致时间不准确。正确做法是记录一个开始时间戳然后每次更新时用Date.now() - startTimestamp计算已过时间这样即使定时器被浏览器暂时挂起恢复时计算出的剩余时间依然是准确的。const startTimestamp Date.now() const duration examInfo.value.duration * 60 * 1000 const timer setInterval(() { const elapsed Date.now() - startTimestamp const remain duration - elapsed if (remain 0) { clearInterval(timer) handleAutoSubmit() return } remainingTime.value Math.ceil(remain / 1000) }, 1000)第二个核心问题是答题状态管理。考试中的题目状态至少有两种当前正在作答的题、已经作答的题。答题卡区域要能高亮当前题也要能标记哪些题已经作答这样学生才能知道还有哪些题没做。我用 Pinia 定义了一个examStore里面用MapquestionId, answer来存用户答案并在答案变化时自动更新作答状态集合。每次用户切换题目时都会把上一题的答案合并到这个Map里同时记录当前题号。第三个核心问题是自动保存。考试过程中如果出现网络抖动、浏览器崩溃、电脑断电用户已经答的一部分题不能丢。我实现了一个自动保存机制每 30 秒把Map中的答案提交到后端一个草稿接口同时手动点击“下一题”时也会触发一次保存。如果用户中途刷新页面重新进入考试页面后接口会把这个考试记录下已保存的答案带回来回填到答题状态里。这个功能在真机上非常实用我当年第一次做考试系统时没做自动保存测试时用浏览器的“刷新”按钮模拟用户误触结果答案全空了那一刻真想把键盘砸了。4.4 组件通信与成绩单展示成绩单页面相对简单一些交卷后跳转到成绩页展示总分、考试用时、每道题的对错情况、正确答案、用户答案、得分。后端提供的接口是获取考试记录详情包含记录信息和每道题的答题明细。这里在组件通信上有一个值得说的实践考试页和成绩单页是两个独立的路由考试成绩通过路由参数传递但路由参数不适合传大量数据而且用户刷新后参数可能丢失。我采用的是考试页交卷成功拿到考试记录 ID 后用router.push({ path: /result, query: { recordId: xx } })跳到成绩页成绩页再根据recordId调后端接口获取完整数据。这样刷新成绩页数据不会丢也不会因为路由参数过长导致浏览器地址栏异常。通过这个设计也能看出一个靠谱的考试系统页面数据不会频繁摆在路由参数里数据的持久化职责永远在后端。前端还有一个容易忽略的场景考试过程中考生不小心点了“关闭页面”或“切到其他标签页”。我没法阻止用户关闭浏览器但在路由离开守卫onBeforeRouteLeave里可以拦截如果考试还没交卷且剩余时间大于 0弹一个确认框提示“考试尚未交卷确认离开吗”。另外window.addEventListener(beforeunload)也可以提示用户关闭页面的风险给用户一个后悔的机会。5. 自动评分算法与学习交流模块设计5.1 客观题自动评分的实现逻辑自动评分是考试系统比纸质考试效率高太多的关键点。客观题单选、多选、判断的自动评分实现起来并不复杂但细节也不少。单选题比较简单用户提交的答案和标准答案字符串直接做equals比较相同就给分不同就不得分。判断题的情况和单选题一样只不过答案规范化成“正确/错误”两个值。多选题要稍微复杂一点取决于计分规则。常见的计分规则有两种严格型和多选部分得分型。严格型要求用户答案和标准答案完全一致一个选项都不能多、一个都不能少宽松型则按“选对的比例”给分。我在这套系统里实现的是严格型代码逻辑是把用户答案按逗号拆成集合把标准答案按逗号拆成集合先判断两边元素个数是否一致再做差集判断是否为空。如果数量不同或差集不为空都不得分完全一致得满分。public boolean checkMultiAnswer(String userAnswer, String standardAnswer) { if (userAnswer null || standardAnswer null) return false; SetString userSet new HashSet(Arrays.asList(userAnswer.split(,))); SetString standardSet new HashSet(Arrays.asList(standardAnswer.split(,))); if (userSet.size() ! standardSet.size()) return false; userSet.removeAll(standardSet); return userSet.isEmpty(); }简答题或作文题需要教师人工评阅所以交卷时简答题不计分得分初始为 0等教师在后台阅卷界面逐题打分提交。这里我把算分逻辑分成两步交卷时先自动算客观题总分简答题分数置为待批改状态教师在后台提交简答题分数后再把这个分数累加到总成绩上。这套流程在代码里对应的接口就是“交卷算分”和“阅卷提交分数”两者互不干扰逻辑很清晰。5.2 交卷时的并发与事务处理交卷是一个写操作比较密集的场景要更新exam_record的状态为已交卷、写入end_time要批量写入所有题目的answer_record要重算总分还要把总分写回exam_record。这四步操作必须在一个事务里完成否则一旦中间某一步失败数据就会出现不一致。我在后端实现时在Transactional注解标记的 Service 方法里依次执行这些操作。这里有一个需要注意的问题幂等性。如果前端因为网络超时在交卷接口请求已到达后端且事务已提交的情况下前端没收到成功响应于是又发了一次交卷请求后台会重复交卷吗我的处理方式是在交卷方法的最开始先查询exam_record的状态如果状态已经是“已交卷”直接返回当前记录信息不重复执行交卷逻辑。这个判断放在事务内部再加上 MySQL 行锁的保护就能防止并发下重复交卷的问题。还有一点需要提的是如果学生超时自动交卷定时任务怎么触发我采用的是简单可靠的做法前端倒计时到 0 时触发交卷接口后端兜底校验考试时间如果请求到达时已超过截止时间后端强制按已作答数据交卷。这样做的好处是依赖前端主动上报后端只做兜底校验不会出现大量定时任务同时扫描表的性能问题。就算某个考生浏览器坏了导致前端没触发交卷后端在查询试卷详情接口时也会判断记录状态发现超时未交卷会直接把状态更新为超时交卷防止后面再进入答题页。5.3 学习交流社区的帖子与评论模块学习交流模块是让平台不止是一个考试工具的部分。它的核心实体是帖子post和评论comment加上点赞post_like表。帖子的表结构设计并不复杂标题、正文内容、发布人 ID、帖子分类比如“Java 基础”“SpringBoot 面试”“Vue3 实战”、浏览数、点赞数、回复数、置顶状态、创建时间。评论表则关联帖子 ID 和回复人 ID同时通过一个parent_id字段来支持二级评论回复某条评论。为了控制查询复杂度我这里只做了二级评论如果一个评论下面有回复回复挂在parent_id指向的那条评论下面。表格里不额外存回复层级避免了无限极递归查询的性能风险和逻辑复杂度。这里分享一个前端的经验帖子列表的浏览量实时推送其实没那么必要所以我在查询的时候直接展示数据库里的view_count值就行不需要 WebSocket 推送或定时轮询。只有“点赞”这种用户能直接感知到自己操作结果的功能才需要立即在界面上反馈其余静态数据的刷新频次都定了就行。后端为这个模块提供的核心接口大概是发帖、删除自己的帖子、分页获取帖子列表、获取帖子详情包含评论列表、发布评论、点赞/取消点赞。帖子列表页通过 MyBatis 的分页插件做分页点赞量的更新用 SQL 自增语句UPDATE post SET like_count like_count 1 WHERE id ?比其他方式先查后更更高效避免两个并发请求拿到同一个like_count值做覆盖更新。6. 前后端联调与常见问题排查实录6.1 跨域问题的排查与解决前后端分离开发中最常见的一个问题就是跨域。我在本地开发时通过 Vite 代理解决但前端部署在 Nginx、后端部署在不同域名或端口时跨域问题会重新出现。先说排查思路。如果你在浏览器控制台看到Access-Control-Allow-Origin相关的报错说明请求已经发出去了但浏览器拦截了响应。要么在 Nginx 层配置反向代理把/api请求转发到后端服务要么在后端加 CORS 配置。我的建议是优先走 Nginx 反向代理这样后端的接口保持纯净不用加额外的 CORS 头配置。Nginx 配置示例server { listen 80; server_name exam.example.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;这部分必须写因为 Vue3 是单页应用前端路由模式用的是 history 模式createWebHistory如果用户直接在浏览器地址栏输入http://exam.example.com/exam/list这样的路径Nginx 找不到对应的物理文件就会返回 404。添加try_files后Nginx 会把所有找不到的路径回退到index.html交给 Vue Router 去处理路由。6.2 考试过程中接口超时或网络异常的处理考试过程中出现网络异常对考生来说会心慌对开发者来说测试时要当回事。我在系统里对这类问题做了两层处理第一层是自动保存机制每 30 秒把已作答的内容回调到草稿接口这个接口的失败不会影响当前答题只会在控制台打日志第二层是交卷时的兜底处理如果交卷请求因为网络问题失败前端弹窗提示“提交失败请检查网络后重试”同时保留本地答题状态考生可以再次点击交卷。这里有一个很好的实践前端在后端接口请求失败时针对考试模块单独封装一个自动重试逻辑用指数退避的方式重试两次每次间隔 1 秒和 2 秒如果还是失败才提示用户。考虑到考试交卷是一个高频且关键的操作做个简单重试能显著降低因为网络瞬断导致的交卷失败概率。不过要注意重试必须配合后端幂等性设计否则可能造成重复交卷。上面的status判断就是幂等的保障。6.3 高频问题速查表我把这套系统开发和联调过程中最常遇到的几个问题整理成一个速查表方便你参考。问题现象可能原因排查与解决启动报Failed to configure a DataSource数据库连接参数配置错误或相关依赖缺失检查 application.yml 中 url、用户名、密码是否正确确认mysql-connector-j依赖已引入接口返回时间字段格式不对Jackson 没有配置日期格式在 yml 中配置spring.jackson.date-format和time-zoneMyBatis 查询报Invalid bound statement (not found)Mapper XML 文件位置不正确或 namespace 写错确认 XML 文件放在mapper-locations指定的目录下namespace 必须是 Mapper 接口的全限定名分页数据不对或没有分页startPage 与查询方法之间夹了其他 SQL确保 startPage 方法后直接调用 Mapper 的查询方法中间不夹杂其他数据库操作考试交卷后分数没算出来交卷事务中算分逻辑异常导致回滚查看后端日志检查算分代码里是否有空指针或集合操作异常前端拿到 403 或 401JWT 过期或角色权限不够检查 token 是否过期请求头是否正确携带 Authorization角色是否匹配接口要求的权限Vue 项目运行时路由刷新 404Nginx 没有配 try_files在 Nginx 的 location / 中添加try_files $uri $uri/ /index.html;多选时漏选却被判正确多选判断逻辑没有做数量校验检查集合 size 判断是否缺失务必先判断数量相等再做差集MySQL 中文字符显示乱码数据库连接 URL 缺少编码参数或表字符集不是 utf8mb4url 加characterEncodingutf8建表语句字符集设为utf8mb46.4 MyBatis SQL 执行慢的排查思路MyBatis 执行慢的问题我在开发中遇到好几回。最典型的场景是数据量上去以后考试记录列表页打开要等 4~5 秒后台管理的用户查询也卡。排查步骤我建议按顺序做。第一步开启 MyBatis 的 SQL 日志看具体是哪条 SQL 执行的 time 明显偏高第二步把这条 SQL 复制到 MySQL 客户端工具里用EXPLAIN看执行计划观察type是否为ALL全表扫描、rows是否扫描了大量行第三步根据执行计划的结果对涉及的条件字段加索引。比如exam_record表经常按user_id查某用户的考试记录那user_id字段必须有索引按status查待批改的考试记录那status也要加索引。加索引的 SQL 很简单ALTER TABLE exam_record ADD INDEX idx_status (status);不过要提醒一点status这种字段的区分度很低往往就 0、1、2 三种值即使加了索引MySQL 优化器也可能觉得走全表扫描更快而放弃索引。所以更实用的是组合索引比如经常按“状态 创建时间”筛选那就建INDEX idx_status_create_time (status, create_time)。考试列表页大多是按创建时间倒序这个组合索引能让排序也走索引避免文件排序。7. 部署上线与安全加固实践7.1 SpringBoot 后端打包与部署后端部署我采用最常规的方式Maven 打包成 jar 包放在服务器上通过java -jar启动。打包命令mvn clean package -DskipTests打包后在target目录下生成exam-system-1.0.0.jar上传到服务器用nohup java -jar exam-system-1.0.0.jar app.log 21 启动。如果想让服务随系统重启自动拉起可以用 systemd 写一个服务文件这是生产环境更规范的做法。开发环境直连 MySQL 的配置在高并发场景下有问题但初期项目用连接池足够。我这里用 HikariCPSpringBoot 默认作为连接池在application.yml中显式配置一下连接池参数避免默认参数不适合生产场景spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000这个配置的意思是连接池最多保持 20 个连接空闲时至少保留 5 个。设置maximum-pool-size不要盲目调大连接数不是越多越好每个连接都占用 MySQL 的服务端资源太高会导致数据库性能和稳定性下降。7.2 数据库初始化与数据迁移策略项目首次部署时数据库初始化是个基本功。我习惯用两种方式结合开发阶段用spring.sql.init或者直接执行 SQL 脚本建表生产环境则用 Flyway 做版本管理。考试系统这种表结构变化不频繁的项目用 Flyway 有点杀鸡用牛刀但如果你预期后续会增加字段、增加表那 Flyway 的价值就体现出来了它能保证每个环境的表结构版本一致避免“本地能跑上线就报字段不存在”的经典问题。如果不用 Flyway至少要做到把建表 SQL 脚本纳入代码仓库统一管理。我在项目的sql目录下放了初始化脚本和增量脚本两种文件init.sql负责建所有表和初始化角色数据upgrade_1.1.sql这类文件记录后续表结构调整。每个版本的脚本都有编号说明避免团队成员各改各的导致数据库对不上。7.3 安全防护密码加密、SQL 注入与 XSS题目热词里有人搜了“SpringBoot 项目全局过滤器处理上传 pdf 文件时 xss 攻击”说明实际开发中对安全细节的重视度越来越高了。考试系统的安全层面我至少做了三件事第一密码加密。用户密码坚决不能明文存储用 BCrypt 加密。Spring Security 自带的BCryptPasswordEncoder就够用加密后的密文每次随机加盐就算两个用户密码相同密文也不同。登录校验用matches方法做比对。BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); String encodedPassword encoder.encode(rawPassword); boolean match encoder.matches(rawPassword, encodedPassword);第二SQL 注入防护。MyBatis 的#{}占位符会在预编译阶段生成?占位由数据库驱动做参数绑定天然免疫 SQL 注入。但如果你用了${}做字符串拼接那就有注入风险。考试系统的排序字段、动态表名这种没法用占位符的地方我建议用白名单校验把传入的字段名和一组预定义的合法字段做比对不匹配就用默认值。第三XSS 攻击防护。前端提交的帖子内容、评论内容都可能包含script标签。我做了两层防护前端在渲染帖子内容时不用v-html直接渲染而是转义后展示后端在接收请求参数时用一个全局过滤器把script、javascript:等危险关键字转义成 HTML 实体。如果你的系统要支持用户上传 PDF 或者图片对文件类型、后缀和内容要做双重校验不能只信任前端传过来的Content-Type。个人体会是安全这块不用求大而全但基础面要在项目初期就铺好。等系统上线被扫出来漏洞再打补丁影响面就大了。7.4 前后端可观测性与日常日志最后补一个运维视角。开发期大家都不在意日志但项目上线后日志几乎是你排查问题唯一的线索。我在代码里定了规范Service 层记录业务日志谁在什么时候做了什么事Controller 层只做参数透传不记冗余日志所有异常在全局异常处理器里统一捕获记录错误堆栈。全局异常处理器的结构大致是这样定义一个RestControllerAdvice在ExceptionHandler方法中根据异常类型返回统一的 JSON 格式结果。业务异常返回业务错误码和提示信息系统异常返回通用“系统繁忙”提示同时把详细堆栈打日志。这样做的好处是前端统一封装了code/message/data数据结构在message非空时可以直接弹给用户看不用每个接口单独处理错误分支。另外考试的高频操作交卷、开始考试我会额外记录耗时日志方便后续做性能分析。如果某个步骤平均耗时超过阈值就说明该优化查 SQL 或加缓存了。8. 项目扩展方向与个人经验总结8.1 从考试系统到在线教育平台的可扩展设计这套系统做完以后往上加东西的空间其实是很大的。如果你后续想把它扩展成一个更完整的在线教育平台我建议优先考虑两个方向。第一个方向是人脸识别防作弊。现在的在线考试最大的痛点之一就是“坐在屏幕前的到底是不是本人”。进阶思路是在开考前做实时人脸采集和用户上传的证件照片做比对考试过程中每隔一段时间抓拍一次通过后端服务检测是否出现人脸缺失或多人同框的情况。这个功能在目前的云服务商产品里都有现成 API 可以接入不需要自己训练模型。第二个方向是智能组卷。现在的做法是教师在后台手动选题组卷但如果题库里题目多了手动组卷效率就很低。可以做一个小型算法根据知识点范围、题型均衡、难度分布三个维度从题库中自动抽取题目生成试卷。这个需求本质就是一个带约束的随机抽样问题比很多人想象的简单用贪心策略就能出到七十分的效果。8.2 常见扩展功能建议除了上面两个大方向还有几个轻量级扩展点增加 Excel 批量导入导出试题功能教师能在本地用 Excel 维护题库再批量上传。对接消息通知考试发布后给符合条件的学生发送站内信或者邮件提醒。增加成绩报表功能按班级、按试卷维度统计平均分、最高分、及格率导出 PDF 或 Excel 报表。引入 Redis 存储考试过程中的临时数据比如当前在线考试人数、排行榜减轻数据库压力。扩展时有一个经验要记住不管加什么功能都要先评估表结构能不能支撑。建表时预留的扩展字段有限但表之间的关联设计如果足够规范新增功能通常不需要大改表结构加几张新表就行。8.3 我做完这个项目的几点体会项目做到最后真正留下来的不只是可以运行的代码而是对整套业务逻辑的清晰认知。我最初写考试模块时觉得“开始考试、答题、交卷、算分”四个步骤很简单深入设计后才发现每一步都有很多边界情况交卷时网络断了怎么办、题目切太快还没保存怎么办、多选漏选了但答案字段暂时为空怎么办、教师修改已经发布试卷的题目答案之后考生成绩怎么处理。这些实际问题不会出现在教科书里只有你在亲自做项目、测试过程中才可能碰到。所以最后想给正在看这篇内容的朋友一个建议如果你准备用这套技术栈做类似系统不用把它想成是“抄一套代码”而是把它当成一个完整的业务案例去理解。先理清表结构关系再看后端服务如何组织事务和权限然后看前端如何承接状态和时间交互最后再动手去改一个按钮、加一个字段已经足够了。等你能独立地把一个考试流程从点击“开始考试”走到成绩单展示这套系统的价值你就真的拿到了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。