简介:基于以太坊的去中心化微博系统设计与实现资料包,面向计算机科学、软件工程、信息工程等专业学生,以及正在入门区块链应用开发的开发者。项目定位为毕业设计与课程设计参考,完整涵盖智能合约、前端界面与设计文档,既可用于教学实践和原型演示,也适合有基础的人员进行二次开发与功能定制。压缩包共38个文件,约2.68MB,核心包括Solidity合约、Truffle迁移部署脚本、JavaScript与HTML前端代码,以及设计报告PDF和LaTeX源文件;同时附有多张系统架构、业务流程和页面运行截图,便于对照工程代码理解整体实现。目前已有69人学习浏览。资源来自一个在毕业设计评审中表现优异的完整项目,核心代码经过验证,结构清晰;使用者可沿合约编写、编译迁移、前端交互的完整链路快速掌握以太坊DApp开发方法,亦可基于现有代码扩展点赞、评论、关注等微博核心功能,获得从设计到落地的综合实践参考。
1. 去中心化微博为什么不能把全部内容都铺在以太坊链上
以太坊天然适合存关键证据,不适合存整条文。一条 200 字的微博若按 calldata 最低成本写入,gas 开销也会超过绝大多数个人用户的日收入;真把图片和视频铺上去,成本会放大几个数量级。所以可落地的去中心化微博必须把链上当作“审计层”,把内容正文放到 IPFS / Arweave,再把内容哈希和作者签名落回以太坊。按这个思路实现的系统,既能保留“不可篡改、可溯源”的核心价值,又不让发帖费用变成一座普通人翻不过去的山。下面的设计与实现从数据模型开始,逐步落到合约、部署、前端时间线重建和源码验收方法。你不用先准备大型基础设施,一个 Sepolia 测试网账号加一个钱包私钥就能把整套链路跑通。
2. 拆解去中心化微博的链上数据模型:以太坊存储与成本边界
2.1 帖子、关注关系为什么不能照搬传统库表
传统微博的核心单元是“用户、帖子、关系”,但在以太坊链上写库表风格的数据结构会产生一个直接问题:合约存储字段越多,部署和写入费用越高。更关键的是,链上状态其实不是给页面查询用的,页面完全可以通过事件日志重建。因此我一般会把“状态存储”和“查询快照”分开考虑:状态里存放验证所需的最少字段,查询交给链下事件索引。
帖子实体只需要作者、内容引用、发布时间、回复目标、引用目标五个字段。作者直接使用钱包地址,不需要自增用户 ID,因为签名天然代表身份。内容引用字段存 IPFS CID,而不是原文。发布时间优先取区块时间戳,同时接受“链上时间可能与真实时间存在几分钟漂移”的现实。关注关系则使用mapping(address => mapping(address => bool))存储,拉取关注列表时从FollowChanged事件里扫描,历史操作审计和数据存储合二为一。
成本边界可以用一笔交易拆解:publishPost的参数里有 64 字节左右的 CID,就算 calldata 价格再低,也会比存原文便宜一个数量级。有人会问“CID 不是同样占用字节吗”,关键差异是 IPFS 上存的是全文,链上只存定位符。你写的不是微博内容本身,而是一条“内容在哪儿”的目录项。
2.2 用 Solidity 写出发帖与关注的最小合约
下面去掉访问控制和可升级逻辑,只保留下最小可运行骨架。完整源码里会在这个基础上补充事件版本、批量发布和黑名单限制。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract DecentraWeibo { struct PostMeta { address author; string cid; uint256 createdAt; uint256 replyTo; uint256 quoteOf; } mapping(uint256 => PostMeta) public posts; uint256 public nextPostId; event PostPublished( uint256 indexed id, address indexed author, string cid, uint256 createdAt, uint256 replyTo, uint256 quoteOf ); event FollowChanged(address indexed follower, address indexed followee, bool isFollow); function publishPost(string memory cid, uint256 replyTo, uint256 quoteOf) external returns (uint256 id) { require(bytes(cid).length > 0, "empty cid"); require(bytes(cid).length <= 64, "cid too long"); id = nextPostId++; posts[id] = PostMeta(msg.sender, cid, block.timestamp, replyTo, quoteOf); emit PostPublished(id, msg.sender, cid, block.timestamp, replyTo, quoteOf); } mapping(address => mapping(address => bool)) private _followed; function setFollow(address target, bool isFollow) external { require(target != msg.sender, "cannot follow self"); _followed[msg.sender][target] = isFollow; emit FollowChanged(msg.sender, target, isFollow); } }这段代码的逻辑说明:
nextPostId从 0 计数,帖子的顺序天然由链上自增决定,前端不需要解析排序后再补 ID。cid字段限制在 64 字节内,CIDv0 固定 46 字节,普通 CIDv1 也远低于这个上限;不要把 IPFS URL 整串塞进合约,只需要保存 CID 本身。replyTo和quoteOf是数字 ID,避免在合约里做字符串比对。父子关系合法性由前端或索引器校验,链上只负责记录“它声明回复了谁”。setFollow不做取关次数限制,因为状态本身是布尔值,重复设置相同值只多付一次写入费用。
参数说明:createdAt来自block.timestamp,取值范围是出块时刻而非用户点击时刻。如果需要精确的发布顺序,应该看blockNumber + logIndex,而不是解析时间字段。对微博流这种弱时间敏感场景,链上时间戳已经足够。
2.3 存储方案对比:把什么交给 IPFS,把什么留给以太坊
| 内容 | 存放位置 | 前端读取方式 | 单条发布成本 | 风险点 |
|---|---|---|---|---|
| 正文 200 字 | IPFS | 网关按 CID 拉取 | 链上只付 CID 写入费 | 节点不固定内容时可能失联 |
| 图片或视频原文件 | IPFS / Arweave | 拼接 CID 后访问 | 链上不参与 | 文件大小影响固定服务费用 |
| 作者钱包地址 | 以太坊 | 读author字段 | 已包含 | 无法修改,需要账号迁移方案 |
| 关注关系 | 以太坊 + 事件 | 从事件日志重建 | 每次关注一笔 | 列表页依赖索引器 |
实际处理时,IPFS 的“永久性”来自内容被其他节点固定。如果只是本地用ipfs add,节点下线后文件照样找不到。常见做法是把内容 Pin 到第三方固定服务,或者在系统里内置“拉取后重新 Pin”的任务,相关代码会在完整源码的services/pinning.ts里体现。文本的丢失代价比图片略低,因此正文和媒体的固定优先级可以分开配置。
提示:如果你要发长微博,别把整段文字拆成多个 CID 再塞进一个数组,那样会让排序和显示复杂度成倍上涨。可以把原文压缩成一个 JSON 文件再上传 IPFS,链上只留这个文件的 CID。
3. 用 Hardhat 跑通微博合约:本地部署与 Sepolia 参数设置
3.1 初始化 Hardhat 项目并规划源码目录
去中心化微博系统的完整源码不只是 Solidity 文件,还包括部署脚本、测试脚本、文档和示例配置。下面是我习惯的目录结构,关键字就是“一个命令能重建、一条文档能跑通”。
decentral-weibo/ ├── contracts/ │ └── DecentraWeibo.sol ├── scripts/ │ ├── deploy.ts │ ├── publish-demo.ts │ └── replay-events.ts ├── test/ │ └── decentra-weibo.test.ts ├── docs/ │ ├── architecture.md │ └── deployment.md ├── .env.example ├── hardhat.config.ts └── package.json初始化命令先执行:
npm init -y npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox ethers dotenv npx hardhat init逻辑说明:hardhat-toolbox聚合了测试、断言、覆盖率等插件,避免再单独装一堆依赖。目录里docs/deployment.md用来记录测试网地址、RPC 提供方和私钥管理方式,这不是文档摆设,而是多节点协作时最重要的交接材料。
3.2 双层网络配置:本地节点与 Sepolia 测试网
在hardhat.config.ts里写两套网络配置,一套给自动化测试,一套给测试网部署。
import '@nomicfoundation/hardhat-toolbox'; import { defineConfig } from 'hardhat/config'; import 'dotenv/config'; const PRIVATE_KEY = process.env.PRIVATE_KEY || ''; const SEPOLIA_RPC = process.env.SEPOLIA_RPC_URL || 'http://127.0.0.1:8545'; export default defineConfig({ solidity: { version: '0.8.20', settings: { optimizer: { enabled: true, runs: 200 }, }, }, networks: { hardhat: {}, sepolia: { url: SEPOLIA_RPC, accounts: [PRIVATE_KEY], }, }, });逻辑说明:runs: 200表示优化器更看重链上重复调用场景,发帖函数本身是高频操作,选 200 比默认值更合适;如果合约里很少被调用的管理函数,runs改成 1 也能降低一次性部署成本。dotenv/config把.env里的内容载入,避免密钥出现在命令行历史中。
部署脚本scripts/deploy.ts只需要读取合约并等待部署确认:
import { ethers } from 'hardhat'; async function main() { const factory = await ethers.getContractFactory('DecentraWeibo'); const contract = await factory.deploy(); await contract.waitForDeployment(); console.log('DecentraWeibo deployed to:', await contract.getAddress()); } main().catch((error) => { console.error(error); process.exitCode = 1; });执行命令如下:
npx hardhat node # 另开终端 npx hardhat run scripts/deploy.ts --network localhost npx hardhat run scripts/deploy.ts --network sepolia参数说明:waitForDeployment是 ethers v6 的写法,返回的承诺对象会在交易上链后结束;如果编辑器提示缺少方法,说明依赖仍是 v5,需要换成contract.deployed()。两个网络的差别在“谁给你确认”:本地节点毫秒级出块,Sepolia 需要十几秒到一分钟,超时时要看 RPC 是否支持eth_getTransactionReceipt。
3.3 发布微博的交易参数和失败排错
部署完成后,用scripts/publish-demo.ts发一条测试微博:
import { ethers } from 'hardhat'; async function main() { const contractAddress = process.env.CONTRACT_ADDRESS || ''; const contract = await ethers.getContractAt('DecentraWeibo', contractAddress); const cid = 'QmYwAPJzv5CZsnAzt8auVZRnHxKf1vZ9n'; const tx = await contract.publishPost(cid, 0, 0); await tx.wait(); console.log('Post published, tx hash:', tx.hash); } main().catch((error) => { console.error(error); process.exitCode = 1; });逻辑说明:getContractAt只需要地址和合约 ABI,不需要重新部署;cid参数来自你先把内容加入 IPFS 后得到的值。这和实际发帖逻辑一致,区别只是真实项目还会把 Pin 服务的结果写入数据库。
如果交易失败,先看三条:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
Invalid CID length | 传入的不是 CID,而是完整 URL | 用cid工具从 URL 中截取资源标识符 |
nonce too low | 本地缓存 nonce 已落后于链上状态 | 重置钱包账号的 nonce,或换一个干净私钥 |
execution reverted | 合约 require 未通过 | 在 Hardhat 网络中启用 verbose 日志后再复现 |
另外,部署测试网时要确认钱包里有 Sepolia ETH 和足够的 RPC 配额。很多人只在本地跑到一半就去链上重放,结果成本耗在反复部署合约而不是交易本身。完整源码的docs/deployment.md会把这些步骤写成核对表,避免漏掉环境变量。
4. 前端索引实战:从以太坊事件重建去中心化微博时间线
4.1 读取事件的起点:新节点如何追历史数据
去中心化微博的前端不能只调eth_call查最新帖 ID,然后逐条读合约字段,因为那会漏掉历史帖,而且 RPC 对大量单次查询有频率限制。正确做法是订阅并重建:从合约部署区块开始,按区间查PostPublished事件。
async function fetchPosts(contract, fromBlock, toBlock) { const filter = contract.filters.PostPublished(); const events = await contract.queryFilter(filter, fromBlock, toBlock); return events.map((e) => ({ id: Number(e.args.id), author: e.args.author, cid: e.args.cid, createdAt: new Date(Number(e.args.createdAt) * 1000), blockNumber: e.blockNumber, logIndex: e.index, })); }逻辑说明:fromBlock设置为合约部署所在区块,能省掉大量空扫;toBlock不传时默认到 latest。事件参数里带有indexed标记的id和author会生成 topics,因此event.id可以直接参与过滤。
如果一屏只需要前 20 条,不要一次性查整个历史然后截取,而是倒序查最近的几个区块段。常见做法是维护一个本地cursorBlock,每轮只查[cursorBlock, cursorBlock + 2000]。2000 这个数字不是固定优化值,它取决于你用的 RPC 单次查询限制。
4.2 按关注关系过滤时间线时,把索引和钱包状态分开
事件日志只包含“谁关注了谁”的流水,不包含“当前我关注谁”。如果每次刷时间线都去链上查 mapping 状态,两千个关注地址就会产生两千次 RPC 调用。因此我会在本地缓存关注状态:先读取FollowChanged事件重建一张Map<follower, Set<followee>>,再结合链上当前块的 mapping 做增量校验。
关注重建代码可以聚合到一个服务里:
function reduceFollows(events) { const followMap = new Map(); for (const e of events) { const follower = e.args.follower; const followee = e.args.followee; const isFollow = e.args.isFollow; if (!followMap.has(follower)) followMap.set(follower, new Set()); if (isFollow) followMap.get(follower).add(followee); else followMap.get(follower).delete(followee); } return followMap; }逻辑说明:事件是按时间排序的,所以后面的事件覆盖前面的事件,但前提是events已按blockNumber和logIndex排好。对于同一个人在同一区块里先发帖再取关的极端情况,只有logIndex能告诉你真正的操作顺序,时间戳在这里没有用处。
4.3 微博正文加载:IPFS 网关选择与容错策略
拿到cid后,前端拼接的 URL 可以是公共网关,也可以是本地节点。下面是几种常见网关的格式差异:
| 网关 | 拼接后地址格式 | 限制 |
|---|---|---|
| 本地节点 | http://127.0.0.1:8080/ipfs/{cid} | 只在本机可用 |
| 公共网关 A | https://ipfs.io/ipfs/{cid} | 可能限速,偶尔抽风 |
| 公共网关 B | https://w3s.link/ipfs/{cid} | 对某些文件类型有内容类型限制 |
拼接逻辑不需要在合约里做,合约里只保存 CID。前端展示时需要做两级容错:先用一个公共网关拉取,失败后再换备用网关,两次都失败则显示占位卡片并保留重试按钮。这里不要用多个网关并发去抢同一份数据,公共网关承受大量请求时容易触发限流,串行失败再切换更可控。
async function fetchPostContent(cid: string, gateways: string[]) { for (const gateway of gateways) { try { const response = await fetch(`${gateway}/ipfs/${cid}`); if (response.ok) return await response.json(); } catch { // 尝试下一个网关 } } return null; }这段代码的异常处理只吃网络异常,不吞 JSON 解析错误,因为解析失败说明返回内容不是预期格式,应该留给更上层检查。
4.4 RPC 参数和聚合脚本的三个坑
实际运行中常见的三个问题:
queryFilter大区间超时。一条 RPC 请求携带一万个块的事件,很多公共 RPC 会拒绝。解决办法是分片后再用Promise.all并发,但并发数控制在 3 到 5 个,不是 20 个。- 部署区块忘记保存。前端索引可能只从当前时间启动,导致上线前的内容全都看不到。部署脚本应该把
receipt.blockNumber写入配置文件或deployment.json。 - 新网络切换后数据串链。把
chainId与缓存 key 绑定,否则用户切换链之后,前端仍可能展示上一条链的时间线。
关于第二点的代码:
const receipt = await contract.deploymentTransaction()?.wait(); console.log('deployed block:', receipt?.blockNumber);逻辑说明:拿到部署区块号之后,把它存在deployment.json。后续索引代码启动时先读这个文件,避免每次手工填区块号。这样从零同步时,前端能知道该从哪个块开始扫描历史事件。
5. 事件日志重放:校验完整源码与文档的最后一个技巧
拿到一份源码包或交付整个项目时,怎么确认“完整”不只是能编译?我的做法是写一个重放脚本,把部署地址里从创世到当前的微博事件完整读出来,并与文档承诺的事件数量做对比。这样既验证源码版本,也验证 RPC 和文档描述一致。
import { ethers } from 'hardhat'; async function main() { const address = process.env.DEPLOYED_ADDRESS || ''; const contract = await ethers.getContractAt('DecentraWeibo', address); const afterDeploy = await contract.queryFilter(contract.filters.PostPublished(), 0, 'latest'); console.log('replayed post events:', afterDeploy.length); }你可以在两份不同的 RPC 上跑同一段脚本,如果事件数量、按 ID 排序后的内容哈希完全一致,说明源码、部署地址和链上数据没有漂移。文档里记录的所有地址和私钥只给测试网使用,生产环境永远别把钱包私钥放进仓库。
把这个脚本放在scripts/replay-events.ts后,前端团队只需要执行:
npm run replay就能拿事件流作为 mock 数据,不依赖线上数据库。相比直接读合约字段,这种验证方式可以覆盖“历史数据是否完整”这一层,而单个事件的单元测试覆盖不了。
给源码文档补一条验收条件:每次修改合约后,npm run compile && npm run test && npm run replay三条命令必须通过。测试账号里单独准备一个只用于测试网的私钥,写入.env.example但不提交到 Git。当replay输出的数量与文档最初记录一致时,再考虑合并代码;不一致时优先检查 RPC 是否分叉,再检查索引缓存有没有断档。
本文还有配套的精品资源,点击获取