diff --git a/docs/module-data-flow.html b/docs/module-data-flow.html
index f38807f7..6b667828 100644
--- a/docs/module-data-flow.html
+++ b/docs/module-data-flow.html
@@ -610,10 +610,10 @@ flowchart LR
| modules/services/vehicle-stat-service |
- 消费 Kafka 全字段事件,抽取统计源点,按车辆规则计算每日里程等日统计;生产消费可使用 tryIngest 和自动装配的 EnvelopeConsumerProcessor 隔离坏 protobuf,缺少 telemetry_snapshot 的消息会标记为 SKIPPED 并进入死信出口。 |
- Kafka Protobuf Envelope、车辆统计规则。 |
- 按 vin + stat_date 保存的日统计结果。 |
- 每日里程规则配置 |
+ 消费 Kafka 全字段事件,808 每日里程只使用 0x0200 附加项 0x01 上报的总里程做当日首末差值;生产消费可使用 tryIngest 和自动装配的 EnvelopeConsumerProcessor 隔离坏 protobuf,缺少 telemetry_snapshot 的消息会标记为 SKIPPED 并进入死信出口。 |
+ Kafka Protobuf Envelope、808 total_mileage_km。 |
+ vehicle_stat_metric:metric_key=daily_mileage_km、calculation_method=JT808_TOTAL_MILEAGE_DIFF。 |
+ 每日里程指标表 |
| modules/apps/command-gateway |
diff --git a/docs/operations/jt808-daily-mileage-runbook.md b/docs/operations/jt808-daily-mileage-runbook.md
index 2b97eedc..01f9044d 100644
--- a/docs/operations/jt808-daily-mileage-runbook.md
+++ b/docs/operations/jt808-daily-mileage-runbook.md
@@ -12,6 +12,7 @@ The JT808 daily-mileage value is calculated from the GPS total mileage reported
```text
daily_mileage_km = last_total_mileage_km - first_total_mileage_km
+calculation_method = JT808_TOTAL_MILEAGE_DIFF
```
The metric is saved only after at least two ordered JT808 location points with valid `total_mileage_km` have been received for the same vehicle and local day. If the difference is negative, no metric row is saved for that state update.
diff --git a/docs/superpowers/plans/2026-06-30-kafka-streaming-mileage.md b/docs/superpowers/plans/2026-06-30-kafka-streaming-mileage.md
index 3b37052c..00a271bb 100644
--- a/docs/superpowers/plans/2026-06-30-kafka-streaming-mileage.md
+++ b/docs/superpowers/plans/2026-06-30-kafka-streaming-mileage.md
@@ -8,11 +8,11 @@ Consume JT808 Kafka location events, keep rolling daily state, and write the der
- Source topic: `vehicle.event.jt808.v1`
- Runtime app: `vehicle-analytics-app`
-- State: Redis or in-memory `Jt808MileageStateStore`
+- Runtime state: Redis `Jt808MileageStateStore`
- Metric output: `VehicleStatRepository.saveDailyStat(...)`
- Production metric storage: JDBC/MySQL `vehicle_stat_metric`
-- Local fallback storage: `VEHICLE_STAT_FILE_PATH/daily-stats.tsv`
- Date boundary: `Asia/Shanghai`
+- Calculation method: `JT808_TOTAL_MILEAGE_DIFF`
JT808 daily mileage is calculated only from the GPS total mileage reported by JT808 location additional information:
diff --git a/docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md b/docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md
index f67da3d9..efce5673 100644
--- a/docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md
+++ b/docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md
@@ -269,28 +269,20 @@ MYSQL_USERNAME
MYSQL_PASSWORD
```
-`vehicle_daily_stat`
+Daily derived metrics are stored in the common metric table, not in a
+protocol-specific daily-mileage table.
```sql
-CREATE TABLE IF NOT EXISTS vehicle_daily_stat (
- vin VARCHAR(32) NOT NULL,
+CREATE TABLE IF NOT EXISTS vehicle_stat_metric (
+ vin VARCHAR(64) NOT NULL,
stat_date DATE NOT NULL,
- platform_account VARCHAR(64),
- total_mileage_km DECIMAL(14,3),
- daily_mileage_km DECIMAL(14,3),
- daily_power_kwh DECIMAL(14,3),
- daily_hydrogen_kg DECIMAL(14,4),
- daily_hydrogen_kg_per_100km DECIMAL(14,4),
- first_event_time DATETIME(3),
- last_event_time DATETIME(3),
- first_total_mileage_km DECIMAL(14,3),
- last_total_mileage_km DECIMAL(14,3),
- last_soc_percent DECIMAL(8,3),
- sample_count BIGINT NOT NULL DEFAULT 0,
- late_sample_count BIGINT NOT NULL DEFAULT 0,
- calculation_quality VARCHAR(32) NOT NULL,
- updated_at DATETIME(3) NOT NULL,
- PRIMARY KEY (vin, stat_date)
+ metric_key VARCHAR(64) NOT NULL,
+ metric_value DECIMAL(18,6) NULL,
+ metric_unit VARCHAR(16) NOT NULL,
+ calculation_method VARCHAR(64) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (vin, stat_date, metric_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
diff --git a/docs/target-architecture.md b/docs/target-architecture.md
index dd32fe9e..b5a969e1 100644
--- a/docs/target-architecture.md
+++ b/docs/target-architecture.md
@@ -159,15 +159,16 @@ Responsibilities:
- Consume normalized telemetry events from Kafka.
- Maintain day-level vehicle point state needed for statistics.
- Run scheduled and event-triggered calculations.
-- Store daily results in a statistics database.
-- Support per-vehicle calculation rules.
+- Store derived results in the common metric table.
-Initial daily mileage strategies:
+Current JT808 daily mileage strategy:
| Strategy | Formula |
|---|---|
-| `CURRENT_LAST_MINUS_PREVIOUS_LAST` | Current day last valid total mileage minus previous day last valid total mileage |
-| `DAY_MAX_MINUS_DAY_MIN` | Current day max valid total mileage minus current day min valid total mileage |
+| `JT808_TOTAL_MILEAGE_DIFF` | Current local day last valid JT808 GPS total mileage minus first valid JT808 GPS total mileage |
+
+The derived value is stored as `metric_key = daily_mileage_km` in
+`vehicle_stat_metric`; there is no protocol-specific JT808 daily-mileage table.
Hydrogen and electricity statistics use internal fields from the same event
contract. Refueling and charging resets must be handled in statistics logic, not
diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageStrategy.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageStrategy.java
index 5098d2f3..c4205708 100644
--- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageStrategy.java
+++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/DailyMileageStrategy.java
@@ -1,6 +1,16 @@
package com.lingniu.ingest.vehiclestat;
public enum DailyMileageStrategy {
- CURRENT_LAST_MINUS_PREVIOUS_LAST,
- DAY_MAX_MINUS_DAY_MIN
+ JT808_TOTAL_MILEAGE_DIFF;
+
+ public static DailyMileageStrategy fromStorage(String value) {
+ if (value == null || value.isBlank()) {
+ return JT808_TOTAL_MILEAGE_DIFF;
+ }
+ String normalized = value.trim();
+ if ("CURRENT_LAST_MINUS_PREVIOUS_LAST".equals(normalized)) {
+ return JT808_TOTAL_MILEAGE_DIFF;
+ }
+ return DailyMileageStrategy.valueOf(normalized);
+ }
}
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 9918dd8b..af0a7557 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
@@ -57,7 +57,7 @@ public final class JdbcVehicleStatMetricRepository implements VehicleStatReposit
rs.getBigDecimal("metric_value") == null
? OptionalDouble.empty()
: OptionalDouble.of(rs.getBigDecimal("metric_value").doubleValue()),
- DailyMileageStrategy.valueOf(rs.getString("calculation_method"))),
+ DailyMileageStrategy.fromStorage(rs.getString("calculation_method"))),
normalizedVin, Date.valueOf(statDate), DAILY_MILEAGE_KEY);
return rows.stream().findFirst();
}
diff --git a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleDailyStatResult.java b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleDailyStatResult.java
index 6b71fe70..d769900f 100644
--- a/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleDailyStatResult.java
+++ b/modules/services/vehicle-stat-service/src/main/java/com/lingniu/ingest/vehiclestat/VehicleDailyStatResult.java
@@ -19,7 +19,7 @@ public record VehicleDailyStatResult(String vin,
dailyMileageKm = OptionalDouble.empty();
}
if (dailyMileageStrategy == null) {
- dailyMileageStrategy = DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST;
+ dailyMileageStrategy = DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF;
}
}
}
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
index 93f2fc84..9da95d47 100644
--- 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
@@ -90,7 +90,7 @@ public final class Jt808DailyMileageState {
statVehicleId(),
statDate,
dailyMileage,
- DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST));
+ DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF));
}
private void updateOdometer(Jt808LocationPoint point) {
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 24183d98..13c4b731 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
@@ -23,12 +23,12 @@ class JdbcVehicleStatMetricRepositoryTest {
"VIN001",
LocalDate.of(2026, 6, 30),
OptionalDouble.of(12.345),
- DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST));
+ DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF));
repository.saveDailyStat(new VehicleDailyStatResult(
"VIN001",
LocalDate.of(2026, 6, 30),
OptionalDouble.of(15.5),
- DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST));
+ DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF));
assertThat(repository.findDailyStat("VIN001", LocalDate.of(2026, 6, 30)))
.isPresent()
@@ -40,5 +40,33 @@ class JdbcVehicleStatMetricRepositoryTest {
assertThat(jdbcTemplate.queryForObject(
"SELECT metric_key FROM vehicle_stat_metric WHERE vin = 'VIN001'",
String.class)).isEqualTo("daily_mileage_km");
+ assertThat(jdbcTemplate.queryForObject(
+ "SELECT calculation_method FROM vehicle_stat_metric WHERE vin = 'VIN001'",
+ String.class)).isEqualTo("JT808_TOTAL_MILEAGE_DIFF");
+ assertThat(jdbcTemplate.queryForObject("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_name = 'vehicle_daily_stat'
+ """, Integer.class)).isZero();
+ }
+
+ @Test
+ void readsLegacyCalculationMethodAsTotalMileageDifference() {
+ JdbcTemplate jdbcTemplate = new JdbcTemplate(new DriverManagerDataSource(
+ "jdbc:h2:mem:vehicle_stat_metric_legacy;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1",
+ "sa",
+ ""));
+ JdbcVehicleStatMetricRepository repository = new JdbcVehicleStatMetricRepository(jdbcTemplate);
+ jdbcTemplate.update("""
+ INSERT INTO vehicle_stat_metric
+ (vin, stat_date, metric_key, metric_value, metric_unit, calculation_method)
+ VALUES ('VIN001', DATE '2026-06-30', 'daily_mileage_km', 8.5, 'km', 'CURRENT_LAST_MINUS_PREVIOUS_LAST')
+ """);
+
+ assertThat(repository.findDailyStat("VIN001", LocalDate.of(2026, 6, 30)))
+ .isPresent()
+ .get()
+ .extracting(VehicleDailyStatResult::dailyMileageStrategy)
+ .isEqualTo(DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF);
}
}
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 0ca7ccef..ad81c7c9 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
@@ -21,14 +21,15 @@ class VehicleStatControllerTest {
"VIN001",
LocalDate.parse("2026-06-22"),
OptionalDouble.of(35.5),
- DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST));
+ DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF));
MockMvc mvc = standaloneSetup(new VehicleStatController(repository)).build();
mvc.perform(get("/api/vehicle-stat/VIN001/daily").param("date", "2026-06-22"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.vin").value("VIN001"))
.andExpect(jsonPath("$.statDate").value("2026-06-22"))
- .andExpect(jsonPath("$.dailyMileageKm").value(35.5));
+ .andExpect(jsonPath("$.dailyMileageKm").value(35.5))
+ .andExpect(jsonPath("$.dailyMileageStrategy").value("JT808_TOTAL_MILEAGE_DIFF"));
}
private static final class InMemoryVehicleStatRepository implements VehicleStatRepository {
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 cec7798e..9679142b 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
@@ -36,7 +36,7 @@ class Jt808MileageStreamProcessorTest {
assertThat(latest.vin()).isEqualTo("VIN001");
assertThat(latest.statDate()).isEqualTo(LocalDate.of(2026, 6, 30));
assertThat(latest.dailyMileageKm()).hasValue(1.0);
- assertThat(latest.dailyMileageStrategy()).isEqualTo(DailyMileageStrategy.CURRENT_LAST_MINUS_PREVIOUS_LAST);
+ assertThat(latest.dailyMileageStrategy()).isEqualTo(DailyMileageStrategy.JT808_TOTAL_MILEAGE_DIFF);
}
@Test