简介:一份面向计算机专业学生、教师及企业开发者的以太坊Dapp众筹项目完整资料包,源于优秀毕业设计/期末大作业,源码可在本地编译运行,评审分达95分以上。项目覆盖智能合约编写(Solidity)、前端界面(HTML5/CSS3及React/Vue框架)、后端服务(Node.js与Express)等全栈实现,并提供详尽设计文档、系统架构图、数据库设计及接口说明。压缩包共37个文件,以JS脚本、Solidity合约、配置文件、Markdown文档、思维导图及PDF说明为主,整体约34.86MB,目录结构清晰,便于按模块检索。目前已有54人学习使用,适合作为毕设参照、课程设计或多层架构Dapp开发入门进阶的实践素材。
1. 从宠物商店到众筹:为什么这个 Dapp 选题能落地
如果你接触过 Truffle 的宠物商店 dapp,会发现它只能演示“领养一只宠物”,整个交互只有一个状态变更。而基于以太坊的 Dapp 众筹项目,把智能合约从“单状态玩具”推到了“多角色资金流”的真实场景:用户可以发起筹款、出资、查看进度,发起人可以提取资金,未达标时出资人可以退款。这套资料恰好把这套逻辑做成了完整工程,前台界面、合约、后端接口、设计文档四件套齐全。很多人的毕业设计或期末大作业都会选这个方向,因为评委能在演示中看到区块链透明性的实际价值,而不是空谈概念。对于已经工作的开发者,这也是理解“链上资产 + 链下展示”分离思想的低成本样本。
2. 基于以太坊的 Dapp 分层架构与工程目录解析
2.1 分层架构:链上资产、链下展示
以太坊 Dapp 与传统 Web 系统最大的区别在于信任边界。传统众筹平台的后端数据库同时保管“订单”和“资金流水”,一旦数据库被改写,用户很难自证。而在这个项目里,资金状态由 Solidity 合约保存在链上,后端服务器即使宕机,合约里的资金和账号记录依然存在。
从工程角度,这套项目按三条线拆开:合约层负责资金规则,前端层负责用户操作,Node.js 后端负责缓存链上事件,给前端提供更友好的查询接口。分层结构可以整理为下面的关系。
| 分层 | 技术选型 | 职责 |
|---|---|---|
| 智能合约层 | Solidity | 记录众筹项目、出资、退款、白名单等核心状态 |
| 本地链 | Ganache / 测试网 | 提供 EVM 环境,部署合约并执行交易 |
| 前端层 | HTML5/CSS3 / React 或 Vue | 展示众筹列表、项目详情,触发钱包交易 |
| 后端层 | Node.js + Express | 监听合约事件,缓存列表数据,提供 REST API |
| 钱包层 | MetaMask | 管理用户私钥,签名并广播交易 |
关键点在于:后端不触碰资金。哪怕后端被攻击,攻击者也只能修改前端展示的数据,无法转走合约里的 ETH。验收演示时,只要“前后端停掉,合约的余额还在”这一条,就足够说明去中心化的价值。
2.2 开发环境选型:脚本编译还是框架迁移
拿到压缩包后,先不要急着看业务代码,先确认环境。这个项目没有直接依赖 Truffle 的 migrate 命令,而是提供了01-compile.js和02-deploy.js两个脚本,逻辑一目了然:用solc编译合约,再用web3.js广播部署交易。好处是摆脱框架黑盒,适合写在论文“系统实现”章节里。
我一般会这样准备依赖:
npm install npm install --save-dev ganache-cli@6.12.2 npm install --save web3@1.10.0 npm install --save solc@0.8.21其中ganache-cli是本地以太坊节点,默认监听8545端口;web3.js负责和节点通信;solc是 Solidity 编译器。注意 Node.js 版本不要用太新的 20+,部分旧版solc-js在 Node 18 以上会有 Buffer 接口兼容问题,如果编译报错,优先固定 Node 16。
2.3 目录结构里的关键文件
解压后目录大致如下,和很多 Truffle 脚手架项目基本一致。
Funding-eth/ ├── contracts/ # Solidity 合约文件 ├── src/ # 前端源码 ├── public/ # 静态资源与入口 HTML ├── 01-compile.js # 编译合约,输出 ABI 与 Bytecode ├── 02-deploy.js # 部署到本地链或测试网 ├── package.json # 项目依赖与脚本 ├── README.md.zbak # 说明文档备份 ├── 众筹项目地图-唯一.xmind # 项目设计思维导图 └── .gitignore对这个结构,我建议重点看三个文件:contracts下的.sol文件是合约源码,整个项目的核心;01-compile.js能帮你理解合约如何从源代码变成 ABI;02-deploy.js则展示了交易如何被打包上链。README.md.zbak里的内容往往是旧版说明,部署参数可能过期,但架构图仍值得参考。
| 路径/文件 | 作用 | 学习优先级 |
|---|---|---|
| contracts/ | 众筹业务的核心规则 | 高 |
| 01-compile.js | 编译流程,适合写进文档 | 中 |
| 02-deploy.js | 部署脚本,可改写为自动部署 | 中 |
| src/ | 前端组件与页面 | 高 |
| .xmind 文件 | 需求分析与模块划分 | 低(答辩资料用) |
3. 众筹合约的 Solidity 设计与部署脚本解读
3.1 融资模型在链上如何表示
众筹业务的核心模型是“目标金额 + 截止时间 + 资金归属”。传统实现需要后端数据库记录项目表、订单表、退款表;而以太坊合约只需要一个结构体和一个映射。
我这里整理出一份最常见的基础模型,和项目中contracts下的实现逻辑基本一致:
| 字段 | 类型 | 含义 |
|---|---|---|
| owner | address payable | 项目发起人,拥有资金提取权 |
| title | string | 项目名称 |
| goal | uint256 | 众筹目标,单位是 wei |
| deadline | uint256 | 截止时间戳,之后不允许出资 |
| amountRaised | uint256 | 当前已筹金额 |
| ended | bool | 是否已结束,防止重复提取 |
| goalReached | bool | 是否达标,决定资金去向 |
合约设计中有两个容易忽略的点。第一,出资人记录不能只存总金额,必须用mapping(uint256 => mapping(address => uint256))记录每个用户对每个项目的出资额,否则退款时无法计算。第二,deadline必须用block.timestamp获取链上时间,不能用服务器时间,否则用户改本地时钟就能绕过截止时间。
3.2 核心合约代码实现
下面是一段简化但可运行的众筹合约,结构思路与毕设项目一致,去掉了管理员和分类信息以方便阅读。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract FundingEth { struct Campaign { address payable owner; string title; uint256 goal; uint256 deadline; uint256 amountRaised; bool ended; } Campaign[] public campaigns; // campaignId => userId => contribution amount mapping(uint256 => mapping(address => uint256)) public contributions; event CampaignCreated(uint256 indexed id, address indexed owner, string title, uint256 goal); event Contribution(uint256 indexed id, address indexed contributor, uint256 amount); function createCampaign( string memory title, uint256 goal, uint256 durationSeconds ) external { campaigns.push(Campaign({ owner: payable(msg.sender), title: title, goal: goal, deadline: block.timestamp + durationSeconds, amountRaised: 0, ended: false })); emit CampaignCreated(campaigns.length - 1, msg.sender, title, goal); } function contribute(uint256 campaignId) external payable { Campaign storage c = campaigns[campaignId]; require(block.timestamp < c.deadline, "campaign ended"); require(msg.value > 0, "empty contribution"); contributions[campaignId][msg.sender] += msg.value; c.amountRaised += msg.value; emit Contribution(campaignId, msg.sender, msg.value); } function withdraw(uint256 campaignId) external { Campaign storage c = campaigns[campaignId]; require(msg.sender == c.owner, "not owner"); require(block.timestamp >= c.deadline, "not finished"); require(c.amountRaised >= c.goal, "goal not reached"); require(!c.ended, "already withdrawn"); c.ended = true; uint256 amount = c.amountRaised; c.amountRaised = 0; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "withdraw failed"); } function refund(uint256 campaignId) external { Campaign storage c = campaigns[campaignId]; require(block.timestamp >= c.deadline, "not finished"); require(c.amountRaised < c.goal, "goal reached"); uint256 amount = contributions[campaignId][msg.sender]; require(amount > 0, "no contribution"); contributions[campaignId][msg.sender] = 0; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "refund failed"); } }代码里有三个设计细节值得展开。
withdraw中先改c.ended = true,再发送 ETH,是为了防止重入攻击。如果先转账,攻击者可以在 fallback 里再次调用withdraw,导致资金被重复提取。虽然演示项目不一定遇到攻击,但论文评审时这一行能明显加分。
refund把用户出资清零后再转账,这样即使退款失败,用户的额度也已清零,不会出现重复退款。这里没有把amountRaised减掉,因为一旦项目失败,合约里剩余资金就是所有人的退款池。
Campaign storage c = campaigns[campaignId]使用storage指针而不是内存副本,直接修改链上数组中的原始结构体。如果写成Campaign memory c,则只修改了临时副本,状态不会更新。这是新手最常见的 bug。
3.3 编译与部署脚本的用法
项目里的01-compile.js和02-deploy.js实际上是在演示一条完整的发布链路。
// 01-compile.js const solc = require('solc'); const fs = require('fs'); const path = require('path'); const source = fs.readFileSync( path.resolve(__dirname, 'contracts', 'FundingEth.sol'), 'utf8' ); const input = { language: 'Solidity', sources: { 'FundingEth.sol': { content: source } }, settings: { outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object'] } } } }; const output = JSON.parse(solc.compile(JSON.stringify(input))); const contract = output.contracts['FundingEth.sol']['FundingEth']; fs.mkdirSync('build', { recursive: true }); fs.writeFileSync( 'build/FundingEth.json', JSON.stringify({ abi: contract.abi, bytecode: contract.evm.bytecode.object }, null, 2) ); console.log('compile done, abi saved to build/FundingEth.json');这段脚本首先读取.sol文件,然后通过solc.compile返回编译结果。outputSelection配置了需要输出 ABI 和部署字节码;evm.bytecode.object是用于链上部署的十六进制字节码。编译结果写入build目录,后续部署脚本直接读取这个 JSON。
部署脚本的核心片段如下。
// 02-deploy.js const Web3 = require('web3'); const fs = require('fs'); (async () => { const web3 = new Web3('http://127.0.0.1:8545'); const [deployer] = await web3.eth.getAccounts(); const artifact = JSON.parse( fs.readFileSync('./build/FundingEth.json', 'utf8') ); const contract = new web3.eth.Contract(artifact.abi); const deployTx = contract.deploy({ data: '0x' + artifact.bytecode }); const gas = await deployTx.estimateGas({ from: deployer }); const instance = await deployTx.send({ from: deployer, gas }); console.log('deployed at', instance.options.address); })();estimateGas会估算部署交易所需的 gas,省去手动设置;send完成签名广播。部署地址就是未来前端要使用的合约地址,建议保存到src/config.js或环境变量,而不是在代码里写死。
4. 前端 MetaMask 交互与 Node.js 数据接口实现
4.1 前端通过钱包地址完成身份认证
前端项目一般不会保存用户私钥,而是用 MetaMask 的注入对象window.ethereum与合约交互。项目在 React 组件里通常会封装一个web3实例。
import Web3 from 'web3'; let web3; if (window.ethereum) { web3 = new Web3(window.ethereum); await window.ethereum.request({ method: 'eth_requestAccounts' }); } else { web3 = new Web3('http://127.0.0.1:8545'); } const accounts = await web3.eth.getAccounts(); const contract = new web3.eth.Contract(artifact.abi, contractAddress); // 用户出资 0.1 ETH await contract.methods.contribute(campaignId).send({ from: accounts[0], value: web3.utils.toWei('0.1', 'ether'), gas: 200000 });这里的eth_requestAccounts会弹出 MetaMask 授权窗口,返回值就是当前钱包地址。用户不需要输入密码,不需要额外注册,链上地址本身就是身份。调用send时,MetaMask 会弹出确认窗口显示 gas 费和数据,确认后才写入链上。
有一个常见坑:前端调用contribute前必须做好表单校验,否则用户输入一个负数或者空字符串,toWei会抛出异常。更重要的是一旦用户点击确认,链上交易无法取消,所以前端要在send之前先通过后端接口查询项目是否已到期,不要完全依赖合约报错。
4.2 Node.js 后端做事件缓存与 REST 接口
智能合约可以通过getPastEvents拉取历史事件,但每次遍历所有区块成本很高。所以项目里用 Node.js + Express 把CampaignCreated和Contribution事件同步到本地内存或数据库,前端列表页直接请求后端接口。
const express = require('express'); const Web3 = require('web3'); const app = express(); const web3 = new Web3('http://127.0.0.1:8545'); const contract = new web3.eth.Contract(abi, contractAddress); app.get('/api/campaigns', async (req, res) => { const events = await contract.getPastEvents('CampaignCreated', { fromBlock: 0, toBlock: 'latest' }); const campaigns = await Promise.all( events.map(async (event) => { const id = event.returnValues.id; const data = await contract.methods.campaigns(id).call(); return { id, title: data.title, goal: web3.utils.fromWei(data.goal, 'ether'), amountRaised: web3.utils.fromWei(data.amountRaised, 'ether'), deadline: data.deadline, owner: data.owner }; }) ); res.json({ code: 0, data: campaigns }); }); app.listen(3000, () => console.log('api server running on 3000'));这里的getPastEvents从 0 区块开始扫描,返回所有创建众筹项目的事件。拿到事件后,再用campaigns(id).call()读取对应项目的最新状态。call()不产生交易,不消耗 gas,适合读操作。
| 接口路径 | 方法 | 说明 | 前端使用场景 |
|---|---|---|---|
| /api/campaigns | GET | 众筹项目列表 | 首页列表 |
| /api/campaigns/:id | GET | 单个项目详情 | 项目详情页 |
| /api/campaigns/:id/events | GET | 出资记录 | 项目进度页 |
实际项目里,后端还可以定时用contract.events.Contribution().on('data', callback)监听新出资事件,把数据写入数据库。这个做法比每次请求都去链上拉取更高效,也更接近生产环境。
4.3 前端展示与错误捕获
链上数据和普通 API 数据混在一起时会有一个问题:deadline是秒级时间戳,而 JavaScript 的Date需要毫秒。合约返回的goal单位是 wei,直接显示会给用户造成很大困扰。我一般会在前端做一个格式化工具,统一处理单位转换。
function formatCampaign(campaign) { return { id: campaign.id, title: campaign.title, goal: window.web3.utils.fromWei(campaign.goal, 'ether'), amountRaised: window.web3.utils.fromWei(campaign.amountRaised, 'ether'), deadline: new Date(Number(campaign.deadline) * 1000).toLocaleString() }; }如果 MetaMask 没有安装,页面应给出明确提示,并保留只读模式让用户查看项目。调试时最有效的方式是在send后的.on('transactionHash')里打印交易哈希,然后去 Ganache 终端看交易是否被打包,这样能快速区分是合约报错、交易阻塞还是前端回调未触发。
5. 把 95 分项目跑起来:编译、验收和排错清单
5.1 从 0 到演示的命令顺序
拿到完整资料后,我建议按下面顺序验证,不要直接改代码。
npm install npx ganache-cli --port 8545 node 01-compile.js node 02-deploy.js npm run dev第一条安装依赖;第二条启动本地以太坊节点;第三条编译合约,生成build目录;第四条把合约部署到本地链,输出合约地址;最后启动前端开发服务器。ganache-cli需要保持前台运行,另外开一个终端执行部署命令。
如果02-deploy.js报Invalid response,先检查 Ganache 是否真的在8545端口运行。如果estimateGas报always failing transaction,说明合约构造函数或前置条件有问题,回到代码检查require条件是否为合理范围。
5.2 验收时需要重点检查的功能
| 检查项 | 操作 | 预期结果 | 失败排查方向 |
|---|---|---|---|
| 网络连接 | curl http://localhost:8545 | 返回 JSON 响应 | Ganache 未启动或端口被占用 |
| 合约部署 | 执行 02-deploy.js | 输出合约地址 | 编译缓存未删除,重新执行 |
| 创建项目 | 调用 createCampaign | 状态显示“已创建” | MetaMask 没切到本地网络 |
| 出资 | 调用 contribute | 金额增加 | from地址不是当前钱包 |
| 退款 | 到期后调用 refund | 金额回退到账户 | 区块时间未到截止日期 |
MetaMask 需要手动添加 Ganache 的网络配置:http://127.0.0.1:8545,Chain ID 是1337。导入 Ganache 启动时显示的助记词,第一个账户会有 100 ETH 测试额度。项目演示时最好准备两个账户,一个发起项目,另一个出资,这样能把事件列表显示得更清楚。
5.3 给毕设答辩留一个验证技巧
在演示退款功能时,不要真的等截止时间结束。可以把合约里的deadline从block.timestamp + durationSeconds临时改成block.timestamp + 10,创建项目后等十秒即可测试超时场景。另一个更好用的方式是直接用ganache-cli的evm_increaseTime命令,让链上时间快进到截止日期之后。
curl -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"evm_increaseTime","params":[3600],"id":1}'执行后,再调用evm_mine产生一个新区块,block.timestamp才会真正更新。这个小技巧在现场演示和期末大作业验收时非常实用,能省去重写合约的时间。
本文还有配套的精品资源,点击获取