简介:面向计算机科学与技术专业本科毕业设计的论文文档,以基于Python的在线考试系统为研究主题,适用于正在筹备毕业论文、需要完整系统设计与实现流程参考的学生,也贴合在线教育、高校考试信息化场景。资源包内为1个docx文件,压缩后仅33KB,呈现的是西南财经大学学士学位毕业论文全文,包含目录、摘要及各章节完整结构。论文从研究背景、目的与意义切入,梳理了Django/Flask等Web开发框架、MySQL/PostgreSQL数据库、用户身份验证与数据加密等关键技术;需求分析区分了功能、非功能、数据与界面四类需求,并详述总体架构、模块划分、系统实现与数据库设计;测试章节覆盖测试策略、用例设计与结果分析,结尾总结工作不足,并对AI智能评阅、移动端适配等方向作出展望。已有325人学习下载。对需要借鉴论文结构、梳理系统设计与实现思路的本科毕业生或开发者而言,这份文档能提供清晰的写作框架与实施路径。
1. 在线考试系统用Python做,值不值:先看你要交付什么
在线考试系统是Python开发者绕不开的实战题目,也几乎是“设计与实现”类毕业设计清单里的常客。它本质上不是一个“网站”,而是把一场线下考试完整搬到线上:考生登录、抽取试卷、限时答题、自动判分、成绩留痕,再加一套管理端用来出题、组卷、查分。用Python做,最稳的路线是Django + MySQL/SQLite + Bootstrap,从空项目到能演示大概两到三周。这个项目真正的价值在于,ORM、Session、事务、并发控制这些面试和答辩高频点,都能在一个小系统里串起来讲,而不是背几个孤立的名词。所以它适合要认真完成课设/毕设的学生,也适合给中小团队搭内部培训考核平台。很多人一开始只盯着增删改查,写到一半才发现考试状态管理才是大头。
2. 需求拆解与数据库建模:把考试系统拆成四类角色和六张核心表
写代码前先把需求拆干净。在线考试系统的核心流程只有一条线:老师选题组卷 -> 安排考试场次 -> 考生进入考试 -> 提交答卷 -> 系统判分并出成绩。围绕这条线,把操作映射给角色,再把角色落到数据表,后面开发就不会反复改表结构。
2.1 角色与权限:谁来建题、谁来考试、谁来查成绩
我一般把用户分成三类:管理员、教师、考生。管理员管科目和全局配置,教师管自己科目下的题目和试卷,考生只参加自己被安排的考试。不需要引入复杂的RBAC框架,Django自带的Group和Permission足够支撑这个规模。
权限控制可以用装饰器实现,区分“有没有登录”和“是不是某个角色”。只判断is_staff太粗糙,因为管理员和教师都要进后台,但教师只能看到自己的科目数据。所以我习惯写一个group_required装饰器,挂在视图函数上:
# accounts/decorators.py from functools import wraps from django.shortcuts import redirect from django.contrib import messages def group_required(*group_names): def decorator(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): if not request.user.is_authenticated: return redirect('accounts:login') if not request.user.groups.filter(name__in=group_names).exists(): messages.error(request, '当前账号无权访问该功能') return redirect('exam:home') return view_func(request, *args, **kwargs) return _wrapped_view return decorator这段代码先检查登录态,再检查用户所属组。request.user.groups.filter(name__in=group_names)会生成一条SQL IN查询,性能没问题。挂在教师视图上用@group_required('teacher', 'admin'),挂在成绩导出上用@group_required('admin'),比在每个视图里写if判断干净得多。答辩时也能讲清楚“我是怎么做权限控制的”。
2.2 核心数据库设计:六张表撑起一次完整考试
在线考试系统最少需要六张表:科目表、题库表、考试表、试卷模板表、考生答卷表、答题记录表。用户直接复用Django的auth.User,再扩展一张Profile存学号和真实姓名。
| 表名 | 关键字段 | 作用 |
|---|---|---|
| Subject | name, teacher | 科目归属,教师只能维护自己的科目 |
| Question | subject, q_type, content, options, answer, score | 题库,支持单选、多选、判断、主观题 |
| Exam | title, subject, start_time, end_time, duration_minutes | 考试场次,设置时间段和时长 |
| ExamQuestion | exam, question, order, score | 试卷模板,锁定这场考试用哪些题、每题分值 |
| ExamPaper | exam, student, is_submitted, submit_time, total_score | 每名考生一份的答卷实例 |
| AnswerRecord | paper, question, question_order, selected_answer, is_correct | 逐题作答和判分情况 |
这里最需要想清楚的是ExamQuestion和AnswerRecord的区别。ExamQuestion是“老师设计试卷时的快照”,一场考试只用一份模板;AnswerRecord是“每个考生答题时生成的记录”,题目集合相同,但题目顺序可以不同。如果把题目直接挂在Exam下,后面想做到“同一场考试每个考生题目顺序不一样”就很别扭,而且改题会污染历史成绩。
2.3 用Django ORM落地模型:字段类型、迁移命令与两个关键约束
模型字段设计直接决定后续代码好不好写。下面是我常用的写法:
from django.db import models from django.contrib.auth.models import User class Subject(models.Model): name = models.CharField('科目名称', max_length=50) teacher = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, related_name='subjects' ) class Meta: ordering = ['id'] class Question(models.Model): class QuestionType(models.TextChoices): SINGLE = 'single', '单选题' MULTIPLE = 'multiple', '多选题' JUDGE = 'judge', '判断题' SUBJECTIVE = 'subjective', '主观题' subject = models.ForeignKey(Subject, on_delete=models.CASCADE) q_type = models.CharField('题型', max_length=20, choices=QuestionType.choices) content = models.TextField('题干') options = models.JSONField('选项', default=list, blank=True) answer = models.CharField('答案', max_length=500) score = models.PositiveIntegerField('分值', default=2) creator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)q_type用TextChoices而不是硬编码字符串,后续判断题型时写Question.QuestionType.SINGLE,不会出现拼写错误。options用JSONField存四个选项,对SQLite和MySQL都友好,数据量不大时比单独建一张选项表简洁。需要说明的是,用JSONField牺牲了一定的“三范式洁癖”,但换来的是出题界面和答题界面直接读写一个字段,课设、毕设这种规模最合适。
接下来是Exam和ExamQuestion:
class Exam(models.Model): title = models.CharField('考试名称', max_length=100) subject = models.ForeignKey(Subject, on_delete=models.CASCADE) start_time = models.DateTimeField('开始时间') end_time = models.DateTimeField('结束时间') duration_minutes = models.PositiveIntegerField('考试时长', default=60) is_published = models.BooleanField('是否发布', default=False) class ExamQuestion(models.Model): exam = models.ForeignKey(Exam, on_delete=models.CASCADE, related_name='exam_questions') question = models.ForeignKey(Question, on_delete=models.CASCADE) order = models.PositiveIntegerField('题目顺序') score = models.PositiveIntegerField('考试分值') class Meta: unique_together = ('exam', 'question')unique_together保证同一道题不会在同一场考试里出现两次,这是一个很容易被忽略的边界。如果老师组卷时手滑把同一题加了两次,数据库层面直接拒绝,比后端判断更可靠。
模型建好后执行两条命令:
python manage.py makemigrations accounts exam python manage.py migratemakemigrations只生成迁移文件,migrate才真正把表写进数据库。如果改过字段后命令行提示你输入默认值,说明表里已经有存量数据,要么给字段设default,要么设置null=True。这里不要随手填一个假值,否则历史数据会变成脏数据。
3. 把一次考试跑通:从登录到自动判分的代码主线
模型层只是地基,真正卡住人的是把“考试中”的状态串起来。下面按我的开发顺序,从环境准备一路写到自动判分。
3.1 环境准备:python安装、虚拟环境与Django初始化
先从环境说起。如果你刚接触Python,先完成python安装,并在PyCharm里完成pycharm配置python环境,指到虚拟环境而不是全局环境。然后执行:
python -m venv venv source venv/bin/activate pip install django==4.2.* django-admin startproject exam_system . python manage.py startapp accounts python manage.py startapp examdjango-admin startproject exam_system .最后的点不能漏,它表示在当前目录生成manage.py,而不是多套一层目录。这里我拆了两个app:accounts管登录注册和权限,exam管考试业务。职责分开后,答辩时模块划分也很好讲。如果后面要导成绩Excel,再装openpyxl;要画图表,再装matplotlib,不用一开始把依赖铺满。
3.2 登录与Session校验:用Django自带auth,不重复造轮子
登录直接用Django的authenticate和login,不要自己写密码比对:
# accounts/views.py from django.shortcuts import render, redirect from django.contrib.auth import authenticate, login, logout from django.contrib import messages def login_view(request): if request.method == 'POST': username = request.POST.get('username') password = request.POST.get('password') user = authenticate(request, username=username, password=password) if user is not None: login(request, user) return redirect('exam:home') messages.error(request, '用户名或密码错误') return render(request, 'accounts/login.html') def logout_view(request): logout(request) return redirect('accounts:login')authenticate会校验密码哈希,login会把用户信息写进Session。在线考试系统里,我建议在settings里保持SESSION_COOKIE_HTTPONLY = True,防止前端JavaScript读取Session Cookie,降低XSS风险。默认Session存在数据库里,几百人同时考试压力不大,不需要上Redis。
3.3 获取试题与提交答卷:随机顺序、时间校验与事务写入
进入考试是一个“写操作”的起点:要为当前考生生成一张空白答卷。这里最容易出的问题有两个:重复点击“开始考试”导致生成多份卷子,以及不校验时间导致过了截止时间还能进考场。我用get_or_create配合事务解决:
# exam/views.py import random from django.utils import timezone from django.db import transaction from django.shortcuts import get_object_or_404 from django.http import JsonResponse from .models import Exam, ExamPaper, AnswerRecord, ExamQuestion def start_exam(request, exam_id): exam = get_object_or_404(Exam, id=exam_id) now = timezone.now() if not (exam.start_time <= now <= exam.end_time): return JsonResponse({'error': '当前不在考试时间内'}, status=400) paper, created = ExamPaper.objects.get_or_create( exam=exam, student=request.user ) if created: template_questions = list( exam.exam_questions.select_related('question').all() ) random.shuffle(template_questions) with transaction.atomic(): AnswerRecord.objects.bulk_create([ AnswerRecord( paper=paper, question=eq.question, question_order=idx, objective_score=eq.score ) for idx, eq in enumerate(template_questions) ]) records = paper.answer_records.select_related('question').order_by('question_order') questions_payload = [] for r in records: q = r.question questions_payload.append({ 'id': q.id, 'type': q.q_type, 'content': q.content, 'options': q.options, }) return JsonResponse({'paper_id': paper.id, 'questions': questions_payload})get_or_create利用ExamPaper表上(exam, student)的唯一约束,从数据库层面避免并发重复开考。random.shuffle只是让每个考生的题目顺序不同,题目集合仍然是同一套。这在答辩时能解释“如何避免相邻考生互相看屏幕”。bulk_create批量写答题记录,几百人同时开考也不会产生过多的SQL请求。
提交答卷的核心问题是“重复交卷”和“跨过截止时间交卷”。我用行锁解决:
def submit_exam(request, exam_id): now = timezone.now() try: with transaction.atomic(): paper = ExamPaper.objects.select_for_update().get( exam_id=exam_id, student=request.user ) if paper.is_submitted: return JsonResponse({'error': '请勿重复交卷'}, status=400) if now > paper.exam.end_time: return JsonResponse({'error': '考试已结束,无法交卷'}, status=400) answers = request.POST.get('answers', '[]') # JSON字符串,前端负责序列化 import json answer_list = json.loads(answers) total = 0 for item in answer_list: record = paper.answer_records.select_related('question').get( question_id=item['question_id'] ) record.selected_answer = item['answer'] if record.question.q_type != 'subjective': record.is_correct = judge_answer(record.question, item['answer']) record.score = record.objective_score if record.is_correct else 0 record.save(update_fields=['selected_answer', 'is_correct', 'score']) if record.score: total += record.score paper.total_score = total paper.is_submitted = True paper.submit_time = now paper.save(update_fields=['total_score', 'is_submitted', 'submit_time']) except ExamPaper.DoesNotExist: return JsonResponse({'error': '试卷不存在,请先进入考试'}, status=404) return JsonResponse({'total_score': paper.total_score})select_for_update()必须放在transaction.atomic()里,否则Django会抛TransactionManagementError。它的作用是锁定这一行,直到事务结束,另一个同时提交的请求只能等锁释放。这道防线解决了并发交卷时的“覆盖写”问题。
judge_answer的写法取决于题型。单选题和判断题比对答案字符串;多选题需要先切割再比集合,我下面给一个参考:
def judge_answer(question, student_answer): if question.q_type == Question.QuestionType.MULTIPLE: correct_set = set(''.join(question.answer.split()).split(',')) answer_set = set(''.join(student_answer.split()).split(',')) return correct_set == answer_set return question.answer.strip() == student_answer.strip()多选题切分时要去掉空格,否则“A,B”和“A,B ”会被判错。字符串比对的另一个隐藏坑是大小写,考生填a和系统存A,可以把两边都strip().upper()后再比。
3.4 成绩导出:用openpyxl把成绩写成Excel
在线考试系统的管理端一定要有成绩导出功能,这也是答辩时最容易演示的功能点之一。用python写入excel,我一般用openpyxl:
from openpyxl import Workbook from django.http import HttpResponse def export_scores(request, exam_id): exam = get_object_or_404(Exam, id=exam_id) wb = Workbook() ws = wb.active ws.append(['学号', '姓名', '总分', '交卷时间']) for paper in exam.papers.select_related('student').filter(is_submitted=True): ws.append([ paper.student.username, paper.student.profile.real_name, paper.total_score, paper.submit_time.strftime('%Y-%m-%d %H:%M:%S'), ]) response = HttpResponse( content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) response['Content-Disposition'] = f'attachment; filename="{exam.title}_成绩.xlsx"' wb.save(response) return responseHttpResponse直接接收wb.save(),不需要先生成临时文件再读完删掉。Content-Disposition里的filename如果包含中文,最好用RFC 5987编码,否则部分浏览器会下载成乱码名。这个小细节踩到的人不少。
4. 这五个坑我基本每次都会遇到:在线考试系统的避坑与排查
在线考试系统功能不复杂,但边界条件特别多。下面五条是我自己在开发和帮别人排错时反复踩到的坑,按“现象 -> 原因 -> 解决”记录。
4.1 倒计时显示还有两分钟,提交时却被提示“考试已结束”
现象:学生端倒计时从60分钟走起,显示还剩2分钟时点交卷,后端返回“考试已结束,无法交卷”。
原因:前端用浏览器本地时间做倒计时,后端用服务器时间做校验。Django默认TIME_ZONE = 'UTC',而本地是东八区,两边一对比就差出8个小时。如果服务器时区没设对,甚至会出现开考时间还没到就已经结束的情况。
解决:settings.py里设置TIME_ZONE = 'Asia/Shanghai',保持USE_TZ = True。Django存库时用UTC,模板渲染时自动转东八区,但前端JavaScript拿到的还是ISO字符串。安全做法是登录后由后端返回一个server_now时间戳,前端用这个时间戳计算倒计时,而不是用new Date()。我这里会把timezone.now().timestamp()放在进入考试接口的响应里,前端统一以后端时间为准。
4.2 考生同时交卷,成绩偶尔变成0分
现象:两个考生同时点提交,一个人总分正常,另一个人总分变成0,或者is_submitted状态被覆盖成False。
原因:视图里先get再判断is_submitted,两个请求同时读到is_submitted=False,都进入判分逻辑。后面提交的人覆盖了前面人的total_score,或者前面的分数写入被后面连带更新掉了。
解决:交卷操作必须在事务内用select_for_update()锁行。Django的select_for_update()在MySQL和PostgreSQL上生效,SQLite不支持行锁,但会有表级锁兜底。锁住ExamPaper这一行后,第二个请求会阻塞,直到第一个事务提交,然后读到is_submitted=True,命中“请勿重复交卷”分支。
4.3 每次打开考试,题目和选项顺序一模一样
现象:考生重考或者换台机器登录,看到的题目顺序和上次完全一致,甚至选项顺序都不变。
原因:题目顺序用的是ExamQuestion里的order字段,选项就是题库里的options原样返回。没有做任何随机化。
解决:题目随机在创建AnswerRecord时做,就像3.3里那样random.shuffle(template_questions)。选项随机要在拼接口时做,而且必须同步映射答案。比如原选项是["A. 北京", "B. 上海", "C. 广州", "D. 深圳"],正确答案是A,随机后变成["C. 广州", "D. 深圳", "A. 北京", "B. 上海"],正确答案仍然要跟着原选项字母走。我一般把选项转成[{key, text}],shuffle时带着key一起动,前端按顺序展示,后端判分只认key。记住一句话:随机的是展示顺序,不是正确答案。
4.4 部署到Linux服务器后,CSS和JS全部变成裸样式
现象:本地runserver一切正常,DEBUG=False部署到Nginx后,登录页完全没有样式,控制台一堆404。
原因:Django官方明确规定,DEBUG=False时runserver不再提供静态文件。这是“开发环境能跑,生产环境翻车”的高发问题。
解决:最简单的方案是上whitenoise。安装后加到INSTALLED_APPS和MIDDLEWARE,再执行python manage.py collectstatic。它会把所有静态文件收集中间件提供的URL下,不需要配Nginx的location。如果项目前后端完全分离,那考试系统的页面另有前端服务托管,Django只出API,静态问题就不存在。但这里说的是服务端渲染的课设场景,whitenoise是后悔药。
4.5 几十个人同时交卷,日志里出现database is locked
现象:局域网内50个人同时进入考试或提交,后台日志频繁出现database is locked。
原因:SQLite同一时间只允许一个写事务,高并发写时直接抛锁错误。我在第2章建议小型系统用SQLite,但如果考试人数超过30人并发交卷,SQLite撑不住。
解决:设计文档里如果写了MySQL,就老老实实装MySQL,别用SQLite应付。本地开发继续用SQLite没问题,部署时切换成MySQL,字段定义基本不用改。切换方式是在settings里改DATABASES的ENGINE为django.db.backends.mysql,然后安装mysqlclient或pymysql。如果坚持SQLite,可以试着加timeout=20和WAL模式缓解,但不要指望根治,这是SQLite的物理限制。
5. 把“设计与实现”落到文档、答辩和可维护性上
很多人的代码能跑,但答辩和文档跟不上,最后得分并不高。在线考试系统这类题目,老师一定会在文档里找“设计”的痕迹,而不只是看功能。
5.1 系统结构图、用例图、E-R图怎么画不空洞
文档里至少要有三张图:系统架构图、功能用例图、数据库E-R图。系统架构图不要画成“浏览器 -> Django -> MySQL”一句话,要从用户终端、Web服务器、应用服务、数据库四层展开,标注出Nginx、uWSGI、Django、MySQL。功能图用用例图表达,参与者写“管理员、教师、考生”,用例里把“自动判分、手动阅卷、成绩导出”单独画出来。E-R图要和模型表严格对应,字段名用英文,和代码里的models保持一致。答辩时老师常问“这图是不是从代码里逆向出来的”,你拿出models.py对一下,就是最有力的证据。
5.2 核心代码如何与论文对应:答辩必问的三个点
答辩老师不看完整代码,但会挑几个关键点问。我整理了一份“论文对应表”,在写文档时可以直接对照:
| 论文章节 | 核心问题 | 对应代码位置 |
|---|---|---|
| 系统设计 | 权限控制怎么做的 | accounts/decorators.py 的 group_required |
| 系统设计 | 试卷和答卷的表关系 | exam/models.py 的 ExamQuestion 和 AnswerRecord |
| 系统实现 | 自动判分怎么实现 | exam/views.py 的 judge_answer |
| 系统实现 | 怎么防止超时交卷 | submit_exam 里对 end_time 的校验 |
| 系统测试 | 并发交卷怎么处理 | submit_exam 里 select_for_update |
把这三段代码的来龙去脉讲清楚,比背五十页“增强了用户体验”有用得多。
5.3 小型系统选SQLite还是MySQL:看设计文档怎么写
我的原则是:设计文档写什么,代码就实现什么。文档写“基于MySQL”,答辩现场老师可能直接敲mysql -u root -p看库,你交一个SQLite文件上去就穿了。如果文档里没硬性规定,本地用SQLite省事,正式演示前换成MySQL。MySQL部署我习惯用Docker:
docker run -d --name exam-mysql \ -e MYSQL_ROOT_PASSWORD=your_password \ -e MYSQL_DATABASE=exam_db \ -p 3306:3306 \ mysql:8.0启动后记得在Django的settings里把HOST改成127.0.0.1,OPTIONS里加上charset=utf8mb4。不设置utf8mb4的话,题库里存中文、emoji表情都会乱码。备份用mysqldump写个定时任务,每天凌晨导出一次SQL,考试数据丢不得。
6. 进阶技巧:用固定随机种子控制试卷难度,并用pytest守住核心接口
如果只想让代码“能跑”,前面的内容够了。但如果你想在答辩里脱颖而出,或者让系统真正可用,再加两个技巧。
第一个技巧是控制试卷难度。考试系统如果只是从题库随机抽题,可能出现这名考生抽到全是简单题、那名考生抽到全是难题的情况。我习惯给每道题加一个difficulty字段,抽题时用加权随机,并固定随机种子,保证同一场考试里每个人的试卷难度分布基本一致:
import random def build_exam_paper(exam, student): questions = list(exam.exam_questions.select_related('question').all()) rng = random.Random(student.id + exam.id) # 固定种子,每人一份可复现顺序 # 简单题权重1,中等题权重2,困难题权重3 weighted = [] for eq in questions: difficulty = eq.question.difficulty # 1, 2, 3 weighted.extend([eq] * difficulty) chosen = rng.sample(weighted, min(exam.question_count, len(weighted))) rng.shuffle(chosen) return chosenrandom.Random(student.id + exam.id)让同一个考生重新进入考试时看到相同顺序,但不同考生顺序不同。这样既公平,又能给老师解释“如何兼顾随机性和公平性”。
第二个技巧是用pytest守住核心接口。考试系统最怕改着改着把时间校验弄坏了。我一般写一个接口测试,专门盯着截止时间:
# exam/tests.py import pytest from django.utils import timezone from .models import Exam, ExamPaper @pytest.mark.django_db def test_submit_after_deadline(client, django_user_model): teacher = django_user_model.objects.create_user('teacher', password='x') student = django_user_model.objects.create_user('student', password='x') exam = Exam.objects.create( title='期末测试', start_time=timezone.now() - timezone.timedelta(hours=2), end_time=timezone.now() - timezone.timedelta(hours=1), duration_minutes=30, ) ExamPaper.objects.create(exam=exam, student=student) client.force_login(student) resp = client.post(f'/exam/{exam.id}/submit/', {'answers': '[]'}) assert resp.status_code == 400这条测试失败,意味着截止时间校验被改坏了。每次改完代码,跑一遍测试集再提交,能少挨很多骂。我现在的习惯是先写这种边界测试,再写功能代码,把“超时不能提交、重复不能提交、未开始不能进去”三条边界固定下来。
这个方向做下来,你会发现在线考试系统最大的难点不是CRUD,而是边界状态的管理。希望我上面这些实践能帮你少走一段弯路。
本文还有配套的精品资源,点击获取