Merge branch 'dev'

This commit is contained in:
2026-09-15 14:32:11 +08:00
27 changed files with 2429 additions and 27 deletions
+6 -2
View File
@@ -53,6 +53,7 @@
| **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 | | **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 |
| **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 | | **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 |
| **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 | | **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 |
| **通知 / Webhook** | 任务成功失败、设备上下线、安装完成、备份恢复等事件推送到企业微信 / 钉钉 / 飞书 / Bark / 自建服务(Slack 用通用 JSON);多条 webhook 各自订阅;聚合+限流防刷屏(见 [doc/NOTIFY.md](doc/NOTIFY.md)) |
--- ---
@@ -120,6 +121,7 @@ auto_control/
│ ├── tailscale_api.py # Tailscale 管理(改名/授权/密钥/IP/auth key) │ ├── tailscale_api.py # Tailscale 管理(改名/授权/密钥/IP/auth key)
│ ├── agent_api.py # AI 控制台(会话/SSE/经验库/动作库/巡检) │ ├── agent_api.py # AI 控制台(会话/SSE/经验库/动作库/巡检)
│ ├── system_api.py # 系统数据备份导出/导入 │ ├── system_api.py # 系统数据备份导出/导入
│ ├── notify_api.py # 通知 / Webhook 配置(系统 Tab)
│ ├── common.py # 跨模块共享工具(合并设备列表、屏幕状态) │ ├── common.py # 跨模块共享工具(合并设备列表、屏幕状态)
│ └── context.py # 共享对象注入(mgr / apk_mgr / device_pool) │ └── context.py # 共享对象注入(mgr / apk_mgr / device_pool)
│ │
@@ -136,6 +138,7 @@ auto_control/
│ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道) │ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道)
│ ├── apk_manager.py # APK 上传/解析/批量安装 │ ├── apk_manager.py # APK 上传/解析/批量安装
│ ├── system_backup.py # 数据备份导出/导入(重启生效) │ ├── system_backup.py # 数据备份导出/导入(重启生效)
│ ├── notifier.py # 通知分发(队列/聚合/限流/适配器)+ notify_events.py 事件目录
│ ├── tailscale_client.py # Tailscale API v2 客户端 │ ├── tailscale_client.py # Tailscale API v2 客户端
│ ├── ssh_client.py # SSH 封装(当前无人调用,预留) │ ├── ssh_client.py # SSH 封装(当前无人调用,预留)
│ ├── logger.py # 分文件日志(core/task/web/action) │ ├── logger.py # 分文件日志(core/task/web/action)
@@ -162,7 +165,7 @@ auto_control/
│ ├── monitor.html # 主单页应用(7 个顶级 Tab + 模态框 + 内联样式) │ ├── monitor.html # 主单页应用(7 个顶级 Tab + 模态框 + 内联样式)
│ ├── login.html # 登录页 │ ├── login.html # 登录页
│ └── wall.html # 监控大屏(独立页面,自包含) │ └── wall.html # 监控大屏(独立页面,自包含)
├── static/admin/ # 前端 JS(11 个文件,按顺序同步加载,见下) ├── static/admin/ # 前端 JS(13 个文件,按顺序同步加载,见下)
├── static/fonts/ # 自托管字体(Bricolage Grotesque + IBM Plex Mono) ├── static/fonts/ # 自托管字体(Bricolage Grotesque + IBM Plex Mono)
│ │
├── data/ # 运行时数据(不入 git) ├── data/ # 运行时数据(不入 git)
@@ -181,7 +184,7 @@ auto_control/
``` ```
base.js → markdown.js → list.js → monitor.js → editor.js → tasks.js base.js → markdown.js → list.js → monitor.js → editor.js → tasks.js
→ tools.js → apps.js → admin.js → agent.js → system.js → tools.js → apps.js → admin.js → agent.js → taskgen.js → system.js → notify.js
``` ```
--- ---
@@ -347,6 +350,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
| `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) | | `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) |
| `logs/web.log` | `web.*` | Web 请求与管理操作 | | `logs/web.log` | `web.*` | Web 请求与管理操作 |
| `logs/action.log` | `action.*` | 操作执行 | | `logs/action.log` | `action.*` | 操作执行 |
| `logs/notify.log` | `notify` | 通知发送(成功/失败/平台错误码) |
```python ```python
from core.logger import get_logger from core.logger import get_logger
+13
View File
@@ -30,6 +30,7 @@ from concurrent.futures import (ThreadPoolExecutor, as_completed,
from config import APK_DIR, ADB_PATH from config import APK_DIR, ADB_PATH
from core.logger import get_logger from core.logger import get_logger
from core import notifier
from core.models import db, ApkFile as ApkRow from core.models import db, ApkFile as ApkRow
from .device_worker import get_all_worker_status from .device_worker import get_all_worker_status
@@ -320,6 +321,8 @@ class ApkManager:
name="apk-install", daemon=True) name="apk-install", daemon=True)
t.start() t.start()
_log.info(f"开始安装 {apk_name} 到 {len(serials)} 台设备") _log.info(f"开始安装 {apk_name} 到 {len(serials)} 台设备")
notifier.notify("apk.install.started", apk_id=apk_id, apk_name=apk_name,
package_name=package_name, total=len(serials))
return True, f"开始安装 {apk_name} 到 {len(serials)} 台设备" return True, f"开始安装 {apk_name} 到 {len(serials)} 台设备"
def _install_worker(self, apk_id, apk_path, apk_name, package_name, def _install_worker(self, apk_id, apk_path, apk_name, package_name,
@@ -378,6 +381,16 @@ class ApkManager:
skipped = sum(1 for v in self._install_task["items"].values() skipped = sum(1 for v in self._install_task["items"].values()
if v["status"] == "skipped") if v["status"] == "skipped")
_log.info(f"安装完成 {apk_name}: 成功={success}, 失败={failed}, 跳过={skipped}") _log.info(f"安装完成 {apk_name}: 成功={success}, 失败={failed}, 跳过={skipped}")
# 通知:批量安装结束(带失败设备与原因,运维要知道哪几台没装上)
try:
items = ((self._install_task or {}).get("items") or {})
failed_items = [f"{v.get('name') or s}·{v.get('msg') or ''}"
for s, v in items.items() if v.get("status") == "failed"][:10]
notifier.notify("apk.install.finished", apk_name=apk_name,
success=success, failed=failed, skipped=skipped,
total=len(items), failed_items=failed_items)
except Exception as e:
_log.warning(f"发送安装完成通知失败(不影响安装): {e}")
def _install_one(self, serial, apk_path, package_name=""): def _install_one(self, serial, apk_path, package_name=""):
"""直连设备安装 APK。 """直连设备安装 APK。
+28
View File
@@ -22,6 +22,7 @@ from config import (USB_ADB_HOST,
DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL) DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL)
from core.adb_helper import _adb, adb_connect_light from core.adb_helper import _adb, adb_connect_light
from core.logger import get_logger from core.logger import get_logger
from core import notifier
from core.models import db, Device, PendingDevice from core.models import db, Device, PendingDevice
_log = get_logger("core.disc") _log = get_logger("core.disc")
@@ -42,6 +43,8 @@ _PROBE_TIMEOUT = 0.4
_app = None _app = None
_scan_lock = threading.Lock() # 定时/手动扫描互斥 _scan_lock = threading.Lock() # 定时/手动扫描互斥
# 上一轮"池内离线"集合:通知只报**状态沿**(这一轮新变成离线的),不每轮刷屏
_last_offline = set()
_stop_event = threading.Event() # shutdown 用 _stop_event = threading.Event() # shutdown 用
_scanning = False # 状态快照(API/前端) _scanning = False # 状态快照(API/前端)
_last_scan = None # (时间串, 开放数, 可连数, 新增数) _last_scan = None # (时间串, 开放数, 可连数, 新增数)
@@ -266,6 +269,7 @@ def scan_once(manual=False):
now = _fmt() now = _fmt()
existing = {p.serial for p in PendingDevice.query.all()} existing = {p.serial for p in PendingDevice.query.all()}
added = 0 added = 0
added_serials = []
from core import device_pool from core import device_pool
fps = {} fps = {}
for serial in verified: for serial in verified:
@@ -283,6 +287,7 @@ def scan_once(manual=False):
first_seen=now, last_seen=now, first_seen=now, last_seen=now,
fingerprint=fp)) fingerprint=fp))
added += 1 added += 1
added_serials.append(serial)
db.session.commit() db.session.commit()
# 5.5 自动认领(可选,默认关):指纹命中池中已有设备 → 直接把记录迁到新地址。 # 5.5 自动认领(可选,默认关):指纹命中池中已有设备 → 直接把记录迁到新地址。
# 默认关是因为认领会改写分组/任务引用(数据结构变动),交人工点一下更稳妥; # 默认关是因为认领会改写分组/任务引用(数据结构变动),交人工点一下更稳妥;
@@ -293,11 +298,34 @@ def scan_once(manual=False):
# 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给 # 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给
# 「未授权新设备」的,正式池设备断联不进 pending,而是自动重连。 # 「未授权新设备」的,正式池设备断联不进 pending,而是自动重连。
back = _reconnect_offline(configured) back = _reconnect_offline(configured)
# 7. 离线集合(供"状态沿"通知用:只报**这一轮新变成离线**的,不每轮刷屏)
offline_now = {s for s in configured if not device_pool.is_online(s)}
_log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台," _log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台,"
f"新增待连接 {added} 台" f"新增待连接 {added} 台"
+ (f",自动重连恢复 {len(back)} 台 {back}" if back else "") + (f",自动重连恢复 {len(back)} 台 {back}" if back else "")
+ (f",自动认领 {len(claimed)} 台 {[c[0] + '→' + c[1] for c in claimed]}" + (f",自动认领 {len(claimed)} 台 {[c[0] + '→' + c[1] for c in claimed]}"
if claimed else "")) if claimed else ""))
# 通知统一放在 `with _ctx():` 之后(别在 DB 事务期间做额外的事)
global _last_offline
try:
newly_offline = sorted(offline_now - _last_offline)
_last_offline = set(offline_now)
if newly_offline:
notifier.notify("device.offline", serials=newly_offline,
count=len(newly_offline),
devices=newly_offline[:10])
if back:
notifier.notify("device.online", serials=list(back), count=len(back))
if added_serials:
notifier.notify("device.discovered", serials=added_serials[:10],
count=len(added_serials))
if claimed:
notifier.notify("device.claimed",
pairs=[f"{c[0]}→{c[1]}" for c in claimed][:10],
count=len(claimed))
except Exception as e:
_log.warning(f"发送设备上下线通知失败(不影响扫描): {e}")
_last_scan = (now, len(open_ips), len(verified), added) _last_scan = (now, len(open_ips), len(verified), added)
_last_error = "" _last_error = ""
return True, {"found": len(open_ips), "verified": len(verified), return True, {"found": len(open_ips), "verified": len(verified),
+11 -1
View File
@@ -19,6 +19,7 @@ import uiautomator2 as u2
from config import USB_ADB_HOST, USB_ADB_PORT from config import USB_ADB_HOST, USB_ADB_PORT
from core.logger import get_logger from core.logger import get_logger
from core import device_pool from core import device_pool
from core import notifier
from .adb_helper import _adb, adb_connect, _adb_remote from .adb_helper import _adb, adb_connect, _adb_remote
_log = get_logger("core.worker") _log = get_logger("core.worker")
@@ -188,9 +189,18 @@ class _Watchdog(threading.Thread):
if hb and now - hb > _HEARTBEAT_TIMEOUT: if hb and now - hb > _HEARTBEAT_TIMEOUT:
stale.append(serial) stale.append(serial)
for serial in stale: for serial in stale:
_log.error(f"[{serial}] 心跳超时 {now - get_worker_heartbeat(serial):.0f}s,标记卡死") hb_age = now - get_worker_heartbeat(serial)
_log.error(f"[{serial}] 心跳超时 {hb_age:.0f}s,标记卡死")
with _WORKERS_LOCK:
cur = dict(_WORKERS.get(serial, {}))
_update_status(serial, status="error", _update_status(serial, status="error",
last_error=f"心跳超时 {_HEARTBEAT_TIMEOUT}s,worker 可能卡死") last_error=f"心跳超时 {_HEARTBEAT_TIMEOUT}s,worker 可能卡死")
# 设备卡死是最需要人工介入的静默故障——通知一次
# (天然只报一次:下面的检查只针对 running/connecting,标成 error 后不再命中)
notifier.notify("device.heartbeat_timeout", serial=serial,
device_name=cur.get("device_name") or "",
timeout_s=int(hb_age), task_job=cur.get("task_job") or "",
model=cur.get("model") or "")
def stop(self): def stop(self):
self._stop.set() self._stop.set()
+2
View File
@@ -31,6 +31,8 @@ _MODULE_FILES = {
"task": "task.log", "task": "task.log",
"web": "web.log", "web": "web.log",
"action": "action.log", "action": "action.log",
# 通知单独一个文件:排障时"哪条通知发了/失败了/为什么"要能一眼翻到
"notify": "notify.log",
} }
_FORMAT = "%(asctime)s [%(levelname)s] [%(name)s] %(message)s" _FORMAT = "%(asctime)s [%(levelname)s] [%(name)s] %(message)s"
+1081
View File
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
"""通知事件目录(**唯一真相**)。
约定:
- **key 点分层**:`模块.对象.动作`(如 `task.device.failed`),支持通配订阅
(`task.*`、`device.*`、`*`),匹配规则见 `match()`。
- **登记 ≠ 会发**:所有事件 `default` 一律 False —— 用户在「系统 → 通知」里
按 webhook 勾选才会推送。`recommend` 只用于界面高亮"建议开启",不改默认值。
- **聚合**:`agg_window` 秒内的同 `(hook, event, agg_key 取值)` 事件合并成一条
(保留前几个样本);`agg_window=0` = 不聚合,立即发(低频高危事件用它)。
- **fields**:该事件保证携带的上下文字段,供前端画字段表、也是模板占位符白名单。
新增事件:在 `EVENTS` 里加一条 + 在触发点调 `notifier.notify(key, **fields)`,
并同步 `doc/NOTIFY.md` 的事件表(见 doc/README.md 的文档同步红线)。
"""
from collections import namedtuple
# key, label, category, default, recommend, agg_window, agg_key, fields, desc
EventDef = namedtuple(
"EventDef",
"key label category default recommend agg_window agg_key fields desc")
def _e(key, label, category, fields, desc="", agg_window=30, agg_key=None,
recommend=False):
"""构造一条事件定义(default 恒为 False:登记不等于推送)。"""
return EventDef(key, label, category, False, recommend, agg_window,
agg_key, tuple(fields), desc)
# 聚合键:按"任务批次"合并(同一次任务的多台设备结果合成一条)
_BY_JOB = ("job_id",)
# 按"设备"合并(一台设备的重复抖动合成一条)
_BY_SERIAL = ("serial",)
EVENTS = [
# ---------------- 任务批次 ----------------
_e("task.batch.started", "任务批次开始", "任务批次",
["job_id", "job_name", "task_type", "device_count", "serials_preview"],
"一次任务开始铺开到 N 台设备", agg_window=0, recommend=True),
_e("task.batch.finished", "任务批次结束", "任务批次",
["job_id", "job_name", "total", "success", "failed", "stopped", "duration_s"],
"整批跑完(含成功/失败/被停台数与耗时)", agg_window=0, recommend=True),
_e("task.batch.no_device", "任务无可用设备", "任务批次",
["job_id", "job_name", "target"],
"触发时一台可用设备都没有(任务空跑)", agg_window=0, recommend=True),
_e("task.batch.unknown_type", "任务类型不存在", "任务批次",
["job_id", "job_name", "task_type"],
"任务类型已从代码里删掉,任务永远不会执行", agg_window=0, recommend=True),
_e("task.cron.stopped", "定时停止任务", "任务批次",
["job_id", "job_name", "stopped_count", "serials"],
"cron_stop 到点,停掉了正在跑的设备", agg_window=0),
# ---------------- 任务 · 单设备(权威结论点) ----------------
_e("task.device.success", "设备任务成功", "任务·单设备",
["serial", "device_name", "job_id", "job_name", "attempt", "duration_s"],
"某台设备上的任务最终成功", agg_key=_BY_JOB, recommend=True),
_e("task.device.failed", "设备任务失败", "任务·单设备",
["serial", "device_name", "job_id", "job_name", "attempts", "cause", "msg"],
"某台设备上的任务最终失败(带真实原因)", agg_key=_BY_JOB, recommend=True),
_e("task.device.offline", "设备离线放弃", "任务·单设备",
["serial", "device_name", "job_id", "job_name", "error"],
"设备离线,不重试直接失败", agg_key=_BY_JOB, recommend=True),
_e("task.device.error", "单次尝试异常", "任务·单设备",
["serial", "device_name", "job_name", "attempt", "error"],
"某次尝试抛异常(后面还会重试,噪音较大)", agg_key=_BY_JOB),
_e("task.device.retry", "设备任务重试", "任务·单设备",
["serial", "device_name", "job_name", "attempt", "next_attempt", "delay_s", "reason"],
"即将重试", agg_key=_BY_JOB),
_e("task.device.stopped", "设备任务被停止", "任务·单设备",
["serial", "device_name", "job_name", "attempt", "phase"],
"用户手动停止 / cron 停止", agg_key=_BY_JOB),
_e("task.device.preempted", "设备被抢占", "任务·单设备",
["serial", "device_name", "job_name", "preempted_job_id", "preempted_job_name"],
"本任务抢占了该设备上正在跑的其他任务", agg_key=_BY_JOB, recommend=True),
_e("task.device.preempt_timeout", "抢占超时跳过", "任务·单设备",
["serial", "device_name", "job_name", "preempted_job_id"],
"等被抢占任务退出超时,本设备放弃执行", agg_window=0, recommend=True),
_e("task.device.released", "抢占结束归还", "任务·单设备",
["serial", "device_name", "job_name", "preempted_job_id", "returned", "reason"],
"抢占结束时重新拉起被抢占的任务", agg_key=_BY_JOB),
# ---------------- Worker 状态机(单次 attempt 级,默认关) ----------------
_e("worker.connected", "设备已连接", "Worker",
["serial", "model", "remote_adb_url"],
"worker 连上设备(单次尝试级,噪音大)"),
_e("worker.attempt.done", "单次执行完成", "Worker",
["serial", "task_type", "duration_s", "max_duration_hit"],
"单次 attempt 正常跑完(不代表任务最终成功)"),
_e("worker.attempt.error", "单次执行出错", "Worker",
["serial", "task_type", "error", "transient"],
"单次 attempt 抛异常"),
# ---------------- 业务(任务内容) ----------------
_e("task.selector.invalid", "选择器连续失效", "业务",
["serial", "device_name", "selector", "miss_count"],
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
recommend=True),
# ---------------- 设备 ----------------
_e("device.online", "设备恢复在线", "设备",
["serials", "count"],
"原本断联的设备又能连上了", agg_window=0, recommend=True),
_e("device.offline", "设备断联", "设备",
["serials", "count", "devices"],
"设备池里的设备连不上了", agg_window=0, recommend=True),
_e("device.discovered", "发现新设备", "设备",
["serials", "count"],
"扫描到尚未在池内的设备(待认领)", agg_window=0),
_e("device.claimed", "设备自动认领", "设备",
["pairs", "count"],
"指纹匹配成功,自动把旧记录迁到新地址", agg_window=0),
_e("device.heartbeat_timeout", "心跳超时", "设备",
["serial", "device_name", "timeout_s", "task_job", "model"],
"设备卡死(长时间没心跳),任务可能已中断", agg_window=0, recommend=True),
# ---------------- 应用安装 ----------------
_e("apk.install.started", "应用安装开始", "安装",
["apk_id", "apk_name", "package_name", "total"],
"开始往 N 台设备推装", agg_window=0),
_e("apk.install.finished", "应用安装完成", "安装",
["apk_name", "success", "failed", "skipped", "total", "failed_items"],
"批量安装结束(含失败台数与原因)", agg_window=0, recommend=True),
# ---------------- 系统 ----------------
_e("system.backup.exported", "备份已导出", "系统",
["filename", "size", "tables", "include_apk", "user"],
"有人导出了整库备份", agg_window=0, recommend=True),
_e("system.backup.imported", "备份导入已挂起", "系统",
["token_prefix", "env_label", "force"],
"上传了备份并确认导入(重启后生效)", agg_window=0),
_e("system.backup.restored", "备份已恢复", "系统",
["applied_rows", "schema_version"],
"启动时应用了待恢复的备份(所以这条是重启后才发)", agg_window=0,
recommend=True),
_e("system.backup.restore_failed", "备份恢复失败", "系统",
["error", "fail_dir"],
"恢复校验没过,已搁置(数据未被改动)", agg_window=0, recommend=True),
_e("service.started", "服务已启动", "系统",
["version", "env", "db_target", "device_count", "job_count"],
"平台进程起来了", agg_window=0, recommend=True),
_e("service.stopping", "服务正在停止", "系统",
["uptime_s"],
"平台进程收到退出信号", agg_window=0),
_e("user.login", "用户登录", "系统",
["username"],
"有人登录了后台", agg_window=0),
# ---------------- AI ----------------
_e("ai.audit.finished", "经验巡检完成", "AI",
["reviewed", "suggested", "summary"],
"每日经验库巡检跑完(或手动触发)", agg_window=0, recommend=True),
_e("ai.audit.failed", "经验巡检异常", "AI",
["error"],
"巡检过程中报错", agg_window=0),
_e("ai.audit.skipped", "经验巡检跳过", "AI",
["reason"],
"没配模型 Key 等原因跳过巡检", agg_window=0),
# ---------------- 其它 ----------------
_e("notify.test", "测试通知", "其它",
["hook_name", "operator"],
"「发送测试」按钮专用", agg_window=0),
]
BY_KEY = {e.key: e for e in EVENTS}
# 界面上的分组顺序
CATEGORIES = ("任务批次", "任务·单设备", "业务", "设备", "安装", "系统", "AI",
"Worker", "其它")
def get(key):
"""按 key 取事件定义;未知 key 返回 None(未知事件会被 notify 丢弃)。"""
return BY_KEY.get(key)
def match(pattern, key):
"""事件订阅匹配。
- `*` → 全部
- `task.*` → task 下所有(含 task.device.failed)
- `task.device.*` → 任意层级前缀匹配
- `task.device.failed` → 精确
"""
if not pattern:
return False
if pattern == "*" or pattern == key:
return True
if pattern.endswith(".*"):
return key.startswith(pattern[:-1]) # "task.*" → "task."
return False
def list_events():
"""给接口/前端用的事件目录(按 CATEGORIES 排序)。"""
order = {c: i for i, c in enumerate(CATEGORIES)}
rows = [{"key": e.key, "label": e.label, "category": e.category,
"default": e.default, "recommend": e.recommend,
"agg_window": e.agg_window, "agg_key": list(e.agg_key or ()),
"fields": list(e.fields), "desc": e.desc}
for e in EVENTS]
rows.sort(key=lambda r: (order.get(r["category"], 99), r["key"]))
return rows
+6
View File
@@ -36,6 +36,7 @@ from config import (DATA_DIR, APK_DIR, BACKUP_DIR,
RESTORE_STAGING_DIR, RESTORE_PENDING_DIR) RESTORE_STAGING_DIR, RESTORE_PENDING_DIR)
from core.logger import get_logger from core.logger import get_logger
from core.models import CURRENT_SCHEMA_VERSION, db from core.models import CURRENT_SCHEMA_VERSION, db
from core import notifier
_log = get_logger("core.backup") _log = get_logger("core.backup")
@@ -547,6 +548,8 @@ def consume_pending_restore():
shutil.move(pending_db, os.path.join(fail_dir, "users.db")) shutil.move(pending_db, os.path.join(fail_dir, "users.db"))
remove_quiet(RESTORE_PENDING_DIR) remove_quiet(RESTORE_PENDING_DIR)
_log.error(f"恢复任务校验失败,已搁置到 {fail_dir},继续使用当前库: {msg}") _log.error(f"恢复任务校验失败,已搁置到 {fail_dir},继续使用当前库: {msg}")
notifier.notify("system.backup.restore_failed", error=str(msg)[:200],
fail_dir=fail_dir)
return False return False
try: try:
@@ -569,6 +572,9 @@ def consume_pending_restore():
remove_quiet(RESTORE_PENDING_DIR) remove_quiet(RESTORE_PENDING_DIR)
schema_version = msg.get("schema_version", "?") if isinstance(msg, dict) else "?" schema_version = msg.get("schema_version", "?") if isinstance(msg, dict) else "?"
_log.info(f"已应用备份恢复(单事务替换): {applied},schema v{schema_version},apks 已合并") _log.info(f"已应用备份恢复(单事务替换): {applied},schema v{schema_version},apks 已合并")
# 通知在"下一次启动"才发得出去(恢复本身是重启生效的),文案里说明白
notifier.notify("system.backup.restored", applied_rows=applied,
schema_version=schema_version)
return True return True
+138 -8
View File
@@ -21,6 +21,7 @@ import json
import time import time
import uuid import uuid
import threading import threading
from collections import Counter
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -31,6 +32,7 @@ from config import DATA_DIR
from core.logger import get_logger from core.logger import get_logger
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
from core import device_pool from core import device_pool
from core import notifier
from .adb_helper import get_foreground_app, get_foreground_app_remote from .adb_helper import get_foreground_app, get_foreground_app_remote
from .device_worker import ( from .device_worker import (
DeviceOfflineError, DeviceOfflineError,
@@ -47,6 +49,42 @@ _log = get_logger("core.tm")
_START_STAGGER_SEC = 0.2 _START_STAGGER_SEC = 0.2
class _BatchTracker:
"""一次任务批次的收尾统计:**所有设备都出结果**后发一条 `task.batch.finished`。
为什么不每台设备各发一条"批次结束":100 台设备就是 100 条刷屏。这里只计数,
归零那一刻发一条汇总(锁外发,见 done())。
每台设备的结果由 `_run_with_retry` 的 finally 调一次 `done(serial, outcome)`——
放在 finally 里是为了保证"任何一个 return 分支都会被计数一次",不会漏也不会重。
"""
def __init__(self, job, total, names=None):
self.job = job
self.total = total
# serial -> 设备名(通知里显示名字而不是裸地址);取不到就是空
self.names = dict(names or {})
self._lock = threading.Lock()
self._left = total
self._stats = Counter()
self._t0 = time.time()
def done(self, serial, outcome="failed"):
"""登记一台设备的结果(success/failed/stopped/skipped)。"""
with self._lock:
self._left -= 1
self._stats[outcome] += 1
left = self._left
s = dict(self._stats)
if left > 0:
return
notifier.notify("task.batch.finished", job_id=self.job.id,
job_name=self.job.name, total=self.total,
success=s.get("success", 0), failed=s.get("failed", 0),
stopped=s.get("stopped", 0), skipped=s.get("skipped", 0),
duration_s=int(time.time() - self._t0))
def _in_run_window(schedule, now=None): def _in_run_window(schedule, now=None):
"""是否在当前运行窗口内。 """是否在当前运行窗口内。
@@ -633,6 +671,8 @@ class TaskManager:
w.stop() w.stop()
stopped.append(serial) stopped.append(serial)
_log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}") _log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}")
notifier.notify("task.cron.stopped", job_id=job.id, job_name=job.name,
stopped_count=len(stopped), serials=stopped[:10])
def next_run_of(self, job): def next_run_of(self, job):
"""任务下次真正执行的时间(考虑运行窗口)。停用/无 cron 返回 None。""" """任务下次真正执行的时间(考虑运行窗口)。停用/无 cron 返回 None。"""
@@ -664,23 +704,42 @@ class TaskManager:
serials = job.resolve_serials(self) serials = job.resolve_serials(self)
if not serials: if not serials:
_log.warning(f"任务 {job.name} 无可用设备") _log.warning(f"任务 {job.name} 无可用设备")
notifier.notify("task.batch.no_device", job_id=job.id, job_name=job.name,
target=job.target)
return return
task_cls = get_task_class(job.task_type) task_cls = get_task_class(job.task_type)
if not task_cls: if not task_cls:
_log.error(f"未知任务类型: {job.task_type}") _log.error(f"未知任务类型: {job.task_type}")
notifier.notify("task.batch.unknown_type", job_id=job.id, job_name=job.name,
task_type=job.task_type)
return return
task = task_cls() task = task_cls()
max_attempts = max(1, job.retry.get("max_attempts", 1)) max_attempts = max(1, job.retry.get("max_attempts", 1))
delay = job.retry.get("delay", 60) delay = job.retry.get("delay", 60)
notifier.notify("task.batch.started", job_id=job.id, job_name=job.name,
task_type=job.task_type, device_count=len(serials),
serials_preview=serials[:5])
# 批次收尾统计:所有设备都出结果后发一条汇总(见 _BatchTracker)
# 设备名一次查好带下去(worker 线程里没有 app context,查库要显式包 context)
names = {}
try:
with self._db():
names = {d["serial"]: (d.get("name") or "")
for d in device_pool.list_devices()}
except Exception as e:
_log.warning(f"读取设备名失败(通知里将显示地址): {e}")
tracker = _BatchTracker(job, len(serials), names)
for idx, serial in enumerate(serials): for idx, serial in enumerate(serials):
# 每台设备一个重试循环线程,互不影响;错峰延迟在各自线程内等待 # 每台设备一个重试循环线程,互不影响;错峰延迟在各自线程内等待
t = threading.Thread(target=self._run_with_retry, t = threading.Thread(target=self._run_with_retry,
args=(task, serial, job, max_attempts, delay, args=(task, serial, job, max_attempts, delay,
idx * _START_STAGGER_SEC), daemon=True) idx * _START_STAGGER_SEC, tracker), daemon=True)
t.start() t.start()
def _run_with_retry(self, task, serial, job, max_attempts, delay, start_delay=0): def _run_with_retry(self, task, serial, job, max_attempts, delay, start_delay=0,
tracker=None):
"""单设备任务执行 + 重试。 """单设备任务执行 + 重试。
异常分类: 异常分类:
@@ -691,36 +750,58 @@ class TaskManager:
# 错峰启动:在各自线程内等待,分摊批量启动的连接/占用压力 # 错峰启动:在各自线程内等待,分摊批量启动的连接/占用压力
if start_delay > 0: if start_delay > 0:
time.sleep(start_delay) time.sleep(start_delay)
# 设备名(通知里显示"设备:A08"而不是裸地址)
dname = (tracker.names.get(serial) if tracker is not None else "") or serial
# 被抢占的原任务 id(抢占结束后归还)——必须在循环外: # 被抢占的原任务 id(抢占结束后归还)——必须在循环外:
# 重试时若重置为 None,finally 归还逻辑会丢失信息,被抢占任务永不恢复 # 重试时若重置为 None,finally 归还逻辑会丢失信息,被抢占任务永不恢复
preempted_job = None preempted_job = None
# 本台设备的结果(供批次统计与通知用)。默认 "failed":走到重试耗尽就是失败;
# 各提前 return 的分支会覆盖它。**在 finally 里统一上报**,保证不漏不重。
outcome = "failed"
try: try:
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
# 用户已请求停止 → 不再启动新 attempt # 用户已请求停止 → 不再启动新 attempt
stopped_by_user = False
with self._lock: with self._lock:
if serial in self._stop_requested: if serial in self._stop_requested:
_log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})") _log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})")
stopped_by_user = True
if stopped_by_user:
outcome = "stopped"
notifier.notify("task.device.stopped", serial=serial, device_name=dname,
job_id=job.id, job_name=job.name, attempt=attempt,
phase="start")
return return
# 同一 serial 同时只能一个 worker;不同任务可配置抢占 # 同一 serial 同时只能一个 worker;不同任务可配置抢占
preempt = False preempt = False
skip_reason = ""
with self._lock: with self._lock:
if serial in self._running: if serial in self._running:
cur = self._running[serial] cur = self._running[serial]
if cur.get("job_id") == job.id: if cur.get("job_id") == job.id:
# 同一任务重复触发:跳过(原行为) # 同一任务重复触发:跳过(原行为)
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})") _log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
return skip_reason = "同一任务已在运行"
if not job.params.get("preempt"): elif not job.params.get("preempt"):
# 不同任务且本任务未开启抢占:跳过 # 不同任务且本任务未开启抢占:跳过
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})") _log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
return skip_reason = "设备被其他任务占用"
else:
preempt = True preempt = True
if not preempted_job: if not preempted_job:
preempted_job = cur.get("job_id") preempted_job = cur.get("job_id")
if skip_reason:
outcome = "skipped"
return
if preempt: if preempt:
# 抢占:锁外停止该设备上的其他任务(stop_device 内部拿同一把锁, # 抢占:锁外停止该设备上的其他任务(stop_device 内部拿同一把锁,
# 在锁内调用会死锁!),等其释放后接管 # 在锁内调用会死锁!),等其释放后接管
_log.warning(f"{serial} 抢占:停止任务 {preempted_job} 后执行 {job.name}") _log.warning(f"{serial} 抢占:停止任务 {preempted_job} 后执行 {job.name}")
notifier.notify("task.device.preempted", serial=serial, device_name=dname,
job_id=job.id, job_name=job.name,
preempted_job_id=preempted_job,
preempted_job_name=(self.jobs.get(preempted_job).name
if self.jobs.get(preempted_job) else ""))
self.stop_device(serial) self.stop_device(serial)
deadline = time.time() + 30 deadline = time.time() + 30
while time.time() < deadline: while time.time() < deadline:
@@ -730,6 +811,10 @@ class TaskManager:
time.sleep(0.5) time.sleep(0.5)
else: else:
_log.warning(f"{serial} 抢占超时(旧任务 30s 未退出),跳过 (job={job.name})") _log.warning(f"{serial} 抢占超时(旧任务 30s 未退出),跳过 (job={job.name})")
outcome = "skipped"
notifier.notify("task.device.preempt_timeout", serial=serial,
device_name=dname, job_id=job.id, job_name=job.name,
preempted_job_id=preempted_job)
return return
with self._lock: with self._lock:
self._running[serial] = {"job_id": job.id, "started_at": time.time(), self._running[serial] = {"job_id": job.id, "started_at": time.time(),
@@ -739,6 +824,7 @@ class TaskManager:
max_attempts=max_attempts) max_attempts=max_attempts)
worker = None worker = None
t_attempt = time.time() # 单次 attempt 耗时(通知里带)
try: try:
worker = task.create_worker(serial, job.params) worker = task.create_worker(serial, job.params)
with self._lock: with self._lock:
@@ -756,11 +842,22 @@ class TaskManager:
_update_status(serial, task_job="") _update_status(serial, task_job="")
if st == "done": if st == "done":
_log.info(f"{serial} 任务 {job.name} 成功完成") _log.info(f"{serial} 任务 {job.name} 成功完成")
outcome = "success"
notifier.notify("task.device.success", serial=serial,
device_name=dname, job_id=job.id,
job_name=job.name, attempt=attempt,
duration_s=int(time.time() - t_attempt),
is_time_up=bool(worker.is_time_up()))
return return
# 用户请求停止(无论 attempt 第几次、status 是什么)→ 不重试 # 用户请求停止(无论 attempt 第几次、status 是什么)→ 不重试
with self._lock: with self._lock:
if serial in self._stop_requested: if serial in self._stop_requested:
_log.info(f"{serial} 用户已请求停止,不再重试 (job={job.name})") _log.info(f"{serial} 用户已请求停止,不再重试 (job={job.name})")
outcome = "stopped"
notifier.notify("task.device.stopped", serial=serial,
device_name=dname, job_id=job.id,
job_name=job.name, attempt=attempt,
phase="after")
return return
_log.warning(f"{serial} 任务未成功(status={st})") _log.warning(f"{serial} 任务未成功(status={st})")
except DeviceOfflineError as e: except DeviceOfflineError as e:
@@ -770,30 +867,46 @@ class TaskManager:
self._running.pop(serial, None) self._running.pop(serial, None)
_update_status(serial, status="failed", task_job="", _update_status(serial, status="failed", task_job="",
last_error=f"设备离线: {e}") last_error=f"设备离线: {e}")
outcome = "failed"
notifier.notify("task.device.offline", serial=serial, device_name=dname,
job_id=job.id, job_name=job.name, error=str(e)[:200])
return return
except Exception as e: except Exception as e:
_log.error(f"{serial} 执行异常: {e}", exc_info=True) _log.error(f"{serial} 执行异常: {e}", exc_info=True)
with self._lock: with self._lock:
self._running.pop(serial, None) self._running.pop(serial, None)
_update_status(serial, task_job="") _update_status(serial, task_job="")
notifier.notify("task.device.error", serial=serial, device_name=dname,
job_name=job.name, attempt=attempt,
error=str(e)[:200])
if attempt < max_attempts: if attempt < max_attempts:
# 用户在 sleep 期间点停止也能中断 # 用户在 sleep 期间点停止也能中断
with self._lock: with self._lock:
if serial in self._stop_requested: if serial in self._stop_requested:
_log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})") _log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})")
outcome = "stopped"
notifier.notify("task.device.stopped", serial=serial,
device_name=dname, job_name=job.name,
attempt=attempt, phase="retry-wait")
return return
# 检查是否是端口耗尽类临时错误,需要更长退避等端口释放 # 检查是否是端口耗尽类临时错误,需要更长退避等端口释放
with _WORKERS_LOCK: with _WORKERS_LOCK:
err = _WORKERS.get(serial, {}).get("last_error", "") err = _WORKERS.get(serial, {}).get("last_error", "")
if err.startswith("[transient]"): transient = err.startswith("[transient]")
if transient:
# Windows TCP 端口耗尽,TIME_WAIT 默认 2-4 分钟,等 120 秒 # Windows TCP 端口耗尽,TIME_WAIT 默认 2-4 分钟,等 120 秒
extra_delay = max(delay, 120) extra_delay = max(delay, 120)
_log.info(f"{serial} ADB 连接临时错误(端口耗尽),{extra_delay}s 后重试 ({attempt+1}/{max_attempts})") _log.info(f"{serial} ADB 连接临时错误(端口耗尽),{extra_delay}s 后重试 ({attempt+1}/{max_attempts})")
time.sleep(extra_delay) wait = extra_delay
else: else:
_log.info(f"{serial} {delay}s 后重试 ({attempt+1}/{max_attempts})") _log.info(f"{serial} {delay}s 后重试 ({attempt+1}/{max_attempts})")
time.sleep(delay) wait = delay
notifier.notify("task.device.retry", serial=serial, device_name=dname,
job_name=job.name, attempt=attempt,
next_attempt=attempt + 1, delay_s=int(wait),
reason="transient" if transient else "normal")
time.sleep(wait)
_log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃") _log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃")
# 带上最后一次的真实失败原因——只写"重试N次失败"会让用户看不到为什么失败 # 带上最后一次的真实失败原因——只写"重试N次失败"会让用户看不到为什么失败
@@ -805,20 +918,37 @@ class TaskManager:
if cause and cause != msg: if cause and cause != msg:
msg += f":{cause}" msg += f":{cause}"
_update_status(serial, status="failed", last_error=msg[:200]) _update_status(serial, status="failed", last_error=msg[:200])
outcome = "failed"
notifier.notify("task.device.failed", serial=serial, device_name=dname,
job_id=job.id, job_name=job.name, attempts=max_attempts,
cause=cause, msg=msg)
finally: finally:
# 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务 # 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务
with self._lock: with self._lock:
self._stop_requested.discard(serial) self._stop_requested.discard(serial)
# 批次统计:**每台设备只在这里上报一次**(所有 return 分支都会走到 finally)
if tracker is not None:
try:
tracker.done(serial, outcome)
except Exception as e:
_log.warning(f"批次统计上报失败(不影响任务): {e}")
# 归还:本任务是抢占任务,结束后自动重新启动被抢占的原任务 # 归还:本任务是抢占任务,结束后自动重新启动被抢占的原任务
if preempted_job: if preempted_job:
j = self.jobs.get(preempted_job) j = self.jobs.get(preempted_job)
if j and j.enabled: if j and j.enabled:
_log.info(f"{serial} 抢占任务 {job.name} 结束,归还设备给任务 {j.name}") _log.info(f"{serial} 抢占任务 {job.name} 结束,归还设备给任务 {j.name}")
notifier.notify("task.device.released", serial=serial, device_name=dname,
job_name=job.name, preempted_job_id=preempted_job,
returned=True, reason="归还并重启被抢占任务")
threading.Thread(target=self._run_job, args=(j,), daemon=True).start() threading.Thread(target=self._run_job, args=(j,), daemon=True).start()
else: else:
# 打日志便于排查:被抢占任务已删除/停用时不会归还,但要知道原因 # 打日志便于排查:被抢占任务已删除/停用时不会归还,但要知道原因
_log.info(f"{serial} 抢占任务 {job.name} 结束," _log.info(f"{serial} 抢占任务 {job.name} 结束,"
f"被抢占任务 {preempted_job} {'已停用,不归还' if j else '已不存在,不归还'}") f"被抢占任务 {preempted_job} {'已停用,不归还' if j else '已不存在,不归还'}")
notifier.notify("task.device.released", serial=serial, device_name=dname,
job_name=job.name, preempted_job_id=preempted_job,
returned=False,
reason="被抢占任务已停用" if j else "被抢占任务已不存在")
# ---- 运行控制 ---- # ---- 运行控制 ----
def stop_device(self, serial): def stop_device(self, serial):
+18 -2
View File
@@ -238,7 +238,23 @@
| POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) | | POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) |
| POST | `/api/system/backup/apply` | 应用恢复(重启生效) | | POST | `/api/system/backup/apply` | 应用恢复(重启生效) |
### 2.11 device_agent(`web/device_agent_api.py`) ### 2.11 notify(`web/notify_api.py`,全部 Admin)
通知 / Webhook 配置与发送记录(事件目录、推送格式、限流语义见 [NOTIFY.md](NOTIFY.md)):
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/api/notify/webhooks` | 全部 webhook(`url` 打码、`secret` 只回 `secret_set`)+ 格式清单 |
| POST | `/api/notify/webhooks` | 新建(body 见 NOTIFY.md §4) |
| PUT | `/api/notify/webhooks/<id>` | 更新(`url`/`secret` 省略或为打码值 → **保持原值**) |
| DELETE | `/api/notify/webhooks/<id>` | 删除 |
| POST | `/api/notify/webhooks/<id>/test` | **同步**发一条测试消息(不占业务令牌桶,单独限 10 次/分) |
| POST | `/api/notify/preview` | 预览真实请求体 + UTF-8 字节数 + 是否截断 |
| GET | `/api/notify/events` | 事件目录(含字段清单) |
| GET | `/api/notify/logs?limit=` | 最近发送记录(内存 200 条,重启清空) |
| POST | `/api/notify/settings` | 全局开关与默认聚合/限流 |
### 2.12 device_agent(`web/device_agent_api.py`)
**设备端专用**(无登录会话,靠 `X-Device-Token` 鉴权;平台未启用时统一 404): **设备端专用**(无登录会话,靠 `X-Device-Token` 鉴权;平台未启用时统一 404):
@@ -321,7 +337,7 @@
| `POST /api/stop_all` | — | 停止全部 | | `POST /api/stop_all` | — | 停止全部 |
| `POST /api/device/clear_error` | `{"serial":"…"}` | 运行中/重试等待中拒绝清理 | | `POST /api/device/clear_error` | `{"serial":"…"}` | 运行中/重试等待中拒绝清理 |
| `POST /api/device/clear_all_errors` | — | 跳过 running/connecting | | `POST /api/device/clear_all_errors` | — | 跳过 running/connecting |
| `POST /api/device/screen_all` | `{"mode":"on"\|"off", "serials":[…]}` | 不传 serials 则对全部在线设备 | | `POST /api/device/screen_all` | `{"mode":"on"\|"off", "serials":[…]}` | 不传 serials 则对全部在线设备。**熄屏是单向的**(`KEYCODE_SLEEP`),不会把已息屏的设备唤醒;亮屏用 `KEYCODE_WAKEUP` + `dismiss-keyguard` |
| `POST /api/scan_foreground` | — | 已扫描中时返回 `{"ok":false}`(**HTTP 200**) | | `POST /api/scan_foreground` | — | 已扫描中时返回 `{"ok":false}`(**HTTP 200**) |
| `POST /api/device/locate` | `{"serial":"…","show":true}` | `show=true` 时在设备上打开 `/locate` | | `POST /api/device/locate` | `{"serial":"…","show":true}` | `show=true` 时在设备上打开 `/locate` |
| `POST /api/device/locate/stop` | `{"serial":"…"}` | 结束定位 | | `POST /api/device/locate/stop` | `{"serial":"…"}` | 结束定位 |
+5
View File
@@ -30,6 +30,7 @@
│ │
┌───────────────────────────▼──────────────────────────────────────────┐ ┌───────────────────────────▼──────────────────────────────────────────┐
│ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │ │ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │
│ notifier(通知分发:队列/聚合/限流/适配器) │
│ models(SQLite)· logger · config │ │ models(SQLite)· logger · config │
└──────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────┘
▲ ▲
@@ -109,6 +110,8 @@
| `apk-install` | `ApkManager.install` | 并发 5 台安装 APK | 安装期,全局单任务 | | `apk-install` | `ApkManager.install` | 并发 5 台安装 APK | 安装期,全局单任务 |
| Agent 执行线程 | `web/agent_api.py` | AI 控制台一轮会话 | 按需 | | Agent 执行线程 | `web/agent_api.py` | AI 控制台一轮会话 | 按需 |
| 巡检手动线程 | `web/agent_api.py` | 手动触发巡检 | 按需 | | 巡检手动线程 | `web/agent_api.py` | 手动触发巡检 | 按需 |
| **通知 dispatcher** | `core/notifier.init_app` | 通知聚合 + 每 hook 限流 + 折叠摘要 | 常驻 1 个 |
| **通知 sender ×3** | 同上 | 真实发 webhook(退避重试、环形记录) | 常驻 3 个 |
两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。 两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。
@@ -391,5 +394,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| 加一张表 | `core/models.py` 模型 + `SCHEMA_MIGRATIONS`(老库) | **登记进备份覆盖清单** + [DATA_MODEL.md](DATA_MODEL.md)(红线) | | 加一张表 | `core/models.py` 模型 + `SCHEMA_MIGRATIONS`(老库) | **登记进备份覆盖清单** + [DATA_MODEL.md](DATA_MODEL.md)(红线) |
| 加一个常驻线程 | 参考 `device_discovery` 的 `init_app` / `shutdown` 模式 | 更新本文 §3 与 [DEVELOPMENT.md](DEVELOPMENT.md) | | 加一个常驻线程 | 参考 `device_discovery` 的 `init_app` / `shutdown` 模式 | 更新本文 §3 与 [DEVELOPMENT.md](DEVELOPMENT.md) |
| 加一个前端模块 | `static/admin/<name>.js` + 在 `monitor.html` 按序引入 | 更新本文 §6 与 [DEVELOPMENT.md](DEVELOPMENT.md) | | 加一个前端模块 | `static/admin/<name>.js` + 在 `monitor.html` 按序引入 | 更新本文 §6 与 [DEVELOPMENT.md](DEVELOPMENT.md) |
| **加一个通知事件** | `core/notify_events.py` 的 `EVENTS` 加一条 + 触发点调 `notifier.notify(key, **fields)` | 事件目录要做全、默认不启用;**notify 必须放在锁外**(见 [NOTIFY.md](NOTIFY.md) §7) |
| 加一种通知格式 | `core/notifier.py` 的 `ADAPTERS` 注册一个 `BaseAdapter` 子类 | 补字节上限/默认限流;更新 [NOTIFY.md](NOTIFY.md) §5 |
| 加一个 MCP 工具 | `mcp_server/mcp_server.py`(需要时加 `direct_ops.py`) | 写操作要挂门控三连;更新 [MCP.md](MCP.md) | | 加一个 MCP 工具 | `mcp_server/mcp_server.py`(需要时加 `direct_ops.py`) | 写操作要挂门控三连;更新 [MCP.md](MCP.md) |
| 加一个配置键 | `config.py`(程序级)或 `.env`(密钥类) | 更新 `.env.example` + [DEVELOPMENT.md](DEVELOPMENT.md) 配置速查 | | 加一个配置键 | `config.py`(程序级)或 `.env`(密钥类) | 更新 `.env.example` + [DEVELOPMENT.md](DEVELOPMENT.md) 配置速查 |
+4
View File
@@ -240,8 +240,12 @@ UTF-8 等价于字节序)。
| `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 | | `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 |
| `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 | | `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 |
| `deployment_claimed_at` | 标签写入时间 | 同上 | | `deployment_claimed_at` | 标签写入时间 | 同上 |
| `notify_webhooks` | **通知 / Webhook 全部配置**(JSON:`{version, settings, webhooks[]}`,见 [NOTIFY.md](NOTIFY.md) §4)。**不建表**——新增/删除 webhook 都只改这一个键 | 系统 → 通知 页 |
> ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。 > ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。
> **同理 `notify_webhooks` 里的 webhook URL 本身就是凭据**(企业微信 `?key=`、钉钉 `?access_token=`、
> 飞书 `/hook/<token>`):拿到它就能往群里发消息。接口回显/发送记录/日志一律走
> `notifier.mask_url()/scrub()` 打码,导出备份时也按敏感信息对待。
> `app_meta` 的列名 `key` 在 MySQL 里是保留字,**不要直接拼裸 SQL**,统一走 > `app_meta` 的列名 `key` 在 MySQL 里是保留字,**不要直接拼裸 SQL**,统一走
> `core/db_config.meta_get / meta_set`(方言中立、自动加引号)。 > `core/db_config.meta_get / meta_set`(方言中立、自动加引号)。
+193
View File
@@ -0,0 +1,193 @@
# 通知 / 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` 是**重启后**才发(恢复本身就是重启生效的)
+1
View File
@@ -18,6 +18,7 @@
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 | | [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 | | [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
| [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:AI 自己在真机探索 → 写出可调度任务 → 人工确认入库(P0 已实现;契约与红线) | 平台开发者、使用者 | | [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 仓库共享的契约 | 设备端开发者、平台开发者 | | [DEVICE_AGENT.md](DEVICE_AGENT.md) | **设备端 Agent 接口契约**:应用商店的设备专用接口(清单/下载/上报)、adb 指令协议、版本约定 —— 与设备端 APK 仓库共享的契约 | 设备端开发者、平台开发者 |
| [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 | | [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 |
| [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 | | [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 |
+1 -1
View File
@@ -118,7 +118,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
| 2 | `stop_app` | 结束 App | `package` | — | — | `app_stop`(`am force-stop`,下次打开是冷启动) | | 2 | `stop_app` | 结束 App | `package` | — | — | `app_stop`(`am force-stop`,下次打开是冷启动) |
| 3 | `screen_on` | 亮屏 | — | — | — | `d.unlock()`(息屏时唤醒并滑动解锁) | | 3 | `screen_on` | 亮屏 | — | — | — | `d.unlock()`(息屏时唤醒并滑动解锁) |
| 4 | `screen_off` | 息屏 | — | — | — | `d.screen_off()` | | 4 | `screen_off` | 息屏 | — | — | — | `d.screen_off()` |
| 5 | `keep_screen` | 保持亮屏 | `mode`("on") | — | — | `mode="off"` → 恢复自动息屏;否则 `svc power stayon true`(充电时常亮,适合长任务) | | 5 | `keep_screen` | 保持亮屏 | `mode`("on") | — | — | `mode="off"` → 恢复自动息屏;否则**把系统息屏超时顶到最大**(`screen_off_timeout=2147483647`)+ `svc power stayon true` + 唤醒一次。⚠️ 只发 `svc power stayon true` 对**没插充电器**的设备(走 WiFi 的那些)**完全无效**——它管的是"充电时屏幕常亮",2026-09-14 就是因此被反馈"保持亮屏没用" |
| 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** | | 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** |
| 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50) | — | 时长取随机值;up = 中轴从 0.8h → 0.2h(down 反向,left/right 同理);**非法 direction 静默不滑** | | 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50) | — | 时长取随机值;up = 中轴从 0.8h → 0.2h(down 反向,left/right 同理);**非法 direction 静默不滑** |
| 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True) | — | 每轮滑动后 sleep 0.5s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 | | 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True) | — | 每轮滑动后 sleep 0.5s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 |
+1
View File
@@ -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-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)); 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==='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==='groups' && typeof loadGroups==='function') loadGroups();
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore(); if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
if(name==='devpool' && typeof loadDevPool==='function'){ if(name==='devpool' && typeof loadDevPool==='function'){
+1 -1
View File
@@ -291,7 +291,7 @@ var _stepEditor={
h+='<div class="help">息屏。任务结束后用,避免长时间亮屏导致烧屏/发热</div>'; h+='<div class="help">息屏。任务结束后用,避免长时间亮屏导致烧屏/发热</div>';
}else if(step.type==='keep_screen'){ }else if(step.type==='keep_screen'){
h+='<div class="form-group"><label>模式</label><select class="form-control" data-key="mode">'+ h+='<div class="form-group"><label>模式</label><select class="form-control" data-key="mode">'+
'<option value="on"'+(p.mode!=='off'?' selected':'')+'>保持亮屏(充电时屏幕常亮)</option>'+ '<option value="on"'+(p.mode!=='off'?' selected':'')+'>保持亮屏(把系统息屏超时顶到最大,不充电也不会黑屏)</option>'+
'<option value="off"'+(p.mode==='off'?' selected':'')+'>恢复自动息屏</option></select></div>'; '<option value="off"'+(p.mode==='off'?' selected':'')+'>恢复自动息屏</option></select></div>';
h+='<div class="help">长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"</div>'; h+='<div class="help">长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"</div>';
}else if(step.type==='swipe'){ }else if(step.type==='swipe'){
+384
View File
@@ -0,0 +1,384 @@
// 系统 → 通知:多 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.*)
let _notifyPrevFormat = ''; // 上一个选中格式(切换时判断限流值要不要跟着走)
let _notifyRateTouched = false; // 用户是否手动改过限流值(没改过就跟随格式的推荐值)
// ================== 加载与渲染 ==================
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 = '<tr><td colspan="6" class="empty">还没有配置 webhook。点「+ 新建 Webhook」加一条——'
+ '比如把「任务失败」推到企业微信群。</td></tr>';
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 '<tr>'
+ '<td>' + esc(h.name) + (h.secret_set ? ' <span class="label label-info" title="已配置密钥">🔑</span>' : '') + '</td>'
+ '<td>' + esc(fmt) + '</td>'
+ '<td style="max-width:280px;word-break:break-all;font-family:var(--mono);font-size:11px">'
+ esc(h.url || '') + '</td>'
+ '<td>' + evs + ' 个事件</td>'
+ '<td>' + (h.enabled ? '<span class="label label-success">启用</span>'
: '<span class="label">停用</span>') + '</td>'
+ '<td style="white-space:nowrap">'
+ '<button class="btn btn-xs" onclick="testNotifyHook(\'' + h.id + '\')" title="立刻发一条测试消息,看群里收不收得到">发送测试</button> '
+ '<button class="btn btn-xs" onclick="toggleNotifyHook(\'' + h.id + '\',' + (h.enabled ? 'false' : 'true') + ')">'
+ (h.enabled ? '停用' : '启用') + '</button> '
+ '<button class="btn btn-xs" onclick="openNotifyModal(\'' + h.id + '\')">编辑</button> '
+ '<button class="btn btn-xs btn-danger" onclick="deleteNotifyHook(\'' + h.id + '\')">删除</button>'
+ '</td></tr>';
}).join('');
}
function renderNotifyLogs(logs){
const tb = document.getElementById('tb-notify-logs');
if(!tb) return;
if(!logs.length){
tb.innerHTML = '<tr><td colspan="5" class="empty">本次运行还没有发送记录(重启后清空,历史见 logs/notify.log)</td></tr>';
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 ? '<span class="label label-success">成功</span>'
: '<span class="label label-warning">失败</span>';
const detail = r.ok ? ('HTTP ' + (r.http_status == null ? '' : r.http_status)
+ (r.errcode ? ' errcode ' + r.errcode : ''))
: esc(r.error || '');
return '<tr><td style="white-space:nowrap">' + esc(t) + '</td>'
+ '<td>' + esc(r.hook_name || '') + '</td>'
+ '<td style="font-family:var(--mono);font-size:11px">' + esc(r.event || '') + '</td>'
+ '<td>' + st + '</td>'
+ '<td style="font-size:11px">' + detail + (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '') + '</td></tr>';
}).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 '<option value="' + f.name + '"' + sel + (f.implemented ? '' : ' disabled') + '>'
+ esc(f.label) + '</option>';
}).join('');
const box = document.getElementById('notify-modal-box');
if(!box) return;
box.innerHTML =
'<div style="display:flex;align-items:center;margin-bottom:12px">'
+ '<div style="font-size:15px;font-weight:700;flex:1">'
+ (id ? '编辑 Webhook' : '新建 Webhook') + '</div>'
+ '<button class="btn btn-xs" title="关闭(不保存)" onclick="closeNotifyModal()">✕ 关闭</button>'
+ '</div>'
+ '<div class="form-row">'
+ '<div class="form-group"><label>名称</label>'
+ '<input id="nf-name" class="form-control" value="' + esc(h.name || '') + '" placeholder="如:运维群"></div>'
+ '<div class="form-group"><label>格式</label>'
+ '<select id="nf-format" class="form-control" onchange="_notifyFormatChanged()">' + fmtOpts + '</select></div>'
+ '</div>'
+ '<div class="form-group"><label>Webhook URL</label>'
// 占位与说明都由**当前格式**决定(见 _notifyFormatChanged),切换格式会跟着变
+ '<input id="nf-url" class="form-control" value="">'
+ '<div class="help" id="nf-url-help"></div></div>'
+ '<div class="form-row">'
+ '<div class="form-group"><label id="nf-secret-label">签名密钥(可选)</label>'
+ '<input id="nf-secret" type="password" class="form-control" placeholder="'
+ (h.secret_set ? '已配置,留空不修改' : '留空即可') + '">'
+ '<div class="help" id="nf-secret-help"></div></div>'
+ '<div class="form-group" style="max-width:150px"><label>聚合窗口(秒)</label>'
+ '<input id="nf-agg" type="number" class="form-control" min="0" max="600" value="'
+ (h.agg_window != null ? h.agg_window : (_notifySettings.default_agg_window || 30)) + '">'
+ '<div class="help">0=不聚合</div></div>'
+ '<div class="form-group" style="max-width:150px"><label>限流(条/分)</label>'
+ '<input id="nf-rate" type="number" class="form-control" min="0" max="60" '
+ 'oninput="_notifyRateTouched=true" value="'
+ (h.rate_limit_per_min != null ? h.rate_limit_per_min
: ((_notifyFormats.find(x => x.name === (h.format || 'wecom')) || {}).limit_default
|| _notifySettings.default_rate_limit || 18)) + '">'
+ '<div class="help" id="nf-limit-help"></div></div>'
+ '</div>'
+ '<div class="form-group"><label>订阅事件(不勾就不推)</label>'
+ '<div class="help" style="margin-bottom:6px">按类别分的完整事件目录;'
+ '带 <b>★</b> 的是建议开启的。也可以直接写通配(如 <code>task.*</code>)——在下面的芯片框里加。</div>'
+ '<div id="nf-events" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:10px"></div>'
+ '</div>'
+ '<div class="form-group" id="nf-pattern-wrap"><label>通配订阅(可选,任何格式都适用)</label>'
+ '<div id="nf-patterns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px"></div>'
+ '<div style="display:flex;gap:6px"><input id="nf-pattern-input" class="form-control" placeholder="task.* 或 device.* 或 *">'
+ '<button class="btn" onclick="addNotifyPattern()">添加</button></div></div>'
+ '<div class="form-group" id="nf-body-wrap" style="display:none"><label>请求体模板(JSON)</label>'
+ '<textarea id="nf-body" class="form-control" rows="5" placeholder=\'{"text":"{{title}}","desc":"{{summary}}"}\'></textarea>'
+ '<div class="help">占位符:{{event}} {{title}} {{summary}} {{ts}} {{level}} {{markdown}} {{fields}} {{fields_json}} {{hook_name}} {{field.名称}}。'
+ '保存前会做干跑校验(渲染后必须是合法 JSON)。Slack 可直接用:'
+ '<code>{"text":"{{markdown}}"}</code></div></div>'
+ '<div class="form-group">'
+ '<label>预览</label><div id="nf-preview" class="help">点「预览请求体」看真实会发出去的内容与字节数。</div>'
+ '</div>'
// 底部操作栏(缺了它就只能靠刷新页面逃出去)
+ '<div style="display:flex;gap:8px;align-items:center;margin-top:16px;padding-top:12px;border-top:1px solid var(--border)">'
+ '<button class="btn btn-primary" onclick="saveNotifyHook()">' + (id ? '保存' : '创建') + '</button>'
+ '<button class="btn" onclick="previewNotifyHook()">预览请求体</button>'
+ '<button class="btn" onclick="closeNotifyModal()">取消</button>'
+ '<span class="help" style="margin-left:auto">Esc 或点空白处也能关</span>'
+ '</div>';
box.querySelector('#nf-body').value = h.body_template || '';
renderEventTree(checked);
renderNotifyPatterns();
_notifyPrevFormat = (h.format || 'wecom');
_notifyRateTouched = (h.rate_limit_per_min != null); // 编辑已有配置时视为"已定"
_notifyFormatChanged();
document.getElementById('notify-modal-overlay').style.display = 'flex';
}
function _notifyFormatChanged(){
const name = (document.getElementById('nf-format') || {}).value;
const meta = _notifyFormats.find(x => x.name === name) || {};
const h = _notifyEditingId
? (_notifyHooks.find(x => x.id === _notifyEditingId) || {}) : {};
// URL:编辑时占位显示现有(打码)地址,新建时显示该格式的示例
const url = document.getElementById('nf-url');
if(url){
url.placeholder = (h.url && h.url !== undefined) ? h.url : (meta.url_hint || 'https://…');
}
const urlHelp = document.getElementById('nf-url-help');
if(urlHelp){
urlHelp.innerHTML = (h.url ? ('已配置:' + esc(h.url) + '(留空=不修改)<br>') : '')
+ esc(meta.url_help || '');
}
// 密钥:不同格式叫法/用途不同(企业微信不需要、Bark 是设备 Key、通用 JSON 是自定义头)
const sl = document.getElementById('nf-secret-label');
if(sl) sl.textContent = (h.secret_set ? '🔑 ' : '') + (meta.secret_label || '密钥(可选)');
const sh = document.getElementById('nf-secret-help');
if(sh) sh.innerHTML = esc(meta.secret_help || '')
+ (h.secret_set ? '<br>已配置过:留空=不修改,想清除请点上面的 🔑 后填写新值' : '');
// 限流:上限因格式而异(企微 20/分、Bark 无硬限)
const lh = document.getElementById('nf-limit-help');
if(lh) lh.textContent = meta.limit_help || '';
// 用户没手动改过限流值时,跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)
const rate = document.getElementById('nf-rate');
if(rate && !_notifyRateTouched && meta.limit_default){
rate.value = meta.limit_default;
}
// 请求体模板:只有需要模板的格式(通用 JSON)才显示
const bodyWrap = document.getElementById('nf-body-wrap');
if(bodyWrap) bodyWrap.style.display = meta.needs_template ? 'block' : 'none';
_notifyPrevFormat = name;
}
// Esc 关闭弹窗(点空白处关闭在 overlay 的 onclick 上)
document.addEventListener('keydown', function(ev){
if(ev.key !== 'Escape') return;
const ov = document.getElementById('notify-modal-overlay');
if(ov && ov.style.display !== 'none') closeNotifyModal();
});
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 '<label style="display:flex;gap:6px;align-items:flex-start;padding:3px 0;cursor:pointer" title="'
+ esc(e.desc || '') + '">'
+ '<input type="checkbox" class="nf-ev" value="' + esc(e.key) + '" id="' + id + '"'
+ (checked.indexOf(e.key) >= 0 ? ' checked' : '') + '>'
+ '<span style="flex:1"><b>' + esc(e.label) + '</b>'
+ (e.recommend ? ' <span class="label label-success">★建议</span>' : '')
+ ' <code style="font-size:10.5px;opacity:.7">' + esc(e.key) + '</code></span></label>';
}).join('');
return '<div style="margin-bottom:8px"><div style="font-weight:600;font-size:12px;margin-bottom:2px">'
+ esc(cat) + ' <button class="btn btn-xs" onclick="_notifyToggleCat(\'' + esc(cat) + '\')">全选/取消</button></div>'
+ body + '</div>';
}).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 '<span class="label label-info" style="cursor:pointer" title="点一下移除" onclick="removeNotifyPattern(' + i + ')">'
+ esc(p) + ' ×</span>';
}).join('') || '<span class="help">(无)</span>';
}
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 = '<span style="color:#dc2626">预览失败:' + esc((r && r.error) || '') + '</span>'; return; }
let html = '<div class="help">UTF-8 字节数:' + (r.bytes == null ? '-' : r.bytes)
+ (r.byte_limit ? ' / 上限 ' + r.byte_limit : '')
+ (r.truncated ? ' <b style="color:#b45309">(会截断)</b>' : '') + '</div>';
html += '<pre style="max-height:220px;overflow:auto;font-size:11px;background:#f6f7f9;padding:8px;border-radius:6px">'
+ esc(r.request_body || '') + '</pre>';
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');
});
}
+35 -5
View File
@@ -37,6 +37,7 @@ from tasks.base import BaseTask, register_task
from core.device_worker import BaseWorker, _update_status from core.device_worker import BaseWorker, _update_status
from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep
from core.logger import get_logger from core.logger import get_logger
from core import notifier
_log = get_logger("task.generic") _log = get_logger("task.generic")
@@ -45,6 +46,13 @@ _log = get_logger("task.generic")
_LEGACY_IDX_XPATH = re.compile(r'^(//\*\[@[^\]]+\])\[(\d+)\](.*)$') _LEGACY_IDX_XPATH = re.compile(r'^(//\*\[@[^\]]+\])\[(\d+)\](.*)$')
# ---- 「保持亮屏」用的系统设置 ----
# 系统息屏超时(毫秒):保持亮屏时顶到最大,恢复时写回原值
_SCREEN_TIMEOUT_MAX = "2147483647"
_SCREEN_TIMEOUT_DEFAULT = "600000" # 兜底恢复值:10 分钟(原值记不住时用)
_SCREEN_TIMEOUT_BACKUP = {} # serial -> 原始 screen_off_timeout
def _norm_legacy_xpath(sel_val): def _norm_legacy_xpath(sel_val):
"""把历史 `//*[@attr=…][k]` 纠正为 `(//*[@attr=…])[k]`(只改整体前缀,保留后续子路径)。 """把历史 `//*[@attr=…][k]` 纠正为 `(//*[@attr=…])[k]`(只改整体前缀,保留后续子路径)。
@@ -141,6 +149,10 @@ class GenericStepsWorker(BaseWorker):
_log.error(f"[{self.serial}] 选择器连续 {n} 次未找到元素,可能已失效(App 改版?):{sel_val}") _log.error(f"[{self.serial}] 选择器连续 {n} 次未找到元素,可能已失效(App 改版?):{sel_val}")
_update_status(self.serial, last_warning=f"选择器连续{n}次未命中") _update_status(self.serial, last_warning=f"选择器连续{n}次未命中")
self._miss_counts.pop(sel_val, None) # 已上报,重置避免刷屏 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): def run_task(self, d):
"""按 steps 顺序执行。loop 步骤递归执行其 children。""" """按 steps 顺序执行。loop 步骤递归执行其 children。"""
@@ -228,17 +240,35 @@ class GenericStepsWorker(BaseWorker):
def _exec_keep_screen(self, d, params, depth=0): def _exec_keep_screen(self, d, params, depth=0):
"""保持亮屏 / 恢复自动息屏。 """保持亮屏 / 恢复自动息屏。
长任务(养号看视频数小时)时屏幕会按系统超时自动熄灭导致任务无法执行, ⚠️ 只用 `svc power stayon true` **不管用**:它管的是「**充电时**屏幕常亮」
用 svc power stayon 让充电时屏幕常亮;任务结束前用 mode=off 恢复自动息屏。 (= stay_on_while_plugged_in)。设备走 WiFi 跑任务、没插充电器时它完全不起作用
——以前"保持亮屏"看着像没生效就是这个原因(2026-09-14 用户反馈)。
真正管用的是把系统**息屏超时**顶到最大(`screen_off_timeout`):不充电也不会
自动黑屏。进入时记下原值,`mode=off` 写回;进程重启丢了记录就写回常规的 10 分钟。
""" """
mode = params.get("mode", "on") mode = params.get("mode", "on")
try: try:
if mode == "off": if mode == "off":
d.shell("svc power stayon false") d.shell("svc power stayon false")
_log.info(f"[{self.serial}] 恢复自动息屏") orig = _SCREEN_TIMEOUT_BACKUP.pop(self.serial, _SCREEN_TIMEOUT_DEFAULT)
d.shell(f"settings put system screen_off_timeout {orig}")
_log.info(f"[{self.serial}] 恢复自动息屏(超时 {orig}ms)")
else: else:
d.shell("svc power stayon true") cur = ""
_log.info(f"[{self.serial}] 保持亮屏(充电时屏幕常亮)") try:
cur = (d.shell("settings get system screen_off_timeout") or "").strip()
except Exception:
pass
if self.serial not in _SCREEN_TIMEOUT_BACKUP:
# 没设过时 `settings get` 会返回 "null"(走系统默认)——别把 null 写回去
_SCREEN_TIMEOUT_BACKUP[self.serial] = \
cur if cur.isdigit() else _SCREEN_TIMEOUT_DEFAULT
d.shell("svc power stayon true") # 充电场景(顺带)
d.shell(f"settings put system screen_off_timeout {_SCREEN_TIMEOUT_MAX}")
d.shell("input keyevent 224") # 立刻唤醒(KEYCODE_WAKEUP,单向)
_log.info(f"[{self.serial}] 保持亮屏(息屏超时→最大,原值 "
f"{_SCREEN_TIMEOUT_BACKUP.get(self.serial)}ms)")
except Exception as e: except Exception as e:
_log.warning(f"[{self.serial}] keep_screen({mode}) 异常: {e}") _log.warning(f"[{self.serial}] keep_screen({mode}) 异常: {e}")
+47
View File
@@ -1112,6 +1112,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<div class="sub-tabs"> <div class="sub-tabs">
<button class="sub-tab active" data-sub="backup" onclick="showSubTab('system','backup')">数据备份</button> <button class="sub-tab active" data-sub="backup" onclick="showSubTab('system','backup')">数据备份</button>
<button class="sub-tab" data-sub="restore" onclick="showSubTab('system','restore')">导入恢复</button> <button class="sub-tab" data-sub="restore" onclick="showSubTab('system','restore')">导入恢复</button>
<button class="sub-tab" data-sub="notify" onclick="showSubTab('system','notify')">通知 / Webhook</button>
</div> </div>
<!-- 子分栏 1:数据备份 --> <!-- 子分栏 1:数据备份 -->
@@ -1151,6 +1152,51 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div> </div>
<div id="restore-preview" style="margin-top:8px"></div> <div id="restore-preview" style="margin-top:8px"></div>
</div> </div>
<!-- 子分栏 3:通知 / Webhook -->
<div class="sub-panel" id="system-sub-notify">
<div class="help">
<b>通知</b>:把平台事件(任务失败/成功、设备上下线、安装完成、备份恢复…)推到 webhook,
例如企业微信群机器人。每条 webhook <b>自己勾选关心哪些事件</b>——不勾就不推。
<br><span class="text-muted">为防刷屏:同一条 webhook 默认每 30 秒合并一次同类事件、每分钟最多 18 条
(企业微信硬限 20),超出的会压成一条「被限流折叠」摘要而不是丢掉。详见 <code>doc/NOTIFY.md</code>。</span>
</div>
<div class="toolbar" style="gap:10px;flex-wrap:wrap">
<button class="btn btn-primary" onclick="openNotifyModal(null)" data-perm="admin">+ 新建 Webhook</button>
<button class="btn" onclick="loadNotifyPanel()">刷新</button>
<label style="display:flex;align-items:center;gap:6px;font-size:12.5px;cursor:pointer"
title="关掉后所有 webhook 暂停推送(配置保留)">
<input type="checkbox" id="notify-global" onchange="toggleNotifyGlobal(this)"> 启用通知
</label>
<span class="text-muted" style="font-size:11.5px">默认聚合
<input type="number" id="notify-agg" min="0" max="600" class="form-control"
style="width:64px;display:inline-block"> 秒 ·
限流 <input type="number" id="notify-rate" min="0" max="60" class="form-control"
style="width:64px;display:inline-block"> 条/分
<button class="btn btn-xs" onclick="saveNotifyGlobal()">保存默认</button>
</span>
</div>
<table class="table">
<thead><tr><th>名称</th><th>格式</th><th>URL</th><th>订阅</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="tb-notify-hooks"></tbody>
</table>
<div style="margin-top:18px;display:flex;align-items:center;gap:10px">
<b>发送记录</b>
<span class="text-muted" style="font-size:11.5px">仅本次运行期间(重启清空);完整历史见 <code>logs/notify.log</code></span>
<button class="btn btn-xs" onclick="loadNotifyPanel()">刷新记录</button>
</div>
<table class="table">
<thead><tr><th style="width:160px">时间</th><th style="width:120px">Webhook</th><th style="width:180px">事件</th><th style="width:70px">结果</th><th>详情</th></tr></thead>
<tbody id="tb-notify-logs"></tbody>
</table>
</div>
</div>
<!-- 通知:编辑弹窗(内容由 static/admin/notify.js 渲染) -->
<div id="notify-modal-overlay" onclick="if(event.target===this)closeNotifyModal()"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1340;align-items:flex-start;justify-content:center;overflow-y:auto;padding:30px 0">
<div id="notify-modal-box" style="background:#fff;border-radius:14px;padding:20px;width:760px;max-width:94vw;margin:auto"></div>
</div> </div>
</div><!-- /.content --> </div><!-- /.content -->
@@ -1219,5 +1265,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<script src="/static/admin/agent.js"></script> <script src="/static/admin/agent.js"></script>
<script src="/static/admin/taskgen.js"></script> <script src="/static/admin/taskgen.js"></script>
<script src="/static/admin/system.js"></script> <script src="/static/admin/system.js"></script>
<script src="/static/admin/notify.js"></script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -11,6 +11,7 @@
tailscale — Tailscale 管理 tailscale — Tailscale 管理
system — 系统数据备份导出/导入 system — 系统数据备份导出/导入
device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置 device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置
notify — 通知 / Webhook 管理(配置、测试发送、发送记录、事件目录)
""" """
from flask import Blueprint from flask import Blueprint
@@ -30,7 +31,8 @@ def register_blueprints(app):
_agent_mod.set_app(app) _agent_mod.set_app(app)
from .system_api import bp as system_bp from .system_api import bp as system_bp
from .device_agent_api import bp as devagent_bp from .device_agent_api import bp as devagent_bp
from .notify_api import bp as notify_bp
for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp, for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp,
devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp, devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp,
devagent_bp): devagent_bp, notify_bp):
app.register_blueprint(bp) app.register_blueprint(bp)
+10
View File
@@ -836,6 +836,8 @@ def run_experience_audit():
cfg = _read_cfg() cfg = _read_cfg()
if not cfg.get("api_key"): if not cfg.get("api_key"):
_log.info("经验巡检跳过:未配置 API Key") _log.info("经验巡检跳过:未配置 API Key")
from core import notifier
notifier.notify("ai.audit.skipped", reason="未配置模型 API Key")
return return
rows = db.session.execute(db.text( rows = db.session.execute(db.text(
"SELECT id, task_prompt, recipe, tool_seq, hits FROM agent_experience " "SELECT id, task_prompt, recipe, tool_seq, hits FROM agent_experience "
@@ -868,8 +870,16 @@ def run_experience_audit():
summary = f"评审 {reviewed} 条,建议删除 {suggested} 条(待人工确认)" summary = f"评审 {reviewed} 条,建议删除 {suggested} 条(待人工确认)"
_audit_state.update(last=now, last_summary=summary) _audit_state.update(last=now, last_summary=summary)
_log.info("经验巡检完成: %s", summary) _log.info("经验巡检完成: %s", summary)
from core import notifier
notifier.notify("ai.audit.finished", reviewed=reviewed,
suggested=suggested, summary=summary)
except Exception as e: except Exception as e:
_log.warning(f"经验巡检异常: {e}") _log.warning(f"经验巡检异常: {e}")
try:
from core import notifier
notifier.notify("ai.audit.failed", error=str(e)[:200])
except Exception:
pass
finally: finally:
_audit_state["running"] = False _audit_state["running"] = False
+5
View File
@@ -144,6 +144,11 @@ def login():
login_user(user) login_user(user)
_csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取 _csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取
_log.info(f"用户 {username} 登录") _log.info(f"用户 {username} 登录")
try:
from core import notifier
notifier.notify("user.login", username=username)
except Exception:
pass
return redirect(request.args.get("next") or url_for("auth.index")) return redirect(request.args.get("next") or url_for("auth.index"))
return render_template("admin/login.html", error="用户名或密码错误") return render_template("admin/login.html", error="用户名或密码错误")
return render_template("admin/login.html", error=None) return render_template("admin/login.html", error=None)
+5 -2
View File
@@ -239,8 +239,11 @@ def api_device_screen_all():
if ":" in serial: if ":" in serial:
adb_connect(serial) adb_connect(serial)
if mode == "off": if mode == "off":
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "26"], # KEYCODE_SLEEP(223) 是**单向**熄屏;不能用 KEYCODE_POWER(26)——
capture_output=True, timeout=15) # KEYCODE_POWER 息屏 # 那是电源键**开关**,对已经息屏的设备会把屏幕点亮(2026-09-14 用户反馈:
# "一键息屏"把睡着的设备唤醒了,看着像按钮反了)
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "223"],
capture_output=True, timeout=15)
else: else:
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"], subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"],
capture_output=True, timeout=15) # WAKEUP capture_output=True, timeout=15) # WAKEUP
+190
View File
@@ -0,0 +1,190 @@
"""通知 / Webhook 管理接口(系统级设置,全部 admin_required)。
配置读写统一走 `core/notifier`(它落 `app_meta.notify_webhooks` 一个键)——本模块
只做"取配置 → 改一处 → 存回去"的编排,**校验与持久化都在 notifier 里**,不在 web 层
重复实现(避免两份规则漂移)。
安全口径:
- URL 里有凭据(企微 `?key=`)→ 回显一律 `notifier.mask_url()`;
- 前端"留空不修改":提交上来的 url 若是打码值或空,`save_config` 会保留原值;
- `secret` 永不回显,只回 `secret_set`。
"""
import uuid
from flask import Blueprint, jsonify, request
from flask_login import current_user
from core import notify_events, notifier
from core.logger import get_logger
from web.auth import admin_required
_log = get_logger("web.notify")
bp = Blueprint("notify", __name__)
def _operator():
try:
return current_user.username or "admin"
except Exception:
return "admin"
def _formats():
"""可用格式(含未实现的,前端据此置灰)。
每项带**该格式自己的提示文案**(URL 占位/说明、密钥标签、限流说明)——
这些必须跟着格式走,不能把企业微信的说明写死在页面上。
"""
out = [cls.meta() for _, cls in notifier.ADAPTERS.items()]
for name, label in notifier.PLANNED_FORMATS.items():
out.append({"name": name, "label": label + "(未实现)", "implemented": False,
"byte_limit": 0, "limit_default": 20, "needs_template": False,
"secret_label": "签名密钥(可选)", "secret_help": "",
"url_hint": "", "url_help": "该格式尚未实现,可先用「通用 JSON」手搓",
"limit_help": ""})
return out
@bp.route("/api/notify/webhooks")
@admin_required
def api_notify_webhooks():
"""全部 webhook 配置(url 打码、secret 只回是否配置过)+ 格式清单 + 事件目录。"""
cfg = notifier.get_public_config()
return jsonify({"ok": True, "webhooks": cfg["webhooks"], "settings": cfg["settings"],
"formats": _formats(), "meta_key": notifier.META_KEY,
"max_hooks": notifier.MAX_HOOKS,
"planned": list(notifier.PLANNED_FORMATS)})
@bp.route("/api/notify/webhooks", methods=["POST"])
@admin_required
def api_notify_webhook_create():
"""新建一条 webhook。body 即该条配置(见 doc/NOTIFY.md 的字段表)。"""
data = request.json or {}
cfg = notifier.get_config()
if len(cfg["webhooks"]) >= notifier.MAX_HOOKS:
return jsonify({"ok": False, "error": f"最多 {notifier.MAX_HOOKS} 条"}), 400
new_id = "wh_" + uuid.uuid4().hex[:8]
hook = dict(data)
hook["id"] = new_id
cfg["webhooks"].append(hook)
ok, msg = notifier.save_config(cfg)
if not ok:
return jsonify({"ok": False, "error": msg, "errors": msg
if isinstance(msg, list) else None}), 400
_log.info("新增通知 webhook: %s(%s)by %s", hook.get("name"), new_id, _operator())
return jsonify({"ok": True, "msg": "已创建", "id": new_id})
@bp.route("/api/notify/webhooks/<hook_id>", methods=["PUT"])
@admin_required
def api_notify_webhook_update(hook_id):
"""更新一条(部分字段;url/secret 省略或为打码值时保持原值)。"""
data = request.json or {}
cfg = notifier.get_config()
target = next((h for h in cfg["webhooks"] if h["id"] == hook_id), None)
if not target:
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
for k, v in data.items():
if k in ("id", "created_at"):
continue
target[k] = v
ok, msg = notifier.save_config(cfg)
if not ok:
return jsonify({"ok": False, "error": msg, "errors": msg
if isinstance(msg, list) else None}), 400
_log.info("更新通知 webhook: %s by %s", hook_id, _operator())
return jsonify({"ok": True, "msg": "已保存"})
@bp.route("/api/notify/webhooks/<hook_id>", methods=["DELETE"])
@admin_required
def api_notify_webhook_delete(hook_id):
cfg = notifier.get_config()
before = len(cfg["webhooks"])
cfg["webhooks"] = [h for h in cfg["webhooks"] if h["id"] != hook_id]
if len(cfg["webhooks"]) == before:
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
ok, msg = notifier.save_config(cfg)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
_log.info("删除通知 webhook: %s by %s", hook_id, _operator())
return jsonify({"ok": True, "msg": "已删除"})
@bp.route("/api/notify/webhooks/<hook_id>/test", methods=["POST"])
@admin_required
def api_notify_webhook_test(hook_id):
"""同步发一条测试消息(用户等结果),返回真实 HTTP 状态与平台错误码。
⚠ 不占业务令牌桶:否则管理员点两下测试就把业务通知的配额吃掉了。
"""
cfg = notifier.get_config()
hook = next((h for h in cfg["webhooks"] if h["id"] == hook_id), None)
if not hook:
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
event = ((request.json or {}).get("event") or "notify.test").strip()
rec = notifier.send_test(hook, event=event, operator=_operator())
return jsonify({"ok": bool(rec.get("ok")),
"msg": "测试消息已发出" if rec.get("ok") else "发送失败",
"http_status": rec.get("http_status"),
"errcode": rec.get("errcode"),
"elapsed_ms": rec.get("elapsed_ms"),
"attempts": rec.get("attempts"),
"error": rec.get("error") or "",
"url": notifier.mask_url(hook.get("url", ""))})
@bp.route("/api/notify/preview", methods=["POST"])
@admin_required
def api_notify_preview():
"""保存前预览:真实请求体 + UTF-8 字节数 + 是否会被截断。"""
data = request.json or {}
hook = data.get("hook") or {}
# 预览时 URL 可能还没填:给个占位,只关心渲染结果
hook = dict(hook)
hook.setdefault("url", "https://example.invalid/hook")
out = notifier.preview(hook, event=(data.get("event") or "notify.test"))
return jsonify({"ok": not out.get("error"), **out})
@bp.route("/api/notify/events")
@admin_required
def api_notify_events():
"""事件目录(前端画勾选树 / 模板占位符速查)。"""
rows = notify_events.list_events()
cats = []
for e in rows:
if e["category"] not in cats:
cats.append(e["category"])
return jsonify({"ok": True, "events": rows, "categories": cats})
@bp.route("/api/notify/logs")
@admin_required
def api_notify_logs():
"""最近发送记录(内存环形缓冲,重启清空;历史见 logs/notify.log)。"""
limit = request.args.get("limit", 50)
try:
limit = max(1, min(int(limit), 200))
except (TypeError, ValueError):
limit = 50
return jsonify({"ok": True, "logs": notifier.get_logs(limit),
"note": "仅显示本次运行期间记录;历史见 logs/notify.log"})
@bp.route("/api/notify/settings", methods=["POST"])
@admin_required
def api_notify_settings():
"""全局设置:global_enabled / default_agg_window / default_rate_limit / log_keep。"""
data = request.json or {}
cfg = notifier.get_config()
for k in ("global_enabled", "default_agg_window", "default_rate_limit",
"log_keep", "http_timeout"):
if k in data and data[k] is not None:
cfg["settings"][k] = data[k]
ok, msg = notifier.save_config(cfg)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
_log.info("更新通知全局设置 by %s: %s", _operator(), data)
return jsonify({"ok": True, "msg": "已保存"})
+9
View File
@@ -9,6 +9,7 @@
from io import BytesIO from io import BytesIO
from flask import Blueprint, jsonify, request, send_file from flask import Blueprint, jsonify, request, send_file
from flask_login import current_user
from web.auth import admin_required from web.auth import admin_required
from core import system_backup as sb from core import system_backup as sb
@@ -39,6 +40,14 @@ def api_system_backup_export():
with open(zip_path, "rb") as f: with open(zip_path, "rb") as f:
data = f.read() data = f.read()
sb.remove_quiet(zip_path) sb.remove_quiet(zip_path)
try:
from core import notifier
notifier.notify("system.backup.exported", filename=fname, size=len(data),
tables=len(manifest.get("tables") or []),
include_apk=include_apk,
user=getattr(current_user, "username", ""))
except Exception:
pass # 通知失败不影响导出
resp = send_file(BytesIO(data), as_attachment=True, download_name=fname, resp = send_file(BytesIO(data), as_attachment=True, download_name=fname,
mimetype="application/zip") mimetype="application/zip")
return resp return resp
+25
View File
@@ -66,6 +66,13 @@ def _consume_pending_restore():
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) # 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
init_db(app) init_db(app)
# 通知模块:**必须早于 _consume_pending_restore()**——启动期就要能发「备份已恢复」
# 这类通知(notify 在未就绪时静默丢弃,晚初始化就丢事件了)
try:
from core import notifier as _notifier
_notifier.init_app(app)
except Exception as _e: # 通知出问题不影响启动
_log.warning(f"通知模块初始化失败(不影响启动): {_e}")
# 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件) # 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件)
_consume_pending_restore() _consume_pending_restore()
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」, # 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
@@ -288,9 +295,27 @@ if __name__ == "__main__":
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复 # 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
_preconnect_pool_devices() _preconnect_pool_devices()
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)") _log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
# 启动通知:放在 __main__ 里是刻意的——以 WSGI 方式 import 本模块只有"半个启动"
# (阶段 A~F 在 import 期就跑完了),不该发"服务已启动"。
try:
from core import notifier as _notifier
with app.app_context():
_devs = len(device_pool.list_configured())
_notifier.notify("service.started", env=db_config.env_badge().get("env", ""),
db_target=db_config.describe_target(),
device_count=_devs, job_count=len(mgr.jobs))
except Exception as _e:
_log.warning(f"发送启动通知失败(不影响启动): {_e}")
_t0 = time.time()
try: try:
_run_server(WEB_HOST, WEB_PORT) _run_server(WEB_HOST, WEB_PORT)
finally: finally:
try:
from core import notifier as _notifier
_notifier.notify("service.stopping", uptime_s=int(time.time() - _t0))
_notifier.shutdown(timeout=3.0)
except Exception:
pass
mgr.shutdown() mgr.shutdown()
device_discovery.shutdown() device_discovery.shutdown()
_stop_uiauto() _stop_uiauto()