SSM+微信小程序投票评选系统实战:登录、防重复与结果导出

发布时间:2026/9/16 11:18:41
SSM+微信小程序投票评选系统实战:登录、防重复与结果导出 简介《基于微信小程序投票评选系统的设计与实现》是一套面向高校毕设、课程设计与企业小型投票活动的完整Java全栈项目采用SpringBoot/SSM作为后端、微信小程序作为前端涵盖从数据库设计到前端交互的核心业务链路。资源包共1170个文件压缩后约16.89MB包括Java源码、Vue管理端页面、小程序wxml/wxss页面、SQL脚本及项目介绍文档等其中png、svg等图片素材占据较多比例。已有2762人学习下载适合需要快速搭建投票评选系统或复现毕设功能的开发者。项目附带数据库建表脚本与配置示例可直接导入运行代码结构区分小程序端、服务端与前端管理后台有助于理解用户注册、投票处理、结果统计等模块的实现思路并可根据实际场景进行二次扩展。1. 为什么“投票评选系统”比“投票系统”难做先定 SSM 的边界一个候选人、一个人、一票能力范围内的东西其实不多。但“评选”两个字一出来事情就变了它意味着后台要管活动期、管评选维度、管评委名单前台要处理重复点击、换票、截止时间、结果公示。微信小程序上做这类系统最大的错觉是“投票就是点按钮”真正的问题全在身份与状态——谁投的、投给谁、能不能改、能不能重复投。以 SSMSpring SpringMVC MyBatis作为后端来承接微信小程序的投票评选系统是目前毕业设计和轻量级业务里比较成熟的一套组合。选它不是因为框架新而是因为它正好覆盖了这类项目的全部需求面Spring 管对象和事务SpringMVC 管接口路由MyBatis 管 SQL 和结果映射。小程序端只管渲染和用户行为服务端负责身份校验、投票约束和结果统计。这套结构足够清晰也让后面的权限、防重、导出都有地方安放。2. SSM 后端与微信小程序端的分工表结构、接口与单选框交互2.1 小程序端为何不能直连数据库SSM 的安全边界很多初学者拿到投票评选需求后的第一个想法是把候选人列表和投票记录直接存到小程序的本地缓存里或者在小程序端用wx.request去请求一个 PHP/Node 脚本。但微信小程序的运行环境不等于浏览器它在网络层有明确的合规限制生产环境必须配置 request 合法域名而且不能暴露数据库账号、不能把核心业务逻辑放在前端。原因很简单小程序包可以被反编译前端代码里所有静态字符串都等于公开信息你把 MySQL 密码写在config.js里就是在给刷票的人递钥匙。SSM 在这套架构里的位置是所有业务状态的唯一出口候选人数据、投票记录、评委身份、活动配置都只在服务端持久化。小程序端通过wx.request调用后端暴露的 REST 接口拿到 JSON 再渲染。这个边界不划清楚后面的防重投票、公示结果就完全无从谈起。对于大多数“基于微信小程序投票评选系统”的实践项目这个分层也是毕业设计和实际交付里最被看重的结构。2.2 评审活动、候选人与投票记录三张表先写清约束再动手数据库设计决定了这个系统能做多稳。一个投票评选系统至少需要三张业务表活动表vote_activity、候选人表candidate、投票记录表vote_record。如果还涉及评委身份则再加一张用户表但这里用户和微信 openid 是绑定关系不是注册关系后面第三章会重点展开。先看建表语句CREATE TABLE vote_activity ( id INT AUTO_INCREMENT PRIMARY KEY, activity_name VARCHAR(64) NOT NULL COMMENT 活动名称, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 截止时间, max_votes_per_user INT DEFAULT 1 COMMENT 每人可投票数, status TINYINT DEFAULT 1 COMMENT 1未开始 2进行中 3已结束, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE candidate ( id INT AUTO_INCREMENT PRIMARY KEY, activity_id INT NOT NULL, candidate_name VARCHAR(64) NOT NULL, photo_url VARCHAR(255) DEFAULT COMMENT 图片URL, intro TEXT COMMENT 简介, vote_count INT DEFAULT 0 COMMENT 冗余计数字段仅用于列表展示, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_activity (activity_id), CONSTRAINT fk_candidate_activity FOREIGN KEY (activity_id) REFERENCES vote_activity(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE vote_record ( id INT AUTO_INCREMENT PRIMARY KEY, activity_id INT NOT NULL, candidate_id INT NOT NULL, openid VARCHAR(64) NOT NULL COMMENT 微信用户唯一标识, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_one_vote (openid, candidate_id), KEY idx_activity_candidate (activity_id, candidate_id), CONSTRAINT fk_record_candidate FOREIGN KEY (candidate_id) REFERENCES candidate(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里最关键的约束是UNIQUE KEY uk_one_vote (openid, candidate_id)它直接从数据库层面保证同一个用户对同一个候选人只能有一条投票记录。vote_count是一个反向冗余字段用来快速渲染榜单而不必每次都COUNT(*)。实际写入时两条 SQL 放在同一个事务里先插入vote_record再UPDATE candidate SET vote_count vote_count 1 WHERE id ?。注意这里用的是自增更新而不是先查再写避免并发下丢更新。如果业务允许同一用户给多个候选人各投一票那唯一索引就换成(openid, activity_id, candidate_id)即一个活动内一个候选人只能投一次这是更常见的评选规则。多票制的情况下还可以在vote_activity里加一列max_votes_per_user每次投票前先查该用户在该活动已投数量再用事务保证不超限。2.3 投票页的微信小程序单选框实现状态切换与防连点小程序端的投票页面最常见的交互是用单选列表展示候选人。微信小程序原生没有radio-group之外的更复杂组件这里直接用radio-grouplabel将整行变成可点区域展示照片、简介和当前票数。view classcandidate-list radio-group bindchangeonVoteChange label classcandidate-item wx:for{{candidateList}} wx:keyid image src{{item.photoUrl}} classcandidate-avatar modeaspectFill / view classcandidate-info text classname{{item.candidateName}}/text text classintro{{item.intro}}/text text classvotes当前票数{{item.voteCount}}/text /view radio value{{item.id}} checked{{item.checked}} color#2E7D32 / /label /radio-group button typeprimary bindtapsubmitVote disabled{{!selectedId}}确认投票/button /viewPage({ data: { candidateList: [], selectedId: null, submitting: false }, onLoad() { this.loadCandidates(); }, onVoteChange(e) { this.setData({ selectedId: e.detail.value }); }, submitVote() { if (this.data.submitting) return; this.setData({ submitting: true }); const token wx.getStorageSync(token); wx.request({ url: https://your-api.example.com/vote, method: POST, header: { Authorization: token }, data: { candidateId: this.data.selectedId }, success: (res) { if (res.data.code 0) { wx.showToast({ title: 投票成功, icon: success }); this.loadCandidates(); } else { wx.showToast({ title: res.data.msg, icon: none }); } }, complete: () this.setData({ submitting: false }) }); } })submitting字段是为了防止用户连点按钮产生重复请求这只是前端层面的第一道闸。真正防止重复投票要靠后端的事务和唯一索引前端防连点只是优化体验和降低无效请求量。disabled{{!selectedId}}的意思是没选中候选人时按钮置灰这也避免了很多无效提交。这里有一个在真实项目里很容易漏掉的细节如果候选人列表是在onLoad里异步加载用户快速滑动页面时setData会频繁触发渲染建议在onLoad里先展示骨架屏等数据返回后再填充列表体验会好很多。3. 微信小程序登录与身份绑定从 wx.login 到 openid 去重3.1 wx.login 拿到 code 之后发生的事情投票评选系统里“谁投的票”这件事必须有一个可信的答案。微信小程序的匿名登录流程是所有身份体系的根基小程序端调用wx.login()拿到一个临时凭证code把它发给后端后端拿着这个code去向微信接口换取openid。这个openid是每个用户在某个小程序下的唯一标识同一个用户在不同小程序里openid不同但在同一个小程序里永远唯一。用它来做投票去重的依据比用户自己填手机号、昵称可靠得多。代码只有一次有效有效期通常为 5 分钟所以后端接口里一旦交换失败就立即返回错误不能重试二次使用同一个 code。3.2 小程序端的登录与 token 存储小程序端第一次进入投票页时先检查本地有没有缓存的 token没有就去走登录流程。App({ onLaunch() { this.login(); }, login() { const token wx.getStorageSync(token); if (token) return; wx.login({ success: (res) { if (res.code) { wx.request({ url: https://your-api.example.com/auth/login, method: POST, data: { code: res.code }, success: (resp) { const { token, expiresIn } resp.data.data; wx.setStorageSync(token, token); wx.setStorageSync(expiresIn, Date.now() expiresIn * 1000); } }); } } }); } })需要注意的一个坑wx.login拿到的 code 是一次性的。如果你的登录请求因为网络超时失败了不能直接重发同一个 code必须重新调用wx.login()拿新的 code。前后端联调时最容易看到的现象是“第一次登录成功第二次登录报错”大概率就是代码里复用了旧的 code。token存到本地存储后每次请求通过Authorization请求头带上。小程序端的wx.request请求头目前不支持自定义Cookie统一用 header 传参是更标准也更省事的做法。3.3 后端登录接口与拦截器校验后端要用RestTemplate或OkHttp把 code 发给微信服务器换openid。这里给出基于RestTemplate的简化实现Service public class WechatAuthService { Value(${wechat.appid}) private String appid; Value(${wechat.secret}) private String secret; private final RestTemplate restTemplate new RestTemplate(); public String code2Openid(String code) { String url https://api.weixin.qq.com/sns/jscode2session?appid appid secret secret js_code code grant_typeauthorization_code; MapString, Object resp restTemplate.getForObject(url, Map.class); if (resp null || resp.containsKey(errcode)) { throw new BusinessException(微信登录失败); } return (String) resp.get(openid); } }appid和secret放在application.yml里不要在代码里硬编码。拿到openid后查用户表如果不存在则插入一条新记录存在则直接为其生成一个token可以用 UUID 或 JWT然后返回给小程序端。后面所有需要身份的接口都通过token找到对应的openid就能拿到当前投票人身份。拦截器的作用是统一校验避免每个 Controller 方法里重复写“从 header 里取 token 再查用户”的逻辑Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !tokenService.isValid(token)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } request.setAttribute(openid, tokenService.getOpenidByToken(token)); return true; } }在spring-mvc.xml或WebMvcConfigurer里注册这个拦截器并配置/vote/**、/record/**这些路径需要拦截/auth/login不需要拦截。此处有一个容易踩的坑未登录用户的请求返回 401 时小程序端的wx.request会自动 failsuccess 回调不会执行所以要在 fail 里统一处理“跳回登录页”的逻辑。4. 投票接口的防重、幂等与并发SSM 控制层到数据库的正确姿势4.1 Controller 层参数校验别只依赖前端到了投票接口前后端的信任边界必须收紧。小程序端可以做单选框选中、按钮禁用但这些都只是用户体验层面的约束服务端必须假设所有请求都可能是伪造的candidateId越界、活动已截止、同一 openid 重复提交。RestController RequestMapping(/vote) public class VoteController { Autowired private VoteService voteService; PostMapping public ResultVoid vote(RequestBody VoteParam param, HttpServletRequest request) { String openid (String) request.getAttribute(openid); if (param.getCandidateId() null) { return Result.error(参数缺失); } VoteResult result voteService.submitVote(openid, param.getCandidateId()); if (result.isSuccess()) { return Result.success(null); } return Result.error(result.getMsg()); } }VoteParam里除了candidateId还可以带一个activityId因为在多活动场景下单靠 candidateId 无法确定它属于哪个活动。校验时先确认 activity 存在、当前时间在start_time和end_time之间再进入投票写入逻辑。4.2 Service 层事务 唯一索引并发下的最后一道防线投票写入是典型的“先检查后写入”场景天然存在并发竞争。两个请求同时通过检查然后同时插入记录没有数据库约束的话就会产生两条重复投票。事务能保证要么全部提交要么全部回滚但事务本身不解决并发冲突真正兜底的是上一章建表时加的唯一索引uk_one_vote (openid, candidate_id)。Service public class VoteService { Autowired private VoteRecordMapper voteRecordMapper; Autowired private CandidateMapper candidateMapper; Transactional(rollbackFor Exception.class) public VoteResult submitVote(String openid, Integer candidateId) { if (voteRecordMapper.exists(openid, candidateId)) { return VoteResult.fail(您已为该候选人投过票); } VoteRecord record new VoteRecord(); record.setOpenid(openid); record.setCandidateId(candidateId); try { voteRecordMapper.insert(record); } catch (DuplicateKeyException e) { return VoteResult.fail(您已为该候选人投过票); } candidateMapper.increaseVoteCount(candidateId); return VoteResult.success(); } }exists检查只是一种提前返回的手段提升正常场景的效率真正防重靠的是DuplicateKeyException捕获和事务回滚。这里有一个实际项目中很关键的点candidateMapper.increaseVoteCount(candidateId)执行后如果事务提交失败会增加计数却查不到投票记录导致票数虚高。所以计数更新和记录插入必须放在同一个事务里且rollbackFor Exception.class必须显式声明因为 Spring 默认只回滚RuntimeException像SQLException这类受检异常不会自动触发回滚。mapper里的两条核心 SQL 写成注解形式如下Mapper public interface CandidateMapper { Update(UPDATE candidate SET vote_count vote_count 1 WHERE id #{id}) int increaseVoteCount(Integer id); }不要把vote_record的查询和插入拿到 service 层用JdbcTemplate拼字符串MyBatis 的#{}预处理能防 SQL 注入这是选 SSM 而不是手写 JDBC 的核心理由之一。4.3 并发验证与 JMeter 参数设置写完防重复逻辑后一定要验证最常见的验证手段是 JMeter 模拟并发请求。先用wx.login的 code 换取真正的 token再把这些 token 放到 JMeter 的 HTTP Header Manager 里对投票接口并发发请求。jmeter -n -t vote_test.jmx -l result.jtl -j jmeter.logvote_test.jmx里主要配置三件事线程组设置 50 个线程、循环次数 1每个线程的 HTTP 请求都带上Authorizationheader值从 CSV 文件里读取添加“JSON 断言”检查返回结果里code字段。跑完之后打开result.jtl观察有多少请求返回“已投过票”有多少返回成功。正确结果应该是每个 token 最多只有一次成功其余全部被拦截且数据库里 vote_record 表的总记录数等于 token 数量。如果你用的不是 JMeter也可以用命令行工具ab做简单验证ab -n 200 -c 20 -T application/json -H Authorization: token值 -p body.json http://localhost:8080/vote。注意-T和 header 都不能省否则 SpringMVC 直接报 415 或 401。这两条验证命令我一般在本地联调阶段都会跑一遍确认防重生效后再进集成测试。5. 小程序发布前要做的三件事加载页、结果导出与真机自测5.1 修改刚进入的加载页面别让白屏劝退评委很多评委打开小程序的第一印象就来自启动加载阶段。SSM 后端接口响应再快小程序首屏也要经过“下载包 - 渲染框架 - 请求数据”几个阶段。默认情况下小程序启动时会短暂显示app.json里 window 配置的背景色或者首屏页面一片空白。常见的做法是把window的navigationBarTitleText改成活动名称同时给首屏页面加一个带活动二维码的加载图等数据返回后再wx.hideLoading。实践中我一般会在onLoad后先展示一个本地静态 banner 作为骨架这样用户感知不到网络延迟这个技巧对投票类工具型小程序尤其有效。5.2 投票结果导出 Excel 的完整链路评选结束后管理端需要把结果导成表格。SSM 后端常用 Apache POI 生成.xlsx文件先查数据库统计结果再写入 Excel 并输出到响应流。RequestMapping(/export) public void exportVoteResult(HttpServletResponse response, Integer activityId) throws IOException { ListMapString, Object resultList candidateMapper.selectVoteStat(activityId); XSSFWorkbook workbook new XSSFWorkbook(); Sheet sheet workbook.createSheet(投票结果); Row header sheet.createRow(0); header.createCell(0).setCellValue(候选人); header.createCell(1).setCellValue(票数); for (int i 0; i resultList.size(); i) { Row row sheet.createRow(i 1); row.createCell(0).setCellValue(resultList.get(i).get(candidateName).toString()); Object count resultList.get(i).get(voteCount); row.createCell(1).setCellValue(count null ? 0 : Double.parseDouble(count.toString())); } response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamevote_result.xlsx); workbook.write(response.getOutputStream()); workbook.close(); }小程序端管理页通过wx.downloadFile下载这个接口返回的文件再用wx.openDocument打开预览。注意wx.downloadFile的 URL 必须是合法域名本地联调时可以在微信开发者工具右上角“详情 - 本地设置”里勾选“不校验合法域名”但真机预览和发布版必须使用 HTTPS 域名这是上线前最容易被卡住的环节。调试时如果要看接口返回的完整报文可以在电脑上用抓包工具看 PC 端微信小程序的 HTTPS 流量确认Content-Disposition是否正确携带文件名有些情况下后台管理系统导出失败是因为服务端用了不兼容的编码方式设置文件名。5.3 上线前自测清单最后过一遍发布前最容易出问题的点app.json里pages数组的第一个页面是不是你自己项目的入口页request合法域名是否已在小程序管理后台配置投票截止后页面是否还能看到按钮——这个要在后端校验时间前端隐藏按钮只是视觉效果同一个微信号在两台手机上分别投票是否会被识别成两个人验证 openid 去重是否生效。你在开发者工具里跑得通不代表真机没问题注册一个体验版先让团队内部投一轮通常能发现 3 个以上只在真机复现的问题。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询