简介:《基于微信小程序投票评选系统的设计与实现》是一套面向高校毕设、课程设计与企业小型投票活动的完整Java全栈项目,采用SpringBoot/SSM作为后端、微信小程序作为前端,涵盖从数据库设计到前端交互的核心业务链路。资源包共1170个文件,压缩后约16.89MB,包括Java源码、Vue管理端页面、小程序wxml/wxss页面、SQL脚本及项目介绍文档等,其中png、svg等图片素材占据较多比例。已有2762人学习下载,适合需要快速搭建投票评选系统或复现毕设功能的开发者。项目附带数据库建表脚本与配置示例,可直接导入运行;代码结构区分小程序端、服务端与前端管理后台,有助于理解用户注册、投票处理、结果统计等模块的实现思路,并可根据实际场景进行二次扩展。
1. 为什么“投票评选系统”比“投票系统”难做:先定 SSM 的边界
一个候选人、一个人、一票,能力范围内的东西其实不多。但“评选”两个字一出来,事情就变了:它意味着后台要管活动期、管评选维度、管评委名单,前台要处理重复点击、换票、截止时间、结果公示。微信小程序上做这类系统,最大的错觉是“投票就是点按钮”,真正的问题全在身份与状态——谁投的、投给谁、能不能改、能不能重复投。
以 SSM(Spring + 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里最关键的约束是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-group+label将整行变成可点区域,展示照片、简介和当前票数。
<view class="candidate-list"> <radio-group bindchange="onVoteChange"> <label class="candidate-item" wx:for="{{candidateList}}" wx:key="id"> <image src="{{item.photoUrl}}" class="candidate-avatar" mode="aspectFill" /> <view class="candidate-info"> <text class="name">{{item.candidateName}}</text> <text class="intro">{{item.intro}}</text> <text class="votes">当前票数:{{item.voteCount}}</text> </view> <radio value="{{item.id}}" checked="{{item.checked}}" color="#2E7D32" /> </label> </radio-group> <button type="primary" bindtap="submitVote" disabled="{{!selectedId}}">确认投票</button> </view>Page({ 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_type=authorization_code"; Map<String, 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会自动 fail,success 回调不会执行,所以要在 fail 里统一处理“跳回登录页”的逻辑。
4. 投票接口的防重、幂等与并发:SSM 控制层到数据库的正确姿势
4.1 Controller 层参数校验,别只依赖前端
到了投票接口,前后端的信任边界必须收紧。小程序端可以做单选框选中、按钮禁用,但这些都只是用户体验层面的约束,服务端必须假设所有请求都可能是伪造的:candidateId越界、活动已截止、同一 openid 重复提交。
@RestController @RequestMapping("/vote") public class VoteController { @Autowired private VoteService voteService; @PostMapping public Result<Void> 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 { List<Map<String, 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; filename=vote_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 个以上只在真机复现的问题。
本文还有配套的精品资源,点击获取