本文详细介绍如何在一个已有的 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 而非抛异常,不影响主应用启动
        }
    }
}

关键设计决策:

  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 哈希

文件不上链,只计算哈希值上链:

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.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 为空。

解决:参考其他项目的做法,在 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 转换
  4. 前端加密使用验证前缀标记来区分密码对错,避免解密出随机值
  5. YAML 中的 hex 值一定要加引号,这是最容易忽略的坑

如果觉得有帮助,欢迎点赞收藏!有问题欢迎评论区交流。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐