feat(platform): diagnose source consistency

This commit is contained in:
lingniu
2026-07-04 04:12:23 +08:00
parent 7b801b6f71
commit e077d99a5e
9 changed files with 104 additions and 5 deletions

View File

@@ -251,6 +251,12 @@ func TestHandlerVehicleServiceIncludesSourceConsistency(t *testing.T) {
if consistency.MileageDeltaKm <= 0 || consistency.SourceTimeDeltaSeconds <= 0 { if consistency.MileageDeltaKm <= 0 || consistency.SourceTimeDeltaSeconds <= 0 {
t.Fatalf("sourceConsistency should expose mileage and source time deltas, got %+v", consistency) t.Fatalf("sourceConsistency should expose mileage and source time deltas, got %+v", consistency)
} }
if consistency.Status == "" || consistency.Severity == "" || consistency.Title == "" || consistency.Detail == "" {
t.Fatalf("sourceConsistency should expose actionable diagnosis, got %+v", consistency)
}
if consistency.Status != "degraded" || consistency.Title != "部分来源离线" {
t.Fatalf("sourceConsistency should diagnose partial source health, got %+v", consistency)
}
} }
func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) { func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) {
@@ -279,6 +285,9 @@ func TestHandlerVehicleServiceOverviewEndpoint(t *testing.T) {
if body.Data.SourceConsistency == nil || body.Data.SourceConsistency.SourceCount != 2 || body.Data.SourceConsistency.OnlineSourceCount != 1 { if body.Data.SourceConsistency == nil || body.Data.SourceConsistency.SourceCount != 2 || body.Data.SourceConsistency.OnlineSourceCount != 1 {
t.Fatalf("overview endpoint should expose source consistency, got %+v", body.Data.SourceConsistency) t.Fatalf("overview endpoint should expose source consistency, got %+v", body.Data.SourceConsistency)
} }
if body.Data.SourceConsistency.Status != "degraded" || body.Data.SourceConsistency.Title != "部分来源离线" {
t.Fatalf("overview source consistency should expose diagnosis, got %+v", body.Data.SourceConsistency)
}
if strings.Contains(rec.Body.String(), `"raw"`) || strings.Contains(rec.Body.String(), `"history"`) { if strings.Contains(rec.Body.String(), `"raw"`) || strings.Contains(rec.Body.String(), `"history"`) {
t.Fatalf("overview endpoint should not return heavy detail pages: %s", rec.Body.String()) t.Fatalf("overview endpoint should not return heavy detail pages: %s", rec.Body.String())
} }

View File

@@ -110,6 +110,10 @@ type VehicleSourceConsistency struct {
MileageDeltaKm float64 `json:"mileageDeltaKm"` MileageDeltaKm float64 `json:"mileageDeltaKm"`
SourceTimeDeltaSeconds float64 `json:"sourceTimeDeltaSeconds"` SourceTimeDeltaSeconds float64 `json:"sourceTimeDeltaSeconds"`
Scope string `json:"scope"` Scope string `json:"scope"`
Status string `json:"status"`
Severity string `json:"severity"`
Title string `json:"title"`
Detail string `json:"detail"`
} }
type VehicleServiceOverview struct { type VehicleServiceOverview struct {

View File

@@ -376,9 +376,53 @@ func buildVehicleSourceConsistency(statuses []VehicleSourceStatus, realtime []Re
if hasTime { if hasTime {
consistency.SourceTimeDeltaSeconds = maxTime.Sub(minTime).Seconds() consistency.SourceTimeDeltaSeconds = maxTime.Sub(minTime).Seconds()
} }
applyVehicleSourceConsistencyDiagnosis(consistency)
return consistency return consistency
} }
func applyVehicleSourceConsistencyDiagnosis(consistency *VehicleSourceConsistency) {
if consistency == nil {
return
}
switch {
case consistency.SourceCount <= 0:
consistency.Status = "no_source"
consistency.Severity = "warning"
consistency.Title = "暂无来源"
consistency.Detail = "当前车辆没有可用于交叉校验的数据来源。"
case consistency.SourceCount == 1:
consistency.Status = "single_source"
consistency.Severity = "warning"
consistency.Title = "单一来源"
consistency.Detail = "当前车辆只有一个数据来源,无法做跨来源一致性校验。"
case consistency.OnlineSourceCount <= 0:
consistency.Status = "offline"
consistency.Severity = "error"
consistency.Title = "全部来源离线"
consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "无法判断实时一致性。")
case consistency.OnlineSourceCount < consistency.SourceCount:
consistency.Status = "degraded"
consistency.Severity = "warning"
consistency.Title = "部分来源离线"
consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "车辆服务可用但需要关注离线来源。")
case consistency.MileageDeltaKm > 5:
consistency.Status = "mileage_divergent"
consistency.Severity = "warning"
consistency.Title = "里程差异偏大"
consistency.Detail = "多个来源的实时总里程差异超过 5 km请核对来源解析、单位和上报时间。"
case consistency.SourceTimeDeltaSeconds > 300:
consistency.Status = "time_divergent"
consistency.Severity = "warning"
consistency.Title = "来源时间差偏大"
consistency.Detail = "多个来源的最新上报时间相差超过 5 分钟,请关注转发延迟或链路断点。"
default:
consistency.Status = "consistent"
consistency.Severity = "ok"
consistency.Title = "来源一致"
consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "多源实时状态一致。")
}
}
func parseVehicleServiceTime(value string) (time.Time, bool) { func parseVehicleServiceTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {
@@ -477,6 +521,7 @@ func applyVehicleServiceOverviewDerivedFields(overview *VehicleServiceOverview,
OnlineSourceCount: overview.OnlineSourceCount, OnlineSourceCount: overview.OnlineSourceCount,
Scope: "all_sources", Scope: "all_sources",
} }
applyVehicleSourceConsistencyDiagnosis(overview.SourceConsistency)
} }
func statusesForOverview(overview *VehicleServiceOverview) []VehicleSourceStatus { func statusesForOverview(overview *VehicleServiceOverview) []VehicleSourceStatus {

View File

@@ -107,6 +107,10 @@ export interface VehicleSourceConsistency {
mileageDeltaKm: number; mileageDeltaKm: number;
sourceTimeDeltaSeconds: number; sourceTimeDeltaSeconds: number;
scope: string; scope: string;
status: string;
severity: string;
title: string;
detail: string;
} }
export interface VehicleServiceOverview { export interface VehicleServiceOverview {

View File

@@ -100,8 +100,8 @@ export function AppShell({
</Tag> </Tag>
) : null} ) : null}
{currentVehicleConsistency ? ( {currentVehicleConsistency ? (
<Tag color={currentVehicleConsistency.onlineSourceCount === currentVehicleConsistency.sourceCount ? 'green' : 'orange'}> <Tag color={currentVehicleConsistency.severity === 'ok' ? 'green' : currentVehicleConsistency.severity === 'error' ? 'red' : 'orange'}>
{currentVehicleConsistency.onlineSourceCount}/{currentVehicleConsistency.sourceCount} {currentVehicleConsistency.title || `${currentVehicleConsistency.onlineSourceCount}/${currentVehicleConsistency.sourceCount} 来源`}
</Tag> </Tag>
) : null} ) : null}
<Tag color="blue"></Tag> <Tag color="blue"></Tag>

View File

@@ -136,6 +136,8 @@ export function VehicleDetail({
const consistencyLocatedSourceCount = consistency?.locatedSourceCount ?? locatedSourceCount; const consistencyLocatedSourceCount = consistency?.locatedSourceCount ?? locatedSourceCount;
const consistencyMileageDelta = consistency?.mileageDeltaKm ?? mileageDelta; const consistencyMileageDelta = consistency?.mileageDeltaKm ?? mileageDelta;
const consistencyTimeDeltaSeconds = consistency?.sourceTimeDeltaSeconds ?? sourceTimeDeltaSeconds; const consistencyTimeDeltaSeconds = consistency?.sourceTimeDeltaSeconds ?? sourceTimeDeltaSeconds;
const consistencyTitle = consistency?.title ?? '-';
const consistencyDetail = consistency?.detail ?? '-';
const activeProtocol = query.protocol?.trim() ?? ''; const activeProtocol = query.protocol?.trim() ?? '';
const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合'; const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合';
const formKey = `${query.keyword}-${query.protocol ?? ''}`; const formKey = `${query.keyword}-${query.protocol ?? ''}`;
@@ -328,11 +330,13 @@ export function VehicleDetail({
<Descriptions <Descriptions
row row
data={[ data={[
{ key: '一致性结论', value: consistencyTitle },
{ key: '来源覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个来源` : '-' }, { key: '来源覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个来源` : '-' },
{ key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' }, { key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' },
{ key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' }, { key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' },
{ key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') }, { key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') },
{ key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) }, { key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) },
{ key: '诊断说明', value: consistencyDetail },
{ key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' } { key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' }
]} ]}
/> />

View File

@@ -386,7 +386,11 @@ test('shows resolved vehicle service status after topbar search', async () => {
locatedSourceCount: 2, locatedSourceCount: 2,
mileageDeltaKm: 0, mileageDeltaKm: 0,
sourceTimeDeltaSeconds: 0, sourceTimeDeltaSeconds: 0,
scope: 'all_sources' scope: 'all_sources',
status: 'degraded',
severity: 'warning',
title: '部分来源离线',
detail: '1/2 个来源在线,车辆服务可用但需要关注离线来源。'
} }
}, },
traceId: 'trace-test', traceId: 'trace-test',
@@ -432,7 +436,7 @@ test('shows resolved vehicle service status after topbar search', async () => {
expect(await screen.findByText('当前车辆:后端判定部分离线')).toBeInTheDocument(); expect(await screen.findByText('当前车辆:后端判定部分离线')).toBeInTheDocument();
expect(screen.getByText('粤AG18312 / LB9A32A24R0LS1426')).toBeInTheDocument(); expect(screen.getByText('粤AG18312 / LB9A32A24R0LS1426')).toBeInTheDocument();
expect(screen.getByText('一致性:1/2 来源')).toBeInTheDocument(); expect(screen.getByText('一致性:部分来源离线')).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312'), undefined); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312'), undefined);
expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/resolve'), undefined); expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/resolve'), undefined);
}); });
@@ -1777,7 +1781,11 @@ test('shows cross-source consistency for one vehicle service', async () => {
locatedSourceCount: 3, locatedSourceCount: 3,
mileageDeltaKm: 0.4, mileageDeltaKm: 0.4,
sourceTimeDeltaSeconds: 35, sourceTimeDeltaSeconds: 35,
scope: 'all_sources' scope: 'all_sources',
status: 'degraded',
severity: 'warning',
title: '部分来源离线',
detail: '2/3 个来源在线,车辆服务可用但需要关注离线来源。'
}, },
sources: ['GB32960', 'JT808', 'YUTONG_MQTT'], sources: ['GB32960', 'JT808', 'YUTONG_MQTT'],
sourceStatus: [ sourceStatus: [
@@ -1815,6 +1823,8 @@ test('shows cross-source consistency for one vehicle service', async () => {
render(<App />); render(<App />);
expect(await screen.findByText('跨来源一致性')).toBeInTheDocument(); expect(await screen.findByText('跨来源一致性')).toBeInTheDocument();
expect(screen.getAllByText('部分来源离线').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('2/3 个来源在线,车辆服务可用但需要关注离线来源。')).toBeInTheDocument();
expect(screen.getByText('3 个来源')).toBeInTheDocument(); expect(screen.getByText('3 个来源')).toBeInTheDocument();
expect(screen.getByText('2/3 在线')).toBeInTheDocument(); expect(screen.getByText('2/3 在线')).toBeInTheDocument();
expect(screen.getByText('3 个来源有位置')).toBeInTheDocument(); expect(screen.getByText('3 个来源有位置')).toBeInTheDocument();

View File

@@ -57,6 +57,27 @@ Returns one vehicle service view with identity, realtime summary, source coverag
`/api/vehicles/detail` remains available as a compatibility alias. New UI and integrations should use `/api/vehicle-service` to make the vehicle-first boundary explicit. `/api/vehicles/detail` remains available as a compatibility alias. New UI and integrations should use `/api/vehicle-service` to make the vehicle-first boundary explicit.
`sourceConsistency` is the vehicle-level diagnosis across GB32960, JT808, and Yutong MQTT sources. The three protocols are source evidence for one vehicle service, so callers should display the diagnosis result instead of asking users to compare protocol rows manually.
```json
{
"sourceConsistency": {
"scope": "detail",
"sourceCount": 3,
"onlineSourceCount": 2,
"locatedSourceCount": 2,
"mileageDeltaKm": 0.8,
"sourceTimeDeltaSeconds": 42,
"status": "degraded",
"severity": "warning",
"title": "部分来源离线",
"detail": "2/3 个来源在线,车辆服务可用但需要关注离线来源。"
}
}
```
`status` values are `consistent`, `degraded`, `offline`, `single_source`, `no_source`, `mileage_divergent`, and `time_divergent`. `severity` values are `ok`, `warning`, and `error`. `/api/vehicle-service/overview` and `/api/vehicle-service/overviews` also return `sourceConsistency`; overview responses may only include lightweight counts and diagnosis, while detail responses include location, mileage, and source-time deltas when realtime source rows are available.
### Realtime Vehicles ### Realtime Vehicles
```http ```http

View File

@@ -22,6 +22,8 @@ Vehicle service coverage is always shown at VIN level. A vehicle may have one or
Vehicle lists should expose a vehicle-level service status derived from binding and source coverage: identity required, no data source, offline, degraded, or healthy. Protocol tags remain attribution and troubleshooting context, not the primary status. Vehicle lists should expose a vehicle-level service status derived from binding and source coverage: identity required, no data source, offline, degraded, or healthy. Protocol tags remain attribution and troubleshooting context, not the primary status.
Vehicle detail and top search context should expose one source-consistency conclusion, such as `来源一致`, `部分来源离线`, or `里程差异偏大`. This diagnosis comes from `sourceConsistency` and turns GB32960, JT808, and Yutong MQTT into explainable evidence under one vehicle service rather than three competing realtime products.
## Interaction Rules ## Interaction Rules
- Tables are the default data surface. - Tables are the default data surface.