☰
Java+MySQL作业批改系统源码:部署、权限与状态机改造
2026/11/2 8:58:49 网站建设 项目流程

简介:这套网上作业批改系统面向 Java Web 初学者、课程设计学生及需要搭建作业管理平台的教师,提供一套可直接参考的完整源码与数据库方案。系统以简洁页面和友好错误提示为特色,管理员可完成班级、教师、学生、公告、留言、资料及系统信息的新增、查询、删除与打印等操作,并兼顾权限控制与安全性,适合用作毕业设计、课程实训或教学管理原型。压缩包共 265 个文件,约 1.93MB,包含 45 个 JSP 页面、24 个 Java 源文件与 24 个 class 编译文件、9 个 JS 脚本、6 个 CSS 样式表,以及 SQL 数据库脚本、说明文档和界面图片等,结构完整,便于理解前后端交互与模块划分。目前已有 1615 人学习下载。读者可从中获取可运行的作业批改与管理实例、数据库表设计、后台增删改查实现思路及页面资源,适合对照学习 Servlet 与 JSP 开发流程,也能快速复用为个人项目的基础框架。

1. 学期末教务处催着要成绩,这套源码解决的就是"作业交上来没人批"

每年期末前两周,最常见的一幕是这样的:学生在群里问"老师我第三次作业交了没",教师在本地文件夹里按学号翻附件,教务那边还要一份按班级汇总的成绩表。折腾半天,最后发现有两份作业压根没进过批改队列。这份网上作业批改系统 JAVA+Mysql 的源码包,瞄准的就是这条链路——把作业的提交、归档、批改、打分、退回、汇总塞进一个 Web 系统里,学生端只看自己的提交记录和分数,教师端只处理自己带的班级。它适合三类人:课程设计要交一个完整可演示系统的学生、需要给小型培训场景快速搭一套作业流工具的开发、以及想拿一个结构完整的 Java Web 项目练手增删改查与权限控制的人。下面按我实际拆包、导库、启动的顺序讲,重点放在"怎么跑起来"和"跑起来之后哪里会翻车"。

2. 解压后先别急着点运行:Java Web 骨架与最小可跑闭环

拿到压缩包的第一件事不是双击启动脚本,而是先确认它属于哪一种 Java Web 形态。市面上叫"作业批改系统"的源码,绝大多数是三种之一:纯 Servlet+JSP 的老式结构、SSM(Spring + SpringMVC + MyBatis)结构、以及近几年改写的 Spring Boot 结构。三种形态的启动方式、配置位置、依赖管理完全不同,判断错了后面每一步都会对不上号。

2.1 用目录结构反推技术栈

解压后先看根目录,我一般按下面这张对照表快速定位,基本三分钟就能判断出项目属于哪一类:

目录/文件特征技术形态启动方式配置入口
有WEB-INF/web.xml、lib/*.jar、无pom.xmlServlet + JSP 原生放进 Tomcat 的webapps启动web.xml、jdbc.properties
有pom.xml、spring-*.xml、WEB-INF/web.xmlSSM外部 Tomcat 部署 warapplicationContext.xml、db.properties
有pom.xml、application.yml、*Application.javaSpring Boot直接跑主类或java -jarapplication.yml

判断依据里最容易被忽略的是WEB-INF/lib目录:如果里面塞满了mysql-connector-java-*.jar、commons-fileupload-*.jar这类包,同时根目录又没有pom.xml,那基本可以确定是"依赖全部手工放进 lib"的原生项目。这类项目的坑在于 jar 版本是死板的,你想换 MySQL 驱动版本必须手动替换文件,不能靠 Maven 拉取。

# 在解压后的根目录执行,快速看清项目形态 find . -maxdepth 3 -name "pom.xml" -o -maxdepth 3 -name "web.xml" -o -maxdepth 3 -name "application*.yml" # 统计 lib 目录下的 jar 数量,超过 20 个基本是手工依赖 ls -1 WEB-INF/lib 2>/dev/null | wc -l # 找出所有数据库配置文件,后面要改的就是它们 grep -rIl --include="*.properties" --include="*.yml" --include="*.xml" "jdbc:mysql" .

第一段命令用find同时探测三类标志文件,输出结果里出现哪个,就决定了你后面走哪条启动路径。第二条命令是经验判断,手工依赖的项目往往伴随旧版驱动和旧版连接池,这在第 5 章会变成具体的报错。第三条grep是最省事的一步:不管配置藏得多深,只要写过 JDBC 连接串,就一定会被搜出来,这样你就不用一个目录一个目录去翻db.properties到底放在src还是resources下面。

提示:如果grep搜出来不止一个文件,说明项目里可能存在"配置了但没被加载"的僵尸文件,改的时候要以实际被 Spring 或 Servlet 读取的那份为准,否则会出现"我明明改了却还是连旧库"的玄学。

课程设计场景下,我建议优先选原生 Servlet 或 SSM 形态的项目做二次开发,原因是目录结构直白、断点好打、报错信息指向明确。Spring Boot 形态虽然启动快,但对新手来说自动配置把很多链路藏起来了,一旦数据库连不上,报错栈能翻三屏还没到根因。

2.2 环境版本对齐:JDK、Tomcat、MySQL 三者必须成套

这类源码包最容易翻车的地方不是代码本身,而是版本组合。我见过太多"项目跑不起来"最后定位到 JDK 17 配 Tomcat 10。原因很具体:Tomcat 10 之后 Servlet API 的包名从javax.servlet换成了jakarta.servlet,凡是项目里import javax.servlet.*的,在 Tomcat 10 上必然ClassNotFoundException。

我一般按这个组合起步,兼容性最好:

组件推荐版本说明
JDK8(u202 及以上)老项目绝大多数按 1.8 编译
Tomcat8.5 或 9.0保留javax.servlet命名空间
MySQL5.7 或 8.08.0 需要换cj驱动并加时区参数
构建工具Maven 3.6+仅 SSM / Boot 形态需要
IDEIntelliJ IDEA / Eclipse前者对 JSP 调试更友好

JDK 这一栏要特别注意:如果你的机器上装的是 JDK 17 而项目pom.xml里写着<source>1.8</source>,Maven 编译阶段就可能报invalid target release: 1.8。解决办法是在 IDE 里把项目 SDK 显式切到 1.8,或者用 Maven Toolchains 指定,别指望默认值。

# 确认当前生效的 JDK 版本 java -version # Maven 编译时显式指定源码与目标版本 mvn clean package -Dmaven.compiler.source=1.8 -Dmaven.compiler.target=1.8

第一条命令看的是运行时版本,第二条命令里的两个参数控制的是编译产物字节码版本,它们必须一致。很多人java -version显示 1.8,但 IDEA 的 Project Structure 里模块 SDK 还是 17,编译出来的 class 在 Tomcat 上就会抛UnsupportedClassVersionError,报错信息里带着class file version 61.0,看到这个数字就知道是 JDK 版本错配。

原生项目不走 Maven 的话,编译方式是把src下的 Java 文件用javac编到WEB-INF/classes,这一步在 IDEA 里配置 Artifact 就能自动完成。我通常的做法是先在 IDE 里跑通,再考虑要不要打成 war,避免一开始就陷进打包配置里。

2.3 导入 SQL 与改连接串:这一步决定后面 80% 的报错

数据库这一步做不干净,后面所有页面都会以 500 收场。正确顺序是:先建库(指定字符集),再导入表结构和初始数据,最后改连接串,三步不能颠倒。

# 第一步:建库时就把字符集定死,避免后面改表 mysql -uroot -p -e "CREATE DATABASE homework_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 第二步:导入脚本,显式指定字符集,防止脚本里有中文被按 latin1 读入 mysql -uroot -p --default-character-set=utf8mb4 homework_db < sql/homework_db.sql # 第三步:验证表数量和字符集 mysql -uroot -p -e "USE homework_db; SHOW TABLES; SELECT @@character_set_database;"

这里有三个参数值得说清楚。DEFAULT CHARACTER SET utf8mb4决定了新建表的默认字符集,如果建库时用了默认的 latin1,后面即使单张表改成 utf8mb4,跨表 JOIN 或者临时表仍可能出乱码。--default-character-set=utf8mb4是客户端侧的读入编码,它和库的编码是两回事,脚本文件本身是 UTF-8 而客户端按 latin1 解析,导入进去的中文就会变成问号。最后那条验证语句必须执行,@@character_set_database返回utf8mb4才算过。

连接串的修改位置取决于项目形态,原生项目一般在src/jdbc.properties或WEB-INF/classes/jdbc.properties,SSM 在db.properties,Boot 在application.yml。典型内容如下:

# MySQL 8.0 写法,注意驱动类带 cj,连接串带时区 jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/homework_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=你的密码

useUnicode=true&characterEncoding=utf8解决的是 JDBC 层的中文传输问题;serverTimezone=Asia/Shanghai解决 MySQL 8.0 驱动对时区解析的强制要求,缺了它会直接抛The server time zone value is unrecognized;allowPublicKeyRetrieval=true是 8.0 默认认证插件caching_sha2_password在非 SSL 连接下的必要参数,缺了会报Public Key Retrieval is not allowed。

如果是 MySQL 5.7 或更早,驱动类要退回com.mysql.jdbc.Driver,连接串里去掉allowPublicKeyRetrieval,其余保留即可。这一条经常被忽略:驱动类和数据库版本不匹配时,报错往往不是"驱动不对",而是Unknown system variable或者连接直接超时,看起来像网络问题,实际是版本问题。

3. 登录到打分的主链路:六张表、两处上传、一个状态机

把项目跑起来只是开始,真正决定这套系统"能不能用"的是主链路是否闭环:学生登录 → 看到待做作业 → 提交文字或附件 → 教师看到待批列表 → 打分写评语 → 学生端显示分数。这条链路上牵扯六张表、两处文件上传、一个状态流转,任何一环设计粗糙,二次开发时都会很难受。

3.1 六张核心表与角色权限的切分方式

不管源码包里表名叫什么,结构基本都是这六类:用户表、角色表、班级表、作业表、提交表、成绩表。我按自己的习惯给出等价结构,方便你对照源码去改:

-- 用户表:一个用户一个角色,班级字段对教师为空 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '密码摘要', real_name VARCHAR(50) NOT NULL, role_code VARCHAR(20) NOT NULL COMMENT 'admin/teacher/student', class_id BIGINT DEFAULT NULL COMMENT '学生所属班级', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 作业表:教师发布,绑定班级与截止时间 CREATE TABLE assignment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, class_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, title VARCHAR(120) NOT NULL, content TEXT, deadline DATETIME NOT NULL, status TINYINT DEFAULT 1 COMMENT '1进行中 0已结束' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 提交表:一个学生一份作业一条记录,附件路径落在这里 CREATE TABLE submission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, assignment_id BIGINT NOT NULL, student_id BIGINT NOT NULL, content TEXT, file_path VARCHAR(255) DEFAULT NULL, file_name VARCHAR(120) DEFAULT NULL, submit_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT '1已提交 2已批改 3已退回', UNIQUE KEY uk_assign_student (assignment_id, student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这三张表是主链路骨架,另外三张(角色表、班级表、成绩表)在多数源码里是冗余拆分,可以按需合并。这里有两个设计点值得强调。

第一个是submission上的唯一索引uk_assign_student。它把"一个学生对一份作业只能有一条提交"从业务逻辑约束变成了数据库约束,能直接挡掉刷新页面导致的重复插入——这是这类系统最高频的脏数据来源。没有这个索引时,学生手抖点两次提交,教师端就会出现两条一模一样的记录,批改时改了一条另一条还挂着"待批"状态。

第二个是权限切分放在role_code上而不是建独立的权限表。课程设计级别的小系统用三值枚举(admin/teacher/student)完全够用,做成 RBAC 反而增加理解成本。真正需要控制的是数据行级权限:教师只能看到自己teacher_id对应的作业,学生只能看到自己class_id下的作业。这部分不能靠前端隐藏菜单实现,必须在 SQL 的WHERE里带条件。

// 教师端待批列表:权限条件直接写进查询,不依赖前端传参 public List<SubmissionVO> listPending(Long teacherId) { String sql = "SELECT s.id, s.assignment_id, s.student_id, u.real_name, " + "s.submit_time, s.file_name " + "FROM submission s " + "JOIN assignment a ON a.id = s.assignment_id " + "JOIN sys_user u ON u.id = s.student_id " + "WHERE a.teacher_id = ? AND s.status = 1 " + "ORDER BY s.submit_time ASC"; // teacherId 来自会话,绝不接受前端传参,否则越权 return jdbcTemplate.query(sql, new SubmissionRowMapper(), teacherId); }

这段代码的关键不在 SQL 语法,而在a.teacher_id = ?这个条件:参数值必须从session.getAttribute("userId")或 Spring Security 的上下文里取,而不是从请求参数里取。如果从请求参数取,学生只要改一下 URL 上的teacherId就能看到别人的批改列表,这是这类课设项目里最典型的越权漏洞,答辩时被问到会很难解释。

3.2 两处文件上传:作业附件与批改附件

上传在这个系统里出现两次:学生交作业传附件,教师批改后可选回传批注文件。两处的处理逻辑不同——前者必须限制类型和大小、需要按作业和学号组织目录;后者是可选操作,失败不应该阻断打分。

// 学生提交附件:先落盘再写库,落盘失败直接返回,不产生半条数据 public String saveSubmissionFile(MultipartFile file, Long assignmentId, Long studentId) throws IOException { if (file == null || file.isEmpty()) return null; // 1. 大小限制:单文件 10MB,与前端提示保持一致 if (file.getSize() > 10 * 1024 * 1024) { throw new IllegalArgumentException("附件超过 10MB"); } // 2. 扩展名白名单,避免上传可执行脚本 String original = file.getOriginalFilename(); String ext = original.substring(original.lastIndexOf('.') + 1).toLowerCase(); if (!Arrays.asList("pdf", "doc", "docx", "zip", "jpg", "png").contains(ext)) { throw new IllegalArgumentException("不支持的文件类型:" + ext); } // 3. 目录按 作业ID/学号 两级拆分,避免单目录文件过多 String relative = assignmentId + "/" + studentId + "/" + UUID.randomUUID() + "." + ext; File target = new File(UPLOAD_ROOT, relative); if (!target.getParentFile().exists()) target.getParentFile().mkdirs(); file.transferTo(target); // 4. 返回相对路径入库,不存绝对路径,换服务器不用改数据 return relative; }

四步的逻辑说明:大小校验放在最前面,避免把大文件读进内存后才拒绝;扩展名白名单用后缀比对,虽然不能防伪造,但能挡掉绝大多数误传;assignmentId/studentId两级目录是为了让运维能按作业批量打包下载,也避免了单目录几万个文件导致ls卡死;最后返回相对路径是关键,绝对路径一旦入库,迁移环境后所有历史附件全部失效。

参数层面,UPLOAD_ROOT必须配置在项目目录之外。我一般设成/data/upload/homework这种独立路径,原因在第 5 章会讲——放在webapps下面,重新部署 war 包时整个目录会被清空。

3.3 批改状态机:三个状态、两条非法转移

提交状态看着简单,实际上是最容易写出脏逻辑的地方。我建议严格限定成三个状态和两条非法转移:

当前状态允许操作目标状态触发角色
1 已提交打分2 已批改教师
1 已提交退回3 已退回教师
3 已退回重新提交1 已提交学生
2 已批改修改分数2 已批改教师(需留痕)

非法转移有两条必须用代码挡住:已批改的记录不允许学生再提交(否则分数和内容对不上),已退回的记录不允许教师直接打分(学生还没重新交)。这两条如果只靠前端按钮控制,接口层面照样能被绕过。

// 批改服务:状态校验 + 分数校验 + 事务提交,三件事必须在一个事务里 @Transactional(rollbackFor = Exception.class) public void grade(Long submissionId, Long teacherId, BigDecimal score, String comment) { Submission sub = submissionMapper.selectById(submissionId); if (sub == null) throw new IllegalArgumentException("提交记录不存在"); // 状态机校验:只有"已提交"才能批改 if (sub.getStatus() != 1) { throw new IllegalStateException("当前状态不允许批改:" + sub.getStatus()); } // 分数范围校验:0~100,保留一位小数 if (score == null || score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(new BigDecimal("100")) > 0) { throw new IllegalArgumentException("分数必须在 0~100 之间"); } gradeMapper.insert(submissionId, teacherId, score, comment); // 状态与成绩在同一事务内更新,避免只改了状态没写成绩 submissionMapper.updateStatus(submissionId, 2); }

这段代码的重点在@Transactional和两个校验的位置。校验必须在写库之前完成,一旦先insert成绩再校验状态,异常回滚虽然能保住数据一致,但自增主键已经被消耗,日志里会留下一串空洞。分数用BigDecimal而不是double,是因为成绩汇总时0.1 + 0.2这类浮点误差会累积成59.9999这种显示问题,用DECIMAL(5,1)存、BigDecimal算,能省掉后面一堆四舍五入的补丁。

4. 二次改造:附件落盘、批改留痕与分页查询的三处硬伤

源码包能跑通不代表能交付。我拆过的这类项目,有三处几乎必然要动刀:附件存储路径写死在项目内、批改记录用 UPDATE 覆盖导致无法追溯、列表查询没有分页或分页写成了全表扫描。这三处不改,演示时没问题,一旦数据量上来或者要重新部署就会集中爆发。

4.1 附件目录外置与访问路径的映射

原始代码里十有八九是把上传目录写成request.getServletContext().getRealPath("/upload")。这个路径指向的是解压后的 web 应用目录,Tomcat 重新部署 war 时会整体删除,学生的作业附件跟着一起没。改造分两步:存储路径外置到配置文件,访问路径用虚拟映射或者下载接口代理。

# upload.properties,放在 classpath 下,随环境改 upload.root=/data/upload/homework upload.max-size=10485760 upload.allow-ext=pdf,doc,docx,zip,jpg,png
// 下载走接口而不是直接暴露目录,顺便做权限校验 @GetMapping("/submission/{id}/download") public void download(@PathVariable Long id, HttpServletResponse resp) throws IOException { Submission sub = submissionMapper.selectById(id); Long currentUserId = SessionUtil.currentUserId(); // 只有提交人本人、任课教师、管理员能下载 if (!sub.getStudentId().equals(currentUserId) && !isTeacherOf(sub, currentUserId)) { resp.sendError(403, "无权下载该附件"); return; } File file = new File(UPLOAD_ROOT, sub.getFilePath()); if (!file.exists()) { resp.sendError(404, "附件已丢失"); return; } resp.setContentType("application/octet-stream"); // 文件名要 URL 编码,否则中文名在部分浏览器上会乱码 resp.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(sub.getFileName(), "UTF-8")); Files.copy(file.toPath(), resp.getOutputStream()); }

配置文件里的upload.root是唯一需要随环境变化的值,测试机和正式机各配一份,代码不用改。下载接口里的权限判断是这个改造的附加收益:直接暴露目录的方案,任何人拿到路径就能下载别人的作业,改成接口后可以在服务端拦住。Content-Disposition里的文件名必须URLEncoder.encode,否则中文文件名在部分浏览器上会变成乱码或者直接被截断。

还有一个细节容易被漏掉:文件不存在时要返回 404 而不是 500。原始代码往往是file.transferTo()或者Files.copy()直接抛FileNotFoundException,前端看到的是 500 错误页,运维排查时以为是服务挂了,实际只是某个附件被手工删了。

4.2 批改留痕:从 UPDATE 覆盖改成流水记录

原始代码的批改逻辑通常是"更新成绩表对应行",教师改一次分,上一次的分数和评语就没了。教学场景下这不是小问题:学生申诉"我明明看到是 85 分"时,你没有证据。改造方式是把成绩表从"当前值"改成"流水",查询时取最新一条。

-- 批改流水表:每次打分插一条,不更新历史 CREATE TABLE grade_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, submission_id BIGINT NOT NULL, teacher_id BIGINT NOT NULL, score DECIMAL(5,1) NOT NULL, comment VARCHAR(500), round_no INT NOT NULL DEFAULT 1 COMMENT '第几次批改', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_submission (submission_id, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 取每份提交的最新一次批改结果 SELECT g.* FROM grade_record g JOIN (SELECT submission_id, MAX(id) AS mid FROM grade_record GROUP BY submission_id) t ON t.mid = g.id;

流水表的关键是round_no和idx_submission (submission_id, id)这个联合索引。前者让教师能看出"这份作业改过两次",后者保证按提交记录查历史时走索引而不是全表扫。取最新的写法用MAX(id)关联而不是ORDER BY ... LIMIT 1子查询,是因为后者在 MySQL 5.7 上对每行都会执行一次子查询,几十份作业看不出来,几千份时列表页能卡到三秒以上。

如果不想动表结构,退一步的做法是在原表上加update_time和update_by两个字段,至少能回答"谁在什么时候改的"。但这只能留最后一次,第二次修改就把第一次覆盖了,严格来说不算留痕。我的建议是既然要改就一次改到位,流水表的改造成本也就是一个建表语句加一个查询替换。

4.3 分页查询:LIMIT 不是万能的

列表页在演示数据下永远流畅,因为总共就二十条。真实场景一个教师带四个班、一学期二十次作业,提交记录轻松过千。原始代码里常见的写法是SELECT * FROM submission然后在 Java 里subList截取,这种写法有两个问题:全量数据进内存,以及subList返回的是视图,序列化时可能抛ConcurrentModificationException。

正确做法是数据库层分页加覆盖索引。

-- 教师端提交列表:按作业+状态过滤,分页取数 SELECT s.id, s.student_id, u.real_name, s.submit_time, s.status FROM submission s JOIN sys_user u ON u.id = s.student_id WHERE s.assignment_id = ? ORDER BY s.status ASC, s.submit_time DESC LIMIT ?, ?; -- 配套的计数查询,条件必须与列表查询完全一致 SELECT COUNT(*) FROM submission WHERE assignment_id = ?; -- 支撑上面两条查询的索引 ALTER TABLE submission ADD INDEX idx_assign_status_time (assignment_id, status, submit_time);

三个要点。第一,LIMIT ?, ?的两个参数分别是偏移量和条数,偏移量大时(比如第一万条)MySQL 仍要扫描前一万行,这时应该改成基于游标的分页,即WHERE id > 上次最大id LIMIT 20,课程设计级别用不上但要知道边界在哪。第二,计数查询的WHERE条件必须和列表查询一模一样,否则会出现"显示共 100 条但翻到第 6 页就空了"这种诡异现象,原因是列表加了状态过滤而计数没加。第三,索引列的顺序(assignment_id, status, submit_time)是按最左前缀原则排的,assignment_id选择性最高放最左,submit_time用于排序放最后,这样ORDER BY也能用上索引避免 filesort。

参数层面还有一个隐藏坑:LIMIT后面不能用?占位符在某些老版本 MyBatis 里会报语法错误,需要用${}拼接。用${}就要自己做整数校验,防止 SQL 注入——这是分页场景里少数几个必须用字符串拼接的地方,务必确认参数是int类型再拼。

5. 避坑排查:启动报错、乱码与附件丢失的五条血泪记录

这一章按"现象 → 原因 → 解决"写,都是我在拆这类项目时真实撞过的。有些坑看起来是环境问题,根因在代码;有些看起来是代码 bug,实际是配置没对齐。按启动期和运行期分两组,方便你对照排查。

5.1 启动期的三类报错

现象一:Tomcat 启动后访问首页 404,控制台无异常。原因通常有两个:一是项目的 context path 不是/,实际访问地址是http://localhost:8080/项目名/,直接访问根路径当然 404;二是web.xml里welcome-file-list配的首页文件名和实际文件对不上,比如配了index.jsp而实际叫login.jsp。解决方式是先看 Tomcat 日志里Deployment of web application archive那一行,后面跟着的路径就是 context path,再进去看目录下有没有配的欢迎页。SSM 项目还有一种情况是DispatcherServlet的url-pattern配成了/*导致 JSP 也被拦截,改成/即可。

现象二:启动报java.lang.ClassNotFoundException: javax.servlet.ServletRequest或 JSP 编译错误提示找不到javax.servlet.*。原因基本可以锁定为 Tomcat 版本过高。Tomcat 10 起 Servlet API 迁移到jakarta.servlet,而这类老项目的依赖和 import 全是javax。有两种解法:降级到 Tomcat 9 或 8.5(推荐,改动最小),或者把项目里所有javax.servlet换成jakarta.servlet并升级相关依赖(工作量大,容易漏)。我一般直接换 Tomcat,因为这类源码包里的 JSP 标签库、过滤器配置往往也依赖旧命名空间,只换 import 跑不起来。

现象三:数据库连接失败,报Unknown database 'homework'或Access denied for user 'root'@'localhost'。前者是库名对不上,配置文件里写的库名和你CREATE DATABASE时用的名字不一致,注意下划线和连字符不要看错。后者是密码错或者权限不足,MySQL 8.0 默认不允许 root 远程连接,本地连接则要确认root的认证插件。还有一种迷惑性很强的报错The server time zone value '中国标准时间' is unrecognized,看着像数据库坏了,实际只是连接串缺serverTimezone=Asia/Shanghai,加上就好。

5.2 运行期的两类数据错乱

现象四:页面上的中文全是问号,但数据库里查出来是正常的。这种"库里正常、页面乱码"的情况,问题在响应编码而不是存储编码。检查三处:JSP 页面顶部有没有<%@ page contentType="text/html;charset=UTF-8" %>;web.xml里有没有配CharacterEncodingFilter且forceEncoding为 true;Tomcat 8 之前的版本 GET 请求还要在server.xml的Connector上加URIEncoding="UTF-8"。三处缺一处就可能出现"表单提交的中文乱码但直接查库正常"的分裂现象。

反过来,"页面正常但库里是问号"则是导入时字符集错了。这种情况必须重新导入:先DROP DATABASE重建,导入时加--default-character-set=utf8mb4,导入后执行SELECT HEX(title) FROM assignment LIMIT 1看十六进制里是不是E4开头(UTF-8 中文的特征),是问号的话十六进制会是3F。

现象五:附件上传成功,重启 Tomcat 后全部 404。原因就是第 4 章提到的,上传目录写在了webapps/项目名/upload下面。Tomcat 重新部署 war 包时会先删掉整个应用目录再解压,历史附件跟着一起没。这个坑最阴的地方在于它不会立刻暴露,你可能演示了一整天都正常,第二天重启才发现全丢了。解决方式只有一条:把upload.root指到应用目录之外,改成绝对路径,并且立刻迁移已有文件。迁移时注意保留原来的相对路径结构,否则数据库里存的file_path全部对不上。

注意:迁移完附件目录后,务必拿一条历史记录实际下载一次验证,只看文件在不在是不够的,路径拼接少一层斜杠也会 404。

6. 交付前自检:用一条 SQL 把漏批作业揪出来

系统跑通、能打分,不代表能交差。真正交付前我最常做的一件事,是用一条 SQL 把"该批没批"的作业全部捞出来——这比在页面上一个个点开看靠谱得多,也顺便验证了状态机的流转是否符合预期。

-- 找出所有已过截止时间、但仍有待批提交的作业,按待批数量倒序 SELECT a.id AS assignment_id, a.title, c.class_name, u.real_name AS teacher_name, COUNT(s.id) AS total_submit, SUM(CASE WHEN s.status = 1 THEN 1 ELSE 0 END) AS pending_cnt, SUM(CASE WHEN s.status = 3 THEN 1 ELSE 0 END) AS returned_cnt FROM assignment a JOIN clazz c ON c.id = a.class_id JOIN sys_user u ON u.id = a.teacher_id LEFT JOIN submission s ON s.assignment_id = a.id WHERE a.deadline < NOW() GROUP BY a.id, a.title, c.class_name, u.real_name HAVING pending_cnt > 0 ORDER BY pending_cnt DESC;

这条 SQL 有几个设计意图值得展开。用LEFT JOIN而不是INNER JOIN,是为了把"一份都没交"的作业也带出来——那种情况下COUNT(s.id)为 0,pending_cnt也是 0,不会进结果集,但你能从总数上看出异常。SUM(CASE WHEN ...)是标准的条件聚合写法,比写三个子查询快得多,也避免了同一张表被扫三遍。HAVING而不是WHERE,是因为pending_cnt是聚合结果,WHERE阶段还不存在这个值。

WHERE a.deadline < NOW()这个条件也刻意保留:截止时间之前待批是正常的,只有过了截止时间还挂着才说明教师积压了。如果你想让这条 SQL 变成教师首页的"待办角标",把WHERE换成a.deadline BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 3 DAY),就能提前三天提醒,比事后催更有效。

配套的验证动作我一般做三步。第一步,用这条 SQL 的结果和教师端列表页逐条对照,确认数量一致——不一致说明列表页的过滤条件写漏了。第二步,拿一条status = 3(已退回)的记录,用学生账号重新提交,再查一次,确认它从returned_cnt挪到了pending_cnt。第三步,拿一条status = 2(已批改)的记录,直接构造接口请求再提交一次,确认被状态机挡住——这一步是在验证第 3.3 节那个if (sub.getStatus() != 1)是真的生效,而不是只在前端禁用了按钮。

还有一个更省事的技巧:把上面这条 SQL 做成数据库视图v_pending_summary,然后写一个定时任务每天早上跑一次,结果低于阈值就发一封汇总邮件给教务。这个改动不大,但把"事后补救"变成了"事前预警",是这个系统从"课设作品"变成"能日常用的工具"的分水岭。

我从那以后每次拿到这类源码包,都强制自己先跑一遍这条 SQL,哪怕演示数据只有五条记录——因为它能同时验证表关联、状态机、时间字段三件事,比逐个页面点开看快得多。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询