ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Django在线考试系统数据库设计与事务处理实战

Django在线考试系统数据库设计与事务处理实战 简介这是一套面向高校计算机相关专业学生与教师的在线考试系统完整项目源码基于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/teacherquestionid, 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_length10, choicesROLE_CHOICES, defaultstudent) class Question(models.Model): TYPE_CHOICES ((single, 单选), (multi, 多选), (judge, 判断), (essay, 简答)) subject models.CharField(max_length50) question_type models.CharField(max_length10, choicesTYPE_CHOICES) content models.TextField() answer models.CharField(max_length255, help_text多选答案用逗号分隔如 A,C) created_at models.DateTimeField(auto_now_addTrue) class Option(models.Model): question models.ForeignKey(Question, on_deletemodels.CASCADE, related_nameoptions) option_key models.CharField(max_length2) # A/B/C/D option_text models.CharField(max_length255) class Exam(models.Model): title models.CharField(max_length100) total_score models.IntegerField(default100) duration models.IntegerField(help_text考试时长单位分钟) start_time models.DateTimeField() end_time models.DateTimeField() questions models.ManyToManyField(Question, throughExamQuestion) class ExamQuestion(models.Model): exam models.ForeignKey(Exam, on_deletemodels.CASCADE) question models.ForeignKey(Question, on_deletemodels.CASCADE) score models.IntegerField(default5) sort_order models.IntegerField(default0) class ExamRecord(models.Model): STATUS ((ongoing, 进行中), (submitted, 已交卷), (graded, 已判分)) exam models.ForeignKey(Exam, on_deletemodels.CASCADE) user models.ForeignKey(User, on_deletemodels.CASCADE) status models.CharField(max_length10, choicesSTATUS, defaultongoing) submit_time models.DateTimeField(nullTrue, blankTrue) total_got models.IntegerField(default0) class AnswerDetail(models.Model): record models.ForeignKey(ExamRecord, on_deletemodels.CASCADE, related_namedetails) question models.ForeignKey(Question, on_deletemodels.CASCADE) user_answer models.CharField(max_length255, blankTrue) is_correct models.BooleanField(defaultFalse) got_score models.IntegerField(default0)逻辑说明ExamQuestion用through显式声明中间表是为了把score和sort_order挂上去Django 默认的ManyToManyField生成的是纯关联表塞不进额外字段。AnswerDetail用related_namedetails后面判分时可以用record.details.all()一次性取出所有作答避免 N1 查询。参数说明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(idrecord_id, userrequest.user, statusongoing) AnswerDetail.objects.update_or_create( recordrecord, question_idquestion_id, defaults{user_answer: user_answer} ) return JsonResponse({code: 0, msg: saved})逻辑说明update_or_create先按record question查查到就更新user_answer查不到就新建。这样无论考生改几次答案AnswerDetail里始终只有一行。statusongoing这个过滤条件很关键已交卷的记录不允许再写否则会出现交卷后还能改答案的漏洞。参数说明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( idrecord_id, userrequest.user, statusongoing ) 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(examrecord.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(examrecord.exam).score取的是这道题在当前试卷里的分值注意这里用的是ExamQuestion中间表不是题目表。简答题correctFalse且got_score0等教师后台人工判分时再更新这样自动判分和人工判分不会互相覆盖。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(statusongoing): deadline record.exam.start_time timedelta(minutesrecord.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(examexam, userrequest.user, defaults{status: ongoing})并在模型上加unique_together (exam, user)。已经产生的脏数据手动合并保留有答题明细的那条。4.3 试卷题目顺序每次刷新都变现象考生反馈题目顺序不稳定刷新后第 3 题变成第 5 题。原因组卷时题目从exam.questions.all()取没有指定排序数据库返回顺序不保证。解决查询时显式.order_by(examquestion__sort_order)或者直接查ExamQuestion.objects.filter(examexam).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_idexam_id, record__status__in[submitted, graded] ).values(question_id, question__content).annotate( totalCount(id), correctCount(id, filterQ(is_correctTrue)), avg_scoreAvg(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配合filterQ(is_correctTrue)算正确数这是 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_idexam_id, status__in[submitted, graded] ).annotate( bucketCase( When(total_got__gte90, then0), When(total_got__gte80, then1), When(total_got__gte60, then2), default3, output_fieldIntegerField() ) ).values(bucket).annotate(countCount(id)).order_by(bucket)逻辑说明Case/When在数据库里给每条记录打一个档位标签再按档位分组计数。output_fieldIntegerField()必须写否则 Django 推断不出类型会报错。返回的bucket是数字前端映射成“优秀/良好/及格/不及格”。参数说明分档阈值按试卷总分调整如果total_score不是 100When里的数字要按比例改。更稳妥的做法是把阈值做成参数传进来别写死在代码里。5.3 导出成绩单时避免 N1 查询导出 Excel 时最容易犯的错是循环里查关联对象。用select_related和prefetch_related一次性把数据拉齐。def export_scores(exam_id): records ExamRecord.objects.filter( exam_idexam_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看总分是不是等于明细之和。这个习惯帮我拦下过好几次“总分和明细对不上”的低级错误。数据库文档里的字段含义和判分规则最好也在这个阶段对照着核一遍文档和代码不一致后面接手的人一定踩坑。希望帮到你。本文还有配套的精品资源点击获取
返回列表