简介:面向计算机相关专业在校学生、教师及企业开发者的区块链方向毕业设计/课程设计优秀项目,基于以太坊平台完整实现线上投票系统,覆盖从业务需求设计、系统框架搭建到前端交互的整套开发链路。源码已获导师指导认可,答辩评审分达到95分,整体测试运行成功,可放心用于毕设、课设、作业或项目初期立项演示,也适合有一定基础的学习者阅读源码进阶。压缩包共收录2004个文件,其中js文件超过1300个,承担前端页面逻辑与交互;md文件约五百余份,集中提供技术说明、开发文档与阅读指引;json用于保存项目配置与数据结构,另有h头文件、html页面、xml及txt辅助资料,整体压缩后约24.39MB,目录结构清晰,便于按模块检索和二次开发。配套的详细文档与项目全部资料一同打包,从代码实现到文档整理均已准备妥当,可直接辅助课程设计、毕业设计及答辩材料的梳理展示,已有47人学习下载。
1. 链上投票系统为什么值得做:从“防作弊”到“可验证”的工程交付
如果有人拿着这个标题来找我,我不会先解释什么是区块链,而是问一句:你手里的投票系统,投完票之后拿什么证明票没有被改过?传统线上投票的痛点从来不在“投票”本身,而在投票之后——计票服务器是黑匣子,数据库有管理员,日志可以删除,质疑一上来整个结果就得重新人工投票。基于以太坊的线上投票系统换了一个思路:票数据写进链上,合约代码和计票规则公开,任何一台机器上的节点都能校验每一张票。换句话说,它解决的问题不是“绝对没人作弊”,而是“任何人都能验证结果没被篡改”。这个标题对应的是一套完整工程:Solidity 投票合约、本地/测试网部署脚本、Web 前端交互、以及配套的详细文档,适合课程设计、毕业设计,也适合企业内部做一次不需要第三方的表决原型。对你来说,真正值钱的不是那个跑通的最小 Demo,而是当你把“投票”从数据库搬到链上后,需要重做的状态设计、权限控制和验收方法——这些才是下文要拆开讲的东西。
2. 选型逻辑与系统架构:为什么是 Solidity + 以太坊,而不是联盟链或自研链
2.1 三个不可替代的选型理由
做线上投票,能选的链不止以太坊一条。FISCO BCOS、Hyperledger Fabric 这类联盟链在国产化和合规场景里也很常见,但如果你想在课程设计或者个人项目里拿一套能快速跑通、有浏览器钱包、有完备测试网、还能被检查合约源码的方案,以太坊几乎是唯一把“开发者体验”和“可验证性”同时做到及格线的选择。
第一个理由是 Solidity 的生态足够成熟。投票合约最核心的防重投票、候选人管理、计票公开,这些都是 Solidity 最经典的编程模式,官方文档和网上可查的参考实现非常多,遇到编译问题、事件监听问题、钱包连接问题,基本都能搜到答案。第二个理由是 MetaMask 把“用户端”体验做完了。投票者不需要安装任何节点软件,浏览器装一个钱包插件就能签名交易,这对一个线上投票系统来说意味着投票门槛极低——老师、同学、同事只需要导入或创建账户就能参与。第三个理由是测试与验收的可视化。Remix 可以一键部署调试,Ganache 可以本地起链看得见每一笔交易,Hardhat 可以写自动化测试。这些工具链让“设计与实现”这件事本身变得可演示、可讲解,恰好命中工程类项目的评分点。
2.2 系统分层:三层职责划分与数据流向
拿到这个标题后,我一般会按三层来设计系统,而不是把逻辑都堆在合约里。合约层负责存储和规则:候选人是谁、谁投过票、投票是否截止、计票结果怎么算。服务层是可选但推荐的一层,负责写入票据、缓存结果、给前端提供只读接口。前端层负责连接钱包、签名交易、绑定事件刷新页面。
| 层级 | 主要职责 | 典型技术 | 与区块链的关系 |
|---|---|---|---|
| 合约层 | 候选人注册、投票、计票、状态控制 | Solidity + OpenZeppelin 库 | 直接部署在链上,规则完全公开 |
| 服务层 | 交易转发、投票结果离线索引、DB 缓存 | Node.js + Express / Java Spring Boot | 监听链上事件,但自身不存最终结果 |
| 前端层 | 钱包连接、表单交互、结果可视化 | React / Vue + ethers.js / web3.js | 通过 RPC 读写链,不单独维护状态 |
有一个关键设计决策需要在动手前想清楚:如果项目要求“最小可用”,服务层完全可以去掉,前端直接通过 MetaMask 调用合约。因为合约本身已经承担了数据的最终一致性,服务层只相当于一个加速缓冲。但如果你的文档里强调并发场景,加入一个服务层做事件索引和缓存,会让系统显得更完整。
2.3 最小工程骨架:从初始化目录开始的落地路径
不论你手上拿到的“全部资料”里整理了哪些文档,我建议自己重新搭一遍工程骨架,这能帮你发现资料里缺什么、编译器版本是什么。常见做法是初始化一个 Hardhat 项目,目录结构如下:
mkdir eth-voting && cd eth-voting npm init -y npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox npx hardhat init --basic执行完后,Hardhat 会生成contracts/、scripts/、test/三个目录。随后安装合约依赖库:
npm install @openzeppelin/contracts dotenv参数说明:@openzeppelin/contracts不是必须的,但如果你在合约里用到 SafeMath 或者 Ownable 这类标准库,装上能省很多事;dotenv用于管理部署私钥和 Infura/Alchemy 的 RPC 地址,避免把密钥写进代码里提交到 Git。
此时骨架已经立好了。contracts/Voting.sol放投票合约,scripts/deploy.js放部署脚本,test/voting.js放 Mocha 测试用例。下一步就是写合约本身,这也是整个项目里最容易被评审挑出问题的地方。
3. 合约设计与实现:状态机、防重投票与可验证计票
3.1 投票合约需要什么样的状态
投票系统和普通数据存储的区别在于:它有明确的生命周期。候选人名单没定就不能投票,投票没结束就不能计票,计票结束后谁都不能再改结果。如果不在合约里控制这些状态,任何人都可能投出“未来票”或者反复投票。
我一般把状态分为两块:时间状态和业务状态。时间状态直接依赖block.timestamp,也就是链上每个区块自带的 Unix 时间戳。业务状态用枚举类型 Expired 或者 uint8 实现:0 表示注册期、1 表示投票期、2 表示结束。每次投票前先检查业务状态,再检查时间戳,两条都过了才允许写入。
防重投票最朴素的做法是维护一个mapping(address => bool) public voted。当地址对应值为 true 时,再次投票会被 revert。这个设计在数据库里要用唯一索引实现,在合约里一个映射就够了,而且这个映射是公开的,任何人都能查到某个地址是否投过票。
3.2 一份能编译通过的最小投票合约
这里给出一个我在类似项目里常用的合约基准版,不依赖 OpenZeppelin,只依赖 Solidity 内置特性:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract Voting { struct Candidate { uint256 id; string name; string intro; uint256 voteCount; } address public admin; uint256 public votingStart; uint256 public votingEnd; bool public votingOpen; Candidate[] public candidates; mapping(address => bool) public voted; mapping(uint256 => Candidate) private candidateMap; event CandidateAdded(uint256 indexed id, string name); event VoteCast(address indexed voter, uint256 indexed candidateId); modifier onlyAdmin() { require(msg.sender == admin, "only admin"); _; } modifier duringVoting() { require(block.timestamp >= votingStart, "voting not started"); require(block.timestamp <= votingEnd, "voting ended"); _; } constructor(string[] memory names, string[] memory intros) { admin = msg.sender; for (uint256 i = 0; i < names.length; i++) { Candidate memory c = Candidate(i, names[i], intros[i], 0); candidates.push(c); candidateMap[i] = c; } } function setVotingWindow(uint256 start, uint256 end) external onlyAdmin { require(end > start, "bad window"); votingStart = start; votingEnd = end; votingOpen = true; } function addCandidate(string memory name, string memory intro) external onlyAdmin { uint256 id = candidates.length; Candidate memory c = Candidate(id, name, intro, 0); candidates.push(c); candidateMap[id] = c; emit CandidateAdded(id, name); } function vote(uint256 candidateId) external duringVoting { require(candidateId < candidates.length, "no such candidate"); require(!voted[msg.sender], "already voted"); voted[msg.sender] = true; candidates[candidateId].voteCount += 1; emit VoteCast(msg.sender, candidateId); } function getCandidateCount() external view returns (uint256) { return candidates.length; } function getVoteCount(uint256 candidateId) external view returns (uint256) { require(candidateId < candidates.length, "no such candidate"); return candidates[candidateId].voteCount; } }逻辑说明:votingOpen是一个开关变量,但它并不真正参与权限控制,真正的限制来自votingStart和votingEnd这两个时间戳。setVotingWindow只有管理员能调,投票期开始和结束都由区块时间决定。vote函数先执行duringVoting修饰符,再检查候选人是否存在和地址是否已经投过票。计票不做独立动作,每投一票直接累加到voteCount上,所以结果天然是一致的。
参数说明两点。第一点,候选人数组与candidateMap同时维护是为了兼顾遍历和随机访问:前端展示列表时用candidates数组,按 id 查询单个候选人详情时用candidateMap,避免遍历全数组。第二点,block.timestamp不是绝对精确的时钟,它在以太坊上由矿工填写,前后可能有一两秒偏移,但对投票系统这种粒度足够。如果评审问“时间戳能不能被操控”,你要能回答:矿工只能在自己挖的区块里微调时间戳,且必须服从网络的整体时间,实际操作中很难定向为某个投票者篡改。
3.3 编译与部署脚本:上线前的自动测试与时间窗口设置
合约写好之后,应先用 Hardhat 跑一个最小流程验证逻辑,而不是直接部署到测试网。下面这个部署脚本是通用的:
const hre = require("hardhat"); async function main() { const Voting = await hre.ethers.getContractFactory("Voting"); const contract = await Voting.deploy( ["张三", "李四", "王五"], ["候选人一", "候选人二", "候选人三"] ); await contract.waitForDeployment(); const now = Math.floor(Date.now() / 1000); const start = now + 60; const end = now + 3600 * 24; const tx = await contract.setVotingWindow(start, end); await tx.wait(); console.log(`Voting deployed at: ${await contract.getAddress()}`); console.log(`Start: ${start}, End: ${end}`); } main().catch((error) => { console.error(error); process.exitCode = 1; });参数说明:waitForDeployment()在 Hardhat 新版本里替代了老版的deployed(),用于等待合约部署交易确认。setVotingWindow里的start与end是 Unix 秒数,这里留了 60 秒缓冲,方便部署完立刻能看到“投票未开始”的提示;如果测试时想立刻投票,可以把start设为now。
要把这套脚本跑在本地,首先需要更新 Hardhat 配置。hardhat.config.js里通常会这样写:
require("@nomicfoundation/hardhat-toolbox"); require("dotenv").config(); module.exports = { solidity: "0.8.24", networks: { ganache: { url: "http://127.0.0.1:7545", accounts: [process.env.PRIVATE_KEY], }, }, };这里容易踩一个坑:0.8.18写进solidity字段时,Hardhat 会去下载对应版本的编译器。如果你的网络环境不好,编译会卡住很久,这时可以把版本改成本地已有的,或者在settings里加"evmVersion": "istanbul"降级编译。后面章节我会集中说明这一类让新手翻车的点。
4. 前端与链上交互:钱包连接、交易签名、事件监听
4.1 用 ethers.js 在纯前端环境里连接合约
合约部署完成之后,网页需要完成三件事:读取候选人列表、发起投票交易、订阅投票事件刷新结果。这三件事如果用后端代劳,就会退化成“中心化数据库 + 区块链存档”的混合体,评审可能质疑其必要性。所以更合理的做法是让浏览器直接通过 MetaMask 与合约通信。
一个不依赖任何框架的最小连接层,用ethers.js的 BrowserProvider 就可以实现:
let provider, signer, contract; async function initContract(contractAddress, abi) { if (typeof window.ethereum === "undefined") { throw new Error("请先安装 MetaMask 钱包"); } provider = new ethers.BrowserProvider(window.ethereum); await provider.send("eth_requestAccounts", []); signer = await provider.getSigner(); contract = new ethers.Contract(contractAddress, abi, signer); }逻辑说明:eth_requestAccounts是触发 MetaMask 弹出授权窗口的 RPC 方法,必须等用户点击授权后才能拿到signer。这里signer用来发交易,而contract同时绑定了signer,意味着读方法默认走只读调用,写方法会自动发交易。如果你只是想查结果不做投票,可以不传signer,直接new ethers.Contract(addr, abi, provider),这样用户不需要连接钱包也能看投票结果。
4.2 投票动作:从点击按钮到链上确认的完整链路
投票是写操作,必须付 Gas,也必须等待矿工打包。前端的投票函数一般长这样:
async function castVote(candidateId) { const address = await signer.getAddress(); try { const tx = await contract.vote(candidateId); const receipt = await tx.wait(); console.log("Block:", receipt.blockNumber); console.log("Tx:", receipt.hash); alert("投票成功,交易哈希: " + receipt.hash); } catch (error) { console.error(error); if (error.code === "ACTION_REJECTED") { alert("你在 MetaMask 里拒绝了这笔交易"); } else if (error.message.includes("already voted")) { alert("该地址已经投过票,不能重复投票"); } else if (error.message.includes("voting ended")) { alert("投票已结束"); } } }参数说明:contract.vote(candidateId)返回的是一个 TransactionResponse 对象,它只代表交易已经被 MetaMask 接受并广播,不代表交易已经上链。必须调用tx.wait()才会返回包含blockNumber的交易收据。很多新手只做第一步,然后发现页面上数据没变,其实是交易还在 pending 中——这就是最常见的“投了票但没生效”的翻车现场。
这里还有一个细节容易被忽略:Solidity 的require错误字符串会以revert的方式传回前端,但不同节点和库的报错格式不一定完全一致。上面代码里我用error.message.includes("already voted")做判断,在有些网络环境下,错误消息会被压缩成 4 字节的 ErrorSelector,这时字符串匹配会失败。更稳妥的做法是用error.revert.args[0]去读。这是我在联调里被坑过的地方,后面避坑章节还会再提到。
4.3 事件监听:让结果页在每次投票后自动刷新
合约里定义的VoteCast事件是该系统的“移动数据源”。链上每一次投票都会产生一条事件日志,前端可以订阅它,不需要每分钟轮询。下面是监听写法:
async function watchVoteEvents() { contract.on("VoteCast", (voter, candidateId, event) => { console.log(`地址 ${voter} 投给了候选人 ${candidateId}`); refreshBoard(candidateId); }); } async function refreshBoard(candidateId) { const count = await contract.getVoteCount(candidateId); document.getElementById(`candidate-${candidateId}`).innerText = count.toString(); }逻辑说明:contract.on在 ethers.js 里监听的是新产生的区块日志,所以refreshBoard只会被新投票触发,历史投票不会重放。如果需要页面首次加载时显示已有结果,需要在初始化时手动调一次getVoteCount遍历所有候选人。事件监听的另一个好处是:即使用户切换 MetaMask 账户,只要监听没有移除,结果照样刷新。切换账户的监听可以单独接window.ethereum.on("accountsChanged")来做,这里不再展开。
从这几节可以看出,前端层真正复杂的不是 UI 交互,而是对交易生命周期和事件日志的理解。如果你的项目文档里恰好只有合约部分没有前端源码,这一章的内容就是你自补前端时的路线图。
5. 本地联调与测试避坑:从 Ganache 到测试网的五个高频问题
5.1 搭建本地以太坊测试环境的关键配置
联调阶段我推荐先用 Ganache 而不是直接上测试网。Ganache 的特点是把十一个账户和对应私钥直接打在屏幕上,余额是假的,但 RPC 行为和真链几乎一致。启动 Ganache 后,MetaMask 里需要新增一条自定义网络,RPC 地址填http://127.0.0.1:7545,Chain ID 填1337。如果你改过 Ganache 的默认 Chain ID,直接在 MetaMask 里填对应的值就行。
如果想让前端项目能自动读取合约地址,而不是每次部署后手改,常见做法是写一个.env文件:
VITE_CONTRACT_ADDRESS=0xYourDeployedContractAddress VITE_RPC_URL=http://127.0.0.1:7545前端构建时用import.meta.env.VITE_CONTRACT_ADDRESS读取。这是我在多个项目里觉得最省心的方案,因为每次npx hardhat run scripts/deploy.js --network ganache之后,只需要复制控制台输出的地址。
5.2 避坑记录:四类高频问题及其现场解法
问题一:交易一直处于 Pending,MetaMask 里显示“Nonce 太高”或干脆不弹签名的确认框。
现象:点击投票按钮后 MetaMask 右上角数字转圈,页面卡住;一段时间后交易在 Ganache 里看不到。
原因:Ganache 默认 Chain ID 是1337,而 MetaMask 内置的 Localhost 网络链 ID 是老版本配置可能写成31337。两条链 ID 不匹配时,MetaMask 认为自己连的不是这条链,拒绝签名或把交易发到错误的链上。
解决:在 MetaMask 自定义网络里把 Chain ID 改成1337,确认 RPC 端口与 Ganache 的端口一致。还有一个连带操作:如果之前 MetaMask 缓存了旧的非零值,在设置里 “清除活动数据” 再重连一次。
问题二:报错insufficient funds for gas * price + value。
现象:本地测试时明明账户里有 100 ETH,但投票时报余额不足。
原因:MetaMask 当前选中的账户不是 Ganache 里那个有余额的账户,或者 Hardhat 部署脚本里用的私钥和 Ganache 第一个账户的私钥不同。测试链上的余额既分账户也分链,切到主网看当然没钱。
解决:部署脚本里用ganache.accounts里的第一个私钥;前端要确认 MetaMask 当前连接的是 Ganache 网络,并且当前账户是那个私钥对应的账户。可以用await signer.getAddress()打印地址,再和 Ganache 第一个账户比对。
问题三:前端拿到的大数字变精度丢失,或者toString()变成了科学计数法。
现象:getVoteCount返回的值在控制台打印正常,但渲染到页面上变成1.234e+21。
原因:Solidity 的uint256最大值超过 JavaScriptNumber的安全整数范围。ethers.js 默认把uint256返回成bigint,但如果你在代码里做了隐式转换,或者用了老版本的 web3.js,就会丢失精度。
解决:拿到任何链上大数先调用.toString(),不要直接参与算术运算。如果你写的是Number(bigint),在小数字时没问题,但这是一个坏习惯。
问题四:投票结束时间到了,还能继续投票。
现象:votingEnd设置正确,前端也显示了截止时间,但过点之后投票仍然成功。
原因:合约里duringVoting修饰符只检查调用当前交易的区块时间戳,如果你的项目是在本地私链上测试,矿工可以随意把时间戳写过去或写回来;另一种可能性是部署脚本里传的end是当前机器的本地时间,而合约执行环境用的是 UTC 时间戳,这个一般不会错,更多是私链环境挖矿时间不受控。
解决:如果是本地测试,不要用真实时钟来验证截止逻辑,用 Hardhat 的evm_increaseTime加上evm_mine来模拟时间推进。这是测试合约时间窗口的业界标准做法,属于验证手段而不是规避手段。
5.3 合约安全与评审关注点:验收前必须自问的四个问题
评审常常不问“功能是什么”,而问“边界在哪里”。关于投票合约,我建议在验收前逐条自查以下问题:
合约是否做了重入保护?投票合约里没有转账操作,重入风险理论上很低,但如果你在vote后加了奖励转账,就必须用 OpenZeppelin 的 ReentrancyGuard。管理员能否在投票结束后还能调addCandidate?当前合约里addCandidate只限onlyAdmin,不限制时间状态,严格来说这是设计缺陷。如果评审质疑,你应该解释:在实际部署中,候选人注册是初始化事务,通常由构造函数一次性注入;或者主动加上require(block.timestamp < votingStart)。选票是否可审计?voted映射公开,意味着可以验证每个地址是否投过票,但地址背后是谁无法直接对应,这一点必须在文档里的隐私部分说明。前端是否承担了业务规则?合约已经做了防重投票和时间窗口控制,前端只是交互壳,这不是一个明确的缺陷,但在“职责分配”这个问题上值得写进文档。
如果这四点都能自圆其说,合约部分就基本站得住。
6. 验收演示自检:一键跑通完整链路与文档配套技巧
项目“优秀”与否,很大程度上取决于演示是否顺畅。这里我分享一个我常用的验收自检流程,核心是让系统在五分钟内从空白状态跑到一个完整结果。
先准备一个演示脚本,把流程固化下来。下面的 bash 片段不做复杂处理,只按顺序执行:
npx hardhat run scripts/deploy.js --network ganache node scripts/save-deployment.js # 将合约地址写入前端环境变量 npm run dev # 启动前端如果你的资料包里已经有save-deployment.js这个脚本就用它,没有的话用下面这段补充:
const fs = require("fs"); const contractAddress = process.argv[2]; const envPath = "./.env"; fs.appendFileSync(envPath, `VITE_CONTRACT_ADDRESS=${contractAddress}\n`); console.log("Address saved to .env");演示时我会把过程拆成四个镜头:部署合约并设置投票时间窗口;用 MetaMask 对候选人 A 投票,展示交易哈希和区块确认;换一个账户投票,展示每个地址只能投一次;投完之后从合约直接读取票数,再打开区块链浏览器展示交易记录。如果测试网有公开浏览器,对比浏览器里的交易记录与页面显示结果,是评审眼里最有说服力的一步。
关于“详细文档+全部资料”这套东西,我的教训是:文档不是给归档看的,是给三天后的自己看的。我会为这个项目单独维护一份DEPLOY.md,记录部署步骤、测试账号私钥位置、合约地址、Chain ID,以及每一次改动的合约 ABI 是否同步更新。很多项目的资料包内容齐全,但 ABI 与合约源码不是同一版本,前端调用时就会出现function signature not found报错,落差往往发生在这种地方。
最后给你一个我自己的习惯:无论多简单的合约,上线前至少写一条测试用例覆盖“重复投票被拒绝”这个场景。很多区块链系统翻车不是因为复杂功能,而是因为基础约束没锁住。带着这个验证习惯去看你手头的所有资料,才不会被表面的一套 Demo 迷惑。希望这些踩坑经验能帮到你。
本文还有配套的精品资源,点击获取