简介:面向Python主观题自动阅卷系统方向的毕业设计文档,适合计算机相关专业学生、课程设计开发者及需要了解自动阅卷实现思路的读者参考。内容围绕考试、阅卷、评分三个模块展开,涉及试题与考试数据生成、学生答卷评分、成绩统计与分析,并梳理JavaScript、MySQL、Python、IDEA等技术简介及功能需求、系统总体建设、性能需求与可行性研究。文档还包含系统总体设计、数据库设计中的E-R图与数据库表实现,以及各模块的具体实现方案,可用于选题论证、论文结构参考和系统设计借鉴。资源包共1个docx文件,约656KB,以毕业设计论文文档为主,目录涵盖绪论、开发技术简介、需求分析、系统可行性研究、系统总体设计、数据库设计和系统实现等章节。目前已有117人学习,适合需要参考完整论文框架、模块划分和数据库设计表述的读者查阅。
1. 期末批卷子这件事,能不能交给程序先过一遍
期末周最磨人的不是出卷,是批主观题。一个班五十份、六个班三百份,同一道“简述数据库三范式的意义”,你得一行行读,读到最后手比脑子快,给分全凭当天心情。Python 主观题自动阅卷系统盯的就是这一段:教师先录参考答案和关键词,学生在线作答提交,后端把答案拉出来做分词、关键词命中和相似度比对,几秒钟给一个建议分,教师只复核异常卷。
这套资源是一份完整的毕业设计交付物,论文、源码、数据库脚本三件套齐全。适合两类人:正在选题、需要一套能跑通的原型来改的毕业设计同学;以及想看看文本相似度打分在真实业务里怎么落地、又不想从零搭架子的开发者。它的评分核心不是玄学大模型,是几个可解释的规则加权,答辩时反而好讲。
2. 技术栈与目录结构:Python 后端配 MySQL,B/S 走到底
2.1 论文里的选型拆开看:三样东西各管一段
论文正文写得比较散,JavaScript、MySQL、Python、IDEA 各占一节,读下来容易以为是四件套。实际落地时它们的分工是清楚的:前端用原生 JS 加 HTML/CSS,负责答卷页的提交和倒计时;后端 Python,承接路由、评分和数据库读写;MySQL 存用户、试题、试卷、答卷、成绩五类数据;IDEA 只是作者当时顺手用的编辑器,跟运行时不发生任何关系,换个 VS Code 或 PyCharm 一样跑。
这里先给一个预期管理:论文的参考文献里混入了不少 JSP、PHP、Java Web 的书目,这是毕业设计模板常见的拼凑痕迹,别照着那些书目去找依赖。判断技术栈只认两处——源码根目录的requirements.txt,以及数据库脚本里用的驱动。这两处对上了,论文怎么写都不影响你跑起来。
后端框架的选择上,这个体量的系统用 Flask 最省事,路由清晰、蓝图能拆模块、跟 MySQL 的结合资料也多。常见做法是Flask + Flask-SQLAlchemy + PyMySQL三件套,Django 当然也行,但对一套只有六七个模块的毕设来说,自带 admin 和 ORM 全家桶反而增加了阅读成本。我一般建议保持轻量,把评分逻辑单独抽成services/grader.py,这样换框架时只需要改调用方。
前端不用 Node 工具链,直接静态文件由 Flask 托管。这么做的好处是部署时只有一个 Python 进程,不需要额外起 Nginx 或打包构建;坏处是页面复用靠手工,改一处导航要动五个 HTML。毕设场景下这个代价值得付,因为评审老师要看的是“能不能跑”,不是“工程规范不规范”。
2.2 压缩包解开后先看哪几个文件
拿到包别急着装环境,先花十分钟把目录扫一遍,能省掉后面两小时的瞎试。典型的目录长这样:
subjective-grading/ ├── app/ │ ├── __init__.py # 应用工厂,注册蓝图和数据库 │ ├── models.py # 五张表的 ORM 定义 │ ├── views/ # 蓝图:auth / exam / paper / score / admin │ └── services/ │ └── grader.py # 评分核心,全文最值得读的文件 ├── static/ # css / js / 图片 ├── templates/ # jinja2 页面 ├── sql/ │ └── init.sql # 建库建表 + 初始管理员 ├── config.py # 数据库连接、密钥、评分权重 ├── requirements.txt └── run.py # 入口优先级排序:先看sql/init.sql确认表结构和字段名,再看config.py确认连接串,最后看run.py确认启动方式。这三份文件读完,你对项目能不能在自己机器上跑起来就有了七成判断。很多人反过来,先pip install再看代码,结果装完发现 Python 版本不匹配,白折腾。
app/services/grader.py是这份资源里最有二次开发价值的部分。它不依赖 Web 框架,输入是学生答案、参考答案、关键词列表和满分值,输出是一个带明细的字典。你可以单独把它拎出来跑单元测试,不用起服务,这一点对想改评分策略的人非常友好。
2.3 数据库脚本与配置:先让五张表立起来
论文里给了 E-R 图和两张表结构,但字段有明显冗余——user表里sex被定义了两次,类型还不一致;shijuan表的字段名newsType明显是从别的项目复制过来没改干净。真跑起来之前,建议按下面的 DDL 重建,字段名保持和代码里的 ORM 一致,改完记得同步改models.py。
-- sql/init.sql 节选:核心三张表 CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` CHAR(64) NOT NULL COMMENT 'sha256 摘要,不存明文', `real_name` VARCHAR(50) DEFAULT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0学生 1教师 2管理员', `phone` VARCHAR(20) DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `question` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `paper_id` INT UNSIGNED NOT NULL, `content` TEXT NOT NULL COMMENT '题干', `ref_answer` TEXT NOT NULL COMMENT '参考答案', -- 关键词用 JSON 数组存,形如 ["范式","函数依赖","无损分解"] `keywords` JSON DEFAULT NULL, `full_score` DECIMAL(5,1) NOT NULL DEFAULT 10.0, PRIMARY KEY (`id`), KEY `idx_paper` (`paper_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `answer` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `paper_id` INT UNSIGNED NOT NULL, `question_id` INT UNSIGNED NOT NULL, `user_id` INT UNSIGNED NOT NULL, `content` TEXT NOT NULL, `auto_score` DECIMAL(5,1) DEFAULT NULL COMMENT '机器分', `final_score` DECIMAL(5,1) DEFAULT NULL COMMENT '教师确认后的最终分', `detail` JSON DEFAULT NULL COMMENT '命中词、余弦值等明细', PRIMARY KEY (`id`), UNIQUE KEY `uk_paper_user_q` (`paper_id`,`user_id`,`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三处设计要点值得说清楚。answer表上那个联合唯一键uk_paper_user_q是防重复交卷的最后一道闸,比在应用层用SELECT判断可靠得多,并发下不会漏。detail字段存 JSON 明细而不是拆成多列,是为了让教师复核时能看到“这个词答到了、那个没答到”,改评分策略时也不用动表结构。auto_score和final_score分开存,是给人工复核留出空间,机器分永远不覆盖人的判断。
config.py里连接串的写法要留意,charset 必须显式指定:
# config.py import os class Config: SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-only-change-me') SQLALCHEMY_DATABASE_URI = ( 'mysql+pymysql://root:123456@127.0.0.1:3306/grading' '?charset=utf8mb4' ) SQLALCHEMY_TRACK_MODIFICATIONS = False # 评分权重,教师端可改,这里给的是默认值 GRADE_WEIGHTS = {'keyword': 0.5, 'cosine': 0.35, 'sequence': 0.15} # 分数落在及格线 ±8% 区间内,自动标记为待复核 REVIEW_BAND = 0.08参数说明:charset=utf8mb4不能省,MySQL 默认的latin1会让中文答案写进去变成问号;GRADE_WEIGHTS三项之和必须为 1,改的时候记得一起调;REVIEW_BAND控制的是复核队列的宽度,调大一点教师更累但更稳,调小一点效率高但容易放过边界卷。
2.4 本地起服务的完整步骤
按顺序走,每一步都能验证,出问题好定位。
# 1. 建虚拟环境,Python 3.9 以上都行,别用 3.6 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 2. 装依赖,先看 requirements.txt 里有没有钉版本 pip install -r requirements.txt # 3. 建库,字符集必须指定 mysql -uroot -p -e "CREATE DATABASE grading DEFAULT CHARSET utf8mb4;" mysql -uroot -p grading < sql/init.sql # 4. 起服务 python run.py浏览器打开http://127.0.0.1:5000,用init.sql末尾插入的管理员账号登录。如果页面能出来但登录报 500,九成是数据库连不上,去终端看堆栈里的OperationalError,按第 5 章排查。如果页面直接 404,说明蓝图没注册上,检查app/__init__.py里register_blueprint那几行有没有被注释。
依赖里有两样容易漏:jieba和scikit-learn。前者做中文分词,后者算 TF-IDF 余弦相似度,都是评分必需。scikit-learn装起来体积不小,国内网络环境下建议先配好 pip 镜像源再装,否则卡在下载环节很容易让人放弃。
3. 需求到设计:六模块怎么切,主链路怎么走
3.1 功能边界:登录之外真正干活的是五个模块
论文里列了六大模块——系统首页、在线考试、试题管理、试卷管理、成绩管理、用户管理。首页那块写的是展示服务器 IP 和系统型号,属于答辩时凑数的模块,实际开发可以砍掉或者简化成一个仪表盘。剩下五个才是主干,按角色分:
| 模块 | 主要使用角色 | 核心动作 |
|---|---|---|
| 用户管理 | 管理员 | 增删改查、角色升降、重置密码 |
| 试题管理 | 教师 | 录题干、参考答案、关键词、分值 |
| 试卷管理 | 教师 | 组卷、发布、设定考试时间窗 |
| 在线考试 | 学生 | 拉题、作答、提交、倒计时 |
| 成绩管理 | 教师/学生 | 查看分数、复核调整、导出 |
切模块的原则是“谁的动作谁负责”,不要按数据库表来分。很多人第一次做会把“answer 模块”“question 模块”当成功能模块,结果页面上一半的接口跨三个模块,改起来牵一发动全身。
角色上只需要三档:管理员管人,教师管题和分,学生只管答和看。论文里提到“可以把用户设为普通管理员或超级管理员”,这套两级管理员在毕设规模下是过度设计,实际用role一个整型字段就够,0/1/2分别对应学生、教师、管理员。权限判断写成装饰器,挂到蓝图上:
# app/views/_decorators.py from functools import wraps from flask import session, jsonify def role_required(*roles): """roles 传允许通过的 role 值,比如 @role_required(1, 2) 表示教师和管理员。""" def deco(fn): @wraps(fn) def wrapper(*args, **kwargs): if 'user_id' not in session: return jsonify(code=401, msg='未登录'), 401 if session.get('role') not in roles: return jsonify(code=403, msg='无权限'), 403 return fn(*args, **kwargs) return wrapper return deco这个装饰器要放在login_required之后用,顺序反了会先判角色再判登录,未登录用户拿到的是 403 而不是 401。参数roles用可变参数而不是列表,写起来短一点,@role_required(1, 2)比@role_required([1, 2])顺手。
3.2 E-R 关系与字段:三组关系撑起整个模型
论文里的 E-R 图画了管理员、用户、考试信息三个实体,但关系线没交代清楚。按实际业务补全,核心是三组:
- 一份试卷包含多道试题(
question.paper_id外键),一对多; - 一个学生对一份试卷有多条作答记录(
answer表),但同一题只能有一条,靠联合唯一键约束; - 一条作答对应一条评分明细(存在
answer.detail的 JSON 里),一对一。
user表字段论文里给了编号、姓名、性别、年龄、电话、邮箱、地址、身份证号。这里面身份证号在毕设场景属于敏感信息,能删就删,真要留就存脱敏后的值。年龄字段也没必要存,有生日就能算,存了反而要处理“今年没更新”的问题。信息冗余带来的不是方便,是同步维护的负担。
shijuan(试卷)表论文里的字段是ID/name/newsType/author/makeTim/maker/modiTime,其中newsType明显是复制粘贴残留。重建时改成paper表:id/title/author_id/status/start_time/end_time/duration/created_at。status用0草稿 1已发布 2已结束三态,比用时间戳推断可靠,因为考试可能提前关闭。
3.3 一条主链路:从出题到出分要经过几次写库
把链路走一遍,能发现很多设计上的坑。教师建题写question一条,组卷写paper一条并回填paper_id,发布时把status改成 1。学生进考场拉题是一次读,提交时是一个事务里写answer若干条加汇总score一条。评分在提交时同步做,因为题量小,几十道题的分词加相似度计算在几百毫秒量级,没必要上异步队列。
顺序上有个容易翻车的点:评分依赖question表里的参考答案和关键词,所以读题和写答案必须在同一个事务快照里,否则教师中途改题会导致前后不一致。常见做法是在提交接口开头一次性把该卷所有题目SELECT ... FOR SHARE锁住,写完再放。毕设规模下锁冲突几乎不会发生,但这个习惯值得养成。
成绩汇总表score不存明细,只存user_id/paper_id/total/objective_total/subjective_total/graded_at。客观题直接比对,主观题累加auto_score。教师复核后更新final_score,同时把score.total重算一遍,这样学生查分只读一张表,不用联查。
3.4 逻辑结构里的部署假设
论文说系统可以部署在 Windows、Linux 等环境,前提是能装 MySQL 和 Python。这句话没错,但对读者有用的信息是:单机部署够不够。以三百人同时在线作答估算,Flask 自带开发服务器顶不住,需要换gunicorn起 4 个 worker,前面挂 Nginx 做静态文件。如果只是本地跑通给答辩看,python run.py完全够,别提前上生产配置给自己添堵。
数据库连接池也要留意。SQLAlchemy 默认池大小 5,超了会排队,压测时表现为“页面转圈但 CPU 不高”。在config.py里加SQLALCHEMY_ENGINE_OPTIONS = {'pool_size': 10, 'pool_recycle': 3600},pool_recycle是为了防止 MySQL 八小时空闲断连后拿到失效连接。
4. 核心实现拆解:评分函数是这套系统的心脏
4.1 后端分层:蓝图、服务、模型各管一段
代码组织的清晰度直接决定你改起来顺不顺。推荐的结构是三层:views/只做参数校验和响应组装,services/放业务逻辑(评分、组卷、统分),models.py只描述表。这样评分逻辑能被单独测试,换 Web 框架时也不用重写。
应用工厂的写法:
# app/__init__.py from flask import Flask from .models import db def create_app(config_object='config.Config'): app = Flask(__name__) app.config.from_object(config_object) db.init_app(app) # 蓝图按业务拆,url_prefix 统一挂在 /api 下 from .views.auth import auth_bp from .views.exam import exam_bp from .views.paper import paper_bp from .views.score import score_bp app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(exam_bp, url_prefix='/api/exam') app.register_blueprint(paper_bp, url_prefix='/api/paper') app.register_blueprint(score_bp, url_prefix='/api/score') return appcreate_app接受配置对象名而不是实例,是为了测试时能传一个测试配置进去,用内存库或独立测试库跑,不污染开发数据。这是个小技巧,但能省掉“跑一次测试清一次库”的麻烦。
4.2 评分函数:关键词命中加权加余弦相似度
这是全文最值得逐行读的部分。设计思路是把“答得全不全”和“答得像不像”分开度量:关键词命中率衡量覆盖度,TF-IDF 余弦衡量语义接近程度,序列相似度兜底处理短答案。三者加权求和乘满分,得到建议分。
# app/services/grader.py import re import jieba from difflib import SequenceMatcher from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # 精简停用词表,够用即可,上完整表反而会误删专业词 STOP = {'的', '了', '是', '在', '和', '与', '也', '就', '都', '而', '及', '或', '这', '那'} def normalize(text: str) -> str: """统一全半角、去掉中文标点和多余空白,避免同一句话因排版被判成两句。""" if not text: return '' text = text.replace('\u3000', ' ').replace('\r', ' ').replace('\n', ' ') text = re.sub(r'[,。;:!?、“”‘’()《》]', ' ', text) return re.sub(r'\s+', ' ', text).strip().lower() def tokenize(text: str): """jieba 分词后过滤单字和停用词,单字噪声大,专业术语多为两字以上。""" return [w for w in jieba.lcut(text) if len(w) >= 2 and w not in STOP] def keyword_hit(student_ans: str, keywords): """返回命中率、命中词、漏掉的词,漏词列表直接喂给教师复核界面。""" if not keywords: return 0.0, [], [] hit = [k for k in keywords if k in student_ans] miss = [k for k in keywords if k not in student_ans] return len(hit) / len(keywords), hit, miss def tfidf_sim(a: str, b: str) -> float: try: vec = TfidfVectorizer(tokenizer=tokenize, token_pattern=None) mat = vec.fit_transform([a, b]) return float(cosine_similarity(mat)[0][1]) except ValueError: # 两段文本都没有有效词时 sklearn 会抛异常,按 0 处理 return 0.0 def grade(student_ans, ref_ans, keywords, full_score, w_kw=0.5, w_cos=0.35, w_seq=0.15): s, r = normalize(student_ans), normalize(ref_ans) rate, hit, miss = keyword_hit(s, keywords) cos = tfidf_sim(s, r) seq = SequenceMatcher(None, s, r).ratio() raw = w_kw * rate + w_cos * cos + w_seq * seq score = round(raw * full_score, 1) return { 'score': score, 'keyword_rate': round(rate, 3), 'cosine': round(cos, 3), 'sequence': round(seq, 3), 'hit_keywords': hit, 'miss_keywords': miss, # 落在及格线附近 ±8% 的,标记待复核 'need_review': abs(score - full_score * 0.6) < full_score * 0.08, }参数说明与调参经验:w_kw给 0.5 是因为关键词最能反映“有没有答到点”,权重低了学生写一堆废话也能拿分;w_cos给 0.35 是因为 TF-IDF 对长答案更敏感,能区分“答到了但没展开”和“展开得很充分”;w_seq只给 0.15,它对语序和字数极端敏感,权重高了会把同义表述判低。三个权重之和必须为 1,改完在config.py里同步,别硬编码在函数默认值里。
need_review这个返回值容易被忽略,但它是整套系统能否落地的关键。机器分永远只是建议,教师复核才是终审。把边界卷自动挑出来,比追求算法精度更实用。
4.3 交卷接口与事务:一次请求写三张表
提交答卷是唯一需要事务的地方,写不好会出现“答案存了、成绩没算”的脏数据。
# app/views/exam.py from flask import Blueprint, request, session, jsonify from ..models import db, Answer, Question, Score from ..services.grader import grade exam_bp = Blueprint('exam', __name__) @exam_bp.route('/<int:paper_id>/submit', methods=['POST']) def submit(paper_id): data = request.get_json(silent=True) or {} answers = data.get('answers') or {} # {question_id: "学生作答文本"} uid = session.get('user_id') if not answers: return jsonify(code=400, msg='答卷为空'), 400 # 先查重,配合表上的唯一键做双保险 if Answer.query.filter_by(paper_id=paper_id, user_id=uid).first(): return jsonify(code=409, msg='该试卷已提交,请勿重复操作'), 409 questions = Question.query.filter_by(paper_id=paper_id).all() total = 0.0 try: for q in questions: text = (answers.get(str(q.id)) or '').strip() result = grade(text, q.ref_answer, q.keywords or [], float(q.full_score)) total += result['score'] db.session.add(Answer( paper_id=paper_id, question_id=q.id, user_id=uid, content=text, auto_score=result['score'], final_score=result['score'], detail=result, )) db.session.add(Score(paper_id=paper_id, user_id=uid, total=round(total, 1))) db.session.commit() except Exception: db.session.rollback() raise return jsonify(code=0, total=round(total, 1))逻辑上注意三点。查重放在循环之前而不是靠唯一键抛异常,是为了给用户一个友好提示而不是 500。answers用字典按question_id取值,学生没作答的题也要走一遍评分,得 0 分并落库,否则统计时该题会被漏掉。db.session.rollback()必须写在except里,中途任何一道题评分抛异常都要整体回滚,不能出现半份答卷。
4.4 前端答卷页的提交与失败处理
前端不用框架,原生fetch加一点防抖就够。关键是提交按钮的禁用和失败恢复,否则学生手抖点两下就是两次请求。
// static/js/exam.js async function submitPaper(paperId, answers) { const btn = document.getElementById('submitBtn'); if (btn.disabled) return; // 防重复点击 btn.disabled = true; btn.textContent = '提交中…'; try { const res = await fetch(`/api/exam/${paperId}/submit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ answers }) }); const data = await res.json(); if (data.code === 0) { location.href = `/score/${paperId}`; // 跳成绩页 } else { alert(data.msg || '提交失败,请重试'); } } catch (e) { alert('网络异常,答案已暂存本地,请重新提交'); localStorage.setItem(`paper_${paperId}`, JSON.stringify(answers)); } finally { btn.disabled = false; btn.textContent = '交卷'; } }finally里恢复按钮状态容易被忘,忘了的话提交失败后按钮永久变灰,学生只能刷新页面。localStorage那一行是补救措施,配合页面加载时的恢复逻辑,能在断网时保住学生已写的内容。
倒计时用setInterval每秒刷新,但剩余时间要以服务端下发的end_time为准,不能用页面加载时间加时长算,否则用户改本地时间就能续考。这是个小细节,但答辩老师常问。
5. 避坑与常见问题:跑不起来先查这五处
5.1 按论文里的技术栈找依赖,找不到
现象:翻遍requirements.txt也没看到论文里提的 Java、JSP、PHP 相关依赖,怀疑源码不全。
原因:论文的“开发技术简介”和“参考文献”是从多个模板拼的,参考文献里二十本书有十几本是 JSP、PHP、Java Web 方向,和实际实现完全无关。这是毕业设计里很常见的痕迹,不代表代码有问题。
解决:以requirements.txt和import语句为准,把论文里的技术介绍当背景读,不要当安装清单。真正的三样依赖是 Flask、PyMySQL、scikit-learn,加一个 jieba。装完能 import 成功就说明环境对了。
5.2 MySQL 8 连接报 2059 认证插件错误
现象:python run.py起得来,一登录就 500,堆栈里是pymysql.err.OperationalError: (2059, "Authentication plugin 'caching_sha2_password' cannot be loaded")。
原因:MySQL 8 默认用caching_sha2_password认证,老版本 PyMySQL 不支持,或者本地客户端库版本对不上。
解决:两条路选一条。升级驱动最干净,pip install -U pymysql cryptography,cryptography是加密握手必需的,漏装会报另一个错。不想动环境就改用户认证方式:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;改完记得重启服务再试。生产环境不建议用第二种,本地跑通没问题。
5.3 学生答案明明写了,评分全是 0
现象:提交后成绩页显示 0 分,但answer.content字段里文本是完整的,评分明细里keyword_rate和cosine都是 0。
原因:八成是编码问题。数据库表建的时候没指定utf8mb4,中文写进去变成乱码,分词结果全是单字节噪声,tokenize过滤后返回空列表,相似度自然为 0。另一个可能是连接串里漏了?charset=utf8mb4。
解决:先查库里的实际存储值,SELECT content, HEX(content) FROM answer LIMIT 1;,HEX 出来如果是3F3F3F这种问号编码,就是字符集问题。重建表时显式写DEFAULT CHARSET=utf8mb4,连接串补上 charset 参数,两处都改才算完。
5.4 时间字段建成 DATE 后排序和判断全乱
现象:试卷列表按时间倒序排出来顺序不对,考试时间窗判断也失灵,明明还没到结束时间却提示已结束。
原因:论文里的shijuan表把makeTim定义成Date类型,只精确到天,代码里却往里塞带时分秒的字符串,MySQL 会截断或报错。同一份表里还有modiTime用VarChar存时间,类型不统一,比较时按字符串比,2024-9-1会排在2024-10-1后面。
解决:统一改成DATETIME,默认值用CURRENT_TIMESTAMP,Python 侧用datetime对象而不是字符串传参。已有的字符串数据用STR_TO_DATE迁移一遍。排序和区间判断都走数据库,不要在 Python 里比字符串。
5.5 交卷接口被重复调用,成绩表出现两条记录
现象:压测或者学生手抖时,score表出现同一用户同一试卷两条记录,总分成倍。
原因:只在应用层做了SELECT查重,两个请求几乎同时到达,都查到“没有记录”,然后都插入。这在单线程本地测试时永远复现不了。
解决:数据库层加联合唯一键UNIQUE KEY uk_paper_user (paper_id, user_id),应用层捕获IntegrityError返回 409。前端再加按钮禁用,三层防护。加唯一键之前先把已经产生的重复数据清掉,否则建索引会失败。
6. 二次开发:把“能跑”改成“真能用”的几个改造点
这套代码跑通之后,最值得动手的不是加页面,是改评分。原始实现的短板很明显:关键词靠人工逐个录,同义表述完全不计分,学生写“第三范式”和“3NF”在系统眼里是两个东西。改造时按投入产出排序,先做前两项收益最大。
| 改造点 | 改动位置 | 工作量 | 收益 |
|---|---|---|---|
| 同义词扩展 | grader.py命中逻辑前 | 半天 | 显著降低误判 |
| 权重配置化 | config.py+ 教师端页面 | 一天 | 不同题型差异化打分 |
| 评分日志留痕 | answer.detail加版本号 | 两小时 | 复核有据可查 |
| 人工复核队列 | 新增review页面 | 两天 | 落地必需 |
同义词的做法是维护一张synonym表,评分前把学生答案里的词做一次归一化替换,把“3NF”映射回“第三范式”再走命中判断。不用上语义模型,一张几十行的映射表就能覆盖大部分场景。代码上在normalize之后插一步:
def expand_synonyms(text: str, mapping: dict) -> str: """mapping 形如 {'3nf': '第三范式', '函数依赖': '函数依赖'},键统一小写。""" for alias, standard in mapping.items(): text = text.replace(alias, standard) return text映射表从数据库读,教师端加个维护页面,比硬编码在代码里强得多。权重配置化同理,把GRADE_WEIGHTS从常量改成表字段,按题型存不同权重,简答题偏命中率、论述题偏余弦相似度,这样一套系统能覆盖多种题型。
有个坑必须提醒:改评分策略之后,历史数据的分和线上对不上,学生来问“为什么我的分变了”就说不清。所以改之前先把detail里加个version字段,记录用的是哪版权重和词表,复核时按版本复现。我第一次做这类系统没留版本,改了一版算法之后被追问某份卷子为什么降了三分,翻了半天日志也说不清,从那以后每次动评分逻辑前都强制走一遍“加版本号 → 跑历史样本对比 → 确认差异可解释”的流程。
如果你想拿这套资源练手,建议的路径是:先在本地把五张表和评分函数跑通,用自己编的十份答卷做回归测试,确认分数合理;再改同义词和权重,对比改动前后的分数差异;最后才动前端。评分逻辑是这类系统的全部价值所在,把它吃透,剩下的页面都是体力活。希望帮到你。
本文还有配套的精品资源,点击获取