简介:这份资源是面向微信小程序开发者与计算机相关专业学生的答题考试系统设计源码,适合作为课程设计、毕业设计或小程序全栈练习的参考方案,帮助解决从题库管理、答题交互到成绩统计等环节的实现问题。压缩包共2029个文件,约29.63MB,以1749个JavaScript文件为核心业务逻辑,配合190个JSON配置、43个Markdown说明文档,以及少量HTML、CSS、XML与TXT文件,整体结构覆盖前端页面、数据配置与项目说明等层面。目前已有1050人学习下载,说明该方案在同类选题中具备一定参考价值。读者可从中获取完整的答题考试系统目录组织方式、页面与逻辑分层思路、题库与配置数据的组织方法,并借助说明文档快速理解模块职责,适合在此基础上二次开发或对照完善自己的小程序项目。
1. 从零搭一套微信小程序答题考试系统:源码里真正值钱的是什么
带过几个校园竞赛和内部培训项目后,我发现「基于微信小程序的答题考试系统设计源码」这个标题背后,真正被需要的东西往往不是一套能跑的代码,而是一条能落地的路径:怎么在微信生态里完成登录、怎么把题库和试卷结构设计得能扩展、怎么在服务端判分而不是把答案暴露在小程序包里。微信小程序登录获取手机号、微信小程序单选框、uniapp 微信小程序打包这些热搜词,恰好对应了这条路径上最容易卡住的几个点。这套系统适合两类人:一类是要交课程设计或毕业设计的学生,需要一套结构清晰、能讲清楚原理的源码;另一类是要给企业做内部考试工具的开发,关心的是防作弊、判分准确和后续维护成本。下面按「先想清楚结构,再动手写,最后避坑」的顺序拆开讲。
2. 答题考试系统的数据模型与判分边界:为什么答案不能放在小程序里
2.1 题库、试卷、作答记录三张核心表怎么设计
很多人拿到源码第一反应是看页面,但真正决定这套系统能不能改、能不能扩展的是数据模型。我一般会把数据拆成三层:题库层、试卷层、作答层。题库层存单道题,试卷层定义一次考试包含哪些题、分值多少、顺序如何,作答层记录某个用户在某次考试里的答案和得分。这样拆的好处是同一道题可以出现在多张试卷里,改题不影响历史记录。
以 MySQL 为例,最小可用的表结构如下:
-- 题库表:一道题一行,选项用 JSON 存,避免选项数量变化时改表 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL COMMENT '1单选 2多选 3判断', stem TEXT NOT NULL COMMENT '题干', options JSON NOT NULL COMMENT '选项数组,如["A选项","B选项"]', answer JSON NOT NULL COMMENT '正确答案,单选存["A"],多选存["A","C"]', analysis TEXT COMMENT '解析', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 试卷表:定义一次考试 CREATE TABLE exam ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, duration INT NOT NULL COMMENT '考试时长,单位秒', total_score INT NOT NULL DEFAULT 100, start_at DATETIME COMMENT '开放开始时间', end_at DATETIME COMMENT '截止时间' ); -- 试卷题目关联表:决定题目顺序和分值 CREATE TABLE exam_question ( exam_id BIGINT NOT NULL, question_id BIGINT NOT NULL, score INT NOT NULL COMMENT '该题在本试卷中的分值', sort INT NOT NULL DEFAULT 0, PRIMARY KEY (exam_id, question_id) ); -- 作答记录表:一次提交一行 CREATE TABLE answer_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT NOT NULL, openid VARCHAR(64) NOT NULL COMMENT '微信用户唯一标识', answers JSON NOT NULL COMMENT '用户答案,结构同 question.answer', score INT NOT NULL DEFAULT 0, submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_exam_openid (exam_id, openid) );这段 SQL 的关键点有三个。第一,options和answer用 JSON 而不是拆成 option_a、option_b 这种列,是因为题目选项数量不固定,拆列会让表结构随业务变化频繁改动。第二,exam_question里带score和sort,意味着同一道题在不同试卷里可以有不同的分值和位置,这是后面做随机组卷的基础。第三,answer_record上加了(exam_id, openid)唯一索引,从数据库层面保证一个人一次考试只能提交一次,比在代码里判断更可靠。
提示:如果题目量在几千条以内,JSON 字段完全够用;超过十万条且需要按选项内容检索时,再考虑把选项拆成独立表。
2.2 判分为什么必须放在服务端
把答案写在小程序里,用开发者工具或者抓包工具一抓就能看到,这是血泪经验。判分的正确姿势是:小程序只负责收集用户选的选项,提交给服务端,服务端从数据库读出正确答案再比对。下面是一个 Node.js 判分函数的最小实现:
// 服务端判分:传入试卷ID和用户答案,返回得分和逐题结果 async function gradeExam(examId, userAnswers) { // 1. 查出这张试卷所有题目及正确答案、分值 const questions = await db.query( `SELECT q.id, q.type, q.answer, eq.score FROM exam_question eq JOIN question q ON q.id = eq.question_id WHERE eq.exam_id = ?`, [examId] ); let totalScore = 0; const detail = []; for (const q of questions) { const correct = JSON.parse(q.answer); // 正确答案数组 const user = userAnswers[q.id] || []; // 用户答案,缺省为空数组 // 2. 排序后比较,避免多选顺序不同导致误判 const isRight = correct.length === user.length && correct.sort().join(',') === user.sort().join(','); if (isRight) totalScore += q.score; detail.push({ questionId: q.id, correct: isRight, score: isRight ? q.score : 0 }); } return { totalScore, detail }; }逻辑说明:先按试卷 ID 把题目和分值一次性查出来,避免在循环里查库。比对时对两个数组都做排序再转字符串,这样多选答案「A,C」和「C,A」会被判为相同。参数方面,userAnswers是一个以题目 ID 为键的对象,前端提交时按这个结构组装即可。失败时优先看两点:一是q.answer在数据库里是不是合法 JSON,二是用户提交的题目 ID 是否和试卷里的 ID 对得上,对不上会直接算错。
2.3 微信登录与手机号获取的接入顺序
微信小程序的登录链路是固定的:小程序端调wx.login拿 code,把 code 发给服务端,服务端用 code 换 openid 和 session_key。手机号则是用户点击按钮后,通过getPhoneNumber事件拿到加密数据,再由服务端解密。顺序不能反,必须先有 openid 建立会话,再谈手机号绑定。
// 小程序端:先登录拿 code,再请求服务端换取登录态 wx.login({ success(res) { if (res.code) { wx.request({ url: 'https://your-domain.com/api/login', method: 'POST', data: { code: res.code }, success(r) { // 服务端返回自定义 token,存起来供后续请求使用 wx.setStorageSync('token', r.data.token); } }); } } });服务端拿到 code 后,用小程序 appid 和 secret 去调用微信接口换 openid,这一步必须在服务端做,secret 不能出现在小程序代码里。换到 openid 后生成自己的 token 返回给小程序,后续所有考试相关请求都带这个 token。手机号获取是可选步骤,如果考试系统只需要区分用户,openid 就够了;如果需要发短信通知或实名,再走getPhoneNumber解密流程。
3. 小程序端答题交互实现:单选框、倒计时与提交防抖
3.1 用 radio-group 实现单选题与状态同步
微信小程序原生的radio-group是单选题最省事的方案,但要注意它只负责 UI 选中,数据要自己维护。下面是一个题目列表的渲染片段:
<!-- 每道题一个 radio-group,绑定 change 事件 --> <view wx:for="{{questions}}" wx:key="id"> <view class="stem">{{index + 1}}. {{item.stem}}</view> <radio-group>Page({ data: { questions: [], answers: {} }, // 用户选中某个选项时,把答案写进 answers 对象 onSelect(e) { const qid = e.currentTarget.dataset.qid; const value = e.detail.value; this.setData({ [`answers.${qid}`]: [value] }); } });逻辑说明:>Page({ data: { remain: 0 }, onLoad() { this.startAt = Date.now(); this.timer = setInterval(() => this.tick(), 1000); }, tick() { const elapsed = Math.floor((Date.now() - this.startAt) / 1000); const remain = this.data.duration - elapsed; if (remain <= 0) { clearInterval(this.timer); this.submit(); // 时间到自动交卷 return; } this.setData({ remain }); }, onUnload() { clearInterval(this.timer); // 页面销毁时清理,避免内存泄漏 } });
关键点是startAt用时间戳而不是累加计数,这样即使定时器被暂停,恢复后算出来的剩余时间仍然准确。onUnload里清理定时器是必须的,否则页面反复进入会叠加多个定时器。
3.3 提交防抖与重复提交拦截
用户手快连点提交按钮,或者网络慢时重复请求,都会造成重复提交。前端做防抖,后端做唯一约束,两层都要有:
Page({ data: { submitting: false }, async submit() { if (this.data.submitting) return; // 前端拦截 this.setData({ submitting: true }); try { await wx.request({ url: 'https://your-domain.com/api/submit', method: 'POST', header: { Authorization: wx.getStorageSync('token') }, data: { examId: this.data.examId, answers: this.data.answers } }); wx.redirectTo({ url: '/pages/result/result' }); } catch (err) { this.setData({ submitting: false }); // 失败要恢复,否则用户无法重试 wx.showToast({ title: '提交失败,请重试', icon: 'none' }); } } });submitting标志位在请求发出前置为 true,成功跳转,失败恢复为 false。后端那边靠answer_record的唯一索引兜底,即使前端拦截失效,数据库也会拒绝第二次插入。
4. 避坑与排查:答题考试系统上线前必须过的五道坎
4.1 现象:用户答完题提交后分数为 0
原因通常是前端提交的答案结构和后端预期不一致。比如前端传的是{ "1": "A" },后端按数组["A"]解析,比对时长度对不上直接判错。解决方式是前后端约定统一结构,提交前在控制台打印一次实际数据,确认每个题目的答案都是数组格式。
4.2 现象:多选题全选对了却判错
原因是没做排序比较。用户选「C,A」,正确答案是「A,C」,直接字符串比较会判错。解决方式是在判分函数里对两个数组都排序后再比较,或者用集合的方式判断包含关系。注意多选还要判断数量相等,否则「A,B,C」和「A,B」会被误判为相同。
4.3 现象:小程序切后台再回来倒计时乱了
原因是用了累加计数而不是时间戳。setInterval在小程序进入后台后会被节流甚至暂停,累加出来的时间比真实时间慢。解决方式是记录开始时间戳,每次用Date.now()减去开始时间算已用时长,这样无论定时器是否被暂停,结果都准确。
4.4 现象:打包时提示 source size 超过 2MB
这是 uniapp 微信小程序打包常见的报错,主包体积超限。原因是把题库数据、图片资源都打进了主包。解决方式是把非首屏资源放到分包,题库数据从服务端拉取而不是打包进代码,图片用 CDN 地址而不是本地文件。如果用了 uniapp,检查manifest.json里的分包配置,把答题页、结果页拆到独立分包。
4.5 现象:微信登录偶尔失败,提示 code 无效
原因是 code 只能使用一次,且有效期很短。如果前端重复用同一个 code 请求,或者用户网络慢导致 code 过期,就会失败。解决方式是在wx.login的 success 回调里立即发送 code,不要缓存;服务端换 openid 失败时返回明确错误码,前端收到后重新调wx.login再试一次。
5. 从能跑到好用:随机组卷、防作弊与成绩导出
把基本流程跑通之后,真正拉开差距的是这几个进阶点。随机组卷的思路是在exam_question之外加一张组卷规则表,记录每个题型抽多少题、每题多少分,开考时按规则从题库随机抽取并写入一张临时试卷。这样每次考试题目不同,但难度可控。实现上可以用ORDER BY RAND() LIMIT n,题目量大时改成先查 ID 列表再随机取,避免全表排序。
防作弊方面,小程序端能做的有限,但有几件事值得做:一是禁止切屏,用onHide记录切出次数,超过阈值自动交卷;二是题目顺序和选项顺序都打乱,让抄答案变得困难;三是提交时带上服务端下发的试卷签名,防止用户篡改试卷 ID 去答别的卷子。这些手段不能杜绝作弊,但能挡住大部分随手尝试。
成绩导出是很多内部考试场景的刚需。我一般会在服务端加一个导出接口,按考试 ID 查出所有作答记录,生成 CSV 返回。字段包括 openid、得分、提交时间、逐题对错。如果绑定了手机号,把手机号也带上,方便对接人事或教务系统。导出时注意分页,一次查几千条会拖慢数据库,用游标或者按 ID 范围分批查。
最后说一个我自己的习惯:每次改完判分逻辑,我都会用一组边界数据跑一遍——全对、全错、多选少选、多选多选、空答案、重复提交。这六种情况覆盖了绝大多数线上问题,比事后看日志排查省事得多。这套系统不难,难的是把边界想清楚,希望帮到你。
本文还有配套的精品资源,点击获取