feat: productionize raw history ingestion
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package com.lingniu.ingest.api.consumer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Optional extension for consumers that can persist a Kafka poll batch in one
|
||||
* storage operation.
|
||||
*/
|
||||
public interface EnvelopeBatchIngestor extends EnvelopeIngestor {
|
||||
|
||||
List<EnvelopeIngestResult> tryIngestAll(List<byte[]> kafkaValues);
|
||||
}
|
||||
@@ -70,6 +70,32 @@ public final class EnvelopeConsumerProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
public List<EnvelopeIngestResult> processBatch(List<EnvelopeConsumerRecord> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
if (!(ingestor instanceof EnvelopeBatchIngestor batchIngestor)) {
|
||||
return records.stream().map(this::process).toList();
|
||||
}
|
||||
List<byte[]> payloads = records.stream()
|
||||
.map(EnvelopeConsumerRecord::payload)
|
||||
.toList();
|
||||
List<EnvelopeIngestResult> results = batchIngestor.tryIngestAll(payloads);
|
||||
if (results.size() != records.size()) {
|
||||
throw new IllegalStateException("batch ingestor returned " + results.size()
|
||||
+ " results for " + records.size() + " records");
|
||||
}
|
||||
List<EnvelopeIngestResult> copy = new ArrayList<>(results.size());
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
EnvelopeIngestResult result = results.get(i);
|
||||
if (DEAD_LETTER_STATUSES.contains(result.status())) {
|
||||
deadLetterSink.publish(toDeadLetter(records.get(i), result));
|
||||
}
|
||||
copy.add(result);
|
||||
}
|
||||
return List.copyOf(copy);
|
||||
}
|
||||
|
||||
private EnvelopeDeadLetterRecord toDeadLetter(EnvelopeConsumerRecord record, EnvelopeIngestResult result) {
|
||||
return new EnvelopeDeadLetterRecord(
|
||||
service,
|
||||
|
||||
@@ -19,6 +19,7 @@ public final class RawArchiveKeys {
|
||||
public static final String META_EVENT_ID = "rawArchiveEventId";
|
||||
public static final String META_KEY = "rawArchiveKey";
|
||||
public static final String META_URI = "rawArchiveUri";
|
||||
public static final String META_PARSED_JSON = "rawArchiveParsedJson";
|
||||
|
||||
private static final DateTimeFormatter DATE_KEY =
|
||||
DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
@@ -150,6 +150,7 @@ public sealed interface VehicleEvent
|
||||
* @param command 协议主命令码(如 32960 0x02/0x03 等),冗余在事件里便于按命令分片归档
|
||||
* @param infoType 协议子类型(可为 0),同上
|
||||
* @param rawBytes 原始入站字节,不可为 null
|
||||
* @param parsedJson 已解析结构化 JSON。只用于 RAW 明细查询,不应复制到位置/字段等派生表。
|
||||
*/
|
||||
record RawArchive(
|
||||
String eventId,
|
||||
@@ -161,6 +162,25 @@ public sealed interface VehicleEvent
|
||||
Map<String, String> metadata,
|
||||
int command,
|
||||
int infoType,
|
||||
byte[] rawBytes
|
||||
) implements VehicleEvent {}
|
||||
byte[] rawBytes,
|
||||
String parsedJson
|
||||
) implements VehicleEvent {
|
||||
public RawArchive(String eventId,
|
||||
String vin,
|
||||
ProtocolId source,
|
||||
Instant eventTime,
|
||||
Instant ingestTime,
|
||||
String traceId,
|
||||
Map<String, String> metadata,
|
||||
int command,
|
||||
int infoType,
|
||||
byte[] rawBytes) {
|
||||
this(eventId, vin, source, eventTime, ingestTime, traceId, metadata,
|
||||
command, infoType, rawBytes, "");
|
||||
}
|
||||
|
||||
public RawArchive {
|
||||
parsedJson = parsedJson == null ? "" : parsedJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.lingniu.ingest.core.dispatcher;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.lingniu.ingest.api.event.RawArchiveKeys;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.api.pipeline.IngestContext;
|
||||
@@ -36,6 +39,9 @@ public final class Dispatcher {
|
||||
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
|
||||
private static final AtomicLong RAW_ARCHIVE_SEQUENCE = new AtomicLong();
|
||||
private static final String REQUIRED_DURABLE_SINK = "kafka";
|
||||
private static final ObjectMapper RAW_ARCHIVE_JSON = JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.build();
|
||||
|
||||
private final HandlerRegistry registry;
|
||||
private final InterceptorChain interceptors;
|
||||
@@ -167,7 +173,10 @@ public final class Dispatcher {
|
||||
byte[] bytes = frame.rawBytes();
|
||||
if (bytes == null || bytes.length == 0) return RawArchiveLookup.empty();
|
||||
|
||||
Map<String, String> meta = frame.sourceMeta() == null ? Map.of() : frame.sourceMeta();
|
||||
Map<String, String> meta = frame.sourceMeta() == null
|
||||
? Map.of()
|
||||
: new LinkedHashMap<>(frame.sourceMeta());
|
||||
String parsedJson = parsedJson(frame.payload(), meta.remove(RawArchiveKeys.META_PARSED_JSON));
|
||||
String vin = meta.getOrDefault("vin", "");
|
||||
Instant ingestTime = frame.receivedAt() != null ? frame.receivedAt() : Instant.now();
|
||||
String eventId = nextRawArchiveEventId(ingestTime);
|
||||
@@ -184,11 +193,34 @@ public final class Dispatcher {
|
||||
archiveMeta,
|
||||
frame.command(),
|
||||
frame.infoType(),
|
||||
bytes);
|
||||
bytes,
|
||||
parsedJson);
|
||||
out.add(raw);
|
||||
return new RawArchiveLookup(eventId, key, RawArchiveKeys.logicalUri(key));
|
||||
}
|
||||
|
||||
private static String parsedJson(Object payload, String explicitParsedJson) {
|
||||
if (explicitParsedJson != null && !explicitParsedJson.isBlank()) {
|
||||
return explicitParsedJson;
|
||||
}
|
||||
if (payload == null) {
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
return RAW_ARCHIVE_JSON.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException ex) {
|
||||
return "{\"serializationError\":\"" + escapeJson(ex.getMessage()) + "\",\"payloadType\":\""
|
||||
+ escapeJson(payload.getClass().getName()) + "\"}";
|
||||
}
|
||||
}
|
||||
|
||||
private static String escapeJson(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static String nextRawArchiveEventId(Instant ingestTime) {
|
||||
// 文件名需要可排序且单进程内唯一:毫秒时间放大到微秒量级,再用 AtomicLong 递增兜底。
|
||||
long base = (ingestTime == null ? Instant.now() : ingestTime).toEpochMilli() * 1000L;
|
||||
|
||||
@@ -42,7 +42,7 @@ class DispatcherRawArchiveMetadataTest {
|
||||
ProtocolId.JT808,
|
||||
0x0200,
|
||||
0,
|
||||
new Object(),
|
||||
new SamplePayload("payload-0200", 7),
|
||||
new byte[]{0x7e, 0x01, 0x7e},
|
||||
Map.of("vin", "VIN001", "peer", "127.0.0.1:10001"),
|
||||
Instant.parse("2026-06-22T08:00:00Z")));
|
||||
@@ -52,6 +52,8 @@ class DispatcherRawArchiveMetadataTest {
|
||||
|
||||
String rawArchiveKey = raw.metadata().get("rawArchiveKey");
|
||||
assertThat(rawArchiveKey).isNotBlank();
|
||||
assertThat(raw.parsedJson()).contains("\"name\":\"payload-0200\"");
|
||||
assertThat(raw.metadata()).doesNotContainKey("rawArchiveParsedJson");
|
||||
assertThat(raw.eventId()).containsOnlyDigits();
|
||||
assertThat(rawArchiveKey).endsWith("/" + raw.eventId() + ".bin");
|
||||
assertThat(location.metadata())
|
||||
@@ -102,7 +104,7 @@ class DispatcherRawArchiveMetadataTest {
|
||||
|
||||
private static HandlerRegistry registryWithLocationHandler() throws NoSuchMethodException {
|
||||
LocationHandler bean = new LocationHandler();
|
||||
Method method = LocationHandler.class.getDeclaredMethod("onLocation", Object.class);
|
||||
Method method = LocationHandler.class.getDeclaredMethod("onLocation", SamplePayload.class);
|
||||
HandlerRegistry registry = new HandlerRegistry();
|
||||
registry.register(new HandlerDefinition(
|
||||
ProtocolId.JT808,
|
||||
@@ -169,7 +171,7 @@ class DispatcherRawArchiveMetadataTest {
|
||||
@ProtocolHandler(protocol = ProtocolId.JT808)
|
||||
static final class LocationHandler {
|
||||
@MessageMapping(command = 0x0200)
|
||||
VehicleEvent.Location onLocation(Object ignored) {
|
||||
VehicleEvent.Location onLocation(SamplePayload ignored) {
|
||||
return new VehicleEvent.Location(
|
||||
"location-1",
|
||||
"VIN001",
|
||||
@@ -182,6 +184,9 @@ class DispatcherRawArchiveMetadataTest {
|
||||
}
|
||||
}
|
||||
|
||||
record SamplePayload(String name, int value) {
|
||||
}
|
||||
|
||||
@ProtocolHandler(protocol = ProtocolId.JT808)
|
||||
public static final class AsyncLocationHandler {
|
||||
@MessageMapping(command = 0x0201)
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
|
||||
@@ -1,70 +1,64 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import com.lingniu.ingest.identity.config.VehicleIdentityProperties;
|
||||
import org.springframework.jdbc.core.ConnectionCallback;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.time.Duration;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.Map;
|
||||
|
||||
public final class MySqlVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry, AutoCloseable {
|
||||
public final class MySqlVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MySqlVehicleIdentityService.class);
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper mapper;
|
||||
private final String tableName;
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final String table;
|
||||
private final String registrationTable;
|
||||
private final ScheduledExecutorService refresher;
|
||||
private volatile InMemoryVehicleIdentityService index;
|
||||
|
||||
public MySqlVehicleIdentityService(DataSource dataSource, String table) {
|
||||
this(dataSource, table, Duration.ZERO);
|
||||
}
|
||||
|
||||
public MySqlVehicleIdentityService(DataSource dataSource, String table, Duration refreshInterval) {
|
||||
if (dataSource == null) {
|
||||
throw new IllegalArgumentException("dataSource must not be null");
|
||||
public MySqlVehicleIdentityService(DataSource dataSource,
|
||||
VehicleIdentityProperties.Mysql properties,
|
||||
ObjectMapper mapper) {
|
||||
this.jdbc = new JdbcTemplate(dataSource);
|
||||
this.mapper = mapper == null ? new ObjectMapper() : mapper;
|
||||
VehicleIdentityProperties.Mysql mysql = properties == null ? new VehicleIdentityProperties.Mysql() : properties;
|
||||
this.tableName = safeTableName(mysql.getTableName());
|
||||
if (mysql.isInitializeSchema()) {
|
||||
initializeSchema();
|
||||
}
|
||||
this.dataSource = dataSource;
|
||||
this.table = sanitizeTable(table);
|
||||
this.registrationTable = this.table + "_registration";
|
||||
initializeSchema();
|
||||
this.index = loadIndex();
|
||||
this.refresher = startRefresher(refreshInterval);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(VehicleIdentityBinding binding) {
|
||||
if (binding == null || !binding.hasResolvedVin()) {
|
||||
return;
|
||||
}
|
||||
upsert(binding.plate(), binding.vin());
|
||||
index.bind(binding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void register(VehicleRegistrationBinding registration) {
|
||||
if (registration == null) {
|
||||
return;
|
||||
}
|
||||
upsertRegistration(registration);
|
||||
if (registration.hasResolvedVin()) {
|
||||
bind(new VehicleIdentityBinding(
|
||||
registration.protocol(),
|
||||
registration.vin(),
|
||||
registration.phone(),
|
||||
registration.deviceId(),
|
||||
registration.plate()));
|
||||
}
|
||||
String metadataJson = metadataJson(binding.metadata());
|
||||
jdbc.update("""
|
||||
INSERT INTO %s
|
||||
(protocol, vin, phone, device_id, plate, province, city, maker, device_type, plate_color, metadata_json,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
vin = VALUES(vin),
|
||||
device_id = VALUES(device_id),
|
||||
province = VALUES(province),
|
||||
city = VALUES(city),
|
||||
maker = VALUES(maker),
|
||||
device_type = VALUES(device_type),
|
||||
plate_color = VALUES(plate_color),
|
||||
metadata_json = VALUES(metadata_json),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""".formatted(tableName),
|
||||
protocol(binding.protocol()), binding.vin(), binding.phone(), binding.deviceId(), binding.plate(),
|
||||
binding.metadata().getOrDefault("province", ""),
|
||||
binding.metadata().getOrDefault("city", ""),
|
||||
binding.metadata().getOrDefault("maker", ""),
|
||||
binding.metadata().getOrDefault("deviceType", ""),
|
||||
binding.metadata().getOrDefault("plateColor", ""),
|
||||
metadataJson);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,223 +66,167 @@ public final class MySqlVehicleIdentityService implements VehicleIdentityResolve
|
||||
if (lookup == null) {
|
||||
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
|
||||
}
|
||||
return index.resolve(lookup);
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
index = loadIndex();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (refresher != null) {
|
||||
refresher.shutdownNow();
|
||||
if (!lookup.vin().isBlank()) {
|
||||
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
|
||||
}
|
||||
VehicleIdentity byPhone = resolveBy("phone", lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE);
|
||||
if (byPhone != null) {
|
||||
return byPhone;
|
||||
}
|
||||
VehicleIdentity byDevice = resolveBy("device_id", lookup.protocol(), lookup.deviceId(), VehicleIdentitySource.BOUND_DEVICE_ID);
|
||||
if (byDevice != null) {
|
||||
return byDevice;
|
||||
}
|
||||
VehicleIdentity byPlate = resolveBy("plate", lookup.protocol(), lookup.plate(), VehicleIdentitySource.BOUND_PLATE);
|
||||
if (byPlate != null) {
|
||||
return byPlate;
|
||||
}
|
||||
if (!lookup.deviceId().isBlank()) {
|
||||
return new VehicleIdentity(lookup.deviceId(), false, VehicleIdentitySource.FALLBACK_DEVICE_ID);
|
||||
}
|
||||
if (!lookup.phone().isBlank()) {
|
||||
return new VehicleIdentity(lookup.phone(), false, VehicleIdentitySource.FALLBACK_PHONE);
|
||||
}
|
||||
if (!lookup.plate().isBlank()) {
|
||||
return new VehicleIdentity(lookup.plate(), false, VehicleIdentitySource.FALLBACK_PLATE);
|
||||
}
|
||||
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
|
||||
}
|
||||
|
||||
private VehicleIdentity resolveBy(String column, ProtocolId protocol, String externalId, VehicleIdentitySource source) {
|
||||
if (externalId == null || externalId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String safeColumn = switch (column) {
|
||||
case "phone", "device_id", "plate" -> column;
|
||||
default -> throw new IllegalArgumentException("unsupported identity lookup column: " + column);
|
||||
};
|
||||
List<VehicleIdentity> matches = jdbc.query("""
|
||||
SELECT vin
|
||||
FROM %s
|
||||
WHERE protocol = ? AND %s = ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""".formatted(tableName, safeColumn),
|
||||
(rs, rowNum) -> identity(rs, source),
|
||||
protocol(protocol), externalId.trim());
|
||||
return matches.isEmpty() ? null : matches.getFirst();
|
||||
}
|
||||
|
||||
private VehicleIdentity identity(ResultSet rs, VehicleIdentitySource source) throws SQLException {
|
||||
return new VehicleIdentity(rs.getString("vin"), true, source);
|
||||
}
|
||||
|
||||
private void initializeSchema() {
|
||||
String sql = """
|
||||
jdbc.execute("""
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
plate VARCHAR(128) NOT NULL,
|
||||
vin VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (plate),
|
||||
KEY idx_vehicle_identity_vin (vin)
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
vin VARCHAR(32) NOT NULL,
|
||||
phone VARCHAR(64) NOT NULL DEFAULT '',
|
||||
device_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
plate VARCHAR(64) NOT NULL DEFAULT '',
|
||||
province VARCHAR(32) NOT NULL DEFAULT '',
|
||||
city VARCHAR(32) NOT NULL DEFAULT '',
|
||||
maker VARCHAR(64) NOT NULL DEFAULT '',
|
||||
device_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||
plate_color VARCHAR(32) NOT NULL DEFAULT '',
|
||||
metadata_json TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_vehicle_identity_protocol_phone (protocol, phone),
|
||||
KEY idx_vehicle_identity_protocol_device (protocol, device_id),
|
||||
KEY idx_vehicle_identity_protocol_plate (protocol, plate)
|
||||
)
|
||||
""".formatted(table);
|
||||
String registrationSql = """
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
phone VARCHAR(64) NOT NULL,
|
||||
vin VARCHAR(64) NOT NULL DEFAULT 'unknown',
|
||||
device_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
plate VARCHAR(128) NOT NULL DEFAULT '',
|
||||
province INT NULL,
|
||||
city INT NULL,
|
||||
maker VARCHAR(64) NOT NULL DEFAULT '',
|
||||
device_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||
plate_color INT NULL,
|
||||
last_registered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (protocol, phone),
|
||||
KEY idx_vehicle_registration_vin (vin),
|
||||
KEY idx_vehicle_registration_device (protocol, device_id),
|
||||
KEY idx_vehicle_registration_plate (protocol, plate)
|
||||
)
|
||||
""".formatted(registrationTable);
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute(sql);
|
||||
statement.execute(registrationSql);
|
||||
} catch (SQLException e) {
|
||||
throw new IllegalStateException("vehicle identity mysql schema initialize failed: " + table, e);
|
||||
""".formatted(tableName));
|
||||
ensureColumn("phone", "phone VARCHAR(64) NOT NULL DEFAULT ''");
|
||||
ensureColumn("device_id", "device_id VARCHAR(64) NOT NULL DEFAULT ''");
|
||||
ensureColumn("plate", "plate VARCHAR(64) NOT NULL DEFAULT ''");
|
||||
ensureColumn("province", "province VARCHAR(32) NOT NULL DEFAULT ''");
|
||||
ensureColumn("city", "city VARCHAR(32) NOT NULL DEFAULT ''");
|
||||
ensureColumn("maker", "maker VARCHAR(64) NOT NULL DEFAULT ''");
|
||||
ensureColumn("device_type", "device_type VARCHAR(128) NOT NULL DEFAULT ''");
|
||||
ensureColumn("plate_color", "plate_color VARCHAR(32) NOT NULL DEFAULT ''");
|
||||
ensureColumn("metadata_json", "metadata_json TEXT");
|
||||
ensureColumn("created_at", "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP");
|
||||
ensureColumn("updated_at", "updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP");
|
||||
ensureIndex("idx_vehicle_identity_protocol_device", "CREATE INDEX idx_vehicle_identity_protocol_device ON %s (protocol, device_id)");
|
||||
ensureIndex("idx_vehicle_identity_protocol_plate", "CREATE INDEX idx_vehicle_identity_protocol_plate ON %s (protocol, plate)");
|
||||
}
|
||||
|
||||
private void ensureColumn(String columnName, String definition) {
|
||||
if (!columnExists(columnName)) {
|
||||
jdbc.execute("ALTER TABLE " + tableName + " ADD COLUMN " + definition);
|
||||
}
|
||||
}
|
||||
|
||||
private InMemoryVehicleIdentityService loadIndex() {
|
||||
InMemoryVehicleIdentityService loaded = new InMemoryVehicleIdentityService();
|
||||
String sql = "SELECT plate, vin FROM " + table;
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
bindGlobalPlate(loaded, resultSet.getString("vin"), resultSet.getString("plate"));
|
||||
private boolean columnExists(String columnName) {
|
||||
return Boolean.TRUE.equals(jdbc.execute((ConnectionCallback<Boolean>) connection -> {
|
||||
DatabaseMetaData metaData = connection.getMetaData();
|
||||
for (String table : nameVariants(tableName)) {
|
||||
for (String column : nameVariants(columnName)) {
|
||||
try (ResultSet rs = metaData.getColumns(null, null, table, column)) {
|
||||
if (rs.next()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loadRegistrationIndex(loaded, connection);
|
||||
return loaded;
|
||||
} catch (SQLException e) {
|
||||
throw new IllegalStateException("vehicle identity mysql index load failed: " + table, e);
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
private void ensureIndex(String indexName, String ddlTemplate) {
|
||||
if (!indexExists(indexName)) {
|
||||
jdbc.execute(ddlTemplate.formatted(tableName));
|
||||
}
|
||||
}
|
||||
|
||||
private static void bindGlobalPlate(InMemoryVehicleIdentityService loaded, String vin, String plate) {
|
||||
for (ProtocolId protocol : new ProtocolId[]{
|
||||
ProtocolId.GB32960,
|
||||
ProtocolId.JT808,
|
||||
ProtocolId.MQTT_YUTONG,
|
||||
ProtocolId.XINDA_PUSH
|
||||
}) {
|
||||
loaded.bind(new VehicleIdentityBinding(protocol, vin, "", "", plate));
|
||||
}
|
||||
}
|
||||
|
||||
private void loadRegistrationIndex(InMemoryVehicleIdentityService loaded, Connection connection) throws SQLException {
|
||||
String sql = "SELECT r.protocol, r.phone, r.device_id, r.plate, b.vin FROM " + registrationTable + " r "
|
||||
+ "JOIN " + table + " b ON b.plate = r.plate "
|
||||
+ "WHERE b.vin IS NOT NULL AND b.vin <> '' AND LOWER(b.vin) <> 'unknown'";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
loaded.bind(new VehicleIdentityBinding(
|
||||
protocol(resultSet.getString("protocol")),
|
||||
resultSet.getString("vin"),
|
||||
resultSet.getString("phone"),
|
||||
resultSet.getString("device_id"),
|
||||
resultSet.getString("plate")));
|
||||
private boolean indexExists(String indexName) {
|
||||
return Boolean.TRUE.equals(jdbc.execute((ConnectionCallback<Boolean>) connection -> {
|
||||
DatabaseMetaData metaData = connection.getMetaData();
|
||||
for (String table : nameVariants(tableName)) {
|
||||
try (ResultSet rs = metaData.getIndexInfo(null, null, table, false, false)) {
|
||||
while (rs.next()) {
|
||||
String existing = rs.getString("INDEX_NAME");
|
||||
if (existing != null && existing.equalsIgnoreCase(indexName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loadLegacyRegistrationVinIndex(loaded, connection);
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
private void loadLegacyRegistrationVinIndex(InMemoryVehicleIdentityService loaded, Connection connection) throws SQLException {
|
||||
String sql = "SELECT protocol, phone, device_id, plate, vin FROM " + registrationTable
|
||||
+ " WHERE vin IS NOT NULL AND vin <> '' AND LOWER(vin) <> 'unknown'";
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
while (resultSet.next()) {
|
||||
loaded.bind(new VehicleIdentityBinding(
|
||||
protocol(resultSet.getString("protocol")),
|
||||
resultSet.getString("vin"),
|
||||
resultSet.getString("phone"),
|
||||
resultSet.getString("device_id"),
|
||||
resultSet.getString("plate")));
|
||||
}
|
||||
}
|
||||
private static String[] nameVariants(String name) {
|
||||
return new String[]{
|
||||
name,
|
||||
name.toLowerCase(Locale.ROOT),
|
||||
name.toUpperCase(Locale.ROOT)
|
||||
};
|
||||
}
|
||||
|
||||
private ScheduledExecutorService startRefresher(Duration refreshInterval) {
|
||||
if (refreshInterval == null || refreshInterval.isZero() || refreshInterval.isNegative()) {
|
||||
return null;
|
||||
private String metadataJson(Map<String, String> metadata) {
|
||||
if (metadata == null || metadata.isEmpty()) {
|
||||
return "{}";
|
||||
}
|
||||
long delayMillis = Math.max(refreshInterval.toMillis(), 1000L);
|
||||
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "vehicle-identity-mysql-refresh");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
executor.scheduleWithFixedDelay(this::safeRefresh, delayMillis, delayMillis, TimeUnit.MILLISECONDS);
|
||||
return executor;
|
||||
}
|
||||
|
||||
private void safeRefresh() {
|
||||
try {
|
||||
refresh();
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("vehicle identity mysql refresh failed table={}", table, e);
|
||||
return mapper.writeValueAsString(metadata);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("vehicle identity metadata serialize failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void upsert(String plate, String vin) {
|
||||
String normalizedPlate = normalize(plate);
|
||||
if (normalizedPlate.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String sql = "INSERT INTO " + table + " (plate, vin) "
|
||||
+ "VALUES (?, ?) "
|
||||
+ "ON DUPLICATE KEY UPDATE vin = VALUES(vin)";
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, normalizedPlate);
|
||||
statement.setString(2, vin.trim());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new IllegalStateException("vehicle identity mysql bind failed: " + table, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void upsertRegistration(VehicleRegistrationBinding registration) {
|
||||
String phone = normalize(registration.phone());
|
||||
if (phone.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String vin = registration.hasResolvedVin() ? registration.vin().trim() : "unknown";
|
||||
String sql = "INSERT INTO " + registrationTable
|
||||
+ " (protocol, phone, vin, device_id, plate, province, city, maker, device_type, plate_color) "
|
||||
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
+ "ON DUPLICATE KEY UPDATE "
|
||||
+ "vin = CASE WHEN VALUES(vin) <> 'unknown' THEN VALUES(vin) ELSE vin END, "
|
||||
+ "device_id = VALUES(device_id), plate = VALUES(plate), province = VALUES(province), "
|
||||
+ "city = VALUES(city), maker = VALUES(maker), device_type = VALUES(device_type), "
|
||||
+ "plate_color = VALUES(plate_color), last_registered_at = CURRENT_TIMESTAMP";
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, protocolName(registration.protocol()));
|
||||
statement.setString(2, phone);
|
||||
statement.setString(3, vin);
|
||||
statement.setString(4, normalize(registration.deviceId()));
|
||||
statement.setString(5, normalize(registration.plate()));
|
||||
setInteger(statement, 6, registration.province());
|
||||
setInteger(statement, 7, registration.city());
|
||||
statement.setString(8, normalize(registration.maker()));
|
||||
statement.setString(9, normalize(registration.deviceType()));
|
||||
setInteger(statement, 10, registration.plateColor());
|
||||
statement.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
throw new IllegalStateException("vehicle identity mysql registration bind failed: " + registrationTable, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setInteger(PreparedStatement statement, int index, Integer value) throws SQLException {
|
||||
if (value == null) {
|
||||
statement.setObject(index, null);
|
||||
} else {
|
||||
statement.setInt(index, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static String sanitizeTable(String table) {
|
||||
String value = table == null || table.isBlank() ? "vehicle_identity_binding" : table.trim();
|
||||
if (!value.matches("[A-Za-z0-9_]+")) {
|
||||
throw new IllegalArgumentException("vehicle identity mysql table must contain only letters, digits, and underscore");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String protocolName(ProtocolId protocol) {
|
||||
private static String protocol(ProtocolId protocol) {
|
||||
return protocol == null ? "UNKNOWN" : protocol.name();
|
||||
}
|
||||
|
||||
private static ProtocolId protocol(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return ProtocolId.UNKNOWN;
|
||||
private static String safeTableName(String value) {
|
||||
String name = value == null || value.isBlank() ? "vehicle_identity_bindings" : value.trim();
|
||||
if (!name.matches("[A-Za-z0-9_]+")) {
|
||||
throw new IllegalArgumentException("invalid vehicle identity mysql table name: " + value);
|
||||
}
|
||||
try {
|
||||
return ProtocolId.valueOf(value.trim().toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return ProtocolId.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
return name.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,26 @@ package com.lingniu.ingest.identity;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record VehicleIdentityBinding(
|
||||
ProtocolId protocol,
|
||||
String vin,
|
||||
String phone,
|
||||
String deviceId,
|
||||
String plate
|
||||
String plate,
|
||||
Map<String, String> metadata
|
||||
) {
|
||||
public VehicleIdentityBinding(ProtocolId protocol, String vin, String phone, String deviceId, String plate) {
|
||||
this(protocol, vin, phone, deviceId, plate, Map.of());
|
||||
}
|
||||
|
||||
public VehicleIdentityBinding {
|
||||
vin = normalize(vin);
|
||||
phone = normalize(phone);
|
||||
deviceId = normalize(deviceId);
|
||||
plate = normalize(plate);
|
||||
metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
|
||||
if (vin.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.lingniu.ingest.identity;
|
||||
|
||||
public enum VehicleIdentitySource {
|
||||
EXPLICIT_VIN,
|
||||
REGISTERED,
|
||||
BOUND_PHONE,
|
||||
BOUND_DEVICE_ID,
|
||||
BOUND_PLATE,
|
||||
|
||||
@@ -11,15 +11,10 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLFeatureNotSupportedException;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(VehicleIdentityProperties.class)
|
||||
@@ -40,22 +35,15 @@ public class VehicleIdentityAutoConfiguration {
|
||||
return new FileVehicleIdentityService(Path.of(properties.getFile().getPath()), objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DataSource.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "mysql")
|
||||
public DataSource vehicleIdentityDataSource(VehicleIdentityProperties properties) {
|
||||
VehicleIdentityProperties.Mysql mysql = properties.getMysql();
|
||||
return new DriverManagerVehicleIdentityDataSource(
|
||||
mysql.getDriverClassName(), mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean({VehicleIdentityResolver.class, VehicleIdentityRegistry.class})
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "mysql")
|
||||
public MySqlVehicleIdentityService mySqlVehicleIdentityService(VehicleIdentityProperties properties,
|
||||
DataSource dataSource) {
|
||||
public MySqlVehicleIdentityService mysqlVehicleIdentityService(VehicleIdentityProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
VehicleIdentityProperties.Mysql mysql = properties.getMysql();
|
||||
return new MySqlVehicleIdentityService(dataSource, mysql.getTable(), mysql.getRefreshInterval());
|
||||
DataSource dataSource = new DriverManagerDataSource(
|
||||
mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword());
|
||||
return new MySqlVehicleIdentityService(dataSource, mysql, objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -65,61 +53,4 @@ public class VehicleIdentityAutoConfiguration {
|
||||
public InMemoryVehicleIdentityService vehicleIdentityService() {
|
||||
return new InMemoryVehicleIdentityService();
|
||||
}
|
||||
|
||||
private static final class DriverManagerVehicleIdentityDataSource implements DataSource {
|
||||
private final String jdbcUrl;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private PrintWriter logWriter;
|
||||
private int loginTimeout;
|
||||
|
||||
private DriverManagerVehicleIdentityDataSource(String driverClassName,
|
||||
String jdbcUrl,
|
||||
String username,
|
||||
String password) {
|
||||
if (jdbcUrl == null || jdbcUrl.isBlank()) {
|
||||
throw new IllegalArgumentException("lingniu.ingest.identity.mysql.jdbc-url is required");
|
||||
}
|
||||
if (driverClassName != null && !driverClassName.isBlank()) {
|
||||
try {
|
||||
Class.forName(driverClassName.trim());
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw new IllegalArgumentException("vehicle identity mysql driver class not found: "
|
||||
+ driverClassName, e);
|
||||
}
|
||||
}
|
||||
this.jdbcUrl = jdbcUrl;
|
||||
this.username = username == null ? "" : username;
|
||||
this.password = password == null ? "" : password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Connection getConnection() throws SQLException {
|
||||
DriverManager.setLoginTimeout(loginTimeout);
|
||||
return DriverManager.getConnection(jdbcUrl, username, password);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Connection getConnection(String username, String password) throws SQLException {
|
||||
DriverManager.setLoginTimeout(loginTimeout);
|
||||
return DriverManager.getConnection(jdbcUrl, username, password);
|
||||
}
|
||||
|
||||
@Override public PrintWriter getLogWriter() { return logWriter; }
|
||||
@Override public void setLogWriter(PrintWriter out) { this.logWriter = out; }
|
||||
@Override public void setLoginTimeout(int seconds) { this.loginTimeout = seconds; }
|
||||
@Override public int getLoginTimeout() { return loginTimeout; }
|
||||
@Override public Logger getParentLogger() throws SQLFeatureNotSupportedException {
|
||||
throw new SQLFeatureNotSupportedException();
|
||||
}
|
||||
@Override public <T> T unwrap(Class<T> iface) throws SQLException {
|
||||
if (iface.isInstance(this)) {
|
||||
return iface.cast(this);
|
||||
}
|
||||
throw new SQLException("not a wrapper for " + iface);
|
||||
}
|
||||
@Override public boolean isWrapperFor(Class<?> iface) {
|
||||
return iface.isInstance(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package com.lingniu.ingest.identity.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.identity")
|
||||
public class VehicleIdentityProperties {
|
||||
|
||||
@@ -26,24 +24,21 @@ public class VehicleIdentityProperties {
|
||||
}
|
||||
|
||||
public static class Mysql {
|
||||
private String table = "vehicle_identity_binding";
|
||||
private String jdbcUrl = "";
|
||||
private String username = "";
|
||||
private String jdbcUrl = "jdbc:mysql://127.0.0.1:3306/lingniu_vehicle?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai";
|
||||
private String username = "root";
|
||||
private String password = "";
|
||||
private String driverClassName = "com.mysql.cj.jdbc.Driver";
|
||||
private Duration refreshInterval = Duration.ofSeconds(60);
|
||||
private String tableName = "vehicle_identity_bindings";
|
||||
private boolean initializeSchema = true;
|
||||
|
||||
public String getTable() { return table; }
|
||||
public void setTable(String table) { this.table = table; }
|
||||
public String getJdbcUrl() { return jdbcUrl; }
|
||||
public void setJdbcUrl(String jdbcUrl) { this.jdbcUrl = jdbcUrl; }
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String username) { this.username = username; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
public String getDriverClassName() { return driverClassName; }
|
||||
public void setDriverClassName(String driverClassName) { this.driverClassName = driverClassName; }
|
||||
public Duration getRefreshInterval() { return refreshInterval; }
|
||||
public void setRefreshInterval(Duration refreshInterval) { this.refreshInterval = refreshInterval; }
|
||||
public String getTableName() { return tableName; }
|
||||
public void setTableName(String tableName) { this.tableName = tableName; }
|
||||
public boolean isInitializeSchema() { return initializeSchema; }
|
||||
public void setInitializeSchema(boolean initializeSchema) { this.initializeSchema = initializeSchema; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.identity.config.VehicleIdentityProperties;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -9,6 +11,7 @@ import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -21,62 +24,67 @@ class MySqlVehicleIdentityServiceJdbcTest {
|
||||
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1");
|
||||
|
||||
try (MySqlVehicleIdentityService service =
|
||||
new MySqlVehicleIdentityService(dataSource, "vehicle_identity_binding")) {
|
||||
service.register(new VehicleRegistrationBinding(
|
||||
ProtocolId.JT808,
|
||||
"unknown",
|
||||
"13079961001",
|
||||
"dev61001",
|
||||
"粤B61001",
|
||||
44,
|
||||
4401,
|
||||
"maker-a",
|
||||
"type-a",
|
||||
1));
|
||||
VehicleIdentityProperties.Mysql mysql = new VehicleIdentityProperties.Mysql();
|
||||
mysql.setTableName("vehicle_identity_binding");
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(
|
||||
dataSource,
|
||||
mysql,
|
||||
new ObjectMapper());
|
||||
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
ResultSet registered = statement.executeQuery("""
|
||||
SELECT vin, device_id, plate, province, city, maker, device_type, plate_color
|
||||
FROM vehicle_identity_binding_registration
|
||||
WHERE protocol = 'JT808' AND phone = '13079961001'
|
||||
""");
|
||||
assertThat(registered.next()).isTrue();
|
||||
assertThat(registered.getString("vin")).isEqualTo("unknown");
|
||||
assertThat(registered.getString("device_id")).isEqualTo("DEV61001");
|
||||
assertThat(registered.getString("plate")).isEqualTo("粤B61001");
|
||||
assertThat(registered.getInt("province")).isEqualTo(44);
|
||||
assertThat(registered.getInt("city")).isEqualTo(4401);
|
||||
assertThat(registered.getString("maker")).isEqualTo("MAKER-A");
|
||||
assertThat(registered.getString("device_type")).isEqualTo("TYPE-A");
|
||||
assertThat(registered.getInt("plate_color")).isEqualTo(1);
|
||||
service.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808,
|
||||
"LNVIN00000061001",
|
||||
"13079961001",
|
||||
"dev61001",
|
||||
"粤B61001",
|
||||
Map.of(
|
||||
"province", "44",
|
||||
"city", "4401",
|
||||
"maker", "maker-a",
|
||||
"deviceType", "type-a",
|
||||
"plateColor", "1")));
|
||||
|
||||
List<String> columns = new ArrayList<>();
|
||||
ResultSet bindingColumns = connection.getMetaData()
|
||||
.getColumns(null, null, "vehicle_identity_binding", null);
|
||||
while (bindingColumns.next()) {
|
||||
columns.add(bindingColumns.getString("COLUMN_NAME"));
|
||||
}
|
||||
assertThat(columns).containsExactly("plate", "vin");
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
ResultSet binding = statement.executeQuery("""
|
||||
SELECT vin, phone, device_id, plate, province, city, maker, device_type, plate_color
|
||||
FROM vehicle_identity_binding
|
||||
WHERE protocol = 'JT808' AND phone = '13079961001'
|
||||
""");
|
||||
assertThat(binding.next()).isTrue();
|
||||
assertThat(binding.getString("vin")).isEqualTo("LNVIN00000061001");
|
||||
assertThat(binding.getString("phone")).isEqualTo("13079961001");
|
||||
assertThat(binding.getString("device_id")).isEqualTo("dev61001");
|
||||
assertThat(binding.getString("plate")).isEqualTo("粤B61001");
|
||||
assertThat(binding.getString("province")).isEqualTo("44");
|
||||
assertThat(binding.getString("city")).isEqualTo("4401");
|
||||
assertThat(binding.getString("maker")).isEqualTo("maker-a");
|
||||
assertThat(binding.getString("device_type")).isEqualTo("type-a");
|
||||
assertThat(binding.getString("plate_color")).isEqualTo("1");
|
||||
|
||||
statement.executeUpdate("""
|
||||
INSERT INTO vehicle_identity_binding (plate, vin)
|
||||
VALUES ('粤B61001', 'LNVIN00000061001')
|
||||
""");
|
||||
List<String> columns = new ArrayList<>();
|
||||
ResultSet bindingColumns = connection.getMetaData()
|
||||
.getColumns(null, null, "vehicle_identity_binding", null);
|
||||
while (bindingColumns.next()) {
|
||||
columns.add(bindingColumns.getString("COLUMN_NAME"));
|
||||
}
|
||||
|
||||
service.refresh();
|
||||
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13079961001", "", "")).vin())
|
||||
.isEqualTo("LNVIN00000061001");
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "dev61001", "")).vin())
|
||||
.isEqualTo("LNVIN00000061001");
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "", "粤B61001")).vin())
|
||||
.isEqualTo("LNVIN00000061001");
|
||||
assertThat(columns).contains(
|
||||
"protocol",
|
||||
"phone",
|
||||
"device_id",
|
||||
"plate",
|
||||
"vin",
|
||||
"metadata_json");
|
||||
}
|
||||
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13079961001", "", "")).vin())
|
||||
.isEqualTo("LNVIN00000061001");
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "dev61001", "")).vin())
|
||||
.isEqualTo("LNVIN00000061001");
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "", "粤B61001")).vin())
|
||||
.isEqualTo("LNVIN00000061001");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,478 +1,157 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.identity.config.VehicleIdentityProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLFeatureNotSupportedException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MySqlVehicleIdentityServiceTest {
|
||||
|
||||
@Test
|
||||
void bindsAndResolvesIdentityThroughJdbcStore() {
|
||||
RecordingIdentityJdbc jdbc = new RecordingIdentityJdbc();
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(jdbc.dataSource(), "vehicle_identity_binding");
|
||||
void upsertsRegistrationBindingAndResolvesByExternalIdentifiers() {
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:mysql-identity;MODE=MySQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
VehicleIdentityProperties.Mysql mysql = new VehicleIdentityProperties.Mysql();
|
||||
mysql.setTableName("vehicle_identity_bindings");
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(
|
||||
dataSource,
|
||||
mysql,
|
||||
new ObjectMapper());
|
||||
|
||||
service.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000000123", "13079960001", "DEV001", "粤B00001"));
|
||||
|
||||
VehicleIdentity byPhone = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13079960001", "", ""));
|
||||
VehicleIdentity byDevice = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "DEV001", ""));
|
||||
VehicleIdentity byPlate = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "", "粤B00001"));
|
||||
|
||||
assertThat(jdbc.createdTables)
|
||||
.anySatisfy(sql -> assertThat(sql).contains("CREATE TABLE IF NOT EXISTS vehicle_identity_binding"));
|
||||
assertThat(jdbc.bindings).containsEntry("粤B00001", "LNVIN000000000123");
|
||||
assertThat(byPhone.vin()).isEqualTo("LNVIN000000000123");
|
||||
assertThat(byPhone.resolved()).isTrue();
|
||||
assertThat(byPhone.source()).isEqualTo(VehicleIdentitySource.BOUND_PHONE);
|
||||
assertThat(byDevice.vin()).isEqualTo("LNVIN000000000123");
|
||||
assertThat(byDevice.source()).isEqualTo(VehicleIdentitySource.BOUND_DEVICE_ID);
|
||||
assertThat(byPlate.vin()).isEqualTo("LNVIN000000000123");
|
||||
assertThat(byPlate.source()).isEqualTo(VehicleIdentitySource.BOUND_PLATE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresUnknownVinBinding() {
|
||||
RecordingIdentityJdbc jdbc = new RecordingIdentityJdbc();
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(jdbc.dataSource(), "vehicle_identity_binding");
|
||||
|
||||
service.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "unknown", "13079960001", "DEV001", "粤B00001"));
|
||||
|
||||
assertThat(jdbc.bindings).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesFromStartupCacheWithoutPerFrameDatabaseLookup() {
|
||||
RecordingIdentityJdbc jdbc = new RecordingIdentityJdbc();
|
||||
jdbc.seedRegistration("JT808", "13079960001", "DEV001", "粤B00001");
|
||||
jdbc.seed("粤B00001", "LNVIN000000000123");
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(jdbc.dataSource(), "vehicle_identity_binding");
|
||||
|
||||
VehicleIdentity byPhone = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13079960001", "", ""));
|
||||
VehicleIdentity byDevice = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "DEV001", ""));
|
||||
VehicleIdentity byPlate = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "", "粤B00001"));
|
||||
|
||||
assertThat(byPhone.vin()).isEqualTo("LNVIN000000000123");
|
||||
assertThat(byDevice.vin()).isEqualTo("LNVIN000000000123");
|
||||
assertThat(byPlate.vin()).isEqualTo("LNVIN000000000123");
|
||||
assertThat(jdbc.loadAllSelects).isEqualTo(1);
|
||||
assertThat(jdbc.lookupSelects).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesGlobalPlateBindingForXindaPush() {
|
||||
RecordingIdentityJdbc jdbc = new RecordingIdentityJdbc();
|
||||
jdbc.seed("浙F00780F", "LA9GG68L8PBAF4776");
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(jdbc.dataSource(), "vehicle_identity_binding");
|
||||
|
||||
VehicleIdentity byPlate = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.XINDA_PUSH, "", "", "", "浙F00780F"));
|
||||
|
||||
assertThat(byPlate.vin()).isEqualTo("LA9GG68L8PBAF4776");
|
||||
assertThat(byPlate.resolved()).isTrue();
|
||||
assertThat(byPlate.source()).isEqualTo(VehicleIdentitySource.BOUND_PLATE);
|
||||
assertThat(jdbc.loadAllSelects).isEqualTo(1);
|
||||
assertThat(jdbc.lookupSelects).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshLoadsExternallyInsertedBindingWithoutPerFrameDatabaseLookup() {
|
||||
RecordingIdentityJdbc jdbc = new RecordingIdentityJdbc();
|
||||
jdbc.seedRegistration("JT808", "13079960002", "DEV002", "粤B00002");
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(jdbc.dataSource(), "vehicle_identity_binding");
|
||||
|
||||
VehicleIdentity before = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13079960002", "", ""));
|
||||
|
||||
jdbc.seed("粤B00002", "LNVIN000000000456");
|
||||
service.refresh();
|
||||
VehicleIdentity after = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13079960002", "", ""));
|
||||
|
||||
assertThat(before.resolved()).isFalse();
|
||||
assertThat(after.vin()).isEqualTo("LNVIN000000000456");
|
||||
assertThat(after.resolved()).isTrue();
|
||||
assertThat(after.source()).isEqualTo(VehicleIdentitySource.BOUND_PHONE);
|
||||
assertThat(jdbc.loadAllSelects).isEqualTo(2);
|
||||
assertThat(jdbc.lookupSelects).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesRegistrationWithoutVinAndResolvesAfterPlateVinIsWrittenBack() {
|
||||
RecordingIdentityJdbc jdbc = new RecordingIdentityJdbc();
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(jdbc.dataSource(), "vehicle_identity_binding");
|
||||
|
||||
service.register(new VehicleRegistrationBinding(
|
||||
ProtocolId.JT808,
|
||||
"unknown",
|
||||
"13079960003",
|
||||
"DEV003",
|
||||
"粤B00003",
|
||||
44,
|
||||
4401,
|
||||
"MAKER",
|
||||
"TYPE-A",
|
||||
1));
|
||||
"JT808AABBCCDDEE1",
|
||||
"13079963289",
|
||||
"9963289",
|
||||
"沪A00113F",
|
||||
Map.of(
|
||||
"province", "31",
|
||||
"city", "113",
|
||||
"maker", "70112",
|
||||
"deviceType", "SEG-9888G",
|
||||
"plateColor", "2")));
|
||||
|
||||
VehicleIdentity before = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13079960003", "", ""));
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(ProtocolId.JT808, "", "13079963289", "", "")))
|
||||
.extracting(VehicleIdentity::vin, VehicleIdentity::resolved, VehicleIdentity::source)
|
||||
.containsExactly("JT808AABBCCDDEE1", true, VehicleIdentitySource.BOUND_PHONE);
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(ProtocolId.JT808, "", "", "9963289", "")))
|
||||
.extracting(VehicleIdentity::vin, VehicleIdentity::resolved, VehicleIdentity::source)
|
||||
.containsExactly("JT808AABBCCDDEE1", true, VehicleIdentitySource.BOUND_DEVICE_ID);
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(ProtocolId.JT808, "", "", "", "沪A00113F")))
|
||||
.extracting(VehicleIdentity::vin, VehicleIdentity::resolved, VehicleIdentity::source)
|
||||
.containsExactly("JT808AABBCCDDEE1", true, VehicleIdentitySource.BOUND_PLATE);
|
||||
|
||||
jdbc.seed("粤B00003", "LNVIN000000000789");
|
||||
service.refresh();
|
||||
VehicleIdentity byPhone = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13079960003", "", ""));
|
||||
VehicleIdentity byDevice = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "DEV003", ""));
|
||||
VehicleIdentity byPlate = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "", "粤B00003"));
|
||||
|
||||
assertThat(jdbc.createdTables)
|
||||
.anySatisfy(sql -> assertThat(sql).contains("CREATE TABLE IF NOT EXISTS vehicle_identity_binding_registration"));
|
||||
assertThat(jdbc.registrations).containsKey("JT808|13079960003");
|
||||
assertThat(jdbc.bindings).containsEntry("粤B00003", "LNVIN000000000789");
|
||||
assertThat(before.resolved()).isFalse();
|
||||
assertThat(byPhone.vin()).isEqualTo("LNVIN000000000789");
|
||||
assertThat(byPhone.source()).isEqualTo(VehicleIdentitySource.BOUND_PHONE);
|
||||
assertThat(byDevice.vin()).isEqualTo("LNVIN000000000789");
|
||||
assertThat(byPlate.vin()).isEqualTo("LNVIN000000000789");
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
Map<String, Object> row = jdbc.queryForMap("""
|
||||
SELECT vin, phone, device_id, plate, province, city, maker, device_type, plate_color
|
||||
FROM vehicle_identity_bindings
|
||||
WHERE protocol = 'JT808' AND phone = '13079963289'
|
||||
""");
|
||||
assertThat(row)
|
||||
.containsEntry("vin", "JT808AABBCCDDEE1")
|
||||
.containsEntry("phone", "13079963289")
|
||||
.containsEntry("device_id", "9963289")
|
||||
.containsEntry("plate", "沪A00113F")
|
||||
.containsEntry("province", "31")
|
||||
.containsEntry("city", "113")
|
||||
.containsEntry("maker", "70112")
|
||||
.containsEntry("device_type", "SEG-9888G")
|
||||
.containsEntry("plate_color", "2");
|
||||
}
|
||||
|
||||
private static final class RecordingIdentityJdbc {
|
||||
private final List<String> createdTables = new ArrayList<>();
|
||||
private final Map<String, String> bindings = new HashMap<>();
|
||||
private final Map<String, RegistrationRow> registrations = new HashMap<>();
|
||||
private int loadAllSelects;
|
||||
private int lookupSelects;
|
||||
@Test
|
||||
void initializesMissingColumnsOnExistingLegacyTable() {
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:mysql-identity-legacy;MODE=MySQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE vehicle_identity_binding (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
vin VARCHAR(32) NOT NULL,
|
||||
legacy_key VARCHAR(64) NOT NULL DEFAULT ''
|
||||
)
|
||||
""");
|
||||
VehicleIdentityProperties.Mysql mysql = new VehicleIdentityProperties.Mysql();
|
||||
mysql.setTableName("vehicle_identity_binding");
|
||||
|
||||
private void seed(String plate, String vin) {
|
||||
bindings.put(normalize(plate), vin);
|
||||
}
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(
|
||||
dataSource,
|
||||
mysql,
|
||||
new ObjectMapper());
|
||||
service.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808,
|
||||
"JT808LEGACY00001",
|
||||
"13079963289",
|
||||
"9963289",
|
||||
"沪A00113F",
|
||||
Map.of("maker", "70112", "deviceType", "SEG-9888G")));
|
||||
|
||||
private void seedRegistration(String protocol, String phone, String deviceId, String plate) {
|
||||
registrations.put(protocol + "|" + normalize(phone),
|
||||
new RegistrationRow(protocol, normalize(phone), "unknown", normalize(deviceId), normalize(plate)));
|
||||
}
|
||||
Map<String, Object> row = jdbc.queryForMap("""
|
||||
SELECT vin, maker, device_type, metadata_json
|
||||
FROM vehicle_identity_binding
|
||||
WHERE protocol = 'JT808' AND phone = '13079963289'
|
||||
""");
|
||||
assertThat(row)
|
||||
.containsEntry("vin", "JT808LEGACY00001")
|
||||
.containsEntry("maker", "70112")
|
||||
.containsEntry("device_type", "SEG-9888G");
|
||||
assertThat((String) row.get("metadata_json")).contains("SEG-9888G");
|
||||
}
|
||||
|
||||
private DataSource dataSource() {
|
||||
return new DataSource() {
|
||||
@Override public Connection getConnection() { return connection(); }
|
||||
@Override public Connection getConnection(String username, String password) { return connection(); }
|
||||
@Override public PrintWriter getLogWriter() { return null; }
|
||||
@Override public void setLogWriter(PrintWriter out) {}
|
||||
@Override public void setLoginTimeout(int seconds) {}
|
||||
@Override public int getLoginTimeout() { return 0; }
|
||||
@Override public Logger getParentLogger() throws SQLFeatureNotSupportedException { throw new SQLFeatureNotSupportedException(); }
|
||||
@Override public <T> T unwrap(Class<T> iface) { throw new UnsupportedOperationException(); }
|
||||
@Override public boolean isWrapperFor(Class<?> iface) { return false; }
|
||||
};
|
||||
}
|
||||
@Test
|
||||
void upsertsByCompositeProtocolPhonePlatePrimaryKey() {
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:mysql-identity-composite;MODE=MySQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("""
|
||||
CREATE TABLE vehicle_identity_binding (
|
||||
protocol VARCHAR(32) NOT NULL DEFAULT 'ALL',
|
||||
phone VARCHAR(128) NOT NULL DEFAULT '',
|
||||
plate VARCHAR(128) NOT NULL DEFAULT '',
|
||||
vin VARCHAR(64) NOT NULL,
|
||||
province VARCHAR(32) NOT NULL DEFAULT '',
|
||||
city VARCHAR(32) NOT NULL DEFAULT '',
|
||||
maker VARCHAR(64) NOT NULL DEFAULT '',
|
||||
device_type VARCHAR(128) NOT NULL DEFAULT '',
|
||||
plate_color VARCHAR(32) NOT NULL DEFAULT '',
|
||||
metadata_json TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
device_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
PRIMARY KEY (protocol, phone, plate)
|
||||
)
|
||||
""");
|
||||
VehicleIdentityProperties.Mysql mysql = new VehicleIdentityProperties.Mysql();
|
||||
mysql.setTableName("vehicle_identity_binding");
|
||||
MySqlVehicleIdentityService service = new MySqlVehicleIdentityService(
|
||||
dataSource,
|
||||
mysql,
|
||||
new ObjectMapper());
|
||||
service.bind(new VehicleIdentityBinding(ProtocolId.JT808,
|
||||
"VIN-A", "13079963289", "DEV-A", "沪A00113F"));
|
||||
|
||||
private Connection connection() {
|
||||
InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) {
|
||||
case "createStatement" -> statement();
|
||||
case "prepareStatement" -> preparedStatement((String) args[0]);
|
||||
case "close" -> null;
|
||||
case "isClosed" -> false;
|
||||
case "unwrap" -> throw new UnsupportedOperationException();
|
||||
case "isWrapperFor" -> false;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
return (Connection) Proxy.newProxyInstance(
|
||||
Connection.class.getClassLoader(), new Class<?>[]{Connection.class}, handler);
|
||||
}
|
||||
service.bind(new VehicleIdentityBinding(ProtocolId.JT808,
|
||||
"VIN-B", "13079963289", "DEV-B", "沪A00114F"));
|
||||
service.bind(new VehicleIdentityBinding(ProtocolId.JT808,
|
||||
"VIN-B2", "13079963289", "DEV-B2", "沪A00114F"));
|
||||
|
||||
private Statement statement() {
|
||||
InvocationHandler handler = (proxy, method, args) -> {
|
||||
if ("execute".equals(method.getName())) {
|
||||
createdTables.add((String) args[0]);
|
||||
return false;
|
||||
}
|
||||
if ("close".equals(method.getName())) {
|
||||
return null;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
};
|
||||
return (Statement) Proxy.newProxyInstance(
|
||||
Statement.class.getClassLoader(), new Class<?>[]{Statement.class}, handler);
|
||||
}
|
||||
|
||||
private PreparedStatement preparedStatement(String sql) {
|
||||
List<Object> params = new ArrayList<>();
|
||||
InvocationHandler handler = (proxy, method, args) -> {
|
||||
switch (method.getName()) {
|
||||
case "setString", "setInt", "setObject" -> {
|
||||
set(params, (Integer) args[0], args[1]);
|
||||
return null;
|
||||
}
|
||||
case "executeUpdate" -> {
|
||||
if (sql.startsWith("INSERT INTO vehicle_identity_binding_registration ")) {
|
||||
upsertRegistration(params);
|
||||
} else {
|
||||
upsert(params);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
case "executeQuery" -> {
|
||||
if (sql.startsWith("SELECT plate, vin FROM ")) {
|
||||
loadAllSelects++;
|
||||
return allBindingsResultSet();
|
||||
}
|
||||
if (sql.startsWith("SELECT protocol, identifier_type, identifier_value, vin FROM ")) {
|
||||
loadAllSelects++;
|
||||
return legacyAllBindingsResultSet();
|
||||
}
|
||||
if (sql.startsWith("SELECT r.protocol, r.phone, r.device_id, r.plate, b.vin FROM ")) {
|
||||
return registrationResultSet();
|
||||
}
|
||||
if (sql.startsWith("SELECT protocol, phone, device_id, plate, vin FROM ")) {
|
||||
return legacyRegistrationResultSet();
|
||||
}
|
||||
lookupSelects++;
|
||||
return resultSet(resolve(params));
|
||||
}
|
||||
case "close" -> {
|
||||
return null;
|
||||
}
|
||||
default -> {
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
}
|
||||
};
|
||||
return (PreparedStatement) Proxy.newProxyInstance(
|
||||
PreparedStatement.class.getClassLoader(), new Class<?>[]{PreparedStatement.class}, handler);
|
||||
}
|
||||
|
||||
private void upsert(List<Object> params) {
|
||||
String plate;
|
||||
String vin;
|
||||
if (params.size() >= 4) {
|
||||
plate = normalize((String) params.get(2));
|
||||
vin = (String) params.get(3);
|
||||
} else {
|
||||
plate = normalize((String) params.get(0));
|
||||
vin = (String) params.get(1);
|
||||
}
|
||||
bindings.put(plate, vin);
|
||||
}
|
||||
|
||||
private void upsertRegistration(List<Object> params) {
|
||||
String protocol = (String) params.get(0);
|
||||
String phone = normalize((String) params.get(1));
|
||||
String vin = (String) params.get(2);
|
||||
String key = protocol + "|" + phone;
|
||||
RegistrationRow existing = registrations.get(key);
|
||||
String effectiveVin = existing != null && "unknown".equalsIgnoreCase(vin) ? existing.vin : vin;
|
||||
registrations.put(key, new RegistrationRow(
|
||||
protocol,
|
||||
phone,
|
||||
effectiveVin,
|
||||
normalize((String) params.get(3)),
|
||||
normalize((String) params.get(4))));
|
||||
}
|
||||
|
||||
private String resolve(List<Object> params) {
|
||||
String protocol = (String) params.get(0);
|
||||
String identifierType = (String) params.get(1);
|
||||
String identifierValue = normalize((String) params.get(2));
|
||||
if (!"PLATE".equals(identifierType)) {
|
||||
return null;
|
||||
}
|
||||
return bindings.get(identifierValue);
|
||||
}
|
||||
|
||||
private ResultSet allBindingsResultSet() {
|
||||
List<Map.Entry<String, String>> entries = new ArrayList<>(bindings.entrySet());
|
||||
InvocationHandler handler = new InvocationHandler() {
|
||||
private int index = -1;
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
|
||||
return switch (method.getName()) {
|
||||
case "next" -> {
|
||||
index++;
|
||||
yield index < entries.size();
|
||||
}
|
||||
case "getString" -> getColumn(entries.get(index), args[0]);
|
||||
case "close" -> null;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
}
|
||||
};
|
||||
return (ResultSet) Proxy.newProxyInstance(
|
||||
ResultSet.class.getClassLoader(), new Class<?>[]{ResultSet.class}, handler);
|
||||
}
|
||||
|
||||
private ResultSet legacyAllBindingsResultSet() {
|
||||
List<Map.Entry<String, String>> entries = new ArrayList<>(bindings.entrySet());
|
||||
InvocationHandler handler = new InvocationHandler() {
|
||||
private int index = -1;
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
|
||||
return switch (method.getName()) {
|
||||
case "next" -> {
|
||||
index++;
|
||||
yield index < entries.size();
|
||||
}
|
||||
case "getString" -> legacyColumn(entries.get(index), args[0]);
|
||||
case "close" -> null;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
}
|
||||
};
|
||||
return (ResultSet) Proxy.newProxyInstance(
|
||||
ResultSet.class.getClassLoader(), new Class<?>[]{ResultSet.class}, handler);
|
||||
}
|
||||
|
||||
private static String getColumn(Map.Entry<String, String> entry, Object column) {
|
||||
String name = column.toString();
|
||||
return switch (name) {
|
||||
case "plate" -> entry.getKey();
|
||||
case "vin" -> entry.getValue();
|
||||
default -> throw new IllegalArgumentException("unsupported column " + name);
|
||||
};
|
||||
}
|
||||
|
||||
private static String legacyColumn(Map.Entry<String, String> entry, Object column) {
|
||||
return switch (column.toString()) {
|
||||
case "protocol" -> "JT808";
|
||||
case "identifier_type" -> "PLATE";
|
||||
case "identifier_value" -> entry.getKey();
|
||||
case "vin" -> entry.getValue();
|
||||
default -> throw new IllegalArgumentException("unsupported column " + column);
|
||||
};
|
||||
}
|
||||
|
||||
private ResultSet registrationResultSet() {
|
||||
List<RegistrationRow> rows = registrations.values().stream()
|
||||
.filter(row -> bindings.containsKey(row.plate))
|
||||
.map(row -> new RegistrationRow(row.protocol, row.phone, bindings.get(row.plate), row.deviceId, row.plate))
|
||||
.toList();
|
||||
return registrationRowsResultSet(rows);
|
||||
}
|
||||
|
||||
private ResultSet legacyRegistrationResultSet() {
|
||||
List<RegistrationRow> rows = registrations.values().stream()
|
||||
.filter(row -> row.vin != null && !row.vin.isBlank() && !"unknown".equalsIgnoreCase(row.vin))
|
||||
.toList();
|
||||
return registrationRowsResultSet(rows);
|
||||
}
|
||||
|
||||
private ResultSet registrationRowsResultSet(List<RegistrationRow> rows) {
|
||||
InvocationHandler handler = new InvocationHandler() {
|
||||
private int index = -1;
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
|
||||
return switch (method.getName()) {
|
||||
case "next" -> {
|
||||
index++;
|
||||
yield index < rows.size();
|
||||
}
|
||||
case "getString" -> registrationColumn(rows.get(index), args[0]);
|
||||
case "close" -> null;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
}
|
||||
};
|
||||
return (ResultSet) Proxy.newProxyInstance(
|
||||
ResultSet.class.getClassLoader(), new Class<?>[]{ResultSet.class}, handler);
|
||||
}
|
||||
|
||||
private static String registrationColumn(RegistrationRow row, Object column) {
|
||||
return switch (column.toString()) {
|
||||
case "protocol" -> row.protocol;
|
||||
case "phone" -> row.phone;
|
||||
case "device_id" -> row.deviceId;
|
||||
case "plate" -> row.plate;
|
||||
case "vin" -> row.vin;
|
||||
default -> throw new IllegalArgumentException("unsupported column " + column);
|
||||
};
|
||||
}
|
||||
|
||||
private static ResultSet resultSet(String vin) {
|
||||
InvocationHandler handler = new InvocationHandler() {
|
||||
private boolean read;
|
||||
|
||||
@Override
|
||||
public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
|
||||
return switch (method.getName()) {
|
||||
case "next" -> {
|
||||
boolean hasNext = !read && vin != null;
|
||||
read = true;
|
||||
yield hasNext;
|
||||
}
|
||||
case "getString" -> vin;
|
||||
case "close" -> null;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
};
|
||||
}
|
||||
};
|
||||
return (ResultSet) Proxy.newProxyInstance(
|
||||
ResultSet.class.getClassLoader(), new Class<?>[]{ResultSet.class}, handler);
|
||||
}
|
||||
|
||||
private static void set(List<Object> params, int oneBasedIndex, Object value) {
|
||||
while (params.size() < oneBasedIndex) {
|
||||
params.add(null);
|
||||
}
|
||||
params.set(oneBasedIndex - 1, value);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == Void.TYPE) return null;
|
||||
if (type == Boolean.TYPE) return false;
|
||||
if (type == Integer.TYPE) return 0;
|
||||
if (type == Long.TYPE) return 0L;
|
||||
if (type == Double.TYPE) return 0D;
|
||||
if (type == Float.TYPE) return 0F;
|
||||
if (type == Short.TYPE) return (short) 0;
|
||||
if (type == Byte.TYPE) return (byte) 0;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static final class RegistrationRow {
|
||||
private final String protocol;
|
||||
private final String phone;
|
||||
private String vin;
|
||||
private final String deviceId;
|
||||
private final String plate;
|
||||
|
||||
private RegistrationRow(String protocol, String phone, String vin, String deviceId, String plate) {
|
||||
this.protocol = protocol;
|
||||
this.phone = phone;
|
||||
this.vin = vin;
|
||||
this.deviceId = deviceId;
|
||||
this.plate = plate;
|
||||
}
|
||||
}
|
||||
Integer rows = jdbc.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM vehicle_identity_binding
|
||||
WHERE protocol = 'JT808' AND phone = '13079963289'
|
||||
""", Integer.class);
|
||||
assertThat(rows).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("""
|
||||
SELECT vin
|
||||
FROM vehicle_identity_binding
|
||||
WHERE protocol = 'JT808' AND phone = '13079963289' AND plate = '沪A00114F'
|
||||
""", String.class)).isEqualTo("VIN-B2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.lang.reflect.Proxy;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -52,75 +45,19 @@ class VehicleIdentityAutoConfigurationTest {
|
||||
@Test
|
||||
void createsMysqlIdentityServiceWhenConfigured() {
|
||||
runner
|
||||
.withBean(DataSource.class, StubDataSource::new)
|
||||
.withPropertyValues(
|
||||
"lingniu.ingest.identity.store=mysql",
|
||||
"lingniu.ingest.identity.mysql.table=vehicle_identity_binding")
|
||||
"lingniu.ingest.identity.mysql.jdbc-url=jdbc:h2:mem:auto-identity;MODE=MySQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1",
|
||||
"lingniu.ingest.identity.mysql.username=sa",
|
||||
"lingniu.ingest.identity.mysql.password=",
|
||||
"lingniu.ingest.identity.mysql.table-name=vehicle_identity_bindings")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(VehicleIdentityResolver.class);
|
||||
assertThat(context.getBean(VehicleIdentityResolver.class).getClass().getSimpleName())
|
||||
.isEqualTo("MySqlVehicleIdentityService");
|
||||
assertThat(context).hasSingleBean(VehicleIdentityRegistry.class);
|
||||
assertThat(context.getBean(VehicleIdentityResolver.class))
|
||||
.isSameAs(context.getBean(VehicleIdentityRegistry.class));
|
||||
assertThat(context.getBean(VehicleIdentityResolver.class).getClass().getSimpleName())
|
||||
.isEqualTo("MySqlVehicleIdentityService");
|
||||
});
|
||||
}
|
||||
|
||||
private static final class StubDataSource implements DataSource {
|
||||
@Override public Connection getConnection() { return connection(); }
|
||||
@Override public Connection getConnection(String username, String password) { return connection(); }
|
||||
@Override public java.io.PrintWriter getLogWriter() { return null; }
|
||||
@Override public void setLogWriter(java.io.PrintWriter out) {}
|
||||
@Override public void setLoginTimeout(int seconds) {}
|
||||
@Override public int getLoginTimeout() { return 0; }
|
||||
@Override public java.util.logging.Logger getParentLogger() throws java.sql.SQLFeatureNotSupportedException { throw new java.sql.SQLFeatureNotSupportedException(); }
|
||||
@Override public <T> T unwrap(Class<T> iface) { throw new UnsupportedOperationException(); }
|
||||
@Override public boolean isWrapperFor(Class<?> iface) { return false; }
|
||||
|
||||
private static Connection connection() {
|
||||
return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class<?>[]{Connection.class},
|
||||
(proxy, method, args) -> switch (method.getName()) {
|
||||
case "createStatement" -> statement();
|
||||
case "prepareStatement" -> preparedStatement();
|
||||
case "close" -> null;
|
||||
case "isClosed" -> false;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Statement statement() {
|
||||
return (Statement) Proxy.newProxyInstance(Statement.class.getClassLoader(), new Class<?>[]{Statement.class},
|
||||
(proxy, method, args) -> switch (method.getName()) {
|
||||
case "execute" -> false;
|
||||
case "close" -> null;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static PreparedStatement preparedStatement() {
|
||||
return (PreparedStatement) Proxy.newProxyInstance(
|
||||
PreparedStatement.class.getClassLoader(), new Class<?>[]{PreparedStatement.class},
|
||||
(proxy, method, args) -> switch (method.getName()) {
|
||||
case "executeQuery" -> emptyResultSet();
|
||||
case "close" -> null;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static ResultSet emptyResultSet() {
|
||||
return (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class<?>[]{ResultSet.class},
|
||||
(proxy, method, args) -> switch (method.getName()) {
|
||||
case "next" -> false;
|
||||
case "close" -> null;
|
||||
default -> defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (type == Void.TYPE) return null;
|
||||
if (type == Boolean.TYPE) return false;
|
||||
if (type == Integer.TYPE) return 0;
|
||||
if (type == Long.TYPE) return 0L;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user