简介这是一套面向微信小程序开发者与课程学习者的投票系统实战源码围绕投票创建、发布和结果展示搭建了可直接运行的 demo适合用来理解小程序项目组织、页面生命周期与前后端数据交互也可作为评选、问卷和市场调研类应用的起步模板。压缩包共 90 个文件以 wxss 样式、wxml 结构、js 逻辑和 json 配置为主另有 png/jpg 界面图片、php 接口脚本、sql 建表脚本与 md 说明文档整体约 153KB体量轻便但模块较完整。目录中可见页面、组件、工具函数、数据模型与模拟数据等分层配合样式库和上传相关代码能帮助读者梳理从表单录入到结果呈现的链路并观察组件复用、请求封装和本地调试数据的组织方式。已有 3647 人学习适合希望快速读懂一个小程序投票项目并在此基础上扩展用户登录、权限控制与防刷机制的中初级开发者。1. 三天要上线的评选活动我从一份投票 Demo 里省下了整个后端上个月接了个社区活动评选的活需求一句话管理员能创建投票、能发布、参与者能投、投完能看结果三天上线。我第一反应是自己从零撸小程序端四个页面加个后端服务加个数据库光部署环境就得两天。后来翻到这份微信小程序投票系统创建投票发布 demo 完整源码导入开发者工具、配一下云开发环境一个小时首页就出数据了。它把「创建—发布—投票—看结果」这条最容易写重复的链路整个做完了适合两类人一类是接到临时活动需求、不想为一次投票搭一套服务器的开发者另一类是刚从小程序原生入门、想找一份能跑通全链路的完整代码对照着改的新手。后面我按自己的拆解顺序把技术栈、目录、核心代码和踩过的坑一次讲清。2. 技术选型与目录结构原生小程序 云开发的三层拆法2.1 为什么这个 Demo 用原生而不是跨端框架先看技术栈。这份源码是微信小程序原生写法加云开发CloudBase的组合页面层用 WXML/WXSS/JS逻辑层的数据存储和业务运算全部落在云函数与云数据库里没有独立后端服务也不需要买服务器和域名。很多人搜 HTML mp 这类关键词本质上是想找一套能直接丢进开发者工具、页面结构和样式都能跑的 mp 端代码而这份 Demo 正好是纯原生不存在编译链路导入即用。原生 云开发这个组合在「一次性活动类需求」上有明显优势。跨端框架uni-app、Taro 那一类的价值在于一套代码出多端但投票这种场景 90% 的流量就在小程序内多端能力基本用不上反而多了一层编译产物调试时要对着编译后的代码找问题出问题时排查链路更长。云开发的价值更直接数据库、云函数、存储都在一个控制台里权限规则改一行就生效不需要人去配 Nginx、配 HTTPS 证书、配跨域。代价也要说清楚。云开发的数据库是文档型没有 JOIN也没有强事务覆盖所有场景所以数据模型必须在设计阶段就想好冗余字段把「一次查询能拿全」作为目标。第 4 章讲投票自增的时候会看到为了避开并发写冲突代码里用了原子自增指令而不是读-改-写这就是文档型数据库逼出来的写法。另一个容易被忽略的点是云函数的冷启动。投票类业务有明显的波峰——活动开始时所有人涌进来投票两分钟后又归于平静。云函数在长时间无调用后会进入冷启动状态第一次调用的响应可能到几百毫秒甚至更久。所以这份 Demo 里凡是能在小程序端直接查数据库的比如列表页、详情页的只读查询都不走云函数只有涉及写入和权限判定的动作才进云函数。这个取舍值得学。2.2 目录结构与文件职责导入项目后根目录的结构大致是这样一份清单我按自己的理解重排了职责说明路径职责改动频率app.js/app.json/app.wxss云开发初始化、页面注册、全局样式变量低project.config.json开发者工具配置AppID 与编译设置一次性pages/index/投票列表首页分页拉取已发布投票中pages/create/创建投票表单选项动态增删高pages/detail/投票详情选项渲染与提交动作高pages/result/结果页票数占比与排行中pages/mine/我创建的 / 我参与过的低components/选项卡片、空状态、加载骨架等复用组件中cloudfunctions/云函数目录每个子目录一个独立函数高utils/时间格式化、表单校验、常量枚举中app.js里的云开发初始化是整个项目的入口写法很标准// app.js App({ globalData: { envId: your-env-id, // 云开发环境 ID控制台可查 openid: // 登录后缓存的用户标识 }, onLaunch() { if (!wx.cloud) { console.error(基础库版本过低请升级到 2.2.3 以上) return } wx.cloud.init({ env: this.globalData.envId, traceUser: true // 在控制台记录访问用户便于排查 }) } })env参数是云开发环境 ID一个账号下可以有多个环境测试、生产切换环境就是改这一行。traceUser打开后云开发控制台的用户管理里能看到访问记录排查「某个用户提交失败」这类问题时很有用能确认请求到底有没有打到云函数。pages目录的划分逻辑是按「一次投票的生命周期」来的index 是发现create 是生产detail 是消费result 是沉淀mine 是回溯。这个划分方式比按「列表页/表单页/详情页」这种 UI 类型划分更好改因为你要加「草稿箱」就挂在 create 旁边要加「分享海报」就挂在 result 旁边不用在通用目录里翻。cloudfunctions/下面每个子目录就是一个独立部署单元目录名即函数名改完必须右键「上传并部署」才生效——这一点是新手最容易翻车的地方本地改了云函数代码、小程序端调用的还是旧版本会怀疑人生。2.3 数据模型三张集合撑起一次投票投票系统的数据模型其实就三件事投票主体是什么、选项有哪些、谁投了什么。这份 Demo 用两个核心集合加一个可选的用户集合来承载。votes集合存投票主体一条文档就是一次投票// votes 集合文档结构示意 { _id: a1b2c3, // 云数据库自动生成 title: 社区最美阳台评选, desc: 每人每天可投一次截止本周日, type: single, // single 单选 | multi 多选 maxSelect: 1, // 多选时最多可选几项 options: [ // 选项内嵌避免二次查询 { key: o1, text: 1 号楼, count: 12 }, { key: o2, text: 2 号楼, count: 8 } ], status: published, // draft 草稿 | published 已发布 | closed 已结束 anonymous: true, // 是否匿名投票 startTime: 1710000000000, endTime: 1710600000000, creatorOpenid: oXXXX, // 创建者用于「我创建的」过滤 createdAt: 1709900000000 }选项内嵌在votes文档里而不是单独建options集合这是文档型数据库的关键取舍。内嵌的好处是详情页一次查询就能拿到全部选项和票数不需要JOIN坏处是选项特别多比如几百个候选人时单文档会变大而且并发自增会集中在同一条文档上。投票场景选项一般在 2 到 20 个内嵌完全够用。records集合存投票记录一条文档就是一次投票动作// records 集合文档结构示意 { _id: r9x8y7, voteId: a1b2c3, // 关联的投票 ID openid: oXXXX, // 投票人匿名场景下也不对外暴露 selections: [o1], // 选中的选项 key 数组兼容单选多选 createdAt: 1709950000000 }selections统一用数组单选就是长度为 1 的数组。这样详情页渲染、结果统计、防重复判断三处的代码结构完全一致不需要为单选多选写两套分支是省事又不容易出错的写法。可选地还会有一个users集合存昵称头像用于「谁投的」这类展示。如果业务是匿名投票这个集合可以完全不要投票记录里只留 openid 用于判重展示层永远不显示投票人。三个集合的关系是votes一对多recordsrecords通过voteId反查。结果页的票数不靠records聚合计算而是直接读votes.options[].count因为聚合查询在数据量上来后很慢而实时票数要求快速返回。records只在需要「投票明细导出」或者「我参与过的」时才会被查。3. 运行与部署从导入开发者工具到首页出数据3.1 环境准备与 AppID 配置跑这个 Demo 的前提是一台装了微信开发者工具的电脑加上一个能开通云开发的小程序 AppID。测试号无 AppID 模式是不能用云开发的这一点必须先确认否则导入后会卡在wx.cloud.init报错上。导入项目的步骤不复杂但有几个参数要选对# 项目导入时的关键配置在开发者工具「导入项目」对话框里填 项目目录 解压后的根目录含 app.json 的那一层 AppID 你自己的小程序 AppID 项目名称 随意不影响运行 后端服务 选择「微信云开发」选错目录层级是最常见的第一个问题。压缩包解压后往往多套一层同名文件夹如果导入时选到了外层开发者工具会提示找不到app.json。正确的判断标准是选中目录后里面应该直接能看到app.json、app.js、pages这三个东西。导入完成后打开project.config.json确认appid字段和你在对话框里填的一致。有些源码包里这个字段是占位值需要手动改{ appid: wx0000000000000000, projectname: vote-demo, miniprogramRoot: miniprogram/, cloudfunctionRoot: cloudfunctions/, setting: { urlCheck: false, es6: true, postcss: true, minified: false } }miniprogramRoot和cloudfunctionRoot这两个字段决定了开发者工具去哪里找小程序代码、去哪里找云函数。云开发的模板项目默认会把小程序代码放在miniprogram/子目录里如果你拿到的源码是在根目录直接放pages那miniprogramRoot就得改成./否则右侧模拟器会白屏。这个字段一改云函数目录也要跟着对两个路径必须能对上实际文件夹。urlCheck设为 false 表示不校验合法域名。云开发调用本身不走这个校验但如果你后续在项目里接了第三方接口或者图片 CDN不关掉会一直报「不在以下 request 合法域名列表中」。3.2 云开发初始化与集合权限AppID 配好之后点开发者工具顶部的「云开发」按钮按提示开通环境。开通后会得到一个环境 ID形如vote-demo-3g8xxxxx把它填回app.js的envId字段。接着建集合。云开发的集合不会自动创建第一次查询不存在的集合会直接报错。需要在云开发控制台的数据库面板里手动新建votes和records两个集合。权限设置是这一步真正的重点也是最容易埋雷的地方集合推荐权限原因votes所有用户可读仅创建者可写列表和详情需要公开读写操作走云函数records仅创建者可读写投票记录不能被别人翻到判重必须走云函数很多人图省事把两个集合都设成「所有用户可读写」本地跑没问题一上线就出事——任何人都能绕过页面直接调用数据库 API 改票数。云开发的权限是数据安全的最后一道闸页面上的按钮禁用只是交互层面的不构成防护。云函数需要在部署时勾选「云端安装依赖」。wx-server-sdk这个包在本地node_modules里有没有都不影响但云端必须装上否则函数执行时会报Cannot find module wx-server-sdk。右键云函数目录选「上传并部署云端安装依赖」等控制台提示部署成功再继续。3.3 跑通验收清单部署完先别急着改代码按下面这几步确认基线是通的后面出问题才能判断是自己改坏的还是本来就没跑通。第一步编译后看首页。如果首页是空列表不一定是错——新环境里votes集合本来就是空的。这时候点「创建投票」填标题和两个选项提交。如果提交成功并跳回列表能看到刚创建的这条说明写入链路通了。第二步点进详情页投票。投完之后返回列表再点进去票数应该 1。如果票数没变但没报错多半是云函数部署了旧版本重新上传一次。第三步换一个账号开发者工具可以切换测试用户或者用手机扫码预览再投一次验证防重复是否生效。第四步在云开发控制台的数据库面板里直接看records集合应该有两条记录openid不同。这一步是确认「判重逻辑真的落库了」而不是只在页面层做了个本地标记。4. 核心链路拆解创建投票到发布上墙的完整实现4.1 创建页选项动态增删与表单状态创建页是整个 Demo 里交互最复杂的一页难点不在表单本身而在「选项数组」的状态管理。小程序的数据绑定是单向的setData更新整个数组最直观但选项多的时候会有性能问题而且输入框光标容易跳。这份源码用的是「数组 索引路径」的更新方式// pages/create/create.js Page({ data: { form: { title: , desc: , type: single, // 单选 / 多选 maxSelect: 1, anonymous: true, endTime: }, options: [, ], // 至少两个空位避免用户面对空页面 submitting: false }, // 选项输入用索引路径精确更新避免整个数组重渲染导致光标跳动 onOptionInput(e) { const { index } e.currentTarget.dataset this.setData({ [options[${index}]]: e.detail.value }) }, // 新增一个选项上限 20 个防止单文档过大 onAddOption() { const { options } this.data if (options.length 20) { wx.showToast({ title: 最多 20 个选项, icon: none }) return } this.setData({ options: [...options, ] }) }, // 删除选项保留至少两个 onRemoveOption(e) { const { index } e.currentTarget.dataset const { options } this.data if (options.length 2) { wx.showToast({ title: 至少保留两个选项, icon: none }) return } options.splice(index, 1) this.setData({ options }) } })setData里的options[${index}]是小程序支持的路径写法只更新数组里指定的那一项不会触发整个列表的 diff。用整数组替换的写法在这里会出问题每输入一个字符就重建整个数组输入框会失焦用户打字打到一半光标就没了这是我在别的项目里实打实翻过的车。onRemoveOption里用了splice原地修改再整体setData。删除操作不像输入那样高频整体替换可以接受而且删完之后索引会重排用路径更新反而容易错位。表单校验放在提交前统一做而不是每个字段实时校验。投票创建是个低频动作实时校验带来的红字提示反而干扰填写// 提交前的集中校验 validate() { const { form, options } this.data const title form.title.trim() if (title.length 2 || title.length 30) { return 标题需要 2 到 30 个字 } const valid options.map(o o.trim()).filter(Boolean) if (valid.length 2) { return 至少填写两个有效选项 } if (new Set(valid).size ! valid.length) { return 选项内容不能重复 } if (form.type multi form.maxSelect valid.length) { return 最多可选数不能超过选项数量 } if (form.endTime form.endTime Date.now()) { return 截止时间必须晚于当前时间 } return }new Set(valid).size ! valid.length这句是去重判断的常用写法比双重循环清爽。maxSelect和选项数量的关系校验容易被漏掉——多选场景下如果允许「最多选 5 项」但只有 3 个选项用户操作时会很困惑虽然不致命但属于体验上的硬伤。4.2 提交写入校验、草稿与发布状态机创建页填完之后数据不是直接写数据库而是先调云函数。中间加这一层的原因有两个一是要把creatorOpenid从服务端上下文里取不能信客户端传的值二是要生成选项的key保证每个选项有稳定的标识。// cloudfunctions/createVote/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 { title, desc, type, maxSelect, anonymous, endTime, options, status } event // 服务端二次校验客户端的校验只是体验不能作为信任边界 if (!title || title.trim().length 2) { return { code: 400, msg: 标题不合法 } } const texts (options || []).map(o String(o).trim()).filter(Boolean) if (texts.length 2) { return { code: 400, msg: 选项不足 } } const now Date.now() const doc { title: title.trim(), desc: (desc || ).trim(), type: type multi ? multi : single, maxSelect: type multi ? Math.min(Math.max(maxSelect, 1), texts.length) : 1, anonymous: !!anonymous, // 选项 key 用序号生成保证同一次投票内唯一且稳定 options: texts.map((text, i) ({ key: o${i 1}, text, count: 0 })), status: status draft ? draft : published, startTime: now, endTime: endTime || 0, creatorOpenid: OPENID, createdAt: now } const res await db.collection(votes).add({ data: doc }) return { code: 0, msg: ok, id: res._id } }cloud.getWXContext()返回的OPENID是云函数运行环境注入的用户标识客户端伪造不了。所有跟「谁」相关的字段都必须从这里取绝不能从event里读客户端传的 openid这是云开发场景下最基本的安全习惯。status字段支持草稿和已发布两种状态草稿不会出现在列表页。这个设计的价值在于「先存着等活动时间到了再发」比一次性填完就必须发出去灵活。列表页的查询条件里带上status: published就自动过滤了草稿。选项key用o1、o2这种序号生成而不是用文本内容做标识。用文本做标识的问题是用户可能改选项文字如果以后加了编辑功能一改历史投票记录就对不上了。序号 key 一旦生成就不再变投票记录只存 key展示时再去votes.options里查对应文本。4.3 列表与详情分页、聚合与实时票数列表页用的是云数据库的分页查询没有走云函数。只读查询直连数据库可以省掉云函数的冷启动开销首屏更快// pages/index/index.js const PAGE_SIZE 10 Page({ data: { list: [], page: 0, loading: false, hasMore: true }, onLoad() { this.loadList(true) }, async loadList(reset false) { if (this.data.loading) return if (!reset !this.data.hasMore) return this.setData({ loading: true }) const page reset ? 0 : this.data.page const db wx.cloud.database() try { const res await db.collection(votes) .where({ status: published }) .orderBy(createdAt, desc) .skip(page * PAGE_SIZE) .limit(PAGE_SIZE) // 列表页不需要完整选项只取前两个做预览 .field({ title: true, desc: true, options: true, endTime: true, createdAt: true }) .get() this.setData({ list: reset ? res.data : this.data.list.concat(res.data), page: page 1, hasMore: res.data.length PAGE_SIZE, loading: false }) } catch (err) { this.setData({ loading: false }) wx.showToast({ title: 加载失败, icon: none }) console.error([loadList], err) } }, onReachBottom() { this.loadList(false) }, onPullDownRefresh() { this.loadList(true).then(() wx.stopPullDownRefresh()) } }).field()指定返回字段是分页查询里容易被忽略的优化。不加这个每条记录会把options里所有选项、creatorOpenid、desc全带回来列表 10 条就是 10 份完整文档首屏体积能差好几倍。列表页只需要标题、描述、前两个选项做预览其余字段没必要传。.skip(page * PAGE_SIZE)这种偏移分页在数据量小的时候没问题数据上千条之后深分页会变慢因为数据库要先跳过前面所有记录。如果要做得更稳可以用createdAt做游标where({ createdAt: _.lt(lastCreatedAt) })每次从上一页最后一条的时间往前取。这个改造留到第 6 章说。详情页的票数不单独请求直接复用列表传来的_id再查一次完整文档。投票成功后不做整页刷新而是局部更新对应选项的票数// 投票成功后的局部更新避免整页重渲染 const idx this.data.vote.options.findIndex(o o.key selectedKey) this.setData({ [vote.options[${idx}].count]: this.data.vote.options[idx].count 1, vote.mySelection: selections })同样是路径更新的思路。整页setData会把所有选项重新渲染一遍视觉上会有一次明显的闪动用户刚点完的选项卡片会「跳」一下体验很差。4.4 投票动作防重复与并发自增投票是整条链路里唯一需要保证数据一致性的地方。要同时做到两件事同一个人对同一次投票不能重复投票数自增不能因为并发丢票。// cloudfunctions/submitVote/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() const { voteId, selections } event if (!voteId || !Array.isArray(selections) || selections.length 0) { return { code: 400, msg: 参数不合法 } } // 1. 读投票主体校验状态与选项合法性 const voteRes await db.collection(votes).doc(voteId).get() const vote voteRes.data if (!vote) return { code: 404, msg: 投票不存在 } if (vote.status ! published) return { code: 403, msg: 投票未开始或已结束 } if (vote.endTime Date.now() vote.endTime) return { code: 403, msg: 投票已截止 } const validKeys vote.options.map(o o.key) if (!selections.every(k validKeys.includes(k))) { return { code: 400, msg: 选项不存在 } } if (vote.type single selections.length ! 1) { return { code: 400, msg: 单选只能选一项 } } if (vote.type multi selections.length vote.maxSelect) { return { code: 400, msg: 最多选 ${vote.maxSelect} 项 } } // 2. 判重同一 openid voteId 只允许一条记录 const dup await db.collection(records) .where({ voteId, openid: OPENID }) .count() if (dup.total 0) { return { code: 409, msg: 你已经投过票了 } } // 3. 先落记录再自增票数 await db.collection(records).add({ data: { voteId, openid: OPENID, selections, createdAt: Date.now() } }) // 用 inc 原子自增避免读-改-写在并发下丢票 const incData {} selections.forEach(key { const idx vote.options.findIndex(o o.key key) incData[options.${idx}.count] _.inc(1) }) await db.collection(votes).doc(voteId).update({ data: incData }) return { code: 0, msg: ok } }_.inc(1)是云数据库的原子自增指令它把加法交给数据库执行不经过「读出来、加一、写回去」这个过程。如果用读-改-写两个人同时投同一个选项时两次读到的都是 8各自加一写回 9最终票数是 9 而不是 10丢了一票。这种错误在测试阶段几乎发现不了要等真实并发才暴露属于典型的黑匣子问题。incData里的options.${idx}.count是嵌套路径更新语法一次update可以同时更新多个选项的票数多选场景下一次请求只写一次数据库。判重用的是「先查 count 再插入」严格来说这两步之间存在极小的并发窗口——同一个人用两个设备同时点提交理论上可能都通过 count 检查。要彻底杜绝得给records加唯一索引或者在云函数里用事务。云开发的数据库支持事务写法是把判重和插入包在db.startTransaction()里。Demo 里没这么做是因为单人重复点击的概率极低页面层也做了按钮置灰。如果你的场景是「抢票」级别的并发这里必须补上事务。顺序上先落记录再自增票数是有意为之。如果先自增票数、后写记录写记录失败时票数已经加上去了结果就是票数虚高且无法回滚。反过来先写记录、自增失败最坏情况是这个人投了票但票数没加用户重试会被判重拦住——虽然也难受但比票数虚高好排查至少records里能查到记录能人工补。5. 避坑与排查五个让我返工的真实问题5.1 云函数改了没生效调到的是旧版本现象本地改了submitVote的返回结构小程序端拿到的还是旧字段控制台也没报错。原因云函数是独立部署单元保存文件不等于部署。开发者工具只会把云函数代码同步到本地云端运行的还是上次上传的版本。解决每次改完云函数右键该函数目录选「上传并部署云端安装依赖」等提示成功再测。判断是否真的部署成功去云开发控制台的云函数列表看「更新时间」时间不对就是没传上去。我现在的习惯是改完云函数先看一眼更新时间再动小程序端。5.2 集合权限设成「仅创建者可读」列表页空数据现象数据库里明明有数据列表页一条都查不到也没有报错。原因votes集合的权限如果是「仅创建者可读写」那么查询只会返回当前用户自己创建的记录。列表页要展示所有人的投票这个权限下必然查不到别人的数据。解决votes集合改成「所有用户可读仅创建者可写」。改完不用重新编译权限是即时生效的。注意这个改动只影响读写操作仍然受限制是安全的。5.3 数据库查询报「collection not exists」现象第一次运行就报错提示集合不存在。原因云开发的集合必须在控制台手动创建代码里db.collection(votes)不会自动建表。这是跟 MySQL 那种「建表语句跑一遍」的习惯不一样的地方。解决去云开发控制台 → 数据库 → 新建集合把votes和records都建上。集合名大小写敏感代码里写什么就建什么别写成Votes。5.4 输入框打字打一半光标消失现象在创建页的选项输入框里连续输入打到第二个字光标就跳走了。原因onOptionInput里用了this.setData({ options: newArray })整体替换数组每次输入都重建列表输入框组件被重新渲染焦点丢失。解决改成路径更新this.setData({ [options[ index ]]: value })。如果组件是自己封装的还要注意wx:key不能用 index要用稳定 id否则列表 diff 时同样会丢焦点。5.5 手机预览时云开发调不通开发者工具里正常现象开发者工具里一切正常扫码到真机上所有云函数调用都失败。原因真机预览时小程序会走真实的网络环境如果app.js里的envId填的是测试环境而真机登录的账号没有该环境权限或者基础库版本过低不支持wx.cloud就会全部失败。解决先在真机上打开调试面板vConsole看具体报错。wx.cloud.init之前那句if (!wx.cloud)的判断要保留基础库低于 2.2.3 直接给出提示比让用户面对一堆看不懂的报错强。另外envId建议从app.globalData里取切换环境只改一处。6. 二次开发把 Demo 改成能上线的业务模块Demo 跑通只是起点真要用在业务里有几处必须动。第一处是判重的可靠性。前面说的「先查 count 再插入」在极端并发下有窗口改造方式是给records加一个联合唯一键的替代方案把voteId openid拼成一个_id直接写入。云数据库的_id天然唯一重复写入会直接报错用try/catch捕获这个错误返回「已投过」比查 count 更稳还省了一次查询。// 用确定性 _id 做唯一约束重复投票会在写入时直接失败 const recordId ${voteId}_${OPENID} try { await db.collection(records).add({ data: { _id: recordId, voteId, openid: OPENID, selections, createdAt: Date.now() } }) } catch (err) { if (String(err).includes(duplicate)) { return { code: 409, msg: 你已经投过票了 } } throw err }第二处是列表的深分页。skip在数据上千后明显变慢换成时间游标// 游标分页从上一页最后一条的 createdAt 往前取 let query db.collection(votes).where({ status: published }) if (lastCreatedAt) { query query.where({ createdAt: _.lt(lastCreatedAt) }) } const res await query.orderBy(createdAt, desc).limit(PAGE_SIZE).get()_.lt(lastCreatedAt)是「小于」指令配合orderBy desc就是「取比上一页最后一条更早的记录」。这种分页不会随着页码变深而变慢代价是不能跳页只能上一页下一页投票列表场景完全够用。第三处是结果导出。运营方基本一定会要「把投票明细导成 Excel」。云开发没有直接的导出接口常见做法是写一个定时云函数把records按voteId分批拉出来生成 CSV 字符串传到云存储返回一个临时下载链接。数据量大时要分批一次get最多返回 100 条用skip或游标翻页拉全。第四处是匿名投票的展示剥离。records里存 openid 是为了判重但任何时候都不能把它返回给前端。所有对外接口在返回数据前要过一层字段裁剪「我参与过的」这类页面只返回voteId和createdAt投票人信息不出服务端。改造完之后怎么验证我的做法是准备三个测试账号分别走一遍A 创建投票并发布、B 投一次、B 再投一次应该被拦、C 投一次、A 看结果票数是否为 2。这五步能覆盖创建、发布、判重、并发、统计五条主链路。上线前再把records集合权限复查一遍确认不是「所有用户可读写」。从那以后我每次接手这类「活动型小程序」都强制先跑一遍这份 Demo 的五步验收再决定是在它上面改还是重写。多数时候答案是前者因为投票这条链路的坑基本都被趟过一遍了没必要再趟第二遍。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
线性回归与逻辑回归:核心原理与实战应用对比 1. 回归模型基础概念解析回归分析是统计学中最基础也最强大的预测建模技术之一,它通过建立自变量与因变量之间的关系方程,帮助我们理解变量间的关联程度并进行预测。在众多回归模型中,线性回归和逻辑回归构成了数据分析的基石,它们… · 2026/11/5 4:15:33
蓝牙协议分析实战:日志提取与数据处理技巧 1. 蓝牙协议分析实战:从日志文件提取关键数据作为一名长期从事无线安全研究的工程师,我经常需要分析各种蓝牙协议数据包。最近在PolarCTF比赛中遇到一道关于蓝牙日志分析的题目,正好借此机会分享一套完整的蓝牙协议分析流程。这个案例不仅适用… · 2026/11/5 4:15:33
基于JSP的高校评教系统开发实践与优化 1. 项目概述高校评教系统是提升教学质量的重要工具,我最近完成了一个基于JSP技术栈的评教系统开发项目。这个系统实现了学生在线评教、教师查看反馈、管理员管理评价指标等功能,采用经典的MVC架构,前端使用JSPHTML/CSS,后端基于Ja… · 2026/11/5 4:15:33
ADS122U04IPWR+STM32L162ZE:工业精密测温前端实战指南 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 5:03:58
基于ADS122U04与PIC32MX795的工业HVAC四通道温度采集方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 5:03:58
工业温度测量信号链设计:ADS122U04IPWR与MK51DN512CLQ10协同实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 5:03:58
工业级温度测量:ADS122U04IPWR+STM32F215RE高精度闭环设计 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 5:03:58
24位ADC搭配UART:基于ADS122U04与PIC18F57Q43的高精度温度采集系统设计 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 5:03:58
工业级温度测量:ADS122U04+STM32F405RG高精度信号链设计 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 5:02:58
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38