简介:这是一套完整的基于Django框架开发的Python在线考试系统,适用于本科毕业设计、课程大作业及教学实践项目,聚焦多角色协同的考试全流程管理。系统支持管理员、教师、学生三级权限体系,覆盖用户管理、班级课程绑定、题库建设(含单选/多选/判断/填空/简答/编程题)、智能组卷(手动+随机)、在线考试、自动判分(编程题支持Python沙箱运行)、成绩统计与错题解析等核心功能。资源包共607个文件,包含56个Python后端逻辑文件、123个Vue前端组件、63个JS交互脚本、159个SVG图标资源,以及SQL数据库脚本、BAT自动化部署脚本(如install.bat、run.bat)和配套文档,整体压缩包大小为21.63MB。已有66人学习下载,提供可直接运行的完整工程结构、清晰的模块划分(如exam、question、user应用)、初始化数据库脚本及图文混排题干支持能力,便于快速部署、二次开发与教学演示。 做在线考试系统这个项目,我前后折腾了两轮,踩了不少坑,也总结出一套比较顺的路径。这篇文章围绕基于Python的Django在线考试系统设计与实现来写,把数据库设计、核心功能、实操步骤和常见问题一次讲清楚,希望能帮你少走弯路。
这个系统解决的问题很直接:把传统纸质考试的出题、发卷、答题、判分、成绩统计全流程搬到线上。教师可以维护题库、创建考试、发布试卷,学生登录后参加考试、交卷后系统自动判分,成绩实时可查。整个系统涉及用户认证、角色权限、试卷管理、在线答题、自动判分、成绩统计等多个模块,是一条非常完整的Web开发链路,既能作为毕业设计/课程设计,也能用于企业培训考核或学校测验。
如果你正在做类似项目,或者刚学完Python基础想找一个综合实战练手,这篇文章会从技术选型、数据库设计、核心逻辑到部署,带你完整走一遍。即使你只是想把Django的知识点串起来,后面几节关于权限设计、并发处理、时间校验的内容也值得一看。
1. 项目概述与技术选型思路
1.1 系统整体需求拆解
开始写代码之前,先把需求摸清楚。在线考试系统通常面向三类角色:学生、教师、管理员。
- 管理员:管理用户账号、课程分类、系统基础配置
- 教师:创建考试、维护题库、查看所有考生成绩并导出
- 学生:查看可参加的考试、在线答题、提交试卷、查看自己的成绩
从业务场景来看,一场考试的生命周期是:教师创建草稿考试 → 添加题目 → 发布考试 → 学生在规定时间内进入答题 → 自动判分 → 成绩汇总统计。这个流程看起来简单,但每个节点都有隐藏的边界条件,比如考试未开始能不能看到试卷、交卷后还能不能修改答案、教师改了题目分数会不会影响已经交卷的学生,这些问题在后面几章会逐个展开。
1.2 为什么选Django而不是Flask或Spring Boot
很多初学者都会纠结框架选型。就我个人的实践经验来说,做这类“管理后台 + 用户登录 + 业务表单”的系统,Django确实是最省心的选择,原因有四条。
第一,Django自带Admin后台。题库管理、用户管理这类操作,配置好Django Admin后几乎不用写前端页面就能直接使用,项目开发周期能缩短很多。第二,Django内置用户认证系统。登录、登出、Session管理、权限分组都是现成的,不需要自己实现密码加密和会话保持。第三,Django的ORM非常成熟。在线考试系统的核心就是数据模型和关系映射,用ORM写模型类直观清晰,还自带数据库迁移工具,改完模型执行一条命令就能同步表结构。第四,安全性有基础保障,CSRF防护、SQL注入防护默认开启,对经验不太多的开发者来说,等于自带了一面盾牌。
作为对比,Flask确实小巧灵活,但需要自己拼装数据库扩展、表单扩展、登录扩展,项目一复杂到处都是重复代码。Spring Boot性能和生态都很好,但Java体系对小型项目来说太重了,学习成本高,开发效率反而不如Python。如果你预计考试并发量不超过几千人,Django完全够用;如果要做几十万人在线的大型考试系统,那是另一个话题,需要引入缓存、消息队列和专门的压力测试,但作为课程设计或企业内部系统,Django是“能力中庸但恰好够用”的选择。
1.3 整体技术方案
后端框架是Django,数据库起步用SQLite,上线换MySQL,模板渲染作为主方案,配合少量AJAX实现切题和暂存答案。之所以不直接上前后端分离,是因为这类系统的核心是业务逻辑和数据处理,模板渲染可以大大减少开发量,一套代码搞定服务端渲染,不用维护两套联调接口。如果你的目标是学习Django REST Framework或者想做成前后端分离项目,可以在完成基础版本后改造,这个下面会提到。
2. 数据库设计:整个系统的地基
2.1 核心表结构拆解
在线考试系统能不能做好,一半取决于数据库设计。我最终定下来的表有7张核心业务表,再加上Django自带的用户表。
第一张是用户表,继承Django的AbstractUser,增加role字段区分学生、教师、管理员,增加real_name字段存真实姓名。为什么不用一张独立的Profile表去存角色和姓名?对于这个项目来说,直接加字段更简单。Django的OneToOneField和Signal机制虽然能做扩展,但新手很容易搞混,项目规模不大时没必要增加复杂度。
第二张是课程表,用来给考试归类,字段就是课程名和课程简介,表里至少要有个外键关联到教师,表示这门课的负责人。
第三张是考试表,字段设计和示例可以参考这个结构:
| 字段名 | 类型 | 说明 |
|---|---|---|
| title | CharField | 考试名称 |
| course | ForeignKey | 关联课程 |
| start_time | DateTimeField | 考试开始时间 |
| end_time | DateTimeField | 考试结束时间 |
| duration | IntegerField | 答题时长(分钟) |
| total_score | IntegerField | 总分 |
| pass_score | IntegerField | 及格分 |
| status | CharField | 草稿/已发布/已关闭 |
| created_by | ForeignKey | 创建人 |
这里有个关键设计:考试状态不要只靠status字段,还要结合start_time和end_time来判断。数据库里只存草稿、已发布、已关闭三个显式状态,至于考试是未开始、进行中还是已结束,用当前时间和开始结束时间比较就能算出来。这样省掉了定时任务去刷新状态,也避免服务器休眠导致状态没刷新。
第四张是试题表,包含题号类型(单选题、判断题)、题干、分值、正确答案。第五张是选项表,一个选择题对应多个选项。第六张是考试记录表,记录某个学生参加某场考试的状态、得分、开始交卷时间。第七张是答题明细表,记录学生每道题的作答情况和判分结果。
2.2 表关系设计的三个关键决策
第一,选择题的选项一定要独立建表,不要用逗号分隔存在一个字段里。很多新手图省事,把ABC三个选项拼成一个字符串存进Question表的options字段,读取时split一下。这个方案前期写起来很爽,后期改选项、统计选项分布时会非常痛苦,而且如果某个选项本身包含逗号,split后数据就错乱了。正确的做法是建Choice表,通过外键关联到Question。
第二,标准答案和学生答案要分开存。这个问题我问过不少人,第一次做的人很容易把“正确答案”和“考生的回答”混在一起。正确答案是题目属性,存在Question表的correct_answer字段;学生的作答结果跟着人走,一个人在多个考试中有多个答案,所以学生答案必须存到AnswerDetail表。如果错把学生答案放在Question表里,那这道题所有考生的答案都会互相覆盖。
第三,要在交卷时就固化判分结果。我的方案是交卷时遍历答题明细,每道题判完分存进AnswerDetail的score字段,最后汇总总分存进ExamRecord。这个思路和电商的“订单快照”是一个道理:考生交卷时的价格(得分)已经确定,后续教师修改题目分值,已经交卷的学生成绩不受影响。如果交卷后不保存分数,每次都实时计算,一旦教师改了题目分数,历史成绩就会悄悄跟着变,数据就不对了。
2.3 数据库文档的整理方法
项目标题里特别提了数据库文档,这部分多说几句。一份合格的数据库文档应该包含四个部分:ER图、表结构说明、状态流转说明、核心查询SQL示例。
ER图可以手动用draw.io画,也可以直接用django-extensions的graph_models自动生成。手动画图的好处是美观规范,自动生成的好处是省时准确。我建议先用自动生成拿到一张准确的底图,再用draw.io整理出最终版。
表结构说明要逐表列出字段名、类型、允许为空、默认值、索引、外键关联。这里有一个容易被忽视的点:很多人的数据库文档只写了字段和类型,但没写业务含义。比如ExamRecord表的status字段,要解释清楚ongoing表示进行中、finished表示已完成,以及状态转换是由哪个动作触发的。
状态流转说明是我强烈建议补充的内容。经验教训是:如果文档里不把状态机写清楚,开发时很容易被“考试记录从进行中变成已完成”这类流转逻辑绕晕。比如考试记录的status从ongoing到finished,触发条件是交卷接口;用户不能查看自己未参加的考试记录,接口层要过滤。
核心SQL示例也很重要。比如“查询某场考试的平均分、最高分、及格率”,这个SQL或ORM写法在答辩或交接时经常被问到,提前整理好省得现场翻代码。
3. 核心功能模块设计与实现
3.1 用户认证与三角色权限控制
用户认证直接复用Django内置的LoginView和Session机制,这部分不需要自己实现。
角色字段建议存字符串,不存数字。为什么?存数字“1、2、3”,写代码时总要回头注释才知道1代表学生还是教师,可读性太差;直接存student、teacher、admin,一眼看懂,数据库多占几个字节无所谓。
权限控制做两层。第一层是视图层装饰器,用login_required保证必须登录,再定义role_required装饰器校验角色是否允许访问:
from functools import wraps from django.core.exceptions import PermissionDenied def role_required(roles): def decorator(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): if request.user.is_authenticated and request.user.role in roles: return view_func(request, *args, **kwargs) raise PermissionDenied return _wrapped_view return decorator用法很简单:
@login_required @role_required(['teacher', 'admin']) def create_exam(request): ...第二层是数据级权限,也就是按数据归属来控制。Django自带的Permission系统是“动词+名词”的粗粒度模型,比如可以控制谁能add_exam,但控制不了“教师只能修改自己创建的考试,管理员可以修改所有考试”这种细分场景。所以数据级权限在视图里通过外键过滤实现。教师查看自己的考试列表,查询时加一个created_by=request.user;管理员查看所有考试,不加这个过滤条件。
Django内置的admin后台,也要在admin.py里做同样的过滤,否则教师登录admin后能看到并修改别人的考试。这个问题我见过很多人踩坑,只改了前端页面权限没改admin后台,结果学生在admin后台里看到了所有答案。
3.2 考试流程:从创建考试到发布
教师的操作路径是:填写考试基本信息 → 设置题目 → 发布考试。
创建考试的表单非常简单,考试名称、选择课程、设置起止时间和时长、设置总分和及格分。这一步会插入一条Exam记录,status是draft。
接着进入题目配置页面。题目的添加有两种方式:一种是逐道手工录入,适合小型考试;另一种是从题库批量选择导入,适合题目量大的场景。为了支持批量复用,Exam和Question之间用ManyToManyField关联。这里Django会自动生成一张中间表,不需要手动建。但如果需要控制每道题在试卷里的顺序,或者不同学生看到不同的题目版本,就要手动建中间表,加一个order字段。
发布考试时,把status从draft改成published即可。前端学生列表的查询条件是:status=published且当前时间在start_time和end_time之间。这里有个设计细节值得强调:未开始的考试只显示考试名称和开始时间,不显示题目内容;点击进入答题页面时后端也要校验时间,不能只靠前端隐藏入口,否则懂点技术的学生直接构造URL就能进入答题页。
3.3 在线答题的核心逻辑
答题页是这个系统交互最复杂的地方,有几个问题在动手前必须想清楚。
第一个问题是倒计时怎么实现。不要想复杂了,前端倒计时只是体验层,后端时间校验才是真正的逻辑保证。我的做法是:学生点击开始考试时,后端创建一个ExamRecord,记录started_at时间,返回试卷数据。前端拿到duration和started_at,用duration减掉已经过去的时间得到剩余秒数,本地做倒计时。倒计时归零时自动调用交卷接口。同时后端交卷接口里再做一次校验,如果服务器时间已经超过started_at + duration,就强制交卷,不管前端有没有自动提交。
from django.utils import timezone from datetime import timedelta deadline = record.started_at + timedelta(minutes=record.exam.duration) if timezone.now() > deadline: # 强制交卷第二个问题是防止重复交卷。学生第一次提交成功后,后端把ExamRecord状态从ongoing改成finished。交卷接口里先查状态,如果不是ongoing直接拒绝。这里要用事务和select_for_update锁行,防止两个并发请求同时通过状态判断后重复提交,这个细节在第5章会详细展开。
第三个问题是学生中途关掉浏览器怎么办。我建议在答题页加一个“保存”功能,学生切换题目时通过AJAX把当前题目选中的答案暂存到AnswerDetail。这样即使中途退出,下次进来还能接着做,不会因为页面刷新导致全部答案丢失。很多初学者第一版不做暂存功能,结果学生答了30道题不小心刷新页面,全部白答,体验非常差。
第四个问题是题目和选项顺序要不要打乱。如果考试需要防作弊,最简单的做法是每个学生进入答题页时随机打乱题目顺序,如果选项也要防,就随机打乱选项顺序。注意打乱选项顺序时,必须同步更新正确选项的标识逻辑。更省事的做法是给答题页面生成一次随机顺序后存到中间表,刷新后顺序不变,否则学生刷新一次题目顺序就变一次,容易把学生搞晕。
3.4 自动判分与成绩统计
选择题和判断题是自动判分的主流题型。选择题的判分逻辑很简单:比较学生提交的答案和正确答案的标识,一致给分,不一致给零分。判断题本质上只有一个选项,答案存True或False,逻辑完全一样。
交卷时的判分核心代码:
def grade_exam(record): details = AnswerDetail.objects.filter(record=record) total = 0 for detail in details: if detail.student_answer and detail.student_answer == detail.question.correct_answer: detail.is_correct = True detail.score = detail.question.score total += detail.question.score else: detail.is_correct = False detail.score = 0 detail.save() record.score = total record.finished_at = timezone.now() record.save()成绩统计页面用Django ORM的aggregate方法就能完成:
from django.db.models import Avg, Max, Min, Count, Q stats = ExamRecord.objects.filter(exam=exam, status='finished').aggregate( avg_score=Avg('score'), max_score=Max('score'), min_score=Min('score'), pass_count=Count('id', filter=Q(score__gte=exam.pass_score)), total_count=Count('id'), )导出成绩用CSV或者Excel。如果是中文环境,建议用openpyxl直接生成xlsx文件,因为CSV用Excel打开时经常遇到中文乱码,xlsx格式谁拿到都能正常打开。
如果后续要支持简答题、论述题这类主观题,自动判分做不了,需要在答案表中加一个status字段记录待判分/已判分状态,教师手动打分后更新得分。这个扩展不复杂,核心思路是主观题不在交卷时判分,而是在教师端待办列表里逐题批改。
4. 实操过程:从零搭建一个可运行的考试系统
4.1 环境准备与项目初始化
动手敲代码的第一步是准备环境。我用Python 3.10以上版本和Django 4.x版本演示。
python -m venv venv venv\Scripts\activate # Windows source venv/bin/activate # Linux/macOS pip install django django-admin startproject exam_project cd exam_project python manage.py startapp exampython -m venv venv这一步是关键,一定要给项目单独建虚拟环境,避免和其他项目的包版本打架。我见过太多人图省事直接用系统全局Python,结果不同项目依赖的Django版本冲突,最后只能逐个重装。
然后修改settings.py,把exam加到INSTALLED_APPS,配置数据库。开发阶段用SQLite零配置起步:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3', } }要切换MySQL,就把ENGINE改成django.db.backends.mysql,加上HOST、PORT、USER、PASSWORD等参数,再安装pymysql并在项目的__init__.py里import pymysql; pymysql.install_as_MySQLdb()。这个细节如果不做,Django连MySQL会报错,很多第一次连的人都会卡在这一步。
4.2 核心模型代码编写
在写任何模型之前,第一件事是在settings.py里设置AUTH_USER_MODEL:
AUTH_USER_MODEL = 'exam.User'先配置这个字段的原因是,Django的用户模型一旦执行过数据库迁移,再想换自定义用户模型就非常麻烦。一开始就把它配好,避免后面改了User表后整个数据库需要重来。
用户模型:
from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): ROLE_CHOICES = ( ('student', '学生'), ('teacher', '教师'), ('admin', '管理员'), ) role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='student') real_name = models.CharField(max_length=50, blank=True)考试模型:
class Exam(models.Model): STATUS_CHOICES = ( ('draft', '草稿'), ('published', '已发布'), ('closed', '已关闭'), ) title = models.CharField(max_length=200) course = models.ForeignKey(Course, on_delete=models.CASCADE) start_time = models.DateTimeField() end_time = models.DateTimeField() duration = models.IntegerField(help_text='考试时长(分钟)') total_score = models.IntegerField(default=100) pass_score = models.IntegerField(default=60) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') created_by = models.ForeignKey(User, on_delete=models.CASCADE) question_pool = models.ManyToManyField('Question', through='ExamQuestion')试题模型和选项模型:
class Question(models.Model): TYPE_CHOICES = ( ('single', '单选题'), ('judge', '判断题'), ) question_type = models.CharField(max_length=20, choices=TYPE_CHOICES) content = models.TextField() score = models.IntegerField(default=5) correct_answer = models.CharField(max_length=10) created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) class Choice(models.Model): question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices') label = models.CharField(max_length=10) content = models.CharField(max_length=200)这里注意related_name='choices'的作用,配了以后通过question.choices.all()就能取到所有选项,代码写起来非常顺手。
考试记录和答题明细:
class ExamRecord(models.Model): STATUS_CHOICES = ( ('ongoing', '进行中'), ('finished', '已完成'), ) student = models.ForeignKey(User, on_delete=models.CASCADE) exam = models.ForeignKey(Exam, on_delete=models.CASCADE) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ongoing') score = models.IntegerField(null=True, blank=True) started_at = models.DateTimeField(auto_now_add=True) submitted_at = models.DateTimeField(null=True, blank=True) class AnswerDetail(models.Model): record = models.ForeignKey(ExamRecord, on_delete=models.CASCADE, related_name='details') question = models.ForeignKey(Question, on_delete=models.CASCADE) student_answer = models.CharField(max_length=10, blank=True) is_correct = models.BooleanField(null=True) score = models.IntegerField(default=0)写完后执行迁移命令:
python manage.py makemigrations python manage.py migrate python manage.py createsuperuser4.3 核心视图与模板落地
学生查看考试列表的视图:
from django.contrib.auth.decorators import login_required from django.utils import timezone @login_required @role_required(['student']) def exam_list(request): now = timezone.now() exams = Exam.objects.filter( status='published', start_time__lte=now, end_time__gte=now, ) return render(request, 'exam_list.html', {'exams': exams})进入答题页的视图,主要工作是初始化考试记录和读取题目列表:
@login_required @role_required(['student']) def exam_detail(request, exam_id): exam = get_object_or_404(Exam, id=exam_id, status='published') now = timezone.now() if now < exam.start_time or now > exam.end_time: messages.error(request, '考试不在进行中') return redirect('exam_list') record, created = ExamRecord.objects.get_or_create( student=request.user, exam=exam, defaults={'status': 'ongoing'} ) if record.status == 'finished': return render(request, 'exam_result.html', {'record': record}) questions = exam.question_pool.all() return render(request, 'exam_detail.html', { 'exam': exam, 'questions': questions, 'record': record, })这里get_or_create很实用,一个学生进入一场考试只会初始化一条记录,不会重复创建。
交卷的AJAX视图:
from django.http import JsonResponse from django.db import transaction @login_required @role_required(['student']) def submit_exam(request, record_id): if request.method != 'POST': return JsonResponse({'code': 1, 'msg': '请求方式错误'}) try: with transaction.atomic(): record = ExamRecord.objects.select_for_update().get( id=record_id, student=request.user ) if record.status == 'finished': return JsonResponse({'code': 1, 'msg': '试卷已提交,请勿重复提交'}) # 保存答案并判分 ... record.status = 'finished' record.save() return JsonResponse({'code': 0, 'msg': '交卷成功'}) except ExamRecord.DoesNotExist: return JsonResponse({'code': 1, 'msg': '考试记录不存在'})模板里表单的csrf_token不能少,AJAX提交要带X-CSRFToken头,否则Django直接403拒绝。这个坑几乎每个月都有人踩一次,具体做法是先引入:
function getCookie(name) { let cookieValue = null; if (document.cookie && document.cookie !== '') { const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.substring(0, name.length + 1) === (name + '=')) { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } } } return cookieValue; } const csrftoken = getCookie('csrftoken');4.4 数据库文档的生成与整理
开发过程中整理数据库文档,最省时的方案是用django-extensions自动生成ER图和模型描述:
pip install django-extensions pydot python manage.py graph_models -a -o erd.png这条命令从模型自动生成ER图,虽然不是特别美观,但用来梳理表关系和撰写项目文档足够用。如果在图片上还需要细调,可以导出dot格式再用draw.io加工。
另外,注意把AUTH_USER_MODEL设置好后,Django自带的admin后台也会自动使用自定义User模型,不需要额外配置,登录后台后就能看到用户管理。
4.5 单元测试与基础验证
写完后不要急着部署,先跑一遍核心功能测试。Django的TestCase自带测试数据库,写几个关键用例比手工测试靠谱太多:
from django.test import TestCase from django.utils import timezone from datetime import timedelta from .models import Exam, ExamRecord, Question class ExamTestCase(TestCase): def setUp(self): self.student = User.objects.create_user( username='stu1', password='123456', role='student' ) self.teacher = User.objects.create_user( username='tea1', password='123456', role='teacher' ) self.exam = Exam.objects.create( title='期中测试', start_time=timezone.now() - timedelta(hours=1), end_time=timezone.now() + timedelta(hours=1), duration=60, status='published', created_by=self.teacher ) def test_student_can_join_exam(self): self.client.login(username='stu1', password='123456') response = self.client.get(f'/exam/{self.exam.id}/') self.assertEqual(response.status_code, 200) self.assertTrue( ExamRecord.objects.filter(student__username='stu1', exam=self.exam).exists() )测试用例覆盖三个最核心的场景:学生能正常进入考试、交卷后不能重复提交、考试时间未到不能进入答题页。这3个测试跑通了,系统的核心逻辑就有了基本保证。
5. 常见问题与排查技巧实录
5.1 时区问题:考试时间总是不对
这是在线考试系统最经典的坑。Django的settings.py里TIME_ZONE默认是'UTC',USE_TZ默认是True。如果你直接调用datetime.now(),得到的是UTC时间,和北京时间差8小时,界面显示的时间自然不对。
解决办法是在settings.py里把时区配置好:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = True然后代码里统一用django.utils.timezone.now()生成时间,不要用datetime.now()。模板显示时间的时候用localtime过滤器或模板标签,这样会自动把UTC时间转成配置的本地时区。判断考试是否在有效期内时,注意不要拿naive datetime和Django返回的aware datetime直接比较,会报“can't compare offset-naive and offset-aware datetimes”的错。
5.2 交卷并发与重复提交
考试系统的交卷接口是非常典型的并发场景。学生可能双击交卷按钮,也可能网络超时后手动再次点击。如果后端不做保护,同一份试卷会生成两条交卷记录,成绩覆盖关系不确定,数据就乱了。
解法有两层。第一层是前端交卷按钮点击后立即置灰并显示“提交中”,从源头减少重复请求。第二层是后端用事务加行锁保护状态判断:
from django.db import transaction with transaction.atomic(): record = ExamRecord.objects.select_for_update().get( id=record_id, student=request.user ) if record.status == 'finished': return JsonResponse({'code': 1, 'msg': '试卷已提交,请勿重复提交'}) # 判分逻辑select_for_update的作用是:在当前事务内对该行加锁,另一个并发事务要修改这行时会被阻塞,只有当前事务提交后另一个事务才能继续。这样就保证了两个交卷请求即使同时到达,第一个执行完提交后,第二个也会看到status已经是finished,从而被拒绝。
5.3 查询性能:列表页越来越慢
如果考试记录列表变得很慢,多半是N+1查询导致的。加入学生姓名和考试名称都要显示,常规写法是循环里再查一次关联表,一个页面发了上百条SQL。解决办法是用select_related和prefetch_related:
# 查询考试记录时一次性把关联的exam和student取出来 records = ExamRecord.objects.filter( exam=exam ).select_related('exam', 'student')select_related适用于ForeignKey和OneToOneField,prefetch_related适用于ManyToManyField和反向查询。答题明细这类一对多场景,用prefetch_related一次性加载所有题目的答案,避免循环查询。
排查SQL执行效率,最直观的方式是安装django-debug-toolbar,页面右侧会展示每个请求执行的SQL语句、参数和耗时,初学者可以很轻松定位到慢查询位置。
5.4 部署时的静态文件问题
本地开发DEBUG=True时Django自动处理静态文件,看不出来问题;一部署到服务器,CSS、JS全丢了。
解决方法是部署前把settings.py里的DEBUG改成False,配置STATIC_ROOT和STATIC_URL:
DEBUG = False ALLOWED_HOSTS = ['your-domain.com'] STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_URL = '/static/'然后在服务器上执行:
python manage.py collectstaticcollectstatic会把所有app里的静态文件收集到STATIC_ROOT目录,然后由nginx或IIS把这个目录映射到/static/路径。Django只处理动态请求,静态文件交给Web服务器处理。
5.5 删除对象的级联陷阱
Django删除对象时,外键关联的数据默认有几种行为:CASCADE是级联删除,PROTECT是保护禁止删除,SET_NULL是删除后置空,SET_DEFAULT是设为默认值。
在考试系统里,很多地方一上来就全用CASCADE,会造成一个风险:删除一个教师账号,他创建的所有考试、所有学生考试记录、所有答题明细全部被级联删除,这个后果非常严重。我的建议是:
- 删除课程时,如果还有考试关联,应该PROTECT禁止删除,提示先把该课程下的考试迁移或删除
- 删除教师账号时,他创建的考试用SET_NULL把created_by置空,保留考试数据
- 删除考试时,学生考试记录通常会级联删除,这个要谨慎,如果有成绩留存需求,考试只能关闭不能删除
这个设计在写模型时就要想清楚,别等数据跑起来了再改外键行为,改起来很麻烦。
5.6 一个容易被忽略的细节:考试结束后还能不能看试卷
考试结束后,学生能不能查看自己做过的试卷和答案?这个需求要看学校规定。如果允许学生查看,答题页要做只读处理,隐藏所有表单控件,只显示题目和学生答案、正确答案、得分。我建议不要让学生随便看全部正确答案,否则后面重考时题目就没法复用了。一个折中的方案是只显示得分和错题数量,不展示正确答案详情,这个权限控制可以作为一个开关放到Exam表里。
6. 项目扩展与经验总结
最后分享一点个人体会。
在线考试系统做完第一版后,我最大的感受是:这个项目看起来简单,但真正把各种边界情况想清楚,需要平衡状态机、时间边界、并发控制、数据快照这四个点。很多第一次做这个选题的人,容易把注意力放在页面好不好看上,反而忽略了核心业务逻辑,结果答辩时被老师一问“交卷时并发怎么处理”“教师改了题目分数怎么办”,就答不上来。推荐的是先把业务闭环走通,再考虑美化UI。
如果你后续想扩展,优先级建议这样排:先加简答题和人工判分,这是功能性的补齐;再把Redis引入做答题进度缓存和倒计时,这是性能的提升;然后可以尝试做Vue3前后端分离,用Django REST Framework提供接口,这是架构层面的升级;最后如果考试人数上来了,再考虑用Celery做异步判分和邮件通知,这是规模化的方向。
在做这个项目时,我还有一个踩过的坑想提醒你:不要把数据库文档留到最后才补。先花一两天把ER图和状态流转图画清楚,写代码时会顺利很多,改模型的次数也会大大减少。文档和代码同步迭代,这才是做项目的正确节奏。
本文还有配套的精品资源,点击获取