`):拿到它就能往群里发消息。接口回显/发送记录/日志一律走
+> `notifier.mask_url()/scrub()` 打码,导出备份时也按敏感信息对待。
> `app_meta` 的列名 `key` 在 MySQL 里是保留字,**不要直接拼裸 SQL**,统一走
> `core/db_config.meta_get / meta_set`(方言中立、自动加引号)。
diff --git a/doc/NOTIFY.md b/doc/NOTIFY.md
new file mode 100644
index 0000000..f1436ad
--- /dev/null
+++ b/doc/NOTIFY.md
@@ -0,0 +1,166 @@
+# 通知 / 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=`)→ 接口回显、发送记录、日志一律打码
+(`mask_url`/`scrub`);编辑时**留空即不修改**;`secret` 永不回显(只回"已配置")。
+
+---
+
+## 5. 推送格式
+
+| 格式 | 请求体 | 关键约束 |
+|---|---|---|
+| `wecom` 企业微信 | `{"msgtype":"markdown","markdown":{"content":"…"}}` | content **≤4096 字节**(超了按字节截断并加「…(已截断)」,不会截出半个汉字);**每机器人每分钟 20 条**,超限 `errcode 45009`;成功必须 `errcode==0` |
+| `json` 通用 / Slack | 由 `body_template` 决定 | 保存前**干跑校验**(渲染后必须是合法 JSON) |
+
+**字段一律渲染成 `> **字段**:值` 引用行,不用 Markdown 表格**——企业微信/钉钉的 markdown
+子集不支持表格,表格会原样吐出来。
+
+通用 JSON 的占位符:`{{event}} {{title}} {{summary}} {{ts}} {{level}} {{markdown}}
+{{fields}} {{fields_json}} {{hook_name}} {{field.<字段名>}}`。
+替换值按 JSON 字符串转义,所以标题里带引号/换行也不会打坏请求体。Slack 直接写
+`{"text":"{{markdown}}"}` 就行。
+
+`dingtalk`/`feishu` 在界面上是**置灰**的:适配器留了插槽(`BaseAdapter._sign/_auth_fields/
+_byte_limit`),要接的时候加一个类 + 注册进 `ADAPTERS` 即可;急用可以拿通用 JSON 手搓
+(飞书的 text 格式就是 `{"msg_type":"text","content":{"text":"{{markdown}}"}}`)。
+
+---
+
+## 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` 是**重启后**才发(恢复本身就是重启生效的)
diff --git a/doc/README.md b/doc/README.md
index 8051a94..ac2299f 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -18,6 +18,7 @@
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
| [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:AI 自己在真机探索 → 写出可调度任务 → 人工确认入库(P0 已实现;契约与红线) | 平台开发者、使用者 |
+| [NOTIFY.md](NOTIFY.md) | **通知 / Webhook**:事件目录、企业微信/通用 JSON 适配、防刷屏(聚合/限流/折叠)、排障、怎么加事件 | 运维、平台开发者 |
| [DEVICE_AGENT.md](DEVICE_AGENT.md) | **设备端 Agent 接口契约**:应用商店的设备专用接口(清单/下载/上报)、adb 指令协议、版本约定 —— 与设备端 APK 仓库共享的契约 | 设备端开发者、平台开发者 |
| [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 |
| [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 |
diff --git a/static/admin/base.js b/static/admin/base.js
index 120dd5b..590d518 100644
--- a/static/admin/base.js
+++ b/static/admin/base.js
@@ -126,6 +126,7 @@ function showSubTab(tabId, name){
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
if(tabId==='agent' && name==='taskgen' && typeof initTaskGen==='function') initTaskGen();
+ if(tabId==='system' && name==='notify' && typeof loadNotifyPanel==='function') loadNotifyPanel();
if(name==='groups' && typeof loadGroups==='function') loadGroups();
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
if(name==='devpool' && typeof loadDevPool==='function'){
diff --git a/static/admin/notify.js b/static/admin/notify.js
new file mode 100644
index 0000000..96ea5b2
--- /dev/null
+++ b/static/admin/notify.js
@@ -0,0 +1,327 @@
+// 系统 → 通知:多 webhook 配置(每条自己订阅事件)+ 测试发送 + 发送记录
+// 后端:web/notify_api.py(全部 admin_required);配置落 app_meta.notify_webhooks 单键。
+// 安全口径:URL 里有凭据(企微 ?key=)→ 列表/记录里一律打码;编辑时**留空即不修改**。
+let _notifyHooks = [];
+let _notifyEvents = [];
+let _notifyFormats = [];
+let _notifySettings = {};
+let _notifyEditingId = null; // null=新建
+let _notifyPatterns = []; // 编辑中的通配订阅(如 task.*)
+
+// ================== 加载与渲染 ==================
+function loadNotifyPanel(){
+ Promise.all([apiGet('/api/notify/webhooks'),
+ apiGet('/api/notify/events'),
+ apiGet('/api/notify/logs?limit=50')]).then(function(res){
+ const [hooks, events, logs] = res;
+ if(hooks && hooks.ok){
+ _notifyHooks = hooks.webhooks || [];
+ _notifyFormats = hooks.formats || [];
+ _notifySettings = hooks.settings || {};
+ }
+ if(events && events.ok) _notifyEvents = events.events || [];
+ renderNotifySettings();
+ renderNotifyHooks();
+ renderNotifyLogs(logs && logs.ok ? (logs.logs || []) : []);
+ });
+}
+
+function renderNotifySettings(){
+ const on = document.getElementById('notify-global');
+ if(on) on.checked = _notifySettings.global_enabled !== false;
+ const agg = document.getElementById('notify-agg');
+ if(agg) agg.value = _notifySettings.default_agg_window != null ? _notifySettings.default_agg_window : 30;
+ const rate = document.getElementById('notify-rate');
+ if(rate) rate.value = _notifySettings.default_rate_limit != null ? _notifySettings.default_rate_limit : 18;
+}
+
+function renderNotifyHooks(){
+ const tb = document.getElementById('tb-notify-hooks');
+ if(!tb) return;
+ if(!_notifyHooks.length){
+ tb.innerHTML = '| 还没有配置 webhook。点「+ 新建 Webhook」加一条——'
+ + '比如把「任务失败」推到企业微信群。 |
';
+ return;
+ }
+ tb.innerHTML = _notifyHooks.map(function(h){
+ const fmt = (_notifyFormats.find(f => f.name === h.format) || {}).label || h.format;
+ const evs = (h.events || []).length;
+ return ''
+ + '| ' + esc(h.name) + (h.secret_set ? ' 🔑' : '') + ' | '
+ + '' + esc(fmt) + ' | '
+ + ''
+ + esc(h.url || '') + ' | '
+ + '' + evs + ' 个事件 | '
+ + '' + (h.enabled ? '启用'
+ : '停用') + ' | '
+ + ''
+ + ' '
+ + ' '
+ + ' '
+ + ''
+ + ' |
';
+ }).join('');
+}
+
+function renderNotifyLogs(logs){
+ const tb = document.getElementById('tb-notify-logs');
+ if(!tb) return;
+ if(!logs.length){
+ tb.innerHTML = '| 本次运行还没有发送记录(重启后清空,历史见 logs/notify.log) |
';
+ return;
+ }
+ tb.innerHTML = logs.map(function(r){
+ const t = r.ts ? new Date(r.ts * 1000).toLocaleString('zh-CN', {hour12:false}) : '';
+ const st = r.ok ? '成功'
+ : '失败';
+ const detail = r.ok ? ('HTTP ' + (r.http_status == null ? '' : r.http_status)
+ + (r.errcode ? ' errcode ' + r.errcode : ''))
+ : esc(r.error || '');
+ return '| ' + esc(t) + ' | '
+ + '' + esc(r.hook_name || '') + ' | '
+ + '' + esc(r.event || '') + ' | '
+ + '' + st + ' | '
+ + '' + detail + (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '') + ' |
';
+ }).join('');
+}
+
+// ================== 编辑弹窗 ==================
+function notifyEventMatches(pattern, key){
+ if(!pattern) return false;
+ if(pattern === '*' || pattern === key) return true;
+ if(pattern.slice(-2) === '.*') return key.indexOf(pattern.slice(0, -1)) === 0;
+ return false;
+}
+
+function openNotifyModal(id){
+ _notifyEditingId = id || null;
+ const h = id ? (_notifyHooks.find(x => x.id === id) || {}) : {};
+ _notifyPatterns = (h.events || []).filter(p => p.indexOf('*') >= 0);
+ const checked = (h.events || []).filter(p => p.indexOf('*') < 0);
+ const fmtOpts = _notifyFormats.map(function(f){
+ const sel = (h.format || 'wecom') === f.name ? ' selected' : '';
+ return '';
+ }).join('');
+ const box = document.getElementById('notify-modal-box');
+ if(!box) return;
+ box.innerHTML =
+ ''
+ + (id ? '编辑 Webhook' : '新建 Webhook') + '
'
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + ''
+ + '
'
+ + '
占位符:{{event}} {{title}} {{summary}} {{ts}} {{level}} {{markdown}} {{fields}} {{fields_json}} {{hook_name}} {{field.名称}}。'
+ + '保存前会做干跑校验(渲染后必须是合法 JSON)。Slack 可直接用:'
+ + '{"text":"{{markdown}}"}
'
+ + '';
+ box.querySelector('#nf-body').value = h.body_template || '';
+ renderEventTree(checked);
+ renderNotifyPatterns();
+ _notifyFormatChanged();
+ document.getElementById('notify-modal-overlay').style.display = 'flex';
+}
+
+function _notifyFormatChanged(){
+ const f = (document.getElementById('nf-format') || {}).value;
+ const bodyWrap = document.getElementById('nf-body-wrap');
+ if(bodyWrap) bodyWrap.style.display = (f === 'json') ? 'block' : 'none';
+ const patWrap = document.getElementById('nf-pattern-wrap');
+ if(patWrap) patWrap.style.display = (f === 'json' || true) ? 'block' : 'none';
+}
+
+function renderEventTree(checked){
+ const box = document.getElementById('nf-events');
+ if(!box) return;
+ const cats = [];
+ _notifyEvents.forEach(function(e){ if(cats.indexOf(e.category) < 0) cats.push(e.category); });
+ box.innerHTML = cats.map(function(cat){
+ const rows = _notifyEvents.filter(e => e.category === cat);
+ const body = rows.map(function(e){
+ const id = 'nfe-' + e.key.replace(/\./g, '-');
+ return '';
+ }).join('');
+ return ''
+ + esc(cat) + '
'
+ + body + '
';
+ }).join('');
+}
+
+function _notifyToggleCat(cat){
+ const keys = _notifyEvents.filter(e => e.category === cat).map(e => e.key);
+ const boxes = [...document.querySelectorAll('#nf-events .nf-ev')].filter(b => keys.indexOf(b.value) >= 0);
+ const allOn = boxes.every(b => b.checked);
+ boxes.forEach(b => { b.checked = !allOn; });
+}
+
+function addNotifyPattern(){
+ const inp = document.getElementById('nf-pattern-input');
+ const v = (inp.value || '').trim();
+ if(!v) return;
+ if(v !== '*' && v.slice(-2) !== '.*'){ showToast('通配请写成 task.* 或 device.* 或 *', 'error'); return; }
+ if(_notifyPatterns.indexOf(v) < 0) _notifyPatterns.push(v);
+ inp.value = '';
+ renderNotifyPatterns();
+}
+
+function renderNotifyPatterns(){
+ const box = document.getElementById('nf-patterns');
+ if(!box) return;
+ box.innerHTML = _notifyPatterns.map(function(p, i){
+ return ''
+ + esc(p) + ' ×';
+ }).join('') || '(无)';
+}
+
+function removeNotifyPattern(i){
+ _notifyPatterns.splice(i, 1);
+ renderNotifyPatterns();
+}
+
+function closeNotifyModal(){
+ const ov = document.getElementById('notify-modal-overlay');
+ if(ov) ov.style.display = 'none';
+}
+
+// ================== 保存 / 删除 / 启停 / 测试 ==================
+function collectNotifyForm(){
+ const events = [...document.querySelectorAll('#nf-events .nf-ev')]
+ .filter(b => b.checked).map(b => b.value);
+ _notifyPatterns.forEach(function(p){ if(events.indexOf(p) < 0) events.push(p); });
+ const body = {name: (document.getElementById('nf-name').value || '').trim(),
+ format: document.getElementById('nf-format').value,
+ events: events,
+ agg_window: parseInt(document.getElementById('nf-agg').value, 10) || 0,
+ rate_limit_per_min: parseInt(document.getElementById('nf-rate').value, 10) || 0,
+ body_template: (document.getElementById('nf-body').value || '')};
+ const url = (document.getElementById('nf-url').value || '').trim();
+ if(url) body.url = url; // 留空 = 不修改(后端保留原值)
+ const sec = (document.getElementById('nf-secret').value || '').trim();
+ if(sec) body.secret = sec;
+ return body;
+}
+
+function saveNotifyHook(){
+ const body = collectNotifyForm();
+ if(!body.name){ showToast('请填名称', 'error'); return; }
+ if(!body.events.length){ showToast('至少要勾一个事件', 'error'); return; }
+ const id = _notifyEditingId;
+ const req = id ? apiPut('/api/notify/webhooks/' + id, body)
+ : apiPost('/api/notify/webhooks', body);
+ req.then(function(r){
+ if(!r || !r.ok){
+ let msg = (r && r.error) || '保存失败';
+ if(r && r.errors && r.errors.length) msg = r.errors.join(';');
+ showToast(msg, 'error');
+ return;
+ }
+ showToast('已保存', 'success');
+ closeNotifyModal();
+ loadNotifyPanel();
+ });
+}
+
+function deleteNotifyHook(id){
+ const h = _notifyHooks.find(x => x.id === id) || {};
+ if(!confirm('删除 webhook「' + (h.name || '') + '」?\n(只是不再推送,不影响任何任务/设备数据)')) return;
+ apiDelete('/api/notify/webhooks/' + id).then(function(r){
+ if(!r || !r.ok){ showToast((r && r.error) || '删除失败', 'error'); return; }
+ showToast('已删除', 'success');
+ loadNotifyPanel();
+ });
+}
+
+function toggleNotifyHook(id, on){
+ apiPut('/api/notify/webhooks/' + id, {enabled: on}).then(function(r){
+ if(!r || !r.ok){ showToast((r && r.error) || '操作失败', 'error'); return; }
+ loadNotifyPanel();
+ });
+}
+
+function testNotifyHook(id){
+ showToast('正在发送测试消息…', 'success');
+ apiPost('/api/notify/webhooks/' + id + '/test', {}).then(function(r){
+ if(!r){ showToast('请求失败', 'error'); return; }
+ const detail = 'HTTP ' + (r.http_status == null ? '-' : r.http_status)
+ + (r.errcode ? ' · errcode ' + r.errcode : '')
+ + (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '')
+ + (r.attempts > 1 ? ' · 重试 ' + r.attempts + ' 次' : '')
+ + (r.error ? '\n' + r.error : '');
+ showToast(r.ok ? ('测试消息已发出(' + detail + ')') : ('发送失败(' + detail + ')'),
+ r.ok ? 'success' : 'error');
+ loadNotifyPanel();
+ });
+}
+
+function previewNotifyHook(){
+ const body = collectNotifyForm();
+ apiPost('/api/notify/preview', {hook: body, event: 'notify.test'}).then(function(r){
+ const box = document.getElementById('nf-preview');
+ if(!box) return;
+ if(!r || !r.ok){ box.innerHTML = '预览失败:' + esc((r && r.error) || '') + ''; return; }
+ let html = 'UTF-8 字节数:' + (r.bytes == null ? '-' : r.bytes)
+ + (r.byte_limit ? ' / 上限 ' + r.byte_limit : '')
+ + (r.truncated ? ' (会截断)' : '') + '
';
+ html += ''
+ + esc(r.request_body || '') + '
';
+ box.innerHTML = html;
+ });
+}
+
+function saveNotifyGlobal(){
+ const on = document.getElementById('notify-global').checked;
+ const body = {global_enabled: on,
+ default_agg_window: parseInt(document.getElementById('notify-agg').value, 10) || 0,
+ default_rate_limit: parseInt(document.getElementById('notify-rate').value, 10) || 18};
+ apiPost('/api/notify/settings', body).then(function(r){
+ if(!r || !r.ok){ showToast((r && r.error) || '保存失败', 'error'); return; }
+ showToast('已保存(新配置对之后的事件生效)', 'success');
+ loadNotifyPanel();
+ });
+}
+
+function toggleNotifyGlobal(el){
+ apiPost('/api/notify/settings', {global_enabled: el.checked}).then(function(r){
+ if(!r || !r.ok){ el.checked = !el.checked; showToast((r && r.error) || '操作失败', 'error'); return; }
+ showToast(el.checked ? '通知总开关:已开启' : '通知总开关:已关闭(所有 webhook 暂停推送)', 'success');
+ });
+}
diff --git a/tasks/generic/task.py b/tasks/generic/task.py
index b751509..dfefb72 100644
--- a/tasks/generic/task.py
+++ b/tasks/generic/task.py
@@ -37,6 +37,7 @@ from tasks.base import BaseTask, register_task
from core.device_worker import BaseWorker, _update_status
from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep
from core.logger import get_logger
+from core import notifier
_log = get_logger("task.generic")
@@ -148,6 +149,10 @@ class GenericStepsWorker(BaseWorker):
_log.error(f"[{self.serial}] 选择器连续 {n} 次未找到元素,可能已失效(App 改版?):{sel_val}")
_update_status(self.serial, last_warning=f"选择器连续{n}次未命中")
self._miss_counts.pop(sel_val, None) # 已上报,重置避免刷屏
+ # 通知:这是"任务显示成功但什么都没做"的隐蔽故障,值得人看一眼
+ # (重置计数后要再攒满 10 次才会再报,天然不刷屏)
+ notifier.notify("task.selector.invalid", serial=self.serial,
+ selector=str(sel_val)[:200], miss_count=n)
def run_task(self, d):
"""按 steps 顺序执行。loop 步骤递归执行其 children。"""
diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html
index 4392129..0c46a84 100644
--- a/templates/admin/monitor.html
+++ b/templates/admin/monitor.html
@@ -1112,6 +1112,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
+
@@ -1151,6 +1152,49 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
+
+
+
+
+ 通知:把平台事件(任务失败/成功、设备上下线、安装完成、备份恢复…)推到 webhook,
+ 例如企业微信群机器人。每条 webhook 自己勾选关心哪些事件——不勾就不推。
+
为防刷屏:同一条 webhook 默认每 30 秒合并一次同类事件、每分钟最多 18 条
+ (企业微信硬限 20),超出的会压成一条「被限流折叠」摘要而不是丢掉。详见 doc/NOTIFY.md。
+
+
+
+
+
+ 默认聚合
+ 秒 ·
+ 限流 条/分
+
+
+
+
+
+
+ 发送记录
+ 仅本次运行期间(重启清空);完整历史见 logs/notify.log
+
+
+
+
+
+
+
+
@@ -1219,5 +1263,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
+