refactor: store jt808 mileage metrics directly
This commit is contained in:
@@ -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}
|
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
|
container_name: gb32960-ingest-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: ${GB32960_MEM_LIMIT:-768m}
|
||||||
environment:
|
environment:
|
||||||
<<: *common-env
|
<<: *common-env
|
||||||
HTTP_PORT: 20100
|
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}
|
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
|
container_name: jt808-ingest-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: ${JT808_MEM_LIMIT:-768m}
|
||||||
environment:
|
environment:
|
||||||
<<: *common-env
|
<<: *common-env
|
||||||
HTTP_PORT: 20400
|
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}
|
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
|
container_name: yutong-mqtt-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: ${YUTONG_MQTT_MEM_LIMIT:-512m}
|
||||||
environment:
|
environment:
|
||||||
<<: *common-env
|
<<: *common-env
|
||||||
HTTP_PORT: 20500
|
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}
|
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
|
container_name: vehicle-history-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: ${VEHICLE_HISTORY_MEM_LIMIT:-1536m}
|
||||||
depends_on:
|
depends_on:
|
||||||
- gb32960-ingest-app
|
- gb32960-ingest-app
|
||||||
- jt808-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_CLIENT_ID_PREFIX: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX_HISTORY:-vehicle-history}
|
||||||
KAFKA_CONSUMER_MAX_POLL_RECORDS: ${KAFKA_CONSUMER_MAX_POLL_RECORDS_HISTORY:-2000}
|
KAFKA_CONSUMER_MAX_POLL_RECORDS: ${KAFKA_CONSUMER_MAX_POLL_RECORDS_HISTORY:-2000}
|
||||||
KAFKA_GROUP_HISTORY: ${KAFKA_GROUP_HISTORY:-vehicle-history}
|
KAFKA_GROUP_HISTORY: ${KAFKA_GROUP_HISTORY:-vehicle-history}
|
||||||
KAFKA_GROUP_HISTORY_GB32960: ${KAFKA_GROUP_HISTORY_GB32960:-vehicle-history}
|
KAFKA_GROUP_HISTORY_GB32960_EVENT: ${KAFKA_GROUP_HISTORY_GB32960_EVENT:-${KAFKA_GROUP_HISTORY:-vehicle-history}-gb32960-event}
|
||||||
KAFKA_GROUP_HISTORY_JT808: ${KAFKA_GROUP_HISTORY_JT808:-vehicle-history}
|
KAFKA_GROUP_HISTORY_JT808_EVENT: ${KAFKA_GROUP_HISTORY_JT808_EVENT:-${KAFKA_GROUP_HISTORY:-vehicle-history}-jt808-event}
|
||||||
KAFKA_GROUP_HISTORY_MQTT_YUTONG: ${KAFKA_GROUP_HISTORY_MQTT_YUTONG:-vehicle-history}
|
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_ENABLED: ${SINK_ARCHIVE_ENABLED:-true}
|
||||||
SINK_ARCHIVE_PATH: /archive/
|
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_JDBC_URL: ${TDENGINE_JDBC_URL:-jdbc:TAOS-WS://172.17.111.57:6041/vehicle_ts}
|
||||||
TDENGINE_DATABASE: ${TDENGINE_DATABASE:-vehicle_ts}
|
TDENGINE_HISTORY_DATABASE: ${TDENGINE_HISTORY_DATABASE:-vehicle_ts}
|
||||||
TDENGINE_JT808_LOCATION_STABLE: ${TDENGINE_JT808_LOCATION_STABLE:-jt808_locations}
|
|
||||||
TDENGINE_USERNAME: ${TDENGINE_USERNAME:-root}
|
TDENGINE_USERNAME: ${TDENGINE_USERNAME:-root}
|
||||||
TDENGINE_PASSWORD: ${TDENGINE_PASSWORD:-taosdata}
|
TDENGINE_PASSWORD: ${TDENGINE_PASSWORD:-taosdata}
|
||||||
TDENGINE_CONNECTION_TIMEOUT_MILLIS: ${TDENGINE_CONNECTION_TIMEOUT_MILLIS:-30000}
|
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}
|
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
|
container_name: vehicle-analytics-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
mem_limit: ${VEHICLE_ANALYTICS_MEM_LIMIT:-1024m}
|
||||||
depends_on:
|
depends_on:
|
||||||
- gb32960-ingest-app
|
- gb32960-ingest-app
|
||||||
- jt808-ingest-app
|
- jt808-ingest-app
|
||||||
@@ -164,7 +171,6 @@ services:
|
|||||||
VEHICLE_STAT_ENABLED: ${VEHICLE_STAT_ENABLED:-true}
|
VEHICLE_STAT_ENABLED: ${VEHICLE_STAT_ENABLED:-true}
|
||||||
VEHICLE_STAT_ZONE_ID: ${VEHICLE_STAT_ZONE_ID:-Asia/Shanghai}
|
VEHICLE_STAT_ZONE_ID: ${VEHICLE_STAT_ZONE_ID:-Asia/Shanghai}
|
||||||
VEHICLE_STAT_JT808_MILEAGE_ENABLED: ${VEHICLE_STAT_JT808_MILEAGE_ENABLED:-true}
|
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_JDBC_URL: ${MYSQL_JDBC_URL:-}
|
||||||
MYSQL_USERNAME: ${MYSQL_USERNAME:-}
|
MYSQL_USERNAME: ${MYSQL_USERNAME:-}
|
||||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ lingniu:
|
|||||||
zone-id: ${VEHICLE_STAT_ZONE_ID:Asia/Shanghai}
|
zone-id: ${VEHICLE_STAT_ZONE_ID:Asia/Shanghai}
|
||||||
jt808:
|
jt808:
|
||||||
enabled: ${VEHICLE_STAT_JT808_MILEAGE_ENABLED:true}
|
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:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,10 +28,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework</groupId>
|
<groupId>org.springframework</groupId>
|
||||||
<artifactId>spring-jdbc</artifactId>
|
<artifactId>spring-jdbc</artifactId>
|
||||||
|
|||||||
@@ -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_KEY = "daily_mileage_km";
|
||||||
private static final String DAILY_MILEAGE_UNIT = "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;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
@@ -33,15 +35,7 @@ public final class JdbcVehicleStatMetricRepository implements VehicleStatReposit
|
|||||||
Date statDate = Date.valueOf(result.statDate());
|
Date statDate = Date.valueOf(result.statDate());
|
||||||
double value = result.dailyMileageKm().getAsDouble();
|
double value = result.dailyMileageKm().getAsDouble();
|
||||||
String strategy = result.dailyMileageStrategy().name();
|
String strategy = result.dailyMileageStrategy().name();
|
||||||
try {
|
upsertMetric(vin, statDate, DAILY_MILEAGE_KEY, value, DAILY_MILEAGE_UNIT, strategy);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -62,15 +56,72 @@ public final class JdbcVehicleStatMetricRepository implements VehicleStatReposit
|
|||||||
return rows.stream().findFirst();
|
return rows.stream().findFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateDailyStat(String vin, Date statDate, double value, String strategy) {
|
@Override
|
||||||
jdbcTemplate.update("""
|
public Optional<VehicleDailyStatResult> recordDailyMileageSample(String vin, LocalDate statDate, double totalMileageKm) {
|
||||||
UPDATE vehicle_stat_metric
|
if (!Double.isFinite(totalMileageKm) || totalMileageKm < 0.0) {
|
||||||
SET metric_value = ?,
|
return Optional.empty();
|
||||||
metric_unit = ?,
|
}
|
||||||
calculation_method = ?,
|
String normalizedVin = clean(vin);
|
||||||
updated_at = CURRENT_TIMESTAMP
|
Date date = Date.valueOf(statDate);
|
||||||
|
OptionalDouble existingStart = findMetric(normalizedVin, date, DAILY_MILEAGE_START_TOTAL_KEY);
|
||||||
|
double startTotalMileage = existingStart.orElse(totalMileageKm);
|
||||||
|
if (existingStart.isEmpty()) {
|
||||||
|
upsertMetric(normalizedVin, date, DAILY_MILEAGE_START_TOTAL_KEY, startTotalMileage,
|
||||||
|
DAILY_MILEAGE_UNIT, DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionalDouble existingLatest = findMetric(normalizedVin, date, DAILY_MILEAGE_LATEST_TOTAL_KEY);
|
||||||
|
if (existingLatest.isPresent() && totalMileageKm < existingLatest.getAsDouble()) {
|
||||||
|
return findDailyStat(normalizedVin, statDate);
|
||||||
|
}
|
||||||
|
if (totalMileageKm < startTotalMileage) {
|
||||||
|
return findDailyStat(normalizedVin, statDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
String strategy = DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF.name();
|
||||||
|
upsertMetric(normalizedVin, date, DAILY_MILEAGE_LATEST_TOTAL_KEY, totalMileageKm,
|
||||||
|
DAILY_MILEAGE_UNIT, strategy);
|
||||||
|
double dailyMileageKm = totalMileageKm - startTotalMileage;
|
||||||
|
VehicleDailyStatResult result = new VehicleDailyStatResult(
|
||||||
|
normalizedVin,
|
||||||
|
statDate,
|
||||||
|
OptionalDouble.of(dailyMileageKm),
|
||||||
|
DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF);
|
||||||
|
saveDailyStat(result);
|
||||||
|
return Optional.of(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OptionalDouble findMetric(String vin, Date statDate, String metricKey) {
|
||||||
|
List<Double> rows = jdbcTemplate.query("""
|
||||||
|
SELECT metric_value
|
||||||
|
FROM vehicle_stat_metric
|
||||||
WHERE vin = ? AND stat_date = ? AND metric_key = ?
|
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() {
|
private void ensureSchema() {
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ public interface VehicleStatRepository {
|
|||||||
|
|
||||||
void saveDailyStat(VehicleDailyStatResult result);
|
void saveDailyStat(VehicleDailyStatResult result);
|
||||||
|
|
||||||
|
Optional<VehicleDailyStatResult> recordDailyMileageSample(String vin, LocalDate statDate, double totalMileageKm);
|
||||||
|
|
||||||
Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate);
|
Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,8 @@ import com.lingniu.ingest.vehiclestat.VehicleStatController;
|
|||||||
import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor;
|
import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor;
|
||||||
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
||||||
import com.lingniu.ingest.vehiclestat.jt808.Jt808LocationPointExtractor;
|
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.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.AutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
|
|
||||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
|
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
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.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
|
||||||
@AutoConfiguration(after = {
|
@AutoConfiguration(after = {
|
||||||
DataSourceAutoConfiguration.class,
|
DataSourceAutoConfiguration.class,
|
||||||
JdbcTemplateAutoConfiguration.class,
|
JdbcTemplateAutoConfiguration.class
|
||||||
RedisAutoConfiguration.class
|
|
||||||
})
|
})
|
||||||
@EnableConfigurationProperties(VehicleStatProperties.class)
|
@EnableConfigurationProperties(VehicleStatProperties.class)
|
||||||
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat", name = "enabled", havingValue = "true")
|
@ConditionalOnProperty(prefix = "lingniu.ingest.vehicle-stat", name = "enabled", havingValue = "true")
|
||||||
@@ -50,27 +43,12 @@ public class VehicleStatAutoConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ConditionalOnBean({StringRedisTemplate.class, ObjectMapper.class})
|
@ConditionalOnBean({Jt808LocationPointExtractor.class, VehicleStatRepository.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})
|
|
||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
public Jt808MileageStreamProcessor jt808MileageStreamProcessor(Jt808LocationPointExtractor extractor,
|
public Jt808MileageStreamProcessor jt808MileageStreamProcessor(Jt808LocationPointExtractor extractor,
|
||||||
Jt808MileageStateStore stateStore,
|
|
||||||
VehicleStatRepository repository,
|
VehicleStatRepository repository,
|
||||||
VehicleStatProperties props) {
|
VehicleStatProperties props) {
|
||||||
return new Jt808MileageStreamProcessor(extractor, stateStore, repository,
|
return new Jt808MileageStreamProcessor(extractor, repository, ZoneId.of(props.getZoneId()));
|
||||||
ZoneId.of(props.getZoneId()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ public class VehicleStatProperties {
|
|||||||
|
|
||||||
public static class Jt808 {
|
public static class Jt808 {
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
private String redisKeyPrefix = "vehicle:mileage:jt808:daily:";
|
|
||||||
private long stateTtlDays = 3;
|
|
||||||
|
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return enabled;
|
return enabled;
|
||||||
@@ -38,22 +36,5 @@ public class VehicleStatProperties {
|
|||||||
public void setEnabled(boolean enabled) {
|
public void setEnabled(boolean enabled) {
|
||||||
this.enabled = 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
package com.lingniu.ingest.vehiclestat.jt808;
|
|
||||||
|
|
||||||
import com.lingniu.ingest.vehiclestat.DailyMileageStrategy;
|
|
||||||
import com.lingniu.ingest.vehiclestat.VehicleDailyStatResult;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.OptionalDouble;
|
|
||||||
|
|
||||||
public final class Jt808DailyMileageState {
|
|
||||||
|
|
||||||
private final String vehicleKey;
|
|
||||||
private final String vin;
|
|
||||||
private final String phone;
|
|
||||||
private final LocalDate statDate;
|
|
||||||
private Instant firstEventTime;
|
|
||||||
private Instant lastEventTime;
|
|
||||||
private double firstTotalMileageKm = Double.NaN;
|
|
||||||
private double lastTotalMileageKm = Double.NaN;
|
|
||||||
private int acceptedPoints;
|
|
||||||
private int totalMileageSamples;
|
|
||||||
private int outOfOrderPoints;
|
|
||||||
private int odometerAnomalies;
|
|
||||||
|
|
||||||
private Jt808DailyMileageState(String vehicleKey, String vin, String phone, LocalDate statDate) {
|
|
||||||
if (vehicleKey == null || vehicleKey.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("vehicleKey must not be blank");
|
|
||||||
}
|
|
||||||
if (statDate == null) {
|
|
||||||
throw new IllegalArgumentException("statDate must not be null");
|
|
||||||
}
|
|
||||||
this.vehicleKey = vehicleKey;
|
|
||||||
this.vin = vin == null ? "" : vin;
|
|
||||||
this.phone = phone == null ? "" : phone;
|
|
||||||
this.statDate = statDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Jt808DailyMileageState empty(String vehicleKey, String vin, String phone, LocalDate statDate) {
|
|
||||||
return new Jt808DailyMileageState(vehicleKey, vin, phone, statDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Jt808DailyMileageState restore(
|
|
||||||
String vehicleKey,
|
|
||||||
String vin,
|
|
||||||
String phone,
|
|
||||||
LocalDate statDate,
|
|
||||||
Instant firstEventTime,
|
|
||||||
Instant lastEventTime,
|
|
||||||
double firstTotalMileageKm,
|
|
||||||
double lastTotalMileageKm,
|
|
||||||
int acceptedPoints,
|
|
||||||
int totalMileageSamples,
|
|
||||||
int outOfOrderPoints,
|
|
||||||
int odometerAnomalies) {
|
|
||||||
Jt808DailyMileageState state = new Jt808DailyMileageState(vehicleKey, vin, phone, statDate);
|
|
||||||
state.firstEventTime = firstEventTime;
|
|
||||||
state.lastEventTime = lastEventTime;
|
|
||||||
state.firstTotalMileageKm = firstTotalMileageKm;
|
|
||||||
state.lastTotalMileageKm = lastTotalMileageKm;
|
|
||||||
state.acceptedPoints = acceptedPoints;
|
|
||||||
state.totalMileageSamples = totalMileageSamples;
|
|
||||||
state.outOfOrderPoints = outOfOrderPoints;
|
|
||||||
state.odometerAnomalies = odometerAnomalies;
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void apply(Jt808LocationPoint point) {
|
|
||||||
if (point == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastEventTime != null && !point.eventTime().isAfter(lastEventTime)) {
|
|
||||||
outOfOrderPoints++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (firstEventTime == null) {
|
|
||||||
firstEventTime = point.eventTime();
|
|
||||||
}
|
|
||||||
updateOdometer(point);
|
|
||||||
lastEventTime = point.eventTime();
|
|
||||||
acceptedPoints++;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Optional<VehicleDailyStatResult> toVehicleDailyStatResult() {
|
|
||||||
OptionalDouble dailyMileage = totalMileageDifference();
|
|
||||||
if (dailyMileage.isEmpty()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return Optional.of(new VehicleDailyStatResult(
|
|
||||||
statVehicleId(),
|
|
||||||
statDate,
|
|
||||||
dailyMileage,
|
|
||||||
DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateOdometer(Jt808LocationPoint point) {
|
|
||||||
if (point.totalMileageKm() == null || !Double.isFinite(point.totalMileageKm())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
double current = point.totalMileageKm();
|
|
||||||
totalMileageSamples++;
|
|
||||||
if (!Double.isFinite(firstTotalMileageKm)) {
|
|
||||||
firstTotalMileageKm = current;
|
|
||||||
}
|
|
||||||
if (!Double.isFinite(lastTotalMileageKm)) {
|
|
||||||
lastTotalMileageKm = current;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (current < lastTotalMileageKm) {
|
|
||||||
odometerAnomalies++;
|
|
||||||
lastTotalMileageKm = current;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastTotalMileageKm = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
private OptionalDouble totalMileageDifference() {
|
|
||||||
if (totalMileageSamples < 2
|
|
||||||
|| !Double.isFinite(firstTotalMileageKm)
|
|
||||||
|| !Double.isFinite(lastTotalMileageKm)
|
|
||||||
|| lastTotalMileageKm < firstTotalMileageKm) {
|
|
||||||
return OptionalDouble.empty();
|
|
||||||
}
|
|
||||||
return OptionalDouble.of(lastTotalMileageKm - firstTotalMileageKm);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String statVehicleId() {
|
|
||||||
if (!vin.isBlank() && !"unknown".equalsIgnoreCase(vin)) {
|
|
||||||
return vin;
|
|
||||||
}
|
|
||||||
return vehicleKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String vehicleKey() {
|
|
||||||
return vehicleKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String vin() {
|
|
||||||
return vin;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String phone() {
|
|
||||||
return phone;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDate statDate() {
|
|
||||||
return statDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant firstEventTime() {
|
|
||||||
return firstEventTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant lastEventTime() {
|
|
||||||
return lastEventTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double lastTotalMileageKm() {
|
|
||||||
return lastTotalMileageKm;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double firstTotalMileageKm() {
|
|
||||||
return firstTotalMileageKm;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int acceptedPoints() {
|
|
||||||
return acceptedPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int totalMileageSamples() {
|
|
||||||
return totalMileageSamples;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int outOfOrderPoints() {
|
|
||||||
return outOfOrderPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int odometerAnomalies() {
|
|
||||||
return odometerAnomalies;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package com.lingniu.ingest.vehiclestat.jt808;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface Jt808MileageStateStore {
|
|
||||||
|
|
||||||
Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate);
|
|
||||||
|
|
||||||
void save(Jt808DailyMileageState state);
|
|
||||||
}
|
|
||||||
@@ -9,17 +9,14 @@ import java.time.ZoneId;
|
|||||||
public final class Jt808MileageStreamProcessor {
|
public final class Jt808MileageStreamProcessor {
|
||||||
|
|
||||||
private final Jt808LocationPointExtractor extractor;
|
private final Jt808LocationPointExtractor extractor;
|
||||||
private final Jt808MileageStateStore stateStore;
|
|
||||||
private final VehicleStatRepository repository;
|
private final VehicleStatRepository repository;
|
||||||
private final ZoneId zoneId;
|
private final ZoneId zoneId;
|
||||||
|
|
||||||
public Jt808MileageStreamProcessor(
|
public Jt808MileageStreamProcessor(
|
||||||
Jt808LocationPointExtractor extractor,
|
Jt808LocationPointExtractor extractor,
|
||||||
Jt808MileageStateStore stateStore,
|
|
||||||
VehicleStatRepository repository,
|
VehicleStatRepository repository,
|
||||||
ZoneId zoneId) {
|
ZoneId zoneId) {
|
||||||
this.extractor = extractor;
|
this.extractor = extractor;
|
||||||
this.stateStore = stateStore;
|
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.zoneId = zoneId;
|
this.zoneId = zoneId;
|
||||||
}
|
}
|
||||||
@@ -29,11 +26,17 @@ public final class Jt808MileageStreamProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void processPoint(Jt808LocationPoint point) {
|
private void processPoint(Jt808LocationPoint point) {
|
||||||
|
if (point.totalMileageKm() == null || !Double.isFinite(point.totalMileageKm())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
LocalDate statDate = LocalDate.ofInstant(point.eventTime(), zoneId);
|
LocalDate statDate = LocalDate.ofInstant(point.eventTime(), zoneId);
|
||||||
Jt808DailyMileageState state = stateStore.load(point.vehicleKey(), statDate)
|
repository.recordDailyMileageSample(statVehicleId(point), statDate, point.totalMileageKm());
|
||||||
.orElseGet(() -> Jt808DailyMileageState.empty(point.vehicleKey(), point.vin(), point.phone(), statDate));
|
}
|
||||||
state.apply(point);
|
|
||||||
stateStore.save(state);
|
private static String statVehicleId(Jt808LocationPoint point) {
|
||||||
state.toVehicleDailyStatResult().ifPresent(repository::saveDailyStat);
|
if (!point.vin().isBlank() && !"unknown".equalsIgnoreCase(point.vin())) {
|
||||||
|
return point.vin();
|
||||||
|
}
|
||||||
|
return point.vehicleKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
package com.lingniu.ingest.vehiclestat.jt808;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public final class RedisJt808MileageStateStore implements Jt808MileageStateStore {
|
|
||||||
|
|
||||||
private final StringRedisTemplate redis;
|
|
||||||
private final ObjectMapper objectMapper;
|
|
||||||
private final String keyPrefix;
|
|
||||||
private final Duration ttl;
|
|
||||||
|
|
||||||
public RedisJt808MileageStateStore(
|
|
||||||
StringRedisTemplate redis,
|
|
||||||
ObjectMapper objectMapper,
|
|
||||||
String keyPrefix,
|
|
||||||
Duration ttl) {
|
|
||||||
this.redis = redis;
|
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.keyPrefix = keyPrefix;
|
|
||||||
this.ttl = ttl;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate) {
|
|
||||||
String json = redis.opsForValue().get(key(vehicleKey, statDate));
|
|
||||||
if (json == null || json.isBlank()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Optional.of(objectMapper.readValue(json, StateSnapshot.class).toState());
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalStateException("Failed to deserialize JT808 mileage state", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void save(Jt808DailyMileageState state) {
|
|
||||||
try {
|
|
||||||
redis.opsForValue().set(key(state.vehicleKey(), state.statDate()),
|
|
||||||
objectMapper.writeValueAsString(StateSnapshot.from(state)), ttl);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalStateException("Failed to serialize JT808 mileage state", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String key(String vehicleKey, LocalDate statDate) {
|
|
||||||
return keyPrefix + statDate + ":" + vehicleKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
record StateSnapshot(
|
|
||||||
String vehicleKey,
|
|
||||||
String vin,
|
|
||||||
String phone,
|
|
||||||
String statDate,
|
|
||||||
String firstEventTime,
|
|
||||||
String lastEventTime,
|
|
||||||
PointSnapshot lastPoint,
|
|
||||||
Double firstTotalMileageKm,
|
|
||||||
Double lastTotalMileageKm,
|
|
||||||
int acceptedPoints,
|
|
||||||
Integer totalMileageSamples,
|
|
||||||
int outOfOrderPoints,
|
|
||||||
int odometerAnomalies) {
|
|
||||||
|
|
||||||
static StateSnapshot from(Jt808DailyMileageState state) {
|
|
||||||
return new StateSnapshot(
|
|
||||||
state.vehicleKey(),
|
|
||||||
state.vin(),
|
|
||||||
state.phone(),
|
|
||||||
state.statDate().toString(),
|
|
||||||
format(state.firstEventTime()),
|
|
||||||
format(state.lastEventTime()),
|
|
||||||
null,
|
|
||||||
finiteOrNull(state.firstTotalMileageKm()),
|
|
||||||
finiteOrNull(state.lastTotalMileageKm()),
|
|
||||||
state.acceptedPoints(),
|
|
||||||
state.totalMileageSamples(),
|
|
||||||
state.outOfOrderPoints(),
|
|
||||||
state.odometerAnomalies());
|
|
||||||
}
|
|
||||||
|
|
||||||
Jt808DailyMileageState toState() {
|
|
||||||
return Jt808DailyMileageState.restore(
|
|
||||||
vehicleKey,
|
|
||||||
vin,
|
|
||||||
phone,
|
|
||||||
LocalDate.parse(statDate),
|
|
||||||
parseInstant(firstEventTime),
|
|
||||||
restoredLastEventTime(),
|
|
||||||
nanIfNull(firstTotalMileageKm),
|
|
||||||
nanIfNull(lastTotalMileageKm),
|
|
||||||
acceptedPoints,
|
|
||||||
restoredTotalMileageSamples(),
|
|
||||||
outOfOrderPoints,
|
|
||||||
odometerAnomalies);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Instant restoredLastEventTime() {
|
|
||||||
Instant parsedLastEventTime = parseInstant(lastEventTime);
|
|
||||||
if (parsedLastEventTime != null) {
|
|
||||||
return parsedLastEventTime;
|
|
||||||
}
|
|
||||||
return lastPoint == null ? null : parseInstant(lastPoint.eventTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
private int restoredTotalMileageSamples() {
|
|
||||||
if (totalMileageSamples != null) {
|
|
||||||
return totalMileageSamples;
|
|
||||||
}
|
|
||||||
if (firstTotalMileageKm != null && lastTotalMileageKm != null) {
|
|
||||||
return acceptedPoints >= 2 || !lastTotalMileageKm.equals(firstTotalMileageKm) ? 2 : 1;
|
|
||||||
}
|
|
||||||
return firstTotalMileageKm != null || lastTotalMileageKm != null ? 1 : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
record PointSnapshot(
|
|
||||||
String vehicleKey,
|
|
||||||
String vin,
|
|
||||||
String phone,
|
|
||||||
String eventTime,
|
|
||||||
double longitude,
|
|
||||||
double latitude,
|
|
||||||
Double speedKmh,
|
|
||||||
Long statusFlag,
|
|
||||||
Double totalMileageKm) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String format(Instant instant) {
|
|
||||||
return instant == null ? null : instant.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Instant parseInstant(String value) {
|
|
||||||
return value == null || value.isBlank() ? null : Instant.parse(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Double finiteOrNull(double value) {
|
|
||||||
return Double.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double nanIfNull(Double value) {
|
|
||||||
return value == null ? Double.NaN : value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,6 +50,34 @@ class JdbcVehicleStatMetricRepositoryTest {
|
|||||||
""", Integer.class)).isZero();
|
""", 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
|
@Test
|
||||||
void readsLegacyCalculationMethodAsTotalMileageDifference() {
|
void readsLegacyCalculationMethodAsTotalMileageDifference() {
|
||||||
JdbcTemplate jdbcTemplate = new JdbcTemplate(new DriverManagerDataSource(
|
JdbcTemplate jdbcTemplate = new JdbcTemplate(new DriverManagerDataSource(
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ class VehicleStatControllerTest {
|
|||||||
results.put(result.vin() + ":" + result.statDate(), result);
|
results.put(result.vin() + ":" + result.statDate(), result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<VehicleDailyStatResult> recordDailyMileageSample(String vin, LocalDate statDate,
|
||||||
|
double totalMileageKm) {
|
||||||
|
VehicleDailyStatResult result = new VehicleDailyStatResult(
|
||||||
|
vin,
|
||||||
|
statDate,
|
||||||
|
OptionalDouble.of(0.0),
|
||||||
|
DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF);
|
||||||
|
saveDailyStat(result);
|
||||||
|
return Optional.of(result);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||||
return Optional.ofNullable(results.get(vin + ":" + statDate));
|
return Optional.ofNullable(results.get(vin + ":" + statDate));
|
||||||
|
|||||||
@@ -6,15 +6,11 @@ import com.lingniu.ingest.vehiclestat.JdbcVehicleStatMetricRepository;
|
|||||||
import com.lingniu.ingest.vehiclestat.VehicleStatController;
|
import com.lingniu.ingest.vehiclestat.VehicleStatController;
|
||||||
import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor;
|
import com.lingniu.ingest.vehiclestat.VehicleStatEnvelopeIngestor;
|
||||||
import com.lingniu.ingest.vehiclestat.VehicleStatRepository;
|
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.Jt808MileageStreamProcessor;
|
||||||
import com.lingniu.ingest.vehiclestat.jt808.RedisJt808MileageStateStore;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
|
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
|
||||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
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.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||||
|
|
||||||
@@ -64,17 +60,13 @@ class VehicleStatAutoConfigurationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createsJt808MileageBeansWhenEnabledWithRedis() {
|
void createsJt808MileageBeansWhenEnabledWithMetricRepositoryOnly() {
|
||||||
contextRunner
|
contextRunner
|
||||||
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
|
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
|
||||||
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
|
|
||||||
.withBean(ObjectMapper.class, ObjectMapper::new)
|
|
||||||
.withPropertyValues(
|
.withPropertyValues(
|
||||||
"lingniu.ingest.vehicle-stat.enabled=true",
|
"lingniu.ingest.vehicle-stat.enabled=true",
|
||||||
"lingniu.ingest.vehicle-stat.jt808.enabled=true")
|
"lingniu.ingest.vehicle-stat.jt808.enabled=true")
|
||||||
.run(context -> {
|
.run(context -> {
|
||||||
assertThat(context).hasSingleBean(Jt808MileageStateStore.class);
|
|
||||||
assertThat(context).hasSingleBean(RedisJt808MileageStateStore.class);
|
|
||||||
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
||||||
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
|
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
|
||||||
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
|
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
|
||||||
@@ -82,53 +74,19 @@ class VehicleStatAutoConfigurationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ignoresLegacyStateStorePropertyAndUsesRedisWhenAvailable() {
|
void ignoresLegacyStateStorePropertyAndUsesMetricRepositoryOnly() {
|
||||||
contextRunner
|
contextRunner
|
||||||
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
|
.withBean(JdbcTemplate.class, () -> mock(JdbcTemplate.class))
|
||||||
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
|
|
||||||
.withBean(ObjectMapper.class, ObjectMapper::new)
|
|
||||||
.withPropertyValues(
|
.withPropertyValues(
|
||||||
"lingniu.ingest.vehicle-stat.enabled=true",
|
"lingniu.ingest.vehicle-stat.enabled=true",
|
||||||
"lingniu.ingest.vehicle-stat.jt808.enabled=true",
|
"lingniu.ingest.vehicle-stat.jt808.enabled=true",
|
||||||
"lingniu.ingest.vehicle-stat.jt808.state-store=memory")
|
"lingniu.ingest.vehicle-stat.jt808.state-store=memory")
|
||||||
.run(context -> {
|
.run(context -> {
|
||||||
assertThat(context).hasSingleBean(Jt808MileageStateStore.class);
|
|
||||||
assertThat(context).hasSingleBean(RedisJt808MileageStateStore.class);
|
|
||||||
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
||||||
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.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
|
@Test
|
||||||
void createsJt808MileageBeansAfterJdbcAutoConfiguration() {
|
void createsJt808MileageBeansAfterJdbcAutoConfiguration() {
|
||||||
new ApplicationContextRunner()
|
new ApplicationContextRunner()
|
||||||
@@ -140,15 +98,12 @@ class VehicleStatAutoConfigurationTest {
|
|||||||
"jdbc:h2:mem:vehicle_stat_auto;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1",
|
"jdbc:h2:mem:vehicle_stat_auto;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1",
|
||||||
"sa",
|
"sa",
|
||||||
""))
|
""))
|
||||||
.withBean(StringRedisTemplate.class, () -> mock(StringRedisTemplate.class))
|
|
||||||
.withBean(ObjectMapper.class, ObjectMapper::new)
|
|
||||||
.withPropertyValues(
|
.withPropertyValues(
|
||||||
"lingniu.ingest.vehicle-stat.enabled=true",
|
"lingniu.ingest.vehicle-stat.enabled=true",
|
||||||
"lingniu.ingest.vehicle-stat.jt808.enabled=true")
|
"lingniu.ingest.vehicle-stat.jt808.enabled=true")
|
||||||
.run(context -> {
|
.run(context -> {
|
||||||
assertThat(context).hasSingleBean(JdbcTemplate.class);
|
assertThat(context).hasSingleBean(JdbcTemplate.class);
|
||||||
assertThat(context).hasSingleBean(JdbcVehicleStatMetricRepository.class);
|
assertThat(context).hasSingleBean(JdbcVehicleStatMetricRepository.class);
|
||||||
assertThat(context).hasSingleBean(RedisJt808MileageStateStore.class);
|
|
||||||
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
assertThat(context).hasSingleBean(Jt808MileageStreamProcessor.class);
|
||||||
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
|
assertThat(context).hasSingleBean(VehicleStatEnvelopeIngestor.class);
|
||||||
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
|
assertThat(context).hasSingleBean(EnvelopeConsumerProcessor.class);
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
package com.lingniu.ingest.vehiclestat.jt808;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
final class InMemoryJt808MileageStateStore implements Jt808MileageStateStore {
|
|
||||||
|
|
||||||
private final Map<String, Jt808DailyMileageState> states = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<Jt808DailyMileageState> load(String vehicleKey, LocalDate statDate) {
|
|
||||||
return Optional.ofNullable(states.get(key(vehicleKey, statDate)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void save(Jt808DailyMileageState state) {
|
|
||||||
states.put(key(state.vehicleKey(), state.statDate()), state);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String key(String vehicleKey, LocalDate statDate) {
|
|
||||||
return statDate + ":" + vehicleKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,24 +14,22 @@ import java.time.ZoneId;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.OptionalDouble;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class Jt808MileageStreamProcessorTest {
|
class Jt808MileageStreamProcessorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updatesDailyStateAndSavesResultToVehicleStatRepository() {
|
void recordsDailyMileageSamplesToVehicleStatRepository() {
|
||||||
InMemoryJt808MileageStateStore stateStore = new InMemoryJt808MileageStateStore();
|
|
||||||
CapturingRepository repository = new CapturingRepository();
|
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:00:00Z", 120.0, 30.0, 100.0));
|
||||||
processor.process(envelope("2026-06-30T00:01:00Z", 120.01, 30.0, 101.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(repository.samples).containsExactly(100.0, 101.0);
|
||||||
assertThat(state.acceptedPoints()).isEqualTo(2);
|
assertThat(repository.results).hasSize(2);
|
||||||
assertThat(state.totalMileageSamples()).isEqualTo(2);
|
|
||||||
assertThat(repository.results).hasSize(1);
|
|
||||||
VehicleDailyStatResult latest = repository.results.getLast();
|
VehicleDailyStatResult latest = repository.results.getLast();
|
||||||
assertThat(latest.vin()).isEqualTo("VIN001");
|
assertThat(latest.vin()).isEqualTo("VIN001");
|
||||||
assertThat(latest.statDate()).isEqualTo(LocalDate.of(2026, 6, 30));
|
assertThat(latest.statDate()).isEqualTo(LocalDate.of(2026, 6, 30));
|
||||||
@@ -41,9 +39,8 @@ class Jt808MileageStreamProcessorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void usesSimpleTotalMileageDifferenceInsteadOfAccumulatedPlausibilityDeltas() {
|
void usesSimpleTotalMileageDifferenceInsteadOfAccumulatedPlausibilityDeltas() {
|
||||||
InMemoryJt808MileageStateStore stateStore = new InMemoryJt808MileageStateStore();
|
|
||||||
CapturingRepository repository = new CapturingRepository();
|
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:00:00Z", 120.0, 30.0, 100.0));
|
||||||
processor.process(envelope("2026-06-30T00:01:00Z", 120.0001, 30.0, 200.0));
|
processor.process(envelope("2026-06-30T00:01:00Z", 120.0001, 30.0, 200.0));
|
||||||
@@ -55,7 +52,7 @@ class Jt808MileageStreamProcessorTest {
|
|||||||
@Test
|
@Test
|
||||||
void skipsNonJt808Envelope() {
|
void skipsNonJt808Envelope() {
|
||||||
CapturingRepository repository = new CapturingRepository();
|
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)
|
VehicleEnvelope envelope = envelope("2026-06-30T00:00:00Z", 120.0, 30.0, 100.0)
|
||||||
.toBuilder()
|
.toBuilder()
|
||||||
.setSource("GB32960")
|
.setSource("GB32960")
|
||||||
@@ -66,12 +63,9 @@ class Jt808MileageStreamProcessorTest {
|
|||||||
assertThat(repository.results).isEmpty();
|
assertThat(repository.results).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Jt808MileageStreamProcessor processor(
|
private static Jt808MileageStreamProcessor processor(VehicleStatRepository repository) {
|
||||||
Jt808MileageStateStore stateStore,
|
|
||||||
VehicleStatRepository repository) {
|
|
||||||
return new Jt808MileageStreamProcessor(
|
return new Jt808MileageStreamProcessor(
|
||||||
new Jt808LocationPointExtractor(),
|
new Jt808LocationPointExtractor(),
|
||||||
stateStore,
|
|
||||||
repository,
|
repository,
|
||||||
ZoneId.of("Asia/Shanghai"));
|
ZoneId.of("Asia/Shanghai"));
|
||||||
}
|
}
|
||||||
@@ -104,12 +98,30 @@ class Jt808MileageStreamProcessorTest {
|
|||||||
|
|
||||||
private static final class CapturingRepository implements VehicleStatRepository {
|
private static final class CapturingRepository implements VehicleStatRepository {
|
||||||
private final List<VehicleDailyStatResult> results = new ArrayList<>();
|
private final List<VehicleDailyStatResult> results = new ArrayList<>();
|
||||||
|
private final List<Double> samples = new ArrayList<>();
|
||||||
|
private double firstSample = Double.NaN;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void saveDailyStat(VehicleDailyStatResult result) {
|
public void saveDailyStat(VehicleDailyStatResult result) {
|
||||||
results.add(result);
|
results.add(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<VehicleDailyStatResult> recordDailyMileageSample(String vin, LocalDate statDate,
|
||||||
|
double totalMileageKm) {
|
||||||
|
samples.add(totalMileageKm);
|
||||||
|
if (!Double.isFinite(firstSample)) {
|
||||||
|
firstSample = totalMileageKm;
|
||||||
|
}
|
||||||
|
VehicleDailyStatResult result = new VehicleDailyStatResult(
|
||||||
|
vin,
|
||||||
|
statDate,
|
||||||
|
OptionalDouble.of(totalMileageKm - firstSample),
|
||||||
|
DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF);
|
||||||
|
results.add(result);
|
||||||
|
return Optional.of(result);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
public Optional<VehicleDailyStatResult> findDailyStat(String vin, LocalDate statDate) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
package com.lingniu.ingest.vehiclestat.jt808;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
|
||||||
import org.springframework.data.redis.core.ValueOperations;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
class RedisJt808MileageStateStoreTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void savesStateAsJsonWithTtl() {
|
|
||||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
|
||||||
when(redis.opsForValue()).thenReturn(values);
|
|
||||||
RedisJt808MileageStateStore store = new RedisJt808MileageStateStore(
|
|
||||||
redis, new ObjectMapper(), "vehicle:mileage:jt808:daily:", Duration.ofDays(3));
|
|
||||||
Jt808DailyMileageState state = Jt808DailyMileageState.empty("VIN001", "VIN001", "13900000001",
|
|
||||||
LocalDate.of(2026, 6, 30));
|
|
||||||
state.apply(point("2026-06-30T00:00:00Z"));
|
|
||||||
|
|
||||||
store.save(state);
|
|
||||||
|
|
||||||
ArgumentCaptor<String> json = ArgumentCaptor.forClass(String.class);
|
|
||||||
verify(values).set(
|
|
||||||
org.mockito.ArgumentMatchers.eq("vehicle:mileage:jt808:daily:2026-06-30:VIN001"),
|
|
||||||
json.capture(),
|
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(3)));
|
|
||||||
assertThat(json.getValue()).contains("\"vehicleKey\":\"VIN001\"");
|
|
||||||
assertThat(json.getValue()).doesNotContain("odometerMileageKm");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void loadsStateFromLegacyJson() {
|
|
||||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
|
||||||
when(redis.opsForValue()).thenReturn(values);
|
|
||||||
when(values.get("vehicle:mileage:jt808:daily:2026-06-30:VIN001"))
|
|
||||||
.thenReturn("""
|
|
||||||
{"vehicleKey":"VIN001","vin":"VIN001","phone":"13900000001","statDate":"2026-06-30",
|
|
||||||
"firstEventTime":"2026-06-30T00:00:00Z","lastEventTime":"2026-06-30T00:01:00Z",
|
|
||||||
"firstTotalMileageKm":100.0,
|
|
||||||
"lastPoint":{"vehicleKey":"VIN001","vin":"VIN001","phone":"13900000001",
|
|
||||||
"eventTime":"2026-06-30T00:01:00Z","longitude":120.0,"latitude":30.0,
|
|
||||||
"speedKmh":36.0,"statusFlag":3,"totalMileageKm":101.0},
|
|
||||||
"lastTotalMileageKm":101.0,"gpsMileageKm":1.0,"speedIntegralKm":1.0,
|
|
||||||
"odometerMileageKm":1.0,"acceptedPoints":2,"badJumpSegments":0,
|
|
||||||
"longGapSegments":0,"outOfOrderPoints":0,"odometerAnomalies":0}
|
|
||||||
""");
|
|
||||||
RedisJt808MileageStateStore store = new RedisJt808MileageStateStore(
|
|
||||||
redis, new ObjectMapper(), "vehicle:mileage:jt808:daily:", Duration.ofDays(3));
|
|
||||||
|
|
||||||
Optional<Jt808DailyMileageState> loaded = store.load("VIN001", LocalDate.of(2026, 6, 30));
|
|
||||||
|
|
||||||
assertThat(loaded).hasValueSatisfying(state -> {
|
|
||||||
assertThat(state.vehicleKey()).isEqualTo("VIN001");
|
|
||||||
assertThat(state.acceptedPoints()).isEqualTo(2);
|
|
||||||
assertThat(state.totalMileageSamples()).isEqualTo(2);
|
|
||||||
assertThat(state.toVehicleDailyStatResult().orElseThrow().dailyMileageKm()).hasValue(1.0);
|
|
||||||
assertThat(state.lastEventTime()).isEqualTo(Instant.parse("2026-06-30T00:01:00Z"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Jt808LocationPoint point(String time) {
|
|
||||||
return new Jt808LocationPoint(
|
|
||||||
"VIN001",
|
|
||||||
"VIN001",
|
|
||||||
"13900000001",
|
|
||||||
Instant.parse(time),
|
|
||||||
120.0,
|
|
||||||
30.0,
|
|
||||||
36.0,
|
|
||||||
3L,
|
|
||||||
100.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user