基于 FISCO BCOS 的区块链存证系统实战:从智能合约到全栈集成
本文详细介绍如何在一个已有的 Spring Boot + Nuxt.js 项目中,从零集成 FISCO BCOS 联盟链,实现完整的区块链存证系统。涵盖智能合约设计、Java SDK 对接、前后端联调,以及踩坑全记录。
目录
一、背景与需求
在招商引平台项目中,需要一个区块链存证在线体验区,让用户可以直观体验区块链的核心能力:
| 功能 | 说明 |
|---|---|
| 文本存证 | 将文本内容上链,返回存证ID和交易哈希 |
| 文件存证 | 计算文件哈希值上链,不存储原始文件 |
| 加密存证 | 内容加密后上链,解密时需密码验证 |
| 存证核验 | 输入原始内容与链上数据比对,验证一致性 |
| 解密查看 | 自动检测加密存证,输入密码解密查看原文 |
| 记录查询 | 按发送者地址查询所有存证记录列表 |
二、技术选型
| 组件 | 版本/选型 | 说明 |
|---|---|---|
| 区块链平台 | FISCO BCOS 3.x | 国产开源联盟链,金融级性能 |
| Java SDK | fisco-bcos-java-sdk 3.3.0 | 官方 Java SDK |
| 智能合约 | Solidity ^0.8.0 | 标准 Solidity 语法 |
| 后端框架 | Spring Boot 2.7.18 + 芋道脚手架 | 已有项目框架 |
| 前端框架 | Nuxt.js 3 (SSR) + TailwindCSS | 门户前端 |
| 加密方式 | 前端 XOR + Base64 | 简易对称加密(生产建议用 AES) |
三、系统架构
┌─────────────────────────────────────────────────────┐
│ Nuxt.js 前端 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ 文本存证 │ │ 文件存证 │ │ 存证核验 │ │ 记录 │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └───┬────┘ │
│ └────────────┼────────────┼────────────┘ │
│ ▼ HTTP API │
├─────────────────────────────────────────────────────┤
│ Spring Boot 后端 │
│ ┌───────────────────────────────────────────────┐ │
│ │ AppBlockchainEvidenceController │ │
│ │ POST /create POST /verify GET /get │ │
│ │ GET /records GET /exists GET /sender/* │ │
│ └───────────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────┐ │
│ │ BlockchainEvidenceService │ │
│ │ AssembleTransactionProcessor │ │
│ │ sendTransactionAndGetResponse (写入) │ │
│ │ sendCall (读取) │ │
│ └───────────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────────────────────────────┐ │
│ │ FiscoBcosSdkConfig │ FiscoBcosProperties │ │
│ │ Client (TLS 双向认证) │ 合约 ABI/BIN 加载 │ │
│ └───────────────────┬───────────────────────────┘ │
├──────────────────────┼──────────────────────────────┤
│ ▼ Channel │
│ ┌───────────────────────────────────────────────┐ │
│ │ FISCO BCOS 区块链节点 │ │
│ │ EvidenceContract │ EvidenceQueryContract │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
四、智能合约设计
4.1 核心合约 - EvidenceContract
合约设计了两种存证类型(文本/文件),每种支持加密和非加密模式:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract EvidenceContract {
// 数据结构
struct TextEvidence {
bytes32 id;
address sender;
string content; // 明文内容(非加密时)
string encryptedContent; // 加密内容(加密时)
bool isEncrypted;
uint256 timestamp;
}
struct FileEvidence {
bytes32 id;
address sender;
string fileName;
string fileHash; // 文件哈希(非加密时)
string fileType;
uint256 fileSize;
string encryptedFileHash; // 加密文件哈希(加密时)
bool isEncrypted;
uint256 timestamp;
}
// 存储映射(使用 internal 支持子合约继承)
mapping(bytes32 => TextEvidence) internal textEvidences;
mapping(bytes32 => FileEvidence) internal fileEvidences;
mapping(address => bytes32[]) internal senderTextIds;
mapping(address => bytes32[]) internal senderFileIds;
// 创建文本存证
function createTextEvidence(string memory content) public returns (bytes32) {
bytes32 id = keccak256(abi.encodePacked(msg.sender, content, block.timestamp));
textEvidences[id] = TextEvidence(id, msg.sender, content, "", false, block.timestamp);
senderTextIds[msg.sender].push(id);
emit TextEvidenceCreated(id, msg.sender, false, block.timestamp);
return id;
}
// 创建加密文本存证
function createEncryptedTextEvidence(string memory encryptedContent) public returns (bytes32) {
bytes32 id = keccak256(abi.encodePacked(msg.sender, encryptedContent, block.timestamp));
textEvidences[id] = TextEvidence(id, msg.sender, "", encryptedContent, true, block.timestamp);
senderTextIds[msg.sender].push(id);
emit TextEvidenceCreated(id, msg.sender, true, block.timestamp);
return id;
}
// 核验文本存证
function verifyTextEvidence(bytes32 id, string memory content) public view returns (bool) {
if (textEvidences[id].id == bytes32(0)) return false;
return keccak256(bytes(textEvidences[id].content)) == keccak256(bytes(content));
}
// ... 文件存证、查询等方法类似
}
4.2 合约接口一览
| 函数 | 类型 | 说明 |
|---|---|---|
createTextEvidence |
写入 | 创建文本存证 |
createEncryptedTextEvidence |
写入 | 创建加密文本存证 |
createFileEvidence |
写入 | 创建文件存证 |
createEncryptedFileEvidence |
写入 | 创建加密文件存证 |
getTextEvidence |
只读 | 查询文本存证详情 |
getFileEvidence |
只读 | 查询文件存证详情 |
verifyTextEvidence |
只读 | 核验文本存证 |
verifyFileEvidence |
只读 | 核验文件存证 |
getSenderTextIds |
只读 | 查询发送者文本存证ID列表 |
getSenderFileIds |
只读 | 查询发送者文件存证ID列表 |
evidenceExists |
只读 | 检查存证是否存在 |
4.3 事件设计
event TextEvidenceCreated(bytes32 indexed id, address indexed sender, bool isEncrypted, uint256 timestamp);
event FileEvidenceCreated(bytes32 indexed id, address indexed sender, string fileName, bool isEncrypted, uint256 timestamp);
event EvidenceVerified(bytes32 indexed id, bool isValid, uint256 timestamp);
五、后端集成
5.1 Maven 依赖
<dependency>
<groupId>org.fisco-bcos.java-sdk</groupId>
<artifactId>fisco-bcos-java-sdk</artifactId>
<version>3.3.0</version>
</dependency>
5.2 YAML 配置
fisco-bcos:
enabled: true
network:
peers:
- "<节点IP>:<端口>"
crypto-material:
cert-path: conf
system:
group-id: group0
hex-private-key: "你的私钥"
contract:
evidence-contract-address: "0x合约地址"
evidence-query-address: "0x查询合约地址"
⚠️ 重要:YAML 中所有
0x开头的值必须用引号包裹,否则 YAML 会将其解析为十六进制整数!
5.3 SDK 客户端初始化
@Configuration
@ConditionalOnProperty(name = "fisco-bcos.enabled", havingValue = "true")
public class FiscoBcosSdkConfig {
@Bean
@Lazy
public Client fiscoBcosClient() {
try {
ConfigProperty configProperty = new ConfigProperty();
// 网络配置
Map<String, Object> networkMap = new HashMap<>();
networkMap.put("peers", Arrays.asList("<节点IP>:<端口>"));
configProperty.setNetwork(networkMap);
// 证书路径(SDK 自动搜索 classpath 下的 conf 目录)
Map<String, Object> cryptoMaterialMap = new HashMap<>();
cryptoMaterialMap.put("certPath", "conf");
configProperty.setCryptoMaterial(cryptoMaterialMap);
ConfigOption configOption = new ConfigOption(configProperty);
Client client = new BcosSDK(configOption).getClient("group0");
// 配置密钥对
String hexPrivateKey = "你的私钥";
CryptoKeyPair keyPair = client.getCryptoSuite()
.getCryptoKeyPair();
keyPair.setPrivateKey(
Numeric.toBytes32(Numeric.hexStringToByteArray(hexPrivateKey))
);
log.info("FISCO BCOS 连接成功, 当前区块高度: {}",
client.getBlockNumber().getBlockNumber().intValue());
return client;
} catch (Exception e) {
log.error("FISCO BCOS 初始化失败: {}", e.getMessage());
return null; // 返回 null 而非抛异常,不影响主应用启动
}
}
}
关键设计决策:
@Lazy— 延迟初始化,防止区块链连接失败导致整个应用无法启动@ConditionalOnProperty— 通过配置开关控制是否启用区块链功能- 异常返回 null — 服务层通过
isAvailable()判断可用性
5.4 服务层 - 合约调用
@Service
public class BlockchainEvidenceService {
@Resource
private Client fiscoBcosClient;
@Resource
private FiscoBcosProperties fiscoBcosProperties;
private AssembleTransactionProcessor txProcessor;
private String contractAddress;
private boolean available = false;
@PostConstruct
public void init() {
if (fiscoBcosClient == null) return;
this.txProcessor = TransactionProcessorFactory
.createAssembleTransactionProcessor(
fiscoBcosClient,
fiscoBcosClient.getCryptoSuite().getCryptoKeyPair()
);
this.contractAddress = fiscoBcosProperties
.getContract().getEvidenceContractAddress();
this.available = true;
}
// 写入操作(上链)
public TransactionResponse createTextEvidence(String content) throws Exception {
return txProcessor.sendTransactionAndGetResponse(
contractAddress,
BlockchainContractConstants.EvidenceContractAbi,
"createTextEvidence",
Arrays.asList(content)
);
}
// 读取操作(不上链)
public CallResponse getTextEvidence(byte[] id) throws Exception {
return txProcessor.sendCall(
fiscoBcosClient.getCryptoSuite().getCryptoKeyPair().getAddress(),
contractAddress,
BlockchainContractConstants.EvidenceContractAbi,
"getTextEvidence",
Arrays.asList((Object) id)
);
}
public String getSenderAddress() {
return fiscoBcosClient.getCryptoSuite().getCryptoKeyPair().getAddress();
}
public boolean isAvailable() { return available; }
}
SDK 3.3.0 注意事项:
- 写入操作用
sendTransactionAndGetResponse,返回TransactionResponse- 读取操作用
sendCall,返回CallResponse- 获取交易哈希用
response.getTransactionReceipt().getTransactionHash()(不是getTransactionHash())- 返回值通过
response.getReturnObject()获取List<Object>
5.5 Controller 层 - 数据转换
合约返回的 getReturnObject() 是一个 List<Object>(按索引访问),需要转换为带字段名的 Map 方便前端使用:
@GetMapping("/get")
public CommonResult<Map<String, Object>> getEvidence(
@RequestParam("evidenceId") String evidenceId,
@RequestParam("type") String type) {
byte[] id = hexToBytes(evidenceId);
CallResponse response = "text".equals(type)
? blockchainEvidenceService.getTextEvidence(id)
: blockchainEvidenceService.getFileEvidence(id);
List<Object> ret = response.getReturnObject();
Map<String, Object> result = new LinkedHashMap<>();
if ("text".equals(type) && ret != null && ret.size() >= 6) {
result.put("evidenceId", ret.get(0));
result.put("sender", String.valueOf(ret.get(1)));
result.put("content", String.valueOf(ret.get(2)));
result.put("encryptedContent", String.valueOf(ret.get(3)));
result.put("isEncrypted", ret.get(4));
result.put("timestamp", String.valueOf(ret.get(5)));
}
// ... file 类型类似,共9个字段
return success(result);
}
存证ID 编码问题:
Solidity 的 bytes32 被 SDK 序列化为 byte[],Jackson 会将其 Base64 编码。需要在 Controller 中做 hex 转换:
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b & 0xFF));
}
return sb.toString();
}
private static byte[] hexToBytes(String hex) {
byte[] bytes = new byte[hex.length() / 2];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16);
}
return bytes;
}
5.6 记录列表接口
通过 getSenderTextIds / getSenderFileIds 获取 ID 列表,再逐个查询详情:
@GetMapping("/records")
public CommonResult<Map<String, Object>> getRecords(@RequestParam("type") String type) {
String sender = blockchainEvidenceService.getSenderAddress();
CallResponse idsResp = blockchainEvidenceService.getSenderTextIds(sender);
List<Object> ids = (List<Object>) idsResp.getReturnObject().get(0);
List<Map<String, Object>> records = new ArrayList<>();
for (Object idObj : ids) {
byte[] idBytes = (byte[]) idObj;
String eidHex = bytesToHex(idBytes);
CallResponse detail = blockchainEvidenceService.getTextEvidence(idBytes);
// ... 组装详情 Map
records.add(item);
}
return success(Map.of("total", records.size(), "records", records));
}
六、前端实现
6.1 页面结构
采用顶部导航 + 全宽内容区的上下布局:
┌─────────────────────────────────────────────┐
│ [ 文本存证 ] [ 文件存证 ] │ ← 一级Tab
├─────────────────────────────────────────────┤
│ 文本存证 [上链] [核验] [记录] │ ← 标题 + 二级Tab
│─────────────────────────────────────────────│
│ │
│ 面板内容区 │
│ │
│ ─── 状态栏 ─────────────────────────────── │
└─────────────────────────────────────────────┘
共 6 个面板:文本(上链/核验/记录) + 文件(上链/核验/记录)。
6.2 加密存证 - 前端加密
使用 XOR 加密 + Base64 编码,加密时在原文前拼接验证标记 \x00OK\x01,解密时校验标记来判断密码是否正确:
const _VERIFY_PREFIX = '\x00OK\x01'
const simpleEncrypt = (text, password) => {
const prefixed = _VERIFY_PREFIX + text
let result = ''
for (let i = 0; i < prefixed.length; i++) {
result += String.fromCharCode(
prefixed.charCodeAt(i) ^ password.charCodeAt(i % password.length)
)
}
return btoa(unescape(encodeURIComponent(result)))
}
const simpleDecrypt = (encrypted, password) => {
try {
const decoded = decodeURIComponent(escape(atob(encrypted)))
let result = ''
for (let i = 0; i < decoded.length; i++) {
result += String.fromCharCode(
decoded.charCodeAt(i) ^ password.charCodeAt(i % password.length)
)
}
if (!result.startsWith(_VERIFY_PREFIX)) return null // 密码错误
return result.slice(_VERIFY_PREFIX.length)
} catch { return null }
}
6.3 存证核验 - 自动检测加密
核验流程不需要用户手动选择是否加密,系统自动判断:
const handleVerify = async () => {
// 1. 先查链上数据
const res = await $http.get('/app-api/.../get', {
params: { evidenceId, type: activeMode }
})
const data = res.data
// 2. 自动检测是否加密
if (data.isEncrypted) {
verifyEncryptedDetected.value = true // 显示密码输入框
verifyChainData.value = data // 缓存链上数据
return
}
// 3. 非加密 → 走普通核验流程
const verifyRes = await $http.post('/app-api/.../verify', payload)
verifyResult.value = !!verifyRes.data.valid
}
6.4 文件存证 - SHA-256 哈希
文件不上链,只计算哈希值上链:
const computeFileHash = async (file) => {
const buffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
七、踩坑记录
7.1 YAML 0x 前缀被解析为十六进制
问题:合约地址 0x1234...abcd 被 YAML 解析为十进制大数。
解决:所有 0x 开头的值必须用引号包裹:
contract:
evidence-contract-address: "0x你的合约地址" # ✅
# evidence-contract-address: 0x你的合约地址 # ❌
7.2 bytes32 序列化为 Base64
问题:SDK 返回的 byte[] 被 Jackson 默认序列化为 Base64 字符串,前端收到的 evidenceId 是乱码。
解决:在 Controller 中将 byte[] 转换为 hex 字符串再返回。
7.3 getTransactionHash() 方法不存在
问题:SDK 3.3.0 的 TransactionResponse 上没有 getTransactionHash() 方法。
解决:通过 receipt 获取:
response.getTransactionReceipt().getTransactionHash()
7.4 证书路径 classpath: 不识别
问题:FISCO BCOS SDK 不理解 Spring 的 classpath: 前缀。
解决:直接使用 conf,SDK 会自动在 classpath 下搜索。
7.5 returnCode: 19 合约调用失败
问题:合约地址被读取为十进制大数(因 YAML hex 解析问题)。
解决:参见 7.1,给合约地址加引号。
7.6 WebSocket 握手超时
问题:使用了错误链的证书(本地链证书连远程节点)。
解决:使用与远程节点匹配的证书(ca.crt、sdk.crt、sdk.key),放在 src/main/resources/conf/ 目录。
7.7 区块链不可用导致应用无法启动
问题:区块链节点不可用时,Client 初始化失败导致整个 Spring Boot 应用无法启动。
解决:
@Lazy延迟初始化 Client Bean@ConditionalOnProperty配置开关- 异常时返回
null,服务层通过isAvailable()判断
7.8 peers=[] 空列表
问题:Spring Boot YAML 嵌套 Map + List 绑定失败,导致 peers 为空。
解决:参考其他项目的做法,在 Java 代码中直接硬编码 peers。
八、总结
项目文件结构
后端(Spring Boot)
├── config/blockchain/
│ ├── FiscoBcosProperties.java # YAML 配置映射
│ ├── FiscoBcosSdkConfig.java # SDK 客户端(@Lazy + @Conditional)
│ └── BlockchainContractConstants.java # ABI/BIN 加载
├── service/blockchain/
│ └── BlockchainEvidenceService.java # 合约调用封装
└── controller/app/blockchain/
├── AppBlockchainEvidenceController.java # REST API
└── vo/ # 请求/响应 VO
前端(Nuxt.js)
└── pages/BaaS/index.vue # 存证体验区完整页面
资源文件
└── resources/
├── application-local.yaml # 区块链配置
├── blockchain/abi/ # 合约 ABI
├── blockchain/bin/ # 合约 BIN(ECC + 国密SM)
└── conf/ # TLS 证书
关键经验
- FISCO BCOS SDK 3.x 与 Spring Boot 集成需要注意 Bean 生命周期管理,
@Lazy是关键 - 合约返回值是
List<Object>,需要手动映射为有意义的字段名 bytes32类型在 Java 中是byte[],序列化/反序列化需要 hex 转换- 前端加密使用验证前缀标记来区分密码对错,避免解密出随机值
- YAML 中的 hex 值一定要加引号,这是最容易忽略的坑
如果觉得有帮助,欢迎点赞收藏!有问题欢迎评论区交流。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)