简介:这是一套采用浏览器/服务器架构的网络投票系统源码,基于ASP动态页面技术实现,面向Web开发学习者、毕业生以及需要快速搭建内部投票活动的小型团队。系统后台功能完整,覆盖投票主题创建与编辑、选项增删改、配置参数调整等管理链路,并提供登录与密码验证的用户认证机制,能够区分管理员与普通用户的操作权限,有效保障投票数据的安全与流程规范。压缩包共56个文件,以26个asp核心页面为功能主体,配合gif、jpg等界面素材、txt说明文档以及数据库文件和安装脚本,整体仅116KB,属于轻量级、可快速部署的完整工程。已有197人学习下载,适合作为学习浏览器/服务器架构、ASP后端开发及后台管理系统设计的入门实例,也方便修改后直接用于课程设计或内部投票场景。
1. B/S方式网络投票系统:浏览器就是投票终端
B/S方式网络投票系统,说穿了就是一套跑在服务器上的网站:用户拿浏览器打开链接,或者拿手机扫一下二维码,就能看到候选人列表、完成投票、查看结果。终端不需要装任何软件,服务器端改一处逻辑,所有用户下一次访问立刻生效。真正做起来才发现,这类系统最容易翻车的地方不在页面,而在“怎么保证一个人只能投一次”。前端按钮禁用、刷新限制这些手段,在一个愿意写几行脚本的人面前基本等于没有。
B/S网络投票擅长解决的是企业内部评优、满意度调查、课程评教、集中表决这类场景:参与者会在短时间内集中涌入,投票时段有明确的开始和结束,结果要可核对,又要能挡住明显的刷票行为。适合的读者是后端或全栈工程师,打算在有限时间内交付一个可靠的投票功能,或者正在几个方案之间做选型判断。
2. B/S架构选型与数据模型:投票要禁得住并发,先得立住表结构
2.1 为什么B/S成了网络投票的默认形态
早期投票系统常做成C/S结构,用户需要安装客户端,服务器升级一次,所有终端都要跟着更新。放在内部办公网还能忍受,放到公网场景几乎是灾难。后来出现的另一种方案是做成小程序或App,体验不错,但要过平台审核、要发版本,投票活动往往就开放几个小时,等审核通过活动都结束了。
B/S方式把逻辑和界面全部集中在服务器端,浏览器只负责渲染和提交。参与者的门槛被降到最低:有浏览器就能投,不需要安装,不需要考虑操作系统是Windows还是macOS,手机、平板、电脑都一样。活动方的收益也很直接:投票时间、候选选项、统计口径都在自己手里,想改随时改,不用等发版。对短时效、高瞬时并发的“活动型系统”来说,这种集中控制的价值比什么都重要。
代价同样明显:服务器要扛住所有并发,且必须自己面对刷票问题。因为浏览器不像原生App那样能拿到强设备标识,服务器光靠IP、Cookie这类信息很难完美区分“一个真人”和“一个脚本”。所以这类系统的核心功夫不在前端,而在后端的数据约束和频控策略上。
2.2 核心数据模型:场次、候选人与投票记录三张核心表
我刚接触这类需求时,第一版只建了两张表:一张存候选人,一张存票数。结果发现活动一旦分时段、分场次,立刻改表;想查谁投了谁,完全无据可查。后来形成的稳定做法是拆成三张表:场次表、候选人表、投票记录表。
| 表名 | 用途 | 关键约束 | 说明 |
|---|---|---|---|
| vote_session | 一次投票活动 | start_time / end_time | 一个活动就是一个场次,控制开放时间 |
| candidate | 场次下的候选人或选项 | session_id 外键 | 一场活动可有多个候选人 |
| vote_record | 用户每次投票的真实记录 | (session_id, voter_key) 唯一索引 | 防重票的核心,也是后续对账的依据 |
这里面最容易被忽略的是 vote_record 的组合唯一索引。如果业务定义是“一个用户在一个场次里只能投一票”,那唯一键就应该建在 session_id 和 voter_key 上。voter_key 可以是登录用户的ID,也可以是匿名用户生成的Token。如果业务改成“一个人可选多名候选人”,那唯一键要变成 (session_id, voter_key, candidate_id),意思是每个人对每个候选人最多一票。这个决定必须在建表时定下来,后期靠代码去查重是不靠谱的。
2.3 技术栈与身份识别:先决定“一票”怎么定义
技术栈选择上,我用过前后端分离的方案,也用过服务端渲染的方案。小规模投票系统完全没必要上前后端分离和跨域配置,选一个后端框架加模板渲染,把页面直接交付,部署一个进程就能跑,维护成本最低。常见做法是Java生态里的Spring Boot配Thymeleaf,数据库用MySQL,再加一个缓存中间件做频控。没有缓存中间件也能跑,只是同一IP刷票时只能靠查数据库来限制,压力会大不少。
身份识别粒度是整个系统的地基,建议在动手前先回答清楚三个问题。第一,投票是记名还是匿名:记名直接拿用户ID当voter_key,匿名则需要生成随机Token放在Cookie里。第二,活动有没有登录要求:没登录的话,voter_key要足够随机,避免被人猜出来伪造。第三,允许一个人投几票:这直接决定唯一索引怎么建。这三个问题想清楚了,表结构基本就定了,后面改起来成本很高。
3. 从零手写投票核心链路:初始化库表、投票接口与结果页
3.1 初始化数据库:建表SQL与唯一索引
这一节先落地数据库。以下SQL是投票系统的最小可用结构,包含场次、候选人和投票记录三张表,并建好了防重票的组合唯一索引。
CREATE DATABASE vote_demo DEFAULT CHARACTER SET utf8mb4; USE vote_demo; CREATE TABLE vote_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '投票场次ID', title VARCHAR(200) NOT NULL COMMENT '场次名称', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用' ) COMMENT='投票场次'; CREATE TABLE candidate ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL COMMENT '所属场次', name VARCHAR(100) NOT NULL COMMENT '候选人/选项名称', sort_order INT DEFAULT 0 COMMENT '展示顺序', vote_count INT NOT NULL DEFAULT 0 COMMENT '冗余计数字段' ) COMMENT='候选选项'; CREATE INDEX idx_candidate_session ON candidate(session_id); CREATE TABLE vote_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL COMMENT '场次ID', candidate_id BIGINT NOT NULL COMMENT '投给哪个候选人', voter_key VARCHAR(64) NOT NULL COMMENT '投票人标识:用户ID或匿名Token', voter_ip VARCHAR(45) DEFAULT NULL COMMENT '来源IP,仅供参考', vote_time DATETIME NOT NULL COMMENT '投票时间', UNIQUE KEY uk_session_voter (session_id, voter_key) ) COMMENT='投票记录';这个SQL里的几个细节值得说明。首先,字符集用utf8mb4而不是utf8,因为候选人姓名、场次标题里都可能出现特殊字符或表情符号,utf8mb4才能完整存下。其次,voter_key设计成VARCHAR(64),无论放用户ID还是UUID都足够。第三,vote_count字段是冗余计数,它存在的原因是为了结果页快速展示,而不是每次都用COUNT去遍历记录表;但这个字段的高并发更新策略很重要,下一节会专门讲。
唯一索引uk_session_voter是整个表的灵魂。它保证了同一场次下,同一个voter_key只有一条记录。即使后端代码写漏了校验,数据库也会在最后一道防线拦下重复插入。后面所有防重逻辑,本质上都是围绕这条约束展开的。
3.2 投票接口:事务、唯一索引与重复投递的捕获
投票接口是系统的核心路径。常见做法是Controller接收参数,Service里完成校验和写入。下面这段Java代码是Service层的核心方法,我按最稳妥的方式来组织。
@Transactional public VoteResult vote(Long sessionId, Long candidateId, String voterKey, String ip) { VoteSession session = sessionMapper.selectById(sessionId); if (session == null) { return VoteResult.error("投票场次不存在"); } LocalDateTime now = LocalDateTime.now(); if (now.isBefore(session.getStartTime()) || now.isAfter(session.getEndTime())) { return VoteResult.error("不在投票时间段内"); } Candidate candidate = candidateMapper.selectById(candidateId); if (candidate == null || !candidate.getSessionId().equals(sessionId)) { return VoteResult.error("候选选项不存在"); } VoteRecord record = new VoteRecord(); record.setSessionId(sessionId); record.setCandidateId(candidateId); record.setVoterKey(voterKey); record.setVoterIp(ip); record.setVoteTime(now); try { insertRecordInNewTransaction(record); } catch (DuplicateKeyException e) { return VoteResult.error("您已经投过票,不能重复投票"); } candidateMapper.increaseCount(candidateId); return VoteResult.ok(); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void insertRecordInNewTransaction(VoteRecord record) { voteRecordMapper.insert(record); }这段代码的逻辑顺序是:先校验场次是否存在以及是否在投票时间内,再校验候选人是否属于当前场次,然后插入投票记录,插入失败则捕获唯一索引冲突。时间校验用的是服务器本地时间LocalDateTime.now(),不是前端传上来的时间。前端展示的倒计时只是提示,最终裁决权必须在后端。
insertRecordInNewTransaction方法单独开了REQUIRES_NEW事务。这样做的原因是:如果直接在当前事务里执行insert并捕获DuplicateKeyException,Spring会把当前事务标记为rollback-only,即使代码catch住了异常,外层事务提交时仍会抛出UnexpectedRollbackException,用户看到的会是500错误而不是“您已经投过票”。把插入放到独立事务里,重复键冲突的影响范围就被限制在插入这一步,外层事务可以正常继续或干净地返回业务错误。
candidateMapper.increaseCount对应SQL是UPDATE candidate SET vote_count = vote_count + 1 WHERE id = ?。这里必须用数据库原子的自增语句,不能先查出vote_count再赋值回去,否则并发时会出现丢失更新。插入记录和更新计数放在同一个外层事务里,要么都成功,要么都回滚,保证数据一致。
3.3 前端投票页与结果页:提交与展示分离
前端页面不需要多复杂,一个候选人列表加一个提交按钮就够了。真正的要点是提交时把身份Token一起交给后端,并在提交成功后及时刷新结果区域。下面给出一个最小可运行的示例骨架。
<script> async function submitVote(candidateId) { const resp = await fetch('/vote/submit', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({sessionId: sessionId, candidateId: candidateId}), credentials: 'same-origin' }); const result = await resp.json(); if (result.code === 0) { document.getElementById('tips').innerText = '投票成功'; document.getElementById('submitBtn').disabled = true; loadResult(); } else { document.getElementById('tips').innerText = result.msg; } } async function loadResult() { const resp = await fetch('/vote/result?sessionId=' + sessionId); const data = await resp.json(); // 用一个开源图表库渲染饼图或柱状图 renderChart(data); } </script>这里有个容易忽略的配置:fetch请求必须带上credentials: 'same-origin',否则匿名场景下浏览器不会把用于识别身份的Cookie发送给后端,后端每次拿到的都是新用户。按钮置灰只是用户体验层面的反馈,防刷靠的还是后端唯一的约束和频控逻辑。接口返回统一用code和msg的JSON结构,好处是前端判断逻辑非常简单,也方便后续接日志和告警。
3.4 从单选扩展多选和加权:改一处唯一索引和统计口径
很多投票活动不是简单的单选,比如优秀员工可以选三人,或者允许给每个候选人打分。这类扩展不需要推翻表结构,只需要调整约束和统计口径。多选场景下,唯一索引从(session_id, voter_key)改成(session_id, voter_key, candidate_id),含义就变成“每个用户对每个候选人最多一票”。统计结果时直接对vote_record做GROUP BY candidate_id取COUNT。
加权的场景稍微复杂一点。常规做法是给candidate表增加一个weight字段,比如讲师评分权重1.0,专家评分权重2.0。统计时会用如下的SQL:
SELECT candidate_id, SUM(weight) AS total_score FROM vote_record r JOIN candidate c ON c.id = r.candidate_id WHERE r.session_id = ? GROUP BY r.candidate_id ORDER BY total_score DESC;要注意weight不能存在vote_record里合并统计。因为权重要跟着候选人走,而不是跟着某张票走;把weight冗余进投票记录的做法,一旦活动中途调整权重,历史数据全要重算,很容易出事。
4. 上线遇坑与排查:并发、重复票与打不开的服务器
4.1 前端按钮禁用拦不住刷票
现象:投票页面提交后按钮置灰,看起来不能再投了。但有人直接对着后端接口发POST请求,一次提交几十张票,全部成功。
原因:前端所有限制都是给正常用户看的。脚本绕过页面后,唯一能依赖的就是后端的校验和数据约束。
解决:第一,vote_record上必须有组合唯一索引,这是最后一道闸;第二,后端必须自己校验voter_key是否已存在,不能把“已投票”状态只存在内存或前端变量里;第三,配合缓存中间件做IP和账号维度的频控。三者叠加才是可靠方案,缺任何一个都存在漏洞窗口。
4.2 高并发下的丢失更新:票数会悄悄变少
现象:用压测工具模拟2000人同时投票,结果页显示的总票数只有1700多,但数据库里vote_record实际插入了2000条记录。
原因:计数更新写成了先SELECT vote_count,再在内存里加1,最后UPDATE。两个人同时读到了同一个旧值,分别加1写回,最终只增加了一次。这是典型的丢失更新问题。
解决:计数更新必须用数据库原子的自增语句,UPDATE candidate SET vote_count = vote_count + 1。同时,要以vote_record的聚合结果作为核对依据,定期跑对账SQL:SELECT candidate_id, COUNT(*) FROM vote_record WHERE session_id = ? GROUP BY candidate_id;,把聚合结果和candidate表的冗余计数做比较。出现不一致时,以vote_record的聚合结果为准去修复count字段。
4.3 Session漂移、本机时间绕过与端口不通
这三个问题几乎每个投票系统上线都会遇到一次,逐个说。
Session漂移的现象是:用户第一次请求分到了服务器A,登录状态写进了A的内存;第二次请求被负载均衡转发到了服务器B,B没有这份Session,用户又被要求重新登录。在多个实例部署时几乎是必然发生。原因就是默认Session存在本机内存。解决思路有两条:要么把Session统一放到集中式缓存中,所有实例共享;要么放弃Session,把身份信息做成签名的Token,由后端验签,彻底绕开“状态保存在服务器”这个问题。投票系统这种请求频率不高的场景,用签名Token更干净。
本机时间绕过的现象是:投票场次还没到开始时间,有人把自己电脑系统时间改到开始时间之后,页面上的倒计时变成了已开始,居然真的投成功了。原因很简单:前端用的是本机时间判断。解决方法是后端所有时间判断一律使用服务器时间,前端倒计时只做展示,提交接口里重新校验start_time和end_time。数据库记录投票时间时也用数据库的CURRENT_TIMESTAMP,保证时间源的统一。
端口不通的现象是:本地用localhost访问一切正常,部署到服务器后用公网IP访问却一直超时。原因通常是服务绑定在了127.0.0.1上,只监听本机回环地址;或者是服务器防火墙没有放行对应端口。解决方法是启动时显式指定监听地址为0.0.0.0,例如Spring Boot里加--server.address=0.0.0.0;然后检查云服务商安全组和本机防火墙的入站规则。排查顺序一般是:先curl http://localhost:8080确认进程活着,再curl http://服务器内网IP:8080确认监听地址正确,最后用公网IP确认安全组放行。
5. 进阶验证与提升:并发压测、实时刷新与匿名Token
投票功能写完后,我习惯先用ab工具做一次小型压测,再决定是否投入真实使用。ab可以模拟指定数量的请求和并发度。压测命令大致是这样:
ab -n 200 -c 20 -T "application/json" -p vote.json http://your-server/vote/submit-n是总请求数,-c是并发数,-p指定请求体文件。注意,如果测试数据里所有请求都用同一个voter_key,压测验证的是“重复投票拦截是否可靠”,数据库里应该只有一条投票记录。如果想测系统吞吐量,就得生成几百个不同的voter_key轮换使用。压测完成后,用对账SQL核对vote_record和candidate.vote_count是否一致,这是上线前最后一道检查。
结果页实时更新这块,小型系统直接用轮询就够了,每5秒请求一次结果接口,实现简单且没有额外基础设施。实时性要求高的话,可以改成SSE推送,服务器主动往浏览器推票数变化。匿名投票的安全性还可以再进一步:生成Token时做签名,用密钥对voter_key加时间戳做HMAC,防止用户自己篡改Cookie伪造身份。
我做这类系统的习惯是:先拿一个小范围的真实投票试跑一轮,哪怕只有五十人参加。试跑能暴露出时间配置、统计展示、移动端适配这些测试数据发现不了的问题。没有一次绕过这个步骤能直接上大场次,投票系统上线容易,出了事想给参与者和活动方解释清楚很难。希望帮到你。
本文还有配套的精品资源,点击获取