简介:基于区块链的投票系统课程设计完整包,面向区块链、软件工程及相关专业的学生与开发者,用于课程设计、毕业设计或初期项目立项演示。资源包含智能合约、前端页面、工程配置、测试脚本与配套报告,覆盖从合约编写、编译迁移到页面交互的完整流程,可帮助理解去中心化投票的业务逻辑、权限管理以及Truffle开发框架的典型用法。压缩包共88个文件,大小仅847KB,文件类型以JavaScript、Solidity、JSON、HTML、CSS、Vue等为主:其中合约文件承担投票、成员、工厂等核心功能,JS与Vue实现前端交互和管理页面,JSON与配置类文件负责工程与依赖配置,Markdown文档及作业报告则补充了说明与参考。整个项目目录结构清晰,代码经过严格测试,运行正常,适合直接用于课设、毕设或作为区块链入门学习素材。目前已有99人学习浏览,遇到环境配置或运行问题还可参考说明文档或远程交流,整体轻量且易于上手。
1. 基于区块链的投票系统作业,先躲开“数据库投票”这个坑
很多同学拿到“基于区块链的投票系统”这个题目,第一反应是先做网页:登录、候选人列表、点投票、弹窗提示成功。页面越顺手,越容易把“上链”理解成在中心化后端里写一条MySQL记录,再补存一个交易哈希当装饰。答辩时老师一句“你这条记录删掉,我还能查出来吗”,整个项目就退回了普通CRUD。
反过来,这套作业拿高分的核心不在合约写得有多花哨,而在于把三件事讲清楚:这张票是否真的写进了链上状态、同一地址是否还能重复改票、投票结束后能不能复核每一笔记录。zip里的源码、作业报告、说明文档三件套,全部围绕这条主线组织,才不至于自相矛盾。这篇博文给出完成它的最小路径,零基础也能在本地把一轮投票完整跑通,有经验的人则可以跳到第五章的验收清单直接自测。
2. 投票系统源码的合约骨架:状态写在哪、事件边界与阶段权限
2.1 用Solidity的存储布局表达“一场投票”
以太坊上的投票系统,一切可验证的事实都来自合约状态。常见做法是用枚举定义阶段:Created、Started、Ended;用结构体保存候选人和投票人;再用 mapping 建立地址到投票人记录的对应关系。下面这段是可跑的骨架,没有引入 OpenZeppelin,方便直接贴进作业源码里逐行解释。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; contract BlockchainVote { enum VoteStage { Created, Started, Ended } struct Candidate { string name; uint256 voteCount; } struct Voter { bool isRegistered; bool hasVoted; uint256 votedCandidateId; } address public owner; VoteStage public stage; Candidate[] public candidates; mapping(address => Voter) public voters; event CandidateRegistered(uint256 indexed candidateId, string name); event VoterRegistered(address indexed voter); event VoteCast(address indexed voter, uint256 indexed candidateId); constructor() { owner = msg.sender; stage = VoteStage.Created; } function addCandidate(string calldata name) external onlyOwner onlyStage(VoteStage.Created) { candidates.push(Candidate(name, 0)); emit CandidateRegistered(candidates.length - 1, name); } function registerVoter(address voter) external onlyOwner onlyStage(VoteStage.Created) { require(!voters[voter].isRegistered, "already registered"); voters[voter].isRegistered = true; emit VoterRegistered(voter); } function startVote() external onlyOwner onlyStage(VoteStage.Created) { stage = VoteStage.Started; } function vote(uint256 candidateId) external onlyStage(VoteStage.Started) { Voter storage voter = voters[msg.sender]; require(voter.isRegistered, "not registered"); require(!voter.hasVoted, "already voted"); require(candidateId < candidates.length, "invalid candidate"); voter.hasVoted = true; voter.votedCandidateId = candidateId; candidates[candidateId].voteCount++; emit VoteCast(msg.sender, candidateId); } function getWinner() external view returns (uint256 winningCandidateId, uint256 maxVotes) { for (uint256 i = 0; i < candidates.length; i++) { if (candidates[i].voteCount > maxVotes) { maxVotes = candidates[i].voteCount; winningCandidateId = i; } } } modifier onlyOwner() { require(msg.sender == owner, "not owner"); _; } modifier onlyStage(VoteStage expected) { require(stage == expected, "wrong stage"); _; } }逻辑说明:candidates 数组的合法索引直接充当候选人的 ID,省去“候选人编号从 1 开始”带来的越界判断;voters 映射天然保证一个地址只有一条记录,hasVoted和votedCandidateId都跟随这条永久存储。枚举把“开票前配置、开票中投票、结束后只读”三个状态互相排斥,避免出现“投票结束了还能通过前端调 vote 继续写入”的低级漏洞。
2.2 角色边界:注册必须白名单化,而不能自助放行
一个常见误区是:把 registerVoter 设计成任何人都能调用,以为这样就“支持公开注册”。问题在于,投票系统的核心前提是资格由主办方认定。如果让调用者自己把自己登记为合法投票人,那一个掌握多个地址的人就能反复刷票,合约写得再严谨也挡不住“合法注册”的数量。
这里采用白名单模式:只有 owner 能逐一登记投票人地址,投票人本人不能自助加入。对应权限检查可以用一张表直接放进作业报告里:
| 合约函数 | 可调用角色 | 动作前检查 | 典型失败原因 |
|---|---|---|---|
addCandidate(name) | 仅 owner,阶段为 Created | 无额外校验 | not owner、wrong stage |
registerVoter(addr) | 仅 owner,阶段为 Created | 地址尚未登记 | already registered |
startVote() | 仅 owner,阶段为 Created | 无 | wrong stage |
vote(id) | 已登记投票人,阶段为 Started | 未投过、ID 合法 | already voted、invalid candidate |
getWinner() | 任何人 | 只读 view | 不消耗 Gas |
参数说明:registerVoter 传入的 voter 地址必须在开票前由主办方收集完毕;如果作业还要求支持“匿名投票”,可以补充一个链下签名授权环节,但不要把它写进合约的公开注册函数里,否则资格边界会被破坏。
2.3 校验、生效、交互的顺序与事件记录
在 vote 函数里,我刻意把hasVoted = true写在emit VoteCast之前,因为事件日志本质是交易收据的一部分,事件在函数结束后永久记录;如果 emit 之前发生异常,状态和日志会一起回滚。这里没有跨合约调用,所以没有重入风险;一旦你把 ERC20 代币或其他合约调用加进投票流程,就必须改成“校验—生效—交互”的顺序,并考虑重入锁。
事件在这里的作用不只是给前端看。一个经过索引的事件参数可以在链上按主题过滤,这对后续在报告中展示“某个地址在某个区块投给了某个候选人”很有价值。因此事件参数里能加 indexed 的尽量加,尤其是 voter 和 candidateId 这类查询条件。
2.4 计票的权威来源:页面读链上状态,而不是后台存表
源码里最容易让老师皱眉的是“后端单独维护一张 count 表”。如果结果表存在 MySQL 里,区块链就只是旁路备份,投票系统的可信度仍然取决于数据库管理员的人品。正确做法是让前端直接通过 RPC 调用只读函数:候选人数走getCandidateCount(),胜者走getWinner(),候选人票数则从 candidates 数组逐一读取。这样每次展示结果都等价于在现场对链上状态做一次实时查询,数据库表在这个系统里根本不需要出现。
第十章代码会覆盖调用细节,这里先记住一个判断标准:如果关掉数据库,系统还能统计出正确的投票结果,那么它才配叫基于区块链的投票系统。
3. 本地跑通投票闭环:Hardhat部署 + Web3.py注册与投票
3.1 最小Hardhat工程与部署脚本
拿到源码后第一件事是在本地跑一条开发链。常见做法是用 Hardhat 内置的本地网络,它不需要单独下载客户端,npx hardhat node就会起一个端口8545的 RPC 服务。初始化工程:
npm init -y npm install --save-dev hardhat @nomicfoundation/hardhat-ethers ethers npx hardhat init把合约放进contracts/BlockchainVote.sol,再写部署脚本scripts/deploy.js:
const hre = require("hardhat"); async function main() { const factory = await hre.ethers.getContractFactory("BlockchainVote"); const contract = await factory.deploy(); await contract.waitForDeployment(); console.log("deployed to:", await contract.getAddress()); } main().catch(console.error);参数说明:factory.deploy()不需要传构造参数,因为构造函数只把msg.sender设为 owner,部署者就是管理员。waitForDeployment()等交易确认后返回合约对象。执行npx hardhat run scripts/deploy.js会输出一串0x开头的合约地址,这个地址要复制给后端脚本使用。
3.2 用Web3.py完成“注册→投票→查结果”的调用闭环
会 Python 的人通常更习惯用 web3.py 做验证脚本,因为这可以在一个文件里把整个流程串起来,作业报告里也能贴。先导出合约 ABI,再写:
import json from web3 import Web3 w3 = Web3(Web3.HTTPProvider("http://127.0.0.1:8545")) assert w3.is_connected() with open("abi.json", "r", encoding="utf-8") as f: abi = json.load(f) contract = w3.eth.contract(address="0x...DeployedAddress", abi=abi) admin, voter = w3.eth.accounts[0], w3.eth.accounts[1] # 管理员登记候选人和投票人 tx_add = contract.functions.addCandidate("候选人A").transact({"from": admin}) w3.eth.wait_for_transaction_receipt(tx_add) tx_reg = contract.functions.registerVoter(voter).transact({"from": admin}) w3.eth.wait_for_transaction_receipt(tx_reg) # 开票 tx_start = contract.functions.startVote().transact({"from": admin}) w3.eth.wait_for_transaction_receipt(tx_start) # 投票,from 必须是投票人本人 tx_vote = contract.functions.vote(0).transact({"from": voter}) receipt = w3.eth.wait_for_transaction_receipt(tx_vote) print("vote status:", receipt.status) # 查询胜者 print(contract.functions.getWinner().call())参数说明:transact里的from决定交易签名者,合约里的msg.sender就是它。vote 时如果把 from 写成 admin,会得到not registered。wait_for_transaction_receipt返回回执,status为 1 表示链上执行成功,为 0 表示 EVM 回滚,需要去本地节点的日志里查 revert 原因。本地开发账号默认持有大量测试币,所以不用考虑 Gas 上限,但显式给一个gas参数可以避免在某些 RPC 实现下的估算偏差。
3.3 用事件日志向前端展示“票真的在链上”
课堂演示时,不能只靠一个统计数字。更直观的做法是,页面启动后读取候选人列表,之后监听VoteCast事件,每次投完票都动态刷新票数。事件查询代码:
logs = contract.events.VoteCast.get_logs( fromBlock=0, argument_filters={"voter": voter} ) for log in logs: print("candidateId:", log["args"]["candidateId"]) print("txHash:", log["transactionHash"].hex())参数说明:argument_filters只能过滤事件定义中标记为indexed的参数,这里 voter 和 candidateId 都是 indexed,所以能直接按地址过滤。get_logs 是节点上只读操作,不消耗 Gas。把历史日志和合约当前状态结合,就能向评审展示“上一轮投票数据并没有丢失”。
下面这张表对应脚本里每个动作的性质,可以直接写进报告的功能说明部分:
| 需求 | web3.py 调用 | 是否消耗 Gas |
|---|---|---|
| 获取候选人数量 | getCandidateCount().call() | 否 |
| 增加候选人 | addCandidate(...).transact(...) | 是 |
| 注册投票人 | registerVoter(...).transact(...) | 是 |
| 开票 | startVote().transact(...) | 是 |
| 投票 | vote(...).transact(...) | 是 |
| 查询胜者 | getWinner().call() | 否 |
| 查历史投票事件 | events.VoteCast.get_logs(...) | 否 |
4. 作业报告和说明文档怎么写,评分点才会落下来
4.1 作业报告的结构:把“我做了”改成“凭什么可信”
作业报告的正文不要用大篇幅介绍区块链是什么,评审默认你已经掌握概念。建议从项目的第一章直接列出需求与角色模型,针对“委托投票、多选一、一票制、白名单限定”逐条映射到合约的数据结构和权限上;第二章给出系统架构图,并说明本地链、合约、ABI、前端之间的数据流向;第三章做安全分析,直接对照“重复投票、非白名单投票、越权调用、结束改票”四类攻击给出合约层的防御;第四章放部署记录,包括区块号、交易 Hash、部署时间。最后单独留一节写明局限性,例如公共链上投票地址可查、没有隐私保护,现实投票系统里可能需要引入零知识证明或同态加密,这类表述反而是加分项。
4.2 把“不可篡改”从口号变成可复核的数据展示
不要写“区块链数据永远无法篡改”这种绝对话,专业评审会追问“是技术上不可能,还是经济上不划算”。更稳妥的写法是:给出同一区块高度的 Hash,说明它由区块内全部交易内容、时间戳和前块 Hash 共同决定;再给出一个投票交易收据里的 transactionHash,说明任何对票数记录的改动都会改变该交易所在区块的 Hash。可以在说明文档中附带一段查询命令,作为报告里结论的可复现证据:
# 查看本地链上指定区块的哈希 curl -s http://127.0.0.1:8545 \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["0x5",false]}'参数说明:0x5是区块高度的十六进制写法。返回 JSON 里的hash字段就是该区块的哈希,报告截图时截这段输出,比手画结构图更有说服力。
4.3 说明文档从源码包如何组织、交付目录和验收清单
zip 名的源码、作业报告、说明文档三件套,实际解压后建议按功能分层,避免把所有文件堆在根目录:
| 文件/目录 | 作用 | 提交前检查项 |
|---|---|---|
contracts/BlockchainVote.sol | 智能合约源码 | 编译器版本与实际测试一致 |
scripts/deploy.js | 部署脚本 | 网络名是否指向本地链 |
abi.json | 给后端脚本使用 | 与合约编译产物一致 |
vote_demo.py | 注册、投票、查询演示 | 里面的合约地址是否为本机部署地址 |
README.md | 说明文档 | 命令可复制粘贴,无绝对路径 |
作业报告.docx | 需求、设计、测试结论 | 附关键交易 Hash |
README 里最常踩的坑是把“本地链、部署、操作”三步顺序写反。推荐顺序:第一步启动本地链,第二步执行部署脚本并记录输出地址,第三步把地址填进 vote_demo.py,最后再运行演示脚本。打包时不要带 node_modules 和 artifacts 缓存目录,否则 zip 体积会膨胀到几十 MB,提交到作业平台时还容易被拦截。使用命令:
zip -r "基于区块链的投票系统.zip" \ contracts scripts abi.json vote_demo.py README.md 作业报告.docx5. 交作业前的自测:重放、过期投票、边界参数与可复现验证
5.1 Hardhat 测试覆盖四个常见扣分点
把自动化测试作为源码的一部分提交,能直接向评审展示“我考虑过反例”。下面这段覆盖了重复投票、未注册投票、无效候选人三个场景,放进test/vote.js:
const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("BlockchainVote", function () { let vote, owner, voter; beforeEach(async function () { [owner, voter] = await ethers.getSigners(); vote = await ethers.deployContract("BlockchainVote"); await vote.addCandidate("候选人A"); await vote.registerVoter(voter.address); }); it("同一个地址只能投一次", async function () { await vote.startVote(); await vote.connect(voter).vote(0); await expect(vote.connect(voter).vote(0)).to.be.revertedWith("already voted"); }); it("未注册地址不能投票", async function () { await vote.startVote(); await expect(vote.connect(owner).vote(0)).to.be.revertedWith("not registered"); }); it("非法候选人ID会被拒绝", async function () { await vote.startVote(); await expect(vote.connect(voter).vote(99)).to.be.revertedWith("invalid candidate"); }); });逻辑说明:connect(voter)用来切换调用者身份;beforeEach 里已经执行过 addCandidate 和 registerVoter,所以每个用例的初始状态一致。运行npx hardhat test,三项全绿后再考虑提交。
5.2 用固定区块高度重查哈希,演示链上数据没有被改
验证“不可篡改”不需要冒险演示分叉或回滚,一个安全的做法是:在支持持久化数据目录的本地链上完成投票后,记录某个投票交易所在区块的高度;重启节点或把数据目录复制到另一台机器,再通过eth_getBlockByNumber查询同一高度的哈希。两次结果一致,说明该区块内容没有被任何层面的单点配置改变过。把这个对比写在作业报告的验证章节里,就是一份可复现的证据。
5.3 答辩验收表和存档技巧
最后在说明文档里附一张验收操作表,演示时按表执行,每一步都能与报告对应:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 启动本地链 | RPC 端口 8545 正常响应 |
| 2 | 执行部署脚本 | 输出合约地址 |
| 3 | 运行 vote_demo.py | 打印vote status: 1 |
| 4 | 再次向同一投票人发起 vote | 回滚,提示already voted |
| 5 | 查询 getWinner | 与前端展示票数一致 |
| 6 | 按区块高度查交易 | 报告中 Hash 与现场一致 |
答辩现场最怕临时“搭环境”。建议提前把部署地址、ABI 文件、合约源码和测试代码放到同一个目录,演示时直接从源码目录启动,而不是打开之前保存的截图。把abi.json作为标准产物提交进 zip,能省去现场重新生成的步骤,整个演示控制在十分钟内完全足够。
本文还有配套的精品资源,点击获取