news 2026/10/8 4:28:30

JSP+Struts+Hibernate+Oracle在线考试系统:部署、改造与踩坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JSP+Struts+Hibernate+Oracle在线考试系统:部署、改造与踩坑指南

简介:这是一套基于 Java Web 技术栈实现的通用在线考试系统源码,采用 JSP+Struts+Hibernate+Oracle 组合开发,适合正在学习 SSH 框架整合与 MVC 分层架构的中级 Java 学习者。压缩包共 342 个文件、约 3.1MB,包含 71 个 Java 源码、34 个 JSP 页面、22 个 XML 配置、SQL 脚本及项目文档,代码结构完整。系统涵盖用户注册登录、选择考试、在线作答、提交答案与成绩查询等核心功能,并通过 Session 保存作答状态。资源完整展现了 JSP 负责视图展示、Struts 处理请求分发、Hibernate 完成对象关系映射的核心流程,对于理解 MVC 设计模式、持久层配置及 Oracle 数据库交互具有很好的参考价值。已有 95 人学习,适合作为课程设计或 SSH 框架入门实践项目参考。

1. 通用的在线考试系统:为什么 JSP+Struts+Hibernate+Oracle 这套老组合还值得你接手

拿到“通用的在线考试系统(jsp+struts+hibernate+oracle)130220.zip”这种压缩包,第一反应往往是:这都什么年代了,还在用 JSP 和 Struts?但真进了企业内网或高校机房你会发现,这类系统不仅还在运行,而且“通用”这两个字恰恰是它的价值点——题库、组卷、考试、阅卷四条链路都留了可替换的扩展点。这篇笔记要讲的就是把这套老组合在本地跑通、读懂它的数据流,再按你的业务去改造它。适合刚接手课程设计、毕设或老系统维护的开发者,也适合想快速搭一套考试平台但不想从零写题组逻辑的后端工程师。先别急着换 Spring Boot,把 Struts + Hibernate + Oracle 的协作逻辑吃透,项目才能真正落到你手里。

2. 先看懂这套组合的分工:Struts 管流程,Hibernate 管持久化,Oracle 管数据

总有新人问 Hibernate 还有人用吗——在这类老项目里,你躲不开它,因为业务逻辑全骑在 ORM 映射上。Struts 1.x 负责 HTTP 请求的流转,Hibernate 负责把 Java 对象变成数据库表记录,Oracle 负责最终的数据存储和复杂查询。三个环节如果只理解一个,部署时就会陷入“能启动但点几下就报错”的泥潭。这一章我按一条考试请求的路径把三个层次拆开讲,后面改动时你才知道该去改哪个文件。

2.1 Struts 管流程:Action、ActionForm、struts-config.xml 的三层流转

Struts 1.x 是这套系统的控制器。JSP 只负责展示,表单提交到 ActionServlet,由 struts-config.xml 把请求路径映射到具体的 Action 类。Action 里拿到的 ActionForm 相当于一个绑定好请求参数的对象,比如登录页提交的 username 和 password,框架会自动帮你填进 LoginForm 的属性里。校验通过后,Action 调用业务层,最后返回 ActionForward 跳转到下一个页面。在线考试系统里的登录、组卷、交卷,全都走这条链路。

一个典型的登录 Action 配置长这样:

<struts-config> <form-beans> <form-bean name="loginForm" type="com.exam.form.LoginForm"/> </form-beans> <action-mappings> <action path="/login" type="com.exam.action.LoginAction" name="loginForm" scope="request"> <forward name="success" path="/main.jsp"/> <forward name="error" path="/login.jsp"/> </action> </action-mappings> </struts-config>

这里path是浏览器访问的 URL,和 JSP 里的form action="login.do"对应;type是真正干活的 Action 类;name指向上面定义的 form-bean;scope="request"表示这个表单对象的生命周期只在当前请求里。老项目里经常把 scope 写成 session,结果用户登录后表单数据一直占着内存,这是后来内存溢出的隐患之一。改造时我一般会把只读数据全部收回 request,只有考试中的答案才放 session。

Action 类里的写法是旧式 Struts 风格,类要继承 Action,重写 execute 方法:

public class LoginAction extends Action { public ActionForward execute(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) { LoginForm loginForm = (LoginForm) form; String username = loginForm.getUsername(); String password = loginForm.getPassword(); // 调业务层验证,成功后把用户信息放进 session request.getSession().setAttribute("currentUser", user); return mapping.findForward("success"); } }

看到这段代码你就明白了:Struts 本身不干数据库的活,它只负责“请求进来之后该找谁”,以及“干完之后跳到哪个页面”。如果系统里所有业务逻辑都被塞进 Action,就会变成业界常说的“胖 Action”。改造成通用考试系统时,我的习惯是让 Action 只做参数校验和页面跳转,真正的组卷、判分逻辑放到 Service 类里。

2.2 Hibernate 管持久化:hbm.xml 映射与 Session 的坑

这个项目能被称为“通用”,很大程度是因为 Hibernate 把题、卷、学生、成绩这几个核心对象做成了可脱离数据库表结构的 POJO。老项目里注解还没普及,基本都是.hbm.xml映射文件。一个试题对象的映射长这样:

<hibernate-mapping package="com.exam.po"> <class name="ExamQuestion" table="EXAM_QUESTION"> <id name="id" column="QUESTION_ID"> <generator class="sequence"> <param name="sequence">SEQ_EXAM_QUESTION</param> </generator> </id> <property name="stem" column="QUESTION_STEM" type="string"/> <property name="type" column="QUESTION_TYPE" type="integer"/> <property name="score" column="SCORE" type="float"/> <property name="answer" column="ANSWER" type="string"/> </class> </hibernate-mapping>

这里最需要注意的是<generator class="sequence">。Oracle 没有 MySQL 那样的自增列,主键只能靠序列生成。如果你改成native,Hibernate 会在 Oracle 上自动选 sequence,但选出来的序列名未必是你建好的那个。新人最容易踩的坑就是数据库里明明有表,一保存就报ORA-02289: sequence does not exist。

Hibernate 的 Session 是个短命对象,每次请求最好开关一次,不要全局共享。老项目里常见的写法是:

Session session = HibernateUtil.getCurrentSession(); Transaction tx = session.beginTransaction(); session.save(question); tx.commit();

getCurrentSession和openSession的区别在于,前者绑定了事务和线程,后者每次要手动 close。如果你用openSession又不关,连接池很快被耗尽,Tomcat 在跑两个小时后开始出现“无法获得数据库连接”。我排查过的好几个在线考试系统都是死在这个细节上。

Hibernate 在这里真正省事的是让你用session.get(ExamQuestion.class, id)就能把一条记录变成对象,而不用写一堆 JDBC 模板。但代价是取列表时容易触发 N+1 查询:先查出来 30 道题的 ID,再对每个 ID 查一次详情,页面就会明显卡顿。后面第 4 章组卷时我会给一个避免方式,核心是让 Hibernate 走 join fetch。

2.3 Oracle 存数据:分页、序列与存储过程

Oracle 分页和 MySQL 完全不是一个套路。MySQL 的LIMIT offset, size在 Oracle 里不存在,老项目里手写分页基本是三层嵌套 ROWNUM:

SELECT * FROM ( SELECT t.*, ROWNUM rn FROM ( SELECT q.* FROM EXAM_QUESTION q ORDER BY q.QUESTION_ID ) t WHERE ROWNUM <= ? ) WHERE rn > ?;

第一个参数是页码乘以每页条数,第二个参数是 (页码减一) 乘以每页条数。这段 SQL 的意思是:先按题目 ID 排序,再给排序结果编一个行号,最后截取行号区间。看起来很绕,但这是 Oracle 分页的标准姿势。如果用 Hibernate 的 Oracle10gDialect,它生成的也是这种结构,你只需要配置好hibernate.dialect=org.hibernate.dialect.Oracle10gDialect。

在线考试系统还经常把阅卷统计逻辑写进 Oracle 存储过程。原因是几十个学生同时交卷时,客观题和主观题分数要回写到多张表,用 Java 逐个更新容易出现中间状态。一个简单的存储过程签名大致是:

CREATE OR REPLACE PROCEDURE SP_CALC_EXAM_SCORE( p_exam_id IN NUMBER, p_student_id IN NUMBER, p_score OUT NUMBER ) IS BEGIN -- 更新 EXAM_RECORD 表的总分 UPDATE EXAM_RECORD SET TOTAL_SCORE = p_score WHERE EXAM_ID = p_exam_id AND STUDENT_ID = p_student_id; COMMIT; END;

调用时用CallableStatement注册出参,这是老项目里常见的套路。你在改造成通用考试系统时,如果自己写阅卷逻辑,优先考虑把“一次交卷要改三张表”的动作放进一个事务或存储过程,别在 Java 里拆成三次 update。第 4 章我会再展开这一点。

3. 在本地把系统跑起来:JDK、Tomcat、Oracle 的版本搭配与部署步骤

这类压缩包拿到手,最怕的是在环境上耗掉一个周末。版本不匹配时,Tomcat 能启动,但一访问数据库就抛各种奇怪异常。我这几年处理老项目的血泪经验是:先定版本,再动代码。不要用最新版 JDK 硬跑 2013 年前后的东西。130220 这个编号我一般先当成打包日期看,那个年代的 Struts 项目普遍是 JDK 6 或 7,Tomcat 6 或 7,Oracle 11g 最常见。

3.1 环境版本搭配:JDK 1.7 + Tomcat 7 + Oracle 11g 最省事

下面这张表是我接手老系统默认采用的搭配,不是绝对标准,但能避开九成兼容性问题。

组件推荐版本说明
JDK1.7(jdk7u80)支持 JSP 编译和旧库反射,不会像 JDK8 那样出现某些 XML 解析异常
Tomcat7.0.x兼容 Servlet 3.0,Struts 1.x 能正常跑,比 Tomcat 6 内存管理好
Oracle11g XE / 11g R2免费版够用,字符集建议建库时选 AL32UTF8
ojdbc 驱动ojdbc6.jar匹配 JDK 1.7,别用 ojdbc14 也别用 ojdbc8
EclipseKepler 或 Luna可直接导入老项目的 .classpath,MyEclipse 更省事

确认本机已经装了 JDK 后,先看版本:

java -version echo $JAVA_HOME

我遇到过一次 Tomcat 能启动但 JSP 编译报UnsupportedClassVersionError的情况,原因就是系统里有两个 JDK,Tomcat 启动脚本里的 JAVA_HOME 指向了 JDK8,而项目编译用的是 JDK7。解决办法是编辑 Tomcat 的setenv.sh或catalina.bat,强制指定 JAVA_HOME。Linux 下这样写:

export JAVA_HOME=/usr/lib/jvm/java-7-oracle export CATALINA_HOME=/opt/tomcat7 $CATALINA_HOME/bin/startup.sh

版本定了之后,接下来不要急着解压项目,先把 Oracle 跑起来,因为后面部署一旦报错,八成问题都出在数据库没准备好。

3.2 初始化数据库:导入建表脚本、序列和初始化管理员

这类项目的压缩包里一般会带一个 database 目录,里面是建表 SQL。常见做法是用 sqlplus 按顺序执行。我先说一句:单独建表脚本、序列脚本、初始化数据脚本最好分文件,因为 oracle 不像 mysql 能容忍重复执行,一个文件里跑到底,中途任何一条语句报错,后面的对象都不会建。

我一般的导入方式是:

sqlplus system/password@//localhost:1521/XE @database/create_tables.sql sqlplus system/password@//localhost:1521/XE @database/create_sequences.sql sqlplus system/password@//localhost:1521/XE @database/init_data.sql

如果手上只有一个大 init.sql,可以在 sqlplus 里先执行第一条语句,确认没有ORA-00955: name is already used by an existing object再继续。这里要特别检查三个东西:序列是否创建、管理员账号是否存在、试题表里有没有测试数据。序列检查用:

SELECT sequence_name FROM user_sequences;

很多系统在登录页输 test 账号进不去,不是代码错了,而是初始化数据根本没导入。我在第 5 章会专门讲这个现象。

3.3 部署到 Tomcat:上下文路径与数据源配置

部署时最省事的方式是直接丢 war 包到 Tomcat 的 webapps 目录,但老项目通常是 exploded 目录,也就是 src、WebRoot、database 分层。你需要把 WebRoot 里的内容整理成标准的 Web 应用结构:WEB-INF/classes、WEB-INF/lib、jsp 页面。用 Eclipse/MyEclipse 导入后右键项目选择 Export WAR 也行。我一般手工打包,因为这样可以顺便删掉编译产生的临时文件。

web.xml 里要确认 Struts 的 ActionServlet 配置,以及默认首页。典型片段:

<servlet> <servlet-name>action</servlet-name> <servlet-class>org.apache.struts.action.ActionServlet</servlet-class> <init-param> <param-name>config</param-name> <param-value>/WEB-INF/struts-config.xml</param-value> </init-param> <load-on-startup>0</load-on-startup> </servlet> <servlet-mapping> <servlet-name>action</servlet-name> <url-pattern>*.do</url-pattern> </servlet-mapping> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list>

数据库连接建议走 Tomcat 的 JNDI 数据源,而不是在 Hibernate 配置里写死连接池。原因很简单:切换环境时只改一个 context.xml。在META-INF/context.xml里写:

<Context> <Resource name="jdbc/ExamDS" auth="Container" type="javax.sql.DataSource" maxActive="20" maxIdle="10" maxWait="10000" driverClassName="oracle.jdbc.OracleDriver" url="jdbc:oracle:thin:@localhost:1521:XE" username="exam" password="exam" validationQuery="SELECT 1 FROM DUAL"/> </Context>

maxActive控制最大连接数,在线考试场景下按并发用户数来调,我一般设 20 起步,压测后再加。validationQuery用SELECT 1 FROM DUAL是 Oracle 专用写法,MySQL 是SELECT 1,别混。Hibernate 那边再通过jndi.dataSource引用这个数据源即可。

启动 Tomcat 后,访问http://localhost:8080/项目名/,如果出现首页但登录报数据库连接失败,回到这里检查 context.xml 里的用户名密码和 Oracle 实例名。实例名 XE 是 Oracle 11g XE 的默认 SID,完整版可能是orcl,写错占位符会报Listener refused the connection。

4. 把老系统改造成“通用”在线考试系统:题库、组卷、考试、阅卷四个关键点

一套在线考试系统能不能叫“通用”,不看登录页漂不漂亮,只看四件事:题库是不是可以随便加题改题,组卷是不是能按题型和难度抽题,考试过程中答案会不会丢,交卷后分数准不准。这一章我按这四个维度拆开讲,每个都给出了可复现的 SQL 或 Java 写法,你照着改就能从“只能跑演示数据”变成“能真正投入小规模使用”。

4.1 题库表设计:把多题型统一成一张表

很多毕设项目把单选、多选、判断、简答各建一张表,表面看很清晰,实际上写组卷逻辑时会疯掉——每种题型都要写一套查询和拼接逻辑。通用系统的做法是统一放进一张 EXAM_QUESTION 表,用类型字段区分。

CREATE TABLE EXAM_QUESTION ( QUESTION_ID NUMBER(10) NOT NULL, QUESTION_TYPE NUMBER(2) NOT NULL, QUESTION_STEM VARCHAR2(2000) NOT NULL, OPTION_A VARCHAR2(500), OPTION_B VARCHAR2(500), OPTION_C VARCHAR2(500), OPTION_D VARCHAR2(500), ANSWER VARCHAR2(500), SCORE NUMBER(4,1) DEFAULT 5, DIFFICULTY NUMBER(1) DEFAULT 1, STATUS NUMBER(1) DEFAULT 1, CONSTRAINT PK_EXAM_QUESTION PRIMARY KEY (QUESTION_ID) );

QUESTION_TYPE我习惯定义为:1 单选、2 多选、3 判断、4 简答。单选题的ANSWER存 “A”;多选题存 “ABD”,取题时前端手工处理成字符串,判断时按字母排序后比较;判断题存 “T” 或 “F”;简答题的ANSWER留空,留给人工阅卷。STATUS是逻辑删除位,考试系统里的题不能物理删,否则历史试卷的引用全断。

查询时这样写:

SELECT * FROM EXAM_QUESTION WHERE QUESTION_TYPE = 1 AND STATUS = 1 ORDER BY DIFFICULTY;

这套设计的核心好处是组卷时只有一个表可查,加新题型时只需要在代码里多注册一个枚举值,不用改表结构。选项超过 D 或需要图片题时,可以再加 OPTION_E、OPTION_F,或者用一个子表存附加选项。图片题在老系统里通常是把图片路径存进QUESTION_STEM里,前端用固定前缀拼完整 URL。如果你做的是 JSP 页面,图片坐标定位可以用 CSS 的 background-position,但考试题一般用不着,存路径就够了。

4.2 随机组卷:DBMS_RANDOM 与 SAMPLE 的取舍

组卷本质是“按一定规则从题库抽题”,最简单的实现是ORDER BY DBMS_RANDOM.VALUE:

SELECT * FROM ( SELECT * FROM EXAM_QUESTION WHERE QUESTION_TYPE = 1 AND STATUS = 1 ORDER BY DBMS_RANDOM.VALUE ) WHERE ROWNUM <= 10;

这段 SQL 先对单选题随机排序,再取前 10 道。题库量在几千道以内时,这个写法完全够用,响应时间几十毫秒。但题库到了几十万道时,ORDER BY DBMS_RANDOM.VALUE要对全表做一次随机排序,代价很大。Oracle 提供了SAMPLE子句做更快但更粗糙的随机抽样:

SELECT * FROM EXAM_QUESTION SAMPLE(1) WHERE QUESTION_TYPE = 1 AND STATUS = 1 AND ROWNUM <= 10;

SAMPLE(1)表示从表中随机读取约 1% 的块,速度极快,但有两个副作用:小表可能抽不出足够行;抽样分布不均匀,同一套题可能连续抽到同一段数据。所以我的实际建议是:题库小于 5 万条用DBMS_RANDOM.VALUE,大于 5 万或对性能敏感时用 SAMPLE 后补非重复条件。

还有一个“抽过的题下次不再出现”的需求。简单方案是在组卷 SQL 里排除 EXAM_PAPER_DETAIL 里已有的题目:

SELECT * FROM ( SELECT * FROM EXAM_QUESTION q WHERE q.STATUS = 1 AND q.QUESTION_ID NOT IN ( SELECT QUESTION_ID FROM EXAM_PAPER_DETAIL WHERE EXAM_ID = ? ) ORDER BY DBMS_RANDOM.VALUE ) WHERE ROWNUM <= 10;

这里把已用题目隔离在外,避免学生两次考试碰到完全一样的题。注意NOT IN子查询里有 NULL 会导致结果为空,所以字段一定要加 NOT NULL 约束。

4.3 考试会话:答案存 Session 还是存库

考试进行中,学生每做一题,最直觉的做法是立刻把答案写进数据库。但这会给数据库带来不必要的写压力,而且一旦中途断网,未提交的答案可能只在界面上存了,数据库里没有。我一般采用的是“Session 为主,自动保存为辅”的双层策略:每道题的答案先放进一个内存 Map,同时用 Ajax 每隔 30 秒把答案快照写进临时表。这样用户刷新页面不会全丢,后端也不会频繁写库。

答案临时表的 Java 逻辑可以这样做:

public class ExamSessionManager { @SuppressWarnings("unchecked") public static void saveAnswer(HttpSession session, Integer questionId, String answer) { Map<Integer, String> answers = (Map<Integer, String>) session.getAttribute("answerMap"); if (answers == null) { answers = new HashMap<Integer, String>(); session.setAttribute("answerMap", answers); } answers.put(questionId, answer); } }

Session 里放一个 Map 比放满是 JSP 输出标签的页面碎片要清爽得多。不过注意,HttpSession 默认有 30 分钟过期时间,考试时长如果超过 30 分钟,必须单独维护一个 examTime 字段,并在每次自动保存时刷新 session 的存活时间,否则学生刚做到一半被踢下线。常见做法是让自动保存的 Ajax 请求同时 touch 一次 session,或者在 web.xml 里把 session-timeout 改成大于考试时长的值。

<session-config> <session-timeout>120</session-timeout> </session-config>

这个参数的单位是分钟。把 30 改成 120 后,学生即使停下来想了几分钟,也不会因为 Session 过期被强制交卷。

4.4 交卷与阅卷:客观题自动判分、主观题人工回写

交卷是整个系统最核心的动作,因为牵涉到多张表的读写:试卷记录、学生答卷明细、总分汇总。我用一个独立方法处理交卷逻辑,所有写操作放在同一个数据库事务里。客观题判分在 Java 层做,因为客观题答案短,查询快;主观题分数由老师在后台逐个批改,改完后统一调存储过程汇总。

客观题判分的核心代码:

private boolean isAnswerCorrect(ExamQuestion question, String userAnswer) { if (question == null || userAnswer == null) { return false; } if (question.getType() <= 2) { // 单选多选 String correct = question.getAnswer().trim().toUpperCase(); String answer = userAnswer.trim().toUpperCase(); if (question.getType() == 2) { // 多选顺序无关 char[] correctChars = correct.toCharArray(); char[] answerChars = answer.toCharArray(); Arrays.sort(correctChars); Arrays.sort(answerChars); return Arrays.equals(correctChars, answerChars); } return correct.equals(answer); } if (question.getType() == 3) { // 判断 return question.getAnswer().trim().equalsIgnoreCase(userAnswer.trim()); } return false; // 简答题不自动判分 }

这段代码有三个细节:答案两边都要 trim,否则用户在选项后不小心打了空格就判错;多选要排序后比较,否则正确答案是 ABD,用户选 BDA 会被误判;判断题的答案统一用 T/F,不要存成对错,否则字符串比较很绕。

交卷事务的伪代码如下:

public void submitExam(int examId, int studentId, Map<Integer, String> answers) { // 开启事务 // 逐题判分,写入 EXAM_ANSWER_DETAIL // 更新 EXAM_RECORD 的总分 // 删除临时快照表记录 // 提交事务 }

这里不能把删除临时表放在判分之前,否则事务回滚时学生已经提交的答案也一起没了。我在一个真实课程设计里就见过这种顺序错误导致的“交卷后查不到成绩”问题,后面第 5 章会列成常见问题。

5. 部署与运行中的 5 个常见问题排查:从监听连不上到中文乱码

这一章是纯踩坑记录。每一类问题我都按“现象 → 原因 → 解决”的顺序写清楚,你遇到类似报错时可以直接照着排查,省得翻一个下午论坛。

5.1 Oracle 监听服务无法启动:先看 listener.ora 的主机名

现象:执行lsnrctl start报TNS-01103: Protocol specific component is invalid,或者监听启动成功但远程连接报ORA-12541: TNS:no listener。

原因:listener.ora 里的 HOST 写的是旧机器的主机名,换电脑后系统里没有这个映射,或者 hosts 文件里没有配置127.0.0.1 主机名。还有一个常见原因:同时装过多个 Oracle 版本,监听配置文件被后装的覆盖。

解决:先用lsnrctl status看当前监听状态,再打开$ORACLE_HOME/network/admin/listener.ora,把 HOST 改成127.0.0.1或当前机器的局域网 IP。改完重启:

lsnrctl stop lsnrctl start

如果还是失败,检查防火墙是否放行了 1521 端口。Linux 下执行:

netstat -tlnp | grep 1521

这条命令会显示监听进程是否存在。看不到输出就是监听根本没起来,回到 listener.ora 继续排查。

5.2 Hibernate 方言配置不匹配导致分页报 ORA-00933

现象:列表页第一页正常,点第二页时后台日志报ORA-00933: SQL command not properly ended,SQL 里出现了LIMIT。

原因:hibernate.cfg.xml 里把数据库方言配成了 MySQLDialect,或者手写分页 SQL 用了 MySQL 的LIMIT ? OFFSET ?。开发者可能之前移植过 MySQL 版本,改 Oracle 时漏了这一处。

解决:把方言改成 Oracle10gDialect 或 Oracle11gDialect。

<property name="hibernate.dialect">org.hibernate.dialect.Oracle10gDialect</property>

同时检查代码里有没有直接拼接LIMIT的 SQL。我一般全局搜索LIMIT,找到就改成 ROWNUM 写法。另外注意:Oracle11gDialect 在 Hibernate 3.6 之后才有,如果你用的是 Hibernate 3.3,就用 Oracle10gDialect,功能差不多。

5.3 中文乱码:JSP、JDBC 驱动、Oracle 字符集三处必须一致

现象:登录后页面上的题目中文变成一串问号,数据库里查出来的数据也是问号,最典型的是“科目名称”全乱。

原因:三处字符集不一致——JSP 页面编码是 ISO-8859-1,项目里用 GBK,Oracle 数据库建库时选了 ZHS16GBK 或 AL32UTF8,驱动传输时又用默认编码。乱码来源非常隐蔽,可能 JSP 正常但写入数据库时乱了。

解决:把整套链路统一成 UTF-8。第一处,JSP 文件顶部加:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>

第二处,Oracle 客户端字符集要通过 NLS_LANG 环境变量指定:

export NLS_LANG=AMERICAN_AMERICA.AL32UTF8

Windows 下在注册表或系统环境变量里设置同样的值。第三处,数据库本身的字符集检查:

SELECT userenv('language') FROM dual;

如果返回值里不是 AL32UTF8,说明数据库是 ZHS16GBK 之类。ZHS16GBK 也能存中文,但和 UTF-8 的 JSP 页面配合时容易转码出错。我建议新建数据库时直接选 AL32UTF8,这样 Java 字符串和 Oracle 存储都是 Unicode,乱码概率最低。已经建好的库改字符集有风险,不如重建后导入。

5.4 交卷按钮重复提交:Struts 的 token 机制怎么用

现象:学生交卷时双击按钮,成绩单里出现了两条一模一样的记录,总分为两倍。

原因:交卷请求被连续发送了两次,Action 每次都把考试成绩写了一遍,也没有检查重复记录。这是 Struts 1 时代最经典的重复提交问题。

解决:利用 Struts 的 Token 机制,在进入考试页时生成一个一次性 token,交卷时校验并立刻作废。先saveToken:

saveToken(request);

然后在 Action 的 execute 方法开头加上:

if (!isTokenValid(request, true)) { // token 无效或已被使用,说明是重复提交 return mapping.findForward("duplicate"); }

isTokenValid第二个参数传 true,表示校验通过后立即销毁 token,这样第二次点交卷时 token 已经不存在,直接拦住。配合前端把交卷按钮在第一次点击后设成 disabled 效果更好,但后端校验必须要有,因为双击产生的是两个独立请求。

5.5 ojdbc 版本与 JDK 不匹配的 NoSuchMethodError

现象:Tomcat 启动正常,登录页能打开,但一点“进入考试”就抛NoSuchMethodError: oracle.jdbc.driver.OracleDriver.getConnection或AbstractMethodError。

原因:WEB-INF/lib 里放了两份 ojdbc,或者 ojdbc14.jar 跑在 JDK 8 上。ojdbc14 是 JDK 1.4 时代的产物,老方法在高版本 JDK 里可能被移除;而 ojdbc8 需要 JDK 8,放在 JDK 7 环境又会报Unsupported major.minor version 52.0。

解决:查看 lib 目录:

ls WEB-INF/lib/ojdbc*.jar

如果有多份,只留一份。JDK 1.7 用 ojdbc6.jar,JDK 1.6 用 ojdbc14.jar。确认后重启 Tomcat,并在启动日志里搜索Loading class看是否只加载了一个驱动类。还可以直接在 jsp 里写一段临时测试代码验证连接,但记得用完删除:

Class.forName("oracle.jdbc.OracleDriver"); Connection conn = DriverManager.getConnection( "jdbc:oracle:thin:@localhost:1521:XE", "exam", "exam");

能连上就说明驱动没问题。报ClassNotFoundException: oracle.jdbc.OracleDriver时则反过来查驱动 jar 是不是被 Maven 或工具过滤掉了。

6. 最后一步:把黑匣子变成白盒,跑一条完整的考试链路

老系统接手后,我最怕的是只看“登录成功就宣布部署完成”。考试系统真正可怕的故障都在链路后半段:组卷抽出的题数对不对、交卷总分是否等于每道题分数之和、主观题回写后统计是否更新。所以我会把整条链路当成一个黑匣子,用最少的数据做一次完整的端到端验证。

验证方法很简单:先手工构造一张只有 5 道题的试卷,然后模拟学生答卷、交卷,最后跑一条汇总 SQL 对比预期总分。

SELECT p.exam_id, COUNT(d.question_id) AS question_count, SUM(q.score) AS expected_total, r.total_score AS actual_total FROM exam_record r JOIN exam_paper p ON r.exam_id = p.exam_id LEFT JOIN exam_paper_detail d ON p.exam_id = d.exam_id LEFT JOIN exam_question q ON d.question_id = q.question_id WHERE r.student_id = ? GROUP BY p.exam_id, r.total_score;

如果question_count不是 5、expected_total不等于actual_total,说明组卷或阅卷链路有断点。这是我养成的一个习惯:任何考试系统,我都先拿 5 题跑一遍“建单选卷 → 作答 → 交卷 → 查总分”的冒烟测试,再交还给业务方。之前接手一个旧系统时,我发现总分总是少 2 分,查了一下午才发现主观题默认按 0 分处理,没有判分完成就提前汇总,后来我在交卷事务里加了“主观题未批改不得汇总总分”的状态判断,才算真正修好。希望这套排查思路对你也有帮助,接手老项目时少走我走过的弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 4:28:11

C#无人值守地磅称重系统设计:串口、状态机与防作弊实现

简介&#xff1a;一套基于C#技术的无人值守地磅称重系统设计源码&#xff0c;面向仓储物流、矿业、化工、港口等行业的软件开发与系统集成人员&#xff0c;用于实现称重流程自动化、数据自动采集与记录&#xff0c;降低人工干预和操作误差。压缩包共243个文件&#xff0c;大小约…

作者头像 李华
网站建设 2026/10/8 4:27:19

从代码生成模型到AI编程助手:上下文、提示词与工程落地全复盘

去年有一段时间&#xff0c;我对“AI 代码生成模型”的预期发生了明显变化。最初我只是把补全当成高配版自动完成&#xff0c;生成一段能跑的代码就满足&#xff1b;直到真把一个半成品模块交给它“帮忙完善”&#xff0c;结果它非常礼貌地把函数补齐了&#xff0c;同时也非常均…

作者头像 李华
网站建设 2026/10/8 4:26:59

企业为什么需要AI应用底座?从模型接入到知识治理的完整指南

你公司到底需不需要一个类似 QuickBlue 的 AI 应用底座&#xff1f;这是我最近被问得最多的问题。很多团队在第一次接入大模型时都会经历类似的兴奋期&#xff1a;调通了接口&#xff0c;跑通了 Demo&#xff0c;领导看了很满意。但等到要做第二个、第三个 AI 应用时&#xff0…

作者头像 李华
网站建设 2026/10/8 4:26:23

图解AI应用架构设计:从RAG到Agent的工程落地指南

做了好几年AI应用架构&#xff0c;从早期在Notebook里跑个模型demo&#xff0c;到后来带团队把RAG、AI Agent这些能力真正部署上线&#xff0c;我最大的一个感受是&#xff1a;大部分项目崩掉&#xff0c;不是因为算法不行&#xff0c;而是因为架构没有“画清楚”。所谓“画清楚…

作者头像 李华
网站建设 2026/10/8 4:25:56

Excel VBA区域选取与动态定位:数组字典高效处理数据移动实战

1. 先搞明白"精准选取"到底难在哪做Excel VBA的人绕不过一个坎&#xff1a;怎么把"我想要的区域"准确告诉代码。说起来像废话&#xff0c;但实际上很多VBA写不好的项目&#xff0c;问题根本不在逻辑复杂&#xff0c;而是第一步"选区"就没选对。你…

作者头像 李华
网站建设 2026/10/8 4:25:52

Java向量化计算实战:用Vector API和FMA把单核吞吐提升近6倍

1. 一次批处理优化让我盯上了向量化计算有一回我在优化一套历史汇率重算服务&#xff0c;核心逻辑其实不复杂&#xff1a;几亿条市场记录需要把价格乘以不同币种的汇率&#xff0c;再做一轮累计和归并。线程池从四个核一路扩到十几个核&#xff0c;锁粒度、缓存行填充、对象池都…

作者头像 李华