From ff833bc0651a4a719281020f12115d04edc8fdbc Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 13:31:36 +0800 Subject: [PATCH] feat(platform): add quality notification plan --- .../apps/api/internal/platform/handler.go | 6 + .../api/internal/platform/handler_test.go | 29 ++ .../apps/api/internal/platform/model.go | 41 +++ .../apps/api/internal/platform/service.go | 305 ++++++++++++++++++ .../apps/web/src/api/client.test.ts | 24 ++ .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 40 +++ .../apps/web/src/pages/Quality.tsx | 57 +++- .../apps/web/src/test/App.test.tsx | 91 ++++++ 9 files changed, 586 insertions(+), 9 deletions(-) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 8a616f06..df8a7e0d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -44,6 +44,7 @@ func (h *Handler) routes() { 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/quality/notification-plan", h.handleQualityNotificationPlan) h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth) } @@ -179,6 +180,11 @@ func (h *Handler) handleQualitySummary(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleQualityNotificationPlan(w http.ResponseWriter, r *http.Request) { + data, err := h.service.QualityNotificationPlan(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 e9df9b0d..2467b069 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -859,6 +859,35 @@ func TestHandlerQualityIssuesFiltersIssueType(t *testing.T) { } } +func TestHandlerQualityNotificationPlan(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/quality/notification-plan?limit=10", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data QualityNotificationPlan `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 body.Data.ActiveRuleCount == 0 || body.Data.P0RuleCount == 0 { + t.Fatalf("notification plan should expose active P0 rules, got %+v", body.Data) + } + if len(body.Data.Rules) == 0 || len(body.Data.Policies) == 0 || len(body.Data.PriorityIssues) == 0 { + t.Fatalf("notification plan should include rules, policies and priority issues: %+v", body.Data) + } + first := body.Data.PriorityIssues[0] + if first.Priority != "P0" || first.ActionLabel == "" || first.SLA == "" || first.VehicleLabel == "" { + t.Fatalf("priority issue should be enriched for notification: %+v", first) + } + if !strings.Contains(first.NotificationText, "实时定位") || !strings.Contains(first.NotificationText, "#/history") { + t.Fatalf("priority issue should include evidence links, got %q", first.NotificationText) + } +} + func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) { handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{RequestTimeoutMs: 1500})) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 0270fbaa..e05d4aae 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -291,6 +291,47 @@ type QualityBucketStat struct { Count int `json:"count"` } +type QualityNotificationPlan struct { + Summary QualitySummary `json:"summary"` + Rules []QualityAlertRule `json:"rules"` + Policies []QualityNotificationPolicy `json:"policies"` + PriorityIssues []QualityPriorityIssue `json:"priorityIssues"` + ActiveRuleCount int `json:"activeRuleCount"` + P0RuleCount int `json:"p0RuleCount"` +} + +type QualityAlertRule struct { + IssueType string `json:"issueType"` + Title string `json:"title"` + Level string `json:"level"` + Owner string `json:"owner"` + Trigger string `json:"trigger"` + Notify string `json:"notify"` + SLA string `json:"sla"` + Count int `json:"count"` +} + +type QualityNotificationPolicy struct { + Name string `json:"name"` + Target string `json:"target"` + Channel string `json:"channel"` + Condition string `json:"condition"` +} + +type QualityPriorityIssue struct { + QualityIssueRow + Priority string `json:"priority"` + ActionLabel string `json:"actionLabel"` + ActionDetail string `json:"actionDetail"` + SLA string `json:"sla"` + VehicleLabel string `json:"vehicleLabel"` + RealtimeHash string `json:"realtimeHash"` + HistoryHash string `json:"historyHash"` + RawHash string `json:"rawHash"` + VehicleHash string `json:"vehicleHash"` + NotificationText string `json:"notificationText"` +} + type OpsHealth struct { LinkHealth []LinkHealth `json:"linkHealth"` KafkaLag *int `json:"kafkaLag"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index b599f849..ecf17c25 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -76,6 +76,58 @@ func asClientError(err error) (clientError, bool) { var canonicalVehicleProtocols = []string{"GB32960", "JT808", "YUTONG_MQTT"} +var qualityAlertRuleTemplates = []QualityAlertRule{ + { + IssueType: "NO_SOURCE", + Title: "无数据来源", + Level: "P0", + Owner: "平台接入", + Trigger: "绑定车辆连续无 GB32960、JT808、MQTT 来源证据", + Notify: "立即通知接入运维,30 分钟未恢复升级给业务责任人", + SLA: "30 分钟确认", + }, + { + IssueType: "VIN_MISSING", + Title: "VIN 缺失", + Level: "P0", + Owner: "车辆档案", + Trigger: "来源有数据但无法归并到 VIN", + Notify: "通知档案维护人补齐车牌、手机号和 VIN 映射", + SLA: "2 小时修复", + }, + { + IssueType: "LINK_GAP", + Title: "链路中断", + Level: "P1", + Owner: "链路运维", + Trigger: "车辆或平台来源上报间断、Redis 在线状态过期", + Notify: "通知平台转发和网关值班人,持续异常进入日报", + SLA: "1 小时恢复", + }, + { + IssueType: "FIELD_MISSING", + Title: "字段缺失", + Level: "P1", + Owner: "协议解析", + Trigger: "核心字段缺失导致定位、里程或统计不可用", + Notify: "通知解析负责人核对字段映射和 RAW 样本", + SLA: "当日闭环", + }, +} + +var qualityNotificationPolicies = []QualityNotificationPolicy{ + {Name: "P0 实时中断", Target: "接入运维 + 业务责任人", Channel: "站内告警 / 邮件 / 企业微信", Condition: "无来源、VIN 缺失、存储不可写"}, + {Name: "P1 数据质量", Target: "协议解析 + 数据治理", Channel: "站内告警 / 每日汇总邮件", Condition: "字段缺失、链路间断、容量风险"}, + {Name: "P2 趋势关注", Target: "数智中心", Channel: "周报 / 趋势看板", Condition: "单源车辆、档案缺项、来源覆盖下降"}, +} + +var qualityIssuePriorityWeight = map[string]int{ + "NO_SOURCE": 10, + "VIN_MISSING": 9, + "LINK_GAP": 8, + "FIELD_MISSING": 7, +} + func completeProtocolStats(stats []ProtocolStat) []ProtocolStat { byProtocol := make(map[string]ProtocolStat, len(stats)+len(canonicalVehicleProtocols)) for _, stat := range stats { @@ -806,6 +858,46 @@ func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[Qua return s.store.QualityIssues(ctx, query) } +func (s *Service) QualityNotificationPlan(ctx context.Context, query url.Values) (QualityNotificationPlan, error) { + summary, err := s.store.QualitySummary(ctx, query) + if err != nil { + return QualityNotificationPlan{}, err + } + issueQuery := cloneValues(query) + if issueQuery.Get("limit") == "" { + issueQuery.Set("limit", "20") + } + issues, err := s.store.QualityIssues(ctx, issueQuery) + if err != nil { + return QualityNotificationPlan{}, err + } + health, err := s.store.OpsHealth(ctx) + if err != nil { + return QualityNotificationPlan{}, err + } + health.Runtime = s.runtime + rules := qualityNotificationRules(summary, health) + activeRuleCount := 0 + p0RuleCount := 0 + for _, rule := range rules { + if rule.Count <= 0 { + continue + } + activeRuleCount++ + if rule.Level == "P0" { + p0RuleCount++ + } + } + return QualityNotificationPlan{ + Summary: summary, + Rules: rules, + Policies: append([]QualityNotificationPolicy(nil), qualityNotificationPolicies...), + PriorityIssues: qualityPriorityIssues(issues.Items), + ActiveRuleCount: activeRuleCount, + P0RuleCount: p0RuleCount, + }, nil +} + func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) { health, err := s.store.OpsHealth(ctx) if err != nil { @@ -815,6 +907,219 @@ func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) { return health, nil } +func qualityNotificationRules(summary QualitySummary, health OpsHealth) []QualityAlertRule { + rows := make([]QualityAlertRule, 0, len(qualityAlertRuleTemplates)+1) + for _, item := range qualityAlertRuleTemplates { + item.Count = qualityIssueCount(summary, item.IssueType) + rows = append(rows, item) + } + storageWritable := health.TDengineWritable && health.MySQLWritable + capacityCount := len(health.CapacityFindings) + capacityRule := QualityAlertRule{ + IssueType: "CAPACITY_RISK", + Title: "容量与存储风险", + Level: "P2", + Owner: "基础设施", + Trigger: "Kafka Lag、连接容量、Redis 在线 Key 或存储读写异常", + Notify: "容量风险进入运维日报,超过阈值升级", + SLA: "当日评估", + Count: capacityCount, + } + if !storageWritable { + capacityRule.Level = "P0" + capacityRule.Notify = "立即通知基础设施值班人处理存储不可写" + capacityRule.SLA = "15 分钟恢复" + capacityRule.Count++ + } + rows = append(rows, capacityRule) + return rows +} + +func qualityIssueCount(summary QualitySummary, issueType string) int { + for _, item := range summary.IssueTypes { + if item.Name == issueType { + return item.Count + } + } + return 0 +} + +func qualityPriorityIssues(rows []QualityIssueRow) []QualityPriorityIssue { + out := make([]QualityPriorityIssue, 0, len(rows)) + for _, row := range rows { + out = append(out, buildQualityPriorityIssue(row)) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Priority != out[j].Priority { + return out[i].Priority == "P0" + } + weightDelta := qualityIssuePriorityWeight[out[j].IssueType] - qualityIssuePriorityWeight[out[i].IssueType] + if weightDelta != 0 { + return weightDelta < 0 + } + return out[i].LastSeen > out[j].LastSeen + }) + if len(out) > 5 { + out = out[:5] + } + return out +} + +func buildQualityPriorityIssue(row QualityIssueRow) QualityPriorityIssue { + actionLabel, actionDetail := qualityAction(row) + priority := "P1" + if row.Severity == "error" || row.IssueType == "NO_SOURCE" { + priority = "P0" + } + lookup := qualityIssueLookup(row) + dateFrom := evidenceDate(row.LastSeen) + dateTo := nextEvidenceDate(dateFrom) + realtimeHash := buildHash("realtime", lookup, row.Protocol, nil) + historyFilters := map[string]string{} + if dateFrom != "" { + historyFilters["dateFrom"] = dateFrom + } + if dateTo != "" { + historyFilters["dateTo"] = dateTo + } + historyHash := buildHash("history", lookup, row.Protocol, historyFilters) + rawFilters := cloneStringMap(historyFilters) + rawFilters["tab"] = "raw" + rawFilters["includeFields"] = "true" + rawHash := buildHash("history", lookup, row.Protocol, rawFilters) + vehicleHash := buildHash("detail", lookup, row.Protocol, nil) + item := QualityPriorityIssue{ + QualityIssueRow: row, + Priority: priority, + ActionLabel: actionLabel, + ActionDetail: actionDetail, + SLA: qualityIssueSLA(row.IssueType), + VehicleLabel: qualityVehicleLabel(row), + RealtimeHash: realtimeHash, + HistoryHash: historyHash, + RawHash: rawHash, + VehicleHash: vehicleHash, + } + item.NotificationText = qualityNotificationText(item) + return item +} + +func qualityAction(row QualityIssueRow) (string, string) { + switch { + case row.IssueType == "NO_SOURCE": + return "确认平台转发", "车辆已绑定但没有任何来源证据,先确认平台转发、端口和订阅。" + case row.IssueType == "VIN_MISSING" || strings.TrimSpace(row.VIN) == "": + return "维护身份绑定", "数据已有来源但无法归并到 VIN,优先用车牌/手机号补齐绑定。" + case row.IssueType == "LINK_GAP": + return "排查来源链路", "来源存在上报间断,优先检查平台转发、网络和消费延迟。" + case row.IssueType == "FIELD_MISSING": + return "核对解析字段", "字段缺失会影响统计和展示,优先核对解析映射和原始 RAW。" + default: + return "查看车辆服务", "进入车辆服务详情,结合来源证据继续排查。" + } +} + +func qualityIssueSLA(issueType string) string { + for _, item := range qualityAlertRuleTemplates { + if item.IssueType == issueType { + return item.SLA + } + } + return "当日闭环" +} + +func qualityVehicleLabel(row QualityIssueRow) string { + identity := firstNonEmpty(strings.TrimSpace(row.VIN), strings.TrimSpace(row.Phone), strings.TrimSpace(row.SourceEndpoint), "-") + if plate := strings.TrimSpace(row.Plate); plate != "" { + return plate + " / " + identity + } + return identity +} + +func qualityIssueLookup(row QualityIssueRow) string { + return firstNonEmpty(strings.TrimSpace(row.VIN), strings.TrimSpace(row.Plate), strings.TrimSpace(row.Phone), strings.TrimSpace(row.SourceEndpoint)) +} + +func evidenceDate(value string) string { + value = strings.TrimSpace(value) + if len(value) < len("2006-01-02") { + return "" + } + date := value[:len("2006-01-02")] + if _, err := time.Parse("2006-01-02", date); err != nil { + return "" + } + return date +} + +func nextEvidenceDate(value string) string { + if value == "" { + return "" + } + date, err := time.Parse("2006-01-02", value) + if err != nil { + return "" + } + return date.AddDate(0, 0, 1).Format("2006-01-02") +} + +func buildHash(page string, keyword string, protocol string, filters map[string]string) string { + values := url.Values{} + if keyword != "" { + values.Set("keyword", keyword) + } + if protocol != "" { + values.Set("protocol", protocol) + } + for key, value := range filters { + if strings.TrimSpace(value) != "" { + values.Set(key, value) + } + } + query := values.Encode() + if query == "" { + return "#/" + page + } + return "#/" + page + "?" + query +} + +func qualityNotificationText(row QualityPriorityIssue) string { + return strings.Join([]string{ + "【" + row.Priority + " 告警通知】" + qualityIssueTitle(row.IssueType), + "车辆:" + row.VehicleLabel, + "数据来源:" + row.Protocol, + "问题:" + qualityIssueTitle(row.IssueType), + "建议动作:" + row.ActionLabel, + "SLA:" + row.SLA, + "最后时间:" + firstNonEmpty(row.LastSeen, "-"), + "详情:" + firstNonEmpty(row.Detail, "-"), + "实时定位:" + row.RealtimeHash, + "轨迹证据:" + row.HistoryHash, + "RAW证据:" + row.RawHash, + "车辆服务:" + row.VehicleHash, + }, "\n") +} + +func qualityIssueTitle(issueType string) string { + for _, item := range qualityAlertRuleTemplates { + if item.IssueType == issueType { + return item.Title + } + } + if issueType == "CAPACITY_RISK" { + return "容量与存储风险" + } + return issueType +} + +func cloneStringMap(values map[string]string) map[string]string { + out := make(map[string]string, len(values)) + for key, value := range values { + out[key] = value + } + return out +} + func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) { resolved := cloneValues(query) vin := firstNonEmpty(resolved.Get("vin"), resolved.Get("keyword")) diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index ede961c9..77020cea 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -172,6 +172,30 @@ test('vehicleServiceSummary reads the vehicle service summary endpoint', async ( expect(result.serviceStatuses.find((item) => item.status === 'no_data')?.count).toBe(461); }); +test('qualityNotificationPlan reads alert rules and priority issues from backend', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + summary: { issueVehicleCount: 1, issueRecordCount: 1, errorCount: 1, warningCount: 0, protocols: [], issueTypes: [] }, + rules: [{ issueType: 'NO_SOURCE', title: '无数据来源', level: 'P0', owner: '平台接入', trigger: '无来源', notify: '立即通知', sla: '30 分钟确认', count: 1 }], + policies: [{ name: 'P0 实时中断', target: '接入运维', channel: '邮件', condition: '无来源' }], + priorityIssues: [{ vin: 'VIN001', plate: '粤A001', phone: '', sourceEndpoint: '', protocol: 'VEHICLE_SERVICE', issueType: 'NO_SOURCE', severity: 'error', lastSeen: '', detail: '无来源', priority: 'P0', actionLabel: '确认平台转发', actionDetail: '确认平台转发', sla: '30 分钟确认', vehicleLabel: '粤A001 / VIN001', realtimeHash: '#/realtime?keyword=VIN001', historyHash: '#/history?keyword=VIN001', rawHash: '#/history?keyword=VIN001&tab=raw', vehicleHash: '#/detail?keyword=VIN001', notificationText: '【P0 告警通知】无数据来源' }], + activeRuleCount: 1, + p0RuleCount: 1 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response); + + const result = await api.qualityNotificationPlan(new URLSearchParams({ issueType: 'NO_SOURCE' })); + + expect(fetchMock).toHaveBeenCalledWith('/api/quality/notification-plan?issueType=NO_SOURCE', undefined); + expect(result.rules[0].level).toBe('P0'); + expect(result.priorityIssues[0].notificationText).toContain('告警通知'); +}); + test('api errors include backend message, detail, and trace id', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index b8265472..33146ab4 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -5,6 +5,7 @@ import type { HistoryLocationRow, MileageSummary, OpsHealth, + QualityNotificationPlan, Page, QualitySummary, QualityIssueRow, @@ -106,5 +107,6 @@ export const api = { 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()}`), + qualityNotificationPlan: (params = new URLSearchParams()) => request(`/api/quality/notification-plan?${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 8741dab8..b4fa8407 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -281,6 +281,46 @@ export interface QualityBucketStat { count: number; } +export interface QualityNotificationPlan { + summary: QualitySummary; + rules: QualityAlertRule[]; + policies: QualityNotificationPolicy[]; + priorityIssues: QualityPriorityIssue[]; + activeRuleCount: number; + p0RuleCount: number; +} + +export interface QualityAlertRule { + issueType: string; + title: string; + level: string; + owner: string; + trigger: string; + notify: string; + sla: string; + count: number; +} + +export interface QualityNotificationPolicy { + name: string; + target: string; + channel: string; + condition: string; +} + +export interface QualityPriorityIssue extends QualityIssueRow { + priority: 'P0' | 'P1'; + actionLabel: string; + actionDetail: string; + sla: string; + vehicleLabel: string; + realtimeHash: string; + historyHash: string; + rawHash: string; + vehicleHash: string; + notificationText: string; +} + 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 e4b30e37..da0e098a 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -2,7 +2,7 @@ import { Button, Card, Col, Form, Row, Select, Space, Table, Tag, Toast } from ' import { IconCopy } from '@douyinfe/semi-icons'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types'; +import type { OpsHealth, QualityAlertRule, QualityNotificationPlan, QualityNotificationPolicy, QualitySummary, QualityIssueRow, QualityPriorityIssue } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { buildAppHash } from '../domain/appRoute'; import { qualityIssueLabel, qualityIssueOptions, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue'; @@ -95,6 +95,7 @@ const notificationPolicies = [ type AlertRuleRow = { issueType: string; + title?: string; level: string; owner: string; trigger: string; @@ -106,8 +107,14 @@ type AlertRuleRow = { type PriorityIssueRow = QualityIssueRow & { priority: 'P0' | 'P1'; actionLabel: string; + actionDetail?: string; sla: string; vehicleLabel: string; + realtimeHash?: string; + historyHash?: string; + rawHash?: string; + vehicleHash?: string; + notificationText?: string; }; function qualityParams(values: Record) { @@ -293,6 +300,21 @@ function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 're return 'grey'; } +function normalizeAlertRules(rows?: QualityAlertRule[]): AlertRuleRow[] | null { + if (!Array.isArray(rows)) return null; + return rows; +} + +function normalizeNotificationPolicies(rows?: QualityNotificationPolicy[]): QualityNotificationPolicy[] | null { + if (!Array.isArray(rows)) return null; + return rows; +} + +function normalizePriorityIssues(rows?: QualityPriorityIssue[]): PriorityIssueRow[] | null { + if (!Array.isArray(rows)) return null; + return rows; +} + async function copyText(value: string, label: string) { const text = value.trim(); if (!text) { @@ -333,16 +355,18 @@ export function Quality({ const [issues, setIssues] = useState([]); const [summary, setSummary] = useState(emptySummary); const [health, setHealth] = useState(null); + const [notificationPlan, setNotificationPlan] = useState(null); const [loadingIssues, setLoadingIssues] = useState(true); const [loadingSummary, setLoadingSummary] = useState(true); const [loadingHealth, setLoadingHealth] = useState(true); const [filters, setFilters] = useState>(initialFilters); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); const primaryIssueType = summary.issueTypes[0]?.name; - const rules = alertRuleRows(summary, health); - const activeRuleCount = rules.filter((item) => item.count > 0).length; - const p0RuleCount = rules.filter((item) => item.count > 0 && item.level === 'P0').length; - const priorityRows = priorityIssueRows(issues); + const rules = normalizeAlertRules(notificationPlan?.rules) ?? alertRuleRows(summary, health); + const policies = normalizeNotificationPolicies(notificationPlan?.policies) ?? notificationPolicies; + const activeRuleCount = notificationPlan?.activeRuleCount ?? rules.filter((item) => item.count > 0).length; + const p0RuleCount = notificationPlan?.p0RuleCount ?? rules.filter((item) => item.count > 0 && item.level === 'P0').length; + const priorityRows = normalizePriorityIssues(notificationPlan?.priorityIssues) ?? priorityIssueRows(issues); const loadIssues = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoadingIssues(true); @@ -376,12 +400,26 @@ export function Quality({ .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoadingHealth(false)); }; + const loadNotificationPlan = (values: Record = filters, pageSize = pagination.pageSize) => { + const params = qualityParams(values); + params.set('limit', String(pageSize)); + api.qualityNotificationPlan(params) + .then((plan) => { + if (Array.isArray(plan.rules) && Array.isArray(plan.policies) && Array.isArray(plan.priorityIssues)) { + setNotificationPlan(plan); + } + }) + .catch(() => { + setNotificationPlan(null); + }); + }; useEffect(() => { setFilters(initialFilters); loadSummary(initialFilters); loadIssues(initialFilters, 1, pagination.pageSize); loadHealth(); + loadNotificationPlan(initialFilters, pagination.pageSize); }, [JSON.stringify(initialFilters)]); const applyFilters = (nextFilters: Record) => { @@ -389,6 +427,7 @@ export function Quality({ onFiltersChange?.(nextFilters); loadSummary(nextFilters); loadIssues(nextFilters, 1, pagination.pageSize); + loadNotificationPlan(nextFilters, pagination.pageSize); }; const drillPrimaryIssue = () => { @@ -454,7 +493,7 @@ export function Quality({ Toast.warning('当前没有可复制的优先告警'); return; } - copyText(priorityIssueDigestText(priorityRows, summary), '优先队列通知汇总'); + copyText(priorityIssueDigestText(priorityRows, notificationPlan?.summary ?? summary), '优先队列通知汇总'); }; return ( @@ -551,7 +590,7 @@ export function Quality({ - + ); @@ -567,7 +606,7 @@ export function Quality({ dataSource={rules} rowKey="issueType" columns={[ - { title: '规则', render: (_: unknown, row: AlertRuleRow) => row.issueType === 'CAPACITY_RISK' ? '容量与存储风险' : qualityIssueLabel(row.issueType) }, + { title: '规则', render: (_: unknown, row: AlertRuleRow) => row.title || (row.issueType === 'CAPACITY_RISK' ? '容量与存储风险' : qualityIssueLabel(row.issueType)) }, { title: '级别', width: 90, render: (_: unknown, row: AlertRuleRow) => {row.level} }, { title: '当前命中', width: 110, render: (_: unknown, row: AlertRuleRow) => row.count.toLocaleString() }, { title: '触发条件', dataIndex: 'trigger' }, @@ -577,7 +616,7 @@ export function Quality({
- {notificationPolicies.map((item) => ( + {policies.map((item) => (
diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 878ba7d1..fdb9ae30 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -2543,6 +2543,97 @@ test('shows actionable priority queue on quality page', async () => { }); }); +test('uses backend quality notification plan on quality page', async () => { + window.history.replaceState(null, '', '/#/quality'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { linkHealth: [], kafkaLag: 0, activeConnections: 120000, capacityFindings: [], redisOnlineKeys: 368, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/notification-plan')) { + return { + ok: true, + json: async () => ({ + data: { + summary: { issueVehicleCount: 9, issueRecordCount: 11, errorCount: 2, warningCount: 9, protocols: [], issueTypes: [{ name: 'NO_SOURCE', count: 4 }] }, + rules: [{ issueType: 'NO_SOURCE', title: '后端无来源规则', level: 'P0', owner: '平台接入', trigger: '后端统一触发', notify: '后端统一通知', sla: '10 分钟确认', count: 4 }], + policies: [{ name: '后端 P0 通知策略', target: '接入运维', channel: '邮件 / 企业微信', condition: '后端统一条件' }], + priorityIssues: [{ + vin: 'VIN-PLAN-001', + plate: '粤A计划1', + phone: '', + sourceEndpoint: 'vehicle_identity_binding', + protocol: 'VEHICLE_SERVICE', + issueType: 'NO_SOURCE', + severity: 'error', + lastSeen: '2026-07-03 20:12:10', + detail: '后端计划生成的优先告警', + priority: 'P0', + actionLabel: '后端建议动作', + actionDetail: '后端建议说明', + sla: '10 分钟确认', + vehicleLabel: '后端车辆标签 / VIN-PLAN-001', + realtimeHash: '#/realtime?keyword=VIN-PLAN-001', + historyHash: '#/history?keyword=VIN-PLAN-001', + rawHash: '#/history?keyword=VIN-PLAN-001&tab=raw', + vehicleHash: '#/detail?keyword=VIN-PLAN-001', + notificationText: '【P0 告警通知】后端计划' + }], + activeRuleCount: 1, + p0RuleCount: 1 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/summary')) { + return { + ok: true, + json: async () => ({ + data: { issueVehicleCount: 1, issueRecordCount: 1, errorCount: 1, warningCount: 0, protocols: [], issueTypes: [] }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/quality/issues')) { + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('后端车辆标签 / VIN-PLAN-001')).toBeInTheDocument(); + expect(screen.getByText('后端建议动作')).toBeInTheDocument(); + expect(screen.getAllByText('10 分钟确认').length).toBeGreaterThan(0); + expect(screen.getByText('后端无来源规则')).toBeInTheDocument(); + expect(screen.getByText('后端 P0 通知策略')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/notification-plan?limit=20'), undefined); +}); + test('copies notification text from quality priority queue', async () => { window.history.replaceState(null, '', '/#/quality'); const writeText = vi.fn(() => Promise.resolve());