fix(monitor): arbitrate conflicting location sources

This commit is contained in:
lingniu
2026-07-16 11:05:28 +08:00
parent 9384d6acf5
commit b9fcf476ec
11 changed files with 360 additions and 44 deletions

View File

@@ -50,6 +50,8 @@ type MonitorMapPoint struct {
TotalMileageKm float64 `json:"totalMileageKm"`
LastSeen string `json:"lastSeen"`
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
LocationSource string `json:"locationSource"`
LocationConflict bool `json:"locationConflict"`
Status string `json:"status"`
}
@@ -1019,6 +1021,9 @@ type VehicleRealtimeRow struct {
BindingStatus string `json:"bindingStatus"`
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
PrimaryProtocol string `json:"primaryProtocol"`
LocationSource string `json:"locationSource"`
LocationConflict bool `json:"locationConflict"`
ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"`
Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"`
SpeedKmh float64 `json:"speedKmh"`

View File

@@ -398,7 +398,14 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
`WHERE ` + strings.Join(where, " AND ") + ` ` +
`GROUP BY l.vin, b.plate, b.phone, b.oem ` +
havingSQL
orderExpr := `l.updated_at DESC, l.protocol ASC`
// Keep one authoritative coordinate source while all protocols are healthy.
// Ordering only by updated_at makes the selected point flap whenever protocols
// report at different cadences. When every source is stale, recency remains the
// safest fallback so an old high-priority source cannot mask newer evidence.
orderExpr := `CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 0 ELSE 1 END ASC, ` +
`CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN CASE l.protocol ` +
`WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END ELSE 100 END ASC, ` +
`l.updated_at DESC, l.protocol ASC`
return SQLQuery{
Text: `SELECT l.vin, ` +
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), b.plate, '') AS plate, ` +
@@ -410,6 +417,9 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
`CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
`CASE WHEN MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1 THEN 'bound' ELSE 'unbound' END AS binding_status, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')) ORDER BY ` + orderExpr + `), ',', 1), '') AS location_source, ` +
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(l.location_conflict, 0) ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS location_conflict, ` +
`SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(CAST(l.location_conflict_distance_m AS CHAR), '-1') ORDER BY ` + orderExpr + `), ',', 1) AS conflict_distance_m, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +

View File

@@ -401,13 +401,22 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
var onlineProtocols string
var online int
var longitude, latitude, speed, soc, mileage string
var conflictDistance sql.NullString
var locationConflict int
var reportIntervalMs int64
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen, &reportIntervalMs); err != nil {
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &row.LocationSource, &locationConflict, &conflictDistance, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen, &reportIntervalMs); err != nil {
return Page[VehicleRealtimeRow]{}, err
}
row.Protocols = splitCSV(protocols)
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen)
row.Online = online == 1
row.LocationConflict = locationConflict == 1
if conflictDistance.Valid {
value := parseFloatString(conflictDistance.String)
if value >= 0 {
row.ConflictDistanceM = &value
}
}
row.ServiceStatus = buildRealtimeServiceStatus(row)
row.Longitude = parseFloatString(longitude)
row.Latitude = parseFloatString(latitude)

View File

@@ -208,6 +208,20 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
if !strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) DESC, l.vin ASC") {
t.Fatalf("SQL should keep vehicle-level stable pagination order: %s", built.Text)
}
for _, want := range []string{
"INTERVAL 2 MINUTE",
"WHEN 'GB32960' THEN 10",
"WHEN 'YUTONG_MQTT' THEN 20",
"WHEN 'JT808' THEN 30",
"ELSE 100 END ELSE 100 END ASC, l.updated_at DESC",
"location_source",
"location_conflict",
"location_conflict_distance_m",
} {
if !strings.Contains(built.Text, want) {
t.Fatalf("realtime source arbitration missing %q: %s", want, built.Text)
}
}
if len(built.Args) != 8 || built.Args[0] != "JT808" || built.Args[1] != "%粤A%" || built.Args[6] != 10 || built.Args[7] != 20 {
t.Fatalf("args = %#v", built.Args)
}

View File

@@ -607,6 +607,8 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, SpeedKmh: vehicle.SpeedKmh,
SOCPercent: vehicle.SOCPercent, TotalMileageKm: vehicle.TotalMileageKm, LastSeen: vehicle.LastSeen,
ReportIntervalMs: vehicle.ReportIntervalMs,
LocationSource: vehicle.LocationSource,
LocationConflict: vehicle.LocationConflict,
Status: monitorVehicleStatus(vehicle),
})
}