☰
Solidity+Java+JavaScript构建去中心化拍卖系统的三层架构解析
2026/10/31 13:52:41 网站建设 项目流程

简介:这是一份基于区块链的去中心化拍卖系统完整源码与文档,采用JavaScript配合Solidity智能合约开发,并辅以Java后端支持,适合计算机相关专业在校学生用于毕业设计、课程设计或区块链方向项目初期演示。系统覆盖拍卖合约部署、商品上架、竞价、托管结算等核心流程,包含可运行的Truffle工程与前端交互页面,代码结构清晰,便于二次修改。压缩包共24个文件,含10个js脚本负责前端逻辑与合约调用,4个sol智能合约实现拍卖、托管及迁移逻辑,3个html页面提供操作界面,另有json配置、git忽略规则与README说明文档,整体仅203KB,体量轻巧,适合快速启动与学习。目前已有98人学习下载。项目代码均测试运行成功,答辩评审平均分96分,配套文档说明完整;下载后还可私聊远程教学。读者能获得完整源码、合约部署配置、前端调用示例及类Ebay拍卖界面的参考实现,可直接用于毕设提交或作为区块链课程设计的基础,具备较强的复用价值。

1. 去中心化拍卖系统到底在解决什么问题:从资金托管说起

传统拍卖行最贵的成本不是场地,而是信任:平台替你保管保证金、核对出价、决定谁中标、再把钱转给卖家。一旦平台作恶或系统被入侵,竞拍历史可以被静默改写。去中心化拍卖系统把这条链路拆成三段——Solidity 合约负责规则和资金托管,Java 链下服务负责监听与结算调度,JavaScript 前端只负责展示和发起交易。任何一方都无法单独篡改竞价记录,这就是它存在的理由。

这个项目标题里最难读的部分不是 JavaScript 也不是 Java,而是“链上状态”和“链下业务”的边界。源码和文档说明解决的不是怎么把页面做得好看,而是钱和物如何在一个无人值守的合约里安全换手。它适合正在评估拍卖上链的开发者,也适合刚拿到源码、不知道先从哪个模块跑起的团队成员。

2. 三层技术栈怎么分工:Solidity 定规则,Java 管链下,JavaScript 接用户

先打破一个常见误解:去中心化不等于把所有逻辑都搬上链。把用户昵称、拍卖封面图、浏览记录全塞进合约,除了让 Gas 费用爆炸之外没有任何意义。链上只需要放那些“一旦被篡改就会破坏公平性”的数据。这个标题把 JavaScript、Java、Solidity 放在一起,本质上是把拍卖系统切成三个可独立演进的层:规则层、服务层、展示层。

2.1 为什么规则必须上链:交给 Solidity 的三个不可篡改状态

拍卖业务里真正不能被人为干预的有三样:当前最高出价、最高出价人的身份、拍卖的结束时间。中心化平台可以偷偷插入一个内部价,或者在中标后把出价记录改掉,链上做不到这一点——每一笔出价都是一笔交易,交易一旦被打包进区块,历史就被固定了。

我一般会把合约里的职责控制在五个字段:拍卖编号、卖家地址、当前最高价、最高出价人、结束时间。其余诸如商品描述、图片 URL 这类信息放在链下数据库,通过 auctionId 关联即可。这样既能保证核心规则不可篡改,又能控制存储成本。

一个值得记住的参数边界是:每次出价都需要用户支付 Gas,所以合约里不应该有任何循环遍历所有投标人的逻辑。遍历越贵,用户越不愿意出价,拍卖就越冷清。能用事件日志解决问题就不要用存储数组。

2.2 Java 链下服务在去中心化架构里的位置:谁去触发结算

很多人问:合约里写了 endTime,拍卖到期后不是自动结束吗?答案是不会。合约是“被动执行”的,链上没有定时器,到期后必须有人调用结算函数才会把资金转给卖家。这个“人”就是 Java 链下服务。

Java 服务在这个架构里承担三件事:监听合约事件并写入业务库、拍卖结束后自动调用 settle、对失败任务做补偿。为什么这个位置适合 Java 而不是继续用 JavaScript?因为链下服务需要长时间稳定运行,需要数据库事务,需要处理重启后的断点续扫。Spring Boot 加 Web3j 的组合在事务管理和定时任务上比临时脚本可靠得多,团队做 Java 后端的也更容易接手。

有一点必须在文档说明里写清楚:链下服务持有的私钥只是一个低权限的结算账号,它不能动用任何用户的资产。用户的资金始终托管在合约里,链下服务只能触发合约中已经写好的资金转移逻辑。权限最小化是这条链的安全底线。

2.3 JavaScript 不是业务核心,但它是用户体验的唯一入口

JavaScript 在这一层做的事情非常聚焦:连接钱包、签名交易、查询链上状态、监听事件后刷新页面。很多团队在这里翻车,是因为把前端显示的价格当成了真实成交价,页面状态和链上状态出现了短暂不一致,结果用户以为自己出价成功,实际交易已经被回滚。

一个稳妥的习惯是:前端所有关键数字都以交易收据和事件日志为准。出价按钮发出去的只是交易请求,真正生效的判定条件是 receipt.status 等于 1,以及合约事件里携带的实际金额。框架选型反而不重要,React 或 Vue 都行,重要的是把“投标中”“确认中”“已成交”这几个状态机的转换逻辑写清楚。

搭建环境的时候按这样准备即可:JDK 17 以上、Node.js 18 以上、Solidity 0.8 以上,本地测试链用 Hardhat 或 Ganache。先把环境变量里的 RPC URL、合约地址、私钥占位符准备好,后面每一章的代码都能直接复用。

3. 用 Solidity 写拍卖核心合约:公开增价模型下的出价、退款与结算

链上拍卖最常用、也最容易讲清楚的是公开增价模型:每次出价必须高于当前最高价,价高者得,被超越的投标人可以从合约里取回自己之前锁进去的资金。这一章我按“数据结构、核心函数、安全边界”三个层次展开,代码可以直接作为源码的参考实现。

3.1 拍卖合约的数据结构:状态机要比业务逻辑先定义

写合约的第一件事不是写函数,而是把状态机画出来。一个拍卖最少需要四个状态:创建、竞拍中、已结算、已取消。状态不清晰,后面改一行逻辑就要付出部署新合约的代价。

// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract DecentralizedAuction { enum State { Created, Live, Settled, Canceled } struct Auction { uint256 id; address payable seller; string itemName; uint256 startingPrice; uint256 endTime; State state; address highestBidder; uint256 highestBid; } uint256 public auctionCounter; // auctionId -> Auction mapping(uint256 => Auction) public auctions; // auctionId -> bidder -> 被超越后可取回的金额 mapping(uint256 => mapping(address => uint256)) public releasedFunds; event AuctionCreated(uint256 indexed auctionId, address seller, uint256 endTime); event BidPlaced(uint256 indexed auctionId, address bidder, uint256 amount); event AuctionSettled(uint256 indexed auctionId, address winner, uint256 finalPrice); }

这里把releasedFunds单独拆出来,而不是直接给每个投标人存余额,是因为这个合约只托管“当前最高出价人的钱”。竞拍进行中,只有最高出价人的资金被锁在合约里,其余人一旦被超越就可以随时取回,这样合约里的资金规模永远等于当前最高价,风险敞口最小。

endTime用的是block.timestamp,注意它代表的是区块打包时间,不是现实世界的精确时间。出块间隔越长的链,结束时间的误差越大,所以合约里不要设置“精确到秒必须结束”的强约束,后续链下服务会在实际结束时间之后等待若干区块确认再结算。

3.2 出价、退款与结算:三个函数撑起一条安全资金流

核心资金逻辑集中在三个函数:bid负责接管最高价,withdraw负责让被超越者取回资金,settle负责把最终成交款转给卖家。资金安全的关键是“先改状态,再转钱”,也就是常说的 CEI 模式。

function createAuction( string calldata itemName, uint256 startingPrice, uint256 duration ) external returns (uint256 auctionId) { auctionId = auctionCounter++; auctions[auctionId] = Auction({ id: auctionId, seller: payable(msg.sender), itemName: itemName, startingPrice: startingPrice, endTime: block.timestamp + duration, state: State.Live, highestBidder: address(0), highestBid: 0 }); emit AuctionCreated(auctionId, msg.sender, auctions[auctionId].endTime); } function bid(uint256 auctionId) external payable { Auction storage a = auctions[auctionId]; require(a.state == State.Live, "auction not live"); require(block.timestamp < a.endTime, "auction ended"); require(msg.value > a.highestBid, "bid too low"); require(msg.value > 0, "zero bid"); address oldBidder = a.highestBidder; uint256 oldBid = a.highestBid; if (oldBid > 0) { releasedFunds[auctionId][oldBidder] += oldBid; } a.highestBidder = msg.sender; a.highestBid = msg.value; emit BidPlaced(auctionId, msg.sender, msg.value); } function withdraw(uint256 auctionId) external { uint256 amount = releasedFunds[auctionId][msg.sender]; require(amount > 0, "nothing to withdraw"); releasedFunds[auctionId][msg.sender] = 0; (bool ok, ) = payable(msg.sender).call{value: amount}(""); require(ok, "withdraw failed"); } function settle(uint256 auctionId) external { Auction storage a = auctions[auctionId]; require(a.state == State.Live, "already settled"); require(block.timestamp >= a.endTime, "auction not ended"); a.state = State.Settled; if (a.highestBid > 0) { (bool ok, ) = a.seller.call{value: a.highestBid}(""); require(ok, "pay seller failed"); } emit AuctionSettled(auctionId, a.highestBidder, a.highestBid); }

为什么不用transfer而是用call?transfer固定 2300 Gas,在目标地址是合约时会因为逻辑复杂而失败,进而导致整笔退款卡住。call可以把全部 Gas 转发出去,但必须搭配“先清零余额再转账”的顺序,否则攻击者可以利用重入把同一笔钱取两次。上面的withdraw和settle都先修改了状态,再执行外部调用,这正是防御重入的核心。

bid函数里没有把旧最高价直接转回去,而是记入releasedFunds,这叫“拉取模式”。好处是合约不主动 push 资金,而是等人来取,出块速度快时还能避免因为退款失败导致出价交易整体回滚。代价是 UX 略差,用户需要多操作一步,前端就需要在页面明显位置提示“你已被超越,点击取回保证金”。

部署顺序也很重要:先用solc --bin --abi --optimize Auction.sol -o build编译,得到 ABI 和字节码,再通过 Hardhat 脚本部署到测试链。部署完成后,把合约地址写进 Java 服务的配置文件和前端的环境变量,整个链路才能开始联调。

3.3 如果要做暗标拍卖(盲拍),合约要加在哪里

公开增价模型的问题是:最后一个出价的人永远知道当前最高价,先出价的人没有信息优势。有些场景想要让所有投标人在不知道别人出价的情况下密封竞价,这就需要 Vickrey 式盲拍。

盲拍的标准做法是 commit-reveal:竞拍期先提交一个keccak256(abi.encodePacked(blindBid, secret))哈希和一笔保证金,竞拍结束后进入揭示期,投标人再提交原始金额和 secret,合约校验哈希一致才算有效出价。这个方案能保护竞价隐私,但代价是流程多了一个阶段,用户容易忘记揭示,必须增加惩罚机制,否则人人都可以恶意提交哈希后不揭示。

从源码角度说,我建议第一版先跑通公开增价模型,把资金安全和结算链路验证完,再扩展盲拍。盲拍需要引入两个时间参数:竞价结束时间和揭示结束时间。这两个时间都要配置成 Unix 时间戳,链下服务的结算任务要额外判断“当前是否已过揭示期”,比公开拍卖多一层状态判断,复杂度会上一个台阶。

4. 用 Java + Web3j 把合约接进业务系统:事件索引、持久化与自动结拍

Solidity 合约写完之后,它只是链上一段没有业务感的代码。用户需要看到竞拍列表,管理员需要知道哪些拍卖即将到期,这些数据必须落到数据库里,Java 服务就是连接链上和业务系统的桥梁。这一章从连接配置讲到事件监听,再讲到自动结算,覆盖一套最小可用的链下服务。

4.1 从 Solidity 到 Java 代码:wrapper 生成与连接配置

Web3j 提供代码生成工具,可以直接从 ABI 生成类型安全的 Java 合约类。生成的 wrapper 里包含合约所有函数的调用方法和事件流,比手写 JSON RPC 请求可靠得多。

web3j generate solidity \ -b build/Auction.bin \ -a build/Auction.abi \ -o src/main/java \ -p com.auction.contract

生成完成后,需要配置 Web3j 实例。这里要区分两个连接协议:HTTP 适合请求响应模式,比如查询余额、调用合约;WebSocket 适合订阅事件,链上每出一个新块都会推送到服务端,实时性好,但断连需要自己重连。生产环境我一般用 WebSocket 做监听,HTTP 做查询兜底。

@Configuration public class ChainClientConfig { @Value("${chain.ws-url:ws://127.0.0.1:8546}") private String wsUrl; @Value("${chain.rpc-url:http://127.0.0.1:8545}") private String rpcUrl; @Bean(destroyMethod = "shutdown") public Web3j web3j() { return Web3j.build(new WebSocketService(wsUrl, true)); } @Bean public Web3j httpWeb3j() { return Web3j.build(new HttpService(rpcUrl)); } }

两个 Bean 对应两种用途:web3j用于监听事件,httpWeb3j用于定时查询和补扫。参数里最需要注意的是超时设置,WebSocket 空闲时间过长会被服务端断开,重连逻辑必须做指数退避,否则重启后事件监听静默失效,链下数据就开始滞后。

4.2 监听链上事件:幂等消费是数据一致性的第一道防线

事件监听的本质是把链上日志转成业务数据库记录。但链上事件不是消息队列,它不保证“只投递一次”。服务重启、节点断线、区块重组都可能导致同一笔出价被处理两次。解决方式是在数据库里给事件日志加唯一约束。

@Component public class AuctionEventIndexer { private final Web3j web3j; private final AuctionRepository auctionRepository; public AuctionEventIndexer(Web3j web3j, AuctionRepository auctionRepository) { this.web3j = web3j; this.auctionRepository = auctionRepository; } @PostConstruct public void start() { AuctionContract contract = AuctionContract.load( auctionContractAddress(), web3j, adminCredentials(), new DefaultGasProvider()); contract.bidPlacedEventFlowable( new DefaultBlockParameterNumber(0), DefaultBlockParameterName.LATEST) .subscribe(this::handleBidPlaced, error -> log.error("event error", error)); } private void handleBidPlaced(BidPlacedEventResponse event) { // 唯一键:交易哈希 + 日志索引,天然保证同一条日志只落库一次 String eventKey = event.log.getTransactionHash() + ":" + event.log.getLogIndex(); if (auctionRepository.existsByEventKey(eventKey)) { return; } AuctionBid bid = AuctionBid.builder() .auctionId(event.auctionId) .bidder(event.bidder) .amount(event.amount) .eventKey(eventKey) .blockNumber(event.log.getBlockNumber()) .build(); auctionRepository.save(bid); } }

这段逻辑里有三个参数值得细看。第一个是new DefaultBlockParameterNumber(0),表示从 0 号区块开始扫描,适合开发测试;生产环境应该把这个值改成项目启动前已处理到的区块高度,避免每次重启都全量重扫。第二个是确认数,如果链上出现重组,在足够确认数之前的事件都可能是临时状态,稳妥做法是只索引已经达到 12 个确认的区块。第三个是eventKey,事务哈希加日志索引在整条链上全局唯一,把它作为数据库唯一键,比单独判断 bidder 和金额可靠得多。

4.3 自动结算与人工兜底:链下调度器的补偿逻辑

拍卖结束后必须有人调用settle。Java 服务用定时任务扫描即将到期和刚结束的拍卖,等到链上确认数足够后再触发结算。这里最容易踩的坑是“看到时间到了就马上结算”,但区块时间可能比现实时间慢几秒,结算交易反而会因为block.timestamp < endTime被 revert。

@Scheduled(cron = "*/30 * * * * *") public void settleDueAuctions() { long now = System.currentTimeMillis() / 1000; long confirmWindow = 12 * blockTimeSeconds; // 按链的出块时间估算 List<Auction> dueAuctions = auctionRepository .findByStateAndEndTimeBefore(AuctionState.LIVE, now); for (Auction auction : dueAuctions) { if (now < auction.getEndTimeSeconds() + confirmWindow) { continue; } try { TransactionReceipt receipt = settleExecutor.settle(auction.getId()); auction.setState(AuctionState.SETTLED); auction.setSettleTxHash(receipt.getTransactionHash()); auctionRepository.save(auction); } catch (Exception ex) { // 失败原因可能是链上已被手动结算,或者 RPC 节点临时故障 alertService.send("auction settle failed, id=" + auction.getId(), ex); } } }

settleExecutor.settle内部调用的就是 Web3j wrapper 生成的合约方法。失败时不要立刻无限重试,先告警,然后人工检查链上状态。因为settle不是必须由我们服务触发的,任何人都可以调用它,所以失败后先去区块浏览器查一下合约状态,往往发现竞拍已经在另一个地址的触发下正常结算了,数据库只需要做一次状态同步。

自动结算的补偿逻辑就是“先查链上,再改本地”。业务库可以落后于链,但不能领先于链。任何时候本地状态和链上状态冲突,都以链上的收据和事件为准。

5. 避坑:从源码到能交易的 5 个高频问题(现象 / 原因 / 解决)

这个标题的源码通常会把环境、合约、链下服务、前端四块都铺开,但真正让新手卡住的往往不是架构,而是几个非常具体的联调问题。我按“前端联调”和“链下服务与数据消费”两组整理出 5 个高频坑。

5.1 前端与合约联调最容易翻车的两个点

坑 1:出价交易一直 pending,最后超时。

现象:MetaMask 弹出确认框,用户点击后交易过了十几分钟还在 pending,前端一直转圈。

原因:合约调用没有显式设 gasLimit,钱包自动估算时把 gasPrice 压得太低,或者链上当前区块拥堵,矿工优先打包高价交易。

解决:在contract.bid(auctionId, { value, gasLimit })里显式传参,gasLimit 不要用固定值,先调用contract.estimateGas.bid(auctionId, { value })估算,再乘一个 1.2 的缓冲系数。同时在前端做一个交易状态机:pending -> mining -> confirmed,超过 5 分钟未确认就让用户手动加速。千万不要用“广播出去了就算成功”的逻辑更新页面。

坑 2:receipt.status 为 0,前端仍然提示出价成功。

现象:交易没有 revert,但 UI 上已经显示用户是最高出价人,实际合约里的 highestBidder 根本没变。

原因:前端只监听了transactionHash,一旦拿到哈希就乐观更新状态。乐观更新在以太坊这种终局性较弱的链上是危险行为,区块回滚或状态异常会直接造成 UI 与链上不一致。

解决:统一等tx.wait()返回的 receipt,判断receipt.status === 1后再更新本地状态。如果项目里已经大量使用乐观更新,就在页面显著位置标注“待确认”,等 receipt 到达后再替换为准确数据。

5.2 链下服务与数据消费的三个坑

坑 3:Java 服务重启后,同一条出价事件被重复入库。

现象:数据库里同一个 bidder 对同一个拍卖出现了多条金额相同的出价记录,统计成交价时翻倍。

原因:服务监听的事件流基于区块高度游标,重启后游标没有持久化,重新订阅时又从 0 号区块开始扫,已经把处理过的事件再扫了一遍。

解决:给事件记录建唯一索引,字段就是transactionHash + logIndex。入库前先查一次,存在则直接跳过。游标本身也要定期持久化,写到业务库或 Redis 里,这样即使唯一约束漏掉,也不会重复消费已经处理完的区块。

坑 4:前端读取链上金额得到一串奇怪的大数。

现象:合约返回的uint256在 JavaScript 里变成了末位被截断的数字,比如 1000000000000000000 变成 1000000000000000100,导致价格显示错乱。

原因:JavaScript 的 Number 类型最多安全表示 2 的 53 次方,而以太坊金额用的 wei 动辄就是 10 的 18 次方,直接转 number 必然丢精度。

解决:前端用ethers.formatEther(value)这类内置格式化工具,或在 Java 链下服务用BigInteger保持原值,传到前端时转成字符串。所有涉及金额的计算都不要用普通浮点数乘法,否则校验成交价时会出现“差一分钱”的玄学问题。

坑 5:测试链上时间基准和真实时间对不上。

现象:本地测试时设置 10 分钟拍卖,结果等了 20 分钟还没结束;或者刚部署完合约拍卖就变为已结束状态。

原因:Ganache 和 Hardhat 默认区块时间行为不一致,有的环境每个区块自动出块,block.timestamp会快进;有的环境固定时间戳,现实过了很久链上时间还没走。测试的时候如果把endTime设成当前时间加 600 秒,在固定时间戳环境下永远不会到达。

解决:测试环境用 Hardhat 的evm_increaseTime和evm_mine手动推进时间,先调用evm_increaseTime(601),再调用evm_mine()生成新块,然后才触发结算。主网环境则要接受现实时间与区块时间存在几十秒偏差,链下服务的确认窗口就是为这个偏差做的缓冲。

6. 用 JavaScript 接前端:从连接钱包到出价确认的最小闭环

前端是整个系统离用户最近的一层,也是最容易把链上状态“画错”的一层。最后一章给出一个最小可运行的 JavaScript 接入示例,并把前面提到的状态管理技巧落到代码里。

6.1 初始化钱包与合约:provider 与 signer 别搞混

provider用来读链上数据,signer用来代用户签名交易,两者职责完全不同。读价格用 provider,发交易必须切换成 signer,否则会报“missing signer”这种运行时报错。

import { ethers } from "ethers"; async function connectAndLoad(contractAddress, abi) { if (!window.ethereum) throw new Error("No Ethereum wallet found"); await window.ethereum.request({ method: "eth_requestAccounts" }); const provider = new ethers.BrowserProvider(window.ethereum); const network = await provider.getNetwork(); // 部署在哪个链,前端就必须切到哪个链 if (network.chainId !== expectedChainId) { await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: `0x${expectedChainId.toString(16)}` }] }); } const signer = await provider.getSigner(); const contract = new ethers.Contract(contractAddress, abi, signer); return { provider, signer, contract }; }

注意 chainId 的检查必须在初始化合约之前完成。很多前端启动后显示一片空白,打开控制台才发现是链不对。用toString(16)转成十六进制是因为钱包的wallet_switchEthereumChain要求的是十六进制链 ID,直接传十进制会被钱包拒绝。

6.2 出价后的确认逻辑:等 receipt,不要等“我以为”

出价的交易一旦发出,用户最关心的是“我到底是不是最高出价人”。这里最稳的顺序是:先拿 receipt 确认交易成功,再根据事件日志刷新竞拍状态,最后才更新 UI。

async function placeBid(contract, auctionId, priceInEth) { const signer = await contract.runner.provider.getSigner(); const sender = await signer.getAddress(); // 用 ethers 内置函数处理 wei 转换,避免 Number 精度丢失 const amountWei = ethers.parseEther(priceInEth); const gasLimit = await contract.bid.estimateGas(auctionId, { value: amountWei }); const tx = await contract.bid(auctionId, { value: amountWei, gasLimit: Math.ceil(Number(gasLimit) * 1.2) }); // 等收据,不要只等 tx.hash 就更新界面 const receipt = await tx.wait(); if (receipt.status !== 1) { throw new Error("bid transaction reverted"); } // 从事件里读取链上确认后的最高价,而不是用本地的 amountWei const event = receipt.logs .map((log) => { try { return contract.interface.parseLog(log); } catch (e) { return null; } }) .find((parsed) => parsed && parsed.name === "BidPlaced"); if (event) { const confirmedAmount = event.args.amount; return { sender, confirmedAmount }; } }

contract.bid.estimateGas在发起交易前先估算一次 Gas,能过滤掉大部分“余额不足”和“低于当前最高价”的失败原因,让用户不用等钱包弹窗才知道交易会失败。乘以 1.2 是给链上执行留一点余量,避免真实执行时微小的 Gas 消耗差异导致交易卡住。

我自己早期做链上应用时,总觉得等 receipt 多此一举,结果在一次测试网演示里因为区块回滚,页面显示用户中标,合约里却是另一笔出价生效,现场很尴尬。后来固定下来一个习惯:出价、拍卖创建、结算,任何一个关键动作都先写收据校验,再动页面状态,最后用事件日志校准一遍数字。

希望这个最小的 JavaScript 接入示例,能让你在跑通整套源码时少走一段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询