ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

智慧校园考试系统实战:数据库设计与Python组卷交卷实现

智慧校园考试系统实战:数据库设计与Python组卷交卷实现 简介这是一套面向高校及培训机构场景的智慧校园考试系统项目采用 Python 语言开发适合具备一定编程基础、希望了解在线考试平台设计思路的开发者。资源中包含完整的前后端源码与运行依赖覆盖题库管理、在线组卷、考试发布、自动阅卷、成绩统计等典型模块可作为毕业设计、课程实训或二次开发的参考基线。压缩包内共两千个文件其中约一千七百九十一个源程序文件承担核心业务逻辑与视图处理一百四十一个超文本文件、一百一十一个脚本文件与三十个样式文件构成前端交互界面另有多语言翻译文件、数据库脚本及部分可执行组件整个资源包大小为四十六点七八兆字节目录结构完整能够按功能模块快速定位代码。目前已有两千七百八十六人学习下载适合需要快速搭建考试系统原型、理解主流服务端框架工程化组织方式的开发者。资源还附带电子表格数据模板、图形界面图标等辅助素材可直接导入数据并运行调试有效降低从零搭建环境的时间成本。1. 智慧校园考试系统要解决的不是答题页面拿到一个“python项目——智慧校园考试系统.zip”第一反应不该是解压跑 demo而是先想清楚这套系统真正负责什么。考试系统的核心不在手机端答题页面而在考务谁有资格考、考前怎么组卷、考中怎么处理断线重连和交卷边界、考后怎么把客观题和主观题分数合到一起。你把它当成普通 CRUD 写考试当天就会在监考老师面前现场翻车。这套系统适合的场景很具体学校机房或者实验室局域网里老师管理题库、按科目随机抽题、限时考试学生登录后在同一时间批量作答最后自动算出客观题分数主观题留给老师人工判。读完这篇够你从零把这个智慧校园考试系统的主要模块在本地搭起来直接套在智慧校园项目里当考试子系统用。2. 考试系统的数据库先定“答卷”还是“试卷”很多人在建表前纠结“要不要用文档数据库存整张答卷”我的结论是别存。一张考试记录要在事后做班级平均分、题目正确率、分数段统计JSON 字段能做展示做不了聚合分析。这里的常见做法是一张宽表加两张明细表考试记录表负责“考务”答题明细表负责“每题作答”试卷题目表负责“这套卷子的题目”。从这套结构往下走后续加导出 Excel 或者做学情分析都会顺手。2.1 用户与班级采用最小模型智慧校园系统通常已经有统一身份独立考试系统不需要像素级复制一套组织架构。我这里只维护四张基础表用户表admin、监考老师、学生三类角色都放同一张、班级表、学生班级关系表、科目表。重点说一下角色字段不要用 0 和 1 表达全量权限考试系统里会出现“能进入考场但不能阅卷”的老师角色拆开比权限点更省事。CREATE TABLE user ( id bigint unsigned NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password_hash varchar(128) NOT NULL, role tinyint NOT NULL DEFAULT 1 COMMENT 1 学生, 2 监考老师, 3 管理员, student_no varchar(20) DEFAULT NULL COMMENT 学号学生必填, real_name varchar(50) NOT NULL, is_active tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;role用 tinyint 比字符串省空间查询时也方便按角色批量过滤。password_hash存的是哈希值而不是明文考试系统直接暴露在学生可访问的网段里密码问题不能省。student_no仅在学生角色上使用监考老师和管理员置空即可不需要再拆一张学生详情表。2.2 题库表的关键字段是“来源章节”而不是“所属试卷”题目要能被多套试卷复用所以题库表必须独立于试卷存在。设计题库时我在实际项目里见过最常见的错误是把paper_id直接写进题目表结果同一道题要被两套卷子引用时只能复制一份后面改题干要逐个改极其痛苦。题库表我一般只保留subject_id、type、difficulty、chapter四个检索字段。CREATE TABLE question ( id bigint unsigned NOT NULL AUTO_INCREMENT, subject_id bigint unsigned NOT NULL, chapter varchar(64) NOT NULL DEFAULT COMMENT 章节或知识点编码, type tinyint NOT NULL COMMENT 1 单选, 2 多选, 3 判断, 4 填空, 5 问答, difficulty tinyint NOT NULL DEFAULT 2 COMMENT 1 易, 2 中, 3 难, content text NOT NULL COMMENT 题干纯文本或HTML, options text NULL COMMENT 选项JSON数组非选择题为NULL, answer text NULL COMMENT 参考答案, analysis text NULL COMMENT 答案解析, score decimal(5,2) NOT NULL DEFAULT 2.00 COMMENT 单题分值, status tinyint NOT NULL DEFAULT 1 COMMENT 1 启用, 0 停用, PRIMARY KEY (id), KEY idx_subject_diff (subject_id, difficulty), KEY idx_chapter (chapter) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;chapter字段强烈不建议直接存“第三章第一节”这种中文名称因为不同科目的章节命名风格不一样导题时容易脏。我在实际项目里通常让导入模板里的章节是一个自定义编码比如chapter_id301由老师在题目管理界面维护编码含义。options在单选多选判断题里存 JSON示例为[A. 选项内容, B. 选项内容]填空和问答题则保留NULL避免写程序时还要解析空数组。2.3 试卷与题目用关联表隔离试卷表保存一场考试的基本形式比如总分、时长、标题试卷题目表保存这套卷子的具体题目顺序和当前分值。中间的关联表是必须的你不能在试卷表里存一份[12,15,22]这样的题目 ID 列表因为发布一次考试成绩后题目表里的score可能被管理员改动关联表里留一份快照才能保证已考完的卷子分数可追溯。CREATE TABLE paper ( id bigint unsigned NOT NULL AUTO_INCREMENT, subject_id bigint unsigned NOT NULL, title varchar(128) NOT NULL, total_score decimal(6,2) NOT NULL DEFAULT 0.00, duration_minutes int NOT NULL DEFAULT 60, status tinyint NOT NULL DEFAULT 0 COMMENT 0 草稿, 1 已发布, creator_id bigint unsigned NOT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci; CREATE TABLE paper_question ( id bigint unsigned NOT NULL AUTO_INCREMENT, paper_id bigint unsigned NOT NULL, question_id bigint unsigned NOT NULL, seq int NOT NULL DEFAULT 1 COMMENT 试卷内序号, score decimal(5,2) NOT NULL COMMENT 本题在试卷中的分值与题库默认分不同, PRIMARY KEY (id), KEY idx_paper (paper_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;paper_question.score才是真正参与计算的分数题目表里的score只是新建试卷时的默认值。为什么要两份因为同一道题在不同试卷里难度定位不同A 卷里是 2 分的基础题到 B 卷里可能变成 3 分的提高题。彻底分开改题库不会影响历史成绩。2.4 考试记录和答题明细决定了能不能做学情分析考试记录表里最重要的是开始时间、结束时间、状态和总分答题明细表里最重要的是题目 ID、用户答案、得分和判分状态。这里设计上有一点务必要注意明细表必须以“一次考试 一个用户 一道题”为唯一键否则并发提交时会出现两条重复记录总分翻倍。CREATE TABLE exam_record ( id bigint unsigned NOT NULL AUTO_INCREMENT, paper_id bigint unsigned NOT NULL, user_id bigint unsigned NOT NULL, exam_title varchar(128) NOT NULL, begin_time datetime DEFAULT NULL COMMENT 实际开始答题时间, end_time datetime DEFAULT NULL COMMENT 实际交卷时间, final_score decimal(6,2) DEFAULT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0 未开始, 1 答题中, 2 已交卷, 3 已判分, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_exam (user_id, paper_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci; CREATE TABLE answer_detail ( id bigint unsigned NOT NULL AUTO_INCREMENT, record_id bigint unsigned NOT NULL, question_id bigint unsigned NOT NULL, user_answer text NULL COMMENT 学生作答选择题为ABD格式, is_correct tinyint DEFAULT NULL COMMENT 1 对, 0 错, NULL 待判分, obtained_score decimal(5,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id), UNIQUE KEY uk_record_question (record_id, question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci;answer_detail里多加一个is_correct字段而不是依赖obtained_score 0判断对错是因为主观题可能出现“学生得了 0 分但老师还没判”的状态两个字段都必须独立维护。需要统计试卷里每道题的得分率时直接SUM(is_correct 1) / COUNT(*)即可不需要回表查答案。3. 随机组卷与答题卡校验的 Python 实现数据库结构定了下来看整场考试最容易被轻视的两个模块组卷算法和交卷校验。我优先写这两块因为它们直接影响“能不能开考”和“分数算不算数”。如果这两处逻辑不对前端页面做得再花哨监考老师也会在核对成绩时崩溃。3.1 分层抽题实现随机组卷常见组卷方式有三种完全随机、按章节均衡、按难度比例。智慧校园考试系统里大多数老师希望的是“看起来每章都考到”所以不要用简单ORDER BY RAND()全表抽那样很容易把题目全部抽到同一章节。正确的做法是分层分组先按章节分组再在每个分组内部随机取固定数量的题目最后按题型统一排序。import random from sqlalchemy import func from models import Question, Paper, PaperQuestion, db def build_paper_from_plan(subject_id, plan, seedNone): # plan 结构: [{type: 1, chapter: 301, count: 5, difficulty: [2, 3]}, ...] if seed is not None: random.seed(seed) paper Paper(subject_idsubject_id, title自动组卷, total_score0) db.session.add(paper) db.session.flush() # 先拿到 paper.id seq 1 total 0 for item in plan: query Question.query.filter( Question.subject_id subject_id, Question.type item[type], Question.chapter item[chapter], Question.status 1, Question.difficulty.in_(item[difficulty]), ) questions query.order_by(func.rand()).limit(item[count]).all() if len(questions) item[count]: db.session.rollback() raise ValueError(f章节 {item[chapter]} 题目数量不足) for q in questions: db.session.add(PaperQuestion( paper_idpaper.id, question_idq.id, seqseq, scoreitem.get(score, q.score), )) seq 1 total item.get(score, q.score) paper.total_score total db.session.commit() return paper.id我在flask项目里常用plan这份配置来表示组卷规则一个章节一行方便老师在前端勾选。random.seed(seed)这个参数容易被忽略但很重要同一次考试如果启用了补考补考生成的历史试卷应该和原卷保持一致这时把seed存到exam表里作为“抽题种子”保证同题同序。func.rand()在 SQLite 里也能跑只是性能比 MySQL 在十万级数据下稍差学校规模完全够用。3.2 交卷时自动校验答题卡边界许多同学的第一个考试系统交卷逻辑就是前端把整个答题卡 POST 过来后端直接存进去。这在局域网里可能没什么问题但学生误触刷新、浏览器崩溃、断网重连都会造成重复提交。我在项目中的做法是细分两个接口save_answer负责逐题暂存submit_exam负责最终交卷并锁定状态两个接口做同样的时间窗口校验。from datetime import datetime from flask import request, g, current_app app.post(/api/exam/submit) def submit_exam(): exam Exam.query.filter_by(idrequest.json[exam_id]).first() if exam is None: return {code: 404, msg: 考试不存在}, 404 now datetime.now() if now exam.start_time: return {code: 403, msg: 考试尚未开始}, 403 record ExamRecord.query.filter_by( exam_idexam.id, user_idg.user_id, paper_idexam.paper_id ).first() if record is None: return {code: 404, msg: 没有找到答卷记录}, 404 if record.status in (2, 3): return {code: 409, msg: 试卷已交重复提交}, 409 # 强制交卷超过结束时间仍可提交但标记为超时 if now exam.end_time: record.status 2 record.end_time exam.end_time record.timeout True else: record.status 2 record.end_time now score_sync calculate_objective_score(record.id) record.final_score score_sync db.session.commit() return {code: 0, score: score_sync}交卷操作必须处理“超时交卷”这个状态而不是直接返回 403。真实考场里总会有学生因为电脑卡顿没来得及点交卷此时系统应该强制保存当前答案并标记timeoutTrue后续老师在查看成绩时能看到该生超时的标签。calculate_objective_score这里只计算客观题逻辑放在 3.3 节展开。提示交卷接口必须采用“后端时间”而不是“浏览器时间”。学生改本机时间能绕过前端倒计时这个漏洞在真实考试环境出现概率极高。3.3 多选题评分规则单独发策略函数客观题评分最容易被低估的是多选的漏选场景。有的学校要求漏选不得分有的允许漏选得一半分。不要把这个规则散落在每个批改函数里把它做成一个评分策略函数参数由一个全局配置表控制老师可以在考试配置里直接切换。MULTI_SCORE_MODES { full: lambda answered, correct: 1.0, half: lambda answered, correct: 0.5 if set(answered).issubset(set(correct)) else 0.0, strict: lambda answered, correct: 1.0 if set(answered) set(correct) else 0.0, } def calculate_objective_score(record_id): details AnswerDetail.query.filter_by(record_idrecord_id).all() total 0.0 for detail in details: q Question.query.get(detail.question_id) if q.type in (1, 3): # 单选、判断题 detail.is_correct 1 if detail.user_answer q.answer else 0 elif q.type 2: # 多选题 mode current_app.config.get(MULTI_SCORE_MODE, strict) ratio MULTI_SCORE_MODES[mode](detail.user_answer, q.answer) detail.is_correct 1 if ratio 0 else 0 ratio ratio if detail.is_correct else 0.0 detail.obtained_score round(q.score * ratio, 2) elif q.type in (4, 5): # 填空、问答题留给人工 detail.is_correct None continue if q.type in (1, 3): detail.obtained_score q.score if detail.is_correct else 0 total detail.obtained_score db.session.commit() return total多选答案的比较必须用set因为前端提交的答案顺序可能和老师维护的答案顺序不一致直接用字符串比较会把选对的判成错。current_app.config的MULTI_SCORE_MODE在 Flask 里可以在创建考试时写入 Exam 表这样每场考试可以有自己的给分规则不会影响历史考试。4. 考务状态机与权限控制是系统不崩的关键考试系统翻车的大头往往不是算法是状态错乱。学生提前交卷后想返回查看管理端误删一篇进行中的考试监考老师把未开始的考试提前开放这些本质上是“状态机缺状态”导致的问题。智慧校园考试系统在这一块只有设计稳固考试当天才能不出幺蛾子。4.1 考试实例的六状态流转考试实例从创建到归档最少应该有以下六个状态。这里用的是我在实际项目里调过一版之后的状态划分最早只写了“未开始、进行中、已结束”三个状态结果无法区分“考试已结束但还没判完主观题”和“成绩已经发布给学生”导致学生反复看到空白分数。EXAM_STATUS { 0: 草稿, # 管理员正在配置试卷和考试参数 1: 待开始, # 已发布但未到允许进入的时间 2: 进行中, # 学生可以进入答题 3: 待判分, # 全部交卷或时间截止等待人工判主观题 4: 已判分, # 主观题已判但成绩尚未对学生开放 5: 已发布, # 学生可查看成绩和答卷 }考试实例的状态不应该靠前端传递必须由后端根据start_time、end_time以及“是否已完成主观评判”推导。做法是在每次请求进来时调用一次sync_exam_status(exam)根据当前时间和判分进度推进状态字段避免学生交卷后系统还停留在“进行中”导致二次作答。4.2 三种角色的操作边界智慧校园考试系统里最常见的是三种角色管理员、监考老师、学生。这里的权限控制要注意学生能做什么取决于该学生在当前考试里的答卷状态而不是学生这个角色本身。一个已经交卷的学生和一个正常答题中的学生操作权限就不一样。def check_exam_access(exam, user): if user.role 3: # 管理员 return manage if exam.status 0: return forbid if user.role 2: # 监考老师 if exam.status in (1, 2, 3): return supervise return forbid if user.role 1: # 学生 record ExamRecord.query.filter_by( exam_idexam.id, user_iduser.id ).first() if exam.status 2 and record.status 1: return answer if exam.status 2 and record.status 2: return readonly # 已交卷但考试还在进行只能看已被锁定的答案 if exam.status in (3, 4, 5): return view return forbid这段代码强调的是“状态 角色”的双重判断而不是单靠login_required一把梭。真实事件里出现过学生交卷后刷新页面后端因为没有锁状态让学生把试卷调出来重新改了一题再交差点造成成绩争议。readonly状态学生的save_answer接口必须拒绝写入submit_exam也必须返回 409。4.3 用 Redis 维护考试倒计时的会话状态考试计时是个看似简单实则坑多的模块。如果只是后端在交卷时判断end_time那学生看到的倒计时就是前端定时器自己算的刷新后重新获取又怕状态不一致。常见做法是考试开始时把end_time写入 Redis过期键在整场考试结束时自动清理。import json import time def start_exam_session(exam_id, user_id, end_timestamp): key fexam:session:{exam_id}:{user_id} payload { started_at: int(time.time()), end_at: end_timestamp, remaining_seconds: end_timestamp - int(time.time()), } redis_client.setex(key, end_timestamp - int(time.time()), json.dumps(payload)) return payload def get_exam_session(exam_id, user_id): key fexam:session:{exam_id}:{user_id} data redis_client.get(key) return json.loads(data) if data else Nonesetex的过期时间设置为“从开始到结束的剩余秒数”这样即使 Redis 服务器重启key也会自然消失而不会残留一个永久有效的会话。前端每隔 30 秒调用一次get_exam_session刷新倒计时而不是依赖本地的setInterval因为本地计时在电脑休眠恢复后误差极大。Redis 在这里的职责是“状态缓存”不是“数据存储”所以考试记录里的end_time仍以 MySQL 的exam_record表为准。Redis 丢失后学生刷新页面可以重新读取数据库获取正确剩余时间只是倒计时会在刷新的瞬间跳变几秒属于可接受现象。5. 部署与排错zip 包落地为可运行服务压轴章讲部署、备份和验证方法。别小看“解压运行”这个环节Python 项目换一台机器跑不起来的情况十有八九是环境差异和数据库初始化顺序问题。5.1 在 Linux 服务器上跑通 Flask 工程我一般会先在本地python环境里把依赖文件装好再部署到服务器。一个 Flask SQLAlchemy Redis 的考试系统依赖写进requirements.txt后部署流程如下python3.10 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple flask db upgradeflask db upgrade是数据库迁移工具 Alembic 的命令它会根据迁移脚本自动建表。如果项目没接迁移工具也可以改为执行schema.sql初始化数据库但不能省掉这一步。很多首次部署的报错都来自“代码跑起来了数据库里没表”。启动服务时不建议直接python app.pyFlask 自带服务器并发能力不足学生同时交卷时容易阻塞。换成 gunicorn 加 gevent 协程gunicorn app:app -b 0.0.0.0:8000 -k gevent --worker-connections 1000-k gevent让每个 worker 用协程处理高并发 IO 请求--worker-connections是单 worker 最大并发连接数。如果学校机房只有几十台电脑同时考试两个 worker 就够用不需要盲目调大。5.2 三个高频故障的处理表部署考试系统时最常撞上的故障我列成了更正对照表按优先级排。故障现象常见原因处理方式交卷后分数丢失提交接口里没有先锁record.status两个请求并发进入在submit_exam开头用SELECT ... FOR UPDATE锁行答题明细重复总分翻倍answer_detail表缺少唯一键建表时加UNIQUE KEY (record_id, question_id)考试时间显示差了 8 小时MySQL 的time_zone未配置为东八区启动命令加--default-time-zone08:00200 人一起交卷的场景最容易碰上第一个故障。原因是前端点击交卷按钮没禁用用户双击产生两个请求后端两个进程同时读到record.status1然后各自累加一次得分。锁行写法如下record ExamRecord.query.filter_by(idrecord_id).with_for_update().first()核心是在事务里先锁定这一行第二个请求只能等待第一个事务提交完此时status已经是 2直接返回“重复提交”。5.3 攒一个冒烟测试脚本在每次上线前我会写一段演练脚本模拟全部考试流程建试卷、进入考试、保存答案、交卷、计分、判主观题。这个脚本的价值在于把时间边界、重复提交、异常字符串都跑一遍不让线上成为第一个测试环境。# smoke_test.py: 提交空答案、重复答案、超长文本 import requests base http://127.0.0.1:8000 def test_submit_empty(exam_id, token): resp requests.post(f{base}/api/exam/submit, json{exam_id: exam_id}, headers{Authorization: fBearer {token}}) assert resp.status_code 200 # 允许白卷提交交卷不能阻断 def test_duplicate_submit(exam_id, token): requests.post(f{base}/api/exam/submit, json{exam_id: exam_id}, headers{Authorization: fBearer {token}}) resp requests.post(f{base}/api/exam/submit, json{exam_id: exam_id}, headers{Authorization: fBearer {token}}) assert resp.json()[code] 409 # 第二次必须判 409冒烟测试脚本里写“空答案交卷”很有必要因为真实考场上一定会有学生一道题没写直接交卷系统要允许这种行为但最终成绩按 0 分计算而不是直接报错。测试重复提交是防止分布式部署下两个 worker 实例同时处理同一请求时把答卷状态写坏。上线前把校验项写成一个 pytest 单测每次改动考务逻辑先跑一遍比人工点十遍页面管用。本文还有配套的精品资源点击获取
返回列表