简介:一套基于Java与Vue的在线作业提交批改系统完整项目源码,面向毕业设计、课程设计及Java Web学习者。系统按管理员、教师、学生三个角色设计,包含用户注册登录、课程管理、作业发布与设定期限、学生在线提交Word作业、教师下载批改并录入评语分数、成绩数据导出Excel等核心业务,业务链路完整,适合参考实现前后端分离架构与角色权限控制。压缩包内共453个文件,以Java源码、class编译文件、HTML/Vue前端页面、CSS样式、JavaScript脚本、SQL数据库脚本及XML/yml配置为主,附带图片和字体资源,整体仅16.81MB,目录按后端控制器、前端页面和数据库脚本划分,便于检索修改。目前已有1629人学习下载。读者可基于完整代码快速部署运行,针对课程定义、作业期限、提交覆盖等逻辑自行扩展,能有效支撑毕业设计答辩或作为Java Web开发练手项目。
1. 在线作业提交批改系统,难点不在上传而在状态机
在线作业提交批改系统的核心链路看起来很简单:学生上传文件、教师下载批改、打分发布。但落到真实校园或培训场景,这套系统真正难的不是文件上传,而是提交状态的流转。一个作业从「未提交」到「已提交」到「已批改」到「已打回」,中间经历过撤销重交、超时禁止、补交通道、匿名评阅、相似度检测,每一环都会改写数据状态;只要其中一步的状态没有锁住,就会出现「学生说交了、教师说没收到」的经典扯皮。
另一个常被低估的点是权限模型的边界。作业系统有学生、教师、助教三种角色,但权限不能只按角色切。助教能批改A班却不能批改B班;学生能看见自己和同组同学的提交,但不能看见全班的;教师能看到班级整体成绩分布,但不能看到具体某个学生的答题细节之外的横向比较。这个矩阵如果只靠role字段硬编码,后期每次加功能都要改一层逻辑。我一般建议直接采用资源级 ACL:作业、提交记录、批改结果各是一个资源类型,权限规则挂在资源实例上,而不是挂在人上。这样后续加「跨校互评」「企业导师围观」都只是加规则,不用动表结构。
适合读这篇文章的人,是已经写过一个能跑的 CRUD 作业系统、但被并发提交、重复批改、文件路径越权、成绩单导出卡死这些问题反复折腾过的开发者。下面从数据模型讲到评测回调再到缓存优化,全部按可落地的方案来。
2. 提交模块设计:用提交单号和对象存储切断路径依赖
2.1 数据库表如何建模才能撑住高频提交
先回答一个最基础的问题:作业提交批改系统的主表到底怎么设计。常见做法是拆成三张:homework(作业定义)、submission(提交记录)、grade(批改结果)。其中submission是关键,因为一次作业允许多次提交、以最后一次为准,还是每次提交都留痕,这两套需求的表结构完全不同。
我建议submission表按「一次提交一行」来设计,不要只留latest_submission_id字段去覆盖旧记录。原因是教学场景里经常要追溯:「你第一次提交的版本和第二次差在哪」,这在代码作业的查重场景里几乎是刚需。表结构大致如下:
CREATE TABLE submission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, homework_id BIGINT NOT NULL, student_id BIGINT NOT NULL, submit_no VARCHAR(32) NOT NULL, file_key VARCHAR(255) NOT NULL, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, file_md5 CHAR(32) NOT NULL, submit_status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_homework_student_submitno (homework_id, student_id, submit_no) );submit_no是本次提交的单号,用UUID或雪花 ID都行。file_key是对象存储中的唯一对象名,不要存用户上传的原始文件名。file_md5用于秒传和查重预处理。submit_status字段建议预留:0 表示已接收待校验,1 表示有效提交,2 表示已打回,3 表示已撤销。
主键用自增 ID 没问题,但业务查询条件几乎总是homework_id + student_id,所以联合唯一键建在业务键上,同时再给student_id + homework_id建一个普通索引,专门用于查询某个学生的历史提交列表。
2.2 文件存储:为什么我不用本地磁盘目录存提交件
很多课程设计的作业系统喜欢把文件存到Nginx静态目录或服务器本地路径,比如/var/homework/submission/20240101/student_id/homework_3.pdf。这种做法在接口测试阶段没有任何问题,但一旦多台应用服务器做负载均衡,学生的文件落在A机器、教师下载时被调度到B机器,就会直接 404。
更隐蔽的问题在路径暴露上:如果接口是把filePath原样传回前端,学生改掉 URL 里student_id就能遍历下载别人的作业。正确的思路是,应用层永远只存对象名,不拼接物理路径。对象名可以用homework/{homeworkId}/{studentId}/{submitNo}_{md5前8位}.pdf这样带业务上下文的结构,但具体物理落在哪台存储节点上,由存储服务自己处理。学校私有化部署如果不想引入MinIO或Ceph,也至少要用一个独立的文件服务,而不是让业务服务器直接暴露磁盘目录。
上传流程我一般是这样设计的:
// 前端先调后端获取上传凭证,拿到对象名和临时直传地址 const resp = await fetch('/api/homework/submit/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ homeworkId: 1024 }) }); const { uploadUrl, fileKey, submitNo } = await resp.json(); // 前端直传文件到对象存储,避免业务服务器做文件流的中间转发 const uploadResp = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' }, body: file });这里用「预签名 URL + 前端直传」的方式是为了避免应用服务器成为文件流的瓶颈。小规模场景下,一台应用服务器扛个几十 MB 的文件转发问题不大;但一个班 50 人同时交 20 MB 的实验报告,瞬时流量就是 1 GB,应用服务器带宽很容易被打满。预签名直传让客户端和对象存储之间直接通信,业务服务器只负责发凭证和落数据库记录。
2.3 提交状态的并发控制:防止重复提交与超时状态不对齐
在线作业提交批改系统最容易出现的并发问题,是学生快速点了两次「提交」按钮,结果生成了两条submission记录。前面那张表加了联合唯一键(homework_id, student_id, submit_no),只能保证单次提交幂等,但两次点击会生成两个不同的submit_no,依然会产生两条记录。
解决方式有两种。第一种是对提交动作加分布式锁,锁键用submit:lock:{homeworkId}:{studentId};第二种是在表设计上增加「是否最终提交」字段,每次新提交进来时把上一条有效记录的submit_status置为 3(已撤销),再把当前记录置为 1。第二种方案在多实例并发时会遇到竞态,两个请求同时读到上一条都是「有效」,都去更新,最后留下两条有效记录。所以稳妥做法是锁和状态更新一起上:
// 伪代码,示意提交的核心逻辑 public SubmitResult submitHomework(SubmitRequest req) { String lockKey = "submit:lock:" + req.getHomeworkId() + ":" + req.getStudentId(); RLock lock = redissonClient.getLock(lockKey); lock.lock(5, TimeUnit.SECONDS); try { // 检查是否已过截止时间 Homework homework = homeworkMapper.selectById(req.getHomeworkId()); if (homework.getDeadline().before(new Date())) { throw new BizException(40012, "提交时间已截止"); } // 生成新提交单号,并更新旧记录状态 String submitNo = generateSubmitNo(req.getHomeworkId(), req.getStudentId()); submissionMapper.disablePreviousValid(req.getHomeworkId(), req.getStudentId()); // 插入新提交记录 submissionMapper.insert(...); return SubmitResult.success(submitNo); } finally { lock.unlock(); } }锁的存在不是为了防止数据库唯一键冲突,而是为了保证「查当前有效提交 → 置为无效 → 插入新提交」这三步的原子性。没有锁的话,极端并发下会出现 A 请求把 B 请求刚插进去的新记录置为无效的情况。这里锁的超时时间设 5 秒、等待时间设 0 秒比较合理,因为提交动作本身很快,如果 5 秒还没执行完,多半是数据库出了问题,不如直接失败让学生重试,而不是排队等锁。
提示:状态字段不要只存「已提交 / 未提交」两个值。预留「已打回」「已撤销」「批改中」这几个中间态,后面对接教师端批改队列时会省很多事。
3. 批改链路:从人工打分到可扩展的自动评测
3.1 教师批改队列:状态驱动而不是列表刷新驱动
教师端批改最差的实现方式,是前端每 5 秒轮询一次「待批改列表」接口。这个方案在小班场景下勉强能用,但班级超过 100 人、每个学生有 3 个附件时,轮询会同时拖垮数据库和前端渲染。更适合的方式是引入队列语义:教师点「领取批改」时,后端锁定一批提交记录,锁定期间其他教师或助教看不到这些记录。
任务的领取和释放可以用一张review_task表来表达,不需要引入RabbitMQ或Kafka这种重组件。表设计如下:
CREATE TABLE review_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, homework_id BIGINT NOT NULL, submission_id BIGINT NOT NULL, reviewer_id BIGINT NOT NULL, task_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待批改,1已领取,2已提交批改,3打回重交', submit_no VARCHAR(32) NOT NULL, create_time DATETIME NOT NULL, review_time DATETIME DEFAULT NULL, -- 该任务当前批改版本对应的提交单号,用于区分学生补交后版本变化 version_no INT NOT NULL DEFAULT 1 );教师领取批改任务的 SQL 要使用原子条件更新,防止两个教师领到同一份作业:
UPDATE review_task SET reviewer_id = #{currentUserId}, task_status = 1 WHERE id = #{taskId} AND task_status = 0;如果更新影响行数为 0,说明任务已被别人领走,前端直接提示「该任务已被其他老师领取」。这里不需要SELECT FOR UPDATE,因为UPDATE本身的行锁已经保证了并发安全。任务打回时,task_status设置为 3,同时将对应submission记录的submit_status置为 2(已打回),学生端就会看到「被退回」状态并允许重新提交。重新提交会触发新的submission记录,此时要在review_task表中新增一条任务,并把version_no加 1,这样教师能看到「第 2 版」的标记。
3.2 自动批改的规则引擎:主观题不能碰,客观题全自动
在线作业提交批改系统真正能被计算机全自动判分的题型,是选择题、填空题、判断题和代码题。而简答题、论述题这类主观题,现阶段只能做关键词辅助打分,不能替代教师。把主观题硬塞给规则引擎做语义判分,是这类系统里最深的坑之一——学生会尝试用「写满字数 + 堆砌关键词」骗分,最后教师的公信力反而受损。
我推荐的自动批改核心是「按题型匹配不同的判定器」:
# 批改判定器分发示例:一个简单的函数映射 def grade_question(question_type: str, student_answer: str, standard_answer: str) -> dict: if question_type == "choice": result = grade_choice(student_answer, standard_answer) elif question_type == "fill_blank": result = grade_fill_blank(student_answer, standard_answer) elif question_type == "code": result = grade_code(student_answer, standard_answer) elif question_type == "short_answer": # 主观题只做关键词覆盖度统计,结果仅供参考,需要教师确认 result = grade_short_answer_keywords(student_answer, standard_answer) else: raise ValueError(f"unsupported question type: {question_type}") return result选择题判分最简单,但要注意答案是多项选择题时,学生提交的选项顺序可能与标准答案不同。比如标准答案是A,C,学生写C,A,如果直接做字符串比较会误判。正确的做法是把选项字符串拆成集合再比较:
def grade_choice(student_answer: str, standard_answer: str) -> dict: student_set = set(student_answer.replace(",", " ").split()) standard_set = set(standard_answer.replace(",", " ").split()) if student_set == standard_set: return {"full_score": True, "score": 5} if student_set.issubset(standard_set): return {"full_score": False, "partial_score": 2} return {"full_score": False, "score": 0}填空题的判定有一个细节容易被忽略:批量常量、超常量空格和中文标点要做归一化。比如标准答案是hello world,学生写hello world(两个空格)或hello,world(中文逗号),如果直接strip()后比较会误判。我一般会先做全角转半角、去除多余空白、再把标点统一为英文标点,最后做lower()比较。
代码题的自动评测就复杂多了。作业提交批改系统里的代码题,评测环境必须是隔离的容器环境,不能在应用服务器上直接执行学生的代码。学生代码里写一个死循环、一个rm -rf、或者申请大量内存,放在应用服务器上跑就是事故。实践中建议的做法是:把学生提交的代码文件和测试用例一起打包发给评测容器(Docker 容器),容器内执行编译和测试用例,再把标准输出和退出码返回给主系统。
3.3 代码题评测回调和超时处理:用异步队列避免HTTP长连接
评测一个代码题通常要经历「拉取代码 → 拉取测试用例 → 编译 → 运行 → 收集输出 → 比对结果」,这个流程耗时从几秒到几十秒不等,绝对不能让学生在 HTTP 请求里同步等待。正确的做法是把评测任务投递到消息队列,然后评测容器处理完把结果写回数据库,同时触发一个 WebSocket 通知推给前端。
有一个非常重要的防呆设计:评测结果的回调必须带提交单号和随机令牌,防止学生构造 HTTP 请求伪造评测结果。评测容器向主系统回调时,要带上submission_id和token两个参数,主系统校验令牌后才认为评测结果可信。令牌在创建评测任务时生成,存进 Redis 并设置 10 分钟过期,过期后评测结果直接丢弃。
超时设置也是必调的参数。不同语言的编译耗时差异很大,比如 Java 首次编译可能要拉 Maven 依赖、C++ 要链接库,但运行时长通常很短。判断题目的评测超时不能只设一个全局值,我按语言区分:
| 语言 | 编译超时 | 运行超时 |
|---|---|---|
| Python | 不编译,直接容器内运行 | 3 秒 |
| Java | 60 秒(含依赖下载) | 5 秒 |
| C/C++ | 15 秒 | 5 秒 |
| Go | 45 秒(含模块下载) | 5 秒 |
运行超时设 3~5 秒不是拍脑袋,而是因为教学场景的代码题通常是单文件、单算法逻辑,超过 3 秒大概率是死循环或复杂度爆炸。评测容器内部用timeout命令兜底,防止用户代码里的while True把容器拖死。
4. 成绩统计与查重:防止数据库被聚合查询拖垮
4.1 成绩分布的分桶查询:不要用 GROUP BY 做直方图
作业批改完成后,教师端几乎必看的一个页面是成绩分布直方图。很多系统的第一版实现是:
SELECT score, COUNT(*) FROM grade WHERE homework_id = ? GROUP BY score;这条 SQL 在作业提交人数 50 人时没有问题,但一旦做平时分加权、补交扣分等调整后,成绩会出现大量小数位,分组会变成每个分数一格,前端直方图直接碎掉。更严重的是,总分 100 分的作业、105 个学生提交,GROUP BY会产生上百行结果,前端只能自己再次分桶。
常规做法是把分桶逻辑放在 SQL 里,用CASE WHEN预先分桶:
SELECT CASE WHEN score >= 90 THEN '90-100' WHEN score >= 80 THEN '80-89' WHEN score >= 70 THEN '70-79' WHEN score >= 60 THEN '60-69' ELSE '0-59' END AS score_band, COUNT(*) AS student_count FROM grade WHERE homework_id = 1024 GROUP BY score_band ORDER BY score_band;这里要注意的是GROUP BY score_band在 MySQL 里可以针对SELECT别名分组,但为了可移植性,我建议在GROUP BY里重复写一遍CASE WHEN表达式,而不是依赖别名。另外,成绩分布接口的数据更新频率极低,完全可以加一层Redis缓存,缓存键用grade:distribution:{homeworkId},设置 5 分钟过期,当批改结果写入时主动删除缓存即可。不要做「数据变更后主动刷新缓存」这种操作,直接删缓存让下一次请求重新回源即可,减少不一致窗口。
4.2 相似度检测:文本哈希与滑动窗口怎么搭配
作业查重是很多学校实际部署后呼声最高的功能。最粗糙的做法是对整个文件做一次MD5,相同的文件会被挑出来,但学生改两个空格、调换段落顺序,MD5就完全变了。工程上兼顾效果和性能的常见方案是「滑动窗口 + 局部敏感哈希」的思路:把文本切成固定长度的片段,每个片段算一个哈希,再对哈希集合做 Jaccard 相似度。
阶段一不用做得太重,只需要让学生在交作业时服务端计算一次指纹并存储。文本类作业(如实验报告、论文)可以用下面的方式提取指纹:
def extract_shingles(text: str, k: int = 5) -> set: # 简单的 k-shingle 提取:将文本按字符滑窗,切为长度为 k 的片段 cleaned = ''.join(text.split()) if len(cleaned) < k: return {cleaned} if cleaned else set() return {cleaned[i:i+k] for i in range(len(cleaned) - k + 1)}k取 5 对于中文字符,相当于以 5 个字符为单位做滑窗。这个值选大了会漏掉局部相似,选小了会误伤正常公共表述。对代码作业,k建议取 4,因为代码中关键词和语法结构重复度高,取太大反而识别不出「变量改名但结构照抄」的作业。
实际落地时不要把每份作业和全班所有作业做两两比对,那是 O(n^2) 的复杂度,100 份作业要比较 4950 对。常见做法是按照shingle建立倒排索引:先算每个 shingle 出现在哪些作业里,再只对有公共 shingle 的作业对计算相似度。这样可以过滤掉大量完全不相关的作业对。
4.3 批量导出成绩单:用异步任务替代同步流式响应
教师端「导出 CSV」在数据量大时会是一个典型性能瓶颈。如果直接在 HTTP 请求里查全量数据并写 CSV 流式返回,教师浏览器等了 30 秒拿到的文件可能是几 MB,看起来能用,但同一时刻如果有 20 个教师同时导出,数据库连接池会被长查询占满,影响正常提交接口。
我一般建议做异步导出:教师点击导出后,后端创建一个导出任务,任务异步执行查询和写文件,完成后把文件地址推送给前端。前端轮询任务状态或通过 WebSocket 主动通知,教师下载文件。任务表结构可以极简:
CREATE TABLE export_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_type VARCHAR(32) NOT NULL COMMENT 'homework_grade, submission_record 等', params JSON NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0排队中,1处理中,2完成,3失败', file_key VARCHAR(255) DEFAULT NULL, operator_id BIGINT NOT NULL, create_time DATETIME NOT NULL, finish_time DATETIME DEFAULT NULL );params字段用JSON存查询条件(如homeworkId、班级 ID、分数段),这样不用为每种导出需求建一张新表。异步导出还有一个额外的好处:可以在导出任务里做「数据脱敏」和「格式规整」,比如把学生学号统一导出为文本格式,避免前端导出组件把长学号自动转成科学计数法而丢失精度。
5. 作业批改系统的缓存策略:Redis 怎么用才不成为新的瓶颈
在作业提交批改系统里,Redis最常见的三个用途是缓存学生提交状态、分布式锁、缓存作业配置。但很多项目把 Redis 当成了万能加速器,什么数据都往里塞,最后反而引入了缓存和数据库不一致的问题。这里分享三个我调试过多次的缓存细节。
第一个是学生端「提交状态」的缓存。学生打开作业列表时,需要同时看到每个作业的截止时间、是否已提交、批改状态,这是一个典型的读多写少场景。如果每次请求都去查submission表,数据库压力不小。把这些状态拼成一个 JSON 对象缓存在 Redis 里,key 为submit:status:{studentId},value 是一个 Map,key 为homeworkId,value 为提交状态。学生提交作业成功时,更新缓存中对应作业的状态值即可。这里有一个关键点:更新缓存要写完整覆盖,不要做读-改-写。因为多实例并发下读-改-写会出现丢失更新,直接重新查询数据库中最新的状态再覆盖整个 key 是更安全的做法。
第二个是分布式锁本身的 key 设计。并发提交时用锁保护状态流转,锁的粒度建议按学生维度而非全局维度。全局锁submit:lock:global会把所有学生的提交请求串行化,一个学生提交慢会拖垮其他人。按submit:lock:{studentId}加锁,只锁同一个学生的并发提交,不同学生之间互不干扰。如果同一个学生同时用手机和电脑登录提交,锁也能保证后提交的请求等待前一个完成。
第三个是缓存穿透的防御。某个作业没有学生提交时,submission表查询结果为空,如果把空结果也缓存到 Redis(缓存值为空 JSON),可以防穿透。但要注意设置较短的过期时间,比如 3 分钟。如果设置太长,学生提交成功后,教师端看到的仍然是「无提交」,就会出问题。我一般会建立缓存删除协议,提交成功时主动DEL对应缓存,而不是等过期。
提示:Redis 缓存的 key 里如果包含
homeworkId和studentId,要统一序列化格式,不要一处拼成1024_10001、另一处拼成1024:10001,排查问题时你会被这种不一致浪费一晚上。
最后补充一个验证技巧。整个在线作业提交批改系统部署完成后,不要只在浏览器里点一遍。用JMeter或wrk模拟 100 个并发学生同时提交 2 MB 文件,观察应用服务器的GC频率、对象存储的上传带宽、数据库锁等待时间。这三个指标分别代表 CPU 内存、网络 IO 和数据库锁三个不同层面的瓶颈。只有这个压测过了,系统才能真正扛住「周一早上八点全班同时交作业」的流量峰值。
本文还有配套的精品资源,点击获取