拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

路演演出报名投票小程序系统设计与实现 路演中 演出报名投票小程序系统的设计与实现路演、演出、比赛这类活动最让人头疼的往往不是场上的表演质量而是场下的报名组织和投票统计。几年前我帮朋友做过一场高校创业路演报名用问卷星现场投票直接手写纸票晚上统计结果的时候三个人加了两小时班还漏了几张。从那次以后我就意识到这类活动太需要一个能覆盖“报名-审核-展示-投票-统计”全流程的小载体了。后来我用微信小程序做了这套“路演中·演出报名投票系统”用户扫码进入后可以填写报名信息观众可以浏览节目并为喜欢的演出投票管理端负责配置活动、审核报名字、查看票数排行和导出结果。这篇文章把整个设计与实现过程以及踩过的坑完整写出来项目结构和代码都是可落地复现的适合正在做毕业设计或者想搭建类似互动工具的朋友参考。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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;我需要说明几个设计细节。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 LambdaQueryWrapperVoteRecord() .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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;前端遍历字段列表生成 WXMLview wx:for{{formFields}} wx:keyid view classfield-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名SetZSetOperations.TypedTupleString 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、设备环境。早期我觉得日志不重要直到一次活动被投诉才后悔没建表后来补上以后省了很多事。写在最后这个路演报名投票系统做下来我最大的体会是技术难点其实不在前端界面炫不炫也不在于用了多少框架而在于把一条完整业务链路想透。报名怎么防重投票怎么防刷状态怎么流转时间边界怎么控制这些看似琐碎的问题拼在一起就是这个小程序是否好用的分水岭。如果你正在设计类似的活动报名投票小程序我建议先把业务状态机图画清楚把唯一索引和幂等字段设计好再动手写前后端代码。中途如果遇到问题欢迎把这篇文章里的排查表当作起点大部分坑都能在这里面找到影子。最后再说一个小技巧做活动类小程序一定记得在活动设置里留一个“是否实时展示票数”的开关很多主办方其实不想让选手看实时票数怕影响心态。这个小开关能让你的系统适用范围广很多我就是一开始漏了这个后来被合作方问了好几次才加上去的。
返回列表