From 54d74178eabdeb326da8dbb2da793801f0e43c5e Mon Sep 17 00:00:00 2001 From: lingniu Date: Fri, 3 Jul 2026 23:25:39 +0800 Subject: [PATCH] feat(platform): summarize quality issues --- .../apps/api/internal/platform/handler.go | 6 +++ .../api/internal/platform/handler_test.go | 3 +- .../apps/api/internal/platform/mock_store.go | 48 +++++++++++++++-- .../apps/api/internal/platform/model.go | 14 +++++ .../api/internal/platform/production_store.go | 22 ++++++-- .../apps/api/internal/platform/service.go | 5 ++ .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 14 +++++ .../apps/web/src/pages/Quality.tsx | 53 +++++++++++++++++-- 9 files changed, 155 insertions(+), 12 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 3721970b..bd626cd5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -36,6 +36,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost) h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary) h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage) + h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary) h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues) h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth) } @@ -122,6 +123,11 @@ func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleQualitySummary(w http.ResponseWriter, r *http.Request) { + data, err := h.service.QualitySummary(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} + func (h *Handler) handleOpsHealth(w http.ResponseWriter, r *http.Request) { data, err := h.service.OpsHealth(r.Context()) h.write(w, r, data, err) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index caacf728..f65cd662 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -56,7 +56,7 @@ func TestHandlerVehicleDetail(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } - for _, want := range []string{"identity", "realtimeSummary", "realtime", "history", "raw", "mileage", "quality", "sources", "sourceStatus", "VIN_MISSING"} { + for _, want := range []string{"identity", "realtimeSummary", "realtime", "history", "raw", "mileage", "quality", "sources", "sourceStatus"} { if !strings.Contains(rec.Body.String(), want) { t.Fatalf("response missing %q: %s", want, rec.Body.String()) } @@ -113,6 +113,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) { {"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"}, {"/api/mileage/summary?limit=10", "totalMileageKm"}, {"/api/mileage/daily?limit=10", "dailyMileageKm"}, + {"/api/quality/summary?limit=10", "issueVehicleCount"}, {"/api/quality/issues?limit=10", "issueType"}, {"/api/ops/health", "linkHealth"}, } diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index c11a49af..9c3ee34e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -275,10 +275,7 @@ func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow { } func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) { - rows := []QualityIssueRow{ - {VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 曾未命中 binding 表,已通过手机号关联 VIN"}, - {VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"}, - } + rows := mockQualityRows() if vin := strings.TrimSpace(query.Get("vin")); vin != "" { rows = keep(rows, func(row QualityIssueRow) bool { return row.VIN == vin }) } @@ -288,6 +285,49 @@ func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[Qua return page(rows, query), nil } +func (m *MockStore) QualitySummary(_ context.Context, query url.Values) (QualitySummary, error) { + page, _ := m.QualityIssues(context.Background(), query) + vehicles := map[string]struct{}{} + protocols := map[string]int{} + issueTypes := map[string]int{} + summary := QualitySummary{IssueRecordCount: page.Total} + for _, row := range page.Items { + vehicles[firstNonEmpty(row.VIN, row.Plate, row.Detail)] = struct{}{} + protocols[row.Protocol]++ + issueTypes[row.IssueType]++ + if row.Severity == "error" { + summary.ErrorCount++ + } else { + summary.WarningCount++ + } + } + summary.IssueVehicleCount = len(vehicles) + summary.Protocols = bucketStats(protocols) + summary.IssueTypes = bucketStats(issueTypes) + return summary, nil +} + +func mockQualityRows() []QualityIssueRow { + return []QualityIssueRow{ + {VIN: "unknown", Plate: "", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 13300000001 未命中 binding 表"}, + {VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"}, + } +} + +func bucketStats(values map[string]int) []QualityBucketStat { + out := make([]QualityBucketStat, 0, len(values)) + for name, count := range values { + out = append(out, QualityBucketStat{Name: name, Count: count}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count == out[j].Count { + return out[i].Name < out[j].Name + } + return out[i].Count > out[j].Count + }) + return out +} + func (m *MockStore) OpsHealth(context.Context) (OpsHealth, error) { summary, _ := m.DashboardSummary(context.Background()) redisOnlineKeys := 92 diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index d5deb0c9..a0327dd0 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -160,6 +160,20 @@ type QualityIssueRow struct { Detail string `json:"detail"` } +type QualitySummary struct { + IssueVehicleCount int `json:"issueVehicleCount"` + IssueRecordCount int `json:"issueRecordCount"` + ErrorCount int `json:"errorCount"` + WarningCount int `json:"warningCount"` + Protocols []QualityBucketStat `json:"protocols"` + IssueTypes []QualityBucketStat `json:"issueTypes"` +} + +type QualityBucketStat struct { + Name string `json:"name"` + Count int `json:"count"` +} + type OpsHealth struct { LinkHealth []LinkHealth `json:"linkHealth"` KafkaLag *int `json:"kafkaLag"` diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 2598cf36..5411ed2b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -460,8 +460,7 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil } -func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) { - limit, offset := buildLimitOffset(query) +func buildQualityIssueWhere(query url.Values) (string, []any) { where := []string{"(r.vin = '' OR r.vin = 'unknown' OR r.vin IS NULL)"} args := []any{} if vin := strings.TrimSpace(query.Get("vin")); vin != "" { @@ -477,9 +476,26 @@ func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) ( like := "%" + keyword + "%" args = append(args, like, like, like) } + return `FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone = r.phone WHERE ` + strings.Join(where, " AND "), args +} + +func (s *ProductionStore) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) { + fromSQL, args := buildQualityIssueWhere(query) + var summary QualitySummary + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT COALESCE(NULLIF(b.vin, ''), r.phone)) AS issue_vehicle_count, COUNT(*) AS issue_record_count `+fromSQL, args...).Scan(&summary.IssueVehicleCount, &summary.IssueRecordCount); err != nil { + return QualitySummary{}, err + } + summary.WarningCount = summary.IssueRecordCount + summary.Protocols = []QualityBucketStat{{Name: "JT808", Count: summary.IssueRecordCount}} + summary.IssueTypes = []QualityBucketStat{{Name: "VIN_MISSING", Count: summary.IssueRecordCount}} + return summary, nil +} + +func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) { + limit, offset := buildLimitOffset(query) + fromSQL, args := buildQualityIssueWhere(query) countArgs := append([]any(nil), args...) args = append(args, limit, offset) - fromSQL := `FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone = r.phone WHERE ` + strings.Join(where, " AND ") total := 0 if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) `+fromSQL, countArgs...).Scan(&total); err != nil { return Page[QualityIssueRow]{}, err diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 41141201..cb2e5595 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -18,6 +18,7 @@ type Store interface { RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error) MileageSummary(context.Context, url.Values) (MileageSummary, error) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) + QualitySummary(context.Context, url.Values) (QualitySummary, error) QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error) OpsHealth(context.Context) (OpsHealth, error) } @@ -194,6 +195,10 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail return s.store.DailyMileage(ctx, query) } +func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) { + return s.store.QualitySummary(ctx, query) +} + func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) { return s.store.QualityIssues(ctx, query) } diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 77def776..932f4257 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -6,6 +6,7 @@ import type { MileageSummary, OpsHealth, Page, + QualitySummary, QualityIssueRow, RawFrameRow, RealtimeLocationRow, @@ -35,6 +36,7 @@ export const api = { rawFrames: (params = new URLSearchParams()) => request>(`/api/history/raw-frames?${params.toString()}`), mileageSummary: (params = new URLSearchParams()) => request(`/api/mileage/summary?${params.toString()}`), dailyMileage: (params = new URLSearchParams()) => request>(`/api/mileage/daily?${params.toString()}`), + qualitySummary: (params = new URLSearchParams()) => request(`/api/quality/summary?${params.toString()}`), qualityIssues: (params = new URLSearchParams()) => request>(`/api/quality/issues?${params.toString()}`), opsHealth: () => request('/api/ops/health') }; diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 3a2d6ffb..c46f25e7 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -150,6 +150,20 @@ export interface QualityIssueRow { detail: string; } +export interface QualitySummary { + issueVehicleCount: number; + issueRecordCount: number; + errorCount: number; + warningCount: number; + protocols: QualityBucketStat[]; + issueTypes: QualityBucketStat[]; +} + +export interface QualityBucketStat { + name: string; + count: number; +} + export interface OpsHealth { linkHealth: LinkHealth[]; kafkaLag: number | null; diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 8cef3b0c..10947f31 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -1,7 +1,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import type { OpsHealth, QualityIssueRow } from '../api/types'; +import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; const statusColor: Record = { @@ -18,19 +18,37 @@ function storageReadStatus(health: OpsHealth | null) { return health?.tdengineWritable && health.mysqlWritable ? 'ok' : 'error'; } +const emptySummary: QualitySummary = { + issueVehicleCount: 0, + issueRecordCount: 0, + errorCount: 0, + warningCount: 0, + protocols: [], + issueTypes: [] +}; + +function qualityParams(values: Record) { + const params = new URLSearchParams(); + if (values?.keyword) params.set('keyword', values.keyword); + if (values?.protocol) params.set('protocol', values.protocol); + return params; +} + export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealth) => void }) { const [issues, setIssues] = useState([]); + const [summary, setSummary] = useState(emptySummary); const [health, setHealth] = useState(null); const [loadingIssues, setLoadingIssues] = useState(true); + const [loadingSummary, setLoadingSummary] = useState(true); const [loadingHealth, setLoadingHealth] = useState(true); const [filters, setFilters] = useState>({}); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); const loadIssues = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoadingIssues(true); - const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) }); - if (values?.keyword) params.set('keyword', values.keyword); - if (values?.protocol) params.set('protocol', values.protocol); + const params = qualityParams(values); + params.set('limit', String(pageSize)); + params.set('offset', String((page - 1) * pageSize)); api.qualityIssues(params) .then((nextPage) => { setIssues(nextPage.items); @@ -40,6 +58,14 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt .finally(() => setLoadingIssues(false)); }; + const loadSummary = (values: Record = filters) => { + setLoadingSummary(true); + api.qualitySummary(qualityParams(values)) + .then(setSummary) + .catch((error: Error) => Toast.error(error.message)) + .finally(() => setLoadingSummary(false)); + }; + const loadHealth = () => { setLoadingHealth(true); api.opsHealth() @@ -52,6 +78,7 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt }; useEffect(() => { + loadSummary({}); loadIssues({}, 1, pagination.pageSize); loadHealth(); }, []); @@ -59,6 +86,22 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt return (
+
+ {[ + { label: '问题车辆', value: summary.issueVehicleCount.toLocaleString() }, + { label: '问题记录', value: summary.issueRecordCount.toLocaleString() }, + { label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` }, + { + label: '主要问题', + value: summary.issueTypes.length > 0 ? `${summary.issueTypes[0].name} ${summary.issueTypes[0].count}` : '-' + } + ].map((item) => ( + +
{item.value}
+
{item.label}
+
+ ))} +
{formatLag(health?.kafkaLag)} {formatLag(health?.redisOnlineKeys)} @@ -72,6 +115,7 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt
{ const nextFilters = values as Record; setFilters(nextFilters); + loadSummary(nextFilters); loadIssues(nextFilters, 1, pagination.pageSize); }} style={{ marginBottom: 12 }}> @@ -82,6 +126,7 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt