简介这是一套面向计算机专业本科生的微信小程序毕业设计实战项目完整实现了一个B/S架构的投票评选系统适用于课程设计、毕设选题与全栈开发能力训练。资源包含可直接运行的前后端源码、详细开发说明文档、MySQL数据库脚本及配套演示视频覆盖从用户端在线投票支持图文投票、单主题限投一次、结果实时查看、公告浏览到后台管理员对用户、主题、公告、投票数据的全流程管理与统计分析。压缩包共884个文件以148个JS逻辑文件、71个Java服务端代码、58个CSS样式文件、56个PNG图标资源及27个GZ压缩配置为主结构清晰便于分层学习与二次开发整体大小为15.56MB轻量易解压。已有1322人下载学习提供开箱即用的完整工程实践方案含数据库建表语句、小程序配置、后端接口联调要点及典型业务场景实现细节助力快速掌握微信小程序JavaMySQL全栈开发核心流程。1. 一个能跑通、能答辩、能改需求的微信小程序投票系统到底长什么样很多同学拿到「微信小程序毕业设计」这个选题时第一反应是前端页面好画但一到「用户只能投一次」「结果实时统计」「管理员后台权限隔离」这些硬逻辑就卡住。更现实的是答辩前两天发现数据库字段没加唯一索引导致重复投票或者小程序上线后用户点开就白屏——不是代码写错了而是app.json里tabBar配置路径少了个斜杠。这个资源包不是“成品演示”而是一套经过真实部署验证的最小可行闭环MySQL 表结构已设UNIQUE(user_id, topic_id)防重投Java 后端用 Spring Boot MyBatis 实现接口幂等校验小程序端wx.request封装了统一错误拦截连project.config.json里的 AppID 都留了占位符。它适合两类人一是想快速搭建可运行原型、把精力聚焦在业务逻辑和答辩陈述上的本科生二是需要在此基础上叠加「图片投票」「多轮评选」「导出 Excel 统计」等扩展功能的进阶者。所有模块都带中文注释数据库.sql文件可直接导入 phpMyAdmin演示视频里甚至录了管理员登录后台后点击「导出投票明细」按钮的完整操作流。2. 前端小程序从页面结构到防重复提交的三层校验2.1 页面路由与 tabbar 配置必须匹配实际目录结构小程序的app.json是整个导航系统的中枢一旦路径写错页面根本无法加载。该资源包中app.json的tabBar配置如下{ tabBar: { list: [ { pagePath: pages/index/index, text: 首页, iconPath: assets/icons/home.png, selectedIconPath: assets/icons/home-active.png }, { pagePath: pages/vote/vote, text: 投票, iconPath: assets/icons/vote.png, selectedIconPath: assets/icons/vote-active.png }, { pagePath: pages/result/result, text: 结果, iconPath: assets/icons/result.png, selectedIconPath: assets/icons/result-active.png } ] } }注意pagePath必须严格对应pages/下的真实目录层级且不能以/开头。若将pages/vote/vote写成pages/vote/或/pages/vote/vote开发工具会报page path is not in pages array错误且真机调试时页面空白。资源包中所有pagePath均已通过微信开发者工具 → 项目详情 → 本地设置 → 使用自定义编译条件验证过路径有效性。2.2 投票交互中的三重防重机制实现用户点击投票按钮后系统需确保「同一用户对同一主题仅投一票」。该资源采用前端 中间层 数据库三级防护2.2.1 前端按钮状态锁UI 层在pages/vote/vote.wxml中投票按钮绑定bindtaphandleVote并在vote.js中实现// pages/vote/vote.js Page({ data: { isVoting: false // 控制按钮禁用状态 }, handleVote() { if (this.data.isVoting) return; // 防止连续点击 this.setData({ isVoting: true }); wx.request({ url: https://your-domain.com/api/vote, method: POST, data: { topicId: this.data.topic.id, imageId: this.data.selectedImageId }, success: (res) { if (res.data.code 200) { wx.showToast({ title: 投票成功, icon: success }); setTimeout(() wx.navigateBack(), 1500); } else { wx.showToast({ title: res.data.msg || 投票失败, icon: none }); } }, fail: () { wx.showToast({ title: 网络错误, icon: none }); }, complete: () { this.setData({ isVoting: false }); // 恢复按钮状态 } }); } });2.2.2 后端接口幂等性控制Service 层Java 后端在VoteService.java中调用checkUserAlreadyVoted(userId, topicId)方法// com.example.service.VoteService.java public boolean checkUserAlreadyVoted(Long userId, Long topicId) { // 查询 vote_record 表中是否存在该用户对该主题的记录 VoteRecordExample example new VoteRecordExample(); example.createCriteria() .andUserIdEqualTo(userId) .andTopicIdEqualTo(topicId); return voteRecordMapper.countByExample(example) 0; } Transactional(rollbackFor Exception.class) public ResultString submitVote(Long userId, Long topicId, Long imageId) { if (checkUserAlreadyVoted(userId, topicId)) { return Result.fail(您已对该主题投过票); } // 插入新投票记录 VoteRecord record new VoteRecord(); record.setUserId(userId); record.setTopicId(topicId); record.setImageId(imageId); record.setCreateTime(new Date()); voteRecordMapper.insert(record); return Result.success(投票成功); }2.2.3 数据库唯一约束存储层MySQL 表vote_record的建表语句中明确声明联合唯一索引-- database/vote_system.sql CREATE TABLE vote_record ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID, topic_id bigint NOT NULL COMMENT 投票主题ID, image_id bigint DEFAULT NULL COMMENT 所选图片ID可为空, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_topic (user_id,topic_id) -- 关键防止重复插入 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;提示若跳过前端按钮锁和后端校验仅依赖数据库唯一索引用户连续点击时仍可能看到多次「主键冲突」错误提示体验极差。三层校验缺一不可前端锁 UI 防误触后端查库防并发数据库兜底保数据一致性。2.3 图片投票的上传与展示逻辑该系统支持「投票图片」功能即用户从相册选择图片参与投票。小程序端使用wx.chooseImagewx.uploadFile上传// pages/vote/vote.js chooseImage() { wx.chooseImage({ count: 1, sizeType: [compressed], sourceType: [album, camera], success: (res) { const tempFilePath res.tempFilePaths[0]; wx.uploadFile({ url: https://your-domain.com/api/upload/image, filePath: tempFilePath, name: file, formData: { userId: getApp().globalData.userId }, success: (uploadRes) { const data JSON.parse(uploadRes.data); if (data.code 200) { this.setData({ selectedImageId: data.data.imageId }); wx.showToast({ title: 图片上传成功, icon: success }); } } }); } }); }后端 Java 接收文件并保存至服务器/uploads/images/目录并将相对路径存入image_info表// com.example.controller.UploadController.java PostMapping(/upload/image) public ResultMapString, Object uploadImage(RequestParam(file) MultipartFile file, RequestParam(userId) Long userId) throws IOException { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName System.currentTimeMillis() _ userId ext; String uploadPath /var/www/uploads/images/ fileName; // Linux 路径 File dest new File(uploadPath); file.transferTo(dest); ImageInfo image new ImageInfo(); image.setUserId(userId); image.setUrl(/uploads/images/ fileName); // 存储相对路径前端拼接域名访问 image.setUploadTime(new Date()); imageInfoMapper.insert(image); MapString, Object result new HashMap(); result.put(imageId, image.getId()); return Result.success(result); }3. 后端 Java 服务Spring Boot MyBatis 的权限分层与统计聚合3.1 管理员与普通用户的 JWT 权限隔离设计系统采用基于角色的访问控制RBAC用户登录后返回 JWT Token后续请求在 Header 中携带Authorization: Bearer token。JwtTokenFilter.java在过滤器链中完成解析与校验// com.example.filter.JwtTokenFilter.java public class JwtTokenFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims Jwts.parser().setSigningKey(your-secret-key.getBytes()) .parseClaimsJws(token).getBody(); String role (String) claims.get(role); // 从 payload 提取 role 字段 if (ADMIN.equals(role)) { SecurityContextHolder.getContext() .setAuthentication(new UsernamePasswordAuthenticationToken( claims.getSubject(), null, Collections.singletonList(new SimpleGrantedAuthority(ROLE_ADMIN)) )); } else if (USER.equals(role)) { SecurityContextHolder.getContext() .setAuthentication(new UsernamePasswordAuthenticationToken( claims.getSubject(), null, Collections.singletonList(new SimpleGrantedAuthority(ROLE_USER)) )); } } catch (Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } } filterChain.doFilter(request, response); } }控制器方法通过PreAuthorize注解限定访问权限// com.example.controller.AdminController.java RestController RequestMapping(/admin) PreAuthorize(hasRole(ADMIN)) // 仅管理员可访问 public class AdminController { GetMapping(/topics) public ResultListTopic getAllTopics() { return Result.success(topicService.listAll()); } PostMapping(/topics) public ResultString addTopic(RequestBody Topic topic) { topicService.save(topic); return Result.success(添加成功); } }关键参数说明JWT 密钥your-secret-key必须在application.yml中替换为生产环境强随机字符串role字段由登录接口LoginController.login()在生成 Token 时注入其值来自数据库sys_user.role字段值为ADMIN或USER。3.2 投票结果实时统计的 SQL 优化策略查看投票结果页需聚合统计各选项得票数。原始 SQL 若直接GROUP BY image_id可能因数据量大而慢资源包中采用预计算 缓存双策略3.2.1 预计算视图MySQL View创建view_vote_summary视图避免每次查询都扫描全表-- database/vote_system.sql CREATE VIEW view_vote_summary AS SELECT t.id AS topic_id, t.title AS topic_title, i.id AS image_id, i.url AS image_url, COUNT(v.id) AS vote_count FROM vote_topic t LEFT JOIN image_info i ON t.id i.topic_id LEFT JOIN vote_record v ON i.id v.image_id GROUP BY t.id, i.id, i.url;3.2.2 MyBatis Mapper 接口调用视图!-- mapper/VoteResultMapper.xml -- select idgetVoteSummaryByTopicId resultTypecom.example.vo.VoteSummaryVO SELECT * FROM view_vote_summary WHERE topic_id #{topicId} /select3.2.3 Redis 缓存热点结果可选增强在VoteResultService.java中加入缓存逻辑Service public class VoteResultService { Autowired private RedisTemplateString, Object redisTemplate; public ListVoteSummaryVO getSummary(Long topicId) { String cacheKey vote:summary: topicId; ListVoteSummaryVO cached (ListVoteSummaryVO) redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return cached; } ListVoteSummaryVO result voteResultMapper.getVoteSummaryByTopicId(topicId); // 缓存 5 分钟避免频繁 DB 查询 redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES); return result; } }3.3 公告管理模块的富文本存储与安全过滤公告内容支持格式化文本加粗、换行、图片但直接存 HTML 有 XSS 风险。资源包采用「纯文本 Markdown 解析」方案前端使用towxml库将 Markdown 渲染为小程序 WXML后端接收时对script、onerror等危险标签做白名单过滤// com.example.util.HtmlSanitizer.java public class HtmlSanitizer { private static final Pattern SCRIPT_PATTERN Pattern.compile(script[^]*.*?/script, Pattern.CASE_INSENSITIVE | Pattern.DOTALL); private static final Pattern ONERROR_PATTERN Pattern.compile(onerror\\s*\\s*\[^\]*\, Pattern.CASE_INSENSITIVE); public static String cleanHtml(String html) { if (html null) return ; String cleaned SCRIPT_PATTERN.matcher(html).replaceAll(); cleaned ONERROR_PATTERN.matcher(cleaned).replaceAll(); return cleaned.replaceAll([^], ); // 移除所有 HTML 标签仅保留文字 } }管理员在后台编辑公告时输入 Markdown如## 本周投票说明 - 所有主题截止时间为 **2024-06-30 23:59** - 每个用户限投 **1 票**请慎重选择后端调用HtmlSanitizer.cleanHtml()后存入notice.content字段前端通过towxml渲染既保证样式又杜绝脚本执行。4. 数据库与部署MySQL 表结构详解与 Nginx 反向代理配置4.1 核心表结构与字段含义对照表表名字段名类型是否为空默认值说明sys_useridBIGINTNO—主键自增usernameVARCHAR(50)NO—登录账号唯一passwordVARCHAR(100)NO—BCrypt 加密后的密码roleVARCHAR(20)NOUSER角色标识ADMIN/USERvote_topicidBIGINTNO—主键titleVARCHAR(100)NO—投票主题标题statusTINYINTNO1状态1-启用0-停用start_timeDATETIMEYES—开始时间end_timeDATETIMEYES—结束时间image_infoidBIGINTNO—主键topic_idBIGINTNO—关联投票主题urlVARCHAR(255)NO—图片相对路径如/uploads/images/123.jpgupload_timeDATETIMENOCURRENT_TIMESTAMP上传时间vote_recordidBIGINTNO—主键user_idBIGINTNO—投票用户 IDtopic_idBIGINTNO—投票主题 IDimage_idBIGINTYES—所选图片 ID可为空表示文字投票create_timeDATETIMENOCURRENT_TIMESTAMP投票时间提示vote_record.user_id和vote_record.topic_id组成联合唯一索引uk_user_topic这是防重复投票的最终防线。导入.sql文件前请确认 MySQL 版本 ≥ 5.7支持utf8mb4_0900_ai_ci排序规则。4.2 后端服务部署Spring Boot Jar 包 Nginx 反向代理Java 后端打包为vote-system.jar部署在 Ubuntu 22.04 服务器上4.2.1 启动脚本start.sh#!/bin/bash APP_NAMEvote-system.jar APP_PATH/opt/vote-system/ LOG_PATH/var/log/vote-system/ mkdir -p $LOG_PATH nohup java -jar -Dspring.profiles.activeprod \ -Xms512m -Xmx1024m \ $APP_PATH$APP_NAME \ $LOG_PATH/app.log 21 echo Vote system started with PID: $(ps -ef | grep $APP_NAME | grep -v grep | awk {print $2})4.2.2 Nginx 配置反向代理/etc/nginx/sites-available/voteserver { listen 80; server_name vote.yourdomain.com; # 小程序静态资源图片、JS、WXML location /uploads/ { alias /var/www/uploads/; expires 1h; } # API 接口代理 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # 前端页面若托管 H5 版 location / { root /var/www/vote-h5; try_files $uri $uri/ /index.html; } }启用配置并重载sudo ln -sf /etc/nginx/sites-available/vote /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx4.3 小程序域名配置与合法 TLS 证书微信小程序要求所有wx.request请求必须使用 HTTPS 协议且域名需在「小程序管理后台 → 开发管理 → 开发管理 → 服务器域名」中备案。备案域名https://vote.yourdomain.com证书获取使用 Certbotsudo apt install certbot python3-certbot-nginx sudo certbot --nginx -d vote.yourdomain.comCertbot 会自动修改 Nginx 配置添加 SSL 相关指令并设置自动续期。验证是否生效curl -I https://vote.yourdomain.com/api/ping # 应返回 HTTP/2 200 OK注意若使用宝塔面板或腾讯云 CDN需在对应平台单独申请并绑定证书Nginx 配置中ssl_certificate和ssl_certificate_key路径需与面板实际路径一致。5. 演示视频与答辩技巧如何用 3 分钟讲清楚技术亮点5.1 演示视频的 5 个必录场景附剪辑建议该资源包中的演示视频并非简单录屏而是按「问题—方案—效果」逻辑组织的微型产品发布会。以下是建议录制的 5 个核心片段每段控制在 30–45 秒内总时长 ≤ 3 分钟场景操作步骤重点展示剪辑建议1. 用户视角投票流程打开小程序 → 进入「投票」页 → 选择主题 → 点击图片 → 提交按钮禁用动画、成功 toast、页面跳转流畅度用鼠标高亮点击区域添加「前端防重」字幕2. 管理员后台数据看板登录后台 → 进入「投票结果」→ 切换主题 → 查看柱状图统计图表实时刷新、导出 Excel 按钮响应录屏时放大图表区域添加「SQL 视图预计算」标注3. 防重复投票验证同一账号连续点击两次投票按钮第二次点击无响应、控制台无新请求发出插入前端 JS 代码片段截图标红isVoting变量4. 图片上传与展示选择相册图片 → 上传成功 → 返回投票页 → 图片显示在选项中上传进度条、缩略图即时渲染用画中画同时显示手机端和 PC 端 Network 面板5. 数据库唯一约束触发用 Navicat 手动插入重复user_idtopic_id记录MySQL 报错Duplicate entry截图vote_record表结构高亮uk_user_topic索引提示答辩时不要说「我用了 Spring Boot」而要说「当用户连续点击投票按钮时我们通过前端按钮锁 后端查库 数据库唯一索引三层机制确保最终只产生一条有效记录这是保障投票公平性的技术底线」。5.2 答辩高频问题应答清单附代码定位面试官常问的问题及精准回答方式均指向资源包内具体文件问题回答要点对应代码位置Q怎么保证一个用户只能投一次「三道防线前端isVoting状态锁防止误触后端checkUserAlreadyVoted()方法查库判断数据库vote_record表uk_user_topic联合唯一索引兜底。即使并发请求MySQL 行锁也能保证原子性。」pages/vote/vote.js第 22 行service/VoteService.java第 35 行database/vote_system.sql第 86 行Q图片上传后怎么在小程序里显示「前端调用wx.uploadFile上传到服务器/uploads/images/目录后端返回image_id小程序通过image_id请求GET /api/image/{id}接口后端读取文件流并设置Content-Type: image/jpeg响应头实现直链访问。」pages/vote/vote.js第 62 行controller/ImageController.java第 41 行Q管理员和普通用户权限怎么区分「登录成功后后端生成 JWT Tokenpayload 中包含role字段ADMIN或USER所有接口加PreAuthorize注解如PreAuthorize(hasRole(ADMIN))Spring Security 自动拦截未授权请求。」controller/LoginController.java第 58 行controller/AdminController.java第 12 行Q投票结果统计很慢怎么办「我们建了view_vote_summary视图预聚合数据避免每次查询都GROUP BY扫全表对热门主题结果加 Redis 缓存TTL 设为 5 分钟兼顾实时性与性能。」database/vote_system.sql第 122 行service/VoteResultService.java第 28 行5.3 修改刚进入的加载页面app.js全局启动逻辑定制微信小程序默认启动页是白屏该资源包已定制首屏加载逻辑提升用户体验// app.js App({ onLaunch: function () { // 显示自定义 loading wx.showLoading({ title: 加载中..., mask: true }); // 检查登录态 const token wx.getStorageSync(token); if (!token) { wx.hideLoading(); wx.redirectTo({ url: /pages/login/login }); return; } // 获取用户信息并设置全局数据 wx.request({ url: https://vote.yourdomain.com/api/user/info, header: { Authorization: Bearer token }, success: (res) { if (res.data.code 200) { getApp().globalData.userInfo res.data.data; getApp().globalData.userId res.data.data.id; } }, complete: () { wx.hideLoading(); // 无论成功失败都关闭 loading } }); } });技巧若需更换加载图标将wx.showLoading替换为自定义 modal例如在app.wxss中定义.loading-modal样式再用wx.createSelectorQuery()动态插入 DOM。但注意小程序不支持document.createElement必须用wx:if控制显隐。本文还有配套的精品资源点击获取