关注

基于 FISCO BCOS 的区块链存证系统实战:从智能合约到全栈集成

最近项目里需要做一个区块链存证的在线体验模块,踩了不少坑,记录一下整个落地过程,也给后面要做类似功能的朋友一个参考。


目录


一、背景与需求

在招商引平台项目中,需要加一个区块链存证在线体验区,让用户能直观感受区块链能干什么。简单说就是下面这几个功能:

功能说明
文本存证将文本内容上链,返回存证ID和交易哈希
文件存证计算文件哈希值上链,不存储原始文件
加密存证内容加密后上链,解密时需密码验证
存证核验输入原始内容与链上数据比对,验证一致性
解密查看自动检测加密存证,输入密码解密查看原文
记录查询按发送者地址查询所有存证记录列表

二、技术选型

组件版本/选型说明
区块链平台FISCO BCOS 3.x国产开源联盟链,金融级性能
Java SDKfisco-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 而非抛异常,不影响主应用启动
        }
    }
}

这里有几个设计上的考量值得说一说:

  1. @Lazy — 延迟初始化,防止区块链连接失败导致整个应用无法启动
  2. @ConditionalOnProperty — 通过配置开关控制是否启用区块链功能
  3. 异常返回 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 哈希

文件本身不上链,只算个 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 合约调用失败

合约地址被读成了十进制大数,根因还是 7.1 那个 YAML hex 解析的问题。给合约地址加上引号就好了。

7.6 WebSocket 握手超时

这个坑比较隐蔽——用了本地链的证书去连远程节点,TLS 握手直接失败。换成跟远程节点匹配的证书(ca.crtsdk.crtsdk.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 配置死活读不到。最后放弃了 YAML 绑定,直接在 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 证书

关键经验

  1. FISCO BCOS SDK 3.x 与 Spring Boot 集成,Bean 生命周期管理是重中之重,@Lazy 基本是必选项
  2. 合约返回值是个 List<Object>,得手动映射成有意义的字段名,这块代码写起来比较啰嗦但没捷径
  3. bytes32 类型在 Java 里是 byte[],序列化/反序列化的 hex 转换别忘了做,不然前端拿到的是一串 Base64 乱码
  4. 前端加密用验证前缀标记来区分密码对错,这个思路还算实用,避免了解密出随机值的问题
  5. YAML 中的 hex 值一定要加引号,这是最容易忽略的坑

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/willim123/article/details/161082432

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--