ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

微信小程序CC投票系统设计:幂等防刷与高并发实践

微信小程序CC投票系统设计:幂等防刷与高并发实践 简介基于微信小程序的 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 classpage view classactivity-header text classtitle{{activityInfo.title}}/text text classstatus进行中 · 剩余{{activityInfo.remainingDays}}天/text /view scroll-view scroll-y classcandidate-list view classcandidate-card wx:for{{candidates}} wx:keyid image src{{item.coverUrl}} modeaspectFill/image view classinfo text classname{{item.name}}/text view classvote-progress view classprogress-bar stylewidth: {{item.progress}}%/view /view text classvotes已获得 {{item.votes}} 票/text /view button classvote-btn {{item.voted ? voted : }} disabled{{item.voted || !ableToVote}} bindtaphandleVote >// 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-classpress在样式中写.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触发较灵敏拖拽时很容易误投必须处理。本文还有配套的精品资源点击获取
返回列表