简介:面向计算机相关专业毕业设计场景,这份资源提供了基于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 | 专家地址作为唯一身份 |
| 原始材料 | MySQL | projectId、fileUrl、description | 只存业务信息,不上链 |
| 链上索引 | MySQL | projectId、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和交易生命周期会建立非常具体的直觉。希望帮到你。
本文还有配套的精品资源,点击获取