微信小程序投票系统基于SSM后端防重复投票实战解析

发布时间:2026/9/13 19:11:55
微信小程序投票系统基于SSM后端防重复投票实战解析 简介一份基于微信小程序与SSM后端框架的投票评选系统毕业设计源码适用于高校计算机、软件工程等相关专业学生完成毕业设计或课程作业。项目涵盖微信小程序前端与Java后端核心工程包含投票创建、评选管理、用户端操作等业务模块可帮助学习者完整理解前后端分离开发模式下小程序与Spring MVC等框架的集成方式。压缩包共883个文件大小约43.24MB主要包含Java源码、Vue页面、小程序wxml/wxss/js文件、SQL数据库脚本、项目配置文件以及部署运行脚本目录结构清晰便于直接导入开发工具后运行调试。目前已有51人学习下载资源内附部署说明与项目配置可作为参考案例快速启动项目并进行二次扩展尤其适合需要完整毕业设计流程参考的同学。1. 微信小程序投票评选系统SSM 后端要管的三个问题微信小程序投票评选系统表面是“一个列表加一个按钮”拆开有三个绕不过去的问题谁能投、投给谁、怎么保证同一人不能重复投。技术路线标题已经点明小程序做触达与交互SSM 做后端。分工具体到依赖上SpringMVC 接收请求Spring 管理业务对象和事务MyBatis 访问 MySQL。这套组合没有微服务复杂度却把前后端分离项目实战里最容易出问题的三处暴露出来接口返回不统一、并发投票重复、票数和明细对不上。下文按数据模型、接口契约、小程序链路、答辩自测的顺序铺开适合正在做毕业设计或者想快速搭建活动评选原型的开发者。2. 表设计与接口契约SSM 后端投票数据的最小闭环在写接口之前先把表结构定下来。一个能拿到台面上讲的投票系统最少需要四张表活动表定义评选场次候选人表定义投给谁投票人表承接 openid 与用户身份的对应投票记录表存每一次明细。这里有个容易被忽略的设计点投票记录表的唯一索引是防重复的兜底和后面 Service 里写的“先查再插”配合才能挡住并发重复请求。2.1 四张表的结构与字段取舍建表 SQL 直接按可执行的标准写字段注释都带上。MyBatis 写 resultMap 或者生成实体时这些注释能省去大量“这个字段是干嘛的”的沟通成本。CREATE TABLE vote_activity ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 活动或评选名称, start_time DATETIME NOT NULL COMMENT 投票开始时间, end_time DATETIME NOT NULL COMMENT 投票结束时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未开始 1进行中 2已结束, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_time (start_time, end_time) ); CREATE TABLE candidate ( id INT PRIMARY KEY AUTO_INCREMENT, activity_id INT NOT NULL COMMENT 所属活动, name VARCHAR(50) NOT NULL COMMENT 候选人/作品名称, description VARCHAR(500) COMMENT 简介, photo_url VARCHAR(255) COMMENT 图片地址, vote_count INT NOT NULL DEFAULT 0 COMMENT 实时票数, sort_no INT DEFAULT 0 COMMENT 展示排序, KEY idx_activity (activity_id) ); CREATE TABLE voter ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL, nickname VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_openid (openid) ); CREATE TABLE vote_record ( id INT PRIMARY KEY AUTO_INCREMENT, activity_id INT NOT NULL, candidate_id INT NOT NULL, voter_id INT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_voter_activity (voter_id, activity_id), KEY idx_candidate (candidate_id) );这四张表已经形成闭环vote_activity 定场次candidate 定对象voter 定身份vote_record 把三者串起来。vote_activity 里保留 status 字段虽然时间字段理论上能推算出是否可投票但保留它能让 service 查询条件更直观也方便管理员手动关闭某场活动。candidate 表的 vote_count 是冗余计数投票结果页直接读这个字段不需要每次 count 明细表。vote_record 的 uk_voter_activity 是整个防重复设计的关键没有这个唯一索引应用层“先查后插”在并发下就是形同虚设数据库层面兜底后即使两个请求同时插入第二次也会被索引直接挡下。2.2 统一返回体 Result 与分页参数约定小程序端和后端联调时最容易出现“后端返回 data 是对象小程序却当数组用”的错位。SSM 项目里我习惯先定义一个 Result所有接口都返回它前后端只看 code 和 msg 两个字段就能对齐。public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.msg ok; r.data data; return r; } public static T ResultT error(Integer code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } public Integer getCode() { return code; } public String getMsg() { return msg; } public T getData() { return data; } }接口返回不再是散装 JSON而是固定三层结构code 表示业务状态msg 给用户可读的中文提示data 放具体数据。业务失败时不走 HTTP 500照样返回 200用 code400 区分小程序端处理逻辑就统一成“先看 code再看 data”。分页参数约定如下表前端按这个表格传参后端按同一套规则接收避免出现 page 和 pageNum 混用的问题参数位置必填说明activityIdquery是活动 ID来自上一个页面传参pageNumquery否页码默认 1pageSizequery否每页条数默认 10最大 50openidbody 或 header是用户标识开发阶段可传 mock 值2.3 Controller 写法和三层边界Controller 层最忌讳堆业务逻辑。判断活动时间、查重、事务提交都该在 Service 层完成Controller 只做三件事接参数、调 Service、把结果包成 Result 返回。RestController RequestMapping(/api/vote) public class VoteController { Autowired private CandidateService candidateService; Autowired private VoteService voteService; GetMapping(/candidates) public ResultPageResultCandidateVO list( RequestParam Integer activityId, RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { if (pageSize 50) { pageSize 50; } PageResultCandidateVO page candidateService.pageByActivity( activityId, pageNum, pageSize); return Result.success(page); } PostMapping(/doVote) public ResultString vote(RequestBody VoteDTO dto) { VoteResult result voteService.vote( dto.getActivityId(), dto.getCandidateId(), dto.getOpenid()); if (!result.isSuccess()) { return Result.error(400, result.getMessage()); } return Result.success(投票成功); } }这里有个细节PageHelper 的 Page 对象里带着 pages、pageSize 等一堆字段直接序列化返回给小程序端会把无用信息一起暴露出去而且字段命名不稳定。常见做法是再包一层 PageResult只保留 list 和 total 两个字段返回体更清爽。list 接口返回 PageResult 的目的就在这里。另外后端跨域问题在小程序原生请求里其实不存在小程序不是浏览器没有同源策略限制。需要关注的是微信公众平台里的 request 合法域名配置正式发布前要把后端域名加进白名单。本地联调时在开发者工具右上角“详情-本地设置”里勾选“不校验合法域名”请求才能正常发出去。3. 投票业务落地SpringMVC 事务边界与 MyBatis 防重复投票接口是典型的写多读少请求插入一条 vote_record同时把 candidate.vote_count 加一。这两步要么同时成功、要么同时失败事务边界就是 Spring 的 Transactional。这里把逻辑写在 ServiceImpl 里而不是 Controller是因为事务只有包在 Service 层才对业务真正生效。3.1 防重复投票先查后插 唯一索引兜底Service public class VoteServiceImpl implements VoteService { Autowired private VoteActivityMapper activityMapper; Autowired private VoterMapper voterMapper; Autowired private VoteRecordMapper voteRecordMapper; Autowired private CandidateMapper candidateMapper; Override Transactional(rollbackFor Exception.class) public VoteResult vote(Integer activityId, Integer candidateId, String openid) { // 1. 活动校验状态 时间范围双条件同时满足 VoteActivity activity activityMapper.selectById(activityId); if (activity null) { return VoteResult.fail(活动不存在); } Date now new Date(); boolean timeOk !now.before(activity.getStartTime()) !now.after(activity.getEndTime()); if (activity.getStatus() ! 1 || !timeOk) { return VoteResult.fail(当前不在投票时间范围内); } // 2. 投票人不存在则先注册openid 唯一键兜底 Voter voter voterMapper.selectByOpenid(openid); if (voter null) { voter new Voter(); voter.setOpenid(openid); try { voterMapper.insert(voter); } catch (DuplicateKeyException e) { voter voterMapper.selectByOpenid(openid); } } // 3. 该活动下是否已投票 Integer exists voteRecordMapper.countByVoterAndActivity( voter.getId(), activityId); if (exists ! null exists 0) { return VoteResult.fail(您已参与过本次评选); } // 4. 写明细 票数自增 VoteRecord record new VoteRecord(); record.setActivityId(activityId); record.setCandidateId(candidateId); record.setVoterId(voter.getId()); try { voteRecordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BusinessException(您已参与过本次评选); } candidateMapper.increaseVoteCount(candidateId); return VoteResult.success(投票成功); } }流程很直白活动校验通过后先查 voter 是否存在不存在就先注册再投票。voter 表用 openid 做唯一键同一个微信号只对应一行vote_record 就可以用 voter_id 关联不用在每个记录里存一长串 openid。但这里藏着一个并发窗口两个请求同时进来都走到第 3 步 exists 判断时数据库里还没有对应的 vote_record两个请求都可能通过检查然后各自 INSERT。这就是唯一索引的价值第二个 INSERT 会触发 DuplicateKeyException。第 4 步捕获这个异常后抛业务异常事务回滚明细和票数都不会落库。像“老用户第 1 次投票还没结束第 2 次请求就已经打到后端”的情况就是靠这一层挡住的。注意第 2 步也做了同样的兜底处理。两个不同 openid 首次请求同时到达时voter 表可能同时插入两条不会冲突同一个 openid 并发注册时uk_openid 会拦住第二次此时再查一次 voter 就能拿到已存在的记录不影响后续投票流程。3.2 活动状态机status 与时间段的双条件校验状态字段不是摆设。只判断 status 等于 1管理员手动改了活动时间就可能出现漏判只判断时间段管理员手动关闭活动就会失效。两个条件同时检查逻辑上才闭合。private boolean isVotable(VoteActivity activity, Date now) { boolean timeOk !now.before(activity.getStartTime()) !now.after(activity.getEndTime()); return activity.getStatus() 1 timeOk; }项目里没有为这个状态机单独建定时任务去更新 status因为投票请求发生时惰性判断即可时间过了后端直接拒绝活动自动进入结束状态。如果还想在结果页展示“活动已结束”的提示可以在列表查询时把 start_time 和 end_time 一并返回前端根据当前时间自行显示不同文案。答辩时如果被问到“过期活动怎么办”直接回答“后端双重校验、前端按时间文案切换”就够了。3.3 票数统计UPDATE 自增与 count 明细表的取舍在 3.1 的代码里票数统计用的是 candidateMapper 一条 UPDATE 语句update idincreaseVoteCount UPDATE candidate SET vote_count vote_count 1 WHERE id #{candidateId} /update这里不写成SET vote_count #{voteCount}因为先查出原票数再更新在并发下会互相覆盖出现丢更新。vote_count vote_count 1在 MySQL 中是原子操作每次投票只加一天然避免覆盖问题。两种方案的取舍可以按下表对比统计方式优点缺点推荐场景update 票数字段自增列表页读取快一条 SQL 完成高并发下 candidate 行会有热点竞争投票进行中的实时结果页count vote_record 明细表计数永远准确不依赖冗余字段候选人和记录多时性能开销大最终对账、导出报表如果事务回滚insert 和 update 会同时撤销所以“明细多了但票数没加”的情况不会出现。唯一索引挡住了重复投票更新语句自然只执行一次。这套组合能回答后端开发面试里最常见的“投票系统怎么防重复、票数怎么保持一致”两个问题也覆盖了答辩时必须讲的“冗余字段一致性”这个点。4. 微信小程序端对接openid、候选列表与投票请求链路小程序端要做的事情有三件登录换取用户标识、加载候选列表、提交投票并处理结果。这一章把这三段链路的代码和参数都说清楚uniapp 写的项目可以平移到uni.request参数结构基本一致。4.1 wx.login 换 openid开发阶段先走 mock正式环境里小程序端先调用wx.login拿到临时 code再把 code 发给后端后端拿 code 去微信接口换 openid。但开发阶段每次真机调试都要走一遍完整流程很费时间我一般会在后端加一个 mock 开关。PostMapping(/login) public ResultLoginVO login(RequestBody LoginDTO dto) { if (loginConfig.isUseMockOpenid()) { return Result.success(new LoginVO(loginConfig.getMockOpenid())); } String url https://api.weixin.qq.com/sns/jscode2session?appid appConfig.getAppId() secret appConfig.getAppSecret() js_code dto.getCode() grant_typeauthorization_code; String json restTemplate.getForObject(url, String.class); JSONObject obj JSON.parseObject(json); return Result.success(new LoginVO(obj.getString(openid))); }这里的参数说明dto.getCode() 是 wx.login 返回的临时凭证有效期约五分钟appid 和 secret 都要放在后端配置里绝对不要写进小程序源码否则会被反编译带走。mock 模式下后端直接返回固定的测试 openid方便本地联调时绕过登录流程切换到真机调试前把开关关掉即可。小程序端登录代码wx.login({ success: (res) { wx.request({ url: ${apiBase}/api/user/login, method: POST, data: { code: res.code }, success: (resp) { if (resp.data.code 200) { app.globalData.openid resp.data.data.openid; this.loadCandidates(1); } else { wx.showToast({ title: 登录失败, icon: none }); } } }); } });登录流程里有一个常见错误把 code 当成 openid 直接用。code 每次登录都会变只有 openid 才是用户唯一标识。题目里的后端接口约定 openid 放在请求体里传是因为这个项目没有做 token 机制属于毕业设计里可接受的简化方案。如果后续要加安全性就把 openid 换成 token登录成功后下发小程序每次请求带在 header 里。4.2 候选列表加载WXML 渲染、setData 与分页刷新候选人列表是首页主内容渲染逻辑集中在 WXML 的 wx:for 和 JS 的 setData。列表数据来自第 2 章定义的/api/vote/candidates接口。view classcandidate-list view classcandidate-card wx:for{{candidates}} wx:keyid image src{{item.photoUrl}} modeaspectFill/image view classinfo text classname{{item.name}}/text text classdesc{{item.description}}/text text classcount{{item.voteCount}} 票/text /view button sizemini >data: { activityId: null, candidates: [], total: 0, pageNum: 1, pageSize: 20, submitting: false }, onLoad(options) { this.setData({ activityId: Number(options.activityId) }); this.loadCandidates(1); }, loadCandidates(pageNum) { wx.request({ url: ${apiBase}/api/vote/candidates, method: GET, data: { activityId: this.data.activityId, pageNum, pageSize: this.data.pageSize }, success: (res) { if (res.data.code 200) { const data res.data.data; this.setData({ candidates: pageNum 1 ? data.list : this.data.candidates.concat(data.list), total: data.total, pageNum }); } } }); }这里有三个需要注意的参数细节。第一options.activityId 从 URL 参数进入时是字符串后端用 RequestParam Integer 接收类型对不上会直接 400所以前端用 Number() 转了再传给后端。第二分页 param 里 pageNum1 表示刷新不等于 1 表示往后拼接。第三wx.request的 data 是对象后端 RequestBody 自动完成 JSON 反序列化字段名大小写必须严格一致例如 photoUrl 不能写成 photourl。进入小程序的加载页如果一直白屏多半是登录接口和列表接口串行等待导致。先调 doLogin 再调 candidates前者网络波动时会拖慢整个首屏。后续优化可以把两个请求并行发或者登录态做成拦截器加缓存但答辩阶段保持串行也能讲清楚。4.3 投票按钮防连点与已投状态回显投票按钮最怕用户连点两次产生两个请求。前端用 submitting 标志位挡住后端用唯一索引挡住两端各防一层。handleVote 方法如下handleVote(e) { const candidateId e.currentTarget.dataset.id; if (this.data.submitting || this.data.voted) { return; } this.setData({ submitting: true }); wx.request({ url: ${apiBase}/api/vote/doVote, method: POST, data: { activityId: this.data.activityId, candidateId, openid: app.globalData.openid }, success: (res) { const { code, msg } res.data; if (code 200) { wx.showToast({ title: 投票成功 }); this.setData({ voted: true }); this.loadCandidates(1); } else { wx.showToast({ title: msg, icon: none }); } }, complete: () { this.setData({ submitting: false }); } }); }submitting 是全局的防连点开关投票请求发出后所有按钮都会禁用收到响应恢复。voted 是本用户已经投过票的标记后端在候选人列表接口里返回了 voted 字段前端据此渲染成“已投票”按钮disabled 同时生效。后端返回的业务错误信息比如“您已参与过本次评选”通过 msg 字段带回前端 showToast 展示即可。按钮在 submitting 和 voted 两个条件下均不可点既挡住连点也挡住重复点击同一个按钮。这里有个边界如果规则改成“每人每天可以投三票”前端要把 voted 数字换成投票次数记录后端要改成同一活动内最多投票数量校验唯一索引的作用就变成防超限额而不是防重复。接口调用关系汇总如下联调时直接对着这张表检查接口方法请求参数返回结果/api/user/loginPOSTcodeopenid/api/vote/candidatesGETactivityId, pageNum, pageSizepage 数据/api/vote/doVotePOSTactivityId, candidateId, openidcode/msg5. 答辩前自测并发投票、数据回滚和性能观测点投票类项目的答辩老师基本都会问“你怎么验证不会重复投票”。与其口头解释不如把验证过程写进测试笔记。这里给三个能直接执行的验证方式。5.1 并发脚本验证唯一索引在确保后端启动、活动状态为进行中的前提下用 bash 循环并发发 30 个投票请求for i in $(seq 1 30); do curl -s -X POST http://127.0.0.1:8080/api/vote/doVote \ -H Content-Type: application/json \ -d {\activityId\:1,\candidateId\:3,\openid\:\dev_$i\} done wait等请求全部返回后查两张表SELECT vote_count FROM candidate WHERE id 3; SELECT COUNT(*) FROM vote_record WHERE activity_id 1 AND candidate_id 3;正常结果应该是两边的值相等都是 30。如果 vote_count 小于记录数说明存在重复投票被插入的问题如果 record 里出现同一投票人两条记录唯一索引没有生效。这里 openid 参数用dev_1到dev_30模拟 30 个不同用户验证的是“不同用户并发投票不丢票数”再把 openid 全部改成同一个验证“同一用户并发投票只成功一次”脚本逻辑完全复用。5.2 事务回滚验证在 VoteServiceImpl 第 4 步的 insert 和 update 之间临时插入一行测试异常candidateMapper.increaseVoteCount(candidateId); // 测试用验证后删除 if (true) { throw new RuntimeException(test rollback); }然后发一次投票请求观察两张表的数据变化。vote_record 表没有新记录candidate 的 vote_count 也没有加一说明事务回滚生效。如果表里出现“明细插入成功但票数未更新”的情况检查 Transactional 是不是写在了 Controller 层或者 rollbackFor 没有覆盖 RuntimeException。5.3 开发者工具里的网络观测点在小程序开发者工具里打开 Network 面板重点看两个指标doVote 请求的状态码和耗时以及滚动加载时 candidates 接口的响应体大小。投票按钮点击后 Network 面板立即出现一个 pending 请求超过两秒没返回优先排查后端 service 里事务是否长时间占用连接或者数据库连接池耗尽。正式发布前还要记得在微信公众平台把后端域名加入 request 合法域名。本地联调时可以在开发者工具详情页勾选“不校验合法域名”但答辩演示用的开发者工具如果没勾选请求会被拦在 send 阶段错误信息会显示为“url not in domain list”。把这一段记进部署文档比现场翻设置页节省时间。本文还有配套的精品资源点击获取