Skip to content

Commit 7e330a9

Browse files
authored
fix: isolate aggregate notification failures (ZhuLinsen#1269)
1 parent 080755c commit 7e330a9

7 files changed

Lines changed: 361 additions & 80 deletions

File tree

apps/dsa-web/src/components/settings/__tests__/NotificationTestPanel.test.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,95 @@ describe('NotificationTestPanel', () => {
5757
expect(screen.getByText('HTTP 200')).toBeInTheDocument();
5858
expect(screen.getByText('https://example.com/hook?token=***')).toBeInTheDocument();
5959
});
60+
61+
it('renders custom webhook partial failure attempts', async () => {
62+
testNotificationChannel.mockResolvedValueOnce({
63+
success: true,
64+
message: '自定义 Webhook 通知测试部分成功(1/2)',
65+
errorCode: null,
66+
stage: 'notification_send',
67+
retryable: true,
68+
latencyMs: 35,
69+
attempts: [
70+
{
71+
channel: 'custom',
72+
success: false,
73+
message: 'HTTP 500',
74+
target: 'https://example.com/hook?token=***',
75+
errorCode: 'http_500',
76+
stage: 'notification_send',
77+
retryable: true,
78+
latencyMs: 12,
79+
httpStatus: 500,
80+
},
81+
{
82+
channel: 'custom',
83+
success: true,
84+
message: 'sent',
85+
target: 'https://example.com/second/***',
86+
errorCode: null,
87+
stage: 'notification_send',
88+
retryable: false,
89+
latencyMs: 23,
90+
httpStatus: 200,
91+
},
92+
],
93+
});
94+
95+
render(
96+
<NotificationTestPanel
97+
items={[{ key: 'CUSTOM_WEBHOOK_URLS', value: 'https://example.com/hook?token=secret' }]}
98+
maskToken="******"
99+
/>,
100+
);
101+
102+
fireEvent.change(screen.getByLabelText('渠道'), { target: { value: 'custom' } });
103+
fireEvent.click(screen.getByRole('button', { name: // }));
104+
105+
expect(await screen.findByText('测试成功')).toBeInTheDocument();
106+
expect(screen.getByText(//)).toBeInTheDocument();
107+
expect(screen.getAllByText('HTTP 500').length).toBeGreaterThanOrEqual(1);
108+
expect(screen.getByText('HTTP 200')).toBeInTheDocument();
109+
expect(screen.getByText('http_500')).toHaveClass('text-warning');
110+
expect(screen.getByText('https://example.com/hook?token=***')).toBeInTheDocument();
111+
});
112+
113+
it('renders retryable timeout diagnostics', async () => {
114+
testNotificationChannel.mockResolvedValueOnce({
115+
success: false,
116+
message: '通知测试异常: timeout',
117+
errorCode: 'timeout',
118+
stage: 'notification_send',
119+
retryable: true,
120+
latencyMs: null,
121+
attempts: [
122+
{
123+
channel: 'wechat',
124+
success: false,
125+
message: 'timeout',
126+
target: 'https://qyapi.example.com/cgi-bin/webhook/send?key=***',
127+
errorCode: 'timeout',
128+
stage: 'notification_send',
129+
retryable: true,
130+
latencyMs: null,
131+
httpStatus: null,
132+
},
133+
],
134+
});
135+
136+
render(
137+
<NotificationTestPanel
138+
items={[{ key: 'WECHAT_WEBHOOK_URL', value: 'https://qyapi.example.com/cgi-bin/webhook/send?key=secret' }]}
139+
maskToken="******"
140+
/>,
141+
);
142+
143+
fireEvent.click(screen.getByRole('button', { name: // }));
144+
145+
expect(await screen.findByText('测试失败')).toBeInTheDocument();
146+
const timeoutEntries = screen.getAllByText('timeout');
147+
expect(timeoutEntries[0]).toBeInTheDocument();
148+
expect(screen.getByText('https://qyapi.example.com/cgi-bin/webhook/send?key=***')).toBeInTheDocument();
149+
expect(timeoutEntries[0]).toHaveClass('text-warning');
150+
});
60151
});

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1111

1212
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
1313
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
14+
- [修复] 聚合报告通知按静态渠道隔离发送失败,并补充自定义 Webhook 部分成功诊断与脱敏测试。
1415
- [修复] 未配置 Tushare / Longbridge 凭据时不再实例化对应可选 fetcher,避免缺失凭据的数据源进入候选集。
1516
- [修复] Longbridge 遇到连接关闭类异常后会进入冷却期,并在美股/港股实时与日线请求中临时跳过该数据源,避免请求级频繁重连。
1617
- [修复] Pytdx 股票名称查询在全部服务器不可达时会短暂冷却,并在冷却期内跳过重复探测,减少无效拨号与告警噪音。

docs/notifications.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 通知能力基线
22

3-
本文档记录通知能力 P0-P4 基线:渠道、配置 key、GitHub Actions 映射、Web 设置元数据、CLI 诊断口径、Web 一键测试、自定义 Webhook Body 模板语义、通知路由策略和降噪机制。P0 只做基线与只读诊断;P1 增加 Web 单渠道真实测试;P2 产品化现有 Body 模板;P3 增加 report / alert / system_error 路由;P4 增加进程内降噪,不包含 per-URL 模板、跨进程持久化、真实每日摘要或新增一等渠道
3+
本文档记录通知能力 P0-P5 基线:渠道、配置 key、GitHub Actions 映射、Web 设置元数据、CLI 诊断口径、Web 一键测试、自定义 Webhook Body 模板语义、通知路由策略、降噪机制和聚合报告失败隔离。P0 只做基线与只读诊断;P1 增加 Web 单渠道真实测试;P2 产品化现有 Body 模板;P3 增加 report / alert / system_error 路由;P4 增加进程内降噪;P5 强化测试诊断和聚合报告逐渠道失败隔离,不包含 per-URL 模板、跨进程持久化、真实每日摘要、重试循环或新增一等渠道
44

55
## 渠道基线
66

@@ -73,7 +73,7 @@ Web 设置页的“通知渠道”分类提供单渠道测试入口。测试会
7373

7474
- 测试范围:11 个静态通知渠道,不包含 `UNKNOWN` 和运行时上下文渠道。
7575
- 普通渠道:返回单次发送结果、耗时和通用错误码。
76-
- 自定义 Webhook:按 URL 顺序返回 attempts,展示每个 URL 的成功/失败、HTTP 状态、耗时和错误码。
76+
- 自定义 Webhook:按 URL 顺序返回 attempts,展示每个 URL 的成功/失败、HTTP 状态、耗时和错误码;多个 URL 部分成功时,顶层 message 会标出成功数 / 总数
7777
- 返回结果会脱敏 token、secret、password、Bearer、完整 webhook query 和疑似 path token。
7878
- 配置缺失或发送失败返回 `success=false`,不会影响已保存配置和默认分析流程。
7979

@@ -136,6 +136,14 @@ P3 新增三类通知路由配置:
136136
- `MERGE_EMAIL_NOTIFICATION` 不需要额外配置;只要 `email` 仍在 report 路由后的渠道中,现有合并邮件行为保持不变。
137137
- `--check-notify` 会把未知渠道值报为 error,把合法但未启用的路由目标报为 warning。
138138

139+
## 聚合报告失败隔离
140+
141+
P5 强化聚合报告通知路径的失败边界:`_send_notifications()` 在 report 路由过滤后对每个静态通知渠道单独发送。某个渠道抛异常会记录日志并视为该渠道失败,但不会跳过后续渠道,也不会中断分析主流程。
142+
143+
- 邮件按 receiver group 单独隔离;某个收件人分组失败时,后续分组仍会继续发送。
144+
- 任一静态渠道发送成功时,P4 降噪 reservation 会写入正式记录;全部静态渠道失败或抛异常时,会释放 reservation。
145+
- `send_to_context()` 仍独立于静态渠道 route 和降噪记录,用于回复触发任务的 Bot 会话上下文。
146+
139147
## 通知降噪机制
140148

141149
P4 新增进程内降噪,只影响静态配置渠道,不影响 `send_to_context()` 的机器人触发会话回执。默认所有配置关闭,未设置时保持旧行为。

0 commit comments

Comments
 (0)