☰
SpringBoot与以太坊区块链项目评审系统:存证边界与落地实践
2026/10/27 22:14:24 网站建设 项目流程

简介:这是一套面向软件工程、计算机科学及区块链方向师生的毕业设计级开发资料,围绕SpringBoot框架与以太坊技术构建项目评审系统,适合作为课程实践、课题研究或项目原型开发的参考范本,也便于具备编程基础的学习者进行功能拓展与定制。压缩包共248个文件,约800KB,以51个Java源码、22个FreeMarker模板、23个JavaScript脚本、9个CSS样式及6个XML配置为主,另含Solidity合约、ABI接口文件、SQL脚本与部署命令脚本,覆盖后端逻辑、前端页面、智能合约与数据库各层。目前已有24人学习关注。资料内含系统设计说明、实现代码与部署指南,代码模块经多轮功能验证,结构完整、可直接运行,能帮助读者快速理解区块链评审系统的整体架构与关键实现,并在此基础上完成二次开发或学术演示。

1. 从一次评审扯皮说起:SpringBoot 与以太坊怎么把「谁改过什么」钉死

去年帮一个做科研项目管理的团队收拾烂摊子,他们的评审系统跑在 SpringBoot 上,MySQL 里存着评审意见和打分。问题出在一次结题验收:三个专家对同一份材料给出了互相矛盾的评审记录,后台日志只显示「update 成功」,谁在什么时间把分数从 82 改成 91,查不出来。数据库 binlog 有,但没人愿意为一次扯皮去翻 binlog,而且 binlog 本身也能被有权限的人清理。这件事让我彻底想明白一个道理:评审系统里最值钱的不是表单和流程,是「不可抵赖的评审痕迹」。

基于 SpringBoot 与以太坊的区块链项目评审系统,要解决的就是这件事。SpringBoot 负责业务:项目申报、专家分配、材料上传、评审打分、结果公示;以太坊负责存证:把关键动作的哈希写进链上,任何人改数据库都改不掉链上那条记录。适合谁做?适合手里有 SpringBoot 基础、想找一个真实业务场景把区块链落地跑通的后端,也适合做毕设或课程设计、需要一套能演示又能讲清原理的完整方案的人。下面我按「先想清楚存什么、再动手跑通、最后踩坑」的顺序讲。

2. 存证边界怎么划:哪些数据上链、哪些坚决不上链

2.1 全量上链是新手最容易犯的错

很多人一听区块链评审系统,第一反应是把评审意见、项目材料、专家信息全塞进链上。这个方向从工程角度看是灾难。以太坊主网存 1KB 数据的 gas 成本高到离谱,就算是私链或测试链,全量上链也会让系统慢到没法用。更关键的是,链上数据默认公开,评审意见里往往有未公示的敏感内容,全量上链等于把内部评审过程直接摊开。

正确的边界是:链上只存「指纹」和「状态变更事件」,原文全部留在链下数据库或对象存储。所谓指纹,就是原文的哈希值。评审意见正文存 MySQL,同时算一个 SHA-256 哈希写进合约;材料文件存 MinIO 或本地磁盘,同样只把哈希上链。这样链上那条记录能证明「某份内容在某时刻存在且未被篡改」,但内容本身不暴露。

我一般会划三条线:第一,身份与权限不上链,用 SpringBoot 的 RBAC 管;第二,大文本和文件不上链,只上哈希;第三,关键状态流转上链,比如「项目提交」「专家确认评审」「评分最终锁定」「结果公示」这四个动作,每个动作写一条事件。这样链上数据量可控,业务查询还是走 MySQL,链只做存证和校验。

2.2 合约该设计成什么样

合约不需要复杂,评审系统的存证合约核心就两个能力:写入一条存证记录、按业务 ID 查询存证记录。下面是一个最小可用的 Solidity 合约,字段设计对应上面说的边界。

// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract ReviewEvidence { // 一条存证记录:业务ID、阶段、内容哈希、操作人、时间戳 struct Evidence { string bizId; // 项目或评审单的业务ID string stage; // 阶段:submit / review / lock / publish string dataHash; // 链下内容的 SHA-256 哈希 string operator; // 操作人标识(工号或脱敏ID) uint256 timestamp; // 区块时间戳 } // bizId => 该业务下的所有存证 mapping(string => Evidence[]) private records; event EvidenceAdded(string bizId, string stage, string dataHash, string operator, uint256 timestamp); // 写入存证,只追加不修改 function addEvidence(string memory bizId, string memory stage, string memory dataHash, string memory operator) public { Evidence memory e = Evidence(bizId, stage, dataHash, operator, block.timestamp); records[bizId].push(e); emit EvidenceAdded(bizId, stage, dataHash, operator, block.timestamp); } // 查询某业务下的存证数量 function getEvidenceCount(string memory bizId) public view returns (uint256) { return records[bizId].length; } // 按索引取一条存证 function getEvidence(string memory bizId, uint256 index) public view returns (string memory, string memory, string memory, string memory, uint256) { Evidence memory e = records[bizId][index]; return (e.bizId, e.stage, e.dataHash, e.operator, e.timestamp); } }

逻辑说明:addEvidence只做追加,没有提供任何修改或删除方法,这是「不可抵赖」的合约层保证。records用mapping(string => Evidence[])而不是数组套数组,是为了按业务 ID 直接定位,查询成本低。参数上,bizId建议用项目编号加评审轮次拼成,比如PRJ2024-001-R1,保证唯一;stage用固定枚举字符串,方便前端过滤;dataHash是链下内容算出来的十六进制字符串,长度固定 64 字符;operator不要直接存姓名,存工号或系统内脱敏 ID,避免链上暴露个人信息。

提示:合约部署到测试链还是私链,取决于你的场景。做毕设或内部演示,用 Ganache 或 Hardhat 本地节点足够;要演示「多节点共识」,再考虑搭一条小规模私链。不要一上来就碰主网,gas 和私钥管理会把新手劝退。

2.3 链下哈希怎么算才和链上对得上

哈希对不上是这类系统最高频的翻车点。原因通常有三个:编码不一致、字段拼接顺序不一致、换行符差异。我的做法是定义一个固定的「存证原文模板」,前后端和合约调用方都按同一个模板拼字符串,再算哈希。

// SpringBoot 侧:生成存证哈希 import java.nio.charset.StandardCharsets; import java.security.MessageDigest; public class EvidenceHashUtil { // 固定模板:bizId|stage|content|operator,用竖线分隔,避免字段歧义 public static String buildHash(String bizId, String stage, String content, String operator) throws Exception { String raw = bizId + "|" + stage + "|" + content + "|" + operator; MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } }

逻辑说明:raw的拼接顺序必须和链下校验时完全一致,任何一方改了顺序,哈希就对不上。StandardCharsets.UTF_8显式指定编码,避免不同环境默认编码不同导致中文内容哈希漂移。%02x保证每个字节转成两位小写十六进制,和前端crypto库、合约里字符串比较时格式统一。参数上,content传的是评审意见正文或文件内容的字符串形式,文件建议先算文件哈希再把这个哈希当 content 传入,避免大文件读进内存。

3. 用 SpringBoot 把链上链下串起来:最小可跑通的调用链

3.1 依赖选型和版本坑

SpringBoot 调以太坊,主流做法是 web3j。这里有个血泪经验:SpringBoot 3.x 配 web3j 要注意 Jakarta 命名空间和旧版 javax 的冲突,很多网上抄来的代码在 SpringBoot 3 下直接编译不过。我一般会锁一套经过验证的组合:SpringBoot 2.7.x 配 web3j 4.9.x,或者 SpringBoot 3.2.x 配 web3j 4.10.x 以上。别盲目追最新,springboot版本太高带来的连锁反应在区块链依赖上尤其明显。

<!-- pom.xml 关键依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.web3j</groupId> <artifactId>core</artifactId> <version>4.10.3</version> </dependency> <dependency> <groupId>org.web3j</groupId> <artifactId>codegen</artifactId> <version>4.10.3</version> </dependency>

逻辑说明:core提供链交互能力,codegen用来把 Solidity 编译后的 abi/bin 生成 Java 包装类,省去手写交易组装。参数上,版本号必须 core 和 codegen 一致,混用会出现NoSuchMethodError。如果你的项目是 Gradle,对应换成implementation 'org.web3j:core:4.10.3',springboot gradle项目搭建时注意仓库里要能拉到 web3j。

3.2 配置链节点连接

# application.yml web3j: node-url: http://127.0.0.1:8545 contract-address: "0x你的合约部署地址" from-address: "0x你的调用账户地址" private-key: "你的测试账户私钥"
@Configuration public class Web3jConfig { @Value("${web3j.node-url}") private String nodeUrl; @Bean public Web3j web3j() { // 关闭默认的轮询,按需开启,避免无谓请求 return Web3j.build(new HttpService(nodeUrl)); } }

逻辑说明:HttpService指向本地或测试链的 RPC 端口,Ganache 默认 8545。private-key只用于测试环境,生产环境必须走密钥管理服务或硬件签名,绝不能明文写在配置文件里提交到仓库。参数上,node-url换成你自己的节点地址;contract-address是合约部署后返回的地址,部署一次固定不变。

3.3 调用合约写入存证

@Service public class EvidenceService { @Autowired private Web3j web3j; @Autowired private ReviewEvidence contract; // codegen 生成的包装类 @Value("${web3j.from-address}") private String fromAddress; @Value("${web3j.private-key}") private String privateKey; public String saveEvidence(String bizId, String stage, String content, String operator) throws Exception { // 1. 算链下哈希 String hash = EvidenceHashUtil.buildHash(bizId, stage, content, operator); // 2. 组装交易 TransactionReceipt receipt = contract.addEvidence(bizId, stage, hash, operator).send(); // 3. 返回交易哈希,供前端展示和后续核验 return receipt.getTransactionHash(); } }

逻辑说明:send()是同步等待交易上链,返回的TransactionReceipt里包含交易哈希和区块号。生产环境建议用sendAsync()配合回调,避免阻塞业务线程。参数上,bizId、stage、operator和算哈希时传入的必须完全一致,否则链上存的是 A 的哈希、数据库存的是 B 的内容,核验时必然失败。交易哈希要落库,它是链上链下关联的唯一凭证。

3.4 核验接口怎么写

核验是评审系统里最实用的功能:任何人拿到一份评审意见,都能验证它是否和链上记录一致。

public boolean verify(String bizId, String stage, String content, String operator, int index) throws Exception { // 重新算哈希 String localHash = EvidenceHashUtil.buildHash(bizId, stage, content, operator); // 从链上取对应记录 Tuple5<String, String, String, String, BigInteger> e = contract.getEvidence(bizId, BigInteger.valueOf(index)).send(); String chainHash = e.component3(); // dataHash 在第三个位置 return localHash.equals(chainHash); }

逻辑说明:核验的本质是「用同样的输入重算哈希,和链上存的比」。component3()对应合约返回元组的第三个字段,位置不能错。参数上,index是该业务下第几条存证,前端一般会展示存证列表让用户选。如果核验失败,先查拼接模板是否一致,再查内容是否被改过,最后查是不是取错了 index。

4. 部署文档里最容易翻车的几个环节

4.1 本地链、合约、后端的启动顺序

部署文档如果只写「启动即可」,新手一定卡住。正确的顺序是:先起链节点,再部署合约拿到地址,把地址写进配置,最后起 SpringBoot。顺序错了,SpringBoot 启动时连不上节点或找不到合约,直接报错退出。

# 1. 启动本地链(以 Ganache 为例) ganache --port 8545 --accounts 10 --defaultBalanceEther 100 # 2. 部署合约(Hardhat 示例) npx hardhat run scripts/deploy.js --network localhost # 输出:ReviewEvidence deployed to: 0x.... # 3. 把地址写入 application.yml 后启动后端 mvn spring-boot:run

逻辑说明:--accounts 10生成 10 个测试账户,够演示多角色;--defaultBalanceEther 100给每个账户足够余额付 gas。部署脚本输出的地址必须原样复制,少一位都连不上。参数上,端口要和node-url一致,网络名要和 Hardhat 配置里的localhost对应。

4.2 打包和部署到服务器

vue打包放进springboot中是这类前后端一体项目常见的部署方式:前端npm run build产出dist,把dist放进 SpringBoot 的src/main/resources/static,再打成一个 jar 部署。这样只需要暴露一个端口,省去 Nginx 反代配置。

# 前端打包 npm run build # 拷贝到后端静态目录 cp -r dist/* ../backend/src/main/resources/static/ # 后端打包 mvn clean package -DskipTests # 运行 java -jar target/review-system-1.0.jar --spring.profiles.active=prod

逻辑说明:-DskipTests在部署时跳过测试加快打包,但首次部署建议先跑一遍测试。--spring.profiles.active=prod加载生产配置,把链节点地址、数据库连接换成生产值。参数上,static目录下的index.html会被 SpringBoot 默认欢迎页规则命中,前端路由如果是 history 模式,需要额外配置 fallback 到index.html,否则刷新页面 404。

注意:宝塔docker部署springboot这类场景下,链节点如果也在同一台机器,容器网络要用 host 模式或自定义网络,否则容器内127.0.0.1指向的是容器自己,连不上宿主机的链节点。这是部署文档里必须写清的一条。

4.3 数据库和链上数据的一致性

链上写入成功、数据库写入失败,或者反过来,都会造成数据不一致。我的做法是把链上交易哈希先落库,状态标记为「待确认」,等交易回执确认后再更新为「已存证」。如果数据库写入失败,链上那条记录就成了孤儿,但因为是只追加,不影响后续核验,只是列表里少一条。反过来,数据库写了但链上没写,核验时会发现链上查不到,这时候要触发补偿重试。

5. 避坑与排查:五条真实踩过的记录

5.1 现象:合约调用报 «insufficient funds for gas»

原因:调用账户余额不足以支付 gas,或者 gas price 设得太低。本地链默认账户余额充足,但换成测试链或自己配的账户,经常忘充。

解决:先查账户余额,web3j.ethGetBalance(address, DefaultBlockParameterName.LATEST).send()。本地链重启后账户余额会重置,重新部署合约前确认余额。gas price 用web3j.ethGasPrice().send()取当前建议值,不要写死。

5.2 现象:中文评审意见上链后核验失败

原因:哈希计算时编码不一致。Windows 默认 GBK,Linux 默认 UTF-8,同一段中文算出的字节不同,哈希自然不同。

解决:所有涉及哈希的地方显式指定StandardCharsets.UTF_8,包括 Java 侧、前端TextEncoder侧、以及任何脚本。数据库连接串也加上characterEncoding=utf8,避免存取过程中编码漂移。

5.3 现象:SpringBoot 启动报 «Unable to determine native type»

原因:web3j 版本和 SpringBoot 版本不匹配,或者 codegen 生成的包装类是用旧版 abi 生成的,字段类型对不上。

解决:core 和 codegen 版本锁一致,重新用当前合约的 abi 生成包装类。如果升级了 SpringBoot 到 3.x,检查是否有javax.annotation相关依赖需要换成jakarta.annotation。

5.4 现象:交易发出去了但一直不确认

原因:本地链出块间隔设置问题,或者交易卡在 pending 池。Ganache 默认即时出块,但如果用了--blockTime参数,会按间隔出块。

解决:查交易状态web3j.ethGetTransactionReceipt(txHash).send(),如果getReceipt()为空说明还没上链。本地链去掉--blockTime恢复即时出块;测试链适当提高 gas price 加速。生产环境要设置合理的超时和重试。

5.5 现象:前端刷新页面 404

原因:Vue 用 history 路由模式,打包进 SpringBoot 后,刷新非根路径时 SpringBoot 找不到对应静态资源,返回 404。

解决:加一个转发配置,把所有非 API、非静态资源的请求转发到index.html。

@Controller public class SpaForwardController { // 匹配不含点(非静态文件)且不以 api 开头的路径 @RequestMapping(value = "/{path:[^\\.]*}") public String forward() { return "forward:/index.html"; } }

逻辑说明:正则[^\\.]*排除带点的静态资源请求,避免把 js、css 也转发。forward:/index.html交给前端路由处理。参数上,如果你的 API 前缀不是/api,需要相应调整排除规则。

6. 进阶:把存证做成可验证凭证,而不是只存个哈希

走到这一步,系统能跑、能存、能核验,但还差一层:怎么让别人在不信任你数据库的前提下,独立验证一条评审记录。答案是让链上记录本身携带足够信息,配合一个公开的核验页面,任何人输入业务 ID 就能拉出链上存证列表,自己算哈希比对。

具体做法是扩展合约的查询能力,支持按业务 ID 一次性返回所有存证,前端拿到后逐条展示阶段、操作人、时间戳和哈希。核验页面不依赖后端数据库,直接读链。这样即使后端数据库被清空,链上记录依然能证明「某份评审意见在某时刻存在过」。

// 一次性返回某业务下所有存证的哈希和阶段,供前端核验 function getAllEvidence(string memory bizId) public view returns (string[] memory, string[] memory, uint256[] memory) { Evidence[] memory list = records[bizId]; string[] memory stages = new string[](list.length); string[] memory hashes = new string[](list.length); uint256[] memory times = new uint256[](list.length); for (uint256 i = 0; i < list.length; i++) { stages[i] = list[i].stage; hashes[i] = list[i].dataHash; times[i] = list[i].timestamp; } return (stages, hashes, times); }

逻辑说明:返回三个平行数组而不是结构体数组,是为了兼容 web3j 的元组解析,结构体数组在部分版本里解析会出问题。参数上,bizId为空时返回空数组,前端要做空值判断。数据量大时这个接口会消耗较多 gas(view 调用不花 gas,但节点有计算限制),评审系统的存证条数一般几十条以内,够用。

验证方法上,我习惯做一次「篡改演练」:手动改数据库里某条评审意见的内容,然后跑核验接口,确认返回 false;再改回去,确认返回 true。这个演练能证明整套存证链路真的有效,而不是摆设。做毕设答辩时,这个演示比讲十页 PPT 都管用。

最后说个我自己的习惯:每次部署新环境,先跑一遍「提交-评审-锁定-公示」四个阶段的完整存证流程,确认四条记录都能在链上查到、哈希都能对上,再去调业务功能。这个顺序帮我省过很多次「业务跑通了但存证是坏的」的后悔药。区块链项目评审系统的价值不在链本身,在于它让评审过程变得可追溯、可验证,把扯皮的成本降到最低。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询