From 561ff691ae9b58dc534ebe625d63471d040d3160 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期日, 06 九月 2026 00:17:28 +0800
Subject: [PATCH] 实现三类历史数据原子导入与共同修订批次
---
battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java | 133 ++++++++++++------
battery-business/src/main/java/com/battery/business/service/ITestConsoleDataService.java | 2
ruoyi-admin/src/main/resources/db/migration-final/V9__workbook_import_revision.sql | 5
ruoyi-admin/src/test/java/com/battery/business/web/ConsoleSingleSheetImportDatabaseTest.java | 102 ++++++++++++++
scripts/test-workbook-import.ps1 | 29 ++++
battery-business/src/main/resources/mapper/battery/TTestMapper.xml | 9
ruoyi-admin/src/test/java/com/battery/business/web/ConsoleWorkbookImportMysqlTest.java | 118 ++++++++++++++++
7 files changed, 348 insertions(+), 50 deletions(-)
diff --git a/battery-business/src/main/java/com/battery/business/service/ITestConsoleDataService.java b/battery-business/src/main/java/com/battery/business/service/ITestConsoleDataService.java
index 5e9128c..cc81ccd 100644
--- a/battery-business/src/main/java/com/battery/business/service/ITestConsoleDataService.java
+++ b/battery-business/src/main/java/com/battery/business/service/ITestConsoleDataService.java
@@ -15,6 +15,6 @@
- /** 在一个事务中写入一次行为、覆盖状态并执行请求的物理删除。 */
+ /** 在测试行锁和事务内执行一次修订;ALL 导入共用修订编号作为三 Sheet 批次号。 */
Long submitAction(TTestDataRevisionAction action, List<DataRevisionItem> overrides);
}
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java
index 65c89dd..f57eb5f 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/TestConsoleDataServiceImpl.java
@@ -33,7 +33,7 @@
@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.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);
}
@@ -50,8 +50,9 @@
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.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());
@@ -64,45 +65,22 @@
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);
- }
+ boolean workbook = "ALL".equals(action.getDataType());
+ Map<String, List<Map<String, Object>>> sheets = importSheets(action, workbook);
List<String> errors = new ArrayList<>();
List<String> addedIdentities = new ArrayList<>();
+ Map<String, Map<String, Object>> bindings = new LinkedHashMap<>();
+ Set<String> expectedBatteries = null;
action.setImportedCount(0); action.setSkippedCount(0);
- for (int index = 0; index < rows.size(); index++)
+ for (var sheet : sheets.entrySet())
{
- 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()); }
+ Set<String> 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));
@@ -153,6 +131,75 @@
mutations.forEach(Runnable::run);
return action.getActionId();
}
+
+ private Map<String, List<Map<String, Object>>> importSheets(TTestDataRevisionAction action, boolean workbook)
+ {
+ Map<String, List<Map<String, Object>>> 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<Map<String, Object>> records = new ArrayList<>();
+ for (Object row : rows)
+ {
+ if (row != null && !(row instanceof Map)) throw new ServiceException(sheetLabel(type) + "工作表:数据行格式无效");
+ records.add((Map<String, Object>) row);
+ }
+ sheets.put(type, records);
+ }
+ }
+ catch (ServiceException ex) { throw ex; }
+ catch (RuntimeException ex) { throw new ServiceException("导入数据格式无效"); }
+ return sheets;
+ }
+
+ private Set<String> prepareImport(TTestDataRevisionAction action, String type, List<Map<String, Object>> rows,
+ boolean workbook, Map<String, Map<String, Object>> bindings, List<String> errors,
+ List<String> addedIdentities, List<Map<String, Object>> changes, List<Runnable> mutations)
+ {
+ Map<String, Map<String, Object>> identities = new LinkedHashMap<>();
+ for (Map<String, Object> existing : mapper.selectImportDataRecords(action.getTestId(), type))
+ identities.put(importIdentity(existing), existing);
+ Set<String> batteries = new HashSet<>();
+ 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);
+ batteries.add(batteryNo);
+ Map<String, Object> binding = bindings.computeIfAbsent(batteryNo, key -> mapper.selectImportBinding(action.getTestId(), key));
+ Map<String, Object> values = importedRecord(action, type, 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);
+ String revisionIdentity = workbook ? type + ":" + identity : identity;
+ addedIdentities.add(revisionIdentity);
+ mutations.add(() -> mapper.insertImportedDataRecord(type, values));
+ Map<String, Object> 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)
{
@@ -207,7 +254,7 @@
values.put("extension_measurements_json", JSON.toJSONString(extension));
}
- private Map<String, Object> importedRecord(TTestDataRevisionAction action, Map<String, Object> row, Map<String, Object> binding)
+ private Map<String, Object> importedRecord(TTestDataRevisionAction action, String type, 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))
@@ -233,20 +280,20 @@
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()))
+ columns(type).keySet().forEach(key -> metrics.put(key, importMetric(row, key)));
+ values.putAll(editableValues(type, metrics));
+ if ("STEP".equals(type))
{
Map<String, Object> extension = new TreeMap<>();
STEP_EXTENSION_FIELDS.forEach(key -> extension.put(key, importMetric(row, key)));
- mergeStepExtension(action.getDataType(), null, extension, values);
+ mergeStepExtension(type, null, extension, values);
}
- if (!"CYCLE".equals(action.getDataType()))
+ 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(action.getDataType()))
+ 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));
diff --git a/battery-business/src/main/resources/mapper/battery/TTestMapper.xml b/battery-business/src/main/resources/mapper/battery/TTestMapper.xml
index 0e2f941..dea8dae 100644
--- a/battery-business/src/main/resources/mapper/battery/TTestMapper.xml
+++ b/battery-business/src/main/resources/mapper/battery/TTestMapper.xml
@@ -192,16 +192,15 @@
<result property="createdAtUtc" column="created_at_utc"/>
</resultMap>
<select id="selectDataRevisionActions" parameterType="com.battery.business.domain.TTestDataRevisionAction" resultMap="TTestDataRevisionActionResultMap">
+ <!-- 列表只排序摘要字段;大批次完整 JSON 留给详情查询,避免 MySQL 排序内存耗尽。 -->
select a.action_id, a.test_id, t.test_name, t.reception_status,
- a.data_type, a.operation_type, a.scope_summary,
- cast(a.identity_summary as char) identity_summary,
- cast(a.changes_summary as char) changes_summary, a.reason,
- cast(a.before_content_json as char) before_content_json, a.operator_id, a.operator_name, a.created_at_utc
+ a.data_type, a.operation_type, a.scope_summary, a.reason,
+ a.operator_id, a.operator_name, a.created_at_utc
from t_test_data_revision_action a join t_test t on t.test_id = a.test_id
<where>
<if test="testId != null">and a.test_id = #{testId}</if>
<if test="testName != null and testName != ''">and t.test_name like concat('%', #{testName}, '%')</if>
- <if test="dataType != null and dataType != ''">and a.data_type = #{dataType}</if>
+ <if test="dataType != null and dataType != ''">and (a.data_type = #{dataType} or a.data_type = 'ALL')</if>
<if test="operationType != null and operationType != ''">and a.operation_type = #{operationType}</if>
<if test="beginCreatedAt != null">and a.created_at_utc >= #{beginCreatedAt}</if>
<if test="endCreatedAt != null">and a.created_at_utc < #{endCreatedAt}</if>
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V9__workbook_import_revision.sql b/ruoyi-admin/src/main/resources/db/migration-final/V9__workbook_import_revision.sql
new file mode 100644
index 0000000..cfa5310
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V9__workbook_import_revision.sql
@@ -0,0 +1,5 @@
+-- 三 Sheet 共用一次修订,ALL 仅表示跨三类数据的导入动作,详见 ADR-0051。
+ALTER TABLE t_test_data_revision_action DROP CONSTRAINT chk_data_revision_type;
+ALTER TABLE t_test_data_revision_action
+ ADD CONSTRAINT chk_data_revision_type
+ CHECK (data_type IN ('REALTIME', 'STEP', 'CYCLE') OR (data_type = 'ALL' AND operation_type = 'IMPORT'));
diff --git a/ruoyi-admin/src/test/java/com/battery/business/web/ConsoleSingleSheetImportDatabaseTest.java b/ruoyi-admin/src/test/java/com/battery/business/web/ConsoleSingleSheetImportDatabaseTest.java
index cce4e80..d2b0686 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/web/ConsoleSingleSheetImportDatabaseTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/web/ConsoleSingleSheetImportDatabaseTest.java
@@ -31,12 +31,13 @@
private TTestController controller;
private TTestMapper mapper;
private ITestConsoleDataService service;
+ private JdbcTemplate jdbc;
@BeforeEach void setUp() throws Exception
{
var source = new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
keepAlive = source.getConnection();
- var jdbc = new JdbcTemplate(source);
+ jdbc = new JdbcTemplate(source);
jdbc.execute("create table sys_user(user_id bigint, nick_name varchar(64), del_flag varchar(1))");
jdbc.execute("create table t_test_type(test_type_id bigint, test_type_name varchar(64), del_flag int)");
jdbc.execute("create table t_test(test_id bigint primary key, test_name varchar(100), creator_id bigint, principal_id bigint, test_type_id bigint, test_purpose varchar(100), test_status varchar(20), reception_status varchar(20), monitor_timeout_minutes int, started_at_utc timestamp, reception_boundary_at_utc timestamp, ended_at_utc timestamp, del_flag int default 0, create_by varchar(64), create_time timestamp, update_by varchar(64), update_time timestamp, remark varchar(100), logic_step_mappings_json varchar(100), console_detail_json varchar(100))");
@@ -50,6 +51,9 @@
jdbc.execute("create table t_detection_cabinet(cabinet_id bigint, adapter_code varchar(64))");
jdbc.execute("insert into t_detection_cabinet values(1,'adapter-a')");
jdbc.execute("create table t_test_data_revision_action(action_id bigint auto_increment primary key, test_id bigint, data_type varchar(20), operation_type varchar(20), scope_summary varchar(200), identity_summary json, before_content_json json, changes_summary json, reason varchar(200), operator_id bigint, operator_name varchar(64), created_at_utc timestamp)");
+ jdbc.execute("alter table t_test_data_revision_action add constraint chk_data_revision_type check (data_type in ('REALTIME','STEP','CYCLE'))");
+ org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(keepAlive,
+ new ClassPathResource("db/migration-final/V9__workbook_import_revision.sql"));
String common = "test_id bigint not null, test_battery_id bigint not null, binding_id bigint not null, adapter_code varchar(64) not null, source_identity varchar(256) not null, source_record_id varchar(128), server_received_at_utc timestamp(3), cycle_no int, content_sha256 binary(32) not null, extension_measurements_json varchar(2000)";
String metrics = "step_no int, step_name varchar(191), step_type varchar(64), voltage_v decimal(30,12), current_a decimal(30,12), capacity_ah decimal(30,12), energy_wh decimal(30,12) check (energy_wh <> 999)";
jdbc.execute("create table t_test_realtime_record(record_id bigint auto_increment primary key," + common + "," + metrics + ", cabinet_devtype int, cabinet_devid int, cabinet_subdevid int, cabinet_chlid int, cabinet_sequence_id bigint, cabinet_device_time_utc timestamp(3), cabinet_device_time_raw varchar(64), cabinet_adapter_timezone_id varchar(64), capture_slot_utc timestamp(3) not null, temperature decimal(30,12), positive_potential decimal(30,12), negative_potential decimal(30,12), collector_id bigint, collector_channel_code varchar(100), collector_device_time_utc timestamp(3), unique(test_id,test_battery_id,capture_slot_utc))");
@@ -102,6 +106,102 @@
assertTrue(service.selectActions(new TTestDataRevisionAction()).isEmpty());
}
+ @Test void workbookImportsThreeSheetsWithOneRevisionAndSkipsRetry()
+ {
+ var sheets = Map.of("REALTIME", List.of(row(), row()), "STEP", List.of(row()), "CYCLE", List.of(row()));
+ var result = submitWorkbook(sheets);
+ assertEquals(3, result.get("importedCount"));
+ assertEquals(1, result.get("skippedCount"));
+ assertNotNull(result.get("actionId"));
+ for (String type : List.of("REALTIME", "STEP", "CYCLE"))
+ {
+ assertEquals(1, mapper.selectImportDataRecords(1L, type).size());
+ var query = new TTestDataRevisionAction(); query.setDataType(type);
+ assertEquals(1, service.selectActions(query).size());
+ }
+ var actions = service.selectActions(new TTestDataRevisionAction());
+ assertEquals(1, actions.size());
+ assertEquals("ALL", actions.get(0).getDataType());
+ assertEquals("三页验收", actions.get(0).getReason());
+ var retry = submitWorkbook(sheets);
+ assertEquals(0, retry.get("importedCount"));
+ assertEquals(4, retry.get("skippedCount"));
+ assertNull(retry.get("actionId"));
+ assertEquals(1, service.selectActions(new TTestDataRevisionAction()).size());
+ }
+
+ private com.ruoyi.common.core.domain.AjaxResult submitWorkbook(Map<String, List<Map<String, Object>>> sheets)
+ {
+ return controller.dataAction(1L, new TTestController.ConsoleDataActionRequest("ALL", "IMPORT", "三 Sheet 导入", "[]",
+ JSON.toJSONString(sheets), "三页验收", List.of()));
+ }
+
+ @Test void workbookLateDatabaseFailureRollsBackAllSheetsAndRevision()
+ {
+ var cycle = row(); cycle.put("dischargeEnergy", 999);
+ jdbc.execute("alter table t_test_cycle_record add check (discharge_energy_wh <> 999)");
+ assertThrows(RuntimeException.class, () -> submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(cycle))));
+ assertWorkbookEmpty();
+ }
+
+ @Test void workbookMissingEmptyInvalidOrConflictingSheetNeverWritesAnySheet()
+ {
+ var sheets = new LinkedHashMap<String, List<Map<String, Object>>>();
+ sheets.put("REALTIME", List.of(row())); sheets.put("STEP", List.of(row()));
+ assertThrows(RuntimeException.class, () -> submitWorkbook(sheets));
+ assertWorkbookEmpty();
+ sheets.put("CYCLE", List.of());
+ assertThrows(RuntimeException.class, () -> submitWorkbook(sheets));
+ assertWorkbookEmpty();
+ var invalid = row(); invalid.put("cycleNo", 0); invalid.put("sourceRow", 23);
+ sheets.put("CYCLE", List.of(invalid));
+ assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("循环工作表,第23行"));
+ assertWorkbookEmpty();
+ var conflict = row(); conflict.put("dischargeCapacity", 88);
+ sheets.put("CYCLE", List.of(row(), conflict));
+ assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("冲突拒绝"));
+ assertWorkbookEmpty();
+ }
+
+ @Test void workbookRejectsDifferentBatterySetsEvenWhenBothBatteriesBelongToTest()
+ {
+ jdbc.execute("insert into t_test_battery values(6,1,'BAT-2',0)");
+ jdbc.execute("insert into t_test_channel_binding values(7,6,11)");
+ var other = row(); other.put("batteryNo", "BAT-2");
+ var sheets = Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(other));
+ assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("电池号集合不一致"));
+ assertWorkbookEmpty();
+ other.put("batteryNo", "OTHER");
+ assertTrue(assertThrows(RuntimeException.class, () -> submitWorkbook(sheets)).getMessage().contains("不属于当前测试"));
+ assertWorkbookEmpty();
+ }
+
+ @Test void workbookConflictWithExistingCyclePreservesFactsAndRejectsOtherSheets()
+ {
+ submit("CYCLE", List.of(row()));
+ var conflict = row(); conflict.put("dischargeCapacity", 88);
+ assertThrows(RuntimeException.class, () -> submitWorkbook(Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(conflict))));
+ assertTrue(mapper.selectImportDataRecords(1L, "REALTIME").isEmpty());
+ assertTrue(mapper.selectImportDataRecords(1L, "STEP").isEmpty());
+ assertEquals(1, mapper.selectImportDataRecords(1L, "CYCLE").size());
+ assertEquals(1, service.selectActions(new TTestDataRevisionAction()).size());
+ }
+
+ @Test void workbookCannotImportBeforeTestCompletionOrWithoutReason()
+ {
+ var sheets = Map.of("REALTIME", List.of(row()), "STEP", List.of(row()), "CYCLE", List.of(row()));
+ assertThrows(RuntimeException.class, () -> controller.dataAction(1L, new TTestController.ConsoleDataActionRequest("ALL", "IMPORT", "三 Sheet 导入", "[]", JSON.toJSONString(sheets), " ", List.of())));
+ jdbc.update("update t_test set test_status='RUNNING', reception_status='RECEIVING' where test_id=1");
+ assertThrows(RuntimeException.class, () -> submitWorkbook(sheets));
+ assertWorkbookEmpty();
+ }
+
+ private void assertWorkbookEmpty()
+ {
+ for (String type : List.of("REALTIME", "STEP", "CYCLE")) assertTrue(mapper.selectImportDataRecords(1L, type).isEmpty());
+ assertTrue(service.selectActions(new TTestDataRevisionAction()).isEmpty());
+ }
+
@Test void differentRecordsInTheSameSecondPreserveDeviceTime()
{
var next = row(); next.put("recordNo", "18"); next.put("sequenceId", 18); next.put("stepNo", 2);
diff --git a/ruoyi-admin/src/test/java/com/battery/business/web/ConsoleWorkbookImportMysqlTest.java b/ruoyi-admin/src/test/java/com/battery/business/web/ConsoleWorkbookImportMysqlTest.java
new file mode 100644
index 0000000..093c2e5
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/web/ConsoleWorkbookImportMysqlTest.java
@@ -0,0 +1,118 @@
+package com.battery.business.web;
+
+import static org.junit.jupiter.api.Assertions.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.DriverManager;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import com.alibaba.fastjson2.JSON;
+import com.battery.business.domain.TTestDataRevisionAction;
+import com.battery.business.mapper.TTestMapper;
+import com.battery.business.service.ITestConsoleDataService;
+import com.battery.business.service.impl.TestConsoleDataServiceImpl;
+import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
+import org.flywaydb.core.Flyway;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.mybatis.spring.SqlSessionFactoryBean;
+import org.mybatis.spring.SqlSessionTemplate;
+import org.springframework.aop.framework.ProxyFactory;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
+import org.springframework.transaction.interceptor.TransactionInterceptor;
+
+/** 仅在脚本提供的随机隔离库内验证真实 MySQL 迁移、事务和甲方全量样例。 */
+@EnabledIfEnvironmentVariable(named = "WORKBOOK_TEST_SCHEMA", matches = "ticket04_import_[a-f0-9]{32}")
+class ConsoleWorkbookImportMysqlTest
+{
+ @Test void fullWorkbookMigrationTransactionRevisionAndWarningIsolation() throws Exception
+ {
+ String schema = System.getenv("WORKBOOK_TEST_SCHEMA");
+ String user = System.getenv("WORKBOOK_TEST_USER"), password = System.getenv("WORKBOOK_TEST_PASSWORD");
+ String base = "jdbc:mysql://127.0.0.1:6134/";
+ String options = "?useSSL=false&allowPublicKeyRetrieval=true&connectionTimeZone=UTC";
+ try (var admin = DriverManager.getConnection(base + options, user, password); var sql = admin.createStatement())
+ {
+ sql.execute("create database " + schema + " character set utf8mb4 collate utf8mb4_0900_ai_ci");
+ try
+ {
+ String url = base + schema + options;
+ var configuration = Flyway.configure().dataSource(url, user, password).locations("classpath:db/migration-final").cleanDisabled(true);
+ configuration.target("8").load().migrate();
+ var flyway = configuration.target("9").load();
+ assertEquals(1, flyway.migrate().migrationsExecuted);
+ assertEquals(0, flyway.migrate().migrationsExecuted);
+ var source = new UnpooledDataSource("com.mysql.cj.jdbc.Driver", url, user, password);
+ var jdbc = new JdbcTemplate(source);
+ jdbc.execute("insert into t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status) values(1,'三 Sheet 验收',1,1,1,'隔离测试','COMPLETED','ENDED')");
+ jdbc.execute("insert into t_detection_cabinet(cabinet_id,adapter_code,cabinet_code,cabinet_name,vendor_device_type,vendor_device_id) values(1,'adapter-a','CAB1','检测柜',27,1)");
+ jdbc.execute("insert into t_detection_channel(channel_id,adapter_code,cabinet_id,devtype,devid,subdevid,chlid) values(11,'adapter-a',1,27,1,1,2)");
+ jdbc.execute("insert into t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) values(4,1,1,'BAT-1',1)");
+ jdbc.execute("insert into t_test_channel_binding(binding_id,test_id,test_battery_id,channel_id) values(5,1,4,11)");
+ jdbc.execute("insert into t_test_warning_threshold(test_id,metric_code,max_value,unit,severity) values(1,'TEMPERATURE',1,'C','WARNING')");
+ var factory = new SqlSessionFactoryBean(); factory.setDataSource(source);
+ factory.setTypeAliasesPackage("com.battery.business.domain");
+ factory.setMapperLocations(new ClassPathResource("mapper/battery/TTestMapper.xml"));
+ var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TTestMapper.class);
+ var proxy = new ProxyFactory(new TestConsoleDataServiceImpl(mapper));
+ proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
+ var service = (ITestConsoleDataService) proxy.getProxy();
+ var controller = new TTestController() {
+ @Override public Long getUserId() { return 1L; }
+ @Override public String getUsername() { return "验收操作员"; }
+ };
+ ReflectionTestUtils.setField(controller, "consoleDataService", service);
+ var sample = JSON.parseObject(Files.readString(Path.of(System.getenv("TICKET03_SAMPLE_JSON"))));
+ Map<String, List<Map<String, Object>>> sheets = new LinkedHashMap<>();
+ for (String type : List.of("REALTIME", "STEP", "CYCLE")) sheets.put(type, (List) sample.getJSONArray(type).toJavaList(Map.class));
+ // 在最后一类写入制造数据库错误,确认前两类和修订一起回滚。
+ jdbc.execute("alter table t_test_cycle_record add constraint workbook_probe_failure check (cycle_no <> 2)");
+ assertThrows(RuntimeException.class, () -> submit(controller, sheets));
+ assertTrue(mapper.selectPersistedFinalRealtimeRecords(1L).isEmpty());
+ assertTrue(mapper.selectPersistedFinalStepRecords(1L).isEmpty());
+ assertTrue(mapper.selectPersistedFinalCycleRecords(1L).isEmpty());
+ assertTrue(service.selectActions(new TTestDataRevisionAction()).isEmpty());
+ jdbc.execute("alter table t_test_cycle_record drop constraint workbook_probe_failure");
+ var imported = submit(controller, sheets);
+ assertEquals(7767, imported.get("importedCount"));
+ assertEquals(7605, mapper.selectPersistedFinalRealtimeRecords(1L).size());
+ assertEquals(129, mapper.selectPersistedFinalStepRecords(1L).size());
+ assertEquals(33, mapper.selectPersistedFinalCycleRecords(1L).size());
+ var actions = service.selectActions(new TTestDataRevisionAction());
+ assertEquals(1, actions.size());
+ var revision = service.selectActionById(actions.get(0).getActionId());
+ assertEquals(imported.get("actionId"), revision.getActionId());
+ assertEquals("三 Sheet 共用原因", revision.getReason());
+ var changes = JSON.parseArray(revision.getChangesSummary());
+ assertEquals(7767, changes.size());
+ assertEquals("REALTIME", changes.getJSONObject(0).getString("dataType"));
+ assertEquals("STEP", changes.getJSONObject(7605).getString("dataType"));
+ assertEquals("CYCLE", changes.getJSONObject(7734).getString("dataType"));
+ for (String type : sheets.keySet()) {
+ var query = new TTestDataRevisionAction(); query.setTestId(1L); query.setDataType(type);
+ assertEquals(1, service.selectActions(query).size());
+ }
+ var retry = submit(controller, sheets);
+ assertEquals(0, retry.get("importedCount")); assertEquals(7767, retry.get("skippedCount")); assertNull(retry.get("actionId"));
+ sheets.get("CYCLE").get(32).put("dischargeCapacity", 99);
+ assertThrows(RuntimeException.class, () -> submit(controller, sheets));
+ assertEquals(1, service.selectActions(new TTestDataRevisionAction()).size());
+ assertTrue(mapper.selectWarningExceptions(1L, null).isEmpty());
+ assertEquals("COMPLETED", mapper.selectTestById(1L).getTestStatus());
+ assertEquals("ENDED", mapper.selectTestById(1L).getReceptionStatus());
+ }
+ finally { sql.execute("drop database " + schema); }
+ }
+ }
+
+ private com.ruoyi.common.core.domain.AjaxResult submit(TTestController controller, Map<String, List<Map<String, Object>>> sheets)
+ {
+ return controller.dataAction(1L, new TTestController.ConsoleDataActionRequest("ALL", "IMPORT", "三 Sheet 导入", "[]",
+ JSON.toJSONString(sheets), "三 Sheet 共用原因", List.of()));
+ }
+}
diff --git a/scripts/test-workbook-import.ps1 b/scripts/test-workbook-import.ps1
new file mode 100644
index 0000000..eb08799
--- /dev/null
+++ b/scripts/test-workbook-import.ps1
@@ -0,0 +1,29 @@
+param([switch] $FullSuite)
+$ErrorActionPreference = 'Stop'
+$workbookSuffix = [guid]::NewGuid().ToString('N')
+$workbookSchema = 'ticket04_import_' + $workbookSuffix
+$workbookUser = 'workbook_' + $workbookSuffix.Substring(0, 16)
+$workbookPassword = [guid]::NewGuid().ToString('N')
+
+function Invoke-WorkbookSql([string] $sql) {
+ $sql | wsl --exec docker exec -i infrastructure-mysql-1 sh -lc 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --batch'
+ if ($LASTEXITCODE -ne 0) { throw '隔离库管理命令失败' }
+}
+
+Push-Location (Join-Path $PSScriptRoot '..')
+try {
+ # 临时账号仅拥有本次随机库的权限,不启动服务、不修改共享开发库。
+ Invoke-WorkbookSql "CREATE USER '$workbookUser'@'%' IDENTIFIED BY '$workbookPassword'; GRANT ALL PRIVILEGES ON ${workbookSchema}.* TO '$workbookUser'@'%';"
+ $env:WORKBOOK_TEST_SCHEMA = $workbookSchema
+ $env:WORKBOOK_TEST_USER = $workbookUser
+ $env:WORKBOOK_TEST_PASSWORD = $workbookPassword
+ $env:TICKET03_SAMPLE_JSON = (Resolve-Path '../.scratch/simulator-and-data-import-v1/ticket03-sample.json').Path
+ if ($FullSuite) { mvn test }
+ else { mvn -pl ruoyi-admin -am test '-Dtest=ConsoleWorkbookImportMysqlTest' '-Dsurefire.failIfNoSpecifiedTests=false' }
+ if ($LASTEXITCODE -ne 0) { throw '三 Sheet 导入验证失败' }
+} finally {
+ Remove-Item Env:WORKBOOK_TEST_SCHEMA,Env:WORKBOOK_TEST_USER,Env:WORKBOOK_TEST_PASSWORD,Env:TICKET03_SAMPLE_JSON -ErrorAction SilentlyContinue
+ if ($workbookSchema -notmatch '^ticket04_import_[a-f0-9]{32}$' -or $workbookUser -notmatch '^workbook_[a-f0-9]{16}$') { throw '隔离资源名称校验失败' }
+ Invoke-WorkbookSql "DROP DATABASE IF EXISTS $workbookSchema; DROP USER IF EXISTS '$workbookUser'@'%';"
+ Pop-Location
+}
--
Gitblit v1.7.1