feat(platform): expose realtime service status

This commit is contained in:
lingniu
2026-07-04 02:08:31 +08:00
parent 526377c13f
commit 6b1e36a5a6
11 changed files with 220 additions and 28 deletions

View File

@@ -276,6 +276,33 @@ func TestHandlerVehicleRealtime(t *testing.T) {
t.Fatalf("response missing %q: %s", want, rec.Body.String()) t.Fatalf("response missing %q: %s", want, rec.Body.String())
} }
} }
var body struct {
Data struct {
Items []VehicleRealtimeRow `json:"items"`
} `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("realtime vehicle row should include canonical serviceStatus: %s", rec.Body.String())
}
}
func TestHandlerVehicleRealtimeFiltersServiceStatus(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?serviceStatus=degraded&limit=10", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "LB9A32A24R0LS1426") {
t.Fatalf("degraded realtime should include partially online vehicle: %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "LNXNEGRR7SR318212") {
t.Fatalf("degraded realtime should exclude healthy single-source vehicle: %s", rec.Body.String())
}
} }
func TestHandlerVehicleRealtimeAcceptsKeyword(t *testing.T) { func TestHandlerVehicleRealtimeAcceptsKeyword(t *testing.T) {

View File

@@ -124,8 +124,12 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
Plate: firstNonEmpty(location.Plate, vehicle.Plate), Plate: firstNonEmpty(location.Plate, vehicle.Plate),
Phone: vehicle.Phone, Phone: vehicle.Phone,
OEM: vehicle.OEM, OEM: vehicle.OEM,
BindingStatus: "unbound",
LastSeen: location.LastSeen, LastSeen: location.LastSeen,
} }
if vehicle.VIN != "" {
current.BindingStatus = "bound"
}
byVIN[location.VIN] = current byVIN[location.VIN] = current
} }
if !containsString(current.Protocols, location.Protocol) { if !containsString(current.Protocols, location.Protocol) {
@@ -149,6 +153,7 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
items := make([]VehicleRealtimeRow, 0, len(byVIN)) items := make([]VehicleRealtimeRow, 0, len(byVIN))
for _, row := range byVIN { for _, row := range byVIN {
sort.Strings(row.Protocols) sort.Strings(row.Protocols)
row.ServiceStatus = buildRealtimeServiceStatus(*row)
switch strings.TrimSpace(query.Get("online")) { switch strings.TrimSpace(query.Get("online")) {
case "online": case "online":
if !row.Online { if !row.Online {
@@ -159,6 +164,9 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
continue continue
} }
} }
if !keepServiceStatus(row.ServiceStatus, query.Get("serviceStatus")) {
continue
}
items = append(items, *row) items = append(items, *row)
} }
sort.Slice(items, func(i, j int) bool { sort.Slice(items, func(i, j int) bool {
@@ -442,15 +450,19 @@ func keepCoverageRow(row VehicleCoverageRow, query url.Values) bool {
return false return false
} }
} }
switch strings.TrimSpace(query.Get("serviceStatus")) { return keepServiceStatus(row.ServiceStatus, query.Get("serviceStatus"))
}
func keepServiceStatus(status *VehicleServiceStatus, raw string) bool {
switch strings.TrimSpace(raw) {
case "identity_required": case "identity_required":
return row.BindingStatus != "bound" return status != nil && status.Status == "identity_required"
case "offline": case "offline":
return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount == 0 return status != nil && status.Status == "offline"
case "degraded": case "degraded":
return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount > 0 && row.OnlineSourceCount < row.SourceCount return status != nil && status.Status == "degraded"
case "healthy": case "healthy":
return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount == row.SourceCount return status != nil && status.Status == "healthy"
default: default:
return true return true
} }

View File

@@ -131,6 +131,8 @@ type VehicleRealtimeRow struct {
SourceCount int `json:"sourceCount"` SourceCount int `json:"sourceCount"`
OnlineSourceCount int `json:"onlineSourceCount"` OnlineSourceCount int `json:"onlineSourceCount"`
Online bool `json:"online"` Online bool `json:"online"`
BindingStatus string `json:"bindingStatus"`
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
PrimaryProtocol string `json:"primaryProtocol"` PrimaryProtocol string `json:"primaryProtocol"`
Longitude float64 `json:"longitude"` Longitude float64 `json:"longitude"`
Latitude float64 `json:"latitude"` Latitude float64 `json:"latitude"`

View File

@@ -174,6 +174,29 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
case "offline": case "offline":
having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0") having = append(having, "COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0")
} }
switch strings.TrimSpace(query.Get("serviceStatus")) {
case "healthy":
having = append(having,
"MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = COUNT(DISTINCT l.protocol)",
)
case "degraded":
having = append(having,
"MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) < COUNT(DISTINCT l.protocol)",
)
case "offline":
having = append(having,
"MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 1",
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) = 0",
)
case "identity_required":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL THEN 1 ELSE 0 END) = 0")
}
countArgs := append([]any(nil), args...) countArgs := append([]any(nil), args...)
args = append(args, limit, offset) args = append(args, limit, offset)
havingSQL := "" havingSQL := ""
@@ -194,6 +217,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
`COUNT(DISTINCT l.protocol) AS source_count, ` + `COUNT(DISTINCT l.protocol) AS source_count, ` +
`COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` + `COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` +
`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 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(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` + `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.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` +

View File

@@ -193,11 +193,12 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
var protocols string var protocols string
var online int var online int
var longitude, latitude, speed, soc, mileage string var longitude, latitude, speed, soc, mileage string
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen); err != nil { if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen); err != nil {
return Page[VehicleRealtimeRow]{}, err return Page[VehicleRealtimeRow]{}, err
} }
row.Protocols = splitCSV(protocols) row.Protocols = splitCSV(protocols)
row.Online = online == 1 row.Online = online == 1
row.ServiceStatus = buildRealtimeServiceStatus(row)
row.Longitude = parseFloatString(longitude) row.Longitude = parseFloatString(longitude)
row.Latitude = parseFloatString(latitude) row.Latitude = parseFloatString(latitude)
row.SpeedKmh = parseFloatString(speed) row.SpeedKmh = parseFloatString(speed)

View File

@@ -104,6 +104,24 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
} }
} }
func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) {
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
built := buildVehicleRealtimeSQL(query)
for _, want := range []string{
"HAVING",
"COUNT(DISTINCT l.protocol) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0",
"COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) < COUNT(DISTINCT l.protocol)",
} {
if !strings.Contains(built.Text, want) {
t.Fatalf("SQL missing realtime service status predicate %q: %s", want, built.Text)
}
}
if !strings.Contains(built.CountText, "vehicle_realtime_count") || !strings.Contains(built.CountText, "HAVING") {
t.Fatalf("count SQL should include realtime service status HAVING: %s", built.CountText)
}
}
func TestBuildDailyMileageSQL(t *testing.T) { func TestBuildDailyMileageSQL(t *testing.T) {
query := url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}} query := url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
built := buildDailyMileageSQL(query) built := buildDailyMileageSQL(query)

View File

@@ -529,6 +529,21 @@ func buildVehicleCoverageServiceStatus(row VehicleCoverageRow) *VehicleServiceSt
} }
} }
func buildRealtimeServiceStatus(row VehicleRealtimeRow) *VehicleServiceStatus {
return buildVehicleCoverageServiceStatus(VehicleCoverageRow{
VIN: row.VIN,
Plate: row.Plate,
Phone: row.Phone,
OEM: row.OEM,
Protocols: row.Protocols,
SourceCount: row.SourceCount,
OnlineSourceCount: row.OnlineSourceCount,
Online: row.Online,
LastSeen: row.LastSeen,
BindingStatus: row.BindingStatus,
})
}
func sourceCoverageDetail(sourceCount int, onlineSourceCount int, suffix string) string { func sourceCoverageDetail(sourceCount int, onlineSourceCount int, suffix string) string {
return strconv.Itoa(onlineSourceCount) + "/" + strconv.Itoa(sourceCount) + " 个来源在线," + suffix return strconv.Itoa(onlineSourceCount) + "/" + strconv.Itoa(sourceCount) + " 个来源在线," + suffix
} }

View File

@@ -128,6 +128,8 @@ export interface VehicleRealtimeRow {
sourceCount: number; sourceCount: number;
onlineSourceCount: number; onlineSourceCount: number;
online: boolean; online: boolean;
bindingStatus: string;
serviceStatus?: VehicleServiceStatus;
primaryProtocol: string; primaryProtocol: string;
longitude: number; longitude: number;
latitude: number; latitude: number;

View File

@@ -11,6 +11,22 @@ function canOpenVehicle(vin?: string) {
return Boolean(value && value !== 'unknown'); return Boolean(value && value !== 'unknown');
} }
function vehicleServiceStatus(row: VehicleRealtimeRow) {
if (row.serviceStatus) {
return {
label: row.serviceStatus.title,
color: row.serviceStatus.severity === 'ok' ? 'green' as const : row.serviceStatus.severity === 'error' ? 'red' as const : 'orange' as const
};
}
if (row.onlineSourceCount <= 0) {
return { label: '车辆离线', color: 'red' as const };
}
if (row.onlineSourceCount < row.sourceCount) {
return { label: '部分来源离线', color: 'orange' as const };
}
return { label: '服务正常', color: 'green' as const };
}
export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, protocol?: string) => void }) { export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, protocol?: string) => void }) {
const [rows, setRows] = useState<VehicleRealtimeRow[]>([]); const [rows, setRows] = useState<VehicleRealtimeRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -23,6 +39,7 @@ export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, proto
if (values?.keyword) params.set('keyword', values.keyword); if (values?.keyword) params.set('keyword', values.keyword);
if (values?.protocol) params.set('protocol', values.protocol); if (values?.protocol) params.set('protocol', values.protocol);
if (values?.online) params.set('online', values.online); if (values?.online) params.set('online', values.online);
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
api.vehicleRealtime(params) api.vehicleRealtime(params)
.then((nextPage) => { .then((nextPage) => {
setRows(nextPage.items); setRows(nextPage.items);
@@ -55,6 +72,12 @@ export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, proto
<Select.Option value="online">线</Select.Option> <Select.Option value="online">线</Select.Option>
<Select.Option value="offline">线</Select.Option> <Select.Option value="offline">线</Select.Option>
</Form.Select> </Form.Select>
<Form.Select field="serviceStatus" label="服务状态" placeholder="全部" style={{ width: 150 }}>
<Select.Option value="healthy"></Select.Option>
<Select.Option value="degraded">线</Select.Option>
<Select.Option value="offline">线</Select.Option>
<Select.Option value="identity_required"></Select.Option>
</Form.Select>
<Space> <Space>
<Button htmlType="submit" theme="solid" type="primary"></Button> <Button htmlType="submit" theme="solid" type="primary"></Button>
<Button onClick={() => { <Button onClick={() => {
@@ -95,6 +118,14 @@ export function Realtime({ onOpenVehicle }: { onOpenVehicle: (vin: string, proto
) )
}, },
{ title: '覆盖', width: 90, render: (_: unknown, row: VehicleRealtimeRow) => `${row.onlineSourceCount}/${row.sourceCount}` }, { title: '覆盖', width: 90, render: (_: unknown, row: VehicleRealtimeRow) => `${row.onlineSourceCount}/${row.sourceCount}` },
{
title: '车辆服务状态',
width: 130,
render: (_: unknown, row: VehicleRealtimeRow) => {
const status = vehicleServiceStatus(row);
return <Tag color={status.color}>{status.label}</Tag>;
}
},
{ title: '在线', width: 90, render: (_: unknown, row: VehicleRealtimeRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> }, { title: '在线', width: 90, render: (_: unknown, row: VehicleRealtimeRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
{ title: '速度 km/h', dataIndex: 'speedKmh' }, { title: '速度 km/h', dataIndex: 'speedKmh' },
{ title: 'SOC %', dataIndex: 'socPercent' }, { title: 'SOC %', dataIndex: 'socPercent' },

View File

@@ -288,6 +288,66 @@ test('keeps primary protocol when opening vehicle service from realtime vehicles
}); });
}); });
test('shows canonical service status in realtime vehicles', async () => {
window.history.replaceState(null, '', '/#/realtime');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.includes('/api/realtime/vehicles')) {
return {
ok: true,
json: async () => ({
data: {
items: [{
vin: 'VIN-RT-DEGRADED',
plate: '粤ART001',
phone: '',
oem: 'G7s',
protocols: ['GB32960'],
sourceCount: 1,
onlineSourceCount: 1,
online: true,
primaryProtocol: 'GB32960',
longitude: 113.2,
latitude: 23.1,
speedKmh: 30,
socPercent: 78,
totalMileageKm: 119925,
lastSeen: '2026-07-03 20:12:10',
bindingStatus: 'bound',
serviceStatus: {
status: 'degraded',
severity: 'warning',
title: '部分来源离线',
detail: '由实时车辆 API 统一判定',
sourceCount: 2,
onlineSourceCount: 1
}
}],
total: 1,
limit: 50,
offset: 0
},
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
return {
ok: true,
json: async () => ({
data: { items: [], total: 0, limit: 10, offset: 0 },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
});
render(<App />);
expect(await screen.findByText('VIN-RT-DEGRADED')).toBeInTheDocument();
expect(screen.getByText('部分来源离线')).toBeInTheDocument();
});
test('shows vehicle service status in vehicle list', async () => { test('shows vehicle service status in vehicle list', async () => {
window.history.replaceState(null, '', '/#/vehicles'); window.history.replaceState(null, '', '/#/vehicles');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {

View File

@@ -60,10 +60,10 @@ Returns one vehicle service view with identity, realtime summary, source coverag
### Realtime Vehicles ### Realtime Vehicles
```http ```http
GET /api/realtime/vehicles?keyword=AG18312&protocol=JT808&online=online&limit=50&offset=0 GET /api/realtime/vehicles?keyword=AG18312&protocol=JT808&online=online&serviceStatus=degraded&limit=50&offset=0
``` ```
Returns VIN-level realtime rows. Protocol is a source filter, not a product boundary. Returns VIN-level realtime rows with canonical vehicle-level `serviceStatus`. Protocol is a source filter, not a product boundary.
### Vehicle Coverage ### Vehicle Coverage