feat: productionize raw history ingestion

This commit is contained in:
lingniu
2026-06-30 23:21:58 +08:00
parent 3cc7ac9669
commit cbba617801
100 changed files with 2995 additions and 1697 deletions

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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"));

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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)

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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");
}

View File

@@ -2,6 +2,7 @@ package com.lingniu.ingest.identity;
public enum VehicleIdentitySource {
EXPLICIT_VIN,
REGISTERED,
BOUND_PHONE,
BOUND_DEVICE_ID,
BOUND_PLATE,

View File

@@ -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);
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}
}