简介:基于微信小程序的 CC 投票活动设计源码,是一份适合微信小程序开发者、前端学习者或需要搭建轻量投票活动的个人与团队参考的开源项目。项目围绕图文投票场景展开,支持按天/全程投票模式设定与投票数量限制,并集成了投票列表、投票分类、投票动态、排行榜、生成投票海报及单项目海报等功能,业务逻辑完整,可直接复用或二次开发。
资源压缩包共441个文件,大小约6.1MB,其中以169个js脚本、89个wxss样式、69个wxml页面文件为主要构成,同时包含64个json配置、43张png图片及少量说明文档和动图等。js负责核心逻辑与数据交互,wxss和wxml实现界面布局与页面结构,整体目录清晰、代码可读性较好,便于按模块研读与维护。
目前已有267人学习,适合具备基础小程序知识、希望快速理解投票类小程序完整实现思路的开发者。通过阅读源码,可掌握小程序项目组织方式、数据库工具封装和海报生成等实用技巧,也可据此快速搭建具备图文投票能力的自定义活动应用。
1. CC投票接入微信小程序的正确姿势:先看清需求边界
如果你运营过一场需要实时排名、刷票防控、结果可追溯的评选活动,就会明白“CC投票”这类场景的真正难点不是那张投票页做得多好看,而是后端状态一致性和数据可信度。所谓基于微信小程序的CC投票活动设计源码,通常解决的是一套完整闭环:用户通过小程序进入活动页、浏览候选对象、消耗票数(或按规则分配票数)、前端即时刷新进度、后台留存全部记录并计算最终排名。这里有一个反直觉的结论:真正让开发者加班到深夜的,不是wx:for里多渲染一张卡片,而是“同一用户重复提交”“票数并发扣减”“结果公布前数据被篡改”这三类问题。这篇文章会沿着“数据模型 -> 小程序端实现 -> 投票核心逻辑 -> 上线前调试”的顺序,给出可直接落地的方案。适合有微信小程序基础、准备自研投票运营系统的团队,也适合用 uniapp 微信小程序做跨端移植的同学作为设计参照。
2. CC投票数据模型与后端接口:先于代码把状态机定好
2.1 投票实体的最小字段集合
投票活动本质上是一个有限状态机:草稿、进行中、已结束、已归档。围绕它至少需要三张基础表:活动表、候选对象表、投票记录表。活动表存基础配置,候选表决定卡片怎么渲染,投票记录表是防刷和统计的唯一事实来源。
CREATE TABLE activity ( id VARCHAR(32) PRIMARY KEY, title VARCHAR(200) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0草稿 1进行中 2已结束 3归档', start_time DATETIME, end_time DATETIME, total_votes_per_user INT DEFAULT 1 COMMENT '每个用户总票数', max_votes_per_candidate INT DEFAULT 0 COMMENT '单个候选可投上限,0不限', created_by VARCHAR(64) ); CREATE TABLE candidate ( id VARCHAR(32) PRIMARY KEY, activity_id VARCHAR(32) NOT NULL, name VARCHAR(200) NOT NULL, cover_url VARCHAR(500), sort_order INT DEFAULT 0, status TINYINT DEFAULT 1, KEY idx_activity (activity_id) ); CREATE TABLE vote_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, activity_id VARCHAR(32) NOT NULL, candidate_id VARCHAR(32) NOT NULL, openid VARCHAR(64) NOT NULL, vote_time DATETIME NOT NULL, source VARCHAR(20) DEFAULT 'weapp', request_id VARCHAR(64) UNIQUE COMMENT '幂等键', KEY idx_activity_candidate (activity_id, candidate_id), KEY idx_openid_activity (openid, activity_id) );字段这么设计的原因很简单:total_votes_per_user表示活动粒度限制,max_votes_per_candidate表示单个候选粒度限制,二者组合可以覆盖绝大多数投票玩法。request_id是幂等键,后面会重点讲。
如果使用微信小程序云开发,不一定要 MySQL,可以通过云数据库集合模拟同样结构。注意云数据库的权限默认是“仅创建者可读写”,用于投票记录时一定要在后端云函数中操作数据库,而不是让小程序客户端直接写库。
2.2 用云开发还是自建服务:两种方案的选择
CC投票这种活动通常生命周期短、并发峰值集中,开发节奏要求快。我一般会优先建议用微信小程序云开发,理由有三个:
- 免鉴权:云函数里通过
cloud.getWXContext()直接拿OPENID,不用自己维护登录态。 - 弹性扩缩容:投票高峰流量上升时,云函数实例自动扩展,比自己租一台按固定带宽的云服务器省心。
- 整合能力:结果导出可以直接用云函数生成 Excel,再通过临时文件地址给运营下载。
但自建服务也有优势:如果你已经有用户体系、积分系统,或者要做复杂的数据分析,自建后端更容易打通。下面是一个简单对比:
| 维度 | 微信云开发 | 自建后端 |
|---|---|---|
| 登录鉴权 | 原生支持 openid | 需要自己处理 code2session |
| 数据库访问 | 文档型,适合快速迭代 | 关系型,适合强事务 |
| 冷启动 | 偶发 | 可控 |
| 成本 | 按量付费,低流量很低 | 固定机器,规模上更划算 |
| 部署复杂度 | 低 | 中高 |
我一般在项目初始阶段用云开发跑通原型,如果后续需要运营后台做复杂报表,再把数据同步到自己的分析库。这也是很多成熟 CC 投票系统的实际演进路径。
2.3 投票提交接口的幂等设计
投票接口的幂等性比想象中重要。用户在弱网下点击“投票”后请求超时,往往不自觉地再点一次,如果接口不幂等,票数就双记了。常见的做法是让小程序客户端生成一个request_id,这个 ID 在同一用户同一投票事件中保持不变。
// 云函数 vote/create const cloud = require('wx-server-sdk') const crypto = require('crypto') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event) => { const { OPENID } = cloud.getWXContext() const { activityId, candidateId, requestId } = event if (!requestId || !activityId || !candidateId) { return { code: 400, msg: '参数不完整' } } const voteCol = db.collection('vote_record') const exist = await voteCol.where({ request_id: requestId, openid: OPENID }).count() if (exist.total > 0) { return { code: 200, msg: '已投过票', duplicated: true } } // 这里还要做活动状态、票数上限校验,见第 4 章 await voteCol.add({ data: { activity_id: activityId, candidate_id: candidateId, openid: OPENID, request_id: requestId, vote_time: db.serverDate() } }) return { code: 200, msg: '投票成功', duplicated: false } }参数说明:
requestId:客户端生成的随机字符串,建议长度为 32 位,由openid + 时间戳 + 随机数哈希得到。db.serverDate():使用云端时间,避免用户在本机修改时钟导致时间异常。- 幂等查询和写入不是原子的,极端并发下仍有重复风险,更可靠做法是将
request_id设为唯一索引,写入冲突时捕获异常返回“已投过票”。云开发虽然不支持唯一索引,但可以通过集合的_id设为requestId来变相实现。
3. 微信小程序端投票页面的实现:从静态UI到可交互
3.1 页面结构拆分:候选区、投票按钮、进度反馈
小程序页面建议拆成三个区域:顶部活动信息区、中间候选列表区、底部操作栏。候选列表用wx:for渲染,每条卡片包含头像、名称、当前票数和投票按钮。为了后续做动画和状态反馈,每个候选对象建议有一个独立的voted标志。
<!-- pages/vote/vote.wxml --> <view class="page"> <view class="activity-header"> <text class="title">{{activityInfo.title}}</text> <text class="status">进行中 · 剩余{{activityInfo.remainingDays}}天</text> </view> <scroll-view scroll-y class="candidate-list"> <view class="candidate-card" wx:for="{{candidates}}" wx:key="id"> <image src="{{item.coverUrl}}" mode="aspectFill"></image> <view class="info"> <text class="name">{{item.name}}</text> <view class="vote-progress"> <view class="progress-bar" style="width: {{item.progress}}%"></view> </view> <text class="votes">已获得 {{item.votes}} 票</text> </view> <button class="vote-btn {{item.voted ? 'voted' : ''}}" disabled="{{item.voted || !ableToVote}}" bindtap="handleVote" >// pages/vote/vote.js const { createRequestId } = require('../../utils/random') Page({ data: { activityId: '', candidates: [], remainingVotes: 0, submitting: false }, async handleVote(e) { if (this.data.submitting) return const candidateId = e.currentTarget.dataset.id if (this.data.remainingVotes <= 0) return this.setData({ submitting: true }) const requestId = createRequestId() // 乐观更新:先把本地票数加 1,按钮置为已投 const candidates = this.data.candidates.map(item => { if (item.id === candidateId) { return { ...item, votes: item.votes + 1, voted: true } } return item }) this.setData({ candidates, remainingVotes: this.data.remainingVotes - 1 }) try { const res = await wx.cloud.callFunction({ name: 'vote_create', data: { activityId: this.data.activityId, candidateId, requestId } }) if (res.result.code !== 200) { // 回滚 const rollback = this.data.candidates.map(item => { if (item.id === candidateId) { return { ...item, votes: item.votes - 1, voted: false } } return item }) this.setData({ candidates: rollback, remainingVotes: this.data.remainingVotes + 1 }) } } catch (err) { wx.showToast({ title: '网络异常,请重试', icon: 'none' }) } finally { this.setData({ submitting: false }) } } })代码逻辑说明:
optimistic update(乐观更新)让用户感觉投票立即生效,避免等待网络往返造成“点了没反应”的错觉。- 一旦接口失败,必须回滚本地状态,否则会出现界面已投成功但库中无记录的情况。
submitting是全局锁,避免用户在短时间内对不同候选对象发起多次请求。这个锁必须在finally中释放,而不是只在成功分支释放,否则异常会卡死整个页面。createRequestId()可以用Math.random().toString(36)加一个时间戳前缀生成,保证每次点击都不同。
3.3 实时反馈:剩余票数、动画与结果占位
除了数字变化,投票按钮的按压反馈也影响用户体感。微信小程序的button默认有hover-class,可以自定义为按压态。更进阶的做法是给票数加一个“+1”飞入动画,但这个动画在 iOS 上会频繁触发渲染压力,建议只对最后点击的那个候选做动画。
/* pages/vote/vote.wxss */ .vote-btn { background-color: #ff5a5f; color: #fff; border-radius: 40rpx; padding: 12rpx 40rpx; font-size: 28rpx; transition: all 0.2s ease; } .vote-btn.voted { background-color: #b2b2b2; color: #f5f5f5; } .vote-btn:active { transform: scale(0.92); } .vote-progress { height: 8rpx; background-color: #eee; border-radius: 4rpx; overflow: hidden; } .progress-bar { height: 100%; background: linear-gradient(90deg, #ff9a56, #ff5a5f); border-radius: 4rpx; }要注意的点:小程序里:active伪类在部分 Android 机型上不稳定,如果发现点击样式不生效,就改用hover-class="press",在样式中写.press { transform: scale(0.92); }。另外进度条的宽度计算不要直接在 WXML 里写表达式,像{{item.votes / maxVotes * 100}}%这种写法会在每次 setData 时重新渲染,性能不如在 JS 里预计算好progress字段再传给视图层。
4. 投票核心逻辑与防刷技巧:排队、签名与幂等
4.1 客户端校验只是摆设:必须服务端验证
投票防刷的第一原则是:不要相信小程序端传上来的任何限制值。很多开发者会把“每个用户限投 3 票”的判断放在前端,通过remainingVotes去禁用按钮,这只能算一种体验优化。攻击者可以直接调用云函数接口,绕过页面 UI 无限提交。
服务端必须校验以下四项:
- 活动状态
status是否为进行中,且时间在start_time和end_time之间。 - 用户在活动总票数内的已投次数是否已达上限。
- 单候选人限制票数是否已达上限。
- 本次请求是否携带有效签名(针对更严格的安全场景)。
在微信小程序环境下,OPENID由云函数内部获取,客户端无法伪造,这是天然优势。但如果你的活动面向“未登录用户”也能投,那么防刷会难得多,此时只能靠行为分析和设备指纹辅助。
4.2 openid、时间戳与随机数:生成防重签名
如果你的 CC 投票活动价值较高(比如奖品是手机、大额代金券),建议在投票请求中加入签名。签名目的有两个:防止请求被恶意篡改参数,防止重放攻击。签名算法不复杂,客户端和服务器共享一个 secret(通常存在云函数环境变量或配置中心)。
// 云函数 vote/create 中的签名校验片段 const crypto = require('crypto') function verifySign(event, openid) { const { activityId, candidateId, timestamp, nonce } = event const serverTime = Math.floor(Date.now() / 1000) if (Math.abs(serverTime - timestamp) > 300) { throw new Error('请求已过期') } const secret = process.env.VOTE_SECRET const raw = [openid, activityId, candidateId, timestamp, nonce].sort().join('|') const sign = crypto.createHmac('sha256', secret).update(raw).digest('hex') if (sign !== event.sign) { throw new Error('签名校验失败') } // 将 nonce 写入缓存,防止重复使用 // Redis 中存储 key: vote_nonce:{nonce},过期时间 5 分钟 const nonceKey = `vote_nonce:${nonce}` const redis = getRedisClient() const isSet = redis.set(nonceKey, 1, 'NX', 'EX', 300) if (!isSet) { throw new Error('重复请求') } }参数说明:
timestamp:客户端发起请求时的 Unix 秒级时间戳,服务器只承认前后 5 分钟内的请求。nonce:随机字符串,每个请求一次,服务器用SET NX EX保证同一 nonce 只能通过一次,防止攻击者录制请求后反复重放。HMAC-SHA256比普通哈希更安全,secret 只保存在服务端,小程序源码即使被反编译也拿不到。
4.3 高并发下的写入约束:队列与限流
投票活动在“锦鲤式”传播中可能瞬间涌入几千次请求。直接操作数据库做count -> insert -> update三步,在并发下会出现数据不一致。常见做法是把“投票”写入消息队列,由消费者串行化处理同一用户的请求。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 同步写库 | 简单直接 | 并发高时数据库压力大,重复计数 |
| 队列异步写库 | 削峰填谷,可重试 | 用户看到结果有短暂延迟 |
| 预扣减+回调 | 体验好,接近实时 | 实现复杂,需要补偿机制 |
在微信云开发下,可以用云函数加@cloudbase/manager-node操作数据库事务。如果活动规模不大,不用太复杂,直接在云函数内部用事务即可:
// 云函数内事务代码示意 const transaction = await db.startTransaction() try { const activityRes = await transaction.collection('activity').doc(activityId).get() const activity = activityRes.data if (activity.status !== 1) return { code: 403, msg: '活动未在进行中' } const countRes = await transaction.collection('vote_record') .where({ openid, activity_id: activityId }) .count() if (countRes.total >= activity.total_votes_per_user) { return { code: 403, msg: '票数已用尽' } } await transaction.collection('candidate') .doc(candidateId) .update({ data: { votes: _.inc(1) } }) await transaction.collection('vote_record') .add({ data: { openid, activity_id: activityId, candidate_id: candidateId } }) await transaction.commit() return { code: 200, msg: '投票成功' } } catch (e) { await transaction.rollback() throw e }说明:db.startTransaction()是云开发提供的事务能力,能保证投票记录和候选票数更新要么同时成功,要么同时失败。使用_.inc(1)做原子自增,避免读改写竞争条件。注意count操作在事务中与add组合时,云开发无法加“唯一索引”,所以最严谨的做法还是把request_id设为主键。
5. 从调试到上线:加载页优化与抓包排错
5.1 修改刚进入的加载页面:骨架屏与预请求
很多 CC 投票活动体验不佳的起点,是用户打开小程序后先看到白屏或“加载中”三秒钟。这里可以借鉴“uniapp 微信小程序”的常用做法:首屏只渲染骨架屏,同时并发拉取活动配置和候选列表。不要把“获取 openid”和“查活动详情”串行执行,它们没有依赖关系。
// app.js 或首屏页面的 onLoad onLoad() { // 并发预请求 Promise.all([ this.fetchActivity(), this.fetchCandidates() ]).then(([activity, candidates]) => { this.setData({ activityInfo: activity, candidates, loading: false }) }) }骨架屏可以用纯 CSS 实现,不需要额外库。核心思想是给占位块一个渐变的背景色动画。
.skeleton { background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%); background-size: 400% 100%; animation: skeleton-loading 1.4s ease infinite; } @keyframes skeleton-loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }5.2 用抓包工具排查提交失败
投票提交失败时,页面提示可能只有“网络异常”,真正的问题需要看请求细节。这里有两个抓包路径:
- 小程序开发者工具自带的 Network 面板:可以看到请求头、参数、返回码,最直接。
- 真机调试:用微信开发者工具的“真机调试”配合 vConsole,在手机上查看调云函数的返回。
如果你想看更底层的协议,可以使用 Charles 抓包电脑端微信小程序。注意:抓包前需要给电脑安装证书,且微信小程序的请求默认开启域名校验,你必须在开发者工具后台将request合法域名加入白名单,否则请求直接 fail。抓包时重点看三个信息:
- 是否携带了正确的
Content-Type。 - 云函数返回的
result.code是否为 200。 - 小程序端
setData前后数据是否有字段名拼写不一致。
5.3 验证投票结果:接口回放与数据核对
上线前可以用一个简单的脚本对投票接口做回放测试:用固定的 openid 发起多次投票,验证服务端是否拒绝了超限请求。
curl -X POST https://api.example.com/vote \ -H "Content-Type: application/json" \ -d '{"activityId":"act001","candidateId":"cd001","openid":"test_openid","requestId":"req_001"}'参数说明:
requestId改成同一个值,连续发两次,预期第二次返回“已投过票”。- 改成不同
requestId但超过限制次数,预期返回“票数已用尽”。 - 如果返回码混乱,优先检查云函数事务的回滚逻辑以及幂等键的唯一索引。
5.4 处理顶部导航栏高度与长按拖拽冲突
微信小程序的顶部导航栏在不同机型上高度不固定,如果在自定义导航栏中放置“剩余票数”文字,可以通过wx.getMenuButtonBoundingClientRect()获取胶囊按钮位置,再动态计算padding-top。另外,投票列表如果用movable-area或scroll-view做长按拖拽排序,容易和投票按钮的bindtap冲突。解决方式是在拖拽开始时设置一个标志位,并在touchend后延迟 100ms 释放点击事件,避免用户快速滑动时误触投票。这个细节在真机上尤其明显,Android 上tap触发较灵敏,拖拽时很容易误投,必须处理。
本文还有配套的精品资源,点击获取