package cn.iocoder.yudao.module.mes.service.trace.integrity; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.mes.controller.admin.trace.integrity.vo.MesTraceIntegrityRecordPageReqVO; import cn.iocoder.yudao.module.mes.controller.admin.trace.integrity.vo.MesTraceIntegrityVerifyRespVO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO; import cn.iocoder.yudao.module.mes.dal.dataobject.trace.consumer.MesTraceConsumerScanEventDO; import cn.iocoder.yudao.module.mes.dal.dataobject.trace.integrity.MesTraceIntegrityRecordDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.productsales.MesWmProductSalesDetailDO; import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper; import cn.iocoder.yudao.module.mes.dal.mysql.trace.consumer.MesTraceConsumerScanEventMapper; import cn.iocoder.yudao.module.mes.dal.mysql.trace.integrity.MesTraceIntegrityRecordMapper; import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesDetailMapper; import cn.iocoder.yudao.module.mes.dal.mysql.wm.productsales.MesWmProductSalesMapper; import cn.iocoder.yudao.module.mes.enums.trace.MesTraceIntegrityBizTypeEnum; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.Objects; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MES_TRACE_INTEGRITY_BIZ_NOT_EXISTS; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MES_TRACE_INTEGRITY_BIZ_TYPE_INVALID; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MES_TRACE_INTEGRITY_RECORD_NOT_EXISTS; /** * 数据完整性 Service 实现类 *

* 摘要仅覆盖不可变核心字段,避免正常业务流转(草稿→生产中→完成、出库后状态更新等)导致误报。 * * @author 超级管理员 */ @Service @Validated @Slf4j public class MesTraceIntegrityServiceImpl implements MesTraceIntegrityService { @Resource private MesTraceIntegrityRecordMapper integrityRecordMapper; @Resource private MesProBatchMapper batchMapper; @Resource private MesProBagCodeMapper bagCodeMapper; @Resource private MesWmProductSalesMapper productSalesMapper; @Resource private MesWmProductSalesDetailMapper productSalesDetailMapper; @Resource private MesTraceConsumerScanEventMapper scanEventMapper; @Override public void recordBatchIntegrity(Long batchId) { try { MesProBatchDO batch = batchMapper.selectById(batchId); if (batch == null) { return; } upsertRecord(MesTraceIntegrityBizTypeEnum.BATCH.getType(), batch.getId(), batch.getCode(), batchHash(batch)); } catch (Exception ex) { log.warn("[recordBatchIntegrity][批次摘要生成失败] batchId={}", batchId, ex); } } @Override public void recordSalesIntegrity(Long salesId) { try { MesWmProductSalesDO sales = productSalesMapper.selectById(salesId); if (sales == null) { return; } String hash = salesHash(sales, productSalesDetailMapper.selectListBySalesId(salesId)); upsertRecord(MesTraceIntegrityBizTypeEnum.SALES.getType(), sales.getId(), sales.getCode(), hash); } catch (Exception ex) { log.warn("[recordSalesIntegrity][出库摘要生成失败] salesId={}", salesId, ex); } } @Override public void recordScanIntegrity(MesTraceConsumerScanEventDO event) { if (event == null || event.getId() == null) { return; } try { upsertRecord(MesTraceIntegrityBizTypeEnum.SCAN.getType(), event.getId(), event.getNormalizedCode(), scanHash(event)); } catch (Exception ex) { log.warn("[recordScanIntegrity][扫码摘要生成失败] scanEventId={}", event.getId(), ex); } } @Override public MesTraceIntegrityVerifyRespVO verify(String bizType, Long bizId) { if (!MesTraceIntegrityBizTypeEnum.isValid(bizType)) { throw exception(MES_TRACE_INTEGRITY_BIZ_TYPE_INVALID); } MesTraceIntegrityRecordDO record = integrityRecordMapper.selectByBiz(bizType, bizId); if (record == null) { throw exception(MES_TRACE_INTEGRITY_RECORD_NOT_EXISTS); } String currentHash; String bizCode = record.getBizCode(); String bagSetHash = null; if (MesTraceIntegrityBizTypeEnum.BATCH.getType().equals(bizType)) { MesProBatchDO batch = batchMapper.selectById(bizId); if (batch == null) { throw exception(MES_TRACE_INTEGRITY_BIZ_NOT_EXISTS); } bizCode = batch.getCode(); currentHash = batchHash(batch); bagSetHash = bagSetHash(bizId); } else if (MesTraceIntegrityBizTypeEnum.SALES.getType().equals(bizType)) { MesWmProductSalesDO sales = productSalesMapper.selectById(bizId); if (sales == null) { throw exception(MES_TRACE_INTEGRITY_BIZ_NOT_EXISTS); } bizCode = sales.getCode(); currentHash = salesHash(sales, productSalesDetailMapper.selectListBySalesId(bizId)); } else { MesTraceConsumerScanEventDO event = scanEventMapper.selectById(bizId); if (event == null) { throw exception(MES_TRACE_INTEGRITY_BIZ_NOT_EXISTS); } bizCode = event.getNormalizedCode(); currentHash = scanHash(event); } boolean matched = StrUtil.equals(record.getContentHash(), currentHash); return new MesTraceIntegrityVerifyRespVO() .setBizType(bizType) .setBizId(bizId) .setBizCode(bizCode) .setMatched(matched) .setStoredHash(record.getContentHash()) .setCurrentHash(currentHash) .setBagSetHash(bagSetHash) .setMessage(matched ? "数据完整,未被篡改" : "数据不一致,可能存在篡改"); } @Override public PageResult getIntegrityPage(MesTraceIntegrityRecordPageReqVO reqVO) { return integrityRecordMapper.selectPage(reqVO); } // ==================== 私有方法 ==================== /** * 批次摘要:仅覆盖创建后不再变更的核心字段,不含状态、袋码数量等可变字段 */ private String batchHash(MesProBatchDO batch) { StringBuilder sb = new StringBuilder(); sb.append(StrUtil.nullToEmpty(batch.getCode())).append('|') .append(nvl(batch.getItemId())).append('|') .append(StrUtil.nullToEmpty(batch.getLineCode())).append('|') .append(nvl(batch.getProduceDate())).append('|') .append(nvl(batch.getPlanQuantity())); return DigestUtil.sha256Hex(sb.toString()); } /** * 出库摘要:明细袋码/托盘码 id 排序后拼入,保证明细顺序不影响摘要 */ private String salesHash(MesWmProductSalesDO sales, List details) { List codeIds = details.stream() .map(d -> d.getBagCodeId() != null ? d.getBagCodeId() : d.getPalletCodeId()) .filter(Objects::nonNull) .sorted(Comparator.naturalOrder()) .toList(); StringBuilder sb = new StringBuilder(); sb.append(StrUtil.nullToEmpty(sales.getCode())).append('|') .append(nvl(sales.getClientId())).append('|') .append(nvl(sales.getSalesDate())).append('|') .append(codeIds); return DigestUtil.sha256Hex(sb.toString()); } /** * 扫码事件摘要:事件仅 insert 不改,全字段安全 */ private String scanHash(MesTraceConsumerScanEventDO event) { StringBuilder sb = new StringBuilder(); sb.append(StrUtil.nullToEmpty(event.getNormalizedCode())).append('|') .append(StrUtil.nullToEmpty(event.getSourceType())).append('|') .append(StrUtil.nullToEmpty(event.getProvince())).append('|') .append(StrUtil.nullToEmpty(event.getCity())).append('|') .append(nvl(event.getSuccess())).append('|') .append(nvl(event.getEventTime())); return DigestUtil.sha256Hex(sb.toString()); } /** * 批次当前袋码集合摘要(实时计算,供人工比对袋码防篡改,不入库) */ private String bagSetHash(Long batchId) { List codes = bagCodeMapper.selectListByBatchId(batchId).stream() .map(MesProBagCodeDO::getCode) .filter(StrUtil::isNotBlank) .sorted() .toList(); return DigestUtil.sha256Hex(String.join("|", codes)); } /** * 摘要不存在时更新,否则覆盖(同一业务记录只保留最新摘要) */ private void upsertRecord(String bizType, Long bizId, String bizCode, String hash) { MesTraceIntegrityRecordDO record = integrityRecordMapper.selectByBiz(bizType, bizId); if (record == null) { integrityRecordMapper.insert(MesTraceIntegrityRecordDO.builder() .bizType(bizType) .bizId(bizId) .bizCode(bizCode) .contentHash(hash) .eventTime(LocalDateTime.now()) .build()); } else { integrityRecordMapper.updateById(MesTraceIntegrityRecordDO.builder() .id(record.getId()) .bizCode(bizCode) .contentHash(hash) .eventTime(LocalDateTime.now()) .build()); } } private String nvl(Object value) { return value == null ? "" : String.valueOf(value); } }