简介:一套基于Hyperledger Fabric的农产品溯源平台项目压缩包,包含区块链网络、小程序端、PC管理端和基础数据后台四大模块,覆盖从Fabric链上数据存储到前后端业务交互的完整链路,适合想学习区块链项目落地的开发者参考。压缩包共1371个文件,大小18.15MB,主要文件类型包括Java后端源码、Vue/JS前端代码、小程序WXML/WXSS页面、Golang智能合约、Docker部署脚本,以及pem/key证书、SQL初始化脚本、YAML配置和cryptogen等Fabric工具生成物,目录结构清晰,便于按模块查找复用。目前已有259人学习。项目以简单的数据上链操作为核心,完整展示了Fabric 1.2网络搭建、Go链码编写、SpringBoot与Mybatis集成、FastDFS文件存储的端到端实现;且采用solo共识与单orderer节点,尤其适合初学者快速启动网络并理解整体流程。作者还针对节点动态伸缩、信誉奖惩、上链粒度等产品问题给出了延伸思考,对希望从技术转产品视角、或在溯源场景中做二次开发的读者很有参考价值。
1. 基于 Fabric 的农产品溯源平台:这套资源能帮你把「从田间到餐桌」真正上链
做农产品溯源最容易翻车的地方,恰恰不在扫码页,而在「链下数据到底是真还是假」。很多项目方搭了个区块链,结果产地信息、检测报告、物流节点全靠人工录入,链上只是把 Excel 搬了个家,消费者扫出来依然是一串没人敢信的字。基于 Fabric 的农产品溯源平台,解决的正是这个问题:用联盟链把生产、加工、仓储、物流、销售这几个参与方的写入权限分开,每种数据只能由对应角色签名上链,任何一方都改不了别人写过的记录。它不是一条公链,而是一条「允许验证、不允许篡改、严格控制写入身份」的业务链。这套资源适合两类人:一类是手里有真实溯源需求、正在选型的企业开发,另一类是刚学完 Fabric 基本概念、想找一个完整项目照着搭的工程师。接下来我按架构、链码、应用层、运维和进阶这几个层面,把整套平台拆开讲。
2. Fabric 溯源平台的整体架构:通道划分、组织身份与数据模型怎么搭
2.1 为什么溯源项目普遍选 Fabric 而不是公链或国产联盟链
做农产品溯源,数据要面对的是监管机构、采购商和普通消费者,而不是让全世界任何人都能写入。以太坊这类公链的问题在于:谁都能调合约,写入身份无法和真实企业绑定,gas 费用和出块时间也让高频的物流节点写入变得不可控。FISCO BCOS 在很多政务项目里很常见,但生态里跟 Hyperledger 相关的教程、SDK 和运维资料明显更厚,出了问题好查。Fabric 最核心的三个特性恰好命中溯源场景:第一个是通道(Channel),可以把同一套网络里的不同业务隔离;第二个是背书策略(Endorsement Policy),能规定某条数据必须由哪几个组织共同签名才能生效;第三个是状态数据库可选 CouchDB,让溯源查询不只能按 key 查,还能按日期、产地、品类做富查询。执行方式上,Fabric 是 execute-order-validate 模型,交易先由背书节点模拟执行,再排序、验证、写入区块,比「先共识再执行」的模式更适合需要细粒度权限控制的业务。
从实际选型角度看,农产品溯源还有一个隐藏需求:监管机构需要能随时审计,但普通消费者只需要只读验证。Fabric 的 MSP(Membership Service Provider)体系天然支持这种不对称权限——监管方作为组织加入网络,拥有只读链码权限;消费者根本不进网络,只通过二维码页面调链码查询接口。这一点是公链完全做不到的。如果你在做一个面向多企业的溯源平台,Fabric 基本是成本最低、社区问答最全的路线。
2.2 溯源数据模型:批次、流转记录与检测报告的三种数据结构
我把溯源数据拆成三类,每一类对应一种链码状态,互不混用。第一类是批次信息(BatchInfo),一个批次就是一车菜、一棚果或者一批加工好的净菜,它是一条数据的「根」;第二类是流转记录(TraceRecord),从采摘、装车、入库、出库到门店签收,每个动作都是一条带时间戳和操作人身份的事件;第三类是检测报告(InspectionReport),包括农残检测、重金属检测、合格证编号,报告原文太大不能直接上链,链上只存哈希和摘要。
这三类数据用 JSON 存在 Fabric 世界里,关键的字段设计如下:
| 数据结构 | 关键字段 | 说明 |
|---|---|---|
| BatchInfo | batch_id, product, origin, farmer_org, produce_at, status | 每个批次一条,status 控制流转状态 |
| TraceRecord | trace_id, batch_id, operator_org, operation, location, timestamp, remark | operator_org 必须是背书组织之一 |
| InspectionReport | report_id, batch_id, report_hash, summary, inspector_org, inspected_at | 原文存链下,链上只存哈希 |
批次 ID 我一般不用自增数字,而是用「产地代码 + 日期 + 随机数」拼出来,比如SD-JN-20240612-7F3K。这样做有两个原因:一是二维码和追溯码可以直接用它,不用再另外维护映射表;二是链上查询按前缀扫 CouchDB 时更快,不需要全量遍历。流转记录的 operator_org 字段非常关键,它必须对应 Fabric 网络里真实存在的组织,否则链路审计时没法定位责任人。状态字段建议用固定枚举:created、in_transit、warehoused、out_of_stock、sold。不要在链码里把状态写成自由文本,否则后面做统计分析时会被脏数据折磨。
2.3 组织与通道设计:生产、加工、物流、监管四方怎么连
一个典型的中小型溯源项目,我会建议至少四个组织:ProducerMSP 代表种植基地或合作社,ProcessorMSP 代表加工厂,LogisticsMSP 代表冷链物流公司,RegulatorMSP 代表监管机构。销售门店如果接入成本太高,可以先不设组织,让门店用 ProcessorMSP 的账号代为录入。通道上,我一般只建一个业务通道 appchannel,把上面四个组织都拉进去,所有溯源链码跑在同一个通道里。不要急着按组织拆多个通道,因为溯源查询往往要跨组织合并数据,通道拆太碎,链码没法跨通道查状态,反而要找 channel 之外的中间件做数据拼接,得不偿失。
Fabric 2.x 的通道配置里,有几个参数必须改,否则后面跑起来很被动。Orderer 的BatchTimeout默认 2 秒,如果物流节点高频写入,建议改成 1 秒,让区块出得更快,消费者扫码时能看到更接近实时的记录;MaxMessageCount默认 500,不必动,写满 500 笔才出块的话延迟太久,配合 BatchTimeout 一起看就行。每个组织至少两个 peer,一个在默认域名下对外服务,一个做锚节点(Anchor Peer),组织间跨链通信全靠锚节点。锚节点没配好,最常见的症状是链码背书时一直超时,peer 日志里出现gossip相关报错。
3. 溯源链码开发:以 Go 链码为例的写入、查询与背书配置
3.1 链码核心逻辑:批次创建与流转记录写入
链码我习惯用 Go 写,因为 Fabric 链码的 Go 合约 API 最稳定,Java 和 Node.js 链码在依赖版本升级时翻车概率更高。下面这段代码是溯源链码最核心的两个方法:创建批次和写入流转记录。
package main import ( "encoding/json" "fmt" "time" "github.com/hyperledger/fabric-contract-api-go/contractapi" ) type TraceContract struct { contractapi.Contract } type BatchInfo struct { BatchID string `json:"batch_id"` Product string `json:"product"` Origin string `json:"origin"` FarmerOrg string `json:"farmer_org"` ProduceAt int64 `json:"produce_at"` Status string `json:"status"` } type TraceRecord struct { TraceID string `json:"trace_id"` BatchID string `json:"batch_id"` OperatorOrg string `json:"operator_org"` Operation string `json:"operation"` Location string `json:"location"` Timestamp int64 `json:"timestamp"` Remark string `json:"remark"` } // CreateBatch 创建批次,只有 ProducerMSP 下身份才能调用 func (c *TraceContract) CreateBatch(ctx contractapi.TransactionContextInterface, batchID string, product string, origin string) error { exists, err := ctx.GetStub().GetState(batchID) if err != nil { return fmt.Errorf("查询批次失败: %v", err) } if exists != nil { return fmt.Errorf("批次 %s 已存在", batchID) } batch := BatchInfo{ BatchID: batchID, Product: product, Origin: origin, FarmerOrg: ctx.GetClientIdentity().GetMSPID(), ProduceAt: time.Now().Unix(), Status: "created", } batchBytes, err := json.Marshal(batch) if err != nil { return err } return ctx.GetStub().PutState(batchID, batchBytes) } // AddTrace 写入流转记录,当前批次状态会自动推进 func (c *TraceContract) AddTrace(ctx contractapi.TransactionContextInterface, traceID string, batchID string, operation string, location string) error { batchBytes, err := ctx.GetStub().GetState(batchID) if err != nil { return fmt.Errorf("查询批次失败: %v", err) } if batchBytes == nil { return fmt.Errorf("批次 %s 不存在", batchID) } var batch BatchInfo if err := json.Unmarshal(batchBytes, &batch); err != nil { return err } // 状态流转校验:已售出的批次不允许再添加流转记录 if batch.Status == "sold" { return fmt.Errorf("批次 %s 已售出,禁止追加流转记录", batchID) } trace := TraceRecord{ TraceID: traceID, BatchID: batchID, OperatorOrg: ctx.GetClientIdentity().GetMSPID(), Operation: operation, Location: location, Timestamp: time.Now().Unix(), Remark: "", } traceBytes, _ := json.Marshal(trace) // 以 traceID 作为 key 存储,方便按单条记录查证 if err := ctx.GetStub().PutState(traceID, traceBytes); err != nil { return err } // 同时更新批次状态,保证批次的整体流转状态可追踪 batch.Status = operation updatedBatchBytes, _ := json.Marshal(batch) return ctx.GetStub().PutState(batchID, updatedBatchBytes) } // QueryByBatchID 按批次查全部溯源记录 func (c *TraceContract) QueryByBatchID(ctx contractapi.TransactionContextInterface, batchID string) (string, error) { // 使用富查询按 batch_id 查询所有 trace 记录 queryString := fmt.Sprintf(`{"selector": {"batch_id": "%s"}}`, batchID) results, err := ctx.GetStub().GetQueryResult(queryString) if err != nil { return "", err } defer results.Close() var records []TraceRecord for results.HasNext() { kv, err := results.Next() if err != nil { return "", err } var r TraceRecord if err := json.Unmarshal(kv.Value, &r); err != nil { return "", err } records = append(records, r) } recordsJSON, _ := json.Marshal(records) return string(recordsJSON), nil }注意这段代码里几个细节。创建批次时我用ctx.GetClientIdentity().GetMSPID()自动写入调用者所属组织,而不是信任前端传过来的字符串,这能防止有人伪造产地组织。AddTrace 里做了状态机校验,已售出的批次不允许继续追加,这是溯源场景里最容易漏掉的逻辑——很多开发只做追加写入,不做状态判断,结果同一箱苹果在路上被写了十几次「已送达」。查询用的是GetQueryResult富查询,但前提是 CouchDB 里必须有对应索引,否则 Fabric 会退化成全表扫描,后面第 5 章我会专门讲这个坑。
3.2 背书策略与私有数据:敏感采购价不落全量账本
溯源项目里最敏感的数据其实不是产地和批次,而是采购价、经销商折扣、质检成本这些商业信息。它们不上链,消费者永远看不到;但如果完全不上链,监管审计时又少了一部分证据。Fabric 的私有数据集合(Private Data Collection)就是干这个的:数据只有指定组织能看到,但它的哈希会照常写进每个区块,形成防篡改证据。
背书策略的配置是这个环节最容易出错的地方。比如我希望「创建批次」必须由 Producer 和 Regulator 同时背书,策略就要写成:
AND('ProducerMSP.member','RegulatorMSP.member')如果写成 OR,那就变成了「两个组织任意一个签名即可」,监管核验的意义就没有了。更复杂的场景是流转记录,我一般要求「录入方 + 监管方」同时背书,防止录入方单方面篡改物流事件。
私有数据集合需要在链码打包时指定 collection 配置文件,一个典型配置如下:
{ "name": "priceCollection", "policy": "OR('ProducerMSP.member','ProcessorMSP.member','RegulatorMSP.member')", "requiredPeerCount": 1, "maxPeerCount": 3, "blockToLive": 0, "memberOnlyRead": true }memberOnlyRead这个参数很多人会漏掉。如果它是 false,集合里的数据虽然不会进公共账本,但拿到链码调用权限的节点仍然可能读到明文;设成 true 后,只有策略列出的组织内身份才能读。blockToLive表示集合数据在私有状态数据库里存多少个区块后过期,我建议溯源场景设成 0,即永久保存,别为了省磁盘把审计证据设成自动删除。
3.3 部署上链:Fabric 链码生命周期命令全流程
Fabric 2.x 的链码生命周期和 1.4 完全是两套逻辑,照着老教程用install+instantiate会直接失败。2.x 的标准流程是:打包、安装、组织审批、提交。下面这组命令是完整的生命周期流程,建议在 peer 容器里执行。
# 1. 打包链码,label 是链码身份标识,升级时不要换名字 peer lifecycle chaincode package tracecc.tar.gz \ --path /opt/gopath/src/github.com/trace \ --lang golang \ --label tracecc_1.0 # 2. 安装到 peer,返回 package identifier,记录下来 peer lifecycle chaincode install tracecc.tar.gz # 输出示例: Package ID: tracecc_1.0:xxxxx # 3. 组织审批,package-id 用上一步返回的值 peer lifecycle chaincode approveformyorg \ --channelID appchannel \ --name tracecc \ --version 1.0 \ --package-id tracecc_1.0:xxxxx \ --sequence 1 \ --signature-policy "AND('ProducerMSP.member','RegulatorMSP.member')" \ --tls --cafile /etc/hyperledger/orderer/tls/ca.crt # 4. 查询审批状态,必须所有组织都 approve 后才能 commit peer lifecycle chaincode checkcommitreadiness \ --channelID appchannel \ --name tracecc \ --version 1.0 \ --sequence 1 \ --signature-policy "AND('ProducerMSP.member','RegulatorMSP.member')" # 5. 提交链码到通道 peer lifecycle chaincode commit \ --channelID appchannel \ --name tracecc \ --version 1.0 \ --sequence 1 \ --signature-policy "AND('ProducerMSP.member','RegulatorMSP.member')" \ --peerAddresses peer0.producer.example.com:7051 \ --peerAddresses peer0.regulator.example.com:7051package-id是安装后生成的哈希标识,每次拉新代码重新打包都会变,必须用命令输出里的实际值替换。sequence参数是链码版本序号,第一次部署是 1,后续升级必须递增。最容易犯的错是多个组织里有一个没执行 approve,checkcommitreadiness会明确告诉你哪个组织还没批准,别急着 commit。
4. 应用层对接与 QR 码溯源页:从 SDK 调用到扫码验证的完整链路
4.1 用 Node.js SDK 调链码:查询与提交的区分
链码部署完,应用层要通过 Fabric SDK 和链码交互。新项目我用 Node.js 的 fabric-network 包,连接部分可以照下面这段做。
const { Gateway, Wallets } = require('fabric-network'); const path = require('path'); const fs = require('fs'); async function connectAndCreateBatch(batchId, product, origin) { // 加载连接配置 ccp,即 connection profile JSON const ccpPath = path.resolve(__dirname, 'appchannel_connection.json'); const ccp = JSON.parse(fs.readFileSync(ccpPath, 'utf8')); // 使用本地文件系统钱包,加载管理员或业务身份 const wallet = await Wallets.newFileSystemWallet(path.join(__dirname, 'wallet')); const gateway = new Gateway(); try { await gateway.connect(ccp, { wallet, identity: 'traceAppUser', discovery: { enabled: true, asLocalhost: true } }); // 拿到通道和应用链码句柄 const network = await gateway.getNetwork('appchannel'); const contract = network.getContract('tracecc'); // submitTransaction 会走完整背书、排序、验证流程 await contract.submitTransaction( 'CreateBatch', batchId, product, origin ); console.log('批次创建交易已提交:', batchId); } finally { gateway.disconnect(); } }注意submitTransaction和evaluateTransaction是两个不同语义的方法。前者是写操作,会把交易发到背书节点、排序节点,最终写进区块;后者是读操作,只在本组织的 peer 上查询,不上共识。不要用submitTransaction去查数据,那会白白消耗背书资源,还会因为查询交易没有写集而报错。identity 参数必须是钱包里已经注册过的身份,不要直接用 admin,生产环境里建议给溯源应用单独注册一个只读或部分权限的身份。
4.2 QR 码溯源页设计:链上哈希如何变成用户可验证的证据
二维码溯源页面的核心不是「展示数据」,而是「展示可否验证的数据」。我见过太多项目把链上的 JSON 原样塞进页面,消费者根本看不懂。常见做法是:应用层把 batch 的批次信息、流转记录、检测报告摘要,连同关键交易的 block number 和 transaction ID 拼成一个 JSON,转成二维码;消费者扫码后,页面展示两条证据链——普通可读的溯源长图和一条「链上存证」信息。链上存证部分不能只写「已上链」,要把 txID 直接展示出来,懂技术的人可以自己到 peer 上查证,不懂的人至少有据可查。
QR 码内容我建议用 batchID 作为主键,而不是整个 JSON。原因很简单:二维码里塞太多内容,在菜市场的昏暗灯光下极难扫出来。扫码后应用层再调链码拿数据,网络不好的情况下可以先用缓存数据渲染,再异步刷新链上最新状态。不要直接把链码返回的原始结构暴露给前端,应用层应该把 batch、trace、report 合成一个适合展示的 DTO。
4.3 CouchDB 富查询:按日期、产地查记录的索引配置
前面链码里用到了GetQueryResult,它依赖 CouchDB 索引。索引文件不是放在服务器上随便配置的,而是打包进链码包里的META-INF/statedb/couchdb/indexes目录。下面是一个按批次 ID 查流转记录的索引定义。
{ "index": { "fields": ["batch_id", "timestamp"] }, "ddoc": "index-trace-by-batch", "name": "trace-by-batch", "type": "json" }把上面文件命名为traceIndex.json,放到链码项目下的META-INF/statedb/couchdb/indexes/目录,再重新打包部署链码,索引才会生效。索引字段顺序有讲究,如果 selector 里只按 batch_id 查,索引的第一个字段就是 batch_id;如果经常加 timestamp 范围过滤,就把 timestamp 放第二位。建完索引后,可以在 CouchDB 的 Fauxton 界面里用GET /appchannel_tracecc/_index确认索引状态,别光看部署成功就以为万事大吉。
5. 部署与运维避坑:Fabric 农产品溯源项目最常见的五个坑
5.1 链码升级后,历史溯源数据「丢」了
现象:链码升级后用同样的查询方法,返回结果为空,或者只能查到升级后新写入的数据,之前的批次全不见了。
原因:升级链码时没有复用原来的链码名称,而是新建了一个链码名。Fabric 的状态数据是绑定在「通道 + 链码名称」上的,链码名一变,账本命名空间就变了,旧数据自然查不到。
解决:链码升级严格保持--name tracecc不变,只改--version和--sequence。升级前用peer lifecycle chaincode queryinstalled确认已安装包里的 label 是否和旧版本一致,label 只代表包标识,链码名才是状态归属的关键。
5.2 CouchDB 富查询慢得离谱,几千条记录卡好几秒
现象:接口超时,peer 日志没有明显异常,CouchDB 容器 CPU 飙高,Fauxton 里能看到查询走了all_docs全盘扫描。
原因:索引文件没打包进链码。很多人以为在 CouchDB 里手动建了索引就行,但 peer 重启或容器重建后索引会丢,手动建的索引和链码状态绑不到一起。
解决:把索引文件放进链码目录的META-INF/statedb/couchdb/indexes/,重新打包、升级链码。升级后用curl -X POST http://couchdb:5984/appchannel_tracecc/_explain -d '{"selector": ...}'查执行计划,确认索引命中,别只看响应时间。
5.3 背书策略是 AND,调用却只从单个组织拿背书
现象:应用层调用 CreateBatch 报错,提示背书不一致或链码返回错误,查看日志发现只有 producer peer 参与了背书。
原因:SDK 连接配置(connection profile)里只写了本组织的 peer 地址,没写监管组织的 peer。Fabric 的背书节点集合由链码背书策略决定,但实际要由 SDK 把交易发到哪些 peer 上去收集签名,SDK 连接配置里没配齐,背书数自然不够。
解决:在 connection profile 里把策略涉及的组织 peer 都列出来,至少各列一个。比如策略是AND('ProducerMSP.member','RegulatorMSP.member'),就要同时配置 producer 和 regulator 的 peer 地址。
5.4 私有数据集合调用报「collection not defined」
现象:使用了私有数据的链码已成功 commit,但调用时马上返回collection ... not defined,其他普通方法正常。
原因:链码打包时没有把 collection 配置文件传给 CLI,或者 commit 时没有带--collections-config参数。Fabric 不会自动扫描链码目录里的 collection JSON,必须显式指定。
解决:打包和提交时都显式加参数:
peer lifecycle chaincode package tracecc.tar.gz \ --path /opt/gopath/src/github.com/trace \ --lang golang \ --label tracecc_1.0 \ --collections-config ./collections_config.json peer lifecycle chaincode commit \ --channelID appchannel \ --name tracecc \ --collections-config ./collections_config.json5.5 容器时区导致溯源时间显示错乱
现象:溯源页面上显示的采摘时间比实际时间慢了 8 小时,只有从链码写入时间戳的记录有问题,物流系统传的时间正常。
原因:peer 和链码容器默认时区是 UTC,time.Now()拿到的是 UTC 时间,前端没有做时区转换就直接渲染。
解决:链码里统一写入 Unix 时间戳,也就是time.Now().Unix()的 int64,前端拿到后自行转成本地时区。不要在链码里用字符串格式化时间,不同组织 peer 的时区配置未必一致,字符串时间在跨组织背书后会变得不可信。
6. 进阶技巧:用私有数据集合实现批次信息的定向披露
最后一个技巧,是我自己在做过两个农产品溯源项目之后沉淀下来的:把「所有人可见的数据」和「部分人可见的数据」从一开始就分开建模。很多团队把所有字段堆进同一个结构体,上链之后发现某个字段不该给消费者看到,又没法单独撤回,只能再写一个脱敏接口,在应用层硬生生把字段抹掉。这个方案有两个问题:一是明文数据已经进了区块历史,懂技术的人去查历史区块照样能看到;二是应用层脱敏不等于链上脱敏,监管要求提供原始数据时,这套方案解释不清楚。
正确做法是在链码设计阶段就用私有数据集合。比如批次信息里,农产品名称、产地、采摘日期是公开数据,放进 PublicState;采购单价、经销商返点、检测成本是敏感数据,放进priceCollection,只有 Producer、Processor、Regulator 三个组织能读。链码写入时用 transient 字段传递敏感数据,普通调用数据照常走公共账本。
用 chaincode-stub 的 transient 方法实现时,客户端先把敏感字段放进 Map,调用链码时不会进交易提案的公共部分,只有被指定背书的 peer 的私有状态数据库里才有明文。查询接口分两层:QueryPublicByBatchID给消费者页面用,只返回公开字段;QueryPriceByBatchID给内部业务系统用,内部加上角色判断,Regulator 组织的身份可以读,消费者身份即使拿到接口地址也读不到。
这个做法带来的直接好处是,监管做飞行检查时,不需要消费者页面暴露任何敏感价格,监管节点自己就能查完整审计数据。运营方也不用再维护一张「哪些字段要隐藏」的前端配置表,权限收敛到了链码层。
从那以后,我每次搭溯源平台,都强制先在白板上画一张字段清单,标注每个字段是 public 还是 private,再动手写链码。这个过程通常只要花十分钟,但能省掉后面一整轮返工。希望这套思路对你手上的农产品溯源项目也有帮助。
本文还有配套的精品资源,点击获取