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-web,SpringBoot 就会自动配好内嵌的 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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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-web(Web 能力)、spring-boot-starter-validation(参数校验)、mybatis-spring-boot-starter(MyBatis 集成)、mysql-connector-j(MySQL 驱动)、lombok(简化实体代码)、spring-boot-starter-security或者jjwt来做登录鉴权、spring-boot-starter-test做单元测试。
这里有个配置层面的注意事项:MyBatis 的mybatis-spring-boot-starter版本要和 SpringBoot 的大版本匹配。SpringBoot 3.x 对应 MyBatis starter 3.x,SpringBoot 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?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/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 XML:
<select id="selectExamRecordPage" resultType="com.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 test="userId != null"> AND er.user_id = #{userId} </if> <if test="status != null"> AND er.status = #{status} </if> <if test="paperName != 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 最典型的应用场景——状态不确定、条件不确定的列表查询,用<where>+<if>的组合能在一个方法里解决所有可能性,并且会自动处理掉第一个条件前的AND关键字,不用手动拼字符串。
再讲一个细节:实际列表往往还需要分页。如果手动用LIMIT控制,每次还得先查 COUNT 再查数据,写起来麻烦。更规范的做法是集成一个分页插件。
3.3 MyBatis 分页插件的用法
分页插件这款工具,在 Java 面试里几乎是必问内容,实际项目里也是高频使用。它是一款基于 MyBatis 拦截器机制实现的物理分页插件,原理是通过拦截Executor的query方法,在 SQL 执行前自动生成COUNT查询和带有LIMIT的分页 SQL。
用法上我总结为三步:
第一步,引入依赖。在pom.xml中加入:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>2.1.0</version> </dependency>第二步,在application.yml中配置:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true第三步,代码里调用:
PageHelper.startPage(pageNum, pageSize); List<ExamRecordVO> list = examRecordMapper.selectExamRecordPage(param); PageInfo<ExamRecordVO> 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,里面用Map<questionId, 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; Set<String> userSet = new HashSet<>(Arrays.asList(userAnswer.split(","))); Set<String> 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-zone |
MyBatis 查询报Invalid bound statement (not found) | Mapper XML 文件位置不正确或 namespace 写错 | 确认 XML 文件放在mapper-locations指定的目录下,namespace 必须是 Mapper 接口的全限定名 |
| 分页数据不对或没有分页 | startPage 与查询方法之间夹了其他 SQL | 确保 startPage 方法后直接调用 Mapper 的查询方法,中间不夹杂其他数据库操作 |
| 考试交卷后分数没算出来 | 交卷事务中算分逻辑异常导致回滚 | 查看后端日志,检查算分代码里是否有空指针或集合操作异常 |
| 前端拿到 403 或 401 | JWT 过期或角色权限不够 | 检查 token 是否过期,请求头是否正确携带 Authorization,角色是否匹配接口要求的权限 |
| Vue 项目运行时路由刷新 404 | Nginx 没有配 try_files | 在 Nginx 的 location / 中添加try_files $uri $uri/ /index.html; |
| 多选时漏选却被判正确 | 多选判断逻辑没有做数量校验 | 检查集合 size 判断是否缺失,务必先判断数量相等再做差集 |
| MySQL 中文字符显示乱码 | 数据库连接 URL 缺少编码参数或表字符集不是 utf8mb4 | url 加characterEncoding=utf8,建表语句字符集设为utf8mb4 |
6.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 2>&1 &启动。如果想让服务随系统重启自动拉起,可以用 systemd 写一个服务文件,这是生产环境更规范的做法。
开发环境直连 MySQL 的配置在高并发场景下有问题,但初期项目用连接池足够。我这里用 HikariCP(SpringBoot 默认)作为连接池,在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 我做完这个项目的几点体会
项目做到最后,真正留下来的不只是可以运行的代码,而是对整套业务逻辑的清晰认知。我最初写考试模块时,觉得“开始考试、答题、交卷、算分”四个步骤很简单,深入设计后才发现每一步都有很多边界情况:交卷时网络断了怎么办、题目切太快还没保存怎么办、多选漏选了但答案字段暂时为空怎么办、教师修改已经发布试卷的题目答案之后考生成绩怎么处理。
这些实际问题不会出现在教科书里,只有你在亲自做项目、测试过程中才可能碰到。所以最后想给正在看这篇内容的朋友一个建议:如果你准备用这套技术栈做类似系统,不用把它想成是“抄一套代码”,而是把它当成一个完整的业务案例去理解。先理清表结构关系,再看后端服务如何组织事务和权限,然后看前端如何承接状态和时间交互,最后再动手去改一个按钮、加一个字段,已经足够了。等你能独立地把一个考试流程从点击“开始考试”走到成绩单展示,这套系统的价值你就真的拿到了。