每次活动结束,老师们最头疼的往往不是表演本身,而是票数统计。这份需求最初是某少儿艺术中心的一位老师找上门的:机构每年办两场汇报演出,演出后要评“最佳小明星”,以前靠纸质票加朋友圈接龙,一天下来几十个家长发来截图,得手动录表格,还经常撞车、漏记。聊到最后,核心诉求就一句话:能不能用一个小程序,让家长自己报名、亲友在线投票、排行榜实时刷新,活动结束了还能一键导出结果。
这就是“少儿明星投票活动发起小程序”的由来。它表面上是给才艺评选用的,但业务链路可以完全复用到校园歌手赛、幼儿园萌娃评选、社区风采展演、品牌活动人气王这类场景,底层的活动管理、选手报名、投票打榜、防刷票、结果统计逻辑几乎一致。这篇文章就把这个项目的设计思路、关键实现、踩坑记录完整梳理一遍,既是复盘,也希望能给准备做类似投票系统或正在学习小程序云开发的同学一个能直接落地的参考。
1. 需求拆解与业务场景:这不是一个单纯的「点赞组件」
1.1 谁在用这个投票小程序
项目上手前,我先把参与角色拆成了三类,分别梳理他们的真实诉求。
活动发起方(机构教务老师、班主任、社团负责人)要的是零成本创建活动:填写活动名称和规则、圈定报名时间、设置投票起止时间,能提前导入一批选手信息,活动结束后最好能自动生成获奖名单,不用人工数票。
选手和家长关心的是报名链路是否顺畅:上传照片、填一段才艺介绍、挂一条才艺视频,最好 3 分钟以内能完成,太复杂家长就放弃了。正式比赛期间,家长是拉票主力,他们需要一键分享给微信群、微信好友的入口,分享出去的卡片还得带着选手ID,别人点进来直接看到这个孩子的页面。
投票者是最普通的一群人:亲戚、朋友、邻居大妈。他们只会在微信里点开链接,看完孩子照片,顺手点一下投票按钮,然后关掉。任何要求下载App、注册手机号、关注公众号才能投票的流程,都会让他们流失。所以微信小程序天然适合这个场景,打开即用,无需安装。
1.2 传统投票方式的痛点
在写代码之前,我习惯先列一遍业务痛点,因为痛点决定了产品应该优先做哪些功能模块。
纸质投票的统计工作量大得吓人,几百张票要一张张录入,评委想复核还得重新翻表。微信群接龙的问题是数据分散,A 家长在群里接了一遍,又私聊老师补投一票,后台根本对不上账。第三方表单工具虽然能收集数据,但缺少选手展示页面,也没有排行榜的紧张感,更没法在微信里自然传播。更重要的是,这些方式都没办法限制一人多投,同一个群里拉票的人自己换着号投,公信力很容易被质疑。
1.3 产品目标与核心流程
基于上面的分析,我把产品目标定为:让一个完全不熟悉技术的机构老师,10 分钟内创建出一个像模像样的投票活动;让家长用一个链接完成报名和拉票;让投票过程公开透明、可追溯、可限制。
核心流程设计为一条闭合链路:创建活动 -> 配置投票规则 -> 选手报名/后台导入 -> 投票阶段(含分享拉票)-> 实时排行榜 -> 活动封板 -> 导出结果。这个流程里,投票规则的可配置性是我特别坚持的,后面在功能设计部分会展开说。
2. 技术选型:小程序云开发为什么够用
2.1 为什么不用独立后端
拿到需求后,我一度考虑用现有的 Node.js 后端加 MySQL 来实现,毕竟团队对人多的技术栈更熟悉。但仔细一算账,发现完全没必要。
这个项目的并发特征很特殊:活动不是全年都有,往往集中在某个周末的晚上,家长群一转发,瞬时流量可能冲到平时的一百倍,活动一结束流量立刻归零。自建服务器为了撑住这几小时的高峰,要提前扩容、事后缩容,维护成本不低。更关键的是,投票功能极度依赖微信生态的登录态和分享能力,自建后端也得通过微信接口换取 openid,绕一圈回来,最终还是微信平台上的事。
于是我把方案定成了「微信小程序原生框架 + 微信云开发」。小程序前端负责页面展示和交互,云函数承担所有核心业务逻辑,云数据库存业务数据,云存储放照片视频,云托管都不需要。这套方案对于中小规模投票活动来说,开发效率、运维成本、稳定性之间取到了最合适的平衡点。
2.2 整体架构分层
整个系统逻辑上分成四层。
展示层就是小程序客户端,包含活动首页、活动详情、选手列表、排行榜、个人中心几个页面。应用层由一组云函数组成,包括创建活动、选手报名、执行投票、获取排行榜、定时检查活动状态等。数据层是云数据库中的四个核心集合:活动表、选手表、投票记录表、用户表。基础设施层则是云存储和云函数定时触发器。
关键点是:所有写操作,尤其是投票和报名,必须经过云函数,前端不能直接改数据库。很多人做小程序为了省事,在客户端代码里直接调用数据库 API 更新票数,这在投票场景里是致命的,等于把印钞机的按钮放到了大街上。后面我会专门讲安全规则怎么配。
2.3 前端页面结构与核心交互
页面结构方面,我用了底部 TabBar 区分三个区域:「活动」列表、排行榜、我的。
活动列表页展示进行中的活动卡片,支持搜索和切换“进行中/已结束”筛选。活动详情页是核心页面,包含活动基本信息、规则说明、选手列表,选手卡片上有照片、介绍、当前票数和投票按钮。排行榜页按票数倒序展示前 50 名,同时显示当前用户名次。我的页面分成两块:我发起的活动、我报名的活动。
交互上有一个容易被忽视的细节:投票按钮点击后,不能只是本地数字加一,要等云函数确认返回成功后再更新,并且立即把按钮置灰或显示“今日票数已用完”,防止用户无反馈地反复点击。
3. 核心功能设计与关键实现
3.1 活动发起与规则配置
创建活动时,前端收集以下配置项,提交给createActivity云函数:活动名称、封面图、活动介绍、报名开始/结束时间、投票开始/结束时间、每人每天可投最大票数、是否允许同一天内对同一选手重复投票、是否开启分享奖励票。
这里我额外加了一个“种子选手”能力,允许管理员在活动发布前通过 CSV 批量导入选手名单,避免活动开始后家长挤在同一时间段报名造成拥塞。字段层面,活动表我会记录status字段,取值范围是 draft(草稿)、ongoing(进行中)、ended(已结束),云函数先判断状态再放行对应操作。
活动表的核心字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| title | string | 活动名称 |
| cover | string | 封面图 URL |
| introduction | string | 活动说明 |
| enrollStartTime | number | 报名开始时间戳 |
| enrollEndTime | number | 报名截止时间戳 |
| voteStartTime | number | 投票开始时间戳 |
| voteEndTime | number | 投票截止时间戳 |
| maxVotesPerDay | number | 每人每天最大票数 |
| repeatVoteAllowed | boolean | 同一天是否可重复投同一选手 |
| shareExtraVotes | number | 分享后额外奖励票数 |
| status | string | 活动状态 |
| creatorOpenid | string | 创建者 openid |
3.2 选手报名与作品上传
报名流程设计成两步:第一步填写选手姓名、年龄、才艺介绍;第二步上传照片和才艺视频。
视频上传是这里最容易出问题的环节。小程序端wx.chooseMedia选择视频后,我做了两层处理:先在前端读取文件大小,超过 20MB 的提示用户重新选择或压缩;再在上传前统一重命名为随机字符串,避免中文文件名和重复名在云存储里互相覆盖。视频和图片上传完成后,返回的 fileID 随报名信息一起提交给joinActivity云函数。
技术上讲,照片我建议生成缩略图,展示列表里只加载 200x200 的压缩图,详情页再加载原图。云开发有图片处理能力,直接在图片 URL 后面拼接参数就能裁剪,不必额外写压缩逻辑。这个细节直接决定了活动列表页在大批量照片下能不能秒开。
3.3 投票主流程:事务、限额与状态校验
投票是这个小程序的命脉,我把核心逻辑全部放在一个vote云函数里,大致流程是:校验活动状态和选手合法性 -> 判断当天该用户是否已达到上限 -> 开启数据库事务写入投票记录并累加选手票数。核心代码大致如下:
const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const _ = db.command function formatDateKey(date) { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return `${y}-${m}-${d}` } exports.main = async (event, context) => { const { OPENID } = cloud.getWXContext() const { activityId, playerId } = event try { const activityRes = await db.collection('activities').doc(activityId).get() const activity = activityRes.data const now = Date.now() if (activity.status !== 'ongoing') { return { code: 1001, msg: '活动不在进行中' } } if (now < activity.voteStartTime || now > activity.voteEndTime) { return { code: 1001, msg: '当前不在投票时间范围内' } } const playerRes = await db.collection('players').doc(playerId).get() const player = playerRes.data if (!player || player.activityId !== activityId) { return { code: 1002, msg: '选手不存在' } } const dateKey = formatDateKey(new Date(now)) const todayVoteCount = await db.collection('votes') .where({ activityId, openid: OPENID, dateKey }) .count() if (todayVoteCount.total >= activity.maxVotesPerDay) { return { code: 1003, msg: '今日投票次数已用完' } } if (!activity.repeatVoteAllowed) { const alreadyVoted = await db.collection('votes') .where({ activityId, playerId, openid: OPENID, dateKey }) .count() if (alreadyVoted.total > 0) { return { code: 1004, msg: '今天已经投过这位选手了' } } } await db.runTransaction(async transaction => { await transaction.collection('votes').add({ data: { activityId, playerId, openid: OPENID, dateKey, source: event.source || 'normal', createTime: now } }) await transaction.collection('players').doc(playerId).update({ data: { votes: _.inc(1) } }) }) return { code: 0, msg: '投票成功' } } catch (e) { console.error('vote error', e) return { code: 500, msg: '服务器异常,请稍后再试' } } }这里要特别解释几个细节。
dateKey不能用toISOString().slice(0, 10)直接生成,因为它返回的是 UTC 日期,北京时间凌晨 8 点之前会被算成前一天,导致限额在凌晨失效。所以我手写了一个按本地时间格式化的函数,这是上线前血泪排查出来的问题。
事务(runTransaction)保证了两个操作的原子性:投票记录插入成功的同时选手票数必须加一,不会出现“记录有了但票数没加”这种半失败状态。高并发下,事务引擎会自动检测写冲突并重试,单人同时狂点按钮、或者同一用户同时从两个设备发起投票时,最终也只会计一次合法的票数。
3.4 排行榜与活动结果封板
排行榜实现起来不复杂,核心是按票数倒序查询选手表,为了减轻数据库压力,我做了一层简单的缓存:每次排行榜查询云函数执行完后,把前 50 名的数据快照写入一个ranking_cache集合,后续 30 秒内其他用户看到的都是缓存结果。
活动结束时除了status自动变为 ended,还有一个容易被忽略的动作:结果封板。我在定时触发器中,把活动结束后的最终排行结果复制到activity_results集合,写死当时的票数快照。这么做是因为正常的投票活动结束后,如果有人在深夜翻旧账,数据可能被误操作或后续测试污染,快照才是具有公信力的“官方结果”。
导出获奖名单时,管理员在小程序里点击一键导出,云函数读取activity_results集合,把票数和排名整理成 CSV 文件传入云存储,前端下载链接即可。
4. 防刷票与数据安全:投票类应用的生死线
4.1 常见的刷票手段分析
投票活动一旦涉及奖品和荣誉,马上会有人动歪脑筋。我在设计阶段就假设系统会被恶意攻击,而不是假设所有用户都是善意的。
最常见的刷票方式有这么几类:同一用户注册多个微信号轮流投;在投票页面用自动化脚本高频点击;破解小程序前端逻辑,直接调用云函数接口模拟请求;甚至有人会获取大量临时手机号注册新用户。还有一些更隐蔽的,比如组织水军群,每人只投一两票但人数众多,这种人工羊毛很难完全识别。
我的应对思路是分层设防:既要挡住纯技术攻击,也要限制普通拉票行为的上限,让刷票成本高于收益。
4.2 三层校验体系
第一层是身份识别。所有投票都必须携带微信登录后拿到的 openid,每个微信用户一个稳定唯一标识。这个 openid 由云函数自动从上下文中解析,前端代码无法伪造。
第二层是频率控制。除了活动配置的每日票数上限外,我还在云函数里加了两次投票的最小间隔,默认是 30 秒。这个间隔放在云函数侧判断,前端按钮置灰只是体验优化,不能当作安全手段。
第三层是行为风控。记录每个 openid 在该活动中的投票时间序列,如果系统检测到某个用户在几分钟内投了大量票、或是大量新 openid 在极短时间窗口内集中给同一个选手投票,会自动给活动管理员发通知,提示开启人工核查。这个功能不追求实时拦截,主要起震慑作用。
4.3 数据库安全规则
云开发的数据库权限默认配置下,客户端可以直接读写集合,这是投票应用绝对不能容忍的。我把投票相关的四个集合全部设置为“仅云函数可读写”,也就是说,前端任何db.collection('players').update这种操作都会在权限校验阶段被拒绝,所有合法请求都只经由云函数进入。
安全规则配置大致长这样:
{ "read": "auth.openid == doc.creatorOpenid || doc.activityId != null", "write": false }实际操作里,我对 activities、players、votes、users 全部关掉客户端写权限,只有活动列表页这种公开读场景才开放读权限。用户表和投票记录表的写权限一律 false。
4.4 事务代码背后的并发保证
前面vote云函数里的事务代码,是防刷票逻辑中最后一道物理闸门。有人可能会问:先 count 今日票数,再事务写入,这两个步骤之间如果有并发请求插进来,会不会绕过每日上限?
db.runTransaction内部会做冲突检测:当两个事务同时读取同一份数据,其中一个先提交后,另一个会读到版本变化并自动重试。重试后重新执行 count,此时已经有一条记录计入,总数就会超出检测条件,请求会被拒绝。所以事务不是靠“运气”保证正确性,而是靠数据库的一致性机制兜底。
5. 数据库模型与接口约定
5.1 四个核心集合设计
除了前面列出的 activities 表,其他三个集合也一并说明。
players 集合承载选手信息和实时票数。需要注意,votes字段是冗余存储的,因为排行榜和列表页需要频繁按票数排序,如果每次都去 count 投票明细,数据库压力会翻无数倍。冗余带来的风险是可能不一致,所以最终验票环节会重新聚合投票明细刷一次这个字段。
| 字段名 | 类型 | 说明 |
|---|---|---|
| activityId | string | 所属活动 ID |
| name | string | 选手姓名 |
| age | number | 年龄 |
| introduction | string | 才艺介绍 |
| photos | array | 照片 fileID 列表 |
| videoUrl | string | 才艺视频 fileID |
| votes | number | 当前实时票数 |
| status | string | 待审核/已通过/已拒绝 |
| createTime | number | 报名时间 |
votes 集合记录每一次投票明细,核心作用有两个:按日期校验每日限额、活动结束后做最终验票。字段为 activityId、playerId、openid、dateKey、source、createTime。source 用来标记普通投票还是分享带来的投票,便于分析拉票渠道效果。
users 集合记录每个参与者的微信资料、openid、参与的活动列表。投票者第一次投票时自动写入一条,报名者和活动发起者也会有记录。
5.2 接口统一响应格式
前端调用云函数时,我统一约定返回结构为{ code, msg, data }。code 为 0 表示成功,非 0 为业务错误码,500 表示服务器异常。这样前端拦截器可以统一处理错误,不用每个接口各写一套。
实际开发中还要注意云函数的冷启动问题。云开发中无调用时函数实例会被回收,第一次请求往往要等待 1 到 3 秒。投票活动的高峰期用户正好挤在开票那一刻,冷启动叠加并发会让第一波体验很糟糕。我的优化办法是:在活动开始前用定时触发器每分钟调用一次 vote 函数保持实例热,或者把投票频率限制从云函数挪到数据库层校验,减少冷启动影响。
6. 实测过程与用户体验优化
6.1 功能联调:从创建到出结果的完整链路
开发完成后,我按真实业务流程完整走了一遍:创建活动 -> 录入两个测试选手 -> 用三个微信号分别报名 -> 开始投票 -> 其中一个号把当天票数投满 -> 验证隔天票数恢复 -> 停止投票 -> 导出结果报表。
这个流程里最容易出 bug 的是时间边界。活动报名截止时间和投票开始时间可能是同一个时刻,如果代码判断逻辑用了<=和>=,就会出现一个用户在 23:59:59 还能操作、下一秒被拒的体验断裂。我统一改成半开区间:开始时间用now >= startTime判断,结束时间用now < endTime判断,活动结束后用户看到“已结束”而不是“不在投票时间内”。
6.2 并发压测与配额控制
上线前我用并发脚本对 vote 云函数做了压测,模拟 100 个用户同时投票。初期版本在事务内还执行了两次 count 查询,导致事务处理时间偏长,冲突重试比例较高。优化后我把重复查询合并为一次,通过活动配置字段一次性判断两种限额情况,吞吐量提升了近一半。
云开发的资源配额需要提前评估。单个云函数实例的并发上限、数据库读写次数上限,在几万人参与的活动规模下都是可能触顶的。我的处理是给数据库读写加了重试,并在云函数中捕获配额超限错误返回友好提示,而不是让用户看到一串云服务错误码。
6.3 真实运营反馈带来的迭代
内测阶段某幼儿园的老师提了一个很实际的建议:家长想看自家孩子排名,但排行榜前 50 名一直滚动刷新,翻半天找不到。于是我加了“我的排名”入口,在排行榜页顶部展示当前登录用户家孩子的名次和票数,省去了搜索的麻烦。
另一个迭代来自某次模拟活动的观察:很多投票者投完票就退出,根本不知道还能分享拉票。我把“分享给好友帮我拉票”的按钮从详情页底部移到了投票成功后的弹窗上,投票成功的瞬间点击率明显提升。这个改动不涉及技术复杂度,但对活动传播效果影响很大。
7. 常见问题与排查实录
7.1 小程序审核与类目选择
投票类小程序审核比较严格。一开始我的服务类目选的是“工具-信息查询”,被拒了一次,理由是涉及在线投票需补充相关资质。实际操作中,我建议优先选择跟业务场景匹配的类目,比如教育机构主体选“教育-培训机构”,社区活动选“生活服务-综合生活服务平台”,避免直接使用“投票”字眼作为类目描述。提交审核时,页面文案要避免出现“抽奖”“有奖竞猜”“红包”等字眼,否则会被平台判定为诱导分享或抽奖活动。
7.2 分享卡片参数丢失
小程序分享到微信群的卡片,点击进入时页面路径必须带着活动ID和选手ID。我最初直接用了onShareAppMessage的默认 path,结果很多用户点进来落在首页,找不到对应选手。正确的做法是自定义分享路径,例如/pages/detail/detail?activityId=xxx&playerId=xxx,并在目标页面的onLoad里解析参数、根据参数自动定位到对应选手卡片。
7.3 视频上传失败与兼容
视频上传是报名环节失败率最高的点。排查发现两类原因:一是视频超过云存储单个文件限制,二是 iOS 端选择 iCloud 中的视频时拿到了本地临时路径但文件实际未下载下来。我的处理方案是:选择视频后先读取文件大小并提示用户;上传前调用wx.getFileSystemManager().saveFile之类的降级逻辑把临时文件存到本地再上传,减少偶现的文件不存在问题。活动规则页面也明确提示“视频建议不超过 20MB,时长 1 分钟以内”。
7.4 票数不一致与最终验票
有一次模拟活动结束后,我发现列表展示的选手票数和投票明细记录的票数差了十几票。原因是线上测试阶段有人直接给选手表补了票数做演示,污染了数据。这给了我一个教训:一切非正常渠道的加票行为必须在验票阶段被清理。最终验票函数会遍历所有投票明细,按选手重新聚合票数,再回写 players 表和结果快照表,确保最终榜单完全从真实投票记录计算得出。
7.5 多活动并行时的数据隔离
同一个开放者可能在同一天发起两场投票,选手 ID、投票记录都挂在不同活动下。开发中我发现一个隐蔽 bug:投票时只传了 playerId 没校验它属于哪个活动,导致选手 A 的投票记录串到了活动 B 的排行榜里。后来所有涉及选手的操作都强制同时传 activityId 并在云函数里校验归属关系,双字段绑定后才彻底解决。
写在最后
这个项目从开发到上线,踩过的坑比预想多,但整体走下来最大的体会有两点。一点是投票类应用的安全意识必须前置,分包校验、事务并发、权限管控这些一开始就要设计进去,等被人刷了票再打补丁就晚了。另一点是产品细节的颗粒度决定实际效果,日期边界、分享路径、视频压缩、冷启动预热,这些看似零碎的小事拼起来才是用户真实感受里的“好用”。
如果后续还要扩展,我会优先考虑加两个方向:一是基于投票数据的多维统计分析,比如拉票渠道转化率、时段热度曲线,帮活动组织者复盘传播效果;二是把“活动模板”做成可配置市场化的方向,让任何机构都能像发朋友圈一样,三分钟发起一场有模有样的投票评选。毕竟是做过一遍的事情,把它复用到更多场景,才是最值得的延续。