news 2026/10/18 12:41:06

SpringBoot+以太坊评审系统:链上存证与防篡改实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+以太坊评审系统:链上存证与防篡改实战

简介:面向计算机相关专业毕业设计场景,这份资源提供了基于Spring Boot与以太坊的项目评审系统完整方案,涵盖系统源码、部署文档与项目配套资料,可支撑毕业设计、课程设计、实训演示以及项目初期立项等多种用途。包体共收录二百零八个文件,压缩后大小约为七百七十一KB,其中Java源码承担后端业务逻辑,JavaScript脚本与FreeMarker页面模板及CSS样式共同搭建前端展示,GIF动画可直观呈现运行效果,SQL脚本用于初始化数据库,Solidity合约及ABI和BIN文件对应以太坊链上交互,从界面操作到智能合约调用均有对应素材,适合完整研读与二次开发。当前已有约一百一十人学习下载。据资源说明,该项目为高分完整源码,答辩得分九十五分且已测试运行,适合软件工程、区块链、人工智能等专业在校师生;解压可获得可部署的工程结构、智能合约交互实现、部署文档与项目资料,内容预览含构建脚本及登录注册页面,能够减少环境搭建与界面理解成本,若有编程基础,可在此基础上扩展评审流程或权限模块,是快速上手区块链应用开发的高质量参考。

1. 基于SpringBoot和以太坊的评审系统,真正的加分项是“哪些地方不上链”

区块链毕业设计里最容易翻车的做法,是开发者兴奋地把项目材料、专家信息、打分理由全部塞进智能合约,结果Gas费高得离谱、界面卡成幻灯片,答辩时还被老师问住。基于SpringBoot+以太坊做项目评审系统,核心卖点从来不是“用区块链替代数据库”,而是让评审过程中最容易被质疑的环节——项目提交时间、专家评分、状态流转——变成链上交易。交易哈希、区块号、时间戳全部公开可查,评审结果改了也赖不掉。这篇文章会按我实际做这类系统的顺序展开:先划清链上和链下的边界,再写合约、用Web3j把合约变成SpringBoot接口,最后给出部署和答辩验证的具体路径。适合正在定题的毕设同学,也适合想给老评审系统加一层“存证外挂”的开发者。

2. 把评审流程拆成链上与链下:先想清楚边界,再动手写合约

2.1 评审系统为什么选择“链上存证”而不是“链上跑完整业务”

项目评审业务的本质是:发起方提交项目→组织方分配专家→专家打分→汇总结果。传统SpringBoot系统用一张score表和一个状态字段就能做完整套流程,但有个没法回避的问题:数据库管理员可以改分,项目方可以质疑某个专家是否真的在那个时间点提交了评分。而区块链能解决的不是“自动化评审”,是“证据固化”——把每一次关键操作变成一笔交易,交易里带上调用者地址和时间戳,任何人可以通过区块浏览器或者Ganache的交易列表看到它。

所以我在给A同学做这个毕设题目时,第一件事不是打开IDE写代码,而是画一张数据边界表。需要上链的是:项目Id、提交者地址、提交时间、专家地址列表、每个专家的评分、项目最终状态。不需要上链的是:项目文档原文、专家姓名、评审意见文本、通知记录。这些留在MySQL里,通过projectId与链上数据关联。这样设计的好处是合约保持轻量,业务功能仍然用SpringBoot的成熟能力实现,区块链只负责“记账”。

2.2 为什么选以太坊而不是Fabric或自研链:毕业设计需要“看得见的交易”

我见过不少同学一开始想用Fabric,理由是“联盟链效率高”。但Fabric的部署依赖Docker镜像、排序节点、组织证书,一套环境折腾两个星期是常态,而且Fabric没有一个像Ganache这样双击就能跑的图形化链。以太坊的优势在于开发体验:Ganache启动后自带十个账户、每个账户100个测试币,还能在界面里直接看到每笔交易流入哪个区块,这对答辩演示非常友好。

另一个更实际的原因是资源:网上能找到的中文资料、博客、错误修复帖绝大多数都是以太坊+Web3j方向,遇到问题搜索成本低。安全方面要提醒一句,测试网络的私钥只用于本地演示,千万不要把它当成正式环境使用。整个开发期我只需要一条本地私链,配置很简单:网络ID用5777,端口7545,Gas上限默认就行。这个网络配置会让后续所有合约交互代码复用同一套参数。

2.3 合约数据模型:状态枚举、项目结构、评分记录的映射关系

写合约之前先把数据结构想清楚。我用的核心模型是这样的:

数据对象所在位置关键字段说明
评审项目合约projectId、title、state、scoreSum、scoreCount状态和分数汇总在链上
评审专家合约address、projectId、hasVoted专家地址作为唯一身份
原始材料MySQLprojectId、fileUrl、description只存业务信息,不上链
链上索引MySQLprojectId、txHash、blockNumber关联链上和链下

合约里用枚举表示评审状态,状态变迁是单向的:待评审(0)→评审中(1)→已结束(2)。这个状态机的好处是杜绝了“评审结束后重新打分”的操作——合约会检查当前状态,只有处于评审中时才能提交评分。每次打分事件里记录评委地址和分数,最终汇总交给合约计算平均分,避免有人篡改MySQL里的score字段。因为评审专家被限制为只有管理员添加进白名单的地址,这个合约不需要实现复杂的权限判断,一个onlyOwner修饰符就可以覆盖大多数流转场景。

3. SpringBoot+Web3j落地:从Ganache到REST接口,一条链路走通

3.1 初始化工程:依赖只要三组,版本别追新

创建SpringBoot项目时,pom.xml里加入Web3j、Spring Web和MySQL驱动就够了。版本搭配上,我用的是SpringBoot 2.7.x配合Web3j 4.8.x,这个组合兼容Java 8和11,网上踩坑资料也最全。不要一上来就追Web3j 4.9以上,它对Java 17和SpringBoot 3做了调整,反而容易引入不必要的报错。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.12</version> </parent> <properties> <java.version>1.8</java.version> <web3j.version>4.8.8</web3j.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.web3j</groupId> <artifactId>core</artifactId> <version>${web3j.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>

Web3j的core依赖会带入okhttp作为HTTP客户端,它负责与Ganache的7545端口通信。SpringBoot的Parent管理了大部分依赖版本,但Web3j需要单独声明版本,否则Maven会给你拉一个非常旧的版本,旧版本的ABI解码方式和现在写的Solidity 0.8.x合约不兼容,这是第一个容易埋雷的地方。

3.2 编写ReviewSystem.sol:只留四个函数

合约我只实现了四个核心函数:提交项目、分配专家、提交评分、查询项目。项目登记做成管理员代提交的方式,因为毕设场景里往往是管理员统一录入项目信息,而不是让每个学生在链上自己注册钱包,这样也减少了前端对接的复杂度。

// SPDX-License-Identifier: MIT pragma solidity ^0.8.7; contract ReviewSystem { enum ReviewState { Pending, Reviewing, Closed } struct Project { string title; uint256 submitTime; ReviewState state; uint256 scoreSum; uint256 scoreCount; } address public owner; mapping(uint256 => Project) public projects; mapping(uint256 => mapping(address => bool)) public hasVoted; mapping(uint256 => address[]) private reviewers; event ProjectSubmitted(uint256 projectId, string title, uint256 submitTime); event ReviewerAssigned(uint256 projectId, address reviewer); event ScoreSubmitted(uint256 projectId, address reviewer, uint256 score); modifier onlyOwner() { require(msg.sender == owner, "not owner"); _; } constructor() { owner = msg.sender; } function submitProject(uint256 projectId, string calldata title) external onlyOwner { require(projects[projectId].submitTime == 0, "existing project"); projects[projectId] = Project({ title: title, submitTime: block.timestamp, state: ReviewState.Pending, scoreSum: 0, scoreCount: 0 }); emit ProjectSubmitted(projectId, title, block.timestamp); } function assignReviewer(uint256 projectId, address reviewer) external onlyOwner { Project storage p = projects[projectId]; require(p.state == ReviewState.Pending, "wrong state"); require(reviewer != address(0), "zero address"); reviewers[projectId].push(reviewer); p.state = ReviewState.Reviewing; emit ReviewerAssigned(projectId, reviewer); } function submitScore(uint256 projectId, uint256 score) external { require(score >= 0 && score <= 100, "invalid score"); Project storage p = projects[projectId]; require(p.state == ReviewState.Reviewing, "not reviewing"); require(!hasVoted[projectId][msg.sender], "already voted"); hasVoted[projectId][msg.sender] = true; p.scoreSum += score; p.scoreCount += 1; emit ScoreSubmitted(projectId, msg.sender, score); } function getProject(uint256 projectId) external view returns (string memory title, uint256 submitTime, uint256 state, uint256 avgScore, uint256 scoreCount) { Project storage p = projects[projectId]; uint256 avg = 0; if (p.scoreCount > 0) { avg = p.scoreSum / p.scoreCount; } return (p.title, p.submitTime, uint256(p.state), avg, p.scoreCount); } }

合约里有个值得注意的代码习惯:状态检查放在业务逻辑之前,所有可能失败的调用先require再改状态,这样能避免“校验失败但状态被污染”的中间态。参数方面,score限制在0到100之间,平均分用整数除法计算,答辩时可以说清楚“链上只存整数分数,统计精度由链下处理”,不必为了浮点数把合约做复杂。每个函数都抛出对应的事件,事件参数里的projectId和reviewer是后续做索引查询的关键。

3.3 编译、生成Java包装类:两条命令拉通

写好的sol文件需要编译成ABI和二进制。我用的是Solidity编译器的命令行版本:

# 安装编译器(macOS为例,具体以你的环境为准) npm install -g solc # 编译,输出ABI和bin solc --optimize --bin --abi ReviewSystem.sol -o build/contracts

编译成功后build/contracts目录下会有ReviewSystem.abi和ReviewSystem.bin两个文件。接下来用Web3j的命令行工具生成Java包装类,这一步会把合约的函数、事件、结构体映射成Java类型。

web3j generate solidity -b build/contracts/ReviewSystem.bin \ -a build/contracts/ReviewSystem.abi \ -o src/main/java \ -p com.demo.blockchain.contract

参数里-o是输出目录,-p是包名。常见的错误是路径分隔符在Windows下用了反斜杠,建议在项目根目录执行,并让build/contracts保持相对路径。生成后会出现一个ReviewSystem.java,里面包含部署方法、每个合约函数的Java封装,还有事件类。Web3j对Solidity struct的返回值处理比较特殊:getProject会变成一个包含多个字段的Java对象列表,下标依次对应返回值顺序,建议在服务层把它封装成DTO,别直接把数组暴露给前端。

3.4 合约部署工具类:把地址、网络ID、管理员私钥做成可配置项

部署合约最稳妥的方式不是手动操作,而是写一个CommandLineRunner启动任务,在SpringBoot启动时读取配置并自动部署。这样每次Ganache重置后,重新启动项目就能自动部署新合约并打印地址。

@Component public class ContractDeployRunner implements CommandLineRunner { @Value("${blockchain.ethereum.rpc-url}") private String rpcUrl; @Value("${blockchain.ethereum.private-key}") private String privateKey; @Override public void run(String... args) throws Exception { Web3j web3j = Web3j.build(new HttpService(rpcUrl)); Credentials credentials = Credentials.create(privateKey); ContractGasProvider gasProvider = new StaticGasProvider( BigInteger.valueOf(3000000L), BigInteger.valueOf(20000000000L) ); ReviewSystem contract = ReviewSystem.deploy( web3j, credentials, gasProvider).send(); System.out.println("contract address: " + contract.getContractAddress()); } }

静态Gas提供者的两个参数分别是Gas上限和Gas价格。Ganache默认Gas价格是20 Gwei,也就是20000000000,这是本地网络的默认值,写进配置即可。部署输出的地址必须记录到application.yml里,后面所有业务接口都依赖这个地址。这里我建议把地址打印和保存做成一步:部署成功后自动写入配置文件,而不是靠手工复制。手工复制是必然翻车的环节,后文避坑章会专门展开。

3.5 业务服务层:把合约调用变成REST接口,处理好链下索引

服务层的核心是把合约操作包成ReviewContractService,对外提供submitProject、assignReviewer、submitScore、getScoreSummary四个方法。以提交评分为例:

@Service public class ReviewContractService { private final Web3j web3j; private final ReviewSystem contract; private final String adminPrivateKey; public ReviewContractService( @Value("${blockchain.ethereum.rpc-url}") String rpcUrl, @Value("${blockchain.ethereum.contract-address}") String contractAddress, @Value("${blockchain.ethereum.private-key}") String adminPrivateKey) { this.web3j = Web3j.build(new HttpService(rpcUrl)); this.contract = ReviewSystem.load( contractAddress, web3j, Credentials.create(adminPrivateKey), new StaticGasProvider( BigInteger.valueOf(3000000L), BigInteger.valueOf(20000000000L))); this.adminPrivateKey = adminPrivateKey; } public TransactionReceipt submitScore(Long projectId, Integer score) { try { TransactionReceipt receipt = contract .submitScore(BigInteger.valueOf(projectId), BigInteger.valueOf(score)) .send(); if (!receipt.isStatusOK()) { throw new RuntimeException("chain transaction failed"); } return receipt; } catch (Exception e) { throw new RuntimeException("submitScore on chain error", e); } } }

加载合约时我用的ReviewSystem.load,它和部署的区别在于不会重新发布合约,只是创建本地代理对象,每次调用都会构造一个交易发到Ganache。TransactionReceipt里的isStatusOK()用来判断交易是否被链上接受,但这里有个容易误判的点:send()本身可能因为Gas不足直接抛异常,这时候要看异常类型是TransactionException还是RuntimeException,前者通常是链上执行回滚,后者才是网络或编码问题。服务层拿到回执后,要同步把txHash和blockNumber存进MySQL的chain_record表,这样评审记录既能在业务页面上展示,也能随时回溯到链上验证。

4. 最常见的5个坑:现象、原因、解决

4.1 致命问题:合约地址没更新,所有接口静默失败

一名开发者在演示前重新部署了合约,Ganache弹出了新地址,但application.yml里的contract-address还是旧值。启动项目后所有接口都返回“transaction failed”,而且日志里看不到合约级别的错误,因为load一个不存在的地址在Web3j里不算异常,交易发送后Ganache会因为合约地址无代码而执行回滚。

解决方法是把部署和配置更新做成一个动作。我的做法是写一个脚本:先启动Ganache,再运行一个仅部署用的测试类,部署成功后把地址直接写回application.yml,最后才启动SpringBoot主类。三个步骤固定顺序,谁也不能跳过。

4.2 中文乱码:合约返回值在页面里变成问号

Solidity的string本质是UTF-8编码的动态字节数组,Web3j解析后会交给Java的String对象,本身不会乱码。真正导致乱码的是SpringBoot默认的HTTP响应编码和数据库连接串没指定characterEncoding=utf-8。现象是合约查出来的标题正常,但从MySQL取出来的材料描述乱码,两者混在一起就显得“区块链有问题”。

原因在链路的不同节点:Web3j返回的String已经是正确的Java Unicode字符串,问题出在SpringBoot的server.servlet.encoding配置和JDBC URL参数。解决方法是三处同时设置:application.yml里配置UTF-8编码,JDBC连接串加上useUnicode=true&characterEncoding=utf8,前端页面通过<meta charset="utf-8">声明编码。这样排查有个顺序:先确认合约查询接口返回的JSON是否正常,再检查后续环节,不要在合约里做十六进制编码的过度设计。

4.3 nonce太低:重启Ganache后连续报错

Ganache每次重置,链上账户的交易计数会清零。但如果SpringBoot进程没有重启,Web3j内部会缓存上一轮会话的nonce,再次发送交易时nonce比链上状态高,Ganache直接返回“nonce too low”。现象是重启网络后的第一笔交易必失败,第二笔偶尔成功,之后完全错乱。

解决方法是每次发送交易前显式获取最新nonce:

EthGetTransactionCount count = web3j .ethGetTransactionCount(credentials.getAddress(), DefaultBlockParameterName.PENDING) .send(); BigInteger nonce = count.getTransactionCount();

4.4 Gas预估不足:界面显示失败,交易却已经上链

合约里遍历评委列表或存储较长字符串时,实际消耗的Gas可能超过手动设置的300万上限。Web3j抛出的TransactionException会让接口报错,但Ganache里已经产生了交易记录,状态是0x0,导致数据库里没有索引记录,链上却有失败的痕迹。

解决方式是用ethEstimateGas先预估,再乘以安全系数作为GasLimit:

EthEstimateGas estimate = web3j .ethEstimateGas(transaction) .send(); BigInteger gasLimit = estimate .getAmountUsed() .multiply(BigInteger.valueOf(2L));

这也是为什么我在部署工具类里用StaticGasProvider而不是写死在合约代码里的原因——GasLimit应该可以在配置文件中调整,而不是每次改合约都要重新编译。

4.5 事件监听错位:服务重启后丢历史事件

很多资料里用ethSubscribe做实时监听,但订阅模式的限制是它只接收订阅之后产生的事件。开发中重启SpringBoot是常态,重启前已经上链的评分事件会丢失,前端统计列表和合约里的实际数据不一致。最常见的翻车场景是:专家投完分后服务重启,页面评分记录不显示,但合约平均分是包含这一票的。

解决方法是改用getPastEvents从指定区块重新扫描:

List<ReviewSystem.ScoreSubmittedEventResponse> events = contract.getScoreSubmittedEvents( new DefaultBlockParameterNumber(0L), new DefaultBlockParameterName("latest") );

首次运行时扫描全部区块,之后每次启动记录上次处理的blockNumber,从那个区块继续增量扫描。这个方案不需要常驻监听进程,还能在服务启动时用链上事件反推数据库遗漏的记录。

5. 答辩验收时怎么自证“数据没被篡改”:一台笔记本做闭环验证

毕业设计答辩只有十几分钟,没法带评委去跑一遍完整区块链浏览器链。我建议把“不可篡改”这个抽象概念转成一个可演示的对照实验,全程只需要一台笔记本和Ganache界面。

实验步骤分为三步。第一步,在MySQL里直接改掉score表的数据,比如把一个80分改成95分。第二步,调用系统的“链上校验”接口,新接口会查一次合约getProject并把链上平均分拉出来。第三步,Ganache界面不用关,直接把评分交易的历史记录翻给评委看。对比结果一目了然:数据库里的分数可以随便改,合约里的分数纹丝不动,因为改动数据库不会产生新的交易,链上状态根本不会跟着变。

为了让对照更直观,我建议在服务层加一个chain-check接口,返回三个字段:数据库分值、链上分值、是否一致。这个接口用到的校验逻辑很简单,就是拿MySQL的score_sum和score_count除以后与getProject对比:

@GetMapping("/chain-check/{projectId}") public Map<String, Object> check(@PathVariable Long projectId) { List<ProjectRecord> records = projectRecordMapper.findByProjectId(projectId); BigInteger[] project = contract.getProject(BigInteger.valueOf(projectId)).send(); long dbAvg = records.stream() .mapToLong(ProjectRecord::getScore) .average() .orElse(0); long chainAvg = project[3].longValue(); Map<String, Object> result = new HashMap<>(); result.put("dbAvg", dbAvg); result.put("chainAvg", chainAvg); result.put("consistent", dbAvg == chainAvg); return result; }

这个接口本身不需要多复杂,但很适合作为答辩演示的“钩子”:评委追问“你凭什么说链上不可篡改”时,现场改动数据库再刷新这个接口,比任何口头解释都有说服力。如果时间充裕,还有一个可选的加分项——定期对账任务。每天晚上扫描一次所有已结束评审的项目,把数据库汇总与链上平均分做比对,不一致就发告警。这个设计虽然不产生新的区块链能力,但它展示了生产环境里“链上真实性校验”的常见用法。

我自己的习惯是在答辩前跑一遍自检脚本:先重置Ganache,再部署新合约,然后把日志头部的“网络ID、合约地址、管理员地址”三行截图留档。有一回我跳过了这个步骤,现场演示时发现配置文件里还是旧合约地址,整场Demo全部打了水漂。从那以后我再也不敢省略这三行核对。坦诚说,这个题目的工作量不算小,但它把“区块链”从一个抽象名词变成了可以亲手验证的系统,做完以后你对Web3j和交易生命周期会建立非常具体的直觉。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 12:39:30

C# WinForm对接问财接口:v值计算、抓包与JSON解析全攻略

简介&#xff1a;一套基于C# Winform实现问财数据获取的完整源码&#xff0c;面向金融数据抓取与桌面程序开发爱好者&#xff0c;解决同花顺问财接口接入、V值获取及数据展示过滤等实际问题。资源共812个文件&#xff0c;压缩包约60MB&#xff0c;除核心的cs源码与项目配置外&a…

作者头像 李华
网站建设 2026/10/18 12:36:55

构建原生视觉生成智能体:感知-反馈-修正闭环实战拆解

构建原生视觉生成智能体&#xff1a;从模型到系统的实战拆解先抛出最近折腾了快两个月的一个方向——“原生视觉生成智能体”。这个词乍听有点拗口&#xff0c;说白了就是让AI不再只是“你说一句话&#xff0c;它给你画张图”的呆板工具&#xff0c;而是把视觉生成能力直接嵌进…

作者头像 李华
网站建设 2026/10/18 12:35:49

Java黄金矿工:Swing双缓冲与2D物理交互实战

简介&#xff1a;本资源是基于Java语言实现的经典休闲游戏“黄金矿工”的完整可运行项目&#xff0c;面向Java初学者与GUI编程进阶学习者&#xff0c;旨在通过实战项目巩固面向对象设计、Swing图形界面开发、事件驱动机制及基础游戏逻辑等核心能力。压缩包共30个文件&#xff0…

作者头像 李华
网站建设 2026/10/18 12:34:33

心死道生:停止精神内耗,在松弛中重建自我价值

1. 老王的变化&#xff1a;身边人最先察觉的事老王在朋友圈消失了大半年&#xff0c;再出现时&#xff0c;发的第一条动态是一张山间石阶的照片&#xff0c;配文只有两个字&#xff1a;还好。底下的评论炸了锅。有人问他是被盗号了&#xff0c;有人说这是中年人的无病呻吟&…

作者头像 李华
网站建设 2026/10/18 12:34:21

若依Vue3 Webpack版实战指南:从环境搭建到部署优化

简介&#xff1a;这是基于 Vue 3 与 Webpack&#xff08;非 Vite&#xff09;构建的若依后台管理前端项目源码包&#xff0c;适合希望快速搭建中后台界面、或想深入学习 Vue3 工程化实践的前端开发者。压缩包内共 2004 个文件&#xff0c;其中 js 文件 966 个、md 文档 975 个&…

作者头像 李华
网站建设 2026/10/18 12:32:36

Notepad++ 安装配置与进阶技巧:从下载校验到宏和正则实战

简介&#xff1a;Notepad安装包面向Windows平台下需要轻量级文本与代码编辑工具的程序员、运维人员及普通用户&#xff0c;用于替代系统自带记事本&#xff0c;解决日常文本处理、代码编写与调试中的效率问题。压缩包共104个文件&#xff0c;约3.9MB&#xff0c;以89个xml配置文…

作者头像 李华