资讯详情

微信小程序投票系统开发实战:从创建发布到防刷票完整demo源码解析

📅 2026/10/14 1:54:33 | 华诺云谱 👁 阅读
微信小程序投票系统开发实战:从创建发布到防刷票完整demo源码解析
简介这份资源是微信小程序投票系统的完整源码包面向希望学习小程序开发或快速搭建投票应用的开发者可用于意见收集、评选活动、市场调研等场景。压缩包共90个文件约153KB以49个wxss样式文件、15个png图片资源为主另含8个js逻辑文件、8个json配置、6个wxml结构文件以及php接口、sql建表脚本和md说明文档覆盖页面结构、样式、逻辑与数据交互各层。源码按小程序标准目录组织包含全局配置、页面模块、工具函数、自定义组件、数据模型与模拟数据等部分投票创建、发布与结果展示流程完整网络请求与数据处理逻辑清晰便于理解页面生命周期、组件化开发与图形数据可视化。目前已有3644人学习下载适合作为小程序入门练手或二次开发的参考模板可在此基础上扩展用户登录、权限控制与防刷机制。1. 微信小程序投票系统从创建到发布一个 demo 能跑通多少事很多人第一次接触微信小程序投票系统都是被一个很具体的需求推着走的社团要选优秀作品、公司要做内部方案评选、班级要选班委用第三方投票工具吧数据在别人服务器上还动不动弹广告、限制选项数自己从头写吧登录、鉴权、数据库、防刷票光想想就头大。于是「微信小程序投票系统创建投票发布demo完整源码下载.zip」这类东西就成了搜索框里的高频词——大家想要的其实不是一份能直接上线的成品而是一套能跑起来、能看懂、能改的最小闭环。这篇笔记就按这个思路来把「创建投票 → 发布 → 投票 → 看结果」这条链路拆开讲清楚一个 demo 里到底该有哪些东西哪些参数必须自己定哪些坑一踩就翻车。适合两类人一类是刚学完小程序基础、想找个完整项目练手的开发者另一类是要快速搭个内部投票、不想被第三方平台绑住的从业者。我不会假装见过某个具体的压缩包而是按这个标题下最常见、最可靠的做法把一套可复现的方案讲透。2. 投票系统的数据模型与页面骨架先想清楚再动手动手写代码之前最该花时间的是数据模型。投票系统看着简单但「一个投票有多个选项、一个用户对一个投票只能投一次、投票有开始和结束时间」这几条规则直接决定了你后面要不要写一堆补丁。我一般会把核心实体拆成三张表投票vote、选项option、投票记录record。用户信息不单独建表直接用微信登录拿到的 openid 当唯一标识省事又够用。2.1 三张核心表怎么设计才不用返工先看投票主表。它要存标题、描述、类型单选/多选、状态草稿/进行中/已结束、开始结束时间、创建者 openid。这里有个容易忽略的点状态不要只靠时间算最好落一个显式字段。因为「到点自动结束」在小程序里没有后台定时任务时很难精确触发显式状态 前端按时间判断双保险更稳。选项表相对简单但要带一个 voteId 外键和排序字段 sort否则选项顺序在不同端展示会乱。投票记录表是防刷票的关键它至少要有 voteId、openid、optionId、创建时间并且要在 voteId openid 上建唯一索引——这一条索引能帮你挡掉绝大多数重复提交。-- 投票主表 CREATE TABLE vote ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, -- 投票标题 desc_text VARCHAR(500) DEFAULT , -- 描述注意 desc 是保留字 vote_type TINYINT DEFAULT 1, -- 1 单选 2 多选 max_choose TINYINT DEFAULT 1, -- 多选时最多可选几项 status TINYINT DEFAULT 0, -- 0 草稿 1 进行中 2 已结束 start_time DATETIME NULL, end_time DATETIME NULL, creator VARCHAR(64) NOT NULL, -- 创建者 openid created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 选项表 CREATE TABLE vote_option ( id BIGINT PRIMARY KEY AUTO_INCREMENT, vote_id BIGINT NOT NULL, content VARCHAR(200) NOT NULL, sort INT DEFAULT 0, INDEX idx_vote (vote_id) ); -- 投票记录表唯一索引是防刷票底线 CREATE TABLE vote_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, vote_id BIGINT NOT NULL, openid VARCHAR(64) NOT NULL, option_id BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_vote_user (vote_id, openid) );字段说明里几个点值得单独拎出来desc_text不用desc因为它是 SQL 保留字新手在这里翻车的不在少数max_choose只在多选时生效单选时固定为 1creator存 openid 而不是自增 userId是为了省掉一次用户表关联查询。唯一索引uk_vote_user是整套系统的安全底线后面讲防刷票还会回到它。2.2 页面骨架四个页面撑起完整闭环数据模型定了页面就顺了。一个能跑通创建到发布的 demo最少需要四个页面首页投票列表、创建页填标题选项、详情页投票 看结果、我的页我创建的 / 我投过的。首页负责入口创建页负责生产数据详情页是核心交互我的页负责管理。创建页的表单校验要做在前面。标题非空且不超过 100 字选项至少 2 个、最多 20 个多选时 max_choose 不能大于选项数。这些校验前端做一遍后端接口再做一遍别嫌重复——前端校验是体验后端校验是安全两者职责不同。// 创建页提交前的本地校验放在 pages/create/create.js function validateForm(form) { if (!form.title || form.title.trim().length 0) { return { ok: false, msg: 标题不能为空 }; } if (form.title.length 100) { return { ok: false, msg: 标题不能超过100字 }; } const options form.options.filter(o o o.trim()); if (options.length 2) { return { ok: false, msg: 至少需要2个选项 }; } if (options.length 20) { return { ok: false, msg: 选项最多20个 }; } if (form.voteType 2 form.maxChoose options.length) { return { ok: false, msg: 最多可选数不能超过选项数 }; } return { ok: true }; }这段校验逻辑很直白但参数边界是踩过坑才定下来的选项上限 20 是因为再多前端渲染会卡标题 100 字是因为数据库字段就这么宽。voteType 2判断多选maxChoose只在多选场景校验。把这些边界写死在函数里比散落在各个事件回调里好维护得多。3. 创建与发布把投票数据真正落到服务端页面和数据模型都齐了接下来是让「创建」这个动作真正产生一条可用的投票记录。这一步的核心是接口设计创建接口负责写入 vote 和 vote_option发布接口负责把 status 从草稿改成进行中。为什么拆成两个接口而不是一步到位因为实际使用中创建者经常是先存草稿、检查一遍再发布一步到位反而不好用。3.1 创建接口一次事务写入主表和选项表创建投票涉及两张表的写入必须放在一个事务里。如果 vote 写成功、option 写失败就会留下一条没有选项的空投票这种脏数据清理起来很烦。用事务包起来要么全成功要么全回滚。// 服务端创建投票接口Node.js mysql2 示例 async function createVote(conn, { title, descText, voteType, maxChoose, options, creator }) { const conn2 await conn.getConnection(); try { await conn2.beginTransaction(); // 1. 写入投票主表初始状态为草稿 const [voteRes] await conn2.execute( INSERT INTO vote (title, desc_text, vote_type, max_choose, status, creator) VALUES (?,?,?,?,0,?), [title, descText, voteType, maxChoose, creator] ); const voteId voteRes.insertId; // 2. 批量写入选项sort 按数组顺序递增 const values options.map((content, i) [voteId, content, i]); await conn2.query( INSERT INTO vote_option (vote_id, content, sort) VALUES ?, [values] ); await conn2.commit(); return { ok: true, voteId }; } catch (e) { await conn2.rollback(); return { ok: false, msg: e.message }; } finally { conn2.release(); } }逻辑上分两步先插主表拿到自增 voteId再用这个 id 批量插选项。参数status写死为 0草稿发布时再改。批量插入用VALUES ?配合二维数组比循环单条插入快很多选项多的时候差距明显。sort用数组下标保证选项顺序和用户填写顺序一致。事务的rollback放在 catch 里任何一步失败都不会留下半条数据。3.2 发布接口与状态流转别让草稿被误投发布接口要做的事很简单校验当前用户是不是创建者然后把 status 从 0 改成 1同时写入 start_time。这里有个权限校验不能省——如果不校验 creator任何人拿到 voteId 都能发布别人的投票这是个低级但常见的漏洞。// 发布投票只有创建者本人能发布 async function publishVote(conn, { voteId, openid }) { const [rows] await conn.execute( SELECT creator, status FROM vote WHERE id ?, [voteId] ); if (rows.length 0) return { ok: false, msg: 投票不存在 }; if (rows[0].creator ! openid) return { ok: false, msg: 无权限 }; if (rows[0].status ! 0) return { ok: false, msg: 当前状态不可发布 }; await conn.execute( UPDATE vote SET status 1, start_time NOW() WHERE id ?, [voteId] ); return { ok: true }; }状态流转的规则要写清楚0 草稿只能变 1 进行中1 进行中只能变 2 已结束不允许从 2 回到 1。这个约束在接口里判断比在前端藏按钮可靠。start_time用数据库的NOW()而不是应用层时间避免服务器时区不一致导致的时间错乱——这个坑我在跨时区部署时踩过一次血泪经验。3.3 详情页拉取数据一次请求拿全投票和选项详情页需要投票信息 选项列表 当前用户是否已投。最省事的做法是一个接口全返回减少小程序端的请求次数。小程序网络请求有并发限制能合并就合并。// 详情接口返回投票、选项、当前用户投票状态 async function getVoteDetail(conn, { voteId, openid }) { const [voteRows] await conn.execute( SELECT id, title, desc_text, vote_type, max_choose, status, end_time FROM vote WHERE id ?, [voteId] ); if (voteRows.length 0) return { ok: false, msg: 投票不存在 }; const [optionRows] await conn.execute( SELECT id, content, sort FROM vote_option WHERE vote_id ? ORDER BY sort ASC, [voteId] ); const [recordRows] await conn.execute( SELECT option_id FROM vote_record WHERE vote_id ? AND openid ?, [voteId, openid] ); return { ok: true, vote: voteRows[0], options: optionRows, myChoice: recordRows.map(r r.option_id), // 已投则非空 hasVoted: recordRows.length 0 }; }三个查询分别拿投票、选项、我的投票记录。myChoice返回数组是为了兼容多选单选时数组长度为 1。hasVoted单独给一个布尔值前端判断是否禁用投票按钮时直接用不用再去判断数组长度。选项查询带ORDER BY sort保证展示顺序稳定。4. 投票提交与防刷票唯一索引只是第一道门投票提交是整个系统里最需要小心的地方。用户点一下按钮背后要做的事包括校验投票是否在进行中、校验选项是否合法、校验是否重复投票、写入记录、返回最新结果。任何一步漏了要么数据错乱要么被人刷票。4.1 提交接口把重复投票挡在数据库层防重复投票最可靠的做法不是先查再插而是直接插、靠唯一索引兜底。先查再插在并发下有两个请求同时通过查询的情况唯一索引则从数据库层面保证不会重复。// 提交投票依赖唯一索引 uk_vote_user 防重复 async function submitVote(conn, { voteId, openid, optionIds }) { // 1. 校验投票状态和类型 const [voteRows] await conn.execute( SELECT vote_type, max_choose, status, end_time FROM vote WHERE id ?, [voteId] ); if (voteRows.length 0) return { ok: false, msg: 投票不存在 }; const vote voteRows[0]; if (vote.status ! 1) return { ok: false, msg: 投票未开始或已结束 }; if (vote.end_time new Date(vote.end_time) new Date()) { return { ok: false, msg: 投票已过期 }; } // 2. 校验选项数量 if (vote.vote_type 1 optionIds.length ! 1) { return { ok: false, msg: 单选只能选一项 }; } if (vote.vote_type 2 optionIds.length vote.max_choose) { return { ok: false, msg: 超过最多可选数 }; } // 3. 写入记录唯一索引冲突即视为重复投票 try { const values optionIds.map(oid [voteId, openid, oid]); await conn.query( INSERT INTO vote_record (vote_id, openid, option_id) VALUES ?, [values] ); return { ok: true }; } catch (e) { if (e.code ER_DUP_ENTRY) { return { ok: false, msg: 你已经投过票了 }; } throw e; } }关键在第三步直接插入捕获ER_DUP_ENTRY错误码返回友好提示。这样即使两个请求同时到达数据库也只会成功一条。参数校验里end_time的判断是双保险因为 status 可能因为没及时更新而滞后。多选时optionIds是数组单选时长度必须为 1。4.2 结果统计实时算还是缓存算结果统计有两种做法每次请求都GROUP BY实时算或者单独维护一个计数表。demo 阶段数据量小实时算完全够用代码也简单。-- 实时统计每个选项的票数 SELECT o.id, o.content, COUNT(r.id) AS cnt FROM vote_option o LEFT JOIN vote_record r ON r.option_id o.id WHERE o.vote_id ? GROUP BY o.id, o.content ORDER BY o.sort ASC;用LEFT JOIN而不是INNER JOIN是为了让零票的选项也能显示出来否则用户会以为选项丢了。COUNT(r.id)统计记录数零票时返回 0。数据量上万以后这个查询会变慢那时候再考虑加计数表或用缓存demo 阶段没必要提前优化。4.3 防刷票的三个层次防刷票不是单一手段能解决的我一般分三层做。第一层是数据库唯一索引挡重复提交第二层是接口频率限制同一 openid 短时间内多次请求直接拒绝第三层是业务规则比如投票必须关注后才能投、投票后不能取消。三层叠加普通刷票基本没戏。频率限制可以用简单的内存计数实现demo 够用// 简易频率限制同一 openid 10 秒内最多提交 3 次 const rateMap new Map(); function checkRate(openid) { const now Date.now(); const arr (rateMap.get(openid) || []).filter(t now - t 10000); if (arr.length 3) return false; arr.push(now); rateMap.set(openid, arr); return true; }这个实现放在单机内存里重启就清空多实例部署也不共享。demo 够用生产环境要换成 Redis。参数 10000 毫秒和 3 次是拍脑袋定的实际按业务调整——投票这种低频操作限制可以更严。5. 避坑与排查那些让 demo 跑不起来的细节前面讲的都是顺路径但真正让人卡住的往往是些不起眼的细节。这一章列几个我在做投票系统时反复遇到的坑每条按现象、原因、解决来说能帮你省下不少调试时间。5.1 投票按钮点了没反应控制台也没报错现象是用户点击投票按钮界面毫无变化开发者工具控制台干净得像什么都没发生。原因通常是按钮绑定的方法名和 js 里定义的不一致或者按钮被一个透明的遮罩层盖住了。小程序不会对这种情况报错只会静默失败。解决方法是先在方法第一行加console.log(clicked)确认事件有没有触发没触发就检查 wxml 里的bindtap名字触发了就检查后续逻辑。这个坑新手几乎必踩加日志是最快的定位手段。5.2 唯一索引建了重复投票还是能提交现象是明明建了uk_vote_user唯一索引测试时同一个账号还是投了两次。原因多半是索引建在了错误的字段组合上比如只建了openid单列索引或者建索引时表里已有重复数据导致索引没生效。解决方法是先用SHOW INDEX FROM vote_record确认索引存在且字段正确再查SELECT vote_id, openid, COUNT(*) FROM vote_record GROUP BY vote_id, openid HAVING COUNT(*) 1看有没有历史脏数据。有脏数据要先清理再重建索引。5.3 多选投票提交后只记录了一项现象是用户多选了三项提交后数据库里只有一条记录。原因是插入时用了execute而不是queryexecute对批量二维数组的支持和query不一样容易只处理第一组。解决方法是批量插入统一用query或者改成循环单条插入。这个差异在 mysql2 里比较隐蔽参数说明里很少提踩过一次就记住了。5.4 投票结束后前端还能点现象是投票已经过了 end_time前端按钮还是可点状态。原因是前端只判断了 status没判断 end_time而 status 可能因为没人触发更新还是 1。解决方法是前端在渲染时同时判断status 1 (!endTime || new Date(endTime) new Date())后端提交接口也要做同样判断。前后端都判断才不会出现「前端禁了后端还能提交」或反过来的情况。5.5 选项顺序每次刷新都不一样现象是同一个投票每次进详情页选项顺序都变。原因是查询时没加ORDER BY数据库返回顺序不保证稳定。解决方法是选项查询固定加ORDER BY sort ASC并且创建时把 sort 按填写顺序写死。这个坑不影响功能但很影响体验用户会以为选项在乱跳。6. 从 demo 到能用几个让投票系统更稳的进阶技巧demo 跑通之后如果想真正拿去用还有几处值得加固。这些不是必须但做了之后系统的可靠性和体验会明显上一个台阶。第一个技巧是给投票加一个「结果可见性」开关。有些投票希望投完才能看结果有些希望随时能看。做法是在 vote 表加一个result_visible字段0 表示投后可见1 表示随时可见。详情接口根据这个字段和hasVoted决定要不要返回票数。这个改动很小但能覆盖大部分真实场景。第二个技巧是结果页的票数用百分比而不是绝对数展示。绝对数在票少的时候会暴露「只有 3 个人投票」的尴尬百分比更耐看。计算时注意除零总票数为 0 时统一显示 0%。// 票数转百分比注意除零 function toPercent(list) { const total list.reduce((s, item) s item.cnt, 0); return list.map(item ({ ...item, percent: total 0 ? 0 : Math.round((item.cnt / total) * 100) })); }Math.round会让百分比之和偶尔不等于 100这是正常的舍入误差不用强行修正。真要精确就用最大余数法分配但 demo 没必要。第三个技巧是给创建者一个「复制投票」功能。实际使用中同一类投票经常要重复发起比如每周选一次优秀作品。复制功能就是把 vote 和 option 读出来换个标题重新插入status 重置为草稿。实现上复用创建接口即可注意新投票的 creator 要是当前操作人。加固项改动位置收益成本结果可见性开关vote 表 详情接口适配更多场景低百分比展示结果页体验更好低复制投票创建接口复用提升复用效率中频率限制换 Redis提交接口多实例可用中计数表缓存统计逻辑大数据量不卡高这张表里前两项我建议 demo 阶段就加上成本低收益直接。后三项等真有量了再说提前做就是过度设计。最后一个技巧关于验证上线前一定要用两个不同微信号互投一遍再用同一个号重复投一次确认唯一索引生效。我自己的习惯是每次改完投票相关逻辑都跑一遍「创建 → 发布 → 单选投 → 多选投 → 重复投 → 过期投」这六步六步都过才算改完。这个习惯帮我挡掉过好几次「改 A 坏 B」的回归问题。投票系统逻辑不复杂但状态和权限的组合多靠人脑记不如靠固定流程跑一遍。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑