
简介这是一套面向高校学生与Java初学者的微信小程序投票评选系统完整项目包可作为毕业设计、课程设计或期末大作业的参考方案帮助缺乏实战经验的同学快速获得一个可运行、可答辩的完整作品。压缩包共878个文件约8.66MB涵盖71个Java源文件、148个JavaScript脚本、91个XML配置、58个CSS样式、38个HTML页面以及27个WXSS与19个WXML小程序页面文件另含SQL数据库脚本、PNG/JPG界面素材与字体图标资源前后端代码与数据库脚本齐备。项目基于Java SSM或SpringBoot后台框架前端为微信小程序配套MySQL数据库开发环境涉及IDEA、微信开发者工具、Navicat与Maven代码注释较为完整新手也能看懂。目前已有282人学习下载。读者可获得完整源码、数据库脚本与部署教程直接用于毕设答辩或课程设计提交省去从零搭建的时间成本。1. 投票评选系统的小程序端与 Java 后端一套能跑通的源码结构长什么样微信小程序开发的投票评选系统落到实际交付里通常不是单一页面而是「小程序端 Java 后端 数据库」三件套。标题里带 java、源码、数据库、教程说明它面向的是课程设计、企业内评、活动投票这类需要快速搭起来、能演示、能二次改的需求。真正卡住人的地方往往不是投票按钮怎么画而是票数怎么保证不重复、并发下怎么不超投、数据库表怎么设计才能既存选手又存记录。这套结构适合有 Java 基础、想拿一个完整项目练手或交作业的人也适合需要给运营活动快速搭投票页的开发者。下面按「先立结构、再落代码、最后排坑」的顺序把可复现的路径拆开。2. 数据库表设计与 Java 实体映射投票系统的地基怎么打投票评选系统的成败七成在表结构。很多人一上来就写 Controller结果做到一半发现「一个用户给同一个选手只能投一票」这个约束没地方放只能回头改表血泪经验。常见做法是先定四张核心表活动表、选手表、投票记录表、用户表。活动表管周期和状态选手表挂活动投票记录表是防重和计数的关键用户表存微信 openid 和基本信息。2.1 四张核心表的字段与约束先看活动表vote_activity它决定投票什么时候开始、什么时候结束、每人能投几票CREATE TABLE vote_activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 活动名称, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, max_votes INT DEFAULT 1 COMMENT 每人可投票数, status TINYINT DEFAULT 0 COMMENT 0未开始 1进行中 2已结束, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;选手表vote_player通过activity_id归属到某个活动vote_count是冗余的计数字段用来避免每次列表都去 count 记录表CREATE TABLE vote_player ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL COMMENT 所属活动, name VARCHAR(50) NOT NULL COMMENT 选手名称, cover_url VARCHAR(255) COMMENT 封面图, vote_count INT DEFAULT 0 COMMENT 累计票数, sort_no INT DEFAULT 0 COMMENT 排序, KEY idx_activity (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;投票记录表vote_record是防重的核心openid player_id activity_id上加唯一索引数据库层面直接挡住重复投票CREATE TABLE vote_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL, player_id BIGINT NOT NULL, openid VARCHAR(64) NOT NULL COMMENT 微信用户标识, vote_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_once (activity_id, player_id, openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用户表vote_user存微信侧拿到的 openid 和昵称openid 是后续所有身份判断的依据CREATE TABLE vote_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL, nickname VARCHAR(50), avatar_url VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明max_votes控制每人票数如果活动允许一人投多个选手就靠它和记录表联合判断vote_count冗余字段是为了列表页性能代价是每次投票要同步更新后面会讲怎么保证一致唯一索引uk_once是防超投的最后一道闸应用层判断可能被并发绕过数据库不会。2.2 Java 实体与 MyBatis 映射表定好后Java 侧用实体类承接。以投票记录为例字段名和表列一一对应用 Lombok 省掉 getter/setterData TableName(vote_record) public class VoteRecord { TableId(type IdType.AUTO) private Long id; private Long activityId; private Long playerId; private String openid; private Date voteTime; }MyBatis-Plus 的 Mapper 接口继承BaseMapper就能拿到基础增删改查投票这种需要原子更新的操作再单独写public interface VotePlayerMapper extends BaseMapperVotePlayer { Update(UPDATE vote_player SET vote_count vote_count 1 WHERE id #{playerId}) int incrVoteCount(Param(playerId) Long playerId); }逻辑说明incrVoteCount用 SQL 的vote_count 1而不是先查再写避免并发下两个请求都读到旧值、各自加一后互相覆盖。参数playerId是选手主键更新影响行数为 0 说明选手不存在调用方要处理。常见误用是「查出来 countJava 里加一再 update 回去」单机低并发看不出问题一上量就丢票。提示唯一索引和原子更新是两件事前者防重复投票后者防计数丢失两个都要有。3. 小程序端投票交互与 Java 接口对接从点击到落库的完整链路小程序端负责展示选手、接收点击、把 openid 和选手 id 发给后端。Java 后端负责校验活动状态、判断是否已投、写记录、更新计数。这条链路里最容易翻车的是「前端以为投成功了后端其实因为重复被拦了」所以接口返回要明确区分成功、重复、活动结束三种情况。3.1 小程序端投票按钮与请求封装小程序页面用wx.request调后端投票前先从缓存或登录接口拿 openid。下面是一个投票按钮的绑定逻辑// pages/vote/vote.js Page({ data: { players: [], activityId: 1 }, onVote(e) { const playerId e.currentTarget.dataset.id; const openid wx.getStorageSync(openid); if (!openid) { wx.showToast({ title: 请先登录, icon: none }); return; } wx.request({ url: https://your-domain/api/vote/cast, method: POST, data: { activityId: this.data.activityId, playerId, openid }, success: (res) { const { code, msg } res.data; if (code 0) { wx.showToast({ title: 投票成功 }); this.refreshPlayers(); } else { wx.showToast({ title: msg, icon: none }); } } }); } });逻辑说明e.currentTarget.dataset.id从按钮的>Transactional(rollbackFor Exception.class) public Result castVote(Long activityId, Long playerId, String openid) { VoteActivity activity activityMapper.selectById(activityId); if (activity null || activity.getStatus() ! 1) { return Result.fail(活动未开始或已结束); } VotePlayer player playerMapper.selectById(playerId); if (player null || !player.getActivityId().equals(activityId)) { return Result.fail(选手不存在); } Long voted recordMapper.selectCount(new LambdaQueryWrapperVoteRecord() .eq(VoteRecord::getActivityId, activityId) .eq(VoteRecord::getPlayerId, playerId) .eq(VoteRecord::getOpenid, openid)); if (voted 0) { return Result.fail(您已经投过票了); } VoteRecord record new VoteRecord(); record.setActivityId(activityId); record.setPlayerId(playerId); record.setOpenid(openid); try { recordMapper.insert(record); } catch (DuplicateKeyException e) { return Result.fail(您已经投过票了); } playerMapper.incrVoteCount(playerId); return Result.ok(); }逻辑说明先查活动状态避免无效请求走到后面再查选手归属防止跨活动投票selectCount是应用层预判真正兜底的是insert时唯一索引抛出的DuplicateKeyException捕获后返回重复提示这样并发下也不会写进两条记录。最后incrVoteCount更新冗余计数。整个方法加Transactional任何一步抛异常都回滚不会出现「记录写了但计数没加」的黑匣子状态。参数说明rollbackFor Exception.class确保受检异常也回滚LambdaQueryWrapper的字段引用避免手写列名拼错DuplicateKeyException是 Spring 对唯一键冲突的封装不同持久层框架异常名可能不同用之前确认一下。注意应用层selectCount和insert之间有时间窗口高并发下两个请求可能都通过预判但唯一索引只会让一个成功另一个走异常分支这是预期行为不是 bug。4. 并发投票与票数一致性三个必调参数和排查思路投票系统最怕的不是功能跑不通而是活动火了之后票数对不上。这一章讲并发下怎么保证「记录数和计数一致」以及出问题时从哪查。4.1 计数一致性的三种方案对比方案做法优点代价冗余字段原子更新vote_count vote_count 1列表快实现简单与记录表可能不一致实时 count 记录表每次查count(*)永远一致选手多时列表慢定时对账冗余字段 定时任务校正兼顾性能和最终一致有延迟要写对账逻辑常见做法是第一种加第三种平时用冗余字段扛读每天凌晨跑一次对账把vote_count重算成记录表的真实数量。对账 SQL 很直接UPDATE vote_player p SET p.vote_count ( SELECT COUNT(*) FROM vote_record r WHERE r.player_id p.id ) WHERE p.activity_id #{activityId};参数说明activityId限定对账范围避免全表扫描。这条语句在活动结束后跑一次或者低峰期定时跑。注意子查询在数据量大时较慢可以改成 join 临时表的方式。4.2 数据库连接池与事务隔离级别Java 侧连接池用 HikariCP投票接口是短事务连接数不用开太大maximum-pool-size设 10 到 20 通常够用设太大反而在数据库侧排队。事务隔离级别用默认的READ_COMMITTED即可投票逻辑靠唯一索引和原子更新保证正确不需要SERIALIZABLE后者会大幅降低并发。spring: datasource: hikari: maximum-pool-size: 15 minimum-idle: 5 connection-timeout: 3000参数说明connection-timeout设 3000 毫秒拿不到连接快速失败避免请求堆积minimum-idle保持 5 个常驻连接减少频繁创建开销。如果活动瞬间流量大先看数据库 CPU 和连接等待而不是盲目加连接数。4.3 票数对不上时的排查顺序发现某选手票数和记录数不一致按这个顺序查先查记录表该选手的实际条数确认是计数多了还是少了再查是否有对账任务跑过、跑的时候活动是否还在进行然后看应用日志里有没有DuplicateKeyException被吞掉没记日志的情况最后确认incrVoteCount的调用是否在事务内、有没有被异常跳过。多数不一致来自「记录插入成功但计数更新失败且没回滚」根源是事务没加或者异常被 catch 后没重新抛出。5. 避坑与常见问题上线前必须过的五道坎5.1 活动状态用时间判断还是用字段判断现象活动明明设了结束时间过了点还能投。原因只判断了status字段而status靠定时任务改任务延迟或没跑就失效。解决投票接口里同时判断status 1和当前时间在start_time与end_time之间两个条件都满足才放行字段和时间的双重校验更稳。5.2 openid 拿不到导致投票全失败现象用户点投票提示请先登录但用户觉得自己已经授权了。原因小程序登录换 openid 的流程没走完或者缓存 key 写错。解决登录接口拿到 openid 后wx.setStorageSync(openid, openid)投票前先读缓存读不到就跳登录。注意 openid 是每个小程序独立的换小程序要重新拿。5.3 唯一索引建错导致防重失效现象同一用户给同一选手投了多票记录表里有重复。原因唯一索引建在了player_id单列上或者建索引时漏了activity_id。解决确认唯一索引是(activity_id, player_id, openid)三列组合用SHOW INDEX FROM vote_record检查。已经产生脏数据的话先去重再重建索引。5.4 列表页 N1 查询拖慢加载现象选手列表加载要好几秒。原因每个选手都单独查一次票数或是否已投。解决列表接口一次性查出所有选手已投状态用一条IN查询批量取在 Java 里用 Map 匹配别在循环里查数据库。5.5 事务里做了远程调用导致连接被占现象高峰期连接池耗尽接口大面积超时。原因投票事务里调了微信接口或发消息远程调用慢数据库连接一直被占。解决事务里只做数据库操作远程调用和消息发送放到事务提交后用TransactionSynchronization或事件机制处理。6. 从能跑到好用给投票系统加一层结果缓存和防刷功能跑通之后真正让这套系统「好用」的是两件事结果页别每次都压数据库以及防止有人脚本刷票。结果页缓存我一般用 Redis选手列表和票数缓存 5 到 10 秒投票成功后主动删掉对应活动的缓存 key下次请求重建。这样既扛住了结果页的高频刷新又不会让用户看到太旧的数据。public ListVotePlayer listPlayers(Long activityId) { String key vote:players: activityId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseArray(cached, VotePlayer.class); } ListVotePlayer list playerMapper.selectList( new LambdaQueryWrapperVotePlayer() .eq(VotePlayer::getActivityId, activityId) .orderByDesc(VotePlayer::getVoteCount)); redisTemplate.opsForValue().set(key, JSON.toJSONString(list), 10, TimeUnit.SECONDS); return list; }逻辑说明先读缓存命中直接返回未命中查库后写缓存过期时间 10 秒。投票接口成功后调redisTemplate.delete(vote:players: activityId)保证下次读到新数据。参数说明过期时间别设太长投票场景对实时性有要求10 秒是体验和压力的平衡点缓存的是 JSON 字符串反序列化时注意字段类型一致。防刷这块光靠 openid 不够因为 openid 可以来自多个账号。常见做法是加一层频率限制同一 openid 每秒最多一次投票请求同一 IP 每分钟最多 N 次。用 Redis 的INCR加过期时间实现简单有效。我自己的习惯是任何投票活动上线前先用脚本模拟 100 个并发投同一选手看记录数和计数是否一致、有没有重复记录这一步过了才敢放出去。这套系统不难难的是把并发和防重想在前头别等票数对不上再回头补那时候数据已经脏了。希望帮到你。本文还有配套的精品资源点击获取