做投票类小程序这几年,我见过不下十种设计方案。有的用第三方问卷工具套个网页链接,有的直接在公众号文章里嵌表单,还有的干脆让用户截图私聊人工记票。这些方案的问题都出在一个点上:投票是强实时、强互动的场景,一旦参与人数上来,表单链接失效、数据统计混乱、重复投票完全挡不住。所以当我接到“多用户微信投票系统源码”这个需求时,第一反应就是:这必须是一个独立小程序,配一套完整后端接口,还得把“多用户”这三个字拆开想清楚。
这套源码解决的核心问题可以概括成一句话:不同管理员各自创建投票活动,各自的微信用户参与投票,每个活动的数据完全隔离,系统统一管理用户身份并防刷票。它适合三类人看:一是想给企业/学校/社区做内部评选的技术同学,二是接外包项目需要一套可复用投票模块的开发者,三是对小程序开发有基础、想低成本搭建一个可运营投票产品的创业者。下面我会从设计思路、数据库模型、接口实现、部署调试到踩坑记录,把整套方案掰开揉碎讲清楚。
1. 先想清楚:投票系统到底在解决什么问题
1.1 单活动网页投票和小程序投票,差的不是一星半点
很多人觉得投票无非是“展示几个选项,用户点一下,累计加一”。真做过一次活动就知道,完全不是这么回事。用网页做的投票,用户每次打开都要重新输入验证码,体验差强人意;数据存在第三方表格里,你想按规则剔除异常票根本无从下手;最要命的是重复投票,同一个用户换台手机换张SIM卡就能再投,主办方看着数据却毫无办法。
小程序投票最大的优势在于用户身份是天然的。微信生态提供了匿名但唯一的 openid,每个用户的身份通过 wx.login 可以稳定获取,这就给“一人一票”打下了基础。再加上小程序本身的传播路径短,用户从微信群点开小程序卡片到完成投票,整个过程三秒内可以结束,转化率比网页链接高一个数量级。
所以这套系统的设计原则就清晰了:身份靠微信,业务靠后端,数据隔离靠活动ID,防刷票靠多维校验。用户端不需要注册、不需要填手机号(用不到的时候坚决不弹窗),点开即投;管理员端看到的是实时更新的趋势数据,每一票都能追溯来源。
1.2 “多用户”的真实含义:不只一个用户能投票
标题里的“多用户”一定要拆成两个层面理解,否则数据库表设计这一步就会跑偏。
第一个层面是 C 端参与者众多。也就是成百上千的微信用户同时在一个活动里投票,系统要扛得住瞬时流量,要保证同一用户不能重复投票,还得能区分“真实用户”和“刷票机器人”。这是技术层面最枪的事情,我的建议是:身份唯一时用 openid,频次控制时配合 IP + 时间窗口,两条腿走路。
第二个层面是 B 端管理员众多。也就是系统里同时存在多个主办方,每个主办方各自创建活动、各自看各自的数据,互不可见。举个例子,同一个部署实例可以同时服务 A 公司的优秀员工评选和 B 社区的最美店主打 call,A 公司的管理员登录后看不到 B 社区的任何一个活动。这个隔离看着简单,实际做起来常见的坑就是查询时漏掉 activity_id 条件,导致数据串台。所以我在源码里把所有活动相关的 SQL 都强制带上 WHERE activity_id = ?,并在函数入口统一校验当前管理员的权限范围。
1.3 低成本的边界在哪里
低成本不等于零成本,这点必须先说清楚。微信小程序个人主体可以注册,但投票类目通常需要企业主体,认证费是省不掉的(300元/年)。域名和 HTTPS 证书也需要,证书可以用免费的 Let's Encrypt 或腾讯云免费证书,域名一年几十块。服务器这块,早期流量不大,一台最便宜的云主机(2核4G)就能跑 PHP + MySQL + Nginx,一个月几十块钱。整套算下来,第一年硬性成本一千元以内能覆盖,相比直接买商业投票 SaaS(通常按活动数收费,一个活动几百到几千不等),自建一套多用户系统的成本优势是数量级的。
真正的低成本还体现在开发效率上。我选了 PHP 作为后端语言而没有用 Java 或 Go,原因很实在:PHP 部署简单(一个 Nginx + PHP-FPM 就够)、生态里现成的鉴权库和工具链丰富、代码改完马上生效不用编译,单人维护成本极低。如果你团队里的同学不会 PHP,前端完全可以用微信原生框架写,后端换成 Node.js 或 Go 也完全可以,架构上不会受任何限制。
2. 系统设计与数据库模型拆解
2.1 技术栈选型:前后端怎么分工最合理
整套系统的架构分成三个部分:微信小程序前端、后端 API 服务、数据库。前端负责展示和交互,后端负责业务逻辑和权限校验,数据库负责持久化。
小程序前端我用了微信原生框架,没有上 uniapp。原因有几个:一是投票系统的小程序端页面很简单(列表页、详情页、统计页,总共就三四个页面),原生框架已经够用,引入 uniapp 反而多一层打包和兼容性成本;二是原生小程序对微信 API 的支持是最及时的,不需要等第三方框架适配;三是如果你后续要改需求,原生代码的调试体验更好。当然,如果你明确知道以后要同时发布到支付宝、抖音小程序,那就上 uniapp,这个属于产品规划层面的取舍。
后端选 PHP 是因为它和 MySQL 配合最省心,部署资料也最多。再说一次,这不是对语言有偏爱,而是综合考虑单人开发效率、服务器成本、排错便利度之后的最优选。只要保持接口返回 JSON、鉴权用 Token 模式、业务间不共享状态,这套后端逻辑用任何语言重写都不困难。
2.2 数据库核心表设计
数据库设计是整套系统的地基。我直接给出表结构,你建库的时候照着用就行。一共五张表:管理员表、用户表、活动表、选项表、投票记录表。
-- 管理员表 CREATE TABLE `admin` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL COMMENT '登录账号', `password_hash` varchar(255) NOT NULL COMMENT '密码hash', `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- C端用户表 CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `openid` varchar(64) NOT NULL COMMENT '微信openid', `nickname` varchar(128) DEFAULT NULL, `avatar_url` varchar(512) DEFAULT NULL, `phone` varchar(32) DEFAULT NULL COMMENT '可选绑定手机号', `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 投票活动表 CREATE TABLE `activity` ( `id` int(11) NOT NULL AUTO_INCREMENT, `admin_id` int(11) NOT NULL COMMENT '所属管理员', `title` varchar(128) NOT NULL COMMENT '活动标题', `description` text, `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1开启 0关闭', `start_time` datetime DEFAULT NULL, `end_time` datetime DEFAULT NULL, `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_admin_id` (`admin_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 投票选项表 CREATE TABLE `activity_option` ( `id` int(11) NOT NULL AUTO_INCREMENT, `activity_id` int(11) NOT NULL, `title` varchar(128) NOT NULL COMMENT '选项名称', `image_url` varchar(512) DEFAULT NULL, `vote_count` int(11) NOT NULL DEFAULT '0' COMMENT '冗余票数', `sort` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_activity_id` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 投票记录表 CREATE TABLE `vote_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `activity_id` int(11) NOT NULL, `option_id` int(11) NOT NULL, `user_id` int(11) NOT NULL, `ip` varchar(64) DEFAULT NULL, `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`), KEY `idx_activity_id` (`activity_id`), KEY `idx_option_id` (`option_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有两个设计细节值得展开讲。第一是activity_option.vote_count字段,它保存的是冗余计数,目的是让列表页、排行榜页查询时直接按这个字段排序,不用每次实时 COUNT 投票记录表,性能会好很多。第二是vote_record表的唯一索引uk_user_activity,这个索引从数据库层面保证了“一个用户在一个活动中只能投一次票”,就算代码里出现并发提交,数据库也会拦下重复的那一条。这两个字段是整套系统数据一致性的关键,千万别省。
2.3 多活动与多用户的数据隔离方案
数据隔离这件事,做得好不好直接决定系统能不能真正“多用户”跑起来。我的做法很简单:所有业务表都带 activity_id,所有查询都带上这个过滤条件。管理员登录后,我生成一个 Token 存进 Redis 或者数据库 session 表,Token 里绑定 admin_id,每次写操作都校验“这个活动属于当前登录的管理员”。
给你举个串数据的具体场景:A 管理员创建了活动 1001,里面有几个候选选项。如果查询时没带 activity_id,而是直接SELECT * FROM activity_option WHERE id = ?,一旦 B 管理员的活动里恰好也有一个同 ID 的选项,那 A 管理员编辑或投票时就会操作到 B 的活动。这种 bug 在测试环境很难暴露,因为大家都只建一两个活动,但一上线、数据一多,问题就炸了。
所以我源码里在所有跨模块的地方都做了统一封装,比如check_activity_owner($activity_id, $admin_token)这个函数,每次操作前先校验归属,校验不通过直接返回 403。一句话总结:隔离靠的不是人的细心,而是系统层面的强制约束。
3. 核心接口与小程序端实现
3.1 微信登录态与手机号获取
微信小程序登录这块,接口流程是固定的,但初学者最容易在“session_key 过期”这件事上栽跟头。正确流程是:
- 小程序端调用
wx.login()拿到临时 code。 - 把 code 传给后端,后端用 code 换 openid 和 session_key。
- 后端用 openid 查 user 表,不存在就自动注册一条新用户记录,然后返回自定义登录态 token。
// PHP 后端:用 code 换 openid $code = $_POST['code']; $appid = '你的AppID'; $secret = '你的AppSecret'; $url = "https://api.weixin.qq.com/sns/jscode2session?appid={$appid}&secret={$secret}&js_code={$code}&grant_type=authorization_code"; $result = file_get_contents($url); $data = json_decode($result, true); // $data['openid'] 就是用户唯一身份 // $data['session_key'] 用于解密手机号等敏感信息,不要写进日志很多人在这一步会犯一个错:把 code 当作登录态直接存到本地,下次登录继续用。这是不行的,微信的 code 是一次性的,用一次就废了。所以后端必须自己签发一个 token 返回给小程序,之后的每一次请求都带这个 token,后端再根据 token 查出用户身份。token 的过期时间建议设 7 天,用户在活动期间不用反复登录。
手机号获取这块,现在微信推广的是“手机号快速验证组件”,需要企业主体小程序才能申请。按钮是<button open-type="getPhoneNumber">,用户点击授权后,后端用 session_key 解密得到手机号。注意:手机号属于敏感信息,获取前要明确告知用户用途,而且一个用户只能主动触发一次授权,不要设计成“不填手机号就不能投票”这种强制逻辑,合规风险大,用户流失率也高。
3.2 投票主流程接口:从创建活动到数据入库
整套系统的核心接口就四个:创建活动(管理员)、获取活动详情(用户)、提交投票(用户)、查询统计(管理员/用户)。我把投票接口的关键代码贴出来,这是最考验正确性的一段逻辑。
// 提交投票接口 public function vote($user_id, $activity_id, $option_id) { // 1. 校验活动状态 $activity = $this->get_activity($activity_id); if ($activity['status'] != 1) { return $this->error('活动已结束'); } if (time() < strtotime($activity['start_time']) || time() > strtotime($activity['end_time'])) { return $this->error('不在投票时间范围内'); } // 2. 校验选项归属 $option = $this->get_option($option_id); if ($option['activity_id'] != $activity_id) { return $this->error('选项不合法'); } // 3. 写入投票记录,依赖唯一索引防重复 try { $this->db->beginTransaction(); $this->db->insert('vote_record', [ 'activity_id' => $activity_id, 'option_id' => $option_id, 'user_id' => $user_id, 'ip' => $this->get_client_ip(), 'created_at' => date('Y-m-d H:i:s'), ]); // 4. 累加选项票数 $this->db->query( "UPDATE activity_option SET vote_count = vote_count + 1 WHERE id = ? AND activity_id = ?", [$option_id, $activity_id] ); $this->db->commit(); return $this->success('投票成功'); } catch (Exception $e) { $this->db->rollback(); // 重复插入会触发唯一索引冲突,说明用户已经投过 return $this->error('您已经投过票啦'); } }这段代码里有三个关键点。一是用了数据库事务,投票记录和票数累加要么同时成功、要么同时失败,不会出现“记录写进去了票数没变”的中间状态。二是如果把 vote_record 插入和 vote_count 更新分成两个独立步骤跑,一旦第二步失败,前端就会看到投票失败的提示,但后台数据已经写了,用户重试时又会被唯一索引拦住,体验特别差。三是投票前做的状态校验和归属校验,看起来啰嗦,但能挡住绝大多数的参数篡改。比如有人直接调接口传一个不存在的 option_id,或者传别人的活动里的 option_id,这两道校验就能挡下来。
3.3 小程序端页面组织与请求封装
小程序端我建议按这个结构组织页面:
pages/index/index:活动列表页,展示当前所有进行中的活动。pages/detail/detail:活动详情页,展示活动信息、候选选项,点击投票。pages/stats/stats:实时统计页,展示每个选项的票数和占比。pages/admin/admin:管理员页,创建活动、管理选项、查看自己活动的数据。
请求封装是每个小程序项目都必须做的基础工作。我在 utils/request.js 里统一封装了 wx.request,自动带 token、统一处理 401 跳转登录、统一处理错误提示,业务代码只需要关心成功回调。
// utils/request.js function request(url, method, data) { return new Promise((resolve, reject) => { wx.request({ url: `https://你的域名.com/api/${url}`, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') }, success(res) { if (res.data.code !== 0) { if (res.data.code === 401) { wx.navigateTo({ url: '/pages/login/login' }); } wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); return; } resolve(res.data.data); }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };细节很重要:所有接口返回格式统一成{ code: 0, data: {...}, msg: 'success' },前端拿到 code 非 0 就统一弹 toast。这样以后不管加了什么新接口,前端逻辑都不用大改。自定义导航栏的时候要处理顶部安全区,调用wx.getWindowInfo()拿statusBarHeight和safeArea来做适配,否则在刘海屏手机上页面会被怼到状态栏底下,看起来特别业余。
3.4 防刷票的几种实用姿势
刷票是投票系统的永恒敌人,但“完美防刷”是不存在的,你要做的是把刷票成本抬高到对方不值得花这个力气。我从易到难给你列层:
第一层是 openid 唯一约束。这是地基,一个微信号只能投一票,数据库唯一索引保证,代码直接挡掉。缺点是有的人会去网上买群控批量注册微信小号来刷,光靠它拦不住。
第二层是 IP 维度限制。同一 IP 在短时间内(比如 1 小时)投票超过 N 次就触发验证码或者直接拒绝。这个方法对付普通用户手刷已经够了。注意获取客户端真实 IP 时,如果服务器用了 Nginx 反代,要读取HTTP_X_FORWARDED_FOR头,而不是直接取REMOTE_ADDR,否则所有用户都是一个 IP。
第三层是时间窗口和行为特征。比如从点开详情页到提交投票之间耗时小于 2 秒,大概率是脚本在跑;比如投票请求的 User-Agent 里不带微信浏览器的特征字段,大概率是模拟器。当然现在很多群控工具会伪造这些特征,所以这一层只能做辅助参考,不能一刀切拦截,否则容易误伤真实用户。
第四层是验证码和诱导授权。如果活动热度高、刷票刷得厉害,可以在投票前加入滑块验证或者“长按按钮 3 秒”这种低成本的验证方式。它拦不住专业团队,但能把随手刷票的路人挡掉百分之七八十,这就够了。记住一句话:你是做投票活动的,不是做杀软安全产品的,防刷力度要和活动成本匹配。
4. 部署、调试与常见问题实录
4.1 本地开发调试:开发者工具与抓包辅助
微信开发者工具是必备的,这是所有流程的起点。项目导入后,第一步先在“详情 - 本地设置”里勾选“不校验合法域名”,这样你本地开发时调用局域网 IP 接口不会被拦截。第二步在“项目配置 - AppID”里填自己的测试号或者正式号,确保 wx.login 能拿到 code。第三步,把工具里的调试器打开,直接看 Network 面板的请求详情,如果接口报错了,先看请求 URL、请求头、响应体,多半问题就定位了。
如果你想抓真机的请求包,Charles 是个好帮手,手机和电脑连同一个局域网,配置好 SSL Proxying 并安装证书,就能在电脑上看到小程序发的每个请求。这个方法在排查“手机上正常、电脑上不正常”的问题时特别有用,因为手机上微信请求的鉴权头、UA 等细节和开发者工具里模拟的其实有细微差别。
不过提醒一句:抓包工具是用来调试自己和后端交互的请求的,用脚本批量构造请求刷投票属于作弊行为,不在我们的讨论范围内。我把抓包这个方法放在这里,用途只有一个:自测接口在真实手机环境下的表现,排查线上问题。
4.2 服务器部署要点:域名、HTTPS 与微信配置
部署这套系统到云服务器,核心步骤就四步:
- 装环境:Nginx + PHP 7.4+ + MySQL 5.7+。PHP 需要装好
pdo_mysql、openssl扩展,这两个是基础依赖。 - 配站点:把前端编译出来的小程序代码包上传到微信后台(这是前端发布的事,跟服务器无关);后端代码放到服务器,Nginx 配置好
server_name,location/api/转发给 PHP-FPM。 - 配 HTTPS:申请免费证书,配置好后用
https://你的域名打开能正常返回 JSON 就说明通了。 - 配微信后台:在微信公众平台 - 开发管理 - 服务器域名里,把 request 合法域名填成你的 HTTPS 域名。这一步忘了配置,线上小程序一调接口就报 “url not in domain list”,这是新手最常见的报错之一。
# Nginx 站点配置示例 server { listen 443 ssl; server_name vote.example.com; ssl_certificate /etc/nginx/cert/example.pem; ssl_certificate_key /etc/nginx/cert/example.key; root /var/www/vote/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }数据库这块,上线前记得把 MySQL 的安全基线提上来:关闭 root 远程登录,单独建一个业务账号,密码用随机字符串,数据表统一用utf8mb4字符集。utf8mb4 不是可选项,是必须项,它能存表情符号,现在用户昵称里没有 emoji 的反而稀奇。
4.3 高频踩坑清单
我按“现象、原因、解决办法”整理了一张表,都是实际项目里高频出现的,照着排查能省你大量时间。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 开发工具里接口正常,手机真机上请求失败 | 真机上微信会校验 request 合法域名,没配置或证书过期 | 在微信后台配置 HTTPS 域名,确认证书链完整且未过期 |
| 用户投票显示“已经投过”但后台没记录 | 前端按钮被点击两次,两个请求同时到达,一个写成功一个被唯一索引拦截 | 前端提交时加 loading 锁;后端处理器返回的“已投过”文案改成“投票成功”也可以,但建议保留提示以体现真实意图 |
| 用户票数统计和投票记录数不一致 | vote_count 更新和记录写入不在一个事务里,或者统计时用了缓存 | 用事务包裹两条 SQL;统计页直接读 vote_count,不做实时 count |
| 活动列表页加载很慢 | 活动表数据量大了,created 排序没走索引 | activity 表加KEY idx_status_start (status, start_time)组合索引 |
| 微信头像是灰色默认头像 | 2021 年后头像昵称获取规则调整,用户未主动授权不会返回真实信息 | 不用强制获取;如确需展示,引导用户点击授权头像昵称填写能力 |
这里把第 6 条展开说一下。微信在 2021 年 10 月后,小程序里通过wx.getUserProfile拿到的头像昵称已经没法直接用了,现在流行的是“头像昵称填写能力”,就是让用户在小程序里自己选一张头像、自己填一个昵称,微信只负责提供官方选图能力。所以做投票系统的时候,你就别纠结用户头像对不对了,直接用 openid 关联就好。活动页面展示“匿名用户 + 票数”反而更干净,还能避免有人用头像和昵称互相攻击。
4.4 多用户场景下的运营与安全建议
最后聊点运营层面的东西。如果是拿这套系统对外接单、服务多个客户,有三件事必须提前做好。
一是数据隔离的权限审计。至少要有一个超级管理员角色,能看到所有活动的基本信息和数据指标,但要看详细投票记录时要有审计日志。这个其实就是给 admin 表加一个role字段区分超管和普通管理员,接口层面做一层角色判断,逻辑不复杂但非常有用。
二是定时任务。投票活动的状态字段不能全靠用户请求时被动更新,我建议起一个 cron 每分钟跑一遍:把 end_time 小于当前时间且 status 还是 1 的活动批量置为 0(已结束)。这种脚本可以放在系统里作为独立 CLI 入口,不要放进 Web 接口里,免得被外部触发。
三是数据库自动备份。投票活动一旦冒出黑马选手,主办方会想要大量截图和实时报表,这时数据库就是命根子。建议至少每天凌晨自动备份一次,定期测试恢复流程。不要问我为什么要强调恢复流程——等你真遇到“备份文件坏了但你已经删掉线上数据”的时候,哭是没有用的。
这套系统从设计到上线,我前后迭代过三个版本。最深的体会是:投票系统的代码写起来不难,难的是把边界情况想清楚,比如并发重复投票、跨管理员数据串台、活动状态变更时机。把数据库表设计对了,把接口校验都写上,把事务用对地方,剩下的事情都不值得害怕。往后再扩展的话,你可以在这个基础上加抽奖转盘、加排行榜分享卡片、加管理员实时看板,骨架都不用变,往上摞功能就行。希望这篇拆解能帮你少走几段弯路。