feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
@@ -49,6 +49,7 @@ type Writer struct {
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
pendingProjection map[string]pendingProjectionEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
sourceIdentities map[string]SourceIdentity
|
||||
mileageKeysByPrefix map[string]map[string]struct{}
|
||||
projectionKeysByPrefix map[string]map[string]struct{}
|
||||
baselineKeysByPrefix map[string]map[string]struct{}
|
||||
@@ -355,6 +356,7 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
result.SamplesSkippedMissingFields = 1
|
||||
return result, nil
|
||||
}
|
||||
env = w.withSourceIdentity(env)
|
||||
seenAt := w.sourceSeenAt(env)
|
||||
w.maybeCleanupCaches(seenAt)
|
||||
identity, hasSource := NewSourceIdentityFromEnvelope(env)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
// ReloadSourceIdentities aligns live JT808 candidates with the durable platform
|
||||
// keys used by backfill and daily statistics. A snapshot avoids a database
|
||||
// lookup per frame; a failed refresh leaves the last successful snapshot intact.
|
||||
func (w *Writer) ReloadSourceIdentities(ctx context.Context) (int, error) {
|
||||
rows, err := w.query.QueryContext(ctx, `SELECT source_ip,COALESCE(source_code,''),COALESCE(platform_name,''),source_kind
|
||||
FROM vehicle_data_source WHERE protocol='JT808'`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
next := map[string]SourceIdentity{}
|
||||
for rows.Next() {
|
||||
var source SourceIdentity
|
||||
if err := rows.Scan(&source.SourceIP, &source.SourceCode, &source.PlatformName, &source.SourceKind); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
source.Protocol = envelope.ProtocolJT808
|
||||
source.SourceIP = NormalizeSourceIP(source.SourceIP)
|
||||
source.SourceCode = strings.TrimSpace(source.SourceCode)
|
||||
source.SourceKind = strings.ToUpper(strings.TrimSpace(source.SourceKind))
|
||||
if source.SourceIP != "" && ((source.SourceKind == "PLATFORM" && source.SourceCode != "") || source.SourceKind == "DIRECT") {
|
||||
next[source.SourceIP] = source
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.sourceIdentities = next
|
||||
w.mu.Unlock()
|
||||
return len(next), nil
|
||||
}
|
||||
|
||||
func (w *Writer) withSourceIdentity(env envelope.FrameEnvelope) envelope.FrameEnvelope {
|
||||
if env.Protocol != envelope.ProtocolJT808 {
|
||||
return env
|
||||
}
|
||||
ip := NormalizeSourceIP(env.SourceEndpoint)
|
||||
w.mu.Lock()
|
||||
source, found := w.sourceIdentities[ip]
|
||||
w.mu.Unlock()
|
||||
if found {
|
||||
env.SourceCode = source.SourceCode
|
||||
env.SourceKind = source.SourceKind
|
||||
if strings.TrimSpace(source.PlatformName) != "" {
|
||||
env.PlatformName = source.PlatformName
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
func TestWriterResolvesForwardingIPBeforeDurableBaselineLookup(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
writer := NewWriter(db, loc)
|
||||
mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnRows(sqlmock.NewRows([]string{"ip", "code", "name", "kind"}).AddRow("115.159.85.149", "g7s", "G7s", "PLATFORM"))
|
||||
if _, err := writer.ReloadSourceIdentities(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vin, phone, key := "LMRKH9AC7R1004120", "64341233712", "JT808:64341233712@PLATFORM:g7s"
|
||||
previous := time.Date(2026, 10, 18, 23, 59, 49, 0, loc)
|
||||
current := time.Date(2026, 10, 19, 12, 0, 0, 0, loc)
|
||||
mock.ExpectExec("INSERT INTO vehicle_data_source").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source").WithArgs(vin, "2026-10-19", "JT808", key).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"total", "time"}).AddRow(52704.4, previous))
|
||||
mock.ExpectBegin()
|
||||
args := []driver.Value{vin, "2026-10-19", "JT808", key}
|
||||
for len(args) < 21 {
|
||||
args = append(args, sqlmock.AnyArg())
|
||||
}
|
||||
args[9] = 52704.4
|
||||
args[10] = 52714.4
|
||||
args[11] = 10.0
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(args...).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(vin, "2026-10-19", "JT808").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("DELETE s").WithArgs(vin, "2026-10-19", "JT808").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectCommit()
|
||||
fields := map[string]any{"jt808.location.total_mileage_km": 52714.4}
|
||||
_, err = writer.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: vin, Phone: phone, SourceEndpoint: "115.159.85.149:12345", Fields: fields, ParsedFields: fields, EventTimeMS: current.UnixMilli(), ReceivedAtMS: current.UnixMilli(), ParseStatus: envelope.ParseOK})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceIdentityRefreshRetainsSnapshotOnFailureAndDoesNotMixProtocols(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
writer := NewWriter(db, time.UTC)
|
||||
mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnRows(sqlmock.NewRows([]string{"ip", "code", "name", "kind"}).AddRow("1.2.3.4", "g7s", "G7s", "PLATFORM").AddRow("2.3.4.5", "", "", "DIRECT").AddRow("3.4.5.6", "", "", "UNKNOWN"))
|
||||
if n, err := writer.ReloadSourceIdentities(context.Background()); err != nil || n != 2 {
|
||||
t.Fatalf("loaded=%d err=%v", n, err)
|
||||
}
|
||||
mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnError(errors.New("temporary database failure"))
|
||||
if _, err := writer.ReloadSourceIdentities(context.Background()); err == nil {
|
||||
t.Fatal("expected refresh failure")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
protocol envelope.Protocol
|
||||
endpoint, kind, code string
|
||||
}{
|
||||
{envelope.ProtocolJT808, "1.2.3.4:10", "PLATFORM", "g7s"},
|
||||
{envelope.ProtocolJT808, "2.3.4.5:20", "DIRECT", ""},
|
||||
{envelope.ProtocolJT808, "3.4.5.6:30", "", ""},
|
||||
{envelope.ProtocolGB32960, "1.2.3.4:40", "", ""},
|
||||
} {
|
||||
got := writer.withSourceIdentity(envelope.FrameEnvelope{Protocol: tc.protocol, SourceEndpoint: tc.endpoint})
|
||||
if got.SourceCode != tc.code || got.SourceKind != tc.kind {
|
||||
t.Fatalf("unexpected source metadata: %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,25 +282,10 @@ func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface {
|
||||
if strings.TrimSpace(statDate) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol))
|
||||
vins, err := PlatformSourceMileageLegacyVINs(ctx, db, statDate, protocol)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vins []string
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(vin) != "" {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
normalized := 0
|
||||
for _, vin := range vins {
|
||||
@@ -315,6 +300,27 @@ func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface {
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// PlatformSourceMileageLegacyVINs lets batch callers apply their own atomic
|
||||
// projection policy while sharing the exact registry/terminal alias rules.
|
||||
func PlatformSourceMileageLegacyVINs(ctx context.Context, db Queryer, statDate string, protocol envelope.Protocol) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var vins []string
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(vin) != "" {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
return vins, rows.Err()
|
||||
}
|
||||
|
||||
func ShouldNormalizePlatformSourceMileage(sample SourceMileageSample) bool {
|
||||
return strings.Contains(sample.SourceKey, platformSourceKeyPrefix)
|
||||
}
|
||||
@@ -441,7 +447,20 @@ const upsertSourceMergedQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(` + up
|
||||
|
||||
const upsertSourceMergedMaxMileageSQL = `(` + upsertSourceMergedQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
|
||||
|
||||
const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY s.first_event_time ASC, s.updated_at ASC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
||||
// Prefer the newest boundary before this natural day. If no historical
|
||||
// boundary exists, retain the earliest sample inside the day. Select the
|
||||
// odometer and its timestamp from the same row so stale forwarding aliases
|
||||
// cannot restore an older baseline after a repair or a cache refresh.
|
||||
const normalizePlatformFirstOrderSQL = `CASE
|
||||
WHEN s.first_event_time < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN 0
|
||||
WHEN s.first_event_time IS NOT NULL THEN 1
|
||||
ELSE 2 END,
|
||||
CASE WHEN s.first_event_time < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN s.first_event_time ELSE NULL END DESC,
|
||||
s.first_event_time ASC, s.updated_at DESC`
|
||||
|
||||
const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY ` + normalizePlatformFirstOrderSQL + ` SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
||||
|
||||
const normalizePlatformFirstEventSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_event_time ORDER BY ` + normalizePlatformFirstOrderSQL + ` SEPARATOR ','), ',', 1) AS DATETIME)`
|
||||
|
||||
const normalizePlatformLatestTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_total_mileage_km ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))`
|
||||
|
||||
@@ -454,7 +473,7 @@ const normalizePlatformDailySQL = `CASE
|
||||
ELSE ` + normalizePlatformDeltaSQL + `
|
||||
END`
|
||||
|
||||
const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(MIN(s.first_event_time))))`
|
||||
const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(` + normalizePlatformFirstEventSQL + `)))`
|
||||
|
||||
const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)`
|
||||
|
||||
@@ -537,7 +556,7 @@ SELECT
|
||||
SUM(s.pure_hydrogen_mileage_km) AS pure_hydrogen_mileage_km,
|
||||
SUM(s.sample_count) AS sample_count,
|
||||
SUM(s.pure_hydrogen_sample_count) AS pure_hydrogen_sample_count,
|
||||
MIN(s.first_event_time) AS first_event_time,
|
||||
` + normalizePlatformFirstEventSQL + ` AS first_event_time,
|
||||
MAX(s.latest_event_time) AS latest_event_time,
|
||||
CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_active ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_active,
|
||||
CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_mode_known ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_mode_known,
|
||||
@@ -548,7 +567,7 @@ SELECT
|
||||
CASE
|
||||
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN 'outside_daily_range'
|
||||
WHEN ` + normalizePlatformDailySQL + ` = 0 AND ` + normalizePlatformDeltaSQL + ` < 0 THEN 'negative_jitter_clamped'
|
||||
WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)
|
||||
WHEN ` + normalizePlatformFirstEventSQL + ` < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)
|
||||
THEN '` + QualityReasonHistorical + `'
|
||||
ELSE '` + QualityReasonCurrentDayFirst + `'
|
||||
END AS quality_reason
|
||||
@@ -611,10 +630,7 @@ ON DUPLICATE KEY UPDATE
|
||||
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
|
||||
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
|
||||
sample_count = sample_count + VALUES(sample_count),
|
||||
first_event_time = CASE
|
||||
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time THEN VALUES(first_event_time)
|
||||
ELSE first_event_time
|
||||
END,
|
||||
first_event_time = ` + upsertSourceMergedFirstEventSQL + `,
|
||||
latest_event_time = CASE
|
||||
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_event_time)
|
||||
ELSE latest_event_time
|
||||
|
||||
@@ -344,7 +344,7 @@ func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) {
|
||||
"ON DUPLICATE KEY UPDATE",
|
||||
"sample_count = sample_count + VALUES(sample_count)",
|
||||
"s.quality_status IN ('" + QualityOK + "', '" + QualityNoPreviousBaseline + "')",
|
||||
"WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)",
|
||||
normalizePlatformFirstEventSQL + " < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)",
|
||||
"THEN '" + QualityInvalidDelta + "'",
|
||||
"ELSE '" + QualityOK + "'",
|
||||
"THEN 'negative_jitter_clamped'",
|
||||
@@ -392,6 +392,25 @@ func TestNormalizePlatformSourceMileageExcludesIncompleteLegacyTotals(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePlatformSourceMileagePreservesTheNewerBoundaryPair(t *testing.T) {
|
||||
// A stale IP-key candidate must not change the canonical baseline's time
|
||||
// back to September while retaining October's first_total_mileage_km.
|
||||
for _, expression := range []string{normalizePlatformFirstTotalSQL, normalizePlatformFirstEventSQL} {
|
||||
if !strings.Contains(expression, "ORDER BY "+normalizePlatformFirstOrderSQL) {
|
||||
t.Fatal("baseline total and timestamp must select the same legacy row")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(normalizePlatformFirstOrderSQL, "THEN s.first_event_time ELSE NULL END DESC") {
|
||||
t.Fatal("prior-day boundaries must prefer the newest timestamp")
|
||||
}
|
||||
if !strings.Contains(normalizePlatformSourceMileageInsertSQL, "first_event_time = "+upsertSourceMergedFirstEventSQL) {
|
||||
t.Fatal("legacy upsert must use the same boundary preference as its odometer")
|
||||
}
|
||||
if strings.Contains(normalizePlatformSourceMileageInsertSQL, "MIN(s.first_event_time)") {
|
||||
t.Fatal("an old forwarding alias must not restore an older boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) {
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user