简介:一份以JavaScript与Solidity为主、结合Java技术栈背景的去中心化拍卖系统完整源码,面向计算机相关专业学生、区块链开发者及毕业设计创作者。项目采用Truffle框架组织,涵盖拍卖合约、托管账户与Web交互层,适合作为课程设计、毕业设计或项目初期演示使用。压缩包共24个文件,整体约203KB,核心文件包括10个JavaScript文件(前端交互与逻辑)、4个Solidity合约(链上拍卖、托管与迁移)、3个HTML页面(界面展示)以及JSON、README等辅助配置与说明,目录结构清晰,便于对照学习。目前平台显示已有98人学习下载,具有一定参考价值。源码内含EcommerceStore、EcommerceStoreV2、Escrow、Migrations等合约,配置了truffle-config.js与webpack打包脚本,可按文档说明直接运行;代码均测试通过,答辩评审平均分达96分,作者表示可远程教学。读者既能整体理解拍卖上链流程,也可在现有基础上扩展功能,适用于从入门到中高级的区块链项目实践。
1. 去中心化拍卖系统:把Ebay式竞拍搬上区块链
很多人问我,拍卖系统为什么非要上区块链?传统电商拍卖,出价记录、成交价格、资金托管全捏在平台手里,买家怕改数据,卖家怕赖账,平台沾上资金就是最大风险点。用Solidity写拍卖合约,把上架、出价、成交、Escrow托管整条链路部署到链上,记录不可篡改,结算交给托管合约自动执行,正好解决毕设和中小团队开发里的信任痛点。这份资源是完整跑通的Truffle项目,包含EcommerceStore.sol拍卖主合约、EcommerceStoreV2.sol升级版合约、Escrow.sol托管合约、Truffle迁移脚本、webpack前端工程和种子脚本,JavaScript与Solidity负责链上交互,Java后端可以对接合约事件做订单同步,适合照着部署复现,也适合直接改造成自己的拍卖dApp。
2. Solidity合约设计:从存储布局看懂拍卖的状态机
2.1 目录结构与合约职责划分
打开项目根目录先别急着跑,把文件结构过一遍心里才有底。典型的Truffle项目结构是这样的:
ebay-truffle-auction-master/ ├── contracts/ │ ├── EcommerceStore.sol │ ├── EcommerceStoreV2.sol │ ├── Escrow.sol │ └── Migrations.sol ├── migrations/ │ ├── 1_initial_migration.js │ ├── 2_deploy_contracts.js │ └── 3_upgrade_ecommerceStore.js ├── app/ │ └── src/ │ ├── index.html │ └── main.js ├── truffle-config.js ├── webpack.config.js ├── seed.js └── package.json其中Migrations.sol是Truffle框架用来记录迁移版本的合约,每次执行迁移脚本前Truffle会先读它,防止同一脚本被重复执行。EcommerceStore.sol是拍卖主合约,定义商品结构体、出价映射和竞拍流程;Escrow.sol承担资金托管,买家付款后资金先锁在托管合约里,确认收货后再放款给卖家。EcommerceStoreV2.sol属于升级版本,在原合约基础上改了部分业务逻辑,我们放到第六章单独讲。
从存储布局角度来看,EcommerceStore.sol里最核心的存储变量是items映射和itemCount计数器。Solidity中mapping(uint256 => Item) public items会自动生成一个同名的getter函数,前端web3可以直接通过items(0)读取第一条商品数据,这是调试时最方便的入口,不需要额外写查询函数。
2.2 拍卖主流程:商品上架、出价、成交与资金托管
拍卖合约的核心业务可以拆成四个动作:上架、出价、成交、确认收货。第一个是上架,代码通常长这样:
function addItem( string memory _name, uint256 _startingPrice, uint256 _duration ) public returns (uint256) { items[itemCount] = Item({ id: itemCount, name: _name, seller: msg.sender, startingPrice: _startingPrice, currentPrice: _startingPrice, endTime: block.timestamp + _duration, isSold: false, highestBidder: address(0) }); itemCount++; emit ItemAdded(itemCount - 1, _name, _startingPrice, _duration); return itemCount - 1; }每次上架都会生成一个新的item id,起始价就是当前价,截止时间是当前区块时间戳加上拍卖持续时间。这里有个细节:itemCount先赋值再自增,事件里记录的ID是itemCount - 1,也就是刚刚创建的那条记录的下标。如果你在事件监听里直接用itemCount去读数据,读到的会是下一条不存在的记录,前端列表就会多出一个空白项。
出价逻辑是拍卖合约里最容易写错的地方:
function bid(uint256 _itemId) public payable { Item storage item = items[_itemId]; require(block.timestamp < item.endTime, "拍卖已结束"); require(msg.value > item.currentPrice, "出价必须高于当前价"); // 退款给上一个出价者 if (item.highestBidder != address(0)) { payable(item.highestBidder).transfer(item.currentPrice); } item.currentPrice = msg.value; item.highestBidder = msg.sender; emit BidPlaced(_itemId, msg.sender, msg.value); }这段逻辑的关键在于:新出价进入后,先把上一次最高价退回给之前的出价者,再把当前价更新为新出价。这样做的好处是资金不经过合约长期沉淀,每个失败的出价者都能及时拿回款项。但注意,如果上一个出价者地址是合约(比如某些代理合约),transfer可能触发重入风险,实际生产环境里通常会用Pull Payment模式改成“先记账、后提取”。做毕设的话用transfer问题不大,但答辩时能说出这个改进点,分数会明显不一样。
成交确认环节一般会用一个finalizePurchase函数让赢家在拍卖结束后确认收货:
function finalizePurchase(uint256 _itemId) public { Item storage item = items[_itemId]; require(block.timestamp >= item.endTime, "拍卖还未结束"); require(msg.sender == item.highestBidder, "只有赢家可以确认"); require(item.highestBidder != address(0), "无人出价,无法成交"); require(!item.isSold, "商品已售出"); item.isSold = true; emit PurchaseFinalized(_itemId, item.highestBidder, item.currentPrice); }写这个函数时有三个校验缺一不可:时间必须已经超过endTime,调用者必须是最高出价者,商品不能是已售状态。很多人做课程设计只写前两个,结果同一个商品被重复确认成交,链上数据就出现矛盾。商品状态流转可以概括为下表:
| 状态 | 触发条件 | 下一个状态 |
|---|---|---|
| 拍卖中 | addItem上架 | 已成交 / 流拍 |
| 已成交 | finalizePurchase确认 | 待资金结算 |
| 待资金结算 | Escrow.deposit托管入金 | 已释放 |
| 已释放 | Escrow.release放款 | 完成 |
2.3 Escrow托管合约的资金流转路径
Escrow.sol扮演的是“中间账户”,它的deposit和release两个函数撑起了资金托管的核心逻辑:
contract Escrow { struct EscrowItem { address buyer; address seller; uint256 amount; bool released; } mapping(uint256 => EscrowItem) public escrowItems; function deposit(uint256 _itemId, address _seller) public payable { require(msg.value > 0, "托管金额必须大于0"); escrowItems[_itemId] = EscrowItem({ buyer: msg.sender, seller: _seller, amount: msg.value, released: false }); } function release(uint256 _itemId) public { EscrowItem storage item = escrowItems[_itemId]; require(item.buyer == msg.sender, "只有买家可以确认收货"); require(!item.released, "资金已释放"); item.released = true; payable(item.seller).transfer(item.amount); } }逻辑不复杂,但参数细节值得注意:买家调用deposit时必须用msg.sender作为buyer,不能传参让别的地址代付;release时校验msg.sender == item.buyer,也就是只有付款方本人能确认收货,这样卖家不能自己给自己放款。如果你要在毕设里加“卖家发货”状态,可以在中间追加一个ship()函数,把状态推进到待确认,同时给合约加一个enum状态机变量。
3. Truffle环境与迁移部署:从配置文件到链上合约地址
3.1 truffle-config.js的网络与编译器配置
Truffle项目的入口配置文件是truffle-config.js,先看常见写法:
module.exports = { networks: { development: { host: "127.0.0.1", port: 7545, network_id: "*" }, ganache: { host: "127.0.0.1", port: 8545, network_id: 5777 } }, compilers: { solc: { version: "0.8.0", settings: { optimizer: { enabled: true, runs: 200 } } } } };这里的network_id用来匹配目标网络。Ganache CLI默认是5777,Ganache GUI的默认端口是7545,如果你本机两个都装了,建议明确指定network_id,否则Truffle可能连错实例。solc版本0.8.0和合约里require、payable的语法是配套的,如果换成0.4.x的旧编译器会直接报语法错误。
如果要把合约部署到测试网,常见做法是在networks里追加一层配置,使用HDWalletProvider注入私钥和BSC或Sepolia的RPC节点地址:
const HDWalletProvider = require('@truffle/hdwallet-provider'); const mnemonic = "你的助记词"; networks: { sepolia: { provider: () => new HDWalletProvider(mnemonic, 'https://rpc.sepolia.org'), network_id: 11155111, gas: 3000000, confirmations: 2, timeoutBlocks: 200 } }gas字段建议手动设置而不是依赖自动估算,测试网Dapp浏览器里经常出现估算结果偏差导致部署卡死的情况。confirmations表示等待多少个区块确认后才认为部署成功,测试网设2就够,主网建议5以上。
3.2 逐个迁移脚本的部署顺序与运行逻辑
Truffle迁移脚本按文件名前缀数字排序执行。第一步1_initial_migration.js部署Migrations.sol注册表:
const Migrations = artifacts.require("Migrations"); module.exports = function (deployer) { deployer.deploy(Migrations); };第二步2_deploy_contracts.js部署业务合约:
const EcommerceStore = artifacts.require("EcommerceStore"); const Escrow = artifacts.require("Escrow"); module.exports = async function (deployer, network, accounts) { await deployer.deploy(EcommerceStore); await deployer.deploy(Escrow); const storeInstance = await EcommerceStore.deployed(); const escrowInstance = await Escrow.deployed(); await storeInstance.setEscrowAddress(escrowInstance.address); };部署顺序值得仔细看:先部署两个合约,拿到各自的deployed地址,再把Escrow地址写回EcommerceStore。如果直接在EcommerceStore构造函数里传入Escrow地址,一旦Escrow部署失败整个迁移都要回滚重来。Truffle的deployer.deploy返回的是Promise,必须await完成之后才能调用deployed()获取地址,这里漏掉await是最容易犯的错,结果就是拿到一个空地址还定位不到原因。
第三步3_upgrade_ecommerceStore.js处理V2升级:
const EcommerceStoreV2 = artifacts.require("EcommerceStoreV2"); module.exports = async function (deployer, network, accounts) { const escrowInstance = await Escrow.deployed(); await deployer.deploy(EcommerceStoreV2, escrowInstance.address); };如果EcommerceStoreV2的构造函数直接接收Escrow地址,迁移脚本就是一行部署加一行参数传递。这种升级策略是在链上新增一个合约实例,旧数据如果需要保留,你得在脚本里手动写数据迁移逻辑,具体做法见第六章。
3.3 部署完成后的验证与种子数据初始化
部署完成后我习惯性跑以下几组命令:
truffle migrate --network development --reset truffle console --network development--reset会强制从Migrations.sol开始重新执行所有迁移脚本,适合合约代码改了之后需要干净重来一遍的场景。如果只是改了前端代码,不需要重置链上数据,直接重启webpack devServer就行。
在console里执行EcommerceStore.deployed()能直接拿到合约实例。合约里有items公共状态变量,可以直接读:
const store = await EcommerceStore.deployed(); const item = await store.items(0); console.log(item.name, item.currentPrice.toNumber());这里的item.currentPrice返回的是BigNumber对象,打印前记得调.toNumber(),不然控制台输出带n后缀,写进前端状态管理容易出类型问题。
seed.js是项目自带的初始化脚本,作用是在部署完成后往链上塞一批测试商品,省得手动调addItem交易:
truffle exec seed.js --network developmenttruffle exec和直接node seed.js不一样:它会在Truffle环境中执行脚本,脚本内部可以通过artifacts.require拿到编译后的合约抽象,自动读取已部署地址。如果你用node直接跑,会遇到artifacts is not defined报错,这是Truffle项目常见的启动误区。
4. 前端JavaScript集成:webpack构建与拍卖页面交互
4.1 webpack.config.js与项目入口
前端部分我单独把webpack.config.js拿出来说,是因为很多人在这一步翻车。项目里前端源码放在app/src下,通过webpack打包成浏览器可执行的bundle。核心配置:
const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { mode: 'development', entry: './app/src/index.js', output: { path: path.resolve(__dirname, 'dist'), filename: 'bundle.js' }, devServer: { static: { directory: path.join(__dirname, 'app'), }, port: 8080, hot: true }, plugins: [ new CopyWebpackPlugin({ patterns: [ { from: './app/src/index.html', to: 'index.html' } ] }) ] };这里的entry指向app/src/index.js,如果项目里入口文件名是main.js或app.js,你得先改这里或者把文件重命名。devServer的static配置在webpack 5里改成了对象格式,老教程里contentBase已经废弃,照旧文章抄配置会直接报contentBase不存在的错。CopyWebpackPlugin的patterns数组写法也是webpack 5之后的规范,v4版本用的from/to参数已经不能用了。
启动前端开发服务器的命令是:
npm install npx webpack serve --open4.2 页面调用合约的完整链路
前端和合约交互的路径是:浏览器 -> MetaMask注入的window.ethereum -> web3.js -> RPC节点 -> 链上合约。项目里常见的初始化代码:
import Web3 from 'web3'; import EcommerceStore from '../../build/contracts/EcommerceStore.json'; let web3; let accounts; let storeInstance; if (window.ethereum) { web3 = new Web3(window.ethereum); await window.ethereum.enable(); } else { console.error('请先安装MetaMask'); } const networkId = await web3.eth.net.getId(); const deployedNetwork = EcommerceStore.networks[networkId]; storeInstance = new web3.eth.Contract( EcommerceStore.abi, deployedNetwork.address );把合约JSON里的abi和deployedAddress读出来,再实例化合约对象。这里有个细节:build/contracts目录下的JSON是Truffle编译时生成的,它包含了networks字段,记录的是每个网络上的部署地址。如果你在Ganache上部署后找不到地址,先确认网络ID能对上。如果deployedNetwork是undefined,最常见的现象是truffle migrate部署成功后没有重新编译,或者前端webpack缓存了旧的JSON文件,清一下缓存重跑即可。
出价按钮的交互逻辑大概是:
async function placeBid(itemId, bidAmount) { const gasEstimate = await storeInstance.methods .bid(itemId) .estimateGas({ from: accounts[0], value: bidAmount }); const result = await storeInstance.methods.bid(itemId).send({ from: accounts[0], value: bidAmount, gas: Math.floor(gasEstimate * 1.2) }); console.log('出价成功', result.transactionHash); }参数说明:estimateGas先估算gas,再乘上1.2倍作为gasLimit,防止实际执行时因为链上状态变化导致out of gas。value字段是wei单位,如果input框读的用户输入是按以太币来的,记得用web3.utils.toWei转换。如果你用Java后端做订单系统,可以在这一步监听合约的BidPlaced事件,通过web3j的flowable或轮询方式同步到数据库,这样前端只负责展示,业务数据落库交给Java服务处理。
4.3 前端联调时的常见报错处理
最常见的报错是Returned values aren't serializable。这类问题出现在合约返回了address数组或struct数组,web3在解码时格式对不上。我一般会先在合约里给每个字段加独立的getter函数,前端一个字段一个字段读,不要一次性拉整个结构体。
另一个高频问题是Invalid number of arguments for Solidity ABI,多半是合约函数签名和前端调用参数个数不匹配。比如合约里bid(uint256 _itemId)只接收一个参数,前端传了两个对象进去。排查方式是把ABI打印出来,对着函数入口逐个核对。
链上特有的问题是nonce冲突:用户连续点击出价按钮,MetaMask还没广播完上一笔交易,第二笔就带着同样的nonce发出去,导致其中一笔被拒绝。解法是在前端提交前禁用按钮,等交易receipt返回后再放开。
async function placeBid(itemId, bidAmount) { const btn = document.getElementById('bidBtn'); btn.disabled = true; try { const result = await storeInstance.methods.bid(itemId).send({ from: accounts[0], value: bidAmount }); // 监听事件刷新当前最高价 storeInstance.events.BidPlaced({ filter: { itemId } }) .on('data', event => { updateCurrentPrice(event.returnValues.currentPrice); }); } catch (err) { console.error('交易失败', err.message); } finally { btn.disabled = false; } }这里用storeInstance.events.BidPlaced做事件监听,可以实现多个用户同时竞价时页面价格自动刷新,不需要手动轮询。注意事件监听的filter参数是可选的,但加上之后可以减少网络传输数据量,测试环境下差别不明显,主网下能明显降低RPC请求压力。
5. 避坑指南:区块链拍卖项目最容易翻车的五个地方
5.1 迁移脚本执行顺序导致合约地址错乱
现象:执行完truffle migrate后,EcommerceStore里的Escrow地址是0x0,调用release时报“地址未设置”。
原因:第二个迁移脚本里使用deployer.deploy时没有等待部署完成就读取地址,或者Truffle回滚后继续执行旧迁移产生了脏状态。
解决:在迁移脚本里改用async/await,确保Escrow部署完成再调用deployed();必要时加--reset强制从Migrations.sol开始重跑。我一般会在脚本开头加一行console.log打印当前网络和账户信息,确认连对了再继续。
5.2 出价函数没有校验起始价下限,导致低价成交
现象:有用户用0.001 ETH拍下一件标价0.01 ETH的商品,前端显示成交价低于卖家设置的起始价。
原因:bid逻辑只判断msg.value > item.currentPrice,没有检查起始价下限,首次出价时currentPrice还等于startingPrice,理论上没问题;但如果起始价是0,就出现了0元成交。
解决:在addItem里加require(_startingPrice > 0),并在bid函数加require(msg.value >= item.startingPrice)兜底。这种防御性校验虽然是冗余的,但能防止前端bug导致异常数据上链。
5.3 webpack 5与copy-webpack-plugin版本不兼容
现象:启动devServer后页面白屏,控制台报Cannot read property 'tap' of undefined。
原因:webpack 5中copy-webpack-plugin必须用v11及以上版本,项目lock文件里锁的还是v6老版本,插件API不兼容。
解决:在package.json里把copy-webpack-plugin升级到^11.0.0,删除node_modules和package-lock.json重装。改完仍然白屏的话,检查bundle.js是否被正确注入到index.html,有时候devServer的静态目录配错导致html加载了旧路径的js。
5.4 合约状态变量用memory而非storage,数据没存进链上
现象:调用addItem返回成功,但再读items数组发现数据全为空,或者只有最后一条数据。
原因:Item storage item = items[itemCount]误写成Item memory item = items[itemCount],memory只是临时拷贝,修改不会写回链上存储。
解决:在需要持久化修改时一律使用storage引用。排查时看Solidity编译警告,memory的赋值位置编译器会给出提示。我自己的习惯是声明变量时先写修饰符,再写类型,养成肌肉记忆就不会错。
5.5 Ganache账号余额不足导致交易反复失败
现象:MetaMask注入账号后调用send交易,报insufficient funds for gas * price + value,但Ganache明明显示有100 ETH。
原因:当前选中账号不是Ganache解锁的账号,而是浏览器里MetaMask的另一个空地址;或者是Ganache端口连错,前端实际连接到了本地其他RPC节点。
解决:确认MetaMask当前账号与Ganache第一个解锁地址一致,检查truffle-config.js的端口号。用web3.eth.getAccounts()拿到的是浏览器选中的账号,拿不到就检查window.ethereum的权限是否成功enable。如果页面加载时MetaMask弹窗没出现,刷新页面后再试一次。
6. 合约升级V2:把旧拍卖数据无损迁移的三条实操经验
EcommerceStoreV2的存在是该资源里最有学习价值的部分,因为它完整演示了合约升级路径。V2相比V1常见改动方向是:增加交易手续费、增加卖家黑名单、修改成交规则。这里我不展开具体代码,只讲通用做法。
第一条经验是“先部署后迁移”。部署V2之前先把V1的items数组全部导出,用脚本把每条item的状态读出来,写入V2的对应映射。导出时可以批量调用getter:
truffle exec scripts/exportItems.js --network developmenttruffle exec可以直接在脚本里用artifacts获取已部署合约实例,遍历items,把数组转成JSON落盘。导入时在迁移脚本里逐条调用V2的importItem函数,注意函数要加onlyAdmin或onlyOwner权限控制,防止别人调用导入接口覆盖数据。
第二条经验是“代理模式优于复制数据”。如果业务场景允许,可以用OpenZeppelin的TransparentUpgradeableProxy方案,让所有用户继续访问同一个代理地址,只有实现合约地址在升级时变化。但这个项目是教学向的毕设资源,直接复制数据成本更低,理解起来也更直观。二选一之前要想清楚:代理模式省Gas但增加可靠性风险,复制数据模式更简单但链上存储成本高。
第三条经验是“升级后必须做回归验证”。部署V2后,我会从头走一遍完整流程:用seed.js初始化一批拍卖商品,模拟三个账号分别出价,跑到成交确认,观察Escrow合约的资金是否按预期释放。把这三步都验证通过,才敢说升级没有问题。
从那以后我每次部署升级版合约,都会强制走一遍“导出—导入—回归”的完整流程,哪怕只是改了一个常量,也不敢跳过验证。这个习惯帮我避掉了不少线上事故,希望帮到你。
本文还有配套的精品资源,点击获取