☰
Django在线考试系统数据库设计与事务处理实战
2026/9/25 1:18:59 网站建设 项目流程

简介:这是一套面向高校计算机相关专业学生与教师的在线考试系统完整项目源码,基于Python与Django框架开发,适用于毕业设计、课程作业及教学实训场景。系统采用管理员、教师、学生三角色分级权限体系,覆盖用户注册登录、班级课程关联、题库维护、智能组卷、考试发布、在线答题与成绩统计等核心流程,并支持单选、多选、判断、填空、简答及Python编程题在线运行等题型。压缩包共607个文件,约21.63MB,以Vue前端组件、JavaScript脚本、Python后端代码、SVG图标与图片资源为主,另含SQL建库脚本、配置文件及数据库说明文档,前后端分离结构清晰。已有66人学习下载。读者可获得可直接运行的工程源码、数据库设计文档与完整业务模块实现,便于二次开发、答辩演示与功能扩展参考。

1. 在线考试系统为什么总在“交卷那一刻”翻车

平时跑得好好的在线考试系统,一到集中交卷就出问题:有人答案没保存上,有人刷新后选项全空,后台还冒出一堆重复提交记录。这类故障跟 Django 本身关系不大,根子往往在数据模型和事务边界没设计好。这套基于 Python + Django 的在线考试系统,要解决的就是把“出题、组卷、答题、判分、成绩归档”整条链路用一套可维护的数据库结构串起来,再配一份能直接照着建库的数据库文档。它适合两类人:一类是拿它当 Django 项目实战练手的新手,想搞懂真实业务里模型怎么拆;另一类是已经上手 Django、但一遇到考试这种“高并发写 + 强一致”场景就踩坑的开发者。下面从建库讲到判分,把能抄的代码和参数都摊开。

2. 先把数据库文档立住:在线考试系统的表结构怎么拆

在线考试系统看着简单,无非是题目、试卷、考生、成绩,但真拆起来,最容易翻车的就是“试卷和题目的关系”以及“一次作答怎么存”。数据库文档不是写完就扔的摆设,它是后面所有 Django 模型、查询、判分逻辑的地基。地基歪了,后面写多少代码都是补丁摞补丁。

2.1 核心实体与关系:从 ER 到物理表

先把实体列清楚。一个可用的在线考试系统,至少需要这几张核心表:用户表(考生和教师共用,靠角色字段区分)、科目/分类表、题目表、选项表、试卷表、试卷-题目关联表、考试记录表(一次作答)、答题明细表、成绩表。这里有个关键决策:选项到底单独建表,还是塞进题目表的一个 JSON 字段?

我的做法是选择题单独建表。原因是判分时要按选项 ID 精确比对,JSON 字段虽然 Django 的JSONField支持,但查询和约束都弱,后期统计“某选项被选了多少次”会很别扭。题目类型(单选、多选、判断、简答)用question_type字段区分,简答题不建选项,判分走人工或关键词匹配。

试卷和题目的关系用中间表exam_question,字段包括exam_id、question_id、score、sort_order。注意score放在关联表而不是题目表,因为同一道题在不同试卷里分值可能不同,这是很多人第一版会踩的坑。

表名关键字段说明
userid, username, role, passwordrole 区分 student/teacher
questionid, subject_id, question_type, content, answeranswer 存标准答案
optionid, question_id, option_key, option_text单选/多选选项
examid, title, total_score, duration, start_time, end_time试卷主表
exam_questionexam_id, question_id, score, sort_order组卷关联
exam_recordid, exam_id, user_id, status, submit_time一次作答
answer_detailid, record_id, question_id, user_answer, is_correct, got_score答题明细

2.2 用 Django 模型把表结构落地

数据库文档定好后,直接映射成 Django 模型。下面这段是核心部分,字段类型和约束都按上面的表来。

# models.py from django.db import models from django.contrib.auth.models import AbstractUser class User(AbstractUser): ROLE_CHOICES = (('student', '学生'), ('teacher', '教师')) role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='student') class Question(models.Model): TYPE_CHOICES = (('single', '单选'), ('multi', '多选'), ('judge', '判断'), ('essay', '简答')) subject = models.CharField(max_length=50) question_type = models.CharField(max_length=10, choices=TYPE_CHOICES) content = models.TextField() answer = models.CharField(max_length=255, help_text='多选答案用逗号分隔,如 A,C') created_at = models.DateTimeField(auto_now_add=True) class Option(models.Model): question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='options') option_key = models.CharField(max_length=2) # A/B/C/D option_text = models.CharField(max_length=255) class Exam(models.Model): title = models.CharField(max_length=100) total_score = models.IntegerField(default=100) duration = models.IntegerField(help_text='考试时长,单位分钟') start_time = models.DateTimeField() end_time = models.DateTimeField() questions = models.ManyToManyField(Question, through='ExamQuestion') class ExamQuestion(models.Model): exam = models.ForeignKey(Exam, on_delete=models.CASCADE) question = models.ForeignKey(Question, on_delete=models.CASCADE) score = models.IntegerField(default=5) sort_order = models.IntegerField(default=0) class ExamRecord(models.Model): STATUS = (('ongoing', '进行中'), ('submitted', '已交卷'), ('graded', '已判分')) exam = models.ForeignKey(Exam, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) status = models.CharField(max_length=10, choices=STATUS, default='ongoing') submit_time = models.DateTimeField(null=True, blank=True) total_got = models.IntegerField(default=0) class AnswerDetail(models.Model): record = models.ForeignKey(ExamRecord, on_delete=models.CASCADE, related_name='details') question = models.ForeignKey(Question, on_delete=models.CASCADE) user_answer = models.CharField(max_length=255, blank=True) is_correct = models.BooleanField(default=False) got_score = models.IntegerField(default=0)

逻辑说明:ExamQuestion用through显式声明中间表,是为了把score和sort_order挂上去,Django 默认的ManyToManyField生成的是纯关联表,塞不进额外字段。AnswerDetail用related_name='details',后面判分时可以用record.details.all()一次性取出所有作答,避免 N+1 查询。

参数说明:answer字段对多选存A,C这种逗号分隔字符串,判分时先split(',')再排序比对,比存 JSON 数组更省事,也方便数据库文档里直接写清楚。duration存分钟数而不是结束时间,是因为考试可能提前开始或延长,用时长 + 开始时间动态算截止更灵活。

2.3 数据库文档里必须写清的三个约束

很多人的数据库文档只画了 ER 图就交差,结果上线后数据脏得没法看。有三条约束一定要在文档里显式写出来,并在模型里落实:

第一,ExamRecord上对(exam, user)加唯一约束,防止同一考生对同一试卷生成多条记录。Django 里用unique_together = ('exam', 'user')。第二,AnswerDetail上对(record, question)加唯一约束,防止重复提交时同一题插入多行。第三,ExamQuestion的sort_order在同一试卷内唯一,保证题目顺序稳定。

class Meta: unique_together = ('exam', 'user') # 加在 ExamRecord 里

这三条约束看着不起眼,但它们是后面处理“重复交卷”“刷新重答”这类问题的第一道防线。数据库文档的价值就在这:让接手的人一眼知道哪些字段不能乱写。

3. 答题与交卷链路:Django 视图、事务和防重复提交

模型建好只是开始,真正让在线考试系统“能用”的是答题和交卷这条写链路。这块的核心矛盾是:考生答题是高频写,交卷是强一致操作,两者混在一起很容易出现“答案丢了一半”或者“成绩算重了”。下面按请求进来、写答案、交卷判分三个阶段拆。

3.1 答题接口:用 update_or_create 保证幂等

考生每选一个选项就发一次请求保存,这种设计对网络抖动最友好,但要求接口幂等——同一个请求发两次,结果必须一样。Django 的update_or_create正好干这个。

# views.py from django.http import JsonResponse from django.views.decorators.http import require_POST from .models import ExamRecord, AnswerDetail, Question @require_POST def save_answer(request): record_id = request.POST.get('record_id') question_id = request.POST.get('question_id') user_answer = request.POST.get('user_answer', '').strip() record = ExamRecord.objects.get(id=record_id, user=request.user, status='ongoing') AnswerDetail.objects.update_or_create( record=record, question_id=question_id, defaults={'user_answer': user_answer} ) return JsonResponse({'code': 0, 'msg': 'saved'})

逻辑说明:update_or_create先按record + question查,查到就更新user_answer,查不到就新建。这样无论考生改几次答案,AnswerDetail里始终只有一行。status='ongoing'这个过滤条件很关键,已交卷的记录不允许再写,否则会出现交卷后还能改答案的漏洞。

参数说明:user_answer对多选存A,C格式,前端提交前把选中的选项 key 用逗号拼好。record_id从考试开始时后端返回,前端存在页面变量里,不要放 URL 里明文传,避免越权改别人的卷子。

3.2 交卷判分:一个事务里完成状态流转和算分

交卷是整个系统最不能出错的地方。判分要读所有答题明细、比对标准答案、累加得分、更新记录状态,这几步必须在一个事务里,否则中途失败会留下“状态已交卷但分数没算”的脏数据。

from django.db import transaction from django.utils import timezone @require_POST @transaction.atomic def submit_exam(request): record_id = request.POST.get('record_id') record = ExamRecord.objects.select_for_update().get( id=record_id, user=request.user, status='ongoing' ) total = 0 for detail in record.details.select_related('question'): q = detail.question if q.question_type in ('single', 'judge'): correct = detail.user_answer == q.answer elif q.question_type == 'multi': correct = sorted(detail.user_answer.split(',')) == sorted(q.answer.split(',')) else: correct = False # 简答走人工判分 detail.is_correct = correct detail.got_score = q.examquestion_set.get(exam=record.exam).score if correct else 0 detail.save() total += detail.got_score record.status = 'submitted' record.submit_time = timezone.now() record.total_got = total record.save() return JsonResponse({'code': 0, 'score': total})

逻辑说明:select_for_update()在事务里给这条记录加行锁,防止考生连点两次交卷导致判分跑两遍。select_related('question')把题目一次性查出来,避免循环里每条明细都查一次数据库。多选判分用sorted比对,是因为考生选择顺序和标准答案顺序可能不同,直接字符串比会误判。

参数说明:q.examquestion_set.get(exam=record.exam).score取的是这道题在当前试卷里的分值,注意这里用的是ExamQuestion中间表,不是题目表。简答题correct=False且got_score=0,等教师后台人工判分时再更新,这样自动判分和人工判分不会互相覆盖。

3.3 防重复提交和超时自动交卷

前端按钮置灰只能防君子,真正的防线在后端。除了上面的事务加锁,还要在ExamRecord上加状态判断:只有ongoing才能交卷,交完变submitted,再请求直接返回错误。超时自动交卷用一个定时任务扫ongoing且submit_time为空的记录,判断start_time + duration是否已过,过了就调用同一个判分函数。

# 定时任务里调用,逻辑与 submit_exam 共用判分函数 def auto_submit_expired(): now = timezone.now() for record in ExamRecord.objects.filter(status='ongoing'): deadline = record.exam.start_time + timedelta(minutes=record.exam.duration) if now > deadline: grade_record(record) # 抽出来的判分函数

把判分逻辑抽成独立函数grade_record,手动交卷和自动交卷都调它,避免两处逻辑不一致。这是血泪经验:第一版我把判分写在视图里,后来加自动交卷时复制了一份,结果多选判分规则改了只改了一处,线上出现两种判分结果。

4. 组卷、判分与成绩统计里的常见问题排查

在线考试系统跑起来之后,问题往往不在主流程,而在边界情况。下面这几条是我在实际项目里反复遇到的,按“现象 → 原因 → 解决”写清楚,方便对照排查。

4.1 交卷后成绩为 0,但答题明细里答案都在

现象:考生确认答了题,交卷后总分是 0,进后台看AnswerDetail里user_answer有值,但is_correct全是 False。

原因:判分时比对的是detail.user_answer == q.answer,但前端提交的答案带了空格或大小写不一致,比如存的是"a"而标准答案是"A"。多选更常见,存成"A, C"带空格,split(',')后得到['A', ' C'],跟['A', 'C']比不相等。

解决:在save_answer里统一清洗,user_answer = user_answer.replace(' ', '').upper(),判分前再兜一次。数据库文档里也要注明answer字段统一大写无空格。

4.2 同一考生出现两条考试记录

现象:后台看到同一个学生对同一张试卷有两条ExamRecord,一条已交卷一条进行中。

原因:考试开始接口没有做幂等,考生刷新页面或网络重试时又创建了一条新记录。unique_together如果没加,数据库层面也不拦。

解决:开始考试用get_or_create(exam=exam, user=request.user, defaults={'status': 'ongoing'}),并在模型上加unique_together = ('exam', 'user')。已经产生的脏数据手动合并,保留有答题明细的那条。

4.3 试卷题目顺序每次刷新都变

现象:考生反馈题目顺序不稳定,刷新后第 3 题变成第 5 题。

原因:组卷时题目从exam.questions.all()取,没有指定排序,数据库返回顺序不保证。

解决:查询时显式.order_by('examquestion__sort_order'),或者直接查ExamQuestion.objects.filter(exam=exam).order_by('sort_order')再取题目。sort_order在组卷时按教师设定写入,不要依赖自增 ID。

4.4 交卷接口偶发超时,日志显示锁等待

现象:集中交卷时部分请求超时,数据库日志有行锁等待。

原因:select_for_update()锁住了记录,但判分循环里又去查ExamQuestion,如果这个查询也涉及锁或者事务开得太久,就会拖长持锁时间。

解决:把判分需要的数据提前查出来,别在锁里做多次查询。select_related和prefetch_related用上,事务里只做写操作。如果并发量确实大,考虑把判分改成异步任务,交卷接口只改状态,判分由后台 worker 跑。

4.5 简答题人工判分后总分没更新

现象:教师给简答题打完分,考生成绩还是原来的自动判分结果。

原因:人工判分只更新了AnswerDetail.got_score,没有重算ExamRecord.total_got。

解决:人工判分保存后,触发一次重算:record.total_got = sum(d.got_score for d in record.details.all()),再record.save()。这个重算逻辑跟自动判分里的累加保持一致,最好抽成同一个函数。

5. 用 Django 查询和统计把成绩分析做扎实

系统能判分只是及格线,真正让教师愿意用的是成绩分析。这块靠的是 Django ORM 的聚合查询,写好了几行代码就能出统计,写不好就是一堆 Python 循环拖垮页面。

5.1 用 annotate 做单题正确率统计

教师最关心“哪道题大家错得多”。用annotate在数据库层算,比取出来在 Python 里循环快一个量级。

from django.db.models import Count, Q, Avg def question_stats(exam_id): stats = AnswerDetail.objects.filter( record__exam_id=exam_id, record__status__in=['submitted', 'graded'] ).values('question_id', 'question__content').annotate( total=Count('id'), correct=Count('id', filter=Q(is_correct=True)), avg_score=Avg('got_score') ).order_by('correct') for s in stats: s['rate'] = round(s['correct'] / s['total'] * 100, 1) if s['total'] else 0 return list(stats)

逻辑说明:values按题目分组,annotate里用Count配合filter=Q(is_correct=True)算正确数,这是 Django 条件聚合的标准写法。order_by('correct')让错得最多的题排前面,教师一眼看到重点。rate在 Python 里算是因为涉及除法,放数据库里要处理除零,不如取出来算。

参数说明:record__status__in过滤掉进行中的记录,只统计已交卷和已判分的,否则正确率会被没答完的卷子拉低。avg_score用Avg而不是自己累加,数据库的聚合函数对 NULL 的处理更稳妥。

5.2 成绩分布和及格率一次查出来

教师看完成绩单,下一步就是看分布。用Case/When把分数分档,一条查询出各档人数。

from django.db.models import Case, When, IntegerField def score_distribution(exam_id): return ExamRecord.objects.filter( exam_id=exam_id, status__in=['submitted', 'graded'] ).annotate( bucket=Case( When(total_got__gte=90, then=0), When(total_got__gte=80, then=1), When(total_got__gte=60, then=2), default=3, output_field=IntegerField() ) ).values('bucket').annotate(count=Count('id')).order_by('bucket')

逻辑说明:Case/When在数据库里给每条记录打一个档位标签,再按档位分组计数。output_field=IntegerField()必须写,否则 Django 推断不出类型会报错。返回的bucket是数字,前端映射成“优秀/良好/及格/不及格”。

参数说明:分档阈值按试卷总分调整,如果total_score不是 100,When里的数字要按比例改。更稳妥的做法是把阈值做成参数传进来,别写死在代码里。

5.3 导出成绩单时避免 N+1 查询

导出 Excel 时最容易犯的错是循环里查关联对象。用select_related和prefetch_related一次性把数据拉齐。

def export_scores(exam_id): records = ExamRecord.objects.filter( exam_id=exam_id, status__in=['submitted', 'graded'] ).select_related('user').prefetch_related('details__question') rows = [] for r in records: rows.append({ 'username': r.user.username, 'total': r.total_got, 'submit_time': r.submit_time, 'detail_count': len(r.details.all()) }) return rows

逻辑说明:select_related('user')把用户信息 JOIN 进来,prefetch_related('details__question')把答题明细和题目分两次查询预取,循环里r.details.all()走的是缓存,不再打数据库。导出几百条记录时,这个差别是秒级和分钟级的区别。

参数说明:prefetch_related用双下划线details__question可以一次预取两层,但要注意数据量,如果单张试卷几千人考,预取全部明细内存会涨,可以分批导出。

5.4 一个我常用的验证习惯

每次改完判分或统计逻辑,我不会直接上页面点,而是先在 Django shell 里跑一遍关键查询,确认数字对得上再动前端。比如判分规则改了,我会造一条测试记录,手动调grade_record,然后record.details.values('question_id', 'is_correct', 'got_score')看每条明细,再record.total_got看总分是不是等于明细之和。这个习惯帮我拦下过好几次“总分和明细对不上”的低级错误。数据库文档里的字段含义和判分规则,最好也在这个阶段对照着核一遍,文档和代码不一致,后面接手的人一定踩坑。希望帮到你。

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

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

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

立即咨询