☰
微信小程序在线考试系统全栈开发:源码、数据库与判分策略
2026/10/22 22:12:04 网站建设 项目流程

简介:这份微信小程序在线考试系统项目面向计算机相关专业的毕业设计、期末大作业与课程设计场景,提供一套可直接编译运行的完整源码与数据库方案,评审分达98分,难度适中,适合希望快速搭建考试类小程序并完成答辩展示的学生与开发者。压缩包共1009个文件,约12.69MB,涵盖vue与java后端源码、js逻辑脚本、wxml与wxss小程序页面、json配置、sql数据库脚本以及png、svg等界面素材,前后端结构完整,便于按模块阅读与二次开发。目前已有285人学习下载,说明该方案在同类选题中具备一定参考价值。读者可获得经过助教审定的项目代码、数据库文件与清晰的目录组织,既能用于课程设计直接提交,也能作为学习小程序与后端接口联调的实战案例,帮助理解在线考试系统的整体实现思路与排错方法。

1. 微信小程序在线考试系统:从源码到数据库,一套能跑通的高分项目拆解

带过几届学生做课程设计,我发现一个规律:凡是选“在线考试系统”的,最后交上来的东西两极分化特别严重。一拨人只做了个静态页面,点开始考试弹个 toast 就没了;另一拨人能把整套流程跑通——学生登录、抽题、倒计时、交卷、自动判分、后台看成绩,数据库里每张表的字段都经得起追问。差距不在智商,在于有没有人把“小程序端 + 服务端 + 数据库”这三层怎么串起来讲清楚。这套微信小程序在线考试系统的源码加数据库方案,解决的就是这个串联问题。它适合正在做课程设计、毕业设计,或者想拿一个完整全栈 Demo 练手的小程序开发者。接下来我不讲虚的,直接按“表怎么设计、接口怎么写、判分怎么做、哪里容易翻车”的顺序,把一套可复现的路径摊开。

2. 先想清楚数据模型:五张表撑起整个考试流程

很多人一上来就写页面,写到交卷那一步发现没地方存答案,回头改表结构,牵一发动全身。我的习惯是先把数据模型定死,再动 UI。在线考试系统的核心链路其实就一条:用户登录 → 拿到试卷 → 逐题作答 → 提交 → 判分 → 出成绩。这条链路上需要持久化的东西,拆出来就是五张表。

2.1 用户表与角色字段的设计取舍

用户表看着简单,但角色字段怎么设计直接影响后面接口的鉴权逻辑。常见做法是加一个role字段,用枚举值区分学生和管理员。也有人拆成两张表,学生表和管理员表分开。我一般倾向单表加角色字段,因为登录接口可以复用,鉴权中间件也好写。

CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `openid` VARCHAR(64) UNIQUE COMMENT '小程序用户唯一标识', `nickname` VARCHAR(64) COMMENT '昵称', `role` TINYINT DEFAULT 0 COMMENT '0学生 1管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

openid是小程序登录后拿到的唯一标识,用它做唯一索引,避免同一个用户重复注册。role用 TINYINT 而不是字符串,查询和索引效率更高。这里有个细节:openid字段长度给到 64,实际微信返回的 openid 是 28 位左右,留足余量是为了兼容不同来源的标识。

2.2 试卷、题目、选项三张表的关联关系

试卷和题目的关系是一对多,题目和选项也是一对多。有人图省事把选项直接塞进题目表的一个 JSON 字段里,查询是方便了,但后面要做选项乱序、单题统计正确率的时候就难受了。我建议老老实实拆三张表。

CREATE TABLE `exam` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL, `duration` INT COMMENT '考试时长,单位分钟', `total_score` INT COMMENT '总分', `status` TINYINT DEFAULT 0 COMMENT '0未发布 1已发布' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `question` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `exam_id` INT NOT NULL, `content` TEXT NOT NULL COMMENT '题干', `type` TINYINT DEFAULT 0 COMMENT '0单选 1多选 2判断', `score` INT DEFAULT 2 COMMENT '本题分值', `correct_answer` VARCHAR(16) COMMENT '正确答案,如A或AB', INDEX `idx_exam` (`exam_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `option` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `question_id` INT NOT NULL, `label` CHAR(1) COMMENT '选项标号A/B/C/D', `content` VARCHAR(255) NOT NULL, INDEX `idx_question` (`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

question表里的correct_answer用字符串存,单选题存 “A”,多选题存 “ABD”。这样判分的时候直接做字符串比对,逻辑最简单。option表用label字段存选项标号,前端渲染时按 label 排序,保证顺序稳定。

2.3 答题记录表:判分逻辑的数据基础

答题记录表是判分的数据来源,也是后面做错题分析的基础。这张表要记录谁、在哪场考试、第几题、选了什么。

CREATE TABLE `answer_record` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `exam_id` INT NOT NULL, `question_id` INT NOT NULL, `user_answer` VARCHAR(16) COMMENT '用户选择的答案', `is_correct` TINYINT DEFAULT 0 COMMENT '0错误 1正确', `score` INT DEFAULT 0 COMMENT '本题得分', UNIQUE KEY `uk_user_question` (`user_id`, `exam_id`, `question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里加了一个唯一索引uk_user_question,防止同一个用户对同一道题重复插入记录。交卷接口里用INSERT ... ON DUPLICATE KEY UPDATE就能做到幂等,用户网络抖动重复提交也不会产生脏数据。这个细节在后面避坑章节还会展开。

3. 小程序端怎么跑通:登录、抽题、倒计时、交卷四步走

数据模型定好之后,小程序端的逻辑就清晰了。整个考试流程拆成四个动作:静默登录拿 openid、请求试卷和题目、前端倒计时控制、交卷提交答案。每一步都有容易翻车的地方,我按顺序说。

3.1 用 wx.login 换 openid 的最小闭环

小程序登录不能直接拿 openid,必须走wx.login拿到 code,再把 code 发给自己的服务端,由服务端去换 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: (resp) => { // 服务端返回自定义 token,存入 storage wx.setStorageSync('token', resp.data.token); wx.setStorageSync('userId', resp.data.userId); } }); } } });

服务端拿到 code 后,用 code + appid + secret 去调用微信的接口换 openid。换到之后,查用户表,没有就插入一条新记录,有就直接用。返回给小程序端的 token 是自己签发的,后续接口用这个 token 做鉴权,不用每次都去微信那边换。这里的关键是:code 只能用一次,且有效期很短,服务端换 openid 的动作必须及时。

3.2 抽题接口:随机组卷的两种实现

抽题有两种常见做法。一种是固定试卷,题目和试卷的关联在后台配置好,学生请求时直接按 exam_id 查。另一种是随机组卷,每次从题库里按规则抽题。课程设计里固定试卷居多,但随机组卷更能体现技术含量。

-- 固定试卷:直接按 exam_id 查题目 SELECT q.id, q.content, q.type, q.score, q.correct_answer FROM question q WHERE q.exam_id = ? ORDER BY q.id; -- 随机组卷:从题库随机抽 20 道 SELECT q.id, q.content, q.type, q.score FROM question q WHERE q.exam_id = ? ORDER BY RAND() LIMIT 20;

ORDER BY RAND()在数据量小的时候没问题,题目上千条之后性能会明显下降。如果题库大,常见做法是先用RAND()算出随机 id 范围,再按 id 区间查,或者维护一个随机序列表。课程设计阶段数据量不大,直接用ORDER BY RAND()就够了,但要知道它的边界在哪。

3.3 倒计时与自动交卷的前端实现

倒计时看着简单,但有两个坑:一是用户切到后台再回来,倒计时不能停;二是时间到了要自动交卷,不能只弹个提示。我的做法是用结束时间戳倒推,而不是每秒减一。

Page({ data: { remainSeconds: 0 }, onLoad(options) { const duration = options.duration * 60; // 分钟转秒 this.endTime = Date.now() + duration * 1000; this.timer = setInterval(() => { const remain = Math.floor((this.endTime - Date.now()) / 1000); if (remain <= 0) { clearInterval(this.timer); this.submitExam(); // 时间到自动交卷 } else { this.setData({ remainSeconds: remain }); } }, 1000); }, onUnload() { clearInterval(this.timer); // 页面卸载时清理定时器 } });

用endTime时间戳做基准,即使用户切后台导致定时器被节流,回来之后算出来的剩余时间依然是准的。如果每秒减一,切后台再回来就会少算时间。onUnload里清理定时器是必须的,否则页面销毁后定时器还在跑,会报错。

3.4 交卷接口的幂等处理与事务边界

交卷是整个流程里最不能出错的一步。用户点了交卷,服务端要做三件事:写入答题记录、计算总分、更新考试状态。这三件事必须在一个事务里完成。

// 服务端交卷接口(Node.js + mysql2 示例) async function submitExam(userId, examId, answers) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); let totalScore = 0; for (const ans of answers) { // 查正确答案 const [qRows] = await conn.query( 'SELECT correct_answer, score FROM question WHERE id = ?', [ans.questionId] ); const isCorrect = qRows[0].correct_answer === ans.userAnswer ? 1 : 0; const score = isCorrect ? qRows[0].score : 0; totalScore += score; // 幂等写入:重复提交只更新不新增 await conn.query( `INSERT INTO answer_record (user_id, exam_id, question_id, user_answer, is_correct, score) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE user_answer = VALUES(user_answer), is_correct = VALUES(is_correct), score = VALUES(score)`, [userId, examId, ans.questionId, ans.userAnswer, isCorrect, score] ); } await conn.commit(); return { totalScore }; } catch (e) { await conn.rollback(); throw e; } finally { conn.release(); } }

ON DUPLICATE KEY UPDATE配合前面的唯一索引,保证同一个用户同一道题只有一条记录。事务保证要么全部写入成功,要么全部回滚,不会出现写了一半的情况。判分逻辑放在服务端而不是前端,是因为前端传上来的答案不可信,正确答案绝不能下发到小程序端。

4. 避坑指南:这套系统最容易翻车的五个地方

上面讲的流程跑通不难,难的是跑稳。下面这五个坑,有的是我自己踩过的,有的是看别人代码时发现的,按“现象 → 原因 → 解决”列出来。

4.1 现象:交卷后成绩为 0,但答案明明选对了

原因:前端把选项内容传给了服务端,而不是选项标号。比如题目选项是“A. 北京”,前端传的是“北京”,但数据库里correct_answer存的是“A”,字符串比对自然不相等。

解决:前端提交时只传选项标号,不传选项内容。在option表里label字段就是干这个用的。如果前端已经传了内容,服务端要做一次映射转换,但更好的做法是从源头规范接口字段。

4.2 现象:多选题判分总是错,少选一个也算错

原因:多选题的正确答案存的是“ABD”,用户选了“AD”,直接字符串比对不相等,判为错误。但实际需求可能是少选得部分分,或者少选不给分但也不能算全错。

解决:判分逻辑要按题型区分。单选题直接比对;多选题先判断用户答案是否是正确答案的子集,再决定给分策略。如果需求是“少选得一半分,错选不得分”,代码要相应调整。

function judgeMulti(correct, userAnswer) { const correctSet = new Set(correct.split('')); const userSet = new Set(userAnswer.split('')); // 有错选,直接 0 分 for (const c of userSet) { if (!correctSet.has(c)) return 0; } // 少选,得一半分 if (userSet.size < correctSet.size) return 0.5; return 1; // 全对 }

4.3 现象:用户切到后台再回来,倒计时乱了

原因:用了setInterval每秒减一,小程序切后台后定时器被系统节流甚至暂停,回来之后剩余时间比实际多。

解决:用结束时间戳做基准,每次从Date.now()重新计算剩余秒数。这样无论定时器被怎么节流,算出来的时间都是准的。这个思路在前面 3.3 的代码里已经体现了。

4.4 现象:同一用户重复交卷,答题记录出现多条

原因:交卷接口没有做幂等,用户网络慢点了两次,或者前端没有防重复点击,导致同一份答案提交了两次,answer_record表里出现重复记录。

解决:两个层面处理。前端加提交锁,提交中禁用按钮;服务端用唯一索引 +ON DUPLICATE KEY UPDATE兜底。唯一索引是最后一道防线,即使前端漏了,数据库也不会产生脏数据。

4.5 现象:考试时间到了,但用户还能继续答题

原因:倒计时只在页面层面控制,用户手动修改本地时间或者通过其他方式绕过前端校验,服务端没有做时间校验。

解决:服务端在交卷接口里校验当前时间是否超过考试结束时间。如果超过,仍然接受交卷但标记为超时交卷,或者直接拒绝。更严格的做法是服务端记录考试开始时间,交卷时计算实际用时。前端的倒计时只是给用户看的,真正的约束必须在服务端。

5. 判分策略与成绩统计:从能跑到好用的最后一公里

把流程跑通只是及格线,真正让这套系统拿得出手的,是判分策略的灵活性和成绩统计的完整性。我带学生做这个项目时,最后拉开差距的往往就是这一块。

5.1 按题型配置判分规则的参数表

判分规则不应该写死在代码里,最好做成可配置的。下面这张表是我常用的配置维度,后台可以针对每场考试单独设置。

配置项可选值说明
单选题判分对即得分 / 错即零分单选题一般没有中间态
多选题判分全对得分 / 少选半分 / 少选零分按考试严格程度选
判断题判分对即得分 / 错即零分同单选题
超时交卷接受并标记 / 直接拒绝建议接受并标记,避免用户投诉
重复提交覆盖 / 拒绝建议覆盖,配合幂等写入

这张表的价值在于,它把“判分”从一个硬编码逻辑变成了一个可配置项。后台管理员新建考试时,勾选对应的判分策略,服务端根据策略走不同的分支。代码结构上,可以用策略模式,每种题型对应一个判分函数,新增题型时只加函数不改主流程。

5.2 成绩统计接口:平均分、及格率、错题率一次算清

成绩统计是后台管理端的需求。常见做法是写一个聚合接口,一次返回多个统计指标。

-- 某场考试的平均分和及格率 SELECT AVG(total_score) AS avg_score, SUM(CASE WHEN total_score >= 60 THEN 1 ELSE 0 END) / COUNT(*) AS pass_rate FROM ( SELECT user_id, SUM(score) AS total_score FROM answer_record WHERE exam_id = ? GROUP BY user_id ) AS user_scores; -- 每道题的错误率 SELECT question_id, SUM(CASE WHEN is_correct = 0 THEN 1 ELSE 0 END) / COUNT(*) AS error_rate FROM answer_record WHERE exam_id = ? GROUP BY question_id ORDER BY error_rate DESC;

第一个查询用子查询先算出每个用户的总分,再在外层算平均分和及格率。第二个查询直接按题目分组算错误率,错误率高的题目说明是难点,后台可以重点标注。这两个查询都不复杂,但能覆盖后台最常用的统计需求。

5.3 用错题率反推题目质量

错题率这个指标很有意思。一道题如果错误率超过 80%,不一定是学生没学好,很可能是题目本身有问题——题干有歧义、选项设置不合理、或者正确答案配错了。我一般会在后台加一个提醒:错误率超过阈值的题目自动标红,让管理员复核。

这个功能实现起来很简单,就是在统计接口返回的数据里加一个判断:

const errorRate = row.error_rate; const needReview = errorRate > 0.8; // 错误率超过 80% 标记复核

别小看这个标记,它能把“数据统计”变成“数据驱动”。管理员看到标红的题目,点进去复核,发现是答案配错了,改过来之后下一场考试的错误率就正常了。这个闭环做出来,整套系统就从“能用”变成了“好用”。

5.4 一个我常犯的错误:忽略数据库连接池的释放

最后说一个我自己的血泪教训。早期写交卷接口时,我只写了conn.beginTransaction()和conn.commit(),忘了在finally里conn.release()。本地测试没问题,因为并发低,连接池够用。一部署到线上,几十个人同时交卷,连接池瞬间被占满,后面的请求全部超时。

现象是接口间歇性 500,日志里报“连接池等待超时”。原因就是异常分支下连接没有释放。解决方法是把conn.release()放在finally块里,无论成功失败都释放。这个习惯我后来带到了所有数据库操作里——只要是从连接池拿的连接,必须保证释放。

这套微信小程序在线考试系统的方案,从数据模型到判分策略,再到统计闭环,基本覆盖了一个课程设计项目从及格到优秀的全部关键点。源码和数据库脚本可以照着上面的表结构和接口逻辑自己搭,跑通之后你会发现,真正花时间的不是写代码,而是想清楚每一步的数据流向和边界情况。希望帮到你。

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

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

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

立即咨询