yeg
2026-09-06 561ff691ae9b58dc534ebe625d63471d040d3160
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
238
239
240
241
242
243
244
245
246
247
package com.battery.business.web;
 
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashMap;
import java.util.UUID;
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.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
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.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.test.util.ReflectionTestUtils;
 
/** 使用生产 Mapper 和 Spring 事务验证导入;内存数据库不连接共享开发库。 */
class ConsoleSingleSheetImportDatabaseTest
{
    private java.sql.Connection keepAlive;
    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();
        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))");
        jdbc.execute("insert into t_test(test_id,test_name,test_status,reception_status) values(1,'导入测试','COMPLETED','ENDED')");
        jdbc.execute("create table t_test_battery(test_battery_id bigint primary key, test_id bigint, battery_no varchar(100), del_flag int default 0)");
        jdbc.execute("insert into t_test_battery values(4,1,'BAT-1',0)");
        jdbc.execute("create table t_test_channel_binding(binding_id bigint, test_battery_id bigint, channel_id bigint)");
        jdbc.execute("insert into t_test_channel_binding values(5,4,11)");
        jdbc.execute("create table t_detection_channel(channel_id bigint, cabinet_id bigint, devtype int, devid int, subdevid int, chlid int)");
        jdbc.execute("insert into t_detection_channel values(11,1,27,1,1,2)");
        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))");
        // 从 V7 的非空槽位起点执行生产迁移,覆盖存量库升级路径。
        org.springframework.jdbc.datasource.init.ScriptUtils.executeSqlScript(keepAlive,
                new ClassPathResource("db/migration-final/V8__import_records_without_polling_slots.sql"));
        String deviceTime = "dedupe_key varchar(512) not null, device_time_utc timestamp(3) not null, device_time_raw varchar(64) not null, adapter_timezone_id varchar(64) not null, unique(test_battery_id,source_identity,dedupe_key)";
        jdbc.execute("create table t_test_step_record(step_record_id bigint auto_increment primary key," + common + "," + metrics + "," + deviceTime + ")");
        jdbc.execute("create table t_test_cycle_record(cycle_record_id bigint auto_increment primary key," + common + ",charge_capacity_ah decimal(30,12),discharge_capacity_ah decimal(30,12),charge_energy_wh decimal(30,12),discharge_energy_wh decimal(30,12),efficiency decimal(30,12)," + deviceTime + ")");
        var factory = new SqlSessionFactoryBean(); factory.setDataSource(source);
        factory.setTypeAliasesPackage("com.battery.business.domain");
        factory.setMapperLocations(new ClassPathResource("mapper/battery/TTestMapper.xml"));
        mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TTestMapper.class);
        var proxy = new ProxyFactory(new TestConsoleDataServiceImpl(mapper));
        proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
        service = (ITestConsoleDataService) proxy.getProxy();
        controller = new TTestController() {
            @Override public Long getUserId() { return 1L; }
            @Override public String getUsername() { return "tester"; }
        };
        ReflectionTestUtils.setField(controller, "consoleDataService", service);
    }
 
    @AfterEach void closeDatabase() throws Exception { if (keepAlive != null) keepAlive.close(); }
 
    @Test void threeTypesPersistActualBindingsTimesAndNumbersAndSkipRetries()
    {
        for (String type : List.of("REALTIME", "STEP", "CYCLE"))
        {
            assertEquals(1, submit(type, List.of(row())).get("importedCount"));
            assertEquals(1, submit(type, List.of(row())).get("skippedCount"));
            var records = mapper.selectImportDataRecords(1L, type);
            assertEquals(1, records.size());
            assertEquals("17", records.get(0).get("source_record_id"));
            assertEquals("adapter-a:27:1:1:2", records.get(0).get("source_identity"));
            String timeColumn = type.equals("REALTIME") ? "cabinet_device_time_raw" : "device_time_raw";
            assertEquals("2023-05-08 11:42:11", records.get(0).get(timeColumn));
            String storedTimeColumn = type.equals("REALTIME") ? "cabinet_device_time_utc" : "device_time_utc";
            assertEquals(1683517331000L, ((java.util.Date) records.get(0).get(storedTimeColumn)).getTime());
        }
        assertEquals("17", mapper.selectPersistedFinalRealtimeRecords(1L).get(0).get("recordno"));
        assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size());
    }
 
    @Test void databaseFailureRollsBackEarlierRowsAndTheRevision()
    {
        var failing = row(); failing.put("recordNo", "18"); failing.put("recordedAt", 1683517391000L); failing.put("energy", 999);
        assertThrows(RuntimeException.class, () -> submit("REALTIME", List.of(row(), failing)));
        assertTrue(mapper.selectImportDataRecords(1L, "REALTIME").isEmpty());
        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);
        assertEquals(2, submit("REALTIME", List.of(row(), next)).get("importedCount"));
        assertEquals(2, submit("REALTIME", List.of(row(), next)).get("skippedCount"));
        var records = mapper.selectImportDataRecords(1L, "REALTIME");
        assertEquals(2, records.size());
        for (var record : records) {
            assertNull(record.get("capture_slot_utc"));
            assertEquals(1683517331000L, ((java.util.Date) record.get("cabinet_device_time_utc")).getTime());
        }
    }
 
    @Test
    @org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable(named = "TICKET03_SAMPLE_JSON", matches = ".+")
    void suppliedPartyWorkbookImportsAllRowsAndSkipsEveryRetry() throws Exception
    {
        var sample = JSON.parseObject(java.nio.file.Files.readString(java.nio.file.Path.of(System.getenv("TICKET03_SAMPLE_JSON"))));
        for (String type : List.of("REALTIME", "STEP", "CYCLE"))
        {
            List<Map<String, Object>> rows = (List) sample.getJSONArray(type).toJavaList(Map.class);
            int count = switch (type) { case "REALTIME" -> 7605; case "STEP" -> 129; default -> 33; };
            assertEquals(count, submit(type, rows).get("importedCount"));
            assertEquals(count, mapper.selectImportDataRecords(1L, type).size());
            assertEquals(count, submit(type, rows).get("skippedCount"));
        }
        assertEquals(3, service.selectActions(new TTestDataRevisionAction()).size());
    }
 
    private Map<String, Object> row()
    {
        var row = new LinkedHashMap<String, Object>(Map.of("recordNo", "17", "batteryNo", "BAT-1", "recordedAt", 1683517331000L,
                "cycleNo", 1, "stepNo", 1, "stepName", "恒流放电", "sequenceId", 17, "capacity", 8.5122));
        row.put("deviceTimeRaw", "2023-05-08 11:42:11"); row.put("adapterTimezoneId", "Asia/Hong_Kong"); row.put("stepDuration", 3752);
        return row;
    }
 
    private com.ruoyi.common.core.domain.AjaxResult submit(String type, List<Map<String, Object>> rows)
    {
        return controller.dataAction(1L, new TTestController.ConsoleDataActionRequest(type, "IMPORT", "单页导入", "[]",
                JSON.toJSONString(rows), "开发测试", List.of()));
    }
}