From 140b3f39957e2d591fd3eb4ace2bad989a12686a Mon Sep 17 00:00:00 2001 From: lingniu Date: Wed, 1 Jul 2026 03:31:10 +0800 Subject: [PATCH] refactor: store jt808 mileage metrics directly --- deploy/portainer/docker-compose.yml | 20 +- .../src/main/resources/application.yml | 2 - .../PortainerComposeResourceLimitsTest.java | 71 +++++++ modules/services/vehicle-stat-service/pom.xml | 4 - .../JdbcVehicleStatMetricRepository.java | 85 ++++++-- .../vehiclestat/VehicleStatRepository.java | 2 + .../config/VehicleStatAutoConfiguration.java | 28 +-- .../config/VehicleStatProperties.java | 19 -- .../jt808/Jt808DailyMileageState.java | 181 ------------------ .../jt808/Jt808MileageStateStore.java | 11 -- .../jt808/Jt808MileageStreamProcessor.java | 19 +- .../jt808/RedisJt808MileageStateStore.java | 155 --------------- .../JdbcVehicleStatMetricRepositoryTest.java | 28 +++ .../VehicleStatControllerTest.java | 12 ++ .../VehicleStatAutoConfigurationTest.java | 49 +---- .../jt808/InMemoryJt808MileageStateStore.java | 25 --- .../jt808/Jt808DailyMileageStateTest.java | 126 ------------ .../Jt808MileageStreamProcessorTest.java | 40 ++-- .../RedisJt808MileageStateStoreTest.java | 88 --------- 19 files changed, 236 insertions(+), 729 deletions(-) create mode 100644 modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/PortainerComposeResourceLimitsTest.java delete mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808DailyMileageState.java delete mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStateStore.java delete mode 100644 modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/RedisJt808MileageStateStore.java delete mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/InMemoryJt808MileageStateStore.java delete mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/Jt808DailyMileageStateTest.java delete mode 100644 modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/RedisJt808MileageStateStoreTest.java diff --git a/deploy/portainer/docker-compose.yml b/deploy/portainer/docker-compose.yml index ab9eed68..af9e3bbe 100644 --- a/deploy/portainer/docker-compose.yml +++ b/deploy/portainer/docker-compose.yml @@ -40,6 +40,7 @@ services: image: crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/gb32960-ingest-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION} container_name: gb32960-ingest-app restart: unless-stopped + mem_limit: ${GB32960_MEM_LIMIT:-768m} environment: <<: *common-env HTTP_PORT: 20100 @@ -67,6 +68,7 @@ services: image: crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/jt808-ingest-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION} container_name: jt808-ingest-app restart: unless-stopped + mem_limit: ${JT808_MEM_LIMIT:-768m} environment: <<: *common-env HTTP_PORT: 20400 @@ -89,6 +91,7 @@ services: image: crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/yutong-mqtt-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION} container_name: yutong-mqtt-app restart: unless-stopped + mem_limit: ${YUTONG_MQTT_MEM_LIMIT:-512m} environment: <<: *common-env HTTP_PORT: 20500 @@ -116,6 +119,7 @@ services: image: crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-history-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION} container_name: vehicle-history-app restart: unless-stopped + mem_limit: ${VEHICLE_HISTORY_MEM_LIMIT:-1536m} depends_on: - gb32960-ingest-app - jt808-ingest-app @@ -126,15 +130,17 @@ services: KAFKA_CONSUMER_CLIENT_ID_PREFIX: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX_HISTORY:-vehicle-history} KAFKA_CONSUMER_MAX_POLL_RECORDS: ${KAFKA_CONSUMER_MAX_POLL_RECORDS_HISTORY:-2000} KAFKA_GROUP_HISTORY: ${KAFKA_GROUP_HISTORY:-vehicle-history} - KAFKA_GROUP_HISTORY_GB32960: ${KAFKA_GROUP_HISTORY_GB32960:-vehicle-history} - KAFKA_GROUP_HISTORY_JT808: ${KAFKA_GROUP_HISTORY_JT808:-vehicle-history} - KAFKA_GROUP_HISTORY_MQTT_YUTONG: ${KAFKA_GROUP_HISTORY_MQTT_YUTONG:-vehicle-history} + KAFKA_GROUP_HISTORY_GB32960_EVENT: ${KAFKA_GROUP_HISTORY_GB32960_EVENT:-${KAFKA_GROUP_HISTORY:-vehicle-history}-gb32960-event} + KAFKA_GROUP_HISTORY_JT808_EVENT: ${KAFKA_GROUP_HISTORY_JT808_EVENT:-${KAFKA_GROUP_HISTORY:-vehicle-history}-jt808-event} + KAFKA_GROUP_HISTORY_YUTONG_MQTT_EVENT: ${KAFKA_GROUP_HISTORY_YUTONG_MQTT_EVENT:-${KAFKA_GROUP_HISTORY:-vehicle-history}-yutong-mqtt-event} + KAFKA_GROUP_HISTORY_GB32960_RAW: ${KAFKA_GROUP_HISTORY_GB32960_RAW:-${KAFKA_GROUP_HISTORY:-vehicle-history}-gb32960-raw} + KAFKA_GROUP_HISTORY_JT808_RAW: ${KAFKA_GROUP_HISTORY_JT808_RAW:-${KAFKA_GROUP_HISTORY:-vehicle-history}-jt808-raw} + KAFKA_GROUP_HISTORY_YUTONG_MQTT_RAW: ${KAFKA_GROUP_HISTORY_YUTONG_MQTT_RAW:-${KAFKA_GROUP_HISTORY:-vehicle-history}-yutong-mqtt-raw} SINK_ARCHIVE_ENABLED: ${SINK_ARCHIVE_ENABLED:-true} SINK_ARCHIVE_PATH: /archive/ - TDENGINE_ENABLED: ${TDENGINE_ENABLED:-true} + TDENGINE_HISTORY_ENABLED: ${TDENGINE_HISTORY_ENABLED:-true} TDENGINE_JDBC_URL: ${TDENGINE_JDBC_URL:-jdbc:TAOS-WS://172.17.111.57:6041/vehicle_ts} - TDENGINE_DATABASE: ${TDENGINE_DATABASE:-vehicle_ts} - TDENGINE_JT808_LOCATION_STABLE: ${TDENGINE_JT808_LOCATION_STABLE:-jt808_locations} + TDENGINE_HISTORY_DATABASE: ${TDENGINE_HISTORY_DATABASE:-vehicle_ts} TDENGINE_USERNAME: ${TDENGINE_USERNAME:-root} TDENGINE_PASSWORD: ${TDENGINE_PASSWORD:-taosdata} TDENGINE_CONNECTION_TIMEOUT_MILLIS: ${TDENGINE_CONNECTION_TIMEOUT_MILLIS:-30000} @@ -151,6 +157,7 @@ services: image: crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-analytics-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION} container_name: vehicle-analytics-app restart: unless-stopped + mem_limit: ${VEHICLE_ANALYTICS_MEM_LIMIT:-1024m} depends_on: - gb32960-ingest-app - jt808-ingest-app @@ -164,7 +171,6 @@ services: VEHICLE_STAT_ENABLED: ${VEHICLE_STAT_ENABLED:-true} VEHICLE_STAT_ZONE_ID: ${VEHICLE_STAT_ZONE_ID:-Asia/Shanghai} VEHICLE_STAT_JT808_MILEAGE_ENABLED: ${VEHICLE_STAT_JT808_MILEAGE_ENABLED:-true} - VEHICLE_STAT_JT808_STATE_TTL_DAYS: ${VEHICLE_STAT_JT808_STATE_TTL_DAYS:-3} MYSQL_JDBC_URL: ${MYSQL_JDBC_URL:-} MYSQL_USERNAME: ${MYSQL_USERNAME:-} MYSQL_PASSWORD: ${MYSQL_PASSWORD:-} diff --git a/modules/apps/vehicle-analytics-app/src/main/resources/application.yml b/modules/apps/vehicle-analytics-app/src/main/resources/application.yml index 2912af18..e978c376 100644 --- a/modules/apps/vehicle-analytics-app/src/main/resources/application.yml +++ b/modules/apps/vehicle-analytics-app/src/main/resources/application.yml @@ -81,8 +81,6 @@ lingniu: zone-id: ${VEHICLE_STAT_ZONE_ID:Asia/Shanghai} jt808: enabled: ${VEHICLE_STAT_JT808_MILEAGE_ENABLED:true} - redis-key-prefix: ${VEHICLE_STAT_JT808_REDIS_KEY_PREFIX:vehicle:mileage:jt808:daily:} - state-ttl-days: ${VEHICLE_STAT_JT808_STATE_TTL_DAYS:3} management: endpoints: diff --git a/modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/PortainerComposeResourceLimitsTest.java b/modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/PortainerComposeResourceLimitsTest.java new file mode 100644 index 00000000..a0e4388a --- /dev/null +++ b/modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/PortainerComposeResourceLimitsTest.java @@ -0,0 +1,71 @@ +package com.lingniu.ingest.historyapp; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class PortainerComposeResourceLimitsTest { + + @Test + void productionServicesHaveOverridableMemoryLimits() throws IOException { + String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml")); + + assertServiceMemoryLimit(compose, "gb32960-ingest-app", "GB32960_MEM_LIMIT", "768m"); + assertServiceMemoryLimit(compose, "jt808-ingest-app", "JT808_MEM_LIMIT", "768m"); + assertServiceMemoryLimit(compose, "yutong-mqtt-app", "YUTONG_MQTT_MEM_LIMIT", "512m"); + assertServiceMemoryLimit(compose, "vehicle-history-app", "VEHICLE_HISTORY_MEM_LIMIT", "1536m"); + assertServiceMemoryLimit(compose, "vehicle-analytics-app", "VEHICLE_ANALYTICS_MEM_LIMIT", "1024m"); + } + + @Test + void vehicleHistoryUsesApplicationConsumerGroupEnvironmentNames() throws IOException { + String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml")); + + assertThat(compose) + .contains("KAFKA_GROUP_HISTORY_GB32960_EVENT: ${KAFKA_GROUP_HISTORY_GB32960_EVENT:-${KAFKA_GROUP_HISTORY:-vehicle-history}-gb32960-event}") + .contains("KAFKA_GROUP_HISTORY_JT808_EVENT: ${KAFKA_GROUP_HISTORY_JT808_EVENT:-${KAFKA_GROUP_HISTORY:-vehicle-history}-jt808-event}") + .contains("KAFKA_GROUP_HISTORY_YUTONG_MQTT_EVENT: ${KAFKA_GROUP_HISTORY_YUTONG_MQTT_EVENT:-${KAFKA_GROUP_HISTORY:-vehicle-history}-yutong-mqtt-event}") + .contains("KAFKA_GROUP_HISTORY_GB32960_RAW: ${KAFKA_GROUP_HISTORY_GB32960_RAW:-${KAFKA_GROUP_HISTORY:-vehicle-history}-gb32960-raw}") + .contains("KAFKA_GROUP_HISTORY_JT808_RAW: ${KAFKA_GROUP_HISTORY_JT808_RAW:-${KAFKA_GROUP_HISTORY:-vehicle-history}-jt808-raw}") + .contains("KAFKA_GROUP_HISTORY_YUTONG_MQTT_RAW: ${KAFKA_GROUP_HISTORY_YUTONG_MQTT_RAW:-${KAFKA_GROUP_HISTORY:-vehicle-history}-yutong-mqtt-raw}") + .doesNotContain("\n KAFKA_GROUP_HISTORY_GB32960:") + .doesNotContain("\n KAFKA_GROUP_HISTORY_JT808:") + .doesNotContain("\n KAFKA_GROUP_HISTORY_MQTT_YUTONG:"); + } + + @Test + void vehicleHistoryUsesTdengineHistoryEnvironmentNames() throws IOException { + String compose = Files.readString(repositoryRoot().resolve("deploy/portainer/docker-compose.yml")); + + assertThat(compose) + .contains("TDENGINE_HISTORY_ENABLED: ${TDENGINE_HISTORY_ENABLED:-true}") + .contains("TDENGINE_HISTORY_DATABASE: ${TDENGINE_HISTORY_DATABASE:-vehicle_ts}") + .doesNotContain("\n TDENGINE_ENABLED:") + .doesNotContain("\n TDENGINE_DATABASE:"); + } + + private static void assertServiceMemoryLimit(String compose, + String serviceName, + String envName, + String defaultLimit) { + assertThat(compose) + .contains(serviceName + ":\n") + .contains("\n " + serviceName + ":\n") + .contains(" mem_limit: ${" + envName + ":-" + defaultLimit + "}"); + } + + private static Path repositoryRoot() { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + while (current != null) { + if (Files.exists(current.resolve("deploy/portainer/docker-compose.yml"))) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("repository root not found"); + } +} diff --git a/modules/services/vehicle-stat-service/pom.xml b/modules/services/vehicle-stat-service/pom.xml index 1324eba4..0209f0ba 100644 --- a/modules/services/vehicle-stat-service/pom.xml +++ b/modules/services/vehicle-stat-service/pom.xml @@ -28,10 +28,6 @@ org.springframework.boot spring-boot-starter-web - - org.springframework.boot - spring-boot-starter-data-redis - org.springframework spring-jdbc diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepository.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepository.java index af0a7557..0fe7e5c3 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepository.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepository.java @@ -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 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 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() { diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRepository.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRepository.java index faaca482..3248deff 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRepository.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleStatRepository.java @@ -7,5 +7,7 @@ public interface VehicleStatRepository { void saveDailyStat(VehicleDailyStatResult result); + Optional recordDailyMileageSample(String vin, LocalDate statDate, double totalMileageKm); + Optional findDailyStat(String vin, LocalDate statDate); } diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java index 58149aaf..f97e7a71 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfiguration.java @@ -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 diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java index d87c8f41..24b00c4f 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/config/VehicleStatProperties.java @@ -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; - } - } } diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808DailyMileageState.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808DailyMileageState.java deleted file mode 100644 index 9da95d47..00000000 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808DailyMileageState.java +++ /dev/null @@ -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 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; - } -} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStateStore.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStateStore.java deleted file mode 100644 index a9e3c43e..00000000 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStateStore.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.lingniu.ingest.vehiclestat.jt808; - -import java.time.LocalDate; -import java.util.Optional; - -public interface Jt808MileageStateStore { - - Optional load(String vehicleKey, LocalDate statDate); - - void save(Jt808DailyMileageState state); -} diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStreamProcessor.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStreamProcessor.java index ca03980d..482005e2 100644 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStreamProcessor.java +++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStreamProcessor.java @@ -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(); } } diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/RedisJt808MileageStateStore.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/RedisJt808MileageStateStore.java deleted file mode 100644 index 6ef1e34c..00000000 --- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/jt808/RedisJt808MileageStateStore.java +++ /dev/null @@ -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 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; - } -} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepositoryTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepositoryTest.java index 13c4b731..e980ef15 100644 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepositoryTest.java +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/JdbcVehicleStatMetricRepositoryTest.java @@ -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( diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatControllerTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatControllerTest.java index ad81c7c9..51ca0b82 100644 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatControllerTest.java +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/VehicleStatControllerTest.java @@ -40,6 +40,18 @@ class VehicleStatControllerTest { results.put(result.vin() + ":" + result.statDate(), result); } + @Override + public Optional 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 findDailyStat(String vin, LocalDate statDate) { return Optional.ofNullable(results.get(vin + ":" + statDate)); diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfigurationTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfigurationTest.java index 22c14f21..c700558c 100644 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfigurationTest.java +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/config/VehicleStatAutoConfigurationTest.java @@ -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); diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/InMemoryJt808MileageStateStore.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/InMemoryJt808MileageStateStore.java deleted file mode 100644 index a6e0219d..00000000 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/InMemoryJt808MileageStateStore.java +++ /dev/null @@ -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 states = new ConcurrentHashMap<>(); - - @Override - public Optional 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; - } -} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/Jt808DailyMileageStateTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/Jt808DailyMileageStateTest.java deleted file mode 100644 index ebc43ee2..00000000 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/Jt808DailyMileageStateTest.java +++ /dev/null @@ -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); - } -} diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStreamProcessorTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStreamProcessorTest.java index 9679142b..37c07c7e 100644 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStreamProcessorTest.java +++ b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/Jt808MileageStreamProcessorTest.java @@ -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 results = new ArrayList<>(); + private final List samples = new ArrayList<>(); + private double firstSample = Double.NaN; @Override public void saveDailyStat(VehicleDailyStatResult result) { results.add(result); } + @Override + public Optional 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 findDailyStat(String vin, LocalDate statDate) { return Optional.empty(); diff --git a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/RedisJt808MileageStateStoreTest.java b/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/RedisJt808MileageStateStoreTest.java deleted file mode 100644 index 06b0aecf..00000000 --- a/modules/services/vehicle-stat-service/src/test/java/com/lingniu/ingest/vehiclestat/jt808/RedisJt808MileageStateStoreTest.java +++ /dev/null @@ -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 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 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 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 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); - } -}