☰
掌上题库小程序开发:登录修复、在线考试与题库导入实战
2026/10/2 5:37:35 网站建设 项目流程

简介:这款微信小程序源码是一套功能完整的掌上题库解决方案,面向需要快速搭建在线考试与刷题平台的开发者或运营者。源码已修复登录接口,支持在线考试、自定义考题导入、积分商城、知识点分类、多种刷题模式及流量主接入,首页模块涵盖专项刷题、题型刷题、乱序刷题、我的收藏、我的错题、未作习题、刷知识点和考前必背等,功能覆盖面广。资源包含1054个文件,其中以JS逻辑脚本、JSON配置、WXSS样式、WXML页面结构为主,并附有若干GIF演示图与PNG素材,有助于理解页面布局与交互流程;整体压缩包仅9.3MB,搭建采用微擎通用安装方式,适合具备基础微信小程序开发知识的学习者。目前已有2037人学习/下载,从源码中可获取完整的前端页面结构、业务逻辑实现以及商户收益、流量主收益等商业化配置思路,便于二次开发与上线运营。

1. 掌上题库小程序源码拿到手之后,先确认这几个核心模块

有些开发者下载一份“掌上题库”微信小程序源代码,解压后习惯性先看页面目录,再编译,结果发现第一个页面就卡在登录上。这不是个例:登录接口失效在网上的小程序源码里几乎成了标配,原因很直接,源码里写死的 appid、secret 和接口域名都属于原作者,微信服务器不认;即便改成自己的,也常常漏了在小程序后台配置 request 合法域名。更麻烦的是在线考试模块,很多源码只做了前端页面,交卷时把答案传回后端,后端判分逻辑却是空的,或者直接把所有答案下发到客户端,这在考试场景里就是硬伤。自定义导入考题同样如此,多数源码提供的入口只能改数据库,没有可视化导入通道。下面这套方案不依赖某个具体的源码包,而是按一个可运行的“掌上题库”小程序应该具备的三个能力来拆:登录链路、考试状态机、题库导入。适合准备拿源码二次开发的人,也适合从零搭题库小程序时对照检查。

2. 修复登录接口:从 wx.login 到服务端 session 的完整链路

登录接口是小程序全部依赖的地基。掌上题库这类工具型小程序,用户不需要输入账号密码,而是通过微信的“静默授权”拿到 openid,再与题库收藏、错题记录、考试记录绑定。

2.1 登录失败的常见位置与断点

先看登录链路的完整形状:小程序端调用 wx.login,拿到一个临时凭证 code;后端拿着 code 请求微信接口 jscode2session;微信返回 openid、session_key 等数据;后端再把这些信息映射成自己的会话 token。网上源码登录失效,通常发生在这几处:

第一,code 被错误复用。wx.login 返回的 code 用一次即作废,5 分钟有效。有些源码会把 code 保存在全局变量里,等用户下次打开时还在用同一个 code,微信接口会返回 errcode 40029。第二,AppSecret 不对或缺失。网上源码里写死的 secret 是原作者的,你自己项目里的 secret 才是有效的。即便换成了自己的,也要注意“小程序密钥”和“微信支付密钥”是两回事。第三,很多源码省略了用户信息校验。真实场景下,jscode2session 返回的 session_key 需要结合 wx.getUserInfo 的签名做校验,但很多源码直接跳过了。第四,小程序后台没有把请求域名加进白名单。开发工具里勾选了“不校验合法域名”可以跑通,真机调试就会直接 fail。

定位问题的时候,不一定要开网络抓包。我习惯分成三段排查:先用 curl 直接打微信接口,验证 code 和 secret 是否有效;再打自己后端的登录接口,看有没有收到 code;最后看小程序控制台的 Network 面板里 login 请求的响应体。下面的 curl 是标准测法:

curl "https://api.weixin.qq.com/sns/jscode2session?appid=APPID&secret=APPSECRET&js_code=CODE&grant_type=authorization_code"

如果返回 JSON 里有 openid 和 session_key,说明 code 有效;如果返回 errcode 40013 或 40125,问题在 appid/secret;40029 则代表 code 已过期或重复使用。这一段排查能筛掉大半“登录接口挂掉”的假象。

2.2 修复后的登录接口代码与关键参数

修复登录接口不能只在客户端换个 url,服务端也要重写。下面是一份常见的 Node.js 实现,逻辑简单但把该有的边界都补上了:

// 服务端:/api/login const express = require('express'); const axios = require('axios'); const jwt = require('jsonwebtoken'); const router = express.Router(); router.post('/api/login', async (req, res) => { const { code } = req.body; if (!code) return res.status(400).json({ message: 'missing code' }); // 本地联调时,可以走 mock 逻辑,不请求微信服务器 if (process.env.MOCK_LOGIN === '1') { const mockOpenid = 'mock_openid_' + Date.now(); return res.json({ token: jwt.sign({ openid: mockOpenid }, process.env.JWT_SECRET, { expiresIn: '2h' }), expiresIn: 7200, userInfo: { openid: mockOpenid } }); } const appid = process.env.WX_APPID; const secret = process.env.WX_SECRET; const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`; try { const { data } = await axios.get(url); if (data.errcode) { return res.status(401).json({ message: `wechat login failed: ${data.errmsg}` }); } // openid 是用户的真实唯一标识,session_key 不能返回给前端 const token = jwt.sign( { openid: data.openid, sessionKey: data.session_key }, process.env.JWT_SECRET, { expiresIn: '2h' } ); res.json({ token, expiresIn: 7200, userInfo: { openid: data.openid } }); } catch (err) { res.status(502).json({ message: 'wechat api unreachable' }); } });

代码里有几个点要注意。MOCK_LOGIN 是我加的环境变量,版本上默认关闭,本地调试时置 1 后不需要依赖微信就能测通整个登录闭环,代价是拿不到真实 openid。JWT_SECRET 必须通过环境变量注入,写死在源码里等于没有登录。token 的有效期设 2 小时,对考试场景够用,但后续要处理刷新逻辑。session_key 不能放进返回给前端的 userInfo 里,它参与用户信息加密数据的解密,泄露后有安全风险。

登录后需要在小程序端存储几个字段,整理成表格方便对照:

| 字段 | 存储位置 | 生命周期 | 用途 | | token | wx.setStorageSync('token') | 2 小时 | 请求头 Authorization 使用的凭证 | | expiresIn | wx.setStorageSync('expiresIn') | 2 小时 | 秒数,用于判断何时该刷新 | | userInfo | wx.setStorageSync('userInfo') | 长期 | 展示用户信息,不能作为权限唯一依据 |

如果需要做主动刷新,可以额外增加 refreshToken,但基础版用“过期后重新 wx.login”也足够。

2.3 小程序端怎么配合新登录接口

小程序端不能还按 wx.login 成功就 setStorage 的做法处理。要封装一个 promise 版本的 login,并且把 code 的生命周期限制在单次请求内。下面这段代码是常见写法:

// utils/auth.js const loginPromise = new Promise((resolve, reject) => { wx.login({ success: (res) => { if (!res.code) return reject(new Error('wx.login failed')); wx.request({ url: `${getApp().globalData.baseUrl}/api/login`, method: 'POST', data: { code: res.code }, success: (loginRes) => { const { token, expiresIn } = loginRes.data; wx.setStorageSync('token', token); wx.setStorageSync('expiresIn', expiresIn); resolve(token); }, fail: reject }); }, fail: reject }); }); function ensureLogin() { const token = wx.getStorageSync('token'); if (token) return Promise.resolve(token); return loginPromise; } module.exports = { ensureLogin };

这里把 wx.login 的 code 只使用一次,没有缓存。ensureLogin 在每次需要身份信息时调用,token 存在就返回,不存在就重新登录。考试页面进入前调用 ensureLogin,避免重复弹登录框。请求拦截器里再加一层 401 处理,统一跳回登录页或重新调 ensureLogin。这样做以后,登录接口就不再是“一次性能通就完事”,而是能在 token 过期后自愈。

登录联调时,我会先用开发者工具跑通,再换真机。真机测试时,直接利用调试器的 Network 面板看请求,足够定位绝大部分问题。注意真机上必须把 api 域名配置为 HTTPS,且在小程序后台添加 request 合法域名。

3. 支持在线考试:题目加载、答题态与交卷判分

登录通了以后,考试模块才能真正关联到用户。在线考试不是一个“显示题目”的页面,而是一套状态机:进入考试、答每道题、切换题目、倒计时归零、交卷、判分、记录结果。

3.1 把题目标头设计成可扩展的 JSON 结构

很多掌上题库源码把题目存成数组,题干和选项全放一个字段里,这样导入简单,但考试页面要支持的题型一多就乱。我的做法是把每道题设计成标准结构,保存到数据库也按这套 JSON 映射:

{ "examId": 20301, "title": "安全生产在线考试(2025 春)", "duration": 1800, "questions": [ { "id": "q_1001", "type": "single", "stem": "下列哪项属于三级安全教育的内容?", "options": ["厂级教育", "班组教育", "车间教育", "以上都是"], "answer": 3, "score": 5 }, { "id": "q_1002", "type": "multi", "stem": "进入受限空间前,需要检测哪些气体?", "options": ["氧气", "可燃气体", "有毒气体", "二氧化碳"], "answer": [0, 2], "score": 8 } ] }

answer 字段只存在于测试数据中,真实接口不应该下发。前端答题时记录的只是用户选择的选项,不能拿到标准答案。如果要支持判断题,可以在 options 里固定为 [“正确”, “错误”],type 设为 judge,判分逻辑与 single 一致。这样做的好处是一个渲染函数可以处理所有带选项的题型。

题目数量多时,一次 setData 传输所有题目会触发性能警告。我一般会将试卷分成“试卷基本信息”和“题目列表”两次请求,题目列表只下发当天要考的题目,最多控制在 100 题以内。小程序单次 setData 的 JSON 数据量最好小于 1MB,超过后 Android 低端机上会有明显卡顿。

3.2 答题态管理:用哈希表存 userAnswers

考生每点一个选项,考试页要立刻把答案记录下来,并且支持返回修改。用数组存答案会带来查找和更新的时间复杂度问题。最直观的是用对象,以题目的 id 为 key:

const userAnswers = {}; userAnswers['q_1001'] = 3; userAnswers['q_1002'] = [0, 2]; // 多选题存数组 userAnswers['q_1003'] = 0; // 判断题存索引

这个对象可以放在页面的 data 中,也可以放进全局 storage。我的习惯是同时写两处:data 里用于渲染答题卡,storage 里用于意外退出后恢复。恢复策略是进入考试时检查 storage 中是否有未完成且未过期的考试记录,有就弹窗询问“继续上次答题”。

答题卡组件根据 userAnswers 的 key 是否存在,决定某个题号显示为“已答”还是“未答”。这比用数组 index 对应题号要稳,因为题目顺序如果被服务端随机打乱,数组下标对应关系会全错。

3.3 倒计时与切后台的边界处理

倒计时是考试中最容易出 bug 的地方。直接在小程序里写 setInterval 每秒钟减少 1,运行时没问题,但用户按 Home 键切入后台,定时器会被系统挂起。回来以后显示的时间比真实时间短,用户就占了便宜。因此我采用“后端时间戳 + 前端秒数校准”的方式:进入考试时,服务端在返回试卷的同时返回 startTimestamp 和 duration;前端用一个定时器每秒计算“当前时间戳 - startTimestamp”得到剩余秒数,而不是单纯递减。

// 考试进行中 startCountdown() { this._timer = setInterval(() => { const now = Date.now(); const remainMs = this.data.startTs + this.data.duration * 1000 - now; if (remainMs <= 0) { this.submitExam(); return; } const remainSec = Math.floor(remainMs / 1000); const h = String(Math.floor(remainSec / 3600)).padStart(2, '0'); const m = String(Math.floor((remainSec % 3600) / 60)).padStart(2, '0'); const s = String(remainSec % 60).padStart(2, '0'); this.setData({ remainTime: `${h}:${m}:${s}` }); }, 1000); }

计时的核心变量是 startTs 和 duration,前者来自服务端,后者是试卷数据中的字段。这样即便定时器在切后台时丢帧,计算公式也会在恢复后自动校准。注意在页面 onHide 时不要清除定时器,因为用户可能只是临时切换到微信其他会话,onShow 回来时继续计算即可;但要在 onUnload 时清除,避免内存泄漏。

3.4 判分不能只在客户端做

网上不少源码的在线考试是“提交答案后前端算分,然后写进 storage”,这在小程序里等于直接给用户开了外挂。任何一个访问过考试页的人,都能从代码包里读到判分逻辑甚至试题答案。正确的姿势是服务端判分:前端把 userAnswers 传给后端,后端从数据库读取该场考试的标准答案,逐题比对,并校验考试时间、用户身份。

下面是一段 Node.js 的服务端判分函数,配合 MySQL 实现:

async function submitExam(userId, examId, answers, elapsed) { // 检查该用户是否已经交过卷,防止重复提交 const existing = await db.query('SELECT id FROM exam_records WHERE user_id = ? AND exam_id = ?', [userId, examId]); if (existing.length > 0) throw new Error('already submitted'); const exam = await db.query('SELECT duration, start_ts FROM exams WHERE id = ?', [examId]); if (!exam.length) throw new Error('exam not found'); const { duration, start_ts } = exam[0]; const maxElapsed = duration + 60; // 给 60 秒网络缓冲 if (elapsed > maxElapsed) throw new Error('timeout'); const questions = await db.query('SELECT id, type, answer, score FROM questions WHERE exam_id = ?', [examId]); let score = 0; const details = []; for (const q of questions) { const userAns = answers[q.id]; let correct = false; if (q.type === 'multi') { const arrUser = Array.isArray(userAns) ? userAns.slice().sort() : []; const arrStd = JSON.parse(q.answer).slice().sort(); correct = arrUser.length === arrStd.length && arrUser.every((v, i) => v === arrStd[i]); } else { correct = userAns === q.answer; } if (correct) score += q.score; details.push({ questionId: q.id, correct }); } await db.query('INSERT INTO exam_records (user_id, exam_id, score, answer_detail) VALUES (?,?,?,?)', [userId, examId, score, JSON.stringify(details)]); return { score, details }; }

这段代码里有两个容易被忽略的参数。第一个是 elapsed,它由客户端计算传上来,只能作为参考。服务端真正应使用 start_ts 字段里记录的开考时间。如果担心用户篡改设备的系统时间,可以把 start_ts 在开考时存到后端缓存中,例如 Redis,交卷时用 Redis 时间对比。第二个是重复提交,必须在数据表上加用户和考试的联合唯一索引,单靠应用层检查会漏掉并发请求。

判分结束后返回给前端的应该是每道题的结果和总得分,而不是重新计算过程。前端拿到结果后展示成绩页面,这个过程要等一下网络,不需要做乐观 UI 加动画,否则用户会以为交卷没有生效。

4. 自定义导入考题:从批量文件到题库落库

标题里“自定义导入考题”是小程序管理员需求里最常见的一项。很多源码只提供数据库 SQL 脚本,管理员不懂 SQL,只能找开发改数据库。更合理的是做一个后台导入接口,让管理员通过上传文件批量导入题目。

4.1 确定导入模板:CSV 比 Excel 更稳

小程序端 wx.chooseMessageFile 可以选择文件,也可以限制 extension 为 xlsx 和 csv。但从解析可靠性和跨端一致来看,我推荐优先支持 CSV。Excel 的 xlsx 实际上是一个 zip 包,小程序端解析需要引入 xlsx 的 js 库,体积不小,且受小程序代码包 2MB 限制影响。CSV 是纯文本,后端用标准库解析,前端即使不做解析,转发给后端也没问题。

定义一份模板要求如下:

type,stem,optA,optB,optC,optD,answer,analysis single,"1+1 等于几?",2,3,4,5,A,基础加法 multi,"下列哪些是偶数?",2,3,4,5,AC,偶数的定义 judge,"冬天有可能下雪",true,false,,,true,注意是"有可能"

这里 type 取值 single 表示单选,multi 表示多选,judge 表示判断题。判断题的 optA 设置为 true,optB 设置为 false,answer 也填 true 或 false。CSV 中如果题干或选项里本身包含逗号,必须用双引号包裹整个字段。线上导入前先在后端做一次格式校验,把有问题的行号返回给管理员,而不是一次性中断。

4.2 后端解析入库的幂等处理

服务端接收文件后,第一步是读文件内容,第二步是解析成结构化数据,第三步是逐行校验,第四步是事务批量插入。下面给出 Node.js 的解析函数:

const fs = require('fs'); const csv = require('csv-parse/lib/sync'); function importQuestionsFromCsv(filePath, bankId) { const content = fs.readFileSync(filePath, 'utf8'); const rows = csv(content, { columns: false, trim: true, skip_empty_lines: true }); if (rows.length < 2) return { imported: 0, errors: [{ line: 1, msg: 'no data' }] }; const questions = []; const errors = []; for (let i = 1; i < rows.length; i++) { const row = rows[i]; if (row.length < 8) { errors.push({ line: i + 1, msg: 'column count mismatch' }); continue; } const [type, stem, optA, optB, optC, optD, answer, analysis] = row; if (!['single', 'multi', 'judge'].includes(type)) { errors.push({ line: i + 1, msg: `unknown type: ${type}` }); continue; } const options = [optA, optB, optC, optD].filter(v => v !== ''); questions.push({ bankId, type, stem, options, answer: type === 'multi' ? answer.split('').map(a => 'ABCD'.indexOf(a)) : (type === 'judge' ? (answer === 'true' ? 0 : 1) : 'ABCD'.indexOf(answer)), analysis }); } // 批量插入,db.insertMany 使用事务 const inserted = db.insertMany(questions); return { imported: inserted.length, errors }; }

这里 answer 存储的是索引。single 和 judge 的答案可以统一为数字索引,multi 存数字索引数组。注意 CSV 的列顺序必须与模板一致,否则导入结果完全出错。另一个重点是幂等性:重复导入同一个文件不能产生重复题目。最简单的手段是给题库的数据表加唯一索引,锁定 bank_id 加题干哈希值:

ALTER TABLE questions ADD COLUMN stem_hash char(32) NOT NULL DEFAULT '', ADD UNIQUE KEY uk_bank_stem (bank_id, stem_hash);

导入前对题干做 md5 存入 stem_hash。这样同一个题库下完全相同的题干会被数据库拒绝,应用层拿到 duplicate 错误后可以把行号反馈给管理员。如果需要覆盖更新旧题目,先根据 bank_id 和 stem_hash 查询已有题目的 id,再做 update 即可。

4.3 小程序端触发导入的完整流程

如果要在小程序内实现导入功能,界面可以放在“管理”页面。用户点击选择文件,调用 wx.chooseMessageFile 选择聊天记录里的文件,再通过 wx.uploadFile 提交到后端。示例代码:

wx.chooseMessageFile({ count: 1, type: 'file', extension: ['csv'], success(res) { const tempFile = res.tempFiles[0]; wx.uploadFile({ url: `${config.baseUrl}/api/import/questions`, filePath: tempFile.path, name: 'file', formData: { bankId: '20250601' }, success(uploadRes) { const result = JSON.parse(uploadRes.data); if (result.imported > 0) { wx.showToast({ title: `成功导入 ${result.imported} 题` }); } else { wx.showModal({ title: '导入失败', content: JSON.stringify(result.errors.slice(0, 5)) }); } } }); } });

注意 wx.uploadFile 的 name 字段是“file”,后端接收文件时要使用相同字段名。uploadFile 的 formData 用于传递普通文本参数。上传接口要校验登录态,管理员身份的判断不能只靠前端隐藏按钮,必须在后端用 token 解析出的角色做鉴权。题库的 bankId 可以由服务端根据管理员的权限范围自动补全,不需要前端传,避免越权导入到别的题库。

5. 上线前必调的 3 个细节:登录态存储、考试防作弊、题库更新策略

5.1 登录态存储别只依赖 setStorageSync

很多源码把 token 直接存到 storage,但小程序在 iOS 上偶尔会出现 storage 写入失败,特别是内存清理后剩余容量很小。稳妥做法是封装一层 storage 读写:

const safeSet = (key, value) => { try { wx.setStorageSync(key, value); return true; } catch (e) { return false; } };

同时把 token 放到内存变量中,避免每次读取 storage。小程序重启后 storage 丢了会导致 401,所以 ensureLogin 里要分两层判断:内存有 token 直接用;没有再去 storage 碰运气;都没有才重新 wx.login。

5.2 考试防作弊做两个开关

第一,禁止截屏。小程序里可以监听 onUserCaptureScreen 事件,在考试页的 onLoad 里注册:

wx.onUserCaptureScreen(() => { wx.showModal({ title: '警告', content: '考试期间不允许截屏' }); });

这个事件只能提示,不能真正阻止用户截屏。第二,切后台检测。在 onHide 记录时间戳,onShow 时判断如果离开时间超过阈值,比如 30 秒,就弹出“本次离开时间过长,是否继续作答”,继续作答后把进入后台次数加一,超过三次自动交卷。服务端也要记录最后一次心跳时间,交卷时如果心跳间隔超过阈值,判分时对该场考试标记异常,考试记录里保留现场,方便事后人工核对。

5.3 题库更新策略:版本号增量下发

题库内容是动态的,每次让用户强制下载整包不现实。建议维护一张题库版本表:version_id、bank_id、update_time。小程序启动时请求 /api/bank/latest-version,拿到最新 version_id 与本地存储的 version_id 比对。不同则请求增量接口,传入本地 version_id,后端返回该版本之后新增或修改的题目,前端合并写入 storage。更新过程要避免在考试进行中触发,不然答题数据可能被覆盖。可以设置“考试结束后提示更新”,给出一个更新按钮。这个策略也支持自定义导入后的即时可见:管理员导入新题成功后,更新 version_id,客户端下次启动就能看到。

把这三个细节固化成检查清单,每次发版前照着过一遍:storage 写入是否安全、截屏和切后台处理是否生效、题库版本更新是否干净。这样从源码包拿过来的“掌上题库”,才能真正可靠地用于在线考试。

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

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

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

立即咨询