区块链投票系统开发课设指南:智能合约、防重投与链上审计
2026/9/12 15:18:34 网站建设 项目流程

简介:基于区块链技术的投票系统课设/毕设完整源码项目,面向计算机类专业学生、高校教师及有二次开发需求的开发者,用于解决区块链课程设计、毕业设计等场景中的选题与实现问题。资源共88个文件,压缩包约849KB,以36个JavaScript脚本、11个Solidity智能合约、7个HTML页面、5个CSS样式及JSON配置、Markdown说明为主要构成,另含设计报告docx和参考资料,覆盖智能合约、前端交互与合约部署等模块。项目实现了投票创建、参与、结果管理的完整流程,核心合约涉及VoteFactory、Vote、Member等,前端页面与合约交互完整,结构清晰、易上手,适合直接运行或进一步扩展。同时附带独立的参考学习项目(Truffle工程)和说明文档,便于对照理解底层逻辑与二次开发。已有120人学习下载,尤其适合用作区块链课程设计、毕业设计选题,也是深入学习区块链应用开发的实践素材。

1. 基于区块链的课设投票系统,难点不在链而在“校验闭环”

一门区块链课设如果只做到“智能合约能跑、前端能调通”,答辩老师大概率会追问一句:“你存的投票数据真的不可篡改吗?前端改掉返回值怎么办?”这个项目标题之所以值钱,是因为它默认你需要交付的不只是“合约 + 页面”,而是把投票流程里的身份校验、防重复投票、结果审计三点串成一个闭环。区块链在这套系统里真正解决的,是“运行在若干不可信节点上的公开账本下,任何节点都无法单方面伪造或篡改结果”——至于选民用哪个浏览器、管理员怎么发币,那都是外围。

从实现路径上说,课设规模不需要接入公链,也不需要自己搭 PoW 共识。最常见的方案是:Ganache 本地模拟链或私有链 + Solidity 智能合约 + Web3.js/ethers.js 前端(或 Python Flask 做中间层)。合约里放选票数据,前端只负责调用和展示。全文我会讲清数据模型怎么设计、合约怎么写才能保证一地址一票、Ganache 部署时参数怎么设、前端落后节点数据怎么同步,以及答辩时最容易被问的三类边界问题。适合第一次做区块链课设、或已经能跑通 Hello World 但想补上“工程合理性”这部分的人。

2. 投票系统的技术与架构选型:先定账本,再定“谁来记账”

2.1 为什么课设场景下优先选 PoA 或本地链而非公链

区块链投票系统的核心诉求是“结果公开可验证”。公链(以太坊主网、Sepolia 测试网)满足这一点,但课设会遇到三个现实障碍:交易要手续费、出块时间不确定、部署合约需要真实测试币。管理员在本地跑一个 Ganache 节点,等于自己持有记账权,在这种“半可信”环境里模拟的其实是联盟链思路。从演示逻辑上讲完全成立,因为投票系统的信任假设本来就是“计票方不能篡改”,而不是“全网任何人都能参与记账”。

我用 Ganache 的频率远高于 Hardhat Network,因为课设演示看重的是交易哈希可见、区块时间戳稳定、账户余额可控。Ganache 启动时就能预设 10 个带 100 ETH 的账户,省去水龙头申请环节。如果你希望更有“区块链感”,可以在部署时切到 Sepolia 测试网,但需要提前把合约部署方的私钥放进环境变量,避免硬编码进前端代码。两种方案我都跑通过,课设答辩用本地链足够,追加“测试网可迁移”说明反而加分。

2.2 投票数据模型:把“一地址一票”映射成链上结构

合约设计的第一要务是定义状态变量。我用过的最稳定投票合约结构如下:

  • Voter结构体:bool isRegistered(是否在白名单)、bool hasVoted(是否投过票)、uint votedProposalId(投给谁)
  • Proposal结构体:string nameuint voteCount
  • 合约状态:address owner(管理员)、Voter[] votersmapping(address => uint) voterIndexProposal[] proposalsuint votingEndTime

mapping(address => uint) voterIndex在这里不是为了省 gas,而是为了支持“撤销注册”这类课后扩展需求。如果只用一个mapping(address => Voter),遍历选民列表会变成 O(n) 问题;加了索引映射,O(1) 找到下标删除。课设答辩时老师大概率会问“为什么 mapping 和数组共存”,这就是标准答案。

2.3 前端架构:直接调合约还是加一层后端

课设最常见的落地组合是:

层级技术选择职责
合约层Solidity 0.8.x存储选票、校验权限、返回结果
部署层Truffle 或 Hardhat编译、迁移、测试脚本
链层Ganache CLI提供 RPC 接口、账户、挖矿
前端Vue/React + ethers.js连接钱包、签名交易、展示结果
可选Express/FastAPI托管静态资源、封装读接口

我不推荐课设再套一层后端数据库来存投票结果,那样会模糊区块链的不可篡改性。可以把后端压缩成纯静态服务器,只部署前端页面,所有状态都从合约读。合约里每次投票都触发Voted事件,前端监听事件更新UI——这样一来,即使刷新页面,前端走ethers.js重新queryFilter事件列表,也能恢复完整计票状态,不依赖任何中心化存储。

3. 智能合约编码:白名单、防重投、截止时间的 Solidity 实现

3.1 合约入口与状态定义

下面给出一个可直接跑在 Remix 或 Hardhat 工程里的完整合约骨架,覆盖“管理员注册选民 → 选民投票 → 任何人查询结果 → 截止后自动锁定”四个动作:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; /// @title 基于区块链的投票系统 /// @notice 管理员注册选民,选民在截止时间前可投一次票 contract Ballot { address public owner; uint public votingEndTime; // 截止时间戳(秒) struct Voter { bool isRegistered; // 是否在白名单 bool hasVoted; // 是否已投票 uint votedProposalId; // 投给了哪个提案 } struct Proposal { string name; uint voteCount; } mapping(address => Voter) public voters; mapping(address => uint) public voterIndex; // 扩展用:支持移除 Proposal[] public proposals; address[] public voterList; event Voted(address indexed voter, uint proposalId, uint timestamp); event VoterRegistered(address indexed voter); modifier onlyOwner() { require(msg.sender == owner, "only owner"); _; } modifier onlyDuringVoting() { require(block.timestamp < votingEndTime, "voting closed"); _; } constructor(uint _durationMinutes, string[] memory _proposalNames) { owner = msg.sender; votingEndTime = block.timestamp + _durationMinutes * 1 minutes; for (uint i = 0; i < _proposalNames.length; i++) { proposals.push(Proposal(_proposalNames[i], 0)); } } /// @dev 注册选民,地址维度防重 function registerVoter(address _voter) external onlyOwner { require(!voters[_voter].isRegistered, "already registered"); voters[_voter] = Voter(true, false, 0); voterList.push(_voter); voterIndex[_voter] = voterList.length - 1; emit VoterRegistered(_voter); } /// @dev 投票:白名单、防重投、截止时间三重检查 function vote(uint _proposalId) external onlyDuringVoting { Voter storage v = voters[msg.sender]; require(v.isRegistered, "not registered"); require(!v.hasVoted, "already voted"); require(_proposalId < proposals.length, "invalid proposal"); v.hasVoted = true; v.votedProposalId = _proposalId; proposals[_proposalId].voteCount++; emit Voted(msg.sender, _proposalId, block.timestamp); } /// @dev 查询某提案得票数 function proposalVotes(uint _proposalId) public view returns (uint) { return proposals[_proposalId].voteCount; } /// @dev 查询某个选民是否已投票 function hasVoted(address _addr) public view returns (bool) { return voters[_addr].hasVoted; } }

3.2 关键参数与设计意图

_durationMinutes参数是演示时的核心旋钮,设成5分钟方便现场展示截止机制;答辩演示建议设成30分钟,避免讲到一半系统锁死。_proposalNames以动态数组传入,可以在部署脚本里用字符串数组初始化候选人名单。

require(_proposalId < proposals.length)这行很容易漏掉。没有它会有一个隐患:前端传一个超大的 uint,会让合约读取不存在的 proposal 并回滚,反而不安全。加入边界判断后,错误提示清晰,前端也能捕获到。voterIndex映射在这个版本里只写入未读取,但保留它,在扩展“取消选民资格”功能时,删除数组元素就能 O(1) 完成,不需要遍历。

3.3 权限与时限的边界处理

onlyDuringVoting修饰器保证了投票截止后vote()直接回滚。这里有个细节:block.timestamp由矿工写入,理论上可以轻微偏移,但在 Ganache 本地链环境下由运行节点控制,不会出现公链上的时间操纵问题。

权限边界上,registerVoter只有owner能调用。如果课设要求“任何人都能发起投票”,去掉onlyOwner即可,但要注意,此时无门槛注册会导致 Sybil 攻击(一人注册多地址刷票)。我在报告中会专门写一段“对抗思路”:引入组织内身份认证(帮助学生分配唯一链上地址)或让注册函数读取一个链下签名白名单。

4. 部署到 Ganache,并处理三个必踩的配置坑

4.1 最小可运行的部署命令序列

用 Truffle 工程结构,项目根目录执行以下命令,即可把上面合约部署到本地链:

npm init -y npm install --save-dev truffle @openzeppelin/contracts npx truffle init # 启动本地链,固定端口与网络号,方便前端写死配置 ganache-cli -p 7545 -i 5777 -m "test joker logo under stuff legal" \ --chain.vmErrorsOnRPCResponse=true --wallet.totalAccounts=10 \ --wallet.defaultBalance=100

-i 5777是网络 ID,很多项目忽略它导致 MetaMask 链 ID 与 Ganache 不一致。--chain.vmErrorsOnRPCResponse=true让合约require报错信息可以通过 JSON-RPC 返回给前端,否则前端只能看到一个笼统的execution reverted,定位不到not registered还是already voted

部署脚本migrations/2_deploy_ballot.js

const Ballot = artifacts.require("Ballot"); module.exports = function (deployer) { const durationMinutes = 30; const proposals = ["候选人A", "候选人B", "候选人C"]; deployer.deploy(Ballot, durationMinutes, proposals); };

运行:

npx truffle migrate --reset --network development

4.2 前端连 Ganache,ethers.js 的 chainId 和 provider 配置

前端ethers.js连接方式:浏览器环境优先window.ethereum,非浏览器环境可以直接用JsonRpcProvider指向本地节点。注意 MetaMask 需要手动添加网络,RPC URL 填http://127.0.0.1:7545,Chain ID 填5777

import { ethers } from "ethers"; const provider = new ethers.providers.Web3Provider(window.ethereum); const signer = provider.getSigner(); const contractAddress = "0x..."; // 部署输出 const abi = [...]; // truffle build/contracts/Ballot.json 中的 abi const ballot = new ethers.Contract(contractAddress, abi, signer); // 调用投票 const tx = await ballot.vote(0); await tx.wait();

tx.wait()很重要——只调用vote()而不等待上链,前端会立刻跳到下一个状态,但合约里hasVoted未必已经更新。演示现场网络慢时会出现“投完票状态没变”的错觉,加一行await tx.wait()是最简单的规避办法。

4.3 课设必踩的 3 个坑与定位方式

现象根因处理
invalid address地址大小写/长度不对,或传成了私钥ethers.utils.getAddress()做 EIP-55 校验;确认没有手打错字符
Transaction ran out of gas投票函数内循环过大或合约部署区块限制太低gas: 3000000写进交易请求;检查 registerVoter 是否意外循环
Voter already registered但仍能投两票前端从后端拿到了伪造的布尔值前端状态全部改由ballot.hasVoted(address)合约方法返回,不信任后端存储

第三个坑是最隐蔽的。如果项目中额外引入了一个后端数据库缓存投票状态,前端优先读缓存,等于把“区块链投票”退化成了“常规网站投票”。我在合约层已经做了防重投,正确的设计是所有展示数据直接由合约view函数提供,缓存只做性能优化,且需要设置极短的过期时间。

4.4 部署后的链上验证命令

部署完成后,不要急于打开前端。用 Node 脚本或 Truffle 控制台验证三个核心状态:

npx truffle console --network development # 在 console 里执行 const ballot = await Ballot.deployed() await ballot.proposals(0) await ballot.proposals(1) await ballot.voters("0x...第一选民地址")

proposals返回的是结构体数组,显示内容包含候选人名和得票数。到这里可以确认合约确实持有数据,前端只是展示层。如果后续前端显示结果和这里不一致,问题一定在前端,而不是链。

5. 用事件监听做实时计票大屏与投票率验证

5.1 前端监听 Voted 事件实现无刷新更新

传统课设的计票结果靠轮询proposalVotes()。轮询间隔设短了,区块多时 RPC 压力大;设长了,现场演示滞后明显。更优雅的做法是监听合约事件,在新区块写入时实时刷新。代码在 ethers.js 里非常轻量:

ballot.on("Voted", (voter, proposalId, timestamp, event) => { // 触发时要重读合约,而不是直接把 event 里的 proposalId 累加 refreshVoteCounts(); }); async function refreshVoteCounts() { const counts = []; for (let i = 0; i < proposalCount; i++) { const c = await ballot.proposalVotes(i); counts.push(c.toNumber()); } renderChart(counts); }

事件参数本身带了proposalId,为什么还要重读合约?因为事件可能在同一个交易里被多次触发(理论上一次交易确实可以批量投票,只是我们的合约不允许),而event参数是“当时”的值,重读合约才能拿到权威最新值。这个细节在答辩时说出来,能体现对“状态 vs 事件”边界的理解。

5.2 从链上校验投票率的完整脚本

投票率 = 已投票人数 / 白名单人数。白名单存在合约里,voterList.length返回总人数,voteCount需要循环。以下脚本可直接放到scripts/audit.js运行:

const Ballot = artifacts.require("Ballot"); module.exports = async function (callback) { const ballot = await Ballot.deployed(); const voterCount = await ballot.getVoterListLength(); const proposalsLength = await ballot.proposalsLength(); let totalVotes = 0; for (let i = 0; i < proposalsLength; i++) { const p = await ballot.proposals(i); totalVotes += Number(p.voteCount); } console.log(`选民总数: ${voterCount}`); console.log(`有效票数: ${totalVotes}`); console.log(`投票率: ${(totalVotes / voterCount * 100).toFixed(2)}%`); callback(); };

执行npx truffle exec scripts/audit.js

5.3 隐藏技巧:用eth_getLogs直接抓取区块中的原始投票记录

不依赖合约里view函数,直接从区块日志拉取历史投票,能验证“链上是否真的存了记录”。手写一个 HTTP 请求即可:

curl -X POST http://127.0.0.1:7545 \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "method": "eth_getLogs", "params": [{ "address": "0x合约地址", "fromBlock": "0x0", "toBlock": "latest", "topics": ["0x签名值"] // keccak256("Voted(address,uint256,uint256)") }], "id": 1 }'

topics里填的是事件签名的哈希,可以用web3.utils.keccak256("Voted(address,uint256,uint256)")计算。eth_getLogs拉到的每一条data里,前 32 字节是 proposalId,后 32 字节是时间戳,topics[1]是投票人地址。这个技巧不仅能在答辩现场在不打开前端的情况下证明数据已经在链上,还能用来排查“账户A到底投没投过票”的问题,比直接调合约节省一次 RPC 往返。

最后的落点:把事件监听、链上查询、原始日志三者并列在报告里,课设的完整度就到了“不只是一个能跑的 DApp”,而是一套可以解释内部机制的最小实现。把这些代码、命令、报错对照记进报告,答辩时从“怎么防重”问到“怎么审计”,你都能拿得出应答。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询