简介:一份基于微信小程序与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 Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "ok"; r.data = data; return r; } public static <T> Result<T> error(Integer code, String msg) { Result<T> 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,用 code=400 区分,小程序端处理逻辑就统一成“先看 code,再看 data”。
分页参数约定如下表,前端按这个表格传参,后端按同一套规则接收,避免出现 page 和 pageNum 混用的问题:
| 参数 | 位置 | 必填 | 说明 |
|---|---|---|---|
| activityId | query | 是 | 活动 ID,来自上一个页面传参 |
| pageNum | query | 否 | 页码,默认 1 |
| pageSize | query | 否 | 每页条数,默认 10,最大 50 |
| openid | body 或 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 Result<PageResult<CandidateVO>> list( @RequestParam Integer activityId, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { if (pageSize > 50) { pageSize = 50; } PageResult<CandidateVO> page = candidateService.pageByActivity( activityId, pageNum, pageSize); return Result.success(page); } @PostMapping("/doVote") public Result<String> 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 id="increaseVoteCount"> 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 Result<LoginVO> 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_type=authorization_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 class="candidate-list"> <view class="candidate-card" wx:for="{{candidates}}" wx:key="id"> <image src="{{item.photoUrl}}" mode="aspectFill"></image> <view class="info"> <text class="name">{{item.name}}</text> <text class="desc">{{item.description}}</text> <text class="count">{{item.voteCount}} 票</text> </view> <button size="mini" >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 里 pageNum=1 表示刷新,不等于 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/login | POST | code | openid |
| /api/vote/candidates | GET | activityId, pageNum, pageSize | page 数据 |
| /api/vote/doVote | POST | activityId, candidateId, openid | code/msg |
5. 答辩前自测:并发投票、数据回滚和性能观测点
投票类项目的答辩老师基本都会问“你怎么验证不会重复投票”。与其口头解释,不如把验证过程写进测试笔记。这里给三个能直接执行的验证方式。
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”。把这一段记进部署文档,比现场翻设置页节省时间。
本文还有配套的精品资源,点击获取