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


目录


一、背景与需求

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

功能 说明
文本存证 将文本内容上链,返回存证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 哈希

文件本身不上链,只算个 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 值一定要加引号,这是最容易忽略的坑
Logo

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

更多推荐