简介:一套基于区块链的电子存证管理平台毕业设计项目,面向软件工程、计算机科学与技术、人工智能等专业的在校学生和老师,适用于毕设选题、课程设计、结课作业或项目初期演示。压缩包共二百二十六个文件,体积约三点三五兆字节,主要包含Java后端源码、Vue前端页面、JavaScript脚本、Solidity智能合约、SQL数据库脚本以及yml、sh等部署与配置脚本,覆盖电子存证的合约编写、接口开发、前端展示到数据库落地的完整链路。项目代码均经过运行测试,可直接部署使用,也可按需求修改后用于毕设或课设答辩;压缩包附带的文档和资料能帮助梳理区块链存证的核心流程、账户与合约交互、哈希上链等关键实现思路。目前已有四十六人浏览学习,适合入门者结合完整工程快速理解项目结构并复用扩展。
1. 电子存证为什么必须上链:哈希、时间戳与“不可篡改”的本质
一件电子合同、一张原创设计图或者一段操作日志,在你最需要的时候,往往已经躺在服务器里被动过。传统系统只能证明“数据存在”,很难证明“数据在那个时间点原本就是这个样子”:DBA 能改数据库、备份能被替换,到了举证的环节,截图和日志都没有说服力。
基于区块链的电子存证管理平台解决的是这个“自证清白”的问题,而不是存储问题。文件内容被提炼成固定长度的哈希,连同提交者地址、区块时间写入区块链;此后任意时刻重算哈希并与链上记录比对,就能得到明确结论。链上只记账、链下存文件、接口做验真,是这套系统最核心的设计原则,也是从合约到平台落地的起点。
2. 从零设计区块链存证合约:选型、字段与最小可用实现
2.1 先选链:测试链、私链还是联盟链
存证平台的第一步不是写代码,而是决定链从哪里来。常见做法有三种:用 Ganache 在本地起一条开发链、用 Geth 自建私链、或者引入 FISCO BCOS 这类联盟链框架。对于毕业设计这个体量,最大的风险是链的搭建耗掉大半时间,最后留给存证、验真和平台管理的空间变得很小。
三条路的差异对比如下:
| 方案 | 启动成本 | 可演示内容 | 与业务系统集成 | 适合的侧重 |
|---|---|---|---|---|
| Ganache + Solidity | 最低,一条命令 | 区块、交易、事件、账户 | JSON-RPC,Web3j/ethers 直连 | 以平台功能为主,链作为可信部件 |
| Geth 私链 | 中,需初始化创世块 | 比 Ganache 更接近真实节点 | 同样走 JSON-RPC | 想增加“从零搭链”的论述权重 |
| FISCO BCOS | 较高,多节点部署 | 控制台、浏览器、权限管理 | 官方 Java/Python SDK | 论文强调联盟链治理与准入 |
我的建议是优先选 Ganache。它从启动到出块几乎不需要等待,支持一键重置链状态,部署合约、查交易记录都很直观,能让答辩现场的演示路径非常短。如果你希望论文里有更多“节点”“共识”相关的内容,可以在文档里补充一条 Geth 私链的部署记录,但主演示链路仍然留在 Ganache 上,这是一个性价比很高的组合。
需要说明的是,存证类业务在生产上通常会走向联盟链,因为参与方之间的信任边界、证书体系、审计权限都比公网测试网明确。但作为源码交付的毕业设计,把联盟链写进论文、把测试链跑成可演示 Demo,是完全成立的。
2.2 合约字段设计:存证对象该存什么
确定链之后,先回答“合约里的一条存证记录长什么样”。我一般会把最小字段集设计成下面这样:
struct Evidence { bytes32 fileHash; // 文件指纹,固定 32 字节 string description; // 存证说明,可填文件名、用途、业务单号 address uploader; // 提交者地址 uint256 timestamp; // 链上出块时间 uint256 blockNumber; // 存证所在区块 }五个字段各有讲究。fileHash用bytes32而不是string,既降低了存储开销,也避免在合约里做字符串比较;哈希由链下计算后转成 32 字节数组传入。uploader直接取msg.sender,这是合约层面能拿到的、无法伪造的身份信息。timestamp用block.timestamp而不是业务服务器时间,因为服务器时间可以被修改,而区块时间由出块节点统一维护,对存证场景来说可信度高得多。blockNumber留作溯源线索,配合timestamp可以快速定位到对应区块。
结构体里的字段不是越多越好。凡是和“证明存在”无关的字段,都应该放进平台数据库而不是链上。还有一个关键点是重复存证:同一个文件的哈希被二次写入,会让验证结果产生歧义。合约层面要做的最简单约束是按fileHash建映射,已经存在的哈希直接revert。
2.3 Solidity 存证合约:直接可用的最小实现
把字段落成合约,涉及一个容易踩坑的细节:外部调用与内部调用。先写一个外部函数addEvidence作为对外入口,再把真正的存放逻辑放到_addEvidence这个internal函数里,这样后面做批量存证时可以直接复用内部逻辑,省掉跨调用的开销:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; contract EvidenceStorage { struct Evidence { bytes32 fileHash; string description; address uploader; uint256 timestamp; uint256 blockNumber; } Evidence[] private _records; mapping(bytes32 => uint256) private _hashIndex; event EvidenceAdded( uint256 indexed id, bytes32 indexed fileHash, address indexed uploader, uint256 timestamp ); function addEvidence(bytes32 fileHash_, string calldata desc_) external returns (uint256) { require(fileHash_ != bytes32(0), "empty hash"); require(_hashIndex[fileHash_] == 0, "already exists"); return _addEvidence(fileHash_, desc_); } function _addEvidence(bytes32 fileHash_, string calldata desc_) internal returns (uint256) { _records.push(Evidence({ fileHash: fileHash_, description: desc_, uploader: msg.sender, timestamp: block.timestamp, blockNumber: block.number })); uint256 id = _records.length - 1; _hashIndex[fileHash_] = id + 1; // 用 id+1 区分“不存在”与“第一条” emit EvidenceAdded(id, fileHash_, msg.sender, block.timestamp); return id; } function getEvidence(uint256 id) external view returns (Evidence memory) { require(id < _records.length, "not found"); return _records[id]; } function getEvidenceByHash(bytes32 fileHash_) external view returns (bool, Evidence memory) { uint256 idx = _hashIndex[fileHash_]; if (idx == 0) { return (false, _records[0]); } return (true, _records[idx - 1]); } function count() external view returns (uint256) { return _records.length; } }这个合约只有四个对外方法。addEvidence负责存证,getEvidence按编号查询,getEvidenceByHash是验真入口,count用于展示总存证量。注意_hashIndex里存的是id + 1:如果直接存id,第一条记录 id 为 0,查询时会和“不存在”混淆,所以用加一的方式规避。getEvidenceByHash的返回值里带上bool标记,是为了让调用端不依赖异常来区分“没存过”和“查询失败”。
关于string calldata,Solidity 0.8 中用calldata修饰只读入参,可以省掉一次从 calldata 到 memory 的拷贝,调用方一般感知不到区别,但上链时能省一点 gas。合约写完后要立即确认编译版本与部署工具一致,我用 Hardhat 时会在hardhat.config.js里显式指定solc版本,避免 IDE 默认版本编出警告。
2.4 部署到本地链并完成首次存证
合约写好后,起本地链。Ganache 推荐用命令行方式而不是 GUI 版本,因为命令行参数可以写进启动脚本,方便答辩前一键还原环境:
ganache --port 7545 --chain.chainId 1337 -m "test mnemonic ..."参数说明:--port指定 JSON-RPC 端口,平台后端和 Hardhat 都连这个端口;--chain.chainId必须与hardhat.config.js里的网络配置一致,否则签名后的交易会被节点以 chainId 不匹配为由拒绝;-m指定助记词,这样每次启动得到的测试账户地址都是固定的,平台里预设的演示账号不会失效。
然后写部署脚本:
const { ethers } = require("hardhat"); async function main() { const factory = await ethers.getContractFactory("EvidenceStorage"); const contract = await factory.deploy(); await contract.waitForDeployment(); console.log("EvidenceStorage:", await contract.getAddress()); console.log("ABI:", JSON.stringify(contract.interface.format("json"))); } main().catch((err) => { console.error(err); process.exit(1); });脚本输出两样东西:合约地址和 ABI。地址要写进后端配置文件,ABI 要复制给前端或者让后端把 ABI 做成可访问的配置项。部署完成后,在 Hardhat 控制台做一次最小存证,验证整条链路是否通畅:
const contract = await ethers.getContractAt("EvidenceStorage", "0x部署地址"); await contract.addEvidence(ethers.keccak256(ethers.toUtf8Bytes("hello evid")), "test"); const [ok, ev] = await contract.getEvidenceByHash( ethers.keccak256(ethers.toUtf8Bytes("hello evid")) ); console.log(ok, ev.timestamp.toString());这里用ethers.keccak256临时生成一个 32 字节参数,实际系统里应该是链下算好的文件 SHA-256。能返回true并打印出timestamp,说明链上链路已经通了,接下来才进入业务平台的部分。
3. 链下指纹与批量上链:把文件变成可信存证之前要处理的三个细节
3.1 文件指纹:流式计算文件 SHA-256
链上合约接的是bytes32哈希,这个哈希来自链下的原始文件。计算文件哈希不是字符串哈希,而是密码学摘要。Java 侧最稳妥的写法是用流式MessageDigest.update,避免把整个文件读进内存:
public static String sha256(Path filePath) throws IOException { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] buffer = new byte[8192]; try (InputStream in = Files.newInputStream(filePath)) { int len; while ((len = in.read(buffer)) != -1) { digest.update(buffer, 0, len); } } return HexFormat.of().formatHex(digest.digest()); }参数上需要注意两点。第一,缓冲区大小决定大文件处理时的 I/O 次数,8KB 对绝大多数场景都够用,换到 64KB 也不会明显影响结果,重点是“分块读入”而不是一次性readAllBytes。第二,返回值是 64 位小写十六进制字符串,传给合约时要先去掉可能存在的0x前缀,再按 32 字节转换;这个前缀问题在验真接口里最容易出 bug。
除了算法本身,还要约定“被哈希的对象”。同一个 PDF 经过一次重新保存,元数据变化也会导致哈希完全不同。平台侧必须固定一个规则:以用户上传的原始二进制流为准,不做任何转码、不重新打包、不存临时文件再处理。否则会出现“同一个文件上午存的、下午验不过”的尴尬局面。
3.2 存证前查重:同文件重复上传有两条路
上链前,先把用户上传文件的哈希在后端查一遍链上记录。这里用此前合约的getEvidenceByHash,而不是再写一套数据库缓存,因为链上就是最权威的数据源。有两个处理策略。
第一种是“直接拒绝”,引用合约的require逻辑,平台返回“该文件已完成存证,证据编号为 xxx”。这种策略逻辑最干净,适合毕业设计的演示场景。第二种是“按用途区分”:同为一份合同,甲方存一次、乙方存一次,或者不同批次存证业务上允许重复,那就别锁哈希,改在description里维护用途编号或业务单号,同时允许同一哈希多次上链。开发者要根据业务场景决定,不要迷信“必须去重”。
对应的后端伪代码:
async function uploadAndEvidence(file) { const hash = sha256(file.path); const [exists, evidence] = await contract.getEvidenceByHash("0x" + hash); if (exists) { return { duplicated: true, evidenceId: evidence.id.toString() }; } return { duplicated: false, txHash: await contract.addEvidence("0x" + hash, file.name) }; }exists直接决定平台给用户的提示信息;即便要去重,也要让查询发生在业务层,给用户更友好的回显,而不是把合约的revert异常直接抛到前端。
3.3 批量存证:Gas、上限与中途失败回滚
批量存证是管理平台很常见的需求,比如一次归档 100 份合同。合约层面最直接的方式是加一个批量入口:
function batchAdd( bytes32[] calldata fileHashes_, string[] calldata descs_ ) external returns (bool) { require(fileHashes_.length == descs_.length, "length mismatch"); require(fileHashes_.length <= 100, "too many in one batch"); for (uint256 i = 0; i < fileHashes_.length; i++) { _addEvidence(fileHashes_[i], descs_[i]); } return true; }这里关键是复用了_addEvidence内部函数而不是循环调用this.addEvidence。如果循环里走外部调用,每一轮都要支付一次 external call 的开销;更重要的是,只要其中一条记录重复,整个批次会全部回滚,这对用户来说很难定位是哪条文件出了问题。所以在批量函数之前,后端要先对数组里的哈希做一次预查重,把已存在的记录挑出来返回给用户,再把干净的集合送上链。
批量大小的选择也和演示环境强相关:
| 单批数量 | 链上成本 | 推荐程度 |
|---|---|---|
| 10 条 | 极低 | 演示首选,出块快且回滚排查容易 |
| 100 条 | 中等 | 单块可接受,是合理上限 |
| 1000 条 | 高 | 不建议单批提交,易触发单块限制或超时 |
实际生产环境里,100 条不是硬上限,而是回滚成本的权衡:批次越大,某一条失败导致整体回滚的概率越高。更好的分层是“合约保留批量能力,业务层控制每次提交 50 条以内”,既满足归档速度,又不至于让一次失误毁掉整批数据。
4. 把存证能力封装成平台接口:验真、溯源与用户签名边界
4.1 平台侧最核心的四个接口
管理平台的后端不需要直接暴露链上所有操作。基于区块链的电子存证管理平台里,后端一般只做四件事:接收文件、算哈希、上链、查询验证。接口层面我习惯这样划分:
| 方法 | 路径 | 职责 | 与链交互 |
|---|---|---|---|
| POST | /api/evidence/upload | 保存文件、计算哈希、调用合约 | 上链 |
| GET | /api/evidence/{fileHash} | 展示链上证据详情与区块信息 | 读链 |
| POST | /api/evidence/verify | 接收新文件,重算哈希并比对链上记录 | 读链 |
| GET | /api/evidence/list | 分页展示存证记录 | 读链 |
verify单独做成 POST 接口,而不是让前端传字符串哈希过来比对,是为了让平台替用户完成“哈希重新计算”这个步骤。用户只需上传文件,后端重新流式计算摘要,再调合约查询;比对结果用成功或失败返回,链上无记录时明确提示“暂无此文件存证”。这比让用户在客户端算好哈希再传上来更不容易出错,也更接近真实产品。
4.2 Java 后端用 Web3j 调合约的完整姿势
毕业设计里前端常是 Vue 或 React,后端用 Spring Boot,所以这里写 Web3j 的接入方式。首先用web3j将合约源码生成 Java wrapper 类,在 Gradle 或 Maven 里配置插件后执行编译任务;生成后会得到一个EvidenceStorage类,地址和 gas 参数都封装在内部。然后写一个 Service:
@Service public class EvidenceService { private final Web3j web3j; private final EvidenceStorage contract; public EvidenceService( @Value("${blockchain.rpc-url}") String rpcUrl, @Value("${blockchain.contract-address}") String contractAddress, @Value("${blockchain.private-key}") String privateKey) { this.web3j = Web3j.build(new HttpService(rpcUrl)); Credentials credentials = Credentials.create(privateKey); this.contract = EvidenceStorage.load( contractAddress, web3j, credentials, new DefaultGasProvider()); } public String storeEvidence(String hexHash, String description) throws Exception { // hexHash 为不含 0x 的 64 位小写字符串 byte[] hashBytes = Hex.decode(hexHash); TransactionReceipt receipt = contract.addEvidence(hashBytes, description).send(); return receipt.getTransactionHash(); } public VerifyResult verifyFile(Path filePath) throws Exception { String hash = sha256Hex(filePath); Tuple2<Boolean, Evidence> result = contract.getEvidenceByHash(Hex.decode(hash)).send(); return new VerifyResult(result.component1(), result.component2()); } }代码里的参数要按环境分开:rpc-url是 Ganache 的http://127.0.0.1:7545,contract-address是部署脚本输出的地址,private-key从 Ganache 第一个账户里复制。私钥千万不能写进仓库,Spring Boot 项目放到application-local.yml并用.gitignore排除;源码包交付时也要在 README 里说明这个文件不参与编译。
Web3j 调用合约时,方法签名里的byte[]对应 Solidity 的bytes32,需要手动用Hex.decode把 64 位字符串转成 32 字节数组。这里最常见的错误是直接把String.getBytes()传进去,导致链上拿到的是 ASCII 字节而非原始哈希,最终链上记录与本地重算结果永远对不上。
4.3 签名边界与权限设计:哪些操作必须用户确认
存证的本质是“谁在什么时间存了什么”。“谁”在链上体现为msg.sender,所以上链操作必须携带签名。客户端方案的差别在于谁持有私钥:如果做前端钱包签名,合约里的uploader就是用户自己的地址,私钥不经过平台后端;如果做后端统一签名,所有存证记录的uploader都是平台地址,用户身份则落在外层数据库字段里。
这两种方案不建议混合使用,因为混合后用户无法分清某条存证的“链上身份”到底是个人还是平台。更关键的是,验真结果应该永远以链上uploader为准,数据库里的用户字段只能做展示,不能参与校验。毕业设计采用后端统一签名能省去用户安装钱包插件的环节,演示流程更顺;但如果论文想体现“用户资产自持”,就要引入钱包签名交互。
后端统一签名还有一个容易被忽视的权限点:验真接口应该开放匿名调用还是需要登录?我的建议是验真开放给所有人,因为它只读链上数据;上链接口必须有登录态,否则任何人都可以向链上写垃圾记录,污染演示数据。登录态与链上地址是两套体系,中间通过“当前登录用户的后端账户”关联,二者不要混为一谈。
4.4 验真失败时的高频排查点
验真失败九成不是链的问题,是链下处理不一致。我有三个固定排查点。第一个是0x前缀:后端返回的 hex 字符串可能带前缀,统一用不含前缀的裸 hex 与合约交互。第二个是编码不一致:同一个文件在 Windows 与 Linux 环境下的换行符、或 PDF 保存软件的增量更新,都会改变二进制内容,前后两次哈希不同不代表系统坏了,而是文件发生了变化。第三个是错误的bytes32转换:用String.getBytes()传参不会报错,只会生成一条永远验不过的记录。
核心定位方法还是把“链上数据”与“本地重算数据”两个来源打印出来对比:交易回执里的blockNumber、事件里的fileHash、以及本地sha256Hex的输出,三者对齐就能定位九成问题。
5. 部署演示与答辩准备:把源码整理成让人信服的证据链
5.1 一条命令跑通演示环境
毕业设计交付源码时,目标不是让答辩老师看懂每一行代码,而是让任何人都能在一台干净电脑上复现这条存证链路。我在大多数项目里会用三个命令完成环境准备:
ganache --port 7545 --chain.chainId 1337 & npx hardhat run scripts/deploy.js --network ganache npm run dev第一条拉起本地链,第二条部署合约并打印地址,第三条同时启动前后端。如果项目里没有统一启动脚本,至少要把这三行写进 README 的第一屏。部署脚本里可以加上写配置文件的逻辑:把输出的合约地址自动覆盖到后端的application.yml和前端的环境变量里,省去手动复制;这一步只针对演示环境,不要污染生产配置。
5.2 用脚本验证“不可篡改”而不是用嘴说
答辩时最有冲击力的演示不是页面截图,而是展示“文件被改了一个字节,链上验真立刻失败”。可以写一个脚本完成篡改验证:
const { ethers } = require("ethers"); async function main() { const provider = new ethers.JsonRpcProvider("http://127.0.0.1:7545"); const contract = new ethers.Contract("合约地址", abi, provider); // abi 从部署文件导入 // 模拟文件被修改后重新计算哈希 const badHash = ethers.keccak256(ethers.toUtf8Bytes("modified-file")); const [ok] = await contract.getEvidenceByHash(badHash); console.log("验证结果:", ok ? "一致" : "不一致"); } main();脚本只演示了“哈希不对应”,真正业务里的正确做法是:修改文件后重新走一遍 SHA-256,再用返回结果请求getEvidenceByHash。如果返回true,说明文件与链上记录完全一致;如果返回false,说明文件已变化。这里不要展示“修改后仍然通过”的场景,那不是 bug,而是说明源文件真的没有变化。
看完结果再打开 Ganache 的交易列表,把存证那笔交易的txHash、blockNumber和区块时间指给老师看。这组数据与前端页面上的存证列表一一对应,就是“不可篡改”最直观的证据——区块链本身不能阻止任何人修改文件,但它能让修改后的文件无法与链上指纹匹配。
5.3 源码与文档组织:交付时最容易加分也最容易丢分的部分
“源码+详细文档+全部资料”是毕业设计交付的常见要求。源码交给导师后,对方先看的一定不是算法,而是 README 能不能让项目跑起来。我一般按这个顺序整理:根目录放 README,写清环境要求、三条启动命令、默认账户、链 ID、Hardhat 版本;deploy/目录放启动脚本和合约地址备份;后端config/放演示环境的配置样例;前端放一份env.example,内容里不许出现任何真实私钥。
下面几个问题答辩被问到的概率最高,建议在文档里预先写清楚答案:为什么把文件哈希放到链上而不是文件本身;区块时间戳与用户提交时间哪个可信;平台把私钥统一保管之后,如何向用户证明存证记录没有被平台替换。前两个从存储成本、节点时间一致性角度解释,第三个问题没有标准答案,但要在文档里写明“平台只负责代签名,链上记录的uploader是平台地址,用户最关键的文件原件始终在用户自己的控制范围内”。
演示前有一个特别容易忽视的步骤:把 Ganache 停掉重开,让链回到空状态。如果持续在同一个链上演示,之前测试产生的记录会堆在存证列表里,老师看到的第一印象就会大打折扣。清空链之后按 5.1 节的三条命令重新部署,这一段“从无到有”的过程本身就是对“源码可复现”的最好证明。
本文还有配套的精品资源,点击获取