2026/10/26 12:14:53

Spring Boot+Vue+区块链农产品溯源系统源码实战

Spring Boot+Vue+区块链农产品溯源系统源码实战 简介面向区块链与大数据课程设计、毕业设计及农产品溯源原型开发者的完整项目资料围绕生产环节数据采集、链上存证、智能合约、溯源查询与大数据分析报表构建端到端演示链路可用于搭建实验环境、理解可信溯源实现并做二次开发。压缩包共2002个文件约58.09MB以JavaScript前端交互与服务端逻辑、Markdown说明文档、JSON配置、HTML页面为主另含Python数据分析脚本、Java服务代码及YAML部署配置目录覆盖基础样式、接口配置、页面模板和运行脚本便于按模块检索。已有618人学习或下载适合需要参考项目结构、准备答辩演示或补全溯源功能的中等技能读者。读者可从中获取溯源流程拆解、区块链数据上链与智能合约设计思路、数据采集到展示的页面组织方式以及安全与隐私保护相关实现参考用于快速形成可运行原型并定位改造点。1. 消费者扫一个码背后要过几道关某生鲜门店里顾客拿手机扫包装上的二维码跳出来的页面写着产地、施肥记录、检测报告和运输温度。反直觉的地方在于这个页面本身证明不了任何事真正扛得住质疑的是每条记录背后的哈希值和链上回执。这套基于大数据与区块链的农产品溯源系统把生产基地建档、批次流转、加工仓储、物流签收、终端扫码查询串成一条闭环后端 Spring Boot、前端 Vue、区块链层独立成接口产区分布和扫码热度这类统计用聚合表加 ECharts 看板落地。它适合两类人想搞明白业务系统怎么跟链对接的后端开发以及需要一个能完整跑通的农产品溯源 Demo 的学生或小团队。2. 技术栈与工程目录Spring Boot Vue 的溯源底座怎么分层这份源码最容易翻车的地方不是功能没做完而是链的逻辑和业务逻辑长在了一起——上链代码直接塞进 Service链节点一抖动整个录入流程就卡死。所以拆包时我第一眼看的是它有没有把「链」单独抽成一层。这套工程的分层还算克制业务管业务链只管存哈希统计单独走一条线三者靠接口和状态字段解耦。下面按选型、目录、数据模型、启动顺序四块拆开讲。2.1 四层分工与技术选型溯源系统的角色比一般后台多生产主体、加工方、物流方、经销终端、平台管理员再加一个不登录的消费者。角色一多权限和数据可见范围就得先划清楚否则后面加字段会非常痛苦。层次选型承担什么接入层Spring Boot 2.7 Spring MVCREST 接口、登录鉴权、参数校验业务层MyBatis-Plus MySQL 8批次、环节流水、主体档案链层链服务接口 本地哈希链/FISCO BCOS 两套实现哈希存证、回执查询、校验展示层Vue 3 Vite Element Plus ECharts管理端录入、看板、消费者扫码页前端选 Vue 3 是为了 Vite 那套启动速度改一行热更新几乎是秒级调试溯源表单这种字段特别多的页面时体验差别很明显。ECharts 单独拎出来说是因为统计看板几乎全靠它产地分布用地图、环节耗时用柱状、扫码趋势用折线三张图基本覆盖了「大数据」这层要展示的东西。数据库这块没有一上来就堆 Hadoop 那一套我认可这个取舍。农产品溯源的明细量级一个县域平台一年也就百万级流水MySQL 加索引完全扛得住真到了千万级再把聚合任务换成 Spark SQL 就行接口层一行不用动。2.2 目录结构前后端、链码、SQL 各归各位agri-trace/ ├── agri-trace-server/ # 后端Spring Boot 单体 │ ├── src/main/java/com/agritrace │ │ ├── controller/ # 对外 REST 接口 │ │ ├── service/ # 业务编排 │ │ ├── chain/ # 区块链抽象层重点看这里 │ │ │ ├── ChainService.java # 上链/查询/校验三个方法 │ │ │ ├── impl/LocalHashChainImpl.java │ │ │ └── impl/FiscoChainImpl.java │ │ ├── mapper/ # MyBatis-Plus Mapper │ │ ├── stat/ # 统计聚合任务 │ │ └── common/ # 统一返回、异常、哈希工具 │ ├── src/main/resources/ │ │ ├── application.yml │ │ └── mapper/ # 复杂 SQL 的 XML │ └── pom.xml ├── agri-trace-web/ # 管理端 消费者扫码页 │ ├── src/views/trace/ # 批次管理、环节录入、扫码查询 │ ├── src/views/stat/ # 数据看板 │ └── vite.config.js ├── chain-contract/ # 链码 │ ├── TraceContract.sol │ └── deploy.sh ├── sql/ │ ├── schema.sql # 建表 │ └── data.sql # 演示数据 └── docs/ # 部署说明与接口文档这个结构里最值得学的是chain/这一层。它把「上链」抽象成三个方法业务侧只依赖接口具体是打到本地哈希链还是打到联盟链节点由配置里的实现类决定。我一般在类似项目里也这么干因为大部分课程设计和毕设环境根本起不了四个链节点本地实现能保证代码跑得起来答辩演示时不至于卡在环境上。stat/单独成包也是同理聚合任务和实时业务分开出问题好定位。你想想如果统计 SQL 和下单 SQL 混在一个 Service 里看板数字不准的时候怎么排查分开放至少能先确认是数据源问题还是展示问题。2.3 数据模型批次是全链路的主心骨溯源的骨架其实就三张表品类档案、批次、环节流水。批次号是全链路串联的唯一 key一物一码的场景再加一张码表映射过去。-- 农产品档案 CREATE TABLE product_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_code VARCHAR(32) NOT NULL COMMENT 品类编码, product_name VARCHAR(64) NOT NULL, category VARCHAR(32) COMMENT 种植/养殖/水产, shelf_life INT COMMENT 保质期(天), UNIQUE KEY uk_code (product_code) ) COMMENT农产品档案; -- 生产批次全链路主键 CREATE TABLE batch_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_no VARCHAR(40) NOT NULL COMMENT 批次号, product_id BIGINT NOT NULL, origin_code VARCHAR(32) COMMENT 产区行政区划编码统计按它分组, producer_id BIGINT COMMENT 生产主体, plant_date DATE, harvest_date DATE, status TINYINT DEFAULT 0 COMMENT 0在产 1已采收 2已售罄, UNIQUE KEY uk_batch (batch_no), KEY idx_origin (origin_code) ) COMMENT生产批次; -- 环节流水真正上链的东西 CREATE TABLE trace_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT 上链业务主键批次号环节序号, batch_id BIGINT NOT NULL, stage VARCHAR(16) NOT NULL COMMENT PLANT/PROCESS/LOGISTICS/RETAIL, operator_id BIGINT, op_time DATETIME NOT NULL, detail_json JSON COMMENT 环节明细施肥量、温度、检测项都在里面, detail_hash CHAR(64) NOT NULL COMMENT 明细的 SHA-256, prev_hash CHAR(64) COMMENT 上一环节的哈希形成链内链, chain_status VARCHAR(12) DEFAULT PENDING, tx_hash VARCHAR(80), block_no BIGINT, retry_cnt INT DEFAULT 0, UNIQUE KEY uk_biz (biz_id), KEY idx_batch (batch_id, op_time) ) COMMENT溯源环节流水;origin_code这个字段是给统计留的口子。很多同类项目把它写成自由文本的产地名称结果做产区分布图的时候得靠LIKE硬匹配数字永远对不上。用行政区划编码前端地图组件直接能按编码着色。retry_cnt和chain_status是上链补偿的关键后面第 3 章会展开。这里先记住一点上链是外部调用不能让它和本地事务同生共死。2.4 本地跑起来的最小步骤工程默认走本地哈希链实现不需要起任何链节点这一点对新手很友好。启动顺序是先库、后后端、再前端。# 1. 建库导数据MySQL 8 注意字符集用 utf8mb4 mysql -uroot -p agri_trace sql/schema.sql mysql -uroot -p agri_trace sql/data.sql # 2. 后端local profile 对应本地哈希链实现 cd agri-trace-server mvn spring-boot:run -Dspring-boot.run.profileslocal # 3. 前端 cd agri-trace-web npm install npm run dev配置文件里对应的是这一段chain: provider: local # local | fisco切 fisco 时改成后者 fisco: group-id: 1 cert-path: classpath:conf/ contract-address: 0x0000000000000000000000000000000000000000provider就是那个开关。改成fisco之后FiscoChainImpl会被装配进来需要自己把证书、节点地址、合约地址填好。合约地址那一项空着的话启动时会直接报错这个设计是对的免得跑到一半才发现没部署合约。提示如果你只是想看效果别急着配真链。先用 local 模式把批次建起来、环节录进去、扫码页能查到整条业务链路通了再去换实现排查成本会低很多。3. 上链链路拆解一条施肥记录怎么变成链上凭证这里先讲清楚一个原则链上不存明细。施肥量多少、用了什么药、检测报告长什么样这些都在 MySQL 里链上只存这份明细算出来的哈希值、业务主键和操作时间。这么设计的原因很实际——链上存储成本高、写入慢真要按 GB 往里塞链节点先撑不住。哈希值的作用是防篡改明细被改一个字符重算出来的哈希就对不上链上那份校验立刻失败。3.1 上链数据结构与哈希口径先看数据结构。字段不多但每个都有明确用途public class TracePayload { private String bizId; // 业务主键例B20240518001-PLANT-01 private String batchNo; // 批次号链上按它做前缀查询 private String stage; // 环节PLANT/PROCESS/LOGISTICS/RETAIL private String operatorId; // 操作方编号追责用 private Long opTime; // 操作时间戳毫秒避免时区问题 private String detailHash; // 明细 JSON 的 SHA-256 private String prevHash; // 上一环节的 payloadHash private int version; // 哈希口径版本后面加字段靠它兼容 }哈希的计算方式决定了系统能不能长期自洽这里我特别强调一点不要直接对 JSON 字符串算哈希。JSON 序列化在不同库、不同版本下键的顺序可能变同样的对象算两次能出两个结果这是血泪经验。正确做法是按固定顺序拼字符串再算public final class HashUtil { /** * 哈希口径 v1字段顺序写死null 用空串占位分隔符统一 | */ public static String calcHashV1(TracePayload p) { String raw String.join(|, nullToEmpty(p.getBizId()), nullToEmpty(p.getBatchNo()), nullToEmpty(p.getStage()), nullToEmpty(p.getOperatorId()), String.valueOf(p.getOpTime() null ? 0L : p.getOpTime()), nullToEmpty(p.getDetailHash()), nullToEmpty(p.getPrevHash())); return DigestUtils.sha256Hex(raw); } private static String nullToEmpty(String s) { return s null ? : s; } }参数说明上opTime用毫秒时间戳而不是yyyy-MM-dd HH:mm:ss字符串是因为字符串在不同时区下会被解析成不同时刻一旦服务器迁移就全乱。prevHash参与计算是关键设计它让同一批次的环节记录首尾相接中间抽掉一条或者顺序调换后面的哈希全部对不上这比单纯存哈希更难伪造。3.2 链服务抽象一个接口两套实现链服务接口只有三个方法职责非常清晰public interface ChainService { /** 提交存证返回链上回执 */ ChainReceipt submit(TracePayload payload); /** 按业务主键取回链上存证 */ TracePayload query(String bizId); /** 校验链上存的那份哈希是否等于入参 */ boolean verify(String bizId, String payloadHash); }本地实现就是往chain_block表写一行同时内存里维护一个哈希链结构重启时从表里回放。真正的联盟链实现则走 SDK 调合约。合约本身很简单映射加事件pragma solidity ^0.4.25; contract TraceContract { // bizId - payloadHash mapping(string string) private hashOf; event TracePut(string bizId, string payloadHash, uint256 time); function put(string bizId, string payloadHash) public returns (bool) { require(bytes(hashOf[bizId]).length 0, exist); hashOf[bizId] payloadHash; emit TracePut(bizId, payloadHash, block.timestamp); return true; } function get(string bizId) public view returns (string) { return hashOf[bizId]; } }require那句是幂等保护同一个bizId重复提交会直接 revert。这个约束在补偿重试的场景下特别重要后面避坑章节会讲它带来的一个副作用。两套实现共用一个接口的好处除了能无链跑通还在于切换时业务代码零改动。我在类似项目上试过从自研接口切到联盟链 SDK只要ChainReceipt里把txHash和blockNo两个字段对齐Service 层一行没动。3.3 落库与上链解耦补偿表 定时重试上链是跨网络的调用把它包在本地事务里是典型错误。链节点慢一点数据库连接就被占着高并发下直接把连接池打满。这套工程的做法是先落库、后补偿Transactional(rollbackFor Exception.class) public String appendRecord(TraceRecordDTO dto) { // 1) 算明细哈希 String detailHash DigestUtils.sha256Hex(JSON.toJSONString(dto.getDetail())); // 2) 取该批次上一环节的哈希串成链内链 String prevHash traceRecordMapper.selectLastPayloadHash(dto.getBatchNo()); // 3) 先落库状态 PENDING这一步完成即返回成功 TraceRecord record new TraceRecord(); record.setBizId(buildBizId(dto)); record.setDetailHash(detailHash); record.setPrevHash(prevHash); record.setChainStatus(PENDING); traceRecordMapper.insert(record); return record.getBizId(); }定时任务负责把PENDING的流水推到链上Scheduled(fixedDelay 30_000) public void flushPending() { // 每次取 100 条失败次数超过 5 次的跳过交人工处理 ListTraceRecord list traceRecordMapper.selectPending(100, 5); for (TraceRecord r : list) { try { ChainReceipt receipt chainService.submit(toPayload(r)); traceRecordMapper.updateChainResult( r.getId(), receipt.getTxHash(), receipt.getBlockNo(), SUCCESS); } catch (Exception e) { // 只累加重试次数并记原因不抛出去打断整批 traceRecordMapper.incrRetry(r.getId(), truncate(e.getMessage(), 200)); } } }参数上fixedDelay用的是上一次执行完毕后再等 30 秒不是固定频率避免任务堆积。取件 SQL 里retry_cnt 5是熔断线反复失败的记录不再无限重试留给人工介入。这种「状态机 重试上限 失败原因留痕」的组合基本是上链场景的标准做法抄过去改改就能用。3.4 校验把链上哈希重新算一遍消费者扫码时看到的那句「存证有效」本质上是服务端做了一次重算比对public VerifyResult verifyBatch(String batchNo) { ListTraceRecord list traceRecordMapper.listByBatchOrderByTime(batchNo); String prev null; for (TraceRecord r : list) { // 1) 用库里的明细重算哈希 String recalc HashUtil.calcHashV1(toPayload(r)); // 2) 从链上取回原始哈希 String onChain chainService.query(r.getBizId()).getDetailHash(); // 3) 单条比对 if (!recalc.equals(onChain)) { return VerifyResult.broken(r.getBizId(), 链上哈希与重算结果不一致); } // 4) 链条比对检查环节有没有被抽掉 if (prev ! null !prev.equals(r.getPrevHash())) { return VerifyResult.broken(r.getBizId(), 上游哈希断链); } prev recalc; } return VerifyResult.ok(list.size()); }四步逻辑对应两类篡改单条明细被改第三步会失败整条记录被删或者顺序被打乱第四步会失败。实际排查中我发现第二种篡改更隐蔽光比对单条哈希是发现不了的必须把prevHash链条串起来查。注意校验接口不要开在公开路径上无条件反射。批次号很容易被枚举一个不带限流的全量校验接口等于把数据库和链节点同时暴露给爬虫。常见做法是加个简单频率限制或者只返回「有效/无效」不返回明细。4. 大数据统计模块从流水表到产区分布看板标题里的「大数据」在这类项目里通常不是指 Hadoop 集群而是指把分散的流水汇总成可决策的指标哪个产区出货最多、哪个环节平均耗时最长、扫码查询集中在什么时段。做这层的难点不在 SQL 复杂度而在统计口径必须先定死不然看板数字和明细表永远对不上。4.1 统计口径先定死按天、按品类、按产区维度就三个日期、品类、产区编码。指标四项批次数、环节记录数、上链成功数、上链失败数。落一张按天预聚合的宽表CREATE TABLE stat_batch_daily ( stat_date DATE NOT NULL COMMENT 统计日期, product_id BIGINT NOT NULL COMMENT 品类, origin_code VARCHAR(32) NOT NULL COMMENT 产区编码, batch_cnt INT DEFAULT 0 COMMENT 当日批次数, record_cnt INT DEFAULT 0 COMMENT 环节记录数, chain_ok_cnt INT DEFAULT 0 COMMENT 上链成功数, chain_fail_cnt INT DEFAULT 0 COMMENT 上链失败数, PRIMARY KEY (stat_date, product_id, origin_code) ) COMMENT批次日统计宽表;主键设计成三列联合是为了让聚合任务可以放心地ON DUPLICATE KEY UPDATE重跑不会产生重复行。这个细节看着小但在补数据的时候能省掉一堆手动清表的麻烦。4.2 聚合任务一条 SQL 也能干但要注意幂等聚合逻辑一条 SQL 就够INSERT INTO stat_batch_daily (stat_date, product_id, origin_code, batch_cnt, record_cnt, chain_ok_cnt, chain_fail_cnt) SELECT DATE(r.op_time) AS stat_date, b.product_id, b.origin_code, COUNT(DISTINCT r.batch_id) AS batch_cnt, COUNT(*) AS record_cnt, SUM(CASE WHEN r.chain_status SUCCESS THEN 1 ELSE 0 END), SUM(CASE WHEN r.chain_status FAILED THEN 1 ELSE 0 END) FROM trace_record r JOIN batch_info b ON b.id r.batch_id WHERE r.op_time #{start} AND r.op_time #{end} GROUP BY DATE(r.op_time), b.product_id, b.origin_code ON DUPLICATE KEY UPDATE batch_cnt VALUES(batch_cnt), record_cnt VALUES(record_cnt), chain_ok_cnt VALUES(chain_ok_cnt), chain_fail_cnt VALUES(chain_fail_cnt);两个细节必须说清楚。第一时间范围用左闭右开 start AND end不要用BETWEEN因为BETWEEN对DATETIME是闭区间23:59:59 之后到零点之间的数据会漏掉跨天补数据的时候这个坑特别难受。第二VALUES()在 MySQL 8.0.20 之后被标记弃用新版本建议换成AS new别名写法旧版本继续用VALUES()也没问题看你库的版本决定。任务调度用Scheduled跑 T-1 的增量每天凌晨触发一次重复跑同一天也是覆盖写天然幂等。如果要按小时粒度看趋势把stat_date换成DATETIME截断到小时即可思路一样。4.3 接口与图表ECharts 的两个典型配置后端接口按维度出聚合结果前端直接塞给 ECharts。产区分布图用地图系列需要一份行政区划的 GeoJSON按origin_code匹配// 产区分布data 形如 [{name:某产区, value:128}, ...] const mapOption { tooltip: { trigger: item }, visualMap: { min: 0, max: 500, inRange: { color: [#e0f3f8, #4575b4] } // 由浅到深表示出货量 }, series: [{ type: map, map: region, roam: true, // 允许缩放拖动 label: { show: true, fontSize: 10 }, data: mapData }] };环节耗时用柱状图更直观横轴是环节名纵轴是平均小时数const durationOption { xAxis: { type: category, data: [种植, 加工, 仓储, 运输, 零售] }, yAxis: { type: value, name: 平均耗时(h) }, series: [{ type: bar, barWidth: 45%, itemStyle: { color: #5b8ff9, borderRadius: [4, 4, 0, 0] }, data: stageAvgHours }] };barWidth别设成 100%留白不够会显得挤。roam: true在移动端看地图时很有用但管理端大屏上建议关掉防止误拖动把视图带偏。4.4 量大了往 Spark 上挪的边界在哪什么时候该把这条聚合 SQL 换成 Spark我的判断标准是两条单表流水超过两千万行或者聚合任务执行时间超过十分钟。到了这个量级MySQL 的GROUP BY会开始吃临时表空间磁盘 IO 打满连业务查询都受影响。迁移的改动面其实很小因为接口层读的是stat_batch_daily你只要保证 Spark 任务把结果写回这张表前端一行不用动。这也是为什么我坚持统计和业务分表换计算引擎的时候只有计算部分要重写。5. 排查实录上链超时、哈希对不上、扫码查不到这类系统真正让人头疼的不是写不出来是跑起来之后「看着都对就是不对」。下面五条是我在同类项目里真实踩过的按现象、原因、解决三段写遇到类似症状可以直接对号入座。5.1 现象上链接口偶发超时记录卡在 PENDING原因早期版本把chainService.submit()直接包在Transactional里链节点响应一慢数据库事务就挂着连接池很快被占满。更麻烦的是事务回滚了但链上可能已经写入成功链和库出现不一致。解决改成本地事务只负责落库状态置PENDING上链交给定时任务补偿重试上限 5 次。补偿任务单独配一个小线程池别和业务线程池共用。另外合约里的require幂等保护要保留重试时重复提交直接 revert捕获这个异常就当成功处理回填一下tx_hash即可。5.2 现象校验失败提示哈希不一致但明细明明没改过原因八成是哈希口径变了。典型场景是有人给TracePayload加了个字段calcHashV1里顺手拼进去结果全部历史数据的重算结果都变了。另一个常见原因是明细 JSON 存进 MySQL 再读出来时JSON类型会自动调整键顺序和空格直接对读出的字符串算哈希必然对不上。解决哈希只对detailHash计算不对原始 JSON 算detailHash在写入前算好存成定长字符串读出来就是原值。加字段必须升级version按版本分支走不同的拼接口径老数据用 v1 校验新数据用 v2。这个version字段我建议一开始就留后面加字段的时候不会后悔。5.3 现象消费者扫码页面 404或者提示批次不存在原因分两种。一是路由问题前端用的是 history 模式静态托管没配try_files直接访问/trace/xxx找不到入口。二是数据问题扫码带的是「一物一码」的码值后端却拿码值去查batch_no字段对不上自然查不到。解决路由层面加一行try_files $uri $uri/ /index.html;就解决了。数据层面要加一张码表code_value映射到batch_id扫码接口先查映射再查批次。别图省事把码值直接当批次号用后面一物一码和一批一码混着来的时候会非常难收拾。5.4 现象看板数字和明细表对不上差几十条原因三种可能。聚合任务跑了两次但没做幂等数据翻倍时间边界用了BETWEEN凌晨那批数据漏统计JOIN的时候批次表有软删除标记没过滤统计里混进了已作废的批次。解决先写一个对账 SQL按同口径直接查明细表和宽表逐行比差在哪天哪个产区一目了然。确认是幂等问题就补联合主键是边界问题就改左闭右开是软删问题就在JOIN上加b.deleted 0。对账 SQL 我一般会留在docs/里每次改统计逻辑都跑一遍。5.5 现象本地切到真链模式启动直接报错连不上原因证书路径写成绝对路径换台机器就失效SDK 版本和链节点版本不匹配合约地址是空的节点端口被本地其他服务占了。这几种报错信息往往长得很像都是连接超时或者握手失败。解决按顺序排查。先确认节点进程活着、端口通再用最小 SDK 示例连一次通了再回来改配置。证书统一放resources/conf/下用classpath:引用别写盘符。合约地址为空的话启动就拦截这一条工程里已经做了照着报错提示去部署合约就行。实在搞不定就先退回 local 模式把业务跑通再说别在环境上耗一整天。6. 二次开发加一个质检环节、换一套链改哪里拿到这套代码最常见的两个改造需求是加环节和换链前者麻烦在兼容后者反而简单。加一个「质检」环节改动是四步stage枚举里加QC前端表单配置里加一组质检字段检测项、结论、报告编号TracePayload里stage传新值哈希口径升级到 v2把质检结论纳入计算。最后一步最关键直接改 v1 的拼接逻辑会让所有历史记录校验失败必须按version分支。我在HashUtil里一般这么写public static String calcHash(TracePayload p) { if (p.getVersion() 2) { // v2在 v1 基础上追加质检结论字段 return DigestUtils.sha256Hex(calcRawV1(p) | nullToEmpty(p.getQcResult())); } return DigestUtils.sha256Hex(calcRawV1(p)); }换链的改造面小得多只要新实现类实现ChainService三个方法把tx_hash、block_no两个回执字段映射对业务层不用动。这里有个容易忽略的点不同链的txHash长度不一样数据库字段留到VARCHAR(80)比贴着某个链的长度设计要稳妥。验证方法我习惯分三层走单元测试里用固定输入算哈希断言结果和预期常量一致集成测试跑一遍「建批次 → 录三个环节 → 校验通过 → 改一条明细 → 校验失败」最后手工在扫码页走一遍确认展示的存证状态是对的。三层都过才认为改造没破坏原有链路。说个自己的教训。早些年我改哈希逻辑时图快直接在原方法上动刀测试环境数据少没看出来上线后历史批次的校验全红用户扫码全显示「存证异常」那天晚上回滚加补数据折腾到凌晨。从那以后我每次动哈希口径都强制先加version分支、先跑一遍全量历史数据的校验脚本确认老数据全部通过才允许合并。这个习惯看着笨但省下的后悔药不止一次。希望帮到你。本文还有配套的精品资源点击获取