
简介一套基于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(funsupported 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两个空格或helloworld中文逗号如果直接strip()后比较会误判。我一般会先做全角转半角、去除多余空白、再把标点统一为英文标点最后做lower()比较。代码题的自动评测就复杂多了。作业提交批改系统里的代码题评测环境必须是隔离的容器环境不能在应用服务器上直接执行学生的代码。学生代码里写一个死循环、一个rm -rf、或者申请大量内存放在应用服务器上跑就是事故。实践中建议的做法是把学生提交的代码文件和测试用例一起打包发给评测容器Docker 容器容器内执行编译和测试用例再把标准输出和退出码返回给主系统。3.3 代码题评测回调和超时处理用异步队列避免HTTP长连接评测一个代码题通常要经历「拉取代码 → 拉取测试用例 → 编译 → 运行 → 收集输出 → 比对结果」这个流程耗时从几秒到几十秒不等绝对不能让学生在 HTTP 请求里同步等待。正确的做法是把评测任务投递到消息队列然后评测容器处理完把结果写回数据库同时触发一个 WebSocket 通知推给前端。有一个非常重要的防呆设计评测结果的回调必须带提交单号和随机令牌防止学生构造 HTTP 请求伪造评测结果。评测容器向主系统回调时要带上submission_id和token两个参数主系统校验令牌后才认为评测结果可信。令牌在创建评测任务时生成存进 Redis 并设置 10 分钟过期过期后评测结果直接丢弃。超时设置也是必调的参数。不同语言的编译耗时差异很大比如 Java 首次编译可能要拉 Maven 依赖、C 要链接库但运行时长通常很短。判断题目的评测超时不能只设一个全局值我按语言区分语言编译超时运行超时Python不编译直接容器内运行3 秒Java60 秒含依赖下载5 秒C/C15 秒5 秒Go45 秒含模块下载5 秒运行超时设 35 秒不是拍脑袋而是因为教学场景的代码题通常是单文件、单算法逻辑超过 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:ik] 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 是一个 Mapkey 为homeworkIdvalue 为提交状态。学生提交作业成功时更新缓存中对应作业的状态值即可。这里有一个关键点更新缓存要写完整覆盖不要做读-改-写。因为多实例并发下读-改-写会出现丢失更新直接重新查询数据库中最新的状态再覆盖整个 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 和数据库锁三个不同层面的瓶颈。只有这个压测过了系统才能真正扛住「周一早上八点全班同时交作业」的流量峰值。本文还有配套的精品资源点击获取