Solidity+Java+JavaScript构建去中心化拍卖系统的三层架构解析 简介这是一份基于区块链的去中心化拍卖系统完整源码与文档采用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而是用calltransfer固定 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; // 按链的出块时间估算 ListAuction 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 分钟未确认就让用户手动加速。千万不要用“广播出去了就算成功”的逻辑更新页面。坑 2receipt.status 为 0前端仍然提示出价成功。现象交易没有 revert但 UI 上已经显示用户是最高出价人实际合约里的 highestBidder 根本没变。原因前端只监听了transactionHash一旦拿到哈希就乐观更新状态。乐观更新在以太坊这种终局性较弱的链上是危险行为区块回滚或状态异常会直接造成 UI 与链上不一致。解决统一等tx.wait()返回的 receipt判断receipt.status 1后再更新本地状态。如果项目里已经大量使用乐观更新就在页面显著位置标注“待确认”等 receipt 到达后再替换为准确数据。5.2 链下服务与数据消费的三个坑坑 3Java 服务重启后同一条出价事件被重复入库。现象数据库里同一个 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 接入示例能让你在跑通整套源码时少走一段弯路。本文还有配套的精品资源点击获取