
简介基于Django的在线考试系统完整设计与实现面向高校毕业设计、课程作业及需要快速搭建在线考试平台的开发者。系统围绕管理员、教师、学生三类角色设计实现权限分级管控支持题库批量导入、多题型录入、手动/随机组卷、考试发布、在线作答与成绩统计等核心功能。压缩包共607个文件约21.63MB包含Python和JavaScript源码、Vue组件、SVG/PNG图标图片、SQL数据库文档、部署运维脚本等前后端与文档层次清晰便于按模块阅读与二次开发。目前已有66人学习下载适合作为基于Python的Web开发实战参考亦可直接用于理解在线考试系统的业务设计与数据库结构辅助完成毕业设计或课程答辩。1. 为什么在线考试系统值得用 Django 做先算清楚要写多少行代码别一上来就写代码。我拆过不少在线考试系统的毕业设计翻车最狠的不是视图写不出来而是数据库表没设计好用户、题库、试卷、答题记录这四块关系一乱后面的组卷、判分、统计全部连锁爆炸。用 Django 和 Python 做在线考试系统最大的优势不是语法简单而是 ORM、Admin、认证这套基础设施能让你省掉一半重复劳动把精力放在业务逻辑上。这套资源除了完整的 Django 项目代码还带一份数据库设计文档正好卡在课程作业和毕业设计最看重的两件事上系统跑得起来设计讲得清楚。适合正为课程作业发愁的同学也适合想快速搭一个管理系统 demo 的开发者。2. 数据模型四层拆解用户表、题库、试卷与答题记录怎么建才不返工在线考试系统的核心不是页面是数据关系。我习惯按「用户 → 题库 → 试卷 → 答题记录」四层去拆每一层只做一件事外键只往一个方向指。设计阶段多花半小时后面写业务逻辑能少掉好几天的返工。2.1 用户表继承 AbstractUser不要单独建 Profile 表很多 Django 教程的习惯是保留默认 auth_user再单独建一张 student_profile 表用 OneToOne 关联。这种做法在纯展示型网站是对的但在考试系统里是自找麻烦学生、教师、管理员三类用户共用同一套登录和 Session区别只在于角色权限。直接用继承request.user.role可以在视图和模板里直接读不用每次查一次关联表。# apps/users/models.py from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): 替代默认 auth_user 的自定义用户表 ROLE_CHOICES ( (student, 学生), (teacher, 教师), (admin, 管理员), ) role models.CharField(max_length20, choicesROLE_CHOICES, defaultstudent) student_no models.CharField(max_length20, blankTrue, nullTrue, verbose_name学号) class Meta: db_table exam_user verbose_name 用户role字段用choices限定取值范围视图里判断身份只需要if request.user.role teacher。student_no是给学生的学号留的教师和管理员可以为空注意blankTrue和nullTrue必须同时写否则数据库层面会强迫必填。db_table改名成exam_user一是区分默认的 auth_user二是后续迁移时减少和第三方应用迁移文件的冲突。改完模型后还有一步更关键去 settings.py 里注册# config/settings.py AUTH_USER_MODEL users.User这一行必须写在第一次migrate之前。如果项目已经跑过迁移再回去改这一行Django 会让你体会什么叫迁移地狱我在第 4 章会专门把这个坑展开讲。外键引用也不要直接写User统一用settings.AUTH_USER_MODEL或models.AUTH_USER_MODEL后面换用户表时不用全局改外键。2.2 题库与试卷多对多关系中间表分值字段放在 PaperQuestion题库设计成一张表所有科目、所有题型都往里面放通过一个subject字段区分科目。试卷表只描述「一场考试」本身题目和试卷通过多对多关联挂上。这个看似简单的设计里有一个最容易踩的点同一道题可能出现在多张试卷里而不同试卷里它的分值不一样。如果分值字段放在 Question 模型上就会出现「改了一道题的分值所有包含这道题的试卷一起变分」的连锁问题。# apps/papers/models.py from django.db import models class Paper(models.Model): 试卷表只保存一次考试的基本信息 title models.CharField(max_length100, verbose_name试卷名称) subject models.CharField(max_length50, verbose_name考试科目) duration models.IntegerField(default120, verbose_name考试时长(分钟)) total_score models.DecimalField(max_digits6, decimal_places1, default100.0) is_published models.BooleanField(defaultFalse, verbose_name是否发布) created_at models.DateTimeField(auto_now_addTrue) questions models.ManyToManyField( questions.Question, throughPaperQuestion, related_namepapers ) class Meta: db_table exam_paper class PaperQuestion(models.Model): 试卷-题目中间表存本题在试卷里的分值和顺序 paper models.ForeignKey(Paper, on_deletemodels.CASCADE, related_namepaper_questions) question models.ForeignKey(questions.Question, on_deletemodels.PROTECT) score models.DecimalField(max_digits5, decimal_places1, verbose_name本题分值) order models.IntegerField(default0, verbose_name本题顺序) class Meta: db_table exam_paper_question ordering [order] constraints [ models.UniqueConstraint(fields[paper, question], nameuniq_paper_question) ]用throughPaperQuestion显式声明中间表后Django 不会再自动生成隐藏的多对多表所有关系都落在可以看见的模型上。on_deletemodels.PROTECT表示只要还有试卷引用这道题就不允许物理删除题目这个策略比 CASCADE 安全考试记录生成后题目外键悬空是会出大问题的。UniqueConstraint保证同一道题在一张试卷里只能出现一次防止组卷时手滑重复加题。2.3 答题记录一张总表加一张明细表外加快照字段答题记录拆成 ExamRecord 和 AnswerRecord 两张表。ExamRecord 记录「谁在什么时间考哪张卷子」一条记录对应一次完整考试AnswerRecord 存每一道题的具体作答。为什么不直接把答案堆在总表里存 JSON因为后续要逐题判分、逐题导出成绩存 JSON 看起来省事写统计代码时全是要手动解析的活。# apps/exams/models.py from django.conf import settings from django.db import models class ExamRecord(models.Model): 一场考试的总记录status 控制状态流转 STATUS_CHOICES ( (ongoing, 考试中), (submitted, 已交卷), (graded, 已判分), ) student models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_nameexam_records) paper models.ForeignKey(papers.Paper, on_deletemodels.CASCADE, related_namerecords) start_time models.DateTimeField(nullTrue, blankTrue, verbose_name开始时间) submit_time models.DateTimeField(nullTrue, blankTrue, verbose_name交卷时间) score models.DecimalField(max_digits6, decimal_places1, nullTrue, blankTrue) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultongoing) class Meta: db_table exam_record constraints [ models.UniqueConstraint(fields[student, paper], nameuniq_student_paper) ] class AnswerRecord(models.Model): 每一道题的作答记录冗余快照字段 exam models.ForeignKey(ExamRecord, on_deletemodels.CASCADE, related_nameanswers) question models.ForeignKey(questions.Question, on_deletemodels.CASCADE) paper_question models.ForeignKey(papers.PaperQuestion, on_deletemodels.CASCADE) answer_content models.TextField(verbose_name学生作答内容) question_type models.CharField(max_length20, verbose_name题型快照) correct_answer models.TextField(verbose_name正确答案快照) is_correct models.BooleanField(nullTrue, blankTrue, verbose_name是否答对) score models.DecimalField(max_digits5, decimal_places1, nullTrue, blankTrue, verbose_name本题得分) class Meta: db_table exam_answer_record constraints [ models.UniqueConstraint(fields[exam, paper_question], nameuniq_exam_question) ]注意 AnswerRecord 里的question_type和correct_answer字段这是我说的「快照」。交卷时把题目当时的题型和正确答案复制一份进来之后题库怎么改都不会影响历史成绩。不做快照的后果在第 4 章有专门一条避坑记录。UniqueConstraint加在 exam 和 paper_question 上保证同一场考试里一道题只有一条作答记录这是防重复提交的地基。2.4 数据库文档里必须写清楚的三件事这套资源的数据库文档列的就是这五张核心表答辩或交课程作业时评审老师问外键设计直接照着这套关系讲就行。表名用途核心字段关系exam_user用户表role、student_no、username、password无exam_paper试卷表title、subject、duration、total_score无exam_paper_question试卷-题目中间表paper、question、score、orderpaper 多对一question 多对一exam_record考试总记录student、paper、start_time、statusstudent 多对一paper 多对一exam_answer_record作答明细表exam、question、answer_content、correct_answerexam 多对一question 多对一数据库文档里除了表名和字段我建议至少再补三件事一是 E-R 图用图形把五张表的关系画清楚说明文档最值钱的就是这张图二是主键策略统一用自增 id 还是 UUID 要写明理由课程作业用自增 id 足够三是索引说明exam_record.student、exam_record.paper、exam_answer_record.exam这三个高频查询字段一定要建索引否则数据量上来后交卷和查成绩都会变慢。注意AUTH_USER_MODEL 务必在第一次执行 migrate 之前配置好这个顺序问题修复成本极高越早做越省事。3. 组卷、判分与统计三条核心业务链路的代码实现数据模型定下来后核心业务链路就三条组卷、交卷判分、成绩统计。这一章按顺序把代码写透每条链路我都会把关键参数和失败时的现象讲清楚。3.1 随机组卷按题型配置抽题题库不足要明确报错在线考试系统里组卷分手工组卷和随机组卷课程作业一般做随机组卷就够了。随机组卷的关键点不在「随机」本身而在「抽题不重复、题型数量可控、题库不足时能明确报错」。# apps/papers/services.py import random from apps.questions.models import Question from .models import Paper, PaperQuestion def generate_paper(title, subject, rule, duration120): 按规则抽题并创建试卷 rule 示例: {single: 10, multiple: 5, judge: 10, fill: 5} paper Paper.objects.create( titletitle, subjectsubject, durationduration, total_scoresum(question_score(qtype) * count for qtype, count in rule.items()) ) order 0 for qtype, count in rule.items(): pool list( Question.objects.filter(typeqtype, subjectsubject, statuspublished) .values_list(id, flatTrue) ) if len(pool) count: raise ValueError(f{subject} 下 {qtype} 题目不足现有 {len(pool)} 道需要 {count} 道) chosen_ids random.sample(pool, count) for qid in chosen_ids: q Question.objects.get(idqid) PaperQuestion.objects.create( paperpaper, questionq, scoreq.score, orderorder, ) order 1 return paperrandom.sample(pool, count)在同一个 pool 里不会抽到重复元素这是比多次random.choice更安全的选择pool用values_list(id, flatTrue)只取 id 列表避免把整个题目对象全部加载进内存。题库数量不足时直接抛ValueError并写明缺多少道比后端静默少题要友好得多。total_score可以按题目分值累加算出前端展示试卷总分就不容易和实际分值对不上。这段代码用的是最基础的随机组卷。如果要多套平行卷可以在sample里针对不同试卷设置不同的随机种子比如random.seed(paper_id)这样同一批题目在同一种子下生成顺序一致方便出 A/B 卷。3.2 在线答题get_or_create 创建考试记录时间戳下发给前端学生点「开始考试」时后端要做两件事建一条 ExamRecord把考试截止时间算好交给前端。这步看起来简单但有个细节很关键学生如果手滑刷新了页面不能重复创建考试记录也不能又给他重新开始一次考试。# apps/exams/views.py from datetime import timedelta from django.shortcuts import get_object_or_404, render, redirect from django.utils import timezone def start_exam(request, paper_id): paper get_object_or_404(Paper, pkpaper_id) if not paper.is_published: return render(request, error.html, {message: 试卷未发布}) record, created ExamRecord.objects.get_or_create( studentrequest.user, paperpaper, defaults{start_time: timezone.now()}, ) if not created and record.status in (submitted, graded): return redirect(exams:record_detail, record_idrecord.id) questions ( paper.paper_questions .select_related(question) .order_by(order) ) deadline int((record.start_time timedelta(minutespaper.duration)).timestamp()) return render(request, exams/exam.html, { paper: paper, record: record, questions: questions, deadline: deadline, })get_or_create是防重复记录最直接的手段传入student和paper两个条件命中已存在记录时created为 False。已经交卷或判分的学生再进这个页面直接重定向到成绩页而不是重新生成考试记录。deadline用时间戳传而不是传「截止时间字符串」因为前端 JavaScript 里Date.now()拿到的就是毫秒时间戳前后端统一用时间戳比格式化字符串再解析要省事很多。select_related(question)一次性把题目对象查出来避免模板里循环时产生 N1 查询。交卷接口我会单独用 POST 处理并且加幂等保护# apps/exams/views.py from django.http import JsonResponse def submit_exam(request, record_id): record ( ExamRecord.objects.select_for_update() .filter(idrecord_id, studentrequest.user, statusongoing) .first() ) if not record: return JsonResponse({status: error, msg: 记录不存在或已提交}) record.status submitted record.submit_time timezone.now() record.save() return JsonResponse({status: ok})select_for_update()会给这一行记录加数据库行锁两个并发的交卷请求同时进来时只有第一个能拿到锁第二个进来会阻塞到锁释放后判断状态再返回「已提交」。这个细节是我自己的血泪经验不加锁的版本在学生双击交卷按钮时会生成两条重复记录。第 4 章还有退款坑包。3.3 自动判分客观题逐题比对主观题留人工入口判分是整个系统里最容易出逻辑 bug 的地方尤其多选题。我的做法是单选、判断用字符串完全匹配多选转成集合后比较相等填空和简答不做自动判分先置为 None留给教师在后台手工打分。# apps/exams/services.py def grade_exam(record_id): record ExamRecord.objects.select_related(paper).get(idrecord_id) total 0 for answer in record.answers.select_related(question, paper_question): q answer.question if q.type in (single, judge): correct answer.correct_answer.strip().upper() given answer.answer_content.strip().upper() ok correct given elif q.type multiple: correct_set set(answer.correct_answer.upper().split(,)) given_set set(answer.answer_content.upper().split(,)) ok correct_set given_set else: ok None # 填空和简答先给 0 分占位后续人工在 Admin 后台修改 answer.score 0 answer.is_correct ok if ok: answer.score float(answer.paper_question.score) total float(answer.paper_question.score) answer.save() record.score total record.status graded record.save()注意这里读的answer.correct_answer是快照字段不是q.answer。多选题正确选项统一用大写字母逗号分隔比如A,B,Canswer_content也按这个格式存集合比较就没有歧义。主观题先把score置 0教师可以在 Django Admin 后台根据is_correctNone过滤出待人工判分的答案逐题补分后再重新计算总分。3.4 成绩统计用 ORM 聚合查询不要用 Python 循环成绩统计最容易写成「把所有记录查出来然后在 Python 里 for 循环做加法」。数据量小的时候无所谓但课程设计答辩时评审老师可能会现场导入几百条记录循环统计就会出现明显的性能问题。用 Django 的聚合查询一句搞定# apps/exams/views.py from django.db.models import Sum, Avg, Count, Max def paper_statistics(pk): stats ExamRecord.objects.filter(paper_idpk, statusgraded).aggregate( student_countCount(id), avg_scoreAvg(score), max_scoreMax(score), total_scoreSum(score), ) # 按班级或学号分组的及格率、分数段统计 grouped ( ExamRecord.objects.filter(paper_idpk, statusgraded) .values(student__student_no) .annotate(score_sumSum(score)) .order_by(-score_sum) ) return stats, groupedaggregate()一次查出整张卷子的总人数、平均分、最高分、总分SQL 层面就聚合完不会把大量记录传到 Python 内存里。需要导出 Excel 或 CSV 给老师存档时用 Django 自带的csv模块直接输出编码用utf-8-sigExcel 打开才不会乱码import csv from django.http import HttpResponse def export_scores(paper_id): records ( ExamRecord.objects.filter(paper_idpaper_id, statusgraded) .values(student__username, student__real_name, score) .order_by(-score) ) response HttpResponse(content_typetext/csv; charsetutf-8-sig) response[Content-Disposition] attachment; filenamescores.csv writer csv.writer(response) writer.writerow([学号, 姓名, 成绩]) for r in records: writer.writerow([r[student__username], r[student__real_name], r[score]]) return response导出文件头里加charsetutf-8-sig是关键很多同学直接用text/csv导出Excel 打开中文全变乱码评审印象分直接掉一档。4. Django 在线考试系统避坑清单五个必踩的坑和后悔药这一章把我在实际项目里遇到的五个高频问题按「现象 → 原因 → 解决」列清楚。每个项目翻车的点几乎一样看到对应的现象直接抄作业即可。4.1 AUTH_USER_MODEL 配置晚于第一次 migrate现象项目初始化时用默认 User 跑过一次migrate后面补上AUTH_USER_MODEL users.User再执行makemigrations时抛ValueError或者提示 admin.LogEntry 的字段引用和现有迁移冲突整个迁移链卡死。原因Django 第一次执行 migrate 时auth、admin、sessions 等内置应用的迁移文件已经把外键固定指向默认用户模型。AUTH_USER_MODEL 是后来才出现在 settings.py 里的迁移系统不会主动去改已经生成的依赖关系。解决课程作业阶段最稳妥的办法是删掉apps目录下全部迁移文件把AUTH_USER_MODEL先写进 settings.py然后重新执行makemigrations和migrate一次性生成全新的迁移链。如果已经录入了测试数据改配置前先备份导出改完后重新导入唯一没有坑的路。4.2 考试已判分改题库答案导致历史成绩全变现象老师发现某道多选题答案标错了直接改了题库里那题的 answer然后重跑了一次判分脚本整张试卷所有学生的历史成绩全部变化。原因AnswerRecord 只存了 question 外键判分时实时去读 Question.answer。题目答案一变历史答卷全部按新答案重算之前的判分结果等于白做。解决交卷时把题目当时的题型、正确答案、选项一并复制到 AnswerRecord 的快照字段判分只读快照不读题库。这样即使后面题库里的题目改到面目全非历史考试结果仍然稳固。我在第 2 章的模型里已经加了question_type和correct_answer两个字段别偷懒删掉它们。4.3 多选题用 in 判断少选也算对现象正确答案是 A、B、C考生只选了 A、B判分结果居然是满分。原因常见误写是if answer_content in question.answer。A,B 是 A,B,C 的子串字符串匹配直接命中或者用学生选项 in 正确选项判断少选的情况被当成了全对。解决多选题选项必须转成set以后做集合相等比较{A,B} {A,B,C}返回 False。存储格式统一成大写字母逗号分隔前后端都按这个约定处理否则a,b和A,B会有大小写问题。4.4 时区配置不对考试截止时间判断差 8 小时现象本地调试一切正常部署到服务器后学生还剩 10 分钟后端却提示已到截止时间直接强制交卷。原因settings.py 里TIME_ZONE没改成中国时区Django 按 UTC 记录时间前端显示的是本地时间前后端各算各的。解决settings.py 里配置TIME_ZONE Asia/Shanghai和USE_TZ True注意 DST 的测试覆盖# config/settings.py TIME_ZONE Asia/Shanghai USE_TZ True LANGUAGE_CODE zh-hans前端不要自己从 0 开始累计倒计时用后端下发的 deadline 时间戳减去当前时间戳。同时确认数据库连接使用utf8mb4否则中文答案乱码会让判分对不上号。4.5 双击交卷生成两条成绩记录现象网络稍卡的时候学生快速点两次「交卷」后台查出两条一模一样的 ExamRecord成绩统计里这个学生被算了两次分。原因交卷接口没有幂等保护没有唯一约束每次 POST 都能创建一条新记录。解决数据模型上加UniqueConstraint(fields[student, paper])交卷视图里用select_for_update()锁行再判断状态代码参考第 3.2 节的submit_exam。前端同样要把交卷按钮在第一次点击后立即禁用并提示「正在提交请勿重复操作」。5. 从本地到答辩演示SQLite 换 MySQL 与 Nginx 部署要点课程设计演示时最担心的场景是本地跑得好好的换一台电脑或部署到服务器就起不来。这一章把环境、数据库、部署三个环节的关键动作过一遍照着做基本不会再翻车。5.1 环境准备虚拟环境、依赖导出、版本锁定Django 项目依赖管理的规范动作是项目根目录建虚拟环境把开发依赖导出到 requirements.txt换机器或部署时用这个文件一键安装。python -m venv venv source venv/bin/activate pip install --upgrade pip pip install django pip freeze requirements.txtpip freeze生成的版本号是精确锁定格式比如Django4.2.*这能避免换环境时 Django 自动升级到大版本导致 API 行为变化。迁移到另一台机器时先建虚拟环境再pip install -r requirements.txt顺序别反否则容易装进系统 Python 里造成版本混乱。5.2 从 SQLite 切到 MySQL依赖、配置、迁移三个动作开发阶段 SQLite 够用但答辩演示时如果涉及并发交卷SQLite 的锁机制会给你找麻烦。换 MySQL 需要做三个动作# config/settings.py DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: exam_system, USER: exam_user, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, OPTIONS: {charset: utf8mb4}, } }在项目入口处加 pymysql 适配# config/__init__.py import pymysql pymysql.install_as_MySQLdb()pip install pymysql python manage.py makemigrations python manage.py migratepymysql 用install_as_MySQLdb()把 MySQLdb 接口替换成 pymysql这是 Django 连接 MySQL 最省事的方案。utf8mb4必须写否则 emoji 和部分中文生僻字在入库时会被截断判分时答案对不上。切库之后检查一下option Question.objects.values(options)这类 JSONField 查询是否正常MySQL 5.7 以下版本要用 TextField 代替 JSONField。5.3 Gunicorn Nginx一条命令加一个 Server 块跑通服务本地开发用python manage.py runserver正式部署要换成 Gunicorn再加 Nginx 做反向代理和静态文件托管。pip install gunicorn cd /var/www/exam_system gunicorn config.wsgi:application -w 3 -b 127.0.0.1:8000 --daemon-w 3表示启动 3 个 worker 进程并发量不大的考试系统 3 个足够--daemon让进程在后台运行不然一关终端服务就断了。Nginx 配置一个专门的 server 块server { listen 80; server_name your_domain_or_ip; location /static/ { alias /var/www/exam_system/static/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }Nginx 里最容易漏的一步是静态文件。Django 的 CSS、JS 默认由 Django 自己处理但关掉 DEBUG 后 Django 不再托管静态文件必须手动收集# config/settings.py DEBUG False STATIC_ROOT /var/www/exam_system/staticpython manage.py collectstaticcollectstatic会把各个 app 的静态文件全部复制到 STATIC_ROOT 目录Nginx 再从那里直接读取。忘记执行这条命令的表现是页面 HTML 正常但样式全丢浏览器控制台刷出一片 404。5.4 答辩演示前的数据准备清单部署完成后系统是空的直接进 Admin 演示没有说服力。我一般会在答辩前按这个顺序准备数据python manage.py createsuperuser python manage.py createsuperuser --username teacher1 --email teacher1example.com # 教师账号先创建管理员和教师账号再进 Admin 后台建 40 道以上覆盖单选、多选、判断、填空四类题型的题目接着用随机组卷接口生成 2 套试卷最后用 3 个学生账号各真实跑完一次考试生成成绩统计。这样演示时既有完整数据链又能直接展示统计页面的图表和导出功能。数据准备好了我有一次学生账号密码设得太像答辩现场被评委老师要求临时登录试试看结果密码复杂度太高谁也记不住现场极其尴尬。从那以后我演示账号一律用student/123456复杂度低但演示效果稳。注意部署到新环境后第一件事先确认python manage.py check --deploy没有致命警告再看collectstatic是否执行最后才开服务。6. 考场防翻车倒计时、自动保存与刷新恢复一页到底的做法在线考试系统上线后最怕的不是题目难而是学生答到一半页面刷新一下填的答案全没了。Django 服务端默认是无状态的页面每次重新加载都要靠前端把已填的内容恢复回表单。我的做法是三步每道题一旦作答就写进浏览器 LocalStorage同时用 setInterval 定时把整个答题卡 POST 到后端自动保存接口页面打开时先检查 LocalStorage 里有没有未提交的答案有就回填。交卷成功后再清掉本地缓存。// 答题卡页面假设 recordId 由模板注入 const storageKey exam_ recordId; function saveAnswer(qid, value) { const answers JSON.parse(localStorage.getItem(storageKey) || {}); answers[qid] value; localStorage.setItem(storageKey, JSON.stringify(answers)); } // 每 30 秒整体同步一次防止进程被清理后丢数据 setInterval(function () { const answers localStorage.getItem(storageKey); if (!answers) return; $.post(/exams/autosave/, { record_id: recordId, answers: answers, }); }, 30000);后端 autosave 视图用update_or_create按 exam 和 paper_question 更新明细表接口里要校验request.user和record.status必须是 ongoing防止交卷后继续写库。倒计时用后端下发的 deadline 时间戳不要用前端自己的计时器累计否则前后端时间不一致时会出现本地显示剩 10 分钟、服务端已经判定超时的情况const deadline parseInt({{ deadline }}); setInterval(function () { const remain Math.floor(deadline - Date.now() / 1000); if (remain 0) { submitExam(); } }, 1000);在那之后我每次做考试模块都会在交卷按钮旁边加一个「已自动保存」的绿色小字提示并且强制要求后端 autosave 接口必须幂等——同一道题重复 POST 无论多少次结果都一致。这个习惯帮我避开过好几次答辩前的翻车。希望帮到你。本文还有配套的精品资源点击获取