简介:这份资源是一套基于微信原生小程序与云开发构建的在线答题小程序完整源码,面向希望快速上手小程序开发、或需要搭建轻量级答题活动平台的开发者与学习者。项目将前端界面与云端能力打通,涵盖答题界面、用户登录注册、题目展示、答案提交、成绩排行与历史记录等模块,并借助云数据库、云函数、云存储实现数据存储、身份验证、逻辑判断与文件托管,适合作为课程设计、练手项目或活动工具二次开发。压缩包为rar格式,共75个文件、约6.06MB,以35张png与4张jpg截图和素材为主,辅以10个wxss、10个json、8个js、6个wxml等小程序页面与配置文件,另含说明文档与忽略配置,目录按pages、utils、image等模块划分,结构清晰便于对照阅读。目前已有224人学习,可帮助读者理解小程序与云开发的集成方式、页面组织与后台题目管理思路。
1. 微信答题小程序:原生小程序加云开发,为什么这套组合至今仍是低成本上线的首选
上周有个做培训的朋友找我,说想搞个内部答题活动,预算不高、时间只有一周,问我用什么方案。我第一反应就是微信原生小程序加云开发——不是因为它多先进,而是这个组合把「服务器、域名、备案、运维」这四件事全绕过去了。你只需要一个已认证的小程序 AppID,开通云开发环境,前端写完直接调云函数和云数据库,答题数据、用户成绩、排行榜全落在云端,连后端接口都不用自己写。这套架构特别适合答题类场景:题库管理、随机抽题、计时交卷、成绩统计,逻辑不复杂但数据交互频繁,云开发的实时数据库和云函数刚好能接住。适合谁?适合前端出身、不想碰服务器、又想快速交付一个能跑能用的小程序的从业者。下面我按「架构怎么搭、题库怎么设计、云函数怎么写、坑在哪」的顺序拆一遍。
2. 架构拆解:原生小程序端与云开发环境怎么分工
2.1 为什么答题场景适合「端上算、云上存」
答题小程序的核心数据流其实很清晰:用户进入 → 拉取题目 → 本地作答 → 提交答案 → 云端判分或存成绩 → 展示结果。这里面真正需要服务端的只有「拉题」和「交卷」两个动作,其余全可以在小程序端完成。云开发的价值就在于把这两个动作变成了一次wx.cloud.callFunction调用,不需要你维护 HTTP 接口、不需要配 SSL 证书、不需要考虑跨域。
我一般会把职责这样切:小程序端负责页面渲染、倒计时、选项交互、本地暂存答案;云函数负责题库查询(含随机抽题)、答案校验、成绩写入、排行榜聚合。数据库分三张集合:questions(题库)、records(答题记录)、users(用户信息,可选)。这样切的好处是,判分逻辑放在云函数里,前端拿不到正确答案,防止有人翻小程序包直接看答案——这是答题类项目最容易被忽略的一点。
原生小程序的优势在于包体积和启动速度。答题页面通常就是题目卡片加选项按钮,用原生view、button组件足够,没必要上跨端框架。云开发 SDK 在小程序里是原生支持的,wx.cloud.init一行初始化,后面直接调,学习成本几乎为零。
2.2 环境初始化与目录结构
开通云开发后,第一件事是在app.js里初始化环境。注意env参数要写你实际的环境 ID,不要用默认的DYNAMIC_CURRENT_ENV在本地调试时容易串环境。
// app.js App({ onLaunch() { if (!wx.cloud) { console.error('请使用 2.2.3 或以上的基础库以使用云能力'); return; } wx.cloud.init({ env: 'your-env-id-xxxxx', // 替换为你的云开发环境 ID traceUser: true // 在用户管理中记录访问用户 }); } });traceUser: true的作用是把访问用户记录到云开发控制台的用户列表里,方便后续做用户维度的成绩统计。env写死环境 ID 而不是动态获取,是为了避免多环境(测试/正式)切换时调错库。
目录结构我习惯这样组织:
miniprogram/ pages/ index/ 首页,活动入口 quiz/ 答题页,核心逻辑 result/ 结果页,成绩与排行 utils/ quiz.js 抽题与计分的纯前端工具函数 cloudfunctions/ getQuestions/ 拉题云函数 submitAnswer/ 交卷判分云函数 getRank/ 排行榜云函数云函数目录和小程序目录平级,在project.config.json里配好cloudfunctionRoot。每个云函数独立一个文件夹,里面index.js加package.json,package.json里依赖wx-server-sdk。这里有个细节:云函数的package.json不需要手动npm install,上传部署时云端会自动装依赖,但本地调试前建议在云函数目录跑一次npm install,否则编辑器会报找不到模块。
2.3 题库集合的字段设计与索引
questions集合的字段设计直接决定后面抽题和判分的顺手程度。我一般这样定:
| 字段 | 类型 | 说明 |
|---|---|---|
_id | string | 云数据库自动生成 |
question | string | 题干文本 |
options | array | 选项数组,如["A选项","B选项"] |
answer | number | 正确选项下标,从 0 开始 |
category | string | 题目分类,用于分卷 |
difficulty | number | 难度 1-3,用于抽题权重 |
status | number | 1 启用,0 停用 |
answer存下标而不是存选项文本,是为了判分时直接比对,避免文本匹配的歧义。status字段是后悔药——活动结束后想下架某道有争议的题,改status为 0 即可,不用删数据。
索引方面,category和status建组合索引,因为抽题时最常见的查询是「取某分类下启用的题」。云开发控制台的数据库索引页面可以直接加,不建索引的话题量上千后where查询会明显变慢。这一点很多新手会忽略,等到活动当天卡顿才回头补。
3. 核心功能落地:抽题、判分、排行榜三段代码
3.1 云函数抽题:随机取题与防重复
抽题逻辑放在云函数getQuestions里。云数据库没有原生的ORDER BY RAND(),常见做法是先用count拿总数,再随机生成偏移量做skip。但skip在数据量大时性能差,题量几百以内可以接受,上千建议改用「预生成随机种子字段」的方案。
// cloudfunctions/getQuestions/index.js const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); exports.main = async (event) => { const { category = '', count = 10 } = event; // 构造查询条件:启用状态 + 可选分类 const where = { status: 1 }; if (category) where.category = category; // 先统计符合条件的题目总数 const totalRes = await db.collection('questions').where(where).count(); const total = totalRes.total; if (total === 0) return { code: 404, msg: '题库为空', data: [] }; // 随机偏移,取不重复的题 const maxStart = Math.max(0, total - count); const start = Math.floor(Math.random() * (maxStart + 1)); const res = await db.collection('questions') .where(where) .skip(start) .limit(count) .field({ answer: false }) // 关键:不把答案下发到前端 .get(); return { code: 0, data: res.data }; };几个参数要说明。count是本次抽题数量,前端传 10 就是 10 道。field({ answer: false })是安全底线,把answer字段排除在返回结果外,前端拿到的题目对象里根本没有正确答案,判分只能回云端做。skip加limit的组合在题量小于 500 时响应稳定在百毫秒级,超过这个量级建议给每道题加一个randomSeed字段(0-1 随机数),查询时用where({ randomSeed: _.gt(Math.random()) })取,性能更好但实现稍复杂。
提示:
skip的随机偏移方案有个边界——当total刚好等于count时,maxStart为 0,start恒为 0,返回全部题目,这是符合预期的,不用特殊处理。
3.2 交卷判分:云函数里比对答案并写记录
判分云函数submitAnswer接收前端提交的答案数组,逐题回查正确答案比对,算分后写入records集合。这里要注意并发和幂等:用户快速点两次交卷,不能写两条记录。
// cloudfunctions/submitAnswer/index.js const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); const _ = db.command; exports.main = async (event) => { const { openid } = cloud.getWXContext(); // 云函数天然拿到用户 openid const { answers = [], quizId } = event; // 幂等检查:同一 quizId 已交过卷直接返回旧成绩 const exist = await db.collection('records') .where({ openid, quizId }).get(); if (exist.data.length > 0) { return { code: 0, data: exist.data[0], repeat: true }; } // 回查题目正确答案 const ids = answers.map(a => a.questionId); const qRes = await db.collection('questions') .where({ _id: _.in(ids) }).get(); const answerMap = {}; qRes.data.forEach(q => { answerMap[q._id] = q.answer; }); // 逐题比对 let score = 0; answers.forEach(a => { if (answerMap[a.questionId] === a.selected) score += 1; }); const record = { openid, quizId, score, total: answers.length, createdAt: db.serverDate() }; await db.collection('records').add({ data: record }); return { code: 0, data: record }; };cloud.getWXContext()拿到的openid是云开发自动注入的,前端伪造不了,这是云开发做用户体系最省事的地方。quizId由前端在进入答题页时生成(比如时间戳加随机串),用来做幂等键。db.serverDate()用服务端时间而不是前端时间,避免用户改手机时间刷记录。判分逻辑里answerMap用对象做映射,比每次find快得多,题量大了也不虚。
3.3 排行榜聚合:用聚合查询替代前端排序
排行榜如果让前端拉全量记录再排序,数据一多就崩。正确做法是云函数里用聚合查询,按分数降序、时间升序取前 N 名。
// cloudfunctions/getRank/index.js const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); exports.main = async (event) => { const { quizId, limit = 20 } = event; const res = await db.collection('records') .aggregate() .match({ quizId }) // 只取本场活动 .sort({ score: -1, createdAt: 1 }) // 分数高优先,同分先交卷的靠前 .limit(limit) .project({ openid: 1, score: 1, total: 1, createdAt: 1 }) .end(); return { code: 0, data: res.list }; };match先过滤quizId,避免把历史活动的记录混进来。sort里score: -1是降序,createdAt: 1是升序,同分情况下先交卷的排前面,这个规则要在活动说明里写清楚,否则用户会来问为什么同分我排后面。project只返回必要字段,减少传输量。聚合查询在云开发里走的是数据库的聚合管道,比前端排序靠谱得多,但要注意match的字段最好有索引,quizId建单字段索引即可。
4. 避坑排查:答题小程序上线前必须过的五道坎
4.1 云函数超时导致交卷失败
现象:用户点交卷后转圈很久,最后提示「网络异常」,但记录其实写进去了。原因:云函数默认超时 3 秒,判分时如果回查题目用了_.in且题目数量多,加上写库,容易踩线。解决:在云开发控制台把submitAnswer的超时时间调到 10 秒,同时把回查题目的_.in分批处理,每批 100 个 ID,避免单次查询过大。
4.2 前端拿到答案字段
现象:有人反馈能提前看到答案。原因:抽题云函数忘了加field({ answer: false }),或者前端把整个题目对象存进了data又在页面上渲染了隐藏字段。解决:云函数侧强制排除answer,前端侧只保留question、options、_id三个字段,其余丢弃。判分永远回云端,前端不做任何答案比对。
4.3 倒计时与后台切换不同步
现象:用户切到微信聊天再切回来,倒计时没走或者直接归零。原因:小程序setInterval在后台会被暂停,切回前台时时间差没补。解决:进入答题页时记录startTime,倒计时用Date.now() - startTime实时计算剩余秒数,而不是靠setInterval累减。setInterval只负责触发重绘,不负责计时。
4.4 数据库权限配置错误导致读写失败
现象:本地调试正常,真机上报「permission denied」。原因:云数据库集合的权限默认是「仅创建者可读写」,而questions集合是管理员写入、所有用户读取,权限不匹配。解决:在控制台把questions的权限改为「所有用户可读,仅管理端可写」,records保持「仅创建者可读写」,云函数走管理端权限不受影响。
4.5 活动高峰期云函数冷启动
现象:活动刚开始那几分钟,前几个用户交卷特别慢。原因:云函数一段时间没调用会冷启动,首次调用要初始化运行环境。解决:活动开始前十分钟手动调几次云函数预热,或者在控制台配置预留实例(如果有额度)。另一个土办法是把判分逻辑尽量精简,减少冷启动时的依赖加载时间。
5. 进阶技巧:把题库导入和成绩导出做成可复用脚本
活动做完,运营那边一定会要两样东西:批量导入新题库、导出成绩做表格。这两件事如果每次手动在控制台点,迟早翻车。我一般会写两个本地 Node 脚本,用云开发的 HTTP API 或者直接在云函数里做批量操作。
题库导入用云函数最稳,因为可以走管理端权限批量写:
// cloudfunctions/importQuestions/index.js const cloud = require('wx-server-sdk'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db = cloud.database(); exports.main = async (event) => { const { list = [] } = event; // 前端或本地传入的题目数组 if (!Array.isArray(list) || list.length === 0) { return { code: 400, msg: '数据为空' }; } // 逐条写入,单次建议不超过 100 条 const tasks = list.map(item => db.collection('questions').add({ data: { question: item.question, options: item.options, answer: item.answer, category: item.category || 'default', difficulty: item.difficulty || 1, status: 1 } })); const results = await Promise.all(tasks); return { code: 0, count: results.length }; };这个函数的关键点是Promise.all并发写入,比串行for循环快一个数量级,但单次别超过 100 条,否则容易触发云函数内存或连接数限制。导入前建议先在本地把 Excel 转成 JSON,字段名对齐上面的集合设计,answer用数字下标,别用「A/B/C」字符串,省得后面判分还要转换。
成绩导出反过来,用聚合查询把records按quizId拉出来,project出openid、score、createdAt,再在本地脚本里转 CSV。openid对运营来说不可读,可以在users集合里存一份openid到昵称的映射,导出时做一次 join。这个映射在用户首次进入小程序时用wx.getUserProfile拿昵称写入即可,注意现在获取用户信息需要用户主动授权,别在onLaunch里静默调。
还有一个验证技巧:上线前用云开发控制台的「数据库」页面手动插 50 条测试题,跑一遍完整流程——抽题、答题、交卷、看排行,确认records里score和total对得上,排行榜排序符合预期。这一步花十分钟,能省掉活动当天半夜被叫起来修 bug 的血泪经验。从那以后我每次交付答题类小程序,都强制走一遍「50 题全流程 + 切后台 + 重复交卷」这三项自测,缺一项不上线。希望帮到你。
本文还有配套的精品资源,点击获取