小程序投票系统源码拆解:数据模型、状态机与票数统计闭环
简介这是一套面向微信小程序初学者与进阶开发者的投票系统实战源码聚焦创建投票、发布投票、结果统计与展示三类核心功能的完整落地可支撑意见征集、评选活动、市场调研等轻量业务场景也可作为小程序工程结构与 wxml 页面编写的参考样例。压缩包共 90 个文件、约 153KB以 49 个 wxss 样式表与 6 个 wxml 结构文件搭建页面骨架8 个 js 与 8 个 json 承载业务逻辑和页面配置另含 png、jpg 图标资源、php 接口示例、sql 建表脚本及 weui 基础样式与 README 说明体量轻小便携。已有 3647 人学习下载在同类小程序示例中具备一定参考价值。目录按 pages、utils、components、model、mock 等模块拆分读者可据此理解页面生命周期、网络请求封装、组件复用与模拟数据调试的常规做法并在此基础上扩展登录、权限控制与防刷等能力。1. 小程序投票系统先看发布链路创建、发布、投票三态怎么串起来拿到这份小程序投票系统创建投票发布 Demo 完整源码时我先不看页面样式而是把创建投票、发布投票、用户投票三条链路各跑一遍。它解决的是投票类活动最费时间的骨架问题管理端建活动、配置选项、发布后让小程序端拉到列表、提交选项并汇总结果。适合课程设计、接私活做原型、小团队临时活动页的人。源码包能不能直接用关键不在页面多漂亮而在发布状态、选项绑定和票数统计是否闭环。下面按我拆包的顺序写先讲数据模型再落到本地跑通和排错最后给一套验收习惯。2. 拆投票数据模型HTML 管理端与 MP 小程序端怎么共用一套字段投票 Demo 最常见的翻车不是页面报错而是“管理端能建、用户端看不到”或者“票数对不上”。根源通常是字段和状态没统一。我的习惯先画一条链路HTML 管理端提交投票草稿服务端保存投票主表和选项表发布接口只负责改状态MP 小程序端按状态和时间窗口拉列表用户提交后写入记录表结果接口从记录表聚合票数。这个链路里投票主表存标题、类型、状态、时间选项表存选项文本和排序记录表存用户标识、投票 ID、选项 ID 快照和提交时间。不要把票数直接堆在选项表里当唯一数据源票数是聚合结果记录表才是黑匣子后面查重复投票、回滚活动、核对历史结果都靠它。2.1 四张表的最小字段设计我拆这类源码时会先找建表脚本或数据库初始化文件把字段对一遍。下面这套字段够跑通创建、发布、投票、统计四步也方便后面换成关系型数据库。字段名用下划线接口层再转成前端习惯的驼峰别让数据库字段直接漏到页面里。表关键字段用途voteid、title、vote_type、max_choices、status、start_at、end_at、created_at投票主表控制类型、状态和时间窗口vote_optionid、vote_id、label、sort_no选项表排序和文案都在这里vote_recordid、vote_id、option_id、voter_key、option_snapshot、created_at投票记录票数从这里聚合可选用户表id、voter_key、created_at需要昵称或头像时再扩展不急着加-- 投票主表status 只允许 draft/published/closed CREATE TABLE vote ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, vote_type TEXT NOT NULL DEFAULT single, max_choices INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT draft, start_at TEXT, end_at TEXT, created_at TEXT NOT NULL ); -- 选项表sort_no 控制展示顺序别依赖 id 排序 CREATE TABLE vote_option ( id INTEGER PRIMARY KEY, vote_id INTEGER NOT NULL, label TEXT NOT NULL, sort_no INTEGER NOT NULL DEFAULT 0 ); -- 记录表option_snapshot 存提交时的选项文案后续改文案不影响历史 CREATE TABLE vote_record ( id INTEGER PRIMARY KEY, vote_id INTEGER NOT NULL, option_id INTEGER NOT NULL, voter_key TEXT NOT NULL, option_snapshot TEXT NOT NULL, created_at TEXT NOT NULL ); -- 同一用户对同一选项只能投一次多选时不同 option_id 仍可写入 CREATE UNIQUE INDEX uk_vote_voter_option ON vote_record(vote_id, voter_key, option_id);逻辑说明vote管活动本身vote_option管选项vote_record管每一次选择。票数不落库到选项表结果页每次聚合数据口径更稳。option_snapshot是我很看重的字段投票发布后难免有人想改文案没有快照就会出现“旧票算到新文案上”的玄学问题。参数说明vote_type用single和multi两种值页面判断和接口校验都认这两个值max_choices在单选时固定为 1多选时不能超过选项总数status只允许draft、published、closed不要出现true/false和字符串混用voter_key用本地生成的稳定标识不要每次提交随机生成sort_no用来固定选项顺序用户提交后顺序变化会让人怀疑票数错位。提示不同数据库对自增主键和唯一索引冲突的写法不一样建表脚本先按你手里的数据库方言改别直接复制到生产库跑。2.2 HTML 管理端与 MP 端的接口字段约定HTML 管理端和 MP 小程序端共用一套接口时最怕一边传voteType一边收vote_type。我的做法是数据库保留蛇形字段服务端出口统一转驼峰页面只认驼峰。管理端创建表单提交title、voteType、maxChoices、options小程序端详情接口返回同样的结构选项只多一个sortNo。这样管理端加字段时小程序端不用跟着改一堆映射。// 服务端出口统一转 DTO避免数据库字段直接漏给页面 function toVoteDto(row, options) { return { id: Number(row.id), title: row.title, voteType: row.vote_type, maxChoices: Number(row.max_choices), status: row.status, startAt: row.start_at || null, endAt: row.end_at || null, options: options .sort((a, b) Number(a.sort_no) - Number(b.sort_no)) .map(o ({ id: Number(o.id), label: o.label, sortNo: Number(o.sort_no) })) }; }逻辑说明toVoteDto把数据库行和选项数组拼成页面能直接用的结构。options先按sort_no排序再映射保证管理端和小程序端看到的顺序一致。id和maxChoices显式转数字减少页面层1和1比较带来的坑。参数说明row是投票主表记录options是当前投票的选项数组startAt、endAt为空时返回null页面判断时间窗口时不用猜空字符串status原样返回页面只对published展示投票按钮。接口外层建议统一成{ code, data, message }页面先判code再取data不要每个接口写一套错误判断。2.3 状态机先定死再写页面投票系统的状态不多但特别容易写散。创建时默认draft发布时只允许draft - published关闭时只允许published - closed。已关闭的投票不能重新发布要重新发起就复制一条新草稿。这个规则提前写进服务端页面按钮只是展示不能靠前端隐藏按钮来保证状态正确。// 状态流转表只允许向前走不允许回退 const ALLOWED { draft: [published], published: [closed], closed: [] }; function assertTransition(from, to) { if (!ALLOWED[from] || !ALLOWED[from].includes(to)) { throw new Error(非法状态流转: ${from} - ${to}); } }逻辑说明assertTransition放在发布和关闭接口的入口先校验再更新。这样即使有人直接调接口也不会把已关闭的投票改回发布中。页面层的“发布”按钮只在draft显示“关闭”按钮只在published显示减少误操作。参数说明from是数据库当前状态to是本次请求目标状态错误信息里带原状态和目标状态排查日志时一眼能看出是哪一步被拒。发布接口还要补三条校验选项至少两个、结束时间晚于开始时间、maxChoices不超过选项数。这三条不补后面投票提交和结果聚合都会出现边界数据。3. 本地跑通创建投票从建表到接口联调的实操顺序很多源码包解压后第一反应是找页面入口我更建议先看服务端配置和建表脚本。顺序是确认服务端入口配置数据库路径和端口导入建表 SQL启动服务用接口工具创建一条草稿再打开 HTML 管理端看列表。不要一上来就点“创建投票”接口报错没有日志只能靠猜。常见做法是把服务端日志级别开高所有请求打印路径、入参摘要和响应码小程序端请求地址集中到一个配置文件别散落在页面里。3.1 服务端配置与启动先看服务端目录有没有依赖清单和入口文件。有依赖清单就先安装依赖没有就找单文件入口。启动命令里的端口和数据库路径用环境变量覆盖后面换环境不用改代码。日志出现监听端口后再联调别服务还没起来就怀疑页面。# 进入服务端目录先看入口和配置文件 ls cat package.json 2/dev/null || true # 有依赖清单时安装依赖没有就跳过这一步 npm install # 启动服务端口和数据库文件路径用环境变量覆盖 PORT3000 DB_FILE./data/vote.db node app.js逻辑说明PORT是接口端口HTML 管理端和小程序端都按这个端口拼地址DB_FILE是本地数据库文件路径第一次启动时目录要先存在node app.js要替换成源码包里的实际入口文件。日志里出现类似listen 3000的输出再走下一步。参数说明端口不要和本机已占用服务冲突换端口时记得同步改前端apiBase数据库文件路径用相对路径时注意启动命令所在目录很多“建表成功但查询为空”就是启动目录不同导致读到了新库。没有依赖清单的源码包先看入口文件里的require或import缺什么补什么不要盲目全量安装。3.2 创建投票接口的入参和校验创建接口只生成草稿不直接发布。入参最少要有标题、投票类型、最大可选数和选项数组。选项数组至少两个单选时maxChoices强制为 1多选时不能超过选项总数。先用接口工具打一条确认返回的id和status正确再打开管理端页面。# 创建一条草稿投票注意字段名要和接口约定一致 curl -X POST http://127.0.0.1:3000/api/votes \ -H Content-Type: application/json \ -d { title: 周末活动方案投票, voteType: single, maxChoices: 1, options: [方案A, 方案B, 方案C] }逻辑说明这条请求走创建接口服务端应该返回{ code: 0, data: { id, status: draft } }。如果返回 400先看字段名是voteType还是vote_type再看选项数组是不是被当成了字符串。创建成功后去数据库查vote和vote_option两张表确认主表和选项都落库。参数说明title去掉首尾空格后不能为空voteType只传single或multimaxChoices单选传 1多选传大于 1 的整数options传字符串数组空字符串先过滤。接口层不要直接信任前端服务端再校验一遍尤其是多选上限。// 创建接口的核心校验和落库顺序结构示意 app.post(/api/votes, async (req, res) { const { title, voteType single, maxChoices 1, options [] } req.body; if (!title || !Array.isArray(options) || options.length 2) { return res.status(400).json({ code: 400, message: 标题和至少两个选项必填 }); } if (voteType single Number(maxChoices) ! 1) { return res.status(400).json({ code: 400, message: 单选投票 maxChoices 必须为 1 }); } if (voteType multi Number(maxChoices) options.length) { return res.status(400).json({ code: 400, message: 多选上限不能超过选项数 }); } const voteId await db.insertVote({ title: title.trim(), voteType, maxChoices: Number(maxChoices), status: draft }); await db.insertOptions(voteId, options.map(s String(s).trim()).filter(Boolean)); res.json({ code: 0, data: { id: voteId, status: draft } }); });逻辑说明先校验再落库避免主表写进去、选项表失败留下半条数据。insertVote和insertOptions是抽象方法实际源码里可能叫别的名字按入口文件替换。创建接口只返回草稿 ID不返回发布态后面发布按钮单独调接口。参数说明title为空、选项少于两个直接 400单选时maxChoices必须等于 1多选时maxChoices不能超过选项数options过滤空字符串后再判断数量否则用户多敲一个空行就会绕过校验。落库顺序先主表后选项表失败时要么回滚要么把主表标记为无效草稿。3.3 HTML 管理端联调列表、创建、发布三个按钮管理端页面联调先跑三个按钮创建、刷新列表、发布。创建表单里选项用多行文本一行一个提交前过滤空行单选时maxChoices强制 1发布按钮单独调发布接口不要在创建接口里顺手改状态。页面请求统一走一个fetch封装错误信息直接弹出来别只写控制台。// 管理端创建投票表单值转接口入参 async function createVote(form) { const options form.options.value .split(\n) .map(s s.trim()) .filter(Boolean); const payload { title: form.title.value.trim(), voteType: form.voteType.value, maxChoices: form.voteType.value single ? 1 : Number(form.maxChoices.value), options }; const resp await fetch(/api/votes, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); const body await resp.json(); if (body.code ! 0) throw new Error(body.message || 创建失败); return body.data; }逻辑说明split(\n)按行拆选项trim去空格filter(Boolean)过滤空行。voteType为single时maxChoices直接给 1不读输入框减少前端误填。返回body.data里有新投票 ID列表刷新用这个 ID 高亮新记录。参数说明form.title、form.options是表单控件实际页面按你的 DOM 结构调整maxChoices多选时转数字别传字符串fetch的地址按当前管理端路径拼接前后端不同端口时要么配代理要么写完整接口地址。发布按钮调/api/votes/:id/publish成功后把列表状态从draft改成published不要只改页面不刷库。4. 发布与投票提交MP 小程序端页面逻辑、校验与结果聚合发布不是改个状态就完事。发布后要保证 MP 小程序端能按时间窗口拉取、选项顺序固定、提交记录幂等、结果聚合不重复。我的顺序是先写发布接口和状态校验再写小程序页面加载详情最后写提交和统计。页面层只做展示和轻校验真正校验在服务端别把maxChoices判断只放在前端否则改包或直接调接口就绕过去了。血泪经验是投票类 Demo 的票数问题十有八九不是统计 SQL 写错而是提交入口没防重。4.1 发布接口的时间窗口和状态校验发布接口先查投票是否存在再校验状态必须是draft选项至少两个结束时间晚于开始时间。开始时间不传就取当前时间结束时间不传表示长期有效。时间统一存 ISO 字符串展示层再本地化。发布成功后返回最新状态管理端列表按返回值刷新。// 发布接口只允许草稿发布时间窗口在入口校验 app.post(/api/votes/:id/publish, async (req, res) { const vote await db.findVote(req.params.id); if (!vote) return res.status(404).json({ code: 404, message: 投票不存在 }); if (vote.status ! draft) { return res.status(409).json({ code: 409, message: 只有草稿能发布 }); } const options await db.findOptions(vote.id); if (options.length 2) { return res.status(400).json({ code: 400, message: 选项不足 }); } const startAt req.body.startAt || new Date().toISOString(); const endAt req.body.endAt || null; if (endAt new Date(endAt) new Date(startAt)) { return res.status(400).json({ code: 400, message: 结束时间必须晚于开始时间 }); } await db.updateVote(vote.id, { status: published, start_at: startAt, end_at: endAt }); res.json({ code: 0, data: { id: vote.id, status: published } }); });逻辑说明先查再校验避免对不存在的投票做更新。状态不是draft直接 409管理端拿到 409 就知道按钮状态过期刷新列表即可。时间窗口在发布时落库小程序端列表接口再按时间过滤。参数说明startAt、endAt用 ISO 字符串服务端存统一时区页面展示再转本地endAt为空表示不设结束时间vote.id从路径参数取记得转数字再查库。发布接口不建议做“重复发布返回成功”否则管理端会误以为状态被重置。4.2 MP 端 onLoad 拉详情与提交投票小程序端页面加载时先拉详情判断status是否为published不是就提示未发布。选项点击时单选直接替换数组多选先判断上限再追加。提交时带optionIds和voterKey按钮加submitting锁成功后刷新结果或跳结果页。// 小程序端页面逻辑示意网络请求和提示按实际运行时替换 Page({ data: { vote: null, selected: [], submitting: false }, onLoad(query) { this.voteId Number(query.id); this.loadVote(); }, async loadVote() { const body await request(/api/votes/${this.voteId}); if (body.code ! 0 || body.data.status ! published) { showToast(投票未发布); return; } this.setData({ vote: body.data }); }, toggleOption(e) { const id Number(e.currentTarget.dataset.id); const { vote, selected } this.data; // 单选直接替换多选判断上限 if (vote.voteType single) { this.setData({ selected: [id] }); return; } const next selected.includes(id) ? selected.filter(x x ! id) : [...selected, id]; if (next.length vote.maxChoices) { showToast(最多选 ${vote.maxChoices} 项); return; } this.setData({ selected: next }); }, async submit() { if (this.data.submitting || this.data.selected.length 0) return; this.setData({ submitting: true }); const body await request(/api/votes/${this.voteId}/records, { method: POST, data: { optionIds: this.data.selected.map(id Number(id)), voterKey: getApp().globalData.voterKey } }); this.setData({ submitting: false }); if (body.code 0) showToast(提交成功); else showToast(body.message || 提交失败); } });逻辑说明onLoad里把路径参数转数字避免后面和接口返回的 ID 类型不一致。toggleOption单选和多选分开处理多选先算下一组再判断上限不能先setData再判断。submit用submitting锁住连点selected为空直接返回。参数说明query.id是页面路径带的投票 IDselected始终是数组单选长度为 1voterKey从全局配置取稳定值不要每次提交随机生成request和showToast是抽象方法实际替换成小程序运行时的网络请求和提示 API。提交成功后刷新详情或跳结果页别只弹提示不更新页面。4.3 结果聚合与多选票数口径结果页最容易把“选项票数”和“投票人数”混在一起。单选时每个用户一条记录按选项COUNT就是票数多选时同一用户多个选项各一条记录按选项COUNT是该选项被选次数总人数要单独COUNT(DISTINCT voter_key)。两个口径分开查别在一个 SQL 里既算人数又算选项票数。-- 选项票数多选时统计的是每个选项被选次数 SELECT o.id, o.label, COUNT(r.id) AS vote_count FROM vote_option o LEFT JOIN vote_record r ON r.option_id o.id WHERE o.vote_id ? GROUP BY o.id, o.label, o.sort_no ORDER BY o.sort_no ASC; -- 总投票人数和选项票数分开查 SELECT COUNT(DISTINCT voter_key) AS voter_count FROM vote_record WHERE vote_id ?;逻辑说明第一条用LEFT JOIN没人投的选项也返回 0页面不会漏项。GROUP BY带上sort_no是为了排序稳定。第二条单独查总人数多选时不会把一个人算成多次。参数说明?是当前投票 ID接口层从路径参数取vote_count是该选项被选次数不是人数voter_count是去重后的人数。结果页展示时把两个数字的标签写清楚别把“票数”和“参与人数”混用。5. 避坑与排查投票 Demo 最容易翻车的 5 个现场这份 Demo 的页面逻辑不复杂真正耗时间的是状态、字段和统计口径。下面 5 条是我拆投票类源码时反复遇到的现场按现象、原因、解决写照着排查能省不少猜。遇到问题先别改页面先看服务端日志和数据库记录页面只是结果数据链路才是黑匣子。5.1 创建成功但小程序列表为空现象HTML 管理端创建后列表有数据MP 小程序端刷新看不到。原因通常有三种创建接口默认draft小程序列表只查published分页参数写死page0后端从page1开始开始时间和结束时间时区处理不一致刚发布就被过滤掉。解决顺序是先查数据库status再看列表接口 SQL 条件最后打印时间参数。-- 列表接口的过滤条件时间参数统一用 UTC 字符串 SELECT id, title FROM vote WHERE status published AND (start_at IS NULL OR start_at ?) AND (end_at IS NULL OR end_at ?) ORDER BY id DESC;逻辑说明这条 SQL 只返回已发布且在时间窗口内的投票。start_at为空表示不限开始时间end_at为空表示不限结束时间。小程序端列表空时先手动执行这条 SQL把两个时间参数换成当前时间看能不能查出数据。参数说明?分别是当前时间和当前时间格式要和入库格式一致分页参数检查page起始值前端传 0 后端按 1 处理时要在接口层统一时间统一存 UTC页面展示再转本地别一半存本地一半存 UTC。5.2 提交投票报 400 或“选项不存在”现象页面明明选中了选项提交返回 400。原因多是类型不一致接口返回的选项 ID 是数字页面dataset取出来变字符串服务端用比较或者管理端建选项传数组服务端只接收逗号分隔字符串多选时传了单个 ID 而不是数组。解决是入口统一转数字提交前打印 payload服务端再校验选项属于当前投票。// 提交前把选项 ID 统一转数字服务端同样做类型归一化 const payload { optionIds: selected.map(id Number(id)), voterKey: String(voterKey) }; console.log(submit payload, payload);逻辑说明map(Number)把页面取到的字符串 ID 转成数字String(voterKey)保证用户标识是字符串。打印 payload 是为了在控制台直接看到提交结构和接口文档对一遍。服务端收到后还要查vote_option确认每个option_id都属于当前vote_id。参数说明selected必须是数组单选也传长度为 1 的数组voterKey不能为空服务端比较 ID 时统一转数字别用字符串和数字混比。返回 400 时把错误信息写具体比如“选项不属于当前投票”比只返回“参数错误”好排查。5.3 票数越投越多或重复提交现象同一用户连续点击提交票数翻倍。原因是前端没有submitting锁后端没有唯一约束提交接口不是幂等。解决分三层前端按钮锁住后端记录表加本文还有配套的精品资源点击获取