JSP+SSM+MySQL在线考试系统:从XML配置到自动判分的工程实践

发布时间:2026/9/14 4:44:43
JSP+SSM+MySQL在线考试系统:从XML配置到自动判分的工程实践 简介这份基于JSPSSMMySQL的在线考试系统是一套面向高校教学、培训机构及个人开发者学习Java Web综合项目开发的完整工程。系统覆盖管理员出题、批量导入、自动组卷、学生在线考试、自动批改与成绩管理等核心环节适合需要完成毕业设计或想了解SSM整合流程的初级、中级学习者作为参考与二次开发蓝本。资源包共706个文件约59.29MB包含jsp页面、java源码、class编译文件、xml配置、SQL数据库脚本及jar依赖库等结构上兼顾前端展示、后端逻辑与数据存储并附带运行环境说明和导入文件示例便于快速部署调试。已有187人学习下载。压缩包内同时提供系统运行截图和图片素材让使用者能直观对照界面理解各功能模块的实现方式是一份兼具教学与实战价值的完整项目资源。1. 解压后先别急着导库那堆 .ashx 和 .class 透露了这套 JSPSSMMySQL 在线考试系统的真实结构解压一个 JSPSSMMySQL 在线考试系统的压缩包最先跳出来的往往不是 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 与 DispatcherServletSSM 项目最常见的启动失败不是代码写错了是容器分不清。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-packagecom.exam/controller 和 service 会被子容器重复扫描一遍事务代理挂在子容器的 service 上你从别处注入的却是父容器那个没有代理的原始对象表现就是注解写了但回滚不生效。!-- web.xml 关键片段两个容器分开配置 -- context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namespringmvc/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namespringmvc/servlet-name url-pattern//url-pattern /servlet-mappingload-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 与 MapperScannerConfigurerMyBatis 在 SSM 里是被人托管的托管方是 spring-mybatis 那个桥接包。核心就两个 beanSqlSessionFactoryBean 负责把数据源、映射文件、别名包拼成 SqlSessionFactoryMapperScannerConfigurer 负责把 mapper 接口批量注册成可以被 Autowired 的代理对象。bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.exam.entity/ property nameconfiguration bean classorg.apache.ibatis.session.Configuration !-- 数据库的 create_time 自动映射到实体的 createTime -- property namemapUnderscoreToCamelCase valuetrue/ /bean /property /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.exam.mapper/ property namesqlSessionFactoryBeanName valuesqlSessionFactory/ /beanmapUnderscoreToCamelCase这个开关非常值得开。题库表里字段多subject_type、option_a、create_time 这类下划线命名和 Java 的驼峰命名不做映射就全是 null查出来的对象一堆字段是空的还不会报错排查起来很费时间。MapperScannerConfigurer 这里用的是sqlSessionFactoryBeanName而不是ref。原因是 MapperScannerConfigurer 属于 BeanDefinitionRegistryPostProcessor执行时机比普通 bean 早用 ref 会提前触发 SqlSessionFactory 初始化导致${jdbc.url}这类占位符还没被解析就注入进去报错信息通常是 Could not resolve placeholder。改成 BeanName 就是延迟查找绕开这个时序问题。配置项作用范围写错的典型症状contextConfigLocationlistenerroot 容器service/dao 注入失败NoSuchBeanDefinitionExceptioncontextConfigLocationservletmvc 子容器视图 404controller 无法映射mapUnderscoreToCamelCase结果集映射实体字段大量为 null但不报错mvc:default-servlet-handler静态资源页面样式、JS 全部 404MapperScannerConfigurermapper 接口代理注入 mapper 时提示找不到 bean2.3 编码过滤器、视图解析器与登录拦截器中文乱码是老问题但在这个系统里出现的地方不止一处。请求参数乱码靠 CharacterEncodingFilter响应乱码靠 JSP 页面的 pageEncoding数据库乱码靠连接串里的 characterEncoding 和表字符集三处缺一不可。filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param !-- forceEncoding 同时设置 request 和 response 的编码 -- init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping过滤器要放在所有 filter 最前面特别是要在任何读取过参数的 filter 之前否则参数已经被按默认编码解析完了后面再设也没用。视图解析器把 controller 返回的字符串拼成 JSP 路径这样 controller 里只写return student/exambean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp// property namesuffix 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_infile1客户端连接串也要带allowLoadLocalInfiletrue两处任一没开都会报错。另外IGNORE 1 LINES跳过 CSV 表头OPTIONALLY ENCLOSED BY 处理题干里带逗号的情况。这套导入方式适合运维批量灌数据不适合放进后台管理页面。后台页面上传更常用的是 MyBatis 的 foreach 拼批量 insertinsert idbatchInsert parameterTypejava.util.List INSERT INTO t_subject (subject_type, difficulty, content, option_a, option_b, option_c, option_d, answer, score) VALUES foreach collectionlist itemit 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 列表已缓存在内存 ListInteger picked new ArrayList(ids); Collections.shuffle(picked); ListInteger 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 表示已交卷。开考前先查一次该学生该试卷是否有 status0 的记录有则复用避免刷新页面就多出一条记录。考试时长必须以服务端时间为准start_time duration和NOW()比较前端倒计时只用来提示防不住改本地时间或直接调接口。交卷时要加一道幂等保护UPDATE t_exam_record SET end_time NOW(), score #{score}, status 1 WHERE id #{recordId} AND status 0;status 0这个条件让第二次提交影响行数为 0Java 层判断返回值为 0 就直接返回「请勿重复提交」。这比先查后改更可靠因为查和改之间存在竞态窗口用户连点两次提交按钮就可能双写。4.3 自动判分题型分支与多选的排序处理判分的入口是交卷接口前端把答案组织成subjectId - 用户答案的映射提交。Controller 只做参数接收和结果包装判分逻辑放 service。Transactional(rollbackFor Exception.class) public int submit(Integer recordId, MapInteger, String answerMap) { ListPaperSubject 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-namejsp/servlet-name servlet-classorg.apache.jasper.servlet.JspServlet/servlet-class init-param param-namekeepgenerated/param-name param-valuetrue/param-value /init-param load-on-startup3/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-size5242880/max-file-size !-- 单文件 5MB -- max-request-size10485760/max-request-size !-- 整个请求 10MB -- file-size-threshold0/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而后台其他功能看起来一切正常很容易被误判成权限问题。本文还有配套的精品资源点击获取