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<TTestDataRevisionAction> selectActions(TTestDataRevisionAction query)
|
{
|
if (query == null) query = new TTestDataRevisionAction();
|
if (query.getDataType() != null && !Set.of("REALTIME", "STEP", "CYCLE").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<DataRevisionItem> items)
|
{
|
if (action == null || action.getTestId() == null) throw new ServiceException("测试ID不能为空");
|
if (action.getDataType() == null || !Set.of("REALTIME", "STEP", "CYCLE").contains(action.getDataType())) throw new ServiceException("数据类型无效");
|
if (action.getOperationType() == null || !Set.of("EDIT", "IMPORT", "DELETE").contains(action.getOperationType())) throw new ServiceException("操作类型无效");
|
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<Map<String, Object>> before = new ArrayList<>();
|
List<Map<String, Object>> changes = new ArrayList<>();
|
List<Runnable> mutations = new ArrayList<>();
|
if ("IMPORT".equals(action.getOperationType()))
|
{
|
if (!"COMPLETED".equals(test.getTestStatus())) throw new ServiceException("测试完成后才可以导入数据");
|
List<Map> rows;
|
try { rows = JSON.parseArray(action.getChangesSummary(), Map.class); }
|
catch (RuntimeException ex) { throw new ServiceException("导入数据格式无效"); }
|
if (rows == null || rows.isEmpty()) throw new ServiceException("导入数据不能为空");
|
Map<String, Map<String, Object>> identities = new LinkedHashMap<>();
|
Map<String, Map<String, Object>> bindings = new LinkedHashMap<>();
|
for (Map<String, Object> existing : mapper.selectImportDataRecords(action.getTestId(), action.getDataType()))
|
{
|
identities.put(importIdentity(existing), existing);
|
}
|
List<String> errors = new ArrayList<>();
|
List<String> addedIdentities = new ArrayList<>();
|
action.setImportedCount(0); action.setSkippedCount(0);
|
for (int index = 0; index < rows.size(); index++)
|
{
|
Map<String, Object> 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);
|
Map<String, Object> binding = bindings.computeIfAbsent(batteryNo, key -> mapper.selectImportBinding(action.getTestId(), key));
|
Map<String, Object> values = importedRecord(action, row, binding);
|
String identity = importIdentity(values);
|
Map<String, Object> previous = identities.get(identity);
|
if (previous != null)
|
{
|
if (!sameImportContent(values, previous)) throw new ServiceException("列「记录编号」:相同来源身份和编号存在不同内容,冲突拒绝");
|
action.setSkippedCount(action.getSkippedCount() + 1);
|
continue;
|
}
|
identities.put(identity, values);
|
addedIdentities.add(identity);
|
mutations.add(() -> mapper.insertImportedDataRecord(action.getDataType(), values));
|
changes.add(new LinkedHashMap<>(row));
|
}
|
catch (ServiceException ex) { errors.add("第" + sourceRow + "行," + ex.getMessage()); }
|
}
|
// 先完成整页校验,再写入修订与数据;测试行锁同时串行化重复提交和整体删除。
|
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<String> 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<String, DataRevisionItem> edits = new LinkedHashMap<>();
|
for (DataRevisionItem item : items == null ? List.<DataRevisionItem>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<String, Object> 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<String, Object> fields;
|
try { fields = JSON.parseObject(edits.get(identity).getFieldValues(), Map.class); }
|
catch (RuntimeException ex) { throw new ServiceException("修改字段格式无效"); }
|
Map<String, Object> 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<String, Object> 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<String, Object> 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 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<String, String> 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<String, Object> editableValues(String type, Map<String, Object> fields)
|
{
|
if (fields == null || fields.isEmpty()) throw new ServiceException("修改内容不能为空");
|
Map<String, Object> values = new LinkedHashMap<>();
|
Map<String, String> 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<String> STEP_EXTENSION_FIELDS = Set.of("stepDuration", "startVoltage", "chargePeakVoltage", "dischargeValleyVoltage");
|
|
private void mergeStepExtension(String type, Object previous, Map<String, Object> fields, Map<String, Object> values)
|
{
|
if (!"STEP".equals(type) || fields.keySet().stream().noneMatch(STEP_EXTENSION_FIELDS::contains)) return;
|
Map<String, Object> 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<String, Object> importedRecord(TTestDataRevisionAction action, Map<String, Object> row, Map<String, Object> 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<String, Object> 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<String, Object> metrics = new LinkedHashMap<>();
|
columns(action.getDataType()).keySet().forEach(key -> metrics.put(key, importMetric(row, key)));
|
values.putAll(editableValues(action.getDataType(), metrics));
|
if ("STEP".equals(action.getDataType()))
|
{
|
Map<String, Object> extension = new TreeMap<>();
|
STEP_EXTENSION_FIELDS.forEach(key -> extension.put(key, importMetric(row, key)));
|
mergeStepExtension(action.getDataType(), null, extension, values);
|
}
|
if (!"CYCLE".equals(action.getDataType()))
|
{
|
values.put("step_no", importInteger(row, "stepNo", "工步序号", Integer.MAX_VALUE));
|
values.put("step_name", importText(row, "stepName", "工步名称", 191));
|
}
|
if ("REALTIME".equals(action.getDataType()))
|
{
|
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> values, Map<String, Object> previous)
|
{
|
// 比较当前数据库内容,不能用编辑前的内容哈希把已修订记录误判为相同数据。
|
Set<String> 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<String, Object> expected = JSON.parseObject(String.valueOf(left));
|
Map<String, Object> actual = right == null ? Map.of() : JSON.parseObject(right.toString());
|
for (String field : expected.keySet()) 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);
|
}
|
}
|