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