
做“python flask在线考试系统vue”这个项目的人通常不是想做一个玩具。真正碰过在线考试的人都知道表面上是前后端增删改查但一上线同时间几百人点“开始考试”数据库报错、答案对不上、交卷交重复、考生刷新一下就丢了状态这些问题会一个接一个冒出来。这篇文章不是贴一段能跑的Demo而是把在线考试系统从需求拆解、数据库建模、后端判分、Vue前端交互到上线部署的完整设计过程拆开讲适合正在用Flask和Vue做课程设计、公司内部考试平台或者培训考核系统的开发者参考。哪怕你只对其中某一层感兴趣每个部分也尽量单独成章可以直接跳到对应小节。1. 先别急着写代码把考试流程拆成角色、状态和用例1.1 为什么考试系统不能当普通CRUD做很多第一次做在线考试系统的人第一反应就是“用户表、题目表、试卷表、成绩表四个表一写就完事”。但真把流程跑一遍就会发现普通CRUD只关心“记录有没有存进去”考试系统关心的是“状态能不能对得上”。举个例子考生点击开始考试系统要创建一条考试记录状态是“进行中”考生答题过程中自动保存状态不能变提交试卷后状态变成“已交卷”然后判分变成“已出分”。如果考生半路断网过十分钟重新进来这条记录要能恢复成“进行中”答案还要在。管理员修改了一道题已经生成的答卷不能跟着变。这些都不是一张表能解决的而是需要一套完整的角色、流程和数据状态定义。所以在写第一行代码前我习惯先把角色画出来管理员管题库、组卷、发布、阅卷考生登录、参加考试、看成绩。然后把核心流程画出来创建考试 - 考生进入 - 答题自动保存 - 交卷 - 判分 - 成绩查看。再把异常流程补上考试中断续考、超时强制交卷、重复提交拦截。把这些用例列清楚后面的表和接口才不会被推翻重写。1.2 技术选型时我为什么坚持 Flask Vue 前后端分离选型不需要争谁比谁强关键是匹配场景。Flask 的特点是轻路由和蓝图可以把模块拆得清楚配合 Flask-SQLAlchemy 操作数据库也很顺手。相比 DjangoFlask 少了很多默认约束但考试系统本身业务不是特别重骨架清晰比“开箱即用”更重要。有人问现在 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_keyTrue) username db.Column(db.String(64), uniqueTrue, nullableFalse) password_hash db.Column(db.String(256), nullableFalse) role db.Column(db.String(16), defaultstudent) # student / admin created_at db.Column(db.DateTime, defaultdatetime.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_keyTrue) question_type db.Column(db.String(16), nullableFalse) # single/multiple/judge/blank/essay content db.Column(db.Text, nullableFalse) options db.Column(db.JSON, nullableTrue) # 如 {A: ..., B: ...} answer db.Column(db.Text, nullableFalse) score db.Column(db.Numeric(5, 2), default1) difficulty db.Column(db.Integer, default3) # 1-5 knowledge_point db.Column(db.String(128)) created_at db.Column(db.DateTime, defaultdatetime.utcnow)这里有一个新手很容易忽略的点同一个题目在不同试卷里的分值可能不一样所以不能把分数字段只放在题目表里。试卷和题目之间必须有一张关联表单独记录“这套卷子里这道题占多少分、排在第几题”。我的做法是把题目的题面、选项、答案、类型都做成快照字段class ExamQuestion(db.Model): __tablename__ exam_question id db.Column(db.Integer, primary_keyTrue) 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_keyTrue) 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), defaultin_progress) # in_progress/submitted/graded/abnormal start_time db.Column(db.DateTime, defaultdatetime.utcnow) submit_time db.Column(db.DateTime, nullableTrue) final_score db.Column(db.Numeric(5, 2), nullableTrue) __table_args__ ( db.UniqueConstraint(exam_id, user_id, nameuk_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(usernamedata.get(username)).first() if user and user.check_password(data.get(password)): token create_access_token( identitystr(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 托管前端静态文件并反向代理 APISupervisor 保活 Gunicorn 进程。后端的启动命令建议gunicorn -w 4 -b 127.0.0.1:8000 app:appNginx 的配置核心是两段前端单页应用所有路由都指向 index.html接口路径 /api 反向代理到本地的 Gunicornserver { 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 高频问题速查表现象常见原因处理办法前端请求接口报跨域后端没有配置 CORSFlask 安装 flask-cors统一配置 /api 路径允许前端域名访问MySQL 中文乱码连接字符集不对数据库连接参数加 charsetutf8mb4表字段用 utf8mb4登录后请求接口返回 401JWT 过期或请求头格式不对前端用Authorization: Bearer token过期后用刷新 token 或重新登录交卷后成绩总是不对前端计算总分或题目答案被改动判分统一放后端以考试快照 answer_snapshot 为准自动保存的答案刷新后丢失Redis key 过期或前端防抖时间太短设置合理的 key 前缀和过期时间保存接口加记录 ID 参数SQLite 报 database is locked并发写入超过 SQLite 能力换 MySQL同时调整连接池大小上传题目图片报 413Nginx 请求体大小限制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 里加了快照所有判分都走快照才彻底解决。如果你也在做这个系统记得从第一天就把状态机和快照放进去不然后面补会非常痛苦。最后再分享一个小技巧考试记录表里的唯一约束一定要加它能在并发场景下保你一次这比任何业务判断都可靠。