news 2026/9/16 13:54:11

Django在线考试系统:从模型设计到部署的完整实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Django在线考试系统:从模型设计到部署的完整实战解析

简介:面向计算机相关专业学生的Django在线考试系统毕业设计源码包,基于Python与MySQL实现,个人项目评审得分97分,经过严格调试确保可直接运行。这套系统覆盖用户登录、试题管理、在线答题、成绩统计等典型考试模块,适合用于毕业设计、期末课程设计或课程大作业,也可作为学习Django与MySQL整合开发的完整案例。压缩包共254个文件,大小约2.86MB,主要包含42个Python源码文件、118个编译后的pyc文件,以及前端所需的HTML、CSS、JavaScript、SCSS页面资源,另有若干图片与字体图标文件,目录结构清晰,便于按功能模块查阅。系统界面基于Bootstrap等前端框架构建,后台逻辑分层合理,读者可从中掌握MTV架构、ORM操作、模板渲染等关键技能,也能直接修改题库与考试参数快速搭建自己的在线考试平台。目前已有150人学习下载,对有实战参考需求的学生和开发者具有较高借鉴价值。

1. Django在线考试系统:为什么这组技术栈最值得复刻

如果只选一个组合来完成在线考试类毕业设计,Django + Python + MySQL几乎是性价比最高的答案。这套源码评审97分,它不是把题目静态堆在页面上,而是把组卷、交卷、判分、成绩回写做成了完整链路,本地结构清晰,改成课程设计或者公司内部培训考试都行。适合计算机相关专业学生直接作为毕设底子,也适合想快速搭一套内部考试系统的从业者拿来做二次开发。我建议拿到源码之后不要急着pip install跑起来,先从头到尾看一遍数据模型,再动手改业务,会省掉后面大量返工时间。

2. 项目结构与数据模型:models.py 决定的表结构边界

2.1 拿到源码先看哪几个目录

展开压缩包后,不要急着装依赖,先确认目录结构。这套系统其实就三层:配置层、业务 app、模板静态资源。我解压后看到的核心结构是这样:

online_exam/ ├── manage.py ├── requirements.txt ├── exam/ # 核心业务 app │ ├── models.py │ ├── views.py │ ├── admin.py │ └── migrations/ ├── static/ # bootstrap.css、font-awesome.min.css 等 ├── templates/ │ ├── login.html │ ├── exam_list.html │ └── exam_paper.html └── exam_system/ # Django 项目配置 ├── settings.py └── urls.py

重点在exam/models.pyexam/views.py,templates 里放的是 Bootstrap 4 那套 CSS,前端交互主要靠原生 JS 加一点 jQuery,没上重框架。这对毕设答辩反而友好,老师问前端逻辑时每一行都能解释清楚,不会被追问 node_modules 里装了什么。

2.2 五个核心模型的设计

打开 models.py,在线考试系统的表设计基本围绕五个模型展开:用户、题库、试卷、考试记录、答题记录。按这个项目的高分定级标准,精简且完整的模型层长这样:

from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): student_id = models.CharField('学号', max_length=20, blank=True) role = models.SmallIntegerField('角色', default=1, choices=((1, '学生'), (2, '教师'))) class QuestionBank(models.Model): subject = models.CharField('科目', max_length=64) qtype = models.SmallIntegerField('题型', choices=((1, '单选'), (2, '多选'), (3, '判断'))) difficulty = models.SmallIntegerField('难度', choices=((1, '易'), (2, '中'), (3, '难'))) content = models.TextField('题干') options = models.JSONField('选项', default=dict) # {"A": ".."} answer = models.CharField('答案', max_length=10) score = models.PositiveSmallIntegerField('分值', default=2) creator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) class ExamPaper(models.Model): title = models.CharField('试卷名称', max_length=128) duration = models.PositiveIntegerField('考试时长/分钟', default=60) question_count = models.PositiveIntegerField('题目数', default=20) questions = models.ManyToManyField(QuestionBank, blank=True) total_score = models.PositiveIntegerField('总分', default=100) published = models.BooleanField('是否发布', default=False) class ExamRecord(models.Model): student = models.ForeignKey(User, on_delete=models.CASCADE) paper = models.ForeignKey(ExamPaper, on_delete=models.CASCADE) score = models.FloatField('得分', default=0) status = models.SmallIntegerField('状态', default=1, choices=((1, '进行中'), (2, '已完成'))) start_time = models.DateTimeField(auto_now_add=True) submit_time = models.DateTimeField(null=True, blank=True) class AnswerRecord(models.Model): record = models.ForeignKey(ExamRecord, on_delete=models.CASCADE) question = models.ForeignKey(QuestionBank, on_delete=models.CASCADE) user_answer = models.CharField('考生答案', max_length=20, blank=True) is_correct = models.BooleanField('是否答对', default=False) gained_score = models.FloatField('该题得分', default=0)

几个选型细节值得展开说。options用 JSONField 存选项,比单独建一张选项表省掉大量 JOIN,MySQL 要用 5.7.8 以上版本;如果目标环境版本旧,改成 TextField 存 JSON 字符串也能跑,但查询选项时就要自己json.loads,多一道转换。ExamRecordAnswerRecord拆成两张表,是为了交卷判分时不反复读题库,尤其是多选题的比对,需要把题目正确答案拿出来逐字符处理。question_countExamPaper上冗余了一个字段,列表页显示「这套卷子有几道题」时不用查中间表,虽然多占了点存储,但查询快,毕设答辩时解释为空间换时间也没问题。

为什么ExamPaper.questions用 ManyToManyField 而不是 ForeignKey?因为同一道题可以被多套卷子引用,而且组卷时经常要先选题目再生成试卷快照,多对多中间表天然支持这套逻辑,后续加 AB 卷也只需要多建一条 ExamPaper 记录,不用动表结构。

2.3 初始化数据的正确顺序

migrate 之后要先建用户再导题库,顺序反了外键会报错。用 admin 创建教师账号,然后在题库里录入题目,最后建试卷并勾选题目。把题库做成 fixture 是常见做法:

python manage.py makemigrations exam python manage.py migrate python manage.py createsuperuser python manage.py loaddata questions.json

questions.json里每个元素对应QuestionBank的一条记录,JSONField 字段在 fixture 里就是普通字典,例如{"A": "进程", "B": "线程", "C": "协程"}。注意 loaddata 不会清空已有数据,重复执行会把题库翻倍,所以初始化之前先确认库里没有脏数据。

数据库连接配置在exam_system/settings.py里,下面的参数是毕设项目里常用的底线配置,表格里这几项是评审时追问概率最高的:

配置项推荐值说明
DEBUGFalse部署时关闭,否则报错页面会泄露绝对路径
ALLOWED_HOSTS['*']毕设演示可放开,上生产环境必须改成具体域名
DATABASE ENGINEdjango.db.backends.mysql对应 MySQL 5.7+,字符集用 utf8mb4
TIME_ZONEAsia/Shanghai配合USE_TZ=True,否则存储时间差 8 小时
STATIC_ROOT服务器绝对路径collectstatic 收集后供 nginx 托管

TIME_ZONE 和 USE_TZ 的组合是新手最常掉坑的地方。Django 开启USE_TZ=True后,写入 MySQL 的时间默认是 UTC,模板里直接渲染ExamRecord.start_time会显示比北京时间早 8 小时,实际开发中应该在视图里用timezone.localtime(record.start_time)转换后再传给模板,或者干脆一次性设置USE_TZ=False配合TIME_ZONE='Asia/Shanghai',让 Django 直接使用本地时间写入数据库。

3. 从组卷到判分:核心考试链路如何落地

3.1 自动组卷:按难度和题型比例抽题

很多在线考试系统的组卷逻辑是硬编码的,题目顺序固定,考前就能背题。这套系统的高分点在于组卷按「题型 + 难度比例」动态抽取,核心思路:先确定单选、多选、判断各几道,再按难度比例从题库里随机 pick。给出核心代码:

import random def generate_paper(subject, rule): questions = [] for qtype, count in rule['types'].items(): qs = QuestionBank.objects.filter(subject=subject, qtype=qtype) hard = list(qs.filter(difficulty=3)) medium = list(qs.filter(difficulty=2)) easy = list(qs.filter(difficulty=1)) picked = ( random.sample(hard, k=min(rule['hard_ratio'] * count // 100, len(hard))) + random.sample(medium, k=min(rule['medium_ratio'] * count // 100, len(medium))) + random.sample(easy, k=min(rule['easy_ratio'] * count // 100, len(easy))) ) questions.extend(picked) paper = ExamPaper.objects.create( title=rule['title'], question_count=len(questions), total_score=sum(q.score for q in questions) ) paper.questions.set(questions) return paper

random.sample会在抽样池不够时抛 ValueError,所以用min()把抽取数量限制在池子长度以内,这是组卷最容易被忽略的边界条件。hard_ratio * count // 100算出该难度应抽几道,整数除法保证结果不超总数;如果某难度题目不够,现在这段代码会少抽,而不是从其他难度补足,实际项目里一般会在抽完后再统计缺口,从剩余题目里按「先中后易」的顺序补齐,保证题数达到question_count

调用时机也有讲究。不要在点击「开始考试」时才临时组卷,应该在教师发布试卷时就把 ExamPaper 记录建好并绑定题目,学生端只读这份快照。这样有两个好处:一是组卷过程不会阻塞学生端请求;二是如果教师在考试中途往题库里加题,已经生成的试卷不会受影响,保证所有考生面对的是同一套题。

3.2 交卷判分:事务边界与答案比对

判分入口放在交卷接口里。重点是事务包裹整个判分过程,防止判到一半数据库异常导致成绩丢失:

import json from django.db import transaction from django.utils import timezone from django.http import JsonResponse @transaction.atomic def submit_exam(request, record_id): record = ExamRecord.objects.select_for_update().get(id=record_id) if record.status == 2: return JsonResponse({'code': 1, 'msg': '试卷已交,请勿重复提交'}) answers = json.loads(request.body).get('answers', {}) total = 0.0 for qid, user_ans in answers.items(): q = QuestionBank.objects.get(id=qid) correct = normalize_answer(user_ans) == normalize_answer(q.answer) gained = q.score if correct else 0.0 AnswerRecord.objects.create( record=record, question=q, user_answer=user_ans, is_correct=correct, gained_score=gained ) total += gained record.score = round(total, 2) record.status = 2 record.submit_time = timezone.now() record.save() return JsonResponse({'code': 0, 'score': record.score})

select_for_update()在事务内对ExamRecord加行级锁,防止学生在两个标签页同时交卷导致成绩被后提交的那次覆盖。Django 里这个锁必须配合@transaction.atomic使用,否则锁不生效,数据库会直接报错。normalize_answer是自定义函数,做的事情是把用户答案和标准答案都转成小写、去掉空格、多选按字母排序,避免因为「A,B」和「B,A」这种顺序差异误判。判分时逐题读取QuestionBank,每个题目查一次库,题目量 50 道以内没压力;如果题库上万,更优的做法是一次性filter(id__in=answers.keys())查出所有题目再组装成 dict,内存换速度。

浮点累计在题目多的情况下会有精度问题,0.1 加 0.2 会得到 0.30000000000000004,所以在最后round(total, 2)保留两位小数,满足成绩单展示需求,也避免数据库里存一长串小数。实际生产场景建议把ExamRecord.score字段类型换成DecimalField(max_digits=5, decimal_places=2),从根上解决浮点误差。

3.3 前端答题交互:倒计时和定时保存

答题页templates/exam_paper.html的核心逻辑不复杂但坑多。页面加载时先向后端拿试卷快照并渲染题目,倒计时用 localStorage 存结束时间戳,防止用户刷新页面后重新计时。交卷前用 fetch 把答案 POST 给submit_exam,同时保留一份本地备份:

const duration = parseInt(document.getElementById('duration').value); const endTime = localStorage.getItem('exam_end') || (Date.now() + duration * 60000); localStorage.setItem('exam_end', endTime); const timer = setInterval(() => { const remain = endTime - Date.now(); if (remain <= 0) { clearInterval(timer); submitExam(); } document.getElementById('timer').innerText = formatTime(remain); }, 1000); function submitExam() { const answers = {}; document.querySelectorAll('.question-item').forEach(item => { const qid = item.dataset.qid; const checked = item.querySelector('input:checked'); if (checked) answers[qid] = checked.value; }); fetch(`/exam/submit/${recordId}/`, { method: 'POST', headers: {'X-CSRFToken': getCookie('csrftoken')}, body: JSON.stringify({answers: answers}) }).then(res => res.json()).then(data => { if (data.code === 0) location.href = `/exam/result/${recordId}/`; }); }

用 localStorage 而不是 sessionStorage 的原因:localStorage 在浏览器关闭后仍然保留,用户误关标签页重新打开,exam_end还在,继续用同一个截止时间;如果用 sessionStorage,关掉标签页就丢失,重新打开会重新计时,相当于考试可以无限续杯。向后端提交时用 fetch 加 JSON 格式,比传统的 form 提交省去页面跳转,也不影响答题状态。注意 Django 的 CSRF 中间件,POST 请求必须带csrftoken,从 cookie 里取出来后手动加到 header 里,否则返回 403。

4. admin 后台定制与防作弊设计:让演示环节拿高分

4.1 admin 后台的定制点

直接裸用 Django admin 也能管理题库和成绩,但展示效果偏素。这套项目在 admin.py 里做了定制:成绩列表显示学生、试卷、得分、状态和交卷时间,再加一个自定义筛选器,按分数段过滤答卷。核心代码:

from django.contrib import admin from .models import ExamRecord @admin.register(ExamRecord) class ExamRecordAdmin(admin.ModelAdmin): list_display = ('student_name', 'paper', 'score', 'status', 'submit_time') list_filter = ('paper', 'status') search_fields = ('student__username', 'paper__title') actions = ['reset_exam'] def student_name(self, obj): return obj.student.username student_name.short_description = '学生' @admin.action(description='重置为未考试状态') def reset_exam(self, request, queryset): queryset.filter(status=2).update( score=0, status=1, submit_time=None )

list_display里不能直接写student__username这种跨表路径,要自定义方法返回obj.student.username,否则 admin 页面直接抛 FieldDoesNotExist。reset_exam这个 action 对毕设演示特别实用,学生考完想再演示一遍完整流程,直接在后台勾选记录点一下就能重置状态。search_fields里的双下划线写法是跨表搜索的标准写法,可以用来按学号或用户名查人。

如果觉得原生 admin 的样式不够好看,可以引入第三方美化组件,但有得必有失。评审时老师更关注功能链路是否完整,admin 页面原生风格反而能强调「这是 Django 自带后台,没有过度封装」,建议保留原生外观,把精力花在业务逻辑上。

4.2 限时、防重与 IP 留痕

在线考试演示环节最怕的场面是:学生刷新页面重新做一遍,或者开两个标签页同时答题。这两个行为必须在后端拦截,不能只靠前端按钮禁用。设计一套状态机:

操作状态变化拦截策略
进入考试无 → 1 进行中record 已存在且 status=1 时直接返回试卷快照
交卷1 → 2 已完成status=2 时抛「已交卷,请勿重复提交」
重进已交卷考试2 保持不变只返回成绩单,不允许重新作答
倒计时归零1 → 2 已完成前端触发交卷,后端校验 submit_time 不超过时限

状态机之外,IP 留痕是性价比很高的加分项。在ExamRecord上加一个ip_addr字段,交卷时从request.META.get('REMOTE_ADDR')取客户端地址存进去。答辩时可以拿数据说明「系统记录了考生 IP,同一 IP 多次登录不同账号会触发异常标记」,虽然代码量不大,但体现出了防作弊意识。

4.3 往中间件里藏日志

不改视图代码就能记录所有访问路径,这是中间件的优势。在 Django 里写一个简单中间件,把每个登录用户的访问行为落盘:

import logging logger = logging.getLogger('exam.access') class ExamAccessLogMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): if request.user.is_authenticated: logger.info( f"{request.user.username} {request.method} {request.path}" ) return self.get_response(request)

中间件要注册到settings.pyMIDDLEWARE列表里,放在最后一位即可。注意 logger 必须先在settings.py里配置 handlers 和 formatter,日志级别设成 INFO 才会落盘;否则默认只输出到控制台,服务器重启后记录全丢,答辩时拿不出有效日志就尴尬了。生产部署一般用 TimedRotatingFileHandler 按天切分日志文件,避免单个文件无限膨胀。

5. 部署瞬间的坑:从本地到服务器的迁移记录

5.1 连接 MySQL 的版本陷阱

本地跑通后换到服务器,卡住 90% 的人都是 mysqlclient。Django 连 MySQL 默认找 mysqlclient,服务器上编译经常报mysql_config not found。常见做法是改用 pymysql,在项目同名目录的__init__.py里做一层适配:

import pymysql pymysql.install_as_MySQLdb()

install_as_MySQLdb()会把 pymysql 注册成 MySQLdb 的替身,Django 的 MySQL 后端检测到 MySQLdb 存在就会放行。这个适配放在exam_system/__init__.py里,确保项目启动时最先执行。相应的settings.py数据库配置:

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'NAME': 'exam_db', 'USER': 'root', 'PASSWORD': '换成你的密码', 'HOST': '127.0.0.1', 'PORT': '3306', 'OPTIONS': {'charset': 'utf8mb4'}, } }

charset指定utf8mb4而不是utf8,因为utf8mb4才能完整支持中文和 emoji,MySQL 8.0 默认就是utf8mb4。如果坚持用 mysqlclient,在 Ubuntu 服务器上先apt install libmysqlclient-dev再 pip install,才是正常路径。

5.2 静态文件与 nginx 托管

DEBUG=False之后最大的变化是 Django 不再托管静态文件,页面全部裸奔。必须先把静态文件收集到一个统一目录:

pip install gunicorn python manage.py collectstatic --noinput gunicorn exam_system.wsgi:application -b 0.0.0.0:8000

collectstatic会把 static 目录和 admin 自带的静态资源都复制到STATIC_ROOT,nginx 再把这些文件直接返回给浏览器,不用经过 Django,减轻应用负载。nginx 配置里注意alias路径的结尾斜杠:

location /static/ { alias /www/wwwroot/online_exam/static/; }

alias路径最后少写一个/,CSS 文件会全部 404,这个错误在浏览器控制台里看 Network 面板一眼就能定位。如果用宝塔面板部署 django 项目,不要在终端里nohup python manage.py runserver,应该用 gunicorn 或 uwsgi 接管,否则进程被杀掉后服务就断了。宝塔的 Python 项目管理器会帮你维护 gunicorn 进程,填启动命令gunicorn exam_system.wsgi:application -b 0.0.0.0:8000就行。

5.3 数据备份与恢复

演示完毕业设计,库里攒了一堆测试账号和答题记录,别让答辩前夜变成数据抢救夜。MySQL 在线考试系统最可靠的备份方式就是 mysqldump:

mysqldump -u root -p exam_db > exam_backup_$(date +%Y%m%d).sql

恢复时先建库再导数据,注意字符集必须带 utf8mb4,否则中文乱码:

mysql -u root -p -e "CREATE DATABASE exam_db DEFAULT CHARSET utf8mb4;" mysql -u root -p exam_db < exam_backup_20250101.sql

恢复完还要检查 Django 的迁移记录是否和数据库表结构同步,跑一下python manage.py migrate --fake通常能把不一致的部分补上。然后重启 gunicorn,重新登录后台确认题目和成绩列表都在,数据备份恢复这套流程才算真正闭环。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 13:53:43

非线性模型预测控制在双电机电动汽车扭矩分配中的Simulink实现

简介&#xff1a;这份资源围绕双电机电池电动汽车的扭矩分配问题&#xff0c;提供一套基于非线性模型预测控制&#xff08;NMPC&#xff09;的Simulink实现方案&#xff0c;适合车辆控制、能量管理方向的研究者与工程师参考。包内共27个文件&#xff0c;包含Simulink整车/电气架…

作者头像 李华
网站建设 2026/9/16 13:51:57

光刻机核心技术突破与产业化应用解析

1. 项目背景与行业痛点光刻机作为半导体制造的核心设备&#xff0c;其技术突破一直被视为产业升级的关键门槛。在芯片制造流程中&#xff0c;光刻工艺直接决定了集成电路的制程水平和良品率&#xff0c;而光刻机就是这个环节的"心脏"。目前全球高端光刻机市场呈现高度…

作者头像 李华
网站建设 2026/9/16 13:50:55

Cesium大规模植被渲染:GPU Instancing到3D Tiles优化实践

简介&#xff1a;面向Cesium开发者与三维GIS学习者&#xff0c;资源包围绕在Cesium中高效渲染大量雪松树这一需求&#xff0c;提供基于LOD&#xff08;细节层次&#xff09;优化的一整套模型与配置。包内共36个文件&#xff0c;包含10个gltf三维模型、10个bin几何数据文件、11个…

作者头像 李华