ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Django+Vue3在线考试系统全栈实战:从模型设计到部署

Django+Vue3在线考试系统全栈实战:从模型设计到部署 最近被问得最多的一句话是用 Python 做在线考试系统后端到底选 Django 还是 Flask前端是不是必须用 Vue 3说实话这两个问题我每次的回答都一样如果你是想做一个能真正上线、能扩展、能扛住并发交卷的系统Django 几乎是 Python 生态里最省心的选择而前端只要你不是一个人硬扛 jQuery 维护到天荒地老Vue 3 组合式 API 写起来比 Vue 2 舒服太多。这篇文章就用一个我最近完整开发的“在线考试系统”项目作为例子把 Django Vue 3 这套前后端分离方案从模型设计、接口开发、前端页面到联调部署全流程拆开讲一遍。已经具备 Python 和 JavaScript 基础、想系统做一次全栈项目的读者可以直接照着抄。整个项目的核心链路其实很简单管理员创建试卷和题目考生登录后选择考试场次进入答题页面完成题目交卷后系统自动判分并展示成绩。技术栈上后端用 Django 4 Django REST Framework JWT 认证前端用 Vue 3 Vite Pinia Vue Router Axios。这套组合的好处是Django 自带 Admin 后台管理端几乎零成本DRF 让接口开发规范化而 Vue 3 的组合式 API 在处理考试这种“状态多、交互密”的场景时代码复用和逻辑拆分都非常顺手。1. 项目整体拆解与方案选型1.1 为什么是 Django Vue 3 这个组合先聊选型。我知道很多人纠结 Django 和 Flask 的区别尤其网上教程总喜欢拿“Flask 轻量、灵活”说事。但轻量是个双刃剑Flask 的灵活性意味着你要自己决定用什么 ORM、用什么认证方案、怎么组织项目结构这些决策本身就消耗大量时间。而 Django 自带 ORM、Admin、迁移工具、认证体系、安全防护特别是在线考试这种“用户权限分明、数据模型复杂、需要后台管理”的应用Django 的开箱即用能让你的开发周期缩短至少三分之一。其次Django REST FrameworkDRF把 API 开发做得非常规范。我们只需要在 models 里定义数据表配合 Serializer 做序列化再用 ViewSet Router 自动生成路由一套标准的 RESTful 接口就出来了。同样的工作量如果用 Flask 写Model、序列化、路由、参数校验全都要自己拼而且团队协作时的接口风格也难以统一。前端选 Vue 3 也是同一个逻辑。考试系统里最核心的答题页面本质上是一个“高频状态变更 多个组件联动”的场景倒计时在走、题目在切换、答案在暂存、标记状态在更新。Vue 3 的组合式 API 把同一逻辑的代码聚合在一起比 Vue 2 的 Options API 更不容易写出“跳来跳去的 this”。再加上 Pinia 做状态管理的体验比 Vuex 好太多没有 mutation 那些绕来绕去的写法直接改 state 就行。对比下来Vue 3 Pinia 这套组合开发效率比 Vue 2 Vuex 高出一个档次。1.2 功能模块划分与核心流程梳理在线考试系统的功能如果画成一张图大致可以分成三条线管理员线、考生线、系统线。我们做项目前一定要先把这几条线理清楚否则后面代码越写越乱。管理员线是三大模块题库管理、试卷管理、考试管理。题库管理维护选择题、判断题、多选题的题干和选项试卷管理把题目按规则组合成卷这里涉及随机抽题、固定抽题、题目分值分配我后面会在模型设计里给出具体方案考试管理负责创建一场考试设置考试时间、时长、参加人员范围、是否允许查看成绩等。得益于 Django Admin这条线甚至可以不用单独写前端页面直接用后台管理界面就能完成。考生线是四个核心链路登录后查看可参加的考试列表、进入考试前阅读考试说明、答题过程中保存答案和标记题目、交卷后查看成绩。这里最容易被忽视的是“答题中途刷新页面”的情况如果没有做答案的本地暂存和恢复机制考生一刷新就回到起点那是妥妥的生产事故。系统线相对隐蔽但恰恰是体现工程能力的地方交卷后的自动判分逻辑、成绩的实时计算、考试时间到点的强制交卷处理。自动判分最容易踩坑的是多选题的判定策略——全对才得分还是部分选对给部分分这里没有标准答案完全取决于业务需求。我在这个项目里采用了“全对才得分”的保守策略并在交卷接口里预留了判定策略的配置项方便以后按不同考试类型调整。2. 后端核心设计与接口实现2.1 Django 工程创建与项目结构开始写代码前先把工程结构对齐。我用的是 Django 4.2 LTSPython 3.10。创建一个虚拟环境并安装依赖核心包就这几个pip install django4.2 pip install djangorestframework pip install djangorestframework-simplejwt pip install django-cors-headers pip install mysqlclient # 或者用 psycopg2-binary 连 PostgreSQL创建一个名为 exam_backend 的工程然后建两个 appusers 负责用户和认证exam 负责考试业务。django-admin startproject exam_backend cd exam_backend python manage.py startapp users python manage.py startapp exam项目结构尽量保持 Django 官方推荐的分层exam_backend/ ├── exam_backend/ # 工程配置目录 │ ├── settings.py │ ├── urls.py │ ├── wsgi.py │ └── asgi.py ├── users/ # 用户与认证模块 │ ├── models.py │ ├── serializers.py │ ├── views.py │ └── urls.py ├── exam/ # 考试业务模块 │ ├── models.py │ ├── serializers.py │ ├── views.py │ └── urls.py └── manage.pysettings.py 里需要把 DRF、CORS、SimpleJWT 都注册进去同时配置好自带的 User 模型替换。这里我强烈建议从一开始就设置 AUTH_USER_MODEL不要等到项目写一半再换否则迁移文件会让你欲哭无泪。AUTH_USER_MODEL users.User INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, rest_framework, corsheaders, users, exam, ] REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: ( rest_framework_simplejwt.authentication.JWTAuthentication, ), DEFAULT_PERMISSION_CLASSES: ( rest_framework.permissions.IsAuthenticated, ), } CORS_ALLOWED_ORIGINS [ http://localhost:5173, # Vite 默认端口 ]每次新建 Django 项目我第一个踩的坑永远是 CORS。前后端分离后前端跑在 5173后端跑在 8000端口不同必然产生跨域请求所以 django-cors-headers 必须一开始就配上。2.2 核心数据模型设计在线考试系统的模型设计是整个项目的灵魂。我经历过的模型改动多了之后总结出一个原则宁可前期多想一步不要后期拆表迁移。User 模型也就是我们自己的用户表from django.contrib.auth.models import AbstractUser class User(AbstractUser): USER_ROLE_CHOICES ( (student, 学生), (teacher, 教师), (admin, 管理员), ) role models.CharField(max_length20, choicesUSER_ROLE_CHOICES, defaultstudent) student_no models.CharField(max_length50, blankTrue, nullTrue) real_name models.CharField(max_length50, blankTrue, nullTrue)这里直接继承 AbstractUser保留了 Django 自带的用户名密码登录能力同时扩展了角色和学号字段。管理员和教师可以进后台考生只走正常接口。题目表 Question我设计成一张表存储选择题和判断题通过 q_type 字段区分题型class Question(models.Model): QUESTION_TYPE_CHOICES ( (single, 单选题), (multiple, 多选题), (judge, 判断题), ) q_type models.CharField(max_length20, choicesQUESTION_TYPE_CHOICES) content models.TextField(verbose_name题干) options models.JSONField(verbose_name选项, defaultdict) answer models.CharField(max_length10, verbose_name正确答案) score models.IntegerField(default5, verbose_name分值) creator models.ForeignKey(User, on_deletemodels.SET_NULL, nullTrue) created_at models.DateTimeField(auto_now_addTrue)选项用 JSONField 存储是最省事的做法。比如单选题的 options 可能长这样{ A: Python, B: Java, C: C, D: Ruby }判断题的 options 就是固定两个{ A: 正确, B: 错误 }用 JSONField 而不是单独建一张选项表原因很简单选项和题目在生命周期上是强绑定的很少需要单独查询或修改选项放一起反而减少联表查询。但这里有一个前提是数据库要支持 JSON 字段MySQL 5.7 和 PostgreSQL 都没问题SQLite 在 Django 4.2 里也有内置支持开发阶段很省心。试卷表 ExamPaper 和考试表 Exam 才是重头戏class ExamPaper(models.Model): title models.CharField(max_length200) total_score models.IntegerField(default100) duration models.IntegerField(verbose_name考试时长(分钟), default60) questions models.ManyToManyField(Question, throughPaperQuestion) created_at models.DateTimeField(auto_now_addTrue) class PaperQuestion(models.Model): paper models.ForeignKey(ExamPaper, on_deletemodels.CASCADE) question models.ForeignKey(Question, on_deletemodels.CASCADE) order models.IntegerField(default0) class Exam(models.Model): paper models.ForeignKey(ExamPaper, on_deletemodels.CASCADE) name models.CharField(max_length200) start_time models.DateTimeField(verbose_name开始时间) end_time models.DateTimeField(verbose_name结束时间) allow_retake models.BooleanField(verbose_name是否允许重考, defaultFalse) students models.ManyToManyField(User, related_nameexams, blankTrue)PaperQuestion 是中间表用来维护每道题在试卷里的顺序。很多初学者直接依赖 ManyToManyField 默认的顺序这是大忌。数据库表的查询顺序是不受控制的没有 order 字段你永远无法保证题目按预期顺序出现。再来是考试记录和答题记录表class ExamRecord(models.Model): student models.ForeignKey(User, on_deletemodels.CASCADE) exam models.ForeignKey(Exam, on_deletemodels.CASCADE) start_time models.DateTimeField(auto_now_addTrue) submit_time models.DateTimeField(nullTrue, blankTrue) score models.IntegerField(nullTrue, blankTrue) status models.CharField(max_length20, defaultin_progress) class AnswerRecord(models.Model): record models.ForeignKey(ExamRecord, on_deletemodels.CASCADE, related_nameanswers) question models.ForeignKey(Question, on_deletemodels.CASCADE) student_answer models.CharField(max_length10, blankTrue, default) is_correct models.BooleanField(defaultFalse)ExamRecord 记录一次考试的完整生命周期status 有 in_progress答题中和 submitted已交卷两种。AnswerRecord 存考生每道题的答案。这套设计支持一个考生参加多场考试、一场考试多次重考只要用 record 把每一次作答隔离即可。2.3 REST API 与自动判分逻辑接口设计遵循一个原则把能算的尽量在后端算完前端只做展示。以考试流程为例核心接口就五个POST /api/auth/login/ 登录拿 tokenGET /api/exams/ 获取可参加的考试列表GET /api/exams/{id}/detail/ 获取考试详情和题目POST /api/exams/{id}/submit/ 提交试卷GET /api/exams/{id}/result/ 查询成绩其中获取考试详情的接口需要特别注意一个业务约束题目接口不应该返回正确答案。否则考生打开 F12 一看网络请求答案直接暴露系统就废了。所以 serializer 里必须动态排除 answer 字段class QuestionSerializer(serializers.ModelSerializer): class Meta: model Question fields [id, q_type, content, options, score] class QuestionWithAnswerSerializer(QuestionSerializer): class Meta(QuestionSerializer.Meta): fields [id, q_type, content, options, score, answer]获取考试详情用 QuestionSerializer提交批卷内部用 QuestionWithAnswerSerializer两个序列化器分工明确。自动判分的核心逻辑放在 submit 接口里我一律采用“后端判分前端只传答案”的模式。前端把每道题的答案拼成一个列表传过来后端拿着正确答案逐题比对。这里有个容易忽略的点判分必须在事务里做确保成绩写入和状态更新要么都成功要么都失败。transaction.atomic def submit_exam(self, request, exam_id): record ExamRecord.objects.get(idrequest.data[record_id]) if record.status submitted: return Response({error: 该考试已交卷}, status400) answers_data request.data.get(answers, []) score 0 answer_records [] for item in answers_data: question Question.objects.get(iditem[question_id]) student_answer item.get(answer, ) if question.q_type multiple: is_correct set(student_answer) set(question.answer) and student_answer ! else: is_correct student_answer question.answer if is_correct: score question.score answer_records.append( AnswerRecord( recordrecord, questionquestion, student_answerstudent_answer, is_correctis_correct, ) ) AnswerRecord.objects.bulk_create(answer_records) record.score score record.status submitted record.submit_time timezone.now() record.save() return Response({score: score, message: 交卷成功})多选题的判定用了 set 比较好处是不管选项顺序怎么打乱只要集合一致就算对适用于用户答案也是多选的情况。这里判断 student_answer ! 是为了防止用户一个选项都没选却交了个空字符串结果 set() 和 set() 相等白捡一道多选题的分。3. 前端 Vue 3 应用搭建3.1 Vite 工程初始化与项目结构前端工程我用 Vite 创建命令简单粗暴npm create vitelatest exam_frontend -- --template vue cd exam_frontend npm install npm install vue-router4 pinia axiosVite 比 Webpack 快在开发服务器的冷启动和 HMR尤其项目大了以后Webpack 改一个文件等两秒、Vite 几十毫秒就刷新了这种体感差距直接决定了开发效率。工程结构我是按路由和业务模块拆分的exam_frontend/ ├── src/ │ ├── api/ # 接口请求封装 │ │ ├── auth.js │ │ ├── exam.js │ │ └── request.js # axios 实例 │ ├── stores/ # Pinia 状态管理 │ │ ├── auth.js │ │ └── exam.js │ ├── router/ # 路由配置 │ ├── views/ │ │ ├── LoginView.vue │ │ ├── ExamListView.vue │ │ ├── ExamDetailView.vue │ │ ├── ExamStartView.vue # 答题主页面 │ │ └── ExamResultView.vue │ ├── components/ # 通用组件 │ └── App.vue └── vite.config.jsaxios 请求封装是整个前后端通信的地基我习惯把所有接口请求都收敛到一个 request.js 文件里统一处理 baseURL、超时时间、token 注入和错误拦截import axios from axios; const request axios.create({ baseURL: http://localhost:8000/api, timeout: 10000, }); request.interceptors.request.use((config) { const token localStorage.getItem(access_token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); request.interceptors.response.use( (response) response.data, (error) { if (error.response error.response.status 401) { localStorage.removeItem(access_token); window.location.href /login; } return Promise.reject(error); } ); export default request;token 存 localStorage 还是 cookie 是个经典讨论。我这个项目图省事用了 localStorage配合后端 JWT 无状态认证。这种做法在纯前端小项目里是合理的但如果是企业级安全要求高的场景建议改成 HttpOnly Cookie CSRF 防护方案虽然麻烦一点但可以从根上避免 XSS 窃取 token 的风险。3.2 登录与鉴权流程实现登录页是整个系统的门槛实现上我并不准备把精力都耗在 UI 上而是更关注登录后的状态管理。我用 Pinia 的 auth store 来维护用户信息和登录状态import { defineStore } from pinia; import request from ../api/request; export const useAuthStore defineStore(auth, { state: () ({ token: localStorage.getItem(access_token) || , userInfo: JSON.parse(localStorage.getItem(user_info) || null), }), actions: { async login(username, password) { const data await request.post(/auth/login/, { username, password }); this.token data.access; this.userInfo data.user; localStorage.setItem(access_token, data.access); localStorage.setItem(user_info, JSON.stringify(data.user)); }, logout() { this.token ; this.userInfo null; localStorage.removeItem(access_token); localStorage.removeItem(user_info); }, }, });逻辑看起来简单但这里的细节坑在于登录接口返回的 token 结构依赖 SimpleJWT 的配置。我习惯在后端自定义一个 LoginSerializer把 access token、refresh token 和用户基本信息一起返回避免前端多写一个“根据 token 获取用户信息”的接口。路由守卫要配合 Pinia 状态一起用router.beforeEach((to, from, next) { const authStore useAuthStore(); if (to.meta.requiresAuth !authStore.token) { next(/login); } else { next(); } });这里有一个实战中很容易忽视的问题刷新页面后 Pinia 的 state 会重置所以 token 和 userInfo 必须从 localStorage 里初始化。上面 auth store 的 state 里我已经直接读了 localStorage这就能保证 F5 刷新后登录状态不丢。3.3 在线答题核心页面答题页面是前端最复杂的一个视图需要同时处理倒计时、题目切换、答案暂存、标记复查、进度条、交卷确认这一堆交互。我用组合式 API 把逻辑拆成两组一组管题目状态一组管倒计时。先看题目状态管理import { ref, computed, onMounted } from vue; const questions ref([]); const currentIndex ref(0); const answers ref({}); const markedQuestions ref(new Set()); const currentQuestion computed(() questions.value[currentIndex.value]); function selectAnswer(questionId, answer) { answers.value[questionId] answer; } function toggleMark(questionId) { if (markedQuestions.value.has(questionId)) { markedQuestions.value.delete(questionId); } else { markedQuestions.value.add(questionId); } } const answeredCount computed( () Object.keys(answers.value).length );这里我用了一个普通对象 answers 来存每个题的答案key 是 question idvalue 是答案字符串。单选和判断直接赋值多选题我会存成一个数组再 join 成字符串。之所以不用 Pinia 管这个是因为答题状态的生命周期只在当前页面交卷后就没用了用局部 ref 就够了过度设计反而是负担。倒计时逻辑踩过不少坑核心问题在于到了交卷时间必须强制提交。如果只在前端用 setInterval 每秒减一浏览器切后台或卡顿可能导致计时不准。我的方案是记录考试开始时间戳用当前时间和结束时间戳做差值const endTime ref(0); const remainingSeconds ref(0); let timer null; function startCountdown() { const exam JSON.parse(localStorage.getItem(current_exam)); endTime.value exam.end_time; updateRemaining(); timer setInterval(updateRemaining, 1000); } function updateRemaining() { const remain Math.floor((new Date(endTime.value) - Date.now()) / 1000); remainingSeconds.value Math.max(0, remain); if (remain 0) { clearInterval(timer); handleAutoSubmit(); } }这里的时间戳我统一用后端返回的时间字符串比如 2025-02-01T10:00:0008:00前端直接 new Date() 解析。注意千万别在前后端各自用本地时间计算考试系统的对时一致性非常重要否则会出现“前端显示还有 3 分钟后端已经拒绝交卷”的尴尬。答题页的 UI 布局我用的是经典的三栏式中间主体显示当前题目和选项右侧边栏展示题目编号网格已答的显示绿色标记的加个特殊边框未答的默认灰色。点击题号可以直接跳转到对应题目这个交互对考生来说非常顺手。4. 前后端联调与考试完整流程打通4.1 跨域配置与接口调试几乎所有第一次做前后端分离项目的人都会在跨域这里卡半小时。报错无非就是那句经典的Access to XMLHttpRequest at http://localhost:8000/api/exams/ from origin http://localhost:5173 has been blocked by CORS policy解决办法在后端加 django-cors-headers这个我前面已经配置过了。但除了 CORS 中间件还有一个容易漏的点Django 工程里某些请求因为 content-type 是 application/json属于非简单请求会先触发一次 OPTIONS 预检请求。django-cors-headers 默认会处理 OPTIONS但前提是中间件位置放在最上面否则可能被其他中间件拦截。联调阶段的接口调试我一般用 Apifox 或者 Postman先把单个接口跑通了再对接前端。这里有一个实际经验登录接口调通后第一件事不是继续写业务而是验证 token 能不能正常访问需要认证的接口。如果这一步卡住后面所有联调都白搭。以这个项目为例登录后用 access token 访问 /api/exams/如果返回 401先看请求头里有没有 Authorization: Bearer xxx。很多新手喜欢把 token 拼成 Token xxx结果 SimpleJWT 只认 Bearer请求一直 401还以为是后端问题。4.2 考试流程全链路串联考试整体流程我建议用状态机来理解未开始考生能看到考试列表但点击进入时后端校验当前时间 start_time提示“考试未开始”进行中考生可以进入答题页获取题目列表和考试详情已交卷考生再次访问只能看到成绩不能重新作答除非 allow_retakeTrue考试结束即使考生没有主动交卷到达 end_time 后系统也要强制收卷这个状态机前后端都要各自实现一层。后端在每个接口里都校验时间前端在路由守卫和倒计时逻辑里也校验时间。面试题里经常问“前后端谁说了算”答案永远是后端前端校验只是为了用户体验后端校验才是安全底线。我在这套系统里前端一旦检测到考试结束或手动交卷立刻跳转到成绩页同时清除本地缓存的 exam 状态。后端则会记录 submit_time 并且把 status 改成 submitted。这里要特别強調一个幂等性问题如果考生手抖点了两次交卷或者交卷请求因为网络问题重试了两次后端必须保证第二次请求不会重复判分和覆盖成绩。处理方式很简单就是用事务加状态判断if record.status submitted: return Response({score: record.score, message: 该试卷已交卷成绩如下})而不是直接报错这样即使用户重复点击交卷看到的也是同一个成绩不会产生数据错乱。4.3 试卷提交与自动阅卷逻辑交卷接口把题目答案数组传回后端后端完成判分后返回总分。这个过程中有一个我在优化阶段才会处理的问题如果试卷有 100 道题每道题 5 分一次交卷要处理 100 条 AnswerRecord 的插入如果用常规的 for 循环 save()100 次数据库交互在高峰期可能会拖慢响应。解决办法就是用 bulk_create 批量插入我在前面涂的事务代码里已经用了。这个优化看起来不起眼但在数据量上去之后收益非常明显一次交卷从 2 秒降到 200 毫秒体验完全不一样。自动阅卷还有一个雷区题目答案在交卷后可能被修改。比如管理员在考试进行中后台改了某道题的正确答案考生交卷时就会用新答案来判。更合理的做法是交卷判分时把当时的正确答案快照存下来。最稳妥的方案是在 AnswerRecord 表里冗余一个 correct_answer 字段记录判分那一刻的标准答案。这样就算以后题目答案被修改历史考试记录也不会受影响。我实际开发中虽然还没遇到这种“考中改答案”的需求但这条经验是我做项目的一个原则凡是涉及历史记录的业务都要考虑字段冗余和快照不要在查询时动态反查可能会变化的源数据。4.4 成绩查询与统计展示成绩查询接口就相对简单了考生提交试卷后跳转到成绩页请求 /api/exams/{id}/result/ 获取成绩同时展示答题详情。我这里的实现是返回一个包含每题判分结果的列表{ exam_name: Python 基础测试, score: 85, total_score: 100, detail: [ { question_id: 1, content: Python 中用于定义函数的关键字是, your_answer: A, correct_answer: C, is_correct: false, score: 5 } ] }前端拿到这个结果后我特意做成了“逐题回顾”的模式考生可以看到每一题自己的答案、正确答案、对错状态和得分。这个功能看着不起眼但学生端的反馈非常好很多老师就是冲着这个回顾功能才指定用这个系统的。另外一个统计功能考试结束后管理员需要看成绩分布比如平均分、最高分、最低分、各分数段人数。我用 Django ORM 的 aggregate 和 Count 就能直接算出来from django.db.models import Avg, Max, Min, Count stats ExamRecord.objects.filter(examexam, statussubmitted).aggregate( avg_scoreAvg(score), max_scoreMax(score), min_scoreMin(score), totalCount(id), )这类聚合查询在成绩报表里非常常用而且数据库端计算比把记录全捞出来在 Python 里算要高效得多。如果考试人数上万一条 SQL 的聚合查询依然是毫秒级而 Python 遍历可能要一秒以上。5. 常见问题与排查实录5.1 跨域、时区与并发提交问题跨域报错是最常见的但它的解决办法我已经写过了这里重点说一个隐蔽场景本地开发时后端开了 CORS_ALLOW_ALL_ORIGINS True一切正常一旦部署到线上用了具体域名就会被 CORS 策略拦。这个坑的常见表现是本地联调没问题线上却报跨域错误。排查思路大概三步第一步看浏览器 Network 面板的 OPTIONS 预检请求是否返回 200第二步看响应头里有没有 Access-Control-Allow-Origin第三步检查后端配置的允许域名和前端实际访问地址是否完全一致。时区问题同样隐蔽。Django 的 settings 里默认 USE_TZ True所有 DateTimeField 存的是 UTC 时间。前端 new Date() 解析后端返回的 2025-02-01T10:00:00Z 会把 Z 当作 UTC转为本地时间时自动加 8 小时。这个逻辑本身没错但如果后端返回时间字符串末尾没有时区标识前端解析就会直接按本地时间算导致考试时间显示错乱。我的建议是后端 settings 里设置 TIME_ZONE Asia/Shanghai 和 USE_TZ True同时序列化时间字段时明确输出带时区偏移的 ISO 格式serializers.DateTimeField(format%Y-%m-%dT%H:%M:%S%z)并发提交则是另一个大坑。我测试时用两个浏览器标签页同时打开同一场考试然后都点交卷发现可能会出现两个交卷请求都通过了状态检查产生了两次答案记录。解决办法除了后端在事务里加 select_for_update 锁行还应该在数据库层面对 ExamRecord 表加唯一约束或状态约束。实用方案是record ExamRecord.objects.select_for_update().get(idrecord_id) if record.status submitted: return Response({message: 已交卷})select_for_update 会在事务内对该行加锁第二个请求必须等第一个事务提交后才能读取这时候看到的 status 已经是 submitted就不会重复判分了。这是我在压测时踩出来的教训不加锁的话高并发下必定出现脏数据。5.2 前端状态丢失与路由守卫答题过程中考生按了 F5 刷新页面这对考试系统来说是一个高频操作。如果不做任何处理页面刷新后 Vue 实例重建questions、answers、markedQuestions 全部清空考生只能从第一题重新答起。这个体验绝对不行。我的解决方案是分两级缓存题目列表和考试信息是一级缓存在进入答题页时写入 sessionStorage答案和标记状态是二级缓存每次选择答案或标记题目时同步写入 sessionStorage。页面刷新后在 onMounted 里先尝试从 sessionStorage 恢复状态如果恢复成功就直接进入答题模式。onMounted(() { const cachedState sessionStorage.getItem(exam_progress); if (cachedState) { const state JSON.parse(cachedState); questions.value state.questions; answers.value state.answers; markedQuestions.value new Set(state.markedQuestions); currentIndex.value state.currentIndex; startCountdown(); } else { fetchExamDetail(); } }); function saveProgress() { sessionStorage.setItem(exam_progress, JSON.stringify({ questions: questions.value, answers: answers.value, markedQuestions: [...markedQuestions.value], currentIndex: currentIndex.value, })); }这里有個细节sessionStorage 和 localStorage 的区别。sessionStorage 在标签页关闭后自动清除localStorage 则持久保存。答题中途刷新应该用 sessionStorage因为一旦考生关闭了标签页就应该认为他放弃了当前答题进度。如果用了 localStorage考生换个时间重新打开浏览器还能恢复上次的答案直接破坏了考试时间的约束。路由守卫方面需要注意的另一个点考生正在答题时如果手动在地址栏输入了其他页面路由Vue Router 默认会直接跳走。考试场景下应该拦截这种操作。我在 beforeRouteLeave 里加了确认弹窗提示“考试正在进行中确定要离开吗”。如果选择离开视为正常交卷还是放弃作答这个要根据业务约定。我这边采用的是“离开即交卷”让考生明确知道离开的后果。5.3 开发过程中容易踩的隐藏雷区隐藏雷区这个词不是吓唬人我是真的花了好几天在这些问题上。第一个是 Django Admin 密码重置问题。本地开发时天天用 Django Admin 管理题目一旦忘记管理员密码要去命令行跑python manage.py createsuperuser --username admin --email adminexample.com或者用 shell 交互式重置。第二个是数据库迁移的坑。很多人开发过程中会因为字段设计不合理直接删数据库重建这在开发阶段问题不大但一旦有线上数据删除重建就是灾难。我的习惯是每次修改 model 后立刻生成并检查迁移文件python manage.py makemigrations exam python manage.py migrate如果只是加字段尽可能设置默认值或者在迁移文件里提供 one-off 的默认数据策略否则迁移库会卡在交互界面等你输入默认值。第三个是前端接口联调时最容易犯的低级错误把后端的返回结构直接当数组用。DRF 的 Response 默认返回的是一个 JSON 对象或数组但分页后返回的格式是{ count: 100, next: http://..., previous: null, results: [...] }所以前端 request.get 拿到的 data 是整体对象你还要再取 data.results 才是列表。我见过太多人拿 data.length 去判断数量结果拿到 undefined 找不到 bug。这里我的建议是给 DRF 配置 DEFAULT_PAGINATION_CLASS 并统一封装一个分页数据提取函数。5.4 性能优化与部署建议在线考试系统的压力点非常集中开考那一刻大量考生同时加载试卷交卷那一刻大量考生同时提交答案。这两个瞬间如果没有做任何防护后端很容易直接打崩。针对开考的并发加载我的优化方案是静态化试卷。考试开始前管理员在后台点击“发布试卷”时后端就把该场考试的题目列表预生成一份 JSON 缓存到 Redis 或数据库的缓存表里。考生访问考试详情时优先从缓存读取不直接查数据库的关联表。另外前端也用了一套懒加载策略——进入答题页先加载题目骨架然后再渲染详情避免首屏长时间白屏。针对交卷的并发写除了前面说的 select_for_update 锁还应该把判分逻辑尽可能做得轻量化。如果你想进一步优化可以引入消息队列比如把交卷请求投递到 Celery Redis异步判分前端轮询成绩。但在大多数场景下同步判分已经够用只有在数千人同时交卷时才需要考虑异步化。部署方案我用的是 Nginx uWSGI MySQL前端构建后的静态文件直接由 Nginx 托管。Django 的 settings.py 里需要设置 ALLOWED_HOSTS [your-domain.com]同时关闭 DEBUG False配置好静态文件收集python manage.py collectstaticVue 前端构建npm run build构建产物在 dist 目录把 dist 内容部署到 Nginx 的 html 目录并配置反向代理把 /api/ 请求转发给后端。这样一个直筒子架构就完成了。要注意的是部署后的 API 地址要从 localhost:8000 改成线上域名前端打包时可以通过环境变量区分。根据我个人经验这类全栈项目最大的价值其实不在“考试系统”本身而是通过一个完整业务场景把 Django 的模型设计、DRF 的接口规范、Vue 3 的状态和交互管理、前后端联调、并发控制这一整条链路都串起来。做完一个项目比看十遍教程都有用。最后再分享一个我踩过很多次的坑如果你在开发时发现 Django 后端返回的数据字段和前端对不上先别急着改前端代码先把后端 serializer 的字段列表完整打印出来逐一对齐。这种“前后端字段命名不一致”的问题往往是整个项目里最耗时的隐形杀手。我习惯在项目一开始就跟自己定一个约定时间字段统一用 end_time、start_time 这种下划线命名前端拿回来后如果不习惯可以在 axios 返回时做一层字段映射而不是两边各改各的改到最后接口文档都成摆设了。
返回列表