1. 起点:为什么放着现成系统不用,非要自己写一个在线答题平台
当培训机构的老师找我,问能不能搭一个“学生模拟考试答题练习在线学习平台”时,我的第一反应是:市面上现成的考试系统多得是,为什么要用 Python Flask 自己写一个?等真正用了才明白,给一个班、一个年级做日常模拟练习,那些商用系统的功能冗余、权限复杂、价格不低,反而一个轻量化、能本地部署、题库可以完全自己掌控的小平台最实用。
这篇文章就聊聊我是怎么从零搭出一个基于 Flask 的在线模拟考试答题平台,从数据库设计、自动组卷逻辑到部署上线的完整实操过程。适合三种人看:一是正在学 Python Flask 但不知道做什么项目的学生;二是想给自家孩子或班级做在线练习题的老师;三是接外包想快速交付轻量化学习平台的开发者。这项目不需要多高的并发量,不需要分布式,一台普通电脑或者小服务器就能跑起来,后期的维护成本也足够低。
先说整个平台的核心功能:学生注册登录后可以看到老师维护好的题目,系统按知识点和难度自动组成一套模拟卷,学生在线作答,客观题自动判分,主观题留人工批改的口子。考完以后能看到得分、正确率,还能把错题自动收进错题本,方便后续反复练习。老师端则负责录题、维护题库、查看班级成绩统计。这些功能听上去复杂,但用 Flask 搭起来一个不落下,关键是要把表结构和路由规划好。
2. 技术选型:Flask 凭什么够用
2.1 不用 Django 和 Spring Boot 的理由
我见过太多人一上来就想上重型框架,其实完全没必要。考试答题这种场景,典型的读多写少,用户量在几百人级别,QPS 压力几乎可以忽略。Flask 的优点是“轻”——没有内置的强制结构,路由、模板、ORM 都是即插即用;缺点是“自由的代价”——很多功能要自己组合,但这恰好适合学习项目和小型交付。
对比一下:
- Django:自带 Admin 后台、ORM、表单、认证,开发效率高,但目录结构重,对初学者不友好,很多代码永远用不上。
- Spring Boot:适合企业级,JVM 生态成熟,但环境搭建重,写一个小考试系统属于高射炮打蚊子。
- Flask:代码量最精简,逻辑一目了然,一个人维护完全够用,调试也容易。
技术栈我定为:Flask 2.x + SQLAlchemy + SQLite + Jinja2 模板 + Bootstrap 前端,不使用单独的前后端分离,主要是考虑到这套系统要能方便本地部署,一个 Flask 进程直接渲染页面,学生打开浏览器就能用,省掉了 Node 构建和跨域问题。
2.2 整体架构与模块划分
这个项目我最满意的地方不是某个功能写得有多花哨,而是模块拆得很干净。按功能拆成五个模块:
- 用户模块:注册、登录、权限区分(学生/老师两种角色)
- 题库模块:题目的增删改查,按学科、知识点、题型、难度分类
- 组卷模块:根据规则自动抽题,生成模拟卷
- 答题与判分模块:提交答案、对客观题进行自动比对,主观题记录答案等待人工评分
- 学习记录模块:成绩统计、错题本、练习历史
3. 数据库设计与核心表结构
很多新手做 Flask 项目,先把 model 写一堆,然后发现字段对不上业务逻辑,只能反复改表。我的经验是:先画业务流程图,再定表结构。下面是这个平台最终稳定下来的核心表,每一张都对应一个明确的业务动作。
3.1 用户表 user
学生和老师都放这一张表,用 role 字段区分。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| username | String(50) | 用户名,唯一 |
| password_hash | String(128) | 密码哈希,不存明文 |
| role | String(10) | admin 或 student |
| real_name | String(50) | 姓名 |
| class_name | String(50) | 班级,方便统计 |
| created_at | DateTime | 注册时间 |
密码存哈希是底线,我用的 Werkzeug 自带的generate_password_hash和check_password_hash,不需要额外装 bcrypt,省事。
3.2 题目表 question
题目是平台的核心资产,字段设计一定要考虑组卷和检索的需求。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| subject | String(50) | 学科,比如数学、英语 |
| chapter | String(100) | 所属章节/知识点 |
| question_type | String(20) | 单选、多选、判断或简答 |
| difficulty | Integer | 难度系数,1到5 |
| content | Text | 题干内容 |
| option_a~option_d | String(200) | 选项内容 |
| answer | String(10) | 正确答案,主观题存关键字 |
| analysis | Text | 题目解析,考完回显给学生 |
| created_by | Integer | 录入老师 id |
题目一旦删掉,历史试卷的数据就乱了。所以实际项目中我加了is_active字段做软删除,列表查询默认只显示还在启用的题。
3.3 答卷与答题记录表
模拟考试不是只显示一个总分,还要保存学生每道题的作答情况。我拆了两张表:
paper 试卷表:记录每次模拟考试的基本信息。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| student_id | Integer | 学生 id |
| title | String(100) | 试卷名称 |
| total_score | Integer | 总分 |
| score | Float | 实际得分 |
| duration | Integer | 答题耗时,单位秒 |
| create_time | DateTime | 交卷时间 |
answer_record 答题明细表:记录每题作答结果。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | Integer | 主键 |
| paper_id | Integer | 关联试卷 |
| question_id | Integer | 关联题目 |
| student_answer | Text | 学生答案 |
| is_correct | Boolean | 是否正确 |
| score | Float | 该题得分 |
3.4 为什么不用复杂的关联表
一开始我在组卷时设计了 exam_paper 和 paper_question 两张表,想让一套试卷供所有学生共用。后来发现业务不需要这么复杂——真实场景中更常用的是“每个学生随机抽一份题”,因为要防止学生互相对答案。而且既然按学生单独生成试卷,paper 表直接关联 answer_record 就够了,省了一层查询。学习项目最忌讳表关系建得比业务还复杂,每个多余的 step 今后都会成为 bug 的来源。
4. 核心功能实现:从注册登录到一键组卷
4.1 用户注册与登录,Flask-Login 的正确姿势
Flask 虽好,但关于登录认证的坑不少。我用了flask_login,非常轻量,不用像 Django 那样背整套 auth 框架。关键配置大致是这样:
from flask import Flask, render_template, request, redirect, url_for from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user from werkzeug.security import generate_password_hash, check_password_hash app = Flask(__name__) app.config['SECRET_KEY'] = 'change-me-to-a-random-key' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///exam_system.db' db = SQLAlchemy(app) login_manager = LoginManager(app) login_manager.login_view = 'login' class User(UserMixin, db.Model): __tablename__ = 'user' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(50), unique=True, nullable=False) password_hash = db.Column(db.String(128), nullable=False) role = db.Column(db.String(10), nullable=False, default='student') 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) @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id))登录路由的逻辑不复杂,但有一个细节非常容易踩坑:一定要用login_user(user)记住用户状态,而不是手动往 session 里塞用户 id。flask_login会在 session 中管理认证状态,后续@login_required装饰器才能正常工作。以前我见过有人自己在 session 里写死session['user'] = ...,结果退出登录或者 session 过期时经常报错,最后还是要倒回来用标准方案。
注册时,注意两个检查:用户名是否重复、两次密码是否一致。表单校验别指望浏览器端那层required属性,服务端一定要重新判断,否则绕过前端就可以灌垃圾数据。
4.2 题库录入与中文相似度查重
题目录多了以后,最烦的问题是重复题。同一道选择题,老师今天录一遍,明天换个标点又录一遍,组卷的时候抽到两道内容几乎一样的题,考试质量直接下降。这个模块我在设计时加了一个用 Python 自带difflib做的简单查重,效果意外地好。
from difflib import SequenceMatcher def is_duplicate(new_content, threshold=0.85): questions = Question.query.filter_by(is_active=True).all() for q in questions: # 先把所有空白字符去掉,避免空格的干扰 a = ''.join(new_content.split()) b = ''.join(q.content.split()) ratio = SequenceMatcher(None, a, b).ratio() if ratio >= threshold: return True, q.id return False, None这个方案的好处是不需要引入任何算法库,Python 标准库自带,就能算出文本相似度。对于题干差异不大的重复题目,召回率非常高。但如果题目只有选项顺序不同,或题干做了一两点改动,difflib的阈值就抓不住,这是它的局限。想做得更准,可以上jieba分词配合 TF-IDF 计算向量余弦相似度,但对一个小型题库模块,我认为性价比不高。实用方案是查重工具返回候选列表,让老师人工确认,而不是直接禁录,既防重复又避免误杀。
4.3 自动组卷:按知识点和难度抽题的随机算法
组卷是这个平台的核心体验之一。我的需求是:每次考试生成一套题,单选题、多选题、判断题、简答题都有,且难度做成“基础题 50%、中档题 30%、拔高题 20%”的分布。这背后不能简单地random.choice,因为可能会抽到过多难题,或者某个知识点一个题都没有。
我用的是“按分层随机抽样”的思路:
def build_paper(student_id, subject, question_count=20): # 先查该学科所有启用的题目 questions = Question.query.filter_by( subject=subject, is_active=True ).all() # 按 (chapter, difficulty) 分组,保证知识点覆盖 selected = [] chapters = set(q.chapter for q in questions) # 平均每个章节分配一定题量 per_chapter = max(question_count // len(chapters), 1) for chapter in chapters: chapter_questions = [q for q in questions if q.chapter == chapter] pick_count = min(per_chapter, len(chapter_questions)) selected.extend(random.sample(chapter_questions, pick_count)) # 如果数量不够,用剩余题目补齐 if len(selected) < question_count: rest = [q for q in questions if q not in selected] selected.extend(random.sample(rest, question_count - len(selected))) # 最后按难度加权排序(只是排序,不是过滤) random.shuffle(selected) return selected这段逻辑最关键的一点是:先保证章节覆盖,再做难度平衡。很多地方的组卷做得差,就是因为一开始就按难度比例抽,抽完发现某个章节的题一个都没选上。顺序反了,效果天差地别。至于难度配比,我用了两层筛:第一层保证每章至少一道题,第二层在“不足固定数量时”用随机补题,整体上难度自然趋于均匀。对模拟练习来说,这个复杂度已经完全够用了。
组完卷之后,把题目 id 连同学号一起存储到 paper 表,并初始化 answer_record 空记录。等到学生交卷,就把作答状态写回这些记录。这个设计让“重考刷新”变得很容易:删掉旧 paper 和对应 answer_record,重新调用 build_paper 就可以。
4.4 答题页与自动判分
答题页用了表单加循环模板渲染,每一题的 name 属性是question_{id},这样提交的时候能直接通过 request.form 拿到所有答案。
判分逻辑要注意区分客观题和主观题。单选、多选、判断这三个题型可以直接比对字符串答案。多选题我会把正确答案存成A,B,C这样的逗号分隔格式,学生答案也存同样的格式,数一下交集就知道有没有选全。输出是否正确的核心代码整理成了这样:
def check_answer(question, student_answer): student_answer = (student_answer or '').strip().upper() # 判断题答错直接判负 if question.question_type == '判断': return student_answer == question.answer.strip().upper() # 多选题需要全选对才得分 if question.question_type == '多选': correct_set = set(question.answer.replace(' ', '').split(',')) student_set = set(student_answer.replace(' ', '').split(',')) return correct_set == student_set # 单选 return student_answer == question.answer.strip().upper()至于简答题这种主观题,我留了一个字段is_auto_score。当question_type == '简答'时,判分函数直接返回 False,页面显示“待老师批改”。老师在管理端看到未批改的答卷,手动给分,再回写到 answer_record.score。这个“半自动判分”设计虽然多了一个步骤,但比强行用关键词抠字眼靠谱得多。
4.5 成绩统计与错题本
交卷之后,页面要立即展示考试结果:总分、得分率、每道题的对错和正确答案解析。这个环节很多人会陷入循环查库的性能坑——成绩页写了个 for 循环,每取一道题就跑一条 SQL,20 道题就是 20 次查询。虽然 SQLite 本地支持这个压力,但代码习惯一旦坏掉,以后换 MySQL 就崩了。正确做法是 join 一把查出来:
records = db.session.query(AnswerRecord, Question).join( Question, AnswerRecord.question_id == Question.id ).filter(AnswerRecord.paper_id == paper_id).all()错题本本质上就是is_correct == False的 answer_record 关联题目表的视图,不需要额外的存储表。很多新手会单独建一个wrong_book表,每次答错就往里插一份数据,重复答题时还要做去重。我一直坚持“不再造数据,只筛选数据”的原则,错题本依然通过查询得到——一张视图而已,性能足够,逻辑还简单。
5. 部署与运行实录:从本地到服务器
5.1 本地环境与初始化运行
先把最基础的东西列清楚。如果你是第一次接触 Flask,先确认机器装了 Python 3.8 以上版本。命令行里输入python --version看看。然后按这个顺序来:
# 创建虚拟环境,隔离依赖 python -m venv venv # Windows 激活 venv\Scripts\activate # macOS/Linux 激活 source venv/bin/activate # 安装依赖 pip install flask flask-sqlalchemy flask-login # 如果要用 gunicorn 部署,linux 上再装一个 pip install gunicorn数据库初始化我用了一个init_db.py脚本,里面就两行核心逻辑:
from app import db db.create_all()注意,改 models 字段之后,db.create_all()不会自动改表结构。实际项目中我把这个升级操作做成了脚本:先sqlite3 exam.db .schema备份旧结构,再用迁移方式处理。Flask 没有 Django 那种现成的 migrate 命令,所以改动 model 前先想清楚字段是一开始就定下来最重要的事。
5.2 多进程部署:Gunicorn 踩过的坑
本地开发时直接python app.py跑内置服务器就行,但真实部署到 Linux 服务器上,Flask 自带服务器性能太弱。我用的是 Gunicorn。启动命令很简单:
# 3 个 worker 进程,监听 8000 端口 gunicorn -w 3 -b 0.0.0.0:8000 app:app坑就藏在参数配置里。-w的 worker 数不是越大越好,它要和 CPU 核心数挂钩,否则进程一多,SQLite 写锁冲突会很频繁。我踩过一次坑:四核小服务器开 8 个 worker,学生交卷高峰期数据库直接报database is locked。后来限定为 3 个 worker,问题不再出现。
如果想让前端静态文件不经过 Flask,生产环境我会在前面挡一层 Nginx,把/static目录直接交给 Nginx 托管。这里给一份最小可用的 Nginx 配置作为参考:
server { listen 80; server_name your-server-ip; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /static { alias /path/to/your/project/static; expires 7d; } }5.3 常见问题排查实录
这一节我整理了开发和上线过程中真的让不少人卡壳的问题,根据我自己的实操经验做成一张速查表。
| 现象 | 根因 | 处理方案 |
|---|---|---|
| 浏览器打开是乱码 | 字符编码不一致,页面或数据库为 UTF-8,系统默认编码为 GBK | 在 Flask 配置中设置app.config['JSON_AS_ASCII'] = False,并统一数据库连接使用charset=utf8 |
| 提交表单后弹 400 | CSRF 没关或表单 token 缺失 | 自建系统可直接关闭 CSRF,但生产环境建议使用 Flask-WTF 并开启 CSRF 保护 |
| 头像/图片加载 404 | 静态目录路径配置错误 | 确认url_for('static', filename='...')与目录层级一致 |
/admin路由谁都进得去 | 只加了@login_required,没判断角色 | 加装饰器@admin_required,内部检查current_user.role |
| 成绩统计小数点多出很多位 | Float 精度问题 | 计算时用round(score, 2),或者改用 Numeric(5,2) 类型字段 |
| SQLite 数据库 locked | 并发写事务冲突 | 切换 worker 数量为 1~3;或将 SQLAlchemy 连接池timeout调大 |
| 部署后时间慢了 8 小时 | 时区未设置 | 生成时间统一用datetime.now()后,在服务器上设置TZ=Asia/Shanghai;数据库存 UTC,页面展示时再转本地时间更稳 |
| 录入 Excel 题库乱码 | CSV 文件编码问题 | Excel 另存为 CSV 时选 UTF-8 编码,或用openpyxl直接读.xlsx |
这里额外补一个新手最容易忽略的安全项:上传题目时如果直接渲染题目里的 HTML 标签,会存在 XSS 风险。Flask 的 Jinja2 模板默认已经做了转义,但如果题目内容里有公式或富文本,建议用白名单过滤之后再渲染。我在录题入口专门做了一层bleach清洗,避免有人通过题目内容插入脚本。
6. 项目还能往哪个方向扩展
这个平台做完一轮迭代之后,我觉得如果不满足于现状,有三个特别值得加的功能方向。
第一,批量导入题库。手动一道一道录题太累了,我最终用openpyxl做了一个 Excel 导入工具,老师在表格里按模板填好学科、章节、题型、题干、选项、答案,一键导入数据库。导入前跑一遍前面写的相似度查重,把疑似重复的题标记出来,由老师决定是否忽略。这一套流程跑通之后,维护 5000 道题目的成本会低很多。
第二,学习数据可视化。成绩单不能只是数字列表,我用 Chart.js 做了几个统计图表:班级整体正确率分布、学生个人历次考试趋势、知识点掌握热力图。这些数据全部来自答题记录表,只需要一条 SQL group by 聚合就能拿到前端展示,不需要额外建表。
第三,练习模式与考试模式分离。考试模式要计时、限制切屏、交卷后不能再看答案;练习模式则不设时间,做完一题立刻给出解析,方便平时刷题。两种模式对应两套前端模板和判分逻辑,但底层复用同一套题库和记录表。这个扩展对用户体验的提升非常明显。
关于防作弊,我只提醒一句:纯前端限制切屏和倒计时都是防君子不防小人,真正的防作弊需要随机题目顺序和选项顺序,以及后台查询切屏次数。这个项目的定位是日常模拟练习,不涉及升学或认证考试,所以我没有过度设计。
7. 收个尾,说说我做完这套系统后的几点体会
如果非要总结成几句给后来人的忠告,我会说:
- 别一开始就上重型框架,Flask 这种轻量方案的开发速度和调试体验真的能让你把精力集中在业务上;
- 数据库表结构宁可多想三天,不要写完代码再改三天;
- 客观题判分和组卷算法是这类平台最核心的竞争力,其他功能都是锦上添花;
- 部署环境一定要自己在虚拟机里从头走一遍,你才知道生产环境有多少默认配置和本机不一样。
最后分享一个我在实际使用中发现的小细节:学生交卷的那一瞬其实是最脆弱的时刻,如果这时候网络抖动一下,用户会以为系统崩了。我在交卷按钮点击后加了前端禁用防重复提交,后端也做了幂等处理——同一份 paper 如果已经评分,就直接返回已有成绩,不再重复计算。就是这个不起眼的处理,让我少接了无数个“我交卷了但分数没有”的咨询。做一个学习平台,业务逻辑可以简单,但对用户体验边缘情况的考虑,一定不能省。