☰
从零搭建在线答题系统:判分引擎、题库导入与并发交卷实战
2026/10/13 3:29:55 网站建设 项目流程

简介:这是一份面向游戏开发者的答题程序完整源码,用于在游戏中快速集成知识问答、智力竞赛等互动玩法,也适合编程学习者研究项目结构。压缩包共27个文件,约870KB,主要包含易语言源码(.e)、核心模块与支持库(.fne/.ec)、数据库脚本(.sql)、配置信息(.ini/.inc)以及相关资源包(.rar/.dat)等,其中sql脚本用于题库与成绩表初始化,txt和ini文件提供说明与参数配置,方便二次开发时调整。源码覆盖题目随机抽取、倒计时作答、答案校验、积分统计、数据持久化等关键逻辑,并体现UI交互、多线程计时、异常处理等开发要点,客户端与服务端程序均有对应实现。已有1167人学习下载,适合具备一定编程基础、需要快速搭建答题功能或希望深入学习易语言项目架构的开发者参考与定制。

1. 题库先别急着跑:这份答题程序源码真正解决什么

每到培训考核季,总有人拿着一个几百行的 Excel 来问同一件事:这些题怎么最快变成一个能在线作答、自动判分、还能看错题分布的答题器。买现成服务要按人数计费,用问卷工具又做不了多选按比例给分和限时自动交卷,最后都绕回自己写一个。这份答题程序源码就是一条能直接落地的路径:后端管题库、会话和判分,前端管答题卡、倒计时和交卷,题库从 Excel 或 CSV 一次导入,单选、多选、判断、填空四种题型都覆盖。它适合两类人——需要在两天内把一场内部测评跑起来的开发者,以及做课程设计、想看清判分逻辑完整链路的同学。真正省时间的不是界面,是那套已经踩过坑的判分归一化规则和交卷状态机。

2. 源码结构拆解:题库、判分、会话三条主线怎么串起来

拿到一份答题程序源码,第一件事不是跑起来看界面,而是先找三条主线:题目从哪来、分数怎么算、一次作答的状态存在哪。这三条线分清楚,后面改题型、加统计、接第三方登录都不会牵一发动全身。常见的分层是 Python + Flask 做接口,SQLite 做本地库,前端用原生 JS,好处是零依赖就能起,坏处是并发上来要换库——这一点在最后的压测小节会具体说。

2.1 目录结构与启动顺序

我一般按职责分目录,而不是按文件类型分。把「出卷」「作答」「判分」当成三个独立模块,改判分规则时不会碰到路由文件。

app/ __init__.py api/ # 路由层:只做参数校验和响应组装 question.py attempt.py service/ paper.py # 抽题、组卷、乱序 judge.py # 判分引擎,纯函数,不碰数据库 stat.py # 分数分布、错题率 models.py # 表结构定义 static/ # 前端答题页 templates/ manage.py # 命令行:initdb / import_bank / export_result data/ bank.xlsx # 原始题库

启动顺序有讲究,顺序错了会报「表不存在」这种低级错:

python -m venv .venv source .venv/bin/activate # Windows 下换成 .venv\Scripts\activate pip install -r requirements.txt python manage.py initdb # 建表 + 建索引,可重复执行 python manage.py import_bank data/bank.xlsx --dry-run # 先空跑,只看校验报告 python manage.py import_bank data/bank.xlsx # 确认无误再入库 python app.py --host 0.0.0.0 --port 5000

--dry-run是我强烈建议加上的一步:它只解析 Excel、打印「识别到 412 行,其中 3 行缺答案,行号 88、151、207」,不写库。题库脏数据几乎是必然的,先看报告再决定是改 Excel 还是在脚本里兜底。

2.2 数据模型:四张表覆盖全流程

题目表的关键设计是options用 JSON 存。选项数量不固定(单选四个、多选五个、判断零个),如果拆成option_a到option_f的列,以后加个「选项 F」就要改表结构,导入脚本也得跟着动。

-- 题目表 CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, qtype TEXT NOT NULL, -- single / multi / judge / blank stem TEXT NOT NULL, -- 题干纯文本,HTML 标签在导入时剥掉 options TEXT, -- JSON 数组,判断题留空 answer TEXT NOT NULL, -- 单选 'B';多选 'A,C';判断 'T';填空用 | 分隔多个可接受写法 score REAL NOT NULL DEFAULT 1, tags TEXT, -- 逗号分隔知识点,抽题和统计都靠它 difficulty INTEGER NOT NULL DEFAULT 2, -- 1 易 2 中 3 难 enabled INTEGER NOT NULL DEFAULT 1 ); -- 试卷表:一次考核的定义 CREATE TABLE exam_paper ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, duration_sec INTEGER NOT NULL, -- 建议按秒存,别存「分钟」再换算 rule_json TEXT NOT NULL, -- 抽题规则,见 4.2 max_attempt INTEGER NOT NULL DEFAULT 1 ); -- 作答会话表:一次作答的完整生命周期 CREATE TABLE attempt ( id INTEGER PRIMARY KEY AUTOINCREMENT, paper_id INTEGER NOT NULL, user_key TEXT NOT NULL, -- 外部传进来的工号/学号,本系统不存账号密码 item_json TEXT NOT NULL, -- 本次实际抽到的题目 id 与选项顺序 start_at TEXT NOT NULL, -- 服务器时间,UTC 字符串 submit_at TEXT, score REAL, status TEXT NOT NULL DEFAULT 'ongoing', -- ongoing / submitted / expired draft_json TEXT -- 断点续答用的答案草稿 ); -- 答题明细表:一行一题,错题本和统计都从这里出 CREATE TABLE answer_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, attempt_id INTEGER NOT NULL, question_id INTEGER NOT NULL, user_ans TEXT, got_score REAL NOT NULL DEFAULT 0 ); CREATE INDEX idx_attempt_user ON attempt(user_key, paper_id); CREATE INDEX idx_record_attempt ON answer_record(attempt_id);

索引这两个是必须的。少写一个,几百人同时交卷时「查我答过没」会走全表扫描,页面直接卡住。

2.3 判分引擎:为什么它必须放服务端

把判分放前端是这类源码里最常见的偷懒。前端判分看着快,但答案随页面一起下发,打开开发者工具就能看到;改一道判断题的答案,得重新发一次前端资源。正确做法是前端只收题干和选项,答案永远不出后端。

判分的难点不在比大小,在于「看起来一样但字符串不一样」。全角字母、中英文逗号、答案里的空格、多选题选项顺序,这几样能把正确率直接打下来。所以判分函数分成两步:先归一化,再比较。

def normalize(raw) -> str: """把作答统一成可比较形式:去空白、全角转半角、选项排序、统一大写。 这一段是整个判分引擎里最容易被低估的部分,宁可写长也别省。""" if raw is None: return "" s = str(raw).strip() # 全角数字字母和常见中文标点一起翻译,避免漏一个就判错 s = s.translate(str.maketrans( "0123456789ABCDEFabcdef,、; ", "0123456789ABCDEFabcdef,,; ")) s = s.replace(" ", "").replace("\t", "").upper() if "," in s: s = ",".join(sorted(p for p in s.split(",") if p)) return s def judge(qtype: str, correct: str, user: str, full_score: float) -> float: """纯函数:输入题型、标准答案、用户答案、满分,返回应得分。 不读库、不写库,方便单独写用例跑回归。""" c, u = normalize(correct), normalize(user) if not u: return 0.0 # 空答一律零分 if qtype in ("single", "judge"): return full_score if c == u else 0.0 if qtype == "multi": cs, us = set(c.split(",")), set(u.split(",")) if us - cs: return 0.0 # 有错选,整题清零 if us == cs: return full_score # 全对 return round(full_score * len(us) / len(cs), 2) # 漏选按比例给分 if qtype == "blank": accepted = {normalize(x) for x in c.split("|")} # 标准答案用 | 写多种写法 return full_score if u in accepted else 0.0 return 0.0

三个参数需要解释清楚。qtype只有四个取值,加新题型必须同步改这个分支,否则默认返回 0 分且不报错,这是最难查的一类问题。full_score传的是单题满分,不是整卷总分,别传错。多选题的漏选比例给分是业务决策,不是技术决策——有些考核要求漏选也不给分,那就把中间那行return改成0.0,改一行就行,这也是把判分做成纯函数的价值。

3. 把 Excel 题库导进去:字段映射表与一次导入脚本

题库导入是这类项目里最耗时间的一步,通常占掉整个工期的一半,因为脏数据永远比想象的多。与其在页面上手工改,不如写一个「读入—校验—入库」三段式的脚本,每次换题库重跑一遍就行。

3.1 先定字段:七个必填列

字段定得越死,后面越省事。我会强制要求题库按下面这个表头整理,缺列的 Excel 直接拒收,而不是在脚本里猜。

列名是否必填取值示例说明
题型是单选 / 多选 / 判断 / 填空会先做同义词映射,单选题和单选都认
题干是以下哪项属于……导入时剥掉 HTML 标签和多余换行
选项A~F单选多选必填小于 3 的整数判断题和填空题留空
答案是B / A,C / T / 光合作用光合
分值否2缺省为 1,建议用整数避免浮点累加
知识点否第三章,基础概念逗号分隔,抽题和错题统计都靠它
难度否1 / 2 / 3缺省为 2,用于分层抽题

3.2 导入脚本:一次读入、一次校验、一次入库

import json import unicodedata import pandas as pd # 题型同义词映射:Excel 里怎么写都能落到四个标准值上 TYPE_MAP = { "单选": "single", "单选题": "single", "single": "single", "多选": "multi", "多选题": "multi", "multi": "multi", "判断": "judge", "判断题": "judge", "judge": "judge", "填空": "blank", "填空题": "blank", "blank": "blank", } def to_halfwidth(v) -> str: """NFKC 归一化顺手把全角字母数字、罗马数字、带圈数字都转成半角形态""" return unicodedata.normalize("NFKC", str(v)).strip() def build_options(row) -> str: opts = [] for key in ("A", "B", "C", "D", "E", "F"): # 两个列名都试一遍,兼容「A」和「选项A」两种写法 v = row.get(f"选项{key}", row.get(key)) if v and str(v).strip() and str(v).strip().lower() != "nan": opts.append(f"{key}. {to_halfwidth(v)}") return json.dumps(opts, ensure_ascii=False) def load_bank(path: str): # dtype=str + keep_default_na=False 是关键,否则纯字母 NA 会被当成缺失值吃掉 df = pd.read_excel(path, dtype=str, keep_default_na=False) rows, bad = [], [] for idx, r in df.iterrows(): excel_row = idx + 2 # +2:忽略表头,且 Excel 行号从 1 开始 qtype = TYPE_MAP.get(to_halfwidth(r.get("题型", "")).lower()) \ or TYPE_MAP.get(to_halfwidth(r.get("题型", ""))) stem = to_halfwidth(r.get("题干", "")) answer = to_halfwidth(r.get("答案", "")).replace(" ", "").replace("、", ",") if not qtype or not stem or not answer: bad.append((excel_row, "题型/题干/答案为空")) continue if qtype == "judge": # 判断题统一收敛到 T / F answer = {"对": "T", "正确": "T", "是": "T", "√": "T"}.get(answer, answer) answer = {"错": "F", "错误": "F", "否": "F", "×": "F"}.get(answer, answer) if answer not in ("T", "F"): bad.append((excel_row, f"判断题答案无法识别:{answer}")) continue if qtype in ("single", "multi") and not build_options(r): bad.append((excel_row, "选择题没有任何选项")) continue rows.append(( qtype, stem, build_options(r), answer, float(to_halfwidth(r.get("分值") or 1)), to_halfwidth(r.get("知识点", "")), int(float(to_halfwidth(r.get("难度") or 2))), )) return rows, bad

几个参数值得单独盯。dtype=str必须加,否则「学号 007」这种会被读成整数 7,填空题答案直接对不上。keep_default_na=False是防 pandas 把NA、NULL、纯空格当缺失值,这一点在 5.1 会展开说。分值这里用float()转,是因为 Excel 单元格里常见的「2」和「2.0」都要能过。

入库用executemany一批提交,不要一行一条INSERT:

def save(conn, rows, batch=200): sql = ("INSERT INTO question(qtype, stem, options, answer, score, tags, difficulty) " "VALUES (?, ?, ?, ?, ?, ?, ?)") for i in range(0, len(rows), batch): conn.executemany(sql, rows[i:i + batch]) conn.commit()

批大小 200 是个经验值:一次提交几百行,几百道题不到一秒就能入库,事务日志也不会撑爆。

3.3 脏数据清洗:全角标点、答案空格、选项错位

导入报告里最常见的三类问题,处理方式不一样:

第一类是答案格式不统一。Excel 里有人写A,B,有人写A、B,有人写A B。统一在导入时换成半角逗号,判分阶段就不用再兼容五种写法。注意别在导入时对答案做sorted,多选答案排序放在判分函数里做一次就够,导入阶段保持原样便于人工核对。

第二类是选项错位。有些题库的选项直接写在题干里,没有独立列。这种情况不要硬拆,宁可让校验报告把它标出来,人工补一次。脚本猜错选项边界,比缺几道题麻烦得多。

第三类是换行和空格。题干里带\r\n会让前端的文本比较失效,导入时统一replace("\r\n", "\n").strip()。题干中间的空格保留,前后的一定要去掉。

提示:每次换题库都先跑--dry-run,把校验报告存成文件。题库版本一多,哪一版出过问题全靠这个文件回溯。

4. 交卷环节的工程细节:服务端计时、乱序抽题与断点续答

答题程序的界面通常三天能做完,真正让人半夜爬起来改代码的是交卷环节。刷新页面把倒计时重置、断网后答案全丢、同一份卷子交两次分数翻倍,这三件事几乎每份自制答题程序都会遇到一次。

4.1 倒计时放服务端,前端只做展示

前端用Date.now()算剩余时间是不行的,改一下系统时间或者刷新页面就重置了。正确做法是开始作答时把start_at写库,之后前端每次心跳都问服务端要remain_sec,本地只负责把秒数往下走和渲染。

from datetime import datetime, timedelta GRACE_SEC = 5 # 交卷宽限:网络慢的人点提交时可能已经差一两秒,给 5 秒避免误判超时 @app.post("/api/attempt/<int:aid>/submit") def submit(aid): at = db.query_attempt(aid) if at["status"] != "ongoing": return {"code": 409, "msg": "该试卷已交卷"}, 409 # 幂等:重复提交直接挡掉 now = datetime.utcnow() deadline = at["start_at"] + timedelta(seconds=at["duration_sec"] + GRACE_SEC) overtime = now > deadline # 超时不拒绝,只标记,避免考生答案被吞 payload = request.get_json(silent=True) or {} total = score_service.grade_and_save(aid, payload.get("answers", {})) # 状态机式更新:只有仍处于 ongoing 的那一次更新会成功 n = db.execute( "UPDATE attempt SET status='submitted', submit_at=?, score=?, " "overtime=? WHERE id=? AND status='ongoing'", (now.isoformat(), total, int(overtime), aid)) db.commit() if n == 0: return {"code": 409, "msg": "该试卷已交卷"}, 409 return {"code": 0, "score": total, "overtime": overtime}

GRACE_SEC别设太大,5 秒是能兜住大部分移动网络抖动的值;设成 60 秒等于给了额外的答题时间。overtime字段的价值在于事后申诉时有据可查,而不是拿来直接判零分——超时原因是网络还是拖延,光看一个布尔值分不清,交给人工判断更稳妥。

前端保持一次重校,切后台回来时对齐时间:

// 倒计时只做展示,基准来自服务端返回的 remain_sec,不信任本地时钟 const state = { remain: 0, timer: null }; function tick() { state.remain -= 1; if (state.remain <= 0) { clearInterval(state.timer); autoSubmit(); return; } renderClock(state.remain); if (state.remain % 20 === 0) saveDraft(); // 每 20 秒落一次草稿 } async function resync() { const r = await fetch(`/api/attempt/${ATTEMPT_ID}`).then(x => x.json()); state.remain = r.remain_sec; // 以服务端为准,差多少补多少 } async function boot() { await resync(); state.timer = setInterval(tick, 1000); // 切到后台再切回来,计时器可能被浏览器降频,必须重校一次 document.addEventListener("visibilitychange", () => { if (!document.hidden) resync(); }); }

4.2 乱序抽题:按知识点分层抽,而不是纯随机

纯ORDER BY RANDOM()抽题看着公平,实际会抽出一份难度极不均匀的卷子——运气好的全是简单题。更合理的是按知识点分组,每组按难度配比抽。

def build_paper(conn, rule, total_count): """rule 形如 {'基础概念': 0.3, '算法应用': 0.5, '综合分析': 0.2}""" picked = [] for tag, ratio in rule.items(): n = round(total_count * ratio) rows = conn.execute( "SELECT id FROM question WHERE enabled=1 AND tags LIKE ? " "ORDER BY difficulty, RANDOM() LIMIT ?", (f"%{tag}%", n)).fetchall() picked += [r[0] for r in rows] random.shuffle(picked) # 最终顺序再打一次,避免同知识点扎堆 return picked

抽出结果必须写进attempt.item_json。这一点很多人漏掉:如果每次请求都重新抽一次题,考生刷新页面会发现题目变了,答案自然全乱。抽一次、存一次、之后一律从item_json读。

选项顺序也建议在抽题时打乱并一并存下来,同一套卷子每个人看到的选项顺序不同,横向抄答案的成本就上去了。

4.3 断点续答与重复交卷

答案草稿每 20 秒落一次库,存进attempt.draft_json,字段结构就是{"题目id": "作答串"}。注意草稿只存作答,不参与判分,避免有人构造特殊的草稿内容影响结果。

重复交卷靠 4.1 那个带status='ongoing'条件的UPDATE兜住:第二次提交时影响行数为 0,直接返回 409,不会二次累加分数。这比在应用层用if判断更可靠,因为多进程部署时两个请求可能同时通过判断。

注意:submit接口不要做成GET。曾经有个版本图省事做成 GET,结果浏览器预取把卷子提前交了,这种问题排查起来纯属浪费时间。

5. 避坑与排查:五条踩过的记录

5.1 题库与导入类

现象:导入后题目数量比 Excel 少了十几行,但报告里没有任何错误。原因是 pandas 默认把NA、NULL、n/a和纯空格当成缺失值,恰好有几道题的题干或答案写成了这类字符串,整行被当成空行丢掉。解决办法是read_excel加上dtype=str, keep_default_na=False,让 pandas 原样读出字符串,空值判断交给自己的校验逻辑。这个坑不解决,最坏的情况是少了几道必考题而没人发现。

现象:多选答案明明选对了,判零分。原因是题库里答案写的是A、C(中文顿号)或者A C(空格分隔),而判分时按逗号切分,切出来是一个整体字符串,跟用户的A,C对不上。解决办法是导入时统一替换、和空格为半角逗号,判分函数里再做一次normalize兜底。两道防线都留着,因为总有人绕过导入脚本直接改库。

5.2 计时与交卷类

现象:考生刷新页面,倒计时从头开始。原因是剩余时间由前端计算,刷新后重新读取了页面加载时间。解决办法是把start_at写库,前端每次只拿服务端算好的remain_sec。这里要顺带确认一件事:服务器时间必须统一走 UTC,别一台机器用本地时间一台用 UTC,否则算出来的截止时间能差出几个小时。

现象:同一份卷子提交两次,分数变成两倍。原因是提交按钮没禁用,且接口没有幂等保护,两次请求都完整跑完了判分和累加。解决办法是两层:前端提交后立即禁用按钮并显示「已交卷」;后端用UPDATE ... WHERE status='ongoing'判断影响行数,为 0 就返回 409。只做前端禁用是不够的,脚本直接调接口一样能绕过。

5.3 判分与数据类

现象:填空题答案看着完全一样,系统判零分;导出的分数里出现 0.30000000000000004 这种数字。这两个现象经常一起出现。填空判错的原因通常是全角符号、首尾空格或者中英文标点混用,用户输入「光合作用 」带了个尾随空格就废了,解决办法是判分前统一走 NFKC 归一化并去掉首尾空白。浮点尾数的原因是分值用浮点数累加,解决办法是把分值存成整数(比如统一乘以 100 存「分」),或者累加完成后round(total, 2)。前者更彻底,后者是补丁——如果这套源码后面要接对账或者导出报表,建议直接改成整数分。

6. 上线前把判分验证跑一遍:影子试卷与并发交卷

判分引擎写成纯函数之后,最大的收益不是代码好看,是它可以脱离数据库和页面单独跑回归。我现在的习惯是上线前必跑一轮「影子试卷」:用一组已知答案的用例,把判分函数的每个分支都走一遍,看看实际得分和预期是否一致。

cases = [ # (题型, 标准答案, 用户作答, 该题满分, 期望得分) ("single", "B", "B", 2.0, 2.0), ("single", "B", " b ", 2.0, 2.0), # 大小写与空格不该扣分 ("multi", "A,C", "A,C", 3.0, 3.0), ("multi", "A,C", "C,A", 3.0, 3.0), # 顺序无关 ("multi", "A,C", "A", 3.0, 1.5), # 漏选按比例给分 ("multi", "A,C", "A,B", 3.0, 0.0), # 错选整题清零 ("judge", "T", "对", 1.0, 1.0), ("judge", "F", "×", 1.0, 1.0), ("blank", "光合作用|光合", " 光合作用 ", 2.0, 2.0), ("single", "B", "", 2.0, 0.0), # 空答 ] fail = 0 for qtype, correct, user, full, expect in cases: got = judge(qtype, correct, user, full) ok = abs(got - expect) < 1e-6 fail += 0 if ok else 1 print(f"{'OK ' if ok else 'FAIL'} {qtype:6s} correct={correct!r:12s} " f"user={user!r:12s} got={got} expect={expect}") print("失败用例数:", fail)

这十行用例覆盖了四种题型的全部判分分支,一次跑完不到一秒。改动判分规则后重跑,改错了立刻能看出来,比在页面上手点快得多。参数上唯一要注意的是浮点比较必须用abs(got - expect) < 1e-6,直接写==会在 1.5 这种比例给分上偶尔翻车。

第二件事是并发交卷。SQLite 在写锁上比较保守,几十个人同时交卷就会撞database is locked。压测不用太复杂,起一批线程打同一个接口就够:

import concurrent.futures import requests def one_submit(aid): r = requests.post(f"http://127.0.0.1:5000/api/attempt/{aid}/submit", json={"answers": {"1": "B", "2": "A,C"}}, timeout=10) return r.status_code with concurrent.futures.ThreadPoolExecutor(max_workers=50) as ex: codes = list(ex.map(one_submit, [aid] * 50)) print({c: codes.count(c) for c in set(codes)}) # 期望:200 出现一次,409 出现四十九次,不该出现 500

结果里只要出现一个 500,基本就是写锁冲突没处理。临时方案是建库连接时开WAL模式并把busy_timeout调到 5000 毫秒;如果并发长期上三位数,就别犹豫,把存储换成支持行级锁的数据库,SQLite 在这类场景下的定位本来就是单机小规模。

最后是一个我自己常用的技巧:准备一份「影子试卷」,用题库里的标准答案自动生成一份必然满分的作答,再故意构造一份「全部漏选一个选项」和一份「全部空答」的卷子,三份一起走完整流程——组卷、作答、提交、判分、统计。三份卷子的总分应该正好是满分、一半和零分,只要有一个对不上,说明判分或者统计链路里有东西被改了。这套自检从建库到出结果不到两分钟,比上线后从考生反馈里倒推问题便宜太多。从那以后,我每次改判分相关代码,都会先把影子试卷跑一遍再提交,这已经是肌肉记忆了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询