简介:这是一套面向微信小程序初学者与全栈练手开发者的在线答题项目源码,采用微信原生小程序搭配云开发(TCB)架构,适合想快速掌握小程序前端交互与云端一体化开发、并需要一个完整可运行案例的人群。压缩包共75个文件,约6.06MB,以35个png与4个jpg截图、10个wxss样式、10个json配置、8个js逻辑、6个wxml模板为主,另含gitignore与README说明,覆盖页面结构、样式与云函数调用等关键部分。已有221人学习下载。项目围绕答题场景实现了题目展示、答案提交、成绩计算与排行榜等模块,目录中可见index、details、rank、history、test、result等页面,以及后台题目管理与题库相关截图,便于读者理解数据存储、云函数鉴权与成绩统计的落地方式,也可作为二次开发与课程设计的参考骨架。
1. 云开发答题小程序:为什么“零后端”反而让架构决策更致命
很多人第一次听到“微信原生小程序 + 云开发”做答题应用,第一反应是:这不就是不用买服务器、不用写接口的省事方案吗?我一开始也这么想,直到某次帮一个团队排查线上问题——他们的答题小程序在活动开始三分钟后集体卡死,用户点选项没反应,排行榜数据错乱。翻车原因不是代码写得多烂,而是他们把“云开发等于零架构”当成了默认前提,所有逻辑都堆在页面里,数据库权限设成了“所有人可写”。云开发确实把运维成本压到了接近零,但它没有帮你做任何架构决策。答题类小程序的核心链路——题库加载、计时、判分、排行榜、防重复提交——每一个环节在云开发模式下都有完全不同的实现约束。这篇文章面向的是打算用原生小程序加云开发落地答题应用的开发者,不管你是要做一个内部培训考核工具,还是一个轻量级的知识竞赛,我会把选型理由、目录结构、云函数设计、数据库权限、性能边界和踩坑记录全部拆开讲清楚,让你能直接照着复现一套可用的架构。
2. 答题小程序的技术选型:原生小程序加云开发到底省掉了什么
2.1 为什么不用 uni-app 或 Taro 转译方案
常见做法是先纠结跨端框架。如果你的答题小程序只跑在微信里,原生小程序加云开发的组合在调试链路上最短。转译框架多一层编译,云开发的原生 API 调用需要额外适配,出了问题排查路径变长。我一般会建议:答题场景交互不复杂,页面数量通常在 5 到 8 个之间,原生写法完全撑得住,而且云开发的小程序端 SDK 在原生环境下调用最直接,不需要额外封装。
另一个实际考量是包体积。答题小程序如果带题库缓存和排行榜,主包容易逼近 2MB 限制。原生小程序配合分包加载和云开发按需拉取,控制起来更精细。转译框架生成的运行时代码会额外占用几十到上百 KB,在临界状态下这就是压死骆驼的最后一根稻草。
2.2 云开发替代了哪些传统后端组件
传统答题系统后端至少需要:一台应用服务器处理请求、一个数据库存题库和成绩、一个缓存层做排行榜、一个定时任务做数据清理。云开发把这四样分别映射到云函数、云数据库、云数据库的聚合查询和定时触发器。映射关系清楚了,架构就不会乱。
| 传统组件 | 云开发对应 | 答题场景用途 |
|---|---|---|
| 应用服务器 | 云函数 | 判分、提交答案、生成排行榜 |
| 数据库 | 云数据库(文档型) | 题库、用户答题记录、成绩 |
| 缓存 | 云数据库聚合 + 索引 | 实时排行榜查询 |
| 定时任务 | 定时触发器 | 清理过期答题会话、归档成绩 |
需要留意的是,云数据库是文档型数据库,不是关系型。题库如果设计成嵌套文档,查询和更新会比关系型麻烦。我一般会把题目和选项拆成独立集合,用 questionId 关联,避免单文档过大导致读写性能下降。
2.3 最小可运行的项目目录结构
下面是我常用的目录骨架,直接照着建就能跑通基础链路。
project-root/ ├── miniprogram/ │ ├── app.js │ ├── app.json │ ├── pages/ │ │ ├── index/ # 答题首页 │ │ ├── quiz/ # 答题页 │ │ └── result/ # 成绩页 │ ├── utils/ │ │ └── request.js # 云函数调用封装 │ └── components/ │ └── question-card/ # 题目卡片组件 ├── cloudfunctions/ │ ├── submitAnswer/ # 提交答案并判分 │ ├── getRanking/ # 获取排行榜 │ └── initQuiz/ # 初始化答题会话 └── project.config.json这个结构的关键在于:所有写操作走云函数,小程序端只做展示和交互。很多翻车案例都是因为图省事,在小程序端直接调db.collection().add(),结果权限一开就收不住。
2.4 云函数与小程序端的分工边界
一个原则:凡是涉及判分、写成绩、改排行榜的操作,全部放云函数。小程序端只负责渲染题目、收集用户选择、调用云函数。这样做的好处是判分逻辑不暴露在客户端,用户改不了答案,也绕不过计时。
// cloudfunctions/submitAnswer/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event) => { const { OPENID } = cloud.getWXContext() const { sessionId, questionId, selectedOption } = event // 1. 校验会话是否有效且未过期 const session = await db.collection('sessions') .where({ _id: sessionId, openid: OPENID, status: 'active' }) .get() if (session.data.length === 0) { return { code: 4001, msg: '会话无效或已结束' } } // 2. 拉取题目正确答案 const question = await db.collection('questions') .doc(questionId) .get() const isCorrect = question.data.answer === selectedOption // 3. 写入答题记录 await db.collection('answers').add({ data: { openid: OPENID, sessionId, questionId, selectedOption, isCorrect, createdAt: db.serverDate() } }) return { code: 0, data: { isCorrect } } }这段代码里三个关键点:用cloud.getWXContext()拿 OPENID 而不是从客户端传,防止伪造;先校验会话状态再判分,防止过期会话继续提交;用db.serverDate()写时间戳,避免客户端时间不准。参数sessionId和questionId由小程序端传入,但openid永远不信任客户端。
3. 题库设计与云数据库权限:文档型数据库的建模取舍
3.1 题目集合的字段设计与索引策略
题库集合的设计直接决定后续查询效率。我一般用这样的字段结构:
// 题目文档示例 { _id: "auto-generated", category: "安全知识", // 分类,用于筛选 type: "single", // 题型:single / multiple stem: "以下哪项属于...", // 题干 options: [ { key: "A", text: "选项A内容" }, { key: "B", text: "选项B内容" } ], answer: "A", // 正确答案 difficulty: 2, // 难度等级 1-5 order: 10 // 排序权重 }索引方面,category和difficulty建组合索引,用于按分类和难度抽题。order建单字段索引用于排序。注意云数据库的索引需要在控制台手动创建,代码里不会自动生成。很多新手不知道这一点,数据量上来后查询慢得离谱,还以为是云函数冷启动的问题。
3.2 数据库权限的四种模式与答题场景选择
云数据库权限有四种:仅创建者可读写、所有用户可读仅创建者可写、所有用户可读、所有用户不可读写。答题场景下,题库集合应该设为“所有用户可读,仅管理端可写”——但云开发没有独立的管理端,所以实际做法是设为“所有用户不可读写”,然后通过云函数来读取。这样小程序端无法直接查题库,必须走云函数,防止答案泄露。
用户答题记录集合设为“仅创建者可读写”,这样每个用户只能看到自己的记录。排行榜集合设为“所有用户可读”,但写入只能通过云函数。这个权限组合是我踩过坑之后固定下来的:早期把题库设为所有用户可读,结果有人直接在小程序端遍历题库把答案全拉走了。
3.3 用云函数做安全读取的完整示例
// cloudfunctions/getQuestions/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 { category, count = 10 } = event // 使用聚合查询随机抽取指定数量的题目 const result = await db.collection('questions') .aggregate() .match({ category: category }) .sample({ size: count }) .end() // 返回时剔除 answer 字段,防止答案泄露到客户端 const safeQuestions = result.list.map(item => { const { answer, ...rest } = item return rest }) return { code: 0, data: safeQuestions } }这里用aggregate().sample()做随机抽题,比先查全部再随机效率高。关键操作是返回前剔除answer字段——这个动作必须在云函数里做,不能指望客户端过滤。参数count控制抽题数量,建议不超过 20,因为单次云函数返回数据量有限制,题目文本较长时容易超。
3.4 答题会话的状态管理
答题会话是容易被忽略的一环。用户开始答题时创建一个 session 文档,记录开始时间、题目列表、当前进度。提交答案时更新 session 状态。会话过期时间建议设为 30 分钟,通过定时触发器清理。
// cloudfunctions/initQuiz/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event) => { const { OPENID } = cloud.getWXContext() const { category, questionIds } = event // 检查是否有未完成的会话 const existing = await db.collection('sessions') .where({ openid: OPENID, status: 'active' }) .get() if (existing.data.length > 0) { return { code: 0, data: { sessionId: existing.data[0]._id, resumed: true } } } const session = await db.collection('sessions').add({ data: { openid: OPENID, category, questionIds, currentIndex: 0, status: 'active', startedAt: db.serverDate(), expireAt: new Date(Date.now() + 30 * 60 * 1000) } }) return { code: 0, data: { sessionId: session._id, resumed: false } } }这段逻辑处理了断点续答:如果用户中途退出再进来,直接返回已有会话而不是新建。expireAt字段配合定时触发器做清理,避免无效会话堆积。
4. 判分、计时与排行榜:云函数里的核心链路实现
4.1 判分逻辑为什么必须放云函数
判分放云函数的原因不只是防作弊。答题场景中,多选题的判分规则、部分分规则、时间加成规则都可能变化。如果这些逻辑写在小程序端,每次调整都要发版审核。放云函数里改完直接生效,不需要走小程序审核流程。这个灵活性在运营活动中特别重要。
4.2 服务端计时的实现与客户端时间校准
计时是答题小程序最容易出问题的地方。客户端计时可以被用户修改系统时间绕过,所以必须服务端计时。做法是在 session 里记录startedAt,每次提交答案时计算已用时间。
// 在 submitAnswer 云函数中增加计时校验 const session = await db.collection('sessions').doc(sessionId).get() const startedAt = session.data.startedAt.getTime() const now = Date.now() const elapsed = Math.floor((now - startedAt) / 1000) const timeLimit = session.data.timeLimit || 600 // 默认10分钟 if (elapsed > timeLimit) { await db.collection('sessions').doc(sessionId).update({ data: { status: 'expired' } }) return { code: 4002, msg: '答题超时' } }客户端展示倒计时只是为了用户体验,真正的超时判断在云函数里。两边时间可能有几秒偏差,所以服务端判断时留 5 秒缓冲,避免用户刚好在最后一秒提交被判超时。
4.3 排行榜的实时聚合查询
排行榜用云数据库聚合查询实现,按正确题数和用时排序。
// cloudfunctions/getRanking/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const $ = db.command.aggregate exports.main = async (event) => { const { sessionId, limit = 50 } = event const result = await db.collection('answers') .aggregate() .match({ sessionId: sessionId }) .group({ _id: '$openid', correctCount: $.sum($.cond({ if: '$isCorrect', then: 1, else: 0 })), totalTime: $.sum('$elapsed') }) .sort({ correctCount: -1, totalTime: 1 }) .limit(limit) .end() return { code: 0, data: result.list } }聚合的group阶段按 openid 分组,统计正确数和总用时。排序规则是先按正确数降序,再按用时升序。limit控制返回条数,建议不超过 100,否则聚合查询可能超时。如果数据量大,需要给sessionId和openid建组合索引。
4.4 防重复提交与幂等处理
用户快速点击提交按钮可能触发多次云函数调用。处理方式是在 answers 集合里对openid + sessionId + questionId建唯一索引,重复插入会失败,云函数捕获错误后返回已提交状态。
try { await db.collection('answers').add({ data: answerData }) } catch (err) { if (err.errCode === -502001) { // 唯一索引冲突 return { code: 0, msg: '已提交', data: { isCorrect: null } } } throw err }唯一索引需要在云开发控制台手动创建,代码里无法声明。这个坑我踩过:本地测试没问题,上线后并发一高就出现重复记录,排行榜数据翻倍。
5. 避坑与排查:答题小程序上线后最容易翻车的五个点
5.1 云函数冷启动导致首题加载慢
现象:用户点开始答题后,第一题加载要等 2 到 3 秒,后续题目正常。原因是云函数冷启动,首次调用需要初始化运行环境。解决办法是设置定时触发器,每 5 分钟调用一次轻量级预热函数,保持云函数活跃。另一个做法是在小程序启动时提前调用一个空操作的云函数,把冷启动时间藏在用户阅读规则说明的阶段。
5.2 数据库查询默认返回 20 条限制
现象:题库明明有 100 题,抽题时只返回 20 题。原因是云数据库小程序端查询默认 limit 是 20,云函数端默认是 100。解决方式是在查询时显式设置.limit(),但注意云函数单次返回数据量上限是 1MB,题目文本多时不要一次拉太多。我一般分页拉取,每页 50 条。
5.3 聚合查询在数据量超过五千条后明显变慢
现象:排行榜查询从 200ms 涨到 3 秒以上。原因是聚合查询没有走索引,全表扫描。解决方式是在answers集合上建sessionId + openid组合索引,并且把聚合的match阶段放在最前面,减少后续阶段处理的数据量。如果数据量持续增长,考虑把排行榜结果缓存到独立集合,定时更新而不是实时聚合。
5.4 云函数超时设置不合理导致判分丢失
现象:用户提交答案后偶尔没有记录,也没有报错。原因是云函数默认超时时间是 3 秒,判分逻辑里如果有多次数据库操作,可能超时被终止。解决方式是在云函数配置里把超时时间调到 10 到 20 秒,同时优化逻辑减少不必要的查询。判分这种关键操作,宁可多等几秒也不能丢数据。
5.5 小程序端直接操作数据库导致权限失控
现象:上线后发现有人刷成绩。原因是开发阶段图方便,在小程序端直接调用了db.collection('answers').add(),权限设成了所有用户可写。解决方式是所有写操作迁移到云函数,数据库权限收紧到“所有用户不可读写”,只允许云函数通过管理端权限访问。这个改动越早做越好,后期迁移成本很高。
6. 从能跑到好用:答题小程序的性能调优与数据验证
6.1 用云开发控制台的慢查询日志定位瓶颈
云开发控制台提供数据库慢查询日志,可以按耗时排序。我一般会关注超过 500ms 的查询,逐个分析是否走了索引。答题场景中,最常见的慢查询是排行榜聚合和题库随机抽题。前者通过加索引和缓存解决,后者通过预生成题目列表解决——在创建 session 时就把题目 ID 列表固定下来,后续按 ID 逐个拉取,避免每次随机抽题都做聚合。
6.2 答题数据的一致性校验脚本
上线后需要定期校验数据一致性。我写了一个云函数,检查每个 session 的答题记录数是否等于题目数,以及是否有重复记录。
// cloudfunctions/checkConsistency/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async () => { const sessions = await db.collection('sessions') .where({ status: 'finished' }) .limit(100) .get() const issues = [] for (const session of sessions.data) { const answers = await db.collection('answers') .where({ sessionId: session._id }) .count() const expected = session.questionIds.length if (answers.total !== expected) { issues.push({ sessionId: session._id, expected, actual: answers.total }) } } return { code: 0, data: { checked: sessions.data.length, issues } } }这个脚本每次跑 100 个会话,避免云函数超时。发现不一致的记录后,人工介入排查是并发写入问题还是逻辑漏洞。
6.3 用体验评分数据反推优化优先级
小程序后台的体验评分里,性能评分和请求耗时直接相关。我一般会盯着两个指标:首次渲染时间和云函数调用平均耗时。首次渲染超过 2 秒,优先检查主包体积和首屏数据请求是否串行。云函数平均耗时超过 800ms,优先检查数据库查询是否走了索引。这两个指标改善后,用户完成率通常会有明显提升。
6.4 一个被低估的技巧:预生成题目快照
答题过程中,如果题库被修改,正在答题的用户可能遇到题目内容变化。解决办法是在创建 session 时,把题目内容快照存到 session 文档里。这样即使用题库后续更新,当前会话不受影响。代价是 session 文档会变大,但答题场景中单个 session 的题目数量有限,通常不超过 50 题,文档大小可控。这个做法我是在一次线上事故后加上的——运营在活动期间修改了一道题的选项,导致已开始答题的用户提交后判分错误。快照机制虽然简单,但能避免这类玄学问题。
我现在的习惯是:任何答题类项目,先把 session 快照和唯一索引建好,再写业务逻辑。这两个东西后期补的代价远大于前期加上的成本。希望帮到你。
本文还有配套的精品资源,点击获取