简介:这是一套基于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_length=100) teacher = models.ForeignKey(User, on_delete=models.CASCADE, related_name='taught_courses') students = models.ManyToManyField(User, related_name='joined_courses') class Assignment(models.Model): JUDGE_CHOICES = [('auto', '自动判题'), ('manual', '人工批改'), ('hybrid', '混合')] course = models.ForeignKey(Course, on_delete=models.CASCADE) title = models.CharField(max_length=200) deadline = models.DateTimeField() full_score = models.PositiveIntegerField(default=100) judge_type = models.CharField(max_length=10, choices=JUDGE_CHOICES, default='auto') # 自动判题时存放测试用例的路径或配置 test_config = models.JSONField(default=dict, blank=True) class Submission(models.Model): STATUS = [('pending', '排队中'), ('running', '判题中'), ('done', '已完成'), ('error', '判题异常')] assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE) student = models.ForeignKey(User, on_delete=models.CASCADE) file = models.FileField(upload_to='submissions/%Y/%m/') language = models.CharField(max_length=20, default='python') status = models.CharField(max_length=10, choices=STATUS, default='pending') submitted_at = models.DateTimeField(auto_now_add=True) cost_ms = models.PositiveIntegerField(default=0) # 判题耗时 class Grade(models.Model): submission = models.ForeignKey(Submission, on_delete=models.CASCADE, related_name='grades') score = models.DecimalField(max_digits=5, decimal_places=2) detail = models.JSONField(default=dict) # 每个用例的通过情况 graded_by = models.CharField(max_length=20, default='auto') # auto / 教师用户名 created_at = models.DateTimeField(auto_now_add=True)这段模型里几个参数值得说清楚。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(bind=True, max_retries=2, default_retry_delay=5) def judge_submission(self, submission_id): sub = Submission.objects.get(id=submission_id) sub.status = 'running' sub.save(update_fields=['status']) try: result = run_test_cases(sub) # 返回 {score, detail, cost_ms} Grade.objects.create( submission=sub, score=result['score'], detail=result['detail'], graded_by='auto', ) 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(exc=exc) finally: sub.save(update_fields=['status', 'cost_ms'])max_retries=2是血泪经验:判题失败很多时候是环境抖动(临时文件没清干净、依赖没装好),重试一次能救回大半,但重试次数不能多,否则一个死循环的提交会把 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], input=case['input'].encode(), capture_output=True, timeout=timeout, cwd=workdir, 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这段代码有几个关键参数必须解释。timeout=timeout是单用例超时,不是整个判题超时,因为一个提交可能有十个用例,卡在第一个不该拖垮后面。preexec_fn里用resource.setrlimit限制RLIMIT_AS(地址空间),这是 Linux 下限制内存的常用手段,Windows 上不支持,所以生产环境建议跑在 Linux 容器里。capture_output=True同时抓 stdout 和 stderr,但这里只比对 stdout,stderr 留给调试用。out[:200]截断输出,防止学生打印海量内容把数据库撑爆——这是踩过的坑,有学生写了死循环打印,detail字段直接存了几十兆。
3.2 超时、内存和并发:三个必须设的上限
判题系统最怕的不是判错,是被一个恶意或失误的提交拖垮。三个上限必须设:单用例超时(上面已设)、单提交总超时、并发判题数。单提交总超时用 Celery 的time_limit控制,并发数用 worker 的--concurrency控制。
# 启动 Celery worker,限制并发为 4,硬超时 60 秒 celery -A myproject worker -l info --concurrency=4 \ --time-limit=60 --soft-time-limit=50--concurrency=4意味着同时最多跑 4 个判题任务,超出的排队。这个数怎么定?看机器核数和单个判题的平均耗时。如果单核跑一个判题平均 2 秒,4 核机器设 4 到 8 都行,设太高会导致上下文切换开销大、内存吃紧。--time-limit=60是硬超时,到点直接杀 worker 进程;--soft-time-limit=50是软超时,给任务一个抛异常的机会,能优雅记录状态。两个都设,软的超时时间要小于硬的,留出写数据库的时间。
注意:
--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, flags=re.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, threshold=0.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里先去掉注释再比对,是因为注释相似不代表代码抄袭,但注释不同也不代表没抄,所以去掉最公平。threshold=0.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-child=50,每跑 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 = True,TIME_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', password='x') self.student = User.objects.create_user('s1', password='x') self.course = Course.objects.create(name='Python基础', teacher=self.teacher) self.course.students.add(self.student) self.assignment = Assignment.objects.create( course=self.course, title='求和', full_score=100, test_config={'cases': [{'input': '1 2\n', 'expect': '3'}], 'timeout': 2}, ) def test_correct_submission_scores_full(self): sub = Submission.objects.create( assignment=self.assignment, student=self.student, file='submissions/test/main.py', language='python', ) # 直接调判题函数,不走 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', type=int) parser.add_argument('--status', default='done', help='只重判该状态的提交') def handle(self, *args, **opts): qs = Submission.objects.filter( assignment_id=opts['assignment_id'], status=opts['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互斥。这套系统真正难的不是判题算法,是把"提交—判题—评分"这条链路的每个异常分支都想到。如果你正准备动手,建议先把状态机和模型定死,再写判题逻辑,顺序反了后面全是返工。希望帮到你。
本文还有配套的精品资源,点击获取