不知道你有没有经历过这种尴尬:一套在线考试系统连忙了三个月,功能都跑通了,结果一上真实考试场景,考生同时交卷时后端崩了;或者考生答到一半不小心刷新了页面,答案全没了;再或者,填空题明明考生写对了,只是因为多了一个空格就判了零分。
这些坑我都踩过。今天这篇就基于我在实际项目中搭建的一套Python在线考试系统 Vue3方案,把技术选型、数据表设计、核心功能实现、联调注意点、安全加固,到最后的部署维护,一条线全部讲透。这套方案不是概念性演示,而是实实在在能扛住真实考试场景的落地代码。
我默认你具备一定的Python基础和Vue基础——至少会写Python函数、知道Vue组件怎么用。如果之前只做过纯后端或纯前端,也能看懂,因为重点部分我会补充原理。但如果你想直接复制全部代码就能跑通,这篇还做不到,核心目的还是先把“为什么这么做”和“怎么做”讲明白。
适合看这篇的人群很明确:正在做毕业设计的在校生、公司内部要做员工考核系统或者培训平台的技术负责人,以及打算从单体应用转向前后端分离架构的初中级开发者。
1. 技术选型:为什么偏偏是Python后端配Vue3
很多人在技术选型上犹豫不决。我做这套在线考试系统时也纠结了很久,最后定下的方案是:Python系后端框架 + Vue3前端框架 + MySQL数据库。先说结论,再说为什么。
1.1 后端不是只有Django一条路
Python后端常用就三个选择:Flask、Django、FastAPI。三个都能做考试系统,但实际体验差异很大。
| 对比点 | Flask | Django | FastAPI |
|---|---|---|---|
| 上手难度 | 简单 | 中等 | 中等 |
| 自带ORM | 无(需配SQLAlchemy) | 自带、功能全 | 无(需配SQLAlchemy/ TortoiseORM) |
| 认证体系 | 需自建 | 自带auth+session | 需自建或接JWT |
| 异步支持 | 弱 | 一般 | 原生异步 |
| 适合场景 | 轻量接口 | 管理后台成体系 | API优先、并发场景 |
我做考试系统最终选的是FastAPI,原因很直接:
第一,自动生成接口文档。FastAPI自带Swagger UI,前后端联调时,前端同事直接打开 /docs 就能看到所有接口的入参出参和示例,不用我们维护一份永远过期的接口文档。
第二,Pydantic数据校验天然适合表单类业务。考试系统里有大量学生提交的答案数据,字段类型、必填性、长度限制,Pydantic都能在入口处解决,不用在业务代码里写十几层if判断。
第三,异步性能加持。真实考试场景最大的技术压力在于“开考瞬间几百人同时提交”。FastAPI原生async支持,配合数据库连接池,同样一台4核8G服务器,并发承载能力明显比同配置的Flask高。
也有人问,Django自带admin管理后台,管理题目是不是更方便?实话说,如果你系统规模不大,Django的admin确实省事。但Django太“重”了,它的ORM和admin绑定很紧,等到考试系统要跟前端做完全分离的API架构时,你会发现自己一直在为“不需要的重量”做适配。FastAPI更符合“前后端完全分离”的架构思路。
1.2 Vue3的Composition API就是为复杂页面状态设计的
前端为什么选Vue3而不是Vue2?对考试系统来说,最核心的页面就是考试作答页。这个页面上的状态多到你没法用Vue2的data平铺硬扛:当前题目索引、已答题目集合、剩余时间、是否标记待检查、是否需要自动保存草稿、答案暂存Map……
Vue3的Composition API允许你按业务逻辑组织代码,而不是按Options分散在所有option里。举个直观例子:我在Vue2里写考试页,时间相关的state、答案相关的state、UI相关的state全挤在data里,40多个字段,维护到后面自己都要来回翻;改成Vue3之后,我拆成了 useCountdown、useAnswerStore、useExamGuard 三个独立的逻辑组合函数,主组件里只剩模板和调用,清爽很多。
Vue3还带来一个对在线考试场景特别有用的特性:reactive 可以深度代理对象,数组操作天然触发响应式更新。Vue2里 this.answers[key] = value 不触发视图更新的经典问题,练习册答案选填位置变白、保存按钮不亮这些诡异bug,在Vue3的reactive下面几乎绝迹了。
另外,如果你在热搜词里看到“vue3和vue2的区别”,那说明你也在这类技术选型里犹豫。我的结论很直接:如果是从零开发新系统,直接上Vue3。生态已经很成熟,Element Plus组件库、Pinia状态管理、Vue Router都跟上来了,没有理由选一个两代前的方案。
1.3 数据库选型:MySQL是考试系统最稳的答案
数据这块我直接锁死MySQL。考试系统是典型的事务型业务:题目表、试卷表、用户表、考试记录表之间的关联性强,强一致性的要求远高于高并发吞吐的追求。
你可能会想,NoSQL是不是更适合?比如MongoDB文档结构存题目不是更方便吗?单看一条数据确实方便,但一旦遇到题库组卷、成绩统计这类跨数据聚合的场景——这个需要多表查询,那个需要按知识点聚合——MongoDB写起来复杂程度陡增,维护成本直线上升。
MySQL配上SQLAlchemy这个ORM层,考试系统的常规操作——按班级查学生列表、按试卷ID查所有题目、按考试记录查成绩分布——都降维打击式的简单。索引建好了,毫秒级响应不是问题。
提示:如果你的目标是高并发考试场景,真正需要重点优化的是数据库连接池参数,而不是换一个数据库。连接池调小、请求排队会导致交卷超时;调大又会把MySQL连接数打满导致服务不可用。后面第5节我会专门讲这个问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:考试系统真正的骨架在这里
很多人做这类系统爱一上来就写登录注册,我恰恰相反——先把数据库表建明白。因为考试系统里表关系多,一乱全乱,后患无穷。
2.1 角色边界必须一开始就定死
在线考试系统有四种基本角色:管理员、教师、考生、阅卷人(有时教师兼任阅卷人)。我在表设计时的思路是:
- user表只负责账号密码和角色标识,不承载业务属性;
- 教师和考生的扩展信息分别放在 teacher_profile 和 student_profile 里,用user_id关联。
这样设计的理由是,两种角色的扩展字段完全不同。考生需要班级编号、学号;教师需要工号、所属教研室。硬把这些字段都塞在user表里,表宽而无当,时间长了必出管理混乱。
2.2 核心数据表:直接抄这个设计
我后面的所有代码示例,都围绕这套表结构展开:
| 表名 | 核心字段 | 用途说明 |
|---|---|---|
| user | id, username, password_hash, role, status | 用户基础信息与登录状态 |
| exam | id, title, start_time, end_time, duration, pass_score, status | 考试场次信息 |
| question | id, type, content, options, answer, score, difficulty, knowledge_point | 题库原始数据 |
| exam_paper | id, exam_id, paper_title | 考卷实例(一次考试一张卷) |
| paper_question | id, paper_id, question_id, order_num | 考卷静态题目快照 |
| exam_record | id, exam_id, student_id, paper_id, submit_time, score, status | 考生答卷主记录 |
| answer_record | id, record_id, paper_question_id, student_answer, correct, score | 考生每道题的作答明细) |
有一个细节值得单独说:题目是存在question表里,但考卷却必须通过paper_question做一次静态快照。
为什么?因为题库会变。教师可能在某场考试结束之后发现某道题有歧义,顺手把题目改了。如果考生交卷后判分时去读question表的实时内容,就会发生“考试答案都提交了,判分依据却是改过的题目”这种严重事故。paper_question把考试当时的题目内容固化了,判分时只读快照,不读题库原始表,这样就彻底杜绝了这个隐患。
2.3 时间字段和状态字段不能偷懒
考试系统的时序非常强,必须在数据库层面把边界做清楚:
- exam.start_time 和 exam.end_time,决定考试场次的有效区间;
- exam.duration 是个人考试时长,定义“考生从入场到必须交卷”的时间窗口;
- exam.status 建议用枚举型:0未开始 / 1进行中 / 2已结束 / 3已归档。这样发布考试、关闭考试、归档历史数据,都通过状态字段流转,而不是删除记录。
踩过一个具体的坑:之前觉得“考试结束时间”可以从start_time+duration反推,就不存end_time了。后来发现这样完全不够——同一场考试,有人迟到早退,有人开考40分钟后因故障重进,个人的结束时间和全局结束时间根本不是一回事。后来我设计成了“全局考试窗口”和“个人作答窗口”两个维度。存储上,exam表存全局start和end,exam_record表存个人实际开始和应交卷时间。这么一拆,所有时间计算都清爽了。
3. 后端核心逻辑:题库管理、组卷与自动判分
后端是整个考试系统的“法律”,规则都在这里定。我按实际开发顺序来讲:先做题库与考试管理,再做组卷,最后做判分。判分这块坑最多,重点讲。
3.1 题库管理和考试发布接口
题库管理就是标准的CRUD,我直接给你看接口设计而不是全部代码:
python复制# app/api/question.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
@router.get("/questions")
def list_questions(
page: int = 1,
page_size: int = 10,
type: str | None = None,
keyword: str | None = None,
db: Session = Depends(get_db),
):
query = db.query(Question)
if type:
query = query.filter(Question.type == type)
if keyword:
query = query.filter(
Question.content.like(f"%{keyword}%")
)
total = query.count()
items = (
query.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {"total": total, "items": [q.to_dict() for q in items]}
这个接口有一个小设计值得提一下:参数里加了 type 和 keyword 两个可选筛选条件,这两个条件在题量大了以后几乎是必然用到的。教师经常要“只挑选择题组一张卷”或者“按知识点检索题库”,没有筛选参数的题目列表接口,开发初期爽,上线后被骂死。
考试发布接口更复杂一点。除了创建exam记录,还要生成一张试卷。我的业务逻辑是这样的:
- 教师选择一场考试(填标题、时间、时长、及格分);
- 教师从题库选题,或设置组卷规则让程序自动抽题;
- 前端拿到选题结果之后,调发布接口,后端把选题快照写进paper_question表。
组卷方式我同时做了手动选题和自动组卷两种,手动就是教师逐题勾选;自动组卷就是按题型和知识点配比随机抽题,细节下一节讲。
发布接口的伪代码:
python复制# app/api/exam.py
@router.post("/exams/{exam_id}/publish")
def publish_exam(exam_id: int, question_ids: list[int], db: Session = Depends(get_db)):
exam = db.query(Exam).filter(Exam.id == exam_id).first()
if not exam:
raise HTTPException(404, "考试不存在")
paper = ExamPaper(exam_id=exam.id, paper_title=exam.title)
db.add(paper)
db.flush() # 先拿到paper.id
for idx, qid in enumerate(question_ids):
q = db.query(Question).get(qid)
db.add(PaperQuestion(
paper_id=paper.id,
question_id=qid,
content_snapshot=q.content,
options_snapshot=q.options,
answer_snapshot=q.answer,
score_snapshot=q.score,
order_num=idx + 1,
))
db.commit()
这个设计里,content_snapshot(题干快照)、options_snapshot(选项快照)、answer_snapshot(答案快照)是paper_question表重要的存在。题目快照把“当时考的内容”固化下来,之后题库随便改,都不会影响历史考试成绩的判分依据。
3.2 自动组卷:写一个让你能安心下班的抽题算法
自动组卷的核心难点在于“满足题型配比和知识点分布”。我这里的实现方案很直接——按知识点分组,再按比例分层抽样。
以一张满分100分的试卷为例,假设组卷规则是:单选只考Python基础(20道,每题2分)+ 多选考Python进阶(10道,每题3分)+ 填空考语法与函数(10道,每题2分)+ 简答考算法与实战(2道,每题10分)。
组卷的核心代码如下:
python复制def generate_paper(db, exam_id, rules: dict):
paper = ExamPaper(exam_id=exam_id, paper_title="自动组卷")
db.add(paper)
db.flush()
for rule in rules:
# rules: [{type: "single", count: 20, score: 2, knowledge_points: ["基础"], difficulty: [1,2,3]}]
query = db.query(Question).filter(Question.type == rule["type"])
if rule.get("knowledge_points"):
query = query.filter(Question.knowledge_point.in_(rule["knowledge_points"]))
candidates = query.all()
# 随机抽样
selected = random.sample(candidates, rule["count"])
for idx, q in enumerate(selected):
db.add(PaperQuestion(
paper_id=paper.id, question_id=q.id,
content_snapshot=q.content, options_snapshot=q.options,
answer_snapshot=q.answer, score_snapshot=rule["score"], order_num=idx + 1
))
db.commit()
return paper.id
这里有几个代码之外的细节值得注意:
一是随机抽样之前,一定要查一下候选题池的数量够不够。比如规则要20道单选的Python基础题,但库里只有15道符合条件的,直接random.sample会抛异常。我在正式实现里会做一次数量预检,不够就把结果返回给前端,让教师明确看到“题库里可以抽的数量不足”,而不至于拍着桌子一脸懵。
二是分数为什么要记在PaperQuestion里而不是沿用Question.score。因为你可能在组卷时临时调整某道题的分数权重,而且一张卷子同一道题的原始分是固定的,但不同试卷里同一道题可能出现不同的分值。把评分规则固化在快照表里,就有了最大灵活性。
三是知识点均衡问题。如果只是全库随机抽二十道单选,很可能抽到十道全是列表操作的题,知识点分布严重失衡。加一道按知识点分组的过滤条件,再在知识点内部随机抽样,就能保证一张卷子的出题质量。
3.3 自动判分:选择题简单,填空题才是深坑
选择题判断,把考生的答案和PaperQuestion里的answer_snapshot做比对,注意题目选项的顺序。当初为了赶进度,用选项字母直接比对,后来发现单选、多选题的选项在组卷随机打乱了顺序,考生答案当然对不上。正确做法是:快照里存的选项内容,判分时按内容比对,绝不按字母顺序比对。
多选题判分要跟业务规则强绑定。规则不同,代码逻辑就不同:
- 完全匹配才给分;
- 少选给一半分,多选不给分(常见于客观题考试);
- 漏选不给分(常见于评卷宽松场景)。
我当时的配置方式是,在exam表加了一个 multiple_choice_rule 字段,取值可以由管理员设定。判分逻辑是这样的:
python复制def judge_multi(user_ans_list, correct_ans_list, rule):
set_user = set(user_ans_list)
set_correct = set(correct_ans_list)
if set_user == set_correct:
return 1.0
if rule == "partial":
# 少选但没选错
if set_user.issubset(set_correct) and len(set_user) < len(set_correct):
return 0.5
return 0
return 0
填空题的判分是最容易翻车的。真实填写过程中,考生的空格、大小写、标点符号都会导致直接比对失败。我的处理方案是判分前做一个标准化清洗:
python复制def normalize_text(text: str) -> str:
# 去首尾空格、统一小写、把全角英文字符转半角、去内部多余空格
import unicodedata
text = unicodedata.normalize("NFKC", text)
text = text.strip().lower()
return " ".join(text.split())
这个清洗函数几乎解决了我90%的“答案明明对了却不给分”的投诉。剩下10%,我采取的是人工复核兜底——标准化之后若还不匹配,把这道题标记为“待人工复核”,由教师在阅卷端手动判断。千万不要为追求“全面自动判分”,把所有判断题都交给算法一刀切,那样涉事考生满意度一定出问题。
另外,程序判分的成绩要同时写入 exam_record 和 answer_record。之后如果教师修改了某题的答案(比如一道题全省统一判为“题目有歧义,所有答案都给分”),重新计算时只需要按exam_record里对该考生的作答重新跑一遍判分算法,最后更新总分即可。
4. 前端考场实现:倒计时、答题卡与防刷新
前端这块是考试系统里最容易被低估的部分。很多人以为前端无非是几个表单,但考试作答页的真实复杂度,一定超出你预期。
4.1 Composition API如何组织考场核心状态
考试页的状态我拆成了三大块,对应三个逻辑钩子:
- useCountdown:管理倒计时、剩余时间、超时自动交卷;
- useAnswerStore:管理已答题目、答案Map、标记待检查题目集合;
- useExamGuard:管理路由守卫、断网检测、刷新拦截。
用Composition API组织的好处是,考试页所有状态逻辑高度内聚——倒计时和答案状态互不干扰,还允许我未来做单元测试时单独对某个钩子写测试用例。
具体到答案存储,我用 reactive 存一个对象,键是paper_question_id,值是考生答案:
javascript复制// composables/useAnswerStore.js
import { reactive, computed } from "vue";
export function useAnswerStore(questionList) {
const answers = reactive({});
const markedQuestionIds = new Set();
const answeredCount = computed(
() => questionList.filter((q) => answers[q.id] !== undefined).length
);
function setAnswer(questionId, value) {
answers[questionId] = value;
}
function toggleMark(questionId) {
if (markedQuestionIds.has(questionId)) {
markedQuestionIds.delete(questionId);
} else {
markedQuestionIds.add(questionId);
}
}
return { answers, markedQuestionIds, answeredCount, setAnswer, toggleMark };
}
这里 reactive 的价值又一次体现——Vue2里往对象上动态添加属性,响应式会丢失,Vue3的Proxy代理则完全没有这个问题。考生选完一道题后,答题卡上对应的格子要立刻亮起来,这一步在Vue3里就是这么自然。
4.2 倒计时不是setInterval就完事了
在线考试系统的倒计时是安全敏感的。这里有两个坑,一个个讲清楚。
第一个坑:单纯使用前端setInterval计时,考生在本地把电脑时间调慢,或者切到后台把Tab冻结,倒计时就不准了。我的方案是:入场的时候后端返回 start_time,前端计时逻辑始终以“当前时间戳 - start_time”计算出已用时间,再拿总时长减它。前端只是展示剩余时间,真正的“交卷时间点”在结束时由后端校验。
倒计时核心逻辑:
javascript复制// composables/useCountdown.js
import { ref, onMounted, onUnmounted } from "vue";
export function useCountdown(deadline) {
const remainSeconds = ref(0);
let timer = null;
function update() {
const remain = Math.max(0, Math.floor((deadline - Date.now()) / 1000));
remainSeconds.value = remain;
if (remain <= 0) {
clearInterval(timer);
// 触发自动交卷
}
}
onMounted(() => {
update();
timer = setInterval(update, 1000);
});
onUnmounted(() => clearInterval(timer));
return { remainSeconds };
}
第二个坑:浏览器Tab的节能策略。Chrome切后台或锁屏后,setInterval会被降低到每分钟执行一次甚至暂停,导致倒计时跳动看起来神秘兮兮的。我的处理方式是把周期改成每3秒tick一次,每秒的展示由剩余秒数计算而来。这样即使Tab被冻住一分钟,恢复的时候重新计算Date.now()仍然能得到正确的剩余时间,不会出现永久停摆的情况。
4.3 答题卡与题目索引跳转
答题卡是一张题号网格,考生点击某个数字就跳转到对应题目。这里有几个交互细节:
- 已答的题号显示蓝色、未答的显示白色、标记待检查的显示橙色;
- 当前正在作答的题号要有边框高亮;
- 点击答题卡虽然可以跳题,但提交前必须检查未答题目数量,并且弹窗确认。
一旦答题卡和动态切换题目都做出来,就需要注意“答案自动保存”的问题。按块保存在前端本地还有后端自动暂存,防止意外刷新丢失。我的做法是每次答案变化后,用debounce(防抖)1秒把答案异步暂存到一个草稿接口。这样考生刷新页面后还能恢复,再从暂停处继续作答。
重要的是,自动暂存接口必须与考生身份、考试场次绑定好,避免A考生的草稿串到B考生那里。
4.4 意外刷新:日志与浏览器提示的双重保险
前端刷新这种意外,防不胜防。我的方案是双保险:
第一层是路由守卫。本地存在未提交的作答,要离开当前页面(不是交卷)时,用浏览器原生beforeunload弹出提示。这个只能防顺手按F5或关标签,防不了浏览器崩溃断网。
第二层是答题数据持久化。我使用了localStorage作为答题暂存备份,每5秒把答案快照序列化写入一次。等考生重新进入考试页,检测到localStorage里有未提交的考试现场,就询问是否恢复作答。
具体的恢复逻辑:
javascript复制// examGuard.js
function checkUnfinishedExam() {
const saved = localStorage.getItem("exam_draft");
if (saved) {
const draft = JSON.parse(saved);
// 检查是否还在考试有效期内
if (Date.now() < draft.deadline) {
return draft;
}
// 已过期则清理
localStorage.removeItem("exam_draft");
}
return null;
}
这里的核心原则是:只要是考试过程中的答题数据,坚决不放在内存里“裸奔”。后端自动保存、localStorage本地备份,至少两道防线同时守着。
5. 联调阶段真正的坑:跨域、时区和并发
前后端分开开发,联调的时候你几乎必然遇到一波难缠的问题。这一节讲的三个坑,每一次都让我多加了快两周的班。
5.1 跨域问题不是前端配置就能解决的
前端跑在Vue3的dev server上(比如localhost:5173),后端FastAPI跑在localhost:8000,两者端口不同,跨域立即发生。羞耻的是,我在做第一个联调时也天真地以为跨域是前端的事。
正确的处理是后端主动加上CORS中间件:
python复制# app/main.py
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"], # 生产环境换成真实域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
值得注意的是,生产环境如果你把前端静态部署在Nginx,且Nginx反代了后端API,那么理论上前端和后端是同一个域名,不存在跨域。但还是建议把CORS中间件加上,因为你还可能需要给移动端H5(不同域名)提供接口。
5.2 时区问题能导致考生“考试还没开始就结束了”
考试系统的各种时间戳,在前后端之间流转,很容易出时区差问题。我最开始后端Python用的 datetime.now() 本地时间,存进MySQL的是服务器本地时区;前端直接从JS那里拿到的是UTC时间戳再本地化;结果就出现了考试状态判断全部错乱的局面——考生看到“未开始”,后端却认为“已结束”。
统一方案:后端和数据库统一使用UTC存储,前端拿到时间戳之后,再用 dayjs 或 moment 本地化展示。原则是“存储永远存UTC,展示永远本地化”。
后端如果你用SQLAlchemy,可以在模型里加一个时间处理:
python复制from datetime import datetime, timezone
def utc_now():
return datetime.now(timezone.utc)
前端倒计时,也直接基于后端返回的UTC时间戳,如 deadline = new Date(utc_timestamp).getTime()。这样即使后端服务器部署在天南地北,前端展示的时间也始终是考生看到的那一刻,不偏不倚。
5.3 并发提交怎么防:幂等性与连接池
在线考试系统最刺激的场景就是收卷那一分钟。几百个考生同时点交卷,请求全部打到 /exams/{id}/submit 上,如果接口处理不做保护,数据库连接池很容易被打满,然后后面的请求全部超时。
我从上线第一版就踩了连接池打满的坑,后来做了三件事:
第一,给提交接口加幂等键。考生首次进入考试页时,后端下发一个 submit_token(唯一标识),本地存在前端的reactive对象里。提交时带上这个token,后端利用数据库唯一约束确保同一考生的同一场考试只会被处理一次。如果学生多点了几次交卷按钮,后到的请求直接返回“已交卷”,不重复写库。
第二,数据库连接池参数必须调对。我用的配置:
python复制# database.py
engine = create_engine(
DATABASE_URL,
pool_size=20,
max_overflow=10,
pool_timeout=30,
pool_recycle=600,
)
pool_size控制在20到30之间比较稳,max_overflow不要太大(10就够了),否则高并发时MySQL的最大连接数一下子被打崩。关键还得配合慢查询优化,比如answer_record表的批量插入,不要用单条ORM save提交,直接使用 bulk_save_objects 或原生批量insert。
第三,前端提交按钮要做状态锁。点击交卷后按钮立即变为不可点击状态,并显示“正在交卷……”。同时交卷请求用上vue-router的路由前置钩子拦截,防止“交卷请求还没返回,用户刷新页面又重新打开答题页”造成二次提交。
6. 考试系统的安全加固:防的不是作弊,是事故
在线考试安全,光想着“防作弊”其实还不够,我更在意的是防止账号被盗刷、接口被乱调、数据被拖走这些基础事故。
6.1 登录鉴权:用JWT但要处理好过期场景
我用的是JWT无状态认证。登录接口给前端返回access_token和refresh_token,access_token短时效(2小时),refresh_token长时效(7天)。前端每次请求在Authorization头里带上token。
FastAPI里用依赖注入做全局鉴权:
python复制from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
):
payload = verify_token(credentials.credentials) # 自实现的jwt校验逻辑
if payload is None:
raise HTTPException(401, "Token无效或已过期")
user = db.query(User).filter(User.id == payload["sub"]).first()
return user
前端配合拦截器做401统一处理,token过期了自动用refresh_token换新的,换不动就让用户重新登录。
6.2 接口防刷与提交篡改
在线考试,最怕有人写个脚本调“提交答案”接口。自己一个人作弊是小范围,万一被培训机构拿到接口参数,批量提交“标准答案”就坑了全校。
我在重要接口(交卷、保存草稿、登录)上都做了频率限制,比如交卷接口,同一个token每分钟最多处理3次请求。FastAPI上我用的是自实现的滑动窗口限流。配合Nginx层面也做一层IP限流,双保险。
还有一个非常隐蔽且容易忽视的问题:前端提交的“题目和答案”组合,后端完全信任,这不行。我后端判分时是用paper_question里面的answer_snapshot去比对,其sha1签名校验可以在防篡改上再撑一层——前端在交卷时,把answers提交给后端,后端算一下哈希,再和当初下发试卷时的哈希比对,如果不一致说明可能有中间人篡改,立即拒绝判分并标记该考试记录为异常。
6.3 密码存储与敏感数据脱敏
密码必须有盐加密。FastAPI里直接用 passlib 加 bcrypt 这个组合,Python社区标准方案。不要在系统里出现明文密码,这是红线。
对用户列表、成绩列表这种接口,返回给前端时要把密码哈希、邮箱、手机号这类字段剔除。用Pydantic的模型定义返回结构,最简单也最安全。前几天处理一个半年前给老客户做的系统,发现他们直接把User对象整个serialize后返回,密码哈希全裸露在前端浏览器里,吓得我连夜排雷。
7. 部署上线的方案:一台4核8G服务器足够
考试系统用不着一开始就上微服务、K8s,一台配置尚可的云服务器就可以撑起一场校级考试。这里分享一套我验证过多次的生产部署方案。
7.1 前端打包 + Nginx静态托管
Vue3前端开发完毕,执行 npm run build,生成 dist 目录。把dist放到Nginx的web目录下,配置一个server块:
nginx复制server {
listen 80;
server_name exam.example.com;
root /var/www/exam-frontend/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
注意 try_files $uri $uri/ /index.html 这行,这是Vue Router的history模式必需的。不加这行,刷新后任何非首页路由都会404。
7.2 后端进程管理与环境配置
后端用 uvicorn 启动,但生产环境不要裸跑,交给 systemd 托管:
code复制# /etc/systemd/system/exam-api.service
[Unit]
Description=Exam API Server
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/exam-api
ExecStart=/var/www/exam-api/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 4
Restart=always
EnvironmentFile=/etc/exam-api.env
[Install]
WantedBy=multi-user.target
环境变量全部放 /etc/exam-api.env 里——数据库连接串、JWT密钥、文件存储路径,一律不进代码仓库。
启动后,记得执行 systemctl enable exam-api。然后验证服务状态,看一下启动日志里数据库连接是否正常。
7.3 备份策略与考试数据安全
考试系统最怕什么?数据丢了。考试成绩、答题记录,所有历史数据必须纳入备份范围。
MySQL每日全量备份 + 每6小时binlog增量备份。最简化可用方案是crontab跑 mysqldump:
bash复制0 2 * * * mysqldump -u backup_user -p**** exam_db > /backup/exam_db_$(date +\%Y\%m\%d).sql
恢复的时候注意,mysqldump出来的SQL文件要用 source 方式导入,并且历史成绩表、考试记录表都要恢复到故障前的那一刻。另外建议每周做一次恢复演练——不恢复的备份都是写了没用的纸。
部署中另一个用户体验相关的细节是文件服务。如果考试系统涉及图片上传(比如简答题拍照),Nginx还需要配一个静态目录代理。这个目录我用的独立路径,防止与前端dist目录冲突。
8. 几个我在真实项目中拿过血的额外提醒
第一,不要相信用户的网络稳定。我做过一场面向远程考生的在线考试,开到一半约有5%考生断网。没有断线重连机制的考试系统,在这个场景下几乎等于废了。我后来专门实现了断网检测 —— 前端写了一个心跳接口,每30秒请求一次,响应失败就判断为断网,弹窗提示“网络已断开,请检查网络”。同时后端在做自动保存,离线的这段时间,答案都存在localStorage里,网络恢复后一次性同步上去。
第二,成绩的修正不要用“覆盖全表”的方式。万一成绩复核时发现有一题判分规则需要调整,你要能精确知道受影响的是哪部分考生、哪道题、原来的分数分别是什么。我设计了一个recalibration表,记录每次成绩修正,留存修订历史。这在争议处理时是救命稻草——学生不满意成绩复核结果,你有据可查。
第三,双设备登录的互踢逻辑。很多学生会在手机上开着考试页,又在电脑上开另一份。我实现的策略是在线状态用后端Redis缓存,每个用户在同一个考试场次里只能建立一条在线会话。新设备登录,旧设备就强制下线,前端通过轮询接口感知到后立即终止考试页的操作。
第四,不要低估公告系统的重要性。真实考试前,往往要发布考场规则、时间调整通知、设备要求提示。我最初觉得在线考试系统的公告模块只是个花瓶,后来考务老师找到我说“没这个我真干不成”——他们在实际考试中超过60%的操作是在跟考生同步规则,而不是在发卷子。
这些经验都不是单靠架构设计推出来的,而是被真实在线考试“折磨”后总结出来的。每一个词背后可能都是一次线上事故。希望我这套方案的思路和代码实现细节,能让你少走我走过的弯路。
