简介:这是一份基于区块链的投票系统毕业设计源码与详细文档,面向计算机及相关专业学生,适用于毕业设计、期末大作业或课程设计。项目利用区块链不可篡改、公开透明的特性,设计投票流程与数据上链方案,涉及智能合约、椭圆曲线加密(如secp256k1)等知识点,编码实现中通过底层加密算法保障投票数据的匿名性与可验证性,评审分达98分,难度适中,已通过助教审定。压缩包共2001个文件,以JavaScript、Markdown、JSON为主,另有C/C++源码、HTML、Java、Shell脚本等,其中js/json对应前端交互与配置,md为说明文档,c/h为底层加密算法实现,整体14.32MB。目前已有106人学习下载。资源内含可本地编译运行的完整工程,配套详细文档说明,可帮助读者快速理清区块链投票系统的架构设计、合约逻辑、加密验证及数据上链全流程,是完成毕设或课程设计的实用参考。
1. 一个被问过几百遍的问题:区块链投票到底解决了什么
做毕业设计选“基于区块链的投票系统”,十个人里至少有八个是冲着“区块链”三个字来的。但真正动手时你会发现,最难回答的不是“区块链怎么存数据”,而是“为什么投票要用区块链”。如果只是把选票存进数据库,加个哈希校验,那和传统系统没有本质区别。区块链投票的核心价值在于可验证性和不可篡改性:任何人(不只是管理员)都能验证每一张选票确实被计入、没有被修改、没有被删除。这种“公开可审计”的能力,才是论文里能写、答辩时能讲的立足点。本篇按“理论立住 → 最小实现 → 完整系统 → 参数与排错 → 答辩技巧”的顺序展开,全程围绕 Solidity + Web3.js + Node.js 的常见技术栈,代码可以直接复用。
2. 选型理由与数据结构:为什么是智能合约而不是数据库
2.1 区块链投票系统的分层架构
一个完整的投票系统通常分三层:前端(用户投票界面)、后端(业务逻辑与链上交互)、链上合约(选票存储与计票规则)。常见做法是前端用 Vue 或 React 配合 Web3.js 或 ethers.js,后端用 Node.js 做中间层(负责转发交易、查询事件),合约层用 Solidity 编写并部署到测试链。分层的理由是:前端不能直接持有私钥,否则用户浏览器里就暴露了签名凭据;后端负责统一管理合约地址和 ABI,避免前端频繁加载合约元数据。
2.1.1 三个核心合约设计
| 合约模块 | 职责 | 关键状态变量 |
|---|---|---|
| 选票登记 | 记录选民的注册状态、是否已投票 | mapping(address => bool) voters |
| 候选人管理 | 维护候选人列表及得票数 | struct Candidate+Candidate[] |
| 投票控制 | 控制投票开始/结束时间、防重复投票 | uint startTime/endTime |
我需要强调:不要把业务逻辑全部塞进一个合约。划分成三个模块后,每个合约的代码量控制在 150 行以内,既容易测试也方便论文里分章节描述。
2.2 为什么用 mapping 存储选民状态
Solidity 里的mapping是哈希表结构,查询复杂度 O(1),适合“给定地址查是否已投票”的场景。但 mapping不可迭代,即无法遍历所有选民。如果需要统计“总投票人数”,必须额外维护一个数组或计数器:
mapping(address => bool) public hasVoted; address[] private votersList; function registerVoter(address _addr) external onlyOwner { require(!hasVoted[_addr], "already registered"); hasVoted[_addr] = true; votersList.push(_addr); } function getVoterCount() public view returns (uint) { return votersList.length; }这段代码的关键点是hasVoted[_addr]的查询和votersList的动态扩容。registerVoter由合约 owner(管理员)调用,普通用户无法自行注册——这是投票场景的硬性约束:不能让一个人注册多个地址刷票。实际项目中还需要结合前端做身份证或学号绑定,但链上只能做地址维度的去重。
2.3 投票时间的链上控制
投票时间如果放在后端控制,用户可以直接调合约绕过限制。正确做法是把时间约束写进合约的vote()函数:
modifier onlyDuringVoting() { require(block.timestamp >= startTime, "voting not started"); require(block.timestamp <= endTime, "voting ended"); _; } function vote(uint _candidateId) external onlyDuringVoting { require(!hasVoted[msg.sender], "already voted"); require(_candidateId < candidates.length, "invalid candidate"); candidates[_candidateId].voteCount++; hasVoted[msg.sender] = true; }这里使用block.timestamp而不是后端传时间,因为矿工打包时区块时间戳是共识层统一的,篡改成本极高。注意测试链上时间与真实时间可能有偏差,Ganache 默认会模拟真实时间,但 Hardhat 网络需要额外配置,这部分在第四章的说一下。
3. 最小可运行系统:搭建开发环境并部署合约
3.1 用 Hardhat 初始化项目的最小命令序列
常见做法是使用 Hardhat 作为 Solidity 开发框架,因为它的调试体验比 Truffle 更友好(stack trace 清晰、支持 console.log)。新项目推荐 nvm 管理的 Node.js 18 LTS,npm 版本不低于 9。
mkdir blockchain-voting && cd blockchain-voting npm init -y npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox npx hardhat init初始化向导选择 “Create a JavaScript project”,然后删除默认的Lock.sol和测试文件。安装完成后生成contracts/、scripts/、test/三个目录。需要说明的是:hardhat-toolbox是一个聚合包,包含 ethers.js、chai、mocha 等测试依赖,不需要再单独安装@nomiclabs/hardhat-ethers。
3.1.1 配置 Goerli 测试网需要的网络参数
在hardhat.config.js中加入测试网配置,这里以 Goerli 为例(注意:Goerli 已于 2023 年停止支持,生产项目应切换到 Sepolia 或本地 Anvil):
require("@nomicfoundation/hardhat-toolbox"); module.exports = { solidity: "0.8.19", networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL || "", accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [] } } };process.env是通过.env文件注入的私钥和 RPC 地址,必须禁止把私钥硬编码进源码。环境变量文件的格式为SEPOLIA_RPC_URL=https://...和PRIVATE_KEY=0x...,后者是部署账户的私钥,泄露会导致合约被恶意接管。这里我把 Goerli 换成 Sepolia 是因为 2024 年后主流测试网已经迁移,公开教程里那些 Goerli 的步骤已经失效。
3.2 投票合约的完整实现与逐段解析
下面是一份可直接复制到contracts/Voting.sol的完整合约,同时覆盖了注册、投票、查询三个核心功能。我保留了这个版本:包含用事件记录投票动作的机制,方便后端监听链上日志。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract Voting { struct Candidate { uint id; string name; uint voteCount; } address public owner; uint public startTime; uint public endTime; mapping(address => bool) public hasVoted; Candidate[] public candidates; event VoteCast(address indexed voter, uint candidateId, uint timestamp); constructor(string[] memory _names, uint _durationMinutes) { owner = msg.sender; startTime = block.timestamp; endTime = block.timestamp + _durationMinutes * 1 minutes; for (uint i = 0; i < _names.length; i++) { candidates.push(Candidate({id: i, name: _names[i], voteCount: 0})); } } function vote(uint _candidateId) external { require(block.timestamp >= startTime, "not started"); require(block.timestamp < endTime, "voting ended"); require(!hasVoted[msg.sender], "already voted"); require(_candidateId < candidates.length, "invalid candidate"); candidates[_candidateId].voteCount++; hasVoted[msg.sender] = true; emit VoteCast(msg.sender, _candidateId, block.timestamp); } function getResults() public view returns (uint[] memory) { uint[] memory results = new uint[](candidates.length); for (uint i = 0; i < candidates.length; i++) { results[i] = candidates[i].voteCount; } return results; } }3.2.1 构造函数、vote 函数与 getResults 返回值的边界问题
构造函数constructor(string[] memory _names, uint _durationMinutes)接收候选人名字数组和投票时长(分钟)。这里有两个细节:第一,memory关键字表示参数从 calldata 复制到内存,避免修改原始输入;第二,block.timestamp + _durationMinutes * 1 minutes计算结束时间,由于 Solidity 0.8 默认启用溢出检查,uint溢出会直接 revert,所以不需要 SafeMath 库。
vote()的四个require按顺序执行:先验时间再验重复,再验候选编号。顺序很重要——如果把hasVoted[msg.sender]放在时间校验之前,用户在非投票期调用时会先看到“already voted”而不是“not started”,这会影响前端错误提示的准确性。getResults()声明为view函数,意味着不消耗 gas,可以直接通过 ethers.js 的callStatic方法读取,不需要交易签名。
3.3 用脚本部署和验证合约
3.3.1 部署脚本中必须处理的三个参数
const hre = require("hardhat"); async function main() { const Voting = await hre.ethers.getContractFactory("Voting"); const candidates = ["Alice", "Bob", "Carol"]; const durationMinutes = 30; const voting = await Voting.deploy(candidates, durationMinutes); await voting.deployed(); console.log("Voting deployed to:", voting.address); } main().catch((error) => { console.error(error); process.exitCode = 1; });部署脚本里要注意三个参数:候选人数组(.合约构造函数要求数组,忘记传或传字符串会报 ABI 编码错误)、时长单位(是分钟,不是秒)、部署账户 gas(默认测试网账户有余额,但如果用本地链需要--gas参数指定)。部署命令是npx hardhat run scripts/deploy.js --network sepolia。这里我把常用做法和参数细节拆解清楚:如果不想用公共测试网,也可以用npx hardhat node启本地链直接部署,不需要--network参数。
4. 前后端联调:从 MetaMask 到网页投票
4.1 后端为什么需要 Web3.js 而不直接用 ethers.js
前端用户通过 MetaMask 钱包签名交易,后端服务负责监听事件、查询结果、做链下聚合统计。我通常会选 Web3.js,因为它在 Node.js 端的文档和社区案例更多,尤其在处理事件订阅(web3.eth.subscribe('logs'))时语法更直观;ethers.js 的优势在前端 bundle 体积更小,如果你前后端都用同一个库,选 ethers.js 更省心。两者不能混用,因为地址格式和交易回执的字段结构有差异,调起来很别扭。
4.1.1 后端需要监听的三个合约事件
| 事件名 | 触发时机 | 后端用途 |
|---|---|---|
VoteCast | 用户成功投票后 | 实时更新前端票数、判断是否有异常重复投票 |
CandidateAdded | 管理员添加新候选人时 | 刷新候选人列表缓存 |
OwnershipTransferred | 合约 owner 更换时 | 刷新权限缓存,防止越权操作 |
实际上CandidateAdded和OwnershipTransferred是 OpenZeppelin 的Ownable扩展,基础合约里没有。我在生产项目里一般会引入@openzeppelin/contracts的 Ownable 模块,用onlyOwner修饰addCandidate()和transferOwnership(),然后把监听这三个事件的代码统一放在一个event-listener.js里。
4.2 前端投票页面的最小代码框架
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>区块链投票</title> <script src="https://cdn.jsdelivr.net/npm/web3@1.10.0/dist/web3.min.js"></script> </head> <body> <button id="connectBtn">连接钱包</button> <div id="candidates"></div> <script src="./vote.js"></script> </body> </html>vote.js核心逻辑:
let web3, contract, account; async function init() { if (window.ethereum) { web3 = new Web3(window.ethereum); await ethereum.request({ method: "eth_requestAccounts" }); const accounts = await web3.eth.getAccounts(); account = accounts[0]; } } async function loadCandidates() { const count = await contract.methods.getCandidateCount().call(); const list = []; for (let i = 0; i < count; i++) { const c = await contract.methods.candidates(i).call(); list.push(c); } renderCandidates(list); } async function castVote(candidateId) { await contract.methods.vote(candidateId).send({ from: account }); alert("投票成功,交易哈希已写入链上"); }4.2.1 前端调用合约时的 gas 参数说明
contract.methods.vote(candidateId).send({ from: account })中send会提示 MetaMask 弹出签名窗口,用户确认后返回交易哈希。默认情况下 MetaMask 会自动估算 gas,但如果合约里require的时间边界正好卡在“几分钟后截止”,估算可能失败。稳妥做法是显式传gas: 100000和gasPrice: web3.utils.toWei('20', 'gwei')。注意:Sepolia 测试网不支持 EIP-1559 的maxFeePerGas时,部分 RPC 会选择忽略这个参数回到gasPrice,实测中我都带上两个字段更保险。
4.3 用 Ganache 本地跑通的调试技巧
如果你不想在第三方测试网上消耗时间,本地开发最常见的方式是 Ganache。启动后它会生成 10 个带余额的账户和对应的私钥,然后把 MetaMask 网络切换到http://127.0.0.1:7545,Chain ID 是 1337。部署合约时:
npx hardhat run scripts/deploy.js --network ganache需要说明的是,Ganache 在 2024 年后官方维护频率下降,很多新项目转向anvil(Foundry 自带)。但 Ganache 的图形界面有一个不可替代的便利:可以看到每一个区块包含的交易列表和 gas 消耗,这对调试投票合约尤其有用——你能直观看到“一次投票消耗了多少 gas”。如果你用 anvil,需要用cast call或cast send手动验证,调试成本略高。
5. 关键参数调优与常见报错的定位方法
5.1 solidity 版本冲突导致的编译失败怎么定位
Hardhat 初始化时如果自动生成的模板依赖pragma solidity ^0.8.19,而你安装的@openzeppelin/contracts版本要求 0.8.20+,会直接报“ParserError: Source file requires different compiler version”。解决方式有两种:要么把合约的pragma改成^0.8.20并安装对应的 solc 版本,要么把 OpenZeppelin 降到匹配的旧版。我推荐前一种,因为新版本修复了旧版的一些已知问题。
5.1.1 三个高频编译错误对照表
| 错误信息 | 原因 | 解决方法 |
|---|---|---|
DeclarationError: Identifier "..." not found | 导入路径错误 | 检查require "./xxx.sol"的路径大小写 |
TypeError: Return argument type ... is not implicitly convertible | 函数返回值类型与声明不一致 | 检查returns (uint[])是否返回了uint256[]数组 |
UnimplementedFeatureError | 某些 ABI 编码器(0.5.x)对嵌套数组支持不全 | 升级 Solidity 或简化数组结构 |
5.2 交易回执一直 pending 的排查顺序
投票过程中最常见的故障是“MetaMask 显示 pending”或“等了几分钟没有回调”。我会按这个顺序排查:第一步,看 RPC 是否返回nonce too low或replacement transaction underpriced,这通常是前端重试导致同一 nonce 发了两笔交易;第二步,检查gasPrice是否低于目标链当前最低价,Sepolia 的默认最低是 1 Gwei,但高峰期要 5 Gwei 以上;第三步,确认合约的endTime是否已过——如果投票时间截止,交易会被 revert 而不是等待打包,MetaMask 需要等 30 秒才能显示错误信息。
5.2.1 用 ethers.js 捕获 revert 原因的标准代码
try { await votingContract.vote(candidateId); } catch (error) { if (error.reason) { console.error("Revert reason:", error.reason); } else { const tx = await web3.eth.getTransaction(error.transactionHash); const decoded = await web3.eth.call(tx); console.error("Revert data:", decoded); } }在上述代码中,error.reason是 ethers.js 6 解析后的 revert 字符串,比如“already voted”或“invalid candidate”。如果reason为空,说明节点返回的是原始十六进制数据,需要web3.eth.call在本地模拟执行才能拿到完整错误码。这里的差异源于 ethers.js v5/v6 的错误处理机制不同:v5 用的是error.reason,v6 改成error.shortMessage或error.reason都可能。建议先console.log整个error对象看字段结构,再针对性取。
5.3 用什么命令验证链上投票结果是可信的
部署和测试完成后,答辩前必须做一次完整的链上验证。常用做法是写一个scripts/verify.js,从链上拉取候选人和得票数,同时核对事件日志中的投票记录数量:
npx hardhat run scripts/verify.js --network sepoliaverify.js的内部逻辑分三步:第一步调用getResults()获取最终票数;第二步用getPastEvents('VoteCast', { fromBlock: 0 })获取所有投票事件;第三步比较“事件数量是否等于总票数之和”。如果两者不相等,说明有投票被强制 revert 或合约有逻辑漏洞。这一步的严谨性直接加分——几乎所有评委会问“你怎么证明结果没被篡改”,用这个脚本比对链上事件与状态,是能自洽的。
6. 答辩演示前必做的三个验证技巧
6.1 技巧一:用事件日志做完整的“审计追踪”
在论文与演示中,不要只展示票数,要展示投票事件的时间序列。打开 MetaMask 或 Etherscan 的交易详情页,截图当前投票事件列表,再结合后端数据库导出的 Excel 表做一次交叉验证。我这里实际操作中遇到过一个问题:某些 RPC 节点在查询getPastEvents时只返回最近 1000 个区块的事件,必须把fromBlock设置成合约部署时的块高。获取部署块高的方法是:
npx hardhat run scripts/get-deploy-block.js --network sepolia脚本内容:用const receipt = await voting.deployTransaction.wait()拿到receipt.blockNumber,然后写死进verify.js。如果不设置初始块高,老事件的缺失会导致审计报告出现偏差。
6.2 技巧二:用 ganache 快照演示“时间跳跃”
答辩现场可能会被问到“如果投票期还没结束,能不能提前看结果?”——链上getResults()是公开的,确实可以。但如果你想演示“投票截止后自动关闭”这一特性,Ganache 的时间跳跃是很好的助手。在 Ganache 界面中点 “Mining” 标签,手动把时间拨到 endTime 之后,然后用getResults()查询。这比改代码重发布更有说服力,因为显示的是同一份合约的完整状态变化。如果你用 Hardhat 自带网络,可以在hardhat.config.js中开启mining: { auto: false }并手动evm_increaseTime。
6.3 技巧三:文档中必须截图的四个页面
对于高分项目而言,代码写得好只是一半。综合来看,以下四个截图在最终文档中缺一不可:
| 截图位置 | 证明什么 |
|---|---|
| Ganache 区块列表 + 交易详情 | 投票交易确实上链,区块高度时间连续 |
| Etherscan 合约页面(含 source code) | 合约已公开验证,代码可读 |
| MetaMask 弹出签名时的界面 | 用户通过钱包授权,非后端代投 |
verify.js的运行终端输出 | 事件数量与总票数一致 |
最后一个值得说明的技巧是:Node.js 代码里的 “console.table” 会比 console.log 打印出对齐的表格,在终端截图中更专业。console.table(results)直接输出候选人和票数的二维表,比贴 JSON 好看得多。不要小看这个细节,每年那么多同题材的毕设,评委记住你的往往就是这种有条理、可验证的小设计。
本文还有配套的精品资源,点击获取