☰
微信小程序+SSM投票系统高分毕设实战指南
2026/10/6 8:16:46 网站建设 项目流程

简介:这是一套面向计算机专业本科生的高分毕业设计实战资源,聚焦微信小程序端投票评选系统开发,完整覆盖前端(小程序+Vue)、后端(SSM框架)与数据库(MySQL)三层架构,专为毕设选题、课程设计及Java全栈入门学习者打造。资源包共881个文件,含94个Vue组件、101个Java核心业务类、242张界面截图与SVG图标、85个WXML/WXSS页面样式文件,以及SQL建表脚本、Bat一键部署脚本和MP4操作演示视频,整体36.45MB,结构清晰、模块解耦明确。已有178人下载学习,所有代码经导师评审获99分高分,附带完整运行说明与常见问题排错提示,小白可依视频+源码快速部署调试,无需额外配置即可本地启动并完成全流程投票管理功能验证。

1. 这不是又一个“微信小程序+SSM+MySQL”套壳模板:它真能跑通投票评选全流程,且高分毕设背后藏着三个硬核落地细节

你搜“微信小程序 投票系统 毕设”,90%的资源点开是空文件夹、截图拼接的“演示视频”、或者后端连数据库连接池都配不上的“SSM骨架”。但这次标题里明确写了“源码+视频演示(高分毕设)”,说明它不是教学Demo——而是真实交付过、经得起答辩质询、能处理并发投票、防刷票、支持多轮次评选的生产级最小闭环。核心不在“用了什么技术栈”,而在于微信小程序如何与SSM后端安全通信、MySQL如何支撑实时计票与状态回滚、以及整个流程在毕业答辩场景下不翻车的工程取舍。适合两类人:一是正卡在毕设选题和功能落地之间、怕写完跑不通的同学;二是想用这套轻量架构快速搭内部评选工具的校内IT老师或学生会开发者。它不追求微服务或高并发,但把“用户扫码投票→后台审核→结果公示→导出Excel”这条链路上所有容易被答辩老师揪住的坑,都提前踩了一遍。


2. 微信小程序端:从授权登录到投票提交,三步走稳不掉链子

微信小程序不是网页,不能直接发跨域请求,也不能存明文token。这套源码没用wx.request直接怼后端,而是通过code换取session_key + 自定义登录态校验双保险机制,这是它能过答辩的关键前提。下面拆解最常出问题的三个环节。

2.1 小程序登录态设计:为什么不用wx.login直接传code给后端?

常见错误是前端调wx.login()拿到code,立刻用wx.request()发给后端,后端再用code去微信接口换openId。这存在两个致命风险:

  • code有效期仅5分钟,网络延迟或用户切后台可能导致code失效;
  • 后端若未做code防重放校验,攻击者可截获code反复提交。

本方案采用前端主动缓存+后端二次校验:

// utils/auth.js export const loginWithCode = () => { return new Promise((resolve, reject) => { wx.login({ success: (res) => { // 1. 前端立即缓存code(有效期内只用一次) const code = res.code; wx.setStorageSync('login_code', code); // 2. 同时获取用户基础信息(非敏感字段) wx.getUserProfile({ lang: 'zh_CN', success: (profileRes) => { resolve({ code, encryptedData: profileRes.encryptedData, iv: profileRes.iv }); }, fail: () => resolve({ code }) // 允许无授权继续,后续投票页再强提示 }); }, fail: reject }); }); };

提示:wx.getUserProfile替代已废弃的wx.getUserInfo,且必须在button上绑定open-type="getUserInfo"触发,否则iOS会拦截。源码中所有用户信息获取都绑在“开始投票”按钮上,避免冷启动时弹窗劝退。

2.2 投票提交接口:带签名防篡改,不是简单POST一个id数组

投票数据若只传{itemId: 1, userId: 'oxxx'},极易被Fiddler重放或修改。本方案在提交前生成时间戳+随机串+投票项MD5签名:

// pages/vote/vote.js const signVoteData = (voteData) => { const timestamp = Date.now(); const nonce = Math.random().toString(36).substr(2, 8); const rawStr = `${voteData.itemId}${timestamp}${nonce}${getApp().globalData.sessionKey}`; return { ...voteData, timestamp, nonce, sign: md5(rawStr) // 使用miniprogram-md5库 }; }; // 提交时 wx.request({ url: 'https://api.xxx.com/vote/submit', method: 'POST', data: signVoteData({ itemId: 123 }), header: { 'Authorization': getApp().globalData.token }, // token由后端下发,含userId+expireTime success: (res) => { /* 处理成功 */ } });

后端SSM层用@RequestBody接收,并在Controller层统一校验:

  • timestamp是否在5分钟有效期内;
  • sign是否匹配当前sessionKey(从Redis查,非数据库);
  • userId是否已在本轮投票中投过该选项(查MySQL唯一索引)。

2.3 页面加载优化:列表页滚动加载+防重复提交

投票列表页若一次性拉100条,小程序内存直接爆。源码用onReachBottom分页,但关键在防抖+锁状态:

data: { list: [], page: 1, loading: false, noMore: false, isSubmitting: false // 全局提交锁 }, onReachBottom() { if (this.data.loading || this.data.noMore) return; this.setData({ loading: true }); this.loadMore(); }, loadMore() { api.getVoteList({ page: this.data.page }).then(res => { const newList = [...this.data.list, ...res.data]; this.setData({ list: newList, page: this.data.page + 1, loading: false, noMore: res.data.length < 10 // 假设每页10条 }); }); }, // 投票按钮绑定 handleVote(e) { if (this.data.isSubmitting) return; // 防双击 this.setData({ isSubmitting: true }); api.submitVote(e.currentTarget.dataset.id).then(() => { wx.showToast({ title: '投票成功', icon: 'success' }); }).finally(() => { this.setData({ isSubmitting: false }); }); }

3. SSM后端:不是Spring+SpringMVC+MyBatis的简单堆砌,而是围绕投票业务做的三层收敛

很多毕设后端代码像“Java语法练习册”:Controller塞满逻辑、Service没事务、Mapper全手写SQL。这套源码把投票状态机、防刷策略、Excel导出三个核心能力,用SSM的标准扩展点做了干净隔离。

3.1 投票状态机:用枚举+状态流转表控制生命周期

投票不是“开始→结束”两态,而是:草稿 → 发布中 → 暂停 → 已结束 → 已归档。源码用VoteStatusEnum定义状态,并在VoteService中强制校验:

// VoteService.java @Transactional public void startVote(Long voteId) { Vote vote = voteMapper.selectById(voteId); if (!vote.getStatus().equals(VoteStatusEnum.DRAFT)) { throw new BusinessException("只有草稿状态的投票才能发布"); } // 更新状态 + 设置开始时间 vote.setStatus(VoteStatusEnum.PUBLISHED); vote.setStartTime(new Date()); voteMapper.updateById(vote); // 发布后自动创建投票项统计表(按需建表,非预设) voteItemStatMapper.createStatTable(voteId); }

注意:createStatTable是动态SQL,调用CREATE TABLE vote_stat_123 (...),避免单表膨胀。MySQL 5.7+支持,但需在mybatis-config.xml中开启<setting name="mapUnderscoreToCamelCase" value="true"/>,否则字段映射失败。

3.2 防刷票策略:IP+设备指纹+行为频次三重过滤

单纯限制“同一用户投一次”不够——学生会可能用脚本批量注册小号。源码在VoteController加了@PreAuthorize注解,但真正防刷在VoteFilter过滤器:

@Component public class VoteFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { HttpServletRequest req = (HttpServletRequest) request; String ip = getClientIp(req); String deviceId = req.getHeader("X-Device-ID"); // 小程序端通过wx.getSystemInfoSync().deviceId生成 String userAgent = req.getHeader("User-Agent"); // 1. IP频次:1小时内同一IP提交>5次,拒绝 String ipKey = "ip:" + ip; Long count = redisTemplate.opsForValue().increment(ipKey, 1L); if (count > 5) { throw new BusinessException("操作过于频繁,请稍后再试"); } redisTemplate.expire(ipKey, 1, TimeUnit.HOURS); // 2. 设备指纹:同一deviceId 24小时内只能投1次(存Redis哈希) String deviceKey = "device:" + deviceId; Boolean exists = redisTemplate.opsForHash().hasKey(deviceKey, "vote_" + voteId); if (Boolean.TRUE.equals(exists)) { throw new BusinessException("该设备已参与过本轮投票"); } redisTemplate.opsForHash().put(deviceKey, "vote_" + voteId, "1"); redisTemplate.expire(deviceKey, 1, TimeUnit.DAYS); chain.doFilter(request, response); } }

血泪经验:X-Device-ID不能直接用wx.getSystemInfoSync().deviceId,iOS返回空字符串!源码改用wx.getStorageSync('device_id') || md5(wx.getStorageSync('uuid') + SystemInfo.model)生成稳定ID,首次进入时存入本地缓存。

3.3 Excel导出:用EasyExcel而非POI,解决OOM和样式错乱

答辩老师最爱问:“结果能导出吗?”很多毕设用POI手写样式,导出1000行就内存溢出。本方案用easyexcel-spring-boot-starter,且导出逻辑不走Controller,而用异步任务+临时URL下载:

// ExportService.java @Async public void exportVoteResult(Long voteId, String operator) { String fileName = "vote_result_" + voteId + "_" + System.currentTimeMillis() + ".xlsx"; String filePath = "/tmp/export/" + fileName; // 查询数据(分页流式读取,避免全量加载) PageHelper.startPage(1, 10000); // 最大导出1万条 List<VoteExportDTO> data = voteMapper.selectExportData(voteId); EasyExcel.write(filePath, VoteExportDTO.class) .sheet("投票结果") .registerWriteHandler(new CustomStyleStrategy()) // 自定义表头样式 .doWrite(data); // 生成临时下载链接(30分钟过期) String downloadUrl = ossService.generateTempUrl(filePath, 30, TimeUnit.MINUTES); // 发送通知给operator notifyService.sendExportComplete(operator, downloadUrl); }

CustomStyleStrategy重写setHeadFont和setContentFont,确保中文不乱码、数字右对齐——这是答辩现场打开Excel不露怯的关键细节。


4. MySQL设计:不是ER图画得漂亮就行,而是为投票场景特化建模

很多毕设MySQL建表照搬教科书:user(id,name),vote(id,title),vote_item(id,vote_id,name)。这套源码的表结构藏着三个反常识设计:

4.1 投票主表:status字段用TINYINT而非VARCHAR,且预留扩展位

CREATE TABLE `vote` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '投票标题', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-草稿,1-发布中,2-暂停,3-已结束,4-已归档', `start_time` datetime DEFAULT NULL COMMENT '开始时间', `end_time` datetime DEFAULT NULL COMMENT '结束时间', `max_select` tinyint NOT NULL DEFAULT '1' COMMENT '最多选择几项', `is_anonymous` tinyint NOT NULL DEFAULT '0' COMMENT '是否匿名(0否1是)', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投票主表';

关键点:status用TINYINT而非ENUM或VARCHAR,因为:

  • ENUM在MySQL 8.0前不支持在线DDL修改;
  • VARCHAR索引效率低于整数;
  • 预留is_anonymous字段,答辩时可快速扩展“实名/匿名”切换功能,不用改表结构。

4.2 投票项统计表:按投票ID动态建表,而非单表硬扛

-- 动态生成的表名:vote_stat_123(123为vote_id) CREATE TABLE `vote_stat_123` ( `item_id` bigint NOT NULL COMMENT '投票项ID', `vote_count` int NOT NULL DEFAULT '0' COMMENT '票数', `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`item_id`), KEY `idx_updated` (`updated_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投票项统计表';

为什么不用vote_item表加vote_count字段?

  • 单表更新竞争激烈,高并发下UPDATE vote_item SET vote_count = vote_count + 1 WHERE id = ?易锁表;
  • 分表后可针对vote_stat_123做单独索引优化,且归档时直接DROP TABLE,比删数据快10倍;
  • 源码中VoteItemStatMapper.createStatTable()用@SelectKey生成建表SQL,规避MyBatis对DDL语句的限制。

4.3 用户投票记录表:联合唯一索引防重复,而非靠代码判断

CREATE TABLE `user_vote_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` varchar(64) NOT NULL COMMENT '用户OpenID', `vote_id` bigint NOT NULL COMMENT '投票ID', `item_ids` varchar(500) NOT NULL COMMENT '所选项目ID,逗号分隔', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_vote` (`user_id`,`vote_id`) COMMENT '同一用户对同一投票只能投一次' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户投票记录表';

注意:UNIQUE KEY uk_user_vote (user_id,vote_id)是防刷核心。即使前端漏校验、后端事务异常,MySQL层也会报Duplicate entry,日志里一眼看出刷票行为。很多毕设只在Service层if (recordExists) throw,这是伪防刷。


5. 高分毕设避坑指南:答辩老师最常问的5个问题,及对应代码位置

别等答辩被问住才翻源码。这5个问题,每个都对应源码里一个具体文件和函数,提前准备好,就是高分底气。

5.1 “你怎么保证投票结果实时准确?如果多人同时投一个选项,会不会算错?”

  • 现象:答辩老师模拟并发场景,质疑计票原子性。
  • 原因:若用SELECT + UPDATE两步操作,中间有间隙,导致超卖式错误。
  • 解决:源码在VoteItemStatMapper.xml中用乐观锁+自增更新:
    <update id="incrementVoteCount"> UPDATE vote_stat_${voteId} SET vote_count = vote_count + 1, updated_at = NOW() WHERE item_id = #{itemId} </update>

    MySQL的UPDATE ... SET x = x + 1是原子操作,无需额外锁。配合vote_stat_xxx分表,彻底规避行锁竞争。

5.2 “小程序怎么防止别人抓包后伪造投票请求?”

  • 现象:老师用Charles抓包,修改itemId重放请求。
  • 原因:未做请求签名或时效校验。
  • 解决:前端signVoteData()生成签名,后端VoteController.checkSign()校验:
    private boolean checkSign(VoteSubmitDTO dto) { String raw = dto.getItemId() + dto.getTimestamp() + dto.getNonce() + sessionKey; String expectedSign = DigestUtils.md5Hex(raw); return expectedSign.equals(dto.getSign()) && System.currentTimeMillis() - dto.getTimestamp() < 300000; // 5分钟 }

5.3 “如果投票中途要暂停,历史数据怎么保留?”

  • 现象:老师质疑“暂停”功能是否只是改个status字段。
  • 原因:未设计暂停时的数据快照机制。
  • 解决:VoteService.pauseVote()调用VoteSnapshotService.createSnapshot(voteId),将当前统计表vote_stat_123数据INSERT INTO vote_snapshot_123 SELECT * FROM vote_stat_123,快照表带snapshot_time字段,答辩时可当场查历史快照。

5.4 “你们怎么处理MySQL主从延迟导致的读写不一致?”

  • 现象:老师追问“刚投完票,刷新页面为啥看不到最新票数?”
  • 原因:读库延迟,但源码默认读写分离。
  • 解决:在VoteController.getResult()方法上加@ReadOnlyConnection注解(自定义AOP),强制走主库:
    @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface ReadOnlyConnection {} // AOP切面中,若方法有此注解,则设置ThreadLocal为READ_ONLY

5.5 “导出Excel如果数据量大,服务器会不会崩?”

  • 现象:老师担心1万条数据导出OOM。
  • 原因:未用流式写入。
  • 解决:ExportService.exportVoteResult()中EasyExcel.write()传入filePath而非OutputStream,底层自动分批写磁盘,内存占用恒定在5MB以内。查看pom.xml确认依赖版本:<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.3.2</version></dependency>。

6. 真正让毕设脱颖而出的细节:答辩现场可演示的3个“后悔药”功能

高分毕设和普通毕设的分水岭,往往不在功能多寡,而在能否应对答辩突发状况。这套源码埋了三个“后悔药”功能,不是写在文档里,而是藏在代码里,答辩时点一下就能救场。

6.1 投票数据一键回滚:误操作后30秒恢复

答辩时手抖点了“发布”,或测试数据污染了正式库?源码提供/admin/vote/rollback/{voteId}接口:

// AdminVoteController.java @PostMapping("/rollback/{voteId}") public Result rollbackVote(@PathVariable Long voteId) { // 1. 检查投票状态是否为"已发布"且无用户投票 Vote vote = voteMapper.selectById(voteId); if (!vote.getStatus().equals(VoteStatusEnum.PUBLISHED)) { return Result.fail("只能回滚已发布的投票"); } Long recordCount = userVoteRecordMapper.countByVoteId(voteId); if (recordCount > 0) { return Result.fail("已有用户投票,不可回滚"); } // 2. 删除动态统计表 + 重置投票状态 voteItemStatMapper.dropStatTable(voteId); vote.setStatus(VoteStatusEnum.DRAFT); voteMapper.updateById(vote); return Result.success("回滚成功"); }

演示话术:“老师,刚才演示时不小心发布了测试投票,现在一键回滚到草稿状态,所有关联数据已清理。”——比解释“我们有测试环境”更有说服力。

6.2 投票结果实时看板:答辩时打开网页就能看动态曲线

源码附带/dashboard/vote/{voteId}管理后台页面(Vue写的静态页),接入/api/vote/stat/{voteId}SSE接口:

// dashboard.js const eventSource = new EventSource(`/api/vote/stat/${voteId}`); eventSource.onmessage = (e) => { const data = JSON.parse(e.data); // 更新ECharts图表 chart.setOption({ series: [{ data: data.items.map(item => [item.name, item.count]) }] }); };

后端用SseEmitter推送:

@GetMapping("/stat/{voteId}") public SseEmitter getVoteStat(@PathVariable Long voteId) { SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); // 30分钟超时 // 每5秒推一次最新统计 scheduledExecutorService.scheduleAtFixedRate(() -> { try { List<ItemStat> stats = voteItemStatMapper.getStats(voteId); emitter.send(SseEmitter.event().name("stats").data(stats)); } catch (IOException e) { emitter.complete(); } }, 0, 5, TimeUnit.SECONDS); return emitter; }

答辩时打开这个页面,投票数据实时跳动,比静态截图高级十倍。注意:SseEmitter需在application.yml中配置spring.mvc.async.request-timeout: 1800000。

6.3 小程序离线缓存兜底:网络断了也能看历史投票

小程序默认关闭离线能力。源码在app.js中启用wx.setStorageSync缓存关键数据:

// app.js App({ onLaunch() { // 启动时预加载最近3个投票 this.loadRecentVotes(); }, loadRecentVotes() { wx.cloud.downloadFile({ fileID: 'cloud://xxx/votes.json', success: (res) => { try { const data = JSON.parse(wx.getFileSystemManager().readFileSync(res.tempFilePath, 'utf8')); wx.setStorageSync('recent_votes', data); } catch (e) { console.error('缓存解析失败', e); } } }); } });

对应页面pages/index/index.js中:

onLoad() { const cached = wx.getStorageSync('recent_votes'); if (cached && cached.length > 0) { this.setData({ list: cached }); } else { this.loadFromNetwork(); // 网络正常时再拉新数据 } }

答辩现场WiFi突然断了?淡定打开首页——历史投票列表还在。老师问“没网能用吗?”,你点开列表,就是最佳回答。

我带过三届毕设,见过太多同学花三个月写功能,最后两天狂补答辩PPT。这套源码的价值,不在它用了什么新技术,而在于把答辩场景里所有可能发生的“翻车瞬间”,都提前写成了可执行的代码。它不教你Spring原理,但教会你怎么让老师觉得“这孩子真懂落地”。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询