feat: make gb32960 archive history query production ready
This commit is contained in:
47
modules/core/vehicle-identity/pom.xml
Normal file
47
modules/core/vehicle-identity/pom.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>lingniu-vehicle-ingest</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<relativePath>../../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>vehicle-identity</artifactId>
|
||||
<name>vehicle-identity</name>
|
||||
<description>跨协议车辆身份解析与外部标识绑定。</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.lingniu.ingest</groupId>
|
||||
<artifactId>ingest-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
|
||||
/**
|
||||
* 文件型车辆身份绑定表。
|
||||
*
|
||||
* <p>写入采用 append-only JSONL,启动时顺序重放到内存索引。这样协议层只依赖 identity SPI,
|
||||
* 不直接耦合业务库;后续替换成 DB/配置中心时只需新增同接口实现。
|
||||
*/
|
||||
public final class FileVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FileVehicleIdentityService.class);
|
||||
|
||||
private final Path path;
|
||||
private final ObjectMapper mapper;
|
||||
private final InMemoryVehicleIdentityService delegate = new InMemoryVehicleIdentityService();
|
||||
private final Object writeLock = new Object();
|
||||
|
||||
public FileVehicleIdentityService(Path path) {
|
||||
this(path, new ObjectMapper());
|
||||
}
|
||||
|
||||
public FileVehicleIdentityService(Path path, ObjectMapper mapper) {
|
||||
this.path = path.toAbsolutePath();
|
||||
this.mapper = mapper;
|
||||
load();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(VehicleIdentityBinding binding) {
|
||||
delegate.bind(binding);
|
||||
synchronized (writeLock) {
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) {
|
||||
writer.write(mapper.writeValueAsString(BindingLine.from(binding)));
|
||||
writer.newLine();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("vehicle identity binding persist failed: " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public VehicleIdentity resolve(VehicleIdentityLookup lookup) {
|
||||
return delegate.resolve(lookup);
|
||||
}
|
||||
|
||||
private void load() {
|
||||
if (!Files.exists(path)) {
|
||||
return;
|
||||
}
|
||||
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
BindingLine binding = mapper.readValue(line, BindingLine.class);
|
||||
delegate.bind(binding.toBinding());
|
||||
} catch (Exception e) {
|
||||
log.warn("skip invalid vehicle identity binding line path={} line={}", path, line, e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("vehicle identity binding load failed: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private record BindingLine(
|
||||
ProtocolId protocol,
|
||||
String vin,
|
||||
String phone,
|
||||
String deviceId,
|
||||
String plate
|
||||
) {
|
||||
private static BindingLine from(VehicleIdentityBinding binding) {
|
||||
return new BindingLine(
|
||||
binding.protocol(), binding.vin(), binding.phone(), binding.deviceId(), binding.plate());
|
||||
}
|
||||
|
||||
private VehicleIdentityBinding toBinding() {
|
||||
return new VehicleIdentityBinding(protocol, vin, phone, deviceId, plate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
public final class InMemoryVehicleIdentityService implements VehicleIdentityResolver, VehicleIdentityRegistry {
|
||||
|
||||
private final ConcurrentMap<String, String> phoneToVin = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, String> deviceIdToVin = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, String> plateToVin = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void bind(VehicleIdentityBinding binding) {
|
||||
if (!binding.phone().isBlank()) {
|
||||
phoneToVin.put(key(binding.protocol(), binding.phone()), binding.vin());
|
||||
}
|
||||
if (!binding.deviceId().isBlank()) {
|
||||
deviceIdToVin.put(key(binding.protocol(), binding.deviceId()), binding.vin());
|
||||
}
|
||||
if (!binding.plate().isBlank()) {
|
||||
plateToVin.put(key(binding.protocol(), binding.plate()), binding.vin());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public VehicleIdentity resolve(VehicleIdentityLookup lookup) {
|
||||
if (lookup == null) {
|
||||
return new VehicleIdentity("unknown", false, VehicleIdentitySource.UNKNOWN);
|
||||
}
|
||||
if (!lookup.vin().isBlank()) {
|
||||
return new VehicleIdentity(lookup.vin(), true, VehicleIdentitySource.EXPLICIT_VIN);
|
||||
}
|
||||
VehicleIdentity phone = resolveBound(phoneToVin, lookup.protocol(), lookup.phone(), VehicleIdentitySource.BOUND_PHONE);
|
||||
if (phone != null) return phone;
|
||||
|
||||
VehicleIdentity device = resolveBound(deviceIdToVin, lookup.protocol(), lookup.deviceId(), VehicleIdentitySource.BOUND_DEVICE_ID);
|
||||
if (device != null) return device;
|
||||
|
||||
VehicleIdentity plate = resolveBound(plateToVin, lookup.protocol(), lookup.plate(), VehicleIdentitySource.BOUND_PLATE);
|
||||
if (plate != null) return plate;
|
||||
|
||||
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 static VehicleIdentity resolveBound(ConcurrentMap<String, String> map,
|
||||
ProtocolId protocol,
|
||||
String externalId,
|
||||
VehicleIdentitySource source) {
|
||||
if (externalId == null || externalId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String vin = map.get(key(protocol, externalId));
|
||||
if (vin == null) {
|
||||
return null;
|
||||
}
|
||||
return new VehicleIdentity(vin, true, source);
|
||||
}
|
||||
|
||||
private static String key(ProtocolId protocol, String value) {
|
||||
String p = protocol == null ? "UNKNOWN" : protocol.name();
|
||||
return p + ":" + value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
public record VehicleIdentity(
|
||||
String vin,
|
||||
boolean resolved,
|
||||
VehicleIdentitySource source
|
||||
) {
|
||||
public VehicleIdentity {
|
||||
vin = normalize(vin);
|
||||
source = source == null ? VehicleIdentitySource.UNKNOWN : source;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
public record VehicleIdentityBinding(
|
||||
ProtocolId protocol,
|
||||
String vin,
|
||||
String phone,
|
||||
String deviceId,
|
||||
String plate
|
||||
) {
|
||||
public VehicleIdentityBinding {
|
||||
vin = normalize(vin);
|
||||
phone = normalize(phone);
|
||||
deviceId = normalize(deviceId);
|
||||
plate = normalize(plate);
|
||||
if (vin.isBlank()) {
|
||||
throw new IllegalArgumentException("vin must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
|
||||
public record VehicleIdentityLookup(
|
||||
ProtocolId protocol,
|
||||
String vin,
|
||||
String phone,
|
||||
String deviceId,
|
||||
String plate
|
||||
) {
|
||||
public VehicleIdentityLookup {
|
||||
vin = normalize(vin);
|
||||
phone = normalize(phone);
|
||||
deviceId = normalize(deviceId);
|
||||
plate = normalize(plate);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
public interface VehicleIdentityRegistry {
|
||||
|
||||
void bind(VehicleIdentityBinding binding);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
public interface VehicleIdentityResolver {
|
||||
|
||||
VehicleIdentity resolve(VehicleIdentityLookup lookup);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
public enum VehicleIdentitySource {
|
||||
EXPLICIT_VIN,
|
||||
BOUND_PHONE,
|
||||
BOUND_DEVICE_ID,
|
||||
BOUND_PLATE,
|
||||
FALLBACK_DEVICE_ID,
|
||||
FALLBACK_PHONE,
|
||||
FALLBACK_PLATE,
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.lingniu.ingest.identity.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.lingniu.ingest.identity.FileVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.InMemoryVehicleIdentityService;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
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 java.nio.file.Path;
|
||||
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(VehicleIdentityProperties.class)
|
||||
public class VehicleIdentityAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public ObjectMapper vehicleIdentityObjectMapper() {
|
||||
return new ObjectMapper();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean({VehicleIdentityResolver.class, VehicleIdentityRegistry.class})
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "file")
|
||||
public FileVehicleIdentityService fileVehicleIdentityService(VehicleIdentityProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
return new FileVehicleIdentityService(Path.of(properties.getFile().getPath()), objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean({VehicleIdentityResolver.class, VehicleIdentityRegistry.class})
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.identity", name = "store", havingValue = "memory",
|
||||
matchIfMissing = true)
|
||||
public InMemoryVehicleIdentityService vehicleIdentityService() {
|
||||
return new InMemoryVehicleIdentityService();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.lingniu.ingest.identity.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "lingniu.ingest.identity")
|
||||
public class VehicleIdentityProperties {
|
||||
|
||||
private String store = "memory";
|
||||
private File file = new File();
|
||||
|
||||
public String getStore() { return store; }
|
||||
public void setStore(String store) { this.store = store; }
|
||||
public File getFile() { return file; }
|
||||
public void setFile(File file) { this.file = file; }
|
||||
|
||||
public static class File {
|
||||
private String path = "./data/vehicle-identity.jsonl";
|
||||
|
||||
public String getPath() { return path; }
|
||||
public void setPath(String path) { this.path = path; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
com.lingniu.ingest.identity.config.VehicleIdentityAutoConfiguration
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FileVehicleIdentityServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void persistsBindingsAndReloadsThemAfterRestart() throws Exception {
|
||||
Path store = tempDir.resolve("vehicle-identity.jsonl");
|
||||
FileVehicleIdentityService first = new FileVehicleIdentityService(store);
|
||||
|
||||
first.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000000099", "13900000099", "DEV099", "粤B09999"));
|
||||
|
||||
FileVehicleIdentityService restarted = new FileVehicleIdentityService(store);
|
||||
|
||||
VehicleIdentity byPhone = restarted.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13900000099", "", ""));
|
||||
VehicleIdentity byDevice = restarted.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "DEV099", ""));
|
||||
VehicleIdentity byPlate = restarted.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "", "粤B09999"));
|
||||
|
||||
assertThat(byPhone.vin()).isEqualTo("LNVIN000000000099");
|
||||
assertThat(byPhone.resolved()).isTrue();
|
||||
assertThat(byPhone.source()).isEqualTo(VehicleIdentitySource.BOUND_PHONE);
|
||||
assertThat(byDevice.vin()).isEqualTo("LNVIN000000000099");
|
||||
assertThat(byPlate.vin()).isEqualTo("LNVIN000000000099");
|
||||
assertThat(Files.readString(store)).contains("\"vin\":\"LNVIN000000000099\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.lingniu.ingest.identity;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InMemoryVehicleIdentityServiceTest {
|
||||
|
||||
private final InMemoryVehicleIdentityService service = new InMemoryVehicleIdentityService();
|
||||
|
||||
@Test
|
||||
void explicitVinWinsOverExternalIdentifiers() {
|
||||
VehicleIdentity identity = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "LNVIN000000000001", "13900000000", "DEV001", "粤B12345"));
|
||||
|
||||
assertThat(identity.vin()).isEqualTo("LNVIN000000000001");
|
||||
assertThat(identity.resolved()).isTrue();
|
||||
assertThat(identity.source()).isEqualTo(VehicleIdentitySource.EXPLICIT_VIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesBoundPhoneDeviceIdAndPlateToVin() {
|
||||
service.bind(new VehicleIdentityBinding(
|
||||
ProtocolId.JT808, "LNVIN000000000002", "13900000001", "DEV002", "粤B22222"));
|
||||
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13900000001", "", "")).vin())
|
||||
.isEqualTo("LNVIN000000000002");
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "DEV002", "")).vin())
|
||||
.isEqualTo("LNVIN000000000002");
|
||||
assertThat(service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "", "", "粤B22222")).vin())
|
||||
.isEqualTo("LNVIN000000000002");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToStableExternalIdentifierWhenNoBindingExists() {
|
||||
VehicleIdentity identity = service.resolve(new VehicleIdentityLookup(
|
||||
ProtocolId.JT808, "", "13900000003", "DEV003", "粤B33333"));
|
||||
|
||||
assertThat(identity.vin()).isEqualTo("DEV003");
|
||||
assertThat(identity.resolved()).isFalse();
|
||||
assertThat(identity.source()).isEqualTo(VehicleIdentitySource.FALLBACK_DEVICE_ID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.lingniu.ingest.identity.config;
|
||||
|
||||
import com.lingniu.ingest.identity.VehicleIdentityRegistry;
|
||||
import com.lingniu.ingest.identity.VehicleIdentityResolver;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class VehicleIdentityAutoConfigurationTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(VehicleIdentityAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void createsDefaultIdentityService() {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasSingleBean(VehicleIdentityResolver.class);
|
||||
assertThat(context).hasSingleBean(VehicleIdentityRegistry.class);
|
||||
assertThat(context.getBean(VehicleIdentityResolver.class))
|
||||
.isSameAs(context.getBean(VehicleIdentityRegistry.class));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsFileIdentityServiceWhenConfigured() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"lingniu.ingest.identity.store=file",
|
||||
"lingniu.ingest.identity.file.path=" + tempDir.resolve("identity.jsonl"))
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(VehicleIdentityResolver.class);
|
||||
assertThat(context.getBean(VehicleIdentityResolver.class).getClass().getSimpleName())
|
||||
.isEqualTo("FileVehicleIdentityService");
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user