简介:这份基于Python的智慧校园考试系统程序源码,面向高校及中小学信息化建设者、Python Web开发学习者,旨在实现用户管理、试题库维护、考试创建、在线答题、自动评分与成绩统计等一体化考试流程。资源包共2000个文件,体积约45MB,以1791个Python源码文件为主体,辅以1113个po/mo国际化语言文件、141个HTML页面、111个JavaScript脚本、128个Tcl脚本及53个说明文档,覆盖后端逻辑、前端交互、多语言适配与部署配置等多个层面。目前已有412人学习浏览。源码包含详细注释,清晰展示了Flask/Django框架下的Web应用架构、ORM数据库操作、身份认证与权限控制、随机组卷及成绩分析等关键实现思路,并涉及Ajax实时通信、Pandas/Matplotlib数据可视化等进阶技术。对于正在完成课程设计、毕业设计或想深入理解Python Web项目工程化结构的开发者而言,是一份便于阅读、调试和二次扩展的实用参考。
1. 基于Python的智慧校园考试系统:先跑通这套源码,再谈二次开发
接手一套基于Python的智慧校园考试系统源码时,我最关心的不是功能列表,而是能不能三小时跑起来、改起来顺手。这套系统走的是传统Web开发路线:Flask/Django全家桶、模板渲染、ORM管数据库、内置三角色权限和防作弊策略,对学校教务场景来说够用且可控。适合三类人:正在学Python Web开发、想拿完整项目练手的python入门者;需要快速搭考试平台的教务老师;以及要基于源码二开做毕设或课设的开发者。如果你想真正把试卷、答题、评分、统计这条链路吃透,这份源码值得下下来逐行读。
2. 从源码目录看架构:Flask 蓝图与数据库表是怎么对应的
拿到压缩包第一件事,别急着装依赖,先把目录摊开看一遍。这套源码的目录结构基本决定了你后面所有改动的落点:哪里加字段、哪里加接口、哪里换前端样式,都跟目录组织方式强相关。
2.1 一份 CSS 文件清单暴露了前端组织方式
源码的文件列表里出现了style.css、bootstrap.min.css、responsive.css、select2.css、base.css、select2.min.css、widgets.css、forms.css这一串静态文件。这说明前端不是 React/Vue 那种前后端分离的单页应用,而是经典的服务端模板渲染:后端把数据填进模板,浏览器直接渲染 HTML,CSS 负责样式。select2.css和select2.min.css是下拉框增强组件,大概率用在试题类型选择、班级选择这类表单上;widgets.css管小组件,forms.css管表单布局。
提示:判断一套 Python Web 源码是不是前后端分离,最快的方式是看 static 目录下有没有
app.js、vendor.js这类打包产物。只有 CSS 没有 JS 框架文件,基本就是模板渲染路线。
这套结构的好处是部署简单,不需要额外启动 Node 服务;坏处是页面交互刷新感强,做不到局部更新。你要改考试倒计时、答案实时比对这类功能,就得在模板里嵌 JavaScript。我一般会先在base.css里找全局颜色变量和间距规范,再动widgets.css,否则改完一个页面试卷排版会乱成一团。
2.2 用户角色与权限校验:session 和登录装饰器怎么用
摘要里提到系统有学生、教师、管理员三个角色。传统模板渲染下,最常见的实现是 session 里存用户 id 和角色,再用装饰器做权限拦截。Flask 里写起来不长,核心代码是这样:
from functools import wraps from flask import session, redirect, url_for, flash def login_required(role=None): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): user_id = session.get('user_id') if not user_id: flash('请先登录', 'warning') return redirect(url_for('auth.login')) if role and session.get('role') != role: flash('当前账号无权访问该页面', 'danger') return redirect(url_for('index.index')) return f(*args, **kwargs) return wrapper return decorator # 教师创建试卷前必须是有 teacher 或 admin 角色 @app.route('/exam/create') @login_required(role='teacher') def create_exam(): return render_template('exam/create.html')这段代码的逻辑是:先查 session 里有没有user_id,没有就跳登录页;有就再比对role,角色不匹配直接拒绝。login_required(role='teacher')这种写法把权限声明直接挂在路由上,比在每个视图函数里手写 if 判断清爽得多。你拿到源码后,第一步就是搜session['role']和@login_required,把权限矩阵画出来——哪些页面学生能进、哪些只有教师能进,这决定了系统安全边界。
参数说明:role传'teacher'或'admin',不传则只校验登录态。如果源码用的是 Django,对应写法是把@login_required和UserPassesTestMixin用在class-based view上,但拦截思路完全一致。改权限时注意 session 里存的角色名要和数据库role字段一致,大小写不一致是权限失效的重灾区。
2.3 数据库初始化与管理员账号:从 models.py 到第一条数据
跑通系统的第一步永远是建库和创建管理员。以 Flask + SQLAlchemy 为例,模型定义、建表和插入初始账号是三个独立动作,很多人把后两步混在一起写,导致二次初始化时报Table already exists。我习惯拆成两个脚本:init_db.py负责建表,seed.py负责写种子数据。
# init_db.py from app import app, db from models import User, Question, Exam, ExamRecord def init_database(): with app.app_context(): db.drop_all() # 开发期重置用,生产环境务必删掉这一行 db.create_all() print('数据表创建完成') if __name__ == '__main__':逻辑说明:db.drop_all()会清掉所有表,只在开发环境用;一旦上了生产服务器,这行代码就是事故源头。db.create_all()只创建 models.py 里定义了但还不存在的表,不会补字段——后期给表加字段必须走alembic或migrate,直接改模型再 create_all 是无效的。
创建管理员账号的核心点是密码不能明文入库。常见做法是用werkzeug.security生成哈希,权限校验时用check_password_hash比对:
# seed.py from werkzeug.security import generate_password_hash from app import app, db from models import User with app.app_context(): if not User.query.filter_by(username='admin').first(): admin = User( username='admin', password_hash=generate_password_hash('admin123'), role='admin', real_name='系统管理员' ) db.session.add(admin) db.session.commit() print('管理员账号创建成功')参数说明:generate_password_hash('admin123')默认用 pbkdf2 算法加盐,生成结果形如pbkdf2:sha256:260000$...,不是可逆的。filter_by(...).first()判断账号是否已存在,避免重复插入。你拿到源码后,先改掉默认密码再绑定域名,这是老生常谈但真有人跳过。
3. 把考试流程串起来:题库导入、随机抽题与自动评分
考试系统的核心链路是:题库 → 组卷 → 答题 → 评分。每一环都有独立的算法和存储设计,也是二开时最常动刀子的地方。这一章把四个环节拆开讲,重点说清楚表怎么设计、抽题参数怎么调、评分代码的边界条件在哪。
3.1 题库表设计:五种题型共用一个表还是分开存
选择题、填空题、解答题混在一起存还是分开建表,这是题库设计第一个要拍的板。分开建表的好处是字段精确——选择题有选项列,解答题有参考答案列,各不干扰;坏处是查询要 UNION,写代码麻烦。共用一张表配合question_type字段区分是更常见的选择,尤其是中小规模考试系统。
class Question(db.Model): __tablename__ = 'question' id = db.Column(db.Integer, primary_key=True) subject = db.Column(db.String(50), nullable=False) # 科目 question_type = db.Column(db.String(20), nullable=False) # choice / blank / essay content = db.Column(db.Text, nullable=False) # 题干 options = db.Column(db.Text) # JSON 字符串,存 A/B/C/D answer = db.Column(db.Text) # 客观题答案 difficulty = db.Column(db.Float, default=0.5) # 0.1 简单 ~ 0.9 难 score = db.Column(db.Integer, default=5) # 单题分值 created_by = db.Column(db.Integer, db.ForeignKey('user.id'))设计要点:options用 JSON 字符串存四个选项,查询时前端把 JSON 解析成数组;difficulty是浮点值,抽题权重全靠它;answer字段对客观题存精确答案,对解答题存要点,评分逻辑按question_type分流。这种宽表设计牺牲了一点范式,换来了查询和导入的便利,实战中我用过一轮,认为利大于弊。
你导入试题时,Excel 或 CSV 里的列要和这张表对齐。常见导入脚本用pandas.read_excel读文件,逐行构造Question对象,遇到重复题号就跳过。注意options列在 Excel 里如果是A:xxx|B:xxx|C:xxx|D:xxx这种格式,导入时要先 split 再json.dumps,这一步漏掉选择题渲染会直接变成空白。
3.2 随机抽题与难度权重:让每次试卷都不一样
组卷算法是教师端最高频的功能,也是源码里值得细读的一段。最简单的实现是random.sample直接随机抽取,但要控制难度就得按权重采样。常见的做法是把题目按难度分桶,比如难度 0.3 以下的算简单题,0.7 以上算难题,中等题放中间,然后每桶按比例抽:
import random def generate_paper(question_ids, total_count=20, difficulty_mix=(0.3, 0.5, 0.2)): easy_ids = [qid for qid in question_ids if qid.difficulty < 0.3] mid_ids = [qid for qid in question_ids if 0.3 <= qid.difficulty <= 0.7] hard_ids = [qid for qid in question_ids if qid.difficulty > 0.7] easy_n = int(total_count * difficulty_mix[0]) mid_n = int(total_count * difficulty_mix[1]) hard_n = total_count - easy_n - mid_n selected = ( random.sample(easy_ids, min(easy_n, len(easy_ids))) + random.sample(mid_ids, min(mid_n, len(mid_ids))) + random.sample(hard_ids, min(hard_n, len(hard_ids))) ) random.shuffle(selected) return selected逻辑说明:先按难度阈值把题库分成三个桶,再按difficulty_mix计算每桶要抽多少题。min(easy_n, len(easy_ids))是保护逻辑——题库里简单题不够 6 道时,有多少抽多少,不会抛ValueError。最后shuffle打乱顺序,避免固定题型位置被考生摸出规律。
参数说明:difficulty_mix=(0.3, 0.5, 0.2)表示简单题占 30%、中等题 50%、难题 20%,这组参数适合期末测试。竞赛或补考场景可以把难题比例调到 0.4。注意桶里题目数不够时,random.sample抽取数量小于期望值,试卷总题数会缩水——你需要在组卷接口里做一道校验:len(selected) < total_count时给前端返回「题库题目不足,请补充题目」的提示,而不是硬生成一张缺题试卷。
3.3 自动评分:客观题精确匹配与填空题模糊判分
客观题评分没有悬念,比对答案字符串即可;填空题开始有玄学成分——学生答案和标准答案可能存在空格、全半角、序号前缀这些差异。评分代码至少要做三层清洗,否则误判率会让你怀疑人生。
import re def normalize_answer(text): if not text: return '' text = str(text).strip() text = re.sub(r'[\s\u3000]+', '', text) # 去掉空格和全角空格 text = text.replace(',', ',').replace('。', '.').replace(';', ';') text = text.upper() # 统一大写 return text def score_blank(student_answer, standard_answer): if not student_answer or not standard_answer: return 0 if normalize_answer(student_answer) == normalize_answer(standard_answer): return 1 # 容错:标准答案包含多个可接受答案时用 | 分隔 for candidate in standard_answer.split('|'): if normalize_answer(student_answer) == normalize_answer(candidate): return 1 return 0逻辑说明:normalize_answer做四层清洗——去首尾空格、去除所有空格和全角空格、统一标点、统一大写。这样" 北京 "、"北京"、"北京市"会被正确区分,"Beijing"和"BEIJING"不会误判。第二层用|分隔多个可接受答案,比如「鲁迅|周树人」,学生写哪个都算对。
参数说明:split('|')的分隔符要和录入题库时约定一致,录入时用中文顿号「、」分隔的话这里就要改成split('、')。解答题没法用字符串比对,常见方案是关键词命中计分:把参考答案拆成关键词数组,命中一个累加一次分值,直到加满单题分值。这种评分有争议但实现简单,源码里如果看到keywords字段,多半就是这个逻辑。我建议你把清洗函数抽成公共工具,因为判分逻辑不仅评客观题用,成绩导出和查重也用。
4. 成绩统计与导出:从聚合查询到可视化报表
考完试之后,教务老师最关心的是成绩单和统计图表。这一章解决三个问题:怎么一次性把平均分、排名、及格率算清楚;怎么出图;以及前端要展示数据时,接口应该返回什么格式。
4.1 一次查清平均分、排名与及格率:聚合查询的写法
初学阶段容易犯的错误是循环里查数据库——遍历每个学生查一次成绩,数据量一上去接口就卡死。正确的姿势是用 SQLAlchemy 的聚合函数一把梭:
from sqlalchemy import func def get_subject_stats(exam_id, subject): result = db.session.query( func.avg(ExamRecord.score).label('avg_score'), func.max(ExamRecord.score).label('max_score'), func.min(ExamRecord.score).label('min_score'), func.sum(func.case( (ExamRecord.score >= 60, 1), else_=0 )).label('pass_count'), func.count(ExamRecord.id).label('total_count') ).filter( ExamRecord.exam_id == exam_id, ExamRecord.subject == subject ).first() stats = { 'avg_score': round(result.avg_score or 0, 1), 'max_score': result.max_score, 'min_score': result.min_score, 'pass_rate': round(result.pass_count / result.total_count * 100, 1) if result.total_count else 0 } return stats逻辑说明:func.avg、func.max、func.min都是 SQL 聚合函数,一次查询返回统计结果,不用在 Python 里做循环。func.case是关键——把score >= 60的记为 1,否则为 0,sum出来就是及格人数。pass_rate计算前先判total_count是否为 0,避免除零异常。
参数说明:60是及格线,不同学校可能用 72 或自定义标准,建议把及格线提成配置文件里的常量,而不是散落在各查询里。排名用窗口函数更高效:func.rank().over(order_by=ExamRecord.score.desc()),SQLAlchemy 1.4 之后支持over()写法,老版本要用text()包裹原生 SQL。你拿到源码后搜func.rank或row_number就能定位排名实现。
4.2 用 Matplotlib 生成班级成绩分布图:后端出图 vs 前端图表
成绩分布图有两种出法:后端用 Matplotlib 生成 PNG 直接渲染到模板,或者前端用 ECharts 画。这套源码走的传统模板渲染路线,后端出图更省事。核心是把成绩列表切成 10 个分段,统计每段人数:
import matplotlib matplotlib.use('Agg') # 非交互环境必须指定,否则报 MissingDisplayError import matplotlib.pyplot as plt def plot_score_distribution(scores, output_path): bins = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100] labels = ['0-10', '11-20', '21-30', '31-40', '41-50', '51-60', '61-70', '71-80', '81-90', '91-100'] counts, _ = np.histogram(scores, bins=bins) plt.figure(figsize=(8, 5)) plt.bar(labels, counts, color='#4C72B0') plt.xlabel('分数段') plt.ylabel('人数') plt.title('成绩分布图') plt.savefig(output_path, dpi=120, bbox_inches='tight') plt.close()逻辑说明:matplotlib.use('Agg')是后端出图的必备行——服务器没有显示器,不指定 Agg 后端会报错。np.histogram把成绩按 10 分一段分箱,labels是分箱的显示名。plt.close()必须放在最后,否则循环出多张图时内存持续上涨,跑一天任务后进程直接 OOM 被杀。
参数说明:bins的边界是左闭右开,[0, 10]包含 0 不包含 10,所以 10 分会被分到第二段,这是 Matplotlib 的固定行为,做图例说明时要注意。dpi=120是清晰度参数,打印版建议 150,网页用 120 足够。你如果发现图里中文显示成方块,是因为系统缺中文字体,需要plt.rcParams['font.sans-serif'] = ['SimHei']或者在后端服务器安装字体,这是最常见的出图翻车点。
4.3 给前端供数据的 JSON 接口与模板渲染取舍
成绩页面如果只是展示少数几个数字,模板渲染直接传对象就行;如果要做一个课程成绩对比的折线图,前端需要的是结构化 JSON。两种方式在 Flask 里切换很直接:
@app.route('/api/exam/stats/<int:exam_id>') def api_exam_stats(exam_id): exam = Exam.query.get_or_404(exam_id) stats_list = [] for subject in exam.subjects: s = get_subject_stats(exam_id, subject) s['subject'] = subject stats_list.append(s) return jsonify({'code': 0, 'data': stats_list}) # 模板渲染版本 @app.route('/exam/report/<int:exam_id>') def exam_report(exam_id): exam = Exam.query.get_or_404(exam_id) stats_list = [] for subject in exam.subjects: s = get_subject_stats(exam_id, subject) s['subject'] = subject stats_list.append(s) return render_template('exam/report.html', stats=stats_list)逻辑说明:两个路由逻辑一样,区别只在返回形式——jsonify输出 JSON 给前端fetch调用,render_template把数据塞进模板直接输出 HTML。接口路径带/api/前缀是一种约定,方便后面用 Nginx 单独分流。返回结构统一用{'code': 0, 'data': ...},code=0表示成功,非 0 表示业务异常,前端判断逻辑简单。
参数说明:get_or_404处理考试不存在的情况,比手动if not exam: abort(404)少一行字但语义相同。JSON 接口注意中文不要用ensure_ascii=False,Flask 的jsonify默认已经处理了。模板版本里,我会用 Jinja2 的for循环渲染统计表格,记得给数字做| round(1)过滤器,否则平均分可能显示成一长串浮点。
5. 避坑与排查:部署这套考试系统最容易翻车的五个地方
这套系统我前后部署过三回,每次都在不同位置踩坑。下面五条都是真实遇到过的,按「现象 → 原因 → 解决」写成排查手册,你复现时大概率能撞上一两条。
5.1 中文乱码:数据库和页面一起变「锟斤拷」
现象:题库导入后,前端页面题目内容全变成锟斤拷或���,数据库里查出来也是乱码。
原因:数据库连接串没指定 utf8mb4 字符集。MySQL 默认的latin1不支持中文,Python 侧传进去的 UTF-8 字符串被 MySQL 转成 latin1 存储,取出来就是乱码。
解决:改数据库连接串,加charset参数。SQLAlchemy 的写法是:
db_uri = 'mysql+pymysql://root:password@localhost/exam_db?charset=utf8mb4' app.config['SQLALCHEMY_DATABASE_URI'] = db_uri同时确认 MySQL 建库语句带了字符集:CREATE DATABASE exam_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci。两个地方都要改,只改一边还是会乱。改完重启服务后,以前写入的乱码数据只能清洗重建,没有增量修复的办法。从那以后我每次建测试库都先执行一条SHOW CREATE DATABASE exam_db;,看到utf8mb4才继续。
5.2 登录状态总丢失:session 过期与跨域 Cookie
现象:测试账号登录后,打开几个页面就自动跳回登录页;有的页面能保持登录,有的页面不行。
原因:Flask 的 session 默认存在浏览器 Cookie 里,有几个可能:一是SECRET_KEY重启后变了,导致旧 session 失效;二是部署时 Nginx 反代没配置 Cookie 透传;三是服务端设置了SESSION_COOKIE_SECURE=True,但测试用的 HTTP 协议不传 Cookie。
解决:排查顺序是先在config.py里看SECRET_KEY是不是写死了。开发场景直接固定为常量,生产环境至少用环境变量注入,但不要设置成每次启动随机生成,否则每次重启所有用户都要重新登录。再检查 Nginx 配置:
proxy_set_header Cookie $http_cookie; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;如果走 HTTPS 且浏览器跨域访问,还要显式设置:
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['SESSION_COOKIE_SECURE'] = False # 生产 HTTPS 下改 TrueSAMESITE='Lax'允许同站点跨路径的 Cookie 发送,解决前端用了独立域名导致 session 不携带的问题。注意SESSION_COOKIE_SECURE在纯 HTTP 环境下必须为False,否则 Cookie 直接被浏览器丢弃。
5.3 页面样式全裸奔:static 目录路径对不上
现象:页面 HTML 结构正常,但所有 CSS 都不加载,控制台报404 GET /static/style.css。
原因:Flask 默认静态目录是项目根下的static/,如果源码文件列表里的style.css等文件被放在static/css/子目录,而模板里写的是{{ url_for('static', filename='style.css') }},路径就拼错了。
解决:先看模板里怎么引用静态文件,再对照实际目录。常见修复是加一层子目录前缀:
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">如果你用的是 Django,路径前缀是{% static 'css/style.css' %}并且要确认STATICFILES_DIRS配置了源码实际目录。这个坑为什么叫玄学,是因为本地开发时 Flask 自动服务/static/下的文件不报错,一旦用 Nginx 托管静态文件,前缀不一致的问题才会暴露。
5.4 自动评分翻车:浮点误差和空答案
现象:明明答案一致,自动评分却判为 0 分;填空题空着不填,后端报 500 错误。
原因:浮点评分时,score字段是Float,答案匹配后累加可能出现0.30000000000000004这种精度误差,四舍五入到 2 位小数时波动出问题。空答案报错是评分函数没有做None值保护,直接调用了.strip()方法。
解决:判分代码里统一加空值保护和 Decimal:
from decimal import Decimal, ROUND_HALF_UP def safe_compare(student, standard): if not student or not standard: return False return normalize_answer(student) == normalize_answer(standard) def accumulate_score(total, delta): return float(Decimal(str(total + delta)).quantize( Decimal('0.10'), rounding=ROUND_HALF_UP ))逻辑说明:not student同时覆盖了None、空字符串和纯空格字符串,避免.strip()炸在空值上。Decimal(str(total + delta))先用字符串构造 Decimal 避免二进制浮点误差,quantize(Decimal('0.10'))保留一位小数。评分数据量大时别逐行算浮点,最终成绩写入前统一做一次四舍五入更稳。
5.5 防作弊的 IP 限制误伤同考场:阈值怎么调
现象:同一考场几十个学生用同一个出口 IP,答题提交时系统提示「操作过于频繁,请稍后再试」,正常考生被当成作弊拦截。
原因:源码里用 IP 加时间窗口限制答题频率,同一 IP 5 分钟内答题提交超过 60 次这类阈值是单机开发场景写的,没考虑学校机房 NAT 出口统一的情况。
解决:先找到限流配置项,改成针对用户ID + IP的组合维度,并对同一 IP 的阈值放宽。常见写法是用 Redis 计数,但小项目没有 Redis 时,直接查数据库也可以:
MIN_INTERVAL_SECONDS = 3 # 单用户答题间隔 MAX_SUBMITS_PER_IP_PER_HOUR = 300 # 同 IP 每小时提交上限,NAT 环境放宽 def check_submit_throttle(user_id, ip): recent = ExamRecord.query.filter_by( user_id=user_id ).order_by(ExamRecord.submit_time.desc()).first() if recent and (datetime.now() - recent.submit_time).seconds < MIN_INTERVAL_SECONDS: return False, '提交过快,请间隔 3 秒再试' ip_count = ExamRecord.query.filter( ExamRecord.ip == ip, ExamRecord.submit_time > datetime.now() - timedelta(hours=1) ).count() if ip_count >= MAX_SUBMITS_PER_IP_PER_HOUR: return False, '当前网络提交次数过多' return True, 'ok'参数说明:MIN_INTERVAL_SECONDS=3针对单个用户、MAX_SUBMITS_PER_IP_PER_HOUR=300针对共享 IP。单机房一个出口 IP 一小时内正常提交量在 100~200 次之间,300 是留了余量的值;如果学校有多个机房共用出口,还要再往上调。这个阈值调高了,作弊识别确实会变弱,但考试系统的第一优先级是别误伤正常考生,检测作弊靠的是题目乱序和答题时间分析,IP 频率只能当辅助信号。
6. 进阶验证:用手跑一遍完整考试数据流,确认系统靠得住
部署完成只是开始,真正要确认系统可靠,得把「创建账号 → 录入题库 → 组卷 → 学生答题 → 自动评分 → 成绩导出」整条链路完整跑一遍。这一章给你一套可操作的验证方案,照着做就能发现问题。
6.1 造一份 20 题测试题库,验证抽题与评分的正确性
先写个测试脚本,往题库批量插入 20 道题,覆盖四种题型和三个难度区间:
# create_test_data.py # 用法: python create_test_data.pyfrom app import app, db from models import Question import random with app.app_context(): subjects = ['语文', '数学', '英语'] types = ['choice', 'blank', 'essay'] for i in range(20): q = Question( subject=random.choice(subjects), question_type=random.choice(types), content=f'测试题目第{i+1}题', options='["A. 选项A", "B. 选项B", "C. 选项C", "D. 选项D"]', answer='A' if random.random() > 0.3 else 'B', difficulty=round(random.uniform(0.2, 0.8), 2), score=5 ) db.session.add(q) db.session.commit() print('测试题库写入完成')插入后调用generate_paper连续生成 5 份试卷,检查每题是否为question_type对应格式、难度分布是否符合difficulty_mix。这一步验证的是组卷算法不会在极端数据下报错。
6.2 模拟 50 个并发请求,看在线答题接口扛不扛得住
用 Python 自带库压一下接口,不需要上 JMeter:
python -c " import threading, requests, statistics url = 'http://localhost:5000/api/submit_answer' def submit(): r = requests.post(url, json={'question_id': 1, 'answer': 'A'}, timeout=5) return r.status_code results = [] threads = [] for _ in range(50): t = threading.Thread(target=lambda: results.append(submit())) t.start() threads.append(t) for t in threads: t.join() print('状态码分布:', {code: results.count(code) for code in set(results)}) "逻辑说明:50 个线程同时向答题接口发请求,统计状态码分布。如果出现大量 500,优先看数据库连接池——SQLite 默认并发写锁会导致database is locked,生产环境要切 MySQL 并调大连接池;如果出现超时,重点看事务是不是没及时提交。并发压测响应时间比功能测试更能暴露架构短板,值得跑。
6.3 数据流闭环检查清单
压测通过后,按清单逐项人工核验:
| 检查项 | 预期结果 | 失败排查方向 |
|---|---|---|
学生提交答案后ExamRecord是否有记录 | 每条提交对应一条记录,答案字段完整 | 检查事务提交逻辑 |
| 客观题评分结果是否正确 | 正确率与人工判分一致 | 检查normalize_answer清洗规则 |
| 成绩导出 Excel 是否含所有列 | 学号、姓名、科目、成绩都在 | 检查 Pandas 列名映射 |
| 教师端成绩统计页是否显示通过率 | 数字与数据库查询结果一致 | 检查pass_rate除零保护 |
我从那次被压测打崩的部署里学到的教训是:考试系统的成绩核验必须用真实题目走一遍,而不能只看接口返回 200。从那以后,我每次拿到新源码都强制走完「录题 → 组卷 → 答题 → 导出」整条数据流,再谈上线。高考强基计划那种重要考试更是如此——数据对了,功能才真的对了。希望这份拆解帮到你。
本文还有配套的精品资源,点击获取