
简介这是一套基于Python与Django框架实现的网上作业批改系统完整项目源码面向计算机、人工智能、通信工程、自动化等相关专业的在校学生、教师及企业员工尤其适合作为毕业设计、课程设计或项目初期立项的参考范例。资源包共27个文件约602KB涵盖html页面模板、xml与json配置、css样式、js脚本、xmind思维导图、vsdx流程图、psd设计稿、sqlite数据库及README说明文档等从需求分析、页面设计到功能实现均有对应文件支撑。项目代码经过实际运行测试功能完整可用答辩评审平均分达95分已有171人学习关注。读者可借助其中的需求功能分析、系统流程图与功能模块图快速理解整体架构并在此基础上修改扩展实现个性化功能适合小白进阶学习也可用于毕设、课设或作业演示等场景。1. 网上作业批改系统用 Django 落地从能跑到敢给学生用差在哪带过编程课的老师大概都经历过这种场面一个班五十份作业压缩包解压出来命名五花八门逐份打开、逐行看、逐个回邮件写评语两小时过去只批了十份。网上作业批改系统要解决的就是这件事——把收作业、判作业、发反馈这条链路搬到 Web 上让学生在线提交让系统自动跑测试用例或按规则打分老师只处理异常和主观题。用 Python Django 实现是这条路上最稳的选型Django 自带 ORM、Admin、认证和表单作业、班级、提交记录这些典型的关系型数据几乎不用自己造轮子新手跟着 django 项目实战新手的路径也能在一周内搭出可用的骨架。但能跑和敢给学生用是两回事。真正上线过的系统难点从来不在写个提交表单而在判题沙箱怎么隔离、并发提交怎么排队、代码相似度怎么查、成绩被误改怎么追溯。这篇笔记按数据模型怎么设计 → 判题怎么落地 → 并发和沙箱怎么防翻车 → 上线前怎么验证的顺序讲适合正在做课程项目、毕设或者想给培训机构搭一套内部批改平台的开发者。读完你应该能判断这套方案值不值得投入以及哪些坑必须提前绕开。2. 数据模型与判题流程先把作业、提交、评分三张表理清楚2.1 为什么模型设计决定了后面所有功能的难度网上作业批改系统的核心不是批改这个动作而是一次提交对应一次评分结果这条数据链。很多人一上来就写视图函数结果做到一半发现同一个学生重复提交怎么算老师改了评分标准历史成绩要不要重算这些问题的答案全在模型层。Django 的 ORM 在这里的优势是关系表达清晰ForeignKey和OneToOneField能把班级—作业—提交—评分四层关系一次定死后面写查询、做统计、导出成绩都省事。我一般会把模型拆成四块Course课程/班级、Assignment作业含截止时间、满分、判题类型、Submission一次提交含学生、文件、状态、耗时、Grade评分结果含得分、明细、批改人。关键点是Submission和Grade分开因为一次提交可能被重判老师手动改、判题脚本升级后重跑分开后历史可追溯这就是成绩的后悔药。# models.py from django.db import models from django.contrib.auth.models import User class Course(models.Model): name models.CharField(max_length100) teacher models.ForeignKey(User, on_deletemodels.CASCADE, related_nametaught_courses) students models.ManyToManyField(User, related_namejoined_courses) class Assignment(models.Model): JUDGE_CHOICES [(auto, 自动判题), (manual, 人工批改), (hybrid, 混合)] course models.ForeignKey(Course, on_deletemodels.CASCADE) title models.CharField(max_length200) deadline models.DateTimeField() full_score models.PositiveIntegerField(default100) judge_type models.CharField(max_length10, choicesJUDGE_CHOICES, defaultauto) # 自动判题时存放测试用例的路径或配置 test_config models.JSONField(defaultdict, blankTrue) class Submission(models.Model): STATUS [(pending, 排队中), (running, 判题中), (done, 已完成), (error, 判题异常)] assignment models.ForeignKey(Assignment, on_deletemodels.CASCADE) student models.ForeignKey(User, on_deletemodels.CASCADE) file models.FileField(upload_tosubmissions/%Y/%m/) language models.CharField(max_length20, defaultpython) status models.CharField(max_length10, choicesSTATUS, defaultpending) submitted_at models.DateTimeField(auto_now_addTrue) cost_ms models.PositiveIntegerField(default0) # 判题耗时 class Grade(models.Model): submission models.ForeignKey(Submission, on_deletemodels.CASCADE, related_namegrades) score models.DecimalField(max_digits5, decimal_places2) detail models.JSONField(defaultdict) # 每个用例的通过情况 graded_by models.CharField(max_length20, defaultauto) # auto / 教师用户名 created_at models.DateTimeField(auto_now_addTrue)这段模型里几个参数值得说清楚。test_config用JSONField而不是单独建表是因为不同作业的判题配置差异大有的比输出、有的跑单元测试、有的查代码相似度塞进 JSON 里改起来灵活Django 3.1 以后对JSONField的查询支持也够用。cost_ms单独存而不是每次现算是为了后面做判题超时统计时不用扫日志。Grade用ForeignKey而不是OneToOneField就是为了保留重判历史——查询时取grades.order_by(-created_at).first()拿最新成绩。2.2 判题流程的四个阶段和状态机模型定好后判题流程要按状态机走不能一个函数从头跑到尾。我一般拆成四段接收提交写Submission状态pending→ 入队推给判题 worker→ 执行判题状态running跑测试用例→ 写回结果状态done或error写Grade。这样拆的好处是任何一段崩了都能从数据库状态恢复不会出现提交了但没成绩也没报错的黑匣子。# tasks.py —— 用 Celery 做异步判题 from celery import shared_task from .models import Submission, Grade from .judge import run_test_cases # 判题核心见下一章 shared_task(bindTrue, max_retries2, default_retry_delay5) def judge_submission(self, submission_id): sub Submission.objects.get(idsubmission_id) sub.status running sub.save(update_fields[status]) try: result run_test_cases(sub) # 返回 {score, detail, cost_ms} Grade.objects.create( submissionsub, scoreresult[score], detailresult[detail], graded_byauto, ) sub.status done sub.cost_ms result[cost_ms] except Exception as exc: sub.status error sub.save(update_fields[status, cost_ms]) raise self.retry(excexc) finally: sub.save(update_fields[status, cost_ms])max_retries2是血泪经验判题失败很多时候是环境抖动临时文件没清干净、依赖没装好重试一次能救回大半但重试次数不能多否则一个死循环的提交会把 worker 占死。update_fields指定字段能避免并发写覆盖——两个 worker 同时改同一个Submission时只更新自己负责的字段减少脏写。状态从pending到running的这一步必须在 worker 里做不能在视图里做否则用户看到判题中但其实还没进队列体验会很怪。2.3 用 Admin 快速搭出教师端管理界面Django 的 Admin 是这套系统里最被低估的部分。教师端不需要花哨 UI需要的是一眼看到谁没交、谁分低、批量导出。把模型注册进 Admin配好list_display和list_filter半小时就能出一个能用的后台。# admin.py from django.contrib import admin from .models import Course, Assignment, Submission, Grade admin.register(Submission) class SubmissionAdmin(admin.ModelAdmin): list_display (id, assignment, student, status, cost_ms, submitted_at) list_filter (status, assignment__course, language) search_fields (student__username, assignment__title) date_hierarchy submitted_at actions [rerun_judge] admin.action(description重新判题) def rerun_judge(self, request, queryset): from .tasks import judge_submission for sub in queryset: sub.status pending sub.save(update_fields[status]) judge_submission.delay(sub.id)date_hierarchy让教师按日期翻提交记录list_filter里用assignment__course做跨表过滤这是 Django Admin 支持的双下划线语法能直接按课程筛。rerun_judge这个 action 是刚需——判题脚本升级后老师需要一键重跑某批提交没有这个功能就得手动改数据库。注意 action 里先把状态改回pending再入队避免重复点击时同一个提交被跑两次。3. 判题核心测试用例执行、超时控制与代码相似度3.1 自动判题到底在判什么自动判题的常见做法分三档最简的是比对标准输出适合算法题中间的是跑单元测试适合工程题最复杂的是静态分析加相似度检测适合防抄袭。网上作业批改系统里前两档覆盖 80% 的场景第三档按需加。我一般把判题逻辑写成一个独立模块judge.py和 Django 解耦这样本地测试判题逻辑时不用起整个 Web 服务。# judge.py import subprocess, tempfile, os, time, resource def run_test_cases(submission): 执行提交的代码逐个跑测试用例返回得分和明细 config submission.assignment.test_config cases config.get(cases, []) # [{input: ..., expect: ...}] timeout config.get(timeout, 3) # 单用例超时秒数 mem_limit config.get(mem_mb, 256) # 内存上限 MB passed, detail 0, [] with tempfile.TemporaryDirectory() as workdir: src os.path.join(workdir, main.py) with open(submission.file.path, rb) as f: code f.read() with open(src, wb) as f: f.write(code) for idx, case in enumerate(cases): start time.time() try: proc subprocess.run( [python3, src], inputcase[input].encode(), capture_outputTrue, timeouttimeout, cwdworkdir, preexec_fn_limit_memory(mem_limit), # 见下方说明 ) out proc.stdout.decode().strip() ok (out case[expect].strip()) except subprocess.TimeoutExpired: ok, out False, TIMEOUT cost int((time.time() - start) * 1000) detail.append({case: idx, ok: ok, output: out[:200], cost_ms: cost}) if ok: passed 1 score round(passed / len(cases) * submission.assignment.full_score, 2) if cases else 0 return {score: score, detail: detail, cost_ms: sum(d[cost_ms] for d in detail)} def _limit_memory(mem_mb): 返回一个 preexec_fn限制子进程内存 def setter(): limit mem_mb * 1024 * 1024 resource.setrlimit(resource.RLIMIT_AS, (limit, limit)) return setter这段代码有几个关键参数必须解释。timeouttimeout是单用例超时不是整个判题超时因为一个提交可能有十个用例卡在第一个不该拖垮后面。preexec_fn里用resource.setrlimit限制RLIMIT_AS地址空间这是 Linux 下限制内存的常用手段Windows 上不支持所以生产环境建议跑在 Linux 容器里。capture_outputTrue同时抓 stdout 和 stderr但这里只比对 stdoutstderr 留给调试用。out[:200]截断输出防止学生打印海量内容把数据库撑爆——这是踩过的坑有学生写了死循环打印detail字段直接存了几十兆。3.2 超时、内存和并发三个必须设的上限判题系统最怕的不是判错是被一个恶意或失误的提交拖垮。三个上限必须设单用例超时上面已设、单提交总超时、并发判题数。单提交总超时用 Celery 的time_limit控制并发数用 worker 的--concurrency控制。# 启动 Celery worker限制并发为 4硬超时 60 秒 celery -A myproject worker -l info --concurrency4 \ --time-limit60 --soft-time-limit50--concurrency4意味着同时最多跑 4 个判题任务超出的排队。这个数怎么定看机器核数和单个判题的平均耗时。如果单核跑一个判题平均 2 秒4 核机器设 4 到 8 都行设太高会导致上下文切换开销大、内存吃紧。--time-limit60是硬超时到点直接杀 worker 进程--soft-time-limit50是软超时给任务一个抛异常的机会能优雅记录状态。两个都设软的超时时间要小于硬的留出写数据库的时间。注意--time-limit杀的是 worker 子进程如果判题逻辑里又开了子进程比如上面的subprocess.run要确保子进程也被清理否则会留下僵尸进程。常见做法是在preexec_fn里设置进程组超时时杀整个组。3.3 代码相似度检测防抄袭的轻量方案作业批改绕不开抄袭检测。重型方案是 AST 比对或 token 序列比对轻量方案是归一化后算相似度。我一般用difflib.SequenceMatcher做初筛把相似度超过阈值的提交标出来给老师人工确认不直接判零分——因为有些相似是模板代码导致的直接判零会误伤。# similarity.py import difflib, re def normalize(code: str) - str: 去掉注释、空行、多余空格降低格式差异干扰 code re.sub(r#.*, , code) # 去单行注释 code re.sub(r.*?, , code, flagsre.S) # 去多行注释 lines [ln.strip() for ln in code.splitlines() if ln.strip()] return \n.join(lines) def similarity(a: str, b: str) - float: return difflib.SequenceMatcher(None, normalize(a), normalize(b)).ratio() # 用法对同一作业下的所有提交两两比对 def find_suspicious(submissions, threshold0.85): pairs [] for i in range(len(submissions)): for j in range(i 1, len(submissions)): s similarity(submissions[i].code, submissions[j].code) if s threshold: pairs.append((submissions[i].id, submissions[j].id, round(s, 3))) return pairsnormalize里先去掉注释再比对是因为注释相似不代表代码抄袭但注释不同也不代表没抄所以去掉最公平。threshold0.85是经验值低于这个值误报太多高于这个值漏报太多具体按课程调整。两两比对是 O(n²)一个班五十人就是 1225 次比对SequenceMatcher单次几毫秒总共几秒能跑完不用上更复杂的算法。如果班级上百人再考虑用哈希分桶或 MinHash 降复杂度。4. 避坑与排查判题系统上线后最容易翻车的五件事4.1 提交文件路径穿越现象学生提交的文件名带../判题时把文件写到了预期目录之外甚至覆盖了系统文件。原因直接用submission.file.name拼路径没做校验。解决Django 的FileField默认会用upload_to生成路径并做一定处理但判题时如果自己拼路径一定要用os.path.basename取文件名或者干脆用tempfile生成随机名永远不要信任用户提供的文件名。4.2 判题 worker 内存泄漏现象系统跑几天后 worker 越来越慢最后 OOM 被杀。原因判题时加载了学生代码里的全局对象或者subprocess没正确回收。解决给 worker 设--max-tasks-per-child50每跑 50 个任务重启一次子进程把泄漏的内存释放掉。这个参数是 Celery 的保命设置生产环境必加。4.3 数据库连接在异步任务里失效现象Celery 任务里查数据库报connection already closed。原因Django 的数据库连接默认在请求结束时关闭但 Celery worker 是长驻进程连接可能被数据库端超时断开。解决在 Celery 配置里设CONN_MAX_AGE或者用django-db-connection-pool做连接池。最简做法是在任务开头调django.db.close_old_connections()强制检查并重建连接。4.4 成绩被并发重判覆盖现象老师手动改了成绩同时自动重判任务也在跑最后成绩变回了自动判的分。原因两个写操作没有互斥。解决Grade表加一个is_final字段老师手动改分时置True自动判题写回前先检查最新Grade的is_final为True就跳过。这是最简单可靠的互斥方案比加锁轻量。4.5 时区导致的截止时间误判现象学生明明在截止前提交系统却标记为迟交。原因USE_TZ没开或者前端传的时间和服务器时区不一致。解决Django 配置里设USE_TZ TrueTIME_ZONE Asia/Shanghai所有时间存 UTC展示时用模板过滤器转本地时区。迟交判断统一用timezone.now()和assignment.deadline比较不要用datetime.now()。5. 上线前的验证清单与一个提效技巧5.1 用 Django 测试框架把判题逻辑锁死判题逻辑一旦上线改动风险很高必须有测试兜底。Django 自带的TestCase配合override_settings能覆盖大部分场景。我一般会写三类测试正常提交得分正确、超时提交状态为error、重复提交不产生重复Grade。# tests.py from django.test import TestCase from django.contrib.auth.models import User from .models import Course, Assignment, Submission from .tasks import judge_submission class JudgeFlowTest(TestCase): def setUp(self): self.teacher User.objects.create_user(t1, passwordx) self.student User.objects.create_user(s1, passwordx) self.course Course.objects.create(namePython基础, teacherself.teacher) self.course.students.add(self.student) self.assignment Assignment.objects.create( courseself.course, title求和, full_score100, test_config{cases: [{input: 1 2\n, expect: 3}], timeout: 2}, ) def test_correct_submission_scores_full(self): sub Submission.objects.create( assignmentself.assignment, studentself.student, filesubmissions/test/main.py, languagepython, ) # 直接调判题函数不走 Celery测试更快 from .judge import run_test_cases result run_test_cases(sub) self.assertEqual(result[score], 100) self.assertTrue(result[detail][0][ok])测试里直接调run_test_cases而不是judge_submission.delay是为了不依赖 Celery broker跑得快。setUp里造数据用create_user而不是create_superuser因为判题流程不需要管理员权限。这类测试跑一次几秒提交前跑一遍能挡住大部分低级错误。5.2 用管理命令批量重判历史提交判题脚本升级后历史提交需要重跑。写个 Django 管理命令比在 shell 里手敲循环靠谱能复用、能加参数、能记日志。# management/commands/rerun_judge.py from django.core.management.base import BaseCommand from judge_app.models import Submission from judge_app.tasks import judge_submission class Command(BaseCommand): help 批量重判指定作业的提交 def add_arguments(self, parser): parser.add_argument(assignment_id, typeint) parser.add_argument(--status, defaultdone, help只重判该状态的提交) def handle(self, *args, **opts): qs Submission.objects.filter( assignment_idopts[assignment_id], statusopts[status]) self.stdout.write(f待重判 {qs.count()} 条) for sub in qs.iterator(): sub.status pending sub.save(update_fields[status]) judge_submission.delay(sub.id) self.stdout.write(self.style.SUCCESS(已全部入队))iterator()避免一次性把大量记录加载进内存--status参数让老师能只重判成功的提交失败的通常要单独看。这个命令配合前面的 Admin action覆盖了单条重判和批量重判两种需求。5.3 一个提效技巧把判题结果缓存到 Redis同一份代码被重复提交学生手抖点两次、网络重试时没必要跑两遍判题。用代码内容的哈希做 key判题结果做 value缓存到 Redis命中就直接写Grade。这个优化在提交高峰期能省掉三成以上的判题开销。实现上在judge_submission开头算hashlib.sha256(code).hexdigest()查缓存命中就跳过run_test_cases。注意缓存要设过期时间比如 24 小时避免学生改了代码但哈希碰撞导致误用旧结果——虽然 SHA256 碰撞概率极低但过期时间能兜住判题配置变更的情况。我自己踩过最深的一个坑是早期没做状态机判题函数里任何一步抛异常Submission就永远卡在running学生那边显示判题中直到天荒地老。后来加了error状态和重试又发现重试会把已经写了一半的Grade搞重复才补上is_final互斥。这套系统真正难的不是判题算法是把提交—判题—评分这条链路的每个异常分支都想到。如果你正准备动手建议先把状态机和模型定死再写判题逻辑顺序反了后面全是返工。希望帮到你。本文还有配套的精品资源点击获取