refactor: store jt808 mileage metrics directly

This commit is contained in:
lingniu
2026-07-01 03:31:10 +08:00
parent a3367f1886
commit 140b3f3995
19 changed files with 236 additions and 729 deletions

View File

@@ -28,10 +28,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>

View File

@@ -13,6 +13,8 @@ public final class JdbcVehicleStatMetricRepository implements VehicleStatReposit
private static final String DAILY_MILEAGE_KEY = "daily_mileage_km";
private static final String DAILY_MILEAGE_UNIT = "km";
private static final String DAILY_MILEAGE_START_TOTAL_KEY = "daily_mileage_start_total_km";
private static final String DAILY_MILEAGE_LATEST_TOTAL_KEY = "daily_mileage_latest_total_km";
private final JdbcTemplate jdbcTemplate;
@@ -33,15 +35,7 @@ public final class JdbcVehicleStatMetricRepository implements VehicleStatReposit
Date statDate = Date.valueOf(result.statDate());
double value = result.dailyMileageKm().getAsDouble();
String strategy = result.dailyMileageStrategy().name();
try {
jdbcTemplate.update("""
INSERT INTO vehicle_stat_metric
(vin, stat_date, metric_key, metric_value, metric_unit, calculation_method)
VALUES (?, ?, ?, ?, ?, ?)
""", vin, statDate, DAILY_MILEAGE_KEY, value, DAILY_MILEAGE_UNIT, strategy);
} catch (DuplicateKeyException ex) {
updateDailyStat(vin, statDate, value, strategy);
}
upsertMetric(vin, statDate, DAILY_MILEAGE_KEY, value, DAILY_MILEAGE_UNIT, strategy);
}
@Override
@@ -62,15 +56,72 @@ public final class JdbcVehicleStatMetricRepository implements VehicleStatReposit
return rows.stream().findFirst();
}
private void updateDailyStat(String vin, Date statDate, double value, String strategy) {
jdbcTemplate.update("""
UPDATE vehicle_stat_metric
SET metric_value = ?,
metric_unit = ?,
calculation_method = ?,
updated_at = CURRENT_TIMESTAMP
@Override
public Optional<VehicleDailyStatResult> recordDailyMileageSample(String vin, LocalDate statDate, double totalMileageKm) {
if (!Double.isFinite(totalMileageKm) || totalMileageKm < 0.0) {
return Optional.empty();
}
String normalizedVin = clean(vin);
Date date = Date.valueOf(statDate);
OptionalDouble existingStart = findMetric(normalizedVin, date, DAILY_MILEAGE_START_TOTAL_KEY);
double startTotalMileage = existingStart.orElse(totalMileageKm);
if (existingStart.isEmpty()) {
upsertMetric(normalizedVin, date, DAILY_MILEAGE_START_TOTAL_KEY, startTotalMileage,
DAILY_MILEAGE_UNIT, DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF.name());
}
OptionalDouble existingLatest = findMetric(normalizedVin, date, DAILY_MILEAGE_LATEST_TOTAL_KEY);
if (existingLatest.isPresent() && totalMileageKm < existingLatest.getAsDouble()) {
return findDailyStat(normalizedVin, statDate);
}
if (totalMileageKm < startTotalMileage) {
return findDailyStat(normalizedVin, statDate);
}
String strategy = DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF.name();
upsertMetric(normalizedVin, date, DAILY_MILEAGE_LATEST_TOTAL_KEY, totalMileageKm,
DAILY_MILEAGE_UNIT, strategy);
double dailyMileageKm = totalMileageKm - startTotalMileage;
VehicleDailyStatResult result = new VehicleDailyStatResult(
normalizedVin,
statDate,
OptionalDouble.of(dailyMileageKm),
DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF);
saveDailyStat(result);
return Optional.of(result);
}
private OptionalDouble findMetric(String vin, Date statDate, String metricKey) {
List<Double> rows = jdbcTemplate.query("""
SELECT metric_value
FROM vehicle_stat_metric
WHERE vin = ? AND stat_date = ? AND metric_key = ?
""", value, DAILY_MILEAGE_UNIT, strategy, vin, statDate, DAILY_MILEAGE_KEY);
""", (rs, rowNum) -> rs.getBigDecimal("metric_value") == null
? Double.NaN
: rs.getBigDecimal("metric_value").doubleValue(), vin, statDate, metricKey);
if (rows.isEmpty() || !Double.isFinite(rows.getFirst())) {
return OptionalDouble.empty();
}
return OptionalDouble.of(rows.getFirst());
}
private void upsertMetric(String vin, Date statDate, String metricKey, double value, String unit, String strategy) {
try {
jdbcTemplate.update("""
INSERT INTO vehicle_stat_metric
(vin, stat_date, metric_key, metric_value, metric_unit, calculation_method)
VALUES (?, ?, ?, ?, ?, ?)
""", vin, statDate, metricKey, value, unit, strategy);
} catch (DuplicateKeyException ex) {
jdbcTemplate.update("""
UPDATE vehicle_stat_metric
SET metric_value = ?,
metric_unit = ?,
calculation_method = ?,
updated_at = CURRENT_TIMESTAMP
WHERE vin = ? AND stat_date = ? AND metric_key = ?
""", value, unit, strategy, vin, statDate, metricKey);
}
}
private void ensureSchema() {

View File

@@ -7,5 +7,7 @@ public interface VehicleStatRepository {
void saveDailyStat(VehicleDailyStatResult result);
Optional<VehicleDailyStatResult> recordDailyMileageSample(String vin, LocalDate statDate, double totalMileageKm);
Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate);
}

View File

@@ -7,12 +7,8 @@ import com.lingniu.ingest.vehiclestat.VehicleStatController;
import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor;
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
import com.lingniu.ingest.vehiclestat.jt808.Jt808LocationPointExtractor;
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStateStore;
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStreamProcessor;
import com.lingniu.ingest.vehiclestat.jt808.RedisJt808MileageStateStore;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -20,16 +16,13 @@ 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.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Duration;
import java.time.ZoneId;
@AutoConfiguration(after = {
DataSourceAutoConfiguration.class,
JdbcTemplateAutoConfiguration.class,
RedisAutoConfiguration.class
JdbcTemplateAutoConfiguration.class
})
@EnableConfigurationProperties(VehicleStatProperties.class)
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat", name = "enabled", havingValue = "true")
@@ -50,27 +43,12 @@ public class VehicleStatAutoConfiguration {
}
@Bean
@ConditionalOnBean({StringRedisTemplate.class, ObjectMapper.class})
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat.jt808", name = "enabled", havingValue = "true")
@ConditionalOnMissingBean(Jt808MileageStateStore.class)
public Jt808MileageStateStore redisJt808MileageStateStore(StringRedisTemplate redis,
ObjectMapper objectMapper,
VehicleStatProperties props) {
VehicleStatProperties.Jt808 jt808 = props.getJt808();
return new RedisJt808MileageStateStore(redis, objectMapper,
jt808.getRedisKeyPrefix(), Duration.ofDays(jt808.getStateTtlDays()));
}
@Bean
@ConditionalOnBean({Jt808LocationPointExtractor.class, Jt808MileageStateStore.class,
VehicleStatRepository.class})
@ConditionalOnBean({Jt808LocationPointExtractor.class, VehicleStatRepository.class})
@ConditionalOnMissingBean
public Jt808MileageStreamProcessor jt808MileageStreamProcessor(Jt808LocationPointExtractor extractor,
Jt808MileageStateStore stateStore,
VehicleStatRepository repository,
VehicleStatProperties props) {
return new Jt808MileageStreamProcessor(extractor, stateStore, repository,
ZoneId.of(props.getZoneId()));
return new Jt808MileageStreamProcessor(extractor, repository, ZoneId.of(props.getZoneId()));
}
@Bean

View File

@@ -28,8 +28,6 @@ public class VehicleStatProperties {
public static class Jt808 {
private boolean enabled;
private String redisKeyPrefix = "vehicle:mileage:jt808:daily:";
private long stateTtlDays = 3;
public boolean isEnabled() {
return enabled;
@@ -38,22 +36,5 @@ public class VehicleStatProperties {
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getRedisKeyPrefix() {
return redisKeyPrefix;
}
public void setRedisKeyPrefix(String redisKeyPrefix) {
this.redisKeyPrefix = redisKeyPrefix;
}
public long getStateTtlDays() {
return stateTtlDays;
}
public void setStateTtlDays(long stateTtlDays) {
this.stateTtlDays = stateTtlDays;
}
}
}

View File

@@ -1,181 +0,0 @@
package com.lingniu.ingest.vehiclestat.jt808;
import com.lingniu.ingest.vehiclestat.DailyMileageStrategy;
import com.lingniu.ingest.vehiclestat.VehicleDailyStatResult;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import java.util.OptionalDouble;
public final class Jt808DailyMileageState {
private final String vehicleKey;
private final String vin;
private final String phone;
private final LocalDate statDate;
private Instant firstEventTime;
private Instant lastEventTime;
private double firstTotalMileageKm = Double.NaN;
private double lastTotalMileageKm = Double.NaN;
private int acceptedPoints;
private int totalMileageSamples;
private int outOfOrderPoints;
private int odometerAnomalies;
private Jt808DailyMileageState(String vehicleKey, String vin, String phone, LocalDate statDate) {
if (vehicleKey == null || vehicleKey.isBlank()) {
throw new IllegalArgumentException("vehicleKey must not be blank");
}
if (statDate == null) {
throw new IllegalArgumentException("statDate must not be null");
}
this.vehicleKey = vehicleKey;
this.vin = vin == null ? "" : vin;
this.phone = phone == null ? "" : phone;
this.statDate = statDate;
}
public static Jt808DailyMileageState empty(String vehicleKey, String vin, String phone, LocalDate statDate) {
return new Jt808DailyMileageState(vehicleKey, vin, phone, statDate);
}
public static Jt808DailyMileageState restore(
String vehicleKey,
String vin,
String phone,
LocalDate statDate,
Instant firstEventTime,
Instant lastEventTime,
double firstTotalMileageKm,
double lastTotalMileageKm,
int acceptedPoints,
int totalMileageSamples,
int outOfOrderPoints,
int odometerAnomalies) {
Jt808DailyMileageState state = new Jt808DailyMileageState(vehicleKey, vin, phone, statDate);
state.firstEventTime = firstEventTime;
state.lastEventTime = lastEventTime;
state.firstTotalMileageKm = firstTotalMileageKm;
state.lastTotalMileageKm = lastTotalMileageKm;
state.acceptedPoints = acceptedPoints;
state.totalMileageSamples = totalMileageSamples;
state.outOfOrderPoints = outOfOrderPoints;
state.odometerAnomalies = odometerAnomalies;
return state;
}
public void apply(Jt808LocationPoint point) {
if (point == null) {
return;
}
if (lastEventTime != null && !point.eventTime().isAfter(lastEventTime)) {
outOfOrderPoints++;
return;
}
if (firstEventTime == null) {
firstEventTime = point.eventTime();
}
updateOdometer(point);
lastEventTime = point.eventTime();
acceptedPoints++;
}
public Optional<VehicleDailyStatResult> toVehicleDailyStatResult() {
OptionalDouble dailyMileage = totalMileageDifference();
if (dailyMileage.isEmpty()) {
return Optional.empty();
}
return Optional.of(new VehicleDailyStatResult(
statVehicleId(),
statDate,
dailyMileage,
DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF));
}
private void updateOdometer(Jt808LocationPoint point) {
if (point.totalMileageKm() == null || !Double.isFinite(point.totalMileageKm())) {
return;
}
double current = point.totalMileageKm();
totalMileageSamples++;
if (!Double.isFinite(firstTotalMileageKm)) {
firstTotalMileageKm = current;
}
if (!Double.isFinite(lastTotalMileageKm)) {
lastTotalMileageKm = current;
return;
}
if (current < lastTotalMileageKm) {
odometerAnomalies++;
lastTotalMileageKm = current;
return;
}
lastTotalMileageKm = current;
}
private OptionalDouble totalMileageDifference() {
if (totalMileageSamples < 2
|| !Double.isFinite(firstTotalMileageKm)
|| !Double.isFinite(lastTotalMileageKm)
|| lastTotalMileageKm < firstTotalMileageKm) {
return OptionalDouble.empty();
}
return OptionalDouble.of(lastTotalMileageKm - firstTotalMileageKm);
}
private String statVehicleId() {
if (!vin.isBlank() && !"unknown".equalsIgnoreCase(vin)) {
return vin;
}
return vehicleKey;
}
public String vehicleKey() {
return vehicleKey;
}
public String vin() {
return vin;
}
public String phone() {
return phone;
}
public LocalDate statDate() {
return statDate;
}
public Instant firstEventTime() {
return firstEventTime;
}
public Instant lastEventTime() {
return lastEventTime;
}
public double lastTotalMileageKm() {
return lastTotalMileageKm;
}
public double firstTotalMileageKm() {
return firstTotalMileageKm;
}
public int acceptedPoints() {
return acceptedPoints;
}
public int totalMileageSamples() {
return totalMileageSamples;
}
public int outOfOrderPoints() {
return outOfOrderPoints;
}
public int odometerAnomalies() {
return odometerAnomalies;
}
}

View File

@@ -1,11 +0,0 @@
package com.lingniu.ingest.vehiclestat.jt808;
import java.time.LocalDate;
import java.util.Optional;
public interface Jt808MileageStateStore {
Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate);
void save(Jt808DailyMileageState state);
}

View File

@@ -9,17 +9,14 @@ import java.time.ZoneId;
public final class Jt808MileageStreamProcessor {
private final Jt808LocationPointExtractor extractor;
private final Jt808MileageStateStore stateStore;
private final VehicleStatRepository repository;
private final ZoneId zoneId;
public Jt808MileageStreamProcessor(
Jt808LocationPointExtractor extractor,
Jt808MileageStateStore stateStore,
VehicleStatRepository repository,
ZoneId zoneId) {
this.extractor = extractor;
this.stateStore = stateStore;
this.repository = repository;
this.zoneId = zoneId;
}
@@ -29,11 +26,17 @@ public final class Jt808MileageStreamProcessor {
}
private void processPoint(Jt808LocationPoint point) {
if (point.totalMileageKm() == null || !Double.isFinite(point.totalMileageKm())) {
return;
}
LocalDate statDate = LocalDate.ofInstant(point.eventTime(), zoneId);
Jt808DailyMileageState state = stateStore.load(point.vehicleKey(), statDate)
.orElseGet(() -> Jt808DailyMileageState.empty(point.vehicleKey(), point.vin(), point.phone(), statDate));
state.apply(point);
stateStore.save(state);
state.toVehicleDailyStatResult().ifPresent(repository::saveDailyStat);
repository.recordDailyMileageSample(statVehicleId(point), statDate, point.totalMileageKm());
}
private static String statVehicleId(Jt808LocationPoint point) {
if (!point.vin().isBlank() && !"unknown".equalsIgnoreCase(point.vin())) {
return point.vin();
}
return point.vehicleKey();
}
}

View File

@@ -1,155 +0,0 @@
package com.lingniu.ingest.vehiclestat.jt808;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
public final class RedisJt808MileageStateStore implements Jt808MileageStateStore {
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
private final String keyPrefix;
private final Duration ttl;
public RedisJt808MileageStateStore(
StringRedisTemplate redis,
ObjectMapper objectMapper,
String keyPrefix,
Duration ttl) {
this.redis = redis;
this.objectMapper = objectMapper;
this.keyPrefix = keyPrefix;
this.ttl = ttl;
}
@Override
public Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate) {
String json = redis.opsForValue().get(key(vehicleKey, statDate));
if (json == null || json.isBlank()) {
return Optional.empty();
}
try {
return Optional.of(objectMapper.readValue(json, StateSnapshot.class).toState());
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to deserialize JT808 mileage state", e);
}
}
@Override
public void save(Jt808DailyMileageState state) {
try {
redis.opsForValue().set(key(state.vehicleKey(), state.statDate()),
objectMapper.writeValueAsString(StateSnapshot.from(state)), ttl);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize JT808 mileage state", e);
}
}
private String key(String vehicleKey, LocalDate statDate) {
return keyPrefix + statDate + ":" + vehicleKey;
}
@JsonIgnoreProperties(ignoreUnknown = true)
record StateSnapshot(
String vehicleKey,
String vin,
String phone,
String statDate,
String firstEventTime,
String lastEventTime,
PointSnapshot lastPoint,
Double firstTotalMileageKm,
Double lastTotalMileageKm,
int acceptedPoints,
Integer totalMileageSamples,
int outOfOrderPoints,
int odometerAnomalies) {
static StateSnapshot from(Jt808DailyMileageState state) {
return new StateSnapshot(
state.vehicleKey(),
state.vin(),
state.phone(),
state.statDate().toString(),
format(state.firstEventTime()),
format(state.lastEventTime()),
null,
finiteOrNull(state.firstTotalMileageKm()),
finiteOrNull(state.lastTotalMileageKm()),
state.acceptedPoints(),
state.totalMileageSamples(),
state.outOfOrderPoints(),
state.odometerAnomalies());
}
Jt808DailyMileageState toState() {
return Jt808DailyMileageState.restore(
vehicleKey,
vin,
phone,
LocalDate.parse(statDate),
parseInstant(firstEventTime),
restoredLastEventTime(),
nanIfNull(firstTotalMileageKm),
nanIfNull(lastTotalMileageKm),
acceptedPoints,
restoredTotalMileageSamples(),
outOfOrderPoints,
odometerAnomalies);
}
private Instant restoredLastEventTime() {
Instant parsedLastEventTime = parseInstant(lastEventTime);
if (parsedLastEventTime != null) {
return parsedLastEventTime;
}
return lastPoint == null ? null : parseInstant(lastPoint.eventTime());
}
private int restoredTotalMileageSamples() {
if (totalMileageSamples != null) {
return totalMileageSamples;
}
if (firstTotalMileageKm != null && lastTotalMileageKm != null) {
return acceptedPoints >= 2 || !lastTotalMileageKm.equals(firstTotalMileageKm) ? 2 : 1;
}
return firstTotalMileageKm != null || lastTotalMileageKm != null ? 1 : 0;
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
record PointSnapshot(
String vehicleKey,
String vin,
String phone,
String eventTime,
double longitude,
double latitude,
Double speedKmh,
Long statusFlag,
Double totalMileageKm) {
}
private static String format(Instant instant) {
return instant == null ? null : instant.toString();
}
private static Instant parseInstant(String value) {
return value == null || value.isBlank() ? null : Instant.parse(value);
}
private static Double finiteOrNull(double value) {
return Double.isFinite(value) ? value : null;
}
private static double nanIfNull(Double value) {
return value == null ? Double.NaN : value;
}
}

View File

@@ -50,6 +50,34 @@ class JdbcVehicleStatMetricRepositoryTest {
""", Integer.class)).isZero();
}
@Test
void recordsJt808GpsMileageDifferenceInsideMetricTable() {
JdbcTemplate jdbcTemplate = new JdbcTemplate(new DriverManagerDataSource(
"jdbc:h2:mem:vehicle_stat_metric_jt808_diff;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1",
"sa",
""));
JdbcVehicleStatMetricRepository repository = new JdbcVehicleStatMetricRepository(jdbcTemplate);
VehicleDailyStatResult first = repository.recordDailyMileageSample(
"VIN001", LocalDate.of(2026, 7, 1), 1000.5).orElseThrow();
VehicleDailyStatResult second = repository.recordDailyMileageSample(
"VIN001", LocalDate.of(2026, 7, 1), 1008.75).orElseThrow();
assertThat(first.dailyMileageKm()).hasValue(0.0);
assertThat(second.dailyMileageKm()).hasValue(8.25);
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM vehicle_stat_metric WHERE vin = 'VIN001' AND stat_date = DATE '2026-07-01'",
Integer.class)).isEqualTo(3);
assertThat(jdbcTemplate.queryForObject("""
SELECT metric_value
FROM vehicle_stat_metric
WHERE vin = 'VIN001' AND metric_key = 'daily_mileage_km'
""", Double.class)).isEqualTo(8.25);
assertThat(jdbcTemplate.queryForList(
"SELECT table_name FROM information_schema.tables WHERE table_name = 'jt808_daily_mileage'",
String.class)).isEmpty();
}
@Test
void readsLegacyCalculationMethodAsTotalMileageDifference() {
JdbcTemplate jdbcTemplate = new JdbcTemplate(new DriverManagerDataSource(

View File

@@ -40,6 +40,18 @@ class VehicleStatControllerTest {
results.put(result.vin() + ":" + result.statDate(), result);
}
@Override
public Optional<VehicleDailyStatResult> recordDailyMileageSample(String vin, LocalDate statDate,
double totalMileageKm) {
VehicleDailyStatResult result = new VehicleDailyStatResult(
vin,
statDate,
OptionalDouble.of(0.0),
DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF);
saveDailyStat(result);
return Optional.of(result);
}
@Override
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
return Optional.ofNullable(results.get(vin + ":" + statDate));

View File

@@ -6,15 +6,11 @@ import com.lingniu.ingest.vehiclestat.JdbcVehicleStatMetricRepository;
import com.lingniu.ingest.vehiclestat.VehicleStatController;
import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor;
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStateStore;
import com.lingniu.ingest.vehiclestat.jt808.Jt808MileageStreamProcessor;
import com.lingniu.ingest.vehiclestat.jt808.RedisJt808MileageStateStore;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
@@ -64,17 +60,13 @@ class VehicleStatAutoConfigurationTest {
}
@Test
void createsJt808MileageBeansWhenEnabledWithRedis() {
void createsJt808MileageBeansWhenEnabledWithMetricRepositoryOnly() {
contextRunner
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
.withBean(ObjectMapper.class, ObjectMapper::new)
.withPropertyValues(
"lingniu.ingest.vehicle-stat.enabled=true",
"lingniu.ingest.vehicle-stat.jt808.enabled=true")
.run(context -> {
assertThat(context).hasSingleBean(Jt808MileageStateStore.class);
assertThat(context).hasSingleBean(RedisJt808MileageStateStore.class);
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
@@ -82,53 +74,19 @@ class VehicleStatAutoConfigurationTest {
}
@Test
void ignoresLegacyStateStorePropertyAndUsesRedisWhenAvailable() {
void ignoresLegacyStateStorePropertyAndUsesMetricRepositoryOnly() {
contextRunner
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
.withBean(ObjectMapper.class, ObjectMapper::new)
.withPropertyValues(
"lingniu.ingest.vehicle-stat.enabled=true",
"lingniu.ingest.vehicle-stat.jt808.enabled=true",
"lingniu.ingest.vehicle-stat.jt808.state-store=memory")
.run(context -> {
assertThat(context).hasSingleBean(Jt808MileageStateStore.class);
assertThat(context).hasSingleBean(RedisJt808MileageStateStore.class);
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
});
}
@Test
void doesNotFallbackToMemoryJt808StateStoreWhenRedisIsDefaultButMissing() {
contextRunner
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
.withPropertyValues(
"lingniu.ingest.vehicle-stat.enabled=true",
"lingniu.ingest.vehicle-stat.jt808.enabled=true")
.run(context -> {
assertThat(context).doesNotHaveBean(Jt808MileageStateStore.class);
assertThat(context).doesNotHaveBean(Jt808MileageStreamProcessor.class);
assertThat(context).doesNotHaveBean(VehicleStatEnvelopeIngestor.class);
});
}
@Test
void doesNotCreateJt808MileageBeansWhenRedisIsMissingEvenWithLegacyStateStoreProperty() {
contextRunner
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
.withPropertyValues(
"lingniu.ingest.vehicle-stat.enabled=true",
"lingniu.ingest.vehicle-stat.jt808.enabled=true",
"lingniu.ingest.vehicle-stat.jt808.state-store=memory")
.run(context -> {
assertThat(context).doesNotHaveBean(Jt808MileageStateStore.class);
assertThat(context).doesNotHaveBean(RedisJt808MileageStateStore.class);
assertThat(context).doesNotHaveBean(Jt808MileageStreamProcessor.class);
assertThat(context).doesNotHaveBean(VehicleStatEnvelopeIngestor.class);
});
}
@Test
void createsJt808MileageBeansAfterJdbcAutoConfiguration() {
new ApplicationContextRunner()
@@ -140,15 +98,12 @@ class VehicleStatAutoConfigurationTest {
"jdbc:h2:mem:vehicle_stat_auto;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1",
"sa",
""))
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
.withBean(ObjectMapper.class, ObjectMapper::new)
.withPropertyValues(
"lingniu.ingest.vehicle-stat.enabled=true",
"lingniu.ingest.vehicle-stat.jt808.enabled=true")
.run(context -> {
assertThat(context).hasSingleBean(JdbcTemplate.class);
assertThat(context).hasSingleBean(JdbcVehicleStatMetricRepository.class);
assertThat(context).hasSingleBean(RedisJt808MileageStateStore.class);
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);

View File

@@ -1,25 +0,0 @@
package com.lingniu.ingest.vehiclestat.jt808;
import java.time.LocalDate;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
final class InMemoryJt808MileageStateStore implements Jt808MileageStateStore {
private final Map<String, Jt808DailyMileageState> states = new ConcurrentHashMap<>();
@Override
public Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate) {
return Optional.ofNullable(states.get(key(vehicleKey, statDate)));
}
@Override
public void save(Jt808DailyMileageState state) {
states.put(key(state.vehicleKey(), state.statDate()), state);
}
private static String key(String vehicleKey, LocalDate statDate) {
return statDate + ":" + vehicleKey;
}
}

View File

@@ -1,126 +0,0 @@
package com.lingniu.ingest.vehiclestat.jt808;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.data.Offset.offset;
class Jt808DailyMileageStateTest {
@Test
void firstPointInitializesStateWithoutDistance() {
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
LocalDate.of(2026, 6, 30));
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0));
assertThat(state.acceptedPoints()).isEqualTo(1);
assertThat(state.totalMileageSamples()).isEqualTo(1);
assertThat(state.firstTotalMileageKm()).isEqualTo(100.0);
assertThat(state.lastTotalMileageKm()).isEqualTo(100.0);
assertThat(state.toVehicleDailyStatResult()).isEmpty();
assertThat(state.firstEventTime()).isEqualTo(Instant.parse("2026-06-30T00:00:00Z"));
assertThat(state.lastEventTime()).isEqualTo(Instant.parse("2026-06-30T00:00:00Z"));
}
@Test
void orderedPointsCalculateDailyMileageFromTotalMileageDifference() {
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
LocalDate.of(2026, 6, 30));
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0));
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, 101.2));
assertThat(state.acceptedPoints()).isEqualTo(2);
assertThat(state.totalMileageSamples()).isEqualTo(2);
assertThat(state.firstTotalMileageKm()).isEqualTo(100.0);
assertThat(state.lastTotalMileageKm()).isEqualTo(101.2);
assertThat(state.toVehicleDailyStatResult()).isPresent();
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm().getAsDouble())
.isCloseTo(1.2, offset(0.000001));
}
@Test
void duplicateOrOlderPointIsIgnored() {
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
LocalDate.of(2026, 6, 30));
state.apply(point("2026-06-30T00:01:00Z", 120.000000, 30.000000, 36.0, 100.0));
state.apply(point("2026-06-30T00:00:59Z", 120.010370, 30.000000, 36.0, 101.0));
assertThat(state.acceptedPoints()).isEqualTo(1);
assertThat(state.outOfOrderPoints()).isEqualTo(1);
assertThat(state.totalMileageSamples()).isEqualTo(1);
}
@Test
void odometerRollbackIsIgnoredAndCounted() {
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
LocalDate.of(2026, 6, 30));
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0));
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, 99.5));
assertThat(state.odometerAnomalies()).isEqualTo(1);
assertThat(state.toVehicleDailyStatResult()).isEmpty();
}
@Test
void odometerJumpAboveConfiguredSpeedUsesSimpleDifference() {
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
LocalDate.of(2026, 6, 30));
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, 100.0));
state.apply(point("2026-06-30T00:01:00Z", 120.000100, 30.000000, 36.0, 200.0));
assertThat(state.odometerAnomalies()).isZero();
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm()).hasValue(100.0);
}
@Test
void totalMileageIsRequiredForDailyMileageMetric() {
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
LocalDate.of(2026, 6, 30));
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, null));
state.apply(point("2026-06-30T00:01:40Z", 120.010370, 30.000000, 36.0, null));
assertThat(state.totalMileageSamples()).isZero();
assertThat(state.toVehicleDailyStatResult()).isEmpty();
}
@Test
void requiresTwoTotalMileageSamplesBeforeSavingMetric() {
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
LocalDate.of(2026, 6, 30));
state.apply(point("2026-06-30T00:00:00Z", 120.000000, 30.000000, 36.0, null));
state.apply(point("2026-06-30T00:01:00Z", 120.000100, 30.000000, 36.0, 100.0));
assertThat(state.toVehicleDailyStatResult()).isEmpty();
state.apply(point("2026-06-30T00:02:00Z", 120.000200, 30.000000, 36.0, 101.0));
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm()).hasValue(1.0);
}
private static Jt808LocationPoint point(String time,
double longitude,
double latitude,
Double speedKmh,
Double totalMileageKm) {
return new Jt808LocationPoint(
"VIN001",
"VIN001",
"13900000001",
Instant.parse(time),
longitude,
latitude,
speedKmh,
3L,
totalMileageKm);
}
}

View File

@@ -14,24 +14,22 @@ import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.OptionalDouble;
import static org.assertj.core.api.Assertions.assertThat;
class Jt808MileageStreamProcessorTest {
@Test
void updatesDailyStateAndSavesResultToVehicleStatRepository() {
InMemoryJt808MileageStateStore stateStore = new InMemoryJt808MileageStateStore();
void recordsDailyMileageSamplesToVehicleStatRepository() {
CapturingRepository repository = new CapturingRepository();
Jt808MileageStreamProcessor processor = processor(stateStore, repository);
Jt808MileageStreamProcessor processor = processor(repository);
processor.process(envelope("2026-06-30T00:00:00Z", 120.0, 30.0, 100.0));
processor.process(envelope("2026-06-30T00:01:00Z", 120.01, 30.0, 101.0));
Jt808DailyMileageState state = stateStore.load("VIN001", LocalDate.of(2026, 6, 30)).orElseThrow();
assertThat(state.acceptedPoints()).isEqualTo(2);
assertThat(state.totalMileageSamples()).isEqualTo(2);
assertThat(repository.results).hasSize(1);
assertThat(repository.samples).containsExactly(100.0, 101.0);
assertThat(repository.results).hasSize(2);
VehicleDailyStatResult latest = repository.results.getLast();
assertThat(latest.vin()).isEqualTo("VIN001");
assertThat(latest.statDate()).isEqualTo(LocalDate.of(2026, 6, 30));
@@ -41,9 +39,8 @@ class Jt808MileageStreamProcessorTest {
@Test
void usesSimpleTotalMileageDifferenceInsteadOfAccumulatedPlausibilityDeltas() {
InMemoryJt808MileageStateStore stateStore = new InMemoryJt808MileageStateStore();
CapturingRepository repository = new CapturingRepository();
Jt808MileageStreamProcessor processor = processor(stateStore, repository);
Jt808MileageStreamProcessor processor = processor(repository);
processor.process(envelope("2026-06-30T00:00:00Z", 120.0, 30.0, 100.0));
processor.process(envelope("2026-06-30T00:01:00Z", 120.0001, 30.0, 200.0));
@@ -55,7 +52,7 @@ class Jt808MileageStreamProcessorTest {
@Test
void skipsNonJt808Envelope() {
CapturingRepository repository = new CapturingRepository();
Jt808MileageStreamProcessor processor = processor(new InMemoryJt808MileageStateStore(), repository);
Jt808MileageStreamProcessor processor = processor(repository);
VehicleEnvelope envelope = envelope("2026-06-30T00:00:00Z", 120.0, 30.0, 100.0)
.toBuilder()
.setSource("GB32960")
@@ -66,12 +63,9 @@ class Jt808MileageStreamProcessorTest {
assertThat(repository.results).isEmpty();
}
private static Jt808MileageStreamProcessor processor(
Jt808MileageStateStore stateStore,
VehicleStatRepository repository) {
private static Jt808MileageStreamProcessor processor(VehicleStatRepository repository) {
return new Jt808MileageStreamProcessor(
new Jt808LocationPointExtractor(),
stateStore,
repository,
ZoneId.of("Asia/Shanghai"));
}
@@ -104,12 +98,30 @@ class Jt808MileageStreamProcessorTest {
private static final class CapturingRepository implements VehicleStatRepository {
private final List<VehicleDailyStatResult> results = new ArrayList<>();
private final List<Double> samples = new ArrayList<>();
private double firstSample = Double.NaN;
@Override
public void saveDailyStat(VehicleDailyStatResult result) {
results.add(result);
}
@Override
public Optional<VehicleDailyStatResult> recordDailyMileageSample(String vin, LocalDate statDate,
double totalMileageKm) {
samples.add(totalMileageKm);
if (!Double.isFinite(firstSample)) {
firstSample = totalMileageKm;
}
VehicleDailyStatResult result = new VehicleDailyStatResult(
vin,
statDate,
OptionalDouble.of(totalMileageKm - firstSample),
DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF);
results.add(result);
return Optional.of(result);
}
@Override
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
return Optional.empty();

View File

@@ -1,88 +0,0 @@
package com.lingniu.ingest.vehiclestat.jt808;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class RedisJt808MileageStateStoreTest {
@Test
void savesStateAsJsonWithTtl() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
RedisJt808MileageStateStore store = new RedisJt808MileageStateStore(
redis, new ObjectMapper(), "vehicle:mileage:jt808:daily:", Duration.ofDays(3));
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
LocalDate.of(2026, 6, 30));
state.apply(point("2026-06-30T00:00:00Z"));
store.save(state);
ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
verify(values).set(
org.mockito.ArgumentMatchers.eq("vehicle:mileage:jt808:daily:2026-06-30:VIN001"),
json.capture(),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(3)));
assertThat(json.getValue()).contains("\"vehicleKey\":\"VIN001\"");
assertThat(json.getValue()).doesNotContain("odometerMileageKm");
}
@Test
void loadsStateFromLegacyJson() {
StringRedisTemplate redis = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> values = mock(ValueOperations.class);
when(redis.opsForValue()).thenReturn(values);
when(values.get("vehicle:mileage:jt808:daily:2026-06-30:VIN001"))
.thenReturn("""
{"vehicleKey":"VIN001","vin":"VIN001","phone":"13900000001","statDate":"2026-06-30",
"firstEventTime":"2026-06-30T00:00:00Z","lastEventTime":"2026-06-30T00:01:00Z",
"firstTotalMileageKm":100.0,
"lastPoint":{"vehicleKey":"VIN001","vin":"VIN001","phone":"13900000001",
"eventTime":"2026-06-30T00:01:00Z","longitude":120.0,"latitude":30.0,
"speedKmh":36.0,"statusFlag":3,"totalMileageKm":101.0},
"lastTotalMileageKm":101.0,"gpsMileageKm":1.0,"speedIntegralKm":1.0,
"odometerMileageKm":1.0,"acceptedPoints":2,"badJumpSegments":0,
"longGapSegments":0,"outOfOrderPoints":0,"odometerAnomalies":0}
""");
RedisJt808MileageStateStore store = new RedisJt808MileageStateStore(
redis, new ObjectMapper(), "vehicle:mileage:jt808:daily:", Duration.ofDays(3));
Optional<Jt808DailyMileageState> loaded = store.load("VIN001", LocalDate.of(2026, 6, 30));
assertThat(loaded).hasValueSatisfying(state -> {
assertThat(state.vehicleKey()).isEqualTo("VIN001");
assertThat(state.acceptedPoints()).isEqualTo(2);
assertThat(state.totalMileageSamples()).isEqualTo(2);
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm()).hasValue(1.0);
assertThat(state.lastEventTime()).isEqualTo(Instant.parse("2026-06-30T00:01:00Z"));
});
}
private static Jt808LocationPoint point(String time) {
return new Jt808LocationPoint(
"VIN001",
"VIN001",
"13900000001",
Instant.parse(time),
120.0,
30.0,
36.0,
3L,
100.0);
}
}