ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信小程序答题考试刷题系统开发实战:源码+数据库完整解析

微信小程序答题考试刷题系统开发实战:源码+数据库完整解析 简介在移动端教学与培训场景中微信小程序因其即用即走、生态成熟的特点成为搭建在线答题、模拟考试与刷题工具的首选载体。理解小程序开发的核心在于数据驱动与本地存储的协同——前端通过JavaScript管理答题状态与计时逻辑后端则依赖结构化数据库设计题组、记录成绩。一套完整的答题系统通常涵盖题目管理、答题交互、自动判分、错题归集等环节而将源码与初始化数据库脚本结合交付能极大降低二次开发门槛。无论是教育培训机构的日常测验还是企业内部考核刷题开发者均可借助此类项目快速落地并基于本地缓存与云端接口配合实现离线答题与成绩同步。本文围绕一套开源的微信小程序答题考试刷题系统梳理其数据库表关系、前端核心逻辑、上线避坑要点与扩展方向为有意构建同类业务闭环的开发者提供工程实践参考。 去年有阵子接了个练手需求想给教育培训机构做一套微信小程序答题系统用在考前刷题、模拟考试和课后测验上。当时搜了一圈现成方案要么是云开发模板改起来费劲要么就是前后端耦合太深根本不适合当独立项目二次开发。后来把一套自用的JavaScript开发微信小程序答题考试刷题系统整理了一下配上初始化数据库脚本打包成“源码数据库”的结构。这篇文章就把这套小程序的完整开发思路、数据库表设计、JavaScript核心逻辑以及我实际运行中踩过的坑一次说清楚给打算开发类似项目的朋友做个参考。这套东西适合谁两类人最对口一类是想给班级、培训机构、企业内部出题考试的人另一类是想拿微信小程序练手、需要一个完整业务闭环的开发者。通过这套源码你能看到题目管理、答题流程、计时交卷、成绩记录、错题收集在真实项目里是怎么组织和实现的。1. 源码包结构拆解从解压到首屏打开的完整流程1.1 拿到压缩包之后先别急着导入先理清目录很多人下载到“源码数据库.zip”之后第一件事就是解压拖进微信开发者工具结果报一堆编译错误或者白屏。这类项目绝大多数本身没问题问题出在没理清目录结构就开始了。我这里直接把通用结构写出来当然你的实际目录可能略有出入miniprogram/ pages/ index/ exam/ result/ wrong/ profile/ components/ question-card/ radio-group/ utils/ storage.js auth.js request.js app.js app.json app.wxss database/ 1_init.sql 2_seed.sql server/如果带后端的话拿到包之后先确认三件事project.config.json里有没有配置appiddatabase目录下有没有SQL脚本pages目录是不是都在app.json里注册了。少了任何一样首屏都不可能正常跑起来。还有一点容易被忽略如果你在开发者工具里打开后一直报“app.json文件未找到”大概率是目录选错了层级。要选含project.config.json的那一层而不是miniprogram这一层。1.2 AppID、合法域名与后端地址的三处配置项目能干净跑起来不是代码多高级而是配置链路通了。这里有三处配置是每次部署都绕不开的。第一处AppID。如果你只想在开发者工具里调试可以用测试号。但真机预览、发布上线必须注册一个小程序账号拿到正式AppID。在project.config.json里改appid: 你的AppID就行。没有正式AppID后面第三步真机调试时会卡在登录授权。第二处合法域名。小程序上线之后wx.request访问的接口域名必须是HTTPS并且在微信公众平台后台配置到“开发管理-服务器域名”里。开发调试阶段可以勾选“不校验合法域名”但上线前记得关掉这个开关很多事故都是因为这个偷懒选项。第三处后端地址。如果项目里用了自建后端比如Java/Node地址一般会集中放在utils/request.js的baseURL变量里。一个很容易踩的坑后端接口在本地跑得好好的换到服务器上就全部超时。不是代码问题是服务器安全组没放行端口或者域名没有备案。微信小程序的request域名必须ICP备案这一点要提前确认不要等项目做完了才去备案会卡你大半个月。1.3 数据库初始化脚本与导入顺序本项目数据库脚本分两个文件1_init.sql建表2_seed.sql灌基础数据。导入顺序不能反seed里用了外键关联表不存在的时候导入必然报错。以MySQL为例初始化命令很简单source /path/to/database/1_init.sql; source /path/to/database/2_seed.sql;如果你用的是Navicat或命令行工具直接执行上面两条。导入完成之后先查一下表结构和数据条数USE exam_app; SHOW TABLES; SELECT COUNT(*) FROM question;一般question表里如果能看到几十条初始题目就说明导入成功了。我把初始化数据的技巧写一下seed里不要只放三五条测试题至少要放一个分类下的完整题目集比如“章节一”的20道题这样前端列表页才能看出分页和筛选效果不然你调试半天也只看到一条孤零零的记录。2. 数据库表设计一个答题小程序最核心的是数据关系2.1 五张核心表的字段设计与关联关系答题小程序和普通内容展示类小程序最大的区别在于它有一套完整的业务状态流转用户要做题、要交卷、要记录成绩、要收集错题。这背后全是数据表在支撑。我实际用的这套设计里最核心的是下面五张表字段基本是项目里长期迭代后沉淀下来的版本。用户表user记录微信用户的基本信息。字段包含openid微信用户唯一标识、nickname、avatar_url、create_time。openid必须建唯一索引它是整个体系里用户识别的核心依据。提一句新规之下小程序获取头像昵称要使用button开放能力不能直接用wx.getUserInfo弹窗了这个后面在页面逻辑里会体现。分类表category题目分类比如“科目一”“科目二”或者“第一章”“第二章”。字段很简单id、name、sort_order。分类表的意义不只是给题目分组它直接影响试卷的组卷逻辑——考试的时候是整卷随机还是分类内随机都要通过category_id圈定范围。题目表question这是整张设计里最需要花心思的。我的字段方案是字段名类型说明idint主键自增category_idint所属分类IDtypetinyint题目类型1单选 2多选 3判断stemtext题干内容optionsjson选项内容格式为JSON数组answervarchar正确答案analysistext解析scoreint分值difficultytinyint难度1简单 2中等 3困难这里options字段用JSON格式存储是经过踩坑之后确定的方案。早期版本用的是option_a, option_b, option_c, option_d四个字段分开存后来题目改版加入“选E”选项所有涉及选项渲染的代码都要改一遍。改成JSON数组后前端只需要拿到options: [选项A内容, 选项B内容, ...]直接循环渲染省掉一堆字段映射。答题记录表exam_record每次交卷生成一条记录。字段包含user_id、category_id、total_score、correct_count、wrong_count、duration用时秒数、create_time。这张表是成绩页和考试历史的数据来源。错题本表wrong_book做题时答错的题自动进错题本。这里要注意一个设计细节wrong_book和exam_record不直接关联而是和question_id关联。因此做错的题目无论来自哪次考试都会被汇总到同一个错题列表里。字段包含user_id、question_id、wrong_count错了几次、create_time、update_time。2.2 什么是“以试卷为中心”和“以答题人为中心”聊聊设计思路。答题系统的数据建模通常有两种思路。第一种是“以试卷为中心”也就是每次考试生成一张完整试卷试卷的题目快照存在卷子里面。好处是历史成绩和提交时看到的一模一样适合严肃考试。坏处是存储冗余大每次组卷都要新建一堆题卷关联记录。第二种是“以答题人为中心”不生成试卷快照只记录用户对每一道题的回答结果用exam_record记录整体成绩用user_answer记录每题作答情况。坏处是如果题库的题后续被改动历史记录里对应的题会显示成新内容严格意义上“当时考的”和“现在看的”对不上。本项目的定位是刷题和模拟考试所以我优先选了第二种方案——它更轻量、数据量小、二次开发也方便。你如果要拿去做正式考试场景建议在exam_record之上再增加一张paper_snapshot表把题目内容复制一份存进去这类场景对历史记录准确性要求更高。2.3 初始化脚本的要点与索引优化1_init.sql里除了建表外索引非常关键。我自己在性能踩坑后总结出几条经验question.category_id、question.type要加索引因为分类筛选、随机组卷都依赖这两个字段做WHERE条件。exam_record.user_id要加索引成绩列表和用户主页默认都要按用户查。wrong_book.user_id wrong_book.question_id建议建联合唯一索引避免同一条错题重复插入。随机抽题是这类小程序的常见功能。ORDER BY RAND()在数据量几千条时问题不大但题库过万后明显变慢。更好的做法是代码里先算出范围内随机ID区间再用WHERE id 随机值 LIMIT n去查。MySQL实话说优先用主键区间随机性能好一个量级。3. JavaScript核心逻辑答题流程、计时交卷与数据响应3.1 页面数据模型为什么我把答案状态放在data里而不是全局变量答题页是小程序里业务逻辑最密集的一页状态管理一旦设计不好后面改起来会非常痛苦。我最终采用的方案是把一组答题状态直接放在页面的data里统一驱动界面渲染。Page({ data: { questions: [], // 当前试卷的题目列表 currentIndex: 0, // 当前第几题 currentQuestion: null, // 当前题目详情 selectedAnswer: [], // 单选存字符串多选存数组 answerList: [], // 所有答题记录 remainTime: 600, // 剩余秒数 isSubmit: false // 是否已交卷交卷后禁点选项 } })这样设计的核心原因小程序里的界面更新靠的是setData数据驱动视图视图上的操作再通过事件回写数据。你要是把状态放在页面的普通全局变量里比如this.answerList []界面不会自动更新你还得手动调用setData去同步多做一步还容易漏。统一放data里的好处是答题切换、选中、提交的每一步动作只需要更新data界面自然跟着变。有一个容易被新手忽略的细节直接在this.data.answerList[index] value赋值并不会触发界面更新必须用this.setData。而且setData有体积限制单次不能超过1MB所以一整套题目的JSON数组建议分页加载不要一次性全塞进setData。3.2 题目切换与选项选中的实现细节题目切换的逻辑很直观点“下一题”时currentIndex 1同时把当前题目的快照更新到currentQuestion。这里有个必须处理好的点用户点了下一题再点上一题之前选的答案必须还在。这就要求answerList在下标维度上做持久化每一次选择题目的同时把答案写入answerList[currentIndex]。代码示例单选选中逻辑handleOptionTap(e) { if (this.data.isSubmit) return; const { index, value } e.currentTarget.dataset; const currentIndex this.data.currentIndex; const answerList this.data.answerList; answerList[currentIndex] value; this.setData({ answerList, selectedAnswer: value }); }多选的逻辑稍微复杂一层要判断当前选项是否已在数组里handleMultiOptionTap(e) { if (this.data.isSubmit) return; const { value } e.currentTarget.dataset; const currentIndex this.data.currentIndex; const answerList [...this.data.answerList]; let selected answerList[currentIndex] || []; if (selected.includes(value)) { selected selected.filter(item item ! value); } else { selected.push(value); } answerList[currentIndex] selected; this.setData({ answerList, selectedAnswer: selected }); }这个方案已经是我迭代过好几版的结果。早期的实现是每题维护单独的selectedAnswer但切题时经常出现“上一题选过的答案跑到下一题”的竞态问题根源就是状态分散。统一收敛到answerList按索引存储后这个问题彻底消失了。3.3 计时器、自动交卷与页面隐藏的清理问题计时是满屏坑的一个功能。常见实现是进入答题页开启setInterval每秒减1秒。但小程序页面可以被切到后台、被其他页面覆盖onHide和onUnload如果不处理定时器轻则该减的不减重则直接内存泄漏或者页面重新展示时倒计时飘了。我在项目里的实现方式onLoad(options) { this.timer setInterval(() { const remainTime this.data.remainTime - 1; if (remainTime 0) { clearInterval(this.timer); this.handleSubmit(); // 自动交卷 return; } this.setData({ remainTime }); }, 1000); } onUnload() { if (this.timer) { clearInterval(this.timer); } } onHide() { // 切后台时不清定时器但记录当前时间戳 this.hideTime Date.now(); } onShow() { // 回到页面时重新校准剩余时间 if (this.hideTime) { const gap Math.floor((Date.now() - this.hideTime) / 1000); this.setData({ remainTime: this.data.remainTime - gap }); this.hideTime null; } }这里有个很微妙的点onHide里不要直接clearInterval不然用户切出去再切回来定时器就没法恢复。比较好的做法是记录切出的时间戳onShow时一次性把离开的秒数扣掉。这种处理方式解决了“后台计时作弊”的问题——用户切出去看资料时间照减。自动交卷后还要通过wx.redirectTo跳转到成绩页并把成绩和答案列表传过去。redirectTo相比navigateTo的好处是可以避免用户从成绩页再“返回”到答题页防止重复提交。3.4 判分逻辑为什么我在前端判分而不是让后端算判断答案对错的逻辑可以放在前端也可以放在后端。本项目选择前端判分理由很简单刷题场景下用户需要即时反馈点完交卷立刻出成绩体验差别很明显。如果是严肃考试建议后端判分防止前端被篡改。前端判分的实现calculateScore() { const { questions, answerList } this.data; let totalScore 0; let correctCount 0; const wrongQuestionIds []; questions.forEach((question, index) { const userAnswer answerList[index]; const isCorrect this.checkAnswer(question.type, userAnswer, question.answer); if (isCorrect) { totalScore question.score; correctCount; } else { wrongQuestionIds.push(question.id); } }); this.setData({ totalScore, correctCount, wrongCount: questions.length - correctCount }); }判断函数里需要注意单选直接字符串比较多选得先排序再比较判断和单选的逻辑一样。多选判断最容易错的是比较顺序问题用户选的是[A,C]答案存的是[A,C]实际可能因为点击顺序不同变成[C,A]。所以比较前先sort()再join()。checkAnswer(type, userAnswer, correctAnswer) { if (type 2) { // 多选 return userAnswer userAnswer.sort().join(,) correctAnswer.sort().join(,); } return userAnswer correctAnswer; }交卷之后还有一个异步操作要留意要把错题写入wrong_book表。这里建议使用批量插入而不是循环单条插入不然错题数量一多网络请求会非常慢。const wrongList wrongQuestionIds.map(qid ({ user_id: userId, question_id: qid, create_time: new Date() })); // 批量插入而非 for 循环逐条 insert这个批量插入的优化在真机低网速环境下特别明显十几条错题单条插入要等十几秒批量插入只需一次请求。4. 数据持久化路线本地存储与云端数据库的配合4.1 本地缓存刷题记录离线保存微信小程序的本地存储API是wx.setStorageSync和wx.getStorageSync。虽然同步API在数据量大时有性能隐患但小体量项目用起来确实方便。这个项目的答题记录、未完成试卷、用户登录态都用了本地缓存。举个例子用户做到第35题退出小程序下次打开还想继续做法就是每次切题时同步当前进度saveProgress() { wx.setStorageSync(exam_progress, { categoryId: this.data.categoryId, currentIndex: this.data.currentIndex, answerList: this.data.answerList, remainTime: this.data.remainTime }); }下次进入答题页时在onLoad里检查有没有对应分类的exam_progress有就提示“是否继续上次答题”。这个功能看设计简单实际效果却特别好。培训场景里用户经常中途退出有了断点续答完考率提升非常明显。4.2 云开发数据库或自建后端怎么选当前小程序的Serverless方案已经很成熟云开发数据库文档型适合中小项目自建后端MySQL适合对数据一致性要求更高的项目。我做选型时主要看三个维度数据量题库和答题记录不超过几十万条云开发完全能扛。并发考试场景通常集中在同一个时间段比如晚上8点机构统一测试偶尔会有并发峰值。云开发有自动扩缩容自建服务器需要自己做好连接池和限流。成本云开发按调用次数计费轻量使用成本很低自建服务器需要买机器维护。如果你想省事又想保留完整数据库能力可以走“本地存题目云开发存成绩”的混合方案。我在这个项目里题库数据因为要支持随机组卷、关联查询选择了MySQL而用户的答题进度、考试成绩这种轻量数据走云开发完全没有问题。两种方案不冲突。4.3 自建后端接口设计登录鉴权与请求参数如果你选择自建后端接口设计要注意几个点。登录接口小程序端使用wx.login拿到code发给后端后端用code换openid再返回一个自定义登录态比如token。后续所有请求都带这个token。注意wx.login的code只能用一次用完即失效。请求封装示例// utils/request.js const BASE_URL https://api.example.com; function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${path}, method, data, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) }, success: (res) { if (res.statusCode 401) { // token过期跳登录 wx.navigateTo({ url: /pages/login/index }); reject(res); return; } resolve(res.data); }, fail: reject }); }); }401统一处理很关键。很多人第一次写小程序每个请求都要单独处理登录过期代码冗余就不提了漏掉任何一个处理都会出现“操作到一半提示登录过期”的糟糕体验。4.4 离线缓存与上报的同步策略刷题场景下离线也要能做题。我的做法是题库按分类预加载到本地存储答题过程完全本地判分。交卷时如果网络可用直接上传不可用就把记录先存本地下次打开小程序时再补传到服务器。syncLocalRecords() { const localRecords wx.getStorageSync(pending_records) || []; if (!localRecords.length) return; const token wx.getStorageSync(token); if (!token) return; // 逐个上传成功就从待同步队列里移除 localRecords.forEach((record, index) { request(/exam/upload, POST, record).then(() { localRecords.splice(index, 1); wx.setStorageSync(pending_records, localRecords); }); }); }这个方案有个边界条件要处理好如果用户一直不上传本地存储的待上传记录会越积越多可能超过setStorageSync的单键上限。所以每次上传前加个数量判断超过50条就提示用户联网同步一次。5. 上线前必须处理的5个隐蔽坑5.1 单选框组件不要直接用原生radio微信小程序的radio组件样式很朴素而且它要求radio-group包裹改样式的自由度极低。答题场景里选项通常需要展示成卡片式列表还要区分选中/未选中/正确/错误状态原生组件基本做不到。我的方案是自写一个组件用view模拟选项通过绑定class控制样式view classoption-card {{selected ? selected : }} wx:for{{options}} wx:keyindex >const systemInfo wx.getSystemInfoSync(); const menuButton wx.getMenuButtonBoundingClientRect(); this.setData({ statusBarHeight: systemInfo.statusBarHeight, navBarHeight: (menuButton.top - systemInfo.statusBarHeight) * 2 menuButton.height });这个代码的逻辑是导航栏高度 状态栏高度 胶囊按钮到状态栏的距离 * 2 胶囊按钮高度。不这样做页面在iPhone 14 Pro Max上和iPhone 8上的显示效果会差很多标题栏不是偏高就是偏低。5.3 抓包排查接口问题真机调试时前端报错但后端没日志是常有的事。建议用好微信开发者工具的“真机调试”功能它可以查看真机上的console日志和网络请求。但要注意真机调试的网络请求不经过开发者工具代理如果后端接口有防火墙限制需要把开发机的IP加入白名单这会让联调变得麻烦。如果你用的是App分析工具类似于网络抓包工具需要注意微信小程序的HTTPS请求默认走系统网络栈抓包时要安装证书并信任证书。这个操作在iOS上尤其麻烦建议还是优先用开发者工具自带的调试能力。5.4 长题库分页加载与渲染性能一张列表页一次性渲染100道题在低端安卓机型上会出现明显卡顿。优化方案是列表页用分页加载只加载当前可见和即将可见的数据。onReachBottom() { if (this.data.loading || this.data.isLastPage) return; const page this.data.page 1; this.setData({ loading: true }); getQuestionPage(page, this.data.categoryId).then((res) { this.setData({ questionList: this.data.questionList.concat(res.data.list), page, isLastPage: res.data.isLastPage, loading: false }); }); }用户滑动到底部时触发onReachBottom每次追加一页数据而不是一次性全部加载。这个改动带来的体验提升立竿见影。5.5 答题中途提交的重复请求防护自动交卷和用户手动交卷可能同时触发。如果用户盯着倒计时在最后一秒点击了“交卷”按钮定时器到0也触发了自动交卷两个请求同时发出成绩记录就会重复。解决方案是加一个交卷锁handleSubmit() { if (this.submitting) return; this.submitting true; // 执行交卷逻辑 }这个submitting标志要在页面实例上定义而非data里因为data的更新是异步的加标志后立即判断可能还没写进去。这一点是实际开发中非常容易忽略的细节我就因为这个bug在线上的成绩记录里出现过大量重复条目排查了好几天才定位到是并发触发导致的。6. 二次开发方向与几个值得扩展的功能点6.1 随机组卷与分类权重目前的题库结构支持随机组卷已经很方便。二次开发时可以给category表加一个question_count字段记录题目数组卷时按分类分配题目。举个实际需求某机构要出100道题的模拟卷要求“第一章30题、第二章40题、第三章30题”。后端接收入参{ categoryConfig: [ { categoryId: 1, count: 30 }, { categoryId: 2, count: 40 }, { categoryId: 3, count: 30 } ] }每个分类内再随机抽取指定的题目数组合成一张完整试卷。这个功能我用了一个简单的SQL查询加代码随机打乱实现。SQL查询题目时按分类取指定条数然后再用Fisher-Yates洗牌算法打乱顺序避免同一分类的题目总是集中出现。实现Fisher-Yates算法的JavaScript代码简洁明了function shuffle(arr) { 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; }6.2 排行榜、打卡与学习报告答题类小程序做完基础功能下一步通常就是加激励体系。排行榜有两种全局榜和好友榜。全局榜需要一张日榜/总榜的聚合表好友榜利用wx.getFriendCloudStorage可以拿到好友的托管数据再合并排行。打卡功能适合结合本地存储记录连续打卡天数。这里有一个很有意思的产品细节判断“连续打卡”不能只判断今天有没有打卡通常要额外判断昨天有没有打卡如果昨天没有连续天数要重置为1。很多打卡类功能bug都出在这个边界条件上。个人学习报告可以复用已有的exam_record数据统计近7天做题数、正确率、各分类薄弱点在个人中心页以图表形式展示。小程序里画图表建议用Canvas自定义绘制或引入第三方图表库如ECharts的小程序适配版不要用WebView加载速度和体验差别很大。6.3 发布上线的检查清单一个答题小程序从开发到上线审核是最容易卡住的环节。这里把检查项列一下类目选择教育-在线教育或者工具-信息查询要看你小程序实际定位。隐私协议涉及收集用户微信信息头像、昵称必须在小程序后台配置《用户隐私保护指引》。用户协议答题考试涉及用户成绩数据最好在个人中心增加用户协议入口。内容安全题目内容不要涉及诱导分享、虚拟交易等违规信息。服务器域名必须HTTPS且ICP备案。登录合规不能用wx.getUserInfo弹窗诱导授权要用button组件触发头像昵称填写能力。这些跑完提交审核基本能一次过。我第一次提审因为类目选了“工具-效率”被驳回了两次后来换成“教育-在线教育”并补充了资质材料才通过。类目选错是最常见也最浪费时间的失败原因提前确认好你的主体资质能支持哪个类目。6.4 关于这套源码的迁移成本最后说一个和迁移成本相关的点。如果你手上拿到的源码是基于老版本基础库写的迁移到新版微信基础库时有几个API已经废弃或者改名了最常见的包括老的登录接口链接已经更新为用新方式唤起登录wx.getUserInfo的授权方式也变了。这些都是小程序的“技术债”动手改造前最好先查一下当前版本的基础库要求。我的经验是拿到源码后不要直接大改先把原有能跑通的流程原样跑一遍确认基线正常再逐个替换废弃API。这个方法看着慢实际上比“边跑边改”省时间因为你能明确知道异常是原代码引入的还是自己改出来的。最后再说两个和数据库直接相关的经验第一题目数据导入不要只依赖SQL脚本。如果后期题量大了建议写一个简单的后台管理页面或者用Excel批量导入。Excel导入需要安装node-xlsx之类的库但能做到表格格式和数据库字段一一对应效率比手写SQL高太多。第二数据库的备份要形成习惯。答题系统的数据库虽然不大但成绩记录、错题本这类数据丢了很难再找回。我一般用mysqldump写个定时任务每天凌晨自动备份一份到服务器其他目录再定期同步到云存储。0 2 * * * mysqldump -u root -p你的密码 exam_app /backup/exam_$(date \%Y\%m\%d).sql我踩过一次备份没配置好的坑一次误操作清了考试记录表好在有昨天的备份数据恢复之后只丢了当天半天的新成绩。从那天起我就把备份当作了系统标配而不是想做才做。本文还有配套的精品资源点击获取
返回列表