
简介面向计算机相关专业在校学生、教师及企业开发者的区块链方向毕业设计/课程设计优秀项目基于以太坊平台完整实现线上投票系统覆盖从业务需求设计、系统框架搭建到前端交互的整套开发链路。源码已获导师指导认可答辩评审分达到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:7545Chain ID 填1337。如果你改过 Ganache 的默认 Chain ID直接在 MetaMask 里填对应的值就行。如果想让前端项目能自动读取合约地址而不是每次部署后手改常见做法是写一个.env文件VITE_CONTRACT_ADDRESS0xYourDeployedContractAddress VITE_RPC_URLhttp://127.0.0.1:7545前端构建时用import.meta.env.VITE_CONTRACT_ADDRESS读取。这是我在多个项目里觉得最省心的方案因为每次npx hardhat run scripts/deploy.js --network ganache之后只需要复制控制台输出的地址。5.2 避坑记录四类高频问题及其现场解法问题一交易一直处于 PendingMetaMask 里显示“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.234e21。原因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 迷惑。希望这些踩坑经验能帮到你。本文还有配套的精品资源点击获取