From ee257b4aa1dde68e8f1b84a927556378a956b983 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 5 Jul 2026 14:31:32 +0800 Subject: [PATCH] feat(platform): add notification reachability checks --- .../apps/web/src/pages/NotificationRules.tsx | 171 ++++++++++++++++++ .../apps/web/src/styles/global.css | 43 +++++ .../apps/web/src/test/App.test.tsx | 15 ++ 3 files changed, 229 insertions(+) diff --git a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx index 79abfb2d..c771009c 100644 --- a/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx +++ b/vehicle-data-platform/apps/web/src/pages/NotificationRules.tsx @@ -77,6 +77,22 @@ type NotificationCoverageOwnerRow = { primaryPolicy?: QualityNotificationPolicy; }; +type NotificationReachabilityRow = { + owner: string; + issueCount: number; + p0IssueCount: number; + ruleCount: number; + channel: string; + target: string; + escalation: string; + acceptance: string; + evidenceComplete: number; + status: 'ready' | 'risk' | 'blocked'; + statusLabel: string; + statusColor: 'green' | 'orange' | 'red'; + nextAction: string; +}; + const notificationExportColumns: CsvColumn[] = [ { title: '类型', value: (row) => row.category }, { title: '名称', value: (row) => row.name }, @@ -270,6 +286,112 @@ function coverageOwnerRows(rules: QualityAlertRule[], policies: QualityNotificat }).sort((left, right) => right.hitCount - left.hitCount || right.p0Rules - left.p0Rules || left.owner.localeCompare(right.owner)); } +function notificationReachabilityRows( + issues: QualityPriorityIssue[], + rules: QualityAlertRule[], + policies: QualityNotificationPolicy[], + slaRows: SlaEscalationRow[] +): NotificationReachabilityRow[] { + const slaByIssue = new Map(slaRows.map((row) => [ + `${row.issue.priority}-${row.issue.protocol}-${row.issue.issueType}-${row.issue.vehicleLabel}-${row.issue.lastSeen}`, + row + ])); + const ownerMap = new Map(); + + for (const rule of rules) { + if (Number(rule.count ?? 0) <= 0) continue; + const owner = rule.owner?.trim() || '未分配责任方'; + const current = ownerMap.get(owner) ?? { issues: [], rules: [], overdue: 0, dueSoon: 0 }; + current.rules.push(rule); + ownerMap.set(owner, current); + } + + for (const issue of issues) { + const rule = rules.find((item) => item.issueType === issue.issueType); + const owner = rule?.owner?.trim() || policyForPriority(issue, policies)?.target || '未分配责任方'; + const current = ownerMap.get(owner) ?? { issues: [], rules: [], overdue: 0, dueSoon: 0 }; + current.issues.push(issue); + const sla = slaByIssue.get(`${issue.priority}-${issue.protocol}-${issue.issueType}-${issue.vehicleLabel}-${issue.lastSeen}`); + if (sla?.status === 'overdue') current.overdue += 1; + if (sla?.status === 'due_soon') current.dueSoon += 1; + if (!current.policy) current.policy = policyForPriority(issue, policies); + ownerMap.set(owner, current); + } + + return Array.from(ownerMap.entries()).map(([owner, value]) => { + const p0IssueCount = value.issues.filter((issue) => issue.priority === 'P0').length; + const evidenceComplete = value.issues.filter((issue) => countEvidenceLinks(issue) >= 4).length; + const p0Rules = value.rules.filter((rule) => rule.level === 'P0').length; + const policy = value.policy + ?? policies.find((item) => policyLevel(item) === (p0IssueCount > 0 || p0Rules > 0 ? 'P0' : 'P1')) + ?? policies[0]; + const hasPolicy = Boolean(policy?.target && policy.channel); + const hasAcceptance = Boolean(policy?.acceptanceCriteria); + const evidenceMissing = value.issues.length > 0 && evidenceComplete < value.issues.length; + const blocked = !hasPolicy || value.overdue > 0; + const risk = !blocked && (value.dueSoon > 0 || evidenceMissing || !hasAcceptance || p0IssueCount > 0); + const status: NotificationReachabilityRow['status'] = blocked ? 'blocked' : risk ? 'risk' : 'ready'; + const statusColor: NotificationReachabilityRow['statusColor'] = blocked ? 'red' : risk ? 'orange' : 'green'; + return { + owner, + issueCount: value.issues.length, + p0IssueCount, + ruleCount: value.rules.length, + channel: policy?.channel || '-', + target: policy?.target || '-', + escalation: policy ? formatEscalationMinutes(policy.escalationMinutes) : '-', + acceptance: policy?.acceptanceCriteria || '-', + evidenceComplete, + status, + statusLabel: blocked ? (value.overdue > 0 ? '已超时' : '策略缺失') : risk ? '需跟进' : '可触达', + statusColor, + nextAction: blocked + ? (value.overdue > 0 ? '立即升级责任人并同步业务侧' : '补齐通知对象与渠道') + : evidenceMissing + ? '补齐车辆服务、实时、轨迹、原始记录证据' + : value.dueSoon > 0 + ? '临近升级,先确认恢复进度' + : p0IssueCount > 0 + ? '先发送P0通知并记录回执' + : '按策略持续观察并复核验收' + }; + }).sort((left, right) => { + const statusWeight: Record = { blocked: 3, risk: 2, ready: 1 }; + return statusWeight[right.status] - statusWeight[left.status] + || right.p0IssueCount - left.p0IssueCount + || right.issueCount - left.issueCount + || left.owner.localeCompare(right.owner); + }); +} + +function notificationReachabilityReport(rows: NotificationReachabilityRow[], release?: string) { + const blocked = rows.filter((row) => row.status === 'blocked').length; + const risk = rows.filter((row) => row.status === 'risk').length; + const ready = rows.filter((row) => row.status === 'ready').length; + return [ + '【通知可达性检查】', + `运行版本:${release?.trim() || '-'}`, + `可达结论:阻断 ${blocked.toLocaleString()} / 需跟进 ${risk.toLocaleString()} / 可触达 ${ready.toLocaleString()}`, + '', + ...(rows.length > 0 ? rows.slice(0, 10).map((row, index) => [ + `${index + 1}. ${row.owner} / ${row.statusLabel}`, + ` 待通知:${row.issueCount.toLocaleString()} 条,P0 ${row.p0IssueCount.toLocaleString()} 条,活跃规则 ${row.ruleCount.toLocaleString()} 类`, + ` 通知对象:${row.target}`, + ` 渠道:${row.channel}`, + ` 升级:${row.escalation}`, + ` 证据:${row.evidenceComplete.toLocaleString()}/${row.issueCount.toLocaleString()} 完整`, + ` 验收:${row.acceptance}`, + ` 下一步:${row.nextAction}` + ].join('\n')) : ['暂无待通知责任方']), + '', + '检查口径:', + '1. 必须有责任方、通知对象和渠道', + '2. P0 和超时告警优先进入升级队列', + '3. 通知前至少带上车辆服务、实时、轨迹和原始记录证据', + '4. 恢复后按验收口径复核,确认告警不再命中' + ].join('\n'); +} + function notificationCoverageReport({ plan, release, @@ -549,6 +671,9 @@ export function NotificationRules() { const nextSlaRow = slaRows[0]; const executionRows = notificationExecutionRows(priorityIssues, rules, policies); const ownerRows = coverageOwnerRows(rules, policies); + const reachabilityRows = notificationReachabilityRows(priorityIssues, rules, policies, slaRows); + const reachabilityBlockedCount = reachabilityRows.filter((row) => row.status === 'blocked').length; + const reachabilityRiskCount = reachabilityRows.filter((row) => row.status === 'risk').length; const totalActiveHits = ownerRows.reduce((total, row) => total + row.hitCount, 0); const unhealthyLinkCount = (health?.linkHealth ?? []).filter((item) => item.status !== 'ok').length; const notificationCustomerSteps = [ @@ -731,6 +856,52 @@ export function NotificationRules() { + 通知可达性检查} + style={{ marginTop: 16 }} + > +
+
+ + 0 ? 'red' : reachabilityRiskCount > 0 ? 'orange' : 'green'}> + {reachabilityBlockedCount > 0 ? '存在阻断' : reachabilityRiskCount > 0 ? '需要跟进' : '可触达'} + + {reachabilityRows.length.toLocaleString()} 个责任方 + + + 阻断 {reachabilityBlockedCount.toLocaleString()} / 风险 {reachabilityRiskCount.toLocaleString()} + + + 按责任方检查通知对象、渠道、升级窗口、证据完整度和验收口径,避免告警已经触发但无人收到或无法闭环。 + +
+
+ {reachabilityRows.length > 0 ? reachabilityRows.slice(0, 6).map((row) => ( +
+ + {row.statusLabel} + 0 ? 'red' : 'blue'}>{row.owner} + + {row.issueCount.toLocaleString()} 条待通知 + + 对象:{row.target};渠道:{row.channel};升级:{row.escalation} + + + 证据 {row.evidenceComplete}/{row.issueCount} + {row.acceptance === '-' ? '缺验收' : '有验收'} + 0 ? 'red' : 'grey'}>P0 {row.p0IssueCount} + + {row.nextAction} +
+ )) : ( + 暂无待检查通知责任方 + )} +
+
+
+ { expect(screen.getByText('升级风险')).toBeInTheDocument(); expect(screen.getByText('链路前置')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '复制通知覆盖报告' })).toBeInTheDocument(); + expect(screen.getByText('通知可达性检查')).toBeInTheDocument(); + expect(screen.getByText('避免告警已经触发但无人收到或无法闭环。', { exact: false })).toBeInTheDocument(); + expect(screen.getByText('阻断 1 / 风险 0')).toBeInTheDocument(); + expect(screen.getAllByText('已超时').length).toBeGreaterThan(0); + expect(screen.getAllByText('1 条待通知').length).toBeGreaterThan(0); + expect(screen.getAllByText('对象:接入运维 + 业务责任人;渠道:邮件 / 企业微信;升级:30 分钟升级').length).toBeGreaterThan(0); + expect(screen.getByText('立即升级责任人并同步业务侧')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '复制通知可达性检查' })).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=50'), undefined); fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' })); @@ -4099,6 +4107,13 @@ test('renders notification rules as a standalone operations page', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('通知渠道:邮件 / 企业微信;每日汇总邮件')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('平台接入')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('主策略:P0 实时中断 / 接入运维 + 业务责任人 / 邮件 / 企业微信')); + fireEvent.click(screen.getByRole('button', { name: '复制通知可达性检查' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知可达性检查】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('可达结论:阻断 1 / 需跟进 0 / 可触达 1')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('平台接入 / 已超时')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('渠道:邮件 / 企业微信')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('证据:1/1 完整')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('下一步:立即升级责任人并同步业务侧')); fireEvent.click(screen.getByRole('button', { name: '复制SLA升级报告' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【告警SLA升级报告】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('升级态势:已超时 1 / 即将升级 0 / 正常跟进 0'));