package com.battery.business.service.impl; import java.math.BigDecimal; import java.security.MessageDigest; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Date; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.Objects; import java.util.TreeMap; import com.alibaba.fastjson2.JSON; import com.battery.business.domain.DataRevisionItem; import com.battery.business.domain.TTest; import com.battery.business.domain.TTestDataRevisionAction; import com.battery.business.mapper.TTestMapper; import com.battery.business.service.ITestConsoleDataService; import com.ruoyi.common.exception.ServiceException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** 在测试行锁内保存修改前完整事实,再编辑、导入或物理删除三类记录。 */ @Service public class TestConsoleDataServiceImpl implements ITestConsoleDataService { private final TTestMapper mapper; public TestConsoleDataServiceImpl(TTestMapper mapper) { this.mapper = mapper; } /** 查询只追加的修订动作。 */ @Override public List selectActions(TTestDataRevisionAction query) { if (query == null) query = new TTestDataRevisionAction(); if (query.getDataType() != null && !Set.of("REALTIME", "STEP", "CYCLE", "ALL").contains(query.getDataType())) throw new ServiceException("数据类型无效"); if (query.getOperationType() != null && !Set.of("EDIT", "IMPORT", "DELETE").contains(query.getOperationType())) throw new ServiceException("操作类型无效"); return mapper.selectDataRevisionActions(query); } /** 查询修订动作详情及修改前内容。 */ @Override public TTestDataRevisionAction selectActionById(Long actionId) { return mapper.selectDataRevisionActionById(actionId); } /** * 一次提交与接收、测试整体删除互斥;任一目标无效时整次动作回滚。导入仅允许检测完成的测试。 * @return 新增修订编号;导入全部重复时返回 null,不新增修订,跳过数量写入 action。 */ @Override @Transactional(rollbackFor = Exception.class) public Long submitAction(TTestDataRevisionAction action, List items) { if (action == null || action.getTestId() == null) throw new ServiceException("测试ID不能为空"); if (action.getDataType() == null || !Set.of("REALTIME", "STEP", "CYCLE", "ALL").contains(action.getDataType())) throw new ServiceException("数据类型无效"); if (action.getOperationType() == null || !Set.of("EDIT", "IMPORT", "DELETE").contains(action.getOperationType())) throw new ServiceException("操作类型无效"); if ("ALL".equals(action.getDataType()) && !"IMPORT".equals(action.getOperationType())) throw new ServiceException("三 Sheet 仅支持导入"); if (action.getReason() == null || action.getReason().isBlank()) throw new ServiceException("数据操作原因不能为空"); if (action.getScopeSummary() == null || action.getScopeSummary().isBlank()) throw new ServiceException("数据操作范围不能为空"); TTest test = mapper.selectTestByIdForUpdate(action.getTestId()); if (test == null) throw new ServiceException("测试不存在"); action.setCreatedAtUtc(new Date()); action.setReason(action.getReason().trim()); List> before = new ArrayList<>(); List> changes = new ArrayList<>(); List mutations = new ArrayList<>(); if ("IMPORT".equals(action.getOperationType())) { if (!"COMPLETED".equals(test.getTestStatus())) throw new ServiceException("测试完成后才可以导入数据"); boolean workbook = "ALL".equals(action.getDataType()); Map>> sheets = importSheets(action, workbook); List errors = new ArrayList<>(); List addedIdentities = new ArrayList<>(); Map> bindings = new LinkedHashMap<>(); Set expectedBatteries = null; action.setImportedCount(0); action.setSkippedCount(0); for (var sheet : sheets.entrySet()) { Set batteries = prepareImport(action, sheet.getKey(), sheet.getValue(), workbook, bindings, errors, addedIdentities, changes, mutations); if (workbook && expectedBatteries != null && !expectedBatteries.equals(batteries)) errors.add(sheetLabel(sheet.getKey()) + "工作表,列「电池号」:三个 Sheet 的电池号集合不一致"); expectedBatteries = batteries; } // 三类全量校验后才执行写入,共享测试行锁、事务和唯一修订编号;失败不留下部分数据或修订。 if (!errors.isEmpty()) throw new ServiceException(String.join(";", errors)); action.setImportedCount(changes.size()); action.setIdentitySummary(JSON.toJSONString(addedIdentities)); if (changes.isEmpty()) return null; } else { List identities; try { identities = JSON.parseArray(action.getIdentitySummary(), String.class); } catch (RuntimeException ex) { throw new ServiceException("身份摘要格式无效"); } if (identities == null || identities.isEmpty() || new HashSet<>(identities).size() != identities.size()) throw new ServiceException("请选择不重复的数据记录"); Map edits = new LinkedHashMap<>(); for (DataRevisionItem item : items == null ? List.of() : items) if (item == null || edits.put(item.getStableIdentity(), item) != null) throw new ServiceException("修改目标无效或重复"); if ("EDIT".equals(action.getOperationType()) && !edits.keySet().equals(new HashSet<>(identities))) throw new ServiceException("修改目标与身份摘要不一致"); for (String identity : identities) { long id = recordId(action.getDataType(), identity); Map row = mapper.selectDataRecord(action.getTestId(), action.getDataType(), id); if (row == null || row.isEmpty()) throw new ServiceException("数据不存在或不属于当前测试"); before.add(new LinkedHashMap<>(row)); if ("DELETE".equals(action.getOperationType())) mutations.add(() -> mapper.deleteDataRecord(action.getTestId(), action.getDataType(), id)); else { Map fields; try { fields = JSON.parseObject(edits.get(identity).getFieldValues(), Map.class); } catch (RuntimeException ex) { throw new ServiceException("修改字段格式无效"); } Map values = editableValues(action.getDataType(), fields); mergeStepExtension(action.getDataType(), row.get("extension_measurements_json"), fields, values); mutations.add(() -> mapper.updateDataRecord(action.getTestId(), action.getDataType(), id, values)); Map displayBefore = new LinkedHashMap<>(); columns(action.getDataType()).forEach((field, column) -> displayBefore.put(field, row.get(column))); if ("STEP".equals(action.getDataType()) && row.get("extension_measurements_json") != null) { Map extensionBefore = JSON.parseObject(row.get("extension_measurements_json").toString()); STEP_EXTENSION_FIELDS.forEach(field -> displayBefore.put(field, extensionBefore.get(field))); } changes.add(Map.of("identity", identity, "before", displayBefore, "after", fields)); } } } // 修改前内容从数据库读取,不信任浏览器提交的 before;事务失败不会留下孤立修订。 action.setBeforeContentJson(JSON.toJSONString(before)); action.setChangesSummary(JSON.toJSONString("DELETE".equals(action.getOperationType()) ? before : changes)); mapper.insertDataRevisionAction(action); mutations.forEach(Runnable::run); return action.getActionId(); } private Map>> importSheets(TTestDataRevisionAction action, boolean workbook) { Map>> sheets = new LinkedHashMap<>(); try { Object parsed = JSON.parse(action.getChangesSummary()); if (workbook && (!(parsed instanceof Map map) || !map.keySet().equals(Set.of("REALTIME", "STEP", "CYCLE")))) throw new ServiceException("三 Sheet 必须包含实时、工步、循环工作表"); for (String type : workbook ? List.of("REALTIME", "STEP", "CYCLE") : List.of(action.getDataType())) { Object value = workbook ? ((Map) parsed).get(type) : parsed; if (!(value instanceof List rows) || rows.isEmpty()) throw new ServiceException(sheetLabel(type) + "工作表:导入数据不能为空"); List> records = new ArrayList<>(); for (Object row : rows) { if (row != null && !(row instanceof Map)) throw new ServiceException(sheetLabel(type) + "工作表:数据行格式无效"); records.add((Map) row); } sheets.put(type, records); } } catch (ServiceException ex) { throw ex; } catch (RuntimeException ex) { throw new ServiceException("导入数据格式无效"); } return sheets; } private Set prepareImport(TTestDataRevisionAction action, String type, List> rows, boolean workbook, Map> bindings, List errors, List addedIdentities, List> changes, List mutations) { Map> identities = new LinkedHashMap<>(); for (Map existing : mapper.selectImportDataRecords(action.getTestId(), type)) identities.put(importIdentity(existing), existing); Set batteries = new HashSet<>(); for (int index = 0; index < rows.size(); index++) { Map row = rows.get(index); Object sourceRow = row == null ? index + 2 : row.getOrDefault("sourceRow", index + 2); try { if (row == null) throw new ServiceException("列「记录编号」:数据行不能为空"); String batteryNo = importText(row, "batteryNo", "电池号", 100); batteries.add(batteryNo); Map binding = bindings.computeIfAbsent(batteryNo, key -> mapper.selectImportBinding(action.getTestId(), key)); Map values = importedRecord(action, type, row, binding); String identity = importIdentity(values); Map previous = identities.get(identity); if (previous != null) { if (!sameImportContent(values, previous)) throw new ServiceException("列「记录编号」:相同来源身份和编号存在不同内容,冲突拒绝"); action.setSkippedCount(action.getSkippedCount() + 1); continue; } identities.put(identity, values); String revisionIdentity = workbook ? type + ":" + identity : identity; addedIdentities.add(revisionIdentity); mutations.add(() -> mapper.insertImportedDataRecord(type, values)); Map change = new LinkedHashMap<>(row); if (workbook) { change.put("dataType", type); change.put("identity", revisionIdentity); } changes.add(change); } catch (ServiceException ex) { errors.add((workbook ? sheetLabel(type) + "工作表," : "") + "第" + sourceRow + "行," + ex.getMessage()); } } return batteries; } private String sheetLabel(String type) { return switch (type) { case "REALTIME" -> "实时"; case "STEP" -> "工步"; default -> "循环"; }; } private long recordId(String type, String identity) { String prefix = switch (type) { case "REALTIME" -> "realtime:"; case "STEP" -> "step:"; default -> "cycle:"; }; try { if (identity == null || !identity.startsWith(prefix)) throw new NumberFormatException(); long id = Long.parseLong(identity.substring(prefix.length())); if (id < 1) throw new NumberFormatException(); return id; } catch (NumberFormatException ex) { throw new ServiceException("数据稳定身份无效"); } } private Map columns(String type) { return switch (type) { case "REALTIME" -> Map.of("voltage", "voltage_v", "current", "current_a", "capacity", "capacity_ah", "energy", "energy_wh", "temperature", "temperature", "positivePotential", "positive_potential", "negativePotential", "negative_potential"); case "STEP" -> Map.of("endVoltage", "voltage_v", "capacity", "capacity_ah", "energy", "energy_wh"); default -> Map.of("chargeCapacity", "charge_capacity_ah", "dischargeCapacity", "discharge_capacity_ah", "chargeEnergy", "charge_energy_wh", "dischargeEnergy", "discharge_energy_wh", "efficiency", "efficiency"); }; } private Map editableValues(String type, Map fields) { if (fields == null || fields.isEmpty()) throw new ServiceException("修改内容不能为空"); Map values = new LinkedHashMap<>(); Map columns = columns(type); fields.forEach((key, value) -> { if ("STEP".equals(type) && STEP_EXTENSION_FIELDS.contains(key)) return; String column = columns.get(key); if (column == null) throw new ServiceException("字段不可修改: " + key); try { values.put(column, value == null ? null : new BigDecimal(value.toString())); } catch (RuntimeException ex) { throw new ServiceException("测量值必须为数值或空值: " + key); } }); return values; } private static final Set STEP_EXTENSION_FIELDS = Set.of("stepDuration", "startVoltage", "chargePeakVoltage", "dischargeValleyVoltage"); private void mergeStepExtension(String type, Object previous, Map fields, Map values) { if (!"STEP".equals(type) || fields.keySet().stream().noneMatch(STEP_EXTENSION_FIELDS::contains)) return; Map extension = previous == null ? new LinkedHashMap<>() : JSON.parseObject(previous.toString()); for (String field : STEP_EXTENSION_FIELDS) { if (!fields.containsKey(field)) continue; Object value = fields.get(field); try { extension.put(field, value == null ? null : new BigDecimal(value.toString())); } catch (RuntimeException ex) { throw new ServiceException("测量值必须为数值或空值: " + field); } } values.put("extension_measurements_json", JSON.toJSONString(extension)); } private Map importedRecord(TTestDataRevisionAction action, String type, Map row, Map binding) { if (binding == null || List.of("test_battery_id", "binding_id", "adapter_code", "devtype", "devid", "subdevid", "chlid") .stream().anyMatch(key -> binding.get(key) == null)) throw new ServiceException("列「电池号」:不属于当前测试或未绑定完整检测柜通道"); Map values = new LinkedHashMap<>(); for (String field : List.of("test_battery_id", "binding_id", "adapter_code")) values.put(field, binding.get(field)); values.put("test_id", action.getTestId()); values.put("source_identity", String.join(":", List.of("adapter_code", "devtype", "devid", "subdevid", "chlid") .stream().map(key -> String.valueOf(binding.get(key))).toList())); values.put("source_record_id", importText(row, "recordNo", "记录编号", 128)); Date time; try { Object raw = row.get("recordedAt"); if (raw == null) raw = row.get("deviceTime"); if (!(raw instanceof Number number)) throw new IllegalArgumentException(); time = new Date(new BigDecimal(number.toString()).longValueExact()); int year = time.toInstant().atZone(ZoneOffset.UTC).getYear(); if (year < 1000 || year > 9999) throw new IllegalArgumentException(); } catch (RuntimeException ex) { throw new ServiceException("列「设备时间」:必须为有效的整数毫秒时间戳"); } String rawTime = row.get("deviceTimeRaw") == null ? time.toInstant().toString() : importText(row, "deviceTimeRaw", "设备时间原文", 64); String timezone = row.get("adapterTimezoneId") == null ? "UTC" : String.valueOf(row.get("adapterTimezoneId")); if (!Set.of("UTC", "Asia/Hong_Kong").contains(timezone)) throw new ServiceException("列「设备时区」:仅支持 UTC 或 Asia/Hong_Kong"); values.put("server_received_at_utc", action.getCreatedAtUtc()); values.put("cycle_no", importInteger(row, "cycleNo", "循环序号", Integer.MAX_VALUE)); Map metrics = new LinkedHashMap<>(); columns(type).keySet().forEach(key -> metrics.put(key, importMetric(row, key))); values.putAll(editableValues(type, metrics)); if ("STEP".equals(type)) { Map extension = new TreeMap<>(); STEP_EXTENSION_FIELDS.forEach(key -> extension.put(key, importMetric(row, key))); mergeStepExtension(type, null, extension, values); } if (!"CYCLE".equals(type)) { values.put("step_no", importInteger(row, "stepNo", "工步序号", Integer.MAX_VALUE)); values.put("step_name", importText(row, "stepName", "工步名称", 191)); } if ("REALTIME".equals(type)) { values.put("cabinet_sequence_id", importInteger(row, "sequenceId", "记录序号", 9007199254740991L)); for (String field : List.of("devtype", "devid", "subdevid", "chlid")) values.put("cabinet_" + field, binding.get(field)); // 导入不是轮询采样;同秒可有不同记录,设备时间不能为槽位唯一性而偏移。 values.put("capture_slot_utc", null); values.put("cabinet_device_time_utc", time); values.put("cabinet_device_time_raw", rawTime); values.put("cabinet_adapter_timezone_id", timezone); } else { values.put("dedupe_key", "record:" + values.get("source_record_id")); values.put("device_time_utc", time); values.put("device_time_raw", rawTime); values.put("adapter_timezone_id", timezone); } try { values.put("content_sha256", MessageDigest.getInstance("SHA-256").digest(JSON.toJSONBytes(row))); } catch (Exception ex) { throw new IllegalStateException(ex); } return values; } private String importText(Map row, String field, String label, int max) { String value = row.get(field) == null ? "" : row.get(field).toString().trim(); if (value.isEmpty() || value.length() > max) throw new ServiceException("列「" + label + "」:不能为空且长度不能超过" + max); return value; } private long importInteger(Map row, String field, String label, long max) { try { long value = new BigDecimal(String.valueOf(row.get(field))).longValueExact(); if (value < 1 || value > max) throw new IllegalArgumentException(); return value; } catch (RuntimeException ex) { throw new ServiceException("列「" + label + "」:必须是范围内的正整数"); } } private BigDecimal importMetric(Map row, String field) { Object raw = row.get(field); if (raw == null) return null; try { BigDecimal value = new BigDecimal(raw.toString()).stripTrailingZeros(); if (Math.max(value.scale(), 0) > 12 || value.precision() - value.scale() > 18) throw new IllegalArgumentException(); return value; } catch (RuntimeException ex) { throw new ServiceException("列「" + metricLabel(field) + "」:必须是最多12位小数的有效数值或空值"); } } private String metricLabel(String field) { return switch (field) { case "voltage" -> "电压(V)"; case "current" -> "电流(A)"; case "capacity" -> "容量(Ah)"; case "energy" -> "能量(Wh)"; case "temperature" -> "温度(℃)"; case "positivePotential" -> "正电位(V)"; case "negativePotential" -> "负电位(V)"; case "endVoltage" -> "终止电压(V)"; case "startVoltage" -> "起始电压(V)"; case "stepDuration" -> "工步时间"; case "chargePeakVoltage" -> "充电峰值电压(V)"; case "dischargeValleyVoltage" -> "放电谷值电压(V)"; case "chargeCapacity" -> "充电容量(Ah)"; case "dischargeCapacity" -> "放电容量(Ah)"; case "chargeEnergy" -> "充电能量(Wh)"; case "dischargeEnergy" -> "放电能量(Wh)"; default -> "充放电效率(%)"; }; } private String importIdentity(Map row) { return JSON.toJSONString(List.of(row.get("test_battery_id"), row.get("source_identity"), String.valueOf(row.get("source_record_id")))); } private boolean sameImportContent(Map values, Map previous) { // 比较当前数据库内容,不能用编辑前的内容哈希把已修订记录误判为相同数据。 Set metadata = Set.of("server_received_at_utc", "capture_slot_utc", "content_sha256", "dedupe_key", "device_time_raw", "adapter_timezone_id", "cabinet_device_time_raw", "cabinet_adapter_timezone_id"); for (String key : values.keySet()) { if (metadata.contains(key)) continue; Object left = values.get(key), right = previous.get(key); if ("extension_measurements_json".equals(key)) { Map expected = JSON.parseObject(String.valueOf(left)); Map actual = right == null ? Map.of() : JSON.parseObject(right.toString()); // 已有额外非空测量也属于内容差异;缺失与空值仍等价。 Set fields = new HashSet<>(expected.keySet()); fields.addAll(actual.keySet()); for (String field : fields) if (!sameValue(expected.get(field), actual.get(field))) return false; } else if (!sameValue(left, right)) return false; } return true; } private boolean sameValue(Object left, Object right) { if (left instanceof Number && right instanceof Number) return new BigDecimal(left.toString()).compareTo(new BigDecimal(right.toString())) == 0; if (left instanceof Date l && right instanceof Date r) return l.getTime() == r.getTime(); return Objects.equals(left, right); } }