
简介这套投票系统源代码是一个基于Java Web的课堂实践项目面向学习JSP/Servlet与数据库操作的开发者解决“学生给老师投票”场景下的界面展示、票数累加与防刷票问题。资源包含24个文件压缩包956KB以java源码、class编译文件、jsp页面为主同时有jar依赖库、xml配置及Eclipse项目元数据适合导入IDE直接运行或对照学习。已有1931人学习下载热度不错。代码实现投票界面、教师信息表格和红色进度条得票显示点击投票链接即可完成票数加一同时项目对数据库访问做了封装便于复用和维护并引导思考Cookie、IP限制、验证码等防刷票手段。对于正在完成Web课程设计或想了解投票类项目完整结构的读者这套源码提供了从页面到数据库、再到安全加固的清晰线索。1. 投票系统源代码的防刷票问题默认方案为什么撑不过上线第一晚上线第一晚奖品还没发出去后台显示某位选手的票数涨了 2400。这不是运营失误而是有人把投票接口当成了提款机。投票系统源代码看起来很简单一张活动表、一张候选人表、一张投票记录表前端点一下按钮后端 INSERT 一条记录。可一旦参与人数、奖品价值或者榜单排名上来了刷票就是必然出现的问题而且对方用的不是暴力破解是正规的 HTTP 请求和你花两个下午写出来的页面看不出区别。这篇文章想讲清楚一件事在你把票数展示页做得漂漂亮亮之前先让“一个人只能投一次”在数据库和接口两层同时成立。适合接外包活动页、做运营内部评选、或者想自己动手把投票网站做扎实的团队。2. 投票表设计先堵三类漏洞建表、唯一约束与字段取舍网上能找到的投票系统源代码很多大部分把重心放在页面效果、排名展示和后台管理上防刷票往往只剩一个前端按钮禁用。真上线后你会发现不管前端加了多少层限制只要后端表结构和接口逻辑没有从源头校验刷票者用命令行加几个请求头就能绕过去。常见漏洞可以归成三类第一类是前端禁点按钮置灰、localStorage记一次清除浏览器数据或者直接在控制台调接口就绕过了第二类是只校验“有没有登录”不校验“这个用户在这次活动里投过没有”于是注册机批量注册账号就能反复投第三类是完全没有频率限制单 IP 只要换个参数就可以每秒刷几十次。这三类漏洞的共性是同一个系统里没有一个可检验的“投票人身份”自然也没法判断这次投票是不是同一个人的第二次。2.1 三类漏洞的共性没有可检验的“投票人身份”先说结论防刷票的根基不是某个酷炫的验证码而是“身份可识别”。匿名投票场景里没有登录体系你只能靠 IP、浏览器指纹、客户端行为这些信号组合出一个近似身份。有了这个身份才能做两件事第一判断同一个人是否已经投过第二判断同一个人/同一个来源在单位时间内是不是投得太快。很多项目失败在把身份定义得太窄。只存 IP办公室 NAT 下几十个真实用户公用一个出口会大面积误杀只存 Cookieclear 掉就变成新人只存设备指纹手机换了浏览器就重新计数。比较稳的做法是组合多个弱信号生成一个服务端指纹并且把这条指纹作为投票记录里的核心字段。注意这个指纹的计算必须放在后端完成前端提交的指纹原文只能作为输入之一不能直接入库当身份。2.2 建一张不会放过重复票的 votes 表设计投票表时最核心的一条是给(poll_id, voter_hash)加唯一约束。这样不管应用层代码怎么写漏了数据库层面都会拦住同一个人在同一个活动里的第二次投票。CREATE TABLE votes ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, poll_id INT UNSIGNED NOT NULL, candidate_id INT UNSIGNED NOT NULL, voter_hash CHAR(64) NOT NULL COMMENT 服务端计算的投票人指纹sha256, fingerprint_version TINYINT NOT NULL DEFAULT 1 COMMENT 指纹算法版本换盐值时必须更新, ip_hash CHAR(64) NOT NULL COMMENT IP 的 sha256只用于限流统计不存明文, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_poll_voter (poll_id, voter_hash), KEY idx_candidate_time (candidate_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_poll_voter这条唯一索引是整个防刷票方案的兜底。它保证即使前端被绕过、接口被并发调用数据库也只会接受同一个人在同一活动里的第一条投票。idx_candidate_time是给排行榜查询用的候选人维度的计数会频繁按created_at做聚合不加这个索引数据量上来以后排行榜接口会先超时。注意voter_hash不是简单的随机串它是由服务端用poll_id IP User-Agent 前端指纹 盐值算出来的具体算法下一章讲。2.3 IP 不存明文指纹哈希字段的取舍很多入门项目会把客户端 IP 原样存进数据库理由是方便查刷票来源。但这里有两个问题一是隐私IP 属于个人信息你的投票活动没有义务也没有必要长期保存明文二是误判IP 本身不是身份公共出口下几十个人共用同一个 IP 是常态拿 IP 当唯一身份必然误伤。所以我一般只存ip_hash也就是用加盐的 SHA-256 算一遍。这个字段拿到限流逻辑里做“同一来源每秒/每分钟多少次”的统计但绝不拿它当“同一个人投没投过”的判断依据。真需要追溯刷票来源时日志系统里单独记录脱敏后的 IP 前段和请求头活动结束自动清理比堆在投票表里干净。2.4 为什么“前端禁点 localStorage”只能算提醒网上不少投票系统源代码的防刷票只有这个点击后按钮变灰localStorage写一个voted1。这个设计只能挡住最懒的重复点击。想绕开的人有至少三种办法清除localStorage重新投换个浏览器投直接用curl调接口投。前端限点的作用是降低正常用户的误操作不是防刷票这点要摆正位置。真正有效的是后端校验、数据库唯一约束和服务端频率限制前端提醒只是让 UI 体验更友好。3. 服务端防刷票核心源代码指纹生成、唯一索引与并发兜底这一章是实际实现的核心。我建议的投票接口防刷票流程分四步接收前端提交的指纹素材在服务端计算voter_hash先做频率限制再插入投票记录并靠唯一索引兜底。每一步都有对应的代码我会写一个 Flask 版的最小实现但思路可以原样搬到 PHP、Java、Node 或者 Go 里。3.1 用 SHA-256 组合指纹把“一个人”钉在数据库里先看指纹生成函数。注意盐值VOTE_SALT必须放到配置中心或环境变量不要写死在源码里提交到 Git。盐值泄露后攻击者可以本地构造同样的指纹绕过唯一索引。import hashlib VOTE_SALT 换成一个随机长字符串 # 从环境变量读取勿硬编码 def build_voter_hash(poll_id, ip, ua, fp_token): raw |.join([ str(poll_id), ip, ua, fp_token, VOTE_SALT, ]) return hashlib.sha256(raw.encode(utf-8)).hexdigest()fp_token是前端上报的浏览器指纹串ua是 User-Agentip取request.remote_addr。把这些参数用竖线拼起来再哈希目标是把“某个人在某个设备上通过某个网络参加某个活动”钉成一个不可逆的字符串。poll_id一定要放进哈希里否则同一个用户在不同活动里的投票会生成相同指纹导致活动之间互相误判。3.2 用数据库唯一约束兜底而不是把查询当防线接下来是投票记录模型。SQLAlchemy 的写法里把唯一约束放到模型层保证建表时索引一定存在。from app import db class VoteRecord(db.Model): __tablename__ votes id db.Column(db.BigInteger, primary_keyTrue, autoincrementTrue) poll_id db.Column(db.Integer, nullableFalse) candidate_id db.Column(db.Integer, nullableFalse) voter_hash db.Column(db.String(64), nullableFalse) fingerprint_version db.Column(db.SmallInteger, default1) ip_hash db.Column(db.String(64), nullableFalse) created_at db.Column(db.DateTime, nullableFalse) __table_args__ ( db.UniqueConstraint(poll_id, voter_hash, nameuk_poll_voter), )注意注释不要写成“先查一次有没有记录没有就插入”。查和插之间有间隙20 个并发请求同时穿过查询就会产生 20 条投票记录。唯一的可靠防线是数据库唯一索引。3.3 前端指纹采集哪些特征值得传哪些只是装饰前端负责采集指纹素材但不要在前端生成最终哈希。一个轻量级的采集脚本长这样async function collectFingerprint() { const canvas document.createElement(canvas); const ctx canvas.getContext(2d); ctx.textBaseline top; ctx.font 14px Arial; ctx.fillText(anti-vote-check, 2, 2); const canvasHash canvas.toDataURL(); const parts [ navigator.userAgent, ${screen.width}x${screen.height}, new Date().getTimezoneOffset(), navigator.languages ? navigator.languages.join(,) : navigator.language, canvasHash ]; return parts.join(|); } // 调用投票接口前 const fpToken await collectFingerprint(); fetch(/api/polls/1/vote, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ candidate_id: 3, fp_token: fpToken }) });这里面canvasHash相对稳定同一台电脑同一浏览器几乎不变屏幕分辨率和时区用来做辅助交叉验证navigator.userAgent虽然可以伪造但它放在哈希里会让伪造者多改一个字段增加成本。注意不要把navigator.plugins这类苹果 Safari 已经废弃的属性放进去移动端兼容性会很差。3.4 投票接口的完整防刷流程代码最后把以上逻辑串成一个接口。这里用 MySQL 的INSERT ... ON DUPLICATE KEY UPDATE配合rowcount判断结果避免捕获异常带来的额外开销。from flask import request, jsonify from sqlalchemy.dialects.mysql import insert app.route(/api/polls/int:poll_id/vote, methods[POST]) def vote(poll_id): data request.get_json() or {} candidate_id data.get(candidate_id) fp_token data.get(fp_token, ) if not candidate_id: return jsonify({ok: False, code: BAD_PARAM}) ip request.remote_addr ua request.user_agent.string if request.user_agent else voter_hash build_voter_hash(poll_id, ip, ua, fp_token) # 频率限制见第 4 章 if not consume_sliding_window(ip, quota20, window_seconds60): return jsonify({ok: False, code: RATE_LIMIT, need_captcha: True}) ip_hash hashlib.sha256(f{ip}|{VOTE_SALT}.encode()).hexdigest() stmt insert(VoteRecord).values( poll_idpoll_id, candidate_idcandidate_id, voter_hashvoter_hash, fingerprint_version1, ip_haship_hash, created_atdatetime.utcnow() ).on_duplicate_key_update({id: VoteRecord.id}) result db.session.execute(stmt) db.session.commit() if result.rowcount 0: return jsonify({ok: False, code: ALREADY_VOTED}) return jsonify({ok: True})这里把on_duplicate_key_update设置成{id: VoteRecord.id}走一个无实际变化的更新MySQL 检测到重复时影响行数为 0代码就能把“重复投票”和“投票成功”区分开。参数上quota20, window_seconds60表示同一 IP 一分钟内最多投 20 次这个值后面要按活动规模调。还要注意request.remote_addr是唯一可信的 IP 来源不要直接信任任何请求头里的客户端 IP 字段。4. 频率限制与验证码联动滑动窗口参数这样调不误伤真人投票指纹和唯一索引解决了“同一个人重复投”的问题但防不住攻击者批量换指纹、换设备、换出口 IP 来模拟大量新用户。这类行为只能靠频率限制去识别真实用户投一次票会先加载页面、读题、点击、等待结果平均耗时至少几十秒而脚本刷票的请求间隔是几十毫秒。有人会说那我用固定窗口计数不就行了实际操作里固定窗口有个非常经典的翻车边界。4.1 滑动窗口限流为什么固定窗口总是被卡在临界点固定窗口的意思是“每分钟最多 N 次”实现简单但攻击者会在 59 秒末刷满第一波下一秒进入新窗口再刷一波。比如限制每分钟 60 次攻击者在 00:59 发 60 条01:00 再发 60 条单位时间内实际通过了 120 条。滑动窗口不会有这个缺口。import time import uuid import redis r redis.Redis.from_url(redis://127.0.0.1:6379/0) def consume_sliding_window(ip, quota20, window_seconds60): key fvote:rl:{ip} now int(time.time()) member f{now}-{uuid.uuid4().hex} pipe r.pipeline() pipe.zremrangebyscore(key, 0, now - window_seconds) pipe.zadd(key, {member: now}) pipe.zcard(key) pipe.expire(key, window_seconds 5) _, _, count, _ pipe.execute() ok int(count) quota if not ok: # 把刚才加进去的这条挪掉避免计数越来越大 r.zrem(key, member) return ok这个实现用的是 Redis 有序集合member 用“时间戳-随机串”避免同一毫秒内并发请求互相覆盖。每次计数前先删掉窗口之外的旧记录再把自己加进去最后数集合大小。超出配额就把自己移除这样不会污染下一次判断。window_seconds 5这个过期时间给集合留一点缓冲防止重建集合的高频开销。4.2 阈值怎么定一张可以直接参考的参数表阈值是投票系统里最需要反复调的东西。定太高等于没限流定太低会把正常用户关在门外。我一般按这个表起步再根据真实日志微调限制对象时间窗口阈值设计意图voter_hash同一个人整个活动期1活动默认规则由唯一索引强制单个出口 IP60 秒20挡住突发脚本给办公室 NAT 留余量单个出口 IP1 小时60超出真人正常投票频率同指纹串10 分钟5同一浏览器环境短时间多次投票判可疑同 IP 段/地区10 分钟500只告警不拦截用于人工核查注意“同一 IP 段”这一行很关键。教学楼、商场 Wi-Fi、大型展会这类场景会出现成百上千人共用出口 IP 的情况拦死会把真实用户全拒之门外。所以大流量活动里IP 段维度只做观测和告警不要直接参与拦截。4.3 频率异常后的第二道关行为验证码一旦频率限制触发直接拒绝并不友好因为可能是真人用户手滑连续点了多次。更常见的做法是返回need_captcha: true让用户先做一次行为验证通过后再继续投票。app.route(/api/polls/int:poll_id/captcha, methods[POST]) def resolve_captcha(poll_id): data request.get_json() or {} ticket data.get(ticket) result captcha_provider.verify(ticket) # 行为验证码服务商的 SDK if not result.ok: return jsonify({ok: False, code: CAPTCHA_FAILED}) r.setex(fvote:captcha:pass:{request.remote_addr}, 600, 1) return jsonify({ok: True})行为验证码服务商一般会给你一个ticket前端拿到后传到后端由后端调用服务商的验证接口确认。永远不要在前端判断验证码对不对。验证通过后给这个出口 IP 发一张 10 分钟有效的“通行证”10 分钟内再触发限流时如果带着有效通行证就直接放行避免真人用户在验证完又要重新投。4.4 验证码放行的 Redis 回写流程放行逻辑要改一下第 3 章的投票接口频率限制不通过时不要立刻返回失败先检查有没有通行证。captcha_key fvote:captcha:pass:{ip} if not consume_sliding_window(ip, quota20, window_seconds60): if not r.exists(captcha_key): return jsonify({ok: False, code: RATE_LIMIT, need_captcha: True})通行证的过期时间要仔细考虑。太短真人用户填完验证码回来又过期太长攻击者人工打码一次就能猛刷很久。10 分钟是比较平衡的起点。如果活动周期短、奖品集中在最后三天建议把通行证缩短到 2 分钟并且同一 IP 每天最多领取 5 次通行证。5. 防刷票上线避坑与排查5 条真实踩过的坑前面几章把结构和代码讲完了但上线后真正的麻烦往往来自环境而不是代码逻辑。以下 5 个坑是我做投票活动时实打实踩过的按“现象 → 原因 → 解决”列出来看完能少走不少弯路。5.1 同办公室全被拒投NAT 出口把几十个真人认成一个现象是活动上线后某公司员工反馈“我们办公室所有人都投不了票”而单个家庭宽带用户完全正常。查日志发现这批人的 IP 全是同一个频率限制直接把它们按一个来源处理了。原因是公司、学校网络普遍做 NAT几十个人共享一个公网出口 IP。解决方法是把“同 IP 限流”定位成“拦截脚本”而不是“拦截第二个人”阈值调宽到 60 秒 20 次同时把身份判断完全交给voter_hash。另外给出口 IP 加上通行证机制真人验证过一次就不再多拦。5.2 同一部手机换网络后又能投别把 IP 当身份这个坑在开发阶段很难发现。测试人员在同一 Wi-Fi 下验证“投过不能重复投”通过了但上线后有人把 Wi-Fi 关掉切到 4G发现又能投一次。原因是早期版本把 IP 拼进了voter_hash而 IP 在网络切换后变了身份自然就变了。解决方法是 IP 只参与限流统计不参与“我是谁”的判断。voter_hash应该以poll_id 设备指纹 UA 盐值为主IP 可以作为辅助信号但不能主导哈希结果否则任何换网络、换代理出口的行为都能重置身份。5.3 X-Forwarded-For 伪造导致 IP 限制全线失效现象是压测时发现只要在请求里加一个伪造的X-Forwarded-For头IP 限流就像不存在一样每次伪造一个 IP 就能绕过。原因在后端代码直接读了request.headers.get(X-Forwarded-For)作为客户端 IP。这个头是标准转发头来自上一跳如果应用直接可访问客户端想填什么就填什么。解决方法是只信request.remote_addr因为它是 TCP 连接对端的真实来源。如果前面有 Nginx 转发就在 Nginx 里拿到真实 IP 后用一个自定义内部请求头传并且 Nginx 配置里主动清掉客户端传来的同名头。5.4 并发双投出现两条成功记录唯一索引到底有没有用现象是我自己在压测时20 个线程同时提交带着同一个指纹的投票数据库里出现了两条votes记录。翻代码发现最初版本是“先 SELECT 再 INSERT”这个查重逻辑在并发下就是摆设。20 个请求同时 SELECT都没查到记录然后一起 INSERT全部通过。解决方法是建uk_poll_voter唯一索引然后改用第 3 章的INSERT ... ON DUPLICATE KEY UPDATE写法让数据库冲突检测来兜底。这里有个经验任何“查一下再写”的防重逻辑都不可靠唯一约束才是并发下的刚性防线。5.5 盐值轮换导致历史票全部失效用户又能再投一次活动进行到第 7 天安全团队要求轮换盐值照做之后出了一个诡异问题之前投过票的人再投时系统不认识了。原因是voter_hash里包含盐值换盐后同一个人的哈希完全变了历史记录匹配不上。这比误杀更危险等于给刷票者打开了重新投票的口子。解决方法是换盐时同时维护新旧两份盐校验重复时对两份盐分别计算哈希任一命中都判定“已投票”然后在下一个自然日再跑一次迁移把旧记录的哈希按新盐重算一遍最后摘掉旧盐。以后轮换盐值一律走这个双盐过渡流程。6. 验证与灰度用压测脚本确认防刷票配置没白写代码写完之后最重要的一件事是验证“唯一索引真的能挡并发”。很多项目上线后才做验证结果活动已经被人刷穿了。我自己习惯在本地先跑一轮并发压测确认结果符合预期再上灰度。6.1 并发压测脚本20 路请求能不能只过 1 条下面这个脚本模拟 20 个并发线程、总共 200 次投票请求全部使用相同的 User-Agent 和相同的指纹素材。如果防刷票配置正确最终只会成功 1 条其他请求都应该被ALREADY_VOTED或RATE_LIMIT拦下。import concurrent.futures import requests from collections import Counter URL http://127.0.0.1:5000/api/polls/1/vote HEADERS {User-Agent: Mozilla/5.0} # 固定 UA 模拟脚本批量刷 PAYLOAD {candidate_id: 3, fp_token: same-fingerprint-token} def once(_): r requests.post(URL, jsonPAYLOAD, headersHEADERS, timeout5) return r.json().get(code, UNKNOWN) with concurrent.futures.ThreadPoolExecutor(max_workers20) as pool: codes list(pool.map(once, range(200))) print(Counter(codes))运行后如果出现{OK: 2}或者更多说明唯一索引没生效或者voter_hash没把固定输入算成同一个值。跑这个脚本还能顺带观察 Redis 里的滑动窗口计数是否符合预期200 次请求下来RATE_LIMIT的出现次数应该在 5 次以上说明限流确实在起作用。6.2 灰度期该盯哪几个指标压测通过只是第一步灰度阶段要盯的是真实流量的分布。我会用一份独立日志表或日志文件记录每一次投票的完整链路包括投票接口名、poll_id、voter_hash前 8 位、ip_hash、指纹版本、返回码和耗时。灰度期重点看这几个指标指标正常区间危险信号ALREADY_VOTED 占比小于 20%大于 80%可能有人在重放请求RATE_LIMIT 触发率小于 5%大于 20%阈值太紧或被脚本集中刷验证码通过率大于 70%小于 40%验证码弹得太频繁或体验太差单 IP 每分钟投票数小于 20持续大于 200大概率是出口 IP 池在轮换日志里最好把指纹版本也记录下来。将来盐值轮换、指纹算法升级时没有版本号的历史记录就是一笔糊涂账。这个日志字段就是防刷票的“后悔药”活动期间可以不看但需要追溯时没有它寸步难行。活动结束后把这些日志在冷存储里保留三个月再清理已经是我的习惯大部分的刷票追查都发生在前三天但总有个别情况拖到快结算时才被举报。希望帮到你。本文还有配套的精品资源点击获取