3 小时以前 b29d86d9ed34fd890eb14cb7a75f6ae4f7148865
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
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 实现类
 * <p>
 * 摘要仅覆盖不可变核心字段,避免正常业务流转(草稿→生产中→完成、出库后状态更新等)导致误报。
 *
 * @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<MesTraceIntegrityRecordDO> 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<MesWmProductSalesDetailDO> details) {
        List<Long> 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<String> 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);
    }
 
}