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

发布时间:2026/10/13 13:04:52
答题程序源码实现要点:题库设计、判分与提交防重 简介这是一个面向游戏开发者与答题活动策划的答题程序完整源码包覆盖知识问答、智力竞赛、娱乐互动等场景能帮助阅读者快速理解答题器的总体架构与二次开发要点。压缩包共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 都是 q001category是给分层抽题用的分类键想按“javascript / css / 网络基础”抽题就靠它score是单题分值及格线passScore单独放不要在代码里写死 60。analysis判分用不到但练习模式要展示解析最好一开始就留好这个字段后面不需要回头改数据结构。2.2 题库校验脚本把“题目录错了”挡在上线前题库一旦超过几十道题靠人眼检查不现实。多人维护时的典型错误包括 id 写重复、单选题答案写成了两个字符、多选题只给了一个正确项。这些错误不在上线前拦住就要在用户交卷后才发现那时候已经晚了。我一般会配一个校验脚本保存题库后先跑一遍再发布。import json import sys def load_exam(path): with open(path, encodingutf-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]), 题)这段脚本有几个参数和细节说明。第一文件读取必须用encodingutf-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]排序后全等可配置部分给分选项顺序不同导致误判判断 judgebooleantrue/false全等比较存成字符串 true 后类型不匹配填空 blankstring[]多个可接受答案归一化后全等空格、全角、大小写、答案别名填空题的归一化是很容易被低估的部分。用户可能在答案里多打个空格也可能输入全角的括号或字母。判分函数里必须有一层“清洗”把转成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(/[---]/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已经处理了空格、全角和大小写但答案仍是精确匹配。如果要支持“包含匹配”或“关键词得分”需要在判分函数里新增一个匹配模式参数精确、包含、正则三档可配不要直接在旧函数里加判断否则后面每加一种题型这个函数都会膨胀一圈。我自己的习惯是先口头把判分规则说一遍说给需求方听也好说给自己听也好确认“这题怎么算对”之后先写单测再写界面。界面越往后做越漂亮改动成本也越高判分规则却是在一两行代码之间就能改掉的越早固定越好。这个顺序一旦颠倒后面每次改判分都要连带调界面、调报表、调历史数据那才是真的痛苦。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询