ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

微信小程序+SSM+MySQL投票评选系统:从数据库设计到防重复投票落地

微信小程序+SSM+MySQL投票评选系统:从数据库设计到防重复投票落地

简介:这套源码包基于微信小程序、SSM后端与MySQL,源自个人大四毕业设计项目,面向计算机相关专业正在准备毕设的学生以及需要项目实战练习的初学者,是投票评选系统的完整毕业设计。项目评审99分,代码完整且能直接运行,并附有演示视频,可帮助理解从微信小程序端到后端管理端的完整业务流程。压缩包内含881个文件,总大小约36.45MB,类型覆盖小程序前端wxml/wxss/js、Vue管理端vue/js、Java后端java/xml/properties以及SQL数据库脚本等,另含mp4演示视频和png/svg界面素材,目录结构清晰,便于按模块检索与学习。同时提供部署用bat脚本和配置文件,能降低环境搭建难度,并涵盖从选题、设计到答辩的常见参考要点,也适合课程设计或期末大作业。资源内按前端、后端、数据库等目录分层,可快速定位关键代码。目前已有178人学习浏览,对希望快速完成一个可展示作品的同学来说,是一份具备参考价值的实战资料。

1. 基于微信小程序+SSM后端+MySQL的投票评选系统:别小看这个选题,它是少数能让你在答辩台上从容讲满十五分钟的题目

基于微信小程序+SSM后端+MySQL的投票评选系统,是我这几年看过最适合做毕业设计的组合之一:业务线清晰,从头到尾就是“发起活动、候选人入库、用户投票、票数统计”四条线;技术栈覆盖得全,有SSM框架的IoC注入、事务管理、MyBatis动态SQL,也有小程序的登录态维护、页面交互和本地缓存;数据模型还不复杂,每一张表都能在答辩现场讲明白。很多同学担心这题目“太普通”,但普通不等于低分——真正拿低分的是做了个自己都说不清的业务。这篇笔记按我从零把一个投票评选系统做到能演示、能上交的顺序来写:先讲为什么选这套栈,再给数据库设计和可复用的接口代码,最后把最容易翻车的坑和让老师眼前一亮的加分点列出来。适合正在选毕设题目的在校学生,也适合想快速搭一套微信端投票Demo的前端或后端开发者。

2. 技术栈选型:为什么“微信小程序+SSM+MySQL”是高分毕设的稳妥答案

2.1 前端为什么选微信小程序:比H5网页端多出来的三道保障

投票评选这个业务,用户操作非常简单:看候选人、点投票、看结果。这样的轻交互场景,微信小程序比H5网页端合适得多。原因有三条:第一,小程序不需要用户安装,微信扫码即用,答辩现场用真机演示比让老师掏手机开浏览器体验好得多;第二,微信自带一套稳定的登录链路(wx.login + code2session),你不需要自己写短信验证码或者账号密码体系,登录态这部分的工作量能省掉一半;第三,小程序的开发工具自带模拟器和真机预览,调试成本比移动端H5低,页面样式也不会因为浏览器内核不一致而翻车。

我一般不建议在毕设阶段把同一套系统同时做成小程序和H5两端。投票评选系统的核心是后端的数据一致性,而不是前端的交互炫技。把小程序端做扎实,比两头都要抓更符合毕设的评审逻辑——你想,答辩老师问的永远是“这个功能怎么做出来的”,而不是“你做了几个端”。

2.2 后端为什么用SSM而不是Spring Boot:为了答辩时“能被问住、也能答上来”

这是一个很现实的考量。很多学校的大纲和教材至今仍以SSM(Spring + SpringMVC + MyBatis)为主线,Spring Boot是作为扩展内容提的。用SSM做毕设,你能把三层架构、IoC容器、SpringMVC请求链路、MyBatis的Mapper代理机制一个个拆开讲;换成Spring Boot,很多配置被自动装配藏起来了,老师问“Spring Boot启动时发生了什么”,你只能说“它自动配好了”,这一句话就把印象分丢了。

SSM的主要成本在配置繁琐——web.xml、spring-mvc.xml、mybatis-config.xml各管一摊。这个成本在毕设场景里恰好是加分项:你能对着配置文件讲清请求进来之后经过了哪些环节。后面第4章的代码会按SSM的标准分包来写,你直接照着分层就能跑通。

2.3 MySQL够用,加Redis反而容易给自己挖坑

投票系统的数据量在毕设场景下很小:一个学校几千人投票已经算大规模了。MySQL加一张带唯一索引的投票记录表,足以保证“一人一票”这个核心约束。Redis不是不能用,而是你一旦引入,就要面对缓存一致性、过期策略、内存淘汰这些追问,这些问题的复杂度远超毕设答辩所需。

这里给一个稳妥的演进话术:系统当前用MySQL唯一索引保证不重复投票,如果将来日活过万,会在投票写入前加一层Redis防抖,把同一活动的投票请求先打到Redis再异步落库。这个回答既说明了当前设计的合理性,又展示了你考虑过扩展路径,比实际硬上Redis效果更好。反过来说,如果你在毕设里硬上Redis又讲不清数据结构设计,评委追问两轮就露馅了。

3. 数据库设计先行:六张表如何支撑投票规则与防重复投票

3.1 核心表结构:用户、活动、候选人、投票记录各司其职

投票评选系统的数据库说复杂也复杂,说简单也简单。复杂在于“投票规则”可以有很多种:每人每活动投一票、每人每天投一票、每人可选择多个候选人。简单在于你只需要把规则抽象成字段,而不是为每种规则单独建表。我常用的设计是四张业务表加两张辅助表:

-- 用户表:与微信openid绑定,role区分普通用户和管理员 CREATE TABLE `user` ( `user_id` BIGINT PRIMARY KEY AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL UNIQUE, `nickname` VARCHAR(64) DEFAULT '', `avatar_url` VARCHAR(255) DEFAULT '', `role` TINYINT NOT NULL DEFAULT 0, -- 0普通用户 1管理员 `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 评选活动表:status字段控制活动生命周期 CREATE TABLE `activity` ( `activity_id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL, `description` TEXT, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, -- 0未开始 1进行中 2已结束 `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 候选人/作品表:votes字段做冗余计数,排序用 CREATE TABLE `candidate` ( `candidate_id` BIGINT PRIMARY KEY AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `name` VARCHAR(64) NOT NULL, `description` VARCHAR(500) DEFAULT '', `image_url` VARCHAR(255) DEFAULT '', `votes` INT NOT NULL DEFAULT 0, `sort_order` INT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_activity` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 投票记录表:唯一索引是防重复投票的底线 CREATE TABLE `vote_record` ( `vote_id` BIGINT PRIMARY KEY AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `candidate_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_activity_user_candidate` (`activity_id`, `user_id`, `candidate_id`), KEY `idx_activity_candidate` (`activity_id`, `candidate_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

四张业务表里,user表存微信用户和角色,activity表管活动生命周期,candidate表管候选人及其票数,vote_record表管每一笔投票流水。两张辅助表分别是管理员操作日志表和用户Token会话表,前者方便答辩时演示“后台审计”能力,后者用于登录态校验。

注意candidate.votes是一个冗余计数。真实的数据源头是vote_record表的行数,votes字段只是为了方便列表排序和首页展示。这两个值在极端情况下可能不一致,比如事务回滚了一半——这一点在第6章防刷票部分会展开讲。

3.2 防重复投票的三种方案:先查再插是错的,唯一索引才是正解

防重复投票是投票系统最容易被追问的点,也是团队里新人最爱写错的地方。很多人的第一反应是在代码里“先查一下这个人投过没有,没投过就插入”。这个方案的问题在于并发:同一时刻两个请求都查到“没投过”,然后都执行插入,结果就是同一个人投了两票。

正确做法是让数据库的唯一索引兜底。你在vote_record表上建了uk_activity_user_candidate联合唯一索引,意味着同一个用户对同一个活动下的同一个候选人只能有一条投票记录。当重复插入发生时,MySQL会抛DuplicateKeyException,Service层捕获这个异常转成业务提示“您已经投过票了”即可。

第三种方案是加分布式锁或者Redis预扣,这个在并发量极大的场景才需要。毕设阶段你只需要在答辩PPT里写一句话:“基于数据库唯一约束实现幂等投票,并通过捕获唯一索引冲突返回友好提示”,比你写“用了Redis所以不会重复”靠谱得多。

在实际表设计时,唯一索引的字段组合取决于业务规则。如果你要的是“每人每活动只能投一次,只能选一个候选人”,那唯一索引应该是(activity_id, user_id);如果允许一个用户在一个活动里投多个候选人,那就保留(activity_id, user_id, candidate_id)。我建议做后者,因为“一次活动选三个候选人”是投票评选里最常见的需求,前者改起来要动表结构,后者天然兼容两种场景——把candidate_id加进唯一索引并不妨碍“只能投一个”的规则,你只需要在投票接口里限制“一个用户在一个活动中的投票记录数不超过1”即可。规则校验放在业务层,数据唯一性交给数据库,各管一摊不越界。

4. 后端与小程序端实现:从登录到投票到统计的完整链路

4.1 后端登录接口:code2Session换openid,新用户自动注册

微信小程序的登录机制是:小程序端调用wx.login()拿到临时凭证 code,把 code 传给后端,后端拿 code 加上小程序的appid和secret去微信接口服务换openid和session_key。code 的有效期是 5 分钟,且只能用一次。拿到openid后,以它作为用户表的唯一标识,查不到就插入新用户。

@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { // dto.getCode() 来自小程序端 wx.login 的返回 String openid = userService.code2Session(dto.getCode()); User user = userService.findOrCreateByOpenid(openid); String token = userService.createToken(user.getUserId()); return Result.success(token, user); } }
@Override public String code2Session(String code) { // 向微信接口服务发起请求,注意这里必须走服务端,不能把 secret 放小程序 String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + appId + "&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code"; String result = httpClient.get(url); JSONObject json = JSON.parseObject(result); String openid = json.getString("openid"); if (openid == null || openid.isEmpty()) { // 常见错误码:40029 code 无效或已使用,45011 频率限制 throw new BizException("登录失败:" + json.getString("errmsg")); } return openid; }

createToken我一般会用 UUID 生成一段随机字符串,写入一张user_token表,同时把user_id和token返回给小程序端。之后小程序端每次请求后端,在请求头里带上token,由后端拦截器查表确认登录态。为什么不用 JWT?因为毕设评审老师更容易接受“查表验证”这种直观逻辑,JWT 的无状态设计在答辩时反而要多解释一套签名和过期策略。

注意code2Session里返回的session_key不要下发到小程序端。session_key是微信用于解密用户手机号和敏感数据的密钥,毕设场景用不到,而且把它放到前端本身就是安全隐患。很多古董教程让前端把session_key存起来,这是错误的。

4.2 投票接口:事务、唯一索引、异常兜底组成的三段式

投票接口是整个系统的核心,代码必须一次性写对。执行顺序是:检查活动状态、插入投票记录、累加候选人票数。这个顺序不能颠倒,因为“先加分再记流水”会在事务回滚时出现票数和流水对不上的情况。

@Transactional(rollbackFor = Exception.class) public VoteResult vote(Long activityId, Long candidateId, Long userId) { // 第一步:校验活动状态,只在进行中允许投票 Activity activity = activityMapper.selectByPrimaryKey(activityId); if (activity == null || activity.getStatus() != 1) { throw new BizException("活动不存在或已结束"); } if (LocalDateTime.now().isBefore(activity.getStartTime()) || LocalDateTime.now().isAfter(activity.getEndTime())) { throw new BizException("不在投票时间内"); } // 第二步:插入投票记录,撞唯一索引说明重复投票 VoteRecord record = new VoteRecord(); record.setActivityId(activityId); record.setCandidateId(candidateId); record.setUserId(userId); try { voteRecordMapper.insert(record); } catch (DuplicateKeyException e) { // 不要在这里吞异常,转成业务提示返回给前端 throw new BizException("您已经投过票了"); } // 第三步:候选人票数 +1,注意用 SQL 自增而不是先查再赋值 candidateMapper.increaseVotes(candidateId); return VoteResult.success(); }

对应CandidateMapper.xml里的更新语句:

<update id="increaseVotes" parameterType="long"> UPDATE candidate SET votes = votes + 1 WHERE candidate_id = #{candidateId} </update>

两个细节值得说明。第一,votes = votes + 1是数据库原子操作,并发下不会丢更新;如果写成votes = #{votes},你需要先查出来再写回去,并发时两次更新互相覆盖,票数必然变少。第二,@Transactional(rollbackFor = Exception.class)必须写,否则 Spring 默认只在RuntimeException下回滚,而很多自定义异常不是运行时异常,会导致投票记录插入成功后票数没加上却没有任何报错。

4.3 小程序端投票页面:卡片列表、弹窗确认、结果回显

小程序端投票页的交互逻辑很简单:进入页面请求后端拿到候选人列表,用户点“投票”按钮弹窗确认,确认后调用后端接口,成功后刷新票数。代码结构如下:

// pages/vote/vote.js Page({ data: { activity: null, candidates: [], votedCandidateId: null }, onLoad(options) { this.loadActivity(options.activityId); }, async loadActivity(activityId) { const res = await this.request({ url: '/api/activity/detail', data: { activityId } }); this.setData({ activity: res.data.activity, candidates: res.data.candidates }); }, onVoteTap(e) { const candidateId = e.currentTarget.dataset.id; const candidate = this.data.candidates.find(c => c.candidateId === candidateId); wx.showModal({ title: '确认投票', content: `确定把票投给「${candidate.name}」吗?提交后不可修改。`, success: async (res) => { if (res.confirm) { await this.doVote(candidateId); } } }); }, async doVote(candidateId) { try { const res = await this.request({ url: '/api/vote/submit', method: 'POST', data: { activityId: this.data.activity.activityId, candidateId } }); // 成功后本地标记已投,刷新列表 await this.loadActivity(this.data.activity.activityId); wx.showToast({ title: '投票成功', icon: 'success' }); } catch (err) { wx.showToast({ title: err.message || '投票失败', icon: 'none' }); } } });

这里的request方法建议封装在独立的utils/request.js里,统一拼接baseUrl,统一从wx.getStorageSync('token')取登录态放进请求头,统一在响应码非 200 时reject。这样每个页面的业务代码不需要关心 token 过期和错误弹窗,维护起来省事得多。真机预览时记得把开发者工具里的“不校验合法域名”关掉再测一遍,否则线上环境请求会被微信拦下来。

5. 常见问题与避坑:环境配置、并发投票和微信登录的五个坎

5.1 MySQL 8.0 连不上:时区报错比密码报错更常见

新拉下来的项目在本地启动,第一次连数据库就抛The server time zone value 'UTC' is unrecognized。这不是密码错了,是 MySQL 8.0 默认时区是 UTC,而 JDBC 驱动没有拿到时区配置。解决方式是在 JDBC URL 上显式指定时区:

jdbc.url=jdbc:mysql://localhost:3306/vote_system?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8

useSSL=false是另一个经典坑,MySQL 8.0 默认开启 SSL 校验,本地开发环境没配证书就会报错。这三个参数建议写死成上面这段,不要偷懒只写前两个。另外<property name="driverClass" value="com.mysql.cj.jdbc.Driver"/>要确认是cj版本,老驱动类名com.mysql.jdbc.Driver在 8.0 下已经不建议使用了。

5.2 LocalDateTime 返回给小程序变成数组:JSON 序列化的坑

后端实体类用了LocalDateTime,接口返回 JSON 后小程序端拿到的不是"2025-05-01 12:00:00",而是一串数组[2025,5,1,12,0,0]。原因是 Jackson 默认不支持 JSR310 时间类型,需要额外配置。在 SpringMVC 的配置文件里加一个消息转换器:

<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="objectMapper" ref="jacksonObjectMapper"/> </bean> </mvc:message-converters> </mvc:annotation-driven>

jacksonObjectMapper的 Bean 里要注册JavaTimeModule并指定格式。你只需要记住:用了LocalDateTime就必须处理序列化,要么全局配置 Jackson,要么让时间字段直接返回 String。图省事的话,我倾向于在 DTO 里直接定义String startTime,Service 层格式化好再返回,这样最不容易出幺蛾子,答辩讲起来也直白。

5.3 并发投票下“一人多票”:先查再插的设计是病根

某同学在一台双核机器上用十个线程同时给同一个候选人投票,结果vote_record表里出现了两条同一用户的记录。查日志发现投票接口里写的是“先select count(*)再决定是否insert”。这不是 MySQL 的问题,是逻辑在并发下不成立。改法在第3章已经给了:去掉select,直接insert,撞了唯一索引就返回“已投过票”。这一步是投票系统的地基,务必在开发第一天就按这个写法定下来,别等并发测试翻车了再改。

5.4 wx.login 的 code 只能用一次:缓存旧 code 导致登录失败 Not

小程序端偶发登录失败,后端日志显示errcode: 40029。原因是前端某处把wx.login返回的 code 存到了globalData,多个接口共用同一个 code 去换 openid。微信的 code 是一次性的,第二次拿着同一个 code 去请求,必然报 invalid code。解决:每次需要登录时都调用一次wx.login()拿新 code,绝不复用。页面进入时的静默登录用wx.checkSession判断session_key是否过期,过期再重新wx.login,这个流程写成工具函数后,一劳永逸。

5.5 真机预览请求不通:开发工具能跑,手机一片红

开发工具里所有接口正常,一上真机预览所有请求都失败。先看控制台报错是不是url not in domain list。微信规定正式环境下小程序的 request 请求只能发往已经配置到后台的合法域名,而且必须是 HTTPS。调试阶段的变通办法是开发工具右上角“详情 - 本地设置 - 勾选不校验合法域名”。真机预览时也可以在预览二维码弹出页里勾选“开启真机调试”并开启不校验选项。要真发布到线上,就得准备一个完成 ICP 备案的域名并配上 HTTPS 证书。这个点答辩时经常被老师问到“你这个系统能直接部署上线吗”,回答里要能说出这条链路,而不是含糊带过。

6. 让系统从“能用”到“高分”:三个答辩加分点与演示视频录制技巧

很多投票系统功能都能跑通,但答辩分数差距体现在细节上。我建议在交差前做三件加事:防刷票说明、统计可视化、演示视频里录出操作层次。

第一件是给活动表加一个rule_type字段,支持“每人限投一票”和“每人每天可投一票”两种规则。前者的判断逻辑是查vote_record是否有该用户在该活动的记录;后者额外判断create_time是否是今天。把规则做成可配置,比写死在代码里更能体现设计能力。答辩时一句话带过:“投票规则是可以配置的,新增规则类型不需要改表结构。”这就是设计功底的证明。

第二件是统计可视化。后端加一个按小时聚合的投票趋势查询:

SELECT DATE_FORMAT(create_time, '%Y-%m-%d %H:00') AS hour, COUNT(*) AS vote_count FROM vote_record WHERE activity_id = #{activityId} GROUP BY hour ORDER BY hour;

小程序端用ec-canvas画一个简单的柱状图,这几乎是投票系统里最亮眼的视觉内容,因为大部分同学的毕设只有表格。没有图表也不扣分,但有了它就是加分项。

第三件是演示视频的录制技巧。开头十秒钟用一句话说明系统角色:“管理员发起评选活动,用户微信扫码进入小程序投票。”然后分三屏录:第一屏管理员创建活动并录入候选人,第二屏用户登录小程序浏览候选人并投票,第三屏查看票数统计和图表。录之前把数据库清空一遍,保证演示数据干净、候选人信息完整;录的时候开发工具控制台要关掉,别把调试日志录进去。视频时长控制在五到八分钟,后期配音把每一步在做什么说清楚就行。

我做这类题目最大的教训,是动手前没有把“每个活动每人能投几票”这条规则问清楚,数据库改了三遍,前端页面也跟着返工。所以你现在写代码之前,把第一条业务规则写死在纸上,后面所有表设计、接口参数、页面交互都围绕它来。投票系统不是难系统,它是一个需要把细节想透的系统。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表