简介:这份基于JSP+SSM+MySQL的在线考试系统,是一套面向高校教学、培训机构及个人开发者学习Java Web综合项目开发的完整工程。系统覆盖管理员出题、批量导入、自动组卷、学生在线考试、自动批改与成绩管理等核心环节,适合需要完成毕业设计或想了解SSM整合流程的初级、中级学习者作为参考与二次开发蓝本。资源包共706个文件,约59.29MB,包含jsp页面、java源码、class编译文件、xml配置、SQL数据库脚本及jar依赖库等,结构上兼顾前端展示、后端逻辑与数据存储,并附带运行环境说明和导入文件示例,便于快速部署调试。已有187人学习下载。压缩包内同时提供系统运行截图和图片素材,让使用者能直观对照界面理解各功能模块的实现方式,是一份兼具教学与实战价值的完整项目资源。
1. 解压后先别急着导库:那堆 .ashx 和 .class 透露了这套 JSP+SSM+MySQL 在线考试系统的真实结构
解压一个 JSP+SSM+MySQL 在线考试系统的压缩包,最先跳出来的往往不是 Java 代码,而是一堆让人犯嘀咕的文件:file_manager_json.ashx、upload_json.ashx、UpLoad_Class.asp、JSON_2.0.4.asp,还有 StudentInfoHandler.class、SubjectInfoHandler.class 两个孤零零的字节码。第一次拿到这类包的人容易当场怀疑自己下错了东西,明明是 Java 项目,怎么混进了 ASP 和 .NET 的处理程序。
这些是历史包袱,不是核心。.ashx 和 .asp 基本是富文本编辑器发布包里自带的 ASP/ASP.NET 版本服务端示例,Java 版走的是另一套 controller 和 config.json;那两个 .class 是编译产物,项目源码目录里能找到同名 .java,改代码时要认源码不认字节码。真正决定这套系统能不能跑起来、能不能改得动的,只有三件事:SSM 三大件在 web.xml 和 XML 配置里怎么接线、MySQL 的题库表怎么设计、交卷之后判分这条链路怎么写。后面几章按这三条线拆,顺带把部署阶段最容易卡住的几个坑提前讲掉。
2. SSM 三个框架在 XML 里怎么接线:web.xml、Spring 容器与 MyBatis 映射
2.1 两个容器的边界:ContextLoaderListener 与 DispatcherServlet
SSM 项目最常见的启动失败,不是代码写错了,是容器分不清。web.xml 里 ContextLoaderListener 负责读 applicationContext.xml,建一个 root 容器,标准做法是把 service、dao、数据源、事务管理器全放进去;DispatcherServlet 读 spring-mvc.xml,建一个子容器,只放 controller、视图解析器、拦截器。子容器能拿到父容器的 bean,父容器拿不到子容器的 bean。
这个方向性决定了 @Transactional 必须挂在 service 上,而且 service 必须被 root 容器扫描到。如果图省事在 spring-mvc.xml 里写了<context:component-scan base-package="com.exam"/>,controller 和 service 会被子容器重复扫描一遍,事务代理挂在子容器的 service 上,你从别处注入的却是父容器那个没有代理的原始对象,表现就是注解写了但回滚不生效。
<!-- web.xml 关键片段:两个容器分开配置 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>springmvc</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>springmvc</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>load-on-startup设成 1 是让 DispatcherServlet 随容器启动就初始化,而不是等第一个请求来了才初始化。好处是配置错误在启动日志里就暴露,不用等到点页面才发现 500。
<url-pattern>/</url-pattern>这个写法要留意,它接管了所有未匹配的请求,包括 .css、.js、图片。JSP 页面一旦样式全丢,十有八九是这里没配静态资源放行:
<!-- spring-mvc.xml 中放行静态资源 --> <mvc:default-servlet-handler/> <mvc:resources mapping="/static/**" location="/static/"/>default-servlet-handler是把找不到映射的请求交回容器默认 Servlet 处理,mvc:resources则是显式指定目录,两者选一个即可,同时配也没坏处。
2.2 MyBatis 的接入点:SqlSessionFactoryBean 与 MapperScannerConfigurer
MyBatis 在 SSM 里是被人托管的,托管方是 spring-mybatis 那个桥接包。核心就两个 bean:SqlSessionFactoryBean 负责把数据源、映射文件、别名包拼成 SqlSessionFactory,MapperScannerConfigurer 负责把 mapper 接口批量注册成可以被 @Autowired 的代理对象。
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.exam.entity"/> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <!-- 数据库的 create_time 自动映射到实体的 createTime --> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.exam.mapper"/> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> </bean>mapUnderscoreToCamelCase这个开关非常值得开。题库表里字段多,subject_type、option_a、create_time 这类下划线命名和 Java 的驼峰命名不做映射就全是 null,查出来的对象一堆字段是空的,还不会报错,排查起来很费时间。
MapperScannerConfigurer 这里用的是sqlSessionFactoryBeanName而不是ref。原因是 MapperScannerConfigurer 属于 BeanDefinitionRegistryPostProcessor,执行时机比普通 bean 早,用 ref 会提前触发 SqlSessionFactory 初始化,导致${jdbc.url}这类占位符还没被解析就注入进去,报错信息通常是 Could not resolve placeholder。改成 BeanName 就是延迟查找,绕开这个时序问题。
| 配置项 | 作用范围 | 写错的典型症状 |
|---|---|---|
| contextConfigLocation(listener) | root 容器 | service/dao 注入失败,NoSuchBeanDefinitionException |
| contextConfigLocation(servlet) | mvc 子容器 | 视图 404,controller 无法映射 |
| mapUnderscoreToCamelCase | 结果集映射 | 实体字段大量为 null,但不报错 |
| mvc:default-servlet-handler | 静态资源 | 页面样式、JS 全部 404 |
| MapperScannerConfigurer | mapper 接口代理 | 注入 mapper 时提示找不到 bean |
2.3 编码过滤器、视图解析器与登录拦截器
中文乱码是老问题,但在这个系统里出现的地方不止一处。请求参数乱码靠 CharacterEncodingFilter,响应乱码靠 JSP 页面的 pageEncoding,数据库乱码靠连接串里的 characterEncoding 和表字符集,三处缺一不可。
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <!-- forceEncoding 同时设置 request 和 response 的编码 --> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>过滤器要放在所有 filter 最前面,特别是要在任何读取过参数的 filter 之前,否则参数已经被按默认编码解析完了,后面再设也没用。
视图解析器把 controller 返回的字符串拼成 JSP 路径,这样 controller 里只写return "student/exam":
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean>/WEB-INF/这个前缀顺带起到保护作用,学生直接敲 URL 访问不到 JSP,必须经过 controller。登录拦截器则拦在 controller 前面,把未登录请求挡回去:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { // 不用 getSession(),避免为未登录用户凭空创建 session HttpSession session = req.getSession(false); if (session == null || session.getAttribute("loginStudent") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return false; } return true; } }getSession(false)和getSession()的差别在这里很实际:前者没有就返回 null,后者没有就新建。考试系统的并发登录数不小,用后者会给每一个爬虫或误访问请求都建一个 session,白白吃内存。
3. MySQL 题库设计:题目表结构、批量导入与随机组卷的 SQL 取舍
3.1 题型怎么落表:字段设计中的几个决定
题库表是整个系统的地基,设计时最纠结的是选项怎么存。三种做法:每种题型一张表、选项拆成独立行、选项做成固定列。这套系统属于中小规模,常见做法是三选项列都塞进一张表,用 subject_type 区分题型。好处是一条 SQL 就能按条件抽题,坏处是不支持选项数量不定的题型。考试场景题型固定为单选、多选、判断、简答,这个约束可以接受。
CREATE TABLE t_subject ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, subject_type TINYINT NOT NULL DEFAULT 1 COMMENT '1单选 2多选 3判断 4简答', difficulty TINYINT NOT NULL DEFAULT 2 COMMENT '1易 2中 3难', content VARCHAR(500) NOT NULL COMMENT '题干', option_a VARCHAR(255) DEFAULT NULL, option_b VARCHAR(255) DEFAULT NULL, option_c VARCHAR(255) DEFAULT NULL, option_d VARCHAR(255) DEFAULT NULL, answer VARCHAR(20) NOT NULL COMMENT '多选按字母升序存,如 ABD', score INT NOT NULL DEFAULT 2, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_type_diff (subject_type, difficulty) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;字符集用 utf8mb4 而不是 utf8,是因为 MySQL 的 utf8 实际只支持三字节,题干里出现某些特殊符号或生僻字会直接插入失败或截断。这个坑在录入数学符号时特别容易撞上。
多选答案约定按字母升序存,是为了判分时只做一次字符串比较。如果库里存 ABC、学生提交 CBA,直接 equals 会判错。索引 idx_type_diff 建在 (subject_type, difficulty) 上,因为抽题条件永远是这两个字段的组合,单建两个索引反而会让优化器选错,这里遵循最左前缀就够了。
3.2 批量导入试题:LOAD DATA 与 MyBatis foreach 批处理
管理员一次要灌几百道题,逐条 insert 太慢。两条路可选:走 MySQL 原生命令,或走应用层批量 insert。
-- 方式一:服务端文件导入,速度最快 LOAD DATA LOCAL INFILE '/tmp/subject.csv' INTO TABLE t_subject CHARACTER SET utf8mb4 FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' LINES TERMINATED BY '\n' IGNORE 1 LINES (subject_type, difficulty, content, option_a, option_b, option_c, option_d, answer, score);LOAD DATA LOCAL INFILE的 LOCAL 关键字表示文件在客户端,不在数据库服务器上。它需要在服务端开启local_infile=1,客户端连接串也要带allowLoadLocalInfile=true,两处任一没开都会报错。另外IGNORE 1 LINES跳过 CSV 表头,OPTIONALLY ENCLOSED BY '"'处理题干里带逗号的情况。这套导入方式适合运维批量灌数据,不适合放进后台管理页面。
后台页面上传更常用的是 MyBatis 的 foreach 拼批量 insert:
<insert id="batchInsert" parameterType="java.util.List"> INSERT INTO t_subject (subject_type, difficulty, content, option_a, option_b, option_c, option_d, answer, score) VALUES <foreach collection="list" item="it" separator=","> (#{it.subjectType}, #{it.difficulty}, #{it.content}, #{it.optionA}, #{it.optionB}, #{it.optionC}, #{it.optionD}, #{it.answer}, #{it.score}) </foreach> </insert>这个写法拼出来的是一条长 SQL,性能比逐条提交高一个数量级,但受max_allowed_packet限制,默认 4MB 到 64MB 不等。一条题干按 500 字节估,一千条大约 0.5MB,分批 500 到 1000 条比较稳。分批是在 Java 层用ListUtils.partition这类工具切,不要一次扔三万条进去,报错信息会是 Packet for query is too large。
重复导入的防护靠内容去重。给 content 加一个唯一索引不太现实,题干可能很长且会重复出现,常见做法是额外加一个content_md5 CHAR(32)列并建唯一索引,配合INSERT IGNORE或ON DUPLICATE KEY UPDATE实现幂等导入。这样管理员误点两次导入不会产生重复题。
3.3 随机组卷:ORDER BY RAND() 的代价与替代写法
最直觉的抽题 SQL 是这样:
-- 有性能隐患:全表扫描 + 临时表排序 SELECT id, content, option_a, option_b, option_c, option_d FROM t_subject WHERE subject_type = 1 ORDER BY RAND() LIMIT 10;RAND() 是逐行调用的,MySQL 会对满足条件的每一行算一次随机数,然后整体排序取前十。题库一万条时还能忍,到十万条就是秒级响应。考试开始时的抽题是并发高峰,这个写法会成为瓶颈。
替代方案有两种。SQL 层用 id 区间近似随机:
SELECT id FROM t_subject WHERE subject_type = 1 AND id >= FLOOR(RAND() * (SELECT MAX(id) FROM t_subject WHERE subject_type = 1)) ORDER BY id LIMIT 10;这个写法只扫一个区间,速度很快,缺点是 id 有空洞时抽出的题目分布不均,且一次可能凑不满 10 条,需要循环补齐。
更稳的做法是把随机逻辑提到应用层。启动时或首次访问时把全量题目 id 按题型缓存,抽题时Collections.shuffle取前 N,再按 id 批量查详情:
// ids 为某题型的全量 id 列表,已缓存在内存 List<Integer> picked = new ArrayList<>(ids); Collections.shuffle(picked); List<Integer> selected = picked.subList(0, Math.min(count, picked.size())); return subjectMapper.listByIds(selected);按难度分布组卷就是分三组各抽若干条,再合并。listByIds用 foreach 拼IN条件即可。
组卷完成后必须做一件事:把抽中的 subject_id、分值和正确答案快照写进试卷关联表 t_paper_subject。考试期间管理员如果修改或删除某道题,判分依据仍然来自快照,不会出现试卷上显示的是旧题干、判分用的是新答案这种错乱。
4. 考试主链路实现:Session 会话、答题提交与多选自动判分
4.1 登录、Session 与密码存储
登录逻辑本身简单,但密码存储方式要定清楚。用明文或单次 MD5 都存在风险,因为 MD5 彩虹表早就公开了。最低标准是加盐后再做多轮摘要,条件允许直接上 BCrypt。
// 加盐摘要:salt 存在用户表里,每个用户独立 String raw = password + user.getSalt(); String encoded = DigestUtils.md5Hex(raw.getBytes(StandardCharsets.UTF_8)); if (!encoded.equals(user.getPassword())) { return Result.fail("账号或密码错误"); } // 登录成功后只把身份标识放进 session,不要放整个用户对象 session.setAttribute("loginStudent", user.getId()); session.setAttribute("studentName", user.getName());登录失败提示统一写成「账号或密码错误」,不要区分账号不存在和密码错误,避免被用来枚举账号。session 里只放必要字段,用户对象里通常带密码字段,整存整取容易在日志或序列化环节泄露。
4.2 开始考试:创建考试记录与防重复开考
点「开始考试」时落一条考试记录,拿到主键作为本次考试的唯一标识,把它写进 session。答题过程不落库,只在交卷时一次性写入,减少数据库压力。
INSERT INTO t_exam_record (student_id, paper_id, start_time, status) VALUES (#{studentId}, #{paperId}, NOW(), 0);status 用 0 表示进行中,1 表示已交卷。开考前先查一次该学生该试卷是否有 status=0 的记录,有则复用,避免刷新页面就多出一条记录。考试时长必须以服务端时间为准:start_time + duration和NOW()比较,前端倒计时只用来提示,防不住改本地时间或直接调接口。
交卷时要加一道幂等保护:
UPDATE t_exam_record SET end_time = NOW(), score = #{score}, status = 1 WHERE id = #{recordId} AND status = 0;status = 0这个条件让第二次提交影响行数为 0,Java 层判断返回值,为 0 就直接返回「请勿重复提交」。这比先查后改更可靠,因为查和改之间存在竞态窗口,用户连点两次提交按钮就可能双写。
4.3 自动判分:题型分支与多选的排序处理
判分的入口是交卷接口,前端把答案组织成subjectId -> 用户答案的映射提交。Controller 只做参数接收和结果包装,判分逻辑放 service。
@Transactional(rollbackFor = Exception.class) public int submit(Integer recordId, Map<Integer, String> answerMap) { List<PaperSubject> items = paperSubjectMapper.listByRecord(recordId); int total = 0; for (PaperSubject item : items) { String userAns = answerMap.get(item.getSubjectId()); if (userAns == null || userAns.trim().isEmpty()) { continue; // 未作答按 0 分处理,不累加 } String std = item.getCorrectAnswer(); if (item.getSubjectType() == 2) { // 多选:先排序归一化再比较 userAns = normalize(userAns); std = normalize(std); } if (std.equalsIgnoreCase(userAns.trim())) { total += item.getScore(); } } int rows = examRecordMapper.finish(recordId, total); if (rows == 0) { throw new IllegalStateException("答卷状态异常,可能已重复提交"); } return total; } private String normalize(String s) { char[] cs = s.toUpperCase().replaceAll("[^A-Z]", "").toCharArray(); java.util.Arrays.sort(cs); return new String(cs); }@Transactional(rollbackFor = Exception.class)里的 rollbackFor 不能省。Spring 默认只在运行时异常时回滚,检查型异常不回滚。这里主动抛的是 IllegalStateException,属于运行时异常,但养成写全的习惯能避免以后改成自定义检查型异常时踩坑。
判分范围要提前想清楚,不同题型的处理方式差别很大:
| 题型 | subject_type | 判分方式 | 边界情况 |
|---|---|---|---|
| 单选 | 1 | 去空格后忽略大小写比较 | 学生提交多字母按错误处理 |
| 多选 | 2 | 字母升序归一化后比较 | 少选、多选一律不给分 |
| 判断 | 3 | 统一成 T/F 或 1/0 后比较 | 前端传「对/错」需先转换 |
| 简答 | 4 | 不自动判分,写待批阅标记 | 成绩总分需在批阅后重算 |
多选的给分策略值得单独定:是「全对才给分」还是「少选给一半」。这套系统按全对处理,实现最简单,也最容易向学生解释。如果要做部分给分,需要在 PaperSubject 里记下正确选项个数,按交集数量折算,逻辑复杂度会明显上升。
简答题不能自动判分,交卷时把它标记为待批阅,总分只统计客观题部分。管理员的批阅页面按 status 筛出未批阅的记录,逐条打分后重算总分。这一步如果不做成独立状态,成绩会出现「已出分但实际未批完」的误导。
5. 部署与排错:Tomcat 下 JSP 编译产物定位、上传配置与残留文件清理
系统跑不起来时,最有价值的动作是找到 JSP 编译后的产物,看异常堆栈里的行号和你的源码对不对得上。Tomcat 把 JSP 编译成 Servlet 后放在工作目录:
# 假设应用部署路径为 /opt/tomcat/webapps/exam ls /opt/tomcat/work/Catalina/localhost/exam/org/apache/jsp/ # 例如输出:login_jsp.class student/exam_jsp.class默认只留 .class,想看对应的 .java 需要开 keepgenerated。在conf/web.xml的 JspServlet 里加初始化参数:
<servlet> <servlet-name>jsp</servlet-name> <servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class> <init-param> <param-name>keepgenerated</param-name> <param-value>true</param-value> </init-param> <load-on-startup>3</load-on-startup> </servlet>改完后清掉 work 目录重启,.java会和.class一起生成。页面报 NullPointerException 但你又找不到自己写的对应行时,对着生成的 .java 看行号,通常能立刻定位到是哪个表达式里取了个空对象。
上传相关有三个参数容易漏。Tomcat 的maxPostSize默认 2MB,超过直接拒绝请求且不给你任何业务层日志,图片稍大就上传失败;maxSwallowSize控制服务端能吞掉多少未读请求体,设成 -1 可以避免连接被强制中断;如果是 Servlet 3.0 以上的 multipart 配置,max-file-size和max-request-size都要在 web.xml 里显式给出,只配一个仍然会被截断。
<multipart-config> <max-file-size>5242880</max-file-size> <!-- 单文件 5MB --> <max-request-size>10485760</max-request-size> <!-- 整个请求 10MB --> <file-size-threshold>0</file-size-threshold> </multipart-config>最后处理压缩包里的历史文件。file_manager_json.ashx、upload_json.ashx、UpLoad_Class.asp、JSON_2.0.4.asp 这些都是编辑器附带的 ASP 与 ASP.NET 示例处理程序,Java 版根本不会加载它们,直接删掉不影响任何功能,留着反而会让接手的人在目录里迷路。StudentInfoHandler.class、SubjectInfoHandler.class 是编译产物,如果源码目录里有同名 .java,把 class 删掉重新编译即可;找不到源码的 class 说明是别人本地编译后误打包进去的,也不该留在 web 应用的 classpath 里。编辑器的 Java 版服务端入口通常是 jsp 目录下的 controller 与 config.json,这两个一旦被误删,上传图片会直接返回 404,而后台其他功能看起来一切正常,很容易被误判成权限问题。
本文还有配套的精品资源,点击获取