拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

微信小程序投票系统开发实战:Java后端与数据库设计避坑指南

微信小程序投票系统开发实战:Java后端与数据库设计避坑指南 简介这是一套面向高校学生与Java初学者的微信小程序投票评选系统完整开发资料可作为毕业设计、课程设计或期末大作业的参考方案帮助缺乏项目经验的同学快速搭建一个功能完善、界面美观、操作简便的评选类应用。资源包共878个文件约8.66MB涵盖71个Java源文件、148个JavaScript脚本、91个XML配置、58个CSS样式、38个HTML页面以及27个WXSS与19个WXML小程序页面文件另含SQL数据库脚本、PNG与JPG图片素材、字体文件及Maven相关配置前后端代码与数据库脚本齐备代码注释详尽新手也能看懂。项目基于Java SSM或SpringBoot后台框架前端为微信小程序配套MySQL数据库可在IDEA与微信开发者工具中部署运行。目前已有282人学习下载适合需要完整赛题方案、可运行源码与排错思路的读者参考借鉴。1. 从一份「投票评选系统」压缩包说起微信小程序 Java 到底能跑出什么如果你手里正好躺着一个名为「微信小程序开发的投票评选系统(java)包括源码数据库教程」的压缩包或者你正打算自己从零搭一套投票评选系统那这篇笔记就是写给你的。投票评选这件事听起来简单——发起活动、拉人投票、按票数排名但真正落到微信小程序 Java 后端这套组合上坑远比想象中多票数并发怎么保证不超投、微信登录态怎么和业务用户绑定、数据库表怎么设计才能既扛住读又方便统计、防刷票做到什么程度算够用。这些问题不解决系统上线当天就可能被刷爆或者数据对不上。我做过几套类似的小程序投票系统也拆过不少别人打包好的课程设计源码最常见的翻车点不是代码写不出来而是数据一致性和并发控制没想清楚。这份笔记会按「先搞懂系统由哪几块组成 → 数据库怎么设计 → Java 后端接口怎么写 → 小程序端怎么对接 → 踩过哪些坑 → 怎么验证和进阶」的顺序展开中间会给可直接抄的建表语句、接口代码和参数说明。适合两类人一是拿到源码包但看不懂结构、想真正跑通并改造成自己项目的开发者二是要做课程设计或接私活、需要一套能落地投票评选方案的 Java 工程师。读完你应该能独立搭出一套可用的系统并且知道哪些地方必须加防护。2. 投票评选系统的四层结构小程序、Java 后端、数据库、管理端各管什么2.1 为什么投票系统不适合「一个接口查到底」很多人第一版会把投票系统写成「一个活动表 一个投票记录表」前端每次刷新直接select count(*)统计票数。活动一火几千人同时投票数据库瞬间被 count 查询打满页面转圈票数还可能出现「我投了但没算上」的玄学现象。根本原因是投票系统天然是读多写多且要求强一致的场景读要实时看到票数写要保证一人一票不重复。所以正确的分层思路是小程序端只负责展示和发起请求不信任任何前端传来的用户身份Java 后端负责鉴权、限流、事务和票数计算数据库负责持久化和唯一约束兜底管理端负责活动配置和结果导出。四层各司其职任何一层偷懒都会把压力转嫁给下一层。常见做法是后端用 Spring Boot 起服务小程序通过wx.login拿到 code后端换 openid 作为用户唯一标识再用 Redis 做投票频次控制和票数缓存MySQL 做最终落库。这套组合在课程设计和小型商用里都够用。2.2 一次投票请求的完整链路把一次投票拆开看链路是这样的小程序调用wx.login()获取临时 code连同活动 ID、选项 ID 一起发给后端。后端用 code 调用微信接口换 openid服务端调用不暴露 session_key。后端校验活动是否在投票时间内、该 openid 是否已投过、该选项是否属于该活动。通过校验后写入投票记录表同时更新选项票数或用缓存累加后异步落库。返回最新票数给小程序。这里第 3 步的「是否已投过」是核心。如果只靠后端查一次数据库再插入高并发下两个请求可能同时查到「没投过」然后都插入成功造成一人多票。解决办法是在投票记录表上建(activity_id, openid)的唯一索引让数据库来兜底插入冲突就说明重复投票。-- 投票记录表唯一索引是防重复投票的最后一道防线 CREATE TABLE vote_record ( id bigint NOT NULL AUTO_INCREMENT, activity_id bigint NOT NULL COMMENT 活动ID, option_id bigint NOT NULL COMMENT 选项ID, openid varchar(64) NOT NULL COMMENT 微信用户唯一标识, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_activity_openid (activity_id, openid) COMMENT 同一活动同一用户只能投一次, KEY idx_option (option_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表语句的关键在uk_activity_openid这个唯一索引。它保证了即使应用层判断失误数据库也不会让同一用户在同一活动里插入第二条记录。idx_option则是为了按选项统计票数时走索引避免全表扫描。字符集用utf8mb4是因为微信昵称可能包含 emoji用utf8会插入失败。提示如果你的投票规则允许「每天投一票」唯一索引要改成(activity_id, openid, vote_date)vote_date 存日期字符串这样每天可以插一条。2.3 票数统计实时 count 还是单独维护字段票数怎么算是第二个容易翻车的地方。两种方案方案做法优点缺点实时统计每次select count(*) from vote_record where option_id?数据绝对准确无冗余高并发下慢记录多了更慢字段维护选项表加vote_count字段投票时update ... set vote_countvote_count1读取快一条记录搞定需要保证更新和插入在同一事务我一般用第二种但必须把「插入投票记录」和「更新票数字段」放在同一个事务里否则会出现记录插了票数没加、或者票数加了记录没插的不一致。代码大概长这样Service public class VoteService { Autowired private VoteRecordMapper voteRecordMapper; Autowired private VoteOptionMapper voteOptionMapper; Transactional(rollbackFor Exception.class) public int vote(Long activityId, Long optionId, String openid) { // 1. 插入投票记录唯一索引冲突会抛 DuplicateKeyException VoteRecord record new VoteRecord(); record.setActivityId(activityId); record.setOptionId(optionId); record.setOpenid(openid); voteRecordMapper.insert(record); // 2. 同一事务内更新票数失败一起回滚 int updated voteOptionMapper.increaseCount(optionId); if (updated 0) { throw new RuntimeException(选项不存在); } return voteOptionMapper.getCount(optionId); } }Transactional保证两步要么都成功要么都回滚。increaseCount对应的 SQL 是update vote_option set vote_count vote_count 1 where id ?用数据库行锁保证并发下票数不会算错。如果插入记录时唯一索引冲突Spring 会抛DuplicateKeyException在全局异常处理里捕获后返回「您已投过票」即可不用额外查一次数据库。3. 数据库设计活动、选项、记录三张表怎么建才不返工3.1 三张核心表与字段取舍投票系统的数据库其实就三张核心表活动表、选项表、投票记录表。但字段怎么设直接决定后面改需求时要不要返工。活动表要存活动标题、描述、封面图、开始时间、结束时间、状态草稿/进行中/已结束、投票规则每人每天几票、创建时间。这里最容易漏的是状态字段。很多人只用开始结束时间判断活动是否可投结果运营想提前结束活动时只能改时间改完又影响历史记录。加一个 status 字段判断逻辑变成「status进行中 且 当前时间在起止时间内」运营随时能手动开关。选项表要存所属活动 ID、选项名称、选项图片、当前票数、排序值。票数字段前面说了用冗余换读取性能。排序值是为了运营能手动调整展示顺序不然只能按 ID 排。投票记录表前面已经给过建表语句核心就是唯一索引。CREATE TABLE vote_activity ( id bigint NOT NULL AUTO_INCREMENT, title varchar(128) NOT NULL, description varchar(512) DEFAULT NULL, cover_url varchar(255) DEFAULT NULL, start_time datetime NOT NULL, end_time datetime NOT NULL, status tinyint DEFAULT 0 COMMENT 0草稿 1进行中 2已结束, daily_limit int DEFAULT 1 COMMENT 每人每天可投票数, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE vote_option ( id bigint NOT NULL AUTO_INCREMENT, activity_id bigint NOT NULL, name varchar(128) NOT NULL, image_url varchar(255) DEFAULT NULL, vote_count int DEFAULT 0 COMMENT 冗余票数读取用, sort int DEFAULT 0, PRIMARY KEY (id), KEY idx_activity (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;daily_limit这个字段是为「每天可投 N 票」准备的。如果规则是活动期间总共只能投一票这个字段设成 0 表示不限制天数靠唯一索引控制如果要按天限制唯一索引里加日期同时用这个字段控制每天上限。3.2 索引与查询排行榜和我的投票怎么查得快投票系统两个高频查询一是排行榜按票数倒序取前 N二是「我投过哪些」。排行榜直接查选项表where activity_id? order by vote_count desc limit 20走idx_activity索引后再排序。如果选项特别多几千个可以再加一个(activity_id, vote_count)的联合索引让排序也走索引。「我投过哪些」查投票记录表where activity_id? and openid?正好命中唯一索引uk_activity_openid非常快。-- 排行榜走 activity_id 索引数据量大时建议加 (activity_id, vote_count) 联合索引 SELECT id, name, image_url, vote_count FROM vote_option WHERE activity_id ? ORDER BY vote_count DESC LIMIT 20; -- 查询某用户在某活动投了哪个选项 SELECT option_id FROM vote_record WHERE activity_id ? AND openid ?;注意如果活动允许每天投票排行榜的票数字段就不能简单 1 了要么按天分表要么在记录表里存日期、统计时按活动聚合。这是需求变更时最容易返工的点设计前一定要和需求方确认清楚投票规则。3.3 数据库连接池与事务隔离级别Java 后端连 MySQL连接池是绕不开的。Spring Boot 默认用 HikariCP配置里几个参数值得调spring: datasource: hikari: maximum-pool-size: 20 # 最大连接数按数据库承载能力设 minimum-idle: 5 # 最小空闲连接 connection-timeout: 3000 # 获取连接超时(ms) idle-timeout: 600000 # 空闲连接回收时间 max-lifetime: 1800000 # 连接最大存活时间maximum-pool-size不是越大越好。投票高峰期如果每个请求都占连接池子设太大反而把数据库连接数打满。一般按「数据库最大连接数 / 应用实例数」来估单实例 20 左右够用。connection-timeout设 3 秒超时快速失败避免请求堆积。事务隔离级别用 MySQL 默认的REPEATABLE READ就行。投票场景下插入记录靠唯一索引更新票数靠行锁不需要额外提升隔离级别。反而要注意事务别开太长投票接口里不要做远程调用比如调微信接口换 openid 要在事务外先做完否则事务持有锁的时间被拉长并发直接掉下来。4. Java 后端接口登录、投票、排行榜三个接口怎么写4.1 微信登录换 openid 与用户态维护小程序端调wx.login()拿到 code后端拿 code appid secret 去微信接口换 openid。这一步必须在服务端做secret 绝不能放在小程序里。RestController RequestMapping(/api/auth) public class AuthController { Value(${wx.appid}) private String appid; Value(${wx.secret}) private String secret; Autowired private RestTemplate restTemplate; PostMapping(/login) public Result login(RequestBody LoginDTO dto) { // 用 code 换 openid注意这是服务端调用secret 不暴露给前端 String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code dto.getCode() grant_typeauthorization_code; String resp restTemplate.getForObject(url, String.class); JSONObject json JSON.parseObject(resp); String openid json.getString(openid); if (openid null) { return Result.fail(登录失败); } // 生成自己的 token 返回给小程序后续请求带 token String token JwtUtil.sign(openid); return Result.ok(token); } }jscode2session是微信官方接口返回 openid 和 session_key。session_key 不要返回给前端它只用于服务端解密敏感数据。后端自己签一个 JWT 给小程序后续投票请求带这个 token拦截器解析出 openid 再处理业务。这样前端拿不到 openid也没法伪造别人身份投票。4.2 投票接口的并发控制与防刷投票接口是核心也是防刷的主战场。除了前面说的唯一索引兜底还要加几层防护PostMapping(/vote) public Result vote(RequestBody VoteDTO dto, HttpServletRequest request) { String openid JwtUtil.getOpenid(request); if (openid null) { return Result.fail(请先登录); } // 1. 活动校验状态、时间 VoteActivity activity activityMapper.selectById(dto.getActivityId()); if (activity null || activity.getStatus() ! 1) { return Result.fail(活动不存在或已结束); } Date now new Date(); if (now.before(activity.getStartTime()) || now.after(activity.getEndTime())) { return Result.fail(不在投票时间内); } // 2. 频次控制同一用户 1 秒内只能请求一次防脚本 String limitKey vote:limit: openid; Boolean allowed redisTemplate.opsForValue() .setIfAbsent(limitKey, 1, 1, TimeUnit.SECONDS); if (Boolean.FALSE.equals(allowed)) { return Result.fail(操作太频繁); } // 3. 业务投票唯一索引兜底重复 try { int count voteService.vote(dto.getActivityId(), dto.getOptionId(), openid); return Result.ok(count); } catch (DuplicateKeyException e) { return Result.fail(您已投过票); } }三层防护各有分工活动校验挡住无效请求Redis 的setIfAbsent做 1 秒频控挡住脚本连点唯一索引挡住重复投票。setIfAbsent是原子操作并发下只有一个能设置成功比「先查再设」可靠。频控时间设 1 秒是平衡体验和防护太长了正常用户手快也会被拦。提示如果活动允许每天投 N 票频控之外还要在业务层查当天已投数量用 Redis 计数器incr配合过期时间到当天 24 点比每次查数据库快得多。4.3 排行榜接口与缓存策略排行榜是读多写少的典型适合加缓存。但投票期间票数一直在变缓存又不能太久。我的做法是排行榜结果缓存 5 秒投票成功后主动删掉该活动的排行榜缓存下次请求重新生成。public ListVoteOption getRank(Long activityId) { String cacheKey rank: activityId; // 先查缓存 Object cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return (ListVoteOption) cached; } // 缓存没有查数据库 ListVoteOption list voteOptionMapper.selectRank(activityId, 20); // 写入缓存5 秒过期 redisTemplate.opsForValue().set(cacheKey, list, 5, TimeUnit.SECONDS); return list; }5 秒是个折中用户刷新页面基本能看到最新票数数据库压力又降下来了。投票成功后调redisTemplate.delete(rank: activityId)保证下一次读取是最新的。如果对实时性要求极高可以把缓存时间降到 1 秒或者干脆不加缓存靠数据库索引扛。5. 小程序端对接请求封装、登录态和投票交互的坑5.1 请求封装与 token 自动携带小程序端不要在每个页面里裸写wx.request封装一层统一处理 token、错误码和 loading。// utils/request.js const BASE_URL https://your-domain.com/api; function request(options) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { content-type: application/json, Authorization: wx.getStorageSync(token) || }, success(res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); } else if (res.data.code 401) { // token 失效重新登录 wx.removeStorageSync(token); reject(new Error(请重新登录)); } else { wx.showToast({ title: res.data.msg || 请求失败, icon: none }); reject(new Error(res.data.msg)); } }, fail(err) { wx.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); } module.exports { request };Authorization头带上 token后端拦截器统一解析。401 表示 token 过期清掉本地 token 让用户重新登录。这样每个业务页面只关心request({url:/vote, method:POST, data:{...}})不用重复处理鉴权。5.2 投票按钮的防连点与状态同步投票按钮最容易出的问题是用户手快连点两下发出去两个请求。虽然后端有频控和唯一索引兜底但前端也应该防一下减少无效请求。// pages/vote/vote.js Page({ data: { voting: false, votedOptionId: null }, async onVote(e) { const optionId e.currentTarget.dataset.id; if (this.data.voting) return; // 正在请求中直接返回 if (this.data.votedOptionId) { // 已经投过提示 wx.showToast({ title: 您已投过票, icon: none }); return; } this.setData({ voting: true }); try { const count await request({ url: /vote, method: POST, data: { activityId: this.data.activityId, optionId } }); this.setData({ votedOptionId: optionId }); // 更新对应选项的票数 const list this.data.options.map(o o.id optionId ? { ...o, voteCount: count } : o ); this.setData({ options: list }); wx.showToast({ title: 投票成功 }); } catch (err) { // 错误已在 request 里 toast这里不用重复 } finally { this.setData({ voting: false }); } } });voting标志位防止请求未返回时重复点击votedOptionId记录已投选项投过后再点直接提示。投票成功后只更新对应选项的票数不重新拉整个列表体验更顺。注意finally里一定要把voting置回 false否则一次失败后按钮就永远点不动了这是血泪经验。5.3 登录态过期与页面刷新小程序冷启动时 token 可能已经过期需要在app.js的onLaunch里做一次静默登录或者在请求返回 401 时触发重新登录。常见做法是封装一个ensureLogin方法页面onLoad时先调它。// app.js App({ async ensureLogin() { let token wx.getStorageSync(token); if (token) return token; return new Promise((resolve, reject) { wx.login({ success: async (res) { try { const data await request({ url: /auth/login, method: POST, data: { code: res.code } }); wx.setStorageSync(token, data); resolve(data); } catch (e) { reject(e); } } }); }); } });页面里await app.ensureLogin()之后再发业务请求保证 token 一定存在。如果 token 存在但已过期业务请求返回 401在 request 封装里清 token 并重新走一次登录用户无感知。6. 避坑与排查投票系统上线后最容易翻车的五件事6.1 票数对不上记录和票数字段不一致现象排行榜显示的票数和投票记录条数对不上有时多有时少。原因插入记录和更新票数没放在同一事务或者更新票数用了「先查再算再写」而不是原子1。解决两步操作加Transactional票数更新用set vote_count vote_count 1的原子写法绝不在应用层算好再写。6.2 一人多票并发下唯一索引没生效现象同一个用户短时间内投了多票记录表里有多条。原因唯一索引建在了错误的字段组合上或者表引擎是 MyISAM 不支持唯一约束的事务行为。解决确认唯一索引是(activity_id, openid)表引擎必须是 InnoDB。用show create table vote_record检查索引是否真的建上了。6.3 活动结束后还能投时间判断漏了状态现象活动过了结束时间用户还能投票。原因只判断了 status 没判断时间或者只判断时间没判断 status运营手动结束后仍可投。解决判断条件写成「status1 且 now 在起止时间内」两个条件都要。时间比较用数据库时间或服务端时间不要信前端传的时间。6.4 小程序请求 401 后死循环现象token 过期后页面一直弹「请重新登录」但登录接口也返回 401。原因登录接口本身也被拦截器拦了或者重新登录后没更新本地 token 就重试。解决拦截器放行/api/auth/login重新登录成功后先setStorageSync再重试原请求重试只做一次避免无限循环。6.5 排行榜缓存不更新投票后忘了删缓存现象投完票刷新页面票数没变过几秒才更新。原因排行榜加了缓存投票成功后没主动失效。解决投票成功的事务提交后redisTemplate.delete(rank: activityId)。注意要在事务提交后删事务没提交就删缓存下次读到的还是旧数据。7. 进阶用 Redis 扛住投票高峰与结果导出的具体做法投票系统真正的考验在活动快结束那几分钟大家集中冲票QPS 可能是平时的几十倍。这时候光靠 MySQL 行锁更新票数数据库很容易成为瓶颈。我的做法是把票数累加放到 Redis用HINCRBY对选项票数做原子累加投票记录仍然写 MySQL 保证不丢票数定时或定量回写数据库。// 投票成功后Redis 里累加票数 public void increaseCountInRedis(Long activityId, Long optionId) { String key vote:count: activityId; redisTemplate.opsForHash().increment(key, optionId.toString(), 1); } // 定时任务每 10 秒把 Redis 票数同步回 MySQL Scheduled(fixedRate 10000) public void syncCountToDb() { SetString keys redisTemplate.keys(vote:count:*); for (String key : keys) { Long activityId Long.parseLong(key.split(:)[2]); MapObject, Object counts redisTemplate.opsForHash().entries(key); for (Map.EntryObject, Object entry : counts.entrySet()) { Long optionId Long.parseLong(entry.getKey().toString()); Long count Long.parseLong(entry.getValue().toString()); // 用增量更新避免覆盖其他实例的写入 voteOptionMapper.syncCount(optionId, count); } } }HINCRBY是 Redis 的原子操作多个实例并发累加也不会算错。定时任务每 10 秒把 Redis 里的票数同步回 MySQL同步用「增量」而不是「覆盖」SQL 写成update vote_option set vote_count vote_count ? where id ?增量值取本次同步和上次同步的差值。这样即使同步期间又有新投票也不会丢。结果导出用 Java 的 EasyExcel 或 POI把选项和票数导成 Excel。数据量大时注意分页查别一次性select *把内存撑爆。public void exportRank(Long activityId, HttpServletResponse response) throws IOException { ListVoteOption list voteOptionMapper.selectRank(activityId, 1000); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment;filenamerank.xlsx); EasyExcel.write(response.getOutputStream(), VoteOption.class) .sheet(投票结果) .doWrite(list); }导出接口记得加管理端鉴权别让普通用户能拉到全量数据。分页查的时候用limit分批写1000 条一批避免大活动几万条记录一次性加载。最后说个我自己的习惯每次上线投票系统前我都会用 JMeter 或简单的脚本模拟 500 并发投同一个选项看票数是否精确等于请求数、有没有重复记录。这个压测花十分钟能省掉上线后被运营追着问「票数怎么不对」的一整天。希望帮到你。本文还有配套的精品资源点击获取
返回列表