ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于微信小程序和SSM的投票评选系统设计与实现

2026/9/16 10:59:54 拓冰建站 浏览量
基于微信小程序和SSM的投票评选系统设计与实现 简介这是一套面向毕业设计及企业轻量级投票评选场景的微信小程序前后端完整源码包。后端基于Java与SpringBoot/SSM框架开发前端采用微信小程序原生组件配合MySQL 5.7与Tomcat7环境即可运行适合需要快速搭建投票评选系统的开发者参考。包内共1170个文件包含88个Java后端逻辑文件、117个Vue组件、148个JavaScript脚本同时配套320个PNG图片、162个SVG图标以及数据库SQL脚本、项目介绍文档和部署批处理工具目录层次清晰便于定位业务模块。资源包大小16.89MB下载后可直接导入开发工具调试。目前已有2762人学习下载源码经过严格调试覆盖用户注册登录、评选项目展示、投票操作与结果统计等核心功能并附有数据库初始化脚本与运行说明能帮助开发者理解小程序与SpringBoot的整合方式也可作为毕业设计或课程项目的起步模板。1. 从投票活动到在线评选这个 ssm 小程序项目在解决什么问题每年临近毕业设计提交季「基于微信小程序投票评选系统的设计与实现」这类标题出现的频率会明显升高。剥开 .zip 压缩包的包装看它代表的是微信小程序客户端 SSM 后端Spring SpringMVC MyBatis MySQL 数据库的经典三层结构。投票系统本身不新鲜但在小程序端有它特殊的地方用户打开即用、不需要安装 App还能借助微信的登录能力免去注册流程这就把参与门槛降到了最低。适合做的场景很明确校园十佳评选、公司内部评优、社团活动投票凡是要靠微信传播拉票的活动都能套这个模子。读者如果是正在选毕设题目的学生或者要给单位快速搭一个内部评选工具的开发者这篇文章把从建表到小程序端拦票的完整路径走一遍比直接解压看代码更省时间。「ssm」三个字母在标题里决定了后端的技术路线也决定了运行方式——它不像 Spring Boot 那样内嵌 Tomcat 一键启动而是打 war 包丢进独立 Tomcat。这个细节会贯穿整个部署环节后面单独说。2. 微信小程序 SSM为什么这套组合适合投票评选场景先把选型逻辑讲透。微信小程序的登录链路和传统 Web 应用完全不同前端调用wx.login()拿临时 code后端拿 code 去微信接口换 openid整个会话基于 openid 建立没有传统的用户名密码登录。投票系统恰恰需要身份标识来限制「一人一票」openid 天然就是用户的唯一标识省去了短信验证、邮箱注册这些额外成本。投票评选的参与路径也适合小程序发起者把小程序码往群里一转用户点开就投投完可以分享拉票「发现-使用-传播」都在微信生态内闭环。SSM 作为后端在 2024 年看确实不如 Spring Boot 时髦但它是大量毕设模板和课程项目的默认选型资料密度极大任何一层报错都能搜到对应的解决记录。Spring 管 Bean 和事务、SpringMVC 管路由和参数绑定、MyBatis 管 SQL 和结果映射三层各管一段边界清楚。投票系统的业务量不大单机部署、一个 MySQL 实例、Tomcat 默认线程池就能扛住绝大多数校园级评选的并发量。真正的性能瓶颈从来不在框架本身而在数据库表的索引设计和防重复提交的幂等控制上。2.1 核心业务流程拆解从创建投票到结果统计投票评选系统抽掉所有边角功能主干业务链有 4 条创建投票、参与投票、查看进度、统计结果。如果做成带单选框的标准模板还可以把单选和多选都纳入进来如果要做成评选模式还需要支持按权重计分或者多人评委评分。绝大多数毕设项目会选择前 4 条加上管理员管理端评分类的高级玩法属于加分项。创建投票是管理端行为设定标题、起止时间、投票类型单选/多选、选项列表有时还要传一张封面图。参与投票是用户端行为进入投票详情页、勾选选项、提交。查看进度可以做成实时柱状图或者只看当前票数。统计结果则要区分「按票数排行」和「按比例展示」两种口径。这个流程里最容易被忽视的是时间状态机投票未开始、投票进行中、投票已结束三个状态决定了用户能不能投、投了能不能改、结果能不能看。状态判断不能只在小程序端做后端接口每次提交投票时必须校验当前时间落在活动时间窗内因为小程序端的请求可以被抓包伪造时间判断放前端等于没有。2.2 为什么投票业务要求后端强制做幂等和防刷投票系统最核心的挑战是防重复提交和防刷票这是它区别于普通 CRUD 项目的地方。普通文章系统的用户点两次提交无非是产生一条多余的评论投票系统的用户点两次提交就直接影响评选结果的公正性。所以投票接口必须同时满足两个条件同一个人对同一个活动只能投一次业务幂等同一秒内的重复点击不能产生多条记录接口幂等。这是个嵌套问题——两个条件都不难实现但只做其中一个就会在特定场景下出问题。只做业务幂等用户在快速双击时两次请求同时到达后端两次查询都发现还没投过票然后插入两条记录业务幂等的检查被并发穿透了。只做接口幂等用前端按钮置灰来防重复但抓包工具比如用 Burp Suite 抓小程序的请求可以绕开前端直接重放请求等于没防。所以正确做法是「数据库唯一索引做底 业务内先检查后插入 事务保证原子性」层次分明地堵住两条路。提示所有防刷手段都只能提高刷票成本不能做到绝对防御。后台管理员直接改数据库就绕过了所有防刷这不是程序设计能解决的问题。3. 数据库设计与 SSM 工程初始化表结构决定投票系统的上限3.1 投票领域最稳的 4 张核心表拆解投票评选业务按「用户、活动、选项、记录」四个维度建模最少需要 4 张表。用户表存 openid、昵称、头像投票活动表存标题、类型、起止时间、状态选项表存每个活动下的候选项投票记录表存每一次有效投票。很多初学会把「选项表」和「投票记录表」合并用option_iduser_id字段存用户选了什么表面上省了一张表实践上会让结果统计变得很别扭因为每个用户可能投多个选项多选场景一行存不下强行存 JSON 又无法走 SQL 聚合。单选框、多选框两种模式的落库方式不一样单选时投票记录表里一条记录对应activity_id user_id option_id多选时一对一变成一对多一个用户对一个活动对应多条选项记录。设计表结构时把option_id设计成普通索引而不是唯一索引配合唯一索引落在(activity_id, user_id)上两种模式都能覆盖。建表 SQL 用一套能跑的数据定义来直接落地-- 用户表用 openid 做业务唯一键id 做物理主键 CREATE TABLE t_user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, openid VARCHAR(64) NOT NULL COMMENT 微信用户openid, nickname VARCHAR(64) DEFAULT COMMENT 用户昵称, avatar_url VARCHAR(512) DEFAULT COMMENT 头像地址, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT小程序用户表; -- 投票活动表status 字段冗余存储避免每次判断时间 CREATE TABLE t_activity ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT 投票主题, cover_url VARCHAR(512) DEFAULT COMMENT 封面图地址, vote_type TINYINT NOT NULL DEFAULT 1 COMMENT 1-单选 2-多选, max_options TINYINT NOT NULL DEFAULT 1 COMMENT 多选时最多可选几项, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-未开始 1-进行中 2-已结束 3-已关闭, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_time (status, start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票活动表; -- 选项表外键指向活动表展示序号用 sort_order CREATE TABLE t_option ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, activity_id INT UNSIGNED NOT NULL COMMENT 所属活动id, option_name VARCHAR(128) NOT NULL COMMENT 选项名称候选人/作品名, intro VARCHAR(512) DEFAULT COMMENT 选项简介, image_url VARCHAR(512) DEFAULT COMMENT 选项图片, sort_order INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 展示顺序, PRIMARY KEY (id), KEY idx_activity_id (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票选项表; -- 投票记录表联合唯一索引是防重复投票的数据库底线 CREATE TABLE t_vote_record ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, activity_id INT UNSIGNED NOT NULL COMMENT 投票活动id, option_id INT UNSIGNED NOT NULL COMMENT 选项id, user_id INT UNSIGNED NOT NULL COMMENT 投票用户id, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_activity_user (activity_id, user_id), KEY idx_option_id (option_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票记录表;表结构设计的几个关键决策点uk_activity_user唯一索引把畸形数据挡在数据库层面这是任何后端业务校验都无法替代的最终防线status字段虽然是冗余设计但避免了每次查询都拿当前时间跟start_time、end_time比对索引效率更高max_options字段为多选模式预留了扩展空间如果限制最多选 3 项后端只需校验提交选项数不大于该值。t_option表的activity_id用普通索引而不是外键约束是刻意取舍InnoDB 的外键约束会带来额外的锁开销和级联成本投票系统的选项表和活动表生命周期天然一致应用层代码保证关联完整性数据库层用索引保证查询性能即可。3.2 用 Spring Initializr 风格目录组织 SSM 工程并配置 MyBatisSSM 工程的标准目录结构按「controller / service / mapper / pojo / common」分包和 Spring Boot 的「controller / service / mapper / entity」几乎没有区别区别只在于配置方式。SSM 用 XML 或 JavaConfig 显式声明组件扫描、数据源、SqlSessionFactory、事务管理器每个环节都写在明面上。MyBatis 的核心配置中最值得认真调的是驼峰映射。数据库字段create_time要能自动映射到 Java 属性createTime必须在mybatis-config.xml里开启configuration settings !-- 开启驼峰命名自动映射数据库下划线转Java驼峰 -- setting namemapUnderscoreToCamelCase valuetrue/ !-- 控制台打印SQL开发期建议开启上线前关闭 -- setting namelogImpl valueSTDOUT_LOGGING/ /settings /configurationmapUnderscoreToCamelCase不开启的话SELECT * FROM t_activity查出来的create_time字段赋值不到createTime属性上结果全是 null这是 SSM 项目最常见的「查出来是空对象」的根源之一。注意 MyBatis 的驼峰映射只对resultType自动映射生效如果写的是resultMap手动映射以resultMap里定义的 column 和 property 为准。Spring 侧的配置则聚焦三件事数据源用 Druid 还是 C3P0、事务管理器用DataSourceTransactionManager、Mapper 扫描路径要指向mapper接口所在的包路径。事务的Transactional默认传播行为是REQUIRED对于投票接口来说保证「检查是否已投票」和「插入投票记录」在同一个事务里即可读操作不需要事务。4. 后端核心逻辑实现投票幂等校验、统计查询与避坑参数4.1 投票接口的完整实现事务、唯一索引、并发控制三步缺一不可投票接口是后端全部逻辑中技术含量最高的一个方法它的实现质量直接决定系统在真实并发下的表现。完整实现分三层防护业务层先查后插、数据库唯一索引兜底、事务保证原子性。三层缺一层在特定恶意场景下都会被钻空子。实现方案选ServiceImpl层来完成主流程Controller 层保持薄路由Service public class VoteServiceImpl implements VoteService { Autowired private ActivityMapper activityMapper; Autowired private OptionMapper optionMapper; Autowired private VoteRecordMapper voteRecordMapper; Override Transactional(rollbackFor Exception.class) public VoteResult vote(VoteRequest request, Integer userId) { // 1. 校验活动存在且处于进行中状态 Activity activity activityMapper.selectById(request.getActivityId()); if (activity null) { return VoteResult.fail(活动不存在); } // 状态机判断status字段由定时任务或懒更新维护此处直接比对 if (activity.getStatus() ! 1) { return VoteResult.fail(当前不在投票时间内); } // 2. 校验用户是否已投过票业务层第一次检查 int existsCount voteRecordMapper.countByActivityIdAndUserId( request.getActivityId(), userId); if (existsCount 0) { return VoteResult.fail(您已经投过票了); } // 3. 校验选项合法性选项必须属于该活动 ListVoteRequest.OptionItem items request.getOptions(); ListInteger optionIds items.stream() .map(VoteRequest.OptionItem::getOptionId) .collect(Collectors.toList()); int validCount optionMapper.countByIdsAndActivityId(optionIds, request.getActivityId()); if (validCount ! optionIds.size()) { return VoteResult.fail(包含无效投票选项); } // 4. 写入投票记录 int inserted 0; for (VoteRequest.OptionItem item : items) { VoteRecord record new VoteRecord(); record.setActivityId(request.getActivityId()); record.setOptionId(item.getOptionId()); record.setUserId(userId); inserted voteRecordMapper.insert(record); } if (inserted ! items.size()) { throw new RuntimeException(投票记录写入失败); } // 5. 原子更新选项票数冗余字段 for (VoteRequest.OptionItem item : items) { optionMapper.increaseVoteCount(request.getActivityId(), item.getOptionId()); } return VoteResult.success(); } }这套实现里的第 2 步和第 4 步相互作用第 2 步先查询防止绝大多数正常流程里的重复提交第 4 步插入时如果撞上数据库的唯一索引uk_activity_userMyBatis 会抛出DuplicateKeyException。这个异常被Transactional捕获后整个事务回滚然后再由 Controller 层的全局异常处理器转换成「请勿重复投票」的提示返回给前端。走完整条链路的最终结果是并发下即使两个请求同时通过第 2 步检查第 4 步的数据库约束也只会放行一条。表达层如果要优化并发性能可以把第 2 步的count查询换成INSERT ... ON DUPLICATE KEY UPDATE的原子写法一次请求完成检查加插入两件事。但考虑到毕设和中小型评选系统的实际流量先查后插 唯一索引的写法更直观维护成本也低。4.2 结果统计的 SQL 与 Java 侧处理GROUP BY 和占比计算的最佳写法投票结果的展示分两个层级各选项票数排行、各选项占比。后者需要在前端画饼状图或进度条时用到后端最好直接返回计算好的占比值而不是让小程序的progress组件自己用Math.round(voteCount / totalVotes * 100)去算因为小程序端 JS 的浮点运算精度有限。正确做法是后端把整数百分比和余数百分比分开返回避免前端出现「各占比之和不是100%」的尴尬。首先是各选项票数的统计 SQL。这里有一个可以埋在设计里的优化在t_option表加一个vote_count冗余字段每次投票完成后用UPDATE t_option SET vote_count vote_count 1 WHERE id ?直接加一统计时直接ORDER BY vote_count DESC查选项表即可。如果不做冗余字段统计就得实时连表聚合-- 实时统计各选项票数按票数降序排列 SELECT o.id AS option_id, o.option_name AS option_name, COUNT(r.id) AS vote_count FROM t_option o LEFT JOIN t_vote_record r ON r.option_id o.id WHERE o.activity_id #{activityId} GROUP BY o.id, o.option_name ORDER BY vote_count DESC, o.sort_order ASC;这段 SQL 有两个易错点。LEFT JOIN而不是INNER JOIN是为了让没有票数的选项也能查出来否则零票选项会直接消失展示时就少一个选项。GROUP BY后面必须带上o.id和o.option_name虽然option_name在函数上依赖o.id但 MySQL 的ONLY_FULL_GROUP_BY模式默认开启会直接报错除非把option_name包进ANY_VALUE()否则就把GROUP BY写完整。占比计算的 Java 侧可以这样处理整数和余数分离public class VoteStatisticVO { private Integer optionId; private String optionName; private Integer voteCount; private Integer percent; // 整数百分比 private Integer remainder; // 余数部分用于拉齐误差 } // 计算逻辑优先保证整数占比之和为100余数做尾数修正 public ListVoteStatisticVO calcPercent(ListVoteStatisticVO list) { int total list.stream().mapToInt(VoteStatisticVO::getVoteCount).sum(); if (total 0) { list.forEach(v - { v.setPercent(0); v.setRemainder(0); }); return list; } int used 0; for (int i 0; i list.size(); i) { VoteStatisticVO vo list.get(i); if (i list.size() - 1) { // 最后一项用100减去已分配值保证总和恰好为100 vo.setPercent(100 - used); vo.setRemainder(0); } else { int p vo.getVoteCount() * 100 / total; used p; vo.setPercent(p); // 余数 票数 * 100 % total供需要精确展示的图表使用 vo.setRemainder(vo.getVoteCount() * 100 % total); } } return list; }尾数修正策略保证了所有选项百分比相加恰好等于 100这在展示「各候选人得票占比」进度条时特别重要。如果前端做的是真实饼图同时给出percent和remainder让它用count / total自己算也行。主要避免的是后端给整数百分比后前端再拿(count / total * 100).toFixed(0)自行取整两端的舍入策略不一致。4.3 SSM 时代必调的 5 个配置文件参数错过一个就部署失败SSM 项目跑不起来九成问题出在配置文件。经历过这些坑的人都知道SSM 的配置文件数量比 Spring Boot 多得多字段记错一个就是 Tomcat 启动失败。第一是 JDBC 连接串时区问题。MySQL 8.x 驱动com.mysql.cj.jdbc.Driver要求连接串里显式声明serverTimezone否则直接报The server time zone value Öйú±ê׼ʱ¼ä乱码是时区被非 UTF-8 环境解析后的结果。最稳妥的值是Asia/Shanghaijdbc.urljdbc:mysql://127.0.0.1:3306/vote_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue是给 MySQL 8.x 的caching_sha2_password认证用的不加的话有些版本会在首次连接时抛Public Key Retrieval is not allowed。useSSLfalse是因为本地开发环境没有配置 SSL 证书测试阶段加上省去一堆握手警告。第二是 Tomcat 的 URI 编码。小程序端提交的中文用户名、投票主题如果在后端request.getParameter()读出来是乱码第一优先检查 Tomcat 的server.xml里Connector是否配置了URIEncodingUTF-8Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8/第三是 Spring MVC 的注解驱动必须开启否则RequestMapping不生效所有请求直接 404。spring-mvc.xml里要有mvc:annotation-driven/和组件扫描指令context:component-scan base-packagecom.example.vote.controller/ mvc:annotation-driven/第四是web.xml的 DispatcherServlet 拦截路径。经典错误是把/配成了/*后者会拦截 JSP 和静态资源导致 Swagger/静态文件全部 404。正确配置servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping第五是 MyBatis 的 Mapper XML 文件扫描。Spring 容器只扫描 Java 接口如果 Mapper XML 放在src/main/resources/mapper/目录下必须在spring-dao.xml里声明bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.vote.mapper/ /bean同时保证 Mapper XML 的namespace与接口全限定名一致id与接口方法名一致否则启动即报Invalid bound statement。对比一下 Spring Boot这些配置全部被自动装配替代Boot 开发者在 SSM 上栽的跟头主要发生在这几个 XML 文件切换时逐项对照排查能省半天时间。5. 微信小程序端从登录到投票单选框、加载页与请求封装5.1 小程序登录换取 openid 的完整时序与后端接口对应小程序端的登录流程和传统 Web 的 session 机制完全不同核心区别在于小程序没有 cookie后端不能靠JSESSIONID识别用户必须通过 openid 建立自己的会话体系。标准流程是四步wx.login()拿到临时 codewx.request()把 code 发给后端后端拿着 code 调微信的jscode2session接口换 openid 和 session_key后端用 openid 查用户表查到就更新信息查不到就注册新用户然后把「用户 id openid」的服务端 session 返回给小程序端。小程序端一般会把后端返回来的 token 存到wx.setStorageSync(token)后续所有请求在请求头带Authorization: Bearer token。后端用一个拦截器HandlerInterceptor统一从请求头解析 token再通过 ThreadLocal 或参数解析器注入到 Controller 方法中。登录核心接口的 Java 实现RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; /** * 小程序登录接口 * 入参为 wx.login() 返回的临时 code后端换取 openid */ PostMapping(/login) public Result login(RequestBody LoginRequest request) { // 调用微信接口用 code 换取 openid String url https://api.weixin.qq.com/sns/jscode2session ?appid wxConfig.getAppId() secret wxConfig.getSecret() js_code request.getCode() grant_typeauthorization_code; String response HttpUtil.get(url); JSONObject json JSON.parseObject(response); String openid json.getString(openid); if (openid null || openid.isEmpty()) { // 拿不到 openid 说明 code 失效或 appid/secret 不匹配 return Result.fail(code 无效: json.getString(errmsg)); } // 查库或注册新用户然后生成服务端 session token User user userService.findOrCreate(openid); String token userService.createSession(user.getId()); return Result.success(token); } }前端的订阅消息、手机号快捷登录都可以挂在登录链路之后作为扩展但基础登录返回 token 这一步是所有后续投票请求的前提。5.2 wx.request 封装与单选框组件的正确绑定姿势小程序端所有业务请求都应该统一走request.js封装好的方法带上 token、处理错误码、统一管理加载状态。切忌每个页面直接写裸的wx.request一旦后端的 baseURL 需要切换几十个页面的 URL 都要改。一个可复用的请求封装// utils/request.js const BASE_URL https://your-domain.com/api; function request(path, method GET, data {}) { return new Promise((resolve, reject) { const token wx.getStorageSync(token); wx.request({ url: BASE_URL path, method, data, header: { Content-Type: application/json, Authorization: token ? Bearer ${token} : }, success: (res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); // 取业务数据 } else if (res.statusCode 401) { wx.redirectTo({ url: /pages/login/login }); reject(new Error(未登录)); } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(new Error(res.data.msg)); } }, fail: (err) { wx.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); } module.exports { request };投票页面的单选框radio需要特别注意bindchange的事件绑定方式。微信小程序的radio-group组件它的bindchange事件里e.detail.value是当前选中的 value 字符串不是对象。如果是多选模式用的是checkbox-groupe.detail.value是数组。这里最容易踩的坑是从后端拉选项列表渲染时value字段必须设置成选项的 id 且是字符串类型否则选中状态的对比会失效——比如后端返回的optionId是数字 1而 radio 的value要求字符串那么value1和e.detail.value 1永远比对不上。单选框投票页面的核心片段radio-group classvote-options bindchangeonOptionChange label classoption-item wx:for{{options}} wx:keyid radio value{{item.id}} checked{{item.id selectedId}} color#4A90E2 / text classoption-name{{item.optionName}}/text text classoption-votes wx:if{{showVotes}}{{item.voteCount}}票/text /label /radio-group button typeprimary bindtapsubmitVote disabled{{submitting}}提交投票/buttondisabled{{submitting}}是前端最简单也最必要的防重复点击手段——在投票请求发出期间把按钮置灰请求结束后恢复。不过这只能防正常用户手滑双击防抓包重放还得靠后端逻辑。5.3 加载页优化与页面下拉刷新配置小程序的加载体验直接影响用户投票意愿。很多模板原生加载页是一个白屏配一个wx.showLoading在弱网环境下体验很差。换成一个定制化的首屏加载骨架屏至少要把页面顶部的主视觉、标题占位块画出来让用户感知到页面在加载而不是「卡死」了。加载状态通常出现在两个地方进入投票详情页时拉取活动信息和选项列表以及提交投票时等待结果回包。详情页可以用onLoad里拉数据加上wx.showNavigationBarLoading()让顶部标题栏显示转圈动画提交按钮的加载用按钮内部 loading 效果避免全屏遮罩挡住用户看到投票结果。页面下拉刷新配置要支持用户投票后下拉刷新看最新票数需要在app.json或页面的json里开启enablePullDownRefresh: true并在页面 JS 里实现onPullDownRefresh。要注意的是票数是全局实时变动的数据下拉刷新会重新拉取getActivityDetail接口此时t-option表的vote_count冗余字段能显著减少聚合计算的开销。关于weixin://dl/business这个跳转链接它是微信内的业务跳转协议常见于从微信外单聊、群聊卡片唤起小程序指定页面。投票评选系统如果在分享卡片上要带上活动 ID就要确保启动参数在小程序端onLoad(options)里能正确取到onLoad(options) { // 从分享卡片获取的活动ID if (options.activityId) { this.setData({ activityId: options.activityId }); this.loadActivityDetail(options.activityId); } }weixin://dl/business生成的核心是服务端调用generateScheme或generateShortLink接口生成 URL Link客户端解析后携带query参数跳转。细节在于参数必须拼接在 scheme 的path后不要拼在query后否则小程序冷启动时onLoad的options里取不到自定义参数。5.4 投票完成提交数据校验和约定的错误码提交投票的流程在业务代码里前后端有一条隐含的约定链。前端提交时只传activityId和options数组每个元素含optionId不传userId因为用户身份已经由请求头里的 token 决定了。这个设计对后端安全至关重要——如果前端把userId也放进请求体只需用 Burp Suite 抓包改一下用户 ID就能伪造别人的投票记录。提交投票的核心请求链路async submitVote() { if (this.data.selectedId null) { wx.showToast({ title: 请先选择选项, icon: none }); return; } this.setData({ submitting: true }); try { const data await request(/vote/do, POST, { activityId: this.data.activityId, options: [{ optionId: this.data.selectedId }] // 单选时只有一个元素 }); // 成功后跳到结果页避免用户返回后重复提交 wx.redirectTo({ url: /pages/vote-result/vote-result?activityId${this.data.activityId} }); } catch (err) { console.error(投票失败, err); } finally { this.setData({ submitting: false }); } }这里使用wx.redirectTo而不是wx.navigateTo是有意为之。navigateTo保留当前页面用户按返回键会回到投票页此时按钮已经恢复存在二次提交的可能。redirectTo直接关闭当前页跳到结果页从页面栈上就堵住了这个口子。服务端对投票请求失败时的错误码要区分开1001表示活动不存在或已删除、1002表示不在投票时间窗内、1003表示已经投过票、1004表示包含非法选项。前端拿到1003时提示「您已参与过本次投票」并引导用户跳转结果页而非留在原地反复尝试。6. 部署上线四个必查项时区、域名校验、抓包与导出项目本地跑通只完成了一半小程序端因为微信平台的审核和真机校验机制部署上线的坑比传统的 Web 项目多得多。这一节把最常踩的四个写清楚。第一HTTPS 与合法域名校验。小程序生产环境要求所有wx.request的请求地址必须是 HTTPS而且该域名必须配置在小程序后台的「开发管理-服务器域名」里。在开发者工具本地调试时可以勾选「不校验合法域名」来绕过但真机预览和体验版、正式版都会强制拦截。后端对应要做的是把 Tomcat 的 8080 端口暴露到 Nginx 后面通过 Nginx 配置 SSL 证书并反向代理到http://127.0.0.1:8080。配置时要注意proxy_set_header Host $host和X-Forwarded-For都要加否则后端的跳转和 IP 记录会出错。如果你用 Burp Suite 抓包调试小程序 HTTPS 请求需要先信任 Burp 的 CA 证书否则抓到的是加密乱码。第二服务器时区与数据库时区一致性。生产环境服务器通常是 UTC 或 CUT 时区而t_activity表的start_time、end_time如果按北京时间写入服务器本地时间却是 UTCnew Date()会比北京时间慢 8 小时投票时间窗口会整体偏移。建议在启动 Tomcat 的catalina.sh里显式设置JAVA_OPTS$JAVA_OPTS -Duser.timezoneAsia/Shanghai同时确保 MySQL 的time_zone也是08:00show variables like %time_zone%;查看如果是SYSTEM则说明跟随系统时区直接用SET GLOBAL time_zone 08:00;修正。第三抓包调试的前后端配合。微信小程序从 2021 年起基本只走 HTTPSHTTP 明文流量在安卓 9.0 及以上版本默认被禁止除非配置usesCleartextTraffic。写小程序时如果发现真机上请求全部报ERR_CLEARTEXT_NOT_PERMITTED排查方向不是改后端而是要么上 HTTPS要么在开发调试阶段用 Charles 或 Burp 抓包确认证书信任链。抓包时另一个高发问题是后端getRemoteAddr拿到的 IP 全部变成代理服务器 IP 而不是用户真实 IP需要在 Nginx 层配置proxy_set_header X-Real-IP $remote_addr后端再用X-Forwarded-For取值。第四投票结果导出 Excel。很多评选活动在结束时需要导出票数明细存档SSM 后端可以用阿里 EasyExcel 生成 xlsx将选项名、票数、占比、投票用户昵称写入多 Sheet。导出接口建议做成异步任务后台生成文件后返回下载链接避免前端wx.request等待超时。文件保存路径不要放在 Tomcat 的webapps目录内否则重启会丢失存到独立的数据目录Nginx 做一个/files/的静态映射。// 使用 EasyExcel 导出投票明细 GetMapping(/export/{activityId}) public void export(HttpServletResponse response, PathVariable Integer activityId) throws IOException { String fileName URLEncoder.encode(投票结果, UTF-8); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); response.setHeader(Content-Disposition, attachment;filename fileName .xlsx); ListVoteResultExportVO list voteRecordMapper.exportByActivityId(activityId); EasyExcel.write(response.getOutputStream(), VoteResultExportVO.class) .sheet(投票结果) .doWrite(list); }导出时setContentType里指定的 MIME 类型直接决定浏览器和小程序端能不能正确识别下载行为URLEncoder.encode处理中文文件名是为了避免下载出来的文件名乱码。配合前端wx.downloadFile下载后调wx.openDocument直接预览整套流程就闭环了。部署完成的验证动作一般先跑通「创建活动 → 分享 → 投票 → 截止 → 导出」全链路再拿两台不同微信账号的真机各投一票看会不会出现重复计数最后检查活动截止时间点前后一秒的请求是否被正确拦截——这三个验证都通过这套系统才算真正可以上线。本文还有配套的精品资源点击获取