yeg
2026-09-29 b78295e8da96c3e5e76befeef9582ce5273dbb74
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
package com.battery.business.web;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Date;
import java.util.HexFormat;
import java.util.Map;
import java.util.UUID;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import com.battery.business.domain.TTestRealtimeRecord;
import com.battery.business.mapper.TTestMapper;
 
/**
 * 逐行接收的落库语义(生产 Mapper + H2 内存库):
 * 设备数据行不占系统轮询槽位,所以同一绝对秒内的多行可以各自成立;
 * 来源行身份(厂商测试编号 + 协议序号)承担幂等与冲突判定。
 */
public class VendorSamplePersistenceDatabaseTest
{
    private static final String IDENTITY = "neware76:21:36:1:2";
 
    private java.sql.Connection keepAlive;
    private JdbcTemplate jdbc;
    private TTestMapper mapper;
 
    @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);
        // H2 无 MySQL 的 HEX();测试中补一个等价别名,生产 SQL 仍在 MySQL 上验证。
        jdbc.execute("create alias HEX for 'com.battery.business.web.VendorSamplePersistenceDatabaseTest.hexOf'");
        jdbc.execute("create table t_test_realtime_record(record_id bigint auto_increment primary key,"
                + "test_id bigint, test_battery_id bigint, binding_id bigint, adapter_code varchar(64),"
                + "source_identity varchar(256) not null, source_record_id varchar(128),"
                + "collector_id bigint, collector_channel_code varchar(100), collector_sequence_id bigint,"
                + "collector_device_time_utc timestamp(3), collector_device_time_raw varchar(64),"
                + "collector_adapter_timezone_id varchar(64),"
                + "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), cycle_no int, step_no int, step_type varchar(64),"
                + "step_name varchar(191), voltage_v decimal(30,12), current_a decimal(30,12),"
                + "capacity_ah decimal(30,12), energy_wh decimal(30,12), temperature decimal(30,12),"
                + "positive_potential decimal(30,12), negative_potential decimal(30,12),"
                + "extension_measurements_json varchar(2000), capture_slot_utc timestamp(3) null, "
                + "server_received_at_utc timestamp(3) not null, content_sha256 binary(32) not null,"
                // 轮询槽位唯一键允许多个空值:逐行接收因此可以把同一秒的多行都保存下来。
                + "unique(test_id, test_battery_id, capture_slot_utc))");
 
        SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
        factory.setDataSource(source);
        factory.setTypeAliasesPackage("com.battery.business.domain");
        factory.setMapperLocations(new org.springframework.core.io.ClassPathResource("mapper/battery/TTestMapper.xml"));
        mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TTestMapper.class);
    }
 
    @AfterEach void closeDatabase() throws Exception { if (keepAlive != null) keepAlive.close(); }
 
    @Test void sameSecondRowsAreKeptSeparatelyAndRepeatIsRecognisedBySourceRowIdentity()
    {
        TTestRealtimeRecord first = row(7437L, "2026-09-28 09:55:56", "2026-09-28T01:55:56Z");
        assertEquals(1, mapper.insertRealtimeRecord(first));
 
        // 同一绝对秒的第二行:槽位为空,唯一键不冲突,两行都成立。
        TTestRealtimeRecord second = row(7438L, "2026-09-28 09:55:56", "2026-09-28T01:55:56Z");
        assertEquals(1, mapper.insertRealtimeRecord(second));
 
        assertEquals(2, jdbc.queryForObject("select count(*) from t_test_realtime_record", Integer.class));
        // 幂等与冲突按来源行身份判定:命中同身份即视为重复,由调用方比较内容摘要。
        assertEquals(hex(first.getContentSha256()),
                mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7437"));
        assertNull(mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7439"));
        assertTrue(!hex(first.getContentSha256()).equals(hex(second.getContentSha256())));
    }
 
    @Test void deviceRowKeepsVendorRawTimeServerReceiveTimeAndNoPollingSlot()
    {
        mapper.insertRealtimeRecord(row(7437L, "2026-09-28 09:55:56", "2026-09-28T01:55:56Z"));
 
        Map<String, Object> stored = jdbc.queryForMap("select source_record_id, source_identity,"
                + " cabinet_device_time_raw, cabinet_device_time_utc, server_received_at_utc, capture_slot_utc,"
                + " cabinet_adapter_timezone_id, extension_measurements_json, voltage_v"
                + " from t_test_realtime_record");
        assertEquals("neware:440:7437", stored.get("source_record_id"));
        assertEquals(IDENTITY, stored.get("source_identity"));
        // 设备时间按适配器时区解释后以 UTC 保存,同时保留原始时间文本与服务器接收时间。
        assertEquals("2026-09-28 09:55:56", stored.get("cabinet_device_time_raw"));
        assertEquals("Asia/Hong_Kong", stored.get("cabinet_adapter_timezone_id"));
        assertEquals(Instant.parse("2026-09-28T01:55:56Z"),
                ((java.sql.Timestamp) stored.get("cabinet_device_time_utc")).toInstant());
        assertEquals(Instant.parse("2026-09-28T02:01:00Z"),
                ((java.sql.Timestamp) stored.get("server_received_at_utc")).toInstant());
        assertNull(stored.get("capture_slot_utc"));
        // H2 的 JSON 列会把 JSON 文本再引一层,这里只断言内容确实保存下来。
        assertTrue(String.valueOf(stored.get("extension_measurements_json")).contains("relativeTimeSeconds"));
        assertEquals(0, new BigDecimal("1.3733").compareTo((BigDecimal) stored.get("voltage_v")));
    }
 
    /** 供 H2 别名使用的 HEX 等价实现;H2 没有 MySQL 的 HEX()。 */
    public static String hexOf(byte[] value)
    {
        return value == null ? null : HexFormat.of().formatHex(value);
    }
 
    private TTestRealtimeRecord row(long sequenceId, String raw, String deviceTimeUtc)
    {
        TTestRealtimeRecord row = new TTestRealtimeRecord();
        row.setTestId(1L); row.setTestBatteryId(4L); row.setBindingId(2L); row.setAdapterCode("neware76");
        row.setSourceIdentity(IDENTITY); row.setSourceRecordId("neware:440:" + sequenceId);
        row.setCabinetDevtype(21); row.setCabinetDevid(36); row.setCabinetSubdevid(1); row.setCabinetChlid(2);
        row.setCabinetSequenceId(sequenceId);
        row.setCabinetDeviceTimeUtc(Date.from(Instant.parse(deviceTimeUtc)));
        row.setCabinetDeviceTimeRaw(raw);
        row.setCabinetAdapterTimezoneId("Asia/Hong_Kong");
        row.setCaptureSlotUtc(null);
        row.setServerReceivedAtUtc(Date.from(Instant.parse("2026-09-28T02:01:00Z")));
        row.setCycleNo(3); row.setStepNo(12); row.setStepType("2");
        row.setVoltageV(new BigDecimal("1.3733")); row.setCurrentA(new BigDecimal("-1.1889"));
        row.setCapacityAh(new BigDecimal("1.398612")); row.setEnergyWh(new BigDecimal("0.030648"));
        row.setExtensionMeasurementsJson("{\"relativeTimeSeconds\":300}");
        // 内容摘要把协议序号算进去,使同秒不同行的内容彼此不同。
        row.setContentSha256(sha256("neware:440:" + sequenceId));
        return row;
    }
 
    private static String hex(byte[] value)
    {
        return HexFormat.of().formatHex(value);
    }
 
    private static byte[] sha256(String value)
    {
        try
        {
            return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
        }
        catch (Exception exception)
        {
            throw new IllegalStateException(exception);
        }
    }
 
}