From 47c3cdc015aef481dca0f67d1ee37a5286be7b9d Mon Sep 17 00:00:00 2001 From: chuanxu742 Date: Wed, 26 Aug 2026 15:57:04 +0100 Subject: [PATCH] feat(notifications): expose delivery and ack evidence --- frontend/app/(app)/notifications/page.tsx | 440 +++++++++++++++--- frontend/lib/api/endpoints.ts | 11 +- frontend/lib/api/hooks.ts | 32 +- frontend/lib/notifications/delivery-status.ts | 120 +++++ ...heck-notification-evidence-regressions.mjs | 52 +++ .../notification-delivery-status.test.mjs | 85 ++++ 6 files changed, 665 insertions(+), 75 deletions(-) create mode 100644 frontend/lib/notifications/delivery-status.ts create mode 100644 frontend/scripts/check-notification-evidence-regressions.mjs create mode 100644 frontend/scripts/notification-delivery-status.test.mjs diff --git a/frontend/app/(app)/notifications/page.tsx b/frontend/app/(app)/notifications/page.tsx index 6c67cdda..b3c06309 100644 --- a/frontend/app/(app)/notifications/page.tsx +++ b/frontend/app/(app)/notifications/page.tsx @@ -1,12 +1,26 @@ 'use client' -import { useState } from 'react' -import { Pencil, Plus, Trash2 } from 'lucide-react' +import { Fragment, useMemo, useState } from 'react' +import { Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react' import { toast } from 'sonner' -import { useDeleteNotificationRule, useNotificationRules } from '@/lib/api/hooks' -import type { NotificationRule } from '@/lib/api/types' +import { + useDeleteNotificationRule, + useInfiniteNotificationLogs, + useInfiniteNotificationRules, + useNotificationRulesByIds, +} from '@/lib/api/hooks' +import type { NotificationLog, NotificationRule } from '@/lib/api/types' +import { formatDateTime, formatRelative } from '@/lib/format' import { notificationChannelLabel } from '@/lib/notification-channels' +import { + acknowledgementStatusPresentation, + dedupeDeliveryAttempts, + sanitizedDeliveryErrorSummary, + transportStatusPresentation, + type DeliveryStatusPresentation, +} from '@/lib/notifications/delivery-status' +import { cn } from '@/lib/utils' import { NotificationRuleFormDialog } from '@/components/notifications/notification-rule-form-dialog' import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components/shell/data-states' import { PageContainer } from '@/components/shell/page-container' @@ -14,7 +28,15 @@ import { ACTION_CENTER_TABS, RouteTabs } from '@/components/shell/route-tabs' import { StatusBadge } from '@/components/shell/status-badge' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' import { Table, TableBody, @@ -24,9 +46,30 @@ import { TableRow, } from '@/components/ui/table' -export default function NotificationsPage() { - const { data, isLoading, isError, error } = useNotificationRules() - const rules = data?.data ?? [] +const DELIVERY_PAGE_SIZE = 25 +const RULE_PAGE_SIZE = 50 + +function DeliveryStatusBadge({ presentation }: { presentation: DeliveryStatusPresentation }) { + return ( + + {presentation.label} + + ) +} + +function RuleTable({ rules }: { rules: NotificationRule[] }) { const [confirmDeleteId, setConfirmDeleteId] = useState(null) const deleteMutation = useDeleteNotificationRule() @@ -44,11 +87,181 @@ export default function NotificationsPage() { }) } + return ( + + + + 名称 + 触发事件 + 通知方式 + 状态 + 操作 + + + + {rules.map((rule) => ( + + + {rule.name} + + + {rule.trigger_event} + + + + {notificationChannelLabel(rule.notifier_type)} + + + + + +
+ } + triggerVariant="ghost" + triggerSize="icon-sm" + triggerAriaLabel="编辑规则" + /> + +
+
+
+ {confirmDeleteId === rule.id ? ( + + +

+ 再次点击删除将永久删除该规则及其全部投递证据,此操作不可恢复。 +

+
+
+ ) : null} +
+ ))} +
+
+ ) +} + +function DeliveryEvidenceTable({ + logs, + rulesById, +}: { + logs: NotificationLog[] + rulesById: Map +}) { + return ( + + + + 通知规则 + 技术提交 + 业务回执 + 关联记录 + 发生时间 + 错误摘要 + + + + {logs.map((log) => { + const rule = rulesById.get(log.rule_id) + const errorSummary = + sanitizedDeliveryErrorSummary(log.error_message) || + (log.ack_status === 'failed' ? '业务回执失败' : null) + return ( + + +
{rule?.name ?? `规则 ${log.rule_id.slice(0, 8)}`}
+
+ {rule ? notificationChannelLabel(rule.notifier_type) : log.rule_id.slice(0, 8)} +
+
+ + + + +
+ + {log.acked_at ? ( + + {formatRelative(log.acked_at)} + + ) : null} +
+
+ + {log.record_id ? ( + + {log.record_id.slice(0, 8)} + + ) : ( + + )} + + + {formatRelative(log.created_at)} + + + {errorSummary ? ( + + {errorSummary} + + ) : ( + + )} + +
+ ) + })} +
+
+ ) +} + +export default function NotificationsPage() { + const rulesQuery = useInfiniteNotificationRules({ limit: RULE_PAGE_SIZE }) + const logsQuery = useInfiniteNotificationLogs({ limit: DELIVERY_PAGE_SIZE }) + const rules = useMemo( + () => dedupeDeliveryAttempts(rulesQuery.data?.pages.flatMap((page) => page.data) ?? []), + [rulesQuery.data], + ) + const logs = useMemo( + () => dedupeDeliveryAttempts(logsQuery.data?.pages.flatMap((page) => page.data) ?? []), + [logsQuery.data], + ) + const loadedRulesById = useMemo(() => new Map(rules.map((rule) => [rule.id, rule])), [rules]) + const missingRuleIds = useMemo( + () => [...new Set(logs.map((log) => log.rule_id))].filter((id) => !loadedRulesById.has(id)), + [loadedRulesById, logs], + ) + const ruleLookups = useNotificationRulesByIds(missingRuleIds) + const rulesById = new Map(loadedRulesById) + ruleLookups.forEach((query) => { + if (query.data) rulesById.set(query.data.id, query.data) + }) + const totalRules = Math.max(rulesQuery.data?.pages[0]?.meta?.total ?? 0, rules.length) + const totalLogs = Math.max(logsQuery.data?.pages[0]?.meta?.total ?? 0, logs.length) + return ( } actions={ } > - {isLoading ? ( - - ) : isError ? ( - - ) : rules.length === 0 ? ( - - ) : ( - - - - - 名称 - 触发事件 - 通知方式 - 状态 - 操作 - - - - {rules.map((r) => ( - - {r.name} - - - {r.trigger_event} - - - - - {notificationChannelLabel(r.notifier_type)} - - - - - - -
- } - triggerVariant="ghost" - triggerSize="icon-sm" - triggerAriaLabel="编辑规则" - /> - -
-
-
- ))} -
-
+
+
+

+ 通知规则 +

+

配置采集事件触发的通知方式。

+
+ {rulesQuery.isLoading && rules.length === 0 ? ( + + ) : rulesQuery.isError && rules.length === 0 ? ( + rulesQuery.refetch()}> + 重试 + + } + /> + ) : rules.length === 0 ? ( + + ) : ( + +
+ +
+ + {rulesQuery.isError ? ( +
+ 更新规则列表失败,已加载的规则仍保留。 + +
+ ) : null} +
+ 已显示 {rules.length} / {totalRules} 条规则 + {rulesQuery.hasNextPage ? ( + + ) : null} +
+
+
+ )} +
+ +
+ + +
+ 投递证据 + + 技术提交与业务回执分开显示;仅展示脱敏状态、关联标识和错误摘要。 + +
+ + + +
+ + {logsQuery.isLoading && logs.length === 0 ? ( + + ) : logsQuery.isError && logs.length === 0 ? ( + logsQuery.refetch()}> + 重试 + + } + /> + ) : logs.length === 0 ? ( + + ) : ( + <> + {logsQuery.isError ? ( +
+ 更新投递证据失败,已加载的记录仍保留。 + +
+ ) : null} +
+ +
+
+ 已显示 {logs.length} / {totalLogs} 条 + {logsQuery.hasNextPage ? ( + + ) : null} +
+ + )} +
- )} +
) } diff --git a/frontend/lib/api/endpoints.ts b/frontend/lib/api/endpoints.ts index fe0b8dd0..e956a95b 100644 --- a/frontend/lib/api/endpoints.ts +++ b/frontend/lib/api/endpoints.ts @@ -536,8 +536,15 @@ export const deleteSchedule = (id: string) => apiClient.delete>(`/schedules/${id}`).then((r) => r.data) // ── Notifications ────────────────────────────────────────────────────────────── -export const listNotificationRules = () => - apiClient.get>('/notifications/rules').then((r) => r.data) +export const listNotificationRules = (params?: { page?: number; limit?: number }) => + apiClient + .get>('/notifications/rules', { params }) + .then((r) => r.data) + +export const getNotificationRule = (id: string) => + apiClient + .get>(`/notifications/rules/${id}`) + .then((r) => r.data.data) export const createNotificationRule = (data: NotificationRuleInput) => apiClient diff --git a/frontend/lib/api/hooks.ts b/frontend/lib/api/hooks.ts index fbaede06..8098043f 100644 --- a/frontend/lib/api/hooks.ts +++ b/frontend/lib/api/hooks.ts @@ -1,6 +1,6 @@ 'use client' -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query' import * as api from './endpoints' import type { @@ -763,6 +763,29 @@ export function useNotificationRules() { }) } +export function useInfiniteNotificationRules(params?: { limit?: number }) { + return useInfiniteQuery({ + queryKey: ['notification-rules', 'infinite', params], + initialPageParam: 1, + queryFn: ({ pageParam }) => api.listNotificationRules({ ...params, page: pageParam }), + getNextPageParam: (lastPage) => { + const meta = lastPage.meta + return meta && meta.page < meta.pages ? meta.page + 1 : undefined + }, + }) +} + +export function useNotificationRulesByIds(ids: string[]) { + return useQueries({ + queries: ids.map((id) => ({ + queryKey: ['notification-rules', id], + queryFn: () => api.getNotificationRule(id), + staleTime: 30_000, + retry: false, + })), + }) +} + export function useCreateNotificationRule() { const queryClient = useQueryClient() return useMutation({ @@ -784,7 +807,12 @@ export function useDeleteNotificationRule() { const queryClient = useQueryClient() return useMutation({ mutationFn: (id: string) => api.deleteNotificationRule(id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notification-rules'] }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['notification-rules'] }), + queryClient.invalidateQueries({ queryKey: ['notification-logs'] }), + ]) + }, }) } diff --git a/frontend/lib/notifications/delivery-status.ts b/frontend/lib/notifications/delivery-status.ts new file mode 100644 index 00000000..0af55c93 --- /dev/null +++ b/frontend/lib/notifications/delivery-status.ts @@ -0,0 +1,120 @@ +export type DeliveryStatusTone = 'informative' | 'positive' | 'warning' | 'negative' | 'neutral' + +export interface DeliveryStatusPresentation { + label: string + description: string + tone: DeliveryStatusTone +} + +export function sanitizedDeliveryErrorSummary(error?: string | null): string | null { + if (!error) return null + const normalized = error.toLowerCase() + if (/timeout|timed out/.test(normalized)) return '通知通道连接超时' + if (/certificate|\btls\b|\bssl\b/.test(normalized)) return '通知通道安全校验失败' + if (/name resolution|dns|resolve host/.test(normalized)) return '无法解析通知通道地址' + if (/connect|connection/.test(normalized)) return '无法连接通知通道' + return '通知处理失败(详细错误已隐藏)' +} + +export function dedupeDeliveryAttempts(items: readonly T[]): T[] { + const seen = new Set() + return items.filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return true + }) +} + +export function transportStatusPresentation(status: string): DeliveryStatusPresentation { + switch (status.toLowerCase()) { + case 'sent': + case 'success': + case 'completed': + return { + label: '已提交', + description: '通知请求已被通道接受,不代表业务方已确认。', + tone: 'informative', + } + case 'pending': + case 'queued': + return { + label: '等待提交', + description: '通知请求仍在等待通道处理。', + tone: 'warning', + } + case 'failed': + case 'error': + return { + label: '提交失败', + description: '通知请求未成功提交到通道。', + tone: 'negative', + } + default: + return { + label: '状态未知', + description: '后端返回了当前控制台无法识别的提交状态。', + tone: 'neutral', + } + } +} + +export function acknowledgementStatusPresentation( + status: string, + transportStatus?: string, +): DeliveryStatusPresentation { + const normalizedStatus = status.toLowerCase() + const normalizedTransport = transportStatus?.toLowerCase() + if ( + normalizedStatus === 'not_required' && + (normalizedTransport === 'pending' || normalizedTransport === 'queued') + ) { + return { + label: '待提交后确定', + description: '通知尚未提交,回执要求和状态将在提交后确定。', + tone: 'neutral', + } + } + if ( + normalizedStatus === 'not_required' && + (normalizedTransport === 'failed' || normalizedTransport === 'error') + ) { + return { + label: '未进入回执', + description: '通知提交失败,因此没有进入业务回执阶段。', + tone: 'neutral', + } + } + + switch (normalizedStatus) { + case 'acked': + return { + label: '已确认', + description: '业务方已返回有效确认。', + tone: 'positive', + } + case 'pending': + return { + label: '等待回执', + description: '通知已提交,仍在等待业务方确认。', + tone: 'warning', + } + case 'failed': + return { + label: '回执失败', + description: '业务方回执未通过验证或明确失败。', + tone: 'negative', + } + case 'not_required': + return { + label: '无需回执', + description: '该通知通道未配置业务回执。', + tone: 'neutral', + } + default: + return { + label: '状态未知', + description: '后端返回了当前控制台无法识别的回执状态。', + tone: 'neutral', + } + } +} diff --git a/frontend/scripts/check-notification-evidence-regressions.mjs b/frontend/scripts/check-notification-evidence-regressions.mjs new file mode 100644 index 00000000..112c8bf7 --- /dev/null +++ b/frontend/scripts/check-notification-evidence-regressions.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const page = fs.readFileSync(path.join(root, 'app/(app)/notifications/page.tsx'), 'utf8') +const hooks = fs.readFileSync(path.join(root, 'lib/api/hooks.ts'), 'utf8') +const endpoints = fs.readFileSync(path.join(root, 'lib/api/endpoints.ts'), 'utf8') + +test('notification center loads paginated delivery evidence', () => { + assert.match(page, /useInfiniteNotificationLogs\(\{ limit: DELIVERY_PAGE_SIZE \}\)/) + assert.match(page, /fetchNextPage\(\)/) + assert.match(page, /已显示 \{logs\.length\} \/ \{totalLogs\} 条/) +}) + +test('rules and evidence expose independent recovery paths', () => { + assert.match(page, /rulesQuery\.isError/) + assert.match(page, /logsQuery\.isError/) + assert.match(page, /logsQuery\.isError && logs\.length === 0/) + assert.match(page, /更新投递证据失败,已加载的记录仍保留/) + assert.match(page, /logsQuery\.refetch\(\)/) +}) + +test('rule management paginates and missing log rules resolve by exact id', () => { + assert.match(page, /useInfiniteNotificationRules\(\{ limit: RULE_PAGE_SIZE \}\)/) + assert.match(page, /useNotificationRulesByIds\(missingRuleIds\)/) + assert.match(hooks, /api\.getNotificationRule\(id\)/) + assert.match(endpoints, /`\/notifications\/rules\/\$\{id\}`/) + assert.doesNotMatch(page, /未知或已删除规则/) +}) + +test('offset pages are deduplicated and destructive evidence loss is disclosed', () => { + assert.match(page, /dedupeDeliveryAttempts\(logsQuery\.data/) + assert.match(page, /永久删除该规则及其全部投递证据,此操作不可恢复/) +}) + +test('delivery evidence does not render raw transport or ACK payloads', () => { + assert.doesNotMatch(page, /log\.response_data/) + assert.doesNotMatch(page, /log\.ack_data/) + assert.match(page, /sanitizedDeliveryErrorSummary\(log\.error_message\)/) +}) + +test('deleting a notification rule invalidates its cascaded log view', () => { + const deletionHook = hooks.slice( + hooks.indexOf('export function useDeleteNotificationRule'), + hooks.indexOf('export function useNotificationLogs'), + ) + assert.match(deletionHook, /queryKey: \['notification-rules'\]/) + assert.match(deletionHook, /queryKey: \['notification-logs'\]/) +}) diff --git a/frontend/scripts/notification-delivery-status.test.mjs b/frontend/scripts/notification-delivery-status.test.mjs new file mode 100644 index 00000000..411a48f2 --- /dev/null +++ b/frontend/scripts/notification-delivery-status.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + acknowledgementStatusPresentation, + dedupeDeliveryAttempts, + sanitizedDeliveryErrorSummary, + transportStatusPresentation, +} from '../lib/notifications/delivery-status.ts' + +test('a successful transport is submitted, never presented as confirmed delivery', () => { + const status = transportStatusPresentation('sent') + + assert.equal(status.label, '已提交') + assert.doesNotMatch(status.label, /送达|确认/) + assert.match(status.description, /不代表业务方已确认/) +}) + +test('transport failures remain operationally distinct from ACK failures', () => { + assert.deepEqual(transportStatusPresentation('failed'), { + label: '提交失败', + description: '通知请求未成功提交到通道。', + tone: 'negative', + }) + assert.deepEqual(acknowledgementStatusPresentation('failed'), { + label: '回执失败', + description: '业务方回执未通过验证或明确失败。', + tone: 'negative', + }) +}) + +test('all backend ACK states have explicit product semantics', () => { + assert.equal(acknowledgementStatusPresentation('acked', 'sent').label, '已确认') + assert.equal(acknowledgementStatusPresentation('pending', 'sent').label, '等待回执') + assert.equal(acknowledgementStatusPresentation('not_required', 'sent').label, '无需回执') +}) + +test('provisional ACK defaults are not mistaken for final no-ACK semantics', () => { + assert.equal( + acknowledgementStatusPresentation('not_required', 'pending').label, + '待提交后确定', + ) + assert.equal( + acknowledgementStatusPresentation('not_required', 'failed').label, + '未进入回执', + ) +}) + +test('a real downstream ACK outranks a transport-side failure inference', () => { + assert.equal(acknowledgementStatusPresentation('acked', 'failed').label, '已确认') + assert.equal(acknowledgementStatusPresentation('failed', 'failed').label, '回执失败') + assert.equal(acknowledgementStatusPresentation('pending', 'failed').label, '等待回执') +}) + +test('unknown backend values fail closed to an unknown state', () => { + assert.equal(transportStatusPresentation('unexpected').label, '状态未知') + assert.equal(acknowledgementStatusPresentation('unexpected').label, '状态未知') +}) + +test('error summaries use an allow-list and never repeat secret-shaped input', () => { + assert.equal( + sanitizedDeliveryErrorSummary( + 'Authorization: Bearer sk-live-secret; password=hunter2; Cookie=session-secret', + ), + '通知处理失败(详细错误已隐藏)', + ) + assert.equal(sanitizedDeliveryErrorSummary('ConnectTimeout: POST https://secret.example'), '通知通道连接超时') + assert.equal(sanitizedDeliveryErrorSummary(null), null) +}) + +test('offset pagination duplicates are removed by stable delivery id', () => { + assert.deepEqual( + dedupeDeliveryAttempts([ + { id: 'new', page: 1 }, + { id: 'boundary', page: 1 }, + { id: 'boundary', page: 2 }, + { id: 'old', page: 2 }, + ]), + [ + { id: 'new', page: 1 }, + { id: 'boundary', page: 1 }, + { id: 'old', page: 2 }, + ], + ) +})