feat(platform): expose service status on vehicle rows

This commit is contained in:
lingniu
2026-07-04 02:23:57 +08:00
parent 826fa76008
commit cbd02956ff
6 changed files with 81 additions and 11 deletions

View File

@@ -35,6 +35,18 @@ func TestHandlerVehicles(t *testing.T) {
if !strings.Contains(rec.Body.String(), "LB9A32A24R0LS1426") { if !strings.Contains(rec.Body.String(), "LB9A32A24R0LS1426") {
t.Fatalf("response missing vehicle: %s", rec.Body.String()) t.Fatalf("response missing vehicle: %s", rec.Body.String())
} }
var body struct {
Data Page[VehicleRow] `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
}
if len(body.Data.Items) == 0 || body.Data.Items[0].ServiceStatus == nil {
t.Fatalf("vehicle row should include canonical vehicle service status: %s", rec.Body.String())
}
if body.Data.Items[0].ServiceStatus.Status != "degraded" {
t.Fatalf("vehicle row should expose degraded status, got %+v", body.Data.Items[0].ServiceStatus)
}
} }
func TestHandlerVehicleCoverage(t *testing.T) { func TestHandlerVehicleCoverage(t *testing.T) {

View File

@@ -61,9 +61,43 @@ func (m *MockStore) DashboardSummary(context.Context) (DashboardSummary, error)
func (m *MockStore) Vehicles(_ context.Context, query url.Values) (Page[VehicleRow], error) { func (m *MockStore) Vehicles(_ context.Context, query url.Values) (Page[VehicleRow], error) {
items := filterVehicles(m.vehicles, query) items := filterVehicles(m.vehicles, query)
for index := range items {
items[index].ServiceStatus = m.vehicleRowServiceStatus(items[index])
}
return page(items, query), nil return page(items, query), nil
} }
func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatus {
status := VehicleCoverageRow{
VIN: row.VIN,
Plate: row.Plate,
Phone: row.Phone,
OEM: row.OEM,
LastSeen: row.LastSeen,
BindingStatus: "bound",
}
if strings.TrimSpace(row.VIN) == "" {
status.BindingStatus = "unbound"
}
for _, vehicle := range m.vehicles {
if vehicle.VIN != row.VIN {
continue
}
if !containsString(status.Protocols, vehicle.Protocol) {
status.Protocols = append(status.Protocols, vehicle.Protocol)
status.SourceCount = len(status.Protocols)
}
if vehicle.Online {
status.Online = true
status.OnlineSourceCount++
}
if vehicle.LastSeen > status.LastSeen {
status.LastSeen = vehicle.LastSeen
}
}
return buildVehicleCoverageServiceStatus(status)
}
func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) { func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
vehicles := filterVehicles(m.vehicles, query) vehicles := filterVehicles(m.vehicles, query)
byVIN := map[string]*VehicleCoverageRow{} byVIN := map[string]*VehicleCoverageRow{}

View File

@@ -46,6 +46,7 @@ type VehicleRow struct {
LastSeen string `json:"lastSeen"` LastSeen string `json:"lastSeen"`
LocationText string `json:"locationText"` LocationText string `json:"locationText"`
BindingScore int `json:"bindingScore"` BindingScore int `json:"bindingScore"`
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
} }
type VehicleCoverageRow struct { type VehicleCoverageRow struct {

View File

@@ -32,13 +32,20 @@ func buildVehicleListSQL(query url.Values) SQLQuery {
fromSQL := `FROM vehicle_realtime_snapshot s ` + fromSQL := `FROM vehicle_realtime_snapshot s ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` + `LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
`LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol ` + `LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol ` +
`LEFT JOIN (` +
`SELECT vin, COUNT(DISTINCT protocol) AS source_count, ` +
`COUNT(DISTINCT CASE WHEN updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN protocol END) AS online_source_count ` +
`FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> '' GROUP BY vin` +
`) vs ON vs.vin = s.vin ` +
`WHERE ` + strings.Join(where, " AND ") `WHERE ` + strings.Join(where, " AND ")
return SQLQuery{ return SQLQuery{
Text: `SELECT s.vin, COALESCE(NULLIF(s.plate, ''), b.plate, '') AS plate, COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, s.protocol, ` + Text: `SELECT s.vin, COALESCE(NULLIF(s.plate, ''), b.plate, '') AS plate, COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, s.protocol, ` +
`CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END AS online, ` + `CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END AS online, ` +
`COALESCE(DATE_FORMAT(s.updated_at, '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` + `COALESCE(DATE_FORMAT(s.updated_at, '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
`COALESCE(CONCAT(l.longitude, ',', l.latitude), '') AS location_text, ` + `COALESCE(CONCAT(l.longitude, ',', l.latitude), '') AS location_text, ` +
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 100 ELSE 60 END AS binding_score ` + `CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 100 ELSE 60 END AS binding_score, ` +
`COALESCE(vs.source_count, 0) AS source_count, COALESCE(vs.online_source_count, 0) AS online_source_count, ` +
`CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` +
fromSQL + ` ORDER BY s.updated_at DESC, s.vin ASC, s.protocol ASC LIMIT ? OFFSET ?`, fromSQL + ` ORDER BY s.updated_at DESC, s.vin ASC, s.protocol ASC LIMIT ? OFFSET ?`,
Args: args, Args: args,
CountText: `SELECT COUNT(*) ` + fromSQL, CountText: `SELECT COUNT(*) ` + fromSQL,

View File

@@ -124,10 +124,25 @@ func (s *ProductionStore) Vehicles(ctx context.Context, query url.Values) (Page[
for rows.Next() { for rows.Next() {
var row VehicleRow var row VehicleRow
var online int var online int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.Protocol, &online, &row.LastSeen, &row.LocationText, &row.BindingScore); err != nil { var sourceCount int
var onlineSourceCount int
var bindingStatus string
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.Protocol, &online, &row.LastSeen, &row.LocationText, &row.BindingScore, &sourceCount, &onlineSourceCount, &bindingStatus); err != nil {
return Page[VehicleRow]{}, err return Page[VehicleRow]{}, err
} }
row.Online = online == 1 row.Online = online == 1
row.ServiceStatus = buildVehicleCoverageServiceStatus(VehicleCoverageRow{
VIN: row.VIN,
Plate: row.Plate,
Phone: row.Phone,
OEM: row.OEM,
Protocols: []string{row.Protocol},
SourceCount: sourceCount,
OnlineSourceCount: onlineSourceCount,
Online: onlineSourceCount > 0,
LastSeen: row.LastSeen,
BindingStatus: bindingStatus,
})
items = append(items, row) items = append(items, row)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {

View File

@@ -43,6 +43,7 @@ export interface VehicleRow {
lastSeen: string; lastSeen: string;
locationText: string; locationText: string;
bindingScore: number; bindingScore: number;
serviceStatus?: VehicleServiceStatus;
} }
export interface VehicleCoverageRow { export interface VehicleCoverageRow {