
简介这是一套基于以太坊、IPFS、Solidity与JavaScript构建的去中心化文件存储平台完整源码适合希望掌握区块链应用开发、熟悉DApp架构的开发者作为学习与实战参照。平台通过以太坊智能合约管理文件上传、下载、权限控制与交易记录利用IPFS完成内容寻址及分布式存储前端基于React.js与Web3.js实现与链上交互可替代传统集中式云存储场景。压缩包共26个文件以JavaScript脚本、Solidity合约、JSON配置、HTML页面及说明文档为主同时包含环境变量示例、Truffle部署迁移脚本、前端组件和测试文件整体大小仅1.23MB目录划分清晰便于按模块理解。目前已有333人学习下载读者可借此直观了解智能合约部署流程、Web3.js调用方式和IPFS文件上链逻辑还可分析合约中权限控制与付费机制的设计思路并在此基础上扩展功能或重构界面。1. 为什么把文件从中心化存储挪到IPFS和以太坊上这个去中心化文件存储平台能解决什么如果你被问到“拿区块链做文件存储”第一反应多半是“把文件写到链上”。但真正动手做这个项目之后你会发现直接把文件丢进以太坊不但贵得离谱而且技术上根本不可行。这个仓库用的是另一种思路用IPFS存文件本体用以太坊智能合约存文件的索引和所有权——界面长得像Dropbox底层没有一台你信任的中央服务器。它适合两类人一类是想做出一个“数据在自己手里”的私有网盘的应用开发者另一类是想把Solidity、Web3.js、React.js这条链路的全栈DApp流程跑通的学习者。下面我按自己复现这个项目的顺序把架构、合约、前端和踩坑一条条拆开讲。2. 技术架构拆解为什么文件本体进IPFS元数据进以太坊合约2.1 为什么选IPFS而不是传统对象存储内容寻址与CID原理IPFS的核心是“内容寻址”。传统对象存储比如阿里云OSS、AWS S3用的是位置寻址你通过一个URL去访问文件URL和内容本身没有强绑定关系。IPFS不是这样它把你上传的文件内容做一次SHA-256哈希生成一个内容标识符CID。同一份文件不管上传多少次CID都一样这就天然带来了两个好处文件去重相同内容不会重复存两份和防篡改CID变化就说明文件内容被动过手脚。这个项目里IPFS承担的是文件本体存储。你上传一张照片IPFS节点会把它切成若干256KB的块每个块都有一个独立的哈希块之间用MerkleDAG组织起来顶层就是文件CID。你下载时只需要拿到CIDIPFS网络就会按图索骥把块拼回来。这个过程在代码里就是一个ipfs.add()调用返回的path字段就是CID。要注意的是IPFS本身不保证文件永久在线。它没有像Filecoin那样的激励层节点只缓存它感兴趣的内容。你本地节点上传了文件如果你关机或者GC垃圾回收清掉未固定的块文件就没了。所以这个项目里凡是上传操作几乎都必须跟随一个pin动作把文件固定到节点上。后面避坑章节我会详细讲这个点。2.2 以太坊在架构里的定位为什么只存元数据不存文件本体以太坊在这套架构里不是一个存储层而是一个“公证层”。它记录的是谁在什么时间上传了什么文件CID值以及谁有权访问这个文件。这种分工是成本逼出来的——以太坊的Gas费是按存储和计算消耗算的SSTORE指令往链上写一个32字节的slot就要消耗20000Gas按当时的行情折算成人民币写1MB数据上去可能要付几十块钱更不用说还有区块Gas上限的限制。所以这个项目的智能合约只保存四个字段文件ID、IPFS的CID、文件名称、所有者地址。CID一般也就几十字节这对链上存储来说是完全可以接受的开销。真正的大文件数据流走IPFS以太坊上只记“凭证”。这个边界想清楚整个项目就不容易跑偏——很多初学者会试图把文件内容塞进合约的string字段这是最容易翻车的设计错误。合约里还定义了FileUploaded事件。事件在Solidity里有双重作用一是给前端提供一个“链上通知机制”当有文件上传时前端可以通过监听事件拿到交易信息实时刷新列表而不需要每次都轮询二是事件的日志数据存在eth_getLogs里比直接改合约状态便宜得多。这个项目中事件的使用很克制只在uploadFile()里触发一条这点设计得不错。2.3 项目目录结构与关键依赖盘点复制完仓库后第一件事是看目录结构。典型的项目布局长这样decentralized-file-storage-platform/ ├── contracts/ │ └── FileStorage.sol # Solidity智能合约 ├── migrations/ │ └── 1_deploy_contracts.js # 合约部署脚本 ├── src/ │ ├── components/ # React组件 │ ├── service/ │ │ └── web3Service.js # Web3.js封装 │ └── App.js ├── truffle-config.js # Truffle或Hardhat配置 └── package.json工程方案我建议用Hardhat而不是Truffle。Hardhat内置了hardhat console、Stack Trace调试和自动化的TypeScript支持如果你用的是TS在处理合约报错时体验好很多。你要是照原仓库用Truffle也没问题但Hardhat的node默认自带一个开发链省掉装Ganache的步骤新手更友好。package.json里关键依赖是这三组web3和MetaMask通信、ipfs-http-client和IPFS节点通信、openzeppelin/contracts如果你需要Ownable这类权限控制。React本身不算难点真正的复杂度在前端把Web3.js和IPFS两个客户端串起来各自初始化、各自维护状态。整理好依赖版本再动手能避开不少奇奇怪怪的类型错误。3. Solidity智能合约实战文件映射与所有权管理的部署细节3.1 合约设计结构体、映射和事件看一下FileStorage.sol的核心实现。这类合约的结构非常固定一个结构体描述文件元数据一个映射存文件ID到结构体一个计数器自增分配ID。// contracts/FileStorage.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract FileStorage { struct File { string cid; // IPFS内容标识符 string fileName; // 原始文件名仅作展示 address owner; // 上传者地址 uint256 timestamp; // 上传时间unix时间戳 } mapping(uint256 File) public files; // fileId File mapping(address uint256[]) private ownerFiles; // 所有者 文件ID数组 uint256 private fileCounter; event FileUploaded( uint256 indexed fileId, string cid, string fileName, address indexed owner, uint256 timestamp ); function uploadFile(string memory _cid, string memory _fileName) public { fileCounter; File storage newFile files[fileCounter]; newFile.cid _cid; newFile.fileName _fileName; newFile.owner msg.sender; newFile.timestamp block.timestamp; ownerFiles[msg.sender].push(fileCounter); emit FileUploaded(fileCounter, _cid, _fileName, msg.sender, block.timestamp); } function getFile(uint256 _fileId) public view returns ( string memory, string memory, address, uint256 ) { require(_fileId 0 _fileId fileCounter, file does not exist); File storage f files[_fileId]; return (f.cid, f.fileName, f.owner, f.timestamp); } function getMyFiles() public view returns (uint256[] memory) { return ownerFiles[msg.sender]; } }这里有几个设计决策值得说清楚。ownerFiles[msg.sender]用了mapping(address uint256[])是为了让前端能用getMyFiles()拿到当前账户的文件ID列表再逐个查getFile()。如果不做这个反向索引前端就得遍历所有文件ID在本地链上还好在测试网或者主网上就是一场灾难——一个for循环几十上百次RPC调用延迟高到没法用。File storage newFile files[fileCounter]这一句是Solidity的storage引用语法意思是newFile直接指向了files[fileCounter]的存储位置修改newFile就是修改合约状态。如果写memory就是复制一份临时副本改完不会存到链上。这两种写法的差别初学者很容易踩坑典型错误是函数执行完状态没变化Gas还照扣。3.2 Hardhat部署脚本配置、Gas和账户管理部署用Hardhat的话先建hardhat.config.js// hardhat.config.js require(nomiclabs/hardhat-ethers); module.exports { solidity: 0.8.19, networks: { hardhat: { chainId: 1337 }, }, paths: { artifacts: ./src/artifacts, // 把ABI输出到前端能读到的位置 } };chainId建议显式设成1337这是MetaMask对Hardhat内置链的默认识别ID。不设的话有时MetaMask会把这个网络当成未知网络连接时前端报错。paths.artifacts写成./src/artifacts这一步很多人会漏——合约编译后的ABI文件默认生成在./artifacts前端在src目录里引用路径很容易写错建议直接指向src/artifacts前端require就不用管相对路径了。部署脚本// scripts/deploy.js const hre require(hardhat); async function main() { const FileStorage await hre.ethers.getContractFactory(FileStorage); const fileStorage await FileStorage.deploy(); await fileStorage.deployed(); console.log(FileStorage 合约地址:, fileStorage.address); // 把合约地址导出到前端配置避免每次部署手改 const fs require(fs); fs.writeFileSync( ./src/contract-address.json, JSON.stringify({ address: fileStorage.address }, null, 2) ); } main().catch((error) { console.error(error); process.exitCode 1; });部署后紧接着要做的一件事是把合约地址写进前端配置。如果你不写成文件而是每次部署后手动粘到React组件里总有那么一次会忘记更新前端连的合约是旧地址所有调用全部落空。把地址——以及ABI——在部署脚本里同步导出到src目录是这套流程里性价比最高的习惯。3.3 合约权限模型owner和访问控制的边界这个仓库的合约没有做复杂的权限控制getFile()是public的任何地址只要知道fileId就能查到CID。也就是说上传到IPFS的文件在链上是公开可读的合约只保证了“谁上传的”这个事实不可篡改。如果要做真正的私有文件需要额外设计——常见做法是上传前用对称密钥加密文件内容再把密钥用椭圆曲线加密传给授权地址链上只存密文CID没有密钥就拿不到明文。开发时可先把权限模型放一放但要对它有清晰认知。测试合约时我一般会验证这几个场景普通地址调用uploadFile()能成功重复调用时fileCounter递增用非owner地址调用getMyFiles()返回空数组因为ownerFiles里没有该地址。这些断言在Mocha里写起来很快但能帮你尽早确认合约的基础行为是正常的。4. Web3.js加React.js前端链路上传、展示、下载的完整实现4.1 连接钱包Web3.js初始化的正确姿势前端的第一道关卡是Web3.js连接MetaMask。window.ethereum是MetaMask注入的EIP-1193 Provider对象Web3.js基于它构造实例// src/service/web3Service.js import Web3 from web3; let web3; export function getWeb3() { if (web3) return web3; if (window.ethereum) { web3 new Web3(window.ethereum); } else if (window.web3) { // 老式DApp浏览器兜底使用旧版Global web3 web3 new Web3(window.web3.currentProvider); } else { // 没有MetaMask时连接公共节点只能读不能写 web3 new Web3(https://localhost:8545); } return web3; } export async function requestAccounts() { const accounts await window.ethereum.request({ method: eth_requestAccounts }); return accounts; }注意eth_requestAccounts是EIP-1102定义的异步方法它返回的是一个PromisePending状态下MetaMask会弹出授权窗口。不要用eth_accounts——这个方法只返回已经授权的账户如果用户之前没授权拿到的是空数组会误导你判断成没有账户。初始化合约实例时要传入两个东西ABI和合约地址。ABI从src/artifacts/FileStorage.json引入合约地址从src/contract-address.json读const contract new web3.eth.Contract( FileStorageArtifact.abi, contractAddress );这里有个小坑ABI是JSON对象require进来后要取.abi字段Truffle导出的artifact文件顶部还会有contractName、networks这些额外字段直接整个传进去Web3也能解析出来但多了一层潜在问题——和别的地方用一个全量JSON做比较时可能不相等。规范写法是只传.abi。4.2 文件上传流程IPFS和合约的双写链路上传是本项目最核心的路径前后端各做一半。先用ipfs-http-client把文件传到IPFS节点拿回CID再调合约的uploadFile()把CID和文件名上链// src/service/storageService.js import { create } from ipfs-http-client; import { getWeb3 } from ./web3Service; import FileStorageArtifact from ../artifacts/FileStorage.json; import contractConfig from ../contract-address.json; const ipfs create({ host: localhost, port: 5001, protocol: http }); export async function uploadToIpfs(file) { const added await ipfs.add(file, { pin: true, // 主动固定文件块防止被GC清理 timeout: 60000 // 100MB级文件上传给足一分钟 }); return added.cid.toString(); } export async function storeFileToChain(cid, fileName) { const web3 getWeb3(); const accounts await web3.eth.getAccounts(); const contract new web3.eth.Contract( FileStorageArtifact.abi, contractConfig.address ); const gasEstimate await contract.methods .uploadFile(cid, fileName) .estimateGas({ from: accounts[0] }); const receipt await contract.methods.uploadFile(cid, fileName).send({ from: accounts[0], gas: Math.floor(gasEstimate * 1.3), // 留30%缓冲防止Gas价格波动 }); return receipt; }ipfs.add(file)里的file可以是File对象、Blob或Buffer这是ipfs-http-client对浏览器环境做得最好的地方——它内部做了流式处理不会把整个文件一次性读进内存。pin: true参数决定了IPFS节点是否把内容固定到本地仓库。如果不主动pin节点会把未引用的块视为临时缓存在GC周期里回收掉之后的访问就会404。Gas估算这里先estimateGas再send是一种稳妥的防御链上Gas价格是浮动的直接给一个写死的gasLimit在拥堵时很可能会让交易Pending到超时。留30%的余量在后半段代码里是个习惯大多数链上交易的实际消耗会比估算值低一些但这个余量能兜住边缘情况。4.3 文件列表与下载从合约读CID从IPFS取流文件列表在React里做两件事挂载时拉一次链上状态再用事件订阅做增量更新。先看拉取和渲染部分// src/components/FileList.jsx import React, { useEffect, useState } from react; import { getWeb3 } from ../service/web3Service; import FileStorageArtifact from ../artifacts/FileStorage.json; import contractConfig from ../contract-address.json; export default function FileList() { const [files, setFiles] useState([]); useEffect(() { async function fetchFiles() { const web3 getWeb3(); const accounts await web3.eth.getAccounts(); const contract new web3.eth.Contract( FileStorageArtifact.abi, contractConfig.address ); const fileIds await contract.methods.getMyFiles().call({ from: accounts[0] }); const fileList []; for (const id of fileIds) { const meta await contract.methods.getFile(id).call(); fileList.push({ id: id.toString(), cid: meta[0], fileName: meta[1] }); } setFiles(fileList); } fetchFiles(); }, []); return ( ul {files.map((file) ( li key{file.id} span{file.fileName}/span span{file.cid.slice(0, 12)}.../span button onClick{() downloadFromIpfs(file.cid)}下载/button /li ))} /ul ); }事件订阅做法是用contract.events.FileUploaded({ fromBlock: latest })返回一个EventEmitter在data回调里把新文件推入状态数组。这个思路值得写上因为链上状态更新和本地状态可能脱节——用户从MetaMask发出交易到矿工确认中间有几十秒延迟如果只是发起交易后立刻刷新大概率看不到新文件监听事件才是可靠做法。下载和上传方向相反先从合约拿到CID再从IPFS拉数据。IPFS取流和普通HTTP请求不太一样ipfs.cat()返回的是一个AsyncIterable流需要自己拼块export async function downloadFromIpfs(cid) { const chunks []; for await (const chunk of ipfs.cat(cid)) { chunks.push(chunk); } const blob new Blob(chunks); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download download; a.click(); URL.revokeObjectURL(url); // 释放临时URL占用的内存 }URL.createObjectURL创建的临时URL会占用浏览器内存直到文档卸载所以用完后要revokeObjectURL。这里的文件名如果是中文建议在链上存的时候就用encodeURIComponent(fileName)编码否则下载时个别浏览器会乱码。5. 部署避坑指南MetaMask、IPFS节点和链上状态的五类常见问题5.1 MetaMask连不上本地开发链交易一直Pending现象本地节点已经在跑MetaMask里也把网络切到了localhost:7545但发送交易后一直Pending几分钟后在MetaMask里报错交易没有上链。原因最常见是Chain ID不匹配。Ganache 2.x默认的Chain ID是1337但在MetaMask“自定义网络”里如果只填了Network ID或RPC URL漏掉Chain IDMetaMask就会把它当一个未知网络交易签名时使用错误Chain ID节点校验签名失败后静默丢弃。解决在truffle-config.js或hardhat.config.js里显式写chainId: 1337并且让节点和MetaMask两边的RPC URL、Chain ID完全一致。另一个隐蔽点Ganache如果用了--port 7545而MetaMask访问的是http://127.0.0.1:8545连到的是另一个节点账户余额对不上。5.2 IPFS文件访问404节点没有Pin文件现象上传成功后用浏览器打开http://localhost:8080/ipfs/CID能正常下载但几小时后或者重启IPFS节点再访问返回404或空内容。神奇的是其他节点上传的文件都能访问就这个文件丢了。原因IPFS节点只在文件被add进来的时候把它放在缓存区这个区域受GC控制。GC默认以StorageMax为阈值当仓库总量超过限制时它会把未标记为“固定”Pinned的块清理掉。文件块被清理后本地节点就不再有该内容公共网关也就访问不到了。解决上传后立刻调用ipfs.pin.add(cid)或者像我上面写的在add时设置pin: true。这二选一就行但要注意pin: true只在当前IPFS节点生效如果后续你换了节点文件不会自动跟随需要重新pin。生产级做法是接Pinata这类远端固定服务在add时把文件发给Pinata的节点做固定保证7x24小时在线。5.3 大文件上传导致浏览器内存溢出现象上传80MB以上的视频文件时标签页直接卡死控制台报Out of memory或Allocation failed - process out of memory其他页面也跟着崩溃。原因代码里用了await ipfs.add(fs.readFileSync(filePath))这种写法把整个文件一次性读成Buffer再交给IPFS。浏览器环境没有Node的fs模块但用file.arrayBuffer()也是一样的逻辑——都要把整个文件加载进内存。IPFS客户端虽然在内部做流式处理但入口如果喂给它的是完整的Buffer内存占用照样居高不下。解决在前端上传时不要用arrayBuffer()直接把File对象传给ipfs.add()它会内部走流式读取。同时配合ipfs-http-client的addAll()做分块import { create } from ipfs-http-client; const ipfs create({ host: localhost, port: 5001, protocol: http }); async function uploadLargeFile(file) { const generator async function* () { yield { path: file.name, content: file }; }; const result await ipfs.addAll(generator(), { pin: true }); for await (const res of result) { return res.cid.toString(); } }addAll接收的是AsyncIterable它把文件切成分片逐个读取内存占用几乎不随文件大小增长。这个方法对100MB以内的文件性能足够更大文件建议走客户端直接到IPFS节点的流式通道绕过浏览器内存瓶颈。5.4 刷新页面后文件列表消失事件监听失效现象上传文件后列表正常显示但刷新浏览器列表变成空白再等一会儿也没有恢复。重新连接MetaMask甚至要等交易重新确认。原因常见的两类实现问题反复导致这个现象。第一种是列表数据只存在组件内部state里刷新后没重新向合约查询——前端没把链上数据当作唯一的“真相源”。第二种是事件订阅写在了浏览器的global scope里它确实在跑但data回调里的setState挂载到了已经被卸载的组件实例上React不承认这个更新于是状态永远不刷新。解决在根组件的useEffect里挂载订阅并把订阅和组件生命周期绑定卸载时调用.removeAllListeners()清理同时每次挂载都主动调一次getMyFiles()全量拉取。这是DApp前端的标准姿势内存持久化的React状态 链上事件增量更新。5.5 伪私有合约是公开的任何人拿CID都能下载现象界面设计了“我的文件”列表用户以为只有自己能看自己的文件但把某个文件的CID复制出来用一个没有登录任何账户的浏览器打开IPFS网关地址文件直接就能下载。原因IPFS没有访问控制的概念文件一旦上传到公共节点任何知道CID的人都可以取走。合约里的ownerFiles只是把你的文件ID索引到了你的地址名下它记录的是“谁上传的”不是“谁能读”。解决在业务层做加密。上传前用AES-GCM对称密钥加密文件内容再把密钥通过msg.sender的地址做二次校验比如把密钥存在链上并授权给特定地址这样才能实现真正意义上的私有文件。这个仓库定位是公共资料分享场景所以权限这块不是重点但如果你的需求是私人网盘必须补上这一步。6. 端到端验证用CID完整性校验给整个系统做一次体检部署完成后别急着收工先跑一遍完整的端到端流程确认三个独立的子系统IPFS、以太坊、前端真的协同工作。我习惯用手上的测试文件走一个清单上传文件拿到CID后手动打开网关确认内容可访问调合约读取文件元数据核对CID和文件名和上传时一致刷新页面确认列表从链上重新拉取成功再下载一次文件用本地工具计算哈希比对。这个项目的验证点在于IPFS和链上的一致性。IPFS的文件ID就是内容的哈希所以我用一把“哈希再哈希”的校验就能验证整条链路没有损坏文件// 校验从IPFS下载的文件和上传时是否一致 async function verifyIntegrity(uploadedBuffer, downloadedBlob) { const uploadedHash await sha256(uploadedBuffer); const downloadedHash await sha256(await downloadedBlob.arrayBuffer()); return uploadedHash downloadedHash; } async function sha256(buffer) { const crypto window.crypto || require(crypto); const digest await crypto.subtle.digest(SHA-256, buffer); return Array.from(new Uint8Array(digest)) .map((byte) byte.toString(16).padStart(2, 0)) .join(); }这个校验在中心化存储里没有意义——服务器已经帮你保证了一致性但在IPFS这种去中心化流动的网络里文件可能在多个节点间多次中转任何一个节点的损伤都会在MerkleDAG校验时暴露。上传时IPFS负责生成CID下载时IPFS负责校验块哈希链上CID是中间那个“锚”哈希比对就是最直接的体检报告。这个项目还有几个可以做的延伸方向。第一个是文件权限把文件内容做AES加密、密钥通过合约按地址分发这是从“公开网盘”到“私有网盘”质变的关口。第二个是持久化本地IPFS节点离线文件就没了接上Pinata或Filecoin的固定服务才能支撑真正意义上的线上服务。第三个是前端体验现在的界面是基础的列表加按钮可以加上文件类型图标、上传进度条、拖拽上传这些在React里都是花不了多少时间但体验提升明显的事。我自己做完这个项目后踩得最深的一个坑是在本地一切正常一换到测试网就全部失灵。后面才发现是IPFS节点连的是localhost:5001而测试网环境根本不具备本地节点。从那以后我每次部署DApp都强制把“存储服务是否和生产环境网络可达”放进检查清单第一位。这项目的源码结构干净、依赖清晰很适合作为你第一个真正能跑的区块链全栈项目。希望帮到你。本文还有配套的精品资源点击获取