路演中 演出报名投票小程序系统的设计与实现
路演、演出、比赛这类活动,最让人头疼的往往不是场上的表演质量,而是场下的报名组织和投票统计。几年前我帮朋友做过一场高校创业路演,报名用问卷星,现场投票直接手写纸票,晚上统计结果的时候三个人加了两小时班,还漏了几张。从那次以后我就意识到,这类活动太需要一个能覆盖“报名-审核-展示-投票-统计”全流程的小载体了。后来我用微信小程序做了这套“路演中·演出报名投票系统”,用户扫码进入后可以填写报名信息,观众可以浏览节目并为喜欢的演出投票,管理端负责配置活动、审核报名字、查看票数排行和导出结果。这篇文章把整个设计与实现过程以及踩过的坑完整写出来,项目结构和代码都是可落地复现的,适合正在做毕业设计或者想搭建类似互动工具的朋友参考。
1. 项目全景与功能拆解
1.1 路演场景下的需求痛点
路演活动通常具备几个特征:时间节点紧凑、参赛者信息多样、观众参与感要求高、结果需要实时可信。以前常用“人工收集报名表+微信群投票”的模式,问题很明显:
- 报名信息分散,有的发邮箱,有的填在线表格,整理的时候字段对不上,漏填、晚填时有发生;
- 路演当天观众投票往往靠举牌、吼声、纸质票,没有任何数据沉淀,想看实时排行很难;
- 主办方需要筛选节目,但缺少一个统一的审核入口,只好电话一个一个确认;
- 最终统计结果人为因素干扰大,稍有争议就要翻聊天记录甚至重数纸条。
这套系统本质上把活动运营链路拆成了三段:报名阶段(选手提交资料)、审核阶段(管理员确认)、展示投票阶段(观众浏览并投票)。三段共用一套数据模型,所以从报名到统计全程数据可追溯,主办方不需要再手动搬运数据。
1.2 功能模块划分
整个小程序按使用角色可以分成三大端:
第一个是观众端,也就是普通微信用户打开后看到的部分。包含活动列表与详情页、选手风采展示、投票入口、排行榜。用户在这里可以做两件事:报名参赛或者给选手投票。没有登录拦截也可以浏览,只有投票和报名时才需要授权。
第二个是选手端,其实和观众端同一个小程序,只是用户点击“报名”后进入选手身份界面。填写演出名称、参演者、联系方式、照片/视频链接等。提交后能看到自己的报名审核状态,通过后才能进入投票池展示。
第三个是管理端,我做成微信小程序内的“管理员扫码进入”页面,同一个小程序只是带上了角色权限。管理端主要功能是配置活动基础信息(名称、封面、报名起止时间、投票规则)、审核报名数据、调整选手序号、查看实时票数和排行榜、导出Excel结果。
之所以不做一个独立的管理后台,是因为微信小程序天然适合轻量管理,管理员不需要额外装App,扫码进入即可。如果后面数据复杂度上升,再拆独立后台也来得及。
1.3 技术选型与设计约束
既然名字叫“小程序系统”,前端自然是微信小程序原生框架,配合ColorUI/Vant Weapp做界面样式。后端采用Spring Boot 2.7 + MyBatis-Plus,数据库用MySQL 8.0,缓存用Redis,文件存储用MinIO或对象存储,Excel 导出用EasyExcel。
选这套组合的原因主要三点:
一是Spring Boot生态足够成熟,代码提示和参考案例多,招聘市场也会问到,适合拿来当毕设项目展示;二是MyBatis-Plus让单表CRUD几乎不用写SQL,开发效率高,空出时间打磨业务流程;三是小程序端原生WXML比uni-app更直接,既然只上微信生态,没必要引入跨端框架增加体积和编译复杂。
设计上有几处关键约束:
- 投票次数限制:默认一个微信用户对同一活动最多投3票,且同一个选手只能投1票,防止刷票;
- 报名时间限制:后端必须校验报名截止时间,不能只靠前端按钮禁用;
- 状态机统一:报名、活动、投票记录都有明确状态流转,所有写操作通过后端接口完成,禁止前端直接更新云端数据库。
2. 核心数据模型与接口设计
2.1 数据库表结构设计
我先从核心业务表说起。整个项目大概有六张表:用户表、活动表、报名表、选手展示表、投票记录表、系统配置表。这里是精简后的核心建表语句:
CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL UNIQUE COMMENT '微信openid', `nickname` VARCHAR(64), `avatar_url` VARCHAR(255), `phone` VARCHAR(20), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `activity` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL, `cover_url` VARCHAR(255), `description` TEXT, `register_start` DATETIME, `register_end` DATETIME, `vote_start` DATETIME, `vote_end` DATETIME, `max_vote_per_user` INT DEFAULT 3 COMMENT '每人总投票数', `max_vote_per_item` INT DEFAULT 1 COMMENT '同一选手每人可投次数', `status` TINYINT DEFAULT 0 COMMENT '0草稿 1进行中 2已结束', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `registration` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `performer_name` VARCHAR(50) COMMENT '参赛者/团队名', `program_name` VARCHAR(100) COMMENT '节目名称', `program_type` VARCHAR(30) COMMENT '类型:歌唱/舞蹈/乐器/路演', `intro` TEXT COMMENT '节目介绍', `contact` VARCHAR(50), `cover_url` VARCHAR(255), `media_url` VARCHAR(255) COMMENT '视频/音频链接', `status` TINYINT DEFAULT 0 COMMENT '0待审核 1已通过 2未通过 3已取消', `sort_no` INT DEFAULT 0 COMMENT '展示序号', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `vote_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `registration_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_activity_user_reg` (`activity_id`, `user_id`, `registration_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;我需要说明几个设计细节。
registration(报名表)同时承担了“选手表”的角色。报名审核通过后,这条记录其实就是选手条目,不需要再单独建一张选手表。这样结构简单,而且展示和投票都能直接关联到报名记录。如果比赛有复赛特殊字段,可以在扩展表里加字段,不影响主流程。
vote_record 的唯一索引是整个投票防刷的核心依据。uk_activity_user_reg确保同一用户在同一活动下只能给同一个选手投一次票。拼接 activity + registration + user 而不是单独用 openid,是因为同一个用户可能参加不同活动,单独用 openid 会导致跨活动误伤。
用户表只保留 openid、昵称、头像,不存密码。微信小程序不需要用户名密码,一切登录基于微信官方换取到的 openid。需要手机号就单独存 phone 字段,但不要在小程序端强制授权手机号,那样会卡掉不少用户。
2.2 核心接口列表
接口设计遵循一个原则:客户端只做展示和采集,业务判断全部落在后端。下面的表格是核心接口概览:
| 模块 | 接口 | 方法 | 说明 |
|---|---|---|---|
| 登录 | /api/auth/login | POST | 通过code换openid,返回自定义token |
| 活动 | /api/activity/list | GET | 分页获取活动列表 |
| 活动 | /api/activity/detail | GET | 获取活动详情+报名/投票状态 |
| 报名 | /api/registration/submit | POST | 提交报名信息 |
| 报名 | /api/registration/my | GET | 用户查看自己的报名记录 |
| 投票 | /api/vote/cast | POST | 投票接口 |
| 排行 | /api/vote/ranking | GET | 获取票数排行 |
| 管理 | /api/admin/activity/save | POST | 新建或修改活动 |
| 管理 | /api/admin/registration/review | POST | 审核通过/驳回 |
| 管理 | /api/admin/vote/statistics | GET | 统计票数与查看进度 |
| 管理 | /api/admin/vote/export | GET | 导出Excel |
投票接口是重点,它的完整业务逻辑是:
@PostMapping("/api/vote/cast") public Result castVote(@RequestBody VoteDTO dto) { // 1. 校验活动是否存在且在投票期内 Activity activity = activityService.getById(dto.getActivityId()); if (activity == null || !isVoteTime(activity)) { return Result.error("不在投票时间内"); } // 2. 校验报名记录已审核通过 Registration reg = registrationService.getById(dto.getRegistrationId()); if (reg == null || reg.getStatus() != 1) { return Result.error("该选手未进入投票列表"); } // 3. 红锁/分布式锁防并发超投 String lockKey = "vote:lock:" + dto.getActivityId() + ":" + currentUser.getId(); boolean locked = redisLock.tryLock(lockKey, 3, TimeUnit.SECONDS); if (!locked) { return Result.error("操作太频繁,请稍候"); } try { // 4. 校验总数限制 Long count = voteRecordService.count( new LambdaQueryWrapper<VoteRecord>() .eq(VoteRecord::getActivityId, dto.getActivityId()) .eq(VoteRecord::getUserId, currentUser.getId())); if (count >= activity.getMaxVotePerUser()) { return Result.error("已达总投票上限"); } // 5. 唯一索引兜底,防止同一选手重复投 VoteRecord record = new VoteRecord(); record.setActivityId(dto.getActivityId()); record.setRegistrationId(dto.getRegistrationId()); record.setUserId(currentUser.getId()); voteRecordService.save(record); // 6. 更新投票缓存计数 redisTemplate.opsForZSet().incrementScore("vote:rank:" + activity.getId(), String.valueOf(reg.getId()), 1); return Result.success(); } catch (DuplicateKeyException e) { return Result.error("您已经为该选手投过票了"); } finally { redisLock.unlock(lockKey); } }这段逻辑很短,但已经把四个关键点都覆盖进去了:时间校验、状态校验、并发防刷、唯一约束兜底。也许有同学觉得Redis + 唯一索引重复了,其实没有。Redis 计数用于展示和快速判断,唯一索引才是最终防超投的胜负手,哪怕多个请求同时打进来,数据库层面也会拒绝重复插入,然后抛出 DuplicateKeyException。
2.3 报名流程的状态机设计
报名不是一个简单的“插入一条记录”就结束,它更像一个状态机。状态定义如下:
- 0:待审核,用户提交报名后默认进入,此时自己可见,观众不可见;
- 1:已通过,管理员审核通过,进入投票池,可被搜索和展示;
- 2:未通过,管理员驳回,用户可以编辑后重新提交;
- 3:已取消,用户主动取消报名。
业务流程大致是:用户提交 -> 待审核;管理员进入列表,可以查看详情后操作通过或驳回;如果驳回,用户端会看到“未通过”状态和驳回原因;用户修改后再次提交,则状态从2回到0。为什么需要这个状态机?因为路演活动对“谁能出现在投票列表里”要求很明确,主办方有筛选权。如果用户提交后直接展示,不合适的节目就会在公众面前漏出,后期处理尴尬。
3. 用户端小程序关键页面实现
3.1 活动列表与详情页
小程序首页就是“活动列表页”,采用卡片式设计,封面图、标题、报名/投票状态、剩余时间一目了然。这里最需要注意的不是样式,而是时间计算。前端会涉及时区问题,小程序返回的时间戳建议统一用Long类型,前端再用dayjs格式化。很多新人在后端直接返回LocalDateTime序列化后的字符串,前端拿去new Date('2025-03-09 10:00:00')在iOS会显示NAN,这个坑我踩过,踩得很痛。
我的方案是后端返回时间戳,活动详情页在小程序侧做一个状态提示函数:
function getActivityStatus(activity) { const now = Date.now(); if (now < activity.registerStart) { return { text: '报名未开始', disabled: true }; } if (now < activity.registerEnd) { return { text: '报名中', disabled: false }; } if (now < activity.voteEnd) { return { text: '投票中', disabled: true }; } return { text: '已结束', disabled: true }; }活动详情页展示的信息要比列表页丰富:活动介绍、报名时间、投票时间、当前报名人数、选手列表。选手列表在投票期内才会完整展示排序,非投票期只显示已通过选手的头像墙,减少运营压力。
3.2 报名表单与提交逻辑
报名表单是我花时间最多的地方。它不是一个静态HTML页面,因为不同类型演出的报名字段不一样:乐队要填成员名单,舞蹈要填人数,讲师要填Title。所以我把表单做成了动态配置,也就是管理端可以配置活动中需要哪些字段、是否必填。前端根据字段配置动态渲染表单。
这里的核心设计是字段配置表。简化版如下:
CREATE TABLE `activity_form_field` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `field_name` VARCHAR(50), `field_label` VARCHAR(50), `field_type` VARCHAR(20) COMMENT 'text/textarea/select/upload', `required` TINYINT DEFAULT 1, `sort_no` INT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;前端遍历字段列表生成 WXML:
<view wx:for="{{formFields}}" wx:key="id"> <view class="field-label">{{item.fieldLabel}}<text wx:if="{{item.required}}">*</text></view> <input wx:if="{{item.fieldType === 'text'}}" value="{{formData[item.fieldName]}}" >async function onVote(e) { const id = e.currentTarget.dataset.id; if (this.data.voteLoading.includes(id)) return; this.setData({ voteLoading: [...this.data.voteLoading, id] }); try { const res = await request.post('/api/vote/cast', { activityId: this.data.activityId, registrationId: id }); toast(res.msg); this.refreshRanking(); } finally { this.setData({ voteLoading: this.data.voteLoading.filter(i => i !== id) }); } }前端限制只能挡住普通用户,无法挡住批量刷票攻击。后端防刷我做了三层:
- OpenID维度:这是最基础的维度。一个微信用户永远不能给同一个选手重复投票,总票数也不能超过活动配置上限;
- 频率维度:如果同一用户对一次投票请求在1秒内连续发起超过5次,直接触发风控拦截,延长等待时间;
- 设备指纹维度:小程序端通过
wx.getSystemInfoSync()拿到设备型号和系统,加上随机数生成 deviceId 存本地,后端记录这个设备指纹,如果来自同设备的请求对应超过3个不同 openid,则判定为批量刷票,锁定半小时。
不要写死“设备指纹一定可靠”,毕竟用户删掉小程序重进就会变化。它只是辅助手段,真正的核心还是唯一索引。
4. 管理后台与统计可视化
4.1 活动配置与报名审核
管理后台是小程序内的隐藏页面,入口放在“我的”页面里,只有管理员openid列表中的用户才能看到。管理员身份不用前端传,后端从登录态解析openid,再查管理员白名单,这样避免任意用户伪造管理员字段。
活动配置页面支持:
- 配置标题、封面、描述、时间;
- 配置投票规则(每人总票数、同一选手可投票数);
- 配置报名动态字段;
- 配置是否允许观众在投票期看到实时票数。
报名审核列表要以活动维度筛选,加载待审核数据。每一条报名记录都可以展开查看详细信息。我建议审核页只展示“待审核”和“已驳回”两种状态,已经通过的直接隐藏,避免误操作。审核通过时,管理端会看到“设置展示序号”的选项,这个序号会影响选手列表排序,通常序号等于节目出场顺序,这样观众看到的排行榜和实际路演顺序符合。
4.2 投票结果统计与导出
统计模块我刚开始只用MySQL的count(*) group by registration_id,数据量小没问题,但活动人数一旦超过千人,就会发现接口响应越来越慢。后来我把投票排名放到了Redis的有序集合vote:rank:{activityId}里,每次投票incrementScore,排行榜直接从Redis取前N名:
Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().reverseRangeWithScores("vote:rank:" + activityId, 0, 99);实时排行用Redis秒出,最终结果导出时仍然从MySQL统计,保证不出错。这里取舍的逻辑是:Redis适合高频读,MySQL适合最终数据落库,两者互补。
Excel导出用EasyExcel一行就够:
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); EasyExcel.write(response.getOutputStream(), VoteExcelVO.class) .sheet("票数统计") .doWrite(dataList);导出内容包含排名、选手名称、节目名、总票数、报名时间。管理端直接在小程序里调用这个接口,解析返回的文件流。小程序里没法直接下载到本地,我这里是后端生成Excel后传到对象存储,返回一个临时下载链接,管理员复制链接到浏览器打开即可下载。
5. 部署上线与常见问题排查
5.1 小程序发布注意事项
发布小程序前,需要在小程序后台配置服务器域名,必须是HTTPS。很多人调试时把后端跑在本地,用http://localhost:8080,手机预览就失败。我的习惯是本地调试用微信开发者工具的“不校验合法域名”,但真机预览必须开一个测试环境HTTPS。没有备案域名的话,小程序上正式环境会遇到障碍,所以如果只是教学演示,我会建议先买一台国内轻量云服务器,绑上备案好的域名,配置Nginx反向代理到Spring Boot服务。
类目选择也很关键。如果小程序名称或服务类型涉及“文娱/演出”,审核要提供资质。避坑方案是:把小程序定位为“活动报名工具”而不是“演出直播平台”,类目选“工具>信息查询”或“商业服务>会展赛事服务”,审核会顺利很多。当然这要结合自己实际需求来选,不能乱来。
5.2 常见问题排查速查表
我在开发和测试阶段遇到过不少问题,挑几个高频的列出来:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 登录后openid为空 | code过期或重复使用 | 确保wx.login后5分钟内换取,且code只能使用一次 |
| 时间显示NAN | 后端返回字符串日期 | 后端返回时间戳Long类型,前端用dayjs格式化 |
| 投票成功后票数不变 | Redis缓存和MySQL未统一 | 投票先写MySQL,成功后更新Redis,失败则回滚 |
| 用户重复提交报名 | 前端点击多次,后端未做幂等 | 增加request_id唯一约束 |
| 管理员看不到入口 | 白名单未配置 | 后端管理员表配置完整openid,且需重新登录 |
| 苹果手机上传图片失败 | 小程序图片路径带tmp前缀 | 用wx.uploadFile上传到对象存储,使用返回的url |
| 审核小程序被驳回 | 类目或名称不合规 | 调整为“工具类”或“商务服务”,去掉直播演出敏感词 |
| Excel导出乱码 | 未设置contentType和字符集 | 设置application/vnd.ms-excel与utf-8,文件名URL编码 |
这些问题的共同点其实是“前后端没有协同一致”。前端做完交互觉得完事了,但后端各种校验和兼容没有跟上,就会反复踩坑。建议脚本化测试每个接口的边界情况,比如时间边界、票数上限、重复提交、重复投票、并发请求,这比功能流程测试更重要。
5.3 避坑心得
再分享几个只有实际做过才懂的心得。
第一,报名和投票时间段边界不要精确到秒。活动运营者通常不会守着0点0分来操作,所以时间校验最好做“开始时间大于等于当前、结束时间小于等于当前”,但在判断报名是否开始时采用“启动前5分钟算可进入报名页但不可提交”,给用户留一点缓冲,体验会好一点。
第二,投票榜的排序需要防并列抖动。如果两个选手票数相同,刷新页面顺序可能反复横跳,观众会怀疑数据造假。我在排序时加入sort_no作为次级排序,票数相同按报名顺序排,保证排序稳定。
第三,千万不能在管理端批量审核时用循环单条update。几十条记录还能扛,几百条就会超时。用UPDATE registration SET status = 1 WHERE id IN (...)批量更新,速度提升明显,而且最好在事务里执行。
第四,在用户报名和投票提交时,要多建议后台生成一份完整的操作日志。虽然日志表会增加一两个字段,但一旦出现争议票、被投诉刷票,能通过日志查到一个具体操作的时间、IP、设备环境。早期我觉得日志不重要,直到一次活动被投诉才后悔没建表,后来补上以后省了很多事。
写在最后
这个路演报名投票系统做下来,我最大的体会是:技术难点其实不在前端界面炫不炫,也不在于用了多少框架,而在于把一条完整业务链路想透。报名怎么防重,投票怎么防刷,状态怎么流转,时间边界怎么控制,这些看似琐碎的问题,拼在一起就是这个小程序是否好用的分水岭。如果你正在设计类似的活动报名投票小程序,我建议先把业务状态机图画清楚,把唯一索引和幂等字段设计好,再动手写前后端代码。中途如果遇到问题,欢迎把这篇文章里的排查表当作起点,大部分坑都能在这里面找到影子。最后再说一个小技巧:做活动类小程序,一定记得在活动设置里留一个“是否实时展示票数”的开关,很多主办方其实不想让选手看实时票数,怕影响心态。这个小开关能让你的系统适用范围广很多,我就是一开始漏了这个,后来被合作方问了好几次才加上去的。