前阵子帮一个做培训业务的朋友搭建在线考试系统,技术选型最终敲定 Flask + Vue。当时对比过 Django、Spring Boot、Node 这些方案,最后让我坚定选择 Flask 的原因是它足够轻、足够灵活,配合 Vue 做前后端分离,开发效率能比传统全栈方案高出不少。这篇文章我把整个系统的设计思路和核心代码实现梳理一遍,涉及数据库建模、随机组卷、自动判分、前端考试页面的倒计时与自动交卷、部署上线等,希望对正在做类似项目的人有帮助。
1. 整体方案设计:为什么是 Flask + Vue
1.1 需求拆解和核心功能清单
在线考试系统听起来简单,但只要接触过真实业务就会发现,它的坑全藏在细节里。我最初接到的需求只有一句话:要能在线出题、答题、出成绩。但和业务方聊了几轮之后,需求变成了下面这张表:
| 模块 | 需求明细 | 优先级 |
|---|---|---|
| 用户管理 | 管理员、教师、学生三种角色,支持批量导入账号 | 高 |
| 题库管理 | 单选、多选、判断、填空四种题型,支持按知识点分类 | 高 |
| 组卷策略 | 支持人工选题和自动随机组卷,按题型数量和分值难度配置 | 高 |
| 考试管理 | 设置考试时间、时长、总分、及格线、考生范围 | 高 |
| 在线答题 | 倒计时、答题卡切换、未答提醒、自动交卷 | 高 |
| 判分与统计 | 客观题自动判分,成绩统计、分数段分布 | 中 |
| 防作弊 | 切屏检测、考试期间禁止复制粘贴 | 中 |
这个功能列表基本覆盖了市面上大多数在线考试系统的核心场景。你如果做内部考核或者学校测验,直接按这个清单去规划功能就行,不需要一上来就设计复杂的分布式架构。
1.2 技术栈选型的心路历程
选型阶段我认真对比过三组方案:
服务端方面,Django 自带 Admin 后台和 ORM,确实省事,但模型耦合较重,想要全前后端分离反而要绕不少弯子;Spring Boot 性能强、生态全,但对于一个中小型的考试系统来说,团队本身不熟悉 Java 的话学习成本太高。Flask 的优势在于核心精简、扩展自由,配合 SQLAlchemy 和蓝图,既保持了清晰的工程结构,又能在需要时灵活接入各种扩展,最终胜出。
前端方面,Vue 的入门曲线比 React 平滑,配合 Element UI 组件库,表格、表单、弹窗这些管理系统里高频使用的组件开箱即用。在线考试页面需要的倒计时、答题卡、题目切换这类交互,用 Vue 的响应式数据模型实现起来非常顺手。
整体架构采用前后端分离:Flask 只提供 JSON API,Vue 负责渲染与交互。这样后面想换掉任何一端,都不会影响另一端。
1.3 总体架构和模块划分
系统按功能划分成两个端:
- 管理端(Vue + Element UI):登录、考试管理、题库管理、考生管理、成绩查看。
- 考生端(Vue):考试列表、在线答题、交卷、成绩查看。
后端 Flask 提供一个 REST API 服务,按模块组织蓝图(Blueprint):auth、user、question、paper、exam、record。数据库采用 MySQL,本地联调用 SQLite 也行。缓存用 Redis,主要存验验证码、考试令牌、临时答题记录。
我把工程目录结构贴出来,方便你照着搭:
examsys/ ├── backend/ │ ├── app.py # 入口 │ ├── config.py # 配置 │ ├── models/ │ │ ├── user.py │ │ ├── question.py │ │ ├── paper.py │ │ ├── exam.py │ │ └── record.py │ ├── api/ │ │ ├── auth.py │ │ ├── question.py │ │ ├── paper.py │ │ ├── exam.py │ │ └── record.py │ ├── utils/ │ │ ├── auth_utils.py │ │ ├── paper_utils.py │ │ └── grade_utils.py │ └── requirements.txt ├── frontend/ │ ├── src/ │ │ ├── api/ # axios 封装 │ │ ├── router/ │ │ ├── store/ │ │ ├── views/ │ │ │ ├── admin/ │ │ │ └── student/ │ │ └── components/ │ └── package.json └── deploy/2. 数据库建模与 API 设计
2.1 数据表设计核心逻辑
做这个系统时,我认为数据库设计是整个项目的灵魂,这块如果没想清楚,后面写业务逻辑会非常痛苦。核心表有七张,我挑几张关键的讲讲设计思路。
用户表(users)区分角色,我用一个小技巧解决扩展性问题——用 role 字段区分 admin、teacher、student,而不是建立单独的用户扩展表。这么做对于中小型系统来说足够,查询效率也高。
题库表(questions)是所有考试系统的地基。设计上支持了题目类型、难度、知识点、分值等多维度的查询条件,这样随机组卷时的过滤选项才丰富。字段包括题干、选项 JSON、正确答案(我存的是字符串,防止多选顺序不对导致判断出错)、解析、难度(1到5)和知识点字段。选项用 JSON 字段存储,虽然有人说关系型数据库不该这么干,但实际开发中,考试题目选项本身就一个整体,永远不需要单独查某个选项,存 JSON 反而是最省事的方案。
试卷表(papers)采用了主表和明细表分离的设计。paper 主表存总分数、总时长等信息,paper_questions 明细表记录每道题的排列顺序,方便考试中出现随机乱序的需求。明细表里的 id 同时用作文科题排版和答题卡排序依据。
考试记录表(exam_records)记录了每个考生的考试时间和成绩。考虑到用户可能中途交卷,我把答题明细单独拆了张表(record_details),记录每道题的用户答案和是否得分。
草稿的时候我以为可以不用 Redis,后来遇到一个实际场景才发现这个想法天真了:学生如果在浏览器崩溃后重新进入考试,需要恢复之前的答题状态。虽然可以靠每秒往后端发一个心跳保存临时答案实现,但频繁写库压力大,后来我在 Redis 里做了一个数据结构来临时缓存答题状态,到了交卷时间再一次性写库,很酸爽。
2.2 关键 API 清单
API 层我采用了统一的返回结构:{code: 0, msg: "success", data: {...}},code 为 0 表示成功,非 0 表示业务异常。下面列出考试系统最核心的接口:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/login | 登录,返回 JWT Token |
| POST | /api/paper/generate | 按规则自动生成试卷 |
| GET | /api/exam/list | 获取考生可参加的考试列表 |
| GET | /api/exam/paper/:id | 获取一份试卷的详情和题目 |
| POST | /api/exam/start | 开始考试,创建答题记录 |
| POST | /api/exam/submit | 提交试卷,触发判分 |
| GET | /api/record/result/:examId | 查询成绩与答题详情 |
2.3 JWT 登录鉴权实现
用户认证没有用 Session,而是采用了 JWT。原因很简单:前后端分离之后,Session 需要处理跨域 Cookie 问题,维护成本高,用 Token 机制更符合现在的实践。
Flask 里我用 PyJWT 手动处理签发和验证,没有引入大而全的扩展,这样代码更可控。代码实现如下:
import jwt from datetime import datetime, timedelta from functools import wraps from flask import request, jsonify SECRET_KEY = "your-secret-key" def generate_token(user_id, role): payload = { "uid": user_id, "role": role, "exp": datetime.utcnow() + timedelta(hours=12) } return jwt.encode(payload, SECRET_KEY, algorithm="HS256") def login_required(f): @wraps(f) def decorated(*args, **kwargs): token = request.headers.get("Authorization", "").replace("Bearer ", "") if not token: return jsonify({"code": 401, "msg": "未登录"}), 401 try: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) request.user_id = payload["uid"] request.role = payload["role"] except jwt.ExpiredSignatureError: return jsonify({"code": 401, "msg": "登录过期"}), 401 except Exception: return jsonify({"code": 401, "msg": "无效令牌"}), 401 return f(*args, **kwargs) return decorated这里我踩过一个坑:前端请求里如果不带Bearer前缀,解码时会被当成非法 Token,导致用户死循环重定向登录。后来在两个端做了一致约定:前端请求拦截器统一加Authorization: Bearer xxx,后端只负责校验。建议你也定好这个规范再动工写代码。
3. 后端核心业务实现
3.1 随机组卷的实现思路
随机组卷,代码很简单,但如果不注意算法设计,会埋下很深的坑。看过网上很多版本,有的直接用ORDER BY RAND()抽题,数据量一大就慢得没法看;有的完全不打乱题序,考试次数一多题目位置每次一样,学生都能背答案了。
我的做法分三步:
第一步:拼查询条件。组卷规则里定义了每个题型有几个题,每个知识点了录多少道,难度分布是几到几。我用字典组合这些条件,代码看起来比较清晰。
第二步:按规则随机抽题。这一块没直接用RAND(),而是先查出符合条件的所有题目 ID,在 Python 层用random.sample抽取。因为题目的 ID 通常用主键列表返回,数据量本身不大,内存里操作远比数据库随机排序高效,而且代码可测试、可复现。
第三步:保存试卷快照。组卷成功后,要把 paper 和 paper_questions 一次性写入数据库,并记录整张试卷的题型分布。这样考生拿到的试卷就是一份“快照”,题目不会因为题库后续更新而变化,避免出现同一场考试不同考生的试卷题目都不稳定的情况。
随机组卷核心代码如下:
def generate_paper(rule): """rule: {'single': {'count': 10, 'score': 2, 'levels': [1,2]}, ...}""" paper = Paper() db.session.add(paper) db.session.flush() total_score = 0 order = 1 for qtype, cfg in rule.items(): qids = Question.query.filter( Question.qtype == qtype, Question.level.in_(cfg["levels"]) ).with_entities(Question.id).all() qids = [i[0] for i in qids] selected = random.sample(qids, min(cfg["count"], len(qids))) for qid in selected: pq = PaperQuestion( paper_id=paper.id, question_id=qid, score=cfg["score"], ordering=order ) db.session.add(pq) order += 1 total_score += cfg["score"] paper.total_score = total_score db.session.commit() return paper.id注意这里分数是试卷快照里定义的score,不是题目表里的 score,因为同一道题在不同试卷里分值可能不同。
3.2 自动判分的完整逻辑
自动判分是考试系统最刺激的一环,也是踩坑重灾区。大家最容易忽视的是两种特殊场景:多选题漏选怎么算,判断题选了空字符串怎么处理。
我的判分逻辑如下:
- 单选:用户答案与标准答案完全一致。
- 多选:用户答案需要和标准答案完全一致,少选、多选都不得分。有些系统支持部分给分,我是按全对给分处理的,逻辑更简单明确。
- 判断:答案转换为 "A"/"B" 再比对,避免前端传 "true"/"false" 造成混乱。
- 填空:先做 trim 去空格,再逐空精确比较。
判分函数如下:
def grade_question(q, user_answer): correct = q.answer # 例如多选 "ABC" if q.qtype == "single" or q.qtype == "judge": return 1 if user_answer and user_answer.strip() == correct else 0 elif q.qtype == "multi": if not user_answer: return 0 return 1 if sorted(user_answer) == sorted(correct) else 0 elif q.qtype == "fill": # 每个填空用 || 分隔 if not user_answer: return 0 user_parts = [p.strip() for p in user_answer.split("||")] correct_parts = [c.strip() for c in correct.split("||")] return 1 if user_parts == correct_parts else 0 return 0通过这一版,一个收获是一定要在前端对选项顺序做统一化处理,比如多选选项都用ABCD这样的字符做 key,这样后端才不用管排列问题。
真正提交判分的流程是:用户点击交卷时,后端从 Redis 里取出答题记录,逐题比对,统计得分,一次事务里把exam_records和record_details写入数据库。全程不调用第三方判题机,速度非常快。
3.3 在线答题的记录与防作弊设计
在线答题期间,前端每隔 20 秒向后端发送一次心跳,后端把答案 JSON 存进 Redis。中途如果浏览器崩溃、断网,恢复页面前端首先调用/api/exam/paper/:id获取空卷子,再调用恢复接口拿到最近的答案快照,继续答题。这个小设计挽救了很多人因为断电差点丢失的答题状态。
防作弊我做了三层:
- 切屏检测:前端监听
visibilitychange,超过两次切出标签页就触发警告,同时把事件上报到后端记录。后端如果发现次数超过设定值,可以选择强制交卷。 - 复制粘贴禁用:考试页面禁掉复制、粘贴、右键菜单,虽然不能完全杜绝,但能挡住大部分新手作弊。
- IP 与账号绑定:同一场考试同一个账号只能在固定 IP 上答题,防止账号共享。实际用下来这一条最有效,不过遇到同一宿舍共用公网 IP 的场景容易误伤,要不要开取决于网管和学校网络架构,建议开会时让业务方自己定。
后端上报切屏事件的接口,我用一个简单装饰器冻结了同一考试的答题链路,防止并发提交导致的数据错乱。经验是,防作弊功能不要写得太重,做过头会影响学生考试体验。
4. 前端核心页面实现
4.1 Vue 工程搭建与路由设计
前端工程基于 Vue 2 + Vuex + Vue Router + Element UI。没有用 Vue 3 的原因很朴素:Element UI 对 Vue 2 的生态最成熟,团队熟悉,线上跑得稳;如果现在让我重新选,可能会换 Vue 3 + Ant Design Vue,但如果是从零开始且有人能接手,Vue 2 也完全够用。
路由设计分了两个模块,管理端和考生端,登录之后根据角色动态加路由:
const routes = [ { path: "/login", component: Login }, { path: "/", component: Layout, children: [ { path: "/admin/exams", component: ExamList, meta: { role: "admin" } }, { path: "/admin/questions", component: QuestionMgr, meta: { role: "admin" } }, { path: "/admin/paper-rule", component: PaperRule, meta: { role: "admin" } }, { path: "/student/exams", component: StudentExamList, meta: { role: "student" } }, { path: "/student/exam/:id", component: ExamRoom, meta: { role: "student" } }, ]} ]这里最坑的是刷新页面后动态路由丢失。我在 router.beforeEach 判断,如果用户信息里有角色但路由没初始化,就先调initRoutes()后next({...to, replace: true}),否则直接 next。这个坑很多人第一次写 SPA 都会踩,建议一开始就把路由初始化逻辑写在登录成功和页面刷新的公共方法里。
Axios 封装也是一样的套路:响应拦截器统一处理 401 过期,弹提示并跳到登录页;请求拦截器统一加 Token 和超时时间。我把所有业务接口都收敛到前端/api目录下,避免把请求散落在页面代码里。
4.2 在线考试倒计时与自动交卷
这个页面是整个系统体验最核心的一页,做好了给考生的感觉是"这系统真专业",做不好就是"答题体验极为糟糕"。
倒计时机制不能用前端setInterval累加秒数,原因是如果用户切走页面,浏览器处于后台时定时器会被节流,时间会不准。正确做法是基于服务端下发的考试截止时间,用本地时钟动态计算剩余秒数。前端拿到截止时间戳,每秒重新计算剩余时间,渲染到页面上,这样即使切页回来,时间依然是准的。
this.deadline = Date.now() + remainingSeconds * 1000; this.timer = setInterval(() => { const remain = Math.floor((this.deadline - Date.now()) / 1000); if (remain <= 0) { clearInterval(this.timer); this.handleAutoSubmit(); // 自动交卷 } else { this.remainTime = remain; this.$store.commit("exam/save", this.paper); } }, 1000);自动交卷调用的就是提交接口,没有额外提醒,和正常交卷走同一套逻辑。唯一区别是提交前前端会把剩余答案强制提交一次,保证后端能拿到最新快照。
这里曾经有个很隐蔽的 bug:火狐浏览器切到其他 Tab 时,setInterval会被放松到每分钟只跑一次,倒计时会短暂滞后,但当用户切回来时会瞬间回到正确数值。只要用户切回来,基本不会误交卷,但如果是考试中途长时间切页,就会迟交。由于我在循环里同步快了,这个 bug 实际影响不大。
4.3 答题卡与试卷渲染
答题卡组件是主要提体验的地方。
试卷渲染用 CSP 数据进行:
<template> <div class="question-item" v-for="(q, idx) in questions" :key="q.id"> <div class="q-title"> {{ idx + 1 }}. {{ q.content }} <span v-if="q.qtype === 'multi'">(多选题)</span> </div> <el-radio-group v-model="answers[q.id]" v-if="q.qtype === 'single'" @change="saveAnswer"> <el-radio v-for="opt in q.options" :label="opt.key">{{ opt.label }}</el-radio> </el-radio-group> </div> </template>这里要关注两个边界情况:
第一,多选的 v-model 绑定必须是数组answers[q.id] = [],不能直接用字符串变量以为会自动收集,否则一旦题干包含中文选项 key,判断就会错乱。
第二,题目选项渲染必须是稳定的。这里的稳定是:同一道题在整场考试期间,选项中各内容的顺序不能变。有些第三方组件库默认会按 key 排序渲染,会导致考题在倒计时过程中不断闪动,极度影响体验。我用数组遍历而非对象遍历,就是为了保持选项顺序稳定。
答题卡区域是一个网格组件,每道题一个方块,已答绿色、未答红色、当前定位高亮。点击一题跳到对应题号,题号滚动到可视区域。实现时用scrollIntoView,同时给顶部留出偏移量,避免被 sticky 导航遮住。这个小细节我在图上查了很久,后来给题目容器加了scroll-margin-top: 80px一行 CSS 就解决了。
4.4 管理后台的关键页面
管理后台主要是给教务或者老师用,重点关注两个页面:考试创建页和成绩统计页。
考试创建页要支持两种组卷方式,这个交互细节非常关键:
- 手工选题:从题库里勾选题目组成试卷。
- 自动组卷:设置题型数量、分值、难度范围、知识点范围,后端生成试卷。
我做了个可视化表单:题型区间用滑块控制数量,难度用五星评分,知识点用多选树。提交接口就是前面说的generate_paper的规则对象。
成绩统计页是一个成绩表格加上简单的 ECharts 统计图。成绩表格的核心功能点是可以按班级/院系筛选,老师把 Excel 导出一套做存档。为了不引入太重框架,我直接把 Excel 导出用 60 行 JS 实现:前端把表格数据转 CSV,再用 Blob 下载。兼容性没问题,也不用在后端生成文件,省了很多事情。
5. 部署、性能与安全细节
5.1 部署方案
后端用 Gunicorn 跑,容器里 Nginx 做反向代理和静态资源服务。前端构建完的 dist 目录直接扔给 Nginx 托管,同时通过location /api/把请求转发到 Gunicorn。
部署时最容易被忽略的是 CORS。如果 Nginx 同域名部署,前端请求走/api就不存在跨域问题,不需要配置 CORS 响应头;但如果开发环境中后端跑在 5000 端口,前端跑在 3000 端口,就必须在后端配置 CORS,否则所有的请求都被浏览器拦截。我在开发时用 Flask-CORS 扩展配置,上线时去掉跨域问题的写法,让 Nginx 统一处理,最简单。
生产环境 Gunicorn 一行命令:
gunicorn -w 4 -b 127.0.0.1:8000 app:app注意-w 4是 worker 进程数,参考 CPU 核数配置。如果试卷生成时用到了 Python 随机函数,强烈建议在系统启动时设全局随机种子,否则某些场景下并发进程抽题结果可能出现重复,我没遇到,但看别人的月报项目中间遇到过,概率不高但不好排查。
5.2 性能优化
在线考试系统在普通规模(单场 200 人以内)下性能不是大瓶颈,但有两个场景必须提前考虑:
- 考试开始瞬间的集中访问。学生在同一时间点开页面,后端大量请求同一份试卷快照,虽然查询快,但会产生不小的 DB 压力。我做了两个优化:参与考试的试卷详情在 Redis 里缓存整份 JSON,设置考试开始时间上浮 5 分钟失效;同时提前批量生成试卷快照到 Redis,避免考场上再去实时查库组卷。
- 成绩导出时的内存占用。一次导出全年级成绩可能出现上百万行,不能全查出来转 JSON 爆内存。我用流式查询,边查边写 CSV,减少一次性内存使用。
5.3 安全检查清单与建议
安全是一个系统能不能上线的生命线。我最后交给上线同学之前,按照一个清单过了一遍:
- 密码存储:绝不能明文存密码,用 Werkzeug 自带的
generate_password_hash和check_password_hash。 - Token 过期:JWT 过期时间设置为 12 小时,管理端可以设置更短,比如 4 小时。
- SQL 注入:全程用 SQLAlchemy 参数化查询,严禁拼接 SQL。
- 敏感数据:不要在日志里打印完整的用户答案和 Token,我见过同事在接口里为了排查 bug 直接把 Body 原样打出来,结果 vc 出去了。
- 传输安全:生产环境强制 HTTPS,WebSocket 也走 WSS,否则被中间人抓包直接能被考出试卷答案。
这些边界问题和页面功能不同,很难在测试阶段全暴露,但上线后如果出问题往往就是大问题。
6. 常见问题与实战排查记录
6.1 高频问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 登录后刷新页面白屏 | 动态路由未初始化 | router.beforeEach 中重新初始化路由并重定向 |
| 考试倒计时忽快忽慢 | 定时器被浏览器节流 | 改用截止时间戳差值计算,而不是秒数累加 |
| 自动交卷后成绩缺失 | Redis 快照未落库 | 交卷接口先取 Redis 数据再写库,保证最后一份快照被提交 |
| 随机组卷题目数量不符 | rand() 抽重了 | 使用random.sample(ids, count)确保不重 |
| 多选答案怎么判断都对不上 | 选项顺序被前端排序 | 后端以选项 key 比对,前端保持数组顺序稳定 |
| 切屏检测误报 | 页面 title 闪烁也算切屏 | 只监听visibilitychange,title 闪烁不触发上报 |
这个表我每次做完考试类系统都会更新,基本上是实战经验集合,很多小问题都要用日志和时间戳去对比排查,不能只靠看代码。
6.2 两个印象深刻的 BUG 排查
第一个 bug:并发倒计时导致双重交卷。考试结束瞬间,前端组件里有两个地方同时触发交卷:setInterval 的 0 哨兵和按钮点击重置的倒计时顺延触发,导致后端收到两条提交请求。如果不加锁,成绩会写两条,统计表直接崩。排查了很久,后来在提交接口用 Redis SETNX 做了分布式锁,保证同一考试同一用户只能提交一次,才算彻底解决。
第二个 bug:数据库备份时间与考试重叠。因为我在考试期间直接写 exam_records 大表,备份任务恰好拖慢了查询,200 人的交卷瞬间全部 5xx。后来把备份任务的时间和考试时间错开,并把 Redis 快照作为读取优先,问题消失。这个坑提醒我,在线考试这种峰值型业务,一定要把数据库并发峰值和运维任务的时间窗口错开,这种经验在压测时很难发现,只有真实考试时才会暴露。
我自己的经验是,在线考试系统这类项目,做得久了会发现它其实是一个高并发读、低并发写的小型业务,技术挑战不在算法,而在各种边界情况和数据的最终一致性。正确的心态是别追求完美,关键是先把核心链路打扎实。
整套系统从数据库设计到前端考试房间页面的打磨,前后花了大约四周时间,其中一半时间在调组卷规则和自动交卷的细节。如果你也要做类似的系统,建议你先跑通一条最短链路:创建考试、随机组卷、学生答题自动判分、结果展示,完成这件事以后,再去考虑防作弊、断点续考、成绩导出这些进阶功能。以一个能跑通的核心版本为基础去迭代,效率会高出很多。