简介:这份资源面向区块链初学者与医疗信息化方向的开发者,展示区块链与IPFS集成的基础实现思路。项目基于以太坊、Truffle、Ganache、MetaMask与MyEtherWallet构建,通过Solidity合约让医生从去中心化服务器检索健康记录的IPFS ID,并支持患者下载对应记录,适合作为去中心化健康记录追踪的入门实践参考。压缩包共30个文件,约1.15MB,包含16张png操作截图、4个js脚本、3个sol智能合约、3个json配置及license、md说明等,覆盖合约编写、部署配置与前端交互各环节。目前已有176人学习浏览。读者可借此了解IPFS与链上合约的协作方式、Truffle项目目录结构及Ganache本地测试流程,并参考截图完成环境搭建与合约部署,为后续补充加密与安全机制打下基础。
1. 从一份健康数据上链 Demo 说起:health-blockchain 到底能跑通什么
如果你正在找一个能把「链上存证 + 链下存储」讲清楚的练手项目,health-blockchain 这个仓库值得花一个下午拆一遍。它做的事情很具体:把一份健康记录文件的哈希写进以太坊合约,文件本体丢给 IPFS,前端用 MetaMask 签名发起交易,再用 MyEtherWallet 验证合约调用结果。整套流程不依赖任何中心化后端,合约、存储、钱包三层各司其职。
它适合两类人:一是刚学完 Solidity 语法、想找个完整链路练手的开发者;二是需要给现有系统加「数据不可篡改」能力、但还没想清楚 IPFS 和链上到底怎么分工的工程师。仓库本身不复杂,但麻雀虽小,Truffle 编译部署、Ganache 本地链、IPFS 节点接入、前端合约实例化这几块都齐了。跑通一遍,你对「什么该上链、什么不该上链」会有比看十篇科普更实在的判断。
2. 环境搭建与合约部署:Truffle + Ganache 的最小闭环
2.1 为什么选 Truffle 而不是 Hardhat
这个仓库用的是 Truffle 工具链,不是现在更流行的 Hardhat。原因很直接:项目成型时间较早,Truffle 的truffle migrate和truffle console对新手更友好,配置文件truffle-config.js结构扁平,改网络、改编译器版本一目了然。Hardhat 的插件生态确实更强,但如果你只是想快速验证「合约能不能存哈希、能不能读回来」,Truffle 的认知负担更低。
常见做法是本地开发用 Ganache 起一条内存链,它默认给你 10 个带 100 ETH 的测试账户,私钥直接暴露在终端里,方便导入 MetaMask。注意 Ganache 的 RPC 地址默认是http://127.0.0.1:7545,而 Truffle 默认连的是8545,这两个端口不一致是新手第一个翻车点。
2.2 从零把合约跑起来
先确认 Node.js 版本,Truffle 对 Node 16 以上支持较好,Node 18 也能跑,但部分老版本 Truffle 在 Node 20 上会有ERR_OSSL_EVP_UNSUPPORTED报错。我一般会锁 Node 16 或 18。
# 全局安装 Truffle 和 Ganache CLI npm install -g truffle npm install -g ganache # 启动本地链,指定端口和网络ID ganache --port 7545 --networkId 5777 --deterministic--deterministic这个参数值得说一句:它让每次启动生成的账户和私钥完全一致,省得你每次重启链都要重新往 MetaMask 里导账户。--networkId 5777是 Ganache 的惯用网络 ID,MetaMask 添加自定义网络时填这个值。
// truffle-config.js 关键片段 module.exports = { networks: { development: { host: "127.0.0.1", port: 7545, // 必须和 Ganache 启动端口一致 network_id: "5777", // 对应 Ganache 的 networkId }, }, compilers: { solc: { version: "0.8.19", // 按合约 pragma 声明调整 }, }, };配置里port和network_id是最容易写错的两个参数。端口写错,truffle migrate会直接报连接超时;network_id 写错,MetaMask 会提示「无法连接到该网络」。改完配置后,先跑truffle compile确认合约能编译通过,再跑truffle migrate --reset部署。
# 编译并部署到本地 Ganache truffle compile truffle migrate --reset --network development # 进入控制台验证合约方法 truffle console --network development进入 console 后,可以手动调一下存哈希和读哈希的方法,确认合约逻辑没问题。这一步很多人跳过,结果前端调不通时不知道是合约问题还是前端问题。先在这里把set和get走一遍,后面排错会省很多时间。
2.3 合约里到底存了什么
这个项目的合约核心就两个动作:存一个字符串(文件哈希),按地址或 ID 读回来。它不会把健康记录原文上链,因为链上存储成本极高,而且一旦写入无法删除,涉及隐私的数据绝不能直接上链。合约里通常是一个mapping结构,key 是记录编号或用户地址,value 是 IPFS 返回的 CID 或文件哈希。
参数设置上,哈希用string类型存即可,长度固定的话也可以用bytes32省 gas。但bytes32对前端不友好,需要额外做转换,练手项目用string更直观。如果你要改成bytes32,记得在合约里加require(bytes(_hash).length <= 32)做长度校验,否则超长字符串会被截断,这是血泪经验。
3. IPFS 接入与文件上传:CID 怎么和链上记录对上
3.1 IPFS 节点的两种接法
IPFS 接入有两种常见方式:一是本地跑一个 IPFS 节点,通过ipfs daemon启动,API 默认在5001端口;二是用公共网关或第三方 Pin 服务。本地节点的好处是数据完全自己掌控,坏处是节点下线后文件可能被垃圾回收。练手阶段我建议本地节点,因为你能看到完整的add和cat过程。
启动本地节点:
# 初始化 IPFS 仓库(只需一次) ipfs init # 启动守护进程,开放 API 和网关 ipfs daemon启动后终端会显示 API 地址http://127.0.0.1:5001和网关地址http://127.0.0.1:8080。前端通过ipfs-http-client连接这个 API 端口,注意不是网关端口。这两个端口搞混是第二个高频翻车点:API 用于上传和查询,网关用于浏览器直接访问文件。
3.2 上传文件并拿到 CID
// 使用 ipfs-http-client 上传文件 const { create } = require('ipfs-http-client'); // 连接本地 IPFS 节点的 API 端口 const ipfs = create({ url: 'http://127.0.0.1:5001/api/v0' }); async function uploadToIPFS(fileBuffer) { // add 方法返回一个异步迭代器,取第一个结果 const result = await ipfs.add(fileBuffer); // result.path 就是 CID,形如 QmXxx... console.log('CID:', result.path); return result.path; }ipfs.add接收 Buffer、字符串或文件流。返回的result.path是 CID v0 格式,以Qm开头。如果你用ipfs.add的cidVersion: 1选项,会得到以b开头的 CID v1,两者在网关访问时路径格式略有不同。练手项目保持默认 v0 即可,兼容性更好。
拿到 CID 后,把它传给合约的存储方法,链上就留下了一条「某文件哈希对应某 CID」的记录。验证时,用ipfs.cat(cid)能把文件内容读回来,再算一次哈希,和链上存的对比,一致就说明整个链路没被篡改。
3.3 前端怎么把 IPFS 和合约串起来
前端通常用web3.js或ethers.js实例化合约。这个仓库用的是 web3.js,配合 MetaMask 注入的window.ethereum。关键步骤是:先请求账户授权,再用账户实例化合约,最后调方法发交易。
// 前端连接 MetaMask 并调用合约 async function storeHash(cid) { // 请求账户授权 const accounts = await window.ethereum.request({ method: 'eth_requestAccounts', }); const web3 = new Web3(window.ethereum); const contract = new web3.eth.Contract(abi, contractAddress); // 发交易,from 必须是已授权账户 await contract.methods.setHash(cid).send({ from: accounts[0] }); }send({ from: accounts[0] })里的from不能省,也不能填一个没授权的地址,否则 MetaMask 会弹窗报错。交易发出后,MetaMask 会弹出确认框,确认后等几秒链上打包,receipt里能看到交易哈希。如果一直 pending,检查 Ganache 是否还在运行,以及 MetaMask 网络是否切到了本地链。
4. 避坑与排查:五个让 Demo 跑不起来的典型问题
4.1 MetaMask 连不上本地链
现象是 MetaMask 添加自定义网络后一直转圈,或者提示「无法获取账户」。原因通常是 RPC 地址填成了http://localhost:7545而 Ganache 只监听了127.0.0.1,或者 chainId 和 networkId 填反了。解决方法是 RPC 填http://127.0.0.1:7545,chainId 填1337(Ganache 默认),networkId 填5777。如果还不行,重启 Ganache 和浏览器。
4.2 合约部署后地址对不上
现象是前端调合约报「返回地址没有合约代码」。原因是truffle migrate每次--reset都会重新部署,合约地址变了,但前端里写死的地址没更新。解决办法是不要在前端硬编码地址,而是从build/contracts/YourContract.json里读networks字段,或者每次部署后手动同步一次。我一般会在部署脚本里把地址写到一个config.js,前端引这个文件。
4.3 IPFS 上传成功但网关访问 404
现象是ipfs.add返回了 CID,但浏览器打开http://127.0.0.1:8080/ipfs/QmXxx显示 404。原因是本地节点默认不自动 Pin 新文件,垃圾回收可能已经把它清了,或者网关端口被占用。解决方法是上传后显式调ipfs.pin.add(cid),并确认ipfs daemon终端没有报错。如果端口冲突,用ipfs config Addresses.Gateway改端口。
4.4 交易一直 pending 不打包
现象是 MetaMask 显示交易已提交,但 Ganache 终端没有新块。原因是 Ganache 默认是即时出块,但如果之前手动改过blockTime或者用了--miner.blockTime参数,就会变成定时出块。解决方法是重启 Ganache 不加额外参数,或者用ganache --miner.blockTime 0恢复即时出块。另外检查 MetaMask 的 gas 费是否设得太低,本地链一般用默认值即可。
4.5 合约编译报 solc 版本不匹配
现象是truffle compile报「Source file requires different compiler version」。原因是合约头部的pragma solidity ^0.8.0和truffle-config.js里指定的solc.version不一致。解决方法是把配置里的版本改成合约 pragma 允许的范围,比如0.8.19。如果合约用了^0.8.0,配置写0.8.19没问题;如果合约写死了0.6.0,配置也得跟着改。改完记得删掉build目录重新编译。
5. 进阶技巧:用脚本批量验证链上记录与 IPFS 文件的一致性
跑通单条记录后,真正有价值的是批量校验。我一般会写一个 Node 脚本,遍历合约里存过的所有 CID,逐个从 IPFS 拉回文件、算哈希、和链上记录比对。这个脚本能当回归测试用,每次改完合约或前端跑一遍,确认没有破坏已有记录。
// verify.js 批量校验链上哈希与 IPFS 文件 const Web3 = require('web3'); const { create } = require('ipfs-http-client'); const crypto = require('crypto'); const ipfs = create({ url: 'http://127.0.0.1:5001/api/v0' }); const web3 = new Web3('http://127.0.0.1:7545'); async function verifyAll(contractAddress, abi, totalCount) { const contract = new web3.eth.Contract(abi, contractAddress); for (let i = 0; i < totalCount; i++) { // 假设合约有 getHash(index) 方法 const onChainHash = await contract.methods.getHash(i).call(); // 从 IPFS 拉回文件内容 const chunks = []; for await (const chunk of ipfs.cat(onChainHash)) { chunks.push(chunk); } const fileBuffer = Buffer.concat(chunks); // 重新计算哈希 const localHash = crypto .createHash('sha256') .update(fileBuffer) .digest('hex'); console.log(`记录 ${i}: 链上 ${onChainHash} | 本地 ${localHash}`); } } verifyAll('0xYourContractAddress', abi, 10);脚本里totalCount需要你根据实际存了多少条记录来传,合约如果没提供计数方法,可以加一个recordCount状态变量,每次setHash时自增。ipfs.cat返回的是异步迭代器,必须用for await收集,直接await会拿到迭代器对象而不是内容,这是第三个容易翻车的地方。
参数上,crypto.createHash('sha256')的算法要和上传时保持一致。如果上传时用的是sha256,校验也用sha256;如果用的是keccak256,Node 原生 crypto 不支持,得用ethers.utils.keccak256。这个细节不注意,校验永远对不上,但你又找不到原因,属于典型的玄学问题。
还有一个实用技巧:把校验结果写进一个 CSV,方便对比。我习惯在脚本里加fs.appendFileSync('verify-log.csv', ...),每次跑完看一眼哪些记录不一致。不一致的记录优先查 IPFS 节点是否被清理过,其次查合约是否被重新部署过导致索引错位。
从那以后我每次改完合约或前端,都强制走一遍这个校验脚本,确认链上链下数据还对得上。希望帮到你。
本文还有配套的精品资源,点击获取