做“python flask在线考试系统vue”这个项目的人,通常不是想做一个玩具。真正碰过在线考试的人都知道,表面上是前后端增删改查,但一上线,同时间几百人点“开始考试”,数据库报错、答案对不上、交卷交重复、考生刷新一下就丢了状态,这些问题会一个接一个冒出来。这篇文章不是贴一段能跑的Demo,而是把在线考试系统从需求拆解、数据库建模、后端判分、Vue前端交互到上线部署的完整设计过程拆开讲,适合正在用Flask和Vue做课程设计、公司内部考试平台或者培训考核系统的开发者参考。哪怕你只对其中某一层感兴趣,每个部分也尽量单独成章,可以直接跳到对应小节。
1. 先别急着写代码:把考试流程拆成角色、状态和用例
1.1 为什么考试系统不能当普通CRUD做
很多第一次做在线考试系统的人,第一反应就是“用户表、题目表、试卷表、成绩表,四个表一写就完事”。但真把流程跑一遍就会发现,普通CRUD只关心“记录有没有存进去”,考试系统关心的是“状态能不能对得上”。
举个例子:考生点击开始考试,系统要创建一条考试记录,状态是“进行中”;考生答题过程中自动保存,状态不能变;提交试卷后,状态变成“已交卷”,然后判分变成“已出分”。如果考生半路断网,过十分钟重新进来,这条记录要能恢复成“进行中”,答案还要在。管理员修改了一道题,已经生成的答卷不能跟着变。这些都不是一张表能解决的,而是需要一套完整的角色、流程和数据状态定义。
所以在写第一行代码前,我习惯先把角色画出来:管理员管题库、组卷、发布、阅卷;考生登录、参加考试、看成绩。然后把核心流程画出来:创建考试 -> 考生进入 -> 答题自动保存 -> 交卷 -> 判分 -> 成绩查看。再把异常流程补上:考试中断续考、超时强制交卷、重复提交拦截。把这些用例列清楚,后面的表和接口才不会被推翻重写。
1.2 技术选型时,我为什么坚持 Flask + Vue 前后端分离
选型不需要争谁比谁强,关键是匹配场景。Flask 的特点是轻,路由和蓝图可以把模块拆得清楚,配合 Flask-SQLAlchemy 操作数据库也很顺手。相比 Django,Flask 少了很多默认约束,但考试系统本身业务不是特别重,骨架清晰比“开箱即用”更重要。有人问现在 FastAPI 也很火,为什么不选?FastAPI 的异步能力和自动接口文档确实好,但如果团队本来就更熟悉 Flask 的扩展生态,或者项目需要快速交付,Flask 会更稳。两者都能做,别在选型上内耗。
前端选 Vue,是因为考试页面实在不适合用服务端模板渲染。答题卡、倒计时、自动保存、题目切换,这些都是强交互场景。用 Jinja2 模板硬写也可以,但状态同步会很痛苦。Vue 的组件化能把“题目区”“答题卡”“倒计时条”拆成独立组件,配合 Pinia 或 Vuex 做全局状态,交互响应才跟得上。前后端分离还有一个额外收益:管理后台和考生端可以共用同一套接口,以后如果要做小程序端,后端基本不用改。
2. 数据库设计:成绩要对得上账,题目不能越改越乱
2.1 七张表就能撑起整个在线考试
数据库是考试系统的地基。我在实际项目里用的表结构不算多,核心七张:用户表、题目表、试卷表、试卷题目关联表、考试记录表、答题明细表、知识点表。知识点表可以简单,初版放到题目字段里也行,但题库量大之后,按知识点筛选抽题会很有用。
先看用户和题目这两张基础表。用户表不要存明文密码,Flask 里用 Werkzeug 的哈希函数处理一下就很安全:
from datetime import datetime from werkzeug.security import generate_password_hash, check_password_hash from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class User(db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, nullable=False) password_hash = db.Column(db.String(256), nullable=False) role = db.Column(db.String(16), default='student') # student / admin created_at = db.Column(db.DateTime, default=datetime.utcnow) def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password)题目表要留出足够的扩展空间。题型至少要有单选、多选、判断、填空、简答,所以题型字段用字符串而不是布尔值。题目内容用 Text,选项用 JSON,这样多选题、判断题都能覆盖:
class Question(db.Model): __tablename__ = 'question' id = db.Column(db.Integer, primary_key=True) question_type = db.Column(db.String(16), nullable=False) # single/multiple/judge/blank/essay content = db.Column(db.Text, nullable=False) options = db.Column(db.JSON, nullable=True) # 如 {"A": "...", "B": "..."} answer = db.Column(db.Text, nullable=False) score = db.Column(db.Numeric(5, 2), default=1) difficulty = db.Column(db.Integer, default=3) # 1-5 knowledge_point = db.Column(db.String(128)) created_at = db.Column(db.DateTime, default=datetime.utcnow)这里有一个新手很容易忽略的点:同一个题目在不同试卷里的分值可能不一样,所以不能把分数字段只放在题目表里。试卷和题目之间必须有一张关联表,单独记录“这套卷子里这道题占多少分、排在第几题”。我的做法是把题目的题面、选项、答案、类型都做成快照字段:
class ExamQuestion(db.Model): __tablename__ = 'exam_question' id = db.Column(db.Integer, primary_key=True) exam_id = db.Column(db.Integer, db.ForeignKey('exam.id')) question_id = db.Column(db.Integer, db.ForeignKey('question.id')) score = db.Column(db.Numeric(5, 2)) sort = db.Column(db.Integer) content_snapshot = db.Column(db.Text) answer_snapshot = db.Column(db.Text) options_snapshot = db.Column(db.JSON) question_type_snapshot = db.Column(db.String(16))为什么要快照?因为考试系统讲究“当时考的是什么,就按什么判”。如果管理员在考试中途发现某道题答案录错了,直接改题库,正在考试和已经交卷的考生到底按哪个答案判?快照能保证所有已生成的试卷都不受后续修改影响。这一点真的是踩过坑之后才明白的。
考试记录表和答题明细表是整个系统的核心。考试记录表有唯一约束,同一个考生在同一场考试里只能有一条记录,从源头防止重复交卷:
class ExamRecord(db.Model): __tablename__ = 'exam_record' id = db.Column(db.Integer, primary_key=True) exam_id = db.Column(db.Integer, db.ForeignKey('exam.id')) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) status = db.Column(db.String(16), default='in_progress') # in_progress/submitted/graded/abnormal start_time = db.Column(db.DateTime, default=datetime.utcnow) submit_time = db.Column(db.DateTime, nullable=True) final_score = db.Column(db.Numeric(5, 2), nullable=True) __table_args__ = ( db.UniqueConstraint('exam_id', 'user_id', name='uk_exam_user'), )答题明细表负责存考生的每一道答案,同时存这道题当时的分值和判定结果。这样成绩出来之后,考生如果申诉,管理员可以逐题核对,不会出现“总分对不上”的扯皮。
2.2 考试记录状态机与答卷快照:数据一致性的基础
考试记录的状态是系统里最容易乱的地方。我建议一开始就明确状态机:in_progress 代表进行中,submitted 代表已交卷,graded 代表判分完成,abnormal 代表异常终止。考生点击开始考试创建记录,状态是 in_progress;交卷接口成功后改成 submitted;判分完成后改成 graded。如果有人反复调用交卷接口,后端靠状态判断直接拦住:已经是 submitted 的记录不再允许重复写答案。
在自动保存的设计上,我推荐用 Redis 先缓存考生的答题进度。每次保存不等于直接写 MySQL,而是把整份答案以 JSON 的形式写到 Redis 里,key 可以用 record_id。这样考生刷新页面、断网重进,都有一个恢复接口从中把答案读回来。交卷时再一次性把答案写入答题明细表,配合事务保证判分和记录状态同时完成。这里非常重要的一点是,如果 Redis 没配置,退而求其次往答题明细表里写 in_progress 状态的数据也行,但考试开始时大量并发写入会让数据库压力很大,用 Redis 做中间层能明显缓解。
3. 后端接口:登录、组卷、交卷判分的核心实现
3.1 JWT 登录与接口权限拦截
在线考试系统最基础但最不能省的接口就是登录鉴权。用 JWT 做登录状态是目前前后端分离项目的主流方案。Flask 里有现成的扩展可以用,核心逻辑是登录成功后签发 token,后续请求在请求头里带上 token,后端用装饰器校验身份。
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity @auth_bp.post('/login') def login(): data = request.get_json() user = User.query.filter_by(username=data.get('username')).first() if user and user.check_password(data.get('password')): token = create_access_token( identity=str(user.id), additional_claims={'role': user.role} ) return {'code': 0, 'token': token, 'role': user.role} return {'code': 40001, 'msg': '用户名或密码错误'}, 401除了解密身份,还要做接口权限控制。考生接口和管理员接口不能混用。我建议不要只在每个接口里重复判断 role,而是封装一个装饰器,例如@admin_required,内部读取 JWT claims 里的角色信息,不是管理员就直接返回 403。这样后面再增加角色也方便改。登录接口还要注意,token 不要放在 cookie 里跨域容易出问题,前端用 Axios 拦截器统一加到 Authorization header,格式是Bearer <token>。
3.2 组卷接口:随机抽题要在生成试卷时固定题目
组卷有手动组卷和随机抽题两种模式。手动组卷就是管理员从题库里勾选题目,写入 exam_question 表。随机抽题则是试卷里定义抽题规则,比如“单选题 10 道、多选题 5 道、判断题 10 道,从知识点 X 里随机选”。这个功能看起来简单,但有一个特别容易犯的错:在考生请求试卷时才随机抽题。
如果每次进入考试都重新随机一次,考生刷新页面题目就变了,这会直接导致答题记录对不上。正确做法是,管理员创建试卷时就把随机抽题的结果落库,生成一批 exam_question 记录,考生看到的永远是固定版本。实现时可以先按题型、知识点、难度筛选出符合条件的题目 ID 列表,再用 random.sample 抽取,最后按 sort 字段排序插入关联表。
获取试卷接口返回给前端的题目一定不能包含答案,一道题的答案都不能有。前端答题页只需要 content、options、question_id、题型、分值这些基础信息。如果题目里带图片,外层套一个 Markdown 编辑器或富文本字段,接口再返回处理后的 HTML 地址,不要让前端自己拼图片链接。
3.3 交卷判分接口:为什么后端必须重新算分
有些项目为了省事,前端算好总分,提交时把分数一起传过来,后端直接存。这是绝对要避免的。前端传什么都可以伪造,判分必须由后端重新算。交卷接口的职责包括:校验考试状态、逐题调用判分函数、写答题明细、更新记录总状态和分数。
判分函数的实现看起来简单,但规则要提前定义清楚。单选题和判断题用精确匹配;多选题建议设计成“完全匹配得分,漏选给一半,错选零分”的规则;填空题可以去掉首尾空格,忽略大小写后比较;简答题无法自动判,先把判分结果标记为 pending,由管理员在后台人工阅卷。核心代码可以这样组织:
def judge_question(q_type, correct, user_answer, score): if q_type == 'single' or q_type == 'judge': return 'correct' if user_answer == correct else 'wrong', score if user_answer == correct else 0 elif q_type == 'multiple': user_set = set(user_answer or []) correct_set = set(correct) if user_set == correct_set: return 'correct', score if user_set and user_set.issubset(correct_set): return 'partial', round(score * 0.5, 2) return 'wrong', 0 elif q_type == 'blank': if user_answer and user_answer.strip().lower() == correct.strip().lower(): return 'correct', score return 'wrong', 0 return 'pending', 0交卷接口里还有一个并发控制问题。两个请求同时进来,都可能先查到状态是 in_progress,然后都执行判分写入,导致数据重复。我的做法是在更新考试记录之前,先用with_for_update()把记录锁住,等第一个事务提交后,第二个事务再查状态就是 submitted,直接返回“考试已经交卷”。这样处理之后,重复提交才真正被拦截住。
3.4 考试中断恢复:刷新页面、断电、换设备的续考设计
在线考试最尴尬的场景就是考到一半断网了,重新打开页面发现答案全没了,或者时间重新开始算。这需要后端提供两个接口配合:保存进度接口和恢复考试接口。
保存进度接口做的事情很简单,把前端传来的 answer 列表更新到 Redis 缓存里。前端在每答一题或者每隔几秒自动调用一次。恢复考试接口则根据 record_id 查状态:如果状态是 in_progress,就返回缓存中的已答题目、剩余秒数、题目列表;如果状态已经是 submitted,就返回成绩和提交时间,前端自动进入成绩页。
时间也不能全信前端。前端倒计时只是展示,真正控制时间的是后端。考试记录创建时记 start_time,试卷里有 duration,结束时间就是 start_time + duration。交卷接口除了判断状态,还要判断当前时间是不是已经超过结束时间,如果超过直接强制交卷并判分。这样用户改了本地时间也钻不了空子,因为校验在后端。
4. Vue 前端:答题体验和后台管理效率一起抓
4.1 用 Vite + Vue3 + Pinia 搭好前端骨架
前端项目我推荐 Vue3 + Vite + Pinia + Vue Router,组件库按团队熟悉度选就行。Vite 开发体验好,Vue3 的组合式 API 更适合组件逻辑复用,Pinia 管理考试状态比 Vuex 简洁很多。
骨架搭好之后,第一件事是封装请求模块。开发的时候会遇到 CORS,这属于后端配置问题,但前端也要把请求和错误处理统一。Axios 的拦截器里要做两件事:请求拦截器自动带上 JWT token,响应拦截器统一处理业务错误码和 HTTP 状态。如果遇到 401,清除本地用户信息并跳转登录页,否则用户会一直看到一个白屏接口报错,体验很差。
import axios from 'axios' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( res => res.data, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') location.href = '/login' } return Promise.reject(err) } )路由要区分考生和管理员。登录接口返回的 role 字段存起来,路由守卫里判断目标路由需要的权限,角色不匹配就重定向到对应首页。这个方案比在每个页面写 if 判断要省心得多,新增页面时只需要在路由 meta 里声明角色。
4.2 答题页的倒计时、答题卡和自动保存怎么配合
答题页是考生最关注的页面,交互细节决定体验。页面通常分成三个区域:顶部是倒计时和交卷按钮,左侧是答题卡,中间是当前题目和选项。三个阶段之间靠 Pinia 里的全局状态串联起来。
倒计时组件的关键不是 countdown 数字本身,而是结束时间的来源。后端恢复考试接口会返回 remaining_seconds,前端用这个值做初始值,再每秒减一。不要直接用本地当前时间加 duration,因为用户改系统时间会导致倒计时异常。倒计时归零时前端主动调用交卷接口,同时后端也会做超时校验,双保险。
答题卡需要显示每道题的状态:未答、已答、标记。每切换一题或者选择答案,都要同步更新 Pinia 中的答题记录。自动保存不能用每答一题立刻请求这种粗暴方式,否则网络差时会产生很多并发请求。更稳的做法是加一个防抖,用户停止操作 2 到 3 秒后再统一保存一次。离开页面时,用 beforeunload 事件提示考生还有未交卷的考试,防止误关页面。
交卷按钮也不要直接提交。弹窗二次确认,文案明确告诉考生交卷后不能修改。交卷成功后前端要清除本地缓存,并跳转成绩页。如果后端返回 score,成绩页直接展示,如果还有人工阅卷的题目,要显示“待阅卷”而不是一个空缺的分数。
4.3 管理后台好不好用,看题库录入和成绩导出
管理后台常常被当成“能增删改查就行”,但实际决定系统好不好用的,是题库批量录入和成绩导出。几百道题让管理员在页面上一条一条输入,会非常痛苦。
我的做法是提供 Excel 导入模板。模板里每一行对应一道题,字段包括题型、题干、选项A到D、正确答案、分值、知识点、难度。后端接收到 Excel 文件后用 openpyxl 解析,逐行校验,最后把所有错误行和错误原因一起返回给前端展示。这样管理员可以一次性导入几百道题,还能看到哪几行格式不对,比逐条录入手工检查高效很多。
试卷发布之后,成绩导出也要做好。后端直接用 openpyxl 生成 xlsx 文件返回,前端用二进制流方式下载,注意设置 Content-Disposition 让浏览器识别为附件。导出内容至少包括考生姓名、账号、总分、客观题得分、主观题得分、提交时间。如果有条件,还可以导出每道题的答题详情,方便老师做试卷分析。
5. 部署上线与安全性能实战
5.1 Nginx + Gunicorn + Supervisor 的常规部署组合
考试系统跑通开发环境只是第一步,上线部署又是一堆问题。开发时 Flask 自带的服务器绝对不能用在生产环境,它单线程、性能差、也没法长期稳定运行。常规组合是 Gunicorn 跑后端,Nginx 托管前端静态文件并反向代理 API,Supervisor 保活 Gunicorn 进程。
后端的启动命令建议:
gunicorn -w 4 -b 127.0.0.1:8000 app:appNginx 的配置核心是两段:前端单页应用所有路由都指向 index.html,接口路径 /api 反向代理到本地的 Gunicorn:
server { listen 80; server_name your-domain; location / { root /var/www/exam-frontend/dist; index index.html; 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }如果答题记录里有图片、富文本内容,Nginx 默认请求体大小限制会导致上传失败,要主动加一条client_max_body_size 20m;。上线后第一件事是检查 HTTPS 证书,考试系统的登录账号和答题数据都属于敏感信息,明文传输风险太大,现在申请证书的成本很低,别省这个环节。
5.2 防作弊不能只靠前端禁右键
很多项目把防作弊做成“禁用右键、禁用复制、切屏弹窗警告”,这些确实可以在前端做,但必须清醒:前端所有限制都只是提示和威慑,真正可靠的是后端数据校验。
比较可行的方案是记录考生行为日志。后端提供心跳接口,前端每隔一段时间上报当前题目 ID 和停留时长。管理员在后台可以查看“某道题作答耗时异常短”“同一 IP 出现多个账号”“切换页面次数过多”这类异常标记。这些数据不能直接当作弊证据,但可以作为人工复查的线索。
还要防止直接调用接口作弊。获取试卷接口不能把所有题目一次性返回,并且返回内容绝不能包含正确答案。自动保存接口也不能返回正确答案。如果有人用抓包工具反复测试接口,后端要加上简单限流,比如同一用户每分钟最多请求接口多少次,超了返回提示。平时做好接口权限校验,避免越权访问他人考试记录,这些比前端禁用右键实用得多。
5.3 考试开始瞬间大量并发,怎么保证系统不卡死
在线考试系统最极端的时刻是开考的那一刻。几百上千人同时点“开始考试”,系统要同时创建考试记录、加载试卷、返回题目列表。这个瞬间如果没处理好,数据库会被打满,页面一直转圈,接下来就是考生的投诉。
第一个思路是尽量用缓存扛读请求。获取试卷接口是典型的读多写少接口,试卷内容在组卷时已经确定,可以把 exam_id 对应的 exam_question 列表缓存到 Redis,设置缓存过期时间和考试结束时间同步。这样大量考生进入考试时,压力主要集中在 Redis,而不是数据库。
第二个思路是异步化。有些操作不需要在请求里同步完成,比如交卷后的判分。如果一场考试几百人同时交卷,判分任务可以扔进任务队列,每个考生拿到“已交卷,成绩正在计算中”的提示,后台任务慢慢跑。简单项目可以用 Redis List + 一个后台 Worker 实现,不用一上来就上重型消息队列。
数据库选型上,生产环境不要用 SQLite。SQLite 的锁机制在并发写入场景下会频繁报 database is locked,即使连接数少也扛不住考试场景。至少要用 MySQL,并合理配置连接池。连接池大小不是越大越好,默认 10 到 20 个连接在大多数考试项目里足够了,太多反而会拖垮数据库。
6. 常见问题与排查经验实录
6.1 高频问题速查表
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 前端请求接口报跨域 | 后端没有配置 CORS | Flask 安装 flask-cors,统一配置 /api 路径允许前端域名访问 |
| MySQL 中文乱码 | 连接字符集不对 | 数据库连接参数加 charset=utf8mb4,表字段用 utf8mb4 |
| 登录后请求接口返回 401 | JWT 过期或请求头格式不对 | 前端用Authorization: Bearer <token>,过期后用刷新 token 或重新登录 |
| 交卷后成绩总是不对 | 前端计算总分或题目答案被改动 | 判分统一放后端,以考试快照 answer_snapshot 为准 |
| 自动保存的答案刷新后丢失 | Redis key 过期或前端防抖时间太短 | 设置合理的 key 前缀和过期时间,保存接口加记录 ID 参数 |
| SQLite 报 database is locked | 并发写入超过 SQLite 能力 | 换 MySQL,同时调整连接池大小 |
| 上传题目图片报 413 | Nginx 请求体大小限制 | Nginx 加 client_max_body_size |
这些问题是做过考试系统的人大概率会遇到的。跨域和中文乱码属于环境配置,排查起来不难,但第一次遇到往往会卡很久。我的建议是先把环境配置整理成一份部署清单,每个新环境照着跑一遍,能省下大量定位问题的时间。
6.2 三个文档不会写但你大概率会遇到的坑
第一个坑是快照问题。管理员改题库,正在考试或者已经考完的试卷不能被影响。这个靠关联表里的 answer_snapshot 和 content_snapshot 解决。判分时不要 join 到 question 表读取 answer 字段,而是直接读 exam_question 里的快照。已经交卷的记录,连答题明细里的题目内容也要留存,不然出了成绩争议都没有证据可查。
第二个坑是浮点精度。多选漏选给一半分,0.5 的分数很容易在多次累加后变成 47.9999999。Python 里 float 做金额和分数累加会出这种问题。题目的分数字段用 Decimal 或 Numeric,累加时最后用 round 处理,保留两位小数。别小看这个问题,成绩单上出现小数误差是会被人揪着投诉的。
第三个坑是时间基准。考试记录和倒计时依赖的系统时间必须统一。服务器、数据库、前端用户分布在不同的时区很容易差 8 小时,倒计时就跟着乱了。我的习惯是数据库统一存 UTC 时间,后端返回给前端时带上剩余秒数,前端只负责倒计时展示,不参与时间计算。这样即使服务器时区配置有问题,剩余时间也不会错。
这套系统做完后,我自己最深的体会是:考试系统的复杂度不在代码量,而在边界情况。之前有一次上线后,管理员发现一道多选的答案录错了,直接改了题库,结果正在考试的考生按新答案判分,已经交卷的考生成绩没变,两边对不上。后来我在 exam_question 里加了快照,所有判分都走快照,才彻底解决。如果你也在做这个系统,记得从第一天就把状态机和快照放进去,不然后面补会非常痛苦。最后再分享一个小技巧:考试记录表里的唯一约束一定要加,它能在并发场景下保你一次,这比任何业务判断都可靠。