ARTICLE DETAIL

资讯详情

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

基于Django+Vue的在线考试系统:前后端分离架构与自动判分实战

基于Django+Vue的在线考试系统:前后端分离架构与自动判分实战 简介基于Python的在线考试系统项目源码及配套文档采用前后端分离架构覆盖登录、考试、管理、阅卷等核心流程适合计算机相关专业的课程设计、毕业设计以及Python Web开发学习者参考。压缩包共2000个文件大小26.61MB其中Python脚本占1316个用于实现后端接口与业务逻辑364个HTML配合223个JS、49个CSS组成前端页面并引入常见响应式框架另有部分txt、md、json、xml配置文件便于理解项目结构与运行配置。资源已通过测试并获导师指导认可目前已有81人学习下载可作为项目初期演示或二次开发基础。附带详细文档和完整源码能够帮助读者从环境搭建、数据库设计、前后端交互等环节快速上手也可在此基础上扩展随机组卷、自动评分、考试记录分析等功能无论用于课程答辩还是实际项目开发都能提供扎实的参考价值。1. 在线考试系统最难的不是考试而是考试前后的状态管理一套考试系统真正让人头疼的不是学生点“开始答题”那一刻而是前端的题目管理、组卷规则、考试状态流转以及考后答案比对和判分归档。这套基于Python的在线考试系统采用前后端分离架构后端把题、卷、考、判全部封装成RESTful接口前端独立渲染跑通从题库导入到自动判分的完整闭环。我拿到资源后照着搭了一套内部培训考试系统环境从零到跑通不到半小时。适合三类人有Python基础想练全栈实战的、毕业设计选型做管理系统的、以及小团队要快速搭内部考试平台的。资料里附带详细文档按文档操作基本不会卡壳。2. 架构选型与项目结构前后端分离为什么是这套系统的正解2.1 后端选型Django DRF 比 Flask 强在数据关系和事务在线考试系统的核心矛盾不是接口吞吐量而是数据实体之间的关系复杂度和事务一致性。一套最小可用的考试系统里至少要有用户、题目、试卷、考试实例、答题明细这五类实体彼此之间是一对多、多对多的嵌套关系并且“提交答案”这个动作必须保证只能成功一次。Django在这个场景下的优势是自带ORM和migration机制建表、改字段、同步数据库全程命令行完成不需要手写SQL建表脚本。Flask虽然轻量但需要自己组装SQLAlchemy、蓝图、JWT扩展项目到了考试系统这种规模依赖兼容问题反而会吃掉大量时间。DRF的价值在于ModelSerializer和视图集题目、试卷、成绩这类高度结构化的数据用序列化器直接把ORM模型转成JSON返回同时内置认证、权限、限流组件。# requirements.txt 核心依赖Python 3.8 - 3.10 实测最稳 Django4.2.7 djangorestframework3.14.0 django-cors-headers4.3.1 djangorestframework-simplejwt5.3.0 mysqlclient2.2.0参数说明Django 4.2是LTS长期支持版本安全补丁会持续更新到2026年比追最新版更稳。DRF 3.14和Django 4.2的兼容性经过大量项目验证不会出现接口文档打不开之类的怪问题。mysqlclient是MySQL驱动如果你用SQLite演示这行可以去掉。2.2 目录结构前后端独立部署接口是唯一的桥这套项目拆开之后是标准的两个工程后端只输出JSON前端完全不碰Django模板语法。资源包解压后的目录结构大致如下exam-system/ ├── backend/ # Django 后端工程 │ ├── manage.py │ ├── config/ # 项目配置settings/urls/wsgi │ └── apps/ │ ├── users/ # 用户、角色、登录注册 │ ├── questions/ # 题库、题型、选项管理 │ ├── exams/ # 试卷、考试实例、发布 │ └── records/ # 答题记录、自动判分、成绩 ├── frontend/ # Vue 前端工程 │ ├── src/ │ │ ├── api/ # axios 封装所有接口请求 │ │ ├── views/ # 登录/考试/管理后台页面 │ │ ├── router/ # 前端路由与权限守卫 │ │ └── store/ # 用户状态、答题状态 └── docs/ # 详细文档环境搭建/接口说明后端按业务域拆成四个appusers管人questions管题exams管卷和考试场次records管考完之后的判分与成绩。前端views下每个页面一个文件夹和路由一一对应。这种拆法的好处是考试流程里任何一环出了问题顺着app名字就能定位到代码位置而不是在一大坨views.py里翻。2.3 从零启动10分钟把环境拉起来后端和前端是两套独立进程启动顺序无所谓但端口要固定。后端默认跑在8000前端跑在5173开发环境下通过跨域配置互通。# 后端启动Windows / macOS / Linux 通用 cd backend python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple python manage.py makemigrations users questions exams records python manage.py migrate python manage.py createsuperuser python manage.py runserver 0.0.0.0:8000# 前端启动 cd frontend npm install npm run serve如果你刚装完Python记得把pip源切到清华镜像不然拉依赖的速度能让人怀疑人生。后端起来之后浏览器打开http://localhost:8000/api/docs/能看到DRF自带的接口调试页面这个页面可以直接模拟登录、发请求调试阶段完全可以当Postman用。前端起来之后打开http://localhost:5173用刚才创建的超级管理员账号登录如果能进到管理后台说明前后端联通正常。3. 数据库设计与权限模型五张核心表撑起整个考试闭环3.1 角色模型自定义User加role字段不拆表这套系统的用户分为三种角色学生、教师、管理员。设计上有一个关键决策——不单独建Student表和Teacher表而是在Django自带的User模型上加一个role字段。原因很简单登录鉴权时只需要一个token就能识别身份和权限拆成多张表会平白增加联表查询。# backend/apps/users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): ROLE_CHOICES ( (student, 学生), (teacher, 教师), (admin, 管理员), ) role models.CharField(max_length10, choicesROLE_CHOICES, defaultstudent)参数说明AbstractUser继承了Django内置的username、password、email等字段不用重复定义。role字段用CharField加choices约束保证数据层不会写入非法角色。DRF的权限控制基于这个字段做判断教师角色才能调组卷接口学生角色只能进考试和查成绩。3.2 核心业务表从题库到成绩的完整链路去掉所有辅助表这套系统最核心的是五张表。它们的关联关系是QuestionBank题库包含多个Question题目多个Question组成Paper试卷Paper被Exam考试场次引用学生参加Exam后生成ExamRecord考试记录。表名核心字段说明question_bankname, owner题库按科目/课程划分questionbank, type, content, options, answer, score题目type区分单选/多选/判断papertitle, total_score, question_ids试卷与题目是多对多exampaper, start_time, end_time, status考试场次绑定一份试卷和时间窗exam_recordexam, student, score, status, submit_time一次考试记录判分结果落在这里Question表的options字段是核心设计点。单选和判断题的选项是固定的多选的选项数量不固定所以选项统一用JSON格式存而不是拆成QuestionOption表。answer字段同样存JSON单选存字符串多选存数组这样判分逻辑只需要做一次类型判断不需要递归查子表。3.3 考试状态流转四个状态控制接口可见性考试状态是这套系统里最容易翻车的设计点。Exam表的status字段只有四个值draft草稿、published已发布、ongoing进行中、finished已结束。草稿状态下学生端的考试列表看不到这场考试只有教师和管理员可见。发布时间到开始时间之间学生可以看到考试但无法进入答题。时间窗口内状态自动流转为ongoing答题接口开放。过了截止时间无论学生是否提交状态都变为finished答题接口直接拒绝写入。# backend/apps/exams/services.py from django.utils import timezone def get_exam_status(exam): now timezone.now() if not exam.is_published: return draft if now exam.start_time: return published if exam.start_time now exam.end_time: return ongoing return finished参数说明这个函数是考试模块的咽喉所有接口在放行前都会调它判断状态。为什么用函数而不是每次在视图里手动比较时间因为考试开始、结束、判分、成绩查询四个场景都要用逻辑集中在一个函数里改时间调整策略时只动一处。很多翻车项目的问题就出在状态判断散落在各个视图函数导致开始时间到了学生进不去、考试结束了还能交卷。4. 后端核心接口实现登录鉴权、组卷策略与自动判分完整链路4.1 JWT登录与权限隔离学生和教师各走各的门前后端分离架构下Session方案要处理跨域Cookie和CSRF麻烦且脆弱。这套系统用的是JWT登录成功后后端返回access token和refresh token前端把token存在本地每次请求放在Authorization头里。# backend/config/settings.py from datetime import timedelta SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(hours2), REFRESH_TOKEN_LIFETIME: timedelta(days7), AUTH_HEADER_TYPES: (Bearer,), }参数说明考点试的场景access token的有效期我一般设2小时覆盖正常考试时长避免答题答到一半token过期强制重新登录。如果你管理的是一场3小时的考试记得改成timedelta(hours3)以上。7天的refresh有效期是折中方案太长有安全风险太短会导致学生频繁重新登录。权限隔离通过DRF的permission_classes实现三个角色各走各的路由# backend/apps/exams/views.py from rest_framework.permissions import IsAuthenticated from apps.users.permissions import IsTeacher, IsStudent class PaperCreateView(APIView): permission_classes [IsAuthenticated, IsTeacher] class ExamTakeView(APIView): permission_classes [IsAuthenticated, IsStudent]教师能创建试卷和发布考试但永远不能进入答题页面提交答案学生能参加考试但碰不到组卷接口。这个隔离是硬性的不是前端隐藏按钮就完事后端接口要真实拦截。4.2 题库与组卷策略从数据库取题到生成试卷组卷是教师侧最核心的操作。常见策略有两种手动选题和随机抽题。这套系统两种都支持随机抽题的核心逻辑是按题型比例从题库捞题再拼装成试卷。# backend/apps/exams/services.py import random from apps.questions.models import Question def generate_paper(bank_id, title, config): config 格式: {single: 10, multiple: 5, judge: 5} 每道题分值: 单选1分多选2分判断1分 paper Paper.objects.create(titletitle, total_score0) total 0 for qtype, count in config.items(): question_pool list(Question.objects.filter( bank_idbank_id, typeqtype )) if len(question_pool) count: raise ValueError(f题库中{qtype}题型数量不足需要{count}道仅有{len(question_pool)}道) selected random.sample(question_pool, count) for q in selected: PaperQuestion.objects.create( paperpaper, questionq, scoreq.score ) total q.score paper.total_score total paper.save() return paper参数说明config字典的前端传参格式是{single: 10, multiple: 5, judge: 5}分别代表单选、多选、判断题的数量。这里有一个防守逻辑如果题库里某个题型不够抽直接抛异常而不是硬抽避免考卷题量不足。总分会根据题目实际分值和数量自动累加不需要前端手填。这个设计在两套实际项目中验证过够用。4.3 提交答案与自动判分事务、幂等与两种判分策略这是整套系统技术含量最高的接口。学生在考试时间内提交答案后端要做四件事校验考试状态、计算客观题得分、标记主观题待批改、写入数据库。最容易被忽略的是幂等性——网络抖动导致前端重试提交时不能让同一场考试被判定两次。# backend/apps/records/views.py from django.db import transaction from django.utils import timezone transaction.atomic def submit_exam(request, exam_id): exam Exam.objects.select_for_update().get(idexam_id) student request.user # 幂等检查已提交过直接返回历史成绩不重复判分 existing ExamRecord.objects.filter(examexam, studentstudent).first() if existing and existing.is_submitted: return Response({score: existing.score, status: already_submitted}) if get_exam_status(exam) ! ongoing: return Response({error: 考试不在进行中}, status400) answers request.data.get(answers) # [{question_id: 1, answer: A}, ...] obtain 0 subjective_ids [] for item in answers: q Question.objects.get(iditem[question_id]) if q.type subjective: subjective_ids.append(q.id) continue user_ans item[answer] correct q.answer # JSON字段单选存字符串多选存数组 if q.type single: if user_ans correct: obtain q.score elif q.type judge: if user_ans correct: obtain q.score elif q.type multiple: # 多选策略全对才得分选错或漏选均不得分 if set(user_ans) set(correct): obtain q.score record, created ExamRecord.objects.update_or_create( examexam, studentstudent, defaults{ objective_score: obtain, subjective_questions: subjective_ids, is_submitted: True, submit_time: timezone.now(), } ) return Response({objective_score: obtain})这里有两个核心设计。第一是select_for_update()加了行级锁同一时刻多个请求同时提交时后到的请求会等待锁释放不会并发写入两条考试记录。第二是update_or_create结合is_submitted标志位实现幂等前端重试提交时直接返回已保存的成绩不会重复判分。多选判分默认是“全对才得分”的严格策略这是考试系统最常见的配置。如果你需要“漏选得一半分”的宽松策略把多选判分逻辑改成elif q.type multiple: correct_set set(correct) user_set set(user_ans) if user_set correct_set: obtain q.score elif user_set.issubset(correct_set) and user_set: obtain round(q.score / 2, 2)参数说明round(q.score / 2, 2)是为了避免浮点精度问题多选按2分算的话就是1分。这种改法适用于模拟考试或者练习模式正式考试一般不用因为给了部分分之后学生可以通过排除法蒙混过关成绩含金量下降。5. 前端对接与联调避坑axios封装、跨域、Token过期5.1 axios实例与拦截器把鉴权逻辑收敛到一个文件里前端和后端联调时最容易出现的问题是每个页面都写一遍token获取逻辑改一处漏一处。这套系统把所有请求统一封装在一个axios实例里请求发出前自动带token响应回来时统一处理登录过期。// frontend/src/api/index.js import axios from axios import router from /router const service axios.create({ baseURL: process.env.VUE_APP_API_BASE || http://localhost:8000/api, timeout: 15000 }) // 请求拦截器每次请求自动带上 JWT service.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器401 时尝试用 refresh token 续期 service.interceptors.response.use( response response.data, async error { const original error.config if (error.response?.status 401 !original._retry) { original._retry true const refresh localStorage.getItem(refresh_token) try { const { data } await axios.post(http://localhost:8000/api/auth/refresh/, { refresh: refresh }) localStorage.setItem(access_token, data.access) original.headers.Authorization Bearer ${data.access} return service(original) } catch (e) { localStorage.clear() router.push(/login) } } return Promise.reject(error) } )参数说明baseURL指向后端API根路径用VUE_APP_API_BASE环境变量做区分开发环境指到8000端口生产环境指到Nginx的同源路径。_retry标志位是防死循环的关键没有它refresh失败后请求会反复进入拦截器无限弹登录框。响应拦截器统一返回response.data意思是所有页面请求拿到的直接就是业务数据不用每次.then(res res.data)。5.2 开发环境跨域后端白名单和前端代理二选一开发时前端在5173端口后端在8000端口浏览器跨域是绕不开的坎。两套方案都可跑通但不要同时开。方案A后端配置 django-cors-headers适合前后端分离程度高的团队。# backend/config/settings.py INSTALLED_APPS [ ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, ... ] CORS_ALLOWED_ORIGINS [ http://localhost:5173, http://127.0.0.1:5173, ]方案B前端用Vite代理转发适合不想动后端配置的场景。// frontend/vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })方案A的优点是直接缺点是生产环境如果忘了关CORS白名单等于把接口暴露给任意来源的脚本调用分分钟被刷。方案B的代理只存在于开发环境打包上线后前端和后端同域根本不存在跨域问题我一般推荐方案B。如果你用的是方案A上线前记得把CORS_ALLOWED_ORIGINS删掉或配成生产前端域名。5.3 考试页面倒计时、本地暂存和自动提交在线考试最怕的就是学生答了一小时刷新页面答案全没了。这套系统的考试页面做了双层保险答题内容实时存localStorage倒计时结束后前端自动提交。// frontend/src/views/exam/TakingExam.vue export default { data() { return { answers: {}, timer: null, remainSeconds: 0 } }, mounted() { this.remainSeconds this.exam.duration * 60 // 恢复上次未提交的本地答案 const saved localStorage.getItem(exam_${this.exam.id}) if (saved) this.answers JSON.parse(saved) this.timer setInterval(() { this.remainSeconds-- // 每 30 秒写一次 localStorage if (this.remainSeconds % 30 0) { localStorage.setItem(exam_${this.exam.id}, JSON.stringify(this.answers)) } if (this.remainSeconds 0) { clearInterval(this.timer) this.submitExam() } }, 1000) }, methods: { async submitExam() { const res await this.$api.submitExam(this.exam.id, { answers: this.answers }) localStorage.removeItem(exam_${this.exam.id}) this.$router.push({ path: /result, query: { examId: this.exam.id } }) } } }参数说明localStorage的key带上考试ID是为了避免同一个学生同时开两个考试页面时答案互相覆盖。remainSeconds % 30 0是节流逻辑每30秒落一次盘既不会频繁写坏浏览器存储又能把刷新丢答案的损失控制在30秒以内。倒计时结束自动提交是最后的防线即使学生忘了点按钮前端也会强制把当前答案发出去成绩以服务端判分为准。这里要注意一个边界本地暂存只是用户体验兜底不是数据持久化。学生清浏览器缓存、换电脑、换浏览器localStorage里的答案就全没了。所以真正重要的数据比如提交完成后的成绩记录必须以后端为准。5.4 联调避坑三则时间格式、浮点精度、Token续期循环第一坑前端传时间字符串后端DateTimeField解析报错。现象是创建考试时设置开始时间前端传2025-06-01T10:00:00Z后端直接抛Z is not a valid DateTimeField format。原因是ISO 8601格式的T和ZDjango默认的parse函数不认。解决方法是前端传时间戳new Date(time).getTime()后端用datetime.fromtimestamp(ts / 1000)转换。第二坑判分结果出现0.30000000000000004。现象是多选题得半分时前端显示一长串小数。原因是Python浮点数的二进制表示精度问题。解决方法是成绩字段用DecimalField而不是FloatField判分时把score转成Decimal(str(q.score))再做运算。第三坑token过期后前端反复弹登录框。现象是考试页面放着不动超过2小时回来点提交答案被踢到登录页重新登录后却又进不去考试。原因是access token过期后axios拦截器尝试用refresh token续期但refresh token也过期了于是陷入死循环。解决方法是拦截器里加_retry标志位前面代码里已经写了refresh失败直接清空本地存储跳登录页不要把用户留在半死不活的状态。6. 部署与验证清单上线前把三个备坑问题一次清干净6.1 生产环境Nginx Gunicorn MySQL开发模式下runserver是单进程的扛不住并发生产环境必须换Gunicorn。部署时后端和前端都交给Nginx前端静态文件由Nginx直接服务后端API通过反向代理转发。# 后端启动命令 cd backend gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4# Nginx 配置关键段 server { listen 80; server_name exam.example.com; # 前端静态资源 root /var/www/frontend/dist; index index.html; # 后端 API 反向代理 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }--workers 4是经验值4核CPU的机器起步用这个配置每个worker是独立进程能同时处理4个请求。生产环境记得在settings.py里把DEBUGFalseALLOWED_HOSTS填成你的域名。很多项目上线后被扫漏洞根因就是DEBUG没关Django把完整报错堆栈和数据库结构直接暴露在页面上。静态文件处理是另一个高频翻车点。npm run build打包出的dist目录要放到Nginx的root路径Django的admin静态文件需要执行python manage.py collectstatic收集到指定目录否则后台管理页面光秃秃的没有样式。6.2 上线前验证清单部署完不要急着给用户用按这套清单跑一遍有问题在可控范围内暴露。验证项操作预期结果管理员登录访问管理后台用createsuperuser创建的账号登录能进入页面样式正常创建题库新建题库并导入10道单选、5道多选、5道判断题目数量正确选项JSON无报错随机组卷调用组卷接口配好题量试卷总分正确无题量不足异常发布考试设置开始时间当前时间5分钟结束时间当前时间1小时学生端能看到考试但进不去进入考试等开始时间到学生账号点击进入正常加载题目计时器启动提交判分学生提交答案包含正确和错误答案客观题分数计算正确多选严格判分重复提交提交成功后再次调用提交接口返回already_submitted不重复计分成绩查询学生端查看成绩成绩与提交结果一致状态已结束这套清单是我自己总结的最小集八项跑完这套系统的核心链路就算验证过了。之前有一整个项目组上线前一天才发现多选判分逻辑写成了“选错也给分”就是因为没跑第六项。6.3 加餐批量模拟考试成绩验证并发一致性单次考试验证不了并发问题。真正的坑往往在50个学生同时交卷时才会出现。用Django shell脚本模拟100个学生答题批量生成考试记录这是上线前最值得做的一次压测。python manage.py shell EOF from django.contrib.auth.models import User from apps.exams.models import Exam from apps.records.models import ExamRecord import random exam Exam.objects.get(id1) students User.objects.filter(rolestudent)[:100] for student in students: answers [] for q in exam.paper.questions.all(): if q.type multiple: ans random.sample([A, B, C, D], random.randint(2, 3)) else: ans random.choice([A, B, C, D]) answers.append({question_id: q.id, answer: ans}) ExamRecord.objects.create( examexam, studentstudent, objective_scorerandom.randint(20, 60), is_submittedTrue, ) print(batch done) EOF跑完检查ExamRecord表里的记录数是否等于100有没有重复提交的记录objective_score有没有超出试卷总分。一切正常这套系统的判分和事务逻辑才算真正过关。之前有一次上线前没跑这个批量脚本结果真实考试时有几十个学生同时提交select_for_update没加锁导致成绩记录少了几条当事学生查不到成绩最后人工对卷对到凌晨。从那以后我每次部署考试系统都会在验收上强制走一遍模拟脚本加验证清单再也不赌“应该没问题”。这套项目本身资料齐全拿到后按第2章的启动顺序十分钟就能跑起来文档里把接口细节和配置参数也写得很清楚剩下时间建议重点研究第4章和第5章的流程设计那才是这套系统的精华。希望帮到你。本文还有配套的精品资源点击获取
返回列表