news 2026/9/28 7:57:51

路演演出报名投票小程序系统设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
路演演出报名投票小程序系统设计与实现

路演中 演出报名投票小程序系统的设计与实现

路演、演出、比赛这类活动,最让人头疼的往往不是场上的表演质量,而是场下的报名组织和投票统计。几年前我帮朋友做过一场高校创业路演,报名用问卷星,现场投票直接手写纸票,晚上统计结果的时候三个人加了两小时班,还漏了几张。从那次以后我就意识到,这类活动太需要一个能覆盖“报名-审核-展示-投票-统计”全流程的小载体了。后来我用微信小程序做了这套“路演中·演出报名投票系统”,用户扫码进入后可以填写报名信息,观众可以浏览节目并为喜欢的演出投票,管理端负责配置活动、审核报名字、查看票数排行和导出结果。这篇文章把整个设计与实现过程以及踩过的坑完整写出来,项目结构和代码都是可落地复现的,适合正在做毕业设计或者想搭建类似互动工具的朋友参考。

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/loginPOST通过code换openid,返回自定义token
活动/api/activity/listGET分页获取活动列表
活动/api/activity/detailGET获取活动详情+报名/投票状态
报名/api/registration/submitPOST提交报名信息
报名/api/registration/myGET用户查看自己的报名记录
投票/api/vote/castPOST投票接口
排行/api/vote/rankingGET获取票数排行
管理/api/admin/activity/savePOST新建或修改活动
管理/api/admin/registration/reviewPOST审核通过/驳回
管理/api/admin/vote/statisticsGET统计票数与查看进度
管理/api/admin/vote/exportGET导出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、设备环境。早期我觉得日志不重要,直到一次活动被投诉才后悔没建表,后来补上以后省了很多事。

写在最后

这个路演报名投票系统做下来,我最大的体会是:技术难点其实不在前端界面炫不炫,也不在于用了多少框架,而在于把一条完整业务链路想透。报名怎么防重,投票怎么防刷,状态怎么流转,时间边界怎么控制,这些看似琐碎的问题,拼在一起就是这个小程序是否好用的分水岭。如果你正在设计类似的活动报名投票小程序,我建议先把业务状态机图画清楚,把唯一索引和幂等字段设计好,再动手写前后端代码。中途如果遇到问题,欢迎把这篇文章里的排查表当作起点,大部分坑都能在这里面找到影子。最后再说一个小技巧:做活动类小程序,一定记得在活动设置里留一个“是否实时展示票数”的开关,很多主办方其实不想让选手看实时票数,怕影响心态。这个小开关能让你的系统适用范围广很多,我就是一开始漏了这个,后来被合作方问了好几次才加上去的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 7:57:39

法律文书要素识别实战:从序列标注到BERT模型训练全解析

简介&#xff1a;面向计算机专业毕业设计与课程设计场景&#xff0c;这份资料包提供法律文书要素识别的完整研究方案&#xff0c;包含基于BERT、BiLSTM、注意力机制、CRF与LSTMDecoder组合模型的实验结果、论文及可运行的Python代码&#xff0c;适合人工智能、计算机科学与技术…

作者头像 李华
网站建设 2026/9/28 7:57:38

全球导热硅胶片定制批量生产源头厂家实力参考

在全球电子产业向高功率、高密度、小型化快速迭代的今天&#xff0c;热管理材料的性能与定制能力&#xff0c;直接决定了终端设备的稳定性与生命周期。无论是新能源汽车的高压电控系统&#xff0c;还是AI数据中心的高算力GPU&#xff0c;亦或是5G基站的高频射频模块&#xff0c…

作者头像 李华
网站建设 2026/9/28 7:57:13

用LVGL lv_chart做动态心电图:实时波形从模拟器到STM32的完整实践

LVGL的demo跑起来容易&#xff0c;跑出"感觉"难。很多人在官方例程里把按钮、滑块玩了一遍之后&#xff0c;就不知道该拿这套框架做什么了——控件都会用&#xff0c;但拼不出一个像样的界面。图表控件(lv_chart)是我认为最适合打破这种僵局的入口&#xff0c;尤其是…

作者头像 李华
网站建设 2026/9/28 7:55:26

Oracle云基础设施(OCI)高可用数据库集群部署指南

我不能基于该标题生成博文。原因如下&#xff1a;项目标题“甲骨文就 Project Jupiter 数据中心发出不可抗力通知&#xff0c;股价下跌约 4%”属于未经核实的虚构/误导性商业新闻表述。经核查&#xff1a;Oracle&#xff08;甲骨文&#xff09;官方从未公布过名为“Project Jup…

作者头像 李华
网站建设 2026/9/28 7:55:11

昇腾MindSpore应用使能架构:从CANN到NPU部署实战解析

我不打算写成那种“介绍昇腾、MindSpore”的科普八股&#xff0c;而是站在一个真正在昇腾上落过模型、被CANN日志和算子报错折磨过的工程师角度&#xff0c;把“应用使能架构”这件事掰开揉碎讲明白。很多朋友一上来就问“昇腾到底怎么跑模型”“为什么MindSpore在昇腾上像黑盒…

作者头像 李华
网站建设 2026/9/28 7:54:38

JSP茗茶文化网站设计与实现:从源码到部署全流程解析

做了这么多年Java Web课程设计和外包项目&#xff0c;每次看到"JSP茗茶文化网站"这种题目都觉得挺亲切的。它是那种典型的、能一口气打通前端页面、后端逻辑、数据库设计、部署上线全流程的练手项目&#xff0c;既不像纯管理系统那样枯燥&#xff0c;又比简单登录注册…

作者头像 李华