ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

答题程序源码实现要点:题库设计、判分与提交防重

答题程序源码实现要点:题库设计、判分与提交防重

简介:这是一个面向游戏开发者与答题活动策划的答题程序完整源码包,覆盖知识问答、智力竞赛、娱乐互动等场景,能帮助阅读者快速理解答题器的总体架构与二次开发要点。压缩包共27个文件,以易语言可执行工程(.e)、扩展功能模块(.fne/.ec)为核心,辅以SQL数据库脚本、ini/inc配置文件、txt说明及dat数据文件,整体约870KB,目录划分清晰,便于按功能检索。已有1167人学习下载。源码涉及用户界面设计、题库数据结构、随机抽题与答案校验、倒计时多线程处理、积分与关卡逻辑、异常容错、本地化翻译、网络通信及数据库读写等十个关键知识点,测试用例与调试方法也包含在内;开发者可基于现有模块替换题库、调整规则或对接在线服务,快速构建属于自己的答题玩法。

1. 答题程序源码:先想清楚判分规则,再谈界面和题库

很多人找答题程序源码,拿到 demo 一跑,第一印象是界面挺漂亮,真正用起来却各种翻车:随机抽题会抽出重复题、多选题少选一项被判全错、用户刷新一下页面答案就全没了。这些问题八成不是出在界面,而是出在数据结构和判分规则上。这篇我按自己做测评系统、刷题小工具的顺序,把题库设计、随机抽题、判分、计时、进度保存和提交稳妥性一次讲清楚,代码是可以直接抄走改的级别。适合三类人:要给团队做内部技能测评的、给学生做在线练习的、还有想把刷题工具做成产品的。

2. 题库数据模型:答题程序的“地基”怎么设计

2.1 题型与字段设计:先定义 JSON Schema,再写答题界面

我做这类程序时,第一步从来不是画页面,而是先把题库文件长什么样定下来。题目最终要落成数据,数据长啥样直接决定后端接口、判分函数和前端渲染好不好写。常见做法是用一个 JSON 文件装整套试卷,结构定成下面这样:

{ "examId": "skill_exam_001", "title": "前端基础技能测评", "durationMinutes": 30, "passScore": 60, "questions": [ { "id": "q001", "category": "javascript", "type": "single", "score": 2, "question": "以下哪个运算符用于严格相等比较?", "options": ["A. ==", "B. ===", "C. =", "D. !="], "answer": "B", "analysis": "=== 会同时比较类型和值,== 会做类型转换。" }, { "id": "q002", "category": "css", "type": "multiple", "score": 3, "question": "以下哪些属性可以产生层叠上下文?", "options": ["A. position", "B. z-index", "C. opacity", "D. transform"], "answer": ["B", "C", "D"], "analysis": "position 本身不产生,需要配合 z-index。" } ] }

为什么要用 JSON 而不是直接建数据库表:对中小规模的答题场景,JSON 可读、可放进版本管理库做 diff、导入导出方便,前端直接用 fetch 拉下来就能渲染。正式考试系统后面会换成数据库,但题库的“逻辑结构”仍然是这一套,换的不是结构,是存储容器。

字段设计要注意几点。id在整张卷子里必须全局唯一,不能出现两道题的 id 都是 q001;category是给分层抽题用的分类键,想按“javascript / css / 网络基础”抽题就靠它;score是单题分值,及格线passScore单独放,不要在代码里写死 60。analysis判分用不到,但练习模式要展示解析,最好一开始就留好这个字段,后面不需要回头改数据结构。

2.2 题库校验脚本:把“题目录错了”挡在上线前

题库一旦超过几十道题,靠人眼检查不现实。多人维护时的典型错误包括 id 写重复、单选题答案写成了两个字符、多选题只给了一个正确项。这些错误不在上线前拦住,就要在用户交卷后才发现,那时候已经晚了。我一般会配一个校验脚本,保存题库后先跑一遍再发布。

import json import sys def load_exam(path): with open(path, encoding="utf-8") as f: return json.load(f) def validate(data): errors = [] qs = data.get("questions") if not isinstance(qs, list) or len(qs) == 0: return ["questions 必须是包含题目的数组"] seen_ids = set() for i, q in enumerate(qs, 1): qid = q.get("id") if qid in seen_ids: errors.append(f"第 {i} 题: id 重复 -> {qid}") seen_ids.add(qid) t = q.get("type") if t not in {"single", "multiple", "judge", "blank"}: errors.append(f"第 {i} 题: 未知题型 -> {t}") if t == "single": ans = q.get("answer") if not (isinstance(ans, str) and len(ans) == 1): errors.append(f"第 {i} 题: 单选答案应为单个字符") if t == "multiple": ans = q.get("answer") if not isinstance(ans, list) or len(ans) < 2: errors.append(f"第 {i} 题: 多选答案应为至少两个选项的数组") if "analysis" not in q: errors.append(f"第 {i} 题: 缺少解析字段 analysis") return errors if __name__ == "__main__": data = load_exam(sys.argv[1]) errs = validate(data) if errs: for e in errs: print("[ERROR]", e) sys.exit(1) print("[OK] 题库校验通过,共", len(data["questions"]), "题")

这段脚本有几个参数和细节说明。第一,文件读取必须用encoding="utf-8",否则在中文系统上读 UTF-8 文件会乱码或直接抛异常,这是最常见的翻车原因。第二,sys.argv[1]是传入的题库路径,执行方式是python validate_exam.py exam.json。第三,校验不通过时sys.exit(1)会让发布流程中断。

实际项目里我会把这段脚本接进发布流程,题库文件一改动就跑一次,跑不过直接拦下。这里值得多说一句:运营同学直接打开 JSON 文件手改,是最容易产生脏数据的路径。如果做成题库管理界面,前端也要先调校验接口再入库,别把校验只留在命令行里。题库越往后越多,校验规则只会越来越重要,这是踩出来的教训。

2.3 判断题、填空题的存储差异与答案容错

上面的示例只覆盖了单选和多选,但答题程序通常逃不掉判断和填空。判断题的answer我建议存布尔值true/false,而不是存字符串"对"/"错"。布尔值在判分时用===直接比较,干净利落;字符串会引入“对 vs 对。”这种别名问题,还要多做一层归一化。

填空题的answer需要升级成数组,因为同一个空往往有多个可接受答案。比如“什么是 X?”这类题,答案可以是["正则表达式", "regex"]。判分时先做归一化再比对,归一化规则一般是去掉首尾空格、压缩中间空白、全角转半角、统一小写。以下用表格把四种题型的差异列清楚:

题型answer 字段类型判分策略典型坑
单选 singlestring,如 "B"全等比较答案大小写、全半角不一致
多选 multiplestring[],如 ["A","C"]排序后全等;可配置部分给分选项顺序不同导致误判
判断 judgeboolean,true/false全等比较存成字符串 "true" 后类型不匹配
填空 blankstring[],多个可接受答案归一化后全等空格、全角、大小写、答案别名

填空题的归一化是很容易被低估的部分。用户可能在答案里多打个空格,也可能输入全角的括号或字母。判分函数里必须有一层“清洗”,把A转成A、把" "转成"",再统一小写。这个逻辑我会放在公共模块里,所有题型共用,而不是散落在各题目的判分分支里。

3. 答题主流程:随机抽题、倒计时与判分逻辑

3.1 随机抽题:洗牌算法与知识点分层

随机抽题看起来简单,很多人的第一版实现是“循环随机取一道题,取过的丢掉”。这种做法在题库小的时候问题不大,题库一大会有性能问题,而且容易出现某道题死活抽不到的情况。更稳的做法是洗牌后取前 N 道:

function shuffle(list) { const arr = list.slice(); for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function sampleByCategory(questionBank, plan) { const picked = []; for (const item of plan) { const pool = questionBank.filter(q => q.category === item.category); if (pool.length < item.count) { throw new Error(`分类 ${item.category} 题量不足:需要 ${item.count},实际 ${pool.length}`); } picked.push(...shuffle(pool).slice(0, item.count)); } // 再整体洗一次,避免题目按分类成块出现 return shuffle(picked); }

为什么不用“每次随机取一道再判断重复”:数组不停splice会让时间复杂度变成 O(n²),题库几千道时明显卡顿;而且Math.random()本身是伪随机,局部取样的分布并不均匀。Fisher-Yates 洗牌算法在数组末尾逐步交换,复杂度 O(n),每条题目被抽中的概率一致。

sampleByCategory里的plan是抽题计划,形如[{ category: 'javascript', count: 10 }, { category: 'css', count: 5 }]。这里的关键参数是count,如果某分类的题库数量小于需求数量,函数直接抛错比静默降级好,否则一场考试题量不够,用户就会发现“怎么题目越做越少”。最后再整体洗一次牌也很重要,不然试卷会按分类一坨一坨地出现,同类题型连排,体验很差。

3.2 判分函数:单选、多选、判断、填空的统一接口

判分逻辑必须收敛到一个函数里,不要散落在每个题目的渲染组件中。统一入口的好处是规则只写一遍,单测只测一个函数,后续要调整“多选题少选是否给分”也只需要改一处:

function normalizeAnswer(text) { return String(text) .trim() .replace(/\s+/g, '') .replace(/[A-Za-z0-9]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0)) .toLowerCase(); } function scoreQuestion(question, userAnswer, config = {}) { const { partialScore = false } = config; switch (question.type) { case 'single': case 'judge': // 判断题 answer 是布尔值,统一转字符串再比较 return String(userAnswer) === String(question.answer) ? question.score : 0; case 'multiple': { const correct = question.answer.slice().sort().join(','); const user = (userAnswer || []).slice().sort().join(','); if (correct === user) { return question.score; } if (partialScore && userAnswer.length > 0) { const hit = userAnswer.filter(a => question.answer.includes(a)).length; return Math.round((hit / question.answer.length) * question.score); } return 0; } case 'blank': { const user = normalizeAnswer(userAnswer); const ok = question.answer.some(acc => normalizeAnswer(acc) === user); return ok ? question.score : 0; } default: return 0; } }

判分逻辑的核心细节在于多选题。正确项是["B","C","D"],用户答["C","B","D"],直接===肯定不相等,所以必须先排序再 join 成字符串比较。partialScore这个开关控制“少选是否给部分分”,默认false表示只要不是全部选对就是 0 分,和很多考试“少选不得分”的规则一致。如果需求是“少选按命中比例给分”,就把partialScore设成true,命中比例按正确项数量计算。

填空题的normalizeAnswer是判分前必过的一道关。它做的事依次是:去首尾空格、压缩中间连续空白、全角字母数字转半角、统一转小写。注意全角转半角的计算方式,ch.charCodeAt(0) - 0xFEE0是 Unicode 全角字符与半角字符之间的固定偏移,可以一次性处理全角字母和数字。这个函数在题库校验脚本里已经出现过一次,前端判分时再放一份,两边的规则必须保持一致,否则会出现“后台校验能过、前端判分说错”的诡异问题。

3.3 计时与自动交卷:别把“时间到了”做成 setTimeout 单点

倒计时是答题程序里最容易写出 bug 的地方。新手写法通常是setTimeout(() => submit(), 30 * 60 * 1000),看起来很直接,实际有两个问题:一是浏览器切后台或系统休眠时定时器会被冻结;二是到点提交如果网络慢,用户可能看到“交卷失败”但成绩已经被算进去了。我用截止时间戳加短间隔轮询代替,让倒计时不受休眠影响:

function createTimer(exam, onSubmit) { // 用截止时间戳而不是每秒递减,避免切后台后计时漂移 const deadline = Date.now() + exam.durationMinutes * 60 * 1000; const timer = setInterval(() => { const remainSec = Math.ceil((deadline - Date.now()) / 1000); updateUi(remainSec); if (remainSec <= 30 && remainSec > 0) { showWarn(`还剩 ${remainSec} 秒,请尽快完成答题`); } if (remainSec <= 0) { clearInterval(timer); onSubmit('timeout'); } }, 250); return { stop() { clearInterval(timer); }, remaining() { return Math.ceil((deadline - Date.now()) / 1000); } }; }

关键参数是轮询间隔250ms,不需要更短,人的感知时间精度到 1 秒就够。Math.ceil取整保证剩余 1 秒时不会提前变 0。倒计时归零后调用onSubmit('timeout'),这里的onSubmit必须是一个“即使重复调用也只提交一次”的函数,下一章会讲怎么做提交去重。还要提前 30 秒给用户弹提醒,别让用户在毫无防备的情况下被强制交卷,这是考试类程序的体验底线。

4. 答题进度的保存与提交:刷新不丢、重复不重

4.1 localStorage 快照:无账号体系下的续答方案

答题做到一半用户误关页面,重新打开后答案全丢,这是答题程序被骂得最多的问题。在没有账号体系的小型应用里,localStorage 是成本最低的续答方案。核心思路是每次答题动作都往本地写一份快照:

const STORAGE_KEY = 'exam_snapshot'; function saveSnapshot(snapshot) { // 快照包含 answers、当前题号、开始时间、截止时间、卷子编号 localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } function loadSnapshot(examId) { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const snap = JSON.parse(raw); // 校验 examId,防止打开另一张卷子时读到上一张的数据 return snap.examId === examId ? snap : null; } catch (e) { // 解析失败直接丢弃,不让页面崩掉 return null; } } function clearSnapshot() { localStorage.removeItem(STORAGE_KEY); }

快照里存什么要克制:answers用对象存储,键是题目 id,值是用户答案;startedAt记录开始时间,用于恢复倒计时;deadline直接存截止时间戳,恢复时不需要重新计算。examId是必须的,不然用户做 A 卷刷新后打开 B 卷,可能直接读到 A 卷的答案。

一个很容易忽略的点是清理时机。快照必须在“交卷成功”之后清除,不能用户一点交卷就删。如果交卷请求失败,快照还在,用户刷新后能续答或重新提交;如果交卷成功但快照没删,下一个人打开同一台电脑就会读到上一个人的答题记录。localStorage 单域名容量大约 5MB,纯文本试卷没问题,但图片题千万别往里塞 base64,一张图就可能撑爆。

4.2 提交去重与幂等:防止“交了两遍卷”

用户看到页面卡住会下意识连点交卷按钮,如果后端没有去重机制,同一份答卷可能被记录两次,成绩单里出现两条同分记录。前端能做的第一层防御是禁掉按钮,但只有这一层不够,网络超时后用户重试,还是可能产生重复提交。我一般会在前端维护一个提交 token,每次提交动作带上它:

let submitToken = 0; async function submitExam(buildPayload) { submitToken += 1; const token = submitToken; submitBtn.disabled = true; submitBtn.textContent = '正在交卷...'; try { const payload = buildPayload(token); // payload 里携带 token,后端对同一 token 只记录一次 await postResult(payload); } finally { if (token === submitToken) { submitBtn.disabled = false; submitBtn.textContent = '交卷'; } } }

这个实现里有个细节:finally里要判断token === submitToken,因为用户在第一次提交还没结束时可能通过其他入口再次触发提交,这会让submitToken递增。只有最后一次提交的请求完成后才恢复按钮,旧请求回来时不再操作界面,避免“旧的失败响应把按钮恢复了,用户又猛点一轮”。

更严格的做法是后端对提交做幂等:请求体带上examId + userId + submitToken,服务端记录已处理过的 token,重复 token 直接返回“已提交,请勿重复操作”。前端 token 只是防手滑,后端 token 才是防网络重试。

4.3 两种部署形态怎么选:纯前端与前后端分离

答题程序源码落地时,第一个要做的架构决策是“要不要后端”。题库存在前端本地文件里、判分也在前端跑、成绩只写 localStorage,这种纯前端形态对练习类场景完全够用。但如果要出成绩报表、要防作弊、要统计每道题的正确率,就必须有后端。

形态实现成本典型场景主要限制
纯前端单文件低内部小范围练习、临时答题成绩只留在本机,无法汇总分析
前端 + 静态题库低题库需要定期更新的刷题站题库完全暴露,无法防刷
前端 + 后端接口中正式考试、企业技能测评需要处理并发、幂等、审计

我的判断标准很简单:交卷后有没有人要看这份成绩。没人看,纯前端最快;有人要看,就从第一天开始做后端。纯前端半路加后端是最痛苦的,因为前端已经把判分逻辑写进每个组件里,到时候要全部拆出来搬到服务端,等于重写一遍判分。

5. 答题程序源码里最常翻车的五处,附排查建议

5.1 题库 JSON 解析失败或中文乱码

现象:页面白屏,终端或浏览器控制台报Unexpected token,题库里的中文显示成乱码。原因:手工编辑 JSON 时引号没转义,或者文件保存成了 GBK 编码,前端以 UTF-8 解析就炸了。解决:统一约定题库文件用 UTF-8 保存,禁止用系统自带记事本直接改 JSON;每次改动后跑一遍题库校验脚本,通过才允许发布。从编辑器到脚本到前端,编码链路全部统一成 UTF-8,这一条省掉了大量无意义的排查时间。

5.2 多选题少选被误判为错

现象:用户只选了 A 和 B,正确答案是 A、B、C,交卷后这题 0 分,用户投诉判分不对。原因:判分逻辑默认“全对才给分”,但需求方心里的预期是“少选给一半分”,两边没对齐。解决:在需求阶段就确认部分给分规则,把partialScore做成配置项而不是写死在代码里;同时在答题页写明“少选、错选均不得分”或“按命中比例给分”,把规则透明化。我见过不少项目在线上跑了一周才发现规则没对齐,改判分函数容易,安抚用户情绪难。

5.3 倒计时归零但答案没交上去

现象:倒计时到 0,自动提交失败,用户还在答题页面,点击交卷提示网络错误。原因:初版用setTimeout做倒计时,浏览器切后台定时器休眠,醒过来时已经超时;同时提交接口没有超时重试,网络一抖动就失败。解决:用截止时间戳加 250ms 轮询替代单点setTimeout;提交接口加上超时重试,重试请求带同一个 submitToken,后端幂等去重。另外在剩余 30 秒时弹提醒,让用户提前进入交卷心态,比最后 1 秒硬提交稳妥得多。

5.4 随机抽题连续抽到相似题

现象:用户反馈试卷前半部分全是同一知识点的题,后半部分又是另一个知识点,连题型都规律性重复。原因:按分类抽题后直接把结果拼在一起,没有做整体洗牌,或者洗牌算法本身写错了。解决:用 Fisher-Yates 洗牌,抽题流程最后整体再洗一次;做题时给每道题加“上一题知识点”和“下一题知识点”的对比检查,如果相同就再交换一次。这个细节直接关系到用户对试卷公平性的判断,值得认真做。

5.5 刷新后答案丢得一干二净

现象:用户答到 20 题,不小心按了 F5,回来发现要从第一题重新开始。原因:答案只存在 React 组件或页面变量里,没有任何持久化。解决:每次选择答案后立即写 localStorage 快照;页面加载时先尝试恢复快照,校验 examId 一致才使用;交卷成功后必须清除快照。这里容易翻车的不是快照读写,而是清除时机:交卷请求还在 pending 时就删快照,失败了用户就连重试的机会都没有。

6. 让答题程序真正可发版:判分单测、答题埋点与填空扩展

很多人觉得判分函数写完就完了,其实它是最值得写单测的代码。判分规则是业务里最容易“说一套做一套”的地方,我把规则固化成最小测试集,每次改动判分都能立刻知道有没有破坏旧行为:

function assert(cond, msg) { if (!cond) throw new Error(msg); } function testScoreQuestion() { const singleQ = { type: 'single', answer: 'B', score: 2 }; assert(scoreQuestion(singleQ, 'B') === 2, '单选正确应得 2 分'); assert(scoreQuestion(singleQ, 'A') === 0, '单选错误应得 0 分'); const multiQ = { type: 'multiple', answer: ['B', 'C', 'D'], score: 3 }; assert(scoreQuestion(multiQ, ['C', 'B', 'D']) === 3, '多选乱序全对应得 3 分'); assert(scoreQuestion(multiQ, ['B', 'C']) === 0, '多选少选默认 0 分'); const blankQ = { type: 'blank', answer: ['正则表达式', 'regex'], score: 2 }; assert(scoreQuestion(blankQ, ' 正则表达式 ') === 2, '填空去空格后应判对'); assert(scoreQuestion(blankQ, 'Regex') === 2, '填空大小写不敏感'); console.log('判分单测全部通过'); }

答题埋点也别等到上线后再补。我在每题判分后记录一条结构:题目来自哪个分类、用户是否答对、用时多少秒。这些数据汇总起来就是题目的正确率和平均耗时,能直接回答“哪些题出得太偏”“哪类题大家花的时间最多”。统计口径要一开始定好:只统计有效交卷的试卷,中途退出未交卷的不计入,否则数据会被刷题练手的人带偏。

填空题扩展是很多答题程序从练习走向考试时绕不开的一步。目前normalizeAnswer已经处理了空格、全角和大小写,但答案仍是精确匹配。如果要支持“包含匹配”或“关键词得分”,需要在判分函数里新增一个匹配模式参数,精确、包含、正则三档可配,不要直接在旧函数里加判断,否则后面每加一种题型这个函数都会膨胀一圈。

我自己的习惯是:先口头把判分规则说一遍,说给需求方听也好,说给自己听也好,确认“这题怎么算对”之后,先写单测,再写界面。界面越往后做越漂亮,改动成本也越高;判分规则却是在一两行代码之间就能改掉的,越早固定越好。这个顺序一旦颠倒,后面每次改判分都要连带调界面、调报表、调历史数据,那才是真的痛苦。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进