掌上题库小程序开发:登录修复、在线考试与题库导入实战 简介这款微信小程序源码是一套功能完整的掌上题库解决方案面向需要快速搭建在线考试与刷题平台的开发者或运营者。源码已修复登录接口支持在线考试、自定义考题导入、积分商城、知识点分类、多种刷题模式及流量主接入首页模块涵盖专项刷题、题型刷题、乱序刷题、我的收藏、我的错题、未作习题、刷知识点和考前必背等功能覆盖面广。资源包含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?appidAPPIDsecretAPPSECRETjs_codeCODEgrant_typeauthorization_code如果返回 JSON 里有 openid 和 session_key说明 code 有效如果返回 errcode 40013 或 40125问题在 appid/secret40029 则代表 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_typeauthorization_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 为 keyconst 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,11 等于几,2,3,4,5,A,基础加法 multi,下列哪些是偶数,2,3,4,5,AC,偶数的定义 judge,冬天有可能下雪,true,false,,,true,注意是有可能这里 type 取值 single 表示单选multi 表示多选judge 表示判断题。判断题的 optA 设置为 trueoptB 设置为 falseanswer 也填 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 写入是否安全、截屏和切后台处理是否生效、题库版本更新是否干净。这样从源码包拿过来的“掌上题库”才能真正可靠地用于在线考试。本文还有配套的精品资源点击获取