Files
auto_control/doc/NOTIFY.md
T
butubb 3b6ec8c98f feat(通知): 加钉钉 / 飞书两种格式(含各自的加签算法)
## 两家签名算法不一样,照抄必错
| | 钉钉 | 飞书 |
|---|---|---|
| HMAC key | `secret` | `"{timestamp}\n{secret}"` |
| 被签内容 | `"{timestamp}\n{secret}"` | 空 |
| timestamp | 毫秒 | 秒 |
| 拼在哪 | URL query | JSON body |
| 结果 | Base64 再 urlencode | Base64 |
| 出错码 | `errcode 310000` | `code 19021` |

实现为 `BaseAdapter.sign_request()` 钩子(默认不动),两家各写各的;
`core/notifier.py` 的 `_post_once` 在发请求前调用它。

## 另外
- 钉钉:markdown 消息(title+text);成功码 0;默认限流 15/分(官方 20/分,**超限会被限 10 分钟**,留余量)
- 飞书:交互式卡片(header 颜色按事件级别 + markdown 元素);成功码 0;默认 60/分
- `PLANNED_FORMATS` 清空(下拉里不再有置灰项)
- **修一个泄漏**:`mask_url` 原来只打码 query 参数——**飞书的 token 在 URL 路径里**(`/hook/<token>`)
  → 接口回显会漏出去。现在同时处理路径 token(≥20 位随机串)与 Slack 的 `/services/T…/B…/X…` 三段。

## 验证(都跑过)
- **签名对拍官方示例**:钉钉逐字节一致(含 urlencode,`%2B` 级别)、飞书的 key/message 口径一致;
  时间戳钉住后比对,不靠"自己跟自己一致"
- 成功码按格式:wecom/dingtalk/feishu 的 0 与 bark 的 200 分别判成功,各自的错误码判失败
- 端到端 15 项:钉钉/飞书在假接收端上真发(URL 带签名、body 带 timestamp/sign、卡片是 markdown 元素、
  毫秒 vs 秒的时间戳),签名错时正确判失败(310000 / 19021),token 在回显里被打码
2026-09-15 14:29:11 +08:00

194 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 通知 / Webhook(NOTIFY)
> 面向:要给平台接告警的运维、以及往各组件加通知点的开发。
> 相关:[API.md](API.md)(接口)、[DATA_MODEL.md](DATA_MODEL.md)(配置存哪)、
> [ARCHITECTURE.md](ARCHITECTURE.md)(线程模型)。
---
## 1. 它是什么
平台各组件(任务、设备、安装、备份、AI 巡检…)在关键时刻调一个统一入口:
```python
from core import notifier
notifier.notify("task.device.failed", serial="192.168.20.71:5555",
device_name="A02", job_name="抖音养号", cause="选择器连续 10 次未命中")
```
`notify()` **只做内存操作**(读配置快照 → 匹配订阅 → 丢进队列),真正发 HTTP 的是后台
daemon 线程。所以:
- 任务线程里可以直接调,**不用包 app_context、不用 try/except**(内部全兜住了)
- **但必须放在所有 `with self._lock` 之外**——别让通知拖住调度锁
- 通知模块自己出问题(地址写错、对方挂了、配置坏了)**绝不影响任务/设备/备份**,
最多在日志里留一条警告
```
业务线程 notify() ──入队──▶ dispatcher(1 线程)──▶ sender ×3 ──▶ 企业微信/自建服务
① 聚合 ② 限流 ③ 折叠
└─▶ 发送记录(内存 200 条 + logs/notify.log)
```
---
## 2. 快速上手(企业微信)
1. 企业微信群里 → 群机器人 → 添加 → 复制 Webhook 地址(形如
`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx`)
2. 后台 → **系统 → 通知 / Webhook → + 新建 Webhook**:名称随便起、格式选「企业微信」、
粘贴地址、在事件树里勾上关心的事件(★ 是建议开的)
3. 点该行的 **发送测试** —— 群里立刻能收到一条 markdown;收不到就看返回的 HTTP/errcode
---
## 3. 事件目录
**权威定义在 `core/notify_events.py`**(后台「通知」页也是从它渲染的,不会漂移)。
所有事件**默认不启用**——登记不等于推送,勾了才发。
| 类别 | 事件 |
|---|---|
| 任务批次 | `task.batch.started` · `.finished` · `.no_device` · `.unknown_type` · `task.cron.stopped` |
| 任务·单设备 | `task.device.success` · `.failed` · `.offline` · `.error` · `.retry` · `.stopped` · `.preempted` · `.preempt_timeout` · `.released` |
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` |
| 安装 | `apk.install.started` · `.finished` |
| 系统 | `system.backup.exported` · `.imported` · `.restored` · `.restore_failed` · `service.started` · `.stopping` · `user.login` |
| AI | `ai.audit.finished` · `.failed` · `.skipped` |
| 其它 | `notify.test`(测试按钮专用) |
订阅支持通配:`*`(全部)、`task.*`、`device.*`。
> 语义分工(**避免重复告警**):`worker.*` 是**单次尝试**层面;`task.device.success/failed`
> 是"这台设备最终成功/失败"的**唯一权威点**。所以一个设备重试 3 次后失败,只会收到 **1 条**
> `task.device.failed`,不会收到 3 条噪音。
---
## 4. 配置
存在 `app_meta.notify_webhooks` 一个键里(JSON,**不建表**,因此不涉及备份覆盖清单):
```json
{
"version": 1,
"settings": {"global_enabled": true, "default_agg_window": 30,
"default_rate_limit": 18, "log_keep": 200, "http_timeout": 5},
"webhooks": [
{"id": "wh_ab12cd34", "name": "运维群", "enabled": true, "format": "wecom",
"url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…",
"secret": "", "events": ["task.device.failed", "device.*"],
"agg_window": 30, "rate_limit_per_min": 18,
"title_template": "", "body_template": "", "headers": {}}
]
}
```
上限(超了**拒绝保存**,不静默截断):webhook ≤ 20 条、整体 JSON ≤ 60000 字符、URL ≤ 2048。
**安全**:URL 里带凭据(企微 `?key=`、钉钉 `?access_token=`、飞书 `/hook/<token>`、
Bark `/…/<key>`、Slack `/services/T…/B…/X…`)→ 接口回显、发送记录、日志一律打码
(`mask_url` 同时处理 query 与**路径里的 token**;异常消息过 `scrub`);
编辑时**留空即不修改**;`secret` 永不回显(只回"已配置")。
---
## 5. 推送格式
| 格式 | 请求体 | URL 怎么填 | 成功判定 / 关键约束 |
|---|---|---|---|
| `wecom` 企业微信 | `{"msgtype":"markdown","markdown":{"content":"…"}}` | 群机器人 → 复制的 Webhook 地址(含 `?key=`) | **`errcode==0`**;content **≤4096 字节**(按字节截断、不会截出半个汉字);**每机器人每分钟 20 条**,超限 `45009` |
| `dingtalk` 钉钉 | `{"msgtype":"markdown","markdown":{"title","text"}}` | 群设置 → 智能群助手 → 添加机器人 → 自定义 → 复制的 Webhook 地址(含 `?access_token=`) | **`errcode==0`**;正文 ≤20000 字节;官方 20 条/分,**超限会被限流 10 分钟**(这里默认 15 留余量);机器人安全设置选「加签」时密钥必填 |
| `feishu` 飞书 | `{"msg_type":"interactive","card":{header + markdown 元素[,"timestamp","sign"]}}` | 群设置 → 群机器人 → 添加 → 自定义机器人 → 复制的 Webhook 地址(`…/bot/v2/hook/<token>`) | **`code==0`**;请求体 ≤20KB;官方 100 条/分(这里默认 60);开了「签名校验」时密钥必填 |
| `bark` iOS 推送 | `{"title","body","markdown","group","level"[,"device_key"]}` | Bark App 里复制的那串(`https://api.day.app/<key>`)**或** `https://api.day.app/push` + 设备 Key 填到「设备 Key」 | **`code==200`**(注意和企业微信不一样!);走 APNs,正文按 2048 字节截断;`markdown` 传富文本、`body` 传纯文本兜底 |
| `json` 通用 / Slack | 由 `body_template` 决定 | 你自己的接收端;Slack 填它的 Incoming Webhook URL | HTTP 200(响应体里有 `code`/`errcode` 时必须为 0);模板保存前**干跑校验**;`secret` 会作为 `X-Webhook-Secret` 头发出 |
> 成功码**按格式**判定(企微/钉钉/飞书 = 0,Bark = 200),写在适配器的 `ok_codes` 上——
> 加新格式时别忘了一起定。
### 加签:钉钉和飞书**算法不一样**,别互相照抄
| | 钉钉 | 飞书 |
|---|---|---|
| key(HMAC 的密钥) | `secret` | `"{timestamp}\n{secret}"` |
| message(被签内容) | `"{timestamp}\n{secret}"` | **空** |
| timestamp 单位 | **毫秒** | **秒** |
| 拼在哪 | **URL query**(`&timestamp=…&sign=…`) | **JSON body** 的 `timestamp`/`sign` |
| 结果编码 | Base64 后再 **urlencode** | Base64 |
| 出错码 | `errcode 310000 invalid signature` | `code 19021 sign match fail` |
实现见 `core/notifier.py` 的 `DingtalkAdapter.sign_request` / `FeishuAdapter.sign_request`;
两家的算法都用官方示例代码对拍过(钉钉逐字节一致,含 urlencode)。
**字段一律渲染成 `> **字段**:值` 引用行,不用 Markdown 表格**——企业微信/钉钉的 markdown
子集不支持表格,表格会原样吐出来。
通用 JSON 的占位符:`{{event}} {{title}} {{summary}} {{ts}} {{level}} {{markdown}}
{{fields}} {{fields_json}} {{hook_name}} {{field.<字段名>}}`。
替换值按 JSON 字符串转义,所以标题里带引号/换行也不会打坏请求体。Slack 直接写
`{"text":"{{markdown}}"}` 就行。
**格式相关的提示文案(URL 示例/说明、密钥叫什么、限流上限)都挂在适配器上**
(`BaseAdapter.url_hint/url_help/secret_label/secret_help/limit_help`),界面按当前格式渲染、
切格式即时更新——新增格式时把这些一起填上,别把某个平台的说明写死在页面上。
**再加新格式**:写一个 `BaseAdapter` 子类(`render` + 可选 `sign_request`)并把元信息
(`byte_limit`/`ok_codes`/`limit_default`/`url_hint`/`url_help`/`secret_*`/`limit_help`)填全,
然后注册进 `ADAPTERS` —— 界面上的下拉项与提示文案会自动跟着出来,**不用改前端**。
`PLANNED_FORMATS` 是"还没实现、下拉里置灰"的占位表(目前为空)。
---
## 6. 防打爆(四层)
100 台设备同时失败 = 100 条消息,群里会被刷到静音——**刷屏会让通知彻底失效**。所以:
| 层 | 机制 | 默认 |
|---|---|---|
| L1 聚合 | 同 webhook、同事件、同聚合键(如 `job_id`)在一个窗口内合并成一条,保留前 3 个样本 | 30s(低频高危事件设 0,立即发) |
| L2 限流 | 每 webhook 一个令牌桶 | 18 条/分(企业微信硬限 20,留余量) |
| L3 折叠 | 被限流的事件**不丢弃**,压成一条「被限流折叠 N 条」摘要 | 最多 60s 一条 |
| L4 背压 | 有界队列(event 2000 / send 1000),满了丢弃并计数 | 溢出会告警一次 |
取舍写明白:**失败通知最多延迟一个聚合窗口(默认 30s)**,换来群不被刷屏。
---
## 7. 开发:给新功能加通知
1. 在 `core/notify_events.py` 的 `EVENTS` 里加一条 `_e("模块.对象.动作", "中文标签",
"分类", ["字段1", "字段2"], "什么时候发", agg_window=…, recommend=…)`
2. 在触发点调 `notifier.notify("模块.对象.动作", 字段1=…, 字段2=…)`
—— **放在所有 `with self._lock` 之外**,且不要改变原有 `return` 的顺序
3. 把事件补进本文档 §3 的表格
约定:
- `notify()` **不阻塞、不抛异常、不碰 DB**——这是硬约束,别在它里面加 HTTP 或查库
- 事件的 `fields` 是前端字段表与模板占位符的白名单,只写真正有用的
- 高频事件(每台设备/每次尝试都会发生的)把 `agg_window` 设大一点或 `recommend=False`
---
## 8. 排障
| 现象 | 看哪里 |
|---|---|
| 完全没收到 | ① 全局开关是不是关了(列表页「启用通知」)② 该 webhook 是否启用 ③ 事件勾了没(`task.device.failed` 是**单设备最终失败**,不是每次尝试) |
| 收到但内容不全 | 消息被 4096 字节截断了(末尾有「…(已截断)」);把 `cause` 之类长字段在事件侧截短 |
| 只在群里看到「被限流折叠」 | 短时间内同类事件太多,触发了 L2/L3;调大该 webhook 的限流值(企微上限 20)或调大聚合窗口 |
| 失败原因 | 后台「通知」页的**发送记录**(内存,重启清空)或 `logs/notify.log`(完整历史)。`errcode 45009`=企微限流、`93000`=Webhook 地址无效、`HTTP 200 + errcode≠0` **也算失败** |
| 配置坏了 | 服务照常启动(启动日志有 error),通知静默不发;在后台删掉坏配置或直接改 `app_meta.notify_webhooks` |
---
## 9. 已知限制
- 发送记录在**内存**(最近 200 条,重启清空);持久历史只有 `logs/notify.log` 文本
- **不支持自定义 webhook 请求头**(`headers` 字段留着但界面没暴露)——需要的话说一声
- 只做 http/https,**不做内网 IP 黑名单**(内网自建 webhook 是合法用法),但禁止重定向
(`allow_redirects=False`)
- `system.backup.restored` 是**重启后**才发(恢复本身就是重启生效的)